Files
msd-core/tests/execute-phase-wave.test.cjs
Behruz Nassre Esfahani 2076d450d7 fix(#2652): gate quick/diagnose dispatch on dispatch.isolation, not the runtime name (#2728)
* fix(#2652): gate quick/diagnose dispatch on dispatch.isolation, not runtime name

quick.md and diagnose-issues.md kept the pre-#2584 `RUNTIME != "claude"`
worktree gate, so every non-Claude runtime failed closed regardless of the
capability it negotiated — including Codex, which declares
orchestrator-worktree. Route both through the negotiated dispatch.isolation
seam via a new shared reference, and migrate the two execute-phase reference
fragments that carried the same runtime-name gate.

- new gsd-core/references/dispatch-isolation-gate.md: canonical ISOLATION
  resolution, harness-flag resolution, single-agent degrade rule
- quick.md / diagnose-issues.md read the gate; dispatch uses the {harnessFlag}
  placeholder rather than a hardcoded isolation="worktree"
- execute-phase-wave-guard.md / execute-phase-between-wave-reset.md: migrate
  [ "$RUNTIME" = "claude" ] -> [ "$ISOLATION" = "harness-worktree" ]
- every degrade site now clears BOTH USE_WORKTREES and ISOLATION; clearing one
  dispatched an isolated agent with no base guard and no manifest
- parity guard in host-integration.test.cjs scans workflows AND references and
  matches six reintroduction shapes
- migrate four tests that pinned the pre-#2584 runtime-name contract

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2652): use the /gsd:<cmd> namespace in the isolation degrade messages

The degrade warnings cited /gsd-execute-phase, the retired hyphen form that
slash-command-namespace.test.cjs rejects in Claude-facing source. Same length,
so the quick.md size budget is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2652): add changeset for PR #2728

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2652): normalize dispatch-site paths to forward slashes for Windows

path.relative() returns backslash-separated paths on Windows, so the
#2652 dispatch-site parity test compared "gsd-core\workflows\quick.md"
against the hardcoded forward-slash literal "gsd-core/workflows/quick.md"
and failed on every windows-latest CI lane. Normalize with
.replace(/\\/g, '/'), matching the existing convention used elsewhere in
this suite (e.g. tests/branch-no-track-guard.test.cjs:37).

* test(#2652): restore the size-growth acknowledgment

The rebase dropped tests/emitted-drift-ack.json. #2757/#2758 fixed the
ATTRIBUTION axis, but the SIZE-GROWTH axis is independent: diagnose-issues.md
(+2086) and quick.md (+230) still need an ack naming them and saying why.

Verified: 65/66 without it (both files named), 66/66 with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2652): convert execute-plan.md Pattern A onto the dispatch-isolation gate

Pattern A hardcoded `isolation="worktree"` — Claude Code's own literal —
gated only on `workflow.use_worktrees`, with no capability negotiation at
all. It is the same defect #2652 fixes at the other four sites, just a
different shape: the file contains no RUNTIME variable, so the new detector
correctly does not flag it.

Concrete break: a Codex user who follows this PR's own newly-documented
pattern and sets `workflow.use_worktrees: true` to get isolated dispatch via
/gsd:quick then runs a plan through /gsd-execute-plan Pattern A, and hits an
unconverted path — either an Agent() call erroring on an unrecognized
parameter or silent unisolated execution, depending on host tolerance.

Pattern A is a single-agent dispatch site through the host's own subagent
tool, so it takes the same treatment as quick.md and diagnose-issues.md:
resolve ISOLATION/HARNESS_FLAG through the canonical reference, degrade to
sequential on orchestrator-worktree hosts, and substitute the host's declared
{harnessFlag} instead of Claude Code's literal.

while the area was open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(#2652): add the INVENTORY row for dispatch-isolation-gate.md, refresh CONTEXT

Two bookkeeping gaps flagged in review:

INVENTORY.md had no row for the new gsd-core/references/dispatch-isolation-gate.md.
INVENTORY-MANIFEST.json was regenerated correctly and its --check only diffs a
live directory scan against the committed manifest, so CI passed regardless —
but gen-inventory-manifest.cjs's own stderr guidance says to add the matching
INVENTORY.md row. This is the repo's named "Inventory Drift" pattern. Placed
with the dispatch/isolation cluster (worktree-branch-check, runtime-aware-dispatch)
rather than alphabetically, matching how that table is grouped.

CONTEXT.md's Host-Integration Interface entry still described dispatch.isolation
as "declared and negotiated but not yet consumed by any scheduler — Phase 1 of
#2584". That was already stale before this PR (execute-phase graduated in Phase 3)
and more so now with three single-agent dispatch sites consuming it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2652): detect reversed-operand runtime gates; add a permutation property

All five reintroduction regexes assumed $RUNTIME on the LEFT of the
comparison, so `[ "claude" != "$RUNTIME" ]` — the same gate written
backwards — evaded every one of them. Verified against the old patterns
before fixing: all four reversed shapes (single bracket, double bracket,
test builtin, JS template) scored EVADED.

Each comparison shape is now generated in both operand orders from a single
template, so a shape cannot be added in one order and forgotten in the other.
The mutation table gains the four reversed cases.

Also adds the fast-check property review suggested in place of the hand-rolled
cases: it generates the cross product of the axes an author actually varies —
bracket form, operator, operand order, quoting, spacing, runtime id — so a
permutation the hand-written patterns miss surfaces here rather than in
production. The 11 explicit cases stay as named regression anchors.

execute-plan.md joins the scan's required-identities list now that it is a
converted dispatch site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2652): acknowledge the execute-plan.md size growth

The Pattern A conversion adds 811 bytes to an emitted workflow. Per #2719 the
size axis needs its own acknowledgment, independent of attribution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2652): repin the execute-plan.md PROSE_ALLOWLIST line after the rebase

The #2751 command-position gate pins its prose exemptions by line number.
This branch inserts the dispatch-isolation resolution above the
`validated downstream by gsd-tools uat classify-coverage` sentence, moving
it from execute-plan.md:387 to :397 — which fired the gate twice for one
displacement (an un-allowlisted mention at 397, a stale entry at 387).
The prose itself is unchanged from next; only the pin moves.

Fixes #2652

* fix(#2652): gate the #2649 base-check on ISOLATION in diagnose-issues.md

The rebase onto next merged #2649's pre-dispatch base-check textually, but
its degrade flipped USE_WORKTREES after ISOLATION was already resolved, so
the degrade never reached the dispatch decision. Gate the block on
ISOLATION = "harness-worktree" and degrade ISOLATION itself, the same
pairing quick.md already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#2652): key quick.md post-dispatch bookkeeping on ISOLATION, not the Claude literal

Review Blocker: the manifest append (l.822), worktree merge-back (l.825), and
its skip clause (l.839) all conditioned on the literal isolation="worktree" —
Claude Code's own rendering of {harnessFlag}. Cursor renders --worktree, so a
newly-unblocked isolated Cursor run created a worktree whose committed work
was never merged back and never cleaned up, silently. All three now key on
ISOLATION = "harness-worktree" at dispatch.

The existing parity detector cannot catch this class (its ISOLATION_TOKEN
treats the literal as a legitimate marker), so this adds a dedicated
literal-condition detector with a discrimination proof against both pre-fix
sentences, a benign-mention control, and a positive pin on all three
re-keyed conditions. Verified fail-first against the pre-fix quick.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#2652): scope the use_worktrees=false install stamp to isolation=none runtimes

`_stampNonClaudeRuntimeDefaults` rewrote every non-Claude runtime's
`workflow.use_worktrees` read to `--default false`. That default resolved
before `gsd_run query dispatch-isolation` was ever consulted, so the five
runtimes that declare worktree support — cursor (harness-worktree) and
codex/opencode/kimi/kimi-code (orchestrator-worktree) — got ISOLATION=none
regardless of what they negotiated. The gate this PR migrates dispatch onto
was therefore still deciding isolation by runtime name, one layer down.

The stamp's #1521 premise was that worktree isolation *was* Claude Code's
isolation="worktree" spawn parameter, which no other host honored. #2584
replaced that premise with the negotiated capability. The stamp is now scoped
to runtimes whose negotiated isolation really is `none`, where the default it
writes is the outcome the resolver reaches anyway.

`_negotiatedDispatchIsolation` mirrors routeDispatchIsolation's resolution
against the same registry — closed vocabulary, a harness-worktree host must
declare its flag, an orchestrator-worktree host must carry a descriptor that
resolves — and fails closed to `none` on anything else, so an undeclared or
unknown runtime keeps today's behavior.

Two #1515 tests pinned the superseded premise for codex and are re-pointed at
the new contract rather than deleted: the safety property they protect is now
held by the isolation gate's fail-closed resolution, not by a name-scoped
install-time default. Verified fail-first — all five assertions red against
the pre-fix source, green after.

* test(#2652): acknowledge the emitted ripple and re-point the end-to-end stamp proof

Scoping the use_worktrees stamp changes emitted output, and two gates caught it.

`gsd-core/workflows/execute-phase.md` now differs at emit time for the five
hosts that declare worktree support (cursor harness-worktree; codex, opencode,
kimi, kimi-code orchestrator-worktree) — the source file is byte-identical, only
the stamp is gone. Acknowledged in this PR's fragment.

`tests/install.test.cjs`'s real-install assertion pinned the superseded premise
end-to-end, asserting codex receives `--default false`. Re-pointed rather than
deleted, matching the two unit tests: it now proves codex keeps the unstamped
`true` read. A second arm installs windsurf — which declares isolation `none` —
and asserts the false stamp is still applied there, so the change cannot
silently degrade into "never stamp" without a test noticing.

The ack entry collides with `2658-trae-instruction-file-path.json`, which is
fully spent (merged via #2925, so all 25 of its entries are present at base and
gate nothing) and is pruned for the same reason and by the same rule as the
spent `2649-*` fragment this PR already removed. #2566 prunes the same file for
the same collision on `new-project.md`; a delete/delete merges cleanly either
way, and the base-side cleanup would make both unnecessary.

* fix(#2652): re-record the sentinel when a dispatch site degrades isolation

Review Blocker B1/B2/B3. Every isolation degrade in a dispatch site is decided
in shell, where routeDispatchIsolation cannot see it. That resolver persists
whatever it resolved to the run-scoped sentinel as an unconditional side effect
(#3045), so a degrade that only reassigns $ISOLATION leaves the sentinel
asserting harness-worktree while the dispatch correctly omits the harness flag.
The shipped PreToolUse guard reads the sentinel at the instant of the Agent()
call and denies that mismatch with exit 2 — the work does not run unisolated,
it does not run at all. Latent on this branch and lands on rebase, since
8f75e275 (#3045) is not yet in the merge-base.

Four sites now push the final shell-computed value through the same single
write path with --force-isolation, matching the idiom #3045 established in
executor-isolation-dispatch.md:

  - quick.md, after the #1941 base-check degrade
  - diagnose-issues.md, after the config-gate degrades and after #2649's
  - execute-plan.md Pattern A, before spawning
  - references/dispatch-isolation-gate.md, both degrade paths, plus a new
    "Re-record after every degrade" section — the canonical file taught the
    defect, so fixing only the call sites would leave the source of truth wrong

Tests assert the RECORDED value, not $ISOLATION. Asserting the local variable
is what let this class through: $ISOLATION was already `none` at every site and
the defect was entirely in what reached the sentinel. Each workflow's own
degrade block is executed under a gsd_run stub that captures the write, with a
fail-first proof that the pre-fix shape records nothing (while $ISOLATION reads
`none` in both), plus a coverage guard so a new degrade site cannot skip it.

Also corrects the drift-ack rationale (review Minor 5): @-references are
eagerly inlined, so extracting the gate does not reduce loaded context. The
reason to extract is single-sourcing across five dispatch sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2652): satisfy the new CRLF-portability and cleanup lint rules in host-integration.test.cjs

next's local/no-crlf-fragile-split and no-raw-rmsync-in-tests rules now
cover the fenced-block regexes, log-line split, and temp-dir removal this
suite added: bash-fence matchers and line counting accept \r\n, and the
raw fs.rmSync becomes helpers.cleanup (Windows-EBUSY retry budget).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#2652): restore next's 2658 ack fragment, minus the one colliding key

trek-e (PR #2728, 2026-08-07): the branch deleted
tests/emitted-drift-acks/2658-trae-instruction-file-path.json wholesale
while next had modified it. That was correct against the 08-03 base, where
the fragment was fully spent; it is wrong against next @ 1d208e5a, which
still carries 23 live entries.

next's copy is restored byte-identical except for the single key that
genuinely collides with this PR's own fragment,
gsd-core/workflows/execute-phase.md. Both acks name that path for
different deltas -- 2658's is the trae CLAUDE.md replacement-target
rewrite, ours is the emit-time _stampNonClaudeRuntimeDefaults ripple from
review round 3. Per the ack-lifecycle law (#2789), an entry already at the
base is spent and inert, so this PR's entry is the live one and 2658's is
dropped.

This follows the guidance given on #2566 in the 08-06 round: "Regenerate
rather than delete -- the collision is one entry."

Verified: lint-emitted-drift-ack ok (0 problems, 357 keys, no cross-source
duplicates); emitted-attribution 170/170 with GSD_EMITTED_BASE=upstream/next;
host-integration 222/222; runtime-converters 130/130.

* fix(#2652): bound the degrade-harness spawn and close the round-6 majors

B1 (CI red, ours): tests/host-integration.test.cjs spawned bash with no
timeout, violating local/no-unbounded-spawn. `next` deleted the allowlist
outright (#3148), so the merge-commit run flags it even though this branch
still carries the file's grandfathered entry. Bounded at 15s, with a named
failure on timeout/signal rather than an opaque `exited null`.

M1: add a parity test between `_negotiatedDispatchIsolation` (install time)
and `routeDispatchIsolation` (dispatch time). Both read the same capability
registry and the same `resolveOrchestratorExec`, but duplicate the DECISION
on top of them across two surfaces with no call edge between them, so neither
symbol appears in the other's impact graph and nothing static can catch them
drifting apart. The resolver leg drives the real gsd-tools CLI per registered
runtime, both ways it is really called: with `--cwd-target` (the executor
spawn, which resolves the orchestrator descriptor — the same question install
time asks) and without it (the `Resolve ISOLATION` call every dispatch site
makes first, which does not). The second leg is what catches an orchestrator
host whose descriptor stops resolving: the install would stamp
`use_worktrees=false` while the workflow gate still reported
`orchestrator-worktree`.

M2: add install-level Cursor coverage. A real `--cursor` install, then the
gate blocks that install emitted, run against the gsd-tools that install
emitted, with the runtime declared through `.planning/config.json` — the tier
`resolveRuntime` actually reads — and any ambient GSD_RUNTIME blanked, so the
install has to reach the right resolver on its own. It then performs the
documented `{harnessFlag}` substitution against the `Agent()` call that
install emitted and asserts on the rendered dispatch: exactly one emitted
Agent() call carries the slot, it is the gsd-executor / gsd-debugger dispatch
rather than some other call in the same file, and rendering it yields
`--worktree` with no residual placeholder and no `isolation="worktree"`.
This is artifact-level — it proves the emitted wiring, not a live Cursor
host invocation. Asserting the shell variable alone would have stayed green
if the placeholder were deleted from the emitted dispatch, or drifted onto
the reviewer call beside it.

M3: diagnose-issues.md inlined a reordered copy of the reference this PR
introduces as the single source of truth. It now reads the reference the
same way quick.md and execute-plan.md do; the drift-ack entry is corrected
to describe what the file actually contains, and to name the four files that
reference the gate rather than claiming five.

M4: CONTEXT.md still called `resolveOrchestratorExec` UNCONSUMED in the same
paragraph this PR edits. It has been consumed since #2584 Phase 3 — routed
through `query dispatch-isolation --json` and process-spawned by
executor-isolation-dispatch.md — and #2652 adds a second consumer.

Every new assertion verified fail-first against a real mutation: cursor's
negotiated isolation (breaks the target-bound parity leg), the no-target
orchestrator branch in routeDispatchIsolation (breaks the gate parity leg),
cursor's harnessIsolationFlag (breaks the resolved value), deleting
`{harnessFlag}` from quick.md's emitted Agent() call (breaks the slot), and
moving it onto the code-reviewer dispatch (breaks the wrong-call guard).

Refs #2652

* fix(#2652): serialize unisolated diagnosis, scope the execute-plan gate to dispatching patterns

Round-7 review findings (independent cross-AI pass over the whole PR against
the current base).

BLOCKER — diagnose-issues.md announced sequential mode and then fanned out.
The `orchestrator-worktree` degrade sets ISOLATION=none and prints "debug
agents run sequentially on the main working tree", but the spawn step still
said "All agents spawn in single message (parallel execution)". On Codex,
OpenCode and Kimi that dispatched N unisolated debuggers concurrently against
the primary checkout — the exact outcome the degrade exists to prevent, and
reachable only because this PR removed the FATAL that used to stop those
hosts earlier. Fan-out is now keyed on ISOLATION: parallel only when each
agent has its own worktree, one at a time otherwise.

BLOCKER — execute-plan.md Pattern B could not dispatch at all on Claude or
Cursor. The gate recorded `harness-worktree` to the #3045 sentinel, but only
Pattern A carries `{harnessFlag}`; Pattern B's segment executors carry none,
and `hooks/gsd-agent-isolation-guard.js` blocks precisely that mismatch with
exit 2. Segments are unisolated BY DESIGN — each continues on the working
tree the previous one left behind, so per-agent worktrees would break the
sequence — so Pattern B now records `none` before its first dispatch and
dispatches without the flag.

MAJOR — the same gate ran before routing was chosen, so an isolation-`none`
host with `use_worktrees=true` hit the fail-closed FATAL even when routing
would have selected Pattern C, which is fully inline and dispatches nothing.
Resolution now happens after the pattern is known, and Pattern C skips it.

MAJOR — tests/host-integration.test.cjs fed `fs.readFileSync` output straight
to bash. The `\r?\n` fence regex guards only the delimiter, leaving embedded
CR on every line of the captured body — DEFECT.WINDOWS-CRLF-TEST-PORTABILITY,
which helpers.cjs documents by name. Now reads through `readFileNormalized`.

MAJOR — the "every dispatch-site degrade block re-records" test hand-listed
three files, so its name was a claim its scan could not support. The scan is
now derived from the workflow/reference tree (SCAN_ROOTS/collectMarkdown
hoisted to module scope so there is one definition, not two). Verified
fail-first against execute-plan.md — a file the previous scan never opened.
The two wave fragments are exempt because they delegate the re-record to
per-plan-worktree-gate.md via USE_WORKTREES_FOR_PLAN; that delegation is now
ASSERTED, so deleting the delegate fails this test instead of widening a hole
silently.

MINOR — the changeset claimed the FATAL was gone for "non-Claude runtimes"
full stop. Narrowed: isolation-`none` hosts still fail closed when worktrees
are explicitly enabled, which is the contract rather than the defect.

Two further findings were investigated and rejected, with evidence:
- Raw `spawnSync` vs `tests/helpers/process-seam.cjs`: the seam exposes
  runNode/runGit/runHook and cannot express the `bash -c` harness these tests
  need; `installAndRead` in this same file is byte-identical to the base and
  still uses raw spawnSync with an explicit timeout, which is the form the
  lint sanctions. Migrating only the new call sites would split the file's
  convention for no safety gain.
- `pending-migration-to-typed-ir` on the runtime-converters parity test: the
  annotation and the rendered-text loop both exist at the merge-base under
  #3090. This PR extends an already-tracked test rather than adding a new one
  under a category CONTRIBUTING closes to new tests.

Refs #2652

* test(#2652): re-point the execute-plan prose allowlist at its shifted line

`PROSE_ALLOWLIST` in tests/no-bare-gsd-tools-command-position.test.cjs keys
entries by LINE NUMBER. The previous commit added the post-routing isolation
block to execute-plan.md, which pushed the `validated downstream by
gsd-tools uat classify-coverage` prose mention from line 397 to 414. That
broke the guard in both directions at once: the entry at 397 went stale, and
the real mention at 414 became an unallowlisted offender.

Caught by CI (7 red jobs, all shard 3/3 plus ubuntu-22) rather than locally,
because I verified only the suites I believed the change touched. Any edit to
a workflow .md shifts line numbers, and this repo carries line-keyed
allowlists — so a workflow edit needs the full suite, not a subset.

Refs #2652

* test(#2652): route the new subprocesses through the process seam

Retracting a rejection I made on the record. In the round-6 response I
argued these call sites could keep a hand-rolled `spawnSync` because the
seam exposes only runNode/runGit/runHook and cannot express `bash -c`, and
because `installAndRead` in the same file uses that shape. The first half
was true and irrelevant, the second half is not a licence: CONTRIBUTING is
unambiguous — "Anything that shells out goes through
tests/helpers/process-seam.cjs — never a hand-rolled spawnSync/execFileSync
in your suite", and "Never use try/finally inside test bodies."

`runHook` already documents `interpreter: 'bash'` for running a shell
script, so writing the harness to a file complies without extending the
seam. I had the rule and the seam's own documentation in front of me and
reasoned around both.

Converted:
- host-integration.test.cjs degrade harness: spawnSync('bash', ['-c', …])
  → runHook(scriptFile, [], { interpreter: 'bash' }).
- install.test.cjs cursor gate: `which bash` probe → process.platform;
  the installer spawn → runNode(…, { env: installSpawnEnv({HOME,
  USERPROFILE}) }), which also blanks ambient GSD_HOME/runtime-location
  vars that could otherwise make capability discovery host-dependent;
  the emitted-gate spawn → runHook(gateScript, [], { interpreter: 'bash' }).
- Three try/finally test bodies → t.after().

Class-norm timeouts: tests/helpers/timeouts.cjs arrived with this branch's
latest base merge, so the literals written earlier (15000/120000/60000) now
duplicate PROBE_TIMEOUT_MS and INSTALL_TIMEOUT_MS. Imported instead — that
module exists because INSTALL_TIMEOUT_MS had already drifted 60s→120s once
after a real bench ETIMEDOUT.

Deliberately NOT converted: `installAndRead`'s spawnSync, which is
byte-identical to the merge-base and predates this PR — converting shared
scaffolding is an unrelated change.

Verified equivalent, not assumed: argv/cwd/env/encoding/timeout and every
assertion are preserved; t.after() still cleans up on the assertion-failure
path the try/finally covered; and the cursor test still resolves Cursor
under a hostile ambient GSD_RUNTIME=claude.

Refs #2652

* fix(#2652): replace the falsified use_worktrees doc row; distinguish an unresolvable gate from a declared none

Round-8 review findings.

BLOCKER — docs/CONFIGURATION.md's `Non-Claude note` asserted three things
this PR overturns: that worktree isolation "no other runtime honors"
(Cursor declares harness-worktree with `--worktree`, and this PR's own
install test asserts that flag reaching the emitted Agent() slot), that
non-Claude installs default the key to `false`, and that forcing `true`
always fails closed. Replaced with the capability-based description, and
the `#1515, #1521` citation dropped — those are the two issues whose
premise this PR removes.

The reviewer flagged that the fix is merge-order dependent, because #2531
rewrites the same row and its replacement text is written in anticipation
of this PR landing. Rather than pick an order, BOTH sides are now
order-independent: #2531's "Current default … until #2652" paragraph
becomes a plain troubleshooting note, and this row states the capability
rule without asserting a stamp state. Whichever merges first, the row is
correct; the second merge is a textual conflict at worst.

MINOR — the gate reported a capability verdict the tool never returned.
`ISOLATION=$(… || echo "none")` made a shim-resolution failure, a non-zero
exit and an empty stdout indistinguishable from a declared `none`, so a
transient query failure aborted /gsd:quick on Claude Code with "runtime
'claude' declares no executor-isolation primitive" — false. The gate now
tracks ISOLATION_RESOLVED separately: both paths still fail closed, but only
a real verdict claims the host declares nothing; the unresolved branch says
it could not resolve and points at the shim. Fixed in the canonical
reference so every dispatch site inherits it.

MINOR — quick.md:527 cited #2649 for its own degrade; that is #1941, and
#2649 is the diagnose-issues/execute-plan gate. Corrected, and the
distinction stated so the next reader does not chase it.

MINOR — quick.md:413 (manifest init) and :429 (worktree_branch_check embed)
still branched on USE_WORKTREES while dispatch, manifest-append, merge-back
and the skip clause had all moved to ISOLATION. Safe only by coincidence —
both now key on ISOLATION.

MINOR — the diff removes a second drift-ack entry (the execute-phase.md key
from 2658-trae-instruction-file-path.json), forced by the same duplicate-key
lint rule as the 2649 removal. Disclosed in the PR comment; the earlier
disclosure covered only one of the two.

Verified: lint:ci green; 300/300 across host-integration,
fix-1941-quick-worktree-stale-base, execute-phase-wave and workflow-guard.

Refs #2652

* test(#2652): anchor the emitted-gate finder on the heading, not the assignment

`b89c3fbf` added a finder that located the gate's `Resolve ISOLATION` block by
the literal `ISOLATION=$(gsd_run query dispatch-isolation --raw`. `f3bccf21`
then split that assignment into `_ISOLATION_RAW`/`ISOLATION_RESOLVED` so a shim
failure stops masquerading as a declared `none` — and the finder stopped
matching. The test did not report the drift it exists to catch; it reported
"emitted dispatch-isolation-gate.md has no Resolve ISOLATION bash block" and
went red, and stayed red because the earlier full-suite run was read from a
truncated log.

Anchored on the heading instead. The workflows tell a dispatch site to run the
`Resolve ISOLATION` and `Resolve the harness flag` blocks BY NAME, so the
heading is the contract and the body is free to change under it.

Verified: 413/413 in tests/install.test.cjs. The test still bites — mutating the
gate's `ISOLATION="$_ISOLATION_RAW"` to `ISOLATION=none` turns it red (the
emitted gate then resolves cursor to none and exits 1 instead of printing
harness-worktree), and reverting restores green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2652): wire the canonical resolver at the one dispatch site that still inlined the old shape

Codex review of the whole PR on the new base found one Major, and it was real.

executor-isolation-dispatch.md declares references/dispatch-isolation-gate.md
canonical at line 10, then kept the OLDER resolver inline: `|| echo "none"`, no
ISOLATION_RESOLVED. So the one site that resolves isolation for the wave path
collapsed a shim failure into a declared `none` and aborted with "runtime
'$RUNTIME' declares no executor-isolation primitive" — false for a Claude or
Cursor user whose resolver merely failed to answer. Still fail-closed, so not an
unsafe-dispatch hole, but the correction this PR is about was unwired at the
site that matters most.

Replaced with the gate's exact shape: capture the raw value, track
ISOLATION_RESOLVED, and emit the "could not resolve" FATAL when no verdict was
learned.

Added a regression test in the #2652 dispatch-site parity suite: every file that
ASSIGNS from `gsd_run query dispatch-isolation --raw` must carry
ISOLATION_RESOLVED, must not use the collapsing form, and must have a distinct
unresolved message. Nothing covered this before — install.test.cjs checks the
emitted REFERENCE, not each site's own inline copy, which is exactly how the two
drifted apart.

The test's first draft also flagged quick.md, diagnose-issues.md and
execute-plan.md. That was a false positive worth recording: those three
@-reference the gate and only make `--force-isolation` re-record calls, which
carry no verdict. The predicate now matches an assignment from the resolver, not
any mention of it, so it flags sites that can actually be wrong.

Verified by mutation: restoring the collapsing line reds the new test.

Validated: lint:ci clean; full suite shows the same 7 known failures as the
pre-change baseline — #1160 _resolveManifest and the #3053 quick_id
host-timezone tests (both reproduce on pristine next @ 33fca50d), plus
helpers-cleanup "outside os.tmpdir()", which fails only in a worktree.

* test(#2652): close two vacuous-pass holes in the new inline-resolver guard

Codex cleared the push and flagged the guard test itself. Both holes were real.

SCAN_ROOTS already yields references/dispatch-isolation-gate.md, and the test
appended it a second time, so the candidate list was [executor, gate, gate] and
`length >= 2` was satisfiable by the gate alone. If the executor site had
dropped out of the predicate — the exact regression the test exists to catch —
it would still have passed. Paths are deduped and the assertion now pins the two
expected inliner identities instead of a count.

The collapse detector keyed on `ISOLATION=$(…)`, so `_ISOLATION_RAW=$(… || echo
"none")` restored the identical defect while satisfying every other assertion
(ISOLATION_RESOLVED still appears in the file). Codex mutation-probed exactly
that and it passed. The pattern now matches any assignment target and any
`|| … echo` tail. Verified: that mutation now reds the test.

Test-only change; the workflow bash is byte-identical to the commit the full
suite ran green against. lint:ci clean, host-integration 223/223.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-11 17:10:28 -04:00

901 lines
39 KiB
JavaScript

/**
* Execute-phase wave filter tests
*
* Validates the /gsd-execute-phase --wave feature contract:
* - Command frontmatter advertises --wave
* - Workflow parses WAVE_FILTER
* - Workflow enforces lower-wave safety
* - Partial wave runs do not mark the phase complete
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const COMMAND_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'execute-phase.md');
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
const COMMANDS_DOC_PATH = path.join(__dirname, '..', 'docs', 'COMMANDS.md');
// After #3039, the comprehensive command reference moved to help/modes/full.md.
const HELP_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'help', 'modes', 'full.md');
// allow-test-rule: source-text-is-the-product
// The workflow and command .md files are the installed AI instructions — their text content
// IS what executes. String presence tests guard against accidental deletion of critical clauses.
// See #2692 for the missing behavioral test for --wave N argument parsing.
describe('execute-phase command: --wave flag', () => {
test('command file exists', () => {
assert.ok(fs.existsSync(COMMAND_PATH), 'commands/gsd/execute-phase.md should exist');
});
test('argument-hint includes --wave, --gaps-only, and --interactive', () => {
const content = fs.readFileSync(COMMAND_PATH, 'utf-8');
const hintLine = content.split(/\r?\n/).find(l => l.includes('argument-hint'));
assert.ok(hintLine, 'should have argument-hint line');
assert.ok(hintLine.includes('--wave N'), 'argument-hint should include --wave N');
assert.ok(hintLine.includes('--gaps-only'), 'argument-hint should keep --gaps-only');
assert.ok(hintLine.includes('--interactive'), 'argument-hint should preserve --interactive');
});
test('objective describes wave-filter execution', () => {
const content = fs.readFileSync(COMMAND_PATH, 'utf-8');
const objectiveMatch = content.match(/<objective>([\s\S]*?)<\/objective>/);
assert.ok(objectiveMatch, 'should have <objective> section');
assert.ok(objectiveMatch[1].includes('--wave N'), 'objective should mention --wave N');
assert.ok(
objectiveMatch[1].includes('no incomplete plans remain'),
'objective should mention phase completion guardrail'
);
});
});
describe('execute-phase workflow: wave filtering', () => {
test('workflow file exists', () => {
assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/execute-phase.md should exist');
});
test('workflow parses WAVE_FILTER from arguments', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
assert.ok(content.includes('WAVE_FILTER'), 'workflow should reference WAVE_FILTER');
assert.ok(content.includes('Optional `--wave N`'), 'workflow should parse --wave N');
});
test('workflow enforces lower-wave safety', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('Wave safety check'),
'workflow should contain a wave safety check section'
);
assert.ok(
content.includes('finish earlier waves first'),
'workflow should block later-wave execution when lower waves are incomplete'
);
});
test('workflow has partial-wave completion guardrail', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
// handle_partial_wave_execution was extracted to
// gsd-core/workflows/execute-phase/steps/partial-wave.md. The parent now only
// references it via a <gsd:section> pointer, so assert the pointer is present here
// and then read the actual step body from the extracted file below.
assert.ok(
content.includes('gsd-core/workflows/execute-phase/steps/partial-wave.md'),
'workflow should reference the extracted partial-wave step file'
);
const PARTIAL_WAVE_STEP_PATH = path.join(
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'partial-wave.md'
);
assert.ok(fs.existsSync(PARTIAL_WAVE_STEP_PATH), 'partial-wave step file should exist');
const stepContent = fs.readFileSync(PARTIAL_WAVE_STEP_PATH, 'utf-8');
assert.ok(
stepContent.includes('<step name="handle_partial_wave_execution">'),
'workflow should have a partial wave handling step'
);
assert.ok(
stepContent.includes('Do NOT run phase verification'),
'partial wave step should skip phase verification'
);
assert.ok(
stepContent.includes('Do NOT mark the phase complete'),
'partial wave step should skip phase completion'
);
});
});
// #2868: a phase whose plans are ALL summarized but which never reached
// verify_phase_goal (most commonly a retired checkpoint plan that still wrote a
// SUMMARY) must resume at the phase gates instead of exiting unconditionally —
// the prior behavior made `code_review_gate`, `regression_gate`, and
// `verify_phase_goal` (the only producer of *-VERIFICATION.md) unreachable.
describe('execute-phase workflow: #2868 stranded-phase resume on discover_and_group_plans', () => {
test('W1: all-filtered outcome is no longer an unconditional exit; it consults verification status', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
assert.ok(
!content.includes('If all filtered: "No matching incomplete plans" → exit.'),
'the old unconditional all-filtered exit line must be gone (#2868)'
);
assert.ok(
content.includes('VERIFY_STATUS'),
'discover_and_group_plans should consult VERIFY_STATUS before exiting on all-filtered'
);
assert.ok(
content.includes('verification status'),
'discover_and_group_plans should call the verification status query'
);
});
test('W2: the resume path names both code_review_gate and regression_gate', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const discoverIdx = content.indexOf('<step name="discover_and_group_plans">');
const discoverEnd = content.indexOf('</step>', discoverIdx) + '</step>'.length;
assert.ok(discoverIdx >= 0, 'discover_and_group_plans step should exist');
const discoverSection = content.substring(discoverIdx, discoverEnd);
assert.ok(
discoverSection.includes('code_review_gate'),
'discover_and_group_plans should name code_review_gate as the resume target'
);
assert.ok(
discoverSection.includes('regression_gate'),
'discover_and_group_plans should name regression_gate so a future rename breaks this test ' +
'instead of silently orphaning the resume path'
);
});
test('W3: the resume path is gated off when a filter is active (--gaps-only or WAVE_FILTER)', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const discoverIdx = content.indexOf('<step name="discover_and_group_plans">');
const discoverEnd = content.indexOf('</step>', discoverIdx) + '</step>'.length;
assert.ok(discoverIdx >= 0, 'discover_and_group_plans step should exist');
const discoverSection = content.substring(discoverIdx, discoverEnd);
const filterIdx = discoverSection.indexOf('A filter is active');
assert.ok(filterIdx >= 0, 'discover_and_group_plans should describe a filter-active branch');
// Both flags must be mentioned near the filter-active branch, not merely
// anywhere in the step (e.g. in the pre-existing filtering prose above).
const filterClause = discoverSection.substring(filterIdx, filterIdx + 200);
assert.ok(
filterClause.includes('--gaps-only'),
'filter-active branch should mention --gaps-only'
);
assert.ok(
filterClause.includes('WAVE_FILTER'),
'filter-active branch should mention WAVE_FILTER'
);
});
test('W4: the resume decision is gated on the absence of blocked_by-skipped plans', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const discoverIdx = content.indexOf('<step name="discover_and_group_plans">');
const discoverEnd = content.indexOf('</step>', discoverIdx) + '</step>'.length;
assert.ok(discoverIdx >= 0, 'discover_and_group_plans step should exist');
const discoverSection = content.substring(discoverIdx, discoverEnd);
// Scope to the resume-decision text specifically (from the "If all filtered" marker
// onward), not the pre-existing #2830 filtering prose above it that already mentions
// blocked_by unconditionally — otherwise this assertion would be vacuous.
const decisionIdx = discoverSection.indexOf('If all filtered');
assert.ok(decisionIdx >= 0, 'discover_and_group_plans should have an all-filtered decision block');
const decisionText = discoverSection.substring(decisionIdx);
assert.ok(
decisionText.includes('blocked_by'),
'the resume-decision text must reference blocked_by so an all-blocked phase is never ' +
'reported as finished (#2868 finding 1)'
);
assert.ok(
/stuck/i.test(decisionText),
'the resume-decision text must call out the blocked-and-incomplete case as stuck, ' +
'distinct from genuinely finished'
);
});
test('W5: the resume path enters at aggregate_results, not code_review_gate', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const discoverIdx = content.indexOf('<step name="discover_and_group_plans">');
const discoverEnd = content.indexOf('</step>', discoverIdx) + '</step>'.length;
assert.ok(discoverIdx >= 0, 'discover_and_group_plans step should exist');
const discoverSection = content.substring(discoverIdx, discoverEnd);
const continueMatch = discoverSection.match(/continue (?:directly )?at\s+`([a-zA-Z_]+)`/);
assert.ok(continueMatch, 'resume decision should state which step it continues at');
assert.strictEqual(
continueMatch[1],
'aggregate_results',
'the resume path must enter at aggregate_results (the only step running the ' +
'SECURITY_FILE / secure-phase threats-open gate), not code_review_gate — skipping ' +
'aggregate_results silently drops the only security gate (#2868 finding 3)'
);
assert.notStrictEqual(
continueMatch[1],
'code_review_gate',
'resume entry point must not be code_review_gate'
);
});
test('W6: RESUME_TAIL_ONLY (dead, write-only state) must not appear anywhere in the workflow', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
assert.ok(
!content.includes('RESUME_TAIL_ONLY'),
'RESUME_TAIL_ONLY was set but never read anywhere in the workflow or its steps files ' +
'(#2868 finding 2) — remove it; the imperative instruction at the decision point is ' +
'what actually carries control flow'
);
});
});
describe('execute-phase docs: user-facing wave flag', () => {
test('COMMANDS.md documents --wave usage', () => {
const content = fs.readFileSync(COMMANDS_DOC_PATH, 'utf-8');
assert.ok(content.includes('`--wave N`'), 'COMMANDS.md should mention --wave N');
assert.ok(
content.includes('/gsd-execute-phase 1 --wave 2'),
'COMMANDS.md should include a wave-filter example'
);
});
test('help workflow documents --wave behavior', () => {
const content = fs.readFileSync(HELP_PATH, 'utf-8');
assert.ok(
content.includes('Optional `--wave N` flag executes only Wave `N`'),
'help.md should describe wave-specific execution'
);
assert.ok(
content.includes('Usage: `/gsd:execute-phase 5 --wave 2`') || content.includes('Usage: `/gsd-execute-phase 5 --wave 2`'),
'help.md should include wave-filter usage'
);
});
test('workflow supports use_worktrees config toggle', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('USE_WORKTREES'),
'workflow should reference USE_WORKTREES variable'
);
assert.ok(
content.includes('config-get workflow.use_worktrees'),
'workflow should read use_worktrees from config'
);
assert.ok(
content.includes('Sequential mode'),
'workflow should document sequential mode when worktrees disabled'
);
});
});
describe('phase-plan-index: wave grouping behavior', () => {
test('phase-plan-index groups plans by wave (DAG-bucketing: P002 depends on P001)', () => {
const fs = require('fs');
const path = require('path');
const tmpDir = createTempProject();
try {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
// Wave 1 plan — no dependencies
fs.writeFileSync(path.join(phaseDir, 'P001-PLAN.md'), [
'---',
'wave: 1',
'objective: First wave task',
'autonomous: true',
'depends_on: []',
'---',
'',
'# Plan 001',
'',
'<objective>First wave task</objective>',
'',
'<task>Do the thing</task>',
].join('\n'));
// Wave 2 plan — depends on P001 so DAG places it in level 1 → wave 2
fs.writeFileSync(path.join(phaseDir, 'P002-PLAN.md'), [
'---',
'wave: 2',
'objective: Second wave task',
'autonomous: true',
'depends_on:',
' - P001',
'---',
'',
'# Plan 002',
'',
'<objective>Second wave task</objective>',
'',
'<task>Do the other thing</task>',
].join('\n'));
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
assert.ok(result.success, `phase-plan-index should succeed: ${result.error}`);
const data = JSON.parse(result.output);
// Wave grouping must be present
assert.ok(data.waves, 'output should have a waves property');
assert.deepEqual(data.waves['1'], ['P001'], 'wave 1 should contain P001');
assert.deepEqual(data.waves['2'], ['P002'], 'wave 2 should contain P002');
// Individual plan records must carry their wave numbers
const p001 = data.plans.find(p => p.id === 'P001');
const p002 = data.plans.find(p => p.id === 'P002');
assert.ok(p001, 'P001 should be in plans array');
assert.ok(p002, 'P002 should be in plans array');
assert.equal(p001.wave, 1, 'P001 should have wave=1');
assert.equal(p002.wave, 2, 'P002 should have wave=2');
// No mismatch warning: declared wave 2 matches topo level 2
assert.strictEqual(data.warnings, undefined, 'no warnings when declared wave matches DAG');
} finally {
cleanup(tmpDir);
}
});
test('phase-plan-index defaults missing wave frontmatter to wave 1', () => {
const fs = require('fs');
const path = require('path');
const tmpDir = createTempProject();
try {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan with no wave field in frontmatter
fs.writeFileSync(path.join(phaseDir, 'P001-PLAN.md'), [
'---',
'objective: No wave specified',
'autonomous: true',
'---',
'',
'# Plan 001',
'',
'<task>Some work</task>',
].join('\n'));
const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir);
assert.ok(result.success, `phase-plan-index should succeed: ${result.error}`);
const data = JSON.parse(result.output);
const p001 = data.plans.find(p => p.id === 'P001');
assert.ok(p001, 'P001 should appear in plans');
assert.equal(p001.wave, 1, 'plan with no wave frontmatter should default to wave 1');
assert.deepEqual(data.waves['1'], ['P001'], 'defaulted plan should land in wave 1 group');
} finally {
cleanup(tmpDir);
}
});
});
describe('use_worktrees config: cross-workflow structural coverage', () => {
const QUICK_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
const DIAGNOSE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'diagnose-issues.md');
const EXECUTE_PLAN_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-plan.md');
const PLANNING_CONFIG_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'planning-config.md');
test('quick workflow reads USE_WORKTREES from config', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
assert.ok(
content.includes('config-get workflow.use_worktrees'),
'quick.md should read use_worktrees from config'
);
assert.ok(
content.includes('USE_WORKTREES'),
'quick.md should reference USE_WORKTREES variable'
);
});
test('diagnose-issues workflow reads USE_WORKTREES from config', () => {
const content = fs.readFileSync(DIAGNOSE_PATH, 'utf-8');
assert.ok(
content.includes('config-get workflow.use_worktrees'),
'diagnose-issues.md should read use_worktrees from config'
);
assert.ok(
content.includes('USE_WORKTREES'),
'diagnose-issues.md should reference USE_WORKTREES variable'
);
});
test('execute-plan workflow references use_worktrees config', () => {
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
assert.ok(
content.includes('workflow.use_worktrees'),
'execute-plan.md should reference workflow.use_worktrees'
);
});
test('planning-config reference documents use_worktrees', () => {
const content = fs.readFileSync(PLANNING_CONFIG_PATH, 'utf-8');
assert.ok(
content.includes('workflow.use_worktrees'),
'planning-config.md should document workflow.use_worktrees'
);
assert.ok(
content.includes('worktree'),
'planning-config.md should describe worktree behavior'
);
});
test('config-set accepts workflow.use_worktrees', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools('config-set workflow.use_worktrees true', tmpDir);
assert.ok(result.success, `config-set should accept workflow.use_worktrees: ${result.error}`);
} finally {
cleanup(tmpDir);
}
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2410-stream-checkpoint-heartbeats.test.cjs — consolidation epic #1969 (B4 #1973)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2410-stream-checkpoint-heartbeats (consolidation epic #1969 B4 #1973)", () => {
// allow-test-rule: source-text-is-the-product (see #2410)
// Workflow .md / agent .md / command .md / reference .md files — their text
// IS what the runtime loads. Testing text content tests the deployed contract.
// Per CONTRIBUTING.md exception matrix.
/**
* Bug #2410 — /gsd:manager background execute-phase Task fails with
* "Stream idle timeout" on multi-plan phases.
*
* Fix: execute-phase.md instructs the orchestrator to emit `[checkpoint]`
* heartbeat lines at every wave boundary AND every plan boundary so the
* Claude API SSE stream never idles long enough to trigger the platform
* timeout. This test validates the workflow contract that backs that fix.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const WORKFLOW_PATH = path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'execute-phase.md'
);
const COMMANDS_DOC_PATH = path.join(__dirname, '..', 'docs', 'COMMANDS.md');
describe('bug #2410: execute-phase emits checkpoint heartbeats', () => {
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
test('workflow references the stream idle timeout symptom by name', () => {
assert.ok(
/Stream idle timeout/.test(workflow),
'workflow should name the API error it is preventing'
);
assert.ok(
workflow.includes('#2410'),
'workflow should cite the tracking issue for future maintainers'
);
});
test('workflow defines a [checkpoint] heartbeat line format', () => {
assert.ok(
workflow.includes('[checkpoint]'),
'workflow should document the [checkpoint] marker prefix'
);
});
test('workflow emits a wave-start heartbeat (A: wave-boundary checkpoint)', () => {
assert.ok(
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} starting/.test(workflow),
'workflow should emit a wave-start [checkpoint] marker before spawning agents'
);
});
test('workflow emits a wave-complete heartbeat (A: wave-boundary checkpoint)', () => {
assert.ok(
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(workflow),
'workflow should emit a wave-complete [checkpoint] marker after spot-checks'
);
});
test('workflow emits a plan-start heartbeat (B: plan-boundary checkpoint)', () => {
assert.ok(
/\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(workflow),
'workflow should emit a plan-start [checkpoint] marker before each Task() dispatch'
);
});
test('workflow emits a plan-complete heartbeat (B: plan-boundary checkpoint)', () => {
assert.ok(
/\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(workflow),
'workflow should emit a plan-complete [checkpoint] marker after executor returns'
);
});
test('workflow handles plan failure and checkpoint-gate heartbeats too', () => {
assert.ok(
/\[checkpoint\][^\r\n]*plan \{plan_id\} failed/.test(workflow),
'workflow should emit a plan-failed [checkpoint] marker on executor error'
);
assert.ok(
/\[checkpoint\][^\r\n]*plan \{plan_id\} checkpoint/.test(workflow),
'workflow should emit a heartbeat when a plan returns a human-gate checkpoint'
);
});
test('heartbeats include a monotonic plans-done counter', () => {
// The {P}/{Q} counter lets grep-based recovery tools reconstruct progress
// from a truncated transcript if the agent dies mid-phase.
assert.ok(
/\{P\}\/\{Q\} plans done/.test(workflow),
'heartbeats should include a {P}/{Q} phase-wide completed-plan counter'
);
});
test('wave-start heartbeat precedes the "Describe what\'s being built" text', () => {
const describeIdx = workflow.indexOf("Describe what's being built");
const heartbeatIdx = workflow.indexOf(
'[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} starting'
);
assert.ok(describeIdx !== -1, 'workflow should still have the describe step');
assert.ok(heartbeatIdx !== -1, 'wave-start heartbeat template should be present');
// The instruction to emit the heartbeat appears in step 2, which is the
// step titled "Describe what's being built". The actual sentinel text we
// look for is the inline literal template — it must be emitted BEFORE any
// tool calls in that step.
const step2 = workflow.slice(
describeIdx,
workflow.indexOf('3. **Spawn executor agents', describeIdx)
);
assert.ok(
step2.includes('[checkpoint]'),
'step 2 should instruct the orchestrator to emit a [checkpoint] heartbeat'
);
assert.ok(
/before any further reasoning or spawning/i.test(step2) ||
/before any tool call/i.test(step2) ||
/no tool call/i.test(step2),
'step 2 should make clear the heartbeat is an assistant-text line, not a tool call'
);
});
test('plan-start heartbeat is inside the spawn step', () => {
const spawnIdx = workflow.indexOf('3. **Spawn executor agents');
const waitIdx = workflow.indexOf('4. **Wait for all agents', spawnIdx);
assert.ok(spawnIdx !== -1 && waitIdx !== -1, 'spawn and wait steps must exist');
const step3 = workflow.slice(spawnIdx, waitIdx);
assert.ok(
/\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(step3),
'plan-start heartbeat should be emitted inside step 3 (spawn executor agents)'
);
});
test('plan-complete and wave-complete heartbeats are inside the wait/report steps', () => {
const waitIdx = workflow.indexOf('4. **Wait for all agents');
const hookIdx = workflow.indexOf('5. **Post-wave hook validation', waitIdx);
assert.ok(waitIdx !== -1 && hookIdx !== -1, 'wait + hook steps must exist');
const step4 = workflow.slice(waitIdx, hookIdx);
assert.ok(
/\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(step4),
'plan-complete heartbeat should be emitted in step 4 (wait for agents)'
);
const reportIdx = workflow.indexOf('6. **Report completion');
const failureIdx = workflow.indexOf('7. **Handle failures', reportIdx);
assert.ok(reportIdx !== -1 && failureIdx !== -1, 'report + failure steps must exist');
const step6 = workflow.slice(reportIdx, failureIdx);
assert.ok(
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(step6),
'wave-complete heartbeat should be emitted in step 6 (report completion)'
);
});
});
describe('bug #2410: checkpoint heartbeat format is user-documented', () => {
const commandsDoc = fs.readFileSync(COMMANDS_DOC_PATH, 'utf-8');
test('COMMANDS.md documents the [checkpoint] format under /gsd-manager', () => {
const managerIdx = commandsDoc.indexOf('### `/gsd-manager`');
assert.ok(managerIdx !== -1, '/gsd-manager section should exist');
const section = commandsDoc.slice(managerIdx, managerIdx + 4000);
assert.ok(
/\[checkpoint\]/.test(section),
'COMMANDS.md /gsd-manager section should document [checkpoint] heartbeat markers'
);
assert.ok(
/Stream idle timeout/i.test(section),
'COMMANDS.md should explain what the heartbeats prevent'
);
assert.ok(
/#2410/.test(section),
'COMMANDS.md should reference the tracking issue'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/fix-1369-wave-stale-base.test.cjs — consolidation epic #1969 (B4 #1973)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:fix-1369-wave-stale-base (consolidation epic #1969 B4 #1973)", () => {
// allow-test-rule: source-text-is-the-product #1369
// Workflow .md files are the installed AI instructions — their text IS what the runtime
// loads. Testing text content tests the deployed contract. Per CONTRIBUTING.md exception matrix.
/**
* Regression tests for bug #1369: execute-phase worktree agents fork from stale base after
* a wave merge advances orchestrator HEAD past origin/HEAD.
*
* Steps 0.5 and 7b+7c are extracted to reference files to satisfy the ADR-857 size cap.
* execute-phase.md contains @-reference pointers; the reference files hold the content.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
const WAVE_GUARD_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-wave-guard.md');
const BETWEEN_WAVE_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-between-wave-reset.md');
describe('execute-phase: inter-wave worktree base re-check (#1369)', () => {
test('workflow file exists', () => {
assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/execute-phase.md should exist');
});
test('wave-guard reference file exists', () => {
assert.ok(fs.existsSync(WAVE_GUARD_PATH), 'references/execute-phase-wave-guard.md should exist');
});
test('workflow contains @-reference pointer to wave-guard (step 0.5 injected at runtime)', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('execute-phase-wave-guard.md'),
'execute-phase.md must have an @-reference to execute-phase-wave-guard.md'
);
});
test('workflow contains step 0.5 inter-wave base re-check section', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(
content.includes('0.5.') && content.includes('Inter-wave worktree base re-check'),
'execute-phase-wave-guard.md must have step 0.5 "Inter-wave worktree base re-check"'
);
});
test('step 0.5 references #1369', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(content.includes('#1369'), 'step 0.5 must reference #1369 for traceability');
});
test('step 0.5 runs worktree.base-check inside the For-each-wave loop', () => {
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const forEachIdx = workflow.indexOf('**For each wave:**');
const refIdx = workflow.indexOf('execute-phase-wave-guard.md');
assert.ok(forEachIdx !== -1, '"For each wave:" section must exist in execute-phase.md');
assert.ok(refIdx !== -1, '@-reference to wave-guard must exist in execute-phase.md');
assert.ok(refIdx > forEachIdx, 'wave-guard @-reference must appear AFTER "For each wave:" so step 0.5 runs per-wave');
});
test('step 0.5 runs worktree.base-check command', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(content.includes('worktree.base-check'), 'step 0.5 must invoke worktree.base-check');
});
test('step 0.5 sets USE_WORKTREES=false when shouldDegrade is true', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(content.includes('USE_WORKTREES=false'), 'step 0.5 must override USE_WORKTREES=false when base divergence is detected');
});
test('step 0.5 appears before step 1 (intra-wave overlap check)', () => {
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const forEachIdx = workflow.indexOf('**For each wave:**');
const refIdx = workflow.indexOf('execute-phase-wave-guard.md');
const step1Idx = workflow.indexOf('1. **Intra-wave', forEachIdx);
assert.ok(refIdx !== -1, 'wave-guard @-reference must exist');
assert.ok(step1Idx !== -1, 'step 1 (intra-wave overlap check) must exist');
assert.ok(refIdx < step1Idx, 'wave-guard @-reference must appear before step 1');
});
// #2652: previously required `RUNTIME = "claude"`, encoding the pre-#2584 premise
// that worktree isolation is Claude-specific. #2584 replaced that with the
// negotiated dispatch.isolation capability — Cursor declares harness-worktree too,
// and the harness fork-base caching this guard exists for is a property of the
// isolation model, not of the runtime name.
test('step 0.5 guards on the negotiated capability, not a runtime id', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(
content.includes('ISOLATION') && content.includes('harness-worktree'),
'step 0.5 must guard on ISOLATION = harness-worktree'
);
assert.ok(
!/\[\s*"\$RUNTIME"\s*=/.test(content),
'step 0.5 must NOT branch on a RUNTIME literal (#2584/#2652)'
);
assert.ok(
content.includes('ISOLATION=none'),
'degrade must clear ISOLATION as well as USE_WORKTREES — dispatch reads ISOLATION (#2652)'
);
});
test('step 0.5 explains root cause: wave merges advance HEAD past origin/HEAD', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(content.includes('origin/HEAD'), 'step 0.5 must name origin/HEAD as the stale fork base');
});
test('step 0.5 cross-references #683 for worktree.baseRef configuration', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(content.includes('#683'), 'step 0.5 must cross-reference #683');
});
test('step 0.5 mentions worktree.baseRef:"head" as permanent fix', () => {
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
assert.ok(
content.includes('worktree.baseRef') && content.includes('head'),
'step 0.5 must mention worktree.baseRef:"head"'
);
});
});
describe('execute-phase: between-wave manifest reset (#1369, #3384)', () => {
test('between-wave reference file exists', () => {
assert.ok(fs.existsSync(BETWEEN_WAVE_PATH), 'references/execute-phase-between-wave-reset.md should exist');
});
test('workflow contains @-reference pointer to between-wave-reset', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('execute-phase-between-wave-reset.md'),
'execute-phase.md must have an @-reference to execute-phase-between-wave-reset.md'
);
});
test('step 7c exists with between-wave manifest reset (#1369)', () => {
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(
content.includes('7c.') && content.includes('Between-wave manifest reset'),
'execute-phase-between-wave-reset.md must have step 7c "Between-wave manifest reset"'
);
});
test('step 7c unsets WAVE_WORKTREE_MANIFEST between waves', () => {
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(content.includes('unset WAVE_WORKTREE_MANIFEST'), 'step 7c must unset WAVE_WORKTREE_MANIFEST');
});
test('step 7c references #1369 and #3384 for traceability', () => {
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(content.includes('#1369'), 'step 7c must reference #1369');
assert.ok(content.includes('#3384'), 'step 7c must reference #3384');
});
test('step 7c calls worktree.set-baseref to re-assert head config', () => {
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(content.includes('worktree.set-baseref'), 'step 7c must call worktree.set-baseref');
});
test('step 7c appears after step 7b and before step 8 in the wave loop', () => {
const ref = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const idx7b = ref.indexOf('7b.');
const idx7c = ref.indexOf('7c.');
const refPtr = workflow.indexOf('execute-phase-between-wave-reset.md');
const idx8 = workflow.indexOf('8. **Execute checkpoint', refPtr);
assert.ok(idx7b !== -1, 'step 7b must exist in between-wave reference file');
assert.ok(idx7c !== -1, 'step 7c must exist in between-wave reference file');
assert.ok(idx8 !== -1, 'step 8 must exist in execute-phase.md after the between-wave @-reference');
assert.ok(idx7b < idx7c, 'step 7c must appear after step 7b');
assert.ok(refPtr < idx8, 'between-wave @-reference must appear before step 8');
});
// #2652: see the step 0.5 note above — migrated from the runtime-name premise to
// the negotiated dispatch.isolation capability.
test('step 7c guards on the negotiated capability, not a runtime id', () => {
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(
content.includes('ISOLATION') && content.includes('harness-worktree'),
'step 7c must guard on ISOLATION = harness-worktree'
);
assert.ok(
!/\[\s*"\$RUNTIME"\s*=/.test(content),
'step 7c must NOT branch on a RUNTIME literal (#2584/#2652)'
);
assert.ok(
content.includes('ISOLATION=none'),
'degrade must clear ISOLATION as well as USE_WORKTREES — dispatch reads ISOLATION (#2652)'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3096-ai-integration-phase-parallel-race.test.cjs — consolidation epic #1969 (B4 #1973)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3096-ai-integration-phase-parallel-race (consolidation epic #1969 B4 #1973)", () => {
'use strict';
// allow-test-rule: source-text-is-the-product (see #3096)
// Reads product workflow markdown (ai-integration-phase.md) to verify
// structural ordering contract.
// Regression guard for bug #3096.
//
// ai-integration-phase.md listed Steps 7+8 (gsd-ai-researcher +
// gsd-domain-researcher) without an explicit sequential ordering constraint.
// An orchestrator optimizing for speed could reasonably parallelize them
// since the sections appeared disjoint. When parallelized, gsd-domain-researcher's
// Write call at finalization replaced the whole AI-SPEC.md file with its
// in-memory copy (pre-researcher state), silently overwriting Sections 3/4.
//
// Confirmed at 40% incidence rate on a real run (2 of 5 worktree agents hit it).
// Recovery cost: one extra ai-researcher dispatch (~18 min wall).
//
// Fix:
// 1. Explicit "MUST run sequentially" note on Steps 7 and 8
// 2. Edit-only tool discipline injected into both agent prompts
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const src = fs.readFileSync(
path.join(ROOT, 'gsd-core', 'workflows', 'ai-integration-phase.md'),
'utf8',
);
describe('bug #3096: ai-integration-phase sequential ordering and Edit-only discipline', () => {
test('Step 7 documents sequential ordering requirement', () => {
assert.ok(
src.includes('sequentially') || src.includes('sequential'),
'Steps 7+8 ordering note is missing — parallel dispatch race can recur',
);
});
test('Step 7 gsd-ai-researcher prompt includes Edit-only tool discipline', () => {
// The discipline block must appear before </objective> for gsd-ai-researcher
const step7Idx = src.indexOf('## 7. Spawn gsd-ai-researcher');
const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher');
assert.ok(step7Idx !== -1, 'Step 7 not found');
assert.ok(step8Idx !== -1, 'Step 8 not found');
const step7Block = src.slice(step7Idx, step8Idx);
assert.ok(
step7Block.includes('Edit tool') && step7Block.includes('NEVER use Write'),
'Step 7 agent prompt missing Edit-only tool discipline',
);
});
test('Step 8 gsd-domain-researcher prompt includes Edit-only tool discipline', () => {
const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher');
const step9Idx = src.indexOf('## 9. Spawn gsd-eval-planner');
assert.ok(step8Idx !== -1, 'Step 8 not found');
assert.ok(step9Idx !== -1, 'Step 9 not found');
const step8Block = src.slice(step8Idx, step9Idx);
assert.ok(
step8Block.includes('Edit tool') && step8Block.includes('NEVER use Write'),
'Step 8 agent prompt missing Edit-only tool discipline',
);
});
test('Step 8 references the wait instruction', () => {
const step8Idx = src.indexOf('## 8. Spawn gsd-domain-researcher');
const step9Idx = src.indexOf('## 9. Spawn gsd-eval-planner');
const step8Block = src.slice(step8Idx, step9Idx);
assert.ok(
step8Block.includes('Wait') || step8Block.includes('wait') || step8Block.includes('complete'),
'Step 8 does not instruct orchestrator to wait for Step 7',
);
});
});
});
}