From f2bc53123a16e65989bd665a8cbf3d172d06f0d0 Mon Sep 17 00:00:00 2001
From: satyaborg <satya.borg@gmail.com>
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 <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`:
 
@@ -138,7 +144,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.
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|<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
 }
 
@@ -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-<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:
+
+devloop --create-pr <spec path>
 
-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)-<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"
+ok "spec launch"
 
 cat > "$fake_bin/codex" <<'AGENT'
 #!/usr/bin/env bash

From 46fa3bc0bc4d8a24f208c20fffd89cabad06b055 Mon Sep 17 00:00:00 2001
From: satyaborg <satya.borg@gmail.com>
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 path>" "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'
