diff --git a/.changeset/proud-koalas-jump.md b/.changeset/proud-koalas-jump.md new file mode 100644 index 000000000..c476225a2 --- /dev/null +++ b/.changeset/proud-koalas-jump.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4476 +--- +**Sequential phase execution stays on the orchestrator's checkout** — non-isolated executors now receive the orchestrator's validated root as a literal prompt pin and halt loudly before any write or commit when their actual root differs, instead of silently committing onto whatever checkout their spawn cwd resolved to. (#4254) diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 5e3b0415a..7adf3fe53 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -627,6 +627,7 @@ "execute-phase/steps/protected-branch.md", "execute-phase/steps/regression-gate-run.md", "execute-phase/steps/regression-gate.md", + "execute-phase/steps/sequential-root-pin.md", "execute-phase/steps/tdd-applicability-resolution.md", "execute-phase/steps/wave-post-gate-hooks.md", "execute-phase/steps/worktree-recovery-policy.md", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index f1e418d65..1aa6e65ee 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -367,7 +367,7 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum | `worktree-branch-check.md` | Canonical spawn-time worktree HEAD/base guard (worktree_branch_check): verify-only and fail-closed — per-agent-branch assertion, protected-ref refusal (#2924), and an exact-base assertion that halts with `exit 42` on mismatch so the orchestrator (worktree lifecycle owner) performs recovery (#48). Embedded into worktree sub-agent prompts at dispatch. | | `runtime-aware-dispatch.md` | Runtime-aware subagent dispatch protocol (#2508 Phase 4 Option A): before any `Agent(subagent_type="gsd-*")` call, resolve the type via `gsd_run query resolve-dispatch-type --requested --raw`. On named-dispatch runtimes (Claude/OpenCode/…) the name is returned unchanged; on built-in-only runtimes (kimi-code) it maps to `coder`/`explore`/`plan` by role-suffix. The persona rides `${AGENT_SKILLS_}` (Phase 3) regardless. Documents why a PreToolUse-remap hook (the epic's original Option B) is infeasible — Kimi Code's hook API supports only allow/deny, not tool_input rewriting. | | `dispatch-isolation-gate.md` | Canonical gate deciding whether a dispatch site may run an agent isolated (#2584/#2652): resolves `ISOLATION` from the negotiated `dispatch.isolation` capability — never from a runtime id — fails closed to `none`, resolves the host's declared `harnessFlag` instead of hardcoding Claude Code's `isolation="worktree"` literal, and degrades single-agent sites to sequential on `orchestrator-worktree` hosts. Read by `quick.md`, `diagnose-issues.md`, and `execute-plan.md`. | -| `worktree-path-safety.md` | Worktree guard suite: HEAD assertion, cwd-drift sentinel (step 0a, #3097), and absolute-path guard (step 0b, #3099) — loaded into executor spawn prompts via ``. | +| `worktree-path-safety.md` | Executor path guards: supplied-root pin (step 0p, #4254 — every mode; execute-phase.md binds the orchestrator-validated root into sequential dispatches as ``), cwd-drift sentinel (step 0a, #3097), and absolute-path guard (step 0b, #3099) — loaded into executor spawn prompts via ``. | | `untrusted-input-boundary.md` | Shared prompt-injection boundary (#1577) `@`-included by the 10 research/doc-ingest agents (`gsd-project-researcher`, `gsd-phase-researcher`, `gsd-ui-researcher`, `gsd-assumptions-analyzer`, `gsd-advisor-researcher`, `gsd-doc-classifier`, `gsd-doc-synthesizer`, `gsd-research-synthesizer`, `gsd-ai-researcher`, `gsd-domain-researcher`): treat fetched/read text as data-not-instructions, self-scan before use (PromptArmor 2507.15219), task-anchor (2504.20472), and fence quoted text with a fresh random delimiter per wrap (PPA 2506.05739). Prompt-level defense-in-depth (2503.00061); the hook scanner is a separate pattern pre-filter. | | `artifact-types.md` | Planning artifact type definitions. | | `phase-argument-parsing.md` | Phase argument parsing conventions. | diff --git a/docs/ja-JP/INVENTORY.md b/docs/ja-JP/INVENTORY.md index b68d3e8cf..98c72fa1f 100644 --- a/docs/ja-JP/INVENTORY.md +++ b/docs/ja-JP/INVENTORY.md @@ -302,7 +302,7 @@ | `scout-codebase.md` | discuss-phase スカウトステップ向けのフェーズタイプ→コードベースマップ選択テーブル(discuss-phase/modes プログレッシブディスクロージャー分割により抽出、#717)。 | | `revision-loop.md` | プラン修正の反復パターン。 | | `universal-anti-patterns.md` | 検出して避けるべきユニバーサルアンチパターン。 | -| `worktree-path-safety.md` | ワークツリーガードスイート: HEAD アサーション、cwd ドリフトセンチネル(ステップ 0a、#3097)、絶対パスガード(ステップ 0b、#3099)— `` 経由でエグゼキュータースポーンプロンプトに読み込まれる。 | +| `worktree-path-safety.md` | エグゼキューターパスガード: 供給ルート pin(ステップ 0p、#4254 — 全モード。execute-phase.md がオーケストレーター検証済みルートをシーケンシャルディスパッチに `` として束縛する)、cwd ドリフトセンチネル(ステップ 0a、#3097)、絶対パスガード(ステップ 0b、#3099)— `` 経由でエグゼキュータースポーンプロンプトに読み込まれる。 | | `artifact-types.md` | 計画アーティファクトタイプの定義。 | | `phase-argument-parsing.md` | フェーズ引数の解析規約。 | | `decimal-phase-calculation.md` | 小数サブフェーズの番号付けルール。 | diff --git a/docs/ko-KR/INVENTORY.md b/docs/ko-KR/INVENTORY.md index b015cc182..04faad000 100644 --- a/docs/ko-KR/INVENTORY.md +++ b/docs/ko-KR/INVENTORY.md @@ -302,7 +302,7 @@ | `scout-codebase.md` | discuss-phase 스카우트 단계를 위한 단계 유형→코드베이스 맵 선택 테이블(discuss-phase/modes 프로그레시브 디스클로저 분할을 통해 추출, #717). | | `revision-loop.md` | 계획 수정 반복 패턴. | | `universal-anti-patterns.md` | 감지하고 피해야 할 보편적인 안티패턴. | -| `worktree-path-safety.md` | 워크트리 가드 스위트: HEAD 어설션, cwd-드리프트 센티널(0a단계, #3097), 절대 경로 가드(0b단계, #3099) — ``를 통해 executor 스폰 프롬프트에 로드됨. | +| `worktree-path-safety.md` | 실행기 경로 가드: 공급 루트 pin(0p단계, #4254 — 모든 모드. execute-phase.md가 오케스트레이터 검증 루트를 시퀀셜 디스패치에 ``으로 바인딩), cwd-드리프트 센티널(0a단계, #3097), 절대 경로 가드(0b단계, #3099) — ``를 통해 executor 스폰 프롬프트에 로드됨. | | `artifact-types.md` | 계획 아티팩트 유형 정의. | | `phase-argument-parsing.md` | 단계 인수 파싱 관례. | | `decimal-phase-calculation.md` | 소수점 하위 단계 번호 규칙. | diff --git a/docs/pt-BR/INVENTORY.md b/docs/pt-BR/INVENTORY.md index 532556e0b..2baeb1b3d 100644 --- a/docs/pt-BR/INVENTORY.md +++ b/docs/pt-BR/INVENTORY.md @@ -302,7 +302,7 @@ Registro completo em `gsd-core/references/*.md`. Referências são documentos de | `scout-codebase.md` | Tabela de seleção de tipo de fase → mapa de base de código para a etapa de scout da discuss-phase (extraída via a divisão progressiva discuss-phase/modes, #717). | | `revision-loop.md` | Padrões de iteração de revisão de plano. | | `universal-anti-patterns.md` | Antipadrões universais a detectar e evitar. | -| `worktree-path-safety.md` | Suite de guarda do worktree: asserção de HEAD, sentinela de drift de cwd (etapa 0a, #3097) e guarda de caminho absoluto (etapa 0b, #3099) — carregados nos prompts de spawn do executor via ``. | +| `worktree-path-safety.md` | Guardas de caminho do executor: pin de raiz fornecida (etapa 0p, #4254 — todo modo; o execute-phase.md binda a raiz validada pelo orquestrador em dispatches sequenciais como ``), sentinela de drift de cwd (etapa 0a, #3097) e guarda de caminho absoluto (etapa 0b, #3099) — carregados nos prompts de spawn do executor via ``. | | `artifact-types.md` | Definições de tipos de artefato de planejamento. | | `phase-argument-parsing.md` | Convenções de análise de argumentos de fase. | | `decimal-phase-calculation.md` | Regras de numeração de subfases decimais. | diff --git a/docs/zh-CN/INVENTORY.md b/docs/zh-CN/INVENTORY.md index bc5e92625..5edb65594 100644 --- a/docs/zh-CN/INVENTORY.md +++ b/docs/zh-CN/INVENTORY.md @@ -302,7 +302,7 @@ | `scout-codebase.md` | discuss-phase 侦察步骤的阶段类型→代码库映射选择表(通过 discuss-phase/modes 渐进式披露分割提取,#717)。 | | `revision-loop.md` | 计划修订迭代模式。 | | `universal-anti-patterns.md` | 需要检测和避免的通用反模式。 | -| `worktree-path-safety.md` | Worktree 守卫套件:HEAD 断言、cwd 漂移哨兵(步骤 0a,#3097)和绝对路径守卫(步骤 0b,#3099)— 通过 `` 加载到执行器生成提示中。 | +| `worktree-path-safety.md` | 执行器路径守卫:供给根 pin(步骤 0p,#4254 — 所有模式;execute-phase.md 将编排器已验证的根绑定为顺序派发中的 ``)、cwd 漂移哨兵(步骤 0a,#3097)和绝对路径守卫(步骤 0b,#3099)— 通过 `` 加载到执行器生成提示中。 | | `artifact-types.md` | 规划产物类型定义。 | | `phase-argument-parsing.md` | 阶段参数解析约定。 | | `decimal-phase-calculation.md` | 十进制子阶段编号规则。 | diff --git a/gsd-core/references/worktree-path-safety.md b/gsd-core/references/worktree-path-safety.md index dac806918..bf7b9bb41 100644 --- a/gsd-core/references/worktree-path-safety.md +++ b/gsd-core/references/worktree-path-safety.md @@ -1,7 +1,117 @@ # Worktree Path Safety -Guards for executor agents running inside Claude Code worktrees. Three checks -must run before any staging, Edit, or Write operation in worktree mode. +Guards for executor agents running inside Claude Code worktrees. The +supplied-root pin (step 0p) runs in EVERY mode; the remaining checks run before +any staging, Edit, or Write operation in worktree mode. + +--- + +## Supplied-root pin — step 0p (#4254, EVERY mode) + +Sequential-mode dispatch (no `isolation="worktree"`) gives the executor no +spawn-time cwd guarantee, and the worktree-only guards below do not apply — so +a sequential executor whose process cwd resolved to a different checkout of +the same repo would self-derive that checkout as its root and commit there, +silently. Step 0p closes that hole by comparing the executor's actual root +against a root the ORCHESTRATOR already validated — never against anything the +executor derives itself. + +**Runtime contract (executor):** if your prompt contains a `` +block, run its guard script verbatim before your first Edit/Write and again +before every commit, in the same cwd as that write or commit. On FATAL, halt +and report — recovery (moving commits between checkouts) is an +orchestrator/human decision, never agent self-repair. If your prompt contains +NO `` block (worktree/isolated dispatch, or a legacy +orchestrator), emit one warning line and continue with steps 0a/0b below — do +not fail closed on dispatches that never carried a pin. **Never bind +`{PINNED_ROOT}` yourself**: if this template reaches you unbound it is +reference prose, not your pin — only the orchestrator's build-time +substitution produces a valid guard. + +**Composition contract (orchestrator — build time, NOT a sub-agent runtime +step):** copy the guard below into the dispatched prompt inside a +`` block, substituting `{PINNED_ROOT}` with the literal value +of `$ORCHESTRATOR_WT` captured at execute_waves entry, shell-single-quoted: +wrap the path in `'…'` and escape any embedded `'` as `'\''`. A path that +cannot be quoted this way must halt the phase (surface a blocker) rather than +ship a pin that could mis-parse. The comparison is git-vs-git on BOTH sides — +`git -C` resolves the pinned path to its repo's canonical toplevel in git's +own path representation, so symlink aliases, trailing slashes, `/var` vs +`/private/var` spellings, and Windows drive-letter forms — forward- or +backslash-separated, `RUNNER~1`-style short names included — compare equal by +construction (shell `pwd -P` normalization does NOT match git's emission on +Windows — do not re-introduce it). + +Two portability rules baked into the guard below, learned from the #4254 CI +Windows legs: (1) a backslash comparator must be GENERATED at runtime +(`printf '\134'`), because a backslash written twice in the script text does +not survive the Windows command-line round-trip into bash — the doubled form +arrives halved, which silently rewrites any escape pattern that relies on it; +(2) every FATAL names its `Guard stage` and, where a git capture failed, +git's own stderr in a `Diagnostic` line, so a platform failure self-describes +instead of surfacing as a bare `Actual root: `. + +```bash +# gsd:guard=supplied-root-pin (#4254) — run before the first Edit/Write and before every commit. +PINNED_ROOT='{PINNED_ROOT}' # orchestrator build-time substitution — the only valid source of this value +PIN_STAGE='' +PIN_DIAG='' +gsd_pin_fail() { + echo "FATAL: executor root does not match the orchestrator-supplied PROJECT_ROOT pin (#4254)." >&2 + echo " Pinned root: ${PINNED_ROOT:-}" >&2 + echo " Actual root: ${ACTUAL_ROOT:-}" >&2 + echo " Guard stage: ${PIN_STAGE:-}" >&2 + if [ -n "$PIN_DIAG" ]; then echo " Diagnostic: $PIN_DIAG" >&2; fi + echo " No writes or commits are permitted from this checkout. HALT and report; recovery is an" >&2 + echo " orchestrator/human decision. Only the IMMEDIATE submodule of the pinned checkout is a" >&2 + echo " legitimate other cwd — nested submodules must surface as a blocker, not self-route." >&2 + exit 1 +} +# Backslash comparator, generated at runtime: a backslash written twice in this +# script does not survive the Windows spawn path into bash (the command-line +# round-trip halves the doubled form), which rejected every C:\ pin at the form +# gate on the #4254 CI Windows legs. printf's octal escape is a lone backslash, +# which does survive; the quoted expansion below is literal in a case pattern. +BS=$(printf '\134') +# Fail closed if the comparator could not be generated: an empty BS would widen +# the drive-form arm below to drive-RELATIVE pins (C:foo) — the one fail-open +# seam in this construction, closed loudly rather than trusted to the shell. +if [ -z "$BS" ]; then + PIN_STAGE=form-gate + PIN_DIAG='backslash comparator generation failed (printf octal escape returned empty)' + gsd_pin_fail +fi +case "$PINNED_ROOT" in + ''|'{PINNED_ROOT}') PIN_STAGE=pin-unbound; gsd_pin_fail ;; # empty or unexpanded pin — fail closed, never warn-and-proceed + /*) ;; # absolute POSIX form + [A-Za-z]:/*|[A-Za-z]:"$BS"*) ;; # Windows drive form, forward- or backslash-separated + *) PIN_STAGE=form-gate; gsd_pin_fail ;; # relative pin — never trustworthy across cwds +esac +ACTUAL_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +if [ -z "$ACTUAL_ROOT" ]; then + PIN_STAGE=actual-capture + PIN_DIAG="git rev-parse --show-toplevel from the cwd failed: $(git rev-parse --show-toplevel 2>&1 1>/dev/null)" + gsd_pin_fail +fi +PINNED_TL=$(git -C "$PINNED_ROOT" rev-parse --show-toplevel 2>/dev/null) +if [ -z "$PINNED_TL" ]; then + PIN_STAGE=pinned-capture + PIN_DIAG="git -C rev-parse --show-toplevel failed: $(git -C "$PINNED_ROOT" rev-parse --show-toplevel 2>&1 1>/dev/null)" + gsd_pin_fail +fi +if [ "$ACTUAL_ROOT" != "$PINNED_TL" ]; then + # Registered-submodule allowance: sub_repos plans legitimately commit inside an + # immediate submodule of the pinned checkout. The superproject working tree is + # git-emitted in the same representation as PINNED_TL, so the equality is + # representation-safe on every platform. + SUPER_TL=$(git rev-parse --show-superproject-working-tree 2>/dev/null) + if [ "$SUPER_TL" != "$PINNED_TL" ]; then + PIN_STAGE=root-mismatch + PIN_DIAG="actual=${ACTUAL_ROOT} pinned=${PINNED_TL} superproject=${SUPER_TL:-}" + gsd_pin_fail + fi +fi +``` --- diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index a5ef4ac59..32e7bbf9c 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -815,14 +815,23 @@ increases monotonically across waves. `{status}` is `complete` (success), **Sequential mode** (`USE_WORKTREES_FOR_PLAN` is `false` — either project-level `USE_WORKTREES=false`, or per-plan submodule intersection forced it false in step 2.5): - Omit `isolation="worktree"` from the Agent call. Replace the `` block with: + Omit `isolation="worktree"` from the Agent call. Before composing the prompt, read and execute + `execute-phase/steps/sequential-root-pin.md` (#4254) — it owns the sequential root-pin build-time + embed and the wave serialization rules. + + Replace the `` block with: ``` You are running as a SEQUENTIAL executor agent on the main working tree. Use normal git commits (with hooks). Do NOT use --no-verify. + Run the `` guard before your first Edit/Write and before every commit (#4254). REQUIRED ORDER: Write SUMMARY.md → commit → only then any narration. No text between Write and commit (truncation risk; #2070 rescue is not primary defense). + + + {ORCHESTRATOR build-time embed: bound step-0p guard per sequential-root-pin.md — never this note} + ``` The sequential mode Agent prompt uses the same structure as worktree mode but with these differences in success_criteria — since there is only one agent writing at a time, there are no shared-file conflicts: @@ -837,8 +846,6 @@ increases monotonically across waves. `{status}` is `complete` (success), ``` - When worktrees are disabled for a plan (per-plan or project-level), that plan's executor runs on the main working tree. If **any** plan in the current wave dropped to sequential mode, execute the affected plan(s) **one at a time** to avoid concurrent writes to the main working tree — plans in the same wave that retained worktree isolation can still run in parallel alongside the sequential ones, but two non-worktree plans in the same wave must serialize. When the project-level `USE_WORKTREES=false`, all plans in the wave serialize regardless of the `PARALLELIZATION` setting. - 4. **Wait for all agents in wave to complete.** **Plan-complete heartbeat (#2410):** as each executor returns (or is verified diff --git a/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md b/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md new file mode 100644 index 000000000..a9a87d613 --- /dev/null +++ b/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md @@ -0,0 +1,35 @@ +# Sequential root pin (#4254) + +Apply response_language to all user-facing prose — narration between tool calls, status +updates, progress notes, and findings included; preserve code, paths, and identifiers. + +Read and execute this fragment from `execute-phase.md`'s **Sequential mode** branch before +composing any sequential dispatch prompt. It owns the sequential root-pin build-time embed, +the `` root substitution, and the wave serialization rules. + +## Root pin — ORCHESTRATOR build-time embed (#4254; NOT a sub-agent runtime step) + +Before this dispatch, read `gsd-core/references/worktree-path-safety.md` step 0p +("Supplied-root pin") and copy its guard template into this prompt inside a +`` block, substituting `{PINNED_ROOT}` with the literal value of +`$ORCHESTRATOR_WT` resolved at execute_waves entry, shell-single-quoted per that section's +composition contract — the dispatched prompt must carry the bound, runnable guard verbatim; +do not pass this instruction through in its place. + +In this dispatch's ``, also replace the self-derivation line +`PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)` with +`PROJECT_ROOT=''` — a sequential executor must never +re-derive its root from its own (possibly drifted) cwd. This substitution is sequential-mode +ONLY: worktree-mode dispatches keep the self-derived line — an isolated executor's own +toplevel IS its correct (and intentionally different) worktree, and substituting the +orchestrator's root there would break every worktree-mode dispatch. + +## Wave serialization (moved verbatim from the host step — ADR-857 Phase 6 ceiling, #1168) + +When worktrees are disabled for a plan (per-plan or project-level), that plan's executor runs +on the main working tree. If **any** plan in the current wave dropped to sequential mode, +execute the affected plan(s) **one at a time** to avoid concurrent writes to the main working +tree — plans in the same wave that retained worktree isolation can still run in parallel +alongside the sequential ones, but two non-worktree plans in the same wave must serialize. +When the project-level `USE_WORKTREES=false`, all plans in the wave serialize regardless of +the `PARALLELIZATION` setting. diff --git a/tests/executor-mvp-tdd-section.test.cjs b/tests/executor-mvp-tdd-section.test.cjs index c3c69338c..2e42932e0 100644 --- a/tests/executor-mvp-tdd-section.test.cjs +++ b/tests/executor-mvp-tdd-section.test.cjs @@ -238,3 +238,309 @@ describe('bug #3099: absolute-path safety guidance in gsd-executor.md', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// #4254 — sequential (non-isolated) executor dispatch had no hard pin to the +// orchestrator's own worktree root: the dispatched prompt told the executor to +// self-derive PROJECT_ROOT via `git rev-parse --show-toplevel`, and every +// existing guard (steps 0a/0b worktree-only, step 0 branch-scoped) is +// self-referential — so an executor spawned with a drifted cwd committed onto +// the wrong checkout silently. The fix ships a mode-agnostic "supplied-root +// pin" guard (step 0p) in worktree-path-safety.md, bound at dispatch time by +// execute-phase.md's SEQUENTIAL branch only (worktree mode keeps its own, +// intentionally different, self-derived root). These tests EXECUTE the shipped +// guard against real git fixtures — no string-only vacuity (#4296 review +// precedent, Blocker 2). +// ──────────────────────────────────────────────────────────────────────── +describe('bug #4254: sequential executor supplied-root pin', () => { + // allow-test-rule: source-text-is-the-product (see #3097) — the reference + // and the workflow markdown ARE the product under test. ROOT and + // executePhaseSrc are re-declared here: the folded #3097/#3099 block above + // declares its own copies inside a closure, out of this describe's scope. + const ROOT = path.join(__dirname, '..'); + const executePhaseSrc = fs.readFileSync( + path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md'), 'utf8', + ); + const safetyRefPath = path.join(ROOT, 'gsd-core', 'references', 'worktree-path-safety.md'); + const pinStepPath = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'sequential-root-pin.md'); + const safetySrc = fs.readFileSync(safetyRefPath, 'utf8'); + const pinStepSrc = fs.readFileSync(pinStepPath, 'utf8'); + const { createTempGitProject, cleanup } = require('./helpers.cjs'); + const { runHook } = require('./helpers/process-seam.cjs'); + const { gitOrThrow } = require('./helpers/git-fixture.cjs'); + const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + + const PIN_MARKER = '# gsd:guard=supplied-root-pin'; + + // Extract the shipped guard — the exact ```bash block that carries the + // marker — so the tests can never pass against a stale or hand-copied body. + function extractPinGuard() { + const body = safetySrc.split('```bash\n').find((b) => b.startsWith(PIN_MARKER)); + assert.ok(body, 'worktree-path-safety.md must ship the #4254 supplied-root-pin guard'); + return body.split('```')[0].trim(); + } + + // The composition contract the orchestrator follows at dispatch: a + // shell-single-quoted literal with `'\''` escaping for embedded quotes. + const shellQuote = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`; + + function composeGuard(pinQuotedLiteral) { + return extractPinGuard().replace("PINNED_ROOT='{PINNED_ROOT}'", `PINNED_ROOT=${pinQuotedLiteral}`); + } + + // Run the composed guard in `cwd`; `probe` (optional bash line) runs only if + // the guard passes — proving the write barrier, not just the exit code. + function runPinGuard(cwd, pin, probe) { + return runHook('-c', [composeGuard(shellQuote(pin)) + (probe ? `\n${probe}` : '')], { + interpreter: 'bash', + cwd, + timeoutMs: HOOK_FANOUT_TIMEOUT_MS, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + } + + // Fixture: a primary checkout plus a linked worktree (the orchestrator's + // lane). Sibling path — a worktree inside the primary's tree would show up + // as an untracked directory and muddy the fixtures. + function makeOrchestratorLane(prefix) { + const primary = createTempGitProject(prefix); + const lane = `${primary}-orchestrator-wt`; + gitOrThrow(['worktree', 'add', '-q', '-b', 'phase/2-1', lane], { cwd: primary }); + return { primary, lane }; + } + + test('#4254: reference ships the supplied-root pin section with composition + runtime contracts', () => { + assert.match(safetySrc, /## Supplied-root pin — step 0p \(#4254, EVERY mode\)/); + // Runtime contract: run before first Edit/Write and every commit; HALT on + // FATAL; warn-and-proceed ONLY when the prompt carries no pin block. + assert.match(safetySrc, /before your first Edit\/Write and again\s+before every commit/); + assert.match(safetySrc, /NO `` block[\s\S]*?warning line and continue/); + // The executor must never bind the placeholder itself — that is exactly + // the #4254 failure mode re-armored as a "fix". + assert.match(safetySrc, /Never bind\s+`\{PINNED_ROOT\}` yourself/); + // Composition contract: build-time literal substitution, single-quoted, + // git-vs-git comparison rationale (the #4296 Windows lesson). + assert.match(safetySrc, /substituting[\s\S]*`\{PINNED_ROOT\}`[\s\S]*shell-single-quoted/); + assert.match(safetySrc, /git-vs-git on BOTH sides/); + }); + + test('#4254: RED regression — drifted-cwd executor halts before the wrong-checkout write', () => { + const { primary, lane } = makeOrchestratorLane('gsd-4254-drift-'); + try { + // The orchestrator pinned its own lane; the executor's process cwd + // resolved to the PRIMARY checkout (the issue's exact shape). + const marker = path.join(primary, 'write-marker'); + const res = runPinGuard(primary, lane, `printf 'W' > ${shellQuote(marker)}`); + assert.equal(res.exitCode, 1, `mismatched root must halt, got:\n${res.stdout}\n${res.stderr}`); + assert.equal(fs.existsSync(marker), false, 'no write may run after a root mismatch'); + assert.match(res.stderr, /FATAL[^\n]*#4254/); + // Loud detection: the FATAL names BOTH roots — the pinned lane and the + // actual (wrong) primary checkout. The primary's basename is unique to + // it (the lane appends '-orchestrator-wt'), so matching it inside the + // Actual-root line proves the right checkout is being named. + assert.match(res.stderr, /Pinned root:[^\n]*orchestrator-wt/, 'FATAL must name the pinned root'); + const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); + const primaryName = escapeRegex(path.basename(primary)); + assert.match(res.stderr, new RegExp(`Actual root:[^\\n]*${primaryName}`), 'FATAL must name the actual (wrong) root'); + assert.doesNotMatch(res.stderr, new RegExp(`Actual root:[^\\n]*orchestrator-wt`), 'the actual root must NOT be the pinned lane'); + // The FATAL self-describes its stage (and, on capture failures, git's own + // stderr) — the discriminator whose absence let this class of failure read + // as "capture returns empty" when the form gate was what fired on Windows. + assert.match(res.stderr, /Guard stage: root-mismatch/, 'drifted cwd is a root mismatch, not a capture or form failure'); + assert.match(res.stderr, /Diagnostic: actual=.*pinned=.*superproject=/, 'the mismatch diagnostic names both roots and the absent superproject'); + } finally { + cleanup(primary); + } + }); + + test('#4254: correct-cwd control — matching root permits the write', () => { + const { primary, lane } = makeOrchestratorLane('gsd-4254-match-'); + try { + const marker = path.join(lane, 'write-marker'); + const res = runPinGuard(lane, lane, `printf 'W' > ${shellQuote(marker)}`); + assert.equal(res.exitCode, 0, `matching root must permit the write, got:\n${res.stderr}`); + assert.equal(fs.readFileSync(marker, 'utf8'), 'W'); + } finally { + cleanup(primary); + } + }); + + test('#4254: unexpanded or empty pin fails closed (never warn-and-proceed inside a pin block)', () => { + const { primary, lane } = makeOrchestratorLane('gsd-4254-unbound-'); + try { + const marker = path.join(lane, 'write-marker'); + // Unexpanded: the orchestrator never performed the build-time substitution. + const unexpanded = runHook('-c', [extractPinGuard() + `\nprintf 'W' > ${shellQuote(marker)}`], { + interpreter: 'bash', cwd: lane, timeoutMs: HOOK_FANOUT_TIMEOUT_MS, + }); + assert.equal(unexpanded.exitCode, 1, 'an unexpanded {PINNED_ROOT} must halt'); + assert.match(unexpanded.stderr, /Guard stage: pin-unbound/, 'an unexpanded pin halts at the pin-unbound stage'); + // Empty: substituted to nothing — indistinguishable from a forgotten transplant. + const empty = runHook('-c', [composeGuard("''") + `\nprintf 'W' > ${shellQuote(marker)}`], { + interpreter: 'bash', cwd: lane, timeoutMs: HOOK_FANOUT_TIMEOUT_MS, + }); + assert.equal(empty.exitCode, 1, 'an empty pin must halt'); + assert.match(empty.stderr, /Guard stage: pin-unbound/, 'an empty pin halts at the pin-unbound stage'); + assert.equal(fs.existsSync(marker), false); + } finally { + cleanup(primary); + } + }); + + test('#4254: normalization — trailing slash, symlink alias, subdirectory cwd, and unresolved temp-root spelling all match', () => { + const { primary, lane } = makeOrchestratorLane('gsd-4254-norm-'); + try { + const alias = `${primary}-alias`; + fs.symlinkSync(lane, alias, 'junction'); + const subdir = path.join(lane, 'nested'); + fs.mkdirSync(subdir); + // The unresolved spelling of the temp root (macOS: /var/... vs git's + // resolved /private/var/...): both sides are git-emitted, so the + // comparison is representation-safe by construction. On platforms + // without the /var symlink this degenerates to the realpath form and + // still asserts the matching property. + const unresolvedLane = fs.realpathSync(lane).replace('/private/var/', '/var/'); + for (const pin of [lane, `${lane}/`, alias, unresolvedLane]) { + const marker = path.join(lane, 'write-marker'); + const res = runPinGuard(subdir, pin, `printf 'W' > ${shellQuote(marker)}`); + assert.equal(res.exitCode, 0, `pin form ${pin} must normalize to the same checkout:\n${res.stderr}`); + fs.unlinkSync(marker); + } + } finally { + cleanup(primary); + } + }); + + test('#4254: boundary — registered submodule of the pinned checkout commits; unregistered and sibling checkouts halt', () => { + const primary = createTempGitProject('gsd-4254-super-'); + const subSource = createTempGitProject('gsd-4254-subsource-'); + try { + gitOrThrow(['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', subSource, 'module'], { cwd: primary }); + gitOrThrow(['commit', '-qm', 'chore: register submodule'], { cwd: primary }); + const moduleRoot = path.join(primary, 'module'); + // Registered immediate submodule: legitimate sub_repos work, permitted. + const inModule = path.join(moduleRoot, 'write-marker'); + assert.equal(runPinGuard(moduleRoot, primary, `printf 'W' > ${shellQuote(inModule)}`).exitCode, 0); + fs.unlinkSync(inModule); + // Unregistered nested clone inside the pinned checkout: halts. + const rogue = path.join(primary, 'rogue-clone'); + gitOrThrow(['clone', '-q', subSource, rogue], { cwd: primary }); + const rogueMarker = path.join(rogue, 'write-marker'); + assert.equal(runPinGuard(rogue, primary, `printf 'W' > ${shellQuote(rogueMarker)}`).exitCode, 1); + assert.equal(fs.existsSync(rogueMarker), false); + // Sibling linked worktree of the SAME repo (right repo, wrong checkout — + // the incident's exact shape): halts. + const sibling = `${primary}-sibling-wt`; + gitOrThrow(['worktree', 'add', '-q', '-b', 'phase/9-9', sibling], { cwd: primary }); + const siblingMarker = path.join(sibling, 'write-marker'); + assert.equal(runPinGuard(sibling, primary, `printf 'W' > ${shellQuote(siblingMarker)}`).exitCode, 1); + assert.equal(fs.existsSync(siblingMarker), false); + } finally { + cleanup(primary); + cleanup(subSource); + } + }); + + test('#4254: pin outside any git repo, and cwd outside any git repo, both fail closed', () => { + const { primary, lane } = makeOrchestratorLane('gsd-4254-norepo-'); + const outside = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-4254-outside-')); + try { + assert.equal(runPinGuard(lane, outside).exitCode, 1, 'pin outside a repo must halt'); + assert.equal(runPinGuard(outside, lane).exitCode, 1, 'cwd outside a repo must halt'); + } finally { + cleanup(outside); + cleanup(primary); + } + }); + + test('#4254: shell-metacharacter pin is safely quoted — no command substitution executes', () => { + const { primary, lane } = makeOrchestratorLane('gsd-4254-meta-'); + try { + const tricky = path.join(primary, `q' $HOME $(touch PWNED) x`); + fs.symlinkSync(lane, tricky, 'junction'); + const marker = path.join(lane, 'write-marker'); + const res = runPinGuard(lane, tricky, `printf 'W' > ${shellQuote(marker)}`); + assert.equal(res.exitCode, 0, `quoted metacharacter pin must match its checkout:\n${res.stderr}`); + assert.equal(fs.existsSync(path.join(lane, 'PWNED')), false, 'command substitution in the pin must not execute'); + assert.equal(fs.existsSync(marker), true); + } finally { + cleanup(primary); + } + }); + + test('#4254: relative pin is rejected; Windows drive-letter forms pass the form gate and fail only at the git lookup', () => { + const { primary, lane } = makeOrchestratorLane('gsd-4254-forms-'); + try { + // Relative pins are never trustworthy across cwds — rejected outright, + // and the FATAL says so at the form-gate stage. + const rel = runPinGuard(lane, 'relative/path'); + assert.equal(rel.exitCode, 1); + assert.match(rel.stderr, /Guard stage: form-gate/, 'a relative pin must halt at the form gate'); + // The drive-letter forms Windows produces must be ACCEPTED by the shipped + // guard's absolute-form gate and then fail only on the git lookup — never + // on the form rejection: the forward-slash form git EMITS (C:/…) and the + // backslash form Node's path.join and cmd.exe PRODUCE (C:\…, including + // RUNNER~1-style short names). The Guard stage line is the discriminator: + // pinned-capture means the form passed and `git -C` spoke (its stderr rides + // the Diagnostic line), form-gate means the gate ate the pin — the exact + // CI defect where every backslash pin died before the actual root was ever + // computed. Driving the SHIPPED guard also removes the hand-rolled + // duplicate `case` this row used to carry (the #4296 Minor 1 duplication + // smell, and itself a transit-fragile copy of the broken pattern). + for (const driveForm of ['C:/definitely/not/a/repo', 'C:\\definitely\\not\\a\\repo']) { + const res = runPinGuard(lane, driveForm); + assert.equal(res.exitCode, 1, `nonexistent drive-letter pin must fail closed (${driveForm})`); + assert.match( + res.stderr, /Guard stage: pinned-capture/, + `drive form ${driveForm} must pass the form gate and halt at the pinned-capture stage, got:\n${res.stderr}`, + ); + assert.match(res.stderr, /Diagnostic: git -C rev-parse --show-toplevel failed:/, 'the capture failure carries git stderr'); + } + // Transit hardening, pinned on the shipped text itself: the guard must + // generate its backslash comparator at RUNTIME (printf octal) and must not + // contain a doubled backslash anywhere — a backslash written twice does + // not survive the Windows command-line round-trip into bash (it arrives + // halved, rewriting any escape pattern that relies on it). Two earlier + // pattern spellings failed the Windows CI leg on exactly this. + const guardBody = extractPinGuard(); + assert.doesNotMatch(guardBody, /\\\\/, 'the shipped guard must contain no doubled backslash (Windows transit halves it)'); + assert.match(guardBody, /printf '\\134'/, 'the drive-form gate must generate its backslash comparator at runtime'); + } finally { + cleanup(primary); + } + }); + + test('#4254: execute-phase.md sequential branch pins the orchestrator root at build time', () => { + const sequential = executePhaseSrc.split('**Sequential mode**')[1].split('4. **Wait for all agents')[0]; + assert.ok(sequential.length > 0, 'sequential-mode section not found'); + // The host step delegates the composition detail to the fragment (ADR-857 + // Phase 6 frozen ceiling — execute-phase.md cannot grow) and the prompt + // gains the pin block plus the per-write/commit instruction. + assert.match(sequential, /read and execute\s+`execute-phase\/steps\/sequential-root-pin\.md`/); + assert.match(sequential, //); + assert.match(sequential, /Run the `` guard before your first Edit\/Write and before every commit/); + // The fragment carries the full build-time embed contract. + assert.match(pinStepSrc, /ORCHESTRATOR build-time embed/); + assert.match(pinStepSrc, /\{PINNED_ROOT\}/); + assert.match(pinStepSrc, /ORCHESTRATOR_WT/); + assert.match(pinStepSrc, /do not pass this instruction through/i); + // The self-derivation is replaced with the literal, preserving the + // `PROJECT_ROOT=` binding the rest of depends on. + assert.match(pinStepSrc, /replace the self-derivation line/); + assert.match(pinStepSrc, /PROJECT_ROOT=' { + const isolated = executePhaseSrc.slice( + executePhaseSrc.indexOf(''), + executePhaseSrc.indexOf('**Sequential mode**'), + ); + assert.ok(isolated.length > 0, 'worktree-mode dispatch slice not found'); + assert.match(isolated, /PROJECT_ROOT=\$\(git rev-parse --show-toplevel/, 'isolated executor keeps its own (correct) root derivation'); + assert.doesNotMatch(isolated, //, 'isolated prompt must not inherit the orchestrator root'); + }); +}); diff --git a/tests/fixtures/install-tree/antigravity.json b/tests/fixtures/install-tree/antigravity.json index 8943408be..b0f94cefa 100644 --- a/tests/fixtures/install-tree/antigravity.json +++ b/tests/fixtures/install-tree/antigravity.json @@ -394,6 +394,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/augment.json b/tests/fixtures/install-tree/augment.json index 588734b0b..00f8f292e 100644 --- a/tests/fixtures/install-tree/augment.json +++ b/tests/fixtures/install-tree/augment.json @@ -466,6 +466,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/claude-local.json b/tests/fixtures/install-tree/claude-local.json index 3cf79d7da..dbdefbd0f 100644 --- a/tests/fixtures/install-tree/claude-local.json +++ b/tests/fixtures/install-tree/claude-local.json @@ -359,6 +359,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/claude.json b/tests/fixtures/install-tree/claude.json index 95195ce26..cad6eb778 100644 --- a/tests/fixtures/install-tree/claude.json +++ b/tests/fixtures/install-tree/claude.json @@ -394,6 +394,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/cline.json b/tests/fixtures/install-tree/cline.json index 6149f0fd9..edce069ac 100644 --- a/tests/fixtures/install-tree/cline.json +++ b/tests/fixtures/install-tree/cline.json @@ -396,6 +396,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/codebuddy.json b/tests/fixtures/install-tree/codebuddy.json index 18f097b05..14f1133bc 100644 --- a/tests/fixtures/install-tree/codebuddy.json +++ b/tests/fixtures/install-tree/codebuddy.json @@ -466,6 +466,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/codex.json b/tests/fixtures/install-tree/codex.json index c0b65addc..cdb5f8445 100644 --- a/tests/fixtures/install-tree/codex.json +++ b/tests/fixtures/install-tree/codex.json @@ -430,6 +430,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/copilot.json b/tests/fixtures/install-tree/copilot.json index 92efd5ee9..8108a27ea 100644 --- a/tests/fixtures/install-tree/copilot.json +++ b/tests/fixtures/install-tree/copilot.json @@ -395,6 +395,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/cursor.json b/tests/fixtures/install-tree/cursor.json index eb4503859..868a8cf07 100644 --- a/tests/fixtures/install-tree/cursor.json +++ b/tests/fixtures/install-tree/cursor.json @@ -394,6 +394,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/hermes.json b/tests/fixtures/install-tree/hermes.json index d0a1b9ac0..dbcf40faf 100644 --- a/tests/fixtures/install-tree/hermes.json +++ b/tests/fixtures/install-tree/hermes.json @@ -394,6 +394,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/kilo.json b/tests/fixtures/install-tree/kilo.json index d43d47aa4..7f9faf98e 100644 --- a/tests/fixtures/install-tree/kilo.json +++ b/tests/fixtures/install-tree/kilo.json @@ -466,6 +466,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/kimi-code.json b/tests/fixtures/install-tree/kimi-code.json index b0970bd63..46870aaea 100644 --- a/tests/fixtures/install-tree/kimi-code.json +++ b/tests/fixtures/install-tree/kimi-code.json @@ -395,6 +395,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/kimi.json b/tests/fixtures/install-tree/kimi.json index dcc0bac5c..f551fbeea 100644 --- a/tests/fixtures/install-tree/kimi.json +++ b/tests/fixtures/install-tree/kimi.json @@ -431,6 +431,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/opencode.json b/tests/fixtures/install-tree/opencode.json index 366cf8ec7..6faa1ca62 100644 --- a/tests/fixtures/install-tree/opencode.json +++ b/tests/fixtures/install-tree/opencode.json @@ -466,6 +466,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/pi.json b/tests/fixtures/install-tree/pi.json index d4fb2bc2d..8b8b68466 100644 --- a/tests/fixtures/install-tree/pi.json +++ b/tests/fixtures/install-tree/pi.json @@ -254,6 +254,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/qwen.json b/tests/fixtures/install-tree/qwen.json index 99ac81efa..edc7463cc 100644 --- a/tests/fixtures/install-tree/qwen.json +++ b/tests/fixtures/install-tree/qwen.json @@ -394,6 +394,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/trae.json b/tests/fixtures/install-tree/trae.json index b1e4423f6..8050cd141 100644 --- a/tests/fixtures/install-tree/trae.json +++ b/tests/fixtures/install-tree/trae.json @@ -394,6 +394,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/windsurf.json b/tests/fixtures/install-tree/windsurf.json index d067cde15..38cebc17d 100644 --- a/tests/fixtures/install-tree/windsurf.json +++ b/tests/fixtures/install-tree/windsurf.json @@ -322,6 +322,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/fixtures/install-tree/zcode.json b/tests/fixtures/install-tree/zcode.json index 9b4bc9ae2..510831b62 100644 --- a/tests/fixtures/install-tree/zcode.json +++ b/tests/fixtures/install-tree/zcode.json @@ -466,6 +466,7 @@ "gsd-core/workflows/execute-phase/steps/protected-branch.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/sequential-root-pin.md", "gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md", "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", diff --git a/tests/worktree-safety.test.cjs b/tests/worktree-safety.test.cjs index 02cd6f9c6..ecd24c2ee 100644 --- a/tests/worktree-safety.test.cjs +++ b/tests/worktree-safety.test.cjs @@ -6502,11 +6502,30 @@ describe('execute-phase.md dispatch wires USE_WORKTREES_FOR_PLAN (#2772)', () => }); test('"Worktrees disabled" sequential rule is documented per-plan, not project-level', () => { + // #4254 moved the wave-serialization rules into the sequential-root-pin step + // fragment (ADR-857 Phase 6 frozen ceiling — the host step cannot grow), so the + // rule is asserted where it now lives AND that the host step still wires the + // fragment in at the Sequential mode branch. + const pinFragmentPath = path.join( + __dirname, + '..', + 'gsd-core', + 'workflows', + 'execute-phase', + 'steps', + 'sequential-root-pin.md' + ); + const frag = fs.readFileSync(pinFragmentPath, 'utf-8'); + assert.match( + frag, + /worktrees are disabled for a plan/i, + 'sequential-execution rule must be expressed per-plan (in the sequential-root-pin fragment)' + ); const md = fs.readFileSync(workflowPath, 'utf-8'); assert.match( md, - /worktrees are disabled for a plan/i, - 'sequential-execution rule must be expressed per-plan' + /execute-phase\/steps\/sequential-root-pin\.md/, + 'execute-phase.md must wire the sequential-root-pin fragment at the Sequential mode branch' ); });