Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ rm -rf ~/.claude/skills/devloop-spec ~/.claude/skills/devloop-review

## Quick Start

Create a spec:
Launch a spec interview:

```sh
devloop spec "add retry behavior to the chat sender"
Expand Down Expand Up @@ -116,14 +116,20 @@ devloop clean

## Specs

A good spec is short, concrete, and verifiable. Start from [`skills/devloop-spec/references/spec-template.md`](skills/devloop-spec/references/spec-template.md), or generate one:
A good spec is short, concrete, and verifiable. Start from [`skills/devloop-spec/references/spec-template.md`](skills/devloop-spec/references/spec-template.md), or launch a spec agent:

```sh
devloop spec
devloop spec --agent claude notes.md
```

Devloop maintains a global config at `~/.devloop/config`. By default, generated specs are written under `~/Projects/specs/`, and the interactive spec picker searches that directory plus the current repo's `.specs/` fallback.
`devloop spec` opens Codex by default, or Claude Code with `--agent claude`, with a prompt that invokes the installed `devloop-spec` skill. The agent interviews when context is thin, writes the spec, then hands back to the PR-backed loop:

```sh
devloop --create-pr <spec path>
```

Devloop maintains a global config at `~/.devloop/config`. By default, spec agents are prompted to write specs under `~/Projects/specs/`, and the interactive spec picker searches that directory plus the current repo's `.specs/` fallback.

Change the shared spec directory from `devloop` > `Settings`, or edit `~/.devloop/config`:

Expand Down Expand Up @@ -177,7 +183,7 @@ devloop [options] <spec.md> [max=5]
When stdout is a terminal, running `devloop` without arguments opens a menu:

- `Run a spec`: pick a spec from the shared spec directory or `.specs/`.
- `Create a spec`: choose the spec agent and provide source context.
- `Create a spec`: choose the spec agent and provide source context; Devloop opens that agent with the `devloop-spec` prompt.
- `Continue a run`: pick a prior `.codex/tracks/*.md` and continue in that worktree.
- `Open reports`: pick a prior report from any registered worktree.
- `Settings`: view or change the shared spec path and set the run timeout.
Expand Down
159 changes: 69 additions & 90 deletions devloop
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ welcome_tui() {
printf ' %-42s %s\n' "devloop clean" "show safe cleanup candidates"
printf ' %-42s %s\n' "devloop continue" "resume a tracked run"
printf ' %-42s %s\n' "devloop menu" "open the guided UI"
printf ' %-42s %s\n' 'devloop spec "add retry behavior"' "generate a spec"
printf ' %-42s %s\n' 'devloop spec "add retry behavior"' "launch a spec agent"
printf ' %-42s %s\n' "devloop .specs/change.md" "run a spec"
printf ' %-42s %s\n' "devloop --create-pr .specs/change.md" "open and maintain a draft PR during the loop"
printf '\n'
Expand Down Expand Up @@ -246,9 +246,10 @@ usage: devloop spec [--agent codex|claude|<cmd>] [--output spec.md] [--force] [c
devloop spec --print-skill
devloop spec --skill-path

Agents: Codex or Claude Code. Custom commands are also supported.
Agents: Codex or Claude Code. Custom launch commands receive the prompt as their first argument.

Without context, the bundled skill uses its interview path before writing a spec.
Launches the selected agent with the devloop-spec skill prompt. Without context,
the agent uses the skill's interview path before writing a spec.
EOF
}

Expand Down Expand Up @@ -2567,6 +2568,12 @@ absolute_path() {
(cd "$dir" >/dev/null 2>&1 && printf '%s/%s\n' "$(pwd -P)" "$base")
}

absolute_dir_path() {
local dir="$1"
mkdir -p "$dir"
(cd "$dir" >/dev/null 2>&1 && pwd -P)
}

line_count() {
local file="$1"
awk 'END{print NR + 0}' "$file"
Expand Down Expand Up @@ -4080,37 +4087,27 @@ spec_command() {
skill-path) printf '%s\n' "$skill"; return ;;
esac

local context today prompt spec_dir
local context today prompt spec_dir target_path target_dir
context="$(resolve_spec_context "${context_items[@]}")"
today="$(date +%F)"
spec_dir="$(devloop_spec_dir)"
prompt="$(spec_prompt "$context" "$output" "$skill" "$today" "$spec_dir")"
if [ "$agent" = "codex" ]; then
with_compact_wait "generate spec" run_with_prompt "$PWD" "" "" "$prompt" codex exec "${CODEX_MODEL_ARGS[@]}" "${CODEX_REASONING_ARGS[@]}" -s read-only -C "$PWD" -
elif [ "$agent" = "claude" ]; then
with_compact_wait "generate spec" run_with_prompt "$PWD" "" "" "$prompt" claude -p "${CLAUDE_MODEL_ARGS[@]}" "${CLAUDE_EFFORT_ARGS[@]}" --add-dir "$PWD"
if [ -n "$output" ]; then
target_path="$(absolute_path "$output")" || return 1
if [ -f "$target_path" ] && [ "$force" = false ]; then
printf 'spec already exists: %s\n' "$target_path" >&2
return 2
fi
target_dir="$(dirname "$target_path")"
else
with_compact_wait "generate spec" run_with_prompt "$PWD" "" "" "$prompt" "$agent"
fi
if [ "$RUN_CODE" -ne 0 ]; then
if [ -n "$RUN_STDERR" ]; then printf '%s\n' "$RUN_STDERR" >&2; else printf '%s\n' "${RUN_STDOUT:-spec agent failed}" >&2; fi
return 2
target_path=""
target_dir="$(absolute_dir_path "$spec_dir")" || return 1
fi

local markdown file
if ! markdown="$(extract_generated_spec "$RUN_STDOUT$RUN_STDERR")"; then
printf '%s\n' "agent output must include spec frontmatter" >&2
return 2
fi
file="$(generated_spec_path "$markdown" "$output" "$today" "$force")"
if [ -f "$file" ] && [ "$force" = false ]; then
printf 'spec already exists: %s\n' "$file" >&2
return 2
fi
mkdir -p "$(dirname "$file")"
printf '%s\n' "$markdown" > "$file"
printf 'spec: %s\n' "$file"
prompt="$(spec_prompt "$context" "$target_path" "$force" "$skill" "$today" "$target_dir")"
printf 'agent: %s\n' "$(agent_label "$agent")"
printf 'mode: interactive spec session\n'
printf 'write: %s\n' "$target_dir"
launch_spec_agent "$agent" "$prompt" "$target_dir"
}

resolve_spec_context() {
Expand All @@ -4136,22 +4133,44 @@ resolve_spec_context() {
spec_prompt() {
local context="$1"
local output="$2"
local skill="$3"
local today="$4"
local spec_dir="$5"
local output_line
local force="$3"
local skill="$4"
local today="$5"
local target_dir="$6"
local target_line overwrite_line
if [ -n "$output" ]; then
output_line="Output path: $output"
target_line="Requested spec path: $output"
else
target_line="Requested spec path: choose $target_dir/$today-<slug>.md"
fi
if [ "$force" = true ]; then
overwrite_line="Overwrite policy: overwriting the requested path is allowed."
else
output_line="Output path: choose a $spec_dir/YYYY-MM-DD-<slug>.md path if you write a file; otherwise return markdown on stdout."
overwrite_line="Overwrite policy: do not overwrite an existing spec; choose a non-conflicting path instead."
fi
cat <<EOF
Use the installed devloop-spec skill to produce one implementation spec.
Skill: use the installed devloop-spec skill.

You are running inside an interactive agent session launched by devloop.
You own the interview and the spec text. Devloop owns the implementation,
review, PR, and report loop after the spec file exists.

Current date: $today
$output_line
Spec directory: $target_dir
$target_line
$overwrite_line

If the source context is missing or too thin, follow the skill's cold-start
interview path before drafting. Ask one question at a time until the spec can be
written without invented requirements.

When ready, write exactly one devloop-ready markdown spec to the requested path.
After writing the spec, tell the user the path and offer to continue with:

If the source context is missing or too thin, follow the skill's interview path before drafting. Return only the final markdown spec. Do not wrap it in a code fence.
devloop --create-pr <spec path>

Do not implement the change directly in this session unless the user explicitly
asks you to leave the devloop flow.

Bundled skill path, for fallback only: $skill

Expand All @@ -4160,61 +4179,21 @@ $context
EOF
}

extract_generated_spec() {
local output="$1"
local tmp stripped next
tmp="$(mktemp "${TMPDIR:-/tmp}/devloop-spec.XXXXXX")"
stripped="$(mktemp "${TMPDIR:-/tmp}/devloop-spec-stripped.XXXXXX")"
printf '%s\n' "$output" > "$tmp"
cp "$tmp" "$stripped"
if head -n 1 "$stripped" | grep -Eq '^[[:space:]]*```(markdown|md)?[[:space:]]*$'; then
next="$(mktemp "${TMPDIR:-/tmp}/devloop-spec-next.XXXXXX")"
sed '1d' "$stripped" > "$next"
mv "$next" "$stripped"
fi
if tail -n 1 "$stripped" | grep -Eq '^[[:space:]]*```[[:space:]]*$'; then
next="$(mktemp "${TMPDIR:-/tmp}/devloop-spec-next.XXXXXX")"
sed '$d' "$stripped" > "$next"
mv "$next" "$stripped"
fi
if ! grep -q '^---[[:space:]]*$' "$stripped"; then
rm -f "$tmp" "$stripped"
return 1
fi
awk 'found || /^---[[:space:]]*$/ { found = 1; print }' "$stripped" | sed -E '${/^[[:space:]]*$/d;}'
rm -f "$tmp" "$stripped"
}

generated_spec_path() {
local markdown="$1"
local output="$2"
local today="$3"
local force="$4"
local title slug file base ext index spec_dir
if [ -n "$output" ]; then
absolute_path "$output"
return
fi
title="$(printf '%s\n' "$markdown" | sed -nE 's/^#[[:space:]]+(.+)/\1/p' | head -n 1)"
slug="$(slugify "${title:-spec}")"
if [ -z "$slug" ]; then slug="spec"; fi
spec_dir="$(devloop_spec_dir)"
case "$spec_dir" in
/*) file="$spec_dir/$today-$slug.md" ;;
*) file="$PWD/$spec_dir/$today-$slug.md" ;;
launch_spec_agent() {
local agent="$1"
local prompt="$2"
local target_dir="$3"
case "$agent" in
codex)
codex "${CODEX_MODEL_ARGS[@]}" "${CODEX_REASONING_ARGS[@]}" -C "$PWD" --add-dir "$target_dir" -- "$prompt"
;;
claude)
claude "${CLAUDE_MODEL_ARGS[@]}" "${CLAUDE_EFFORT_ARGS[@]}" --add-dir "$PWD" --add-dir "$target_dir" -- "$prompt"
;;
*)
"$agent" "$prompt"
;;
esac
if [ "$force" = true ]; then
printf '%s\n' "$file"
return
fi
base="${file%.md}"
ext=".md"
index=2
while [ -e "$file" ]; do
file="$base-$index$ext"
index=$((index + 1))
done
printf '%s\n' "$file"
}

if [ "${DEVLOOP_LIB:-}" != "1" ]; then
Expand Down
29 changes: 18 additions & 11 deletions scripts/devloop_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,6 @@ printf '%s\n' "# Track" > "$clean_status_track"
if has_user_dirty_paths "$clean_status_repo"; then fail "clean worktree reported dirty"; fi
equals "$(clean_candidate_status "$clean_status_track" "$clean_status_repo" "$work/source-repo")" "ready" "clean candidate ready"

spec_output=$'preface\n---\nstatus: draft\n---\n\n# Generated'
equals "$(extract_generated_spec "$spec_output")" $'---\nstatus: draft\n---\n\n# Generated' "extract_generated_spec"

config_repo="$work/config-repo"
config_home="$work/config-home"
config_default_specs="$config_home/Projects/specs"
Expand All @@ -353,7 +350,6 @@ equals "$(cd "$config_repo" && HOME="$config_home" configured_spec_dir_scope)" "
configured_specs="$(cd "$config_repo" && HOME="$config_home" list_spec_files)"
contains "$configured_specs" ".specs/default.md" "configured spec search includes default dir"
if printf '%s\n' "$configured_specs" | grep -Fq ".devloop/specs/devloop.md"; then fail "configured spec search included .devloop/specs"; fi
equals "$(cd "$config_repo" && HOME="$config_home" generated_spec_path "$spec_output" "" "2026-05-29" false)" "$config_repo_real/custom-specs/2026-05-29-generated.md" "configured generated spec path"
if (cd "$config_repo" && HOME="$config_home" write_config_spec_dir "../bad") >/dev/null 2>&1; then fail "write_config_spec_dir accepted path traversal"; fi

absolute_specs="$work/shared-specs"
Expand All @@ -365,7 +361,6 @@ printf '%s\n' "# Shared" > "$absolute_specs/shared.md"
absolute_configured_specs="$(cd "$config_repo" && HOME="$config_home" list_spec_files)"
contains "$absolute_configured_specs" "$absolute_specs/shared.md" "configured spec search includes absolute dir"
contains "$absolute_configured_specs" ".specs/default.md" "absolute spec search includes default dir"
equals "$(cd "$config_repo" && HOME="$config_home" generated_spec_path "$spec_output" "" "2026-05-29" false)" "$absolute_specs/2026-05-29-generated.md" "absolute generated spec path"
equals "$(spec_dir_status "$absolute_specs")" "exists" "spec dir status exists"
equals "$(spec_dir_status "$work/missing-specs")" "missing" "spec dir status missing"
(cd "$config_repo" && HOME="$config_home" remove_config_spec_dir local)
Expand Down Expand Up @@ -896,8 +891,8 @@ agent="$work/spec-agent"
cat > "$agent" <<'AGENT'
#!/usr/bin/env bash
set -euo pipefail
cat >/tmp/devloop-spec-agent-prompt.txt
printf '%s\n' '---' 'status: draft' 'type: feat' 'created: 2026-05-29' 'pr: null' '---' '' '# Shell migration spec'
printf '%s\n' "$1" >/tmp/devloop-spec-agent-prompt.txt
printf '%s\n' "launched spec agent"
AGENT
chmod +x "$agent"

Expand All @@ -913,12 +908,24 @@ USE_TUI=false
HOME="$spec_home" main spec --agent "$agent" "Keep devloop as Bash." >/tmp/devloop-spec-test.out
)
USE_TUI="$old_use_tui"
contains "$(cat /tmp/devloop-spec-test.out)" "spec:" "spec command"
[[ -f "$repo_specs/$(date +%F)-shell-migration-spec.md" ]] || fail "spec command did not write dated spec under absolute configured dir"
repo_specs_real="$(cd "$repo_specs" && pwd -P)"
contains "$(cat /tmp/devloop-spec-test.out)" "interactive spec session" "spec command"
contains "$(cat /tmp/devloop-spec-test.out)" "write: $repo_specs_real" "spec command target"
if [ -f "$repo_specs/$(date +%F)-shell-migration-spec.md" ]; then fail "spec command wrote the spec instead of launching the agent"; fi
contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Skill: use the installed devloop-spec skill." "spec prompt skill"
contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Keep devloop as Bash." "spec prompt"
contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Output path: choose a $repo_specs/" "spec prompt configured output"
contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Requested spec path: choose $repo_specs_real/$(date +%F)-<slug>.md" "spec prompt configured output"
contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Devloop owns the implementation" "spec prompt ownership"
contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "devloop --create-pr <spec path>" "spec prompt handoff"
contains "$(absolute_path "$work/absolute-path/nested.md")" "/absolute-path/nested.md" "absolute path"
ok "spec generation"
existing_spec="$repo/existing.md"
existing_spec_real="$(cd "$repo" && pwd -P)/existing.md"
printf '%s\n' "# Existing" > "$existing_spec"
rm -f /tmp/devloop-spec-agent-prompt.txt
if (cd "$repo" && HOME="$spec_home" main spec --agent "$agent" --output "$existing_spec" "Replace me") >/tmp/devloop-spec-existing.out 2>/tmp/devloop-spec-existing.err; then fail "spec command allowed existing output without force"; fi
contains "$(cat /tmp/devloop-spec-existing.err)" "spec already exists: $existing_spec_real" "spec existing output"
if [ -f /tmp/devloop-spec-agent-prompt.txt ]; then fail "spec command launched agent for existing output"; fi
ok "spec launch"

cat > "$fake_bin/codex" <<'AGENT'
#!/usr/bin/env bash
Expand Down