fix(#4254): pin sequential executor to the orchestrator's validated root (#4476)

* test(#4254): sequential executor root pin — failing-first regression + matrix

The new suite executes the shipped supplied-root-pin guard against real git
fixtures (drifted primary-checkout cwd halts before the write and the FATAL
names both roots; matching cwd permits it; unexpanded/empty pins halt;
normalization forms; submodule and sibling boundaries; metacharacter quoting;
drive-letter form gate) and locks the dispatch contract across execute-phase.md,
its sequential-root-pin step fragment, and worktree-path-safety.md. The #2772
per-plan serialization assertion retargets to the fragment that now carries
those rules (ADR-857 Phase 6 ceiling), plus the host-step wiring.

* fix(#4254): pin sequential executor to the orchestrator's validated root

Sequential-mode dispatch told the executor to self-derive PROJECT_ROOT from its
own cwd; every existing guard is worktree-mode-only or self-referential, so an
executor spawned with a drifted cwd committed onto the wrong checkout silently.

- worktree-path-safety.md step 0p: mode-agnostic supplied-root pin guard,
  composed by the orchestrator at build time with the literal $ORCHESTRATOR_WT
  (git-vs-git comparison on both sides — representation-safe on Windows, the
  #4296 lesson), fail-closed on empty/unexpanded pins, registered-submodule
  allowance, warn-and-proceed only when the dispatch carries no pin block.
- execute-phase.md sequential branch: build-time embed of the bound
  <project_root_pin> via the new execute-phase/steps/sequential-root-pin.md
  fragment (ADR-857 Phase 6 frozen ceiling — the host step cannot grow; the
  wave serialization rules move with the fragment, verbatim in substance) plus
  the per-write/commit pin instruction in <sequential_execution>. Worktree-mode
  dispatch untouched (its self-derived toplevel IS correct there).
- INVENTORY rows (5 locales) + INVENTORY-MANIFEST + install-tree goldens
  regenerated for the new fragment; changeset added.

* chore(#4254): backfill changeset PR number

* fix(#4254): accept backslash-separated Windows drive pins

CI on windows-latest showed every permit-path test failing with
"Actual root: <none>": pins composed from Node's path.join arrive in the
backslash drive form (C:\Users\RUNNER~1\...), which the guard's absolute-form
gate rejected before the cwd-side root was ever computed — a legitimate
matching pin could never pass. The gate now accepts either separator
([A-Za-z]:[\\/]); git -C resolves both forms (and 8.3 short names) to the
same canonical toplevel, so the git-vs-git comparison is unaffected. Form-gate
tests cover the emitted (C:/…) and produced (C:\…) spellings plus short names.

* fix(#4254): portable drive-form gate for MSYS bash

The bracket class [\\/] that accepted backslash drive pins parses
inconsistently on MSYS bash (the Windows CI leg still rejected C:\ pins —
every permit-path test red with "Actual root: <none>"). Replace it with
standard pattern escaping outside brackets: [A-Za-z]:/*|[A-Za-z]:\\* —
the escape form is version- and build-portable. Verified across all forms:
both drive spellings accepted; bare "C:", relative, empty, and unexpanded
rejected.

* fix(#4254): runtime-generated backslash comparator + self-describing FATAL

The Windows CI legs failed every #4254 permit-path row with
'Actual root: <none>' across two prior pattern spellings ([\\/] and \\*).
Stage misattribution: <none> appears whenever the FATAL fires BEFORE the
cwd-side capture assigns ACTUAL_ROOT — the absolute-form gate was what fired.

Mechanism: the test harness spawns bash -c <script> through the Windows
command-line boundary; that round-trip applies one extra shell-quoting pass
with double-quote semantics — a backslash written twice in the script text
arrives halved, while a lone backslash survives (the pin displays intact;
row 9's pure-bash gate independently showed the halved pattern rejecting
C:\ while C:/ still passed its surviving arm). On windows-latest every pin
carries backslashes (os.tmpdir() is the 8.3 short form C:\Users\RUNNER~1\...),
so the gate ate every pin before the actual root was ever computed.

Fix, robust by construction:
- the drive-form gate generates its backslash comparator at RUNTIME
  (BS=$(printf '\134'); match [A-Za-z]:"$BS"*) — the shipped guard now
  contains no doubled backslash anywhere, enforced by a regression
  assertion on the extracted guard text;
- the FATAL self-describes: Guard stage (pin-unbound / form-gate /
  actual-capture / pinned-capture / root-mismatch) plus a Diagnostic line
  carrying git's own stderr for capture failures and both compared values
  for mismatches — future platform failures name their stage in the log;
- row 9's hand-rolled duplicate case gate (transit-fragile copy, #4296
  Minor 1 duplication smell) is replaced by driving the SHIPPED guard and
  asserting the stage; rows 2/4 pin the new stage machinery.

Validated on darwin across drift/match/relative/unbound/empty/bare-drive/
forward-and-backslash drive forms, each also re-run under a simulated
Windows transit (every doubled backslash halved) with identical outcomes.

* fix(#4254): close the empty-comparator fail-open seam in the drive-form gate

Self-review of the runtime-generated backslash comparator: if printf's
octal escape ever returned empty, the drive arm [A-Za-z]:"$BS"* would
widen to drive-RELATIVE pins (C:foo) — the construction's one theoretical
fail-open path. Fail closed with a self-describing diagnostic instead of
trusting the shell's printf.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-07 10:54:30 -04:00
committed by GitHub
parent 2388e6ab34
commit 476394689a
31 changed files with 514 additions and 12 deletions

View File

@@ -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)

View File

@@ -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",

View File

@@ -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 <name> --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_<ROLE>}` (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 `<execution_context>`. |
| `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 `<project_root_pin>`), cwd-drift sentinel (step 0a, #3097), and absolute-path guard (step 0b, #3099) — loaded into executor spawn prompts via `<execution_context>`. |
| `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. |

View File

@@ -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)— `<execution_context>` 経由でエグゼキュータースポーンプロンプトに読み込まれる。 |
| `worktree-path-safety.md` | エグゼキューターパスガード: 供給ルート pin(ステップ 0p、#4254 — 全モード。execute-phase.md がオーケストレーター検証済みルートをシーケンシャルディスパッチに `<project_root_pin>` として束縛する)、cwd ドリフトセンチネル(ステップ 0a、#3097)、絶対パスガード(ステップ 0b、#3099)— `<execution_context>` 経由でエグゼキュータースポーンプロンプトに読み込まれる。 |
| `artifact-types.md` | 計画アーティファクトタイプの定義。 |
| `phase-argument-parsing.md` | フェーズ引数の解析規約。 |
| `decimal-phase-calculation.md` | 小数サブフェーズの番号付けルール。 |

View File

@@ -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) — `<execution_context>`를 통해 executor 스폰 프롬프트에 로드됨. |
| `worktree-path-safety.md` | 실행기 경로 가드: 공급 루트 pin(0p단계, #4254 — 모든 모드. execute-phase.md가 오케스트레이터 검증 루트를 시퀀셜 디스패치에 `<project_root_pin>`으로 바인딩), cwd-드리프트 센티널(0a단계, #3097), 절대 경로 가드(0b단계, #3099) — `<execution_context>`를 통해 executor 스폰 프롬프트에 로드됨. |
| `artifact-types.md` | 계획 아티팩트 유형 정의. |
| `phase-argument-parsing.md` | 단계 인수 파싱 관례. |
| `decimal-phase-calculation.md` | 소수점 하위 단계 번호 규칙. |

View File

@@ -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 `<execution_context>`. |
| `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 `<project_root_pin>`), sentinela de drift de cwd (etapa 0a, #3097) e guarda de caminho absoluto (etapa 0b, #3099) — carregados nos prompts de spawn do executor via `<execution_context>`. |
| `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. |

View File

@@ -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)— 通过 `<execution_context>` 加载到执行器生成提示中。 |
| `worktree-path-safety.md` | 执行器路径守卫:供给根 pin(步骤 0p,#4254 — 所有模式;execute-phase.md 将编排器已验证的根绑定为顺序派发中的 `<project_root_pin>`)、cwd 漂移哨兵(步骤 0a,#3097)和绝对路径守卫(步骤 0b,#3099)— 通过 `<execution_context>` 加载到执行器生成提示中。 |
| `artifact-types.md` | 规划产物类型定义。 |
| `phase-argument-parsing.md` | 阶段参数解析约定。 |
| `decimal-phase-calculation.md` | 十进制子阶段编号规则。 |

View File

@@ -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 `<project_root_pin>`
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 `<project_root_pin>` 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
`<project_root_pin>` 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: <none>`.
```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:-<empty or unexpanded>}" >&2
echo " Actual root: ${ACTUAL_ROOT:-<none>}" >&2
echo " Guard stage: ${PIN_STAGE:-<unset>}" >&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 <pinned root> 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:-<none>}"
gsd_pin_fail
fi
fi
```
---

View File

@@ -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 `<parallel_execution>` 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 `<parallel_execution>` block with:
```
<sequential_execution>
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 `<project_root_pin>` 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).
</sequential_execution>
<project_root_pin>
{ORCHESTRATOR build-time embed: bound step-0p guard per sequential-root-pin.md — never this note}
</project_root_pin>
```
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),
</success_criteria>
```
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

View File

@@ -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 `<required_reading>` 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
`<project_root_pin>` 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 `<required_reading>`, also replace the self-derivation line
`PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)` with
`PROJECT_ROOT='<the same literal $ORCHESTRATOR_WT>'` — 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.

View File

@@ -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 `<project_root_pin>` 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=<none>/, '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 <pinned root> 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, /<project_root_pin>/);
assert.match(sequential, /Run the `<project_root_pin>` 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 <required_reading> depends on.
assert.match(pinStepSrc, /replace the self-derivation line/);
assert.match(pinStepSrc, /PROJECT_ROOT='<the same literal/);
// Scoping: the fragment must say worktree mode keeps the self-derived form.
assert.match(pinStepSrc, /sequential-mode\s+ONLY/i);
// The wave serialization rules moved with it, verbatim in substance.
assert.match(pinStepSrc, /two non-worktree plans in the same wave must serialize/);
});
test('#4254: worktree-mode dispatch is untouched — keeps self-derivation, gains no pin', () => {
const isolated = executePhaseSrc.slice(
executePhaseSrc.indexOf('<parallel_execution>'),
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, /<project_root_pin>/, 'isolated prompt must not inherit the orchestrator root');
});
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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'
);
});