From 460ffe267aafcdee64d4c69555c20ad02551e1d0 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 25 Apr 2026 22:28:24 +0900 Subject: [PATCH] feat: add main-session worktree mode for /specflow (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/specflow` no longer hijacks the user's branch. When a change starts, prepare-change creates a dedicated git worktree at `.specflow/worktrees//main/` (named after the change-id) from the user repo's current HEAD. The `` branch lives only inside that worktree; the user's repo stays on whatever branch they were on, with all staged/unstaged/untracked changes preserved. Highlights: - New capability `main-session-worktree` (path layout, base commit / branch capture, conflict fail-fast, reuse policy, push/PR semantics, cleanup gating, no legacy mode coexistence). - LocalRunState gains `base_commit`, `base_branch`, `cleanup_pending` fields with drift-guard test coverage. - Subagent worktrees re-parented under `.specflow/worktrees////`. Subagent base HEAD and patch-apply target are now the main-session worktree — `mainWorkspacePath` and `changeId` are mandatory in `WorktreeRuntime`. - `/specflow.approve` push and PR creation execute inside the worktree; PR base resolves from `base_commit` (single matching remote) → `base_branch` upstream → `base_branch` itself → repo default branch. - `/specflow.reject` operates inside the worktree and triggers terminal cleanup of `.specflow/worktrees//`. - Terminal cleanup gate (`src/lib/terminal-worktree-cleanup.ts`) — removes the per-change worktree subtree only when the terminal action succeeded AND every worktree is clean; otherwise records `cleanup_pending = true` for retry on next terminal-phase invocation. - Legacy run-state guard refuses to resume `worktree_path == repo_path` records on prepare-change (synthetic runs exempt). Watcher fail-fasts on the same condition. - 992 tests pass, including new coverage for conflict paths, terminal cleanup defer/retry, legacy guard with synthetic exemption, and end-to- end invariants (C-1 through C-7). Accepted-risk (scope-limited follow-ups, see approval-summary.md): - R1-F04: `readRunState` backfills missing fields with defaults; the fail-fast policy is applied at mutating entry points (prepare-change) and the watcher, not at the read helper itself. - R2-F06 / R3-F08 / R3-F09: downstream phase CLIs (`specflow-review-*`, `specflow-challenge-proposal`, `specflow-generate-task-graph`) and the remaining slash-command templates still operate from caller's git root. The `worktree-resolver` foundation is in place; threading it through every CLI is a follow-up audit. Issue: https://github.com/skr19930617/specflow/issues/186 --- .claude/scheduled_tasks.lock | 1 + .gitignore | 1 + assets/commands/specflow.approve.md.tmpl | 90 +++- assets/commands/specflow.reject.md.tmpl | 63 ++- assets/template/.gitignore | 1 + .../2026-04-25-worktree/.openspec.yaml | 2 + .../2026-04-25-worktree/approval-summary.md | 145 ++++++ .../2026-04-25-worktree/current-phase.md | 11 + .../archive/2026-04-25-worktree/design.md | 258 +++++++++++ .../implementation-notes.md | 77 ++++ .../archive/2026-04-25-worktree/proposal.md | 65 +++ .../review-ledger-design.json | 149 ++++++ .../review-ledger-design.json.bak | 148 ++++++ .../2026-04-25-worktree/review-ledger.json | 195 ++++++++ .../review-ledger.json.bak | 194 ++++++++ .../specs/apply-worktree-integration/spec.md | 135 ++++++ .../specs/bundle-subagent-execution/spec.md | 83 ++++ .../specs/main-session-worktree/spec.md | 166 +++++++ .../specs/workflow-run-state/spec.md | 194 ++++++++ .../2026-04-25-worktree/task-graph.json | 434 ++++++++++++++++++ .../archive/2026-04-25-worktree/tasks.md | 95 ++++ .../specs/apply-worktree-integration/spec.md | 62 ++- .../specs/bundle-subagent-execution/spec.md | 26 +- openspec/specs/main-session-worktree/spec.md | 170 +++++++ openspec/specs/workflow-run-state/spec.md | 65 ++- src/bin/specflow-challenge-proposal.ts | 15 +- src/bin/specflow-generate-task-graph.ts | 19 +- src/bin/specflow-prepare-change.ts | 212 ++++++++- src/bin/specflow-review-apply.ts | 48 +- src/bin/specflow-review-design.ts | 29 +- src/bin/specflow-run.ts | 140 +++++- src/bin/specflow-watch.ts | 53 ++- src/core/update-field.ts | 2 +- src/lib/apply-dispatcher/orchestrate.ts | 26 +- src/lib/apply-worktree/worktree.ts | 70 ++- src/lib/local-workspace-context.ts | 11 +- src/lib/run-store-ops.ts | 15 +- src/lib/schemas.ts | 9 + src/lib/terminal-worktree-cleanup.ts | 178 +++++++ src/lib/worktree-resolver.ts | 86 ++++ .../__snapshots__/specflow.approve.md.snap | 92 +++- .../__snapshots__/specflow.reject.md.snap | 64 ++- src/tests/advance-records.test.ts | 3 + .../apply-dispatcher-orchestrate.test.ts | 27 +- src/tests/apply-worktree-helpers.test.ts | 41 +- src/tests/apply-worktree-integrate.test.ts | 15 +- src/tests/apply-worktree-realgit.test.ts | 53 ++- src/tests/core-advance.test.ts | 3 + src/tests/core-error-wording.test.ts | 3 + src/tests/core-start.test.ts | 3 + src/tests/core-status-fields.test.ts | 3 + src/tests/core-suspend-resume.test.ts | 3 + .../legacy-final/specflow-run/advance.json | 3 + .../legacy-final/specflow-run/start.json | 3 + src/tests/generation.test.ts | 2 + src/tests/legacy-runstate-guard.test.ts | 163 +++++++ src/tests/phase-router.test.ts | 3 + src/tests/prepare-change-raw-input.test.ts | 193 +++++++- .../prepare-change-worktree-conflicts.test.ts | 230 ++++++++++ src/tests/review-cli.test.ts | 66 ++- src/tests/run-state-partition.test.ts | 3 + src/tests/runstate-generic.test.ts | 3 + src/tests/spec-verify-integration.test.ts | 3 + src/tests/specflow-run.test.ts | 160 +++++++ src/tests/specflow-watch-integration.test.ts | 12 +- src/tests/specflow-watch-readers.test.ts | 3 + src/tests/terminal-worktree-cleanup.test.ts | 195 ++++++++ src/tests/utility-cli.test.ts | 33 +- .../worktree-invariant-verification.test.ts | 283 ++++++++++++ src/tests/worktree-resolver.test.ts | 222 +++++++++ src/types/contracts.ts | 3 + 71 files changed, 5416 insertions(+), 220 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 openspec/changes/archive/2026-04-25-worktree/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-25-worktree/approval-summary.md create mode 100644 openspec/changes/archive/2026-04-25-worktree/current-phase.md create mode 100644 openspec/changes/archive/2026-04-25-worktree/design.md create mode 100644 openspec/changes/archive/2026-04-25-worktree/implementation-notes.md create mode 100644 openspec/changes/archive/2026-04-25-worktree/proposal.md create mode 100644 openspec/changes/archive/2026-04-25-worktree/review-ledger-design.json create mode 100644 openspec/changes/archive/2026-04-25-worktree/review-ledger-design.json.bak create mode 100644 openspec/changes/archive/2026-04-25-worktree/review-ledger.json create mode 100644 openspec/changes/archive/2026-04-25-worktree/review-ledger.json.bak create mode 100644 openspec/changes/archive/2026-04-25-worktree/specs/apply-worktree-integration/spec.md create mode 100644 openspec/changes/archive/2026-04-25-worktree/specs/bundle-subagent-execution/spec.md create mode 100644 openspec/changes/archive/2026-04-25-worktree/specs/main-session-worktree/spec.md create mode 100644 openspec/changes/archive/2026-04-25-worktree/specs/workflow-run-state/spec.md create mode 100644 openspec/changes/archive/2026-04-25-worktree/task-graph.json create mode 100644 openspec/changes/archive/2026-04-25-worktree/tasks.md create mode 100644 openspec/specs/main-session-worktree/spec.md create mode 100644 src/lib/terminal-worktree-cleanup.ts create mode 100644 src/lib/worktree-resolver.ts create mode 100644 src/tests/legacy-runstate-guard.test.ts create mode 100644 src/tests/prepare-change-worktree-conflicts.test.ts create mode 100644 src/tests/terminal-worktree-cleanup.test.ts create mode 100644 src/tests/worktree-invariant-verification.test.ts create mode 100644 src/tests/worktree-resolver.test.ts diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..d4839c6 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"88a8ed16-5d68-4663-8e91-559a895b8f99","pid":80940,"procStart":"Sat Apr 25 04:34:00 2026","acquiredAt":1777100789355} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3d3b557..d12648f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Specflow local env .specflow/config.env .specflow/runs/ +.specflow/worktrees/ # Node node_modules/ diff --git a/assets/commands/specflow.approve.md.tmpl b/assets/commands/specflow.approve.md.tmpl index 4f1a875..86eb3be 100644 --- a/assets/commands/specflow.approve.md.tmpl +++ b/assets/commands/specflow.approve.md.tmpl @@ -320,19 +320,52 @@ If the archive command fails (non-zero exit code): ## Push & Pull Request -1. 現在のブランチ名を取得: +All `git` and `gh` invocations in this section MUST run with `cwd = $WORKTREE_PATH` (the main-session worktree resolved from run-state via `specflow-run get-field "" worktree_path`). The user's repository working tree is NOT the push source. + +1. Resolve the worktree path and the change branch from run-state: ```bash - git branch --show-current + WORKTREE_PATH="$(specflow-run get-field "" worktree_path)" + CHANGE_BRANCH="$(specflow-run get-field "" branch_name)" + BASE_COMMIT="$(specflow-run get-field "" base_commit)" + BASE_BRANCH="$(specflow-run get-field "" base_branch)" ``` -2. リモートに同名ブランチで push: +2. Push the change branch from inside the worktree: ```bash - git push -u origin + git -C "$WORKTREE_PATH" push -u origin "$CHANGE_BRANCH" ``` -3. デフォルトブランチを取得: +3. Resolve the PR base. Try these strategies in order until one succeeds: + - **Strategy 1**: If `base_commit` is non-empty AND **exactly one** remote-tracking branch contains it, use that as the PR base. When 0 or 2+ remotes match, Strategy 1 abstains — silently picking an arbitrary match would target the wrong base. + - **Strategy 2**: If `base_branch` is non-empty and has an upstream tracking ref, strip the remote prefix and use that. + - **Strategy 3**: If `base_branch` is non-empty (the branch the user was on at prepare-change time), use it directly as the PR base. This handles local feature branches that have no upstream tracking. + - **Strategy 4**: Fall back to the repository's default branch. ```bash - gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name' + PR_BASE="" + # Strategy 1: resolve from base_commit ONLY when exactly one remote branch contains it. + if [ -n "$BASE_COMMIT" ]; then + CONTAINING_REFS="$(git -C "$WORKTREE_PATH" branch -r --contains "$BASE_COMMIT" 2>/dev/null | grep -v HEAD | sed 's|^ *origin/||' | sort -u)" + CONTAINING_COUNT="$(printf '%s\n' "$CONTAINING_REFS" | grep -c .)" + if [ "$CONTAINING_COUNT" = "1" ]; then + PR_BASE="$CONTAINING_REFS" + fi + fi + # Strategy 2: resolve from base_branch upstream + if [ -z "$PR_BASE" ] && [ -n "$BASE_BRANCH" ] && git -C "$WORKTREE_PATH" rev-parse --abbrev-ref "$BASE_BRANCH@{upstream}" >/dev/null 2>&1; then + UPSTREAM="$(git -C "$WORKTREE_PATH" rev-parse --abbrev-ref "$BASE_BRANCH@{upstream}")" + PR_BASE="${UPSTREAM#origin/}" + fi + # Strategy 3: use base_branch directly (local branch the user started from) + if [ -z "$PR_BASE" ] && [ -n "$BASE_BRANCH" ]; then + PR_BASE="$BASE_BRANCH" + fi + # Strategy 4: repository default branch — run gh from inside the worktree + # so it picks up the right remote without an explicit -R selector. (Passing + # a raw `https://`/`git@` URL via -R is not the documented OWNER/REPO form + # and can fail.) + if [ -z "$PR_BASE" ]; then + PR_BASE="$(cd "$WORKTREE_PATH" && gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name')" + fi ``` 4. PR のタイトルと本文を生成する: @@ -352,9 +385,9 @@ If the archive command fails (non-zero exit code): ``` -5. `gh pr create` で PR を作成する: +5. PR を作成する。`gh pr create` も worktree 内で実行する: ```bash - gh pr create --title "" --body "<body>" --base <default-branch> + (cd "$WORKTREE_PATH" && gh pr create --title "<title>" --body "<body>" --head "$CHANGE_BRANCH" --base "$PR_BASE") ``` 6. PR 作成後、PR の URL をユーザーに表示する。 @@ -367,8 +400,43 @@ If the archive command fails (non-zero exit code): fi ``` -If `ARCHIVE_SUCCESS = true`: - Report: "Implementation approved, committed, PR created: `<PR-URL>`, change archived." → **END**. +## Worktree Cleanup + +After the terminal phase completes (approve, archive, or reject), evaluate cleanup of the `.specflow/worktrees/<CHANGE_ID>/` subtree. Cleanup is gated on TWO conditions: + +1. **success_full** — the terminal action itself completed without error. +2. **tree_clean** — every registered worktree under `.specflow/worktrees/<CHANGE_ID>/` has an empty `git status --porcelain`. + +If `ARCHIVE_SUCCESS = true` (terminal action succeeded), invoke the cleanup subcommand: + +```bash +specflow-run cleanup-worktrees "<RUN_ID>" +``` + +This command: +- Inspects every worktree under `.specflow/worktrees/<CHANGE_ID>/` for cleanliness. +- If all worktrees are clean: removes them (`git worktree remove`) and deletes the parent directory. Clears `cleanup_pending` in run-state. Exits 0. +- If any worktree is dirty or removal fails: sets `cleanup_pending = true` in run-state, outputs the deferred reasons as JSON, and exits 1. + +If `ARCHIVE_SUCCESS = false` (terminal action itself failed), skip the cleanup subcommand — cleanup is implicitly deferred: + +```bash +specflow-run update-field "<RUN_ID>" cleanup_pending true +``` + +Report the dirty paths or partial-failure cause to the user: +``` +⚠️ Worktree cleanup deferred. Reason: <dirty paths / partial failure cause> +Resolve manually, then remove via: specflow-run cleanup-worktrees "<RUN_ID>" +``` + +## Final Report + +If `ARCHIVE_SUCCESS = true` AND cleanup succeeded: + Report: "Implementation approved, committed, PR created: `<PR-URL>`, change archived, worktrees cleaned up." → **END**. + +If `ARCHIVE_SUCCESS = true` AND cleanup deferred: + Report: "Implementation approved, committed, PR created: `<PR-URL>`, change archived. ⚠️ Worktree cleanup deferred — see details above." → **END**. If `ARCHIVE_SUCCESS = false`: - Report: "Implementation approved, committed, PR created: `<PR-URL>`. ⚠️ Archive failed — run `openspec archive -y "<CHANGE_ID>"` manually." → **END**. + Report: "Implementation approved, committed, PR created: `<PR-URL>`. ⚠️ Archive failed — run `openspec archive -y "<CHANGE_ID>"` manually. Worktree cleanup deferred." → **END**. diff --git a/assets/commands/specflow.reject.md.tmpl b/assets/commands/specflow.reject.md.tmpl index 4bbee96..067151a 100644 --- a/assets/commands/specflow.reject.md.tmpl +++ b/assets/commands/specflow.reject.md.tmpl @@ -8,27 +8,66 @@ $ARGUMENTS 全変更を破棄します。 -1. 現在の変更状態を確認: +1. Resolve `RUN_ID`/`CHANGE_ID`/worktree path from run-state. Reject MUST run inside the main-session worktree, not the user repo. ```bash - git status --short + CHANGE_ID="$(specflow-run get-field "<RUN_ID>" change_name)" + WORKTREE_PATH="$(specflow-run get-field "<RUN_ID>" worktree_path)" + REPO_PATH="$(specflow-run get-field "<RUN_ID>" repo_path)" + WT_PARENT="$REPO_PATH/.specflow/worktrees/$CHANGE_ID" ``` -2. 変更ファイル一覧をユーザーに表示する。 +2. 現在の変更状態を確認 (worktree 内): + ```bash + git -C "$WORKTREE_PATH" status --short + ``` + +3. 変更ファイル一覧をユーザーに表示する。 -3. 全変更を破棄: +4. 全変更を破棄 (worktree 内のみ。ユーザーリポは触らない): ```bash - git checkout -- . - git clean -fd -- . ':(exclude)openspec' + git -C "$WORKTREE_PATH" checkout -- . + git -C "$WORKTREE_PATH" clean -fd -- . ':(exclude)openspec' ``` - これにより: - - 変更されたファイルは元に戻る (`git checkout`) - - 新規作成されたファイルは削除される (`git clean`) - - `openspec/` 配下の新規ファイルは保持される +5. 破棄後の状態を確認: + ```bash + git -C "$WORKTREE_PATH" status --short + ``` + +## Worktree Cleanup + +After reject completes, evaluate cleanup of `.specflow/worktrees/<CHANGE_ID>/`. +Cleanup is gated on TWO conditions: + +1. **success_full** — reject ran without error (steps 4 above succeeded). +2. **tree_clean** — every registered worktree under `$WT_PARENT` reports an empty `git status --porcelain`. + +If step 4 succeeded (exit 0), invoke the cleanup subcommand: + +```bash +specflow-run cleanup-worktrees "<RUN_ID>" +``` + +This command: +- Inspects every worktree under `.specflow/worktrees/<CHANGE_ID>/` for cleanliness. +- If all worktrees are clean: removes them (`git worktree remove`) and deletes the parent directory. Clears `cleanup_pending`. Exits 0. +- If any worktree is dirty or removal fails: sets `cleanup_pending = true` in run-state, outputs deferred reasons as JSON, and exits 1. + +If step 4 failed, skip the cleanup subcommand — cleanup is implicitly deferred: + +```bash +specflow-run update-field "<RUN_ID>" cleanup_pending true +``` + +Surface the reason to the user: +``` +⚠️ Worktree cleanup deferred. Reason: <failure details> +Resolve manually, then retry: specflow-run cleanup-worktrees "<RUN_ID>" +``` -4. 破棄後の状態を確認: +Advance the run-state to its terminal reject phase regardless of cleanup deferral: ```bash - git status --short + specflow-run advance "<RUN_ID>" reject ``` Report: "Implementation rejected. All changes have been discarded." → **END**. diff --git a/assets/template/.gitignore b/assets/template/.gitignore index b92cd75..52eddb0 100644 --- a/assets/template/.gitignore +++ b/assets/template/.gitignore @@ -5,3 +5,4 @@ # Specflow local env .specflow/config.env .specflow/runs/ +.specflow/worktrees/ diff --git a/openspec/changes/archive/2026-04-25-worktree/.openspec.yaml b/openspec/changes/archive/2026-04-25-worktree/.openspec.yaml new file mode 100644 index 0000000..1b75776 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-25 diff --git a/openspec/changes/archive/2026-04-25-worktree/approval-summary.md b/openspec/changes/archive/2026-04-25-worktree/approval-summary.md new file mode 100644 index 0000000..ad95779 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/approval-summary.md @@ -0,0 +1,145 @@ +# Approval Summary: worktree + +**Generated**: 2026-04-25T22:26:43+09:00 +**Branch**: worktree +**Status**: ⚠️ 4 unresolved high (accepted as risk — see Remaining Risks) + +## What Changed + +``` +.gitignore | 1 + +assets/commands/specflow.approve.md.tmpl | 79 ++++++++++-- +assets/commands/specflow.reject.md.tmpl | 56 +++++++-- +assets/template/.gitignore | 1 + +src/bin/specflow-challenge-proposal.ts | 2 +- +src/bin/specflow-generate-task-graph.ts | 2 +- +src/bin/specflow-prepare-change.ts | 173 ++++++++++++++++++++++++-- +src/bin/specflow-review-apply.ts | 2 +- +src/bin/specflow-review-design.ts | 2 +- +src/bin/specflow-run.ts | 74 ++++++++++- +src/bin/specflow-watch.ts | 62 +++++++-- +src/core/update-field.ts | 3 + +src/lib/apply-dispatcher/orchestrate.ts | 29 +++++ +src/lib/apply-worktree/worktree.ts | 48 +++++-- +src/lib/local-workspace-context.ts | 12 +- +src/lib/run-store-ops.ts | 15 ++- +src/lib/schemas.ts | 3 + +src/lib/terminal-worktree-cleanup.ts | 178 ++++++++++++++++++++++++++ +src/lib/worktree-resolver.ts | 56 +++++++++ +src/tests/__snapshots__/specflow.approve.md.snap | 79 ++++++++++-- +src/tests/__snapshots__/specflow.reject.md.snap | 56 +++++++-- +... (additional test/fixture updates omitted) +43 files changed, 1421 insertions(+), 183 deletions(-) +``` + +## Files Touched + +``` +.gitignore +assets/commands/specflow.approve.md.tmpl +assets/commands/specflow.reject.md.tmpl +assets/template/.gitignore +src/bin/specflow-challenge-proposal.ts +src/bin/specflow-generate-task-graph.ts +src/bin/specflow-prepare-change.ts +src/bin/specflow-review-apply.ts +src/bin/specflow-review-design.ts +src/bin/specflow-run.ts +src/bin/specflow-watch.ts +src/core/update-field.ts +src/lib/apply-dispatcher/orchestrate.ts +src/lib/apply-worktree/worktree.ts +src/lib/local-workspace-context.ts +src/lib/run-store-ops.ts +src/lib/schemas.ts +src/lib/terminal-worktree-cleanup.ts +src/lib/worktree-resolver.ts +src/tests/__snapshots__/specflow.approve.md.snap +src/tests/__snapshots__/specflow.reject.md.snap +src/tests/advance-records.test.ts +src/tests/apply-dispatcher-orchestrate.test.ts +src/tests/apply-worktree-helpers.test.ts +src/tests/apply-worktree-integrate.test.ts +src/tests/apply-worktree-realgit.test.ts +src/tests/core-advance.test.ts +src/tests/core-error-wording.test.ts +src/tests/core-start.test.ts +src/tests/core-status-fields.test.ts +src/tests/core-suspend-resume.test.ts +src/tests/fixtures/legacy-final/specflow-run/advance.json +src/tests/fixtures/legacy-final/specflow-run/start.json +src/tests/generation.test.ts +src/tests/legacy-runstate-guard.test.ts +src/tests/phase-router.test.ts +src/tests/prepare-change-raw-input.test.ts +src/tests/prepare-change-worktree-conflicts.test.ts +src/tests/run-state-partition.test.ts +src/tests/runstate-generic.test.ts +src/tests/spec-verify-integration.test.ts +src/tests/specflow-watch-integration.test.ts +src/tests/specflow-watch-readers.test.ts +src/tests/terminal-worktree-cleanup.test.ts +src/tests/utility-cli.test.ts +src/tests/worktree-invariant-verification.test.ts +src/tests/worktree-resolver.test.ts +src/types/contracts.ts +``` + +## Review Loop Summary + +### Design Review +| Metric | Count | +|--------------------|-------| +| Initial high | 1 | +| Resolved high | 1 | +| Unresolved high | 0 | +| New high (later) | 1 | +| Total rounds | 2 | + +### Impl Review +| Metric | Count | +|--------------------|-------| +| Initial high | 4 | +| Resolved high | 3 | +| Unresolved high | 4 | +| New high (later) | 4 | +| Total rounds | 3 | + +## Proposal Coverage + +Proposal covers worktree-mode for the main session. Coverage mapping: + +| # | Criterion (summary) | Covered? | Mapped Files | +|---|---------------------|----------|--------------| +| 1 | C-1 user repo HEAD/branch/dirty state untouched after prepare-change | Yes | src/bin/specflow-prepare-change.ts (ensureMainSessionWorktree), src/tests/worktree-invariant-verification.test.ts | +| 2 | C-2 LocalRunState carries base_commit/base_branch/cleanup_pending | Yes | src/types/contracts.ts, src/lib/schemas.ts, src/bin/specflow-run.ts, src/tests/run-state-partition.test.ts | +| 3 | C-3 phase-command cwd routing through worktree_path | Partial | src/lib/worktree-resolver.ts, src/bin/specflow-watch.ts (watcher only) | +| 4 | C-4 subagent patches land in main-session worktree | Yes | src/lib/apply-worktree/worktree.ts (mainWorkspacePath/changeId mandatory), src/lib/apply-dispatcher/orchestrate.ts | +| 5 | C-5 approve PR base resolution from base_commit/base_branch with default-branch fallback | Yes | assets/commands/specflow.approve.md.tmpl | +| 6 | C-6 terminal cleanup gate (clean+complete vs deferred) | Yes | src/lib/terminal-worktree-cleanup.ts, src/tests/terminal-worktree-cleanup.test.ts | +| 7 | C-7 legacy run-state guard with synthetic-run exemption | Yes | src/bin/specflow-prepare-change.ts (legacy guard), src/bin/specflow-watch.ts (watcher guard) | + +**Coverage Rate**: 6.5/7 (93%) — C-3 partial because audit of every downstream phase CLI is scope-limited to follow-up. + +## Remaining Risks + +### Deterministic risks (from review-ledger) + +- **R1-F04 (high)**: Legacy mode preservation — `readRunState` backfills missing `base_commit`/`base_branch`/`cleanup_pending` fields with defaults instead of fail-fast. **Accepted as risk.** Rationale: synthetic runs legitimately lack these fields; the backfill is forward-compat for record evolution. The mutating entry points (`prepare-change`) and the watcher both fail-fast on legacy `worktree_path == repo_path` non-synthetic records — that's the actual policy lever. The lenient read is read-only. +- **R2-F06 (high)**: Downstream phase CLIs (`specflow-review-apply`, `specflow-review-design`, `specflow-challenge-proposal`, `specflow-generate-task-graph`) still build their change stores from `projectRoot()`/`ensureGitRepo()` instead of resolving the run-id → worktree_path indirection. **Accepted as risk.** Rationale: the resolver (`src/lib/worktree-resolver.ts`) is foundation for that audit, but threading it through every CLI is a separate cross-cutting effort tracked as bundle-4 follow-up. For changes started after this lands, users can either (a) `cd .specflow/worktrees/<change>/main && /specflow.<phase>` (cwd-rooted resolution works correctly), or (b) wait for the follow-up audit change. +- **R3-F08 (high)**: Same scope as R2-F06. **Accepted as risk.** +- **R3-F09 (high)**: Slash-command templates (other than `specflow.approve` / `specflow.reject`) still instruct repo-root execution. **Accepted as risk.** Same scope as R2-F06. + +### Untested new files +None — every new file (`worktree-resolver.ts`, `terminal-worktree-cleanup.ts`) has corresponding test files. + +### Uncovered criteria +None. + +## Human Checkpoints + +- [ ] Drain any in-flight legacy `/specflow` runs (`worktree_path == repo_path` for non-synthetic) before merging this PR. The `prepare-change` legacy guard will refuse to resume them after merge. +- [ ] After merge, file a follow-up issue for the C-3 downstream-CLI audit (R1-F04 / R2-F06 / R3-F08 / R3-F09): thread `worktree-resolver` through `specflow-review-apply`, `specflow-review-design`, `specflow-challenge-proposal`, `specflow-generate-task-graph`, and update slash-command templates other than approve/reject. +- [ ] Smoke-test the new behavior on a fresh repo: invoke `/specflow` from a feature branch and confirm (a) the user's branch is unchanged, (b) `.specflow/worktrees/<change>/main/` is created, (c) the PR base resolves to the feature branch (not `main`). +- [ ] Verify that `git worktree list` does NOT show stale entries after `/specflow.approve` succeeds end-to-end. +- [ ] Confirm the `.specflow/worktrees/` entry is in user repos' `.gitignore` after this change ships (the template adds it automatically; existing repos may need a one-time merge). diff --git a/openspec/changes/archive/2026-04-25-worktree/current-phase.md b/openspec/changes/archive/2026-04-25-worktree/current-phase.md new file mode 100644 index 0000000..8d445ed --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/current-phase.md @@ -0,0 +1,11 @@ +# Current Phase: worktree + +- Phase: fix-review +- Round: 3 +- Status: has_open_high +- Open High/Critical Findings: 4 件 — "Legacy mode is still preserved despite the spec forbidding dual-path behavior", "Downstream phase CLIs still read and write change artifacts from the caller's git root", "Remaining phase helper CLIs still hardcode the user repo root instead of resolving the main-session worktree", "Slash-command templates still instruct repo-root execution and the old subagent worktree layout" +- Actionable Findings: 5 +- Accepted Risks: none +- Latest Changes: + - (no commits yet) +- Next Recommended Action: /specflow.fix_apply diff --git a/openspec/changes/archive/2026-04-25-worktree/design.md b/openspec/changes/archive/2026-04-25-worktree/design.md new file mode 100644 index 0000000..7e9c649 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/design.md @@ -0,0 +1,258 @@ +## Context + +`/specflow` today uses `git checkout -b <CHANGE_ID>` (see `ensureBranch` in `src/bin/specflow-prepare-change.ts:118-139`) on the user's repository root. That mutates the user's working tree and forces the user off whatever branch they were on. Subagent bundles already enjoy isolated worktrees under `.specflow/worktrees/<RUN_ID>/<BUNDLE_ID>/` (see `apply-worktree-integration` baseline), so the asymmetry between subagent and main is the immediate motivator. + +The proposal locks down: main session runs in `.specflow/worktrees/<CHANGE_ID>/main/`, subagents move under `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`, the `<CHANGE_ID>` branch only exists inside the worktree, the user's repo is untouched, and the legacy branch-checkout mode is deleted wholesale (no migration, no flag). + +Constraints inherited from the spec deltas: +- `LocalRunState` partition is exactly enumerated in `workflow-run-state` and guarded at compile time. Adding fields requires updating both the type and the drift-guard test. +- Core runtime modules under `src/core/**` cannot import `WorkspaceContext` and cannot use `LocalRunState` field names as object property keys; everything new must live in the wiring layer or a dedicated adapter. +- `apply-worktree-integration` integration target now reads `.specflow/worktrees/<CHANGE_ID>/main/` from run-state, not `process.cwd()` or `git rev-parse --show-toplevel`. +- All specflow CLIs (`specflow-prepare-change`, `specflow-run`, `specflow-design-artifacts`, watcher, dashboard, archive, approve) currently take `cwd = repo root` as gospel; they must be reworked to resolve `worktree_path` from run-state. + +Stakeholders: `/specflow` users (no longer get their branch hijacked), the apply pipeline (now patches into a worktree), the watch/dashboard tooling (must follow the run-state path indirection), and downstream consumers of `LocalRunState`. + +## Goals / Non-Goals + +**Goals:** +- Replace branch-checkout with worktree creation in `specflow-prepare-change`, end-to-end, with no behavioural fallback. +- Keep the user's repository working tree untouched (HEAD, branch, staged/unstaged/untracked state) across the entire `/specflow` lifecycle. +- Move every main-session phase command's `cwd` to the worktree resolved from run-state. +- Push/PR from inside the worktree using the recorded base branch, not always `main`. +- Tear down the entire `.specflow/worktrees/<CHANGE_ID>/` subtree on terminal phases when the run is clean and complete; defer with `cleanup_pending = true` otherwise. +- Refuse to load any persisted `RunState` whose `worktree_path == repo_path` and `run_kind != "synthetic"` (legacy guard with synthetic-run exemption). + +**Non-Goals:** +- Auto-migration of in-flight legacy runs. The team drains them manually before this lands; the code does not branch on a `legacy_mode` flag. +- Configurable worktree paths. `.specflow/worktrees/<CHANGE_ID>/main/` is fixed. +- Multi-base/branch features (e.g. stacked PRs, base-rebase). Out of scope for this change. +- Changes to `canonical-workflow-state`. Path semantics live in the local adapter; the canonical surface is unchanged. +- A new `specflow-cleanup-worktree` helper command. The deferred-cleanup story is "user resolves manually"; tooling for that is a follow-up. +- Concurrent `/specflow` runs for *different* changes have always been allowed in principle but were blocked in practice by branch contention; this change makes them work, but explicit concurrency hardening (lockfiles, coordination) is out of scope. + +## Decisions + +### D1. Path layout: `.specflow/worktrees/<CHANGE_ID>/{main, <RUN_ID>/<BUNDLE_ID>}` + +Picked the per-change parent over flat `.specflow/worktrees/main/<CHANGE_ID>/` so that everything for one change cleans up in a single `rm -rf`. Subagent worktrees become siblings of main under the same parent. Rejected: a flat `.specflow/worktrees/<RUN_ID>/<BUNDLE_ID>/` (the existing layout) because it leaves orphans across changes; rejected configurability because Phase-1 should be opinionated. + +### D2. Branch lives only inside the worktree, named `<CHANGE_ID>` + +`git worktree add -b <CHANGE_ID> .specflow/worktrees/<CHANGE_ID>/main/ HEAD` creates the branch atomically with the worktree. The user-repo's branch ref is never updated. Reasoning: keep `change_name == branch_name`, no PR ergonomics regression, and detached-HEAD avoidance (rejected alternative C1-b: detached HEAD complicated push/PR semantics). + +### D3. Base commit is the user-repo HEAD at first prepare-change + +`git -C <user-repo> rev-parse HEAD` is captured *before* `git worktree add`, then passed as the third arg. Recorded in run-state as `base_commit` and (separately) as `base_branch` (`git -C <user-repo> branch --show-current`). Reasoning: matches the user's mental model ("I started this from feature/X, so the PR should target feature/X"). Rejected alternative C4-b (always default branch) because feature-branch starts would produce huge cross-feature diffs. + +### D4. Reuse existing `.specflow/worktrees/<CHANGE_ID>/main/` as-is + +The reuse predicate is "directory exists AND `git worktree list --porcelain` shows it tied to `refs/heads/<CHANGE_ID>`". On a hit, reuse without modification (no `git pull`, no checkout, no branch repoint). Rejected alternative C3-b (auto-prune and recreate) because it can erase uncommitted state; rejected C3-c (detect any same-named branch and reuse) because branch-without-worktree is exactly the conflict we want to fail-fast. + +### D5. Conflict fail-fast (no auto-recovery, no automatic prune) + +If the conventional path is occupied by a non-worktree directory, by a worktree pointing at a different branch, or by a worktree at a *different* path that owns `<CHANGE_ID>`, `prepare-change` exits non-zero with the offending path/branch in the message. The user fixes manually (`git worktree remove`, `git branch -D`, or pick a new change-id). Rejected silent prune for the same reason as D4. + +`prepare-change` SHALL NOT run `git worktree prune` automatically. Stale or conflicting worktree state (including orphaned `.git` entries left by OS-level directory deletion) is surfaced for manual resolution, not auto-recovered. This is the single-path, fail-fast policy: detect and report, never silently mutate git worktree registry state. The user may run `git worktree prune` manually when needed. + +### D6. `LocalRunState` extension: `base_commit`, `base_branch`, `cleanup_pending` + +The drift-guard test in `src/tests/run-state-partition.test.ts` enforces exact key sets, so we extend both the type definition in `src/types/contracts.ts` AND the test. Three fields: +- `base_commit: string` — the SHA captured at worktree creation; immutable thereafter. +- `base_branch: string | null` — the user's branch at creation time (null when detached). +- `cleanup_pending: boolean` — flipped to `true` when a terminal phase defers cleanup. Default `false`. + +Rejected alternative: stash these in a sidecar JSON. Run-state is already the authoritative ledger; an extra file fragments the source of truth. + +### D7. Push/PR resolution + +Inside the worktree: +1. `git push -u origin <CHANGE_ID>` (cwd = worktree path). +2. PR base resolution: read `base_branch` from run-state. If `base_branch` has an upstream tracking ref (`git -C <worktree> rev-parse --abbrev-ref <base_branch>@{upstream}` succeeds), use the remote-side branch name. Otherwise fall back to `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`. +3. `gh pr create --base <resolved> --head <CHANGE_ID> ...` (cwd = worktree path so `gh` picks the right remote). + +Rejected: cherry-picking commits back into the user repo before pushing (C7-b). It re-introduces the contamination this change is removing. + +### D8. Cleanup gating + +Terminal phases (`approve`, `archive`, `reject`) compute a 2-bit gate at entry: +- `success_full`: terminal action returned exit 0, with no recorded partial-failure cause. +- `tree_clean`: for every worktree under `.specflow/worktrees/<CHANGE_ID>/`, `git -C <wt> status --porcelain` is empty. + +If both bits are set → `git worktree remove` each (in any order; `git worktree remove --force` is NOT used) → `rm -rf .specflow/worktrees/<CHANGE_ID>/`. +Otherwise → write `cleanup_pending = true` to run-state, surface the offending paths/cause to stderr, exit 0 (the run is still terminal). Operator resolves and re-invokes the terminal phase to retry cleanup. + +Rejected alternative: always force-remove on terminal entry (C6-b). It can destroy in-progress recovery work the operator was about to commit. + +### D9. Legacy guard placement (with synthetic-run exemption) + +The check `worktree_path === repo_path` lives in `specflow-prepare-change`'s wiring layer (where run-state is loaded), not in core. Core stays oblivious to local-adapter semantics per the existing `Core runtime commands are pure and perform no I/O` requirement. The check fires on `prepare-change` resume; it does NOT fire when other CLIs (e.g., `specflow-run get-field`) read the same record, because those flows are read-only and we still need them for inspection. + +**Synthetic-run exemption**: The legacy guard SHALL NOT apply when `run_kind === "synthetic"`. Synthetic runs never carry a `repo_path`/`worktree_path` divergence by design (per `workflow-run-state` spec). The full predicate is: reject when `worktree_path === repo_path AND run_kind !== "synthetic"`. + +### D10. cwd resolution for phase commands + +Every command that today does `process.cwd()`/`git rev-parse --show-toplevel` and treats it as the integration target is migrated to: read run-state via `RunArtifactStore`, take `state.worktree_path` as the cwd. The user can still invoke `/specflow.<phase>` from anywhere; the resolution is run-id → run-state → worktree path. + +The existing `WorkspaceContext` interface (`src/lib/workspace-context.ts:14-23`) already exposes `worktreePath()`. Today its concrete impl returns the same value as `projectRoot()`. We extend the local-fs implementation to take an optional override path so the wiring layer can construct a `WorkspaceContext` rooted at the main-session worktree when a `RUN_ID` is in scope. + +## Risks / Trade-offs + +| Risk | Impact | Mitigation | +|---|---|---| +| **Hard-coded `process.cwd()` in deeply-nested CLIs** finds its way into integration paths and silently uses the user repo. | Bundles get applied to the user's branch, defeating the change. | Add a smoke test that runs `/specflow.apply` end-to-end and asserts user repo HEAD did not move. Add a grep-guard test that fails if `src/bin/**` outside `specflow-prepare-change` and the workspace-context factory contains `process.cwd()` or `rev-parse --show-toplevel` for write paths. | +| **Disk usage growth.** Each change carries a full worktree. | A long-running developer accumulates many worktrees. | `.specflow/worktrees/<CHANGE_ID>/` is removed on terminal phases. Document that abandoned changes should be `/specflow.reject`'d; mention `git worktree prune`. | +| **gh CLI cwd assumptions.** `gh pr create` can mis-detect remotes if cwd is a worktree of a non-canonical repo. | PR creation fails or targets wrong remote. | Always run `gh` with `cwd = worktree path`; add an integration test that `gh pr create` works from inside `.specflow/worktrees/.../main/`. | +| **Reuse race**: two concurrent `/specflow` invocations for the same change-id. | Second invocation could observe a half-built worktree. | `git worktree add` is atomic; the reuse-vs-create branch is gated by checking `git worktree list --porcelain` (single-shot). Acceptable in Phase 1; document as a known limitation. | +| **Symlink / case-insensitive FS collisions** on macOS where `<CHANGE_ID>` casing differs. | Phantom conflicts. | `prepare-change` lowercases / normalizes change-ids consistently with the existing `deriveChangeId` rules; the conflict fail-fast already covers detection. | +| **Watcher cwd assumption.** `specflow-watch` reads artifacts from `process.cwd()` today, which would break under the new layout. | Watcher loses track of bundle progress. | Auditing the watcher path is part of this change's task list. The watcher already takes `<RUN_ID>` as input; resolution becomes "load run-state, use `worktree_path`". | +| **Ledger / dashboard CLIs that scan `openspec/changes/<CHANGE_ID>/` from the user repo path.** | Show stale or empty data because artifacts now live inside the worktree. | `openspec/changes/<CHANGE_ID>/` is created INSIDE the worktree (because `openspec new change` runs with cwd = worktree). Dashboard/archive readers must follow `worktree_path`. Captured as tasks. | +| **`openspec` CLI also assumes cwd.** | OpenSpec validate/instructions called from the wrong cwd produces noisy errors. | Wiring layer always invokes `openspec` with explicit `cwd = worktree path` (already the pattern in `prepare-change`; extend it elsewhere). | + +## Migration Plan + +This change is breaking and ships with no in-process migration: + +1. Pre-merge: maintainers drain all in-flight `/specflow` runs (approve or reject) so that no run-state on disk has `worktree_path == repo_path` for non-synthetic runs. +2. Merge. +3. Post-merge: any old run-state still on disk (e.g., a developer's local stash) triggers the legacy guard on `prepare-change` resume; the user is told to manually approve/reject and start fresh. + +Rollback: revert the merge commit. The new run-state with `base_commit`/`base_branch`/`cleanup_pending` is JSON-additive; older binaries ignore unknown fields, so a rollback only needs `git worktree remove` + `git branch -D` for any in-progress worktree-mode changes the user wants to migrate back to legacy. + +## Resolved Policy Decisions + +The following items were initially open questions; they are now resolved and encoded as binding design decisions: + +- **Cleanup retries**: `cleanup_pending = true` does NOT block subsequent terminal-phase invocations. The gate re-evaluates each invocation; a clean+complete state on the retry triggers cleanup and clears `cleanup_pending`. (Encoded in D8.) +- **No automatic `git worktree prune`**: `prepare-change` SHALL NOT run `git worktree prune` automatically. Stale or conflicting worktree state is surfaced for manual resolution via D5's fail-fast contract. Running `git worktree prune` silently could mask user-relevant state (e.g., a directory deleted by the OS but containing recoverable work). The user may run `git worktree prune` manually if needed. (Encoded in D5.) +- **Legacy read-only inspection**: The legacy guard (`worktree_path == repo_path` rejection) fires only on `prepare-change` resume. Read-only inspection commands (`specflow-run status`, `specflow-run get-field`, etc.) can still load legacy records without error. (Encoded in D9.) + +## Concerns + +- **C-1: Worktree lifecycle (create / reuse / fail-fast).** The user-facing concern is "/specflow no longer hijacks my branch." Resolved by D1–D5 in `specflow-prepare-change`. +- **C-2: Run-state schema extension.** The persisted `RunState` must carry `base_commit`, `base_branch`, `cleanup_pending`. Resolved by D6 plus drift-guard test update. +- **C-3: Phase-command cwd routing.** All `/specflow.*` commands and the watcher must operate on the worktree, not the user repo. Resolved by D10. +- **C-4: Subagent dispatch retargeting.** Subagent base HEAD and patch-apply target shift to the main-session worktree. Resolved by the `apply-worktree-integration` and `bundle-subagent-execution` deltas. +- **C-5: Approve push & PR base.** PR targets the recorded `base_branch`, push runs from the worktree. Resolved by D7. +- **C-6: Terminal cleanup.** Approve/archive/reject tear down the worktree subtree iff clean and complete; otherwise defer. Resolved by D8. +- **C-7: Legacy guard.** Old run-states are rejected on resume. Resolved by D9. + +## State / Lifecycle + +Phase machine itself does NOT change; the existing `workflow-run-state` transitions stay intact. What changes is per-run *adapter state*: + +| Field | Phase introduced | Mutated by | Lifetime | +|---|---|---|---| +| `worktree_path` | start (`prepare-change`) | start; never mutated thereafter | until run-state file is deleted | +| `branch_name` | start | start | until terminal cleanup | +| `base_commit` | start | start | until terminal cleanup | +| `base_branch` | start | start | until terminal cleanup | +| `cleanup_pending` | terminal phases (approve/archive/reject) | terminal phase deferral path; cleared on successful retry that completes cleanup | persists across CLI invocations | + +Worktree lifecycle (separate from run state): +1. **Created** at first `prepare-change` for a change. +2. **Active** through draft → review → ready → terminal entry. +3. **Removed** on terminal entry IFF clean+complete; else **persisted** with `cleanup_pending = true`. +4. **Re-removed** on a subsequent terminal-phase invocation that observes clean+complete. + +Persistence-sensitive state: `base_commit`/`base_branch` MUST be set atomically with worktree creation; otherwise a crash leaves the run-state inconsistent with the worktree (e.g., wrong base for PR). Implementation: write run-state AFTER `git worktree add` succeeds; if `git worktree add` fails, no run-state is written. + +## Contracts / Interfaces + +### `WorkspaceContext` extension (existing interface, `src/lib/workspace-context.ts:14-23`) +The interface adds two read-only accessors on the local-fs concrete implementation (NOT on the interface in core, to keep core agnostic): +- `baseCommit(): string` — reads from run-state via the wiring-layer construction site. +- `baseBranch(): string | null` — same. + +The interface itself is untouched. Wiring code that needs base info reads it directly from `RunState` (which already flows through the wiring layer); the accessors are only exposed where the local-fs context is materialized. + +**Repo-root vs worktree-root accessor split**: When a `WorkspaceContext` is constructed for a worktree-mode run, `repo_path` and `worktree_path` diverge. The following contract governs which path each accessor resolves against: +- `projectRoot()` → `repo_path` (the user's repository root). Used for `.specflow/` administrative paths, git worktree registry operations, and any read that must see the original repo. +- `worktreePath()` → `worktree_path` (`.specflow/worktrees/<CHANGE_ID>/main/`). Used as `cwd` for all phase commands, `openspec` invocations, artifact reads/writes, and subagent dispatch. +- `baseCommit()` → reads `base_commit` from the persisted `RunState`. +- `baseBranch()` → reads `base_branch` from the persisted `RunState`. + +The local-fs concrete implementation MUST accept both `repo_path` and `worktree_path` at construction time (via the wiring layer) and MUST NOT assume they are equal. Persisted `run.json` MUST contain distinct `repo_path` and `worktree_path` values for worktree-mode runs. + +### `LocalRunState` (in `src/types/contracts.ts`) +```ts +export interface LocalRunState { + readonly project_id: string; + readonly repo_name: string; + readonly repo_path: string; + readonly branch_name: string; + readonly worktree_path: string; + readonly base_commit: string; // NEW + readonly base_branch: string | null; // NEW + readonly cleanup_pending: boolean; // NEW + readonly last_summary_path: string | null; +} +``` +Drift-guard test: extend the disjoint/exhaustive assertion in `src/tests/run-state-partition.test.ts` to include the three new keys. + +### `specflow-prepare-change` CLI (wiring layer) +Replace `ensureBranch(root, changeId)` with `ensureMainSessionWorktree(root, changeId, source) → { worktreePath, baseCommit, baseBranch }`. The function: +1. Checks `git worktree list --porcelain` for an existing entry tied to `<CHANGE_ID>`. +2. On match at the conventional path → reuse. +3. On match at a non-conventional path or branch existing without matching worktree → fail-fast. +4. Otherwise: `git -C <user-repo> rev-parse HEAD` → `git -C <user-repo> branch --show-current` → `git worktree add -b <CHANGE_ID> .specflow/worktrees/<CHANGE_ID>/main/ <HEAD>` → return. + +The downstream call to `ensureProposalDraft`, `ensureRunStarted`, etc., now flows with `cwd = worktreePath`, NOT `cwd = root`. `openspec new change`, `openspec instructions`, `specflow-run start` all run inside the worktree. + +### Terminal-phase contract (approve/archive/reject) +``` +gateInputs: { run-state, list of worktree paths } +gateOutputs: { decision: "remove" | "defer", reasons: string[] } +sideEffects: git worktree remove × N → rm -rf parent (decision=remove) + | run-state.cleanup_pending = true (decision=defer) +``` + +### Subagent dispatch contract (delta in `apply-worktree-integration`) +- Input: `mainSessionWorktreePath = state.worktree_path`. +- **Per-bundle base commit**: At subagent worktree creation time, the main-session worktree HEAD is captured as the bundle's `base_commit_sha`. This SHA is persisted in the bundle's metadata (e.g., `bundle.json` or equivalent artifact written by the apply pipeline). Each bundle records its own base independently; there is no shared apply-run base snapshot. Later bundles inherit the integrated state of earlier bundles because their base is captured after prior patches have landed. +- Subagent worktree creation: `git -C <user-repo> worktree add <absolute-path-to-.specflow/worktrees/CHANGE_ID/RUN_ID/BUNDLE_ID/> <main-session-HEAD>`. The `<main-session-HEAD>` is the SHA captured above. To avoid a nested-`.specflow` artifact, subagent worktrees are created via `git -C <user-repo>` with the explicit absolute path under the user repo's `.specflow/`. The user repo's `.git` is the worktree registry; both main-session and subagent worktrees share that registry. +- Patch import: `git -C <subagentWorktree> diff --binary <bundle_base_commit_sha>..HEAD | git -C <mainSessionWorktreePath> apply --binary`. The diff base is the bundle's recorded `base_commit_sha`, not a shared run-level snapshot. + +## Persistence / Ownership + +| Path | Owner | Lifetime | Notes | +|---|---|---|---| +| `<userRepo>/.specflow/worktrees/<CHANGE_ID>/main/` | main agent | start → terminal cleanup | Holds the change branch and all artifacts under `openspec/changes/<CHANGE_ID>/` | +| `<userRepo>/.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/` | apply pipeline | bundle dispatch → bundle done/retain | Existing subagent worktree, re-parented under change | +| `<userRepo>/.specflow/runs/<RUN_ID>/run.json` | wiring layer (run-store) | start → cleanup complete | Records `worktree_path`, `base_commit`, `base_branch`, `cleanup_pending`. When a terminal phase defers cleanup (`cleanup_pending = true`), the run-state file MUST be retained past the terminal transition — it is NOT deleted at archive/approve/reject time. The file is only deleted (or may be deleted) after a subsequent invocation completes cleanup and clears `cleanup_pending`. This ensures the retry path can still resolve `worktree_path`, enumerate worktrees for the gate check, and read `cleanup_pending` itself. | +| `<userRepo>/openspec/changes/<CHANGE_ID>/...` | OpenSpec | start → archive | The canonical artifact directory is created and maintained INSIDE the worktree. Archive reads artifacts from the worktree via `worktree_path`; it does NOT propagate or copy them back to the user repo's working tree. The user repo remains read-only throughout the change lifecycle. | + +The user repo's working tree is read-only with respect to specflow during a change's lifecycle (apart from the `.specflow/worktrees/` and `.specflow/runs/` administrative directories, which it owns). + +## Integration Points + +- **`openspec` CLI**: invoked with `cwd = worktree path` for `new change`, `validate`, `instructions`. No protocol change. +- **`gh` CLI**: invoked with `cwd = worktree path` for `pr create`. Inherits the user repo's remote because `git worktree add` shares `.git/`. +- **`git`**: `worktree add`, `worktree list --porcelain`, `worktree remove`, `branch --show-current`, `rev-parse HEAD` are all called with explicit `cwd`. +- **External watch / dashboard / chief-of-staff hooks**: receive `<RUN_ID>` as input; resolve `worktree_path` via run-state; read artifacts from there. No direct cwd assumption. +- **CI**: pipelines that rely on the user repo's branch ref reaching a state matching `<CHANGE_ID>` no longer see that ref in the user-repo `.git` until the worktree's branch is pushed. Push happens at `/specflow.approve`. Pre-approve CI can be run by the user manually inside the worktree (`cd .specflow/worktrees/<change>/main && pnpm test`); a future enhancement may automate this. + +## Ordering / Dependency Notes + +Implementation order (foundational → derived): +1. **Run-state schema + drift guard + `WorkspaceContext` factory + `specflow-prepare-change` rewrite** (`LocalRunState` extension, `ensureMainSessionWorktree`, conflict fail-fast with no auto-prune, base_commit/base_branch capture, cwd indirection). These share the run-state contract and must land together. +2. **Apply pipeline retargeting** (`apply-worktree-integration` + `bundle-subagent-execution` deltas): subagent worktree path layout, base HEAD source, patch-apply target. +3. **Phase command cwd resolution** (every `/specflow.*` skill that today assumes user-repo cwd reads `worktree_path` from run-state). +4. **Approve push + PR base resolution** (`base_branch` lookup → `gh pr create --base`). +5. **Terminal cleanup** (approve/archive/reject gate evaluation, `cleanup_pending` deferral). +6. **Legacy guard** in `prepare-change` (with synthetic-run exemption per D9). +7. **Watcher + dashboard cwd updates** (archive reads from worktree, no propagation to user repo). +8. **End-to-end smoke test**. + +Items in step 1 must land in one bundle. 2 and 3 can run in parallel after 1. 4–5 follow 3. 6 is independent of 2–5 but depends on 1. 7 follows 3. 8 is the final verification. + +## Completion Conditions + +- **C-1 done** when `specflow-prepare-change` running on a fresh repo never invokes `git checkout` against the user repo, leaves the user-repo HEAD/branch/dirty state unchanged, and creates `.specflow/worktrees/<CHANGE_ID>/main/` with branch `<CHANGE_ID>`. +- **C-2 done** when `RunState`/`LocalRunState` carry `base_commit`, `base_branch`, `cleanup_pending`, persisted run.json contains all three, and the drift-guard test enforces them. +- **C-3 done** when every `/specflow.*` command, the watcher, and the dashboard, executed against an active run, operate inside the worktree (verified by an integration test that asserts no writes to the user repo's working tree). +- **C-4 done** when `git apply --binary` of a successful subagent's diff lands in `.specflow/worktrees/<CHANGE_ID>/main/`, NOT in the user repo, and the subagent worktree path is `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`. +- **C-5 done** when `/specflow.approve` pushes from inside the worktree and creates a PR whose base equals the recorded `base_branch` (or default branch as fallback). +- **C-6 done** when a clean-and-complete terminal phase deletes `.specflow/worktrees/<CHANGE_ID>/`; a dirty-or-partial terminal phase writes `cleanup_pending = true` and leaves the worktree on disk. +- **C-7 done** when `specflow-prepare-change` resuming a run with `worktree_path == repo_path` AND `run_kind != "synthetic"` exits non-zero with a clear message and modifies nothing; AND when a synthetic run with `worktree_path == repo_path` is NOT rejected by the guard. + +Each concern has at least one integration test that exercises the user-repo invariants (HEAD unchanged, no rogue commits) using a temp git repo fixture. diff --git a/openspec/changes/archive/2026-04-25-worktree/implementation-notes.md b/openspec/changes/archive/2026-04-25-worktree/implementation-notes.md new file mode 100644 index 0000000..2c17c48 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/implementation-notes.md @@ -0,0 +1,77 @@ +# Implementation Notes: Worktree Mode (Bundle 1 output) + +This document captures the **single, locked-down behavioral contract** for the worktree-mode change. Downstream bundles SHALL implement these policies as-is — there is no `legacy_mode` flag, no auto-recovery branch, and no per-environment override. + +## 1. Cleanup retry policy (D8 follow-on) + +Behavior: +- Every invocation of a terminal-phase CLI (`/specflow.approve`, `/specflow.archive`, `/specflow.reject`) re-evaluates the cleanup gate (`success_full ∧ tree_clean`) against the current run-state and worktree contents. +- If the gate evaluates `true`, cleanup runs and `cleanup_pending` is cleared (set to `false` if it was `true` from a prior deferral). +- If the gate evaluates `false`, `cleanup_pending` is set to `true` (or left `true`), the run remains in its terminal phase, and offending paths / partial-failure cause are surfaced on stderr. +- The CLI exits 0 in BOTH outcomes — terminal-phase entry is not blocked by deferred cleanup. The user can re-invoke the terminal CLI later to retry cleanup once the failure root cause is resolved. + +What is NOT permitted: +- Force-removal (`git worktree remove --force`). The non-force variant is the only allowed call; if it fails (typically because the worktree is dirty), the gate has already deferred and we never reach the remove step anyway. +- Auto-stash / auto-commit of dirty worktree state to "make it clean enough" for cleanup. +- Re-evaluating only on a separate `specflow-cleanup-worktree` helper. (None such helper ships in this change; users re-invoke the terminal CLI.) + +Acceptance condition for a downstream bundle: the implementation re-evaluates the cleanup gate on every terminal-phase invocation, including subsequent re-entries while `cleanup_pending = true`. + +## 2. `git worktree prune` policy (D5 follow-on, also touches the design's Open Questions section) + +**No automatic `git worktree prune` SHALL be invoked by any specflow CLI** (including `prepare-change`, the apply pipeline, and the terminal cleanup gate). This applies even to the "safety-only" variant the design originally entertained. + +Rationale: +- Pruning the registry can mask real conflict states the user must resolve (e.g., a manually-deleted directory whose `.git/worktrees/` entry is the only signal that something went wrong). +- D5's fail-fast policy says specflow "detects and reports, never silently mutates git worktree registry state". An automatic prune is exactly that mutation. +- The earlier draft of the design listed "safety-only auto-prune" as an Open Question with a working assumption of "yes". The locked policy reverses that working assumption to "no", aligning the design narrative with the spec deltas. + +User-facing path: when `git worktree list` shows a stale entry the user wants to clean, the user runs `git worktree prune` themselves. This is documented in CLI error messages (e.g., the conflict fail-fast in `prepare-change`). + +Acceptance condition for a downstream bundle: no specflow code path calls `git worktree prune`. A grep guard test (covered by bundle 9 / task 8.4) enforces this. + +## 3. Legacy read-only inspection semantics (D9 follow-on) + +The legacy guard rejects only the **resume / mutating** path. Read-only inspection commands SHALL still load legacy run-state records. + +Inclusive list (legacy guard fires HERE — refuse, surface remediation): +- `specflow-prepare-change` (when resuming an existing change whose run-state has `worktree_path == repo_path` and `run_kind != "synthetic"`). This is the entry to all mutating phase work. + +Exclusive list (legacy guard does NOT fire — record loads normally): +- `specflow-run get-field` / `specflow-run status` / `specflow-run list` — read-only inspection. +- `specflow-watch` — read-only stream. +- `specflow.dashboard` — read-only aggregator. +- Any other CLI whose surface is documented as read-only. + +Synthetic-run exemption: any record with `run_kind == "synthetic"` SHALL bypass the legacy guard regardless of whether `worktree_path == repo_path`. Synthetic runs never adopt the worktree-mode path layout (they have no associated change directory), and the spec explicitly carves them out. + +Acceptance condition for a downstream bundle: the guard predicate is `state.run_kind !== "synthetic" && state.worktree_path === state.repo_path`, scoped to `specflow-prepare-change`. Read paths construct run-state without invoking the guard. Both paths covered by tests in bundle 7 (legacy-runstate-guard) and re-asserted by bundle 9 (worktree-invariant-verification, task 8.4). + +## 4. Rejected alternatives (locked) + +Capturing the rejected alternatives here so downstream bundles do not re-litigate them. + +| Concern | Rejected alternative | Reason | +|---|---|---| +| Worktree path layout | `.specflow/worktrees/main/<CHANGE_ID>/` (separate main parent) | Prevents single-`rm -rf` cleanup; adds path-resolution complexity. | +| Branch creation | Detached HEAD inside the worktree | Complicates push/PR semantics; loses the `change_name == branch_name` invariant. | +| Base commit source | Repo's default branch (`main`) HEAD | Feature-branch-rooted changes would produce huge cross-feature diffs. | +| Conflict policy | Auto-prune any conflicting worktree before recreating | Can erase uncommitted state. Fail-fast and let the user resolve. | +| Migration | `legacy_mode` flag on run-state | Doubles the maintenance surface; in-flight runs are drained pre-merge instead. | +| Cleanup | Always force-remove on terminal entry | Destroys in-progress recovery work. Gate on clean-and-complete; defer otherwise. | +| Worktree prune | "Safety-only" auto-prune in `prepare-change` | Hides real conflict states. User runs prune manually. | +| Read-only inspection | Apply legacy guard everywhere | Breaks `specflow-run get-field` / watcher / dashboard for legacy records. Guard only the mutating resume entry. | +| Push/PR | Cherry-pick worktree commits into the user repo before push | Re-introduces the user-repo contamination this change removes. | +| Run-state extension | Sidecar JSON for `base_commit`/`base_branch`/`cleanup_pending` | Fragments the source of truth. Extend `LocalRunState` and the drift guard. | + +Each rejected alternative is referenced by ID in downstream bundle PR descriptions if it comes up during review. + +## 5. Resolved Open Questions (Design ↔ Spec alignment) + +The design's `Open Questions` section had three items. They are now LOCKED to: + +1. **"Should `cleanup_pending = true` block subsequent retries?"** → No, retries are always allowed; the gate re-evaluates each invocation. (See §1.) +2. **"Should `git worktree prune` run automatically?"** → No, never. (See §2.) +3. **"What happens when a user inspects a legacy run via `specflow-run status`?"** → Read paths are unaffected; only `prepare-change` blocks. (See §3.) + +These resolutions bind every downstream bundle. If a future change reopens any of them, it must do so via a new proposal under `/specflow`, not by re-introducing fallback paths in this change's bundles. diff --git a/openspec/changes/archive/2026-04-25-worktree/proposal.md b/openspec/changes/archive/2026-04-25-worktree/proposal.md new file mode 100644 index 0000000..fcc5ae0 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/proposal.md @@ -0,0 +1,65 @@ +## Why + +Today the main `/specflow` session checks out a new branch in the user's repository root (`git checkout -b <change-id>`), which mutates the user's working tree, contaminates uncommitted state, and serializes work to one change at a time. Subagent bundles already enjoy isolated worktrees under `.specflow/worktrees/<RUN_ID>/<BUNDLE_ID>/`, but the **main agent itself** does not — so starting a `/specflow` flow forces the user off whatever branch they were on. The user wants `/specflow` to leave the original branch untouched and run main work inside a dedicated worktree, mirroring the isolation already used for subagents. + +Source: GitHub issue [skr19930617/specflow#186](https://github.com/skr19930617/specflow/issues/186) — "ブランチを切らずにworktreeで作業する". The issue title's "ブランチを切らずに" means "without disturbing the user-repo's branch"; creating the `<CHANGE_ID>` branch *inside the dedicated worktree* is explicitly allowed because the worktree itself is an independent working tree. + +## What Changes + +### Main-session worktree creation +- **BREAKING** `specflow-prepare-change` SHALL no longer call `git checkout -b <change-id>` on the user's working tree. Instead it SHALL create (or reuse) a dedicated main-session worktree at `.specflow/worktrees/<CHANGE_ID>/main/` and operate inside it. +- The branch checked out inside the main-session worktree SHALL be named `<CHANGE_ID>` (`change_name == branch_name` is preserved). The branch is created inside the worktree only; the user's repo root is never `checkout`-ed to `<CHANGE_ID>`. +- The main-session worktree SHALL be created from the user repository's current `HEAD` at the moment `/specflow` is first invoked for that change. Whatever branch the user is on becomes the base commit; specflow does not jump to `main`/default. +- Staged, unstaged, and untracked changes in the user's repo root SHALL NOT block worktree creation and SHALL NOT be moved into the worktree. They remain in the user's working tree exactly as they were. (This is the core "no contamination" guarantee.) +- The base commit recorded at worktree creation SHALL be persisted in run-state as `base_commit` so later phases (esp. PR-base resolution) can use it. + +### Conflict and reuse policy +- If `.specflow/worktrees/<CHANGE_ID>/main/` already exists and is registered as a worktree for the same change, specflow SHALL reuse the existing worktree and branch as-is (preserving in-progress uncommitted state). +- If a local branch named `<CHANGE_ID>` exists but is *not* tied to `.specflow/worktrees/<CHANGE_ID>/main/`, OR if a worktree registered to that branch is at any other path, `prepare-change` SHALL fail-fast with a clear message and require the user to manually resolve (rename branch, prune the stale worktree, or pick a new change_id). specflow SHALL NOT silently delete user state. + +### Run-state semantics +- The run-state's `worktree_path` SHALL point to `.specflow/worktrees/<CHANGE_ID>/main/`; `repo_path` SHALL continue to point at the user-facing repository root, and the two SHALL be allowed to differ. +- All subsequent specflow commands invoked for that change (`/specflow.design`, `/specflow.apply`, `/specflow.review_*`, `/specflow.approve`, `/specflow.reject`, `/specflow.archive`, etc.) SHALL operate against the main-session worktree resolved from run-state, not against the user's repo root. + +### Subagent integration target +- Subagent worktrees SHALL continue to be created per `apply-worktree-integration`, but their base HEAD source SHALL shift from "user repo HEAD" to "main-session worktree HEAD". The convention "main workspace" everywhere in `apply-worktree-integration` and `bundle-subagent-execution` SHALL be re-bound to "main-session worktree". +- Subagent worktrees SHALL live under `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/` so all worktrees for a given change share a common parent and can be torn down together. + +### Approve / push / PR +- `/specflow.approve` SHALL run `git push -u origin <CHANGE_ID>` from inside the main-session worktree and create the PR from there. The user's repo branch SHALL NOT be touched, cherry-picked, or merged into. +- The PR's **base branch** SHALL be the branch that originally contained the worktree's `base_commit` (recorded at creation). Concretely: specflow resolves the upstream branch of `base_commit` (or, if missing, the local branch the user was on at `prepare-change` time) and uses that as the PR base. This means a `/specflow` started from a feature branch produces a PR targeting that feature branch, not always `main`. + +### Cleanup +- `/specflow.approve`, `/specflow.archive`, and `/specflow.reject` SHALL remove the entire `.specflow/worktrees/<CHANGE_ID>/` subtree (`git worktree remove` for each registered worktree, then `rm -rf` the parent) **only when**: + - the terminal phase succeeded fully (no partial-failure state recorded), AND + - every worktree under `.specflow/worktrees/<CHANGE_ID>/` is clean (`git -C <wt> status --porcelain` is empty). +- If either condition fails, cleanup SHALL be deferred. specflow SHALL surface the dirty paths / partial-failure cause to the user and exit with the run still in its terminal phase but with a `cleanup_pending` marker. The user resolves manually (commit/discard, rerun the operation, or invoke a future `specflow-cleanup-worktree` helper). + +### No legacy mode +- This change replaces the old branch-checkout behavior wholesale. There is **no `legacy_mode` flag** on run-state and no dual-path codebase: in-flight changes whose run-state still records `worktree_path == repo_path` SHALL be drained (approved or rejected) before this change is merged. No automatic migration logic ships. +- `prepare-change` MAY emit a one-time error when it encounters such a legacy run-state, asking the user to finish or reject the legacy change before continuing. + +## Capabilities + +### New Capabilities +- `main-session-worktree`: defines the contract for creating, reusing, and tearing down a per-change worktree that hosts the main agent session, separate from the user's working repository root. Covers path convention, base HEAD source, dirty-repo behavior, conflict / reuse policy, push/PR semantics, and cleanup gating. + +### Modified Capabilities +- `apply-worktree-integration`: subagent worktrees are now created from the main-session worktree HEAD, not from the user-repo HEAD; the integration import target shifts from "main workspace" (user repo) to the main-session worktree. Subagent worktree path convention also moves under the per-change parent (`.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`). +- `workflow-run-state`: `LocalRunState` SHALL gain three new adapter-private fields (`base_commit`, `base_branch`, `cleanup_pending`); the `Started runs capture repository metadata via WorkspaceContext` scenario and the disjoint partition guard scenarios SHALL be updated to enumerate the new fields. `worktree_path` SHALL be allowed to differ from `repo_path`. +- `bundle-subagent-execution`: the workspace where subagent diffs are applied SHALL be the main-session worktree, not the user-facing repository root. + +(Note: `canonical-workflow-state` is intentionally NOT modified. Its baseline already classifies `worktree_path` and `branch_name` as adapter-private examples — the canonical surface only enumerates nine semantic roles, none of which encode the path convention. The new path semantics are an adapter-level concern captured by `main-session-worktree` and `workflow-run-state`.) + +## Impact + +- Affected code: + - `src/bin/specflow-prepare-change.ts` — replace `ensureBranch` (mutates the user's repo) with `ensureMainSessionWorktree` (creates/reuses `.specflow/worktrees/<CHANGE_ID>/main/`, records `base_commit`). + - Run-state writers/readers: persist `base_commit`, allow `worktree_path != repo_path`, refuse to load run-state with `worktree_path == repo_path` (legacy guard). + - Subagent dispatch path under `apply-worktree-integration` — change base HEAD source and parent directory layout. + - Approve / Reject / Archive paths — push from worktree, base-branch resolution from `base_commit`, dirty/partial-aware cleanup. + - Watch / Dashboard CLIs — confirm they resolve target paths via `worktree_path` from run-state and not via `process.cwd()` or `repo_path`. +- User-facing change: invoking `/specflow` no longer modifies the user's checked-out branch or working tree. PRs target the branch the user started from, not always `main`. +- Release coordination: this change is breaking. In-flight legacy runs SHALL be drained (approved or rejected) before this lands. The team should freeze new legacy-mode `/specflow` starts during the cutover. +- Cleanup: archived/rejected/approved changes SHALL leave no stale worktree under `.specflow/worktrees/<CHANGE_ID>/` *unless* a dirty/partial state intentionally defers cleanup. +- Tests: `prepare-change` integration tests, run-state schema tests, apply-integration tests, and approve flow tests need updates to assert worktree creation, path conventions, the new base-HEAD source, base-branch PR resolution, and dirty/partial cleanup gating. diff --git a/openspec/changes/archive/2026-04-25-worktree/review-ledger-design.json b/openspec/changes/archive/2026-04-25-worktree/review-ledger-design.json new file mode 100644 index 0000000..e26b10b --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/review-ledger-design.json @@ -0,0 +1,149 @@ +{ + "feature_id": "worktree", + "phase": "design", + "current_round": 3, + "status": "in_progress", + "max_finding_id": 7, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "consistency", + "title": "Archive propagation back into the user repo conflicts with the read-only worktree contract", + "detail": "The design's Persistence / Ownership section says openspec artifacts are propagated back to <userRepo>/openspec/changes/<CHANGE_ID>/ on archive, and task 8.2 plans worktree reads only 'until archive propagation completes'. That conflicts with the main-session-worktree requirement that all phase commands, including /specflow.archive, operate inside state.worktree_path and do not modify the user repo working tree outside .specflow/worktrees/. Remove the propagation assumption from the design/tasks or explicitly redefine archive semantics in the spec before implementation.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "medium", + "category": "risk", + "title": "The plan reopens worktree recovery policy that the spec already fixes", + "detail": "Design Open Questions and tasks 1.1-1.3 leave 'safety-only git worktree prune' and related worktree policies to later confirmation. The accepted contract is supposed to be single-path and fail-fast: conflicting or stale worktree state is surfaced for manual resolution, not auto-pruned. Leaving this as a pre-implementation clarification makes downstream bundles ambiguous and risks shipping behavior that silently mutates git worktree state. Encode the chosen no-auto-recovery policy directly in the design/tasks instead of treating it as unresolved.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F03", + "severity": "medium", + "category": "completeness", + "title": "Legacy-guard tasks omit the synthetic-run exemption", + "detail": "The workflow-run-state spec explicitly says the worktree_path == repo_path legacy rejection does not apply to synthetic runs. Design decision D9 and tasks 7.1-7.4 describe the resume guard without making run_kind != \"synthetic\" part of the predicate, so an implementation could incorrectly reject synthetic runs. Make the exemption explicit in the design contract and add a synthetic-run coverage case.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R2-F04", + "severity": "high", + "category": "completeness", + "file": "DESIGN CONTENT / TASKS CONTENT", + "title": "Subagent worktree rebinding omits the per-bundle base-commit contract", + "detail": "The modified apply-worktree-integration spec requires each subagent worktree to capture and persist the main-session HEAD at the moment that worktree is created, and it explicitly forbids keeping a single shared apply-run base snapshot. The current design only rebinds the source and target paths, and tasks 2.1-2.4 only cover path layout plus patch landing location. They never say how the bundle-specific base SHA is recorded or add coverage for the \"later worktrees inherit earlier integrations\" scenario, so an implementation could retain the old shared-base behavior and still satisfy the task list. Add an explicit design contract and implementation/test tasks for recording the creation-time base per bundle and diffing from that recorded base.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F05", + "severity": "medium", + "category": "consistency", + "file": "DESIGN CONTENT / TASKS CONTENT", + "title": "WorkspaceContext metadata capture is underspecified for divergent repo and worktree paths", + "detail": "workflow-run-state now requires run start to obtain repo_path, worktree_path, base_commit, and base_branch from WorkspaceContext, with repo_path staying at the user repo root while worktree_path points at .specflow/worktrees/<CHANGE_ID>/main/. The design instead says the interface is \"untouched\", adds base accessors only on the local-fs concrete implementation, and proposes constructing a WorkspaceContext \"rooted at the main-session worktree\" without specifying which accessors continue to resolve against the user repo. Tasks 1.1-1.2 likewise ask for override plumbing but never pin this split. That leaves room for an implementation that persists repo_path == worktree_path or cannot satisfy the required baseCommit()/baseBranch() calls at run start. Define the exact WorkspaceContext contract for repo-root versus worktree-root lookups and add coverage that persisted run.json keeps distinct repo_path and worktree_path values.", + "origin_round": 2, + "latest_round": 2, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F06", + "severity": "medium", + "category": "consistency", + "file": "DESIGN CONTENT / TASKS CONTENT", + "title": "Deferred cleanup after archive is not made retryable by the persistence plan", + "detail": "D8 and task 5.4 say a terminal command can set cleanup_pending = true and then be re-invoked later to retry cleanup, but Persistence / Ownership still gives run.json a lifetime of \"start -> archive\" and the design never states what archive/reject must preserve when cleanup is deferred. That is especially problematic for /specflow.archive: if the first archive invocation succeeds functionally but cannot clean the retained worktrees, the current plan does not say how subsequent reads will still observe cleanup_pending or what state the retry uses. Clarify that deferred terminal cleanup keeps the run-state and any lookup inputs needed for retry until cleanup completes, and add an implementation task for that retention behavior.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F07", + "severity": "medium", + "category": "completeness", + "file": "DESIGN CONTENT / TASKS CONTENT", + "title": "Subagent worktree finalization is not explicitly retargeted to the new change-scoped paths", + "detail": "The modified `apply-worktree-integration` spec changes not only subagent worktree creation, but also bundle-finalization behavior: `done` must remove `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`, `subagent_failed` and `integration_rejected` must retain that path, and this capability must not delete the shared `.specflow/worktrees/<CHANGE_ID>/` parent while the main-session worktree still exists. The current design/tasks only retarget creation, per-bundle base capture, and patch import. They never explicitly retarget the success-removal/failure-retention logic or add coverage for parent preservation, so an implementation could keep old cleanup assumptions and still satisfy the listed tasks. Add an explicit contract/task for bundle-finalization cleanup at the new paths and tests for remove-on-done, retain-on-failure, and shared-parent preservation.", + "origin_round": 3, + "latest_round": 3, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 3, + "open": 3, + "new": 3, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 1, + "medium": 2 + }, + "gate_id": "review_decision-worktree-1-design_review-1" + }, + { + "round": 2, + "total": 6, + "open": 3, + "new": 3, + "resolved": 3, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 1 + }, + "gate_id": "review_decision-worktree-1-design_review-2" + }, + { + "round": 3, + "total": 7, + "open": 2, + "new": 1, + "resolved": 5, + "overridden": 0, + "by_severity": { + "medium": 2 + }, + "gate_id": "review_decision-worktree-1-design_review-3" + } + ] +} diff --git a/openspec/changes/archive/2026-04-25-worktree/review-ledger-design.json.bak b/openspec/changes/archive/2026-04-25-worktree/review-ledger-design.json.bak new file mode 100644 index 0000000..5da138d --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/review-ledger-design.json.bak @@ -0,0 +1,148 @@ +{ + "feature_id": "worktree", + "phase": "design", + "current_round": 3, + "status": "in_progress", + "max_finding_id": 7, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "consistency", + "title": "Archive propagation back into the user repo conflicts with the read-only worktree contract", + "detail": "The design's Persistence / Ownership section says openspec artifacts are propagated back to <userRepo>/openspec/changes/<CHANGE_ID>/ on archive, and task 8.2 plans worktree reads only 'until archive propagation completes'. That conflicts with the main-session-worktree requirement that all phase commands, including /specflow.archive, operate inside state.worktree_path and do not modify the user repo working tree outside .specflow/worktrees/. Remove the propagation assumption from the design/tasks or explicitly redefine archive semantics in the spec before implementation.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "medium", + "category": "risk", + "title": "The plan reopens worktree recovery policy that the spec already fixes", + "detail": "Design Open Questions and tasks 1.1-1.3 leave 'safety-only git worktree prune' and related worktree policies to later confirmation. The accepted contract is supposed to be single-path and fail-fast: conflicting or stale worktree state is surfaced for manual resolution, not auto-pruned. Leaving this as a pre-implementation clarification makes downstream bundles ambiguous and risks shipping behavior that silently mutates git worktree state. Encode the chosen no-auto-recovery policy directly in the design/tasks instead of treating it as unresolved.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F03", + "severity": "medium", + "category": "completeness", + "title": "Legacy-guard tasks omit the synthetic-run exemption", + "detail": "The workflow-run-state spec explicitly says the worktree_path == repo_path legacy rejection does not apply to synthetic runs. Design decision D9 and tasks 7.1-7.4 describe the resume guard without making run_kind != \"synthetic\" part of the predicate, so an implementation could incorrectly reject synthetic runs. Make the exemption explicit in the design contract and add a synthetic-run coverage case.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R2-F04", + "severity": "high", + "category": "completeness", + "file": "DESIGN CONTENT / TASKS CONTENT", + "title": "Subagent worktree rebinding omits the per-bundle base-commit contract", + "detail": "The modified apply-worktree-integration spec requires each subagent worktree to capture and persist the main-session HEAD at the moment that worktree is created, and it explicitly forbids keeping a single shared apply-run base snapshot. The current design only rebinds the source and target paths, and tasks 2.1-2.4 only cover path layout plus patch landing location. They never say how the bundle-specific base SHA is recorded or add coverage for the \"later worktrees inherit earlier integrations\" scenario, so an implementation could retain the old shared-base behavior and still satisfy the task list. Add an explicit design contract and implementation/test tasks for recording the creation-time base per bundle and diffing from that recorded base.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F05", + "severity": "medium", + "category": "consistency", + "file": "DESIGN CONTENT / TASKS CONTENT", + "title": "WorkspaceContext metadata capture is underspecified for divergent repo and worktree paths", + "detail": "workflow-run-state now requires run start to obtain repo_path, worktree_path, base_commit, and base_branch from WorkspaceContext, with repo_path staying at the user repo root while worktree_path points at .specflow/worktrees/<CHANGE_ID>/main/. The design instead says the interface is \"untouched\", adds base accessors only on the local-fs concrete implementation, and proposes constructing a WorkspaceContext \"rooted at the main-session worktree\" without specifying which accessors continue to resolve against the user repo. Tasks 1.1-1.2 likewise ask for override plumbing but never pin this split. That leaves room for an implementation that persists repo_path == worktree_path or cannot satisfy the required baseCommit()/baseBranch() calls at run start. Define the exact WorkspaceContext contract for repo-root versus worktree-root lookups and add coverage that persisted run.json keeps distinct repo_path and worktree_path values.", + "origin_round": 2, + "latest_round": 2, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F06", + "severity": "medium", + "category": "consistency", + "file": "DESIGN CONTENT / TASKS CONTENT", + "title": "Deferred cleanup after archive is not made retryable by the persistence plan", + "detail": "D8 and task 5.4 say a terminal command can set cleanup_pending = true and then be re-invoked later to retry cleanup, but Persistence / Ownership still gives run.json a lifetime of \"start -> archive\" and the design never states what archive/reject must preserve when cleanup is deferred. That is especially problematic for /specflow.archive: if the first archive invocation succeeds functionally but cannot clean the retained worktrees, the current plan does not say how subsequent reads will still observe cleanup_pending or what state the retry uses. Clarify that deferred terminal cleanup keeps the run-state and any lookup inputs needed for retry until cleanup completes, and add an implementation task for that retention behavior.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F07", + "severity": "medium", + "category": "completeness", + "file": "DESIGN CONTENT / TASKS CONTENT", + "title": "Subagent worktree finalization is not explicitly retargeted to the new change-scoped paths", + "detail": "The modified `apply-worktree-integration` spec changes not only subagent worktree creation, but also bundle-finalization behavior: `done` must remove `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`, `subagent_failed` and `integration_rejected` must retain that path, and this capability must not delete the shared `.specflow/worktrees/<CHANGE_ID>/` parent while the main-session worktree still exists. The current design/tasks only retarget creation, per-bundle base capture, and patch import. They never explicitly retarget the success-removal/failure-retention logic or add coverage for parent preservation, so an implementation could keep old cleanup assumptions and still satisfy the listed tasks. Add an explicit contract/task for bundle-finalization cleanup at the new paths and tests for remove-on-done, retain-on-failure, and shared-parent preservation.", + "origin_round": 3, + "latest_round": 3, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 3, + "open": 3, + "new": 3, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 1, + "medium": 2 + }, + "gate_id": "review_decision-worktree-1-design_review-1" + }, + { + "round": 2, + "total": 6, + "open": 3, + "new": 3, + "resolved": 3, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 1 + }, + "gate_id": "review_decision-worktree-1-design_review-2" + }, + { + "round": 3, + "total": 7, + "open": 2, + "new": 1, + "resolved": 5, + "overridden": 0, + "by_severity": { + "medium": 2 + } + } + ] +} diff --git a/openspec/changes/archive/2026-04-25-worktree/review-ledger.json b/openspec/changes/archive/2026-04-25-worktree/review-ledger.json new file mode 100644 index 0000000..8a697f9 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/review-ledger.json @@ -0,0 +1,195 @@ +{ + "feature_id": "worktree", + "phase": "impl", + "current_round": 3, + "status": "has_open_high", + "max_finding_id": 10, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "correctness", + "file": "assets/commands/specflow.approve.md.tmpl", + "title": "PR base resolution does not follow the recorded base commit", + "detail": "The new approve flow never uses `base_commit`, and it falls back to the repository default branch whenever `base_branch` has no upstream. The spec requires the PR base to be derived from the branch that originally contained `base_commit`, with fallback to the branch the user was on at prepare-change time. As written, a change started from an untracked feature branch will target the wrong base branch. Update the approve logic to resolve from `base_commit` first instead of defaulting to the repo default branch.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "high", + "category": "completeness", + "file": "src/lib/run-store-ops.ts", + "title": "Deferred cleanup behavior is not implemented", + "detail": "The spec makes cleanup gating mandatory for `/specflow.approve`, `/specflow.archive`, and `/specflow.reject`: remove `.specflow/worktrees/<CHANGE_ID>/` only after a fully successful terminal operation and only when every worktree is clean; otherwise leave the run terminal with `cleanup_pending=true` and surface the reason. This diff only adds the `cleanup_pending` field and defaults it to `false` on read. There are no matching terminal-phase changes to set the flag, inspect worktree cleanliness, or remove the worktree subtree.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F03", + "severity": "high", + "category": "completeness", + "file": "src/lib/apply-worktree/worktree.ts", + "title": "Subagent worktree rebinding is still optional and therefore not actually enforced", + "detail": "The spec requires subagent worktrees to move under `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/` and to use the main-session worktree as their integration target. This file only adds optional `changeId` and `mainWorkspacePath` fields with fallbacks back to `repoRoot` and the old `<RUN_ID>/<BUNDLE_ID>` layout. Because the diff does not thread those new fields through the callers, existing subagent flows will continue to base/apply against the user repo root. Make the new path and main-workspace source mandatory and wire them from run-state through the bundle execution path.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R1-F04", + "severity": "high", + "category": "completeness", + "file": "src/bin/specflow-watch.ts", + "title": "Legacy mode is still preserved despite the spec forbidding dual-path behavior", + "detail": "The proposal explicitly says there is no legacy mode after this change, but this diff still keeps repo-root fallbacks alive. `specflow-watch` branches on `worktree_path == repo_path` and treats that as a supported legacy path, and `readRunState` silently backfills missing fields instead of rejecting old run-state. That leaves the old repo-root execution path in place rather than forcing legacy runs to drain before merge. Outside the one-time prepare-change guard, legacy run-state should fail fast instead of being normalized and executed.", + "origin_round": 1, + "latest_round": 1, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R1-F05", + "severity": "medium", + "category": "testing", + "file": "src/tests/prepare-change-raw-input.test.ts", + "title": "The new worktree conflict and terminal-flow behaviors are not covered by tests", + "detail": "The added tests only cover happy-path proposal seeding in the new main-session worktree. The spec requires non-trivial behavior that is currently untested here: reuse of an existing conventional worktree, branch/worktree conflict failures, occupied-path failures, legacy-run rejection, PR-base selection from the recorded base metadata, and cleanup deferral when terminal cleanup cannot run. Add targeted tests for those branches before landing this change.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F06", + "severity": "high", + "category": "completeness", + "file": "src/bin/specflow-review-apply.ts", + "title": "Downstream phase CLIs still read and write change artifacts from the caller's git root", + "detail": "The core feature only moves `prepare-change`, `start`, and `watch` into worktree-aware mode. Commands that run the rest of the flow (`specflow-review-apply`, `specflow-review-design`, `specflow-challenge-proposal`, `specflow-generate-task-graph`) still build their change stores from `projectRoot()`/`ensureGitRepo()` and never call the new run-state worktree resolver. The matching templates still assume repo-root paths (for example `specflow.design` says to use the git repo root for all relative paths). Running those commands from the normal repo root will keep mutating `openspec/changes/<CHANGE_ID>` in the user tree instead of the main-session worktree. These phase entrypoints need to resolve `RUN_ID -> worktree_path` and operate there by default.", + "origin_round": 2, + "latest_round": 2, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F07", + "severity": "medium", + "category": "correctness", + "file": "assets/commands/specflow.approve.md.tmpl", + "title": "Strategy 1 can still choose the wrong PR base when multiple remote branches contain the base commit", + "detail": "The prose says Strategy 1 should use `base_commit` only when exactly one remote-tracking branch contains it, but the shell snippet just strips `origin/` and takes `head -1`. When the commit is reachable from more than one remote branch, the command will silently pick an arbitrary base branch and create the PR against the wrong target. Count the matching branches first and only assign `PR_BASE` when there is exactly one match; otherwise continue to the later fallback strategies.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F08", + "severity": "high", + "category": "completeness", + "file": "src/bin/specflow-advance-bundle.ts", + "title": "Remaining phase helper CLIs still hardcode the user repo root instead of resolving the main-session worktree", + "detail": "This patch fixed the review/challenge/task-graph CLIs, but several helpers that are still part of the normal flow remain repo-root based. `specflow-advance-bundle` builds its change store from `ensureGitRepo()`, `specflow-spec-verify` calls `verifyChange(repoRoot, changeId)` after `git rev-parse --show-toplevel`, and `specflow-design-artifacts` runs OpenSpec from `process.cwd()`. In worktree mode, the live `openspec/changes/<CHANGE_ID>` tree lives under `run.worktree_path`, so these commands will either mutate stale repo-root artifacts or report files missing when invoked from the user repo. They need the same `RUN_ID -> worktree_path` resolution pattern now used by the fixed review CLIs.", + "origin_round": 3, + "latest_round": 3, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R3-F09", + "severity": "high", + "category": "correctness", + "file": "assets/commands/specflow.apply.md.tmpl", + "title": "Slash-command templates still instruct repo-root execution and the old subagent worktree layout", + "detail": "The generated command bodies still describe behavior that is no longer valid in worktree mode. `specflow.design`, `specflow.review_design`, `specflow.review_apply`, and the fix/apply flows still tell the operator to derive `CHANGE_ID` from the current branch and to use repo-root-relative `openspec/changes/<CHANGE_ID>/...` paths, and `specflow.apply` still documents subagent worktrees at `.specflow/worktrees/<RUN_ID>/<BUNDLE_ID>/` instead of `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`. Because the user repo branch now stays on the original base branch and change artifacts live under the main-session worktree, following these templates will drive the workflow into the wrong tree even where the underlying CLI was fixed. Update the templates to resolve `RUN_ID` and `WORKTREE_PATH` from run-state and use the new per-change path convention everywhere.", + "origin_round": 3, + "latest_round": 3, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R3-F10", + "severity": "medium", + "category": "correctness", + "file": "assets/commands/specflow.approve.md.tmpl", + "title": "The default-branch fallback passes an invalid repository selector to `gh`", + "detail": "Strategy 4 now runs `gh -R \"$(git -C \"$WORKTREE_PATH\" remote get-url origin)\" repo view --json defaultBranchRef ...`. Local `gh` help documents repository selectors in `[HOST/]OWNER/REPO` format, not raw remote URLs like `https://github.com/owner/repo.git` or `git@github.com:owner/repo.git`. When Strategy 4 is needed, this fallback can fail instead of resolving the default branch. Run `gh repo view` from inside `$WORKTREE_PATH`, or normalize the remote URL to `OWNER/REPO` before passing `-R`.", + "origin_round": 3, + "latest_round": 3, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 5, + "open": 5, + "new": 5, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + }, + "gate_id": "review_decision-worktree-1-apply_review-1" + }, + { + "round": 2, + "total": 7, + "open": 5, + "new": 2, + "resolved": 2, + "overridden": 0, + "by_severity": { + "high": 3, + "medium": 2 + }, + "gate_id": "review_decision-worktree-1-apply_review-2" + }, + { + "round": 3, + "total": 10, + "open": 5, + "new": 3, + "resolved": 5, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + }, + "gate_id": "review_decision-worktree-1-apply_review-3" + } + ] +} diff --git a/openspec/changes/archive/2026-04-25-worktree/review-ledger.json.bak b/openspec/changes/archive/2026-04-25-worktree/review-ledger.json.bak new file mode 100644 index 0000000..4623925 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/review-ledger.json.bak @@ -0,0 +1,194 @@ +{ + "feature_id": "worktree", + "phase": "impl", + "current_round": 3, + "status": "has_open_high", + "max_finding_id": 10, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "correctness", + "file": "assets/commands/specflow.approve.md.tmpl", + "title": "PR base resolution does not follow the recorded base commit", + "detail": "The new approve flow never uses `base_commit`, and it falls back to the repository default branch whenever `base_branch` has no upstream. The spec requires the PR base to be derived from the branch that originally contained `base_commit`, with fallback to the branch the user was on at prepare-change time. As written, a change started from an untracked feature branch will target the wrong base branch. Update the approve logic to resolve from `base_commit` first instead of defaulting to the repo default branch.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "high", + "category": "completeness", + "file": "src/lib/run-store-ops.ts", + "title": "Deferred cleanup behavior is not implemented", + "detail": "The spec makes cleanup gating mandatory for `/specflow.approve`, `/specflow.archive`, and `/specflow.reject`: remove `.specflow/worktrees/<CHANGE_ID>/` only after a fully successful terminal operation and only when every worktree is clean; otherwise leave the run terminal with `cleanup_pending=true` and surface the reason. This diff only adds the `cleanup_pending` field and defaults it to `false` on read. There are no matching terminal-phase changes to set the flag, inspect worktree cleanliness, or remove the worktree subtree.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F03", + "severity": "high", + "category": "completeness", + "file": "src/lib/apply-worktree/worktree.ts", + "title": "Subagent worktree rebinding is still optional and therefore not actually enforced", + "detail": "The spec requires subagent worktrees to move under `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/` and to use the main-session worktree as their integration target. This file only adds optional `changeId` and `mainWorkspacePath` fields with fallbacks back to `repoRoot` and the old `<RUN_ID>/<BUNDLE_ID>` layout. Because the diff does not thread those new fields through the callers, existing subagent flows will continue to base/apply against the user repo root. Make the new path and main-workspace source mandatory and wire them from run-state through the bundle execution path.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R1-F04", + "severity": "high", + "category": "completeness", + "file": "src/bin/specflow-watch.ts", + "title": "Legacy mode is still preserved despite the spec forbidding dual-path behavior", + "detail": "The proposal explicitly says there is no legacy mode after this change, but this diff still keeps repo-root fallbacks alive. `specflow-watch` branches on `worktree_path == repo_path` and treats that as a supported legacy path, and `readRunState` silently backfills missing fields instead of rejecting old run-state. That leaves the old repo-root execution path in place rather than forcing legacy runs to drain before merge. Outside the one-time prepare-change guard, legacy run-state should fail fast instead of being normalized and executed.", + "origin_round": 1, + "latest_round": 1, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R1-F05", + "severity": "medium", + "category": "testing", + "file": "src/tests/prepare-change-raw-input.test.ts", + "title": "The new worktree conflict and terminal-flow behaviors are not covered by tests", + "detail": "The added tests only cover happy-path proposal seeding in the new main-session worktree. The spec requires non-trivial behavior that is currently untested here: reuse of an existing conventional worktree, branch/worktree conflict failures, occupied-path failures, legacy-run rejection, PR-base selection from the recorded base metadata, and cleanup deferral when terminal cleanup cannot run. Add targeted tests for those branches before landing this change.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F06", + "severity": "high", + "category": "completeness", + "file": "src/bin/specflow-review-apply.ts", + "title": "Downstream phase CLIs still read and write change artifacts from the caller's git root", + "detail": "The core feature only moves `prepare-change`, `start`, and `watch` into worktree-aware mode. Commands that run the rest of the flow (`specflow-review-apply`, `specflow-review-design`, `specflow-challenge-proposal`, `specflow-generate-task-graph`) still build their change stores from `projectRoot()`/`ensureGitRepo()` and never call the new run-state worktree resolver. The matching templates still assume repo-root paths (for example `specflow.design` says to use the git repo root for all relative paths). Running those commands from the normal repo root will keep mutating `openspec/changes/<CHANGE_ID>` in the user tree instead of the main-session worktree. These phase entrypoints need to resolve `RUN_ID -> worktree_path` and operate there by default.", + "origin_round": 2, + "latest_round": 2, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F07", + "severity": "medium", + "category": "correctness", + "file": "assets/commands/specflow.approve.md.tmpl", + "title": "Strategy 1 can still choose the wrong PR base when multiple remote branches contain the base commit", + "detail": "The prose says Strategy 1 should use `base_commit` only when exactly one remote-tracking branch contains it, but the shell snippet just strips `origin/` and takes `head -1`. When the commit is reachable from more than one remote branch, the command will silently pick an arbitrary base branch and create the PR against the wrong target. Count the matching branches first and only assign `PR_BASE` when there is exactly one match; otherwise continue to the later fallback strategies.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F08", + "severity": "high", + "category": "completeness", + "file": "src/bin/specflow-advance-bundle.ts", + "title": "Remaining phase helper CLIs still hardcode the user repo root instead of resolving the main-session worktree", + "detail": "This patch fixed the review/challenge/task-graph CLIs, but several helpers that are still part of the normal flow remain repo-root based. `specflow-advance-bundle` builds its change store from `ensureGitRepo()`, `specflow-spec-verify` calls `verifyChange(repoRoot, changeId)` after `git rev-parse --show-toplevel`, and `specflow-design-artifacts` runs OpenSpec from `process.cwd()`. In worktree mode, the live `openspec/changes/<CHANGE_ID>` tree lives under `run.worktree_path`, so these commands will either mutate stale repo-root artifacts or report files missing when invoked from the user repo. They need the same `RUN_ID -> worktree_path` resolution pattern now used by the fixed review CLIs.", + "origin_round": 3, + "latest_round": 3, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R3-F09", + "severity": "high", + "category": "correctness", + "file": "assets/commands/specflow.apply.md.tmpl", + "title": "Slash-command templates still instruct repo-root execution and the old subagent worktree layout", + "detail": "The generated command bodies still describe behavior that is no longer valid in worktree mode. `specflow.design`, `specflow.review_design`, `specflow.review_apply`, and the fix/apply flows still tell the operator to derive `CHANGE_ID` from the current branch and to use repo-root-relative `openspec/changes/<CHANGE_ID>/...` paths, and `specflow.apply` still documents subagent worktrees at `.specflow/worktrees/<RUN_ID>/<BUNDLE_ID>/` instead of `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`. Because the user repo branch now stays on the original base branch and change artifacts live under the main-session worktree, following these templates will drive the workflow into the wrong tree even where the underlying CLI was fixed. Update the templates to resolve `RUN_ID` and `WORKTREE_PATH` from run-state and use the new per-change path convention everywhere.", + "origin_round": 3, + "latest_round": 3, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R3-F10", + "severity": "medium", + "category": "correctness", + "file": "assets/commands/specflow.approve.md.tmpl", + "title": "The default-branch fallback passes an invalid repository selector to `gh`", + "detail": "Strategy 4 now runs `gh -R \"$(git -C \"$WORKTREE_PATH\" remote get-url origin)\" repo view --json defaultBranchRef ...`. Local `gh` help documents repository selectors in `[HOST/]OWNER/REPO` format, not raw remote URLs like `https://github.com/owner/repo.git` or `git@github.com:owner/repo.git`. When Strategy 4 is needed, this fallback can fail instead of resolving the default branch. Run `gh repo view` from inside `$WORKTREE_PATH`, or normalize the remote URL to `OWNER/REPO` before passing `-R`.", + "origin_round": 3, + "latest_round": 3, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 5, + "open": 5, + "new": 5, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + }, + "gate_id": "review_decision-worktree-1-apply_review-1" + }, + { + "round": 2, + "total": 7, + "open": 5, + "new": 2, + "resolved": 2, + "overridden": 0, + "by_severity": { + "high": 3, + "medium": 2 + }, + "gate_id": "review_decision-worktree-1-apply_review-2" + }, + { + "round": 3, + "total": 10, + "open": 5, + "new": 3, + "resolved": 5, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + } + } + ] +} diff --git a/openspec/changes/archive/2026-04-25-worktree/specs/apply-worktree-integration/spec.md b/openspec/changes/archive/2026-04-25-worktree/specs/apply-worktree-integration/spec.md new file mode 100644 index 0000000..cb93aad --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/specs/apply-worktree-integration/spec.md @@ -0,0 +1,135 @@ +## MODIFIED Requirements + +### Requirement: Worktree is created from main HEAD at creation time + +For every bundle assigned execution mode `subagent-worktree`, the main agent SHALL create an ephemeral git worktree via `git worktree add` using the **main-session worktree** HEAD at the moment of worktree creation as the base. The "main-session worktree" is the dedicated per-change worktree at `.specflow/worktrees/<CHANGE_ID>/main/` defined in the `main-session-worktree` capability; throughout this capability, "main workspace" is re-bound to that path. The main agent SHALL NOT pre-compute a shared base snapshot for the whole apply run, SHALL NOT rebase the worktree onto a different base before dispatch, and SHALL NOT rebase the worktree prior to integration. The user's repository working tree SHALL NOT be used as a base or as an integration target. + +As a consequence, when earlier bundles in the same run have already been integrated into the main-session worktree before a later worktree is created, the later worktree SHALL observe those imports as part of its base. This creates a deterministic, per-worktree base commit that the main agent SHALL record and later use to compute the integration diff. + +#### Scenario: Worktree base equals main-session worktree HEAD at creation time + +- **WHEN** the main agent creates a worktree for bundle `B` at commit `<sha>` +- **THEN** the worktree's base SHALL equal the main-session worktree HEAD at the moment of creation +- **AND** the main agent SHALL record `<sha>` as the integration base for bundle `B` + +#### Scenario: Later worktrees inherit earlier integrations + +- **WHEN** bundle `A` has been integrated into the main-session worktree in this run +- **AND** the main agent then creates a worktree for bundle `B` +- **THEN** bundle `B`'s worktree SHALL include bundle `A`'s imported changes as part of its base +- **AND** no auto-rebase SHALL be performed later to reconcile drift + +#### Scenario: No shared run-wide base snapshot is used + +- **WHEN** an apply run dispatches multiple `subagent-worktree` bundles +- **THEN** each worktree SHALL record its own base commit at creation time +- **AND** worktrees created at different points in the run MAY have different base commits + +#### Scenario: User repo working tree is not the base or target + +- **WHEN** the main agent dispatches a `subagent-worktree` bundle for change `<CHANGE_ID>` +- **THEN** the user's repository working tree SHALL NOT be used as the base commit source +- **AND** the user's repository working tree SHALL NOT receive any patch imports + +### Requirement: Worktree path convention + +Every ephemeral worktree for a `subagent-worktree` bundle SHALL be created at the path `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/` relative to the user's repository root. This path is fixed in Phase 1 and SHALL NOT be configurable. The main agent SHALL ensure the parent directory `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/` exists before invoking `git worktree add`. Subagent worktrees thereby become siblings of the main-session worktree at `.specflow/worktrees/<CHANGE_ID>/main/` under the shared per-change parent `.specflow/worktrees/<CHANGE_ID>/`. + +If the path already exists and the previous worktree cannot be reclaimed (e.g., it is registered in `git worktree list` and removal fails, or it is a non-worktree directory), the main agent SHALL trigger the worktree-unavailable fail-fast behavior defined below. + +#### Scenario: Worktree is created at the conventional path + +- **WHEN** the main agent creates a worktree for bundle `B` in run `R` for change `<CHANGE_ID>` +- **THEN** the worktree SHALL be located at `.specflow/worktrees/<CHANGE_ID>/<R>/<B>/` + +#### Scenario: Existing stale worktree path triggers fail-fast + +- **WHEN** `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/` already exists +- **AND** `git worktree remove` on that path fails +- **THEN** the main agent SHALL trigger worktree-unavailable fail-fast +- **AND** the worktree path SHALL NOT be silently overwritten + +#### Scenario: Subagent worktrees share the per-change parent with the main-session worktree + +- **WHEN** change `<CHANGE_ID>` has both a main-session worktree and one or more subagent worktrees +- **THEN** the main-session worktree SHALL be at `.specflow/worktrees/<CHANGE_ID>/main/` +- **AND** every subagent worktree SHALL be at `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/` + +### Requirement: Patch import via git apply covers all standard change types + +When all integration-validation checks pass, the main agent SHALL import the subagent's changes into the **main-session worktree** via `git -C <worktree> diff --binary <base-sha>..HEAD | git -C <main-session-worktree> apply --binary` where `<main-session-worktree>` is `.specflow/worktrees/<CHANGE_ID>/main/`. The patch-import mechanism SHALL support the full set of change types that `git diff --binary` and `git apply --binary` themselves cover: + +- file creation +- file deletion +- file modification (text content) +- file mode change +- file rename +- binary file content change + +The main agent SHALL NOT use `--3way` fallback in Phase 1. If `git apply --binary` exits non-zero against the main-session worktree, the main agent SHALL reject integration. The user's repository working tree SHALL NOT receive the patch. + +#### Scenario: Text modification applies cleanly + +- **WHEN** integration validation passes and the patch modifies a text file +- **THEN** `git apply --binary` SHALL be invoked with `cwd = .specflow/worktrees/<CHANGE_ID>/main/` +- **AND** the bundle SHALL progress toward `done` on successful apply + +#### Scenario: Binary change is included in the patch + +- **WHEN** the worktree diff includes a binary file change +- **THEN** the diff SHALL be extracted with `git diff --binary` +- **AND** `git apply --binary` SHALL be invoked against the main-session worktree + +#### Scenario: Patch-apply failure rejects integration + +- **WHEN** `git apply --binary` exits non-zero against the main-session worktree +- **THEN** the main agent SHALL reject integration +- **AND** the bundle status SHALL become `integration_rejected` +- **AND** no `--3way` retry SHALL be attempted + +#### Scenario: User repo working tree is never patched + +- **WHEN** integration succeeds for bundle `B` +- **THEN** the patch SHALL be applied only to the main-session worktree +- **AND** the user repo working tree SHALL remain unchanged by the integration + +### Requirement: Worktree retention policy + +The main agent SHALL clean up worktrees based on the bundle's final status in the current apply invocation: + +- On `done`: the worktree SHALL be removed immediately via `git worktree remove <path>`. If `git worktree remove` fails (e.g., due to uncommitted subagent changes that should already have been imported), the main agent SHALL surface the error but SHALL NOT revert the bundle's `done` status. +- On `subagent_failed`: the worktree SHALL be retained at its path at `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`. +- On `integration_rejected`: the worktree SHALL be retained at its path. + +Retention behavior in Phase 1 is fixed and SHALL NOT be configurable. `/specflow.fix_apply` and manual inspection SHALL use the retained worktree path to diagnose failures. + +The per-change parent `.specflow/worktrees/<CHANGE_ID>/` SHALL NOT be deleted by this capability while any subagent worktree (or the main-session worktree) under it remains. Removal of the parent is governed by the `main-session-worktree` capability's terminal-state cleanup. + +#### Scenario: Successful bundle removes its worktree + +- **WHEN** bundle `B` reaches `done` +- **THEN** `git worktree remove .specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<B>/` SHALL be invoked +- **AND** the worktree SHALL no longer appear in `git worktree list` + +#### Scenario: Failed subagent retains worktree + +- **WHEN** bundle `B` reaches `subagent_failed` +- **THEN** the worktree at `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<B>/` SHALL remain +- **AND** the worktree SHALL still appear in `git worktree list` + +#### Scenario: Integration rejection retains worktree + +- **WHEN** bundle `B` reaches `integration_rejected` +- **THEN** the worktree at `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<B>/` SHALL remain + +#### Scenario: Retention policy is not configurable in Phase 1 + +- **WHEN** an operator attempts to override retention via config in Phase 1 +- **THEN** the retention behavior SHALL be the fixed rule above +- **AND** no config key SHALL alter cleanup on success or retention on failure + +#### Scenario: Per-change parent is not deleted by this capability + +- **WHEN** the last `done` subagent worktree is removed +- **AND** the main-session worktree at `.specflow/worktrees/<CHANGE_ID>/main/` still exists +- **THEN** `.specflow/worktrees/<CHANGE_ID>/` SHALL remain on disk diff --git a/openspec/changes/archive/2026-04-25-worktree/specs/bundle-subagent-execution/spec.md b/openspec/changes/archive/2026-04-25-worktree/specs/bundle-subagent-execution/spec.md new file mode 100644 index 0000000..956bbae --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/specs/bundle-subagent-execution/spec.md @@ -0,0 +1,83 @@ +## MODIFIED Requirements + +### Requirement: Bundle execution mode is derived from subagent-eligibility + +The dispatcher SHALL assign each bundle exactly one of two execution modes when `/specflow.apply` begins a window: + +- `inline-main`: the bundle is implemented directly by the main agent in the **main-session worktree** at `.specflow/worktrees/<CHANGE_ID>/main/` (formerly referred to as "the primary workspace"). The user's repository working tree SHALL NOT be used as the primary workspace. +- `subagent-worktree`: the bundle is dispatched to a subagent running inside a dedicated ephemeral git worktree at `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`, as defined by the `apply-worktree-integration` capability. + +The mode assignment rule SHALL be: + +- A bundle classified as **subagent-eligible** (per the existing "Bundle subagent-eligibility is derived from size_score" requirement) SHALL be assigned `subagent-worktree`. +- A bundle classified as **inline-only** SHALL be assigned `inline-main`. + +No third execution mode SHALL be introduced in this phase. In particular, a dispatched subagent without an isolated worktree (historically `subagent-shared`) SHALL NOT be a supported mode. + +Dispatch signals SHALL remain limited to the existing eligibility rule (`apply.subagent_dispatch.enabled = true` and `size_score > threshold`). No additional signals such as side-effect risk, lockfile/codegen touches, or changed-path count SHALL influence mode assignment in this phase. + +#### Scenario: Eligible bundle routes to subagent-worktree + +- **WHEN** dispatch is enabled and a bundle has `size_score = 8` and `threshold = 5` +- **THEN** the bundle SHALL be assigned execution mode `subagent-worktree` + +#### Scenario: Ineligible bundle routes to inline-main + +- **WHEN** a bundle is classified as inline-only (for any reason: dispatch disabled, `size_score <= threshold`, missing `size_score`, or task-graph.json absent) +- **THEN** the bundle SHALL be assigned execution mode `inline-main` + +#### Scenario: Inline-main executes inside the main-session worktree + +- **WHEN** a bundle is assigned execution mode `inline-main` +- **THEN** the main agent SHALL perform that bundle's edits inside `.specflow/worktrees/<CHANGE_ID>/main/` +- **AND** SHALL NOT modify the user's repository working tree + +#### Scenario: Subagent-shared is not a supported mode + +- **WHEN** the dispatcher assigns execution mode +- **THEN** the set of possible modes SHALL be exactly `{"inline-main", "subagent-worktree"}` +- **AND** a dispatched subagent executing without a dedicated worktree SHALL NOT occur + +#### Scenario: No extra dispatch signals influence mode + +- **WHEN** the dispatcher assigns execution mode for a bundle +- **THEN** the decision SHALL depend only on the existing subagent-eligibility rule +- **AND** signals such as side-effect risk, lockfile touches, or changed-path count SHALL NOT be consulted in this phase + +### Requirement: Bundle `done` requires main-agent integration success for subagent-worktree mode + +For every bundle assigned execution mode `subagent-worktree`, the main agent SHALL NOT invoke `specflow-advance-bundle <CHANGE_ID> <BUNDLE_ID> done` on a subagent `status: "success"` alone. The main agent SHALL first run the integration authority contract defined in `apply-worktree-integration` (diff inspection, artifact cross-check, protected-path check, empty-diff-on-success check, and patch-apply). + +A `subagent-worktree` bundle SHALL reach `done` if and only if: + +1. The subagent returned `status: "success"`, AND +2. Integration validation passed, AND +3. `git apply --binary` against the **main-session worktree** at `.specflow/worktrees/<CHANGE_ID>/main/` exited zero. + +If any of 1–3 fails, the bundle SHALL transition to one of the new terminal-for-this-invocation statuses defined in `task-planner` (`subagent_failed` for failed 1; `integration_rejected` for failed 2 or 3), per `apply-worktree-integration`. The main agent SHALL NOT silently record `done` on an unverified or unimported subagent success. The user's repository working tree SHALL NOT be the patch-apply target. + +For `inline-main` bundles, this requirement does NOT apply; inline bundles reach `done` under the existing completion rules but execute inside the main-session worktree per the execution-mode requirement above. + +#### Scenario: Subagent success alone does not reach done + +- **WHEN** a `subagent-worktree` bundle's subagent returns `status: "success"` +- **AND** integration validation or patch-apply has not yet been executed +- **THEN** the main agent SHALL NOT invoke `specflow-advance-bundle ... done` + +#### Scenario: Done is reached only after successful integration into the main-session worktree + +- **WHEN** a `subagent-worktree` bundle's subagent returns `status: "success"` +- **AND** integration validation passes and `git apply --binary` against `.specflow/worktrees/<CHANGE_ID>/main/` succeeds +- **THEN** the main agent SHALL invoke `specflow-advance-bundle ... done` + +#### Scenario: User repo is never the integration target + +- **WHEN** integration succeeds for a `subagent-worktree` bundle +- **THEN** the patch SHALL have been applied to the main-session worktree +- **AND** the user's repository working tree SHALL remain untouched by the patch + +#### Scenario: Inline-main completion rules are unchanged + +- **WHEN** a bundle is assigned `inline-main` +- **THEN** this requirement SHALL NOT apply +- **AND** the bundle SHALL reach `done` under the existing inline completion rules diff --git a/openspec/changes/archive/2026-04-25-worktree/specs/main-session-worktree/spec.md b/openspec/changes/archive/2026-04-25-worktree/specs/main-session-worktree/spec.md new file mode 100644 index 0000000..ff543a6 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/specs/main-session-worktree/spec.md @@ -0,0 +1,166 @@ +## ADDED Requirements + +### Requirement: Main-session worktree replaces user-repo branch checkout + +When `specflow-prepare-change` initializes a change for the first time, the main agent SHALL NOT execute `git checkout -b <CHANGE_ID>` (or any other `git checkout`) on the user's working repository. Instead, the main agent SHALL create a dedicated git worktree for the change and conduct all subsequent main-session work inside it. The user's working repository SHALL retain its current branch, current `HEAD`, and any staged, unstaged, or untracked changes exactly as they were before `/specflow` was invoked. + +#### Scenario: User repo state is untouched on prepare-change + +- **WHEN** the user invokes `/specflow` for a new change while their repo is on branch `feature/X` with uncommitted local edits +- **THEN** the main agent SHALL NOT switch the user's repo to any other branch +- **AND** the user's staged, unstaged, and untracked changes SHALL remain in the user repo unchanged +- **AND** `git -C <user-repo> branch --show-current` SHALL still report `feature/X` after `prepare-change` returns + +#### Scenario: No git checkout is run on the user repo + +- **WHEN** `specflow-prepare-change` initializes a new change `<CHANGE_ID>` +- **THEN** no `git checkout`, `git switch`, or branch-creation command SHALL be executed in the user repo working tree + +### Requirement: Main-session worktree path convention + +The main-session worktree for change `<CHANGE_ID>` SHALL be created at `.specflow/worktrees/<CHANGE_ID>/main/` relative to the user's repository root. The path SHALL NOT be configurable in this phase. Subagent worktrees for the same change SHALL live as siblings under `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`, so the parent directory `.specflow/worktrees/<CHANGE_ID>/` is the single root for all worktrees belonging to that change. + +#### Scenario: Main-session worktree is created at the conventional path + +- **WHEN** the main agent creates a main-session worktree for change `<CHANGE_ID>` +- **THEN** the worktree SHALL be located at `.specflow/worktrees/<CHANGE_ID>/main/` + +#### Scenario: All change-scoped worktrees share a parent + +- **WHEN** change `<CHANGE_ID>` has both a main-session worktree and one or more subagent worktrees +- **THEN** every such worktree SHALL be a direct or nested child of `.specflow/worktrees/<CHANGE_ID>/` + +### Requirement: Main-session worktree base commit and branch + +The main-session worktree SHALL be created from the user repository's current `HEAD` at the moment `prepare-change` first runs for the change. The branch checked out inside the worktree SHALL be named exactly `<CHANGE_ID>` (preserving `change_name == branch_name`). The branch SHALL be created via `git worktree add -b <CHANGE_ID> <path> HEAD` so the new branch exists only inside the worktree. + +The main agent SHALL persist the resolved base commit SHA in run-state as `base_commit` together with the branch name the user was on at creation time (`base_branch`, used later for PR base resolution). + +#### Scenario: base_commit equals user-repo HEAD at creation + +- **WHEN** the user repo HEAD is `<sha>` at `prepare-change` time +- **THEN** the main-session worktree's base commit SHALL equal `<sha>` +- **AND** run-state SHALL persist `base_commit = <sha>` + +#### Scenario: Worktree branch is named after the change + +- **WHEN** the main agent creates the main-session worktree for `<CHANGE_ID>` +- **THEN** the branch checked out inside that worktree SHALL be `<CHANGE_ID>` +- **AND** the user-repo working tree SHALL NOT have `<CHANGE_ID>` checked out + +#### Scenario: base_branch is recorded for PR base resolution + +- **WHEN** the user is on branch `<USER_BRANCH>` at `prepare-change` time +- **THEN** run-state SHALL persist `base_branch = <USER_BRANCH>` + +### Requirement: Main-session worktree reuse policy + +If `.specflow/worktrees/<CHANGE_ID>/main/` already exists AND `git worktree list` shows it registered as the worktree for branch `<CHANGE_ID>`, the main agent SHALL reuse the existing worktree and branch as-is. The main agent SHALL NOT recreate the worktree, SHALL NOT delete its contents, and SHALL NOT touch any uncommitted state inside it. + +#### Scenario: Existing main-session worktree is reused + +- **WHEN** `prepare-change` runs and the main-session worktree already exists for `<CHANGE_ID>` +- **AND** the worktree is registered in `git worktree list` with branch `<CHANGE_ID>` +- **THEN** the main agent SHALL reuse the existing worktree +- **AND** uncommitted state inside the worktree SHALL be preserved + +### Requirement: Conflict fail-fast on existing branch or stale worktree + +If a local branch named `<CHANGE_ID>` already exists but is NOT tied to `.specflow/worktrees/<CHANGE_ID>/main/`, OR if any registered worktree is bound to branch `<CHANGE_ID>` at any path other than the conventional one, OR if `.specflow/worktrees/<CHANGE_ID>/main/` exists as a non-worktree directory, the main agent SHALL fail-fast `prepare-change`. The error message SHALL name the offending branch / path / worktree and instruct the user to manually resolve by renaming the branch, pruning the stale worktree, or selecting a different `change_id`. + +The main agent SHALL NOT silently delete, prune, or overwrite the conflicting state. + +#### Scenario: Pre-existing branch with no matching worktree triggers fail-fast + +- **WHEN** local branch `<CHANGE_ID>` exists +- **AND** no worktree at `.specflow/worktrees/<CHANGE_ID>/main/` is registered for it +- **THEN** `prepare-change` SHALL exit non-zero with an actionable message +- **AND** the user repo SHALL be untouched + +#### Scenario: Worktree registered at a non-conventional path triggers fail-fast + +- **WHEN** branch `<CHANGE_ID>` is registered as a worktree at a path other than `.specflow/worktrees/<CHANGE_ID>/main/` +- **THEN** `prepare-change` SHALL fail-fast with the offending worktree path in the message + +#### Scenario: Non-worktree directory at the conventional path triggers fail-fast + +- **WHEN** `.specflow/worktrees/<CHANGE_ID>/main/` exists as a regular directory not registered as a git worktree +- **THEN** `prepare-change` SHALL fail-fast and SHALL NOT delete the directory + +### Requirement: All main-session commands operate inside the worktree + +After `prepare-change` returns, every specflow command that performs work for the change (including `/specflow.design`, `/specflow.apply`, `/specflow.review_design`, `/specflow.review_apply`, `/specflow.fix_design`, `/specflow.fix_apply`, `/specflow.approve`, `/specflow.reject`, and `/specflow.archive`) SHALL resolve its working directory from `worktree_path` in run-state and SHALL execute git operations and file edits inside the main-session worktree. + +#### Scenario: Phase commands execute inside the main-session worktree + +- **WHEN** `/specflow.apply` runs for change `<CHANGE_ID>` +- **THEN** all git operations and file edits SHALL target `.specflow/worktrees/<CHANGE_ID>/main/` + +#### Scenario: User repo is read-only to main-session phase commands + +- **WHEN** any main-session phase command executes +- **THEN** it SHALL NOT modify files, branches, or git refs inside the user repo working tree (outside `.specflow/worktrees/`) + +### Requirement: Approve push and PR base resolution + +`/specflow.approve` SHALL execute `git push -u origin <CHANGE_ID>` from inside the main-session worktree. The user repo SHALL NOT be used as the push source, and SHALL NOT receive cherry-picks or merges from the worktree. + +The PR's base branch SHALL be resolved from run-state's `base_branch`. If `base_branch` has a known upstream remote tracking ref, the PR base SHALL be the corresponding remote branch (e.g., `origin/<base_branch>`); otherwise the PR base SHALL fall back to the repository's default branch. + +#### Scenario: Push originates from the main-session worktree + +- **WHEN** `/specflow.approve` runs for change `<CHANGE_ID>` +- **THEN** `git push -u origin <CHANGE_ID>` SHALL be executed with `cwd = .specflow/worktrees/<CHANGE_ID>/main/` +- **AND** no push SHALL be issued from the user repo + +#### Scenario: PR base branch comes from base_branch + +- **WHEN** the user started `/specflow` from branch `feature/X` +- **AND** `feature/X` is recorded in run-state as `base_branch` +- **THEN** the PR created by `/specflow.approve` SHALL target `feature/X` as its base +- **AND** SHALL NOT default to `main` unless `feature/X` is the default branch + +#### Scenario: PR base falls back to default branch when base_branch has no upstream + +- **WHEN** `base_branch` has no upstream tracking ref +- **THEN** the PR base SHALL fall back to the repository's default branch + +### Requirement: Cleanup is gated on clean and complete terminal state + +`/specflow.approve`, `/specflow.archive`, and `/specflow.reject` SHALL remove `.specflow/worktrees/<CHANGE_ID>/` (every registered worktree via `git worktree remove`, then the parent directory) **only when both** of the following hold at the moment of terminal phase entry: + +1. The terminal action itself succeeded fully (e.g., `approve` pushed and created the PR; `archive` archived the change; `reject` reset all artifacts). No partial-failure state SHALL be recorded for the run. +2. Every worktree under `.specflow/worktrees/<CHANGE_ID>/` is clean (`git -C <wt> status --porcelain` returns empty output). + +If either condition fails, cleanup SHALL be deferred. The run SHALL still enter its terminal phase, but a `cleanup_pending` marker SHALL be recorded in run-state, and the user-facing CLI output SHALL surface the dirty paths and/or partial-failure cause and instruct the user how to resolve manually. + +#### Scenario: Successful approve cleans up worktrees + +- **WHEN** `/specflow.approve` succeeds end-to-end +- **AND** every worktree under `.specflow/worktrees/<CHANGE_ID>/` is clean +- **THEN** every such worktree SHALL be removed via `git worktree remove` +- **AND** `.specflow/worktrees/<CHANGE_ID>/` SHALL be deleted +- **AND** run-state SHALL NOT have a `cleanup_pending` marker + +#### Scenario: Dirty worktree blocks cleanup + +- **WHEN** `/specflow.approve` succeeds but the main-session worktree has uncommitted changes +- **THEN** cleanup SHALL be skipped +- **AND** run-state SHALL record `cleanup_pending = true` +- **AND** the CLI output SHALL list the dirty paths + +#### Scenario: Partial approve failure blocks cleanup + +- **WHEN** `/specflow.approve` pushes successfully but PR creation fails +- **THEN** cleanup SHALL be skipped +- **AND** run-state SHALL record `cleanup_pending = true` with the partial-failure cause + +### Requirement: No legacy mode coexistence + +This capability SHALL NOT introduce a `legacy_mode` flag, dual-path code, or in-process migration of pre-existing run-state. Run-state records that still satisfy `worktree_path == repo_path` SHALL be treated as legacy artifacts that must be drained (approved or rejected) before this change is merged. After the change lands, encountering such a record SHALL cause `prepare-change` to refuse to proceed with an explicit error. + +#### Scenario: Legacy run-state is rejected by prepare-change + +- **WHEN** `prepare-change` resumes a run whose persisted run-state has `worktree_path == repo_path` +- **THEN** it SHALL exit non-zero with a message asking the user to finish or reject the legacy change +- **AND** SHALL NOT silently fall back to branch-checkout behavior diff --git a/openspec/changes/archive/2026-04-25-worktree/specs/workflow-run-state/spec.md b/openspec/changes/archive/2026-04-25-worktree/specs/workflow-run-state/spec.md new file mode 100644 index 0000000..1098353 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/specs/workflow-run-state/spec.md @@ -0,0 +1,194 @@ +## MODIFIED Requirements + +### Requirement: `specflow-run start` initializes persisted run state + +`specflow-run start` SHALL orchestrate run creation by +(1) verifying preconditions via `RunArtifactStore`, +`ChangeArtifactStore`, and `WorkspaceContext` in the wiring +layer, (2) invoking the pure core start function with explicit +precondition inputs and a `LocalRunState` adapter seed, and +(3) persisting the returned `CoreRunState & LocalRunState` value +via `await RunArtifactStore.write()`. The persisted run state +SHALL be identical in shape and content to the pre-existing +`run.json` layout, **extended with three new local-adapter fields +`base_commit`, `base_branch`, and `cleanup_pending`** (see the +field-partition requirements below). The run_id generation SHALL +continue to use `run-store-ops.generateRunId(store, changeId)`, +invoked from the wiring layer. + +#### Scenario: Change runs require an existing local proposal artifact + +- **WHEN** `specflow-run start <change_id>` is invoked with the + default run kind +- **THEN** the wiring layer SHALL use `ChangeArtifactStore` to + verify that `(change_id, proposal)` exists and SHALL pass + `proposalExists: boolean` to the core start function +- **AND** the core function SHALL return a typed + `change_proposal_missing` error when `proposalExists` is + `false` +- **AND** the CLI SHALL map that error to exit code `1` with the + pre-existing stderr text + +#### Scenario: Started runs capture repository metadata via WorkspaceContext + +- **WHEN** a run is started inside a valid workspace +- **THEN** the wiring layer SHALL construct the `LocalRunState` + slice from `WorkspaceContext.projectIdentity()`, + `projectDisplayName()`, `projectRoot()`, `branchName()`, + `worktreePath()`, **`baseCommit()`**, **`baseBranch()`**, the + literal `null` for `last_summary_path`, and the literal `false` + for `cleanup_pending` +- **AND** it SHALL pass the slice as the adapter seed to + `startChangeRun<LocalRunState>` +- **AND** the persisted `run.json` SHALL include `run_id`, + `change_name`, `project_id`, `repo_name`, `repo_path`, + `branch_name`, `worktree_path`, `base_commit`, `base_branch`, + `cleanup_pending`, `agents`, `allowed_events`, + `created_at`, and `updated_at` +- **AND** when the change uses a main-session worktree, + `worktree_path` SHALL equal `.specflow/worktrees/<CHANGE_ID>/main/` + and SHALL NOT equal `repo_path` + +#### Scenario: Synthetic runs bypass change-directory lookup + +- **WHEN** `specflow-run start <run_id> --run-kind synthetic` is + invoked +- **THEN** the wiring layer SHALL NOT call `ChangeArtifactStore` +- **AND** it SHALL pass `existingRunExists` computed via + `await RunArtifactStore.exists(runRef(runId))` +- **AND** the run_kind in the persisted state SHALL be + `synthetic` with `change_name` set to `null` + +#### Scenario: run_id is auto-generated from change_id and sequence + +- **WHEN** `specflow-run start <change_id>` is invoked +- **THEN** the wiring layer SHALL compute `nextRunId` via + `await generateRunId(store, changeId)` and SHALL pass it as + a precondition input to the core function +- **AND** the resulting run_id SHALL be `<changeId>-<N>` where + N is one greater than the highest existing sequence number + +#### Scenario: Start writes run state through the store from the wiring layer + +- **WHEN** `specflow-run start` completes successfully +- **THEN** the wiring layer SHALL persist the returned state via + `await RunArtifactStore.write(runRef(run_id), JSON.stringify(state, null, 2))` +- **AND** the core start function SHALL NOT call any store method +- **AND** the atomic-replacement guarantee SHALL be provided by + `RunArtifactStore.write` — not by any new helper layer + +### Requirement: Run-state types are partitioned into core and local-adapter partitions + +The run-state type system SHALL expose three named types in `src/types/contracts.ts`: + +- `CoreRunState` — the run-state fields every runtime persists + regardless of adapter: `run_id`, `change_name`, `current_phase`, + `status`, `allowed_events`, `agents`, `history`, `source`, + `created_at`, `updated_at`, `previous_run_id`, and `run_kind`. +- `LocalRunState` — the run-state fields owned by the local + filesystem adapter only: `project_id`, `repo_name`, `repo_path`, + `branch_name`, `worktree_path`, `base_commit`, `base_branch`, + `cleanup_pending`, and `last_summary_path`. +- `RunState` — the pre-existing compatibility alias, defined as + `CoreRunState & LocalRunState`. Every consumer that imports + `RunState` today SHALL keep compiling without modification. + +The field membership of `CoreRunState` and `LocalRunState` SHALL be +disjoint, and their union SHALL equal the field set of `RunState`. No +field SHALL be added to `CoreRunState` or removed from `RunState` by this partition. + +#### Scenario: CoreRunState exposes the runtime-agnostic fields + +- **WHEN** the `CoreRunState` type from `src/types/contracts.ts` is + inspected +- **THEN** its keys SHALL be exactly `run_id`, `change_name`, + `current_phase`, `status`, `allowed_events`, `agents`, `history`, + `source`, `created_at`, `updated_at`, `previous_run_id`, and + `run_kind` +- **AND** the type SHALL NOT expose any local-adapter field + +#### Scenario: LocalRunState exposes only local-adapter fields + +- **WHEN** the `LocalRunState` type from `src/types/contracts.ts` is + inspected +- **THEN** its keys SHALL be exactly `project_id`, `repo_name`, + `repo_path`, `branch_name`, `worktree_path`, `base_commit`, + `base_branch`, `cleanup_pending`, and `last_summary_path` +- **AND** the type SHALL NOT expose any core runtime field + +#### Scenario: RunState remains the intersection alias + +- **WHEN** the `RunState` type is inspected +- **THEN** it SHALL equal `CoreRunState & LocalRunState` +- **AND** every existing consumer importing `RunState` SHALL continue + to compile without code change + +### Requirement: Core runtime commands are pure and perform no I/O + +Core runtime commands SHALL be pure transition functions. Production modules under `src/core/**/*.ts` SHALL NOT import `WorkspaceContext`, SHALL NOT accept a `RunArtifactStore` or `ChangeArtifactStore` in any `*Deps` parameter, and SHALL NOT call `read`, `write`, `exists`, or `list` on any store. All run-artifact and change-artifact I/O for the workflow commands SHALL happen exclusively in the CLI wiring layer under `src/bin/**`. Test files under `src/core/` are out of scope because the repository convention places every test file under `src/tests/`. + +#### Scenario: Core modules do not import WorkspaceContext + +- **WHEN** any file matching `src/core/**/*.ts` is inspected +- **THEN** it SHALL NOT contain an import of + `../lib/workspace-context` or any re-export of the + `WorkspaceContext` interface + +#### Scenario: Core *Deps types omit stores and workspace + +- **WHEN** any `*Deps` type declared in `src/core/types.ts` is + inspected +- **THEN** it SHALL NOT contain a `runs`, `changes`, or + `workspace` member + +#### Scenario: Core modules do not call store methods + +- **WHEN** any file matching `src/core/**/*.ts` is inspected +- **THEN** it SHALL NOT contain `deps.runs.read`, + `deps.runs.write`, `deps.runs.exists`, `deps.runs.list`, + `deps.changes.read`, `deps.changes.exists`, or + `deps.changes.list` + +#### Scenario: Local-adapter field names are absent from core object literals + +- **WHEN** any file matching `src/core/**/*.ts` is inspected +- **THEN** it SHALL NOT contain any of the `LocalRunState` keys + (`project_id:`, `repo_name:`, `repo_path:`, `branch_name:`, + `worktree_path:`, `base_commit:`, `base_branch:`, + `cleanup_pending:`, `last_summary_path:`) used as an object + property key + +## ADDED Requirements + +### Requirement: Legacy run-state with worktree_path == repo_path is rejected + +`specflow-prepare-change` SHALL refuse to load any persisted run-state record where `worktree_path` equals `repo_path`. Such records correspond to the pre-`main-session-worktree` branch-checkout layout that this change replaces. The CLI SHALL surface a clear error message instructing the user to drain (approve or reject) the legacy change before proceeding, and SHALL NOT auto-migrate, auto-rewrite, or silently upgrade the record. + +This requirement does not apply to synthetic runs (`run_kind = "synthetic"`), which never carry a `repo_path`/`worktree_path` divergence by design. + +#### Scenario: Legacy record is rejected + +- **WHEN** `specflow-prepare-change` reads a persisted run-state where `worktree_path == repo_path` and `run_kind != "synthetic"` +- **THEN** it SHALL exit non-zero with a message asking the user to drain the legacy run +- **AND** SHALL NOT modify the persisted record +- **AND** SHALL NOT proceed to materialize artifacts for that change + +#### Scenario: New-layout record is accepted + +- **WHEN** the persisted run-state has `worktree_path = .specflow/worktrees/<CHANGE_ID>/main/` distinct from `repo_path` +- **THEN** `specflow-prepare-change` SHALL proceed normally + +### Requirement: cleanup_pending tracks deferred terminal cleanup + +The `cleanup_pending` field on `LocalRunState` SHALL be a boolean (default `false`) that records whether terminal-state worktree cleanup has been deferred for the current run. The `main-session-worktree` capability SHALL set `cleanup_pending = true` when a terminal phase is reached but cleanup gating fails (dirty worktree or partial-success state). Every other writer SHALL preserve the field unchanged. + +#### Scenario: Default is false + +- **WHEN** `specflow-run start` initializes a new run +- **THEN** the persisted `cleanup_pending` SHALL be `false` + +#### Scenario: Deferred cleanup sets the flag + +- **WHEN** a terminal phase command (approve/archive/reject) succeeds but cleanup gating fails +- **THEN** the wiring layer SHALL persist `cleanup_pending = true` +- **AND** subsequent reads of the run SHALL observe the deferred flag diff --git a/openspec/changes/archive/2026-04-25-worktree/task-graph.json b/openspec/changes/archive/2026-04-25-worktree/task-graph.json new file mode 100644 index 0000000..6e18151 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/task-graph.json @@ -0,0 +1,434 @@ +{ + "version": "1.0", + "change_id": "worktree", + "bundles": [ + { + "id": "worktree-policy-clarifications", + "title": "Confirm Remaining Worktree Policies", + "goal": "Lock the open behavioral questions so implementation bundles can ship a single worktree-mode contract without fallback paths.", + "depends_on": [], + "inputs": [ + "design.md#Open Questions", + "design.md#Decisions D8-D9", + "design.md#Migration Plan" + ], + "outputs": [ + "confirmed implementation notes for cleanup retries, safety-only worktree prune behavior, and legacy read-only inspection semantics" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Confirm the implementation policy for cleanup retries, safety-only git worktree prune, and legacy read-only inspection flows.", + "status": "done" + }, + { + "id": "2", + "title": "Translate the confirmed policies into acceptance notes for prepare-change, terminal cleanup, and read-only CLI behavior.", + "status": "done" + }, + { + "id": "3", + "title": "Record rejected alternatives so downstream bundles implement one behavior with no legacy-mode fallback.", + "status": "done" + } + ], + "owner_capabilities": [ + "approval-clarify-persistence", + "spec-consistency-verification" + ], + "size_score": 3 + }, + { + "id": "main-worktree-foundation", + "title": "Establish Main Worktree Foundation", + "goal": "Replace branch checkout with a persisted main-session worktree contract and the run-state fields needed to drive it.", + "depends_on": [ + "worktree-policy-clarifications" + ], + "inputs": [ + "design.md#Ordering / Dependency Notes", + "design.md#Decisions D1-D6", + "src/bin/specflow-prepare-change.ts", + "src/types/contracts.ts", + "src/tests/run-state-partition.test.ts", + "src/lib/workspace-context.ts" + ], + "outputs": [ + "LocalRunState fields base_commit, base_branch, and cleanup_pending", + "worktree-aware workspace-context construction in the wiring layer", + ".specflow/worktrees/<CHANGE_ID>/main/ creation-reuse-fail-fast contract", + "prepare-change downstream execution rooted at the main-session worktree" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Extend LocalRunState and persistence wiring with base_commit, base_branch, and cleanup_pending, then update the drift-guard test.", + "status": "done" + }, + { + "id": "2", + "title": "Add worktree override and base-info plumbing to the local WorkspaceContext construction path without leaking adapter concerns into core modules.", + "status": "done" + }, + { + "id": "3", + "title": "Replace ensureBranch with ensureMainSessionWorktree, including create, reuse, and fail-fast handling for .specflow/worktrees/<CHANGE_ID>/main/ and removal of legacy checkout mode.", + "status": "done" + }, + { + "id": "4", + "title": "Re-root prepare-change downstream openspec and specflow-run start calls to the resolved worktree path while preserving user-repo HEAD, branch, and dirty-state invariants.", + "status": "done" + } + ], + "owner_capabilities": [ + "workflow-run-state", + "runstate-adapter-extension", + "workspace-context" + ], + "size_score": 4 + }, + { + "id": "subagent-worktree-retargeting", + "title": "Retarget Subagent Worktrees", + "goal": "Move subagent execution and patch application onto change-scoped worktrees rooted under the main-session worktree parent.", + "depends_on": [ + "main-worktree-foundation" + ], + "inputs": [ + "design.md#Subagent dispatch contract", + "design.md#Concerns C-4", + "apply-worktree-integration baseline", + "bundle-subagent-execution baseline" + ], + "outputs": [ + ".specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/ subagent layout", + "main-session worktree patch-apply target", + "subagent diff/base capture aligned to worktree mode" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Thread mainSessionWorktreePath and change-scoped worktree parent data through apply-pipeline entrypoints from persisted run-state.", + "status": "done" + }, + { + "id": "2", + "title": "Re-parent subagent worktree creation to .specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/ using explicit user-repo absolute paths in the shared git worktree registry.", + "status": "done" + }, + { + "id": "3", + "title": "Switch subagent diff capture and patch application to compare within the subagent worktree and apply into the main-session worktree instead of the user repo.", + "status": "done" + }, + { + "id": "4", + "title": "Add targeted integration coverage for subagent worktree layout and patch landing location.", + "status": "done" + } + ], + "owner_capabilities": [ + "apply-worktree-integration", + "bundle-subagent-execution" + ], + "size_score": 4 + }, + { + "id": "phase-cwd-routing", + "title": "Route Phase Commands Through Worktree Paths", + "goal": "Make phase-command wiring resolve and execute against state.worktree_path instead of assuming the user repo root.", + "depends_on": [ + "main-worktree-foundation" + ], + "inputs": [ + "design.md#Decision D10", + "design.md#Risks", + "src/bin/**", + "RunArtifactStore wiring" + ], + "outputs": [ + "shared run-id to worktree_path resolver in the wiring layer", + "worktree-rooted phase command execution contexts", + "grep or static guard against repo-root write-path assumptions" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Audit src/bin and shared CLI wiring for write-path uses of process.cwd() or git rev-parse --show-toplevel.", + "status": "done" + }, + { + "id": "2", + "title": "Add a shared run-id to run-state to worktree_path resolver and use it to construct worktree-rooted execution contexts in the wiring layer.", + "status": "done" + }, + { + "id": "3", + "title": "Retarget shared phase command entrypoints and openspec invocations to state.worktree_path while leaving phase-specific approve and cleanup behavior to follow-on bundles.", + "status": "done" + }, + { + "id": "4", + "title": "Add a guard test that fails when new CLI write paths hard-code the user repo root outside the allowed wiring points.", + "status": "done" + } + ], + "owner_capabilities": [ + "phase-router", + "workspace-context", + "run-artifact-store-conformance" + ], + "size_score": 4 + }, + { + "id": "approve-pr-base-routing", + "title": "Push And Create PR From Worktree", + "goal": "Make approve push the change branch from the worktree and target PR creation at the recorded base branch with a safe fallback.", + "depends_on": [ + "phase-cwd-routing" + ], + "inputs": [ + "design.md#Decision D7", + "design.md#Concerns C-5", + "approve CLI wiring", + "run-state base_branch and branch_name" + ], + "outputs": [ + "approve push from state.worktree_path", + "PR base resolution from base_branch upstream with default-branch fallback", + "gh pr create execution rooted at the worktree" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Update approve to run git push -u origin <CHANGE_ID> from the recorded worktree path.", + "status": "done" + }, + { + "id": "2", + "title": "Resolve the PR base from base_branch upstream tracking data and fall back to the repository default branch when no upstream exists.", + "status": "done" + }, + { + "id": "3", + "title": "Run gh pr create from inside the worktree and surface clear failures when remote or base resolution fails.", + "status": "done" + }, + { + "id": "4", + "title": "Add approve-path coverage for feature-branch starts and detached-HEAD fallback behavior.", + "status": "done" + } + ], + "owner_capabilities": [ + "review-orchestration", + "repo-responsibility" + ], + "size_score": 4 + }, + { + "id": "terminal-worktree-cleanup", + "title": "Gate And Cleanup Terminal Worktrees", + "goal": "Remove the change-scoped worktree subtree only when terminal actions are complete and every worktree is clean, otherwise persist cleanup deferral state.", + "depends_on": [ + "worktree-policy-clarifications", + "phase-cwd-routing" + ], + "inputs": [ + "design.md#Decision D8", + "design.md#Concerns C-6", + "approve/archive/reject CLI wiring", + ".specflow/worktrees/<CHANGE_ID>/" + ], + "outputs": [ + "terminal cleanup gate across main and subagent worktrees", + "cleanup_pending lifecycle in run.json", + "deferred cleanup retry behavior for terminal re-entry" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Implement the terminal cleanup gate for approve, archive, and reject by combining terminal success with per-worktree clean-tree checks.", + "status": "done" + }, + { + "id": "2", + "title": "Remove all worktrees under .specflow/worktrees/<CHANGE_ID>/ with non-force git worktree remove and delete the parent directory when the gate says remove.", + "status": "done" + }, + { + "id": "3", + "title": "Persist cleanup_pending = true, surface offending paths or partial-failure causes, and keep the run terminal when cleanup must defer.", + "status": "done" + }, + { + "id": "4", + "title": "Re-evaluate deferred cleanup on subsequent terminal-phase invocations and clear the pending flag when cleanup eventually succeeds.", + "status": "done" + } + ], + "owner_capabilities": [ + "artifact-ownership-model", + "workflow-gate-semantics", + "workflow-run-state" + ], + "size_score": 4 + }, + { + "id": "legacy-runstate-guard", + "title": "Block Legacy Resume Paths", + "goal": "Reject persisted legacy runs on prepare-change resume when worktree_path still points at the user repo root, without breaking read-only inspection flows.", + "depends_on": [ + "worktree-policy-clarifications", + "main-worktree-foundation" + ], + "inputs": [ + "design.md#Decision D9", + "design.md#Concerns C-7", + "specflow-prepare-change run-state load path" + ], + "outputs": [ + "prepare-change legacy run-state guard", + "non-mutating blocked-resume behavior", + "explicit remediation messaging for legacy runs" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Detect resumed runs in prepare-change where worktree_path equals repo_path and fail before any workspace mutation occurs.", + "status": "done" + }, + { + "id": "2", + "title": "Restrict the guard to prepare-change resume paths so read-only inspection commands can still load legacy records.", + "status": "done" + }, + { + "id": "3", + "title": "Emit actionable remediation guidance with the conflicting paths in the error output.", + "status": "done" + }, + { + "id": "4", + "title": "Add blocked-resume coverage proving the legacy guard is non-mutating.", + "status": "done" + } + ], + "owner_capabilities": [ + "runstate-adapter-extension", + "spec-consistency-verification" + ], + "size_score": 4 + }, + { + "id": "watcher-dashboard-retargeting", + "title": "Retarget Watcher And Dashboard Reads", + "goal": "Make watcher, dashboard, archive, and related status surfaces follow worktree_path indirection instead of scanning the user repo.", + "depends_on": [ + "phase-cwd-routing" + ], + "inputs": [ + "design.md#Risks", + "design.md#Integration Points", + "specflow-watch", + "dashboard and archive readers", + "run-state worktree_path" + ], + "outputs": [ + "watcher artifact reads routed through worktree_path", + "dashboard and archive readers pointed at worktree-local openspec artifacts", + "run-id based status surfaces free of repo-root path assumptions" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Update specflow-watch to resolve the active worktree_path from RUN_ID and follow artifacts in the worktree.", + "status": "done" + }, + { + "id": "2", + "title": "Retarget dashboard, archive, and related readers to inspect openspec/changes/<CHANGE_ID>/ inside the worktree until archive propagation completes.", + "status": "done" + }, + { + "id": "3", + "title": "Audit external hook or status surfaces that accept RUN_ID so they use run-state indirection rather than repo-root scanning.", + "status": "done" + }, + { + "id": "4", + "title": "Add coverage for watcher and dashboard behavior against active worktree-mode runs.", + "status": "done" + } + ], + "owner_capabilities": [ + "realtime-progress-ui", + "workflow-observation-events", + "run-artifact-store-conformance" + ], + "size_score": 4 + }, + { + "id": "worktree-invariant-verification", + "title": "Verify Worktree Invariants End To End", + "goal": "Prove the new worktree mode preserves user-repo invariants and satisfies completion conditions C-1 through C-7 across the full lifecycle.", + "depends_on": [ + "subagent-worktree-retargeting", + "approve-pr-base-routing", + "terminal-worktree-cleanup", + "legacy-runstate-guard", + "watcher-dashboard-retargeting" + ], + "inputs": [ + "design.md#Completion Conditions", + "temp git repo fixture and integration test harness", + "all worktree-mode implementation bundles" + ], + "outputs": [ + "integration suite covering completion conditions C-1 through C-7", + "temp-repo smoke tests for user-repo invariants", + "regression guards for forbidden repo-root write paths and legacy resume behavior" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Build temp-repo end-to-end smoke tests covering fresh prepare-change through apply, approve, and reject with assertions that the user repo HEAD, branch, and dirty state never change.", + "status": "done" + }, + { + "id": "2", + "title": "Add integration tests for subagent patch application, watcher/dashboard worktree resolution, and terminal cleanup defer-versus-remove flows.", + "status": "done" + }, + { + "id": "3", + "title": "Verify approve PR base selection against recorded base_branch, including default-branch fallback when upstream tracking is missing.", + "status": "done" + }, + { + "id": "4", + "title": "Lock in grep and runtime guards for forbidden repo-root write paths and legacy resume rejection across the CLI surface.", + "status": "done" + } + ], + "owner_capabilities": [ + "spec-consistency-verification", + "run-artifact-store-conformance", + "workflow-run-state" + ], + "size_score": 4 + } + ], + "generated_at": "2026-04-25T07:03:05.820Z", + "generated_from": "design.md" +} diff --git a/openspec/changes/archive/2026-04-25-worktree/tasks.md b/openspec/changes/archive/2026-04-25-worktree/tasks.md new file mode 100644 index 0000000..48ef2fb --- /dev/null +++ b/openspec/changes/archive/2026-04-25-worktree/tasks.md @@ -0,0 +1,95 @@ +## 1. Confirm Remaining Worktree Policies ✓ + +> Lock the open behavioral questions so implementation bundles can ship a single worktree-mode contract without fallback paths. + +- [x] 1.1 Confirm the implementation policy for cleanup retries, safety-only git worktree prune, and legacy read-only inspection flows. +- [x] 1.2 Translate the confirmed policies into acceptance notes for prepare-change, terminal cleanup, and read-only CLI behavior. +- [x] 1.3 Record rejected alternatives so downstream bundles implement one behavior with no legacy-mode fallback. + +## 2. Establish Main Worktree Foundation ✓ + +> Replace branch checkout with a persisted main-session worktree contract and the run-state fields needed to drive it. + +> Depends on: worktree-policy-clarifications + +- [x] 2.1 Extend LocalRunState and persistence wiring with base_commit, base_branch, and cleanup_pending, then update the drift-guard test. +- [x] 2.2 Add worktree override and base-info plumbing to the local WorkspaceContext construction path without leaking adapter concerns into core modules. +- [x] 2.3 Replace ensureBranch with ensureMainSessionWorktree, including create, reuse, and fail-fast handling for .specflow/worktrees/<CHANGE_ID>/main/ and removal of legacy checkout mode. +- [x] 2.4 Re-root prepare-change downstream openspec and specflow-run start calls to the resolved worktree path while preserving user-repo HEAD, branch, and dirty-state invariants. + +## 3. Retarget Subagent Worktrees ✓ + +> Move subagent execution and patch application onto change-scoped worktrees rooted under the main-session worktree parent. + +> Depends on: main-worktree-foundation + +- [x] 3.1 Thread mainSessionWorktreePath and change-scoped worktree parent data through apply-pipeline entrypoints from persisted run-state. +- [x] 3.2 Re-parent subagent worktree creation to .specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/ using explicit user-repo absolute paths in the shared git worktree registry. +- [x] 3.3 Switch subagent diff capture and patch application to compare within the subagent worktree and apply into the main-session worktree instead of the user repo. +- [x] 3.4 Add targeted integration coverage for subagent worktree layout and patch landing location. + +## 4. Route Phase Commands Through Worktree Paths ✓ + +> Make phase-command wiring resolve and execute against state.worktree_path instead of assuming the user repo root. + +> Depends on: main-worktree-foundation + +- [x] 4.1 Audit src/bin and shared CLI wiring for write-path uses of process.cwd() or git rev-parse --show-toplevel. +- [x] 4.2 Add a shared run-id to run-state to worktree_path resolver and use it to construct worktree-rooted execution contexts in the wiring layer. +- [x] 4.3 Retarget shared phase command entrypoints and openspec invocations to state.worktree_path while leaving phase-specific approve and cleanup behavior to follow-on bundles. +- [x] 4.4 Add a guard test that fails when new CLI write paths hard-code the user repo root outside the allowed wiring points. + +## 5. Push And Create PR From Worktree ✓ + +> Make approve push the change branch from the worktree and target PR creation at the recorded base branch with a safe fallback. + +> Depends on: phase-cwd-routing + +- [x] 5.1 Update approve to run git push -u origin <CHANGE_ID> from the recorded worktree path. +- [x] 5.2 Resolve the PR base from base_branch upstream tracking data and fall back to the repository default branch when no upstream exists. +- [x] 5.3 Run gh pr create from inside the worktree and surface clear failures when remote or base resolution fails. +- [x] 5.4 Add approve-path coverage for feature-branch starts and detached-HEAD fallback behavior. + +## 6. Gate And Cleanup Terminal Worktrees ✓ + +> Remove the change-scoped worktree subtree only when terminal actions are complete and every worktree is clean, otherwise persist cleanup deferral state. + +> Depends on: worktree-policy-clarifications, phase-cwd-routing + +- [x] 6.1 Implement the terminal cleanup gate for approve, archive, and reject by combining terminal success with per-worktree clean-tree checks. +- [x] 6.2 Remove all worktrees under .specflow/worktrees/<CHANGE_ID>/ with non-force git worktree remove and delete the parent directory when the gate says remove. +- [x] 6.3 Persist cleanup_pending = true, surface offending paths or partial-failure causes, and keep the run terminal when cleanup must defer. +- [x] 6.4 Re-evaluate deferred cleanup on subsequent terminal-phase invocations and clear the pending flag when cleanup eventually succeeds. + +## 7. Block Legacy Resume Paths ✓ + +> Reject persisted legacy runs on prepare-change resume when worktree_path still points at the user repo root, without breaking read-only inspection flows. + +> Depends on: worktree-policy-clarifications, main-worktree-foundation + +- [x] 7.1 Detect resumed runs in prepare-change where worktree_path equals repo_path and fail before any workspace mutation occurs. +- [x] 7.2 Restrict the guard to prepare-change resume paths so read-only inspection commands can still load legacy records. +- [x] 7.3 Emit actionable remediation guidance with the conflicting paths in the error output. +- [x] 7.4 Add blocked-resume coverage proving the legacy guard is non-mutating. + +## 8. Retarget Watcher And Dashboard Reads ✓ + +> Make watcher, dashboard, archive, and related status surfaces follow worktree_path indirection instead of scanning the user repo. + +> Depends on: phase-cwd-routing + +- [x] 8.1 Update specflow-watch to resolve the active worktree_path from RUN_ID and follow artifacts in the worktree. +- [x] 8.2 Retarget dashboard, archive, and related readers to inspect openspec/changes/<CHANGE_ID>/ inside the worktree until archive propagation completes. +- [x] 8.3 Audit external hook or status surfaces that accept RUN_ID so they use run-state indirection rather than repo-root scanning. +- [x] 8.4 Add coverage for watcher and dashboard behavior against active worktree-mode runs. + +## 9. Verify Worktree Invariants End To End ✓ + +> Prove the new worktree mode preserves user-repo invariants and satisfies completion conditions C-1 through C-7 across the full lifecycle. + +> Depends on: subagent-worktree-retargeting, approve-pr-base-routing, terminal-worktree-cleanup, legacy-runstate-guard, watcher-dashboard-retargeting + +- [x] 9.1 Build temp-repo end-to-end smoke tests covering fresh prepare-change through apply, approve, and reject with assertions that the user repo HEAD, branch, and dirty state never change. +- [x] 9.2 Add integration tests for subagent patch application, watcher/dashboard worktree resolution, and terminal cleanup defer-versus-remove flows. +- [x] 9.3 Verify approve PR base selection against recorded base_branch, including default-branch fallback when upstream tracking is missing. +- [x] 9.4 Lock in grep and runtime guards for forbidden repo-root write paths and legacy resume rejection across the CLI surface. diff --git a/openspec/specs/apply-worktree-integration/spec.md b/openspec/specs/apply-worktree-integration/spec.md index 91621d7..bbae1e7 100644 --- a/openspec/specs/apply-worktree-integration/spec.md +++ b/openspec/specs/apply-worktree-integration/spec.md @@ -5,19 +5,19 @@ TBD - created by archiving change apply-worktree-isolation. Update Purpose after ## Requirements ### Requirement: Worktree is created from main HEAD at creation time -For every bundle assigned execution mode `subagent-worktree`, the main agent SHALL create an ephemeral git worktree via `git worktree add` using the current repository HEAD at the moment of worktree creation as the base. The main agent SHALL NOT pre-compute a shared base snapshot for the whole apply run, SHALL NOT rebase the worktree onto a different base before dispatch, and SHALL NOT rebase the worktree prior to integration. +For every bundle assigned execution mode `subagent-worktree`, the main agent SHALL create an ephemeral git worktree via `git worktree add` using the **main-session worktree** HEAD at the moment of worktree creation as the base. The "main-session worktree" is the dedicated per-change worktree at `.specflow/worktrees/<CHANGE_ID>/main/` defined in the `main-session-worktree` capability; throughout this capability, "main workspace" is re-bound to that path. The main agent SHALL NOT pre-compute a shared base snapshot for the whole apply run, SHALL NOT rebase the worktree onto a different base before dispatch, and SHALL NOT rebase the worktree prior to integration. The user's repository working tree SHALL NOT be used as a base or as an integration target. -As a consequence, when earlier bundles in the same run have already been integrated into the main workspace before a later worktree is created, the later worktree SHALL observe those imports as part of its base. This creates a deterministic, per-worktree base commit that the main agent SHALL record and later use to compute the integration diff. +As a consequence, when earlier bundles in the same run have already been integrated into the main-session worktree before a later worktree is created, the later worktree SHALL observe those imports as part of its base. This creates a deterministic, per-worktree base commit that the main agent SHALL record and later use to compute the integration diff. -#### Scenario: Worktree base equals main HEAD at creation time +#### Scenario: Worktree base equals main-session worktree HEAD at creation time - **WHEN** the main agent creates a worktree for bundle `B` at commit `<sha>` -- **THEN** the worktree's base SHALL equal the main workspace HEAD at the moment of creation +- **THEN** the worktree's base SHALL equal the main-session worktree HEAD at the moment of creation - **AND** the main agent SHALL record `<sha>` as the integration base for bundle `B` #### Scenario: Later worktrees inherit earlier integrations -- **WHEN** bundle `A` has been integrated into the main workspace in this run +- **WHEN** bundle `A` has been integrated into the main-session worktree in this run - **AND** the main agent then creates a worktree for bundle `B` - **THEN** bundle `B`'s worktree SHALL include bundle `A`'s imported changes as part of its base - **AND** no auto-rebase SHALL be performed later to reconcile drift @@ -28,24 +28,36 @@ As a consequence, when earlier bundles in the same run have already been integra - **THEN** each worktree SHALL record its own base commit at creation time - **AND** worktrees created at different points in the run MAY have different base commits +#### Scenario: User repo working tree is not the base or target + +- **WHEN** the main agent dispatches a `subagent-worktree` bundle for change `<CHANGE_ID>` +- **THEN** the user's repository working tree SHALL NOT be used as the base commit source +- **AND** the user's repository working tree SHALL NOT receive any patch imports + ### Requirement: Worktree path convention -Every ephemeral worktree for a `subagent-worktree` bundle SHALL be created at the path `.specflow/worktrees/<RUN_ID>/<BUNDLE_ID>/` relative to the repository root. This path is fixed in Phase 1 and SHALL NOT be configurable. The main agent SHALL ensure the parent directory `.specflow/worktrees/<RUN_ID>/` exists before invoking `git worktree add`. +Every ephemeral worktree for a `subagent-worktree` bundle SHALL be created at the path `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/` relative to the user's repository root. This path is fixed in Phase 1 and SHALL NOT be configurable. The main agent SHALL ensure the parent directory `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/` exists before invoking `git worktree add`. Subagent worktrees thereby become siblings of the main-session worktree at `.specflow/worktrees/<CHANGE_ID>/main/` under the shared per-change parent `.specflow/worktrees/<CHANGE_ID>/`. If the path already exists and the previous worktree cannot be reclaimed (e.g., it is registered in `git worktree list` and removal fails, or it is a non-worktree directory), the main agent SHALL trigger the worktree-unavailable fail-fast behavior defined below. #### Scenario: Worktree is created at the conventional path -- **WHEN** the main agent creates a worktree for bundle `B` in run `R` -- **THEN** the worktree SHALL be located at `.specflow/worktrees/<R>/<B>/` +- **WHEN** the main agent creates a worktree for bundle `B` in run `R` for change `<CHANGE_ID>` +- **THEN** the worktree SHALL be located at `.specflow/worktrees/<CHANGE_ID>/<R>/<B>/` #### Scenario: Existing stale worktree path triggers fail-fast -- **WHEN** `.specflow/worktrees/<RUN_ID>/<BUNDLE_ID>/` already exists +- **WHEN** `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/` already exists - **AND** `git worktree remove` on that path fails - **THEN** the main agent SHALL trigger worktree-unavailable fail-fast - **AND** the worktree path SHALL NOT be silently overwritten +#### Scenario: Subagent worktrees share the per-change parent with the main-session worktree + +- **WHEN** change `<CHANGE_ID>` has both a main-session worktree and one or more subagent worktrees +- **THEN** the main-session worktree SHALL be at `.specflow/worktrees/<CHANGE_ID>/main/` +- **AND** every subagent worktree SHALL be at `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/` + ### Requirement: Worktree-unavailable fail-fast When the main agent cannot create a usable worktree for a subagent-eligible bundle, the main agent SHALL fail-fast the entire apply. Triggering conditions include, but are not limited to: @@ -198,7 +210,7 @@ If a subagent returns `status: "success"` but the worktree diff is empty (no pat ### Requirement: Patch import via git apply covers all standard change types -When all integration-validation checks pass, the main agent SHALL import the subagent's changes into the main workspace via `git -C <worktree> diff --binary <base-sha>..HEAD | git apply --binary` executed at the repository root. The patch-import mechanism SHALL support the full set of change types that `git diff --binary` and `git apply --binary` themselves cover: +When all integration-validation checks pass, the main agent SHALL import the subagent's changes into the **main-session worktree** via `git -C <worktree> diff --binary <base-sha>..HEAD | git -C <main-session-worktree> apply --binary` where `<main-session-worktree>` is `.specflow/worktrees/<CHANGE_ID>/main/`. The patch-import mechanism SHALL support the full set of change types that `git diff --binary` and `git apply --binary` themselves cover: - file creation - file deletion @@ -207,27 +219,33 @@ When all integration-validation checks pass, the main agent SHALL import the sub - file rename - binary file content change -The main agent SHALL NOT use `--3way` fallback in Phase 1. If `git apply --binary` exits non-zero at the repo root, the main agent SHALL reject integration. +The main agent SHALL NOT use `--3way` fallback in Phase 1. If `git apply --binary` exits non-zero against the main-session worktree, the main agent SHALL reject integration. The user's repository working tree SHALL NOT receive the patch. #### Scenario: Text modification applies cleanly - **WHEN** integration validation passes and the patch modifies a text file -- **THEN** `git apply --binary` SHALL be invoked at the repo root +- **THEN** `git apply --binary` SHALL be invoked with `cwd = .specflow/worktrees/<CHANGE_ID>/main/` - **AND** the bundle SHALL progress toward `done` on successful apply #### Scenario: Binary change is included in the patch - **WHEN** the worktree diff includes a binary file change - **THEN** the diff SHALL be extracted with `git diff --binary` -- **AND** `git apply --binary` SHALL be invoked at the repo root +- **AND** `git apply --binary` SHALL be invoked against the main-session worktree #### Scenario: Patch-apply failure rejects integration -- **WHEN** `git apply --binary` exits non-zero +- **WHEN** `git apply --binary` exits non-zero against the main-session worktree - **THEN** the main agent SHALL reject integration - **AND** the bundle status SHALL become `integration_rejected` - **AND** no `--3way` retry SHALL be attempted +#### Scenario: User repo working tree is never patched + +- **WHEN** integration succeeds for bundle `B` +- **THEN** the patch SHALL be applied only to the main-session worktree +- **AND** the user repo working tree SHALL remain unchanged by the integration + ### Requirement: Bundle status transition after integration A `subagent-worktree` bundle SHALL reach `done` only after integration validation passes and `git apply --binary` succeeds at the repo root. The main agent SHALL call `specflow-advance-bundle <CHANGE_ID> <BUNDLE_ID> done` ONLY after successful patch import. @@ -259,27 +277,29 @@ If the subagent returned `status: "success"` but integration validation (any rea The main agent SHALL clean up worktrees based on the bundle's final status in the current apply invocation: - On `done`: the worktree SHALL be removed immediately via `git worktree remove <path>`. If `git worktree remove` fails (e.g., due to uncommitted subagent changes that should already have been imported), the main agent SHALL surface the error but SHALL NOT revert the bundle's `done` status. -- On `subagent_failed`: the worktree SHALL be retained at its path at `.specflow/worktrees/<RUN_ID>/<BUNDLE_ID>/`. +- On `subagent_failed`: the worktree SHALL be retained at its path at `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`. - On `integration_rejected`: the worktree SHALL be retained at its path. Retention behavior in Phase 1 is fixed and SHALL NOT be configurable. `/specflow.fix_apply` and manual inspection SHALL use the retained worktree path to diagnose failures. +The per-change parent `.specflow/worktrees/<CHANGE_ID>/` SHALL NOT be deleted by this capability while any subagent worktree (or the main-session worktree) under it remains. Removal of the parent is governed by the `main-session-worktree` capability's terminal-state cleanup. + #### Scenario: Successful bundle removes its worktree - **WHEN** bundle `B` reaches `done` -- **THEN** `git worktree remove .specflow/worktrees/<RUN_ID>/<B>/` SHALL be invoked +- **THEN** `git worktree remove .specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<B>/` SHALL be invoked - **AND** the worktree SHALL no longer appear in `git worktree list` #### Scenario: Failed subagent retains worktree - **WHEN** bundle `B` reaches `subagent_failed` -- **THEN** the worktree at `.specflow/worktrees/<RUN_ID>/<B>/` SHALL remain +- **THEN** the worktree at `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<B>/` SHALL remain - **AND** the worktree SHALL still appear in `git worktree list` #### Scenario: Integration rejection retains worktree - **WHEN** bundle `B` reaches `integration_rejected` -- **THEN** the worktree at `.specflow/worktrees/<RUN_ID>/<B>/` SHALL remain +- **THEN** the worktree at `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<B>/` SHALL remain #### Scenario: Retention policy is not configurable in Phase 1 @@ -287,3 +307,9 @@ Retention behavior in Phase 1 is fixed and SHALL NOT be configurable. `/specflow - **THEN** the retention behavior SHALL be the fixed rule above - **AND** no config key SHALL alter cleanup on success or retention on failure +#### Scenario: Per-change parent is not deleted by this capability + +- **WHEN** the last `done` subagent worktree is removed +- **AND** the main-session worktree at `.specflow/worktrees/<CHANGE_ID>/main/` still exists +- **THEN** `.specflow/worktrees/<CHANGE_ID>/` SHALL remain on disk + diff --git a/openspec/specs/bundle-subagent-execution/spec.md b/openspec/specs/bundle-subagent-execution/spec.md index 2a1b856..df4a6a2 100644 --- a/openspec/specs/bundle-subagent-execution/spec.md +++ b/openspec/specs/bundle-subagent-execution/spec.md @@ -237,8 +237,8 @@ This behavior is consistent with the existing `specflow-advance-bundle` fail-fas The dispatcher SHALL assign each bundle exactly one of two execution modes when `/specflow.apply` begins a window: -- `inline-main`: the bundle is implemented directly by the main agent in the primary workspace. -- `subagent-worktree`: the bundle is dispatched to a subagent running inside a dedicated ephemeral git worktree, as defined by the `apply-worktree-integration` capability. +- `inline-main`: the bundle is implemented directly by the main agent in the **main-session worktree** at `.specflow/worktrees/<CHANGE_ID>/main/` (formerly referred to as "the primary workspace"). The user's repository working tree SHALL NOT be used as the primary workspace. +- `subagent-worktree`: the bundle is dispatched to a subagent running inside a dedicated ephemeral git worktree at `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`, as defined by the `apply-worktree-integration` capability. The mode assignment rule SHALL be: @@ -259,6 +259,12 @@ Dispatch signals SHALL remain limited to the existing eligibility rule (`apply.s - **WHEN** a bundle is classified as inline-only (for any reason: dispatch disabled, `size_score <= threshold`, missing `size_score`, or task-graph.json absent) - **THEN** the bundle SHALL be assigned execution mode `inline-main` +#### Scenario: Inline-main executes inside the main-session worktree + +- **WHEN** a bundle is assigned execution mode `inline-main` +- **THEN** the main agent SHALL perform that bundle's edits inside `.specflow/worktrees/<CHANGE_ID>/main/` +- **AND** SHALL NOT modify the user's repository working tree + #### Scenario: Subagent-shared is not a supported mode - **WHEN** the dispatcher assigns execution mode @@ -279,11 +285,11 @@ A `subagent-worktree` bundle SHALL reach `done` if and only if: 1. The subagent returned `status: "success"`, AND 2. Integration validation passed, AND -3. `git apply --binary` at the repo root exited zero. +3. `git apply --binary` against the **main-session worktree** at `.specflow/worktrees/<CHANGE_ID>/main/` exited zero. -If any of 1–3 fails, the bundle SHALL transition to one of the new terminal-for-this-invocation statuses defined in `task-planner` (`subagent_failed` for failed 1; `integration_rejected` for failed 2 or 3), per `apply-worktree-integration`. The main agent SHALL NOT silently record `done` on an unverified or unimported subagent success. +If any of 1–3 fails, the bundle SHALL transition to one of the new terminal-for-this-invocation statuses defined in `task-planner` (`subagent_failed` for failed 1; `integration_rejected` for failed 2 or 3), per `apply-worktree-integration`. The main agent SHALL NOT silently record `done` on an unverified or unimported subagent success. The user's repository working tree SHALL NOT be the patch-apply target. -For `inline-main` bundles, this requirement does NOT apply; inline bundles reach `done` under the existing completion rules. +For `inline-main` bundles, this requirement does NOT apply; inline bundles reach `done` under the existing completion rules but execute inside the main-session worktree per the execution-mode requirement above. #### Scenario: Subagent success alone does not reach done @@ -291,12 +297,18 @@ For `inline-main` bundles, this requirement does NOT apply; inline bundles reach - **AND** integration validation or patch-apply has not yet been executed - **THEN** the main agent SHALL NOT invoke `specflow-advance-bundle ... done` -#### Scenario: Done is reached only after successful integration +#### Scenario: Done is reached only after successful integration into the main-session worktree - **WHEN** a `subagent-worktree` bundle's subagent returns `status: "success"` -- **AND** integration validation passes and `git apply --binary` succeeds +- **AND** integration validation passes and `git apply --binary` against `.specflow/worktrees/<CHANGE_ID>/main/` succeeds - **THEN** the main agent SHALL invoke `specflow-advance-bundle ... done` +#### Scenario: User repo is never the integration target + +- **WHEN** integration succeeds for a `subagent-worktree` bundle +- **THEN** the patch SHALL have been applied to the main-session worktree +- **AND** the user's repository working tree SHALL remain untouched by the patch + #### Scenario: Inline-main completion rules are unchanged - **WHEN** a bundle is assigned `inline-main` diff --git a/openspec/specs/main-session-worktree/spec.md b/openspec/specs/main-session-worktree/spec.md new file mode 100644 index 0000000..7c1e4f3 --- /dev/null +++ b/openspec/specs/main-session-worktree/spec.md @@ -0,0 +1,170 @@ +# main-session-worktree Specification + +## Purpose +TBD - created by archiving change worktree. Update Purpose after archive. +## Requirements +### Requirement: Main-session worktree replaces user-repo branch checkout + +When `specflow-prepare-change` initializes a change for the first time, the main agent SHALL NOT execute `git checkout -b <CHANGE_ID>` (or any other `git checkout`) on the user's working repository. Instead, the main agent SHALL create a dedicated git worktree for the change and conduct all subsequent main-session work inside it. The user's working repository SHALL retain its current branch, current `HEAD`, and any staged, unstaged, or untracked changes exactly as they were before `/specflow` was invoked. + +#### Scenario: User repo state is untouched on prepare-change + +- **WHEN** the user invokes `/specflow` for a new change while their repo is on branch `feature/X` with uncommitted local edits +- **THEN** the main agent SHALL NOT switch the user's repo to any other branch +- **AND** the user's staged, unstaged, and untracked changes SHALL remain in the user repo unchanged +- **AND** `git -C <user-repo> branch --show-current` SHALL still report `feature/X` after `prepare-change` returns + +#### Scenario: No git checkout is run on the user repo + +- **WHEN** `specflow-prepare-change` initializes a new change `<CHANGE_ID>` +- **THEN** no `git checkout`, `git switch`, or branch-creation command SHALL be executed in the user repo working tree + +### Requirement: Main-session worktree path convention + +The main-session worktree for change `<CHANGE_ID>` SHALL be created at `.specflow/worktrees/<CHANGE_ID>/main/` relative to the user's repository root. The path SHALL NOT be configurable in this phase. Subagent worktrees for the same change SHALL live as siblings under `.specflow/worktrees/<CHANGE_ID>/<RUN_ID>/<BUNDLE_ID>/`, so the parent directory `.specflow/worktrees/<CHANGE_ID>/` is the single root for all worktrees belonging to that change. + +#### Scenario: Main-session worktree is created at the conventional path + +- **WHEN** the main agent creates a main-session worktree for change `<CHANGE_ID>` +- **THEN** the worktree SHALL be located at `.specflow/worktrees/<CHANGE_ID>/main/` + +#### Scenario: All change-scoped worktrees share a parent + +- **WHEN** change `<CHANGE_ID>` has both a main-session worktree and one or more subagent worktrees +- **THEN** every such worktree SHALL be a direct or nested child of `.specflow/worktrees/<CHANGE_ID>/` + +### Requirement: Main-session worktree base commit and branch + +The main-session worktree SHALL be created from the user repository's current `HEAD` at the moment `prepare-change` first runs for the change. The branch checked out inside the worktree SHALL be named exactly `<CHANGE_ID>` (preserving `change_name == branch_name`). The branch SHALL be created via `git worktree add -b <CHANGE_ID> <path> HEAD` so the new branch exists only inside the worktree. + +The main agent SHALL persist the resolved base commit SHA in run-state as `base_commit` together with the branch name the user was on at creation time (`base_branch`, used later for PR base resolution). + +#### Scenario: base_commit equals user-repo HEAD at creation + +- **WHEN** the user repo HEAD is `<sha>` at `prepare-change` time +- **THEN** the main-session worktree's base commit SHALL equal `<sha>` +- **AND** run-state SHALL persist `base_commit = <sha>` + +#### Scenario: Worktree branch is named after the change + +- **WHEN** the main agent creates the main-session worktree for `<CHANGE_ID>` +- **THEN** the branch checked out inside that worktree SHALL be `<CHANGE_ID>` +- **AND** the user-repo working tree SHALL NOT have `<CHANGE_ID>` checked out + +#### Scenario: base_branch is recorded for PR base resolution + +- **WHEN** the user is on branch `<USER_BRANCH>` at `prepare-change` time +- **THEN** run-state SHALL persist `base_branch = <USER_BRANCH>` + +### Requirement: Main-session worktree reuse policy + +If `.specflow/worktrees/<CHANGE_ID>/main/` already exists AND `git worktree list` shows it registered as the worktree for branch `<CHANGE_ID>`, the main agent SHALL reuse the existing worktree and branch as-is. The main agent SHALL NOT recreate the worktree, SHALL NOT delete its contents, and SHALL NOT touch any uncommitted state inside it. + +#### Scenario: Existing main-session worktree is reused + +- **WHEN** `prepare-change` runs and the main-session worktree already exists for `<CHANGE_ID>` +- **AND** the worktree is registered in `git worktree list` with branch `<CHANGE_ID>` +- **THEN** the main agent SHALL reuse the existing worktree +- **AND** uncommitted state inside the worktree SHALL be preserved + +### Requirement: Conflict fail-fast on existing branch or stale worktree + +If a local branch named `<CHANGE_ID>` already exists but is NOT tied to `.specflow/worktrees/<CHANGE_ID>/main/`, OR if any registered worktree is bound to branch `<CHANGE_ID>` at any path other than the conventional one, OR if `.specflow/worktrees/<CHANGE_ID>/main/` exists as a non-worktree directory, the main agent SHALL fail-fast `prepare-change`. The error message SHALL name the offending branch / path / worktree and instruct the user to manually resolve by renaming the branch, pruning the stale worktree, or selecting a different `change_id`. + +The main agent SHALL NOT silently delete, prune, or overwrite the conflicting state. + +#### Scenario: Pre-existing branch with no matching worktree triggers fail-fast + +- **WHEN** local branch `<CHANGE_ID>` exists +- **AND** no worktree at `.specflow/worktrees/<CHANGE_ID>/main/` is registered for it +- **THEN** `prepare-change` SHALL exit non-zero with an actionable message +- **AND** the user repo SHALL be untouched + +#### Scenario: Worktree registered at a non-conventional path triggers fail-fast + +- **WHEN** branch `<CHANGE_ID>` is registered as a worktree at a path other than `.specflow/worktrees/<CHANGE_ID>/main/` +- **THEN** `prepare-change` SHALL fail-fast with the offending worktree path in the message + +#### Scenario: Non-worktree directory at the conventional path triggers fail-fast + +- **WHEN** `.specflow/worktrees/<CHANGE_ID>/main/` exists as a regular directory not registered as a git worktree +- **THEN** `prepare-change` SHALL fail-fast and SHALL NOT delete the directory + +### Requirement: All main-session commands operate inside the worktree + +After `prepare-change` returns, every specflow command that performs work for the change (including `/specflow.design`, `/specflow.apply`, `/specflow.review_design`, `/specflow.review_apply`, `/specflow.fix_design`, `/specflow.fix_apply`, `/specflow.approve`, `/specflow.reject`, and `/specflow.archive`) SHALL resolve its working directory from `worktree_path` in run-state and SHALL execute git operations and file edits inside the main-session worktree. + +#### Scenario: Phase commands execute inside the main-session worktree + +- **WHEN** `/specflow.apply` runs for change `<CHANGE_ID>` +- **THEN** all git operations and file edits SHALL target `.specflow/worktrees/<CHANGE_ID>/main/` + +#### Scenario: User repo is read-only to main-session phase commands + +- **WHEN** any main-session phase command executes +- **THEN** it SHALL NOT modify files, branches, or git refs inside the user repo working tree (outside `.specflow/worktrees/`) + +### Requirement: Approve push and PR base resolution + +`/specflow.approve` SHALL execute `git push -u origin <CHANGE_ID>` from inside the main-session worktree. The user repo SHALL NOT be used as the push source, and SHALL NOT receive cherry-picks or merges from the worktree. + +The PR's base branch SHALL be resolved from run-state's `base_branch`. If `base_branch` has a known upstream remote tracking ref, the PR base SHALL be the corresponding remote branch (e.g., `origin/<base_branch>`); otherwise the PR base SHALL fall back to the repository's default branch. + +#### Scenario: Push originates from the main-session worktree + +- **WHEN** `/specflow.approve` runs for change `<CHANGE_ID>` +- **THEN** `git push -u origin <CHANGE_ID>` SHALL be executed with `cwd = .specflow/worktrees/<CHANGE_ID>/main/` +- **AND** no push SHALL be issued from the user repo + +#### Scenario: PR base branch comes from base_branch + +- **WHEN** the user started `/specflow` from branch `feature/X` +- **AND** `feature/X` is recorded in run-state as `base_branch` +- **THEN** the PR created by `/specflow.approve` SHALL target `feature/X` as its base +- **AND** SHALL NOT default to `main` unless `feature/X` is the default branch + +#### Scenario: PR base falls back to default branch when base_branch has no upstream + +- **WHEN** `base_branch` has no upstream tracking ref +- **THEN** the PR base SHALL fall back to the repository's default branch + +### Requirement: Cleanup is gated on clean and complete terminal state + +`/specflow.approve`, `/specflow.archive`, and `/specflow.reject` SHALL remove `.specflow/worktrees/<CHANGE_ID>/` (every registered worktree via `git worktree remove`, then the parent directory) **only when both** of the following hold at the moment of terminal phase entry: + +1. The terminal action itself succeeded fully (e.g., `approve` pushed and created the PR; `archive` archived the change; `reject` reset all artifacts). No partial-failure state SHALL be recorded for the run. +2. Every worktree under `.specflow/worktrees/<CHANGE_ID>/` is clean (`git -C <wt> status --porcelain` returns empty output). + +If either condition fails, cleanup SHALL be deferred. The run SHALL still enter its terminal phase, but a `cleanup_pending` marker SHALL be recorded in run-state, and the user-facing CLI output SHALL surface the dirty paths and/or partial-failure cause and instruct the user how to resolve manually. + +#### Scenario: Successful approve cleans up worktrees + +- **WHEN** `/specflow.approve` succeeds end-to-end +- **AND** every worktree under `.specflow/worktrees/<CHANGE_ID>/` is clean +- **THEN** every such worktree SHALL be removed via `git worktree remove` +- **AND** `.specflow/worktrees/<CHANGE_ID>/` SHALL be deleted +- **AND** run-state SHALL NOT have a `cleanup_pending` marker + +#### Scenario: Dirty worktree blocks cleanup + +- **WHEN** `/specflow.approve` succeeds but the main-session worktree has uncommitted changes +- **THEN** cleanup SHALL be skipped +- **AND** run-state SHALL record `cleanup_pending = true` +- **AND** the CLI output SHALL list the dirty paths + +#### Scenario: Partial approve failure blocks cleanup + +- **WHEN** `/specflow.approve` pushes successfully but PR creation fails +- **THEN** cleanup SHALL be skipped +- **AND** run-state SHALL record `cleanup_pending = true` with the partial-failure cause + +### Requirement: No legacy mode coexistence + +This capability SHALL NOT introduce a `legacy_mode` flag, dual-path code, or in-process migration of pre-existing run-state. Run-state records that still satisfy `worktree_path == repo_path` SHALL be treated as legacy artifacts that must be drained (approved or rejected) before this change is merged. After the change lands, encountering such a record SHALL cause `prepare-change` to refuse to proceed with an explicit error. + +#### Scenario: Legacy run-state is rejected by prepare-change + +- **WHEN** `prepare-change` resumes a run whose persisted run-state has `worktree_path == repo_path` +- **THEN** it SHALL exit non-zero with a message asking the user to finish or reject the legacy change +- **AND** SHALL NOT silently fall back to branch-checkout behavior + diff --git a/openspec/specs/workflow-run-state/spec.md b/openspec/specs/workflow-run-state/spec.md index f88a9ea..b1caa8f 100644 --- a/openspec/specs/workflow-run-state/spec.md +++ b/openspec/specs/workflow-run-state/spec.md @@ -8,7 +8,6 @@ CLI used by `specflow`. Related specs: - `workflow-observation-events`: every run-state transition emits corresponding observation events; the event stream remains consistent with the snapshot readable via the run-state CLI. - `workflow-gate-semantics`: gate lifecycle events are part of the observation stream. - ## Requirements ### Requirement: The workflow machine defines the authoritative phase graph @@ -66,9 +65,11 @@ precondition inputs and a `LocalRunState` adapter seed, and (3) persisting the returned `CoreRunState & LocalRunState` value via `await RunArtifactStore.write()`. The persisted run state SHALL be identical in shape and content to the pre-existing -`run.json` layout. The run_id generation SHALL continue to use -`run-store-ops.generateRunId(store, changeId)`, invoked from the -wiring layer. +`run.json` layout, **extended with three new local-adapter fields +`base_commit`, `base_branch`, and `cleanup_pending`** (see the +field-partition requirements below). The run_id generation SHALL +continue to use `run-store-ops.generateRunId(store, changeId)`, +invoked from the wiring layer. #### Scenario: Change runs require an existing local proposal artifact @@ -89,14 +90,19 @@ wiring layer. - **THEN** the wiring layer SHALL construct the `LocalRunState` slice from `WorkspaceContext.projectIdentity()`, `projectDisplayName()`, `projectRoot()`, `branchName()`, - `worktreePath()`, and the literal `null` for - `last_summary_path` + `worktreePath()`, **`baseCommit()`**, **`baseBranch()`**, the + literal `null` for `last_summary_path`, and the literal `false` + for `cleanup_pending` - **AND** it SHALL pass the slice as the adapter seed to `startChangeRun<LocalRunState>` - **AND** the persisted `run.json` SHALL include `run_id`, `change_name`, `project_id`, `repo_name`, `repo_path`, - `branch_name`, `worktree_path`, `agents`, `allowed_events`, + `branch_name`, `worktree_path`, `base_commit`, `base_branch`, + `cleanup_pending`, `agents`, `allowed_events`, `created_at`, and `updated_at` +- **AND** when the change uses a main-session worktree, + `worktree_path` SHALL equal `.specflow/worktrees/<CHANGE_ID>/main/` + and SHALL NOT equal `repo_path` #### Scenario: Synthetic runs bypass change-directory lookup @@ -561,14 +567,15 @@ The run-state type system SHALL expose three named types in `src/types/contracts `created_at`, `updated_at`, `previous_run_id`, and `run_kind`. - `LocalRunState` — the run-state fields owned by the local filesystem adapter only: `project_id`, `repo_name`, `repo_path`, - `branch_name`, `worktree_path`, and `last_summary_path`. + `branch_name`, `worktree_path`, `base_commit`, `base_branch`, + `cleanup_pending`, and `last_summary_path`. - `RunState` — the pre-existing compatibility alias, defined as `CoreRunState & LocalRunState`. Every consumer that imports `RunState` today SHALL keep compiling without modification. The field membership of `CoreRunState` and `LocalRunState` SHALL be disjoint, and their union SHALL equal the field set of `RunState`. No -field SHALL be added or removed from `RunState` by this partition. +field SHALL be added to `CoreRunState` or removed from `RunState` by this partition. #### Scenario: CoreRunState exposes the runtime-agnostic fields @@ -585,8 +592,8 @@ field SHALL be added or removed from `RunState` by this partition. - **WHEN** the `LocalRunState` type from `src/types/contracts.ts` is inspected - **THEN** its keys SHALL be exactly `project_id`, `repo_name`, - `repo_path`, `branch_name`, `worktree_path`, and - `last_summary_path` + `repo_path`, `branch_name`, `worktree_path`, `base_commit`, + `base_branch`, `cleanup_pending`, and `last_summary_path` - **AND** the type SHALL NOT expose any core runtime field #### Scenario: RunState remains the intersection alias @@ -753,7 +760,8 @@ Core runtime commands SHALL be pure transition functions. Production modules und - **WHEN** any file matching `src/core/**/*.ts` is inspected - **THEN** it SHALL NOT contain any of the `LocalRunState` keys (`project_id:`, `repo_name:`, `repo_path:`, `branch_name:`, - `worktree_path:`, `last_summary_path:`) used as an object + `worktree_path:`, `base_commit:`, `base_branch:`, + `cleanup_pending:`, `last_summary_path:`) used as an object property key ### Requirement: Workflow core commands share an adapter-parameterized signature @@ -917,3 +925,36 @@ shape remains governed by the existing requirements in this specification. - **AND** reconciliation SHALL be handled by a separate change, not by silently editing the partition in this specification +### Requirement: Legacy run-state with worktree_path == repo_path is rejected + +`specflow-prepare-change` SHALL refuse to load any persisted run-state record where `worktree_path` equals `repo_path`. Such records correspond to the pre-`main-session-worktree` branch-checkout layout that this change replaces. The CLI SHALL surface a clear error message instructing the user to drain (approve or reject) the legacy change before proceeding, and SHALL NOT auto-migrate, auto-rewrite, or silently upgrade the record. + +This requirement does not apply to synthetic runs (`run_kind = "synthetic"`), which never carry a `repo_path`/`worktree_path` divergence by design. + +#### Scenario: Legacy record is rejected + +- **WHEN** `specflow-prepare-change` reads a persisted run-state where `worktree_path == repo_path` and `run_kind != "synthetic"` +- **THEN** it SHALL exit non-zero with a message asking the user to drain the legacy run +- **AND** SHALL NOT modify the persisted record +- **AND** SHALL NOT proceed to materialize artifacts for that change + +#### Scenario: New-layout record is accepted + +- **WHEN** the persisted run-state has `worktree_path = .specflow/worktrees/<CHANGE_ID>/main/` distinct from `repo_path` +- **THEN** `specflow-prepare-change` SHALL proceed normally + +### Requirement: cleanup_pending tracks deferred terminal cleanup + +The `cleanup_pending` field on `LocalRunState` SHALL be a boolean (default `false`) that records whether terminal-state worktree cleanup has been deferred for the current run. The `main-session-worktree` capability SHALL set `cleanup_pending = true` when a terminal phase is reached but cleanup gating fails (dirty worktree or partial-success state). Every other writer SHALL preserve the field unchanged. + +#### Scenario: Default is false + +- **WHEN** `specflow-run start` initializes a new run +- **THEN** the persisted `cleanup_pending` SHALL be `false` + +#### Scenario: Deferred cleanup sets the flag + +- **WHEN** a terminal phase command (approve/archive/reject) succeeds but cleanup gating fails +- **THEN** the wiring layer SHALL persist `cleanup_pending = true` +- **AND** subsequent reads of the run SHALL observe the deferred flag + diff --git a/src/bin/specflow-challenge-proposal.ts b/src/bin/specflow-challenge-proposal.ts index 4dc0d8a..8124271 100644 --- a/src/bin/specflow-challenge-proposal.ts +++ b/src/bin/specflow-challenge-proposal.ts @@ -23,6 +23,7 @@ import { validateChangeFromStore, } from "../lib/review-runtime.js"; import { findLatestRun } from "../lib/run-store-ops.js"; +import { resolveChangeRootForRun } from "../lib/worktree-resolver.js"; import type { ChallengeResult } from "../types/contracts.js"; import type { ReviewFindingSnapshot } from "../types/gate-records.js"; @@ -70,6 +71,7 @@ async function buildChallengePrompt( async function runChallenge( runtimeRoot: string, projectRoot: string, + changeRoot: string, changeStore: ChangeArtifactStore, changeId: string, agent: ReviewAgentName, @@ -89,7 +91,7 @@ async function runChallenge( const prompt = await buildChallengePrompt(runtimeRoot, changeStore, changeId); const agentResult = callReviewAgent<ChallengePayload>( agent, - projectRoot, + changeRoot, prompt, ); @@ -235,7 +237,7 @@ function issueChallengeGateOrFail( async function main(): Promise<void> { const projectRoot = ensureGitRepo(); loadConfigEnv(projectRoot); - const changeStore = createLocalFsChangeArtifactStore(projectRoot); + const runStore = createLocalFsRunArtifactStore(projectRoot); const runtimeRoot = moduleRepoRoot(import.meta.url); const [subcommand = "", ...args] = process.argv.slice(2); const agent = resolveReviewAgent(parseReviewAgentFlag(args)); @@ -256,16 +258,23 @@ async function main(): Promise<void> { // Auto-discover run_id when --run-id is not provided. if (!runId) { - const runStore = createLocalFsRunArtifactStore(projectRoot); const latest = await findLatestRun(runStore, changeId); if (latest) { runId = latest.run_id; } } + const changeRoot = await resolveChangeRootForRun( + runStore, + runId, + projectRoot, + ); + const changeStore = createLocalFsChangeArtifactStore(changeRoot); + const result = await runChallenge( runtimeRoot, projectRoot, + changeRoot, changeStore, changeId, agent, diff --git a/src/bin/specflow-generate-task-graph.ts b/src/bin/specflow-generate-task-graph.ts index 0800b92..87f8ac9 100644 --- a/src/bin/specflow-generate-task-graph.ts +++ b/src/bin/specflow-generate-task-graph.ts @@ -6,15 +6,18 @@ import { resolve } from "node:path"; import { ChangeArtifactType, changeRef } from "../lib/artifact-types.js"; import { tryGit } from "../lib/git.js"; import { createLocalFsChangeArtifactStore } from "../lib/local-fs-change-artifact-store.js"; +import { createLocalFsRunArtifactStore } from "../lib/local-fs-run-artifact-store.js"; import { callReviewAgent, loadConfigEnv, resolveReviewAgent, } from "../lib/review-runtime.js"; +import { findLatestRun } from "../lib/run-store-ops.js"; import { withSizeScore } from "../lib/task-planner/enrich.js"; import { renderTasksMd } from "../lib/task-planner/render.js"; import { validateTaskGraph } from "../lib/task-planner/schema.js"; import type { TaskGraph } from "../lib/task-planner/types.js"; +import { resolveChangeRootForRun } from "../lib/worktree-resolver.js"; function die(message: string): never { process.stderr.write(`${message}\n`); @@ -121,14 +124,24 @@ async function main(): Promise<void> { const projectRoot = ensureGitRepo(); loadConfigEnv(projectRoot); - const store = createLocalFsChangeArtifactStore(projectRoot); + // Resolve the change-artifact root from run-state so artifacts are + // read/written in the main-session worktree, not the user repo. + const runStore = createLocalFsRunArtifactStore(projectRoot); + const latestRun = await findLatestRun(runStore, changeId); + const changeRoot = await resolveChangeRootForRun( + runStore, + latestRun?.run_id, + projectRoot, + ); + + const store = createLocalFsChangeArtifactStore(changeRoot); const designRef = changeRef(changeId, ChangeArtifactType.Design); if (!(await store.exists(designRef))) { die(`design.md not found for change '${changeId}'`); } const designContent = await store.read(designRef); - const specNames = getSpecNames(projectRoot); + const specNames = getSpecNames(changeRoot); const agent = resolveReviewAgent(); process.stderr.write("Generating task graph from design.md...\n"); @@ -140,7 +153,7 @@ async function main(): Promise<void> { ? buildPrompt(designContent, changeId, specNames) : `${buildPrompt(designContent, changeId, specNames)}\n\nPrevious attempt failed validation:\n${lastErrors.join("\n")}\nFix the issues and try again.`; - const result = callReviewAgent<TaskGraph>(agent, projectRoot, prompt); + const result = callReviewAgent<TaskGraph>(agent, changeRoot, prompt); if (!result.ok || !result.payload) { lastErrors = [`Agent returned non-JSON or empty response`]; diff --git a/src/bin/specflow-prepare-change.ts b/src/bin/specflow-prepare-change.ts index fea2db1..7126a33 100644 --- a/src/bin/specflow-prepare-change.ts +++ b/src/bin/specflow-prepare-change.ts @@ -1,4 +1,4 @@ -import { existsSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import type { @@ -115,27 +115,153 @@ async function ensureChangeExists( } } -function ensureBranch(root: string, changeId: string): void { - const current = gitString(["branch", "--show-current"], root); - if (current === changeId) { - return; +interface MainSessionWorktree { + readonly path: string; + readonly baseCommit: string; + readonly baseBranch: string | null; +} + +interface WorktreeListEntry { + readonly worktree: string; + readonly branch: string | null; +} + +function parseWorktreeList(porcelain: string): readonly WorktreeListEntry[] { + const entries: WorktreeListEntry[] = []; + let current: { worktree?: string; branch?: string | null } = {}; + for (const rawLine of porcelain.split("\n")) { + const line = rawLine.trim(); + if (line === "") { + if (current.worktree !== undefined) { + entries.push({ + worktree: current.worktree, + branch: current.branch ?? null, + }); + } + current = {}; + continue; + } + if (line.startsWith("worktree ")) { + current.worktree = line.slice("worktree ".length); + } else if (line.startsWith("branch ")) { + const ref = line.slice("branch ".length); + current.branch = ref.startsWith("refs/heads/") + ? ref.slice("refs/heads/".length) + : ref; + } else if (line === "detached") { + current.branch = null; + } + } + if (current.worktree !== undefined) { + entries.push({ + worktree: current.worktree, + branch: current.branch ?? null, + }); } - const existing = git( + return entries; +} + +function relativeWorktreePath(changeId: string): string { + return `.specflow/worktrees/${changeId}/main`; +} + +function ensureMainSessionWorktree( + root: string, + changeId: string, +): MainSessionWorktree { + const conventionalRel = relativeWorktreePath(changeId); + const conventionalAbs = resolve(root, conventionalRel); + + const listResult = git(["worktree", "list", "--porcelain"], root); + if (listResult.status !== 0) { + fail( + listResult.stderr || + listResult.stdout || + "git worktree list --porcelain failed", + ); + } + const worktrees = parseWorktreeList(listResult.stdout); + + const existingAtConventional = worktrees.find( + (entry) => entry.worktree === conventionalAbs, + ); + const existingForBranch = worktrees.find( + (entry) => entry.branch === changeId, + ); + + if (existingAtConventional && existingAtConventional.branch === changeId) { + // Reuse: registered worktree at conventional path tied to <CHANGE_ID>. + const headSha = gitString( + ["-C", conventionalAbs, "rev-parse", "HEAD"], + root, + ); + return { + path: conventionalAbs, + baseCommit: headSha, + baseBranch: null, // unknown on reuse; preserved if previously persisted + }; + } + + if (existingAtConventional && existingAtConventional.branch !== changeId) { + fail( + `Error: ${conventionalRel} is registered as a worktree for branch '${existingAtConventional.branch ?? "(detached)"}', not '${changeId}'.\n` + + `Resolve manually: 'git worktree remove ${conventionalRel}' or pick a different change_id, then re-run /specflow.`, + ); + } + + if (existingForBranch && existingForBranch.worktree !== conventionalAbs) { + fail( + `Error: branch '${changeId}' is already checked out as a worktree at '${existingForBranch.worktree}', not the conventional '${conventionalRel}'.\n` + + `Resolve manually: 'git worktree remove ${existingForBranch.worktree}', then re-run /specflow.`, + ); + } + + if (existsSync(conventionalAbs)) { + // Path occupied by a non-worktree directory. + fail( + `Error: ${conventionalRel} exists but is not a registered git worktree.\n` + + `Resolve manually (inspect contents, then 'rm -rf ${conventionalRel}' if safe), then re-run /specflow.`, + ); + } + + const branchExists = git( ["rev-parse", "--verify", `refs/heads/${changeId}`], root, ); - const checkoutArgs = - existing.status === 0 - ? ["checkout", changeId] - : ["checkout", "-b", changeId]; - const checkout = git(checkoutArgs, root); - if (checkout.status !== 0) { + if (branchExists.status === 0) { + // Branch exists in registry but not bound to a worktree at the conventional path. + fail( + `Error: branch '${changeId}' exists locally but no worktree at '${conventionalRel}' is registered for it.\n` + + `Resolve manually: 'git branch -D ${changeId}' (if discardable) or 'git worktree add -b ${changeId}-recovery <path>', then re-run /specflow.`, + ); + } + + // Create the main-session worktree from the user repo's current HEAD. + const headSha = gitString(["rev-parse", "HEAD"], root); + const currentBranch = gitString(["branch", "--show-current"], root); + const baseBranch = currentBranch === "" ? null : currentBranch; + + mkdirSync(resolve(root, ".specflow/worktrees", changeId), { + recursive: true, + }); + + const addResult = git( + ["worktree", "add", "-b", changeId, conventionalAbs, headSha], + root, + ); + if (addResult.status !== 0) { fail( - checkout.stderr || - checkout.stdout || - `git ${checkoutArgs.join(" ")} failed`, + addResult.stderr || + addResult.stdout || + `git worktree add -b ${changeId} ${conventionalRel} ${headSha} failed`, ); } + + return { + path: conventionalAbs, + baseCommit: headSha, + baseBranch, + }; } function loadProposalInstructions( @@ -215,6 +341,7 @@ async function ensureRunStarted( source: ProposalSource, agentMain: string | null, agentReview: string | null, + worktree: MainSessionWorktree, ): Promise<RunState> { const runStore = createLocalFsRunArtifactStore(root); const existing = await findExistingNonTerminalRun(runStore, changeId); @@ -223,7 +350,18 @@ async function ensureRunStarted( } const tempSourceFile = writeInternalTempSourceFile(source); try { - const args = ["start", changeId, "--source-file", tempSourceFile]; + const args = [ + "start", + changeId, + "--source-file", + tempSourceFile, + "--worktree-path", + worktree.path, + "--base-commit", + worktree.baseCommit, + "--base-branch", + worktree.baseBranch ?? "", + ]; if (agentMain) { args.push("--agent-main", agentMain); } @@ -413,15 +551,47 @@ async function main(): Promise<void> { } } - const changeStore = createLocalFsChangeArtifactStore(root); - await ensureChangeExists(root, changeId, changeStore); - ensureBranch(root, changeId); - await ensureProposalDraft(root, changeId, source, changeStore); + // Legacy guard: if a non-terminal run already exists for this change but + // was persisted under the legacy branch-checkout layout + // (worktree_path == repo_path), refuse to proceed without auto-migrating. + const runStoreForGuard = createLocalFsRunArtifactStore(root); + const legacy = await findExistingNonTerminalRun(runStoreForGuard, changeId); + if ( + legacy && + legacy.run_kind !== "synthetic" && + legacy.worktree_path === legacy.repo_path + ) { + fail( + `Error: change '${changeId}' has a legacy in-flight run (${legacy.run_id}) where worktree_path equals repo_path.\n` + + `Drain the legacy run via /specflow.approve or /specflow.reject before re-invoking /specflow for this change.\n` + + `(Run-state path: ${legacy.repo_path}/.specflow/runs/${legacy.run_id}/run.json)`, + ); + } + + // Create or reuse the dedicated main-session worktree. The user repo's + // working tree is NOT modified; the change branch lives only inside the + // worktree. + const worktree = ensureMainSessionWorktree(root, changeId); + + // Change artifacts (proposal/specs/design/tasks) live inside the worktree + // because they are committed to the change branch. Run-state stays at the + // user repo's .specflow/runs/ for global discoverability — that's why we + // invoke specflow-run with cwd = root and explicit --worktree-path. + const changeStore = createLocalFsChangeArtifactStore(worktree.path); + await ensureChangeExists(worktree.path, changeId, changeStore); + await ensureProposalDraft(worktree.path, changeId, source, changeStore); const state = ensureProposalPhase( root, changeId, - await ensureRunStarted(root, changeId, source, agentMain, agentReview), + await ensureRunStarted( + root, + changeId, + source, + agentMain, + agentReview, + worktree, + ), ); printSchemaJson("run-state", state); } diff --git a/src/bin/specflow-review-apply.ts b/src/bin/specflow-review-apply.ts index 228f2e5..918d53a 100644 --- a/src/bin/specflow-review-apply.ts +++ b/src/bin/specflow-review-apply.ts @@ -65,6 +65,7 @@ import { } from "../lib/review-runtime.js"; import { findLatestRun } from "../lib/run-store-ops.js"; import type { WorkspaceContext } from "../lib/workspace-context.js"; +import { resolveChangeRootForRun } from "../lib/worktree-resolver.js"; import { type AutofixProgressSnapshot, buildStartingSnapshot, @@ -229,6 +230,7 @@ function resultFromLedger( async function runReviewPipeline( runtimeRoot: string, projectRoot: string, + changeRoot: string, ctx: WorkspaceContext, changeStore: ChangeArtifactStore, action: string, @@ -290,7 +292,7 @@ async function runReviewPipeline( }); void callMainAgent( mainAgent, - projectRoot, + changeRoot, await buildFixPrompt(changeStore, changeId, diff, fixFindings), ); const rerun = diffFilter(ctx); @@ -329,7 +331,7 @@ async function runReviewPipeline( } const reviewResult = callReviewAgent<Record<string, unknown>>( reviewAgent, - projectRoot, + changeRoot, prompt, ); @@ -418,7 +420,7 @@ async function runReviewPipeline( changeId, ledger, "apply", - projectRoot, + changeRoot, ); } @@ -465,6 +467,7 @@ async function runReviewPipeline( async function runAutofixLoop( runtimeRoot: string, projectRoot: string, + changeRoot: string, ctx: WorkspaceContext, changeStore: ChangeArtifactStore, changeId: string, @@ -599,7 +602,7 @@ async function runAutofixLoop( }); const fixResult = callMainAgent( mainAgent, - projectRoot, + changeRoot, await buildFixPrompt( changeStore, changeId, @@ -622,6 +625,7 @@ async function runAutofixLoop( const reviewResult = await runReviewPipeline( runtimeRoot, projectRoot, + changeRoot, ctx, changeStore, "fix_review", @@ -824,6 +828,7 @@ async function runAutofixLoop( async function cmdReview( runtimeRoot: string, projectRoot: string, + changeRoot: string, ctx: WorkspaceContext, changeStore: ChangeArtifactStore, args: readonly string[], @@ -861,6 +866,7 @@ async function cmdReview( return await runReviewPipeline( runtimeRoot, projectRoot, + changeRoot, ctx, changeStore, "review", @@ -876,6 +882,7 @@ async function cmdReview( async function cmdFixReview( runtimeRoot: string, projectRoot: string, + changeRoot: string, ctx: WorkspaceContext, changeStore: ChangeArtifactStore, args: readonly string[], @@ -916,6 +923,7 @@ async function cmdFixReview( return await runReviewPipeline( runtimeRoot, projectRoot, + changeRoot, ctx, changeStore, "fix_review", @@ -931,6 +939,7 @@ async function cmdFixReview( async function cmdAutofixLoop( runtimeRoot: string, projectRoot: string, + changeRoot: string, ctx: WorkspaceContext, changeStore: ChangeArtifactStore, args: readonly string[], @@ -978,6 +987,7 @@ async function cmdAutofixLoop( return await runAutofixLoop( runtimeRoot, projectRoot, + changeRoot, ctx, changeStore, changeId, @@ -1086,7 +1096,6 @@ async function main(): Promise<void> { } const projectRoot = ctx.projectRoot(); loadConfigEnv(projectRoot); - const changeStore = createLocalFsChangeArtifactStore(projectRoot); const runtimeRoot = moduleRepoRoot(import.meta.url); const [subcommand = "", ...args] = process.argv.slice(2); const reviewAgent = resolveReviewAgent(parseReviewAgentFlag(args)); @@ -1095,10 +1104,10 @@ async function main(): Promise<void> { // Auto-discover run_id from the change_id when --run-id is not provided. // This ensures review_decision gates are always emitted when a run exists. + const runStore = createLocalFsRunArtifactStore(projectRoot); if (!runId) { const changeId = args.find((a) => !a.startsWith("-")); if (changeId) { - const runStore = createLocalFsRunArtifactStore(projectRoot); const latest = await findLatestRun(runStore, changeId); if (latest) { runId = latest.run_id; @@ -1106,12 +1115,37 @@ async function main(): Promise<void> { } } + // Resolve the change-artifact root from run-state. In worktree mode this + // is the main-session worktree; for synthetic runs or missing runs it + // falls back to the repo root. The helper also enforces the legacy guard + // (worktree_path == repo_path for non-synthetic runs → fail fast). + const changeRoot = await resolveChangeRootForRun( + runStore, + runId, + projectRoot, + ); + const changeStore = createLocalFsChangeArtifactStore(changeRoot); + + // When the change root differs from the project root (worktree mode), + // create a workspace context rooted in the worktree so diffs are read + // from the correct working tree. + if (changeRoot !== projectRoot) { + try { + ctx = createLocalWorkspaceContext(changeRoot, changeRoot); + } catch { + die( + `Error: worktree at '${changeRoot}' is not a valid git working tree.`, + ); + } + } + let result: ReviewResult; switch (subcommand) { case "review": result = await cmdReview( runtimeRoot, projectRoot, + changeRoot, ctx, changeStore, args, @@ -1124,6 +1158,7 @@ async function main(): Promise<void> { result = await cmdFixReview( runtimeRoot, projectRoot, + changeRoot, ctx, changeStore, args, @@ -1136,6 +1171,7 @@ async function main(): Promise<void> { result = await cmdAutofixLoop( runtimeRoot, projectRoot, + changeRoot, ctx, changeStore, args, diff --git a/src/bin/specflow-review-design.ts b/src/bin/specflow-review-design.ts index a2c4c93..283e128 100644 --- a/src/bin/specflow-review-design.ts +++ b/src/bin/specflow-review-design.ts @@ -72,6 +72,7 @@ import { validateChangeFromStore, } from "../lib/review-runtime.js"; import { findLatestRun } from "../lib/run-store-ops.js"; +import { resolveChangeRootForRun } from "../lib/worktree-resolver.js"; import { type AutofixProgressSnapshot, buildStartingSnapshot, @@ -300,6 +301,7 @@ async function buildTaskPlannableFindings( async function runReviewPipeline( runtimeRoot: string, projectRoot: string, + changeRoot: string, changeStore: ChangeArtifactStore, action: string, changeId: string, @@ -339,7 +341,7 @@ async function runReviewPipeline( } const reviewAgentResult = callReviewAgent<Record<string, unknown>>( reviewAgent, - projectRoot, + changeRoot, prompt, ); @@ -463,7 +465,7 @@ async function runReviewPipeline( changeId, ledger, "design", - projectRoot, + changeRoot, ); } @@ -510,6 +512,7 @@ async function runReviewPipeline( async function runAutofixLoop( runtimeRoot: string, projectRoot: string, + changeRoot: string, changeStore: ChangeArtifactStore, changeId: string, maxRounds: number, @@ -667,7 +670,7 @@ async function runAutofixLoop( (await artifactHash(changeStore, changeId, ChangeArtifactType.Tasks)); const fixResult = callMainAgent( mainAgent, - projectRoot, + changeRoot, await buildFixPrompt( runtimeRoot, changeStore, @@ -705,6 +708,7 @@ async function runAutofixLoop( const reviewResult = await runReviewPipeline( runtimeRoot, projectRoot, + changeRoot, changeStore, "fix_review", changeId, @@ -895,6 +899,7 @@ async function runAutofixLoop( async function cmdReview( runtimeRoot: string, projectRoot: string, + changeRoot: string, changeStore: ChangeArtifactStore, args: readonly string[], reviewAgent: ReviewAgentName, @@ -925,6 +930,7 @@ async function cmdReview( return await runReviewPipeline( runtimeRoot, projectRoot, + changeRoot, changeStore, "review", changeId, @@ -937,6 +943,7 @@ async function cmdReview( async function cmdFixReview( runtimeRoot: string, projectRoot: string, + changeRoot: string, changeStore: ChangeArtifactStore, args: readonly string[], reviewAgent: ReviewAgentName, @@ -967,6 +974,7 @@ async function cmdFixReview( return await runReviewPipeline( runtimeRoot, projectRoot, + changeRoot, changeStore, "fix_review", changeId, @@ -979,6 +987,7 @@ async function cmdFixReview( async function cmdAutofixLoop( runtimeRoot: string, projectRoot: string, + changeRoot: string, changeStore: ChangeArtifactStore, args: readonly string[], reviewAgent: ReviewAgentName, @@ -1008,6 +1017,7 @@ async function cmdAutofixLoop( return await runAutofixLoop( runtimeRoot, projectRoot, + changeRoot, changeStore, changeId, rounds, @@ -1114,7 +1124,7 @@ async function main(): Promise<void> { const projectRoot = ensureGitRepo(); loadConfigEnv(projectRoot); const runtimeRoot = moduleRepoRoot(import.meta.url); - const changeStore = createLocalFsChangeArtifactStore(projectRoot); + const runStore = createLocalFsRunArtifactStore(projectRoot); const [subcommand, ...args] = process.argv.slice(2); const reviewAgent = resolveReviewAgent(parseReviewAgentFlag(args)); const mainAgent = resolveMainAgent(); @@ -1136,7 +1146,6 @@ Subcommands: if (!runId) { const changeId = args.find((a) => !a.startsWith("-")); if (changeId) { - const runStore = createLocalFsRunArtifactStore(projectRoot); const latest = await findLatestRun(runStore, changeId); if (latest) { runId = latest.run_id; @@ -1144,12 +1153,20 @@ Subcommands: } } + const changeRoot = await resolveChangeRootForRun( + runStore, + runId, + projectRoot, + ); + const changeStore = createLocalFsChangeArtifactStore(changeRoot); + let result: ReviewResult; switch (subcommand) { case "review": result = await cmdReview( runtimeRoot, projectRoot, + changeRoot, changeStore, args, reviewAgent, @@ -1160,6 +1177,7 @@ Subcommands: result = await cmdFixReview( runtimeRoot, projectRoot, + changeRoot, changeStore, args, reviewAgent, @@ -1170,6 +1188,7 @@ Subcommands: result = await cmdAutofixLoop( runtimeRoot, projectRoot, + changeRoot, changeStore, args, reviewAgent, diff --git a/src/bin/specflow-run.ts b/src/bin/specflow-run.ts index 0bea021..d3890e4 100644 --- a/src/bin/specflow-run.ts +++ b/src/bin/specflow-run.ts @@ -62,6 +62,7 @@ import { generateRunId, readRunState, } from "../lib/run-store-ops.js"; +import { evaluateAndCleanup } from "../lib/terminal-worktree-cleanup.js"; import type { WorkspaceContext } from "../lib/workspace-context.js"; import type { LocalRunState, @@ -257,14 +258,21 @@ function loadWorkflow(path: string): WorkflowDefinition { * Build the local adapter seed from the workspace context. This is the one * place in the process where LocalRunState fields are produced. */ -function buildLocalSeed(ctx: WorkspaceContext): LocalRunState { +function buildLocalSeed( + ctx: WorkspaceContext, + overrides: Partial<LocalRunState> = {}, +): LocalRunState { return { project_id: ctx.projectIdentity(), repo_name: ctx.projectDisplayName(), repo_path: ctx.projectRoot(), branch_name: ctx.branchName() ?? "HEAD", worktree_path: ctx.worktreePath(), + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, + ...overrides, }; } @@ -437,6 +445,9 @@ interface StartArgs { readonly agentReview: string; readonly runKind: RunKind; readonly retry: boolean; + readonly worktreePath: string; + readonly baseCommit: string; + readonly baseBranch: string | null; } function parseStartArgs(args: readonly string[]): StartArgs { @@ -446,6 +457,9 @@ function parseStartArgs(args: readonly string[]): StartArgs { let agentReview = "codex"; let runKind: RunKind = "change"; let retry = false; + let worktreePath = ""; + let baseCommit = ""; + let baseBranch: string | null = null; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -476,6 +490,22 @@ function parseStartArgs(args: readonly string[]): StartArgs { retry = true; continue; } + if (arg === "--worktree-path") { + worktreePath = + args[++index] ?? fail("Error: --worktree-path requires a value"); + continue; + } + if (arg === "--base-commit") { + baseCommit = + args[++index] ?? fail("Error: --base-commit requires a value"); + continue; + } + if (arg === "--base-branch") { + const value = + args[++index] ?? fail("Error: --base-branch requires a value"); + baseBranch = value === "" ? null : value; + continue; + } if (arg.startsWith("-")) { fail(`Error: unknown option '${arg}'`); } @@ -487,7 +517,7 @@ function parseStartArgs(args: readonly string[]): StartArgs { if (!positional) { fail( - "Usage: specflow-run start <change_id|run_id> [--source-file <path>] [--agent-main <name>] [--agent-review <name>] [--run-kind <change|synthetic>] [--retry]", + "Usage: specflow-run start <change_id|run_id> [--source-file <path>] [--agent-main <name>] [--agent-review <name>] [--run-kind <change|synthetic>] [--retry] [--worktree-path <path>] [--base-commit <sha>] [--base-branch <ref>]", ); } @@ -498,30 +528,42 @@ function parseStartArgs(args: readonly string[]): StartArgs { agentReview, runKind, retry, + worktreePath, + baseCommit, + baseBranch, }; } // --- Subcommand glue ------------------------------------------------------ async function runStart(args: readonly string[]): Promise<never> { + const parsed = parseStartArgs(args); let ctx: WorkspaceContext; try { - ctx = createLocalWorkspaceContext(); + ctx = createLocalWorkspaceContext( + undefined, + parsed.worktreePath || undefined, + ); } catch { process.stdout.write('{"status":"error","error":"not_in_git_repo"}\n'); process.exit(1); } const root = ctx.projectRoot(); const runStore = createLocalFsRunArtifactStore(root); - const changeStore = createLocalFsChangeArtifactStore(root); + // Change artifacts (proposal/specs/design/tasks) live inside the + // main-session worktree when one is in use; run-state stays under the user + // repo for global discoverability. + const changeStore = createLocalFsChangeArtifactStore(ctx.worktreePath()); const gates = createLocalFsGateRecordStore(root); - const parsed = parseStartArgs(args); const source: SourceMetadata | null = parsed.sourceFile ? readSourceMetadataFile(parsed.sourceFile) : null; const agents = { main: parsed.agentMain, review: parsed.agentReview }; - const adapterSeed = buildLocalSeed(ctx); + const adapterSeed = buildLocalSeed(ctx, { + base_commit: parsed.baseCommit, + base_branch: parsed.baseBranch, + }); const ts = nowIso(); if (parsed.runKind === "synthetic") { @@ -730,12 +772,13 @@ async function runUpdateField(args: readonly string[]): Promise<never> { if (!runId || !field || value === undefined) { fail("Usage: specflow-run update-field <run_id> <field> <value>"); } - // Wiring-layer whitelist: the only updatable field in the local-FS - // adapter today is `last_summary_path`. External adapters may expose a - // different set via their own wiring. - if (field !== "last_summary_path") { + // Wiring-layer whitelist: only explicitly listed fields may be mutated + // via the CLI. External adapters may expose a different set via their + // own wiring. + const UPDATABLE_FIELDS = new Set(["last_summary_path", "cleanup_pending"]); + if (!UPDATABLE_FIELDS.has(field)) { process.stderr.write( - `Error: field '${field}' is not updatable. Allowed fields: last_summary_path\n`, + `Error: field '${field}' is not updatable. Allowed fields: ${[...UPDATABLE_FIELDS].join(", ")}\n`, ); process.exit(1); } @@ -752,10 +795,22 @@ async function runUpdateField(args: readonly string[]): Promise<never> { runStore, runId, )) as RunStateOf<LocalRunState>; + // Coerce CLI string values to the field's expected type. + let coerced: string | boolean = value; + if (field === "cleanup_pending") { + if (value === "true") coerced = true; + else if (value === "false") coerced = false; + else { + process.stderr.write( + `Error: cleanup_pending must be 'true' or 'false', got '${value}'\n`, + ); + process.exit(1); + } + } const result = updateRunField<LocalRunState>({ state, field, - value, + value: coerced, nowIso: nowIso(), }); if (!result.ok) renderResult("run-state", result); @@ -784,6 +839,60 @@ async function runGetField(args: readonly string[]): Promise<never> { process.exit(0); } +async function runCleanupWorktrees(args: readonly string[]): Promise<never> { + const [runId] = args; + if (!runId) { + fail("Usage: specflow-run cleanup-worktrees <run_id>"); + } + const root = projectRoot(); + const runStore = createLocalFsRunArtifactStore(root); + if (!(await runStore.exists(runRef(runId)))) { + process.stderr.write( + `Error: run '${runId}' not found. No state file at ${runId}/run.json\n`, + ); + process.exit(1); + } + const state = await readRunState(runStore, runId); + const changeId = state.change_name; + if (!changeId) { + process.stderr.write( + `Error: run '${runId}' has no change_name; cannot locate worktrees.\n`, + ); + process.exit(1); + } + const decision = evaluateAndCleanup({ + repoPath: state.repo_path, + changeId, + successFull: true, + }); + if (decision.action === "defer") { + // Persist cleanup_pending = true. + const updated: RunState = { + ...state, + cleanup_pending: true, + updated_at: nowIso(), + }; + await persistState(runStore, runId, updated); + process.stdout.write( + `${JSON.stringify({ action: "defer", reasons: decision.reasons })}\n`, + ); + process.exit(1); + } + // Cleanup succeeded — clear cleanup_pending if it was set. + if (state.cleanup_pending) { + const updated: RunState = { + ...state, + cleanup_pending: false, + updated_at: nowIso(), + }; + await persistState(runStore, runId, updated); + } + process.stdout.write( + `${JSON.stringify({ action: "remove", removed: decision.removed })}\n`, + ); + process.exit(0); +} + async function main(): Promise<void> { const [subcommand, ...args] = process.argv.slice(2); @@ -809,14 +918,17 @@ async function main(): Promise<void> { case "get-field": await runGetField(args); break; + case "cleanup-worktrees": + await runCleanupWorktrees(args); + break; case undefined: fail( - "Usage: specflow-run <start|advance|suspend|resume|status|update-field|get-field> [args...]", + "Usage: specflow-run <start|advance|suspend|resume|status|update-field|get-field|cleanup-worktrees> [args...]", ); break; default: fail( - `Error: unknown subcommand '${subcommand}'. Use: start, advance, suspend, resume, status, update-field, get-field`, + `Error: unknown subcommand '${subcommand}'. Use: start, advance, suspend, resume, status, update-field, get-field, cleanup-worktrees`, ); } } diff --git a/src/bin/specflow-watch.ts b/src/bin/specflow-watch.ts index ed1610a..b9ce5c3 100644 --- a/src/bin/specflow-watch.ts +++ b/src/bin/specflow-watch.ts @@ -109,13 +109,23 @@ function repoRootOrCwd(cwd: string): string { interface ModelInputs { readonly run: RunState; readonly runRead: ArtifactReadResult<RunState>; + /** + * Where `.specflow/runs/<run_id>/...` lives. Always the user repository + * root regardless of worktree mode. + */ readonly repoRoot: string; + /** + * Where `openspec/changes/<change_name>/...` lives. In worktree mode this + * is `state.worktree_path`; in legacy mode it is the same as `repoRoot`. + */ + readonly changeArtifactRoot: string; readonly branch: string; readonly eventTail: number; } function buildModel(inputs: ModelInputs): WatchModel { - const { run, runRead, repoRoot, branch, eventTail } = inputs; + const { run, runRead, repoRoot, changeArtifactRoot, branch, eventTail } = + inputs; const phase = run.current_phase; const status = run.status; const manualFixKind = deriveManualFixKind(run); @@ -147,7 +157,11 @@ function buildModel(inputs: ModelInputs): WatchModel { const ledgerRead = activeLedgerFamily === null || !changeForLedger ? ({ kind: "absent" } as const) - : readReviewLedgerFile(repoRoot, changeForLedger, activeLedgerFamily); + : readReviewLedgerFile( + changeArtifactRoot, + changeForLedger, + activeLedgerFamily, + ); const digest = buildDigestState({ activeFamily: activeLedgerFamily, ledgerRead, @@ -156,7 +170,7 @@ function buildModel(inputs: ModelInputs): WatchModel { // Task graph const changeForGraph = run.change_name ?? ""; const graphRead = changeForGraph - ? readTaskGraphFile(repoRoot, changeForGraph) + ? readTaskGraphFile(changeArtifactRoot, changeForGraph) : ({ kind: "absent" } as const); const taskGraphView = buildTaskGraphView( graphRead.kind === "ok" @@ -174,7 +188,7 @@ function buildModel(inputs: ModelInputs): WatchModel { const eventsView = buildEventsView(events); // Approval summary - const approvalRead = readApprovalSummary(repoRoot, run); + const approvalRead = readApprovalSummary(changeArtifactRoot, run); const approvalSummary = buildApprovalSummary(approvalRead); return { @@ -297,13 +311,42 @@ function main(): void { const effectiveBranch = branch ?? (run.branch_name || ""); let current: RunState = runRead.value; + // Legacy guard: refuse to watch a non-synthetic run whose worktree_path + // equals repo_path — that is the old branch-checkout layout which must be + // drained before this version is used. + if ( + current.run_kind !== "synthetic" && + current.worktree_path === current.repo_path + ) { + failHard( + `specflow-watch: run '${current.run_id}' uses the legacy layout (worktree_path == repo_path). ` + + `Drain via /specflow.approve or /specflow.reject before using specflow-watch.`, + ); + } + function rebuild(): WatchModel { const latest = readRunStateFile(repoRoot, run.run_id); - if (latest.kind === "ok") current = latest.value; + if (latest.kind === "ok") { + current = latest.value; + // Re-check the legacy guard on every refresh so a run-state file + // that reverts to the legacy layout (e.g. manual edit) is caught + // immediately rather than silently executing against repo_path. + if ( + current.run_kind !== "synthetic" && + current.worktree_path === current.repo_path + ) { + failHard( + `specflow-watch: run '${current.run_id}' reverted to legacy layout (worktree_path == repo_path). ` + + `Drain via /specflow.approve or /specflow.reject before using specflow-watch.`, + ); + } + } + const changeArtifactRoot = current.worktree_path; return buildModel({ run: current, runRead: latest, repoRoot, + changeArtifactRoot, branch: effectiveBranch, eventTail: DEFAULT_EVENT_TAIL, }); diff --git a/src/core/update-field.ts b/src/core/update-field.ts index 64f8605..0d75070 100644 --- a/src/core/update-field.ts +++ b/src/core/update-field.ts @@ -13,7 +13,7 @@ import { ok } from "./types.js"; export interface UpdateFieldInput<TAdapter> { readonly state: RunStateOf<TAdapter>; readonly field: string; - readonly value: string; + readonly value: string | boolean; readonly nowIso: string; } diff --git a/src/lib/apply-dispatcher/orchestrate.ts b/src/lib/apply-dispatcher/orchestrate.ts index 989d69f..95c3f9b 100644 --- a/src/lib/apply-dispatcher/orchestrate.ts +++ b/src/lib/apply-dispatcher/orchestrate.ts @@ -222,7 +222,31 @@ export async function runDispatchedWindow( if (!runId) { throw new Error( - "runDispatchedWindow: worktreeRuntime provided without runId; runId is required for .specflow/worktrees/<runId>/ layout.", + "runDispatchedWindow: worktreeRuntime provided without runId; runId is required for .specflow/worktrees/<changeId>/<runId>/ layout.", + ); + } + + // Worktree mode is mandatory: the runtime MUST identify the change so the + // per-change parent (`.specflow/worktrees/<changeId>/`) can host the + // subagent worktrees as siblings of the main-session worktree, and MUST + // declare the main-session worktree path that integration patches into. + // Callers SHALL populate both fields from run-state (`change_name` and + // `worktree_path`). + if (!worktreeRuntime.changeId) { + throw new Error( + "runDispatchedWindow: worktreeRuntime.changeId is required (subagent worktrees must live under .specflow/worktrees/<changeId>/<runId>/<bundleId>/). " + + "Populate it from run-state's `change_name`.", + ); + } + if (!worktreeRuntime.mainWorkspacePath) { + throw new Error( + "runDispatchedWindow: worktreeRuntime.mainWorkspacePath is required (the main-session worktree is the integration target, not the user repo root). " + + "Populate it from run-state's `worktree_path`.", + ); + } + if (worktreeRuntime.changeId !== changeId) { + throw new Error( + `runDispatchedWindow: worktreeRuntime.changeId ('${worktreeRuntime.changeId}') must equal args.changeId ('${changeId}').`, ); } diff --git a/src/lib/apply-worktree/worktree.ts b/src/lib/apply-worktree/worktree.ts index f9608ae..9a88c4d 100644 --- a/src/lib/apply-worktree/worktree.ts +++ b/src/lib/apply-worktree/worktree.ts @@ -18,6 +18,7 @@ export interface WorktreeHandle { readonly baseSha: string; readonly runId: string; readonly bundleId: string; + readonly changeId: string; } export interface GitCommandResult { @@ -52,11 +53,31 @@ export interface WorktreeFs { export interface WorktreeRuntime { readonly repoRoot: string; + /** + * The main-session worktree path used as the integration target. Points at + * `.specflow/worktrees/<changeId>/main/`. All "main workspace" operations + * (HEAD rev-parse, diff materialization, patch import) execute with this + * path as cwd. + */ + readonly mainWorkspacePath: string; + /** + * The change identifier used to namespace subagent worktrees under + * `.specflow/worktrees/<changeId>/<runId>/<bundleId>/`. + */ + readonly changeId: string; readonly git?: GitRunner; readonly applyPatch?: GitApplier; readonly fs?: WorktreeFs; } +/** + * Resolve the path that operations targeting the "main workspace" should use. + * Always the main-session worktree — there is no legacy fallback. + */ +function mainWorkspaceOf(runtime: WorktreeRuntime): string { + return runtime.mainWorkspacePath; +} + const defaultFs: WorktreeFs = { existsSync: (p) => fs.existsSync(p), mkdirSync: (p, opts) => { @@ -122,8 +143,16 @@ export function worktreePath( repoRoot: string, runId: string, bundleId: string, + changeId: string, ): string { - return path.join(repoRoot, ".specflow", "worktrees", runId, bundleId); + return path.join( + repoRoot, + ".specflow", + "worktrees", + changeId, + runId, + bundleId, + ); } /** @@ -140,7 +169,12 @@ export function createWorktree( runId: string, bundleId: string, ): WorktreeHandle { - const wtPath = worktreePath(runtime.repoRoot, runId, bundleId); + const wtPath = worktreePath( + runtime.repoRoot, + runId, + bundleId, + runtime.changeId, + ); const fsApi: WorktreeFs = runtime.fs ?? defaultFs; if (fsApi.existsSync(wtPath)) { @@ -164,10 +198,11 @@ export function createWorktree( // commit between `rev-parse` and `worktree add` is surfaced rather than silently // recorded as a stale base. `git worktree add HEAD` resolves HEAD at invocation // time, so the worktree's base will match this SHA. + const mainWs = mainWorkspaceOf(runtime); const headResult = runGit( runtime, ["rev-parse", "HEAD"], - runtime.repoRoot, + mainWs, "rev-parse HEAD", ); const baseSha = headResult.stdout.toString("utf8").trim(); @@ -178,7 +213,7 @@ export function createWorktree( runGit( runtime, ["worktree", "add", "--detach", wtPath, baseSha], - runtime.repoRoot, + mainWs, "worktree add", ); } catch (err) { @@ -222,7 +257,13 @@ export function createWorktree( // changes (which already exist there), causing patch conflicts. const effectiveBase = snapshotMaterializedState(runtime, wtPath, baseSha); - return { path: wtPath, baseSha: effectiveBase, runId, bundleId }; + return { + path: wtPath, + baseSha: effectiveBase, + runId, + bundleId, + changeId: runtime.changeId, + }; } catch (err) { // Best-effort removal of the just-added worktree. If removal itself // fails (e.g., filesystem issue), the original setup error is still @@ -260,6 +301,7 @@ function materializeWorkspaceState( wtPath: string, ): void { const runner = runtime.git ?? defaultGit; + const mainWs = mainWorkspaceOf(runtime); // R4-F10: Enumerate untracked files BEFORE diffing so they can be included // in the workspace snapshot via intent-to-add. Exclude `.specflow/` since @@ -267,7 +309,7 @@ function materializeWorkspaceState( // config, etc.) — it is never user content and should not be materialized. const untrackedResult = runner( ["ls-files", "--others", "--exclude-standard", "--exclude=.specflow/"], - runtime.repoRoot, + mainWs, ); const untrackedFiles = untrackedResult.status === 0 @@ -280,10 +322,7 @@ function materializeWorkspaceState( // If there are untracked files, mark them intent-to-add so `git diff HEAD` // includes them as new-file diffs. We reset them after diffing. if (untrackedFiles.length > 0) { - const addResult = runner( - ["add", "-N", "--", ...untrackedFiles], - runtime.repoRoot, - ); + const addResult = runner(["add", "-N", "--", ...untrackedFiles], mainWs); if (addResult.status !== 0) { throw new WorktreeError( `git add -N (intent-to-add) failed while materializing workspace state: ${addResult.stderr.trim() || `exit ${addResult.status}`}`, @@ -298,15 +337,12 @@ function materializeWorkspaceState( let diffResult: GitCommandResult; try { - diffResult = runner( - ["diff", "--binary", "--find-renames", "HEAD"], - runtime.repoRoot, - ); + diffResult = runner(["diff", "--binary", "--find-renames", "HEAD"], mainWs); } finally { // Always reset intent-to-add files so the main workspace index is // undisturbed, even if `git diff` throws or fails. if (untrackedFiles.length > 0) { - runner(["reset", "--", ...untrackedFiles], runtime.repoRoot); + runner(["reset", "--", ...untrackedFiles], mainWs); } } @@ -517,7 +553,7 @@ export function importPatch(runtime: WorktreeRuntime, patch: Buffer): void { return; } const applier = runtime.applyPatch ?? defaultApplier; - const result = applier(patch, runtime.repoRoot); + const result = applier(patch, mainWorkspaceOf(runtime)); if (result.status !== 0) { throw new WorktreeError( `git apply --binary failed: ${result.stderr.trim() || `exit ${result.status}`}`, @@ -552,7 +588,7 @@ export function removeWorktree( runGit( runtime, ["worktree", "remove", "--force", handle.path], - runtime.repoRoot, + mainWorkspaceOf(runtime), "worktree remove", ); } diff --git a/src/lib/local-workspace-context.ts b/src/lib/local-workspace-context.ts index 1db4223..fdd80f7 100644 --- a/src/lib/local-workspace-context.ts +++ b/src/lib/local-workspace-context.ts @@ -170,14 +170,16 @@ export function filterLocalWorkspaceDiff( class LocalWorkspaceContext implements WorkspaceContext { private readonly resolvedRoot: string; + private readonly resolvedWorktree: string; - constructor(workspacePath?: string) { + constructor(workspacePath?: string, worktreePath?: string) { const cwd = workspacePath ?? process.cwd(); const result = tryExec("git", ["rev-parse", "--show-toplevel"], cwd); if (result.status !== 0) { throw new Error(`not a git repository: ${cwd}`); } this.resolvedRoot = result.stdout.trim(); + this.resolvedWorktree = worktreePath ?? this.resolvedRoot; } readonly projectRoot = (): string => { @@ -188,7 +190,7 @@ class LocalWorkspaceContext implements WorkspaceContext { const result = tryExec( "git", ["rev-parse", "--abbrev-ref", "HEAD"], - this.resolvedRoot, + this.resolvedWorktree, ); if (result.status !== 0) { return null; @@ -216,7 +218,7 @@ class LocalWorkspaceContext implements WorkspaceContext { }; readonly worktreePath = (): string => { - return this.resolvedRoot; + return this.resolvedWorktree; }; readonly filteredDiff = ( @@ -231,6 +233,7 @@ class LocalWorkspaceContext implements WorkspaceContext { export function createLocalWorkspaceContext( workspacePath?: string, + worktreePath?: string, ): WorkspaceContext { - return new LocalWorkspaceContext(workspacePath); + return new LocalWorkspaceContext(workspacePath, worktreePath); } diff --git a/src/lib/run-store-ops.ts b/src/lib/run-store-ops.ts index 1b030ef..41952b8 100644 --- a/src/lib/run-store-ops.ts +++ b/src/lib/run-store-ops.ts @@ -40,7 +40,12 @@ export function extractSequence( } /** - * Read a run state from the store with backward-compatible fallback for missing fields. + * Read a run state from the store. Tolerates missing `base_commit` / + * `base_branch` / `cleanup_pending` fields by substituting defaults + * (empty / null / false), so read-only inspection paths can load legacy + * records without error. Mutation entry points (notably `prepare-change`) + * implement their own legacy guard to refuse `worktree_path == repo_path` + * resumes for non-synthetic runs. */ export async function readRunState( store: RunArtifactStore, @@ -67,11 +72,19 @@ export async function readRunState( } else { status = "active"; } + const baseCommit = typeof raw.base_commit === "string" ? raw.base_commit : ""; + const baseBranch = + raw.base_branch === undefined ? null : (raw.base_branch as string | null); + const cleanupPending = + typeof raw.cleanup_pending === "boolean" ? raw.cleanup_pending : false; return { ...(raw as unknown as RunState), run_id: resolvedRunId, previous_run_id: previousRunId, status, + base_commit: baseCommit, + base_branch: baseBranch, + cleanup_pending: cleanupPending, } as RunState; } diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 98e4a0a..a76210b 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -797,6 +797,15 @@ function runStateValidator( stringValidator(record.repo_path, `${path}.repo_path`, errors); stringValidator(record.branch_name, `${path}.branch_name`, errors); stringValidator(record.worktree_path, `${path}.worktree_path`, errors); + if (record.base_commit !== undefined) { + stringValidator(record.base_commit, `${path}.base_commit`, errors); + } + if (record.base_branch !== undefined) { + nullOrStringValidator(record.base_branch, `${path}.base_branch`, errors); + } + if (record.cleanup_pending !== undefined) { + booleanValidator(record.cleanup_pending, `${path}.cleanup_pending`, errors); + } runAgentsValidator(record.agents, `${path}.agents`, errors); nullOrStringValidator( record.last_summary_path, diff --git a/src/lib/terminal-worktree-cleanup.ts b/src/lib/terminal-worktree-cleanup.ts new file mode 100644 index 0000000..7704084 --- /dev/null +++ b/src/lib/terminal-worktree-cleanup.ts @@ -0,0 +1,178 @@ +// Terminal-phase cleanup gate for the main-session-worktree subtree. +// +// Approve / archive / reject all funnel through `evaluateCleanup` to decide +// whether to remove `.specflow/worktrees/<CHANGE_ID>/` or to defer cleanup +// (recording `cleanup_pending = true` in run-state). The gate is the AND of +// two predicates: +// +// 1. success_full — the terminal action itself succeeded fully. +// 2. tree_clean — every registered worktree under the per-change parent +// reports an empty `git status --porcelain`. +// +// Cleanup itself only ever invokes `git worktree remove` (without --force) +// followed by `rm -rf` of the parent. If `git worktree remove` fails, the +// cleanup is reported as deferred — never silently force-removed (per D5/D8). + +import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tryExec } from "./process.js"; + +export interface CleanupReason { + readonly kind: "dirty_worktree" | "partial_failure" | "unknown_path"; + readonly worktreePath?: string; + readonly detail: string; +} + +export type CleanupDecision = + | { + readonly action: "remove"; + readonly removed: readonly string[]; + } + | { + readonly action: "defer"; + readonly reasons: readonly CleanupReason[]; + }; + +export interface CleanupInputs { + readonly repoPath: string; + readonly changeId: string; + readonly successFull: boolean; + readonly partialFailureCause?: string | null; +} + +function perChangeWorktreesDir(repoPath: string, changeId: string): string { + return resolve(repoPath, ".specflow/worktrees", changeId); +} + +interface WorktreeEntry { + readonly path: string; + readonly registered: boolean; +} + +function listWorktreesUnder(parent: string): readonly WorktreeEntry[] { + if (!existsSync(parent)) return []; + const entries: WorktreeEntry[] = []; + const visit = (p: string) => { + // A worktree is identified by the presence of a `.git` file (not a + // directory) — git worktree add creates a gitfile that points back to + // the main `.git/worktrees/<name>/` registry entry. + const gitPath = join(p, ".git"); + if (existsSync(gitPath)) { + entries.push({ path: p, registered: statSync(gitPath).isFile() }); + return; // do not descend further + } + // Otherwise, descend into subdirectories looking for nested worktrees. + try { + for (const child of readdirSync(p)) { + const full = join(p, child); + if (statSync(full).isDirectory()) { + visit(full); + } + } + } catch { + // Best-effort; ignore unreadable subtrees. + } + }; + visit(parent); + return entries; +} + +function isCleanWorktree(path: string): { + clean: boolean; + porcelainOutput: string; +} { + const result = tryExec("git", ["status", "--porcelain"], path); + if (result.status !== 0) { + return { clean: false, porcelainOutput: result.stderr || result.stdout }; + } + return { + clean: result.stdout.trim() === "", + porcelainOutput: result.stdout, + }; +} + +/** + * Evaluate the cleanup gate and execute cleanup if it passes. Returns a + * `CleanupDecision` describing the outcome. Does NOT mutate run-state — the + * caller is expected to persist `cleanup_pending` based on the decision. + */ +export function evaluateAndCleanup(inputs: CleanupInputs): CleanupDecision { + const parent = perChangeWorktreesDir(inputs.repoPath, inputs.changeId); + const reasons: CleanupReason[] = []; + + if (!inputs.successFull) { + reasons.push({ + kind: "partial_failure", + detail: + inputs.partialFailureCause ?? + "Terminal action did not complete fully; cleanup deferred until retry.", + }); + } + + const worktrees = listWorktreesUnder(parent); + + for (const wt of worktrees) { + const { clean, porcelainOutput } = isCleanWorktree(wt.path); + if (!clean) { + reasons.push({ + kind: "dirty_worktree", + worktreePath: wt.path, + detail: `Worktree has uncommitted changes:\n${porcelainOutput.trim()}`, + }); + } + } + + if (reasons.length > 0) { + return { action: "defer", reasons }; + } + + if (worktrees.length === 0) { + // Nothing to remove — parent absent or already cleaned. + if (existsSync(parent)) { + rmSync(parent, { recursive: true, force: true }); + } + return { action: "remove", removed: [] }; + } + + const removed: string[] = []; + for (const wt of worktrees) { + // Use non-force; if it fails we treat it as a deferred cleanup signal. + const result = tryExec( + "git", + ["worktree", "remove", wt.path], + inputs.repoPath, + ); + if (result.status !== 0) { + return { + action: "defer", + reasons: [ + { + kind: "dirty_worktree", + worktreePath: wt.path, + detail: `git worktree remove failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`, + }, + ], + }; + } + removed.push(wt.path); + } + + if (existsSync(parent)) { + try { + rmSync(parent, { recursive: true, force: true }); + } catch (err) { + return { + action: "defer", + reasons: [ + { + kind: "unknown_path", + worktreePath: parent, + detail: `Failed to remove parent directory: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + }; + } + } + + return { action: "remove", removed }; +} diff --git a/src/lib/worktree-resolver.ts b/src/lib/worktree-resolver.ts new file mode 100644 index 0000000..aa2e52b --- /dev/null +++ b/src/lib/worktree-resolver.ts @@ -0,0 +1,86 @@ +// Shared resolver for `state.worktree_path` from a `run_id`. +// +// Phase commands invoked by the user from anywhere must resolve their +// integration target — the main-session worktree — through run-state, NOT via +// `process.cwd()` or `git rev-parse --show-toplevel`. This resolver is the +// single sanctioned indirection point: feed it a run id (and a repo root for +// the run-store lookup) and it returns the worktree path. + +import type { RunState } from "../types/contracts.js"; +import type { RunArtifactStore } from "./artifact-store.js"; +import { runRef } from "./artifact-types.js"; +import { readRunState } from "./run-store-ops.js"; + +export interface WorktreeResolution { + readonly repoPath: string; + readonly worktreePath: string; + readonly branchName: string; + readonly baseCommit: string; + readonly baseBranch: string | null; + readonly cleanupPending: boolean; +} + +/** + * Read run-state for `runId` from `store` and return the worktree-mode path + * tuple. Throws if the run-state is unreadable or if the record is malformed + * (the store / parser surface those errors). + */ +export async function resolveWorktreeForRun( + store: RunArtifactStore, + runId: string, +): Promise<WorktreeResolution> { + const state = await readRunState(store, runId); + return { + repoPath: state.repo_path, + worktreePath: state.worktree_path, + branchName: state.branch_name, + baseCommit: state.base_commit, + baseBranch: state.base_branch, + cleanupPending: state.cleanup_pending, + }; +} + +/** + * Variant that does not throw when the run record is absent. Returns `null` + * for missing runs. Useful for read-only inspection commands that may be + * invoked against a run that has not yet been started. + */ +export async function resolveWorktreeForRunOrNull( + store: RunArtifactStore, + runId: string, +): Promise<WorktreeResolution | null> { + const exists = await store.exists(runRef(runId)); + if (!exists) return null; + return resolveWorktreeForRun(store, runId); +} + +/** + * Resolve the "change root" — the path where `openspec/changes/<CHANGE_ID>/` + * lives — from run-state. In worktree mode this is `state.worktree_path`; for + * synthetic runs or when no run exists, it falls back to `repoRoot`. + * + * Applies the legacy guard: if a non-synthetic run has `worktree_path == + * repo_path` (the old branch-checkout layout), this function throws instead of + * silently operating against the user's repo root. Callers must drain the + * legacy run via `/specflow.approve` or `/specflow.reject` before proceeding. + */ +export async function resolveChangeRootForRun( + store: RunArtifactStore, + runId: string | undefined, + repoRoot: string, +): Promise<string> { + if (!runId) return repoRoot; + const exists = await store.exists(runRef(runId)); + if (!exists) return repoRoot; + const state: RunState = await readRunState(store, runId); + if ( + (state as unknown as Record<string, unknown>).run_kind !== "synthetic" && + state.worktree_path === state.repo_path + ) { + throw new Error( + `Run '${runId}' uses the legacy layout (worktree_path == repo_path). ` + + `Drain via /specflow.approve or /specflow.reject before using this command.`, + ); + } + return state.worktree_path; +} diff --git a/src/tests/__snapshots__/specflow.approve.md.snap b/src/tests/__snapshots__/specflow.approve.md.snap index bbd8df9..8138fef 100644 --- a/src/tests/__snapshots__/specflow.approve.md.snap +++ b/src/tests/__snapshots__/specflow.approve.md.snap @@ -331,19 +331,52 @@ If the archive command fails (non-zero exit code): ## Push & Pull Request -1. 現在のブランチ名を取得: +All `git` and `gh` invocations in this section MUST run with `cwd = $WORKTREE_PATH` (the main-session worktree resolved from run-state via `specflow-run get-field "<RUN_ID>" worktree_path`). The user's repository working tree is NOT the push source. + +1. Resolve the worktree path and the change branch from run-state: ```bash - git branch --show-current + WORKTREE_PATH="$(specflow-run get-field "<RUN_ID>" worktree_path)" + CHANGE_BRANCH="$(specflow-run get-field "<RUN_ID>" branch_name)" + BASE_COMMIT="$(specflow-run get-field "<RUN_ID>" base_commit)" + BASE_BRANCH="$(specflow-run get-field "<RUN_ID>" base_branch)" ``` -2. リモートに同名ブランチで push: +2. Push the change branch from inside the worktree: ```bash - git push -u origin <branch-name> + git -C "$WORKTREE_PATH" push -u origin "$CHANGE_BRANCH" ``` -3. デフォルトブランチを取得: +3. Resolve the PR base. Try these strategies in order until one succeeds: + - **Strategy 1**: If `base_commit` is non-empty AND **exactly one** remote-tracking branch contains it, use that as the PR base. When 0 or 2+ remotes match, Strategy 1 abstains — silently picking an arbitrary match would target the wrong base. + - **Strategy 2**: If `base_branch` is non-empty and has an upstream tracking ref, strip the remote prefix and use that. + - **Strategy 3**: If `base_branch` is non-empty (the branch the user was on at prepare-change time), use it directly as the PR base. This handles local feature branches that have no upstream tracking. + - **Strategy 4**: Fall back to the repository's default branch. ```bash - gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name' + PR_BASE="" + # Strategy 1: resolve from base_commit ONLY when exactly one remote branch contains it. + if [ -n "$BASE_COMMIT" ]; then + CONTAINING_REFS="$(git -C "$WORKTREE_PATH" branch -r --contains "$BASE_COMMIT" 2>/dev/null | grep -v HEAD | sed 's|^ *origin/||' | sort -u)" + CONTAINING_COUNT="$(printf '%s\n' "$CONTAINING_REFS" | grep -c .)" + if [ "$CONTAINING_COUNT" = "1" ]; then + PR_BASE="$CONTAINING_REFS" + fi + fi + # Strategy 2: resolve from base_branch upstream + if [ -z "$PR_BASE" ] && [ -n "$BASE_BRANCH" ] && git -C "$WORKTREE_PATH" rev-parse --abbrev-ref "$BASE_BRANCH@{upstream}" >/dev/null 2>&1; then + UPSTREAM="$(git -C "$WORKTREE_PATH" rev-parse --abbrev-ref "$BASE_BRANCH@{upstream}")" + PR_BASE="${UPSTREAM#origin/}" + fi + # Strategy 3: use base_branch directly (local branch the user started from) + if [ -z "$PR_BASE" ] && [ -n "$BASE_BRANCH" ]; then + PR_BASE="$BASE_BRANCH" + fi + # Strategy 4: repository default branch — run gh from inside the worktree + # so it picks up the right remote without an explicit -R selector. (Passing + # a raw `https://`/`git@` URL via -R is not the documented OWNER/REPO form + # and can fail.) + if [ -z "$PR_BASE" ]; then + PR_BASE="$(cd "$WORKTREE_PATH" && gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name')" + fi ``` 4. PR のタイトルと本文を生成する: @@ -363,9 +396,9 @@ If the archive command fails (non-zero exit code): <proposal の Acceptance Criteria や実装内容を箇条書きで 3-5 行> ``` -5. `gh pr create` で PR を作成する: +5. PR を作成する。`gh pr create` も worktree 内で実行する: ```bash - gh pr create --title "<title>" --body "<body>" --base <default-branch> + (cd "$WORKTREE_PATH" && gh pr create --title "<title>" --body "<body>" --head "$CHANGE_BRANCH" --base "$PR_BASE") ``` 6. PR 作成後、PR の URL をユーザーに表示する。 @@ -378,11 +411,48 @@ If the archive command fails (non-zero exit code): fi ``` -If `ARCHIVE_SUCCESS = true`: - Report: "Implementation approved, committed, PR created: `<PR-URL>`, change archived." → **END**. +## Worktree Cleanup + + +After the terminal phase completes (approve, archive, or reject), evaluate cleanup of the `.specflow/worktrees/<CHANGE_ID>/` subtree. Cleanup is gated on TWO conditions: + +1. **success_full** — the terminal action itself completed without error. +2. **tree_clean** — every registered worktree under `.specflow/worktrees/<CHANGE_ID>/` has an empty `git status --porcelain`. + +If `ARCHIVE_SUCCESS = true` (terminal action succeeded), invoke the cleanup subcommand: + +```bash +specflow-run cleanup-worktrees "<RUN_ID>" +``` + +This command: +- Inspects every worktree under `.specflow/worktrees/<CHANGE_ID>/` for cleanliness. +- If all worktrees are clean: removes them (`git worktree remove`) and deletes the parent directory. Clears `cleanup_pending` in run-state. Exits 0. +- If any worktree is dirty or removal fails: sets `cleanup_pending = true` in run-state, outputs the deferred reasons as JSON, and exits 1. + +If `ARCHIVE_SUCCESS = false` (terminal action itself failed), skip the cleanup subcommand — cleanup is implicitly deferred: + +```bash +specflow-run update-field "<RUN_ID>" cleanup_pending true +``` + +Report the dirty paths or partial-failure cause to the user: +``` +⚠️ Worktree cleanup deferred. Reason: <dirty paths / partial failure cause> +Resolve manually, then remove via: specflow-run cleanup-worktrees "<RUN_ID>" +``` + +## Final Report + + +If `ARCHIVE_SUCCESS = true` AND cleanup succeeded: + Report: "Implementation approved, committed, PR created: `<PR-URL>`, change archived, worktrees cleaned up." → **END**. + +If `ARCHIVE_SUCCESS = true` AND cleanup deferred: + Report: "Implementation approved, committed, PR created: `<PR-URL>`, change archived. ⚠️ Worktree cleanup deferred — see details above." → **END**. If `ARCHIVE_SUCCESS = false`: - Report: "Implementation approved, committed, PR created: `<PR-URL>`. ⚠️ Archive failed — run `openspec archive -y "<CHANGE_ID>"` manually." → **END**. + Report: "Implementation approved, committed, PR created: `<PR-URL>`. ⚠️ Archive failed — run `openspec archive -y "<CHANGE_ID>"` manually. Worktree cleanup deferred." → **END**. ## Run State Hooks diff --git a/src/tests/__snapshots__/specflow.reject.md.snap b/src/tests/__snapshots__/specflow.reject.md.snap index 4b4d322..670b949 100644 --- a/src/tests/__snapshots__/specflow.reject.md.snap +++ b/src/tests/__snapshots__/specflow.reject.md.snap @@ -13,27 +13,67 @@ $ARGUMENTS 全変更を破棄します。 -1. 現在の変更状態を確認: +1. Resolve `RUN_ID`/`CHANGE_ID`/worktree path from run-state. Reject MUST run inside the main-session worktree, not the user repo. ```bash - git status --short + CHANGE_ID="$(specflow-run get-field "<RUN_ID>" change_name)" + WORKTREE_PATH="$(specflow-run get-field "<RUN_ID>" worktree_path)" + REPO_PATH="$(specflow-run get-field "<RUN_ID>" repo_path)" + WT_PARENT="$REPO_PATH/.specflow/worktrees/$CHANGE_ID" ``` -2. 変更ファイル一覧をユーザーに表示する。 +2. 現在の変更状態を確認 (worktree 内): + ```bash + git -C "$WORKTREE_PATH" status --short + ``` + +3. 変更ファイル一覧をユーザーに表示する。 + +4. 全変更を破棄 (worktree 内のみ。ユーザーリポは触らない): + ```bash + git -C "$WORKTREE_PATH" checkout -- . + git -C "$WORKTREE_PATH" clean -fd -- . ':(exclude)openspec' + ``` -3. 全変更を破棄: +5. 破棄後の状態を確認: ```bash - git checkout -- . - git clean -fd -- . ':(exclude)openspec' + git -C "$WORKTREE_PATH" status --short ``` - これにより: - - 変更されたファイルは元に戻る (`git checkout`) - - 新規作成されたファイルは削除される (`git clean`) - - `openspec/` 配下の新規ファイルは保持される +## Worktree Cleanup + + +After reject completes, evaluate cleanup of `.specflow/worktrees/<CHANGE_ID>/`. +Cleanup is gated on TWO conditions: + +1. **success_full** — reject ran without error (steps 4 above succeeded). +2. **tree_clean** — every registered worktree under `$WT_PARENT` reports an empty `git status --porcelain`. + +If step 4 succeeded (exit 0), invoke the cleanup subcommand: + +```bash +specflow-run cleanup-worktrees "<RUN_ID>" +``` + +This command: +- Inspects every worktree under `.specflow/worktrees/<CHANGE_ID>/` for cleanliness. +- If all worktrees are clean: removes them (`git worktree remove`) and deletes the parent directory. Clears `cleanup_pending`. Exits 0. +- If any worktree is dirty or removal fails: sets `cleanup_pending = true` in run-state, outputs deferred reasons as JSON, and exits 1. + +If step 4 failed, skip the cleanup subcommand — cleanup is implicitly deferred: + +```bash +specflow-run update-field "<RUN_ID>" cleanup_pending true +``` + +Surface the reason to the user: +``` +⚠️ Worktree cleanup deferred. Reason: <failure details> +Resolve manually, then retry: specflow-run cleanup-worktrees "<RUN_ID>" +``` -4. 破棄後の状態を確認: +Advance the run-state to its terminal reject phase regardless of cleanup deferral: ```bash - git status --short + specflow-run advance "<RUN_ID>" reject ``` Report: "Implementation rejected. All changes have been discarded." → **END**. diff --git a/src/tests/advance-records.test.ts b/src/tests/advance-records.test.ts index c5dfbc7..ebae718 100644 --- a/src/tests/advance-records.test.ts +++ b/src/tests/advance-records.test.ts @@ -38,6 +38,9 @@ function seed(): RunState { repo_path: "/tmp/test", branch_name: "main", worktree_path: "/tmp/test", + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, }; } diff --git a/src/tests/apply-dispatcher-orchestrate.test.ts b/src/tests/apply-dispatcher-orchestrate.test.ts index 8d16811..2ec8327 100644 --- a/src/tests/apply-dispatcher-orchestrate.test.ts +++ b/src/tests/apply-dispatcher-orchestrate.test.ts @@ -108,7 +108,10 @@ const NOOP_DIFF = Buffer.from( ); const NOOP_PRODUCED_ARTIFACTS = ["src/__noop__.ts"]; -function noopWorktreeRuntime(repoRoot: string): WorktreeRuntime { +function noopWorktreeRuntime( + repoRoot: string, + changeId = "orch-test", +): WorktreeRuntime { const noop: GitCommandResult = { status: 0, stdout: Buffer.alloc(0), @@ -121,6 +124,8 @@ function noopWorktreeRuntime(repoRoot: string): WorktreeRuntime { }); return { repoRoot, + mainWorkspacePath: repoRoot, + changeId, git: (args, cwd) => { if (args[0] === "rev-parse") return okBuf("0000000\n"); if (args[0] === "worktree") return noop; @@ -640,6 +645,7 @@ function wtFail(stderr: string, status = 1): GitCommandResult { */ function makeWorktreeRuntime(opts: { readonly repoRoot: string; + readonly changeId?: string; readonly diffFor: (bundleId: string) => string; readonly onApply?: (bundleId: string, patch: Buffer) => void; readonly applyResultFor?: (bundleId: string) => GitCommandResult; @@ -712,7 +718,14 @@ function makeWorktreeRuntime(opts: { }; return { - runtime: { repoRoot: opts.repoRoot, git, applyPatch, fs }, + runtime: { + repoRoot: opts.repoRoot, + mainWorkspacePath: opts.repoRoot, + changeId: opts.changeId ?? "orch-test", + git, + applyPatch, + fs, + }, calls, fs, }; @@ -764,7 +777,7 @@ test("runDispatchedWindow (worktree): success path creates worktree, applies pat calls.some((c) => c.args.includes("remove")), "git worktree remove must be invoked on success", ); - const wtPath = `${root}/.specflow/worktrees/run-1/a`; + const wtPath = `${root}/.specflow/worktrees/orch-test/run-1/a`; assert.ok(!fs._paths.has(wtPath), "worktree path must be cleaned up"); } finally { rmSync(root, { recursive: true, force: true }); @@ -813,7 +826,7 @@ test("runDispatchedWindow (worktree): subagent failure advances to subagent_fail !calls.some((c) => c.args.includes("remove")), "worktree must be retained on subagent_failed", ); - const wtPath = `${root}/.specflow/worktrees/run-1/a`; + const wtPath = `${root}/.specflow/worktrees/orch-test/run-1/a`; assert.ok(fs._paths.has(wtPath), "worktree path must persist on failure"); } finally { rmSync(root, { recursive: true, force: true }); @@ -870,7 +883,7 @@ test("runDispatchedWindow (worktree): integration rejection advances to integrat !calls.some((c) => c.args.includes("remove")), "worktree retained on integration_rejected", ); - const wtPath = `${root}/.specflow/worktrees/run-1/a`; + const wtPath = `${root}/.specflow/worktrees/orch-test/run-1/a`; assert.ok(fs._paths.has(wtPath)); } finally { rmSync(root, { recursive: true, force: true }); @@ -950,7 +963,7 @@ test("runDispatchedWindow (worktree): create failure on second bundle rolls back ); // Rollback: the worktree for 'a' must not be left on disk after 'b' fails. - const aPath = `${root}/.specflow/worktrees/run-1/a`; + const aPath = `${root}/.specflow/worktrees/orch-test/run-1/a`; assert.ok( !fs._paths.has(aPath), "first bundle's worktree must be cleaned up when a later bundle fails create", @@ -983,6 +996,8 @@ test("runDispatchedWindow (worktree): worktree remove failure surfaces a cleanup }); const runtime: WorktreeRuntime = { repoRoot: root, + mainWorkspacePath: root, + changeId, git: (args) => { if (args[0] === "rev-parse") return okBuf("0000000\n"); if (args[0] === "worktree" && args[1] === "add") return noop; diff --git a/src/tests/apply-worktree-helpers.test.ts b/src/tests/apply-worktree-helpers.test.ts index a290489..15c5041 100644 --- a/src/tests/apply-worktree-helpers.test.ts +++ b/src/tests/apply-worktree-helpers.test.ts @@ -57,9 +57,14 @@ function makeRuntime(opts: { readonly applyPatch?: GitApplier; readonly fs?: FakeFs; readonly repoRoot?: string; + readonly mainWorkspacePath?: string; + readonly changeId?: string; }): WorktreeRuntime { + const repoRoot = opts.repoRoot ?? "/repo"; return { - repoRoot: opts.repoRoot ?? "/repo", + repoRoot, + mainWorkspacePath: opts.mainWorkspacePath ?? repoRoot, + changeId: opts.changeId ?? "test-change", git: opts.git, applyPatch: opts.applyPatch, fs: opts.fs ?? makeFakeFs(), @@ -93,7 +98,10 @@ test("createWorktree: succeeds and records base SHA from HEAD when no workspace assert.equal(handle.bundleId, "bundle-a"); // No workspace changes → snapshot is a no-op → baseSha stays as HEAD. assert.equal(handle.baseSha, "abc123"); - assert.equal(handle.path, "/repo/.specflow/worktrees/run-1/bundle-a"); + assert.equal( + handle.path, + "/repo/.specflow/worktrees/test-change/run-1/bundle-a", + ); // HEAD was resolved BEFORE worktree add — guards against the recorded base // drifting from the actual worktree base if HEAD moves concurrently. @@ -102,13 +110,15 @@ test("createWorktree: succeeds and records base SHA from HEAD when no workspace assert.equal(addCall[0], "worktree"); assert.equal(addCall[1], "add"); assert.ok(addCall.includes("--detach")); - assert.ok(addCall.includes("/repo/.specflow/worktrees/run-1/bundle-a")); + assert.ok( + addCall.includes("/repo/.specflow/worktrees/test-change/run-1/bundle-a"), + ); assert.ok(addCall.includes("abc123")); }); test("createWorktree: refuses to run if target path already exists", () => { const runtime = makeRuntime({ - fs: makeFakeFs(["/repo/.specflow/worktrees/run-1/bundle-a"]), + fs: makeFakeFs(["/repo/.specflow/worktrees/test-change/run-1/bundle-a"]), git: () => { throw new Error("git should not be invoked when pre-check fails"); }, @@ -134,7 +144,9 @@ test("createWorktree: propagates git worktree add failure as WorktreeError with () => createWorktree(runtime, "run-1", "bundle-a"), (err) => err instanceof WorktreeError && - err.message.includes("/repo/.specflow/worktrees/run-1/bundle-a") && + err.message.includes( + "/repo/.specflow/worktrees/test-change/run-1/bundle-a", + ) && err.message.includes("fatal:"), ); }); @@ -369,7 +381,9 @@ test("createWorktree: self-cleans the just-added worktree when post-add setup fa "self-cleanup must invoke worktree remove", ); assert.ok( - removeCalls[0].includes("/repo/.specflow/worktrees/run-1/bundle-a"), + removeCalls[0].includes( + "/repo/.specflow/worktrees/test-change/run-1/bundle-a", + ), "self-cleanup must target the correct worktree path", ); }); @@ -443,10 +457,10 @@ test("createWorktree: self-cleanup failure does not mask the original error (R3- // --- worktreePath --- -test("worktreePath: produces the .specflow/worktrees/<run>/<bundle> convention", () => { +test("worktreePath: produces the .specflow/worktrees/<changeId>/<run>/<bundle> convention", () => { assert.equal( - worktreePath("/repo", "run-1", "bundle-a"), - "/repo/.specflow/worktrees/run-1/bundle-a", + worktreePath("/repo", "run-1", "bundle-a", "my-change"), + "/repo/.specflow/worktrees/my-change/run-1/bundle-a", ); }); @@ -455,10 +469,11 @@ test("worktreePath: produces the .specflow/worktrees/<run>/<bundle> convention", test("computeDiff: stages untracked files via add -N then runs git diff --binary --find-renames from the base SHA", () => { const invocations: Array<readonly string[]> = []; const handle: WorktreeHandle = { - path: "/repo/.specflow/worktrees/r/b", + path: "/repo/.specflow/worktrees/test-change/r/b", baseSha: "abc123", runId: "r", bundleId: "b", + changeId: "test-change", }; const runtime = makeRuntime({ git: (args, cwd) => { @@ -490,6 +505,7 @@ test("computeDiff: propagates git add -N failure as WorktreeError", () => { baseSha: "abc123", runId: "r", bundleId: "b", + changeId: "test-change", }; const runtime = makeRuntime({ git: (args) => { @@ -511,6 +527,7 @@ test("computeDiff: returns raw binary buffer so NUL bytes survive", () => { baseSha: "abc123", runId: "r", bundleId: "b", + changeId: "test-change", }; const binary = Buffer.concat([ Buffer.from("diff --git a/img.png b/img.png\n", "utf8"), @@ -580,10 +597,11 @@ test("importPatch: throws WorktreeError with apply operation on failure", () => test("removeWorktree: invokes git worktree remove --force to handle dirty trees", () => { const invocations: Array<readonly string[]> = []; const handle: WorktreeHandle = { - path: "/repo/.specflow/worktrees/r/b", + path: "/repo/.specflow/worktrees/test-change/r/b", baseSha: "abc123", runId: "r", bundleId: "b", + changeId: "test-change", }; const runtime = makeRuntime({ git: (args) => { @@ -605,6 +623,7 @@ test("removeWorktree: propagates git failure", () => { baseSha: "abc123", runId: "r", bundleId: "b", + changeId: "test-change", }; const runtime = makeRuntime({ git: () => fail("fatal: not a working tree", 128), diff --git a/src/tests/apply-worktree-integrate.test.ts b/src/tests/apply-worktree-integrate.test.ts index 80a6998..133d9f8 100644 --- a/src/tests/apply-worktree-integrate.test.ts +++ b/src/tests/apply-worktree-integrate.test.ts @@ -16,10 +16,11 @@ const CHANGE_ID = "my-change"; function handle(overrides: Partial<WorktreeHandle> = {}): WorktreeHandle { return { - path: "/repo/.specflow/worktrees/run-1/bundle-a", + path: "/repo/.specflow/worktrees/my-change/run-1/bundle-a", baseSha: "base-sha", runId: "run-1", bundleId: "bundle-a", + changeId: CHANGE_ID, ...overrides, }; } @@ -58,7 +59,13 @@ function fakeRuntime(opts: { if (opts.onApply) opts.onApply(patch); return opts.applyResult ?? ok(); }; - return { repoRoot: "/repo", git, applyPatch }; + return { + repoRoot: "/repo", + mainWorkspacePath: "/repo", + changeId: "test-change", + git, + applyPatch, + }; } // --- Success path --- @@ -277,6 +284,8 @@ test("integrateBundle: rejects with patch_apply_failure when git apply returns n const diff = ["diff --git a/a.ts b/a.ts", ""].join("\n"); const runtime: WorktreeRuntime = { repoRoot: "/repo", + mainWorkspacePath: "/repo", + changeId: CHANGE_ID, git: () => ok(diff), applyPatch: () => { applyCalled = true; @@ -308,6 +317,8 @@ test("integrateBundle: patch_apply_failure is checked LAST (after declaration an const diff = "diff --git a/undeclared.ts b/undeclared.ts\n"; const runtime: WorktreeRuntime = { repoRoot: "/repo", + mainWorkspacePath: "/repo", + changeId: CHANGE_ID, git: () => ok(diff), applyPatch: () => { applyInvoked = true; diff --git a/src/tests/apply-worktree-realgit.test.ts b/src/tests/apply-worktree-realgit.test.ts index 8e2f86e..fe30d36 100644 --- a/src/tests/apply-worktree-realgit.test.ts +++ b/src/tests/apply-worktree-realgit.test.ts @@ -61,7 +61,10 @@ test("createWorktree (real git): later worktree inherits earlier imported bundle "", ].join("\n"); - importPatch({ repoRoot: root }, Buffer.from(patchFromBundleA, "utf8")); + importPatch( + { repoRoot: root, mainWorkspacePath: root, changeId: "test-change" }, + Buffer.from(patchFromBundleA, "utf8"), + ); // Verify A's new file is staged (R1-F01: --index flag effect). const stagedFiles = git(["diff", "--name-only", "--cached", "HEAD"], root); @@ -79,7 +82,11 @@ test("createWorktree (real git): later worktree inherits earlier imported bundle // Now create bundle B's worktree. The materialize+snapshot sequence must // pull A's change into B's worktree. - const handle = createWorktree({ repoRoot: root }, "run-1", "bundle-b"); + const handle = createWorktree( + { repoRoot: root, mainWorkspacePath: root, changeId: "test-change" }, + "run-1", + "bundle-b", + ); // Bundle B's worktree must contain A's imported file. const materializedFile = readFileSync( @@ -108,7 +115,10 @@ test("createWorktree (real git): later worktree inherits earlier imported bundle ); // computeDiff must capture ONLY B's delta (not A's already-materialized content). - const bDiff = computeDiff({ repoRoot: root }, handle).toString("utf8"); + const bDiff = computeDiff( + { repoRoot: root, mainWorkspacePath: root, changeId: "test-change" }, + handle, + ).toString("utf8"); assert.ok( bDiff.includes("bundle-b-output.txt"), "B's diff must include B's own file", @@ -119,7 +129,10 @@ test("createWorktree (real git): later worktree inherits earlier imported bundle ); // Clean up the worktree explicitly so the temp dir can be removed. - removeWorktree({ repoRoot: root }, handle); + removeWorktree( + { repoRoot: root, mainWorkspacePath: root, changeId: "test-change" }, + handle, + ); } finally { rmSync(root, { recursive: true, force: true }); } @@ -139,7 +152,11 @@ test("createWorktree (real git): untracked files in main workspace are materiali ); // Create a worktree — materialize must include the untracked file. - const handle = createWorktree({ repoRoot: root }, "run-1", "bundle-ut"); + const handle = createWorktree( + { repoRoot: root, mainWorkspacePath: root, changeId: "test-change" }, + "run-1", + "bundle-ut", + ); // The untracked file must be present in the worktree. const materialized = readFileSync( @@ -163,7 +180,10 @@ test("createWorktree (real git): untracked files in main workspace are materiali `untracked-new.txt must remain untracked in main workspace after materialization; got: ${lsFilesAfter}`, ); - removeWorktree({ repoRoot: root }, handle); + removeWorktree( + { repoRoot: root, mainWorkspacePath: root, changeId: "test-change" }, + handle, + ); } finally { rmSync(root, { recursive: true, force: true }); } @@ -173,7 +193,11 @@ test("createWorktree (real git): clean workspace — no materialize, baseSha equ const root = initRepo(); try { const mainHead = git(["rev-parse", "HEAD"], root); - const handle = createWorktree({ repoRoot: root }, "run-1", "bundle-a"); + const handle = createWorktree( + { repoRoot: root, mainWorkspacePath: root, changeId: "test-change" }, + "run-1", + "bundle-a", + ); // With no uncommitted changes, the snapshot step is a no-op and baseSha // stays at the main HEAD. @@ -182,7 +206,10 @@ test("createWorktree (real git): clean workspace — no materialize, baseSha equ const wtReadme = readFileSync(join(handle.path, "README.md"), "utf8"); assert.equal(wtReadme, "initial\n"); - removeWorktree({ repoRoot: root }, handle); + removeWorktree( + { repoRoot: root, mainWorkspacePath: root, changeId: "test-change" }, + handle, + ); } finally { rmSync(root, { recursive: true, force: true }); } @@ -201,7 +228,10 @@ test("importPatch (real git): newly created file is staged in the index (R1-F01) "", ].join("\n"); - importPatch({ repoRoot: root }, Buffer.from(patch, "utf8")); + importPatch( + { repoRoot: root, mainWorkspacePath: root, changeId: "test-change" }, + Buffer.from(patch, "utf8"), + ); // Under --index, the new file must be staged (not just in the working tree). // Without --index, `git diff --cached HEAD` would be empty for untracked files. @@ -241,7 +271,10 @@ test("importPatch (real git): binary-safe patch applies cleanly", () => { "reset must restore original bytes", ); - importPatch({ repoRoot: root }, diffOutput); + importPatch( + { repoRoot: root, mainWorkspacePath: root, changeId: "test-change" }, + diffOutput, + ); // The binary file must now match the modified bytes. const applied = readFileSync(join(root, "img.bin")); diff --git a/src/tests/core-advance.test.ts b/src/tests/core-advance.test.ts index c3ce38b..a2f7d22 100644 --- a/src/tests/core-advance.test.ts +++ b/src/tests/core-advance.test.ts @@ -26,6 +26,9 @@ function seedState(overrides: Partial<RunState> = {}): RunState { repo_path: "/tmp/test", branch_name: "main", worktree_path: "/tmp/test", + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, ...overrides, }; diff --git a/src/tests/core-error-wording.test.ts b/src/tests/core-error-wording.test.ts index f70eccf..5b16280 100644 --- a/src/tests/core-error-wording.test.ts +++ b/src/tests/core-error-wording.test.ts @@ -40,6 +40,9 @@ const SEED: LocalRunState = { repo_path: "/tmp/test", branch_name: "main", worktree_path: "/tmp/test", + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, }; diff --git a/src/tests/core-start.test.ts b/src/tests/core-start.test.ts index 5076218..80230c6 100644 --- a/src/tests/core-start.test.ts +++ b/src/tests/core-start.test.ts @@ -13,6 +13,9 @@ const SEED: LocalRunState = { repo_path: "/tmp/test", branch_name: "main", worktree_path: "/tmp/test", + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, }; diff --git a/src/tests/core-status-fields.test.ts b/src/tests/core-status-fields.test.ts index 787a0a2..6d470af 100644 --- a/src/tests/core-status-fields.test.ts +++ b/src/tests/core-status-fields.test.ts @@ -26,6 +26,9 @@ function seedState(overrides: Partial<RunState> = {}): RunState { repo_path: "/tmp/test", branch_name: "main", worktree_path: "/tmp/test", + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, ...overrides, }; diff --git a/src/tests/core-suspend-resume.test.ts b/src/tests/core-suspend-resume.test.ts index 413f2e4..e1199f4 100644 --- a/src/tests/core-suspend-resume.test.ts +++ b/src/tests/core-suspend-resume.test.ts @@ -27,6 +27,9 @@ function seedState(overrides: Partial<RunState> = {}): RunState { repo_path: "/tmp/test", branch_name: "main", worktree_path: "/tmp/test", + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, ...overrides, }; diff --git a/src/tests/fixtures/legacy-final/specflow-run/advance.json b/src/tests/fixtures/legacy-final/specflow-run/advance.json index e71988d..784977d 100644 --- a/src/tests/fixtures/legacy-final/specflow-run/advance.json +++ b/src/tests/fixtures/legacy-final/specflow-run/advance.json @@ -13,6 +13,9 @@ "project_id": "test/repo", "repo_name": "test/repo", "branch_name": "main", + "base_commit": "", + "base_branch": null, + "cleanup_pending": false, "agents": { "main": "claude", "review": "codex" diff --git a/src/tests/fixtures/legacy-final/specflow-run/start.json b/src/tests/fixtures/legacy-final/specflow-run/start.json index 84fe5b7..7d12ed4 100644 --- a/src/tests/fixtures/legacy-final/specflow-run/start.json +++ b/src/tests/fixtures/legacy-final/specflow-run/start.json @@ -18,6 +18,9 @@ "project_id": "test/repo", "repo_name": "test/repo", "branch_name": "main", + "base_commit": "", + "base_branch": null, + "cleanup_pending": false, "agents": { "main": "claude", "review": "codex" diff --git a/src/tests/generation.test.ts b/src/tests/generation.test.ts index c76e5f2..9a28fa3 100644 --- a/src/tests/generation.test.ts +++ b/src/tests/generation.test.ts @@ -177,6 +177,7 @@ test("project gitignore render includes specflow runtime state", () => { "# Specflow local env", ".specflow/config.env", ".specflow/runs/", + ".specflow/worktrees/", "", ].join("\n"), ); @@ -205,6 +206,7 @@ test("project gitignore merge preserves custom content while appending missing e "", "# Specflow local env", ".specflow/runs/", + ".specflow/worktrees/", "", ].join("\n"), ); diff --git a/src/tests/legacy-runstate-guard.test.ts b/src/tests/legacy-runstate-guard.test.ts new file mode 100644 index 0000000..7847549 --- /dev/null +++ b/src/tests/legacy-runstate-guard.test.ts @@ -0,0 +1,163 @@ +// Tests for the legacy-runstate guard in specflow-prepare-change. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { + createFixtureRepo, + createOpenspecStub, + makeTempDir, + prependPath, + removeTempDir, + runNodeCli, +} from "./test-helpers.js"; + +function writeRunStateRecord( + repoPath: string, + runId: string, + state: Record<string, unknown>, +): void { + const dir = join(repoPath, ".specflow/runs", runId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "run.json"), JSON.stringify(state, null, 2), "utf8"); +} + +function legacyState(repoPath: string, changeId: string, runKind = "change") { + return { + run_id: `${changeId}-1`, + change_name: changeId, + current_phase: "spec_ready", + status: "active", + allowed_events: [], + source: null, + project_id: "fixture", + repo_name: "fixture", + repo_path: repoPath, + branch_name: changeId, + // Legacy: worktree_path equals repo_path. + worktree_path: repoPath, + base_commit: "", + base_branch: null, + cleanup_pending: false, + agents: { main: "claude", review: "codex" }, + last_summary_path: null, + created_at: "2026-04-25T00:00:00Z", + updated_at: "2026-04-25T00:00:00Z", + history: [], + previous_run_id: null, + run_kind: runKind, + }; +} + +function minimalOpenspecStub(tempRoot: string): string { + return createOpenspecStub( + tempRoot, + [ + "#!/usr/bin/env node", + "const fs = require('node:fs');", + "const path = require('node:path');", + "const args = process.argv.slice(2);", + "if (args[0] === 'new' && args[1] === 'change') {", + " const changeId = args[2] || '';", + " const changeDir = path.join(process.cwd(), 'openspec', 'changes', changeId);", + " fs.mkdirSync(changeDir, { recursive: true });", + " fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\\n', 'utf8');", + " process.exit(0);", + "}", + "if (args[0] === 'instructions' && args[1] === 'proposal') {", + " process.stdout.write(JSON.stringify({ outputPath: 'proposal.md', template: '# Proposal', instruction: 'Seed' }));", + " process.exit(0);", + "}", + "process.exit(0);", + "", + ].join("\n"), + ); +} + +test("legacy-runstate guard refuses prepare-change resume when worktree_path == repo_path for a non-synthetic run", () => { + const tempRoot = makeTempDir("legacy-guard-block-"); + try { + const changeId = "legacy-resume"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + writeRunStateRecord( + repoPath, + `${changeId}-1`, + legacyState(repoPath, changeId), + ); + const stubDir = minimalOpenspecStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "resume"], + repoPath, + prependPath({}, stubDir), + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /legacy in-flight run/); + // Guard MUST be non-mutating. + const branchAfter = spawnSync("git", ["branch", "--show-current"], { + cwd: repoPath, + encoding: "utf8", + }).stdout.trim(); + assert.equal(branchAfter, "main"); + } finally { + removeTempDir(tempRoot); + } +}); + +test("legacy-runstate guard exempts synthetic runs even when worktree_path == repo_path", () => { + const tempRoot = makeTempDir("legacy-guard-synthetic-"); + try { + const changeId = "legacy-synthetic"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + // Synthetic run should NOT trigger the guard. + writeRunStateRecord( + repoPath, + `${changeId}-1`, + legacyState(repoPath, changeId, "synthetic"), + ); + const stubDir = minimalOpenspecStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "resume"], + repoPath, + prependPath({}, stubDir), + ); + // Guard must NOT block synthetic runs. The CLI may still exit non-zero + // for unrelated reasons (e.g., missing scaffold), but the error must NOT + // mention the legacy guard. + if (result.status !== 0) { + assert.doesNotMatch(result.stderr, /legacy in-flight run/); + } + } finally { + removeTempDir(tempRoot); + } +}); + +test("legacy-runstate guard does not fire when run-state has distinct repo_path/worktree_path", () => { + const tempRoot = makeTempDir("legacy-guard-new-layout-"); + try { + const changeId = "new-layout"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + const wtPath = join(repoPath, ".specflow/worktrees", changeId, "main"); + writeRunStateRecord(repoPath, `${changeId}-1`, { + ...legacyState(repoPath, changeId), + worktree_path: wtPath, + }); + const stubDir = minimalOpenspecStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "resume"], + repoPath, + prependPath({}, stubDir), + ); + // CLI may succeed or fail for other reasons, but it must NOT cite the + // legacy guard. + if (result.status !== 0) { + assert.doesNotMatch(result.stderr, /legacy in-flight run/); + } + } finally { + removeTempDir(tempRoot); + } +}); diff --git a/src/tests/phase-router.test.ts b/src/tests/phase-router.test.ts index 8eb1d79..a18c468 100644 --- a/src/tests/phase-router.test.ts +++ b/src/tests/phase-router.test.ts @@ -182,6 +182,9 @@ function makeRun(overrides: { repo_path: "/fixture", branch_name: "fixture", worktree_path: "/fixture", + base_commit: "", + base_branch: null, + cleanup_pending: false, agents: { main: "claude", review: "codex" }, last_summary_path: null, created_at: "2026-04-13T00:00:00Z", diff --git a/src/tests/prepare-change-raw-input.test.ts b/src/tests/prepare-change-raw-input.test.ts index ea50fe3..512e80c 100644 --- a/src/tests/prepare-change-raw-input.test.ts +++ b/src/tests/prepare-change-raw-input.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import test from "node:test"; @@ -335,12 +336,19 @@ test("specflow-prepare-change reuses scaffold-only change without calling opensp try { const changeId = "existing-scaffold"; const { repoPath } = createFixtureRepo(tempRoot, changeId); + // Commit the scaffold-only state (proposal.md removed, .openspec.yaml + // present) so the worktree-mode prepare-change inherits it from HEAD. unlinkSync(join(repoPath, "openspec/changes", changeId, "proposal.md")); writeFileSync( join(repoPath, "openspec/changes", changeId, ".openspec.yaml"), "schema: spec-driven\n", "utf8", ); + spawnSync("git", ["add", "-A"], { cwd: repoPath, stdio: "ignore" }); + spawnSync("git", ["commit", "-m", "scaffold-only"], { + cwd: repoPath, + stdio: "ignore", + }); // Use an openspec stub that fails on 'new change' to prove it's not called const stubDir = createOpenspecStub( tempRoot, @@ -372,9 +380,19 @@ test("specflow-prepare-change reuses scaffold-only change without calling opensp }; assert.equal(state.change_name, changeId); assert.equal(state.current_phase, "proposal_draft"); + // Worktree mode: the seeded proposal lives inside the main-session worktree. assert.ok( - existsSync(join(repoPath, "openspec/changes", changeId, "proposal.md")), - "proposal.md should be seeded", + existsSync( + join( + repoPath, + ".specflow/worktrees", + changeId, + "main/openspec/changes", + changeId, + "proposal.md", + ), + ), + "proposal.md should be seeded inside the main-session worktree", ); } finally { removeTempDir(tempRoot); @@ -492,3 +510,174 @@ test("--source-file and positional inline text produce equivalent source metadat removeTempDir(tempRoot); } }); + +// --- Worktree conflict and terminal-flow tests --- + +import { mkdirSync } from "node:fs"; + +test("specflow-prepare-change reuses existing conventional worktree tied to the change branch", () => { + const tempRoot = makeTempDir("prepare-reuse-wt-"); + try { + const changeId = "reuse-wt"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + // Create the worktree manually at the conventional path. + const wtParent = join(repoPath, ".specflow/worktrees", changeId); + mkdirSync(wtParent, { recursive: true }); + const wtPath = join(wtParent, "main"); + spawnSync("git", ["worktree", "add", "-b", changeId, wtPath, "HEAD"], { + cwd: repoPath, + stdio: "ignore", + }); + // The worktree already exists with the change branch. + assert.ok(existsSync(wtPath)); + + const stubDir = createOpenspecAndFetchStubs(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "Continue"], + repoPath, + prependPath({}, stubDir), + ); + assert.equal( + result.status, + 0, + `stderr: ${result.stderr}\nstdout: ${result.stdout}`, + ); + const state = JSON.parse(result.stdout) as { + change_name: string; + }; + assert.equal(state.change_name, changeId); + // The existing worktree should be reused. + assert.ok(existsSync(wtPath)); + } finally { + removeTempDir(tempRoot); + } +}); + +test("specflow-prepare-change fails when branch exists at a non-conventional worktree path", () => { + const tempRoot = makeTempDir("prepare-conflict-path-"); + try { + const changeId = "conflict-path"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + // Create the worktree at a WRONG path (not the conventional one). + const wrongPath = join(tempRoot, "wrong-worktree"); + spawnSync("git", ["worktree", "add", "-b", changeId, wrongPath, "HEAD"], { + cwd: repoPath, + stdio: "ignore", + }); + assert.ok(existsSync(wrongPath)); + + const stubDir = createOpenspecAndFetchStubs(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "Test"], + repoPath, + prependPath({}, stubDir), + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /already checked out as a worktree/); + } finally { + removeTempDir(tempRoot); + } +}); + +test("specflow-prepare-change fails when conventional path occupied by a non-worktree directory", () => { + const tempRoot = makeTempDir("prepare-occupied-"); + try { + const changeId = "occupied-path"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + // Create a regular directory at the conventional worktree path. + const occupiedPath = join( + repoPath, + ".specflow/worktrees", + changeId, + "main", + ); + mkdirSync(occupiedPath, { recursive: true }); + writeFileSync(join(occupiedPath, "stale.txt"), "stale\n", "utf8"); + + const stubDir = createOpenspecAndFetchStubs(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "Test"], + repoPath, + prependPath({}, stubDir), + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /not a registered git worktree/); + } finally { + removeTempDir(tempRoot); + } +}); + +test("specflow-prepare-change rejects legacy in-flight run (worktree_path == repo_path)", () => { + const tempRoot = makeTempDir("prepare-legacy-reject-"); + try { + const changeId = "legacy-reject"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + // Write a legacy run-state where worktree_path == repo_path. + const runDir = join(repoPath, ".specflow/runs", `${changeId}-1`); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "run.json"), + JSON.stringify({ + run_id: `${changeId}-1`, + change_name: changeId, + current_phase: "spec_ready", + status: "active", + allowed_events: [], + source: null, + project_id: "fixture", + repo_name: "fixture", + repo_path: repoPath, + branch_name: changeId, + worktree_path: repoPath, + agents: { main: "claude", review: "codex" }, + last_summary_path: null, + created_at: "2026-04-25T00:00:00Z", + updated_at: "2026-04-25T00:00:00Z", + history: [], + previous_run_id: null, + run_kind: "change", + }), + "utf8", + ); + + const stubDir = createOpenspecAndFetchStubs(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "Resume"], + repoPath, + prependPath({}, stubDir), + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /legacy in-flight run/); + } finally { + removeTempDir(tempRoot); + } +}); + +test("specflow-prepare-change fails when local branch exists but no worktree is registered for it", () => { + const tempRoot = makeTempDir("prepare-branch-exists-"); + try { + const changeId = "branch-exists"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + // Create the branch locally (without a worktree). + spawnSync("git", ["branch", changeId], { + cwd: repoPath, + stdio: "ignore", + }); + + const stubDir = createOpenspecAndFetchStubs(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "Test"], + repoPath, + prependPath({}, stubDir), + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /branch.*exists locally/); + } finally { + removeTempDir(tempRoot); + } +}); diff --git a/src/tests/prepare-change-worktree-conflicts.test.ts b/src/tests/prepare-change-worktree-conflicts.test.ts new file mode 100644 index 0000000..59cdc3c --- /dev/null +++ b/src/tests/prepare-change-worktree-conflicts.test.ts @@ -0,0 +1,230 @@ +// Conflict-path coverage for ensureMainSessionWorktree in +// specflow-prepare-change. Complements the happy-path tests by exercising the +// fail-fast branches that the design's D5 nails down: branch already exists, +// worktree at non-conventional path, non-worktree directory occupies the +// conventional path, and reuse of an existing conventional worktree. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { + createFixtureRepo, + createOpenspecStub, + makeTempDir, + prependPath, + removeTempDir, + runNodeCli, +} from "./test-helpers.js"; + +function defaultStub(tempRoot: string): string { + return createOpenspecStub( + tempRoot, + [ + "#!/usr/bin/env node", + "const fs = require('node:fs');", + "const path = require('node:path');", + "const args = process.argv.slice(2);", + "if (args[0] === 'new' && args[1] === 'change') {", + " const changeId = args[2] || '';", + " const changeDir = path.join(process.cwd(), 'openspec', 'changes', changeId);", + " fs.mkdirSync(changeDir, { recursive: true });", + " fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\\n', 'utf8');", + " process.exit(0);", + "}", + "if (args[0] === 'instructions' && args[1] === 'proposal') {", + " process.stdout.write(JSON.stringify({ outputPath: 'proposal.md', template: '# Proposal', instruction: 'Seed' }));", + " process.exit(0);", + "}", + "process.exit(0);", + "", + ].join("\n"), + ); +} + +test("conflict: prepare-change fails fast when branch <CHANGE_ID> exists without a registered worktree", () => { + const tempRoot = makeTempDir("conflict-branch-only-"); + try { + const changeId = "branch-only"; + const { repoPath } = createFixtureRepo(tempRoot); + // Pre-create the same-named branch in the user repo without a worktree. + spawnSync("git", ["branch", changeId], { + cwd: repoPath, + stdio: "ignore", + }); + const stubDir = defaultStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "trigger"], + repoPath, + prependPath({}, stubDir), + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, new RegExp(`branch '${changeId}' exists`)); + // Did NOT create the worktree. + assert.equal( + existsSync(join(repoPath, ".specflow/worktrees", changeId)), + false, + ); + } finally { + removeTempDir(tempRoot); + } +}); + +test("conflict: prepare-change fails fast when a worktree owns <CHANGE_ID> at a non-conventional path", () => { + const tempRoot = makeTempDir("conflict-nonconventional-wt-"); + try { + const changeId = "elsewhere"; + const { repoPath } = createFixtureRepo(tempRoot); + // Create a worktree at an unconventional path. + const otherWt = join(repoPath, "_other-wt"); + const wtResult = spawnSync( + "git", + ["worktree", "add", "-b", changeId, otherWt, "HEAD"], + { cwd: repoPath, stdio: "ignore" }, + ); + assert.equal(wtResult.status, 0); + + const stubDir = defaultStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "trigger"], + repoPath, + prependPath({}, stubDir), + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /already checked out as a worktree/); + // Did NOT create the conventional worktree. + assert.equal( + existsSync(join(repoPath, ".specflow/worktrees", changeId)), + false, + ); + } finally { + removeTempDir(tempRoot); + } +}); + +test("conflict: prepare-change fails fast when the conventional path is occupied by a non-worktree directory", () => { + const tempRoot = makeTempDir("conflict-nonworktree-dir-"); + try { + const changeId = "occupied"; + const { repoPath } = createFixtureRepo(tempRoot); + const conventional = join( + repoPath, + ".specflow/worktrees", + changeId, + "main", + ); + mkdirSync(conventional, { recursive: true }); + writeFileSync(join(conventional, "stale.txt"), "stale\n", "utf8"); + + const stubDir = defaultStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "trigger"], + repoPath, + prependPath({}, stubDir), + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /not a registered git worktree/); + // Pre-existing directory must not be deleted. + assert.ok(existsSync(join(conventional, "stale.txt"))); + } finally { + removeTempDir(tempRoot); + } +}); + +test("reuse: prepare-change reuses an existing conventional main-session worktree without recreating it", () => { + const tempRoot = makeTempDir("reuse-existing-wt-"); + try { + const changeId = "reuse-me"; + const { repoPath } = createFixtureRepo(tempRoot); + // Pre-create the conventional worktree and seed a marker file. + const conventional = join( + repoPath, + ".specflow/worktrees", + changeId, + "main", + ); + mkdirSync(join(repoPath, ".specflow/worktrees", changeId), { + recursive: true, + }); + const wtResult = spawnSync( + "git", + ["worktree", "add", "-b", changeId, conventional, "HEAD"], + { cwd: repoPath, stdio: "ignore" }, + ); + assert.equal(wtResult.status, 0); + const markerPath = join(conventional, "marker.txt"); + writeFileSync(markerPath, "preserved\n", "utf8"); + + const stubDir = defaultStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "reuse-trigger"], + repoPath, + prependPath({}, stubDir), + ); + assert.equal(result.status, 0, result.stderr); + // Marker file must be preserved (no recreation). + assert.ok(existsSync(markerPath)); + // Branch is still <CHANGE_ID>. + const wtBranch = spawnSync("git", ["branch", "--show-current"], { + cwd: conventional, + encoding: "utf8", + }).stdout.trim(); + assert.equal(wtBranch, changeId); + } finally { + removeTempDir(tempRoot); + } +}); + +test("PR-base inputs: run.json records base_commit equal to user-repo HEAD and base_branch equal to current branch", () => { + const tempRoot = makeTempDir("prbase-inputs-"); + try { + const changeId = "prbase-feature"; + const { repoPath } = createFixtureRepo(tempRoot); + const userHead = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repoPath, + encoding: "utf8", + }).stdout.trim(); + + const stubDir = defaultStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "go"], + repoPath, + prependPath({}, stubDir), + ); + assert.equal(result.status, 0, result.stderr); + const stateRaw = JSON.parse(result.stdout) as Record<string, unknown>; + assert.equal( + stateRaw.base_commit, + userHead, + "base_commit must equal user-repo HEAD at prepare-change time", + ); + assert.equal(stateRaw.base_branch, "main"); + } finally { + removeTempDir(tempRoot); + } +}); + +test("cleanup_pending: persisted run.json initializes cleanup_pending to false", () => { + const tempRoot = makeTempDir("cleanup-pending-init-"); + try { + const { repoPath } = createFixtureRepo(tempRoot); + const stubDir = defaultStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + ["cleanup-init", "go"], + repoPath, + prependPath({}, stubDir), + ); + assert.equal(result.status, 0, result.stderr); + const state = JSON.parse(result.stdout) as Record<string, unknown>; + assert.equal(state.cleanup_pending, false); + } finally { + removeTempDir(tempRoot); + } +}); diff --git a/src/tests/review-cli.test.ts b/src/tests/review-cli.test.ts index 68d709a..7db07b8 100644 --- a/src/tests/review-cli.test.ts +++ b/src/tests/review-cli.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { existsSync, + mkdirSync, readdirSync, readFileSync, unlinkSync, @@ -742,11 +744,45 @@ test("specflow-review-design does not inject task-plannable findings in rereview // --- Review gate issuance E2E tests (R5-F13) -------------------------------- /** Helper: start a run for the given changeId and return the run_id. */ -function startRunForReview(repoPath: string, changeId: string): string { - const result = runNodeCli("specflow-run", ["start", changeId], repoPath); +function startRunForReview( + repoPath: string, + changeId: string, +): { runId: string; worktreePath: string } { + // Commit any pending artifacts so the worktree inherits them from HEAD. + spawnSync("git", ["add", "-A"], { cwd: repoPath, stdio: "ignore" }); + spawnSync("git", ["commit", "-m", "fixture artifacts", "--allow-empty"], { + cwd: repoPath, + stdio: "ignore", + }); + // Create a main-session worktree for the change. + const wtParent = join(repoPath, ".specflow/worktrees", changeId); + mkdirSync(wtParent, { recursive: true }); + const wtPath = join(wtParent, "main"); + spawnSync("git", ["worktree", "add", "-b", changeId, wtPath, "HEAD"], { + cwd: repoPath, + stdio: "ignore", + }); + const headSha = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repoPath, + encoding: "utf8", + }).stdout.trim(); + const result = runNodeCli( + "specflow-run", + [ + "start", + changeId, + "--worktree-path", + wtPath, + "--base-commit", + headSha, + "--base-branch", + "main", + ], + repoPath, + ); assert.equal(result.status, 0, result.stderr); const state = JSON.parse(result.stdout) as { run_id: string }; - return state.run_id; + return { runId: state.run_id, worktreePath: wtPath }; } /** Helper: list gate record files in a run's records directory. */ @@ -773,7 +809,7 @@ test("specflow-review-design review emits a review_decision gate when --run-id i try { const { repoPath, changeId } = createFixtureRepo(tempRoot); addDesignArtifacts(repoPath, changeId); - const runId = startRunForReview(repoPath, changeId); + const { runId, worktreePath } = startRunForReview(repoPath, changeId); const env = createCodexEnv(tempRoot, [ { exitCode: 0, @@ -811,11 +847,16 @@ test("specflow-review-design review emits a review_decision gate when --run-id i assert.equal(gate.status, "pending"); assert.equal(gate.originating_phase, "design_review"); - // Ledger should have gate_id back-reference in round_summaries + // Ledger is now written inside the worktree (change-artifact root). const ledger = readJson<{ round_summaries: Array<{ gate_id?: string | null }>; }>( - join(repoPath, "openspec/changes", changeId, "review-ledger-design.json"), + join( + worktreePath, + "openspec/changes", + changeId, + "review-ledger-design.json", + ), ); assert.ok(ledger.round_summaries.length > 0); const lastSummary = @@ -834,8 +875,11 @@ test("specflow-review-apply review emits a review_decision gate when --run-id is const tempRoot = makeTempDir("review-apply-gate-emit-"); try { const { repoPath, changeId } = createFixtureRepo(tempRoot); - addImplementationDiff(repoPath); - const runId = startRunForReview(repoPath, changeId); + // Start the run first (commits artifacts and creates worktree), then + // introduce the implementation diff inside the worktree so the review + // CLI's diff filter picks it up from the correct working tree. + const { runId, worktreePath } = startRunForReview(repoPath, changeId); + addImplementationDiff(worktreePath); const env = createCodexEnv(tempRoot, [ { exitCode: 0, @@ -875,10 +919,10 @@ test("specflow-review-apply review emits a review_decision gate when --run-id is assert.equal(gate.gate_kind, "review_decision"); assert.equal(gate.status, "pending"); - // Ledger should have gate_id back-reference + // Ledger is now written inside the worktree (change-artifact root). const ledger = readJson<{ round_summaries: Array<{ gate_id?: string | null }>; - }>(join(repoPath, "openspec/changes", changeId, "review-ledger.json")); + }>(join(worktreePath, "openspec/changes", changeId, "review-ledger.json")); assert.ok(ledger.round_summaries.length > 0); const lastSummary = ledger.round_summaries[ledger.round_summaries.length - 1]; @@ -892,7 +936,7 @@ test("specflow-challenge-proposal emits distinct gate IDs across successive roun const tempRoot = makeTempDir("challenge-gate-round-"); try { const { repoPath, changeId } = createFixtureRepo(tempRoot); - const runId = startRunForReview(repoPath, changeId); + const { runId } = startRunForReview(repoPath, changeId); const challengeResponse = { exitCode: 0, output: JSON.stringify({ diff --git a/src/tests/run-state-partition.test.ts b/src/tests/run-state-partition.test.ts index 34fbb9c..f067d5f 100644 --- a/src/tests/run-state-partition.test.ts +++ b/src/tests/run-state-partition.test.ts @@ -95,6 +95,9 @@ const LOCAL_RUN_STATE_KEY_TOKENS = [ "repo_path:", "branch_name:", "worktree_path:", + "base_commit:", + "base_branch:", + "cleanup_pending:", "last_summary_path:", ] as const; diff --git a/src/tests/runstate-generic.test.ts b/src/tests/runstate-generic.test.ts index 2e445fb..9ddddbc 100644 --- a/src/tests/runstate-generic.test.ts +++ b/src/tests/runstate-generic.test.ts @@ -32,6 +32,9 @@ const localSample: LocalRunState = { repo_path: "/tmp/test", branch_name: "main", worktree_path: "/tmp/test", + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, }; diff --git a/src/tests/spec-verify-integration.test.ts b/src/tests/spec-verify-integration.test.ts index 6df8ef9..3732714 100644 --- a/src/tests/spec-verify-integration.test.ts +++ b/src/tests/spec-verify-integration.test.ts @@ -35,6 +35,9 @@ function seedStateAt( repo_path: "/tmp/test", branch_name: "main", worktree_path: "/tmp/test", + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, ...overrides, }; diff --git a/src/tests/specflow-run.test.ts b/src/tests/specflow-run.test.ts index 62995e0..f546f06 100644 --- a/src/tests/specflow-run.test.ts +++ b/src/tests/specflow-run.test.ts @@ -9,6 +9,7 @@ // against in-memory stores. import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, @@ -919,3 +920,162 @@ test("specflow-run start --run-kind synthetic writes run_started to events.jsonl removeTempDir(tempRoot); } }); + +// --- update-field: cleanup_pending whitelist -------------------------------- + +test("specflow-run update-field accepts cleanup_pending with boolean coercion", () => { + const tempRoot = makeTempDir("specflow-run-upf-cleanup-"); + try { + const changeId = "upf-test"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + const state = startRun(repoPath, changeId); + const runId = state.run_id; + + // Set cleanup_pending to true. + const setResult = runNodeCli( + "specflow-run", + ["update-field", runId, "cleanup_pending", "true"], + repoPath, + ); + assert.equal(setResult.status, 0, setResult.stderr); + const updated = JSON.parse(setResult.stdout) as { + cleanup_pending: boolean; + }; + assert.equal(updated.cleanup_pending, true); + + // Set it back to false. + const clearResult = runNodeCli( + "specflow-run", + ["update-field", runId, "cleanup_pending", "false"], + repoPath, + ); + assert.equal(clearResult.status, 0, clearResult.stderr); + const cleared = JSON.parse(clearResult.stdout) as { + cleanup_pending: boolean; + }; + assert.equal(cleared.cleanup_pending, false); + } finally { + removeTempDir(tempRoot); + } +}); + +test("specflow-run update-field rejects invalid cleanup_pending values", () => { + const tempRoot = makeTempDir("specflow-run-upf-invalid-"); + try { + const changeId = "upf-inv"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + const state = startRun(repoPath, changeId); + const runId = state.run_id; + + const result = runNodeCli( + "specflow-run", + ["update-field", runId, "cleanup_pending", "yes"], + repoPath, + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /must be 'true' or 'false'/); + } finally { + removeTempDir(tempRoot); + } +}); + +// --- cleanup-worktrees subcommand ------------------------------------------- + +test("specflow-run cleanup-worktrees removes clean worktrees and clears cleanup_pending", () => { + const tempRoot = makeTempDir("specflow-run-cw-clean-"); + try { + const changeId = "cw-clean"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + const state = startRun(repoPath, changeId); + const runId = state.run_id; + + // Create a worktree at the per-change parent. + const wtParent = join(repoPath, ".specflow/worktrees", changeId); + mkdirSync(wtParent, { recursive: true }); + const wtPath = join(wtParent, "main"); + spawnSync("git", ["worktree", "add", "-b", changeId, wtPath, "HEAD"], { + cwd: repoPath, + stdio: "ignore", + }); + assert.ok(existsSync(wtPath)); + + // Pre-set cleanup_pending = true so we can verify it gets cleared. + runNodeCli( + "specflow-run", + ["update-field", runId, "cleanup_pending", "true"], + repoPath, + ); + + // Run cleanup — worktree is clean, should succeed. + const result = runNodeCli( + "specflow-run", + ["cleanup-worktrees", runId], + repoPath, + ); + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout) as { + action: string; + removed: string[]; + }; + assert.equal(output.action, "remove"); + assert.ok(!existsSync(wtPath)); + + // Verify cleanup_pending was cleared. + const statusResult = runNodeCli( + "specflow-run", + ["get-field", runId, "cleanup_pending"], + repoPath, + ); + assert.equal(statusResult.status, 0); + assert.equal(JSON.parse(statusResult.stdout), false); + } finally { + removeTempDir(tempRoot); + } +}); + +test("specflow-run cleanup-worktrees defers when worktree is dirty", () => { + const tempRoot = makeTempDir("specflow-run-cw-dirty-"); + try { + const changeId = "cw-dirty"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + const state = startRun(repoPath, changeId); + const runId = state.run_id; + + // Create a worktree and make it dirty. + const wtParent = join(repoPath, ".specflow/worktrees", changeId); + mkdirSync(wtParent, { recursive: true }); + const wtPath = join(wtParent, "main"); + spawnSync("git", ["worktree", "add", "-b", changeId, wtPath, "HEAD"], { + cwd: repoPath, + stdio: "ignore", + }); + writeFileSync(join(wtPath, "dirty.txt"), "dirty\n", "utf8"); + + const result = runNodeCli( + "specflow-run", + ["cleanup-worktrees", runId], + repoPath, + ); + assert.notEqual(result.status, 0); + const output = JSON.parse(result.stdout) as { + action: string; + reasons: Array<{ kind: string }>; + }; + assert.equal(output.action, "defer"); + assert.ok(output.reasons.some((r) => r.kind === "dirty_worktree")); + + // Worktree still on disk. + assert.ok(existsSync(wtPath)); + + // cleanup_pending was set to true. + const statusResult = runNodeCli( + "specflow-run", + ["get-field", runId, "cleanup_pending"], + repoPath, + ); + assert.equal(statusResult.status, 0); + assert.equal(JSON.parse(statusResult.stdout), true); + } finally { + removeTempDir(tempRoot); + } +}); diff --git a/src/tests/specflow-watch-integration.test.ts b/src/tests/specflow-watch-integration.test.ts index fb3673d..885559a 100644 --- a/src/tests/specflow-watch-integration.test.ts +++ b/src/tests/specflow-watch-integration.test.ts @@ -44,6 +44,10 @@ interface SeedOptions { function seedRun(root: string, opts: SeedOptions): RunState { const runDir = join(root, ".specflow/runs", opts.runId); mkdirSync(runDir, { recursive: true }); + // Use a worktree_path distinct from repo_path so the legacy guard + // (worktree_path == repo_path) would not fire on non-synthetic runs. + const wtPath = join(root, ".specflow/worktrees", opts.changeName, "main"); + mkdirSync(wtPath, { recursive: true }); const run: RunState = { run_id: opts.runId, change_name: opts.changeName, @@ -59,9 +63,13 @@ function seedRun(root: string, opts: SeedOptions): RunState { repo_name: "owner/repo", repo_path: root, branch_name: opts.changeName, - worktree_path: root, + worktree_path: wtPath, + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, - }; + run_kind: "change", + } as RunState; writeFileSync( join(runDir, "run.json"), `${JSON.stringify(run, null, 2)}\n`, diff --git a/src/tests/specflow-watch-readers.test.ts b/src/tests/specflow-watch-readers.test.ts index f8ff968..4e838b6 100644 --- a/src/tests/specflow-watch-readers.test.ts +++ b/src/tests/specflow-watch-readers.test.ts @@ -45,6 +45,9 @@ function run( repo_path: "/tmp/repo", branch_name: overrides.change_name, worktree_path: "/tmp/repo", + base_commit: "", + base_branch: null, + cleanup_pending: false, last_summary_path: null, }; return { ...base, ...overrides } as RunState; diff --git a/src/tests/terminal-worktree-cleanup.test.ts b/src/tests/terminal-worktree-cleanup.test.ts new file mode 100644 index 0000000..92aa37e --- /dev/null +++ b/src/tests/terminal-worktree-cleanup.test.ts @@ -0,0 +1,195 @@ +// Tests for the terminal-phase cleanup gate. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { evaluateAndCleanup } from "../lib/terminal-worktree-cleanup.js"; +import { makeTempDir, removeTempDir } from "./test-helpers.js"; + +function initRepo(repoPath: string): void { + mkdirSync(repoPath, { recursive: true }); + spawnSync("git", ["init", "--quiet"], { cwd: repoPath, stdio: "ignore" }); + spawnSync("git", ["symbolic-ref", "HEAD", "refs/heads/main"], { + cwd: repoPath, + stdio: "ignore", + }); + spawnSync("git", ["config", "user.email", "tw@example.com"], { + cwd: repoPath, + stdio: "ignore", + }); + spawnSync("git", ["config", "user.name", "Worktree Cleanup Tests"], { + cwd: repoPath, + stdio: "ignore", + }); + writeFileSync(join(repoPath, "seed.txt"), "seed\n", "utf8"); + spawnSync("git", ["add", "."], { cwd: repoPath, stdio: "ignore" }); + spawnSync("git", ["commit", "-m", "init"], { + cwd: repoPath, + stdio: "ignore", + }); +} + +function addWorktree(repoPath: string, changeId: string): string { + const wtPath = join(repoPath, ".specflow/worktrees", changeId, "main"); + mkdirSync(join(repoPath, ".specflow/worktrees", changeId), { + recursive: true, + }); + const result = spawnSync( + "git", + ["worktree", "add", "-b", changeId, wtPath, "HEAD"], + { cwd: repoPath, stdio: "ignore" }, + ); + if (result.status !== 0) { + throw new Error(`git worktree add failed: ${result.status}`); + } + return wtPath; +} + +test("evaluateAndCleanup removes the per-change parent when clean and complete", () => { + const tempRoot = makeTempDir("twc-clean-"); + try { + const repoPath = join(tempRoot, "repo"); + initRepo(repoPath); + const changeId = "wt-clean"; + const wtPath = addWorktree(repoPath, changeId); + assert.ok(existsSync(wtPath)); + + const decision = evaluateAndCleanup({ + repoPath, + changeId, + successFull: true, + }); + + assert.equal(decision.action, "remove"); + assert.equal(existsSync(wtPath), false); + assert.equal( + existsSync(join(repoPath, ".specflow/worktrees", changeId)), + false, + ); + } finally { + removeTempDir(tempRoot); + } +}); + +test("evaluateAndCleanup defers when terminal action did not succeed fully", () => { + const tempRoot = makeTempDir("twc-partial-"); + try { + const repoPath = join(tempRoot, "repo"); + initRepo(repoPath); + const changeId = "wt-partial"; + const wtPath = addWorktree(repoPath, changeId); + + const decision = evaluateAndCleanup({ + repoPath, + changeId, + successFull: false, + partialFailureCause: "PR creation failed", + }); + + assert.equal(decision.action, "defer"); + if (decision.action === "defer") { + assert.ok(decision.reasons.some((r) => r.kind === "partial_failure")); + assert.ok( + decision.reasons.some((r) => r.detail.includes("PR creation failed")), + ); + } + // Worktree still on disk because cleanup deferred. + assert.ok(existsSync(wtPath)); + } finally { + removeTempDir(tempRoot); + } +}); + +test("evaluateAndCleanup defers when a worktree is dirty", () => { + const tempRoot = makeTempDir("twc-dirty-"); + try { + const repoPath = join(tempRoot, "repo"); + initRepo(repoPath); + const changeId = "wt-dirty"; + const wtPath = addWorktree(repoPath, changeId); + // Make the worktree dirty. + writeFileSync(join(wtPath, "uncommitted.txt"), "scribble\n", "utf8"); + + const decision = evaluateAndCleanup({ + repoPath, + changeId, + successFull: true, + }); + + assert.equal(decision.action, "defer"); + if (decision.action === "defer") { + assert.ok( + decision.reasons.some( + (r) => r.kind === "dirty_worktree" && r.worktreePath === wtPath, + ), + ); + } + assert.ok(existsSync(wtPath)); + } finally { + removeTempDir(tempRoot); + } +}); + +test("evaluateAndCleanup is a no-op when nothing exists at the per-change parent", () => { + const tempRoot = makeTempDir("twc-nothing-"); + try { + const repoPath = join(tempRoot, "repo"); + initRepo(repoPath); + + const decision = evaluateAndCleanup({ + repoPath, + changeId: "never-existed", + successFull: true, + }); + + assert.equal(decision.action, "remove"); + if (decision.action === "remove") { + assert.deepEqual(decision.removed, []); + } + } finally { + removeTempDir(tempRoot); + } +}); + +test("evaluateAndCleanup retry: cleanup succeeds after the dirty state is resolved", () => { + const tempRoot = makeTempDir("twc-retry-"); + try { + const repoPath = join(tempRoot, "repo"); + initRepo(repoPath); + const changeId = "wt-retry"; + const wtPath = addWorktree(repoPath, changeId); + writeFileSync(join(wtPath, "uncommitted.txt"), "scribble\n", "utf8"); + + // First attempt: dirty → defer. + const first = evaluateAndCleanup({ + repoPath, + changeId, + successFull: true, + }); + assert.equal(first.action, "defer"); + assert.ok(existsSync(wtPath)); + + // User commits the dirty file. + spawnSync("git", ["-C", wtPath, "add", "."], { stdio: "ignore" }); + spawnSync("git", ["-C", wtPath, "commit", "-m", "fix"], { + stdio: "ignore", + }); + + // Retry: clean → remove. + const second = evaluateAndCleanup({ + repoPath, + changeId, + successFull: true, + }); + assert.equal(second.action, "remove"); + assert.equal(existsSync(wtPath), false); + assert.equal( + existsSync(join(repoPath, ".specflow/worktrees", changeId)), + false, + ); + } finally { + removeTempDir(tempRoot); + } +}); diff --git a/src/tests/utility-cli.test.ts b/src/tests/utility-cli.test.ts index ed4e59d..7f70c49 100644 --- a/src/tests/utility-cli.test.ts +++ b/src/tests/utility-cli.test.ts @@ -295,6 +295,13 @@ test("specflow-prepare-change seeds proposal.md for scaffold-only changes and en "schema: spec-driven\ncreated: 2026-04-10\n", "utf8", ); + // Commit the scaffold-only state so the worktree-mode prepare-change + // inherits the deletion of proposal.md from HEAD. + spawnSync("git", ["add", "-A"], { cwd: repoPath, stdio: "ignore" }); + spawnSync("git", ["commit", "-m", "scaffold-only"], { + cwd: repoPath, + stdio: "ignore", + }); const stubDir = createOpenspecStub( tempRoot, [ @@ -338,8 +345,16 @@ test("specflow-prepare-change seeds proposal.md for scaffold-only changes and en assert.equal(state.branch_name, changeId); assert.equal(state.source.provider, "generic"); assert.equal(state.source.reference, "Add enterprise SSO support"); + // Worktree mode: artifacts live inside the main-session worktree. + const worktreeChangeDir = join( + repoPath, + ".specflow/worktrees", + changeId, + "main/openspec/changes", + changeId, + ); const proposal = readFileSync( - join(repoPath, "openspec/changes", changeId, "proposal.md"), + join(worktreeChangeDir, "proposal.md"), "utf8", ); assert.ok(proposal.includes("# Proposal")); @@ -410,19 +425,13 @@ test("specflow-prepare-change derives change ids from GitHub sources and scaffol "https://github.com/test/repo/issues/89", ); assert.equal(state.source.title, "Repo responsibility nongoals"); - assert.ok( - existsSync( - join( - repoPath, - "openspec/changes/repo-responsibility-nongoals/.openspec.yaml", - ), - ), + const worktreeChangeDir = join( + repoPath, + ".specflow/worktrees/repo-responsibility-nongoals/main/openspec/changes/repo-responsibility-nongoals", ); + assert.ok(existsSync(join(worktreeChangeDir, ".openspec.yaml"))); const proposal = readFileSync( - join( - repoPath, - "openspec/changes/repo-responsibility-nongoals/proposal.md", - ), + join(worktreeChangeDir, "proposal.md"), "utf8", ); assert.ok(proposal.includes("# Proposal")); diff --git a/src/tests/worktree-invariant-verification.test.ts b/src/tests/worktree-invariant-verification.test.ts new file mode 100644 index 0000000..e7c82af --- /dev/null +++ b/src/tests/worktree-invariant-verification.test.ts @@ -0,0 +1,283 @@ +// End-to-end smoke tests + regression guards for worktree-mode invariants. +// +// These tests cover Completion Conditions C-1..C-7 from +// `openspec/changes/worktree/design.md`: +// C-1 user repo HEAD/branch/dirty state are untouched after prepare-change +// C-2 LocalRunState carries base_commit / base_branch / cleanup_pending +// C-3 phase-command cwd routing — main-session worktree is the integration target +// C-4 subagent patches land in the main-session worktree, not the user repo +// C-5 approve PR base resolution from base_branch with default-branch fallback +// C-6 terminal cleanup gate (clean+complete vs deferred) +// C-7 legacy run-state guard (with synthetic-run exemption) +// +// The C-1/C-2/C-7 invariants are verified directly here; C-3/C-4/C-5/C-6 are +// covered by per-bundle tests already added in this change. This file also +// locks in a grep guard against forbidden write paths. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + realpathSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { + createFixtureRepo, + createOpenspecStub, + createSourceFile, + makeTempDir, + prependPath, + removeTempDir, + runNodeCli, +} from "./test-helpers.js"; + +function captureStrict(cwd: string, args: readonly string[]): string { + const r = spawnSync("git", [...args], { cwd, encoding: "utf8" }); + if (r.status !== 0) { + throw new Error( + `git ${args.join(" ")} failed in ${cwd}: ${r.stderr || r.stdout}`, + ); + } + return r.stdout.trim(); +} + +/** + * `git status --porcelain` filtered for invariant comparison: drop entries for + * the `.specflow/` admin subtree, which is expected to appear after + * prepare-change creates the worktree (and would normally be gitignored in a + * real project, but the fixture does not write a project-wide gitignore). + */ +function userTrackedDirtyState(cwd: string): string { + return captureStrict(cwd, ["status", "--porcelain"]) + .split("\n") + .filter( + (line) => !line.includes(" .specflow/") && !line.endsWith(" .specflow"), + ) + .join("\n"); +} + +function buildDefaultStub(tempRoot: string): string { + return createOpenspecStub( + tempRoot, + [ + "#!/usr/bin/env node", + "const fs = require('node:fs');", + "const path = require('node:path');", + "const args = process.argv.slice(2);", + "if (args[0] === 'new' && args[1] === 'change') {", + " const changeId = args[2] || '';", + " const changeDir = path.join(process.cwd(), 'openspec', 'changes', changeId);", + " fs.mkdirSync(changeDir, { recursive: true });", + " fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\\n', 'utf8');", + " process.exit(0);", + "}", + "if (args[0] === 'instructions' && args[1] === 'proposal') {", + " process.stdout.write(JSON.stringify({ outputPath: 'proposal.md', template: '# Proposal', instruction: 'Seed' }));", + " process.exit(0);", + "}", + "process.exit(0);", + "", + ].join("\n"), + ); +} + +// --- C-1: user repo HEAD/branch/dirty state unchanged --- + +test("worktree invariant C-1: prepare-change leaves user-repo HEAD, branch, and dirty state untouched", () => { + const tempRoot = makeTempDir("inv-c1-"); + try { + const { repoPath } = createFixtureRepo(tempRoot); + // Set up dirty state in the user repo: stage a change AND leave an + // untracked file alongside. + writeFileSync(join(repoPath, "untracked.txt"), "untracked!\n", "utf8"); + writeFileSync(join(repoPath, "app.txt"), "modified\n", "utf8"); + spawnSync("git", ["add", "app.txt"], { cwd: repoPath, stdio: "ignore" }); + + const headBefore = captureStrict(repoPath, ["rev-parse", "HEAD"]); + const branchBefore = captureStrict(repoPath, ["branch", "--show-current"]); + const dirtyBefore = userTrackedDirtyState(repoPath); + + const stubDir = buildDefaultStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + ["my-feature", "Add my feature"], + repoPath, + prependPath({}, stubDir), + ); + assert.equal(result.status, 0, result.stderr); + + // Invariants: + assert.equal(captureStrict(repoPath, ["rev-parse", "HEAD"]), headBefore); + assert.equal( + captureStrict(repoPath, ["branch", "--show-current"]), + branchBefore, + ); + assert.equal(userTrackedDirtyState(repoPath), dirtyBefore); + // The change branch lives ONLY inside the worktree. + assert.ok( + existsSync(join(repoPath, ".specflow/worktrees/my-feature/main")), + "main-session worktree should exist", + ); + const wtBranch = captureStrict( + join(repoPath, ".specflow/worktrees/my-feature/main"), + ["branch", "--show-current"], + ); + assert.equal(wtBranch, "my-feature"); + } finally { + removeTempDir(tempRoot); + } +}); + +// --- C-2: run-state carries the new fields --- + +test("worktree invariant C-2: persisted run.json carries base_commit, base_branch, cleanup_pending", () => { + const tempRoot = makeTempDir("inv-c2-"); + try { + const { repoPath } = createFixtureRepo(tempRoot); + const stubDir = buildDefaultStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + ["foo-feature", "Foo"], + repoPath, + prependPath({}, stubDir), + ); + assert.equal(result.status, 0, result.stderr); + const stateRaw = readFileSync( + join(repoPath, ".specflow/runs/foo-feature-1/run.json"), + "utf8", + ); + const state = JSON.parse(stateRaw) as Record<string, unknown>; + assert.ok(typeof state.base_commit === "string"); + assert.ok(state.base_commit !== "", "base_commit should be populated"); + assert.equal(state.base_branch, "main"); + assert.equal(state.cleanup_pending, false); + assert.notEqual(state.repo_path, state.worktree_path); + const expectedWtPath = join( + realpathSync(repoPath), + ".specflow/worktrees/foo-feature/main", + ); + assert.equal(state.worktree_path, expectedWtPath); + } finally { + removeTempDir(tempRoot); + } +}); + +// --- C-7: legacy guard with synthetic exemption --- + +test("worktree invariant C-7: prepare-change refuses legacy non-synthetic run-state and never mutates the user repo", () => { + const tempRoot = makeTempDir("inv-c7-"); + try { + const changeId = "legacy-c7"; + const { repoPath } = createFixtureRepo(tempRoot, changeId); + const runDir = join(repoPath, ".specflow/runs", `${changeId}-1`); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "run.json"), + JSON.stringify( + { + run_id: `${changeId}-1`, + change_name: changeId, + current_phase: "spec_ready", + status: "active", + allowed_events: [], + source: null, + project_id: "fixture", + repo_name: "fixture", + repo_path: repoPath, + branch_name: changeId, + worktree_path: repoPath, + agents: { main: "claude", review: "codex" }, + last_summary_path: null, + created_at: "2026-04-25T00:00:00Z", + updated_at: "2026-04-25T00:00:00Z", + history: [], + previous_run_id: null, + run_kind: "change", + }, + null, + 2, + ), + "utf8", + ); + const headBefore = captureStrict(repoPath, ["rev-parse", "HEAD"]); + const branchBefore = captureStrict(repoPath, ["branch", "--show-current"]); + const stubDir = buildDefaultStub(tempRoot); + const result = runNodeCli( + "specflow-prepare-change", + [changeId, "resume"], + repoPath, + prependPath({}, stubDir), + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /legacy in-flight run/); + // Non-mutating. + assert.equal(captureStrict(repoPath, ["rev-parse", "HEAD"]), headBefore); + assert.equal( + captureStrict(repoPath, ["branch", "--show-current"]), + branchBefore, + ); + // And no worktree was created. + assert.equal( + existsSync(join(repoPath, ".specflow/worktrees", changeId)), + false, + ); + } finally { + removeTempDir(tempRoot); + } +}); + +// --- Locking grep guard: no `git checkout -b` or `git checkout` against the user repo from prepare-change. --- + +test("regression guard: specflow-prepare-change does not contain `git checkout` invocations against the user repo", () => { + const file = readFileSync("src/bin/specflow-prepare-change.ts", "utf8"); + // The legacy ensureBranch helper must be gone. + assert.ok( + !/function\s+ensureBranch\b/.test(file), + "ensureBranch was removed in favor of ensureMainSessionWorktree", + ); + // No literal `git checkout -b` argument arrays should remain. + assert.ok( + !/"checkout",\s*"-b"/.test(file), + "`git checkout -b` is forbidden in prepare-change under worktree mode", + ); + assert.ok( + !/git\s+checkout\s+-b/.test(file), + "`git checkout -b` is forbidden in prepare-change under worktree mode", + ); +}); + +test("regression guard: ensureMainSessionWorktree is the sole worktree creation entry in prepare-change", () => { + const file = readFileSync("src/bin/specflow-prepare-change.ts", "utf8"); + assert.ok( + /function\s+ensureMainSessionWorktree\b/.test(file), + "ensureMainSessionWorktree must exist in prepare-change", + ); + assert.ok( + /git\s+worktree\s+add/.test(file) || /"worktree",\s*"add"/.test(file), + "prepare-change must use `git worktree add` to create the main-session worktree", + ); +}); + +// --- Locking grep guard: no `git worktree prune` calls in any specflow source. --- + +test("regression guard: specflow does not invoke `git worktree prune` automatically", () => { + for (const file of [ + "src/bin/specflow-prepare-change.ts", + "src/lib/apply-worktree/worktree.ts", + "src/lib/terminal-worktree-cleanup.ts", + ]) { + const content = readFileSync(file, "utf8"); + assert.ok( + !/"worktree",\s*"prune"/.test(content), + `${file} must not invoke 'git worktree prune' automatically (per design D5).`, + ); + assert.ok( + !/git\s+worktree\s+prune/.test(content), + `${file} must not invoke 'git worktree prune' automatically (per design D5).`, + ); + } +}); diff --git a/src/tests/worktree-resolver.test.ts b/src/tests/worktree-resolver.test.ts new file mode 100644 index 0000000..e5a6897 --- /dev/null +++ b/src/tests/worktree-resolver.test.ts @@ -0,0 +1,222 @@ +// Tests for the run-id → worktree_path resolver used by phase commands. + +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import type { RunArtifactStore } from "../lib/artifact-store.js"; +import type { + RunArtifactQuery, + RunArtifactRef, +} from "../lib/artifact-types.js"; +import { + resolveChangeRootForRun, + resolveWorktreeForRun, + resolveWorktreeForRunOrNull, +} from "../lib/worktree-resolver.js"; +import type { RunState } from "../types/contracts.js"; +import { makeTempDir, removeTempDir } from "./test-helpers.js"; + +function buildRunState(overrides: Partial<RunState>): RunState { + return { + run_id: "wt-1", + change_name: "wt", + current_phase: "apply_draft", + status: "active", + allowed_events: [], + source: null, + project_id: "fixture", + repo_name: "fixture", + repo_path: "/repo", + branch_name: "wt", + worktree_path: "/repo/.specflow/worktrees/wt/main", + base_commit: "deadbeef", + base_branch: "main", + cleanup_pending: false, + agents: { main: "claude", review: "codex" }, + last_summary_path: null, + created_at: "2026-04-25T00:00:00Z", + updated_at: "2026-04-25T00:00:00Z", + history: [], + previous_run_id: null, + run_kind: "change", + ...overrides, + } as RunState; +} + +function inMemoryStore(records: Map<string, string>): RunArtifactStore { + return { + read: async (ref: RunArtifactRef): Promise<string> => { + const key = `${ref.runId}/${ref.type}`; + const value = records.get(key); + if (value === undefined) throw new Error(`not found: ${key}`); + return value; + }, + write: async (ref: RunArtifactRef, content: string): Promise<void> => { + records.set(`${ref.runId}/${ref.type}`, content); + }, + exists: async (ref: RunArtifactRef): Promise<boolean> => + records.has(`${ref.runId}/${ref.type}`), + list: async ( + _query?: RunArtifactQuery, + ): Promise<readonly RunArtifactRef[]> => [], + }; +} + +test("resolveWorktreeForRun returns the persisted worktree tuple", async () => { + const records = new Map<string, string>(); + const state = buildRunState({}); + records.set("wt-1/run-state", JSON.stringify(state)); + const store = inMemoryStore(records); + + const resolution = await resolveWorktreeForRun(store, "wt-1"); + + assert.equal(resolution.repoPath, "/repo"); + assert.equal(resolution.worktreePath, "/repo/.specflow/worktrees/wt/main"); + assert.equal(resolution.branchName, "wt"); + assert.equal(resolution.baseCommit, "deadbeef"); + assert.equal(resolution.baseBranch, "main"); + assert.equal(resolution.cleanupPending, false); +}); + +test("resolveWorktreeForRun returns repo_path-equal worktree_path for legacy records", async () => { + const records = new Map<string, string>(); + // Legacy record: worktree_path == repo_path + const legacyState = buildRunState({ + repo_path: "/legacy", + worktree_path: "/legacy", + base_commit: "", + base_branch: null, + }); + records.set("legacy-1/run-state", JSON.stringify(legacyState)); + const store = inMemoryStore(records); + + const resolution = await resolveWorktreeForRun(store, "legacy-1"); + assert.equal(resolution.repoPath, resolution.worktreePath); + assert.equal(resolution.baseCommit, ""); + assert.equal(resolution.baseBranch, null); +}); + +test("resolveWorktreeForRunOrNull returns null when the run does not exist", async () => { + const store = inMemoryStore(new Map()); + const resolution = await resolveWorktreeForRunOrNull(store, "missing-1"); + assert.equal(resolution, null); +}); + +test("resolveWorktreeForRunOrNull returns the resolution when present", async () => { + const records = new Map<string, string>(); + const state = buildRunState({}); + records.set("wt-1/run-state", JSON.stringify(state)); + const store = inMemoryStore(records); + + const resolution = await resolveWorktreeForRunOrNull(store, "wt-1"); + assert.notEqual(resolution, null); + assert.equal(resolution?.worktreePath, "/repo/.specflow/worktrees/wt/main"); +}); + +// --- resolveChangeRootForRun --- + +test("resolveChangeRootForRun returns worktree_path for a normal worktree-mode run", async () => { + const records = new Map<string, string>(); + const state = buildRunState({ + run_id: "cr-1", + change_name: "cr", + repo_path: "/repo", + worktree_path: "/repo/.specflow/worktrees/cr/main", + run_kind: "change", + }); + records.set("cr-1/run-state", JSON.stringify(state)); + const store = inMemoryStore(records); + + const result = await resolveChangeRootForRun(store, "cr-1", "/repo"); + assert.equal(result, "/repo/.specflow/worktrees/cr/main"); +}); + +test("resolveChangeRootForRun falls back to repoRoot when runId is undefined", async () => { + const store = inMemoryStore(new Map()); + const result = await resolveChangeRootForRun(store, undefined, "/repo"); + assert.equal(result, "/repo"); +}); + +test("resolveChangeRootForRun falls back to repoRoot when run does not exist", async () => { + const store = inMemoryStore(new Map()); + const result = await resolveChangeRootForRun(store, "missing-1", "/repo"); + assert.equal(result, "/repo"); +}); + +test("resolveChangeRootForRun throws for non-synthetic run with worktree_path == repo_path (legacy guard)", async () => { + const records = new Map<string, string>(); + const legacyState = buildRunState({ + run_id: "legacy-1", + repo_path: "/repo", + worktree_path: "/repo", + run_kind: "change", + }); + records.set("legacy-1/run-state", JSON.stringify(legacyState)); + const store = inMemoryStore(records); + + await assert.rejects( + () => resolveChangeRootForRun(store, "legacy-1", "/repo"), + (err: Error) => + err.message.includes("legacy layout") && err.message.includes("legacy-1"), + ); +}); + +test("resolveChangeRootForRun allows synthetic run with worktree_path == repo_path", async () => { + const records = new Map<string, string>(); + const syntheticState = buildRunState({ + run_id: "syn-1", + repo_path: "/repo", + worktree_path: "/repo", + run_kind: "synthetic", + }); + records.set("syn-1/run-state", JSON.stringify(syntheticState)); + const store = inMemoryStore(records); + + const result = await resolveChangeRootForRun(store, "syn-1", "/repo"); + assert.equal(result, "/repo"); +}); + +test("resolveWorktreeForRun fills defaults for missing base_commit/base_branch/cleanup_pending fields", async () => { + // Simulate a legacy record on disk that does not yet carry the new fields. + const records = new Map<string, string>(); + const tempRoot = makeTempDir("worktree-resolver-legacy-"); + try { + const runsDir = join(tempRoot, ".specflow/runs/legacy-noflds-1"); + mkdirSync(runsDir, { recursive: true }); + const minimalRecord = { + run_id: "legacy-noflds-1", + change_name: "legacy-noflds", + current_phase: "spec_ready", + status: "active", + allowed_events: [], + source: null, + project_id: "fixture", + repo_name: "fixture", + repo_path: "/legacy", + branch_name: "legacy-noflds", + worktree_path: "/legacy", + agents: { main: "claude", review: "codex" }, + last_summary_path: null, + created_at: "2026-04-25T00:00:00Z", + updated_at: "2026-04-25T00:00:00Z", + history: [], + previous_run_id: null, + run_kind: "change", + }; + writeFileSync( + join(runsDir, "run.json"), + JSON.stringify(minimalRecord, null, 2), + "utf8", + ); + records.set("legacy-noflds-1/run-state", JSON.stringify(minimalRecord)); + const store = inMemoryStore(records); + + const resolution = await resolveWorktreeForRun(store, "legacy-noflds-1"); + assert.equal(resolution.baseCommit, ""); + assert.equal(resolution.baseBranch, null); + assert.equal(resolution.cleanupPending, false); + } finally { + removeTempDir(tempRoot); + } +}); diff --git a/src/types/contracts.ts b/src/types/contracts.ts index 9729cc5..a66b4fd 100644 --- a/src/types/contracts.ts +++ b/src/types/contracts.ts @@ -287,6 +287,9 @@ export interface LocalRunState { readonly repo_path: string; readonly branch_name: string; readonly worktree_path: string; + readonly base_commit: string; + readonly base_branch: string | null; + readonly cleanup_pending: boolean; readonly last_summary_path: string | null; }