From f2bc53123a16e65989bd665a8cbf3d172d06f0d0 Mon Sep 17 00:00:00 2001 From: satyaborg Date: Wed, 3 Jun 2026 16:36:54 +1000 Subject: [PATCH 1/2] feat: launch spec agents interactively --- README.md | 14 ++++-- devloop | 104 +++++++++++++++++++++++++++--------------- tests/devloop_test.sh | 17 ++++--- 3 files changed, 89 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 8a7b03f..50717aa 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ devloop doctor ## Quick Start -Create a spec: +Launch a spec interview: ```sh devloop spec "add retry behavior to the chat sender" @@ -77,14 +77,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 +``` + +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`: @@ -138,7 +144,7 @@ devloop [options] [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. diff --git a/devloop b/devloop index 3dc1432..29b743e 100755 --- a/devloop +++ b/devloop @@ -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' @@ -246,9 +246,10 @@ usage: devloop spec [--agent codex|claude|] [--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 } @@ -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" @@ -4004,37 +4011,23 @@ 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 + 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() { @@ -4060,22 +4053,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-.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-.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 < -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. +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 @@ -4084,6 +4099,23 @@ $context EOF } +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 +} + extract_generated_spec() { local output="$1" local tmp stripped next diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh index 85af3aa..985ea90 100755 --- a/tests/devloop_test.sh +++ b/tests/devloop_test.sh @@ -701,8 +701,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" @@ -718,12 +718,17 @@ 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)-.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 prompt handoff" contains "$(absolute_path "$work/absolute-path/nested.md")" "/absolute-path/nested.md" "absolute path" -ok "spec generation" +ok "spec launch" cat > "$fake_bin/codex" <<'AGENT' #!/usr/bin/env bash From 46fa3bc0bc4d8a24f208c20fffd89cabad06b055 Mon Sep 17 00:00:00 2001 From: satyaborg Date: Wed, 3 Jun 2026 16:59:00 +1000 Subject: [PATCH 2/2] fix: remove dead spec generation path --- devloop | 61 +++---------------------------------------- tests/devloop_test.sh | 12 +++++---- 2 files changed, 11 insertions(+), 62 deletions(-) diff --git a/devloop b/devloop index 29b743e..00ee4ec 100755 --- a/devloop +++ b/devloop @@ -4017,6 +4017,10 @@ spec_command() { spec_dir="$(devloop_spec_dir)" 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 target_path="" @@ -4116,63 +4120,6 @@ launch_spec_agent() { esac } -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" ;; - 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 main "$@" fi diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh index 985ea90..0af5a63 100755 --- a/tests/devloop_test.sh +++ b/tests/devloop_test.sh @@ -284,9 +284,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" @@ -313,7 +310,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" @@ -325,7 +321,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) @@ -728,6 +723,13 @@ contains "$(cat /tmp/devloop-spec-agent-prompt.txt)" "Requested spec path: choos 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 prompt handoff" contains "$(absolute_path "$work/absolute-path/nested.md")" "/absolute-path/nested.md" "absolute path" +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'