Files
msd-core/gsd-core/workflows/execute-phase.md
Tom Boucher 9e6c404151 chore: merge release v1.4.0 to main (#884)
* fix(#663): resolve open CodeQL/Dependabot security alerts (ReDoS, prototype pollution, workflow perms, qs DoS) (#665)

* fix(#663): resolve open CodeQL/Dependabot security alerts

- ReDoS: collapse ambiguous nested quantifiers in phase-heading regexes
  (verify/validate/commands) and the plan-filename lookahead (phase) to
  provably-equivalent non-backtracking forms
- prototype pollution: guard __proto__/constructor/prototype in setConfigValue
- remove dead no-op .replace(/-/g,'-') in phase.cts
- escape all regex metachars in bug-2839 test
- add contents:read permissions to security-scan + install-smoke workflows
- pin qs >= 6.15.2 via overrides (DoS GHSA)
- broaden prompt-injection allowlist to translated security-model docs

Closes #663

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#663): regression tests for prototype-pollution guard and roadmap-phase ReDoS

Behavioral test that config-set rejects __proto__/constructor/prototype keys
without polluting Object.prototype, plus a ReDoS guard (timing-bound) and
behavior-preservation assertions for the collapsed phase-heading regexes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#663): make ReDoS regression assert structured result, not elapsed time

Replace elapsed-time assertions (which tripped local/no-elapsed-assertion
ESLint rule and were unsound for synchronous ReDoS) with structured-result
assertions on adversarial inputs: assert that malformed phase headings/
unchecked-item lines without a terminating colon/space yield an empty Set,
which is both the correct behavior and an exercise of the fixed linear regex
on the catastrophic-backtracking input shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#663): add Security changeset fragment for #665

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#663): fold prototype-pollution regression into config.test.cjs

The standalone bug-663-config-prototype-pollution.test.cjs was a 9th
config-module test file, tripping lint-test-file-count (the allowlist is
ratcheted and must not grow). Consolidated into config.test.cjs instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#660): bump next to 1.3.1-dev.0 (-dev stream per ADR 660) (#672)

After the 1.3.0 release, next moves onto the -dev prerelease stream so the
trunk self-identifies as unreleased (floor = next patch). First manual
exercise of the ADR-660 release model.

Refs #660

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* ci(#660): use scoped GSD_BOT_PR_TOKEN for backmerge & release merge-back PR creation (#673)

The open-gsd org blocks the Actions GITHUB_TOKEN from creating PRs, so
auto-backmerge and the release finalize merge-back PR steps can't open
their PRs (must be done manually). Point those two steps at a scoped
secret (pull-requests:write + contents:write), falling back to
GITHUB_TOKEN so behavior is unchanged until the secret is added.

Refs #660

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: accept published installer migration checksums

* fix(#670): self-healing recovery for installer-migration checksum drift (#675)

Editing the body of an already-released installer migration drifts its computed
checksum (it hashes plan.toString()). The integrity guard then hard-aborted
every prior install on upgrade with "applied migration checksum changed" — a
100% reproducible blocker (v1.3.0, all platforms).

Already-applied migrations are filtered out of `pending` and never re-run, so
a drifted checksum is functionally inert. ADR-0008 anticipates checksum-mismatch
state as something the install-state layer must handle gracefully (plan -> apply
-> recover/report), not abort on.

This supersedes the published-checksum allowlist merged in #674 (per-release
maintenance debt — every historical checksum hand-pinned, still throws for any
unregistered value) with a general, self-healing recovery:

- Replace the throwing guard with non-fatal `collectAppliedChecksumDrift`,
  surfaced on `plan.checksumDrift`.
- Reconcile drifted stored checksums durably on the next state write
  (`reconcileDriftedChecksums`), idempotently (no perpetual writes).
- Relocate the "shipped migration bodies are immutable" rule to a CI baseline
  test that locks every shipped migration's checksum and fails on body drift —
  where #615 should have been caught, instead of blocking users.

Removes #674's legacyChecksums field, per-migration checksum pins,
published-checksums.json fixture, and compat test.

Fixes #670

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#676): consolidate hotfix into release.yml (delete standalone hotfix workflow) (#678)

* fix(#676): consolidate hotfix into release.yml; delete standalone hotfix workflow

npm allows only one trusted publisher per package and it is release.yml, so the
standalone hotfix.yml (token-auth) could never publish via OIDC (ENEEDAUTH on the
v1.3.1 finalize). Fold the patch/hotfix path into release.yml — the sole OIDC
trusted publisher — and delete hotfix.yml.

- validate-version accepts X.Y.Z (Z>0) → hotfix/X.Y.Z + base_tag; rejects rc for
  hotfixes; X.Y.0 still → release/X.Y.0.
- create branches hotfix/X.Y.Z from the base tag with optional auto-cherry-pick
  (default on) of fix:/chore: from next; release path unchanged.
- finalize is branch-agnostic already and publishes @latest via the existing
  OIDC trusted publisher (no NODE_AUTH_TOKEN).
- CONTRIBUTING branching table updated; hotfix.yml removed.

Fixes #676
Supersedes #677.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#676): add hotfix/patch path to release.yml (OIDC trusted publisher)

The companion to the hotfix.yml deletion: release.yml now handles patch
versions (X.Y.Z) via hotfix/X.Y.Z branches and publishes @latest through the
existing OIDC trusted publisher. CONTRIBUTING branching table updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#676): update tests/docs referencing deleted hotfix.yml (#680)

hotfix.yml was deleted (folded into release.yml). Remove the now-broken
release-coverage-scope and policy-release-no-npm-self-upgrade assertions that
readFileSync'd hotfix.yml (release.yml equivalents retained), drop the dead
install-smoke.yml path trigger, and update VERSIONING.md / docs/branching.md
prose to describe hotfixes via the Release workflow with a patch version.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: bump hono to 4.12.23 on next to clear moderate advisory

Same moderate hono advisory (GHSA-3hrh-pfw6-9m5x et al.) that blocked the 1.3.1
hotfix is present on next (was 4.12.19); bump to keep the npm-audit gate green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#690): promote CHANGELOG 1.3.x + gate release-notes promotion (#694)

* fix(#690): promote CHANGELOG 1.3.x + gate release-notes promotion

/gsd:update showed an empty "What's New" preview after updating to 1.3.1
because CHANGELOG.md's 1.3.x content was never promoted out of [Unreleased]
into dated sections, so `scripts/changeset/cli.cjs extract` returned exit 2
("no releases in range").

- CHANGELOG.md: split [Unreleased] into dated [1.3.0] and [1.3.1] sections
  (1.3.1 = hono advisory bump + installer-migration checksum self-heal, #670;
  1.3.0 = the feature release), restoring an empty [Unreleased].
- scripts/changeset/cli.cjs: new `verify` subcommand that exits non-zero when
  CHANGELOG has no dated `## [x.y.z]` heading for a version; hoist shared
  stripV/resolveChangelogPath helpers used by extract + verify.
- .github/workflows/release.yml: gate the finalize job on `verify` (after the
  build, before tag/publish) so an unpromoted CHANGELOG can never ship again.
- gsd-core/workflows/update.md: move `rm -f $CHANGELOG_TMP` after the
  human-readable extract re-run so the preview no longer degrades to
  "(changelog unavailable)".
- tests: regression guard for the 1.3.x headings + extract range + verify
  command coverage (present/absent/undated/v-prefixed/--json/prerelease).

Closes #690

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#690): add changeset fragment for #694

Fixed-type fragment for the user-facing /gsd:update preview fix and the
release-notes promotion gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#698): make auto-backmerge main→next actually land (admin-merge via PAT) (#699)

The Auto Back-Merge workflow could open a back-merge PR but never landed it,
and silently reported success on failure. Fixes:

- Merge via admin bypass using GSD_BOT_PR_TOKEN (the PAT) instead of auto-merge,
  since back-merge PRs structurally can't satisfy next's required checks
  (Issue-link / PR-template / changeset-lint).
- Stop swallowing create/merge failures with "|| echo ::warning" — real
  failures now fail the job. (That greenwashing hid the whole bug.)
- Resolve the PR number with `--jq '.[0].number // empty'` (a no-match returns
  the string "null", not empty) and capture a freshly-created PR's number from
  the create URL to avoid GitHub API eventual-consistency races.
- Merge the exact PR number (env-bound) rather than by branch name.
- Force-push the disposable SHA-named bot branch, guarded by a
  chore/backmerge-main-to-next-* name check so a mislabeled PR can't redirect
  the force-push.
- Apply labels non-fatally so a missing label can't abort PR creation.
- On a genuine merge conflict, fail loudly (::error + exit 1) instead of
  pushing an empty branch and opening a PR with no diff.

Closes #698

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#637): route 3 more workflows through gsd_run launcher (hardcoded $HOME sweep) (#642)

* fix(#637): route 3 more workflows through gsd_run launcher (hardcoded $HOME sweep)

The hardcoded `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs"` invocation form
fixed in plan-phase.md (#621) survived in three more workflows. Same bug class:
on a global/shim-only install with no project-local runtime, the hardcoded path
can miss a working install, so the step reports the tool "not found" instead of
resolving it via the launcher. #3668 introduced gsd_run resolution; these sites
were missed.

- plan-review-convergence.md: convert the 3 hardcoded invocations (init,
  roadmap get-phase, state planned-phase) to gsd_run. File already carried the
  canonical preamble (first gsd_run is the earlier convergence-enabled check).
- ingest-docs.md, spec-phase.md: convert their hardcoded invocations to gsd_run
  and inject the canonical launcher preamble via
  `node scripts/sync-runtime-launcher.cjs` (these files previously had no
  gsd_run and no preamble). The injected preamble is byte-equal to
  _runtime-launcher.snippet.sh and precedes the first gsd_run call, per
  runtime-launcher-parity invariant (B).
- Add tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs: repo-wide
  regression guard asserting NO workflow .md invokes gsd-tools via a hardcoded
  $HOME path. Generalizes the plan-phase-only guard from #621 — the parity test
  guards retired $GSD_SDK / bare /gsd-tools tokens but not this form, which is
  how it survived across four files. Fails on the pre-fix files, passes after.

runtime-launcher-parity 7/7; full unit suite green (3477 pass / 0 fail).

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

* chore(#637): add changeset fragment for PR #642

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

* test(#637): update stale bug-2801 assertion to expect gsd_run

bug-2801 pinned ingest-docs.md to the hardcoded node "$HOME/.../gsd-tools.cjs" init form, which #637 replaces with the gsd_run launcher. Flip the assertion to expect gsd_run init ingest-docs; the bare-gsd-tools rejection and CLI-handler tests are unchanged, and bug-637's repo-wide guard now owns the no-hardcoded-$HOME invariant.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>

* fix(#705): route hardcoded $HOME gsd-tools invocations in agents/commands through gsd_run (#707)

* fix(#705): route hardcoded $HOME gsd-tools invocations in agents/commands through gsd_run

The hardcoded `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" <cmd>` form
(fixed for workflows in #621/#637) survived in agent/command surfaces and
misresolves on global/shim-only installs. Route every agent-executed
invocation through the resolved `gsd_run` launcher in gsd-phase-researcher,
gsd-planner (load_graph_context extracted to a shared reference to stay under
the planner size budget), import, and graphify. Add a regression guard over
agents/ + commands/ + gsd-core/references/ bash blocks. User-facing display
messages and docs are intentionally left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#705): use repo changeset fragment format (type: Fixed, pr: 707)

The hand-written fragment used the standard changesets package format
(package: bump) which lacks the type:/pr: frontmatter the repo's
docs-required lint consumes (fail_malformed_fragment / missing_type).
Regenerated via scripts/changeset/new.cjs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#685): set windowsHide on all Windows child-process spawns (#688)

* fix(#685): set windowsHide on all Windows child-process spawns

A visible "gsd-core" console window flashed on Windows whenever a gsd-core
child process spawned without `windowsHide: true`. The most visible offenders
fire on every SessionStart / `/clear` (execNpm's `shell:true` npm view via the
update-check worker) and on every Edit/Write/MultiEdit in a worktree (the
worktree-path guard's git probe).

Add `windowsHide: true` to every external-binary spawn in the runtime source:
- hooks/gsd-context-monitor.js (record-session spawn)
- hooks/gsd-worktree-path-guard.js (SPAWNOPT)
- hooks/gsd-workflow-guard.js (git branch --show-current)
- src/shell-command-projection.cts (execGit / execNpm / execTool)
- src/check-command-router.cts (git log execFileSync)
- src/roadmap-upgrade.cts (git status/rev-parse/reset/clean execSync)

gsd-check-update.js already had it (the precedent). probeTty's tty call is
POSIX-only and intentionally untouched. Adds a regression test that asserts
each site plus a repo-wide completeness guard so a future external-binary
spawn that omits windowsHide fails CI. No behavior change off-Windows.

Closes #685

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#685): set changeset pr number to 688

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#687): bound agy print mode with its native --print-timeout (#689)

`/gsd-review --agy` hung indefinitely on large prompts. agy's print mode runs the
full tool-enabled agent, and on a big, file-path-rich prompt its agentic Cascade
loops on the code_search/grep tool and never converges; the transcript fallback
only runs after agy exits, so it can't recover a run that never exits.

The agy CLI exposes no per-tool deny (that lives in the Antigravity SDK), but it
does expose --print-timeout — agy's native print-mode cap. Pass it explicitly so a
stalled run self-terminates through the tool's own mechanism; a non-zero exit
discards any partial output so the existing transcript fallback / "review failed"
stub take over. Adds a regression test.

Closes #687

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#669): /gsd-review --cursor actually invokes cursor-agent (#686)

* fix(#669): /gsd-review --cursor actually invokes cursor-agent

The Cursor reviewer branch in review.md never ran the agent:
- detection probed `cursor` (the IDE launcher) instead of the headless
  `cursor-agent` binary
- the invocation used the two-token `cursor agent` (the IDE treats `agent`
  as a file-path argument, so the agent never starts)
- the prompt was piped via stdin, but `cursor-agent -p` reads the prompt
  from a command-line argument, and `2>/dev/null` hid the empty result

Probe `cursor-agent`; invoke `cursor-agent -p --mode ask --trust
--output-format text` with the prompt passed as a file-path-reference
argument (avoids the OS arg-length limit on large prompts); capture stderr
so failures are diagnosable. Invert tests/cursor-reviewer.test.cjs to assert
the corrected contract, with negative guards against the two-token form and
the stdin pipe. The sibling `agy` reviewer already used the argument form.

Closes #669

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#669): set changeset pr number to 686

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore: archive 463 shipped changeset fragments before wiring render (#714)

CHANGELOG promotion was a manual operator step that was never run, so 463
fragments for work already shipped in <=1.3.1 accumulated in .changeset/.
Their notes were already hand-curated into the dated [1.2.0]/[1.3.0]/[1.3.1]
CHANGELOG sections (#690 backfill, PR #694). Rendering them now would
duplicate and mis-attribute shipped work.

Move them to .changeset/archived/ (read non-recursively by all changeset
tooling, so never rendered), keeping only the 3 genuinely-unreleased
fragments at the top level. Prep for wiring `render` into the release
finalize job (#690 follow-up).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(release): wire CHANGELOG render into release finalize job (#690 follow-up) (#715)

* feat(#690): wire CHANGELOG render into release finalize job

CHANGELOG promotion has always been a manual operator step, which is why
1.3.0/1.3.1 shipped unpromoted (#690). PR #694 added a `verify` latch that
fails a release lacking a dated heading, but nothing performed the promotion.

Wire `changeset render` into the finalize job, after build/test and before
the verify gate, committing the promoted CHANGELOG so it ships with the
release. Add a `--allow-empty` flag to cmdRender so a zero-fragment release
still emits a dated heading (with a '_No notable changes._' placeholder)
instead of writing nothing and tripping the verify gate.

Note: requires the changeset-archive cleanup (separate PR) to land first, so
the first render consumes only genuinely-unreleased fragments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#713): set changeset pr number to 715

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs: document --only and --text flags for /gsd-autonomous (#695) (#716)

Adds --only N and --text to the COMMANDS.md reference table and the
run-phases-autonomously how-to guide, revised to fit the Diataxis
framework (reference: factual/parallel rows; how-to: goal-framed sections).

* feat(#656): Research module — content-addressed cache + provider seam + registry-API legitimacy (#664)

* feat(#656): add Research Store module (content-addressed cache, TTL staleness)

Content-addressed research cache behind a clock seam: researchKey (sha256, deterministic), putResearch/getResearch ({hit,stale}, never throws), ttlForSource (curated HIGH 30d / MED 7d / web LOW 1d), two-tier resolveStorePath (curated -> ~/.gsd/research-cache, web/synthesis -> project .planning/research/.cache). 28 behavioral + property tests; boundary coverage at ttl-1/ttl/ttl+1.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): add Research Provider module (waterfall + confidence + plan)

Single source of truth for the Balanced provider waterfall (docs Context7->Ref->Jina, web Exa+Tavily, fallback Perplexity/Brave, Firecrawl scrape-only). classifyConfidence stamps HIGH|MEDIUM|LOW by provider (never throws). providerAvailability maps config flags to usable providers. planResearch checks the Research Store (injected seam) and returns cache-hits + a per-question fetch plan, falling through the waterfall to the always-available websearch terminal. 22 behavioral + property tests.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): add Package Legitimacy module (registry-API verdicts, slopcheck optional)

Replaces the pip-install-or-degrade slopcheck prose gate with code: classifyPackage (pure, never throws) computes OK|SUS|SLOP from tunable thresholds (minAgeDays 30, minWeeklyDownloads 1000, requireRepo). checkPackages queries injectable npm/PyPI/crates registry adapters (real https with 5s timeout, degraded-not-thrown on failure); slopcheck is one optional adapter that can only escalate severity, never degrade to [ASSUMED]. 34 behavioral + property tests; boundary coverage on age and downloads (limit-1/limit/limit+1).

Known follow-up: real npm adapter must add api.npmjs.org last-week downloads fetch (currently null -> unknown-downloads). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): detect Tavily/Ref/Perplexity/Jina provider keys; complete npm downloads adapter

config: add tavily_search/ref_search/perplexity/jina availability flags (env var or ~/.gsd/<x>_api_key), mirroring brave_search/exa_search/firecrawl, so the Research Provider waterfall can gate them. package-legitimacy: real npm adapter now fetches api.npmjs.org last-week downloads (bounded, degraded-not-thrown) so weeklyDownloads is populated. +12 config tests; 34 legitimacy tests unchanged.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): expose Research seam via gsd-tools query (research-plan, research-store, package-legitimacy)

Routes the L2-hybrid surface so agents reach it as CLI: 'query research-store get/put' (cache, HOME-sandboxable), 'query research-plan --input' (cache-hits + fetch plan from planResearch), 'query package-legitimacy check --ecosystem' (async registry verdicts). Commands skip .planning root resolution and appear in top-level usage. 5 behavioral runGsdTools tests; command-contract unchanged (335).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(#656): document Research module (CONTEXT predicates, ADR-0656, architecture, changeset)

Adds GSD-RESEARCH.* + DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT predicates to CONTEXT.md, ADR-0656 recording the L2-hybrid seam decision, a docs/ARCHITECTURE.md Research Module subsection, and an Added changeset fragment (pr:0, backfill on PR). Notes the #657 deferrals (agent collapse + install.js MCP mapping).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): sync inventory for research modules

Regenerate INVENTORY-MANIFEST.json and bump docs/INVENTORY.md CLI Modules count 82->85 with rows for research-store/research-provider/package-legitimacy (DEFECT.INVENTORY-DRIFT).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): eslint-ignore generated research .cjs artifacts (ADR-457)

research-store/research-provider/package-legitimacy .cjs are tsc-generated from src/*.cts, so they belong in the ESLint ignore block (lint the .cts source, not the emitted .cjs). Fixes tests/551-eslint-bin-lib-coverage.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): backfill changeset pr number to #664

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

* chore(#656): satisfy eslint lint-tests gate

Fix 20 eslint errors in the new research files: use helpers.cleanup() instead of raw fs.rmSync() in tests (local/no-raw-rmsync-in-tests, Windows-EBUSY retry budget); drop redundant '| string' union members and unnecessary type assertions; deterministic object normalization in researchKey (no-base-to-string). Logic unchanged; 6180 tests still green.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): harden package legitimacy per review (W1/W2/I3/I4)

W1: httpsGet now reads statusCode; npm/PyPI/crates map 404 -> exists:false -> SLOP (registry-existence is the #1 slopsquatting defense; previously only npm caught it). Transport made injectable (_setHttpGet) for hermetic 404 tests. W2: suspicious-postinstall is now terminal SLOP independent of the optional slopcheck adapter, and the regex drops the bare https?:// arm (over-fired on esbuild/sharp/node-gyp) for shell-exec/download-exec signatures only. I3: checkPackages now threads version to registry.lookup and adapters verify that specific version exists. I4: moreServerVerdict -> moreSevereVerdict. +11 regression tests (all RED-first); 45 total green.

Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): research-store tier coherence + freshness + version TTL (W4/I1/I2/I4)

I1: tier now derives from source (curated -> user ~/.gsd, else -> project .planning), not kind, so put-tier and get-tier can't diverge; kind is a key component only. W4: getResearch searches both tiers and returns the freshest (non-stale preferred), never letting a stale curated entry shadow a fresh web one; blank version caps TTL at 1 day (no 30d on version-blind keys). I2: atomic platformWriteSync instead of raw fs.writeFileSync on the shared global path. I4: dropped the dead ttlForSource arm. CLI get now searches both tiers. +5 RED-first regression tests; 38 green.

Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): expose classifyConfidence as a CLI route, killing dead code (W3)

Adds 'gsd-tools query classify-confidence --provider X [--verified]' so research agents get the confidence tier FROM CODE (provider waterfall + verification lever) instead of asserting it in prose. classifyConfidence previously had no runtime caller. HIGH means 'trusted provider'; --verified raises web results to MEDIUM (verification semantics documented in ADR-0656). +4 behavioral tests.

Addresses review by @davesienkowski on #664 (W3). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): close Codex adversarial-review findings (path-traversal, version-age, malformed-cache)

HIGH: research key must be 64-hex sha256 (isValidResearchKey) + resolved-path containment check in put/get + CLI validation -> blocks '../../x' arbitrary-file-write. HIGH: package legitimacy now derives publishedAt from the REQUESTED version (npm time[version], PyPI releases[version] upload_time, crates versions[].created_at) so a new malicious version of an old package can't inherit old age and evade 'too-new'. MEDIUM: getResearch validates entry shape (finite fetched_at + positive ttl + required fields) -> malformed cache entry is a miss, not fresh-forever. +regression tests (RED-first); 111 green.

Codex adversarial review (required pre-PR gate). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): close code-review correctness findings

(1) package-legitimacy CLI now rejects unknown --flags instead of silently consuming the following package as a flag value; only --ecosystem takes a value. (2) crates recent_downloads (90-day) normalized to a weekly figure before the minWeeklyDownloads threshold (was ~13x too lenient). (3) research-plan --input validates parsed JSON is an object with an Array questions before destructuring -> clean usage error instead of an uncaught TypeError on null/bad input. (4) research-store put rejects a flag value that is itself a --flag (no more storing '--source' as content). (5) planResearch skips questions whose text is not a non-empty string instead of emitting question:undefined. +13 RED-first regression tests; 143 green.

Code-review gate. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#657): extract researcher documentation_lookup to shared @-reference

6 researcher agents carried a near-duplicate <documentation_lookup> block; consolidate into gsd-core/references/research-documentation-lookup.md (@-included). Unifies the ctx7 CLI fallback to the safer 'command -v ctx7' guard (drops silent 'npx --yes ctx7@latest' execution in 5 agents). Behavior-preserving dedup; inventory 63->64 references. Phase A of the agent collapse.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#657): extract researcher philosophy + verification-protocol to shared @-references

philosophy and the pitfalls+pre-submission-checklist common-core were near-duplicated in project/phase researchers; consolidate into gsd-core/references/research-{philosophy,verification-protocol}.md (@-included). phase-researcher keeps its 3 extra checklist items inline. Pre-submission domains checklist made agent-agnostic so project-researcher doesn't lose features/architecture coverage. Write-contract intentionally left inline (bug-214 tests assert it verbatim). Inventory 64->66 refs. Behavior-preserving. Phase A.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): wire gsd-phase-researcher to the Research seam (Phase B / S1)

The phase researcher now CALLS the code seam instead of carrying inline mechanics: provider waterfall -> 'gsd-tools query research-plan' (+ research-store put to cache digests); confidence-tier prose -> 'gsd-tools query classify-confidence'; slopcheck pip-install protocol -> 'gsd-tools query package-legitimacy check'. This makes the Research module a real runtime consumer (validates the seam end-to-end, addresses reviewer S1) and removes the duplicated waterfall/confidence/slopcheck prose. RESEARCH.md output contract, commit step, structured returns, and Phase-A @-includes unchanged. package-legitimacy-gate.test.cjs rewritten prose-grep -> behavioral (asserts the seam invocation).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): wire gsd-project-researcher to the seam + add tavily/ref/jina MCP tools (Phase C.1)

project-researcher now calls gsd-tools query research-plan / classify-confidence (+ research-store put) instead of the inline provider waterfall + confidence-tier prose (mirrors the phase-researcher rewire; no package-legitimacy — phase-only). Output contract (STACK/FEATURES/ARCHITECTURE/PITFALLS/SUMMARY.md + sections, no-commit, structured returns, Phase-A @-includes) unchanged. Adds mcp__tavily/ref/jina__* to the project/phase/ui researcher tools frontmatter (Balanced provider set) so install.js MCP mapping (C.2) has a consumer.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(#657): cover tavily/ref/jina MCP install handling + frontmatter parity guard (Phase C.2)

Investigation: exa/firecrawl have no explicit per-runtime tool-mapping — every mcp__<server>__* except context7 rides the generic passthrough (Copilot lowercases; OpenCode/Cursor/Windsurf/Augment keep as-is; Gemini auto-discovers). tavily/ref/jina are handled identically, no install path broken. Added 12 copilot-install passthrough tests + a mcp-tool-inheritance parity guard (tavily co-declared with exa, jina with firecrawl, ref present across the 3 web researchers) so the MCP set can't drift. No io.github registry ids invented (none sourceable in-repo); documented as a follow-up. 488 tests green.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): profiles as source of truth for researcher agents + drift-guard (Phase C.3)

scripts/research-profiles.cjs declares each of the 7 researcher agents' identity + contract (name, description, color, tools, required @-includes, required gsd-tools seam calls, output-contract markers). scripts/gen-research-agents.cjs --check validates every committed agent against its profile; --write regenerates ONLY the frontmatter from profiles (body untouched) and is a verified no-op against the current agents (zero diff = fidelity). tests/research-agent-profiles.test.cjs is the DEFECT.GENERATIVE-FIX drift guard. Design note: profiles govern the generatable/contract surface rather than destructively regenerating the disparate operational prose bodies (those were deduped via @-includes in Phase A). scripts/ is not inventoried (no inventory change).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#657): complete agent provider-dispatch + parity guard; align legitimacy field; validate profiles

Adversarial-review findings: (HIGH) the seam-wired agents' Step-C dispatch only mapped 6 providers, so a planResearch result of jina/ref/perplexity/brave (reachable via the waterfall fallbacks) had no handling -> agent stall; completed both agents' dispatch to all 9 PROVIDER_WATERFALL ids + a catch-all, and added a parity test asserting agent dispatch stays in sync with research-provider PROVIDER_WATERFALL (DEFECT.GENERATIVE-FIX). (MEDIUM) phase-researcher package-legitimacy JSON example used 'package' but the module returns 'name' -> aligned. (LOW) gen-research-agents checkAgent now returns a clear failure for a malformed profile instead of throwing. +parity/validation tests (RED-first).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): make classifyConfidence verification-evidence-driven (W3)

Confidence conflated provider authority with claim verification — context7/ref
stamped HIGH purely by provider identity, and the only verification lever was a
self-set --verified flag. Split into two axes: provider authority (static) +
verification evidence (code-computed). HIGH now requires ground-truth
corroboration (legitimacyVerdict OK), independent of provider; authority alone
caps at MEDIUM; SLOP caps at LOW; the self-reported --verified is demoted to a
MEDIUM-only web lever. HIGH = corroborated-against-authoritative-source, not a
correctness guarantee. Adds --legitimacy-verdict to the classify-confidence CLI;
updates CONTEXT.md predicate + ADR-0656 (tier set unchanged, ADR-consistent).

Addresses davesienkowski's W3 review on #664.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#656): bind classify-confidence verdict to code, closing CLI self-grading

Adversarial review found the new --legitimacy-verdict flag was caller-supplied,
so an agent could self-assert OK->HIGH without any real legitimacy check —
reintroducing the exact self-grading hole W3 closes. Remove the free flag; the
CLI now computes the verdict via checkPackages only when --package/--ecosystem
is given (code-computed, not agent-asserted). Update the stale CLI test
(context7 alone -> MEDIUM) and extend the property test to vary legitimacyVerdict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#717): re-base workflow size budget on bytes + document quality rationale (#719)

* feat(#717): re-base workflow size budget on bytes + document quality rationale

Re-base tests/workflow-size-budget.test.cjs from line counts to byte
counts (matches Codex's 32,768-byte project_doc_max_bytes cap; deterministic,
no tokenizer). Tier ceilings: XL=90000, LARGE=54000, DEFAULT=38000, GRACE=3000;
discuss-phase target re-expressed as <30 KB. The #597 tighten-only ratchet and
per-file budget semantics are preserved unchanged — only the unit swaps.

byteCount() uses fs.statSync().size to match `wc -c` (includes trailing
newline), deliberately not lineCount()'s newline-stripping.

Document the context-rot / attention-budget QUALITY rationale (independent of
prompt caching) in the test JSDoc and docs/ARCHITECTURE.md, plus the
Goodhart caveat: the byte budget measures one file, so the real goal is
bounded *loaded* context — eager @-imports game the proxy; legitimate
extraction is lazy. Update CONTEXT.md RULESET.WORKFLOW_SIZE_BUDGET to bytes and
remove a stale duplicate ruleset entry that still said "1800 lines".

Defers the #3182 MVP-mode split (tracked separately): MVP is a cross-cutting
concern woven through plan-phase/execute-phase, not a discrete extractable mode.

Closes #717

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#717): add changeset fragment for byte-budget re-base

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#159): auto-use existing RESEARCH.md in /gsd:plan-phase --research-phase (#718)

* feat(#159): auto-use existing RESEARCH.md in /gsd:plan-phase --research-phase

When RESEARCH.md already exists in research-only mode and neither --research
nor --view is passed, emit a one-line notice and exit cleanly instead of
prompting update/view/skip. This matches the promptless auto-use of standard
/gsd:plan-phase <N> (§5.1) and removes the §5.0/§5.1 inconsistency, making
AI-agent and CLI invocations non-interactive in the common case. The two
explicit-flag escape hatches (--research to refresh, --view to print) cover
any deviation.

Closes #159

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#159): point changeset fragment at PR #718

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#159): tighten research-phase reference register (Diataxis)

Make the 'no modifier' research-phase entries descriptive rather than
imperative and drop the trailing 'pass --research/--view' clauses, which
duplicated the adjacent --research/--view documentation. Reference docs
describe; the recovery flags are documented in their own entries. The
emitted runtime notice in the workflow keeps naming the flags (in-band
recovery), unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore: clean up clear-cut ESLint warnings (#732) (#734)

Pay down pre-existing error→warn lint debt. Removes dead imports/vars, unused functions, redundant regex/string escapes, and stale eslint-disable directives; converts unused `catch (_e)` to optional catch binding (src/*.cts).

No behavior change. Lint 345→125 warnings (0 errors); deferred categories (n/no-process-exit, test-sleeps, control-regex) tracked in #732 for follow-up. Full test suite green (0 failures); code-review verified all removals unused and all escape fixes semantics-preserving.

Closes #732

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(lint): justify intentional no-control-regex (ANSI strip) + ratchet to error (#737)

The 5 no-control-regex warnings are all the same intentional ANSI-color-strip
pattern /\x1b\[[0-9;]*m/g across 5 test files. The \x1b (ESC) control char is
the required leading byte of an ANSI SGR sequence, so matching it is the whole
point of stripping color codes from captured CLI/console output. Add an inline
eslint-disable-next-line with justification at each site (not a refactor — the
control char is essential, not accidental), then flip no-control-regex from
warn to error so the debt can't regrow.

Refs #736

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(lint): refactor magic-sleep tests to async waits + ratchet rules to error (#735)

Replace raw setTimeout/Atomics.wait synchronization sleeps in 4 test files
with a shared async delay()/waitFor() poll-for-condition helper in
tests/helpers.cjs, then flip local/no-magic-sleep-in-tests and
no-restricted-syntax from warn to error so the debt can't regrow.

- tests/helpers.cjs: add delay(ms) + waitFor(predicate, opts), exported
- bug-1974: setTimeout backoff -> await delay()
- config.test: drop Atomics.wait sleep(); async retry via await delay()
- graphify: waitForBuildStatus/cleanupHookRepo async via await delay()
- locking-bugs: 3 Atomics.wait poll loops -> await waitFor()
- eslint.config.mjs: ratchet both rules warn -> error

Refs #733

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#704): exclude } and ) from Codex path-rewrite lookbehind (no literal $gsd-core in installs) (#710)

* fix(#704): exclude } and ) from Codex path-rewrite lookbehind

Shell variable expressions like \${VAR}/gsd-core/ and command-substitution
paths like \$(cmd)/gsd-local-patches were being rewritten to \$gsd-core and
\$gsd-local-patches respectively because the negative lookbehind in
convertSlashCommandsToCodexSkillMentions did not include } or ).

Add both characters to the lookbehind set:
  (?<![a-zA-Z0-9./})])

Also adds regression test:
  tests/bug-704-codex-launcher-path-corruption.test.cjs

Closes #704

* chore: add changeset for #704

* test: use RUNTIME_ROOT_PATH in assertion to eliminate dead-code lint warning

Replace the partial hard-coded fragment '}/gsd-core/bin/' with the
existing RUNTIME_ROOT_PATH const so the assertion both compiles clean
(no unused variable) and self-documents which canonical launcher path
must survive Codex conversion intact (#704).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: link changeset to PR #710

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#706): skip rescue of already-committed SUMMARY to avoid worktree cleanup merge_failed (#709)

* fix(#706): skip rescueSummaryArtifacts when SUMMARY is already committed

rescueSummaryArtifacts now probes `git cat-file -e HEAD:<path>` before
copying a SUMMARY.md into the main checkout.  When the file is already
committed on the worktree branch, copying it as an untracked file causes
`git merge --no-ff` to abort with "untracked working tree files would be
overwritten by merge" — a permanent merge_failed cleanup-wave failure.

Fail-closed on timeout: if cat-file is unreliable we skip rescue (the
merge will surface the collision as it did before, which is recoverable).

Adds 4 new test cases in worktree-safety.test.cjs covering:
- committed SUMMARY skipped, merge succeeds (#706 regression case)
- committed SUMMARY skipped even when timeout (fail-closed)
- uncommitted SUMMARY still rescued (existing contract preserved)
- rescue failure on ENOSPC still propagates (unchanged)

Closes #706

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add changeset for #706

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#706): treat cat-file exit 128 as uncertain — skip rescue (fail-closed)

The previous guard skipped rescue only when `exitCode === 0` (committed) or
`timedOut`. Any other non-zero exit, including `128` (fatal git error: corrupt
object store, unborn HEAD, missing repo), fell through and PROCEEDED with
rescue — potentially re-creating the #706 untracked-file merge collision.

Fix: rescue ONLY when `exitCode === 1` (cat-file definitively reports the
object absent). All other outcomes — 0 (committed), 128 (fatal), null/SIGTERM
(timeout), or any other code — are treated as "uncertain → skip rescue".

Also corrects the JSDoc bullet that still referenced `git ls-files
--error-unmatch` (the old mechanism); updated to `git cat-file -e HEAD:<relPath>`.

Regression test added: asserts rescue is SKIPPED when cat-file returns exit 128,
leaving the merge to surface the issue safely rather than silently copying an
already-committed file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: link changeset to PR #709

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(scripts): replace process.exit() with ExitError + runMain handler (#739) (#740)

Part 1 of 2 of the n/no-process-exit cleanup (umbrella #738): convert every
process.exit() call in standalone scripts/** CLIs to the rule-compliant pattern.

- New shared helper scripts/lib/cli-exit.cjs: ExitError(code,message) + runMain()
  which translates a thrown ExitError / returned number into process.exitCode
  (never process.exit()), flushing output and still firing process.on('exit').
- main()-based entrypoints: throw new ExitError(code) for errors, return <code>
  for verdicts; invoked via runMain(main). Child exit codes preserved via return.
- top-level-only scripts: imperative body extracted into main() so mid-flow
  aborts (throw ExitError) actually halt; pure consts/helpers stay at module scope.
- diff-touches-shipped-paths.cjs: stdin event handling restructured to an async
  read so the whole flow runs under runMain; uncaughtException/unhandledRejection
  nets replaced by an in-band catch that preserves EXIT_ERROR=2.

Exit codes verified unchanged for every converted script (success/error/help and
the 0/1/2 semantic codes in diff-touches). Rule stays warn here; flipped to error
in part 2 (#738) once gsd-core/bin/** is also clean.

Refs #739

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(bin): replace process.exit() in CLI entrypoints + ratchet rule to error (#738) (#741)

Part 2 of 2 of the n/no-process-exit cleanup (completes umbrella #738; part 1
was #739/scripts). Converts the 20 flagged process.exit() calls in the three
hand-written gsd-core/bin CLI entrypoints and flips n/no-process-exit to error.

- New src/cli-exit.cts -> gsd-core/bin/lib/cli-exit.cjs (ExitError + runMain),
  the gsd-core-side equivalent of scripts/lib/cli-exit.cjs; registered in
  .gitignore, eslint ignores, and the inventory manifest like its siblings.
- gsd-tools.cjs: 13 apply-prompt-budget exits -> throw ExitError; main()->runMain.
- verify-reapply-patches.cjs: 6 exits -> throw ExitError / return verdict; runMain.
- check-latest-version.cjs: 1 exit -> return verdict; runMain.
- eslint.config.mjs: n/no-process-exit warn -> error.

Scope note: the gsd-core/bin/lib/*.cjs modules (core, state, profile-pipeline,
roadmap-command-router, adr-parser, ui-safety-gate) are tsc-generated and
eslint-ignored (ADR-457), so their process.exit calls were never flagged and are
intentionally left untouched. Only the linted hand-written entrypoints are in scope.

Exit codes verified unchanged for all three entrypoints.

Closes #738

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(#720): lazy-load MVP-only reference bodies on non-MVP runs (#746)

* refactor(#720): lazy-load MVP-only reference bodies (eager @-import → gated Read)

Convert eager @-imports of MVP-only reference bodies into lazy "Read" instructions
gated on MVP_MODE / WALKING_SKELETON / MVP+TDD, so non-MVP planning/execution runs
no longer pull MVP guidance into context. Covers both the workflow files and the
planner/executor agent definitions (the dominant context-cost path):

- workflows/plan-phase.md: planner-mvp-mode.md + skeleton-template.md (L146/936/937/941)
- workflows/execute-phase.md: execute-mvp-tdd.md halt-report ref, now gated on gate-trip (L191)
- agents/gsd-planner.md: planner-mvp-mode.md, user-story-template.md, skeleton-template.md
- agents/gsd-executor.md: execute-mvp-tdd.md

The dedicated always-MVP mvp-phase workflow keeps its eager imports (intentional).
Behaviour is unchanged; non-MVP runs simply carry less loaded context. Adds a
regression guard mirroring the discuss-phase lazy-load test, and documents the
conformance in docs/ARCHITECTURE.md.

Refs #720

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#720): add changeset fragment (pr #746)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(#712): replace Codex slash-command denylist lookbehind with positive-boundary match (#747)

* refactor(#712): replace Codex slash-command denylist lookbehind with positive-boundary match

The hyphen-style /gsd-<cmd> -> $gsd-<cmd> conversion in
convertSlashCommandsToCodexSkillMentions used a negative-lookbehind DENYLIST
enumerating characters that must NOT precede a real mention. #637 -> #704 showed
this is an unbounded treadmill: each new unanticipated preceding char (/, ., word
chars, then }, )) leaked the same path-corruption bug class, and a backtick-wrapped
path (`/gsd-core/workflows/update.md`) still leaked through.

Replace it with a POSITIVE two-boundary definition of a mention:
  1. Left: opens at start-of-string, whitespace, or an inline-prose delimiter
     (backtick/quote/paren/bracket).
  2. Right: the command token is not followed by a path separator `/` (a path
     continues, a command does not). The (?![a-z0-9/-]) lookahead also blocks
     regex backtracking to a shorter command.

This closes the whole class by construction (no preceding-char denylist to
maintain) and fixes the backtick-wrapped-path corruption the #704 test
documented as a pre-existing gap, while preserving conversion of legitimate
backtick-wrapped mentions (e.g. CONTEXT.md's `/gsd-execute-phase` lists).

The colon-style /gsd: replace is intentionally left unguarded (it never appears
as a filesystem path segment) and is annotated as such.

Tests assert the regex directly (function now exported) across a convert/
don't-convert matrix plus one end-to-end pipeline assertion for the headline
backtick-path case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#712): add changeset fragment for PR #747

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#730): scope current-milestone Phase Details section in roadmap parser (#748)

`extractCurrentMilestone()` scoped the current-milestone window to its
`## Phases` checklist subsection and terminated at the milestone's own
`## Milestone … (Phase Details)` heading, so the `### Phase N:` detail
headers fell outside scope. Every parser-backed command — `init.phase-op`
(and thus `/gsd:discuss-phase`, `/gsd:plan-phase`), `state`, `roadmap list`,
and `validate health` (W006) — therefore could not resolve phases of any
milestone after the first until a `.planning/phases/` directory already
existed, blocking discuss/plan.

The parser now additionally includes the current milestone's `(Phase Details)`
section in scope, located via the already-computed version matches and anchored
(boundary-aware) to the selected milestone's version token so sibling
sub-milestones sharing a version prefix do not cross-pollinate. The existing
heading selection and primary window are unchanged.

Adds tests/bug-730-milestone-phase-details-scope.test.cjs covering the
two-milestone reproduction, first-milestone non-regression, direct
getRoadmapPhaseInternal resolution, validate-health W006 visibility, a
three-milestone roadmap, and the closed-sibling sub-milestone case.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#683): auto-degrade phase execution to sequential on worktree base mismatch (#749)

* fix(#683): auto-degrade phase execution to sequential on worktree base mismatch

Claude Code forks worktree-isolated executors off the repository default
branch (origin/HEAD), not the orchestrator's HEAD. Running /gsd-execute-phase
on a branch diverged from the default (unmerged milestone/feature branch) left
every executor without the phase's plan files and tripped the
worktree-branch-check guard with `exit 42` — 100% reproducible, all OSes.

- New module src/worktree-base-ref.cts: HEAD-vs-fork-base drift detection
  (origin/HEAD with symbolic-ref fallback) and no-clobber worktree.baseRef
  management, exposed as `worktree base-check` / `worktree set-baseref`.
- execute-phase.md: pre-dispatch, for Claude Code with worktrees enabled,
  auto-degrades the run to sequential on the main tree when a base mismatch
  is detected, recommending worktree.baseRef:"head". The exit-42 guard stays
  as a backstop.
- Installer: fresh local Claude installs set worktree.baseRef:"head" in
  .claude/settings.local.json (no-clobber, respecting an explicit shared
  settings.json value); upgrades print an opt-in notice pointing at
  `gsd-tools worktree set-baseref`.
- Docs: how-to guide, CLI/config reference, planning-config cross-ref.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#683): auto-apply worktree.baseRef on upgrade; gate fresh+upgrade on use_worktrees

Per maintainer direction: on a local Claude Code UPGRADE, set
worktree.baseRef:"head" automatically (no opt-in notice) when the project's
workflow.use_worktrees is enabled, instead of merely printing a remediation
notice. For consistency the FRESH path is now gated the same way: both paths
compute worktrees-enabled once (bounded walk-up read of .planning/config.json,
default enabled unless workflow.use_worktrees === false) and apply the
no-clobber baseRef only when enabled — never overwriting an explicit value in
settings.local.json or a shared settings.json. gsd-tools worktree set-baseref
remains for manual use. Docs + changeset updated; tests hardened (file-exists
assertions, fresh+disabled case, upgrade idempotency).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#683): measure workflow byte-budget on LF, fixing Windows-only CI failure

The workflow-size-budget test failed only on Windows: git checks out the .md
files as CRLF (no eol=lf in .gitattributes) and byteCount used
fs.statSync().size (raw on-disk bytes), counting an extra \r per line. That
inflated execute-phase.md — the XL high-water-mark file pinned near its ceiling
by the tighten-only ratchet — from 88492 LF bytes to ~90245 on Windows, over
the 90000 XL ceiling, while passing on the LF-checkout Mac/Linux runners.

The ceilings are explicitly "calibrated against raw `wc -c`" on an LF checkout,
so the measurement should be LF-based on every platform. byteCount now reads the
file and counts Buffer.byteLength after stripping CR, making the budget
platform-independent (a no-op on LF checkouts; verified statSync === normalized
for all 88 workflow files). No ceilings changed. Added a regression test
asserting CRLF and LF content of the same file count identically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#683): make worktree-base-ref test path mocks Windows-safe (path.join)

tests/worktree-base-ref.test.cjs keyed its injected readFile/writeFile mocks
(and a few expected `file` values) with forward-slash template literals like
`${claudeDir}/settings.local.json`. The module composes those paths with
path.join(), which emits backslashes on Windows, so the mock keys never matched
the module's lookup → readFile returned null → resolveEffectiveBaseRef /
cmdWorktreeBaseCheck / cmdWorktreeSetBaseRef (and the JSONC variants) failed on
the Windows full-test runner only (they passed on Mac/Linux, and the install
tests passed because they use the real filesystem). The module is correct;
only the test fixtures hardcoded '/'.

All mock keys and path assertions now use path.join(base, ...) mirroring the
module, so they match on every platform (no-op on POSIX). 19 path references
across 16 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#703): add --granularity override flag to /gsd:plan-phase (#750)

* feat(#703): add --granularity override flag to /gsd:plan-phase

Add a `--granularity <coarse|standard|fine>` flag to /gsd:plan-phase that
overrides the configured planning granularity for a single invocation.

The override is a new highest-priority tier above the existing precedence
chain (granularities[phaseType] -> granularity -> planning.granularity ->
'standard') in resolveGranularityInternal; when the flag is absent, resolution
is byte-for-byte unchanged. cmdInitPlanPhase now resolves with phaseType
'planning' so granularities.planning participates, and emits the resolved
value in the init JSON, which the plan-phase workflow forwards to the planner
prompt. Invalid values are rejected at the CLI boundary via a shared
assertValidGranularityOverride helper.

Closes #703

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#703): set changeset pr to 750

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#751): recognise config-set prototype-pollution guard in CodeQL + test dynamic-key vectors (#752)

CodeQL alert #26 (js/prototype-pollution-utility) kept firing on setConfigValue
because its dataflow does not trace the #663 Set-based, pre-loop keys.some(...)
forbidden-key check as a sanitising barrier on the write site.

- src/config.cts: replace the Set + pre-loop check with inline literal
  comparisons (key === '__proto__' || 'prototype' || 'constructor') on the exact
  key used to index `current`, immediately before each write (intermediate keys
  in the descent loop, plus the final key). Same forbidden set, same error
  message and ERROR_REASON.CONFIG_PARSE_FAILED — behaviour unchanged from #663,
  but the barrier is now CodeQL-recognised.
- tests/config.test.cjs: add regression tests for schema-valid dynamic-prefix
  keys (agent_skills.__proto__, agent_skills.constructor, agent_skills.prototype,
  features.__proto__, review.models.constructor) that pass the isValidConfigKey
  schema gate and reach the guard. Each asserts the guard's own message fires
  (not the schema gate's "Unknown config key") and Object.prototype is not
  polluted. The prior #663 tests never reached the guard — their keys are
  rejected by the schema gate first — so the guard's real attack surface was
  untested.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#25): scope gsd-verifier Step 7b to enumerate-or-single-test; forbid full-suite re-runs (#753)

* feat(#25): scope gsd-verifier Step 7b to enumerate-or-single-test; forbid full-suite re-runs

Step 7b's lone test example (`npm test -- --grep "$PHASE_TEST_PATTERN"`) is
mocha/vitest/jest-specific, where `--grep` filters which tests *execute*. Models
generalized it to `cargo test --workspace 2>&1 | grep X` (runs the whole suite,
filters only *output*) and repeated it once per must-have, adding minutes per
verification with no new evidence after the first run.

Replace the example with language-agnostic guidance: prove a test EXISTS via
enumeration (`cargo test -- --list` / `pytest --collect-only` / `npx vitest
list` / `go test -list`), and prove it PASSES via a single named test
(`cargo test <name> -- --exact` / `pytest -k` / `npx vitest run -t`). Add a
Spot-check constraint forbidding more than one full-suite run per verification
or piping a full run through grep per must-have, while still permitting one
saved run + grep when a full run is genuinely required. docs/AGENTS.md gains a
one-line Key-behaviors note, and a new test asserts the Step 7b content.

Scoped per the maintainer decision on the issue: folded into Step 7b (no new
top-level Step 7a) with no VERIFICATION.md label changes.

Closes #25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#25): add Changed changeset fragment for PR #753

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#52): add agent_skills_security.trusted_global_roots allowlist for global skills (#754)

* feat(#52): add agent_skills_security.trusted_global_roots allowlist

Opt-in allowlist so a global: agent skill whose SKILL.md realpath resolves
outside the default global skills base (e.g. ~/.claude/skills) is accepted
when its real target lies under a user-declared trusted root. Default [] is
byte-identical to prior behavior; the symlink-escape guard is preserved and
simply re-applied against each declared root.

- src/security.cts: loadTrustedGlobalRoots — tilde-expand (~ and ~/), reject
  project-relative and dangerously broad roots (filesystem/UNC root, homedir),
  realpath-canonicalize each root every run and drop non-existent ones.
- src/init.cts: on base-check failure the guard consults the trusted roots
  (hoisted out of the loop); emits a stderr NOTE when a skill is accepted via
  a trusted root so the widened boundary is visible.
- src/core.cts: thread agent_skills_security through loadConfig.
- config-schema.manifest.json: allow the new key path.
- docs/CONFIGURATION.md: document the option and its security model.
- tests/agent-skills.test.cjs: unit + end-to-end CLI coverage (regression,
  feature, negative, broad-root hardening, stderr NOTE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#52): add changeset fragment for trusted_global_roots (#754)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(#651): consolidate verification-status routing into one queryable seam (#755)

* refactor(#651): consolidate verification-status routing into one queryable seam

The passed/gaps_found/human_needed verification status was re-encoded as
bare strings across three prose surfaces (gsd-verifier emits, execute-phase
routes, ship gates), each independently deciding the per-status next action
with no parity coupling — the DEFECT.GENERATIVE-FIX class.

Give the enum one home: src/verification.cts (-> bin/lib/verification.cjs)
exposing `gsd_run query verification.status <phaseDir>` returning a typed
{status, next_action, next_command}. ship.md and execute-phase.md now consume
the query instead of re-deriving the routing in prose; gsd-verifier.md points
at the shared vocabulary as the single emitter (values unchanged).

Also fixes the latent broad-grep status misread (DEFECT.FRONTMATTER-SCALAR-
BROAD-GREP): execute-phase.md read `grep "^status:"` over the whole report, so
a body `status:` line could misroute a valid phase. Extraction is now
frontmatter-scoped in one place. A parity test fails if a verifier status
gains no route. Lands the two CONTEXT.md DEFECT entries captured on the issue.

Closes #651

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#651): set changeset pr to 755

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#758): trigger draft-PR auto-close on pull_request_target (#760)

Bare `pull_request` hands fork PRs a read-only GITHUB_TOKEN, so the
close/comment API calls 403 and a first-time/external contributor's draft
PR survives — bypassing the auto-close for exactly the population the job
targets. Switch to `pull_request_target`, which runs in the base-repo
context with a write-capable token even for fork PRs. Safe because the job
never checks out or executes PR-supplied code; it only reads event metadata
and calls the GitHub API. The minimal `permissions: pull-requests: write`
block still constrains the token.

Add a regression guard in tests/workflow-maintainer-skip.test.cjs asserting
the workflow triggers on pull_request_target and not bare pull_request.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#58): add ADR for Runtime Install Policy Module boundary (#762)

Record the Runtime Install Policy Module decision and ownership
boundary: install policy projects a pure, typed install plan by
composing artifact placements (ADR-3660) and command text (ADR-0009)
plus per-runtime config intentions, with no filesystem IO; runtime
adapters consume the plan and execute concrete file mutations and
format-specific config rendering. Explicitly records what stays
outside the policy module (TOML/JSON/Markdown serialization, merge
semantics, filesystem effects).

Adds the ADR index row in docs/adr/README.md and a glossary entry in
CONTEXT.md. Leads the installer-refactor chain (#58 -> #60 -> #56).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#759): non-destructive CHANGELOG preview in the rc release job (#763)

The rc action publishes a release candidate to @next for testing but
never surfaces the curated CHANGELOG section for the version under test —
render only runs destructively at finalize (#715), so there was no safe
way to preview the upcoming notes during the RC window.

Add a --preview mode to scripts/changeset/cli.cjs cmdRender: it renders
the dated release section to stdout via the existing renderChangelog/
serializeChangelog path (with priorChangelog: null, so only the new
section is emitted), reuses the shared injectEmptyPlaceholder helper for
zero-fragment releases, and returns WITHOUT writing CHANGELOG.md or
deleting any .changeset fragment. Wire a "Preview CHANGELOG" step into
the rc job that renders to a file (standalone command, so a malformed
fragment fails the step) and cats it to the job summary and log.

Closes #759

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#761): add scheduled base-context sweep to close SHA-branch-evading draft PRs (#765)

close-draft-prs.yml (on pull_request_target after #760) cannot close fork draft
PRs whose head branch name looks like a Git SHA — GitHub never dispatches
pull_request_target for such branches, and a pull_request run from a fork gets a
read-only token. So a draft PR on a SHA-named fork branch evades the auto-close.

Add close-draft-prs-sweep.yml: a schedule (every 6h) + workflow_dispatch sweep
running in base-repo context with pull-requests: write that paginates open PRs,
filters to non-OWNER/MEMBER/COLLABORATOR drafts, and closes + comments them with
the identical policy/message as the event-driven workflow. Re-fetches each
candidate before mutating (TOCTOU guard), closes before commenting so
enforcement is never gated on the explanatory comment, and core.setFailed on
partial failures. The per-PR workflow remains the fast path; this is the
safety net for the documented residual bypass.

Extends tests/workflow-maintainer-skip.test.cjs with structural guards locking
the triggers, write permission, maintainer carve-out, pagination, and message.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix discord release changelog announcements

* test(#339): regression guard for gsd-sdk refs in runtime surfaces (#691)

* test(#339): add regression guard for gsd-sdk refs in runtime surfaces

Lock in the already-clean runtime surface so a retired `gsd-sdk`/`GSD_SDK`
reference cannot creep back into a shipped prompt or hook. Scans
gsd-core/workflows, gsd-core/references, commands/gsd, agents, and hooks
(excluding the gitignored dist/ build artifact).

Complements gsd-tools-path-refs.test.cjs, which only catches the
`gsd-sdk query` binary form; this catches any runtime reference. A second
test guards against an empty sweep so a future dir rename can't silently
turn the guard into a no-op.

Intentionally does NOT touch bin/install.js (live stale-package-detection
mechanics per #339 triage) or CI/lint scripts (legitimate stale detection).

Refs #339

* test(#339): split file content on /\r?\n/ for Windows CRLF parity

The windows-test-parity-guard lint requires test files that readFileSync +
split to use /\r?\n/, not '\n', so CRLF files don't leave a trailing \r.
New test files are not in the PR #3649 allowlist.

* test(#339): cover .sh/.json runtime files in workflows surface

Address #691 review: the gsd-core/workflows surface scanned only .md,
silently skipping two deployed runtime files — _runtime-launcher.snippet.sh
(synced into every hook) and discuss-phase/templates/checkpoint.json. Add
.sh/.json so the guard covers all 290 deployed runtime files (was 288/290),
not just the .md subset.

* test(#339): cover templates/contexts surfaces + per-ext empty-sweep guard

Address PR #691 review (trek-e):
- Add gsd-core/templates and gsd-core/contexts to RUNTIME_SURFACES —
  both are deep-copied by the installer and runtime-loaded via
  @~/.claude/gsd-core/templates/*.md anchors, so a reintroduced gsd-sdk
  ref there would have slipped past the guard. (major)
- Reword the bin/install.js exclusion rationale: it has zero gsd-sdk refs
  today (subsystem removed in #515, shim retired in #522); the real reason
  it is excluded is that it is installer code, not a deployed prompt/hook
  surface. (minor)
- Make the empty-sweep guard assert coverage per configured extension, not
  per surface — .md files alone kept gsd-core/workflows green even if
  .sh/.json were dropped, silently un-covering _runtime-launcher.snippet.sh
  and discuss-phase/templates/*.json. (low)

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>

* refactor(#60): make runtime config adapter registry explicit (#795)

* refactor(#60): make runtime config adapter registry explicit

Replace scattered inline `runtime === '...'` config-mutation branching in
bin/install.js with an explicit, typed adapter registry. The new
src/runtime-config-adapter-registry.cts maps each of the 15 supported
runtimes to a config intent { installSurface, writesSharedSettings,
finishPermissionWriter }; install()/finishInstall() dispatch by resolved
intent instead of runtime-name checks (cursor/windsurf/trae collapse to one
profile-marker-only branch).

Behavior-preserving: the same config files are written for the same runtimes
(opencode still writes both settings.json and its permissions; kilo writes
only its permissions; codex minimal-mode and opencode GSD_TEST_MODE guards
unchanged). Unknown runtimes fail loudly via TypeError, with an Object.hasOwn
barrier so prototype-chain keys (__proto__/constructor) also throw rather than
returning a bogus intent. Leads the installer-refactor chain (#58 -> #60 ->
#56), building on ADR-58.

Closes #60

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#60): add changeset for runtime config adapter registry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#60): register Runtime Config Adapter Registry in CONTEXT.md glossary

Per docs/contributor-standards.md, every new Module/seam must get a
`### <Name>` entry under the domain glossary. Adds the entry for the
runtime-config-adapter-registry seam introduced in this PR (interface,
policy boundary, source file, ADR-58 / #60 cross-references).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#764): skip cross-platform test matrix for docs-only and inert-CI PRs (#798)

test.yml had no paths filter and the ci-test-scope classifier treated docs/
and every .github/workflows/* as code_changed, so documentation edits and
product-irrelevant automation tweaks still spun up the full Linux/Windows/macOS
matrix. Narrow the heavy matrix to changes that can actually affect the product
or the test pipeline.

- ci-test-scope.cjs: drop docs/ from code_changed (docs-only -> full skip; the
  required-tests fan-in still reports green). Add src/ to code_changed (it was
  missing -> a source-only PR previously skipped all tests). Add INERT_WORKFLOWS
  allowlist + isInertCi() + an "inert CI" rule, and a product_changed output that
  gates the heavy test/coverage jobs. Fail-safe: any workflow not on the inert
  allowlist defaults to the full matrix. A module-load assertion throws if a
  PROTECTED_WORKFLOWS entry (test/install-smoke/mutation/security-scan/release)
  is ever added to the inert set, so a weakening edit fails CI loudly.
- test.yml: keep the static 3-lane matrix (so the H1 shell-policy linter can
  still statically verify the Windows lane), gate test/coverage on
  product_changed, add a lightweight ubuntu-only test-inert job, and branch the
  required-tests fan-in on product_changed.
- docs-required.yml: run docs-parity-live-registry (gated on docs/ changes) so
  pure-docs PRs still catch live-registry drift without the matrix.
- tests: cover docs-only, inert-only, src/, pipeline, unknown-workflow fail-safe,
  mixed escalation, the code_changed=false -> no-lanes invariant, and protected-
  workflow tamper-evidence.

Closes #764

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#766): distribute gsd-core as a native Claude Code plugin (#797)

* feat(#766): distribute gsd-core as a native Claude Code plugin

Add an additive .claude-plugin/plugin.json manifest plus hooks/hooks.json so gsd-core can be installed as a first-class Claude Code plugin (marketplace or zero-friction @skills-dir), with /gsd-core: namespaced commands and lifecycle management — alongside the unchanged npm/file-copy installer.

- .claude-plugin/plugin.json: validated with 'claude plugin validate --strict'
- hooks/hooks.json: mirrors the installer's always-on Claude hook wiring via ${CLAUDE_PLUGIN_ROOT}
- package.json: ship .claude-plugin in the npm tarball
- tests/issue-766-plugin-manifest.test.cjs: manifest + always-on-hook-contract drift guards
- docs: install-on-your-runtime.md + FEATURES.md

Closes #766

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#766): add ADR-766 + glossary entry for Claude Code Plugin Manifest Module

Record the plugin manifest as the Seam projecting gsd-core's artifact surfaces onto the Claude Code plugin contract (sibling of the Runtime Artifact Layout Module, ADR-3660), with the defined kind->field mapping and the always-on hook projection rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(#56): retire legacy runtime directory helpers into runtime-homes projection (#802)

* refactor(#56): retire legacy runtime directory helpers into runtime-homes projection

Consolidate per-runtime global config-dir resolution onto the single
canonical projection runtime-homes:getGlobalConfigDir. Extend it with the
explicitDir override (CLI --config-dir) and the opencode/kilo
OPENCODE_CONFIG/KILO_CONFIG file-path precedence the installer helpers had,
making it byte-for-behavior equivalent to the old getGlobalDir across all
15 install runtimes.

Delete bin/install.js's getGlobalDir/getOpencodeGlobalDir/getKiloGlobalDir
(and the orphaned local expandTilde), repoint all 9 call-sites, and remove
getGlobalDir from module.exports (net -242 lines in the installer). Migrate
the 5 test importers to the canonical projection; harden default/XDG
assertions against ambient *_CONFIG env vars. getAgentsDir now respects
OPENCODE_CONFIG/KILO_CONFIG consistently with the installer (intentional
convergence). Update CONTEXT.md Installer Module entry.

Completes the installer-refactor chain #58 -> #60 -> #56.

Closes #56

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#56): add changeset for runtime directory helper retirement

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#786): elevate GitHub Copilot installer — lifecycle hook + AGENTS.md (#804)

* feat(#786): elevate Copilot installer with lifecycle hook + AGENTS.md

Emit a self-contained sessionStart hook config (.github/hooks/gsd-session.json
local, ~/.copilot/hooks/gsd-session.json global) and write AGENTS.md at the repo
root (Copilot CLI reads it as primary instructions) alongside
copilot-instructions.md. The hook is an inline `command` hook (no separate hook
script), so it cannot dangle. Uninstall removes both and preserves user content.

Verified against GitHub Copilot CLI primary docs: hooks-configuration (camelCase
events, version+hooks shape, inline bash/powershell command hooks) and
add-custom-instructions (AGENTS.md read at repo root as primary instructions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#786): set changeset pr number to 804

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#783): resolve Kilo global skills base to ~/.kilo/skills (#806)

* fix(#783): resolve Kilo global skills base to ~/.kilo/skills

getGlobalSkillsBase('kilo') returned ~/.config/kilo/skills (the XDG config
dir), but Kilo Code discovers global skills from ~/.kilo/skills/ (the .kilo
dir in HOME), independent of the kilo.jsonc config dir. Add a HOME-relative
special case so the resolver matches Kilo's actual discovery path.

The config dir (~/.config/kilo) and the installer's command/ path are
correct and unchanged. This corrects the path used by doctor/status and
agent-skills-block resolution; the installer writes commands (not skills)
for Kilo, so no files were being written to the wrong location.

Closes #783

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#783): set changeset pr to 806

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#785): write .cursor/commands/ Cursor 1.6 slash-command surface (#805)

* feat(#785): write .cursor/commands/ as Cursor 1.6 slash-command surface

Cursor 1.6 (released 2025-09-12) introduced plain-markdown slash commands
in `.cursor/commands/<name>.md` — no frontmatter, invocable via `/` in the
Agent input. GSD previously emitted only `~/.cursor/skills/` for Cursor.

This PR wires a second artifact kind for `cursor` in
`runtime-artifact-layout.cts`: `convertedCommandsKind('commands', 'gsd-',
'convertClaudeCommandToCursorCommand', configDir)`. The new kind applies the
same `convertClaudeToCursorMarkdown` transforms (tool renames, brand
substitution, slash-command normalisation) and then strips YAML frontmatter
so the output is plain prose. Skills output is unchanged.

`stageCommandsForRuntimeFlat` in `install-profiles.cts` stages each source
`.md` as a flat `<stem>.md` in a temp dir; the existing `_copyStaged` commands
path then prefixes and copies to `<configDir>/commands/`.

`.cursor/mcp.json` is explicitly OUT OF SCOPE: GSD ships no MCP server; the
`mcpServers` schema cannot be usefully populated by the installer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#785): address review nit

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#787): elevate Cline — .clinerules/ dir form, PreToolUse hook, AGENTS.md (#803)

* feat(#787): elevate Cline — .clinerules/ dir form, PreToolUse hook, AGENTS.md

Migrate the installer's Cline output from a single-file .clinerules to the
.clinerules/ directory form (.clinerules/gsd.md), which is the prerequisite for
Cline's v3.36 hooks (a path cannot be both a file and a directory). Add a
.clinerules/hooks/PreToolUse lifecycle hook implementing Cline's JSON stdin ->
{cancel,errorMessage,contextModification} protocol; it guards .planning/
artifacts and fails open. On global installs, merge GSD instructions into the
cross-tool ~/.agents/AGENTS.md target (marker-delimited, merge-safe). A legacy
single-file .clinerules is migrated in place; --uninstall removes the new
artifacts and strips the AGENTS.md GSD block.

Also fixes the uninstall targetDir for Cline local installs (it pointed at
./.cline instead of the project root) and re-runs writeManifest after the
Cline artifacts are written so they are hash-tracked.

Self-contained: implemented independently of the #782 Cline skills work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#787): address review findings

- Scope PreToolUse hook path-walk to PATH_KEY fields only (eliminates false
  positive when doc body content mentions .planning/)
- Use lstatSync + isSymbolicLink() for migration guard so GSD never writes
  through a user's symlinked .clinerules into an external directory
- Add regression tests for both cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(#787): set changeset pr: 803

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#812): honor COPILOT_HOME in Copilot global config-dir resolution (#814)

* fix(#812): honor COPILOT_HOME in Copilot global config-dir resolution

getGlobalConfigDir('copilot') resolved the global config directory using
only --config-dir > COPILOT_CONFIG_DIR > ~/.copilot, ignoring the
COPILOT_HOME env var. Per GitHub's Copilot CLI docs, COPILOT_HOME
overrides the default ~/.copilot location (and user-level hooks are read
from $COPILOT_HOME/hooks/), so a global --copilot install wrote all
artifacts (skills, agents, copilot-instructions.md, the gsd-session.json
hook) to ~/.copilot even when the user relocated their Copilot home,
making them undiscoverable by Copilot CLI.

Mirror the codex/CODEX_HOME branch: precedence is now
--config-dir > COPILOT_CONFIG_DIR > COPILOT_HOME > ~/.copilot. Uninstall
uses the same resolver, so it stays symmetric.

Also: document COPILOT_HOME in the installer --help notes, the
USER-GUIDE env-var table, and the installer-migrations Copilot row; and
clear COPILOT_HOME in the two default-path test suites so they stay
hermetic now that the resolver honors it.

Closes #812

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#812): add changeset for PR #814

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#788): expand Qwen Code hook-event coverage (#807)

* feat(#788): expand Qwen Code hook-event coverage to 4 new events

Register SubagentStop, Stop, PreCompact (gsd-context-monitor.js) and
UserPromptSubmit (gsd-prompt-guard.js) in the Qwen Code installer.
Guard is isQwen-only — Claude Code and all other runtimes are unchanged.
Uninstall loop extended to include the 4 new event names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#788): reconcile to 3 Qwen-only events — defer UserPromptSubmit

gsd-prompt-guard exits unless tool_name is Write|Edit (PreToolUse
payload shape); UserPromptSubmit carries raw user-prompt text with no
tool_name field, so wiring it would be a silent no-op.  Deferred to a
follow-on issue.

Artifacts made consistent:
- bin/install.js: drop UserPromptSubmit registration block; uninstall
  loop drops UPS from event list
- .changeset/788-qwen-hook-events.md: corrected to 3 events + rationale
- docs/how-to/install-on-your-runtime.md: remove UPS row from hook table
- tests/enh-788-qwen-hook-events.test.cjs: assert UPS NOT registered;
  fix idempotency suite to persist settings between installs; drop
  UPS-specific assertions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(#788): update changeset PR number to #807

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#788): prune stale install-bucket allowlist entry for enh-788 test

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* enhancement(#782): emit gsd skills to ~/.cline/skills for Cline >= v3.48 (#809)

Cline added a global skills system (~/.cline/skills/<name>/SKILL.md) in
v3.48.0, but gsd treated Cline as rules-only and emitted zero skills
(getGlobalSkillsBase('cline')=null, empty artifact kinds). This makes gsd
emit skills for Cline at global scope, alongside the existing .clinerules.

- runtime-homes: getGlobalSkillsBase('cline') -> ~/.cline/skills (was null)
- runtime-artifact-layout: cline emits a skills kind for GLOBAL scope only
  (local stays .clinerules-only), mirroring claude's scope dispatch
- install.js: convertClaudeCommandToClineSkill emits name+description-only
  SKILL.md frontmatter (Cline/agentskills.io spec; no Claude-specific
  allowed-tools/argument-hint/agent), hyphen-normalized + .cline/-rewritten
  body; global cline routed through the skills path while .clinerules is
  still written; _applyRuntimeRewrites cline case handles custom
  CLINE_CONFIG_DIR; convertClaudeToCliineMarkdown also rewrites bare
  ~/.claude and CLAUDE_CONFIG_DIR
- docs: install-on-your-runtime.md documents Cline global skills vs local rules
- tests: converter (name+description-only), global emission, skills+.clinerules
  coexistence, scope-aware layout, custom-dir paths, idempotency

Closes #782

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#790): emit Augment slash commands (~/.augment/commands/) (#808)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* enh(#784): emit native skills for OpenCode + Kilo runtimes (#810)

* feat(#784): emit native skills for OpenCode + Kilo runtimes

OpenCode and Kilo share a config schema and both discover on-demand
skills from skills/<name>/SKILL.md. The installer previously emitted
only flat commands (command/) and file-based agents (agents/) for these
runtimes. Add a shared OpenCode-family skill writer that stages each GSD
command as a spec-compliant SKILL.md (name matching the directory,
description 1-1024 chars), wired through the runtime artifact layout so
uninstall cleans skills/ automatically. Skills respect the active
install profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#784): correct skill body paths + preserve user dev-preferences

Address adversarial-review findings:
- Add opencode/kilo cases to _applyRuntimeRewrites so staged SKILL.md
  bodies are re-pointed from the converter's hardcoded default config dir
  to the actual install target (fixes --local / --config-dir installs;
  commands/agents already did this by applying pathPrefix pre-conversion).
- Preserve user-owned skills/gsd-dev-preferences across reinstall in
  installOpencodeFamilySkills (snapshot+restore around the gsd-* prune),
  matching installRuntimeArtifacts.
- Export installOpencodeFamilySkills and add regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#784): guarantee command/skill body parity, fix kilo-alt double-rewrite

Follow-up adversarial-review found the post-conversion path rewrite could
double-rewrite custom Kilo dirs (kilo -> kilo-alt -> kilo-alt-alt) because
the kilo pathPrefix is a $HOME (non-absolute) superset of the hardcoded
default base. Restructure so OpenCode/Kilo skills mirror copyFlattenedCommands
exactly: stage raw commands, apply pathPrefix BEFORE conversion via a new
shared applyOpencodeFamilyPathPrefix() helper (now used by both the command
and skill writers), then convert. This guarantees byte-for-byte command/
skill body parity for global, --local, and --config-dir installs and removes
the prefix-overlap hazard. Drop the fragile _applyRuntimeRewrites opencode/
kilo case. Strengthen the path regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(#784): derive opencode/kilo skills from the same staged command set

Pass the installer's _stageSkills() output directly to
installOpencodeFamilySkills instead of re-staging via the layout, so the
command/ and skills/ surfaces always cover the identical profile-resolved
set — including the --minimal/--core-only alias path, which stages
differently from a plain --profile=core. Verified: minimal install now
emits 8 commands and 8 skills.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#784): set changeset PR number to 810

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#784): fully escape backslashes in test helper (CodeQL js/incomplete-string-escaping)

Replace the dot-only escape `replace(/[.]/g, '\\.')` with a complete
regex-escape pattern `replace(/[\\.*+?^${}()|[\]]/g, '\\$&')` so all
regex metacharacters (including backslash itself) in `defaultBase` are
safely escaped before interpolation into `new RegExp(...)`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#813): apply per-runtime skill path rewrites in applySurface (#817)

* fix(#813): apply per-runtime skill path rewrites in applySurface

applySurface() re-staged skill artifacts but, unlike installRuntimeArtifacts(),
never applied the per-runtime path rewrites. So /gsd:surface
(profile/enable/disable/reset) overwrote installed SKILL.md bodies with the
converter's default ~/.claude paths instead of the install target (pathPrefix),
silently regressing skill path references for every skillsKind runtime until
the next reinstall.

applySurface now mirrors installRuntimeArtifacts: for kind.kind === 'skills' it
derives pathPrefix the same way and applies applyRuntimeContentRewritesInPlace
on the staged dir before syncing.

- bin/install.js: export applyRuntimeContentRewritesInPlace
- runtime-artifact-layout.cts: carry resolved scope on Layout; export
  getInstallExports; type computePathPrefix/applyRuntimeContentRewritesInPlace
  on InstallExports
- surface.cts: lazily derive pathPrefix (only when a skills kind exists) and
  apply the rewrite via the shared getInstallExports accessor — single source of
  truth with install, only skills kinds rewritten (matches install)
- tests: regression test parameterized over cursor + codex asserting
  post-applySurface bodies carry the install pathPrefix, not ~/.claude
- CONTEXT.md: glossary updated for the applySurface rewrite parity + scope seam

Closes #813

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#813): add changeset fragment for PR #817

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#813): normalize configDir prefix to forward slashes for Windows CI

The #813 regression assertion compared skill bodies against a raw
${configDir}/ prefix, but production derives pathPrefix via
path.resolve(configDir).replace(/\\/g, '/'). On Windows, mkdtempSync
returns backslash paths while the rewritten body uses forward slashes,
so the assertion would fail Windows-only (not covered by local gsd-test).
Normalize the expected prefix the same way production does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#816): mirror install command-prefix handling in _syncGsdDir (#822)

* fix(#816): mirror install command-prefix handling in _syncGsdDir

applySurface() via _syncGsdDir handled command artifacts differently from a
fresh install. For flat command dirs (cursor/augment/opencode/kilo) install's
_copyStaged adds kind.prefix (gsd-<stem>.md) and _removeGsdEntries prunes
prefix-scoped, but _syncGsdDir copied staged files verbatim (unprefixed) and
pruned by exact name. So every /gsd:surface toggle wrote wrong filenames,
orphaned the installed gsd-*.md, and deleted user-authored command files.

_syncGsdDir's commands/agents branch now mirrors install:
- flat command dirs get the gsd- prefix on copy; namespaced dirs (commands/gsd)
  and agents keep staged names, using install's namespacedByDir rule
- prune is prefix-scoped so user files in shared flat dirs are preserved;
  namespaced commands/gsd stays membership-pruned so superseded commands are
  still removed on profile shrink

The naming rule is intentionally re-implemented (not via require('bin/install.js')
to avoid its module-load banner side-effect); a strict parity test asserts
applySurface and installRuntimeArtifacts produce identical command filenames for
opencode/kilo/cursor/augment/gemini, guarding against drift.

Closes #816

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#816): add changeset fragment for PR #822

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#771): convert agent color: hex/magenta values to documented named colors (#823)

* chore(#771): convert agent color: hex/magenta values to documented named colors

Claude Code's sub-agent `color:` field documents only 8 named colors
(red, blue, green, yellow, purple, orange, pink, cyan). Twelve agent
files used hex values and two used the undocumented `magenta`; convert
each to the nearest documented named color so the intended per-agent
TUI color differentiation is spec-compliant.

- agents/*.md: 14 color values hex/magenta -> nearest named color
- scripts/research-profiles.cjs: update the 3 generated research-agent
  profiles (source of truth) so gen-research-agents stays in sync
- docs/AGENTS.md: update documented colors; add missing Color rows for
  gsd-nyquist-auditor, gsd-project-researcher, gsd-phase-researcher
- tests/agent-frontmatter.test.cjs: add regression guard asserting every
  agent color: is in the documented named-color set

Closes #771

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#771): add changeset

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#773): add --ephemeral and --dangerously-bypass-hook-trust to automated codex exec wrappers (#824)

* feat(#773): add --ephemeral and --dangerously-bypass-hook-trust to automated codex exec invocations

Automated codex exec calls in the review workflow now carry --ephemeral
(no session-state accumulation across CI runs) and
--dangerously-bypass-hook-trust (skip hook-trust prompts for hooks
whose provenance gsd-core already controls). Both flags were verified
present in the installed codex CLI (codex exec --help).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#773): correct changeset pr: reference to #824

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#775): ship a gemini-extension.json extension package (#818)

Add a Gemini CLI extension package so users can install, update, and
remove GSD through Gemini's own extension lifecycle and have it appear in
`gemini extensions list`:

  gemini extensions install https://github.com/open-gsd/gsd-core
  gemini extensions update gsd-core
  gemini extensions uninstall gsd-core
  gemini extensions link /path/to/gsd-core   # dev

This mirrors the additive Claude Code plugin manifest (#766): a thin,
version-stamped manifest enforced by an in-repo drift test. The extension
ships the context-file payload (GEMINI.md), loaded into every Gemini
session; slash-command/agent/hook TOML projection into the extension is a
documented follow-up. The manual `npx gsd-core --gemini` installer (which
provides the /gsd:* commands) is unchanged — purely additive, no breaking
change.

- gemini-extension.json: name=binName, version tracks package.json,
  description, contextFileName=GEMINI.md (minimal; no mcpServers — gsd
  ships no MCP server)
- GEMINI.md: Gemini-session context payload
- package.json: add both artifacts to files[] so they publish
- CONTEXT.md: add "Gemini Extension Package" glossary entry
- docs: USER-GUIDE + install-on-your-runtime how-to
- tests/issue-775-gemini-extension.test.cjs: manifest validity, version
  parity with package.json, contextFileName existence, files[] publication

Closes #775

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#768): pre-populate settings.json permissions.allow/deny for Claude Code (#819)

* feat(#768): pre-populate settings.json permissions.allow/deny for Claude Code

Adds mergeClaudePermissions() to bin/install.js which non-destructively
appends GSD's known-safe tool-call patterns to permissions.allow and
defense-in-depth credential-file patterns to permissions.deny during
Claude Code installs. Merge is idempotent (no duplicates on reinstall)
and additive (existing user entries preserved). Uninstall removes only
the exact GSD-owned entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: update changeset pr number to 819

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#774): emit service_tier/model_verbosity in Codex agent TOML + agents/openai.yaml skill chip (#828)

* feat(#774): emit service_tier/model_verbosity in Codex agent TOML + agents/openai.yaml skill chip

- Add service_tier = "flex" and model_verbosity = "low" to the Codex
  ConfigProfile TOML for light-tier agents (gsd-research-synthesizer,
  gsd-codebase-mapper, gsd-plan-checker, and 8 others identified via
  AGENT_DEFAULT_TIERS). Field names/values verified against Codex schema
  (profile_toml.rs / config_types.rs Verbosity enum). Non-light agents
  are unaffected.

- Add generateCodexSkillMetadataYaml() and writeCodexSkillMetadataFiles():
  after installRuntimeArtifacts, iterate every gsd-* skill directory,
  read the short-description already emitted in the SKILL.md frontmatter
  by convertClaudeCommandToCodexSkill, and write agents/openai.yaml with
  interface.display_name and interface.short_description for the Codex
  TUI skill picker chip.
  - yamlQuote (JSON.stringify) handles all YAML-unsafe chars.
  - User-owned gsd-dev-preferences dir is never overwritten.
  - Errors per-skill are swallowed so a bad SKILL.md can't abort install.
  - agents/openai.yaml is covered by the snapshot/rollback system and
    manifest hash (writeManifest hashes skill dirs recursively).
  - Uninstall symmetry: _removeGsdEntries removes whole gsd-* dirs.

- 21 new tests in codex-config.test.cjs covering service_tier/verbosity
  TOML emission, YAML generation (round-trip via js-yaml), and
  writeCodexSkillMetadataFiles including an e2e integration test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#774): correct docs-lint coverage — proper changeset format + USER-GUIDE entry

Rewrite the changeset fragment from old @opengsd/gsd-core:patch format to the
required type:/pr: schema so the docs-lint parser can consume it.  Add a new
"Codex skill picker and agent scheduling (#774)" section to docs/USER-GUIDE.md
describing the flex-tier scheduling and /skills TUI chip enrichments — both are
user-visible and belong in docs rather than behind a docs-exempt marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#769): adopt context:fork + effort on heavy workflow skills (#820)

* feat(#769): emit context:fork + effort: frontmatter on heavy workflow skills

Add `context: fork` and `effort: xhigh` to the three heaviest workflow
commands (plan-phase, execute-phase, autonomous) and `effort: low` to the
two quick-status commands (progress, stats).

On Claude Code, `context: fork` runs the skill in an isolated subagent
context window so the main session's context budget is protected.
`effort: xhigh` / `effort: low` signal the appropriate token-budget tier to
the runtime. Both fields are silently ignored by runtimes that do not
recognise them (Gemini, Codex, Cursor, etc.) — no behaviour change outside
Claude Code.

Update convertClaudeCommandToClaudeSkill in bin/install.js to preserve
`context:` and `effort:` when rewriting source command files to SKILL.md for
a Claude global install. Add install-suite tests to assert the fields are
present in both source commands and the installed SKILL.md output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#769): tighten regex assertions + add execute/plan-phase effort coverage

Fix low-severity adversarial finding: tighten test regex patterns from
`\s*` to `[ \t]*` so they cannot match across newlines (CRLF parity).
Add missing effort: xhigh assertions for gsd-execute-phase and gsd-plan-phase
SKILL.md install output to complete the black-box coverage gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#772): adopt stable Codex hook events + commandWindows for Windows parity (#827)

* feat(#772): adopt stable Codex hook events + commandWindows for Windows parity

Register three new stable Codex hook events (SubagentStart, Stop,
PostToolUse) wired to gsd-context-monitor.js so Codex installs get
the same context-headroom tracking at subagent and session boundaries
that Claude/Qwen already have.

Add commandWindows field to the SessionStart hook entry on Windows so
Codex uses the .cmd shim directly (Git Bash/MSYS cannot POSIX-exec
node.exe). commandWindows is only emitted on win32; POSIX is unchanged.

Refactor reconcileCodexHooksJsonSessionStart into a generic
reconcileCodexHooksJsonEvent so any event name can be reconciled with
the same dedup/preserve-user-entries logic.

Add gsd-context-monitor.js and .cmd to MANAGED_HOOK_COMMAND_BASENAMES
_BY_SURFACE so idempotent re-runs de-duplicate entries correctly.

30 new tests covering: export surface, event registration for each of
the three events, commandWindows parity (POSIX vs win32), idempotency,
uninstall, and user-entry preservation.

Closes #772

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#772): windows path normalization + docs-lint

- Normalize scriptPath backslashes to forward slashes in
  ensureCodexHooksJsonEvent and ensureCodexHooksJsonSessionStart so that
  isManagedHookCommand can match stored commands against configDir on
  Windows CI runners. path.resolve returns backslash paths on Windows,
  but when platform is not 'win32' (e.g. platform:'linux' in tests),
  projectManagedHookCommand skips normalization — producing a mismatch
  that breaks idempotency deduplication (the same hook entry appended
  twice on re-register). Forward-slash paths are always valid in both
  Node.js and Codex, so the normalization is safe for all platforms.
- Fix changeset pr: 0 → 827 to resolve fail_malformed_fragment.
- Add Codex hook coverage table to docs/how-to/install-on-your-runtime.md
  documenting the SubagentStart/Stop/PostToolUse events + commandWindows
  Windows-parity field added by this enhancement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#778): cross-runtime command enrichment (Gemini {{args}}/!{}, Qwen priority) (#825)

* feat(#778): cross-runtime command enrichment (Gemini {{args}}/!{}, Qwen priority)

Enrich the installer's per-runtime command/skill generators with native,
verified, additive fields:

- Gemini CLI: map Claude's $ARGUMENTS -> Gemini's {{args}} in generated TOML
  commands so typed arguments interpolate; inject live .planning/STATE.md into
  /gsd:progress via a fixed, injection-safe !{cat .planning/STATE.md 2>/dev/null}
  shell block (no interpolated input).
- Qwen Code: emit the optional numeric `priority` field on main-loop skills so
  the most-used workflows sort first in the /skills list (higher = earlier per
  the Qwen skills spec; the issue's inverse numbering was corrected).

OpenCode per-command model/agent/subtask/variant enrichment was evaluated and
intentionally not implemented: `model` reintroduces the #1156
ProviderModelNotFoundError regression for non-Anthropic providers (the converter
deliberately strips model:), `subtask`/`agent` change execution semantics for
GSD's interactive commands, and `variant` is not in the OpenCode command schema.

Schemas verified against primary docs (Gemini custom-commands, Qwen skills,
OpenCode commands/skills). Adds tests/enh-778-* and how-to + USER-GUIDE docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#778): set changeset PR number to 825

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#789): elevate CodeBuddy — slash commands (#830)

* feat(#789): elevate CodeBuddy — emit slash commands (+ document subagent/MCP scope)

Emit a CodeBuddy slash-command surface so GSD workflows appear in the
'/' menu, reaching parity with other elevated runtimes.

- Add convertClaudeCommandToCodebuddyCommand and register a commands/
  artifact kind for the codebuddy runtime (commands/gsd-<name>.md),
  consistent with the Cursor (#785) and Augment (#790) commands surfaces.
- Mark emitted skills user-invocable:false so the commands surface is the
  sole '/' entry point (no duplicate /gsd-* entries); skills stay
  model-invocable. CodeBuddy's SKILL.md supports this field.
- Normalize $HOME/.codebuddy (bare + slash) path forms in runtime
  rewrites so --config-dir/local installs don't leak the default home.
- Report installed commands/ count on install; uninstall prunes gsd-*
  commands while preserving user-owned commands.

Scope: subagents (~/.codebuddy/agents/) are already emitted by the
generic agents block (unchanged); no mcp.json is written (gsd ships no
MCP server, and CodeBuddy's mcp.json registers only external servers).

Closes #789

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#789): set changeset pr number to 830

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#776): Gemini hook events (BeforeAgent/AfterAgent/BeforeModel) + hooksConfig.enabled check (#829)

* feat(#776): Gemini hook events (BeforeAgent/AfterAgent/BeforeModel) + hooksConfig.enabled check

Register three new Gemini-CLI hook events on install:
  - BeforeAgent: fires before agent planning; wired to gsd-context-monitor
  - AfterAgent: fires after final response generation; wired to gsd-context-monitor
  - BeforeModel: fires before each LLM call (per-turn); wired to gsd-context-monitor

All three reuse gsd-context-monitor.js (no new hook files). Uninstall cleanup
loop extended to remove the new events. Non-array guard added for robustness
against malformed settings.

Also detect hooksConfig.enabled:false in Gemini settings and emit a clear
warning — without this check, all registered hooks silently do nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: update changeset pr: 829

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#776): document Gemini hook events

Add hook coverage table to the Gemini CLI section of install-on-your-runtime.md,
covering the three new events (BeforeAgent/AfterAgent/BeforeModel wired to
gsd-context-monitor) plus a callout for the hooksConfig.enabled:false silent
failure mode detected by the installer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#777): register Cursor-native hooks (.cursor/hooks.json) for session-start/post-tool parity (#831)

* feat(#777): register Cursor-native hooks (.cursor/hooks.json) for session-start/post-tool parity

- Add gsd-cursor-session-start.js: injects STATE.md presence reminder (or
  new-project nudge) into Cursor sessions via the sessionStart hook event
- Add gsd-cursor-post-tool.js: emits an additional_context nudge when
  write-class tool calls touch .planning/ files (postToolUse hook event)
- Add 'cursor-hooks-json' installSurface to runtime-config-adapter-registry;
  writeCursorHooksJson/reconcileCursorHooksJson write the canonical
  { version: 1, hooks: { sessionStart, postToolUse } } JSON shape with
  idempotent reconciliation that preserves user-owned hook entries
- Hook scripts are copied with /gsd:→gsd- rewrite so installed files
  contain no colon-form slash-command refs (bug-376 invariant)
- 20 new tests in tests/cursor-hooks.test.cjs cover all reconciler paths,
  entry helpers, removal, runtime adapter surface, and hook script behavior
- Update CONTEXT.md, ARCHITECTURE.md, installer-migrations.md, and
  000-first-time-baseline.cts to include Cursor hooks.json surface

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#777): build hooks/dist on demand in bug-376 test for scoped/windows CI

hooks/dist is gitignored and only produced by `npm run build:hooks`.
The CI scoped (ubuntu-latest/node-22) and windows (windows-latest/node-24)
test jobs do NOT run build:hooks before executing tests, so bug-376's
prerequisite suite was failing with "hooks/dist not found" on both legs.

Add ensureHooksDist() helper (mirrors bug-3357 pattern) that builds
hooks/dist on demand in the before() hooks of prerequisite and Suite 3.
Also add ensureHooksDist() call to Suite 3's before() so the snapshot
step is also hermetic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#832): add how-to guide for minimal install / skill profiles (#835)

Add docs/how-to/install-minimal-and-add-skills.md covering the --minimal
/ --core-only / --profile=core install, the core/standard/full profiles,
and growing the surface live via /gsd:surface or on reinstall. Register
it in the docs/README.md How-to guides index.

Closes #832

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#815): add /gsd-update --next to install the @next RC channel (#839)

Adds an opt-in --next (alias --rc) flag to /gsd-update targeting the @next RC dist-tag (ADR #660), with a {latest,next} allowlist enforced at three layers, channel-aware version check + banner, and byte-for-byte unchanged default @latest behavior.

Closes #815

* fix(#837): three-dot diff in ci-test-scope so docs-only PRs skip the heavy matrix (#841)

CI test-scope detection diffed changed files with a two-dot
`git diff --name-only base head`, where base is the moving tip of `next`.
A PR branch cut from a slightly older `next` surfaced every product file
`next` had gained since the merge-base, flipping product_changed/full_matrix
and running the full Windows/macOS matrix + coverage on docs-only PRs.

Switch to a three-dot `git diff --name-only base...head` (vs the merge-base),
matching GitHub's PR "Files changed" semantics. Add a regression test that
builds a stale-base topology, plus a guard test pinning `fetch-depth: 0` on
the `changes` job (required for the merge-base to be locally available).

Closes #837

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#836): no-LLM duplicate-issue detection + challenge + 1-day auto-close (#843)

* feat(#836): no-LLM duplicate-issue detection + challenge + 1-day auto-close

Adds a deterministic (no-LLM) duplicate-issue governance lifecycle:

- scripts/issue-dedupe.cjs: pure, unit-tested module (tokenize, Sørensen–Dice
  title similarity, scoreCandidates, renderChallengeComment, shouldClose) with
  fail-safe destructive-action guards.
- duplicate-check.yml (issues:opened): scores new-issue title against open
  issues, posts a challenge comment + applies the pending `possible-duplicate`
  label on a clear match.
- duplicate-sweep.yml (daily cron): closes possible-duplicate issues whose
  challenge comment is >24h old with no human reply and no 👎 veto; honors
  exempt labels; re-checks the label immediately before close (TOCTOU guard);
  strips the label on close to avoid reopen loops.
- remove-duplicate-label.yml (issue_comment:created): clears the label and
  applies needs-maintainer-review when any human responds.
- bug_report.yml / docs_issue.yml: add the required "I searched existing
  issues" preflight checkbox so all five forms force a pre-search attestation.
- docs/agents/triage-labels.md: document the label + lifecycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#836): add changeset fragment for duplicate-issue detection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#770): register Claude Code lifecycle hooks (SubagentStop/Stop/PreCompact/FileChanged) (#821)

* feat(#770): register Claude Code lifecycle hooks (SubagentStop/Stop/PreCompact/FileChanged)

Wire three new context-tracking events (SubagentStop, Stop, PreCompact) to
gsd-context-monitor so context-headroom warnings surface at model-stop and
subagent-finalisation moments — not just on PostToolUse.  Add a new
FileChanged hook (gsd-config-reload.js) that hot-reloads .planning/config.json
context mid-session when the user edits it, injecting a config summary as
hookSpecificOutput.additionalContext.  Updates plugin manifest hooks.json,
managed-hooks-registry, installer-migration-report allowlist, and
shell-command-projection cleanup tables.  Tests: 21 new assertions in
enh-770-claude-hook-events.test.cjs; enh-788 and issue-766 test suites updated.

Closes #770

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#770): document newly-registered Claude Code lifecycle hooks

Add a Hook coverage table to the Claude Code npm installer section of
docs/how-to/install-on-your-runtime.md describing SubagentStop, Stop,
PreCompact, and the new FileChanged (gsd-config-reload.js) hook that
hot-reloads .planning/config.json mid-session. Also fixes the changeset
frontmatter (adds type: Added + pr: 821) so docs-lint can consume the
fragment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#770): add gsd-config-reload.js to INVENTORY.md and regenerate manifest

The feat commit added hooks/gsd-config-reload.js but did not bump the
Hooks count in docs/INVENTORY.md (14→15) or add the new row, and did not
regenerate docs/INVENTORY-MANIFEST.json. Both inventory-counts and
inventory-manifest-sync tests failed across the full CI matrix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#770): make lifecycle-hook tests deterministic on scoped runner

Replace the shared hooks/dist/ ensemble setup (ensureHooksDist /
teardownHooksDist) in the Claude hook tests with per-test isolation:
pre-populate each test's own tmpDir/.claude/hooks/ with stub files and
pass installerMigrations:[] to install() so the first-time-baseline
migration does not remove the stubs before the copy step can run.

Root cause: hooks/dist/ is gitignored and absent on a fresh npm ci.
ensureHooksDist() created it and teardownHooksDist() deleted it, but
with --test-concurrency=4 both test files ran concurrently as separate
Node.js worker processes sharing the same filesystem.  One file's
afterEach teardown deleted hooks/dist/ while the other file's install()
was copying from it, producing an ENOENT (reproduced 2/10 runs locally).

The additional issue: even with pre-placed stubs surviving the copy race,
the 000-first-time-baseline migration classified hooks/gsd-*.js as
bundled-gsd-hook artifacts, auto-removed them, and the copy step never
re-ran (hooks/dist/ absent) — leaving contextMonitorFile missing and all
hook registrations silently skipped (the 'got: []' symptom).

Fix: pre-populate targetDir/hooks/ per-test (isolated temp dir) AND pass
installerMigrations:[] so the baseline scan is skipped.  The Qwen suites
already used this pattern correctly; the Claude suites are aligned to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#770): ship gsd-config-reload.js by adding it to build-hooks HOOKS_TO_COPY

The #770 feature added hooks/gsd-config-reload.js and registered it in
MANAGED_HOOKS, the installer, INVENTORY, and the test EXPECTED_ALL_HOOKS
list — but never added it to scripts/build-hooks.js HOOKS_TO_COPY. As a
result the hook was never copied into hooks/dist/ during the build, so:

  - the hook would never ship to users (real production bug — the
    FileChanged config-reload feature was dead-on-arrival), and
  - install-minimal-hooks.test.cjs #1755 ("all expected hooks are copied
    from hooks/dist/ to target", ".js hooks are executable after copy",
    "manifest contains .js hook entries") failed on any environment with
    a clean checkout (no pre-existing hooks/dist/): coverage, full test
    macos-22/macos-24, test ubuntu-24.

The failures were masked locally only by a stale hooks/dist/ left from a
prior build (build-hooks copies into dist without clearing it). On CI's
fresh `npm ci` there is no dist, so the omission surfaced.

Fix: add 'gsd-config-reload.js' to HOOKS_TO_COPY so build-hooks stages it
into hooks/dist/ alongside the other JS hooks. Verified by removing
hooks/dist/ and rerunning the full suite green (0 fail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#770): make config prototype-pollution beforeEach deterministic on scoped runner

Root cause: the #663 and alert-#26 prototype-pollution describe blocks
seeded .planning/config.json in beforeEach via a bare
runGsdTools('config-ensure-section') whose result was discarded. That
command runs in a spawned gsd-tools child; on the scoped CI lane
(--test-concurrency=4, config.test.cjs scheduled alongside the heavy
install/tarball suites that #770 pulled into the targeted set) the child
can be transiently killed under resource pressure (non-zero exit, empty
stderr — an OS-level kill, not an app error). The swallowed failure left
config.json absent, so the first subtest's readConfig() threw ENOENT
opening <tmp>/.planning/config.json. Only 1 of 4 subtests failed,
confirming a per-invocation transient, not a deterministic miss; the full
suite schedules files differently so config.test.cjs did not collide with
those heavy neighbors → passed there.

Fix: add ensureConfigReady(tmpDir) which retries config-ensure-section on
ANY failure or missing file and throws a clear diagnostic if it still
cannot create config.json, then use it in both prototype-pollution
beforeEach blocks. Setup is now deterministic under load; the #663/alert-#26
security assertions are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#844): sync runtime manifest versions on npm version bump (#845)

* fix(#844): sync runtime manifest versions on npm version bump

The release workflow bumps package.json via `npm version` but never
stamped the runtime-integration manifests that must track it
(.claude-plugin/plugin.json #766, gemini-extension.json #775), so the
first RC/finalize whose version diverged from the -dev stream failed the
test suite before tagging/publishing.

Add scripts/sync-manifest-versions.cjs (single VERSIONED_MANIFESTS
registry) wired to a `version` npm lifecycle hook that stamps + stages
the manifests on every `npm version` — covering all four release bump
sites and local bumps with no workflow edits. A regression guard test
fails if any repo JSON whose version matches package.json is not
registered, forcing future version-bearing manifests into the sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#844): add changeset for manifest version sync fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump to 1.4.0-rc.2

* chore: finalize v1.4.0

* chore: promote CHANGELOG for v1.4.0

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Colin <colin@solvely.net>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Joe <44273333+jslitzkerttcu@users.noreply.github.com>
Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
2026-06-08 14:37:33 -04:00

87 KiB
Raw Blame History

Execute all plans in a phase using wave-based parallel execution. Orchestrator stays lean — delegates plan execution to subagents.

<core_principle> Orchestrator coordinates, not executes. Each subagent loads the full execute-plan context. Orchestrator: discover plans → analyze deps → group waves → spawn agents → handle checkpoints → collect results. </core_principle>

<runtime_compatibility> Subagent spawning is runtime-specific:

  • Claude Code: Uses Agent(subagent_type="gsd-executor", ...) — blocks until complete, returns result
  • Copilot: Subagent spawning does not reliably return completion signals. Default to sequential inline execution: read and follow execute-plan.md directly for each plan instead of spawning parallel agents. Only attempt parallel spawning if the user explicitly requests it — and in that case, rely on the spot-check fallback in step 3 to detect completion.
  • Other runtimes: If Agent/agent tool is unavailable, use sequential inline execution as the fallback. Check for tool availability at runtime rather than assuming based on runtime name.

Fallback rule: If a spawned agent completes its work (commits visible, SUMMARY.md exists) but the orchestrator never receives the completion signal, treat it as successful based on spot-checks and continue to the next wave/plan. Never block indefinitely waiting for a signal — always verify via filesystem and git state. </runtime_compatibility>

<required_reading> Read STATE.md before any operation to load project context. @/.claude/gsd-core/references/agent-contracts.md @/.claude/gsd-core/references/context-budget.md @~/.claude/gsd-core/references/gates.md </required_reading>

<available_agent_types> These are the valid GSD subagent types registered in .claude/agents/ (or equivalent for your runtime). Always use the exact name from this list — do not fall back to 'general-purpose' or other built-in types:

  • gsd-executor — Executes plan tasks, commits, creates SUMMARY.md
  • gsd-verifier — Verifies phase completion, checks quality gates
  • gsd-planner — Creates detailed plans from phase scope
  • gsd-phase-researcher — Researches technical approaches for a phase
  • gsd-plan-checker — Reviews plan quality before execution
  • gsd-debugger — Diagnoses and fixes issues
  • gsd-codebase-mapper — Maps project structure and dependencies
  • gsd-integration-checker — Checks cross-phase integration
  • gsd-nyquist-auditor — Validates verification coverage
  • gsd-ui-researcher — Researches UI/UX approaches
  • gsd-ui-checker — Reviews UI implementation quality
  • gsd-ui-auditor — Audits UI against design requirements </available_agent_types>
Parse `$ARGUMENTS` before loading any context:
  • First positional token → PHASE_ARG
  • Optional --wave N → WAVE_FILTER
  • Optional --gaps-only keeps its current meaning
  • Optional --cross-ai → CROSS_AI_FORCE=true (force all plans through cross-AI execution)
  • Optional --no-cross-ai → CROSS_AI_DISABLED=true (disable cross-AI for this run, overrides config and frontmatter)

If --wave is absent, preserve the current behavior of executing all incomplete waves in the phase.

Load all context in one call:
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi
INIT=$(gsd_run query init.execute-phase "${PHASE_ARG}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS=$(gsd_run query agent-skills gsd-executor)

Parse JSON for: executor_model, verifier_model, commit_docs, parallelization, branching_strategy, branch_name, phase_found, phase_dir, phase_number, phase_name, phase_slug, plans, incomplete_plans, plan_count, incomplete_count, state_exists, roadmap_exists, phase_req_ids, response_language.

Model resolution: If executor_model is "inherit", omit the model= parameter from all Agent() calls — do NOT pass model="inherit" to Agent. Omitting the model= parameter causes Claude Code to inherit the current orchestrator model automatically. Only set model= when executor_model is an explicit model name (e.g., "claude-sonnet-4-6", "claude-opus-4-7").

If response_language is set: Include response_language: {value} in all spawned subagent prompts so any user-facing output stays in the configured language.

Read runtime/worktree config and fail closed before any executor dispatch:

RUNTIME=$(gsd_run query config-get runtime --default claude 2>/dev/null || echo "claude")
USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees 2>/dev/null || echo "true")
EXECUTOR_STALL_INTERVAL_MINUTES=$(gsd_run query config-get executor.stall_detect_interval_minutes 2>/dev/null || echo "5")
EXECUTOR_STALL_THRESHOLD_MINUTES=$(gsd_run query config-get executor.stall_threshold_minutes 2>/dev/null || echo "10")

if [ "$RUNTIME" = "codex" ] && [ "$USE_WORKTREES" != "false" ]; then
  echo "FATAL: Codex execute-phase worktree isolation is unsupported. Set workflow.use_worktrees=false or use a runtime with Agent isolation=\"worktree\" support." >&2
  exit 1
fi
# Sweep orphaned locked worktrees from prior crashed sessions before spawning executors (#3707).
[ "$USE_WORKTREES" != "false" ] && gsd_run query worktree.reap-orphans 2>/dev/null || true
# Auto-degrade to sequential if HEAD has diverged from the worktree fork base (#683).
# Only applies to Claude Code (isolation="worktree" is Claude-Code-specific).
if [ "$RUNTIME" = "claude" ] && [ "$USE_WORKTREES" != "false" ]; then
  _SHOULD_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || true)
  if [ "$_SHOULD_DEGRADE" = "true" ]; then
    _DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true)
    [ -n "$_DEGRADE_MSG" ] && printf '%s\n' "$_DEGRADE_MSG" >&2
    USE_WORKTREES=false
  fi
fi

Codex maps subagents to spawn_agent, which has no direct Codex mapping for Claude Code's isolation="worktree" parameter. Failing closed prevents main-checkout edits while the workflow believes agents are isolated.

If the project uses git submodules, worktree isolation is unsafe only when a plan touches a submodule path — the executor commit protocol cannot correctly handle submodule commits inside isolated worktrees. The previous behavior unconditionally disabled worktree isolation whenever .gitmodules existed, which penalised every plan in a submodule project even when the plan was nowhere near a submodule. Compute submodule paths once and intersect them per-plan with the plan's declared files_modified frontmatter.

# Parse submodule paths from .gitmodules once (empty if no .gitmodules).
# SUBMODULE_PATHS is a newline-separated list of repo-relative paths.
if [ -f .gitmodules ]; then
  SUBMODULE_PATHS=$(git config --file .gitmodules --get-regexp '^submodule\..*\.path$' 2>/dev/null | awk '{print $2}')
else
  SUBMODULE_PATHS=""
fi

SUBMODULE_PATHS is exported to the execute_waves step, where the per-plan decision actually happens (see "Per-plan worktree decision" sub-step inside execute_waves). The decision is per-plan because different plans in the same wave can touch different files — only plans whose paths intersect a submodule must drop worktree isolation; plans nowhere near a submodule keep parallel isolation.

When USE_WORKTREES (project-level) is false, all executor agents run without isolation="worktree" — they execute sequentially on the main working tree instead of in parallel worktrees. The per-plan decision below has no effect when worktrees are project-disabled.

USE_WORKTREES is also automatically set to false for the duration of a run when worktree base-check detects that the orchestrator HEAD has diverged from the worktree fork base (the #683 condition — e.g. an unmerged milestone or feature branch). This check runs only when RUNTIME=claude because isolation="worktree" is a Claude Code-specific feature; other runtimes do not use it. The auto-degrade prints a one-line warning to stderr and falls through to the sequential path so executors do not hit the exit-42 worktree-branch-check halt. To restore parallel worktree execution, set worktree.baseRef:"head" in .claude/settings.local.json (or run gsd-tools worktree set-baseref) — this makes the fork base track the live HEAD instead of a fixed remote ref. The worktree-branch-check exit-42 guard inside each executor remains in place as a backstop.

Read context window size for adaptive prompt enrichment:

CONTEXT_WINDOW=$(gsd_run query config-get context_window 2>/dev/null || echo "200000")

When CONTEXT_WINDOW >= 500000 (1M-class models), subagent prompts include richer context:

  • Executor agents receive prior wave SUMMARY.md files and the phase CONTEXT.md/RESEARCH.md
  • Verifier agents receive all PLAN.md, SUMMARY.md, CONTEXT.md files plus REQUIREMENTS.md
  • This enables cross-phase awareness and history-aware verification

When CONTEXT_WINDOW < 200000 (sub-200K models), subagent prompts are thinned to reduce static overhead:

  • Executor agents omit extended deviation rule examples and checkpoint examples from inline prompt — load on-demand via @~/.claude/gsd-core/references/executor-examples.md
  • Planner agents omit extended anti-pattern lists and specificity examples from inline prompt — load on-demand via @~/.claude/gsd-core/references/planner-antipatterns.md
  • Core rules and decision logic remain inline; only verbose examples and edge-case lists are extracted
  • This reduces executor static overhead by ~40% while preserving behavioral correctness

If phase_found is false: Error — phase directory not found. If plan_count is 0: Error — no plans found in phase. If state_exists is false but .planning/ exists: Offer reconstruct or continue.

When parallelization is false, plans within a wave execute sequentially.

Runtime detection for Copilot: Check if the current runtime is Copilot by testing for the @gsd-executor agent pattern or absence of the Agent() subagent API. If running under Copilot, force sequential inline execution regardless of the parallelization setting — Copilot's subagent completion signals are unreliable (see <runtime_compatibility>). Set COPILOT_SEQUENTIAL=true internally and skip the execute_waves step in favor of check_interactive_mode's inline path for each plan.

REQUIRED — Sync chain flag with intent. If user invoked manually (no --auto), clear the ephemeral chain flag from any previous interrupted --auto chain. This prevents stale _auto_chain_active: true from causing unwanted auto-advance. This does NOT touch workflow.auto_advance (the user's persistent settings preference). You MUST execute this bash block before any config reads:

# REQUIRED: prevents stale auto-chain from previous --auto runs
if [[ ! "$ARGUMENTS" =~ --auto ]]; then
  gsd_run query config-set workflow._auto_chain_active false || true
fi

Resolve MVP_MODE once via the centralized phase.mvp-mode query verb (precedence chain: CLI flag → ROADMAP **Mode:** mvp → workflow.mvp_mode config → false):

MVP_FLAG_ARG=""
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--mvp([[:space:]]|$) ]]; then MVP_FLAG_ARG="--cli-flag"; fi
MVP_MODE=$(gsd_run query phase.mvp-mode "${PHASE_NUMBER}" $MVP_FLAG_ARG --pick active)
TDD_MODE=$(gsd_run query config-get workflow.tdd_mode 2>/dev/null || echo "false")
Before trusting `STATE.md` or dispatching any executor, derive `CURRENT_PLAN_ID` from the active incomplete plan in `INIT`, then search recent history: ```bash CURRENT_PLAN_ID="{phase_number}-{plan_padded}" SUMMARY_PATH="{phase_dir}/{plan_padded}-SUMMARY.md" PLAN_COMMITS=$(git log --oneline --grep="${CURRENT_PLAN_ID}" -30) ``` If production commits exist and `SUMMARY.md is missing`, stop before spawning a new executor; continuing risks duplicate work and stale `STATE.md`/ROADMAP progress. Offer these recovery options: - `close out manually` — inspect commits, write SUMMARY.md, then update STATE/ROADMAP. - `re-execute from scratch` — revert or supersede partial commits before dispatch. - `mark-and-skip` — record the anomaly and move on only with explicit confirmation.

MVP+TDD gate. Task-scoped enforcement runs inside plan execution (immediately before each implementation step), where TASK_FILE, PLAN_ID, and TASK_ID are defined. Keep the same predicate and RED-commit contract:

if [ "$MVP_MODE" = "true" ] && [ "$TDD_MODE" = "true" ]; then
  IS_BEHAVIOR_ADDING=$(gsd_run query task.is-behavior-adding "$TASK_FILE" --pick is_behavior_adding)
  if [ "$IS_BEHAVIOR_ADDING" = "true" ]; then
    RED_COMMIT=$(git log --oneline --grep="^test(${PHASE_NUMBER}-${PLAN_ID}):" -- "**/*.test.*" "**/*.spec.*" "tests/" | head -1)
    if [ -z "$RED_COMMIT" ]; then
      gsd_run query state.update last_gate_trip "${PLAN_ID}/${TASK_ID}" || true
      echo "MVP+TDD GATE TRIPPED: missing RED commit for ${PLAN_ID}/${TASK_ID}"
      exit 1
    fi
  fi
fi

Pure doc-only / config-only / test-only tasks return is_behavior_adding=false and are exempt. When the gate trips, Read ~/.claude/gsd-core/references/execute-mvp-tdd.md for the exact halt report format.

**MANDATORY — Check for blocking anti-patterns before any other work.**

Look for a .continue-here.md in the current phase directory:

ls ${phase_dir}/.continue-here.md 2>/dev/null || true

If .continue-here.md exists, parse its "Critical Anti-Patterns" table for rows with severity = blocking.

If one or more blocking anti-patterns are found:

This step cannot be skipped. Before proceeding to check_interactive_mode or any other step, the agent must demonstrate understanding of each blocking anti-pattern by answering all three questions for each one:

  1. What is this anti-pattern? — Describe it in your own words, not by quoting the handoff.
  2. How did it manifest? — Explain the specific failure that caused it to be recorded.
  3. What structural mechanism (not acknowledgment) prevents it? — Name the concrete step, checklist item, or enforcement mechanism that stops recurrence.

Write these answers inline before continuing. If a blocking anti-pattern cannot be answered from the context in .continue-here.md, stop and ask the user for clarification.

If no .continue-here.md exists, or no blocking rows are found: Proceed directly to check_interactive_mode.

**Parse `--interactive` flag from $ARGUMENTS.**

If --interactive flag present: Switch to interactive execution mode.

Interactive mode executes plans sequentially inline (no subagent spawning) with user checkpoints between tasks. The user can review, modify, or redirect work at any point.

Interactive execution flow:

  1. Load plan inventory as normal (discover_and_group_plans)

  2. For each plan (sequentially, ignoring wave grouping):

    a. Present the plan to the user:

    ## Plan {plan_id}: {plan_name}
    
    Objective: {from plan file}
    Tasks: {task_count}
    
    Options:
    - Execute (proceed with all tasks)
    - Review first (show task breakdown before starting)
    - Skip (move to next plan)
    - Stop (end execution, save progress)
    

    b. If "Review first": Read and display the full plan file. Ask again: Execute, Modify, Skip.

    c. If "Execute": Read and follow ~/.claude/gsd-core/workflows/execute-plan.md inline (do NOT spawn a subagent). Execute tasks one at a time.

    d. After each task: Pause briefly. If the user intervenes (types anything), stop and address their feedback before continuing. Otherwise proceed to next task.

    e. After plan complete: Show results, commit, create SUMMARY.md, then present next plan.

  3. After all plans: proceed to verification (same as normal mode).

Benefits of interactive mode:

  • No subagent overhead — dramatically lower token usage
  • User catches mistakes early — saves costly verification cycles
  • Maintains GSD's planning/tracking structure
  • Best for: small phases, bug fixes, verification gaps, learning GSD

Skip to handle_branching step (interactive plans execute inline after grouping).

Check `branching_strategy` from init:

"none": Skip, continue on current branch.

"phase" or "milestone": Use pre-computed branch_name from init.

Fork the new phase branch off origin/HEAD (the project's default branch), not the current HEAD — otherwise consecutive phases compound and stay unpushed (#2916). If $BRANCH_NAME already exists locally, reuse it as-is.

DEFAULT_BRANCH=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}

if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
  git switch "$BRANCH_NAME" || { echo "ERROR: Could not switch to existing branch '$BRANCH_NAME'." >&2; exit 1; }
else
  if ! git fetch --quiet origin "$DEFAULT_BRANCH"; then  # #2916
    git show-ref --verify --quiet "refs/remotes/origin/$DEFAULT_BRANCH" \
      || { echo "ERROR: fetch origin/$DEFAULT_BRANCH failed and no local copy exists. Refusing to create '$BRANCH_NAME' off current HEAD (#2916)." >&2; exit 1; }
    echo "WARNING: fetch origin/$DEFAULT_BRANCH failed; using local copy as base." >&2
  fi
  if [ -n "$(git status --porcelain)" ]; then
    echo "WARNING: Uncommitted changes will be carried onto '$BRANCH_NAME' (branched off origin/$DEFAULT_BRANCH, not previous HEAD)."
  else
    git switch --quiet "$DEFAULT_BRANCH" 2>/dev/null && git merge --ff-only --quiet "origin/$DEFAULT_BRANCH" 2>/dev/null || true
  fi
  # Pinned base + fail-fast: on success HEAD is exactly at origin/$DEFAULT_BRANCH,
  # so a post-creation merge-base or "ahead-of" guard would be unreachable. The
  # explicit base argument here is the single source of correctness for #2916.
  git checkout -b "$BRANCH_NAME" "origin/$DEFAULT_BRANCH" \
    || { echo "ERROR: Could not create '$BRANCH_NAME' from origin/$DEFAULT_BRANCH (#2916)." >&2; exit 1; }
fi

All subsequent commits go to this branch. User handles merging.

From init JSON: `phase_dir`, `plan_count`, `incomplete_count`.

Report: "Found {plan_count} plans in {phase_dir} ({incomplete_count} incomplete)"

Update STATE.md for phase start:

gsd_run query state.begin-phase --phase "${PHASE_NUMBER}" --name "${PHASE_NAME}" --plans "${PLAN_COUNT}"

This updates Status, Last Activity, Current focus, Current Position, and plan counts in STATE.md so frontmatter and body text reflect the active phase immediately.

Load plan inventory with wave grouping in one call:
PLAN_INDEX=$(gsd_run query phase-plan-index "${PHASE_NUMBER}")

Parse JSON for: phase, plans[] (each with id, wave, autonomous, objective, files_modified, task_count, has_summary), waves (map of wave number → plan IDs), incomplete, has_checkpoints.

Filtering: Skip plans where has_summary: true. If --gaps-only: also skip non-gap_closure plans. If WAVE_FILTER is set: also skip plans whose wave does not equal WAVE_FILTER.

Wave safety check: If WAVE_FILTER is set and there are still incomplete plans in any lower wave that match the current execution mode, STOP and tell the user to finish earlier waves first. Do not let Wave 2+ execute while prerequisite earlier-wave plans remain incomplete.

If all filtered: "No matching incomplete plans" → exit.

Report:

## Execution Plan

**Phase {X}: {Name}** — {total_plans} matching plans across {wave_count} wave(s)

{If WAVE_FILTER is set: `Wave filter active: executing only Wave {WAVE_FILTER}`.}

| Wave | Plans | What it builds |
|------|-------|----------------|
| 1 | 01-01, 01-02 | {from plan objectives, 3-8 words} |
| 2 | 01-03 | ... |
**Optional step 2.5 — Delegate plans to an external AI runtime.**

This step runs after plan discovery and before normal wave execution. It identifies plans that should be delegated to an external AI command and executes them via stdin-based prompt delivery. Plans handled here are removed from the execute_waves plan list so the normal executor skips them.

Activation logic:

  1. If CROSS_AI_DISABLED is true (--no-cross-ai flag): skip this step entirely.
  2. If CROSS_AI_FORCE is true (--cross-ai flag): mark ALL incomplete plans for cross-AI execution.
  3. Otherwise: check each plan's frontmatter for cross_ai: true AND verify config workflow.cross_ai_execution is true. Plans matching both conditions are marked for cross-AI.
CROSS_AI_ENABLED=$(gsd_run query config-get workflow.cross_ai_execution 2>/dev/null || echo "false")
CROSS_AI_CMD=$(gsd_run query config-get workflow.cross_ai_command 2>/dev/null || echo "")
CROSS_AI_TIMEOUT=$(gsd_run query config-get workflow.cross_ai_timeout 2>/dev/null || echo "300")

If no plans are marked for cross-AI: Skip to execute_waves.

If plans are marked but cross_ai_command is empty: Error — tell user to set workflow.cross_ai_command via gsd-tools.cjs query config-set workflow.cross_ai_command "<command>".

For each cross-AI plan (sequentially):

  1. Construct the task prompt from the plan file:

    • Extract <objective> and <tasks> sections from the PLAN.md
    • Append PROJECT.md context (project name, description, tech stack)
    • Format as a self-contained execution prompt
  2. Check for dirty working tree before execution:

    if ! git diff --quiet HEAD 2>/dev/null; then
      echo "WARNING: dirty working tree detected — the external AI command may produce uncommitted changes that conflict with existing modifications"
    fi
    
  3. Run the external command from the project root, writing the prompt to stdin. Never shell-interpolate the prompt — always pipe via stdin to prevent injection:

    echo "$TASK_PROMPT" | timeout "${CROSS_AI_TIMEOUT}s" ${CROSS_AI_CMD} > "$CANDIDATE_SUMMARY" 2>"$ERROR_LOG"
    EXIT_CODE=$?
    
  4. Evaluate the result:

    Success (exit 0 + valid summary):

    • Read $CANDIDATE_SUMMARY and validate it contains meaningful content (not empty, has at least a heading and description — a valid SUMMARY.md structure)
    • Write it as the plan's SUMMARY.md file
    • Update STATE.md plan status to complete
    • Update ROADMAP.md progress
    • Mark plan as handled — skip it in execute_waves

    Failure (non-zero exit or invalid summary):

    • Display the error output and exit code
    • Warn: "The external command may have left uncommitted changes or partial edits in the working tree. Review git status and git diff before proceeding."
    • Offer three choices:
      • retry — run the same plan through cross-AI again
      • skip — fall back to normal executor for this plan (re-add to execute_waves list)
      • abort — stop execution entirely, preserve state for resume
  5. After all cross-AI plans processed: Remove successfully handled plans from the incomplete plan list so execute_waves skips them. Any skipped-to-fallback plans remain in the list for normal executor processing.

Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZATION=true`, sequential if `false`.

Orchestrator cwd-drift guard (FIRST ACTION at execute_waves entry — #48):

A prior Agent(isolation="worktree") dispatch can silently leave the orchestrator's cwd inside an agent worktree (or a subdirectory of one). Every subsequent orchestrator-side git call would then target the wrong tree — this is how a wrong-base merge nearly shipped ~1000 files. Resolve the worktree root (so a subdirectory cwd cannot skew the check) and refuse if it is an agent worktree. The discriminator is the per-agent branch namespace worktree-agent-*, NOT the .claude/worktrees/ path: the orchestrator may itself be legitimately invoked from a feature worktree under .claude/worktrees/, so a path-substring refusal would break legitimate runs. Do NOT pin to git worktree list's first entry — that is the main worktree, the wrong target when the orchestrator legitimately runs from a feature worktree.

ORCHESTRATOR_WT=$(git rev-parse --show-toplevel 2>/dev/null) || {
  echo "FATAL: execute_waves entry is not inside a git worktree (#48)." >&2; exit 1; }
ORCH_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
if printf '%s' "$ORCH_BRANCH" | grep -Eq '^worktree-agent-'; then
  echo "FATAL: orchestrator cwd is inside an agent worktree (branch '$ORCH_BRANCH', root '$ORCHESTRATOR_WT') — refusing to execute waves (#48). A prior isolation=\"worktree\" dispatch drifted the cwd; re-run from the orchestrator's own worktree." >&2
  exit 1
fi
# Pin to the worktree root; each later orchestrator-side block re-pins the same way
# (see the #3174 cleanup guard). Treat $ORCHESTRATOR_WT as the canonical root for the
# rest of the phase — prefer `git -C "$ORCHESTRATOR_WT"` for cross-step git calls,
# since a bare `cd` does not persist across separate tool invocations.
export ORCHESTRATOR_WT
cd "$ORCHESTRATOR_WT" || { echo "FATAL: cannot cd to orchestrator worktree '$ORCHESTRATOR_WT' (#48)." >&2; exit 1; }

Stream-idle-timeout prevention — checkpoint heartbeats (#2410):

Multi-plan phases can accumulate enough subagent context that the Claude API SSE layer terminates with Stream idle timeout - partial response received between a large tool_result and the next assistant turn (seen on Claude Code

  • Opus 4.7 at ~200K+ cache_read). To keep the stream warm, emit short assistant-text heartbeats — no tool call, just a literal line — at every wave and plan boundary. Each heartbeat MUST start with [checkpoint] so tooling and /gsd:manager's background-completion handler can grep partial transcripts. {P}/{Q} is the phase-wide completed/total plans counter and increases monotonically across waves. {status} is complete (success), failed (executor error), or checkpoint (human-gate returned).
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} starting, {wave_plan_count} plan(s), {P}/{Q} plans done
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} starting ({P}/{Q} plans done)
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} {status} ({P}/{Q} plans done)
[checkpoint] phase {PHASE_NUMBER} wave {N}/{M} complete, {P}/{Q} plans done ({wave_success}/{wave_plan_count} ok)

For each wave:

  1. Intra-wave files_modified overlap check (BEFORE spawning):

    Before spawning any agents for this wave, inspect the files_modified list of all plans in the wave. Check every pair of plans in the wave — if any two plans share even one file in their files_modified lists, those plans have an implicit dependency and MUST NOT run in parallel.

    Detection algorithm (pseudocode):

    seen_files = {}
    overlapping_plans = []
    for each plan in wave_plans:
      for each file in plan.files_modified:
        if file in seen_files:
          overlapping_plans.add(plan, seen_files[file])  # both plans overlap on this file
        else:
          seen_files[file] = plan
    

    If overlap is detected:

    • Warn the user:
      ⚠ Intra-wave files_modified overlap detected in Wave {N}:
        Plan {A} and Plan {B} both modify {file}
        Running these plans sequentially to avoid parallel worktree conflicts.
      
    • Override PARALLELIZATION to false for this wave only — run all plans in the wave sequentially regardless of the global parallelization setting.
    • This is a safety net for plans that were incorrectly assigned to the same wave. The planner should have caught this; flag it as a planning defect so the user can replan the phase if desired.

    If no overlap: proceed normally (parallel if PARALLELIZATION=true).

  2. Describe what's being built (BEFORE spawning):

    First, emit the wave-start checkpoint heartbeat as a literal assistant-text line — no tool call (#2410). Do NOT skip this even for single-plan waves; it is required before any further reasoning or spawning:

    [checkpoint] phase {PHASE_NUMBER} wave {N}/{M} starting, {wave_plan_count} plan(s), {P}/{Q} plans done
    

    Then read each plan's <objective>. Extract what's being built and why.

    ---
    ## Wave {N}
    
    **{Plan ID}: {Plan Name}**
    {2-3 sentences: what this builds, technical approach, why it matters}
    
    Spawning {count} agent(s)... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)
    ---
    
    • Bad: "Executing terrain generation plan"
    • Good: "Procedural terrain generator using Perlin noise — creates height maps, biome zones, and collision meshes. Required before vehicle physics can interact with ground."

2.5. Per-plan worktree decision (run for each plan in this wave BEFORE its dispatch):

Read and execute gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md for each plan. It extracts PLAN_FILES from the plan's JSON, intersects against SUBMODULE_PATHS (with normalization, bidirectional matching, and glob-prefix handling), and sets USE_WORKTREES_FOR_PLAN to false when the plan touches a submodule path. Append plan_id to a WAVE_WORKTREE_PLANS accumulator when USE_WORKTREES_FOR_PLAN != false.

The dispatch branches in step 3 below MUST gate on USE_WORKTREES_FOR_PLAN for the current plan, not on the project-level USE_WORKTREES.

  1. Spawn executor agents:

    Emit a plan-start heartbeat (literal line, no tool call) immediately before each Agent() dispatch (#2410):

    [checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} starting ({P}/{Q} plans done)

    Pass paths only — executors read files themselves with their fresh context window. For 200k models, this keeps orchestrator context lean (~10-15%). For 1M+ models (Opus 4.6, Sonnet 4.6), richer context can be passed directly.

    Worktree mode (USE_WORKTREES_FOR_PLAN is not false — evaluated per-plan in step 2.5):

    Before spawning, capture the current HEAD:

    EXPECTED_BASE=$(git rev-parse HEAD)
    DISPATCH_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    EXPECTED_BRANCH=$(git rev-parse --abbrev-ref HEAD)
    if [ "${USE_WORKTREES_FOR_PLAN:-true}" != "false" ] && [ -z "${WAVE_WORKTREE_MANIFEST:-}" ]; then
      WAVE_WORKTREE_MANIFEST=$(mktemp "${TMPDIR:-/tmp}/gsd-worktree-wave-XXXXXX.json")
      # Persist the dispatch-time orchestrator worktree root so wave-cleanup can pin back to the
      # orchestrator's OWN worktree — NOT `git worktree list`'s first entry (always the main
      # checkout), which pins a non-primary (per-phase lane) orchestrator off its branch (#630).
      # Dispatch runs from the orchestrator's lane, so show-toplevel here is the correct root.
      ORCH_ROOT=$(git rev-parse --show-toplevel)
      ORCH_ROOT="$ORCH_ROOT" MANIFEST="$WAVE_WORKTREE_MANIFEST" node -e 'const fs=require("fs");fs.writeFileSync(process.env.MANIFEST,JSON.stringify({orchestrator_root:process.env.ORCH_ROOT||null,worktrees:[]})+"\n")'
      export WAVE_WORKTREE_MANIFEST
    fi
    

    Sequential dispatch for parallel execution (waves with 2+ agents): Dispatch each Agent() call one at a time with run_in_background: true. Do NOT send all Agent calls in a single message: simultaneous git worktree add calls race on .git/config.lock. Agents still run in parallel once their worktrees are created.

    # CORRECT: one Agent() per message with run_in_background: true
    # WRONG: multiple Agent() calls in one message -> .git/config.lock contention
    
    Agent(
      subagent_type="gsd-executor",
      description="Execute plan {plan_number} of phase {phase_number}",
      # Only include model= when executor_model is an explicit model name.
      # When executor_model is "inherit", omit this parameter entirely so
      # Claude Code inherits the orchestrator model automatically.
      model="{executor_model}",  # omit this line when executor_model == "inherit"
      isolation="worktree",
      prompt="
        <objective>
        Execute plan {plan_number} of phase {phase_number}-{phase_name}.
        Commit each task atomically. Create SUMMARY.md.
        Do NOT update STATE.md or ROADMAP.md — the orchestrator owns those writes after all worktree agents in the wave complete.
        </objective>
    
        <worktree_branch_check>
        ORCHESTRATOR build-time embed (NOT a sub-agent runtime step): before this dispatch, read `gsd-core/references/worktree-branch-check.md`, substitute `{EXPECTED_BASE}` with the base SHA captured above ({EXPECTED_BASE}), and replace this note with that fragment's `<worktree_branch_check>` block so the dispatched prompt carries the runnable guard verbatim — do not pass this instruction through in its place.
        Per-commit HEAD/cwd-drift/path-guard: `agents/gsd-executor.md` steps 0/0a/0b + `references/worktree-path-safety.md` (in <execution_context>).
        </worktree_branch_check>
    
        <parallel_execution>
        You are running as a PARALLEL executor agent in a git worktree. Worktree path safety (cwd-drift, absolute-path guards) is in `worktree-path-safety.md` (loaded below).
        Run `git commit` normally — hooks run by default. Do NOT pass `--no-verify`
        unless the orchestrator surfaces `workflow.worktree_skip_hooks=true` in this
        prompt; silent bypass violates project CLAUDE.md guidance (#2924).
    
        IMPORTANT: Do NOT modify STATE.md or ROADMAP.md. execute-plan.md
        auto-detects worktree mode (`.git` is a file, not a directory) and skips
        shared file updates automatically. The orchestrator updates them centrally
        after merge.
    
        REQUIRED: SUMMARY.md MUST be committed before you return. In worktree mode the
        git_commit_metadata step in execute-plan.md commits SUMMARY.md and REQUIREMENTS.md
        only (STATE.md and ROADMAP.md are excluded automatically). Do NOT skip or defer
        this commit — the orchestrator force-removes the worktree after you return, and
        any uncommitted SUMMARY.md will be permanently lost (#2070).
        REQUIRED ORDER: Write SUMMARY.md → commit → only then any narration. No text between Write and commit (truncation risk; #2070 rescue is not primary defense).
        </parallel_execution>
    
        <execution_context>
        @~/.claude/gsd-core/workflows/execute-plan.md
        @~/.claude/gsd-core/templates/summary.md
        @~/.claude/gsd-core/references/checkpoints.md
        @~/.claude/gsd-core/references/tdd.md
        @~/.claude/gsd-core/references/worktree-path-safety.md
        ${CONTEXT_WINDOW < 200000 ? '' : '@~/.claude/gsd-core/references/executor-examples.md'}
        </execution_context>
    
        <files_to_read>
        Read these files at execution start using the Read tool.
        First resolve repo root so every path is anchored:
        \`PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)\`
        - ${PROJECT_ROOT}/{phase_dir}/{plan_file} (Plan)
        - ${PROJECT_ROOT}/.planning/PROJECT.md (Project context — core value, requirements, evolution rules)
        - ${PROJECT_ROOT}/.planning/STATE.md (State)
        - ${PROJECT_ROOT}/.planning/config.json (Config, if exists)
        ${CONTEXT_WINDOW >= 500000 ? `
        - ${PROJECT_ROOT}/${phase_dir}/*-CONTEXT.md (User decisions from discuss-phase — honors locked choices)
        - ${PROJECT_ROOT}/${phase_dir}/*-RESEARCH.md (Technical research — pitfalls and patterns to follow)
        - ${PROJECT_ROOT}/${prior_wave_summaries} (SUMMARY.md files from earlier waves in this phase — what was already built)
        ` : ''}
        - ${PROJECT_ROOT}/CLAUDE.md (Project instructions, if exists — follow project-specific guidelines and coding conventions)
        - ${PROJECT_ROOT}/.claude/skills/ or ${PROJECT_ROOT}/.agents/skills/ (Project skills, if either exists — list skills, read SKILL.md for each, follow relevant rules during implementation)
        </files_to_read>
    
        ${AGENT_SKILLS}
    
        <mcp_tools>
        If CLAUDE.md or project instructions reference MCP tools (e.g. jCodeMunch, context7,
        or other MCP servers), prefer those tools over Grep/Glob for code navigation when available.
        MCP tools often save significant tokens by providing structured code indexes.
        Check tool availability first — if MCP tools are not accessible, fall back to Grep/Glob.
        </mcp_tools>
    
        <success_criteria>
        - [ ] All tasks executed
        - [ ] Each task committed individually
        - [ ] SUMMARY.md created in plan directory
        - [ ] No modifications to shared orchestrator artifacts (the orchestrator handles all post-wave shared-file writes)
        </success_criteria>
      "
    )
    

    Immediately after each worktree Agent() spawn returns metadata, atomically append {agent_id, worktree_path, branch, expected_base} to WAVE_WORKTREE_MANIFEST. If any field is missing, stop and ask for recovery instead of scanning all agent worktrees.

    ORCHESTRATOR FAIL-CLOSED RULE (#48): worktree_branch_check is verify-only — an executor that hits a base/HEAD-namespace mismatch prints FATAL: and exits 42 instead of self-recovering. If any executor result reports a FATAL:/exit 42 (or its commits never appear because it halted at the check), mark that plan blocked: do NOT merge or clean up its worktree (preserve it for inspection), do NOT count the wave as successful, and surface the mismatch with recovery guidance to the user. The orchestrator — the worktree lifecycle owner — performs any base correction (e.g. recreate the worktree on {EXPECTED_BASE}); the sub-agent never does. Never proceed past a halted executor on the assumption it succeeded.

    ORCHESTRATOR RULE — CODEX RUNTIME: After calling Agent() above to spawn executor agent(s), stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available.

    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:

        <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.
        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>
    

    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:

        <success_criteria>
        - [ ] All tasks executed
        - [ ] Each task committed individually
        - [ ] SUMMARY.md created in plan directory
        - [ ] STATE.md updated with position and decisions
        - [ ] ROADMAP.md updated with plan progress (via `roadmap update-plan-progress`)
        </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.

  2. Wait for all agents in wave to complete.

    Plan-complete heartbeat (#2410): as each executor returns (or is verified via spot-check below), emit one line — complete advances {P}, failed and checkpoint do not but still warm the stream:

    [checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} complete ({P}/{Q} plans done)
    [checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} failed ({P}/{Q} plans done)
    [checkpoint] phase {PHASE_NUMBER} wave {N}/{M} plan {plan_id} checkpoint ({P}/{Q} plans done)
    

    Completion signal fallback (Copilot and runtimes where Agent() may not return):

    If a spawned agent does not return a completion signal but appears to have finished its work, do NOT block indefinitely. Instead, verify completion via spot-checks:

    # For each plan in this wave, check if the executor finished:
    SUMMARY_EXISTS=$(test -f "{phase_dir}/{plan_number}-{plan_padded}-SUMMARY.md" && echo "true" || echo "false")
    COMMITS_FOUND=$(git log --oneline --all --grep="{phase_number}-{plan_padded}" --since="1 hour ago" | head -1)
    COMMITS_SINCE_DISPATCH=$(git log "${EXPECTED_BRANCH}" --since="${DISPATCH_TS}" --oneline | head -1)
    

    If SUMMARY.md exists AND commits are found: The agent completed successfully — treat as done and proceed to step 5. Log: "✓ {Plan ID} completed (verified via spot-check — completion signal not received)"

    If SUMMARY.md does NOT exist after a reasonable wait: The agent may still be running or may have failed silently. Check git log --oneline -5 for recent activity. If commits are still appearing, wait longer. If no activity, report the plan as failed and route to the failure handler in step 6.

    Configurable stall surveillance (#3212): Every ${EXECUTOR_STALL_INTERVAL_MINUTES} minutes while waiting, inspect git log "${EXPECTED_BRANCH}" --since="${DISPATCH_TS}" for activity. If no completion signal, no SUMMARY.md, and no expected-branch commits appear for ${EXECUTOR_STALL_THRESHOLD_MINUTES} minutes, pause and ask for one recovery path: continue waiting, kill and retry, or kill and switch to inline execution.

    This fallback applies automatically to all runtimes. Claude Code's Agent() normally returns synchronously, but the fallback ensures resilience if it doesn't.

  3. Post-wave hook validation (parallel mode only): Hooks run on every executor commit by default (#2924); this post-wave run only fires when workflow.worktree_skip_hooks=true opted out of per-commit hooks:

    SKIP_HOOKS=$(gsd_run query config-get workflow.worktree_skip_hooks 2>/dev/null || echo "false")
    if [ "$SKIP_HOOKS" = "true" ]; then
      # Stash uncommitted changes under a named ref so we always pop (bare `git stash` strands them on hook/script failure). #3542: `refs/stash` is shared across worktrees, so this helper runs ONLY in the orchestrator's main checkout after all wave worktrees have been merged + removed; executors are forbidden from running any `git stash` subcommand (see `<destructive_git_prohibition>` in `agents/gsd-executor.md`).
      STASHED=false
      if (! git diff --quiet || ! git diff --cached --quiet) && git stash push -u -m "gsd-post-wave-hook-$$" >/dev/null 2>&1; then STASHED=true; fi
      git hook run pre-commit 2>&1 || echo "⚠ Pre-commit hooks failed — review before continuing"
      [ "$STASHED" = "true" ] && (git stash pop >/dev/null 2>&1 || echo "⚠ Could not pop gsd-post-wave-hook stash — recover manually")
    fi
    

    If hooks fail: report the failure and ask "Fix hook issues now?" or "Continue to next wave?"

5.5. Worktree cleanup (when isolation="worktree" was used):

Standard wave contract: Each wave's worktrees merge to main via the templated path below before the next wave's worktrees fork. The cleanup loop runs once per wave at the end of the wave lifecycle. Worktrees created in wave N must be fully removed before wave N+1 forks new ones.

Cross-wave dependency deviation (supported execution mode): When the orchestrator legitimately deviates from the standard wave model — for example, a phase with cross-wave plan dependencies that requires custom inter-worktree base-update merges (e.g., merge: bring 09-01 + 09-02 into 09-03 base) — the cleanup loop below is NOT automatically re-entered for those custom merges. The deviation path produces correct final history but bypasses this loop, leaving worktree-agent-* directories in place. Use the cleanup-tail snippet below to remove any residual worktrees after such a deviation.

When executor agents ran in worktree isolation, their commits land on temporary branches in separate working trees. After the wave completes, merge these changes back and clean up:

Manifest source of truth (#3384): Cleanup consumes the WAVE_WORKTREE_MANIFEST created and populated during executor dispatch in step 3. Do not recreate or truncate it here.

Prefer the bounded helper, which validates branch identity, expected base, deletion diffs, merge result, and worktree removal before deleting the temporary branch. If the helper reports a blocked cleanup, resolve the reported manifest entry and rerun the same command. Do not fall back to broad worktree discovery.

[ -n "${WAVE_WORKTREE_MANIFEST:-}" ] && [ -f "$WAVE_WORKTREE_MANIFEST" ] || {
  echo "BLOCKED: missing WAVE_WORKTREE_MANIFEST; refusing broad worktree cleanup (#3384)." >&2
  exit 1
}

# Guard: pin cleanup back to the orchestrator's OWN worktree and fail on branch drift (#3174, #630).
# Resolve from the dispatch-time orchestrator root persisted in the manifest — NOT `git worktree
# list`'s first entry, which is always the main checkout and would pin a non-primary (per-phase
# lane) orchestrator off its own branch, tripping the #3174 assertion below (#630). Byte-identical
# for a primary orchestrator (its root IS the first entry); the fallback covers pre-#630 manifests.
PRIMARY_WT=$(MANIFEST="$WAVE_WORKTREE_MANIFEST" node -e 'const fs=require("fs");try{const j=JSON.parse(fs.readFileSync(process.env.MANIFEST,"utf8"));if(j&&j.orchestrator_root)process.stdout.write(String(j.orchestrator_root))}catch(e){}')
[ -n "$PRIMARY_WT" ] || PRIMARY_WT=$(git worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}')
if [ -z "$PRIMARY_WT" ]; then
  echo "FATAL: could not resolve orchestrator worktree before cleanup" >&2
  exit 1
fi
if [ -n "$PRIMARY_WT" ] && [ "$(pwd -P 2>/dev/null)" != "$(cd "$PRIMARY_WT" 2>/dev/null && pwd -P)" ]; then echo "⚠ Orchestrator CWD drifted to $(pwd) — pinning to $PRIMARY_WT before worktree cleanup (#3174)"; cd "$PRIMARY_WT" || { echo "FATAL: cannot cd to primary worktree $PRIMARY_WT" >&2; exit 1; }; fi
ORCH_BRANCH=$(git rev-parse --abbrev-ref HEAD)
[ -z "${EXPECTED_BRANCH:-}" ] || [ "$ORCH_BRANCH" = "$EXPECTED_BRANCH" ] || { echo "FATAL: orchestrator on '$ORCH_BRANCH' but expected '$EXPECTED_BRANCH' before worktree cleanup — refusing to merge (#3174-class drift)" >&2; exit 1; }

# Fail closed: SDK refusal (safety guard #3174/#3384) must surface — do not swallow exit 1.
gsd_run query worktree.cleanup-wave --manifest "$WAVE_WORKTREE_MANIFEST" || exit 1

Cleanup-tail snippet (use after any wave whose merges did not flow through the templated path above):

If the orchestrator deviated from the standard wave merge path (e.g., custom inter-worktree base-update merges with merge: bring … style messages), run this snippet after the custom merges are complete. It reads only WAVE_WORKTREE_MANIFEST; do not discover unrelated worktree-agent-* worktrees.

# Cleanup-tail: pin orchestrator CWD to its OWN worktree before cleanup-tail (#3174, #630).
# Same fix as the templated path: resolve the dispatch-time orchestrator root from the manifest,
# not `git worktree list`'s first entry (always the main checkout — wrong for a lane orchestrator).
PRIMARY_WT=$(MANIFEST="$WAVE_WORKTREE_MANIFEST" node -e 'const fs=require("fs");try{const j=JSON.parse(fs.readFileSync(process.env.MANIFEST,"utf8"));if(j&&j.orchestrator_root)process.stdout.write(String(j.orchestrator_root))}catch(e){}')
[ -n "$PRIMARY_WT" ] || PRIMARY_WT=$(git worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}')
if [ -n "$PRIMARY_WT" ] && [ "$(pwd -P 2>/dev/null)" != "$(cd "$PRIMARY_WT" 2>/dev/null && pwd -P)" ]; then echo "⚠ Orchestrator CWD drifted to $(pwd) — pinning to $PRIMARY_WT before cleanup-tail (#3174)"; cd "$PRIMARY_WT" || { echo "FATAL: cannot cd to primary worktree $PRIMARY_WT" >&2; exit 1; }; fi
# Cleanup-tail: remove residual agent worktrees after a cross-wave-dependency deviation.
# Uses only the current wave manifest to avoid touching unrelated active agents (#3384).
WT_PATHS_FILE=$(mktemp "${TMPDIR:-/tmp}/gsd-worktree-paths-XXXXXX")
node -e 'const fs=require("fs");const p=process.env.WAVE_WORKTREE_MANIFEST;try{if(!p)throw new Error("WAVE_WORKTREE_MANIFEST is unset");if(!fs.existsSync(p))throw new Error("manifest does not exist");const s=fs.readFileSync(p,"utf8");if(!s.trim())throw new Error("manifest is empty");const j=JSON.parse(s);for(const w of j.worktrees||[])if(w.worktree_path)console.log(w.worktree_path)}catch(e){console.error(`ERROR: cannot read worktree manifest ${p||"(unset)"}: ${e.message}`);process.exit(1)}' > "$WT_PATHS_FILE" || { echo "BLOCKED: cannot read WAVE_WORKTREE_MANIFEST; refusing cleanup (#3384)." >&2; exit 1; }
while IFS= read -r WT; do
  [ -z "$WT" ] && continue
  WT_BRANCH=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null)
  [ -z "$WT_BRANCH" ] || [ "$WT_BRANCH" = "HEAD" ] && continue
  echo "Cleaning up residual worktree: $WT (branch: $WT_BRANCH)"
  git worktree unlock "$WT" 2>/dev/null || true
  if ! git worktree remove "$WT" --force; then
    WT_NAME=$(basename "$WT")
    if [ -f ".git/worktrees/${WT_NAME}/locked" ]; then
      echo "⚠ Worktree $WT is locked — unlock failed; manual cleanup required:"
      echo "    git worktree unlock \"$WT\" && git worktree remove \"$WT\" --force && git branch -D \"$WT_BRANCH\""
    else
      echo "⚠ Residual worktree at $WT — remove failed; manual cleanup required"
    fi
  else
    git branch -D "$WT_BRANCH" 2>/dev/null || true
  fi
done < "$WT_PATHS_FILE"
git worktree prune

When to skip step 5.5:

If no plan in this wave used worktree isolation (project-level USE_WORKTREES=false OR every plan in the wave had USE_WORKTREES_FOR_PLAN=false — i.e. WAVE_WORKTREE_PLANS from step 2.5 is empty): all agents ran on the main working tree — skip this step entirely.

If the orchestrator merged via custom messages (cross-wave-dependency deviation): the templated cleanup loop above was not triggered for those merges. Run the cleanup-tail snippet above instead. After the snippet completes, proceed to step 5.6.

If at least one plan used worktrees but others did not: still run this cleanup — it iterates over actual git worktree list output and only merges back the worktrees that were created, leaving sequential plans' commits on the main tree untouched.

If no worktrees found at runtime: Skip silently — agents may have been spawned without worktree isolation, or the orchestrator already cleaned them up.

5.6. Post-merge build & test gate:

After merging all worktrees in a wave (parallel mode), or after the last plan completes (serial mode), run a build and then the project's test suite to catch cross-plan integration issues that individual worktree self-checks cannot detect (e.g., conflicting type definitions, removed exports, import changes, link errors).

This addresses the Generator self-evaluation blind spot identified in Anthropic's harness engineering research: agents reliably report Self-Check: PASSED even when merging their work creates failures.

Read and execute gsd-core/workflows/execute-phase/steps/post-merge-gate.md.

5.7. Post-wave shared artifact update (when at least one plan used worktrees, skip if tests failed):

When any executor agent in this wave ran with isolation="worktree", that agent skipped STATE.md and ROADMAP.md updates to avoid last-merge-wins overwrites. The orchestrator is the single writer for these files. After worktrees are merged back, update shared artifacts once for every completed plan in the wave (worktree-mode plans and sequential plans that ran on the main tree but deferred to the orchestrator for tracking writes).

Only update tracking when tests passed (TEST_EXIT=0). If tests failed or timed out, skip the tracking update — plans should not be marked as complete when integration tests are failing or inconclusive.

# Guard: only update tracking if post-merge tests passed
# Timeout (124) is treated as inconclusive — do NOT mark plans complete
if [ "${TEST_EXIT}" -eq 0 ]; then
  # Update ROADMAP plan progress for each completed plan in this wave
  for plan_id in {completed_plan_ids}; do
    gsd_run query roadmap.update-plan-progress "${PHASE_NUMBER}" "${plan_id}" "complete"
  done

  # Only commit tracking files if they actually changed
  if ! git diff --quiet .planning/ROADMAP.md .planning/STATE.md 2>/dev/null; then
    gsd_run query commit "docs(phase-${PHASE_NUMBER}): update tracking after wave ${N}" --files .planning/ROADMAP.md .planning/STATE.md
  fi
elif [ "${TEST_EXIT}" -eq 124 ]; then
  echo "⚠ Skipping tracking update — test suite timed out. Plans remain in-progress. Run tests manually to confirm."
else
  echo "⚠ Skipping tracking update — post-merge tests failed (exit ${TEST_EXIT}). Plans remain in-progress until tests pass."
fi

Where WAVE_PLAN_IDS is the space-separated list of plan IDs that completed in this wave.

If no plan in this wave used worktrees (project-level USE_WORKTREES=false OR WAVE_WORKTREE_PLANS is empty): sequential agents already updated STATE.md and ROADMAP.md themselves — skip this step.

5.8. Handle test gate failures (when WAVE_FAILURE_COUNT > 0):

## ⚠ Post-Merge Test Failure (cumulative failures: ${WAVE_FAILURE_COUNT})

Wave {N} worktrees merged successfully, but {M} tests fail after merge.
This typically indicates conflicting changes across parallel plans
(e.g., type definitions, shared imports, API contracts).

Failed tests:
{first 10 lines of failure output}

Options:
1. Fix now (recommended) — resolve conflicts before next wave
2. Continue — failures may compound in subsequent waves

Note: If WAVE_FAILURE_COUNT > 1, strongly recommend "Fix now" — compounding failures across multiple waves become exponentially harder to diagnose.

If "Fix now": diagnose failures (typically import conflicts, missing types, or changed function signatures from parallel plans modifying the same module). Fix, commit as fix: resolve post-merge conflicts from wave {N}, re-run tests.

Why this matters: Worktree isolation means each agent's Self-Check passes in isolation. But when merged, add/add conflicts in shared files (models, registries, CLI entry points) can silently drop code. The post-merge gate catches this before the next wave builds on a broken foundation.

  1. Report completion — spot-check claims first:

    Wave-close heartbeat (#2410): after spot-checks finish (pass or fail), before the ## Wave {N} Complete summary, emit as a literal line:

    [checkpoint] phase {PHASE_NUMBER} wave {N}/{M} complete, {P}/{Q} plans done ({wave_success}/{wave_plan_count} ok)
    

    For each SUMMARY.md:

    • Verify first 2 files from key-files.created exist on disk
    • Check git log --oneline --all --grep="{phase}-{plan}" returns ≥1 commit
    • Check for ## Self-Check: FAILED marker

    If ANY spot-check fails: report which plan failed, route to failure handler — ask "Retry plan?" or "Continue with remaining waves?"

    If pass:

    ---
    ## Wave {N} Complete
    
    **{Plan ID}: {Plan Name}**
    {What was built — from SUMMARY.md}
    {Notable deviations, if any}
    
    {If more waves: what this enables for next wave}
    ---
    
  2. Handle failures: Step 7.0 — classify before branching (#3095):

    CLASS_JSON=$(gsd_run query agent.classify-failure -- "$AGENT_RETURN_BODY")
    CLASS=$(echo "$CLASS_JSON" | jq -r '.class')
    SENTINEL=$(echo "$CLASS_JSON" | jq -r '.sentinel // empty')
    RETRY_AFTER=$(echo "$CLASS_JSON" | jq -r '.retryAfterSeconds // empty')
    if [ -n "$RETRY_AFTER" ]; then RETRY_HINT="  Provider hinted retry-after: ${RETRY_AFTER}s"; else RETRY_HINT=""; fi
    

    One classifier branch handles sentinels across Claude/Copilot/Codex/Gemini. Reference: docs/research/provider-rate-limit-signals.md. Step 7.1 — class == "quota-exceeded": Do not offer "retry now". Run step-5 spot-check first; if SUMMARY.md is missing but commits exist, route to safe-resume (state.verify-against-disk) instead of immediate redispatch.

    ⚠ Plan {plan_id} terminated by provider quota / rate limit
      Runtime sentinel: {SENTINEL}
      {RETRY_HINT}
      Partial commits on worktree branch: {N}
      SUMMARY.md present: {yes|no}
      1. Wait for quota reset, then resume (recommended)
    2. Switch to a different runtime / model and resume
    3. Abort phase and report partial state
    

    Re-run /gsd:execute-phase after quota reset for Option 1. Step 7.2 — class == "classify-handoff-bug": If error contains classifyHandoffIfNeeded is not defined, treat as Claude runtime bug. Run the same step-5 spot-checks; PASS => treat as success, FAIL => fall through. Step 7.3 — class == "unknown-failure": Report failed plan and ask Continue/Stop; continuing may cascade into dependent plan failures.

7b. Pre-wave dependency check (waves 2+ only): Before wave N+1, run gsd-tools.cjs query verify.key-links {phase_dir}/{plan}-PLAN.md for each upcoming plan. If any PRIOR-wave artifact link fails, present: - ## Cross-Plan Wiring Gap with plan/link/from/pattern rows - Options: investigate+fix before continue, or continue with cascade risk Skip key-links that reference files in the CURRENT (upcoming) wave. 8. Execute checkpoint plans between waves — see <checkpoint_handling>. 9. Proceed to next wave. Plans with autonomous: false require user interaction. Auto-mode checkpoint handling: Read auto-advance config (chain flag OR user preference — same boolean as check.auto-mode):

AUTO_MODE=$(gsd_run query check auto-mode --pick active 2>/dev/null || echo "false")

When executor returns a checkpoint AND AUTO_MODE is true:

  • human-verify → Auto-spawn continuation agent with {user_response} = "approved". Log ⚡ Auto-approved checkpoint.
  • decision → Auto-spawn continuation agent with {user_response} = first option from checkpoint details. Log ⚡ Auto-selected: [option].
  • human-action → Present to user (existing behavior below). Auth gates cannot be automated.

Standard flow (not auto-mode, or human-action type):

  1. Spawn agent for checkpoint plan
  2. Agent runs until checkpoint task or auth gate → returns structured state
  3. Agent return includes: completed tasks table, current task + blocker, checkpoint type/details, what's awaited
  4. Present to user:
    ## Checkpoint: [Type]
    
    **Plan:** 03-03 Dashboard Layout
    **Progress:** 2/3 tasks complete
    
    [Checkpoint Details from agent return]
    [Awaiting section from agent return]
    
  5. User responds: "approved"/"done" | issue description | decision selection
  6. Spawn continuation agent (NOT resume) using continuation-prompt.md template:
    • {completed_tasks_table}: From checkpoint return
    • {resume_task_number} + {resume_task_name}: Current task
    • {user_response}: What user provided
    • {resume_instructions}: Based on checkpoint type
  7. Continuation agent verifies previous commits, continues from resume point
  8. Repeat until plan completes or user stops

Why fresh agent, not resume: Resume relies on internal serialization that breaks with parallel tool calls. Fresh agents with explicit state are more reliable.

Checkpoints in parallel waves: Agent pauses and returns while other parallel agents may complete. Present checkpoint, spawn continuation, wait for all before next wave.

After all waves:
## Phase {X}: {Name} Execution Complete

**Waves:** {N} | **Plans:** {M}/{total} complete

| Wave | Plans | Status |
|------|-------|--------|
| 1 | plan-01, plan-02 | ✓ Complete |
| CP | plan-03 | ✓ Verified |
| 2 | plan-04 | ✓ Complete |

### Plan Details
1. **03-01**: [one-liner from SUMMARY.md]
2. **03-02**: [one-liner from SUMMARY.md]

### Issues Encountered
[Aggregate from SUMMARYs, or "None"]

Security gate check:

SECURITY_CFG=$(gsd_run query config-get workflow.security_enforcement --raw 2>/dev/null || echo "true")
SECURITY_FILE=$(ls "${PHASE_DIR}"/*-SECURITY.md 2>/dev/null | head -1)

If SECURITY_CFG is false: skip.

If SECURITY_CFG is true AND SECURITY_FILE is empty (no SECURITY.md yet): Include in the next-steps routing output:

⚠ Security enforcement enabled — run before advancing:
  /gsd:secure-phase {PHASE} ${GSD_WS}

If SECURITY_CFG is true AND SECURITY.md exists: check frontmatter threats_open. If > 0:

⚠ Security gate: {threats_open} threats open
  /gsd:secure-phase {PHASE} — resolve before advancing
**Optional step — TDD collaborative review.**
TDD_MODE=$(gsd_run query config-get workflow.tdd_mode 2>/dev/null || echo "false")

Skip if TDD_MODE is false.

When TDD_MODE is true, check whether any completed plans in this phase have type: tdd in their frontmatter:

TDD_PLANS=$(grep -rl "^type: tdd" "${PHASE_DIR}"/*-PLAN.md 2>/dev/null | wc -l | tr -d ' ')

If TDD_PLANS > 0: Insert end-of-phase collaborative review checkpoint.

  1. Collect all SUMMARY.md files for TDD plans
  2. For each TDD plan summary, verify the RED/GREEN/REFACTOR gate sequence:
    • RED gate: A failing test commit exists (test(...) commit with MUST-fail evidence)
    • GREEN gate: An implementation commit exists (feat(...) commit making tests pass)
    • REFACTOR gate: Optional cleanup commit (refactor(...) commit, tests still pass)
  3. If any TDD plan is missing the RED or GREEN gate commits, flag it:
    ⚠ TDD gate violation: Plan {plan_id} missing {RED|GREEN} phase commit.
      Expected commit pattern: test({phase}-{plan}): ... → feat({phase}-{plan}): ...
    
  4. Present collaborative review summary:
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
     TDD REVIEW — Phase {X}
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    
    TDD Plans: {TDD_PLANS} | Gate violations: {count}
    
    | Plan | RED | GREEN | REFACTOR | Status |
    |------|-----|-------|----------|--------|
    | {id} |  ✓  |   ✓   |    ✓     | Pass   |
    | {id} |  ✓  |   ✗   |    —     | FAIL   |
    

Escalation under MVP+TDD. When MVP_MODE=true AND TDD_MODE=true, the review verdict escalates from advisory to blocking: missing RED or GREEN gate commits prevent marking the phase complete.

Phase blocked: {N} TDD plan(s) violate the RED→GREEN gate sequence under MVP+TDD.
Resolve and re-run /gsd execute-phase, or override with
/gsd execute-phase {phase} --force-mvp-gate to ship anyway.

--force-mvp-gate is the escape hatch (documented, not yet implemented). Policy is:

  • MVP_MODE=true AND TDD_MODE=true: violations are blocking unless explicitly overridden.
  • otherwise: violations are advisory/non-blocking and are surfaced for review. The verifier agent (step verify_phase_goal) still checks TDD discipline in both cases.
If `WAVE_FILTER` was used, re-run plan discovery after execution:
POST_PLAN_INDEX=$(gsd_run query phase-plan-index "${PHASE_NUMBER}")

Apply the same "incomplete" filtering rules as earlier:

  • ignore plans with has_summary: true
  • if --gaps-only, only consider gap_closure: true plans

If incomplete plans still remain anywhere in the phase:

  • STOP here
  • Do NOT run phase verification
  • Do NOT mark the phase complete in ROADMAP/STATE
  • Present:
## Wave {WAVE_FILTER} Complete

Selected wave finished successfully. This phase still has incomplete plans, so phase-level verification and completion were intentionally skipped.

/gsd:execute-phase {phase} ${GSD_WS}                # Continue remaining waves
/gsd:execute-phase {phase} --wave {next} ${GSD_WS}  # Run the next wave explicitly

If no incomplete plans remain after the selected wave finishes:

  • continue with the normal phase-level verification and completion flow below
  • this means the selected wave happened to be the last remaining work in the phase
**This step is REQUIRED and must not be skipped.** Auto-invoke code review on the phase's source changes. Advisory only — never blocks execution flow.

Config gate:

CODE_REVIEW_ENABLED=$(gsd_run query config-get workflow.code_review 2>/dev/null || echo "true")

If CODE_REVIEW_ENABLED is "false": display "Code review skipped (workflow.code_review=false)" and proceed to next step.

Invoke review:

Skill(skill="gsd-code-review", args="${PHASE_NUMBER}")

Check results using deterministic path (not glob):

PADDED=$(printf "%02d" "${PHASE_NUMBER}")
REVIEW_FILE="${PHASE_DIR}/${PADDED}-REVIEW.md"
REVIEW_STATUS=$(sed -n '/^---$/,/^---$/p' "$REVIEW_FILE" | grep "^status:" | head -1 | cut -d: -f2 | tr -d ' ')

If REVIEW_STATUS is not "clean" and not "skipped" and not empty, display:

Code review found issues. Consider running:
/gsd:code-review ${PHASE_NUMBER} --fix

Error handling: If the Skill invocation fails or throws, catch the error, display "Code review encountered an error (non-blocking): {error}" and proceed to next step. Review failures must never block execution.

Regardless of review result, ALWAYS proceed to close_parent_artifacts → regression_gate → verify_phase_goal.

**For decimal/polish phases only (X.Y pattern):** Close the feedback loop by resolving parent UAT and debug artifacts.

Skip if phase number has no decimal (e.g., 3, 04) — only applies to gap-closure phases like 4.1, 03.1.

1. Detect decimal phase and derive parent:

# Check if phase_number contains a decimal
if [[ "$PHASE_NUMBER" == *.* ]]; then
  PARENT_PHASE="${PHASE_NUMBER%%.*}"
fi

2. Find parent UAT file:

PARENT_INFO=$(gsd_run query find-phase "${PARENT_PHASE}" --raw)
# Extract directory from PARENT_INFO JSON, then find UAT file in that directory

If no parent UAT found: Skip this step (gap-closure may have been triggered by VERIFICATION.md instead).

3. Update UAT gap statuses:

Read the parent UAT file's ## Gaps section. For each gap entry with status: failed:

  • Update to status: resolved

4. Update UAT frontmatter:

If all gaps now have status: resolved:

  • Update frontmatter status: diagnosed → status: resolved
  • Update frontmatter updated: timestamp

5. Resolve referenced debug sessions:

For each gap that has a debug_session: field:

  • Read the debug session file
  • Update frontmatter status: → resolved
  • Update frontmatter updated: timestamp
  • Move to resolved directory:
mkdir -p .planning/debug/resolved
mv .planning/debug/{slug}.md .planning/debug/resolved/

6. Commit updated artifacts:

gsd_run query commit "docs(phase-${PARENT_PHASE}): resolve UAT gaps and debug sessions after ${PHASE_NUMBER} gap closure" --files .planning/phases/*${PARENT_PHASE}*/*-UAT.md .planning/debug/resolved/*.md
Run prior phases' test suites to catch cross-phase regressions BEFORE verification.

Skip if: This is the first phase (no prior phases), or no prior VERIFICATION.md files exist.

Step 1: Discover prior phases' test files

# Find all VERIFICATION.md files from prior phases in current milestone
PRIOR_VERIFICATIONS=$(find .planning/phases/ -name "*-VERIFICATION.md" ! -path "*${PHASE_NUMBER}*" 2>/dev/null)

Step 2: Extract test file lists from prior verifications

For each VERIFICATION.md found, look for test file references:

  • Lines containing test, spec, or __tests__ paths
  • The "Test Suite" or "Automated Checks" section
  • File patterns from key-files.created in corresponding SUMMARY.md files that match *.test.* or *.spec.*

Collect all unique test file paths into REGRESSION_FILES.

Step 3: Run regression tests (if any found)

# Resolve test command: project config > Makefile > language sniff
REG_TEST_CMD=$(gsd_run query config-get workflow.test_command --default "" 2>/dev/null || true)
if [ -z "$REG_TEST_CMD" ]; then
  if [ -f "Makefile" ] && grep -q "^test:" Makefile; then
    REG_TEST_CMD="make test"
  elif [ -f "Justfile" ] || [ -f "justfile" ]; then
    REG_TEST_CMD="just test"
  elif [ -f "package.json" ]; then
    REG_TEST_CMD="npm test"
  elif [ -f "Cargo.toml" ]; then
    REG_TEST_CMD="cargo test"
  elif [ -f "go.mod" ]; then
    REG_TEST_CMD="go test ./..."
  elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then
    REG_TEST_CMD="python -m pytest ${REGRESSION_FILES} -q --tb=short"
  else
    REG_TEST_CMD="true"
  fi
fi
# Detect test runner and run prior phase tests
eval "$REG_TEST_CMD" 2>&1

Step 4: Report results

If all tests pass:

✓ Regression gate: {N} prior-phase test files passed — no regressions detected

→ Proceed to verify_phase_goal

If any tests fail:

## ⚠ Cross-Phase Regression Detected

Phase {X} execution may have broken functionality from prior phases.

| Test File | Phase | Status | Detail |
|-----------|-------|--------|--------|
| {file} | {origin_phase} | FAILED | {first_failure_line} |

Options:
1. Fix regressions before verification (recommended)
2. Continue to verification anyway (regressions will compound)
3. Abort phase — roll back and re-plan

Use AskUserQuestion to present the options.

Post-execution schema drift detection. Catches false-positive verification where build/types pass because TypeScript types come from config, not the live database.

Run after execution completes but BEFORE verification marks success.

SCHEMA_DRIFT=$(gsd_run query verify.schema-drift "${PHASE_NUMBER}" 2>/dev/null)

Parse JSON result for: drift_detected, blocking, schema_files, orms, unpushed_orms, message.

If drift_detected is false: Skip to verify_phase_goal.

If drift_detected is true AND blocking is true:

Check for override:

SKIP_SCHEMA=$(echo "${GSD_SKIP_SCHEMA_CHECK:-false}")

If SKIP_SCHEMA is true:

Display:

⚠ Schema drift detected but GSD_SKIP_SCHEMA_CHECK=true — bypassing gate.

Schema files changed: {schema_files}
ORMs requiring push: {unpushed_orms}

Proceeding to verification (database may be out of sync).

→ Continue to verify_phase_goal.

If SKIP_SCHEMA is not true:

BLOCK verification. Display:

## BLOCKED: Schema Drift Detected

Schema-relevant files changed during this phase but no database push command
was executed. Build and type checks pass because TypeScript types come from
config, not the live database — verification would produce a false positive.

Schema files changed: {schema_files}
ORMs requiring push: {unpushed_orms}

Required push commands:
{For each unpushed ORM, show the push command from the message}

Options:
1. Run push command now (recommended) — execute the push, then re-verify
2. Skip schema check (GSD_SKIP_SCHEMA_CHECK=true) — bypass this gate
3. Abort — stop execution and investigate

If TEXT_MODE is true, present as a plain-text numbered list. Otherwise use AskUserQuestion.

If user selects option 1: Present the specific push command(s) to run. After user confirms execution, re-run the schema drift check. If it passes, continue to verify_phase_goal.

If user selects option 2: Set override and continue to verify_phase_goal.

If user selects option 3: Stop execution. Report partial completion.

Post-execution structural drift detection (#2003). Non-blocking by contract: any internal error here MUST fall through to `verify_phase_goal`. The phase is never failed by this gate.

Load and follow the full step spec from gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md — covers the SDK call, JSON contract, warn vs auto-remap branches, mapper spawn template, and the two workflow.drift_* config keys.

Verify phase achieved its GOAL, not just completed tasks.
VERIFIER_SKILLS=$(gsd_run query agent-skills gsd-verifier)
Agent(
  description="Verify phase {phase_number} goal achievement",
  prompt="Verify phase {phase_number} goal achievement.
Phase directory: {phase_dir}
Phase goal: {goal from ROADMAP.md}
Phase requirement IDs: {phase_req_ids}
Check must_haves against actual codebase.
Cross-reference requirement IDs from PLAN frontmatter against REQUIREMENTS.md — every ID MUST be accounted for.
Create VERIFICATION.md.

<files_to_read>
Read these files before verification:
- {phase_dir}/*-PLAN.md (All plans — understand intent, check must_haves)
- {phase_dir}/*-SUMMARY.md (All summaries — cross-reference claimed vs actual)
- .planning/REQUIREMENTS.md (Requirement traceability)
${CONTEXT_WINDOW >= 500000 ? `- {phase_dir}/*-CONTEXT.md (User decisions — verify they were honored)
- {phase_dir}/*-RESEARCH.md (Known pitfalls — check for traps)
- Prior VERIFICATION.md files from earlier phases (regression check)
` : ''}
</files_to_read>

${VERIFIER_SKILLS}",
  subagent_type="gsd-verifier",
  model="{verifier_model}"
)

ORCHESTRATOR RULE — CODEX RUNTIME: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available.

Read status via the canonical query (scoped to frontmatter, covers missing/unknown cases):

VERIFICATION=$(gsd_run query verification.status "$PHASE_DIR" 2>/dev/null)
STATUS=$(printf '%s' "$VERIFICATION" | jq -r '.status' 2>/dev/null || echo "")
NEXT_ACTION=$(printf '%s' "$VERIFICATION" | jq -r '.next_action' 2>/dev/null || echo "")
NEXT_COMMAND=$(printf '%s' "$VERIFICATION" | jq -r '.next_command' 2>/dev/null || echo "")

Route on $STATUS: if passed, proceed to update_roadmap. Otherwise keep the phase pending — present $NEXT_ACTION to the user and, when $NEXT_COMMAND is non-empty, show it as the next command to run. The query covers all cases including missing files (missing) and unexpected values (unknown), so no per-status arm needs to be listed here.

If human_needed:

Step A: Persist human verification items as UAT file.

Create {phase_dir}/{phase_num}-UAT.md using UAT template format:

---
status: testing
phase: {phase_num}-{phase_name}
source: [{phase_num}-VERIFICATION.md]
started: [now ISO]
updated: [now ISO]
---

## Current Test

number: 1
name: {first human_verification item description}
expected: |
  {expected behavior from VERIFICATION.md}
awaiting: user response

## Tests

{For each human_verification item from VERIFICATION.md:}

### {N}. {item description}
expected: {expected behavior from VERIFICATION.md}
result: [pending]

## Summary

total: {count}
passed: 0
issues: 0
pending: {count}
skipped: 0
blocked: 0

## Gaps

Commit the file:

gsd_run query commit "test({phase_num}): persist human verification items as UAT" --files "{phase_dir}/{phase_num}-UAT.md"

Step B: Present to user:

## ◷ Phase {X}: {Name} — Human Verification Needed

All automated checks passed. {N} item(s) require human testing before this phase can be marked complete:

{From VERIFICATION.md human_verification section}

Tests saved to `{phase_num}-UAT.md`.

When ready to run the tests:

`/gsd:verify-work {X} ${GSD_WS}`

Verify-work will walk you through each item and mark the phase complete when all tests pass.

Do NOT advance the phase from this branch. Phase completion is handled by verify-work's auto-transition after UAT passes.

If user acknowledges without reporting issues (including "ok", "noted", "ack", "got it", "approved", "done", "yes", "pass", or similar): Stop. The phase remains pending. No further orchestrator action — wait for the user to run /gsd:verify-work.

If user reports issues now (before running verify-work): Proceed to gap closure as currently implemented.

If gaps_found:

## ⚠ Phase {X}: {Name} — Gaps Found

**Score:** {N}/{M} must-haves verified
**Report:** {phase_dir}/{phase_num}-VERIFICATION.md

### What's Missing
{Gap summaries from VERIFICATION.md}

---
## ▶ Next Up — [${PROJECT_CODE}] ${PROJECT_TITLE}

`/clear` then:

`/gsd:plan-phase {X} --gaps ${GSD_WS}`

Also: `cat {phase_dir}/{phase_num}-VERIFICATION.md` — full report
Also: `/gsd:verify-work {X} ${GSD_WS}` — manual testing first

Gap closure cycle: /gsd:plan-phase {X} --gaps ${GSD_WS} reads VERIFICATION.md → creates gap plans with gap_closure: true → user runs /gsd:execute-phase {X} --gaps-only ${GSD_WS} → verifier re-runs.

**Mark phase complete and update all tracking files:**
COMPLETION=$(gsd_run query phase.complete "${PHASE_NUMBER}")

The CLI handles:

  • Marking phase checkbox [x] with completion date
  • Updating Progress table (Status → Complete, date)
  • Updating plan count to final
  • Advancing STATE.md to next phase
  • Updating REQUIREMENTS.md traceability
  • Scanning for verification debt (returns warnings array)

Extract from result: next_phase, next_phase_name, is_last_phase, warnings, has_warnings.

If has_warnings is true:

## Phase {X} marked complete with {N} warnings:

{list each warning}

These items are tracked and will appear in `/gsd:progress` and `/gsd:audit-uat`.
gsd_run query commit "docs(phase-{X}): complete phase execution" --files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md {phase_dir}/*-VERIFICATION.md
**Auto-copy phase learnings to global store (when enabled).**

This step runs AFTER phase completion and SUMMARY.md is written. It copies any LEARNINGS.md entries from the completed phase to the global learnings store at ~/.gsd/knowledge/.

Check config gate:

GL_ENABLED=$(gsd_run query config-get features.global_learnings --raw 2>/dev/null || echo "false")

If GL_ENABLED is not true: Skip this step entirely (feature disabled by default).

If enabled:

  1. Check if LEARNINGS.md exists in the phase directory (use the phase_dir value from init context)
  2. If found, copy to global store:
gsd_run query learnings.copy 2>/dev/null || echo "⚠ Learnings copy failed — continuing"

Copy failure must NOT block phase completion.

**Auto-close pending todos tagged for this phase (#2433).**

This step runs AFTER update_roadmap marks the phase complete. It moves any pending todos that carry resolves_phase: <current-phase-number> to the completed directory.

PHASE_NUM="${PHASE_NUMBER}"
PENDING_DIR=".planning/todos/pending"
COMPLETED_DIR=".planning/todos/completed"
mkdir -p "$COMPLETED_DIR"

CLOSED=()
for TODO_FILE in "$PENDING_DIR"/*.md; do
  [ -f "$TODO_FILE" ] || continue
  # Extract resolves_phase from YAML frontmatter (first --- block only)
  RP=$(awk '/^---/{c++;next} c==1 && /^resolves_phase:/{print $2;exit} c==2{exit}' "$TODO_FILE" 2>/dev/null || true)
  if [ "$RP" = "$PHASE_NUM" ] || [ "$RP" = "\"$PHASE_NUM\"" ]; then
    mv "$TODO_FILE" "$COMPLETED_DIR/"
    CLOSED+=("$(basename "$TODO_FILE")")
  fi
done

if [ ${#CLOSED[@]} -gt 0 ]; then
  gsd_run query commit "docs(phase-${PHASE_NUMBER}): auto-close ${#CLOSED[@]} todo(s) resolved by this phase" --files .planning/todos/completed/ .planning/STATE.md|| true
  echo "◆ Closed ${#CLOSED[@]} todo(s) resolved by Phase ${PHASE_NUMBER}:"
  for f in "${CLOSED[@]}"; do echo "  ✓ $f"; done
fi

If no todos have resolves_phase: <this-phase>: Skip silently — this step is always additive and never blocks phase completion.

**Evolve PROJECT.md to reflect phase completion (prevents planning document drift — #956):**

PROJECT.md tracks validated requirements, decisions, and current state. Without this step, PROJECT.md falls behind silently over multiple phases.

  1. Read .planning/PROJECT.md
  2. If the file exists and has a ## Validated Requirements or ## Requirements section:
    • Move any requirements validated by this phase from Active → Validated
    • Add a brief note: Validated in Phase {X}: {Name}
  3. If the file has a ## Current State or similar section:
    • Update it to reflect this phase's completion (e.g., "Phase {X} complete — {one-liner}")
  4. Update the Last updated: footer to today's date
  5. Commit the change:
gsd_run query commit "docs(phase-{X}): evolve PROJECT.md after phase completion" --files .planning/PROJECT.md

Skip this step if .planning/PROJECT.md does not exist.

Exception: If gaps_found, the verify_phase_goal step already presents the gap-closure path (/gsd:plan-phase {X} --gaps). No additional routing needed — skip auto-advance.

No-transition check (spawned by auto-advance chain):

Parse --no-transition flag from $ARGUMENTS.

If --no-transition flag present:

Execute-phase was spawned by plan-phase's auto-advance. Do NOT run transition.md. After verification passes and roadmap is updated, return completion status to parent:

## PHASE COMPLETE

Phase: ${PHASE_NUMBER} - ${PHASE_NAME}
Plans: ${completed_count}/${total_count}
Verification: {Passed | Gaps Found}

[Include aggregate_results output]

STOP. Do not proceed to auto-advance or transition.

If --no-transition flag is NOT present:

Auto-advance detection:

  1. Parse --auto flag from $ARGUMENTS
  2. Read consolidated auto-mode (active = chain flag OR user preference; chain flag already synced in init step):
    AUTO_MODE=$(gsd_run query check auto-mode --pick active 2>/dev/null || echo "false")
    

If --auto flag present OR AUTO_MODE is true (AND verification passed with no gaps):

╔══════════════════════════════════════════╗
║  AUTO-ADVANCING → TRANSITION             ║
║  Phase {X} verified, continuing chain    ║
╚══════════════════════════════════════════╝

Execute the transition workflow inline (do NOT use Agent — orchestrator context is ~10-15%, transition needs phase completion data already in context):

Read and follow ~/.claude/gsd-core/workflows/transition.md, passing through the --auto flag so it propagates to the next phase invocation.

If neither --auto nor AUTO_MODE is true:

STOP. Do not auto-advance. Do not execute transition. Do not plan next phase. Present options to the user and wait.

IMPORTANT: There is NO /gsd-transition command. Never suggest it. The transition workflow is internal only.

Check whether CONTEXT.md already exists for the next phase:

ls .planning/phases/*{next}*/{next}-CONTEXT.md 2>/dev/null || echo "no-context"

If CONTEXT.md does not exist for the next phase, present:

## ✓ Phase {X}: {Name} Complete

/gsd:progress ${GSD_WS} — see updated roadmap
/gsd:discuss-phase {next} ${GSD_WS} — start here: discuss next phase before planning  ← recommended
/gsd:plan-phase {next} ${GSD_WS} — plan next phase (skip discuss)
/gsd:execute-phase {next} ${GSD_WS} — execute next phase (skip discuss and plan)

If CONTEXT.md exists for the next phase, present:

## ✓ Phase {X}: {Name} Complete

/gsd:progress ${GSD_WS} — see updated roadmap
/gsd:plan-phase {next} ${GSD_WS} — start here: plan next phase (CONTEXT.md already present)  ← recommended
/gsd:discuss-phase {next} ${GSD_WS} — re-discuss next phase
/gsd:execute-phase {next} ${GSD_WS} — execute next phase (skip planning)

Only suggest the commands listed above. Do not invent or hallucinate command names.

<context_efficiency> Orchestrator: ~10-15% context for 200k windows, can use more for 1M+ windows. Subagents: fresh context each (200k-1M depending on model). No polling (Agent blocks). No context bleed.

For 1M+ context models, consider:

  • Passing richer context (code snippets, dependency outputs) directly to executors instead of just file paths
  • Running small phases (≤3 plans, no dependencies) inline without subagent spawning overhead
  • Relaxing /clear recommendations — context rot onset is much further out with 5x window </context_efficiency>

<failure_handling>

  • Quota / rate-limit (any runtime — #3095): Agent return body contains a sentinel like usage limit, rate limit, 429, too many requests, RESOURCE_EXHAUSTED, usage_limit_reached. Route via gsd-tools.cjs query agent.classify-failure → class: "quota-exceeded". Do not offer retry-now; the right action is wait-for-reset and resume.
  • classifyHandoffIfNeeded false failure: Agent reports "failed" but error is classifyHandoffIfNeeded is not defined → Claude Code bug, not GSD. Spot-check (SUMMARY exists, commits present) → if pass, treat as success
  • Agent fails mid-plan: Missing SUMMARY.md → report, ask user how to proceed
  • Dependency chain breaks: Wave 1 fails → Wave 2 dependents likely fail → user chooses attempt or skip
  • All agents in wave fail: Systemic issue → stop, report for investigation
  • Checkpoint unresolvable: "Skip this plan?" or "Abort phase execution?" → record partial progress in STATE.md </failure_handling>
Re-run `/gsd:execute-phase {phase}` → discover_plans finds completed SUMMARYs → skips them → resumes from first incomplete plan → continues wave execution.

STATE.md tracks: last completed plan, current wave, pending checkpoints.