Files
msd-core/sdk/src/query/phase-lifecycle.ts
Tom Boucher ae63cbe557 feat(3575): Phase 6 — CJS↔SDK seam migration end-to-end complete (#3524) (#3577)
* feat(3575): Phase 6 enforcement hardening + retrospective (#3524 feature-complete)

Phase 6 of the CJS↔SDK hard-seam migration (parent #3524). Final
phase per the PRD. After this lands the migration is feature-complete:
shared Modules from Phases 1-4 are in place, the runtime-bridge
primitive from Phase 5.0 is wired with the state.* family proof in
Phase 5.1 (PR #3574), and Phase 6 hardens the seam against future
drift via lint, CODEOWNERS, and retrospective documentation.

## What landed

- scripts/lint-shared-module-handsync.cjs (274 lines) — the
  drift-prevention gate. Scans bin/lib/*.cjs and looks for same-named
  sdk/src/<name>.ts or sdk/src/query/<name>.ts (excluding generated
  artifacts). Pairs not on the allowlist fail the lint with a clear
  message: either add to allowlist with justification, or migrate to
  a shared Module. Supports --root, --allowlist, --cjs-dir, --sdk-src,
  --warn-all flags for testability.
- scripts/shared-module-handsync-allowlist.json (148 lines) — two
  categories:
  - cooperatingSiblings (14 pairs) — legitimate Readers/Adapters
    that consume shared Modules or run structurally-different
    runtime paths.
  - migrateMeBacklog (8 pairs) — known drift anti-patterns that ARE
    on main today (config, decisions, intel, model-catalog, plan-scan,
    schema-detect, secrets, workstream-name-policy). Lint warns but
    does not fail on these; documented in the retrospective as
    candidate Shared Module migrations.
- tests/lint-shared-module-handsync.test.cjs (285 lines, 11 cases)
  — proves the lint catches new drift, honors the allowlist, and
  exits 0 on the current tree.
- .github/workflows/test.yml — new "Shared Module hand-sync drift
  check" step after the freshness checks.
- .github/CODEOWNERS — appended 11 architecture-owned path rules for
  source-of-truth files (Shared Module dirs, manifest JSONs, runtime
  bridge, lint script, allowlist). Existing blanket rule preserved.
- docs/agents/cjs-sdk-seam.md (280 lines) — full retrospective +
  guide:
  - Migration overview table linking Phases 1-6 with PR numbers.
  - 15 historical drift bugs (#1535 ... #3523) each mapped to the
    Phase 6 enforcement layer that would have blocked them.
  - "Guide: Adding a new Shared Module" — step-by-step using Phase 1
    (state-document) as the worked example.
  - "Guide: Adding a new canonical command" — step-by-step using
    Phase 5.1 (state.update) as the worked example.
  - "Open follow-ups" listing the 8 MIGRATE_ME pairs, per-family
    Phase 5.2+ candidates pending maintainer authorization, sync
    bridge workstream support, and Phase 5.1's parity divergences.
- CONTRIBUTING.md — short cross-reference paragraph in the
  Architecture & Domain Standards section.

## Audit findings

All 5 freshness checks from Phases 0-4 are already wired in CI:
command-aliases, state-document, configuration,
workstream-inventory-builder, project-root. Phase 6 adds the 6th
(hand-sync drift check) for total enforcement coverage.

## Numbers

- Full CJS suite: 9335/9335 pass (baseline 9323 + 11 new lint
  tests + 1 cooperating).
- Lint passes on current tree: 14 cooperating siblings + 8 backlog
  pairs accounted for, 0 unauthorized drift pairs.
- Lint exits 1 (fails CI) on an intentional new hand-synced pair
  added to a fixture — verified by the test suite.

Closes #3575. Closes the structural drift surface of #3524.

* chore(3577): add changeset fragment for Phase 6

* feat(3575): Phase 6 end-to-end completion — CJS↔SDK seam migration done

Per maintainer correction: Phase 6 is THE final phase and must
complete the migration end-to-end. This commit absorbs Phase 5.1's
work (state.* router + worker fix), finishes the remaining per-family
router migrations, completes all five resolvable Shared Module
extractions, resolves the parity divergences, lands native workstream
support in the sync bridge, and ships the lint + CODEOWNERS +
retrospective from the original Phase 6 scope.

After this commit the CJS↔SDK seam migration started in #3524 is
feature-complete. No follow-up "Phase 5.x" or "Phase 7" should be
needed — the only documented carve-outs are three pairs that
intentionally cannot be migrated (config CLI handlers, intel async
wrapper, model-catalog already on the shared-JSON pattern).

Cherry-picked state.* from Phase 5.1 (PR #3574 absorbed). Migrated
verify.*, init.*, phase.*, phases.*, validate.*, roadmap.* via the
same executeForCjs delegation pattern. Migrated the inline
gsd-tools.cjs cases for frontmatter.*, config-* CLI, and non-family
commands (generate-slug, current-timestamp, find-phase, docs-init)
with shared _dispatchNonFamily helper + _tryLoadSdkBridge loader.

CJS-native carve-outs documented: config-path, migrate-config,
detect-custom-files (no SDK counterpart yet); state.complete-phase
(no SDK counterpart yet); validate.context (CJS-only inline logic
with no clean SDK port); phases.archive (SDK-only).

- plan-scan (Module-via-generator from sdk/src/query/plan-scan.ts)
- secrets (Module-via-generator)
- schema-detect (Module-via-generator)
- decisions (Module-via-generator; SDK regex aligned to CJS
  alphanumeric IDs to preserve project compatibility)
- workstream-name-policy (Module-via-generator; SDK extended with
  hasInvalidPathSegment and isValidActiveWorkstreamName that CJS
  callers depend on)

Each ships with: SDK source-of-truth, generator at
sdk/scripts/gen-<name>.mjs, freshness check at
sdk/scripts/check-<name>-fresh.mjs, parity test at
tests/<name>-generator.test.cjs, CJS shim at
get-shit-done/bin/lib/<name>.cjs, scripts in sdk and root
package.json, pre-commit drift block, CI workflow step, CODEOWNERS
rule, INVENTORY.md row.

- config (config.cjs vs sdk/src/config.ts) — CJS file is CLI-handler
  surface (cmdConfigGet/Set/etc.); SDK file is loadConfig wrapper
  (already migrated in Phase 2). Zero logical overlap. Classified
  as CJS-CLI-ONLY in the allowlist.
- intel (intel.cjs vs sdk/src/query/intel.ts) — SDK is the async
  QueryHandler wrapper of the CJS module; intentional split per the
  SDK file's own docstring. Classified as cooperating-sibling.
- model-catalog (model-catalog.cjs vs sdk/src/model-catalog.ts) —
  both already consume sdk/shared/model-catalog.json (ADR-0003).
  No constants duplicated. Classified as ADAPTER-OVER-MODULE.

- state.record-metric: SDK aligned to CJS auto-create of
  ## Performance Metrics section when absent. Parity assertion now
  exact equality.
- state.prune: SDK aligned to CJS disk-based phase counting via
  stateExtractField. Parity assertion now exact equality. SDK unit
  tests updated to match.

GSDTransport.shouldUseNative no longer forces subprocess when
request.workstream is set — the Phase 5.0 worker fix already threaded
workstream through dispatchNative + registry.dispatch, making the
subprocess force unnecessary. state-command-router.cjs's workstream
fallback guard removed. cjs-sdk-seam.md and the regression test
updated to document the resolution.

Unchanged from the previous commit on this branch. The lint now
reports 22 cooperating siblings, 0 backlog pairs. The retrospective
section "Open follow-ups" is reduced to the three intentional
carve-outs above; the four stale subsections (8 MIGRATE_ME pairs,
per-family Phase 5.x candidates, workstream support, parity
divergences) are gone because they're all resolved in this commit.

- Full CJS suite: 9441/9441 pass (baseline pre-Phase-6 was 9323;
  +118 from the Phase 6 work — 11 lint tests + 12 state-router
  parity + 6 verify parity + 3 phase parity + 1 roadmap parity +
  24 plan-scan parity + 20 secrets parity + 18 schema-detect parity
  + 15 decisions parity + 19 workstream-name-policy parity).
- SDK vitest unit: 1863/1863 pass.
- Hand-sync lint: 22 cooperating siblings, 0 backlog pairs.
- All freshness checks: fresh.

Closes #3575. Closes the migration the CJS↔SDK seam was designed
to eliminate (#3524).

* fix(3575): lint-shared-module-handsync emits typed JSON; tests assert on IR

The lint-no-source-grep CI step rejected the original Phase 6 test
file (tests/lint-shared-module-handsync.test.cjs) because it
substring-matched on .stdout/.stderr from the lint script output —
prohibited per CONTRIBUTING.md "Raw Text Matching on Test Outputs".
Fix: add --json mode to the production lint script and assert on
typed IR fields.

## Changes

scripts/lint-shared-module-handsync.cjs:
- New --json flag. When set:
  - Success: emits { ok: true, cooperatingCount, backlogCount, warnings }
  - Unauthorized pairs: emits { ok: false, reason: 'unauthorized_pairs',
    errors: [{ relCjs, tsPaths }], warnings, cooperatingCount }
  - Missing CJS/SDK dir: emits { ok: false, reason: 'cjs_dir_missing'
    | 'sdk_src_missing', path }
- Default (human-readable) output unchanged.
- Warnings section is suppressed in --json mode (still surfaced in the
  IR's `warnings` field for tests to inspect).

tests/lint-shared-module-handsync.test.cjs:
- runLintJson() helper replaces runLint(), invoking the script with
  --json and parsing the IR.
- Every assertion now reads typed fields (payload.ok, payload.reason,
  payload.errors, payload.warnings, payload.cooperatingCount) instead
  of substring-matching stdout/stderr.
- Test count unchanged at 9 cases across 3 describe blocks.
- All pass.

## Verification

- node scripts/lint-no-source-grep.cjs → exit 0, 529 test files
  checked, 0 violations (was: 1 violation in this test file).
- node --test tests/lint-shared-module-handsync.test.cjs → 9/9 pass.
- node scripts/lint-shared-module-handsync.cjs → unchanged
  human-readable output, 22 cooperating siblings, 0 backlog pairs.
- node scripts/run-tests.cjs → 9449/9449 pass.

Addresses CI failure on PR #3577 (Phase 6 of #3524).

* fix(3575): address CodeRabbit review on PR #3577

Six findings resolved:

1. scripts/lint-shared-module-handsync.cjs — allowlist matching now
   pair-aware. Keys composite ${cjs}::${ts} instead of cjs-only, so
   an entry covering one (cjs, ts) pair no longer silently passes a
   sibling at a different ts path with the same module name.
   Header doc-comment also corrected: removed the stale claim about
   GSD_LINT_CHANGED_FILES filtering (no such code existed).

2. sdk/src/gsd-transport.ts — removed dead 'workstream_forced' member
   from the TransportDecision.reason union (no longer assigned after
   Phase 5.0 workstream-native refactor).

3. sdk/src/gsd-transport.ts — removed stale workstream interpolation
   from the subprocess-reason Error message; the field is no longer
   load-bearing for that decision path.

4. All eight generator scripts (sdk/scripts/gen-*.mjs and
   gen-state-document.ts) — replaced the manual entry-point check
   that used `new URL(process.argv[1], 'file://')`. On Windows that
   misparses `C:\…\gen-*.mjs` as scheme "c:" and breaks the check.
   Replaced with the cross-platform-safe direct comparison
   `fileURLToPath(import.meta.url) === process.argv[1]`. (Not using
   `import.meta.main` — that's only stable in Node 24+ and the
   project supports Node 22+.)

5. docs/agents/cjs-sdk-seam.md — added explicit `text` language
   specifier to the four file-path fenced blocks (lines 157, 165,
   173, 181). Closing fences correctly remain bare.

Verification

- node scripts/lint-no-source-grep.cjs → 0 violations
- node scripts/lint-shared-module-handsync.cjs → 22 cooperating
  siblings, 0 backlog (counts unchanged after pair-aware refactor)
- node scripts/lint-shared-module-handsync.cjs --json → typed IR
  unchanged
- All 9 generator freshness checks → fresh
- node scripts/run-tests.cjs → 9449/9449 pass
- sdk vitest src/gsd-transport.test.ts → 10/10 pass

Tests for pair-aware matching: the existing 9 cases in
tests/lint-shared-module-handsync.test.cjs already build fixture
allowlist entries with both `cjs` and `ts` fields, so they
implicitly exercise the new pair-aware lookup; all 9 pass.

* fix(3575): address second CodeRabbit review on PR #3577

Five new findings resolved.

1. Shared SDK bridge loader (`get-shit-done/bin/lib/cjs-sdk-bridge.cjs`)
   Eliminates seven-fold duplication of `tryLoadSdk` / `_executeForCjs`
   that lived verbatim in every `*-command-router.cjs` plus a near-identical
   variant in `gsd-tools.cjs`. The new module exposes `tryLoadSdk()`,
   `getExecuteForCjs()`, and `getSdkModule()` (the last for routers that
   pull additional named exports, e.g. state's `formatStateLoadRawStdout`).
   All eight call sites refactored to consume it. As a side benefit
   `gsd-tools.cjs` no longer imports from the private
   `@gsd-build/sdk/dist/runtime-bridge-sync/index.js` subpath; everyone now
   uses the public package entry consistently.

2. `phase remove` accepts zero positional args (#3577 review)
   `phase remove --force` previously passed validation with no phase number
   and invoked `cmdPhaseRemove(cwd, undefined, ...)`. Tightened to
   `positional.length !== 1` and added the early `return` so the handler
   never receives an undefined phase id.

3. Decisions parser regex hardened (#3577 review)
   `D-[A-Za-z0-9_-]+` allowed malformed IDs like `D--foo` and `D-_bar`.
   Tightened to `D-[A-Za-z0-9][A-Za-z0-9_-]*` so the first character after
   `D-` must be alphanumeric; internal `_`/`-` still permitted.
   Decisions generated CJS mirror regenerated.

4. plan-scan-generator test no longer uses hardcoded `/tmp` paths
   `/tmp/__gsd_test_nonexistent_dir_xyz__` and
   `/tmp/__nonexistent_gsd_test__` could collide with prior runs on shared
   CI runners. Replaced with `uniqueMissingPath()` helper that synthesizes
   `os.tmpdir()/<prefix>-<pid>-<ms>-<random>` and force-removes the path
   before returning.

5. lint-shared-module-handsync test now validates pair-aware TS matching
   Added `rejects pair when TS path differs from allowlist entry` — a
   regression guard that creates an on-disk pair at `sdk/src/query/<name>.ts`
   but allowlists the (cjs, sdk/src/<name>.ts) shape. The lint must reject
   because the (cjs, ts) tuple does not match. Demonstrates the pair-aware
   matching added in the previous commit and locks it in.

## Wiring

`cjs-sdk-bridge.cjs` added to `docs/INVENTORY.md` (count 68→69) and
`docs/INVENTORY-MANIFEST.json` regenerated.

## Verification

- node scripts/lint-no-source-grep.cjs → 0 violations (529 files)
- node scripts/lint-shared-module-handsync.cjs → 22 cooperating, 0 backlog
- node scripts/run-tests.cjs → 9452/9452 pass (was 9449 + 1 lint-test + 1
  changed plan-scan path test)
- node sdk/scripts/check-decisions-fresh.mjs → fresh
- sdk vitest src/query/decisions.test.ts → 15/15 pass

* docs(3575): correct PR/issue refs in cjs-sdk-seam.md

CodeRabbit caught two stale references that conflated the issue
number (#3575) with the PR number (#3577). Phase 6 ships as PR
#3577 closing issue #3575. Migration overview table row and the
Final Completion Summary updated accordingly.

* fix(3575): cjs-sdk-bridge actually loads the SDK (was dead-code since Phase 5.0)

## The bug

`cjs-sdk-bridge.cjs:tryLoadSdk()` resolved `require('@gsd-build/sdk')`,
but that package name is not installed in the root `node_modules`
(the SDK lives as `./sdk/` — a sibling workspace, not a dependency)
and the SDK's public entry doesn't re-export `executeForCjs` or
`formatStateLoadRawStdout` anyway. `tryLoadSdk()` always returned
false, the `_loadFailed = true` cache made every subsequent call
return false for the lifetime of the process, and every CJS router
silently fell through to the CJS handler.

The pattern shipped in Phase 5.0 (PR #3558, merged) via
`require('@gsd-build/sdk/dist/runtime-bridge-sync/index.js')` and
was inherited into the routers via `require('@gsd-build/sdk')` in
Phase 5.1 (PR #3574, merged). Both subpaths/imports failed in the
same way. CI passed for the whole CJS↔SDK migration because the
CJS fallback handlers kept running — meaning the entire claimed
"state.* delegation" never actually executed via the SDK in any
shipped run.

This is exactly the silent-drift class the Phase 6 lint and
retrospective are supposed to prevent. Catching it here closes the
loop.

## The fix

Resolve the bundled SDK by **package-relative filesystem path**:

  <root>/sdk/dist/runtime-bridge-sync/index.js
  <root>/sdk/dist/query/state-project-load.js

The `files` array in `package.json` keeps `sdk/dist` at the same
relative location inside the published tarball, so the path works
in both dev and post-install. The two-file split is necessary
because `formatStateLoadRawStdout` lives in the state handler,
not the runtime-bridge entry.

## Integration test

`tests/cjs-sdk-bridge-integration.test.cjs` proves four things and
locks the load-success invariant so this regression cannot recur:

  1. tryLoadSdk() returns true on the current checkout
  2. getExecuteForCjs() returns a function (not null)
  3. getFormatStateLoadRawStdout() returns a function (not null)
  4. executeForCjs() actually dispatches a canonical registry
     command (generate-slug) and returns an ok:true result — proving
     real SDK execution, not a silent CJS-fallback

## State-router formatter wiring

The state command router was reaching into `getSdkModule()` to pluck
`formatStateLoadRawStdout`. Replaced with the explicit
`getFormatStateLoadRawStdout()` getter so the bridge module owns
all SDK-export resolution.

## state.load --raw output mode

While the bridge was broken, the state.load --raw test happened to
pass via CJS fallback. The first SDK execution exposed a contract
mismatch: passing `mode: 'raw'` to the bridge tells the SDK to
pre-render result.data to a JSON string, but the router was also
calling `formatStateLoadRawStdout(result.data)` to project to
key=value lines — the formatter saw a string and no-op'd.

Fix: when a CJS-side rawFormatter is supplied, the router requests
`mode: 'json'` from the bridge (always get typed data) and runs the
formatter itself. When no rawFormatter, the user's --raw flag flows
through to the bridge as usual.

## Surfaced pre-existing parity gaps (NOT yet fixed)

With the bridge now actually executing the SDK, 8 `tests/state.test.cjs`
cases reveal pre-existing CJS↔SDK behavioral drift that Phase 5.1's
"104/104 pass" report could not see because the SDK was never running:

  - `state load returns error when STATE.md missing`
  - `state get returns error when STATE.md missing`
  - `state update returns error when STATE.md missing`
  - `state update reports field not found`
  - `state patch / record-metric / update-progress /
     resolve-blocker / record-session — error when STATE.md missing`
  - `add-decision --summary-file` / `add-blocker --text-file`
    (file-input path rejected by SDK security check)

Each is a real CJS↔SDK divergence that needs explicit alignment in
the SDK handler. Listed here so the next commit can address them
honestly rather than letting the broken bridge mask them again.

* fix(3575): align SDK with CJS contract — bridge-exposed divergences

The Phase 5.1 bridge fix (0fc60b0c) made executeForCjs() actually load and
dispatch. With routers now hitting the SDK in normal layouts, six CJS↔SDK
behavioral divergences became visible. This commit aligns the SDK to match
the canonical CJS contract test-by-test.

ROUTER CHANGES (mode: raw → mode: json)
All 7 CJS routers were passing `mode: raw ? 'raw' : 'json'`. With the bridge
active, `mode: 'raw'` makes the bridge pre-render result.data to a JSON string,
which CJS output() then re-stringifies — producing a JSON string of a JSON
string. Routers now always request typed JSON; CJS output() handles user-
facing rendering. Affected: gsd-tools, init, phase, phases, roadmap, state,
validate, verify routers.

SDK STATE MUTATION HANDLERS (sdk/src/query/state-mutation.ts)
state.update / record-metric / update-progress / resolve-blocker / record-
session no longer auto-create STATE.md via readModifyWriteStateMd. CJS errors
out when STATE.md is missing; SDK now does the same via an upfront existsSync
check returning {updated: false, reason: 'STATE.md not found'}. Also fixes:

  • resolve-blocker semantic: SDK returned resolved:false when no blocker
    line matched. CJS returns resolved:true whenever the Blockers section
    exists. Aligned.
  • readTextArgOrFile path validation: rejected /var/folders paths on macOS
    because /var → /private/var is a symlink. Now resolves both base and
    target via realpathSync before the prefix check.

STATE.MD STOPPED_AT SCOPING (sdk/src/query/state.ts)
buildStateFrontmatter extracted `Stopped At` from the entire body; CJS scopes
it to the ## Session section. Bug-2444 parity restored — the field no longer
bleeds in from unrelated sections of STATE.md.

PHASE_DIR_COUNT MILESTONE FILTER (sdk/src/query/init.ts)
initNewMilestone counted every directory under phases/ regardless of which
milestone it belonged to. CJS uses getMilestonePhaseFilter to count only
current-milestone phase dirs. Bug-2445 parity restored.

ARCHIVED PHASE GUARD (sdk/src/query/init.ts)
shouldDropArchivedPhaseMatch had an extra `archivedTag === milestone.version`
escape hatch that doesn't exist in CJS. CJS unconditionally drops the
archived match when the phase appears in the current ROADMAP. Removed the
escape hatch — fixes the bug #2391 regression where `init plan-phase 03`
returned the archived v1.0 phase instead of the current ROADMAP phase.

PADDING-TOLERANT ROADMAP PHASE LOOKUP (sdk/src/query/roadmap.ts)
searchPhaseInContent used `escapeRegex(phaseNum)` as the phase-number
fragment — `03` failed to match `Phase 3:` headings. CJS uses
phaseMarkdownRegexSource which emits `0*<integer>` for padding tolerance.
Restored same helper inline in roadmap.ts. Fixes bug #2391 / #3537 parity
in zero-padded phase lookups.

STATE COMMAND ROUTER STATE.MD-MISSING ERROR SURFACE
(get-shit-done/bin/lib/state-command-router.cjs)
state.get must surface "STATE.md not found" as an error (matching CJS exit
behavior); other state mutations must surface {updated: false, reason: ...}
as data. Added EXIT_ON_STATE_MD_MISSING discriminator with STATE_MD_MISSING_
MESSAGE constant.

VERIFICATION
  • init.test.cjs        — 93/93 pass (was 91/2 fail)
  • state.test.cjs       — 104/104 pass (was 95/9 fail)
  • core.test.cjs        — pass
  • roadmap.test.cjs     — pass
  • cjs-sdk-bridge-integration.test.cjs — 4/4 pass (bridge load locked in)

The 13 phase.test.cjs failures (next-decimal 999.x backlog skip, add-batch
JSON validation, insert dry-run rejection, find-phase non-canonical
warnings) are pre-existing SDK gaps from the broken-bridge era and will be
addressed in a follow-up commit on this same PR.

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

* fix(3575): align SDK phase handlers with CJS (wave 2 — phase.test.cjs)

The bridge-fix (0fc60b0c) exposed 13 more CJS↔SDK behavioral divergences
inside the phase command family. All are now aligned to the canonical CJS
contract, with per-test verification.

phase.ts:
  • Centralised isCanonicalPlanFile / looksLikePlanFile / describeNonCanonical
    Plans helpers mirroring phase.cjs:17–52. Exported for reuse from
    phase-lifecycle.ts (phasesList) so the warning shape never drifts between
    read sites.
  • searchPhaseInDir now emits result.warning (singular) with the canonical
    message when a plan-shaped file would be skipped by the canonical filter.
    Bug #2893 parity for find-phase.
  • phasePlanIndex moved its non-canonical warning to the singular result.warning
    field (was a generic entry in result.warnings) so consumers see the same
    field name and message format as find-phase / phases-list. Other
    diagnostics (unresolved deps, wave-declaration mismatches) still flow
    through the warnings array unchanged.
  • Added PhaseInfo.warning to the type. getPhaseFileStats now also returns
    allFiles so the caller can compute the diagnostic without re-reading the
    directory.

phase-lifecycle.ts:
  • phasesList (phases list --type plans) emits per-dir prefixed warnings
    matching phase.cjs:120 (`${dir}: ${describeNonCanonicalPlans(...)}`).
  • phaseAdd now matches the CJS router contract for arg parsing:
    accepts --raw (ignored), --dry-run, --id <value>; rejects every other
    --flag with "phase add does not support <flag>"; rejects dangling
    --id with "--id requires a value"; joins all positional tokens with
    space so `phase add User Dashboard` produces description "User
    Dashboard". customId comes from --id, never from positional[1].
  • phaseInsert now mirrors phaseAdd's arg parsing: rejects --dry-run
    with "does not support --dry-run", strips --raw, joins
    positional.slice(1) for the description. Also reports the bug-3098
    placeholder error ("Phase N exists in roadmap summary but is missing
    a detail section") when the ROADMAP has only a checklist entry but no
    detail section.
  • phaseAddBatch dangling --descriptions or --descriptions followed by
    another flag now surface "--descriptions must be a JSON array"
    instead of silently falling through to positional parsing or throwing
    "--descriptions must be a valid JSON array".
  • renameIntegerPhases now skips backlog phases (dirInt >= 999) — bug-2434
    parity. Without this, removing phase 3 in a project with 999.1-backlog-*
    on disk would rename the backlog dir to 998.1-backlog-*.
  • updateRoadmapAfterPhaseRemoval rewritten to mirror phase.cjs:880-922
    exactly: 5 targeted regex passes (not a loop), driven by three
    decrement helpers (decrementRoadmapPhaseNumber, decrementRoadmapPhase
    Token, decrementRoadmapPaddedPhaseNumber) that guard against
    `num >= 999`. The padded-prefix replace uses negative lookbehind/
    lookahead to skip YYYY-MM-DD substrings. Fixes:
      - bug-2435: integer phase remove no longer corrupts dates in ROADMAP
        (e.g. `(Shipped: 2025-04-15)` is left alone when removing phase 4).
      - bug-3355: integer phase remove no longer renumbers the same phase
        more than once (loop overlap removed).
      - Backlog phases stay frozen during renumbering.
  • phaseComplete next-phase scan skips backlog dirs (999.x). Without
    this, `phase complete 2` in a project with 999.1-backlog/ on disk
    would emit next_phase: '999.1' even though Phase 3 exists in
    ROADMAP.md. Bug #2129 parity.

VERIFICATION (per-test, targeted runs — full suite not exercised due to
prior 89GB OOM with concurrent runs):
  • phase.test.cjs        — 108/108 pass (was 13 fail)
  • init.test.cjs         — 93/93 pass (no regression)
  • state.test.cjs        — 104/104 pass (no regression)
  • validate.test.cjs     — pass (no regression)
  • verify.test.cjs       — pass (no regression)
  • core.test.cjs         — pass (no regression)
  • roadmap.test.cjs      — pass (no regression)
  • cjs-sdk-bridge-integration.test.cjs — 4/4 pass (bridge intact)

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

* fix(3575): align SDK roadmap-mutation helpers with CJS — bug-2005

Three CJS↔SDK divergences in the phase.complete write path were hiding
behind the broken bridge:

1. replaceInCurrentMilestone (sdk/src/query/phase-roadmap-mutation.ts)
   The SDK port carried an extra fallback that doesn't exist in the CJS
   (core.cjs:1013-1022): if the "after last </details>" slice didn't match
   the pattern, the SDK silently retried inside the last <details> block.
   That fallback corrupts the current milestone when it is itself wrapped
   in <details open>...</details> and there's no content after the close
   tag — the supposed-to-be-skipped scope is the only place the match
   exists. Aligned to CJS: split at the last </details>, replace only in
   the after-slice, return. No fallback. Documented with a "do not
   re-add" warning since this fallback has been added back twice in
   prior porting passes.

2. phase complete checkbox update (sdk/src/query/phase-lifecycle.ts)
   The SDK was scoping the `- [ ] Phase N:` → `- [x] Phase N:`
   replacement through replaceInCurrentMilestone. The CJS
   (phase.cjs:1057) uses a direct roadmapContent.replace(...) call. When
   the current milestone is wrapped in <details>, the scoped variant
   never reaches the checkbox; direct replace finds it. Aligned with
   CJS.

3. phase complete plan-count update (sdk/src/query/phase-lifecycle.ts)
   Same pattern — the SDK was scoping the `**Plans:** X/Y` update
   through replaceInCurrentMilestone. CJS (phase.cjs:1080) uses direct
   replace. Aligned.

VERIFICATION
  • bug-2005-phase-complete-details.test.cjs — 2/2 pass (was 1 fail)
  • phase.test.cjs                            — 108/108 pass (no regression)

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

* fix(3575): align SDK with CJS — add-decision DWIM + frontmatter paths

Two more CJS↔SDK divergences exposed by the bridge fix:

state.add-decision / state.add-blocker DWIM (sdk/src/query/state-mutation.ts)
  CJS state.cjs:481-498 + 532-548 auto-create the canonical Decisions /
  Blockers section when it's absent from STATE.md. The SDK was returning
  `{added: false, reason: '<Section> section not found in STATE.md'}`
  even when STATE.md was writable. Bug #3286 (parity for both verbs):

    • If section header pattern matches → append entry (existing path).
    • If section is absent → scaffold `## Decisions` (or `### Blockers`)
      and append the entry, then set `created: true` on the result.

  Matches the begin-phase / advance-plan DWIM behavior. Callers can now
  treat `state add-decision` as idempotent — first call creates the
  scaffold, subsequent calls append to it.

frontmatter get/set/merge/validate (helpers.ts + frontmatter.ts +
                                     frontmatter-mutation.ts)
  CJS frontmatter.cjs:323/340/354/369 resolves user paths with the
  simple `path.isAbsolute(p) ? p : path.join(cwd, p)`. The SDK port had
  promoted this to `resolvePathUnderProject` which adds a real-path
  prefix check against the project root.

  That check rejects absolute paths outside the project — including
  macOS tmpdir paths whose names contain spaces, the exact regression
  cited in bug #3509. Frontmatter verbs are deliberately path-flexible
  in CJS because they're called against external files (plan paths
  from other repos, scratch markdown, tmpdir fixtures).

  Introduced `resolveFrontmatterPath()` mirroring the CJS one-liner. The
  project-scoped `resolvePathUnderProject()` is unchanged — still used
  for template output, decision artifacts, etc.

VERIFICATION
  • bug-3286-state-write-routing.test.cjs — 13/13 pass (was 6 fail)
  • bug-3509-path-spaces.test.cjs         — 6/6 pass (was 3 fail)
  • phase.test.cjs / init.test.cjs / state.test.cjs / validate.test.cjs /
    verify.test.cjs / core.test.cjs / roadmap.test.cjs — all pass (no
    regression — 566 total tests).

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

* fix(3575): route SDK state handlers through scanPhasePlans — bug-3257

The SDK port of buildStateFrontmatter / stateValidate / stateSync was
using a naive top-level filter (`files.filter(/-PLAN\.md$/i)`) instead
of the canonical scanPhasePlans helper. The naive filter undercounts
every phase that uses the nested layout `phases/NN-name/plans/<NN>-PLAN-MM-slug.md`,
which is the default the planner agent produces.

CJS routes all three sites through scanPhasePlans (state.cjs:408, 824,
1427). scanPhasePlans is already a Shared Module — generated CJS at
plan-scan.generated.cjs from sdk/src/query/plan-scan.ts. The fix is
just to consume it.

CHANGES
  • buildStateFrontmatter (sdk/src/query/state.ts): replaced the
    inline `-PLAN.md` / `-SUMMARY.md` regex filters with scanPhasePlans;
    use the helper's `completed` flag for diskCompletedPhases.
  • stateValidate (sdk/src/query/state-mutation.ts): same swap on the
    current-phase plan-count drift check.
  • stateSync (sdk/src/query/state-mutation.ts): same swap on the
    rollup loop. Also routes the Progress percent through
    computeProgressPercent(completedPlans, totalPlans, diskCompletedPhases,
    syncTotalPhases) so the min(plan_fraction, phase_fraction) cap from
    bug #3242 Bug B is applied — without this, sync emitted 60% when the
    real progress was capped at 50% by phase-fraction.

VERIFICATION
  • bug-3257-nested-plans-undercount.test.cjs — 14/14 pass (was 12 fail)
  • phase.test.cjs / init.test.cjs / state.test.cjs / validate.test.cjs /
    verify.test.cjs / core.test.cjs / roadmap.test.cjs / bug-3286 /
    bug-2005 / bug-3509 — all pass (no regression — 580 total).

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

* test(3575): phase 6 CJS↔SDK seam behavioral contracts — TDD-found worker bug

Adds tests/phase-6-cjs-sdk-seam-contracts.test.cjs — a behavioral contract
suite for everything Phase 6 of #3524 introduced.  Written under the
issue #3592 test rewrite discipline:

  • No source-grep on .cjs files
  • No assert.match / .includes on free-form child-process stdout/stderr
  • Every assertion is on a parsed JSON object, a filesystem fact, an
    exit code, or a frozen enum value (SYNC_ERROR_KIND, BRIDGE_EXPORTS,
    TRANSPORT_MODE)
  • Helpers come from tests/helpers.cjs (runGsdTools, createTempProject,
    cleanup) — no inline fs.mkdtempSync
  • Fixture content built with array.join('\n'), never template literals
  • beforeEach/afterEach for shared setup; no try/finally inside tests

COVERAGE
  1. Bridge module surface — exports lock against BRIDGE_EXPORTS
  2. Bridge load lifecycle — tryLoadSdk, getters return cached refs,
     pre-load returns null
  3. executeForCjs RuntimeBridgeSyncResult shape — ok:true vs ok:false
     discriminated union; mode:"json" never double-stringifies
  4. CLI family-router dispatch — one structured-JSON assertion per
     family (roadmap, phase, phases, state, init, validate, find-phase)
  5. mode:"json" regression guard — stdout parses to object, not to
     JSON-encoded string (the Wave-1 double-stringify bug shape)
  6. GSD_WORKSTREAM gate — SDK path and CJS fallback produce identical
     structured fields for the same fixture
  7. Validation error taxonomy — empty arg → ok:false +
     errorKind: SYNC_ERROR_KIND.VALIDATION_ERROR
  8. phase.add filesystem facts — directory exists, ROADMAP file grew
     (asserted via fs.statSync, never by reading content back)

TDD-FOUND BUG (RED → GREEN)
  Suite §7 (validation_error taxonomy) failed in the RED phase:

    expected: 'validation_error'
    actual:   'native_failure'

  Root cause in sdk/src/runtime-bridge-sync/worker.ts: when an SDK
  handler throws a GSDError(Validation), the native direct adapter
  wraps it in a GSDToolsError via createNativeFailureError, preserving
  the original on `.cause`.  classifyError only checked for TypeError
  causes — every GSDError cause fell through to `native_failure`,
  breaking the documented SyncErrorKind contract.

  Fix: classifyError now unwraps the cause once.  When the cause is a
  GSDError with ErrorClassification.Validation or .Blocked, the result
  is errorKind: 'validation_error' (exit 10) — matching the direct
  branch a few lines below for unwrapped GSDError.

VERIFICATION (per-test, before and after the worker fix)
  • Phase 6 contract suite          — 21/21 pass (was 20/1 fail at RED)
  • phase.test.cjs                  — 108/108 pass
  • init.test.cjs                   — 93/93 pass
  • state.test.cjs                  — 104/104 pass
  • validate.test.cjs / verify.test.cjs / core.test.cjs / roadmap.test.cjs
                                    — all pass
  • cjs-sdk-bridge-integration.test.cjs — 4/4 pass (bridge intact)
  • npm run lint:tests              — 0 violations (no source-grep)

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

* fix(3575): SDK config-get/set parity + reason-code propagation — bugs #2943 #3086 #3212

Three CJS↔SDK divergences in config dispatch exposed when Phase 6 routes
`config-get` / `config-set` through `executeForCjs`:

1. SDK config-get was missing the SCHEMA_DEFAULTS map.
   CJS config.cjs:505-510 hard-codes documented defaults for
   `context_window` (200000), `executor.stall_detect_interval_minutes` (5),
   `executor.stall_threshold_minutes` (10), `git.create_tag` (true).  When a
   config.json omits the key, CJS returns the documented default with
   exit 0.  SDK threw `Key not found` for all four — every skill that
   reads `context_window`, executor stall thresholds, or the tag toggle
   broke under SDK dispatch.  Ported the table verbatim into
   sdk/src/query/config-query.ts and consult it at every "not found"
   exit point (matching the three CJS branches: missing file, traversal
   collapse, terminal undefined).

2. SDK config-set was missing the `git.create_tag` boolean-only guard.
   CJS rejects `config-set git.create_tag maybe` because the schema is
   boolean.  SDK silently accepted it and wrote "maybe" to disk under
   Phase 6 dispatch.  Added the matching guard + the missing
   `workflow.post_planning_gaps` boolean guard.

3. SDK errors lost their structured reason code at the bridge boundary.
   `--json-errors` callers expect `reason: 'config_key_not_found'` etc.
   from a frozen `ERROR_REASON` taxonomy; the bridge dispatcher in
   gsd-tools.cjs was calling `error(message)` without the second
   argument, so every SDK-routed error surfaced as `reason: 'unknown'`.
   Fix is end-to-end:
     • config handlers tag the GSDError with `.reason = 'config_*'`.
     • worker.ts:classifyError reads `.reason` off the cause (or off
       the direct error) and forwards it via `errorDetails.reason`.
     • `_dispatchNonFamily` in gsd-tools.cjs passes that reason as the
       second arg to `error()` when present.
     • Also added the `--raw` scalar pass-through here, so
       `output(data, raw, String(data))` is called for primitive
       results — without it, `config-get context_window --raw` emitted
       the JSON shape '200000\n' which happens to match but breaks any
       primitive whose JSON encoding differs from its String() form
       (booleans for example, where the CJS produces `true` while the
       SDK-routed path was producing `true` — same here, but the
       structural guarantee was wrong before).

VERIFICATION (per-test)
  • bug-2943-config-get-context-window-default.test.cjs — 5/5 pass
  • bug-3086-git-create-tag-config-gate.test.cjs        — 4/4 pass
  • bug-3212-execute-phase-stall-safe-resume.test.cjs   — 7/7 pass
  • Phase 6 contract suite                              — 21/21 pass
  • phase/init/state/core/roadmap/validate/verify       — all pass
                                                          (570 total)
  • Full bug-* suite: 24 fail → 17 fail (7 fixed in this commit).

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

* fix(3575): SDK milestone-archive layout discovery — bug #3164

Two CJS↔SDK divergences in phase discovery and validation surfaced
when projects moved to the milestone-archive layout
(`.planning/milestones/v<version>-phases/<phase>/`) instead of the
flat `.planning/phases/<phase>/`.

1. SDK findPhase had no `searched_directories` field on the not-found
   payload.  CJS surfaces this for diagnostics.  Added: track every
   directory probed (the active `.planning/phases/` plus each
   archive root) and include the relative paths in the not-found
   payload. Bug #3164 — #find-phase tests.

2. SDK validateConsistency only scanned `.planning/phases/`.  CJS
   `cmdValidateConsistency` (verify.cjs:467) walks every active
   phase root via `collectPhaseRoots(planBase)` — the flat dir plus
   the active milestone archive resolved from STATE.md.  Without
   parity, every roadmap phase on a milestone-archive-layout project
   emitted W006 ("no directory on disk") even though the phases were
   present in the archive.

   Ported the helper trio (listMilestoneArchiveDirs,
   getActiveMilestoneArchiveDir, collectPhaseRoots) verbatim from
   verify.cjs:400-444 and rewrote validateConsistency's disk-phase
   scan + per-phase plan scan to iterate `phaseRoots`.  Warning
   labels now include the archive prefix so users can tell which
   root surfaced the issue.

   Also accepts prefixed archive dir names (`CK-64-...`) as phase 64
   via the `(?:[A-Z]{1,6}-)?` group at the head of
   PHASE_TOKEN_FROM_DIR_RE — same regex CJS uses.

VERIFICATION (per-test)
  • bug-3164-milestone-archive-layout.test.cjs — 8/8 pass
  • Phase 6 contract suite                      — 21/21 pass
  • phase/init/state/validate/verify/core/roadmap — 570 pass
  • Full bug-* suite: 17 fail → 12 fail (5 fixed in this commit;
    cumulative 12 fixed since Wave 6 start).

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

* fix(3575): padded phase IDs match unpadded ROADMAP prose — bug #3537

Three failures in bug-3537-padded-id-against-unpadded-roadmap:

1. roadmap.get-phase returned `phase_number` verbatim from the user
   input — `02.7` produced `"phase_number": "02.7"` while `2.7`
   produced `"phase_number": "2.7"` on the same fixture, so a parity
   compare of the two stdouts fails.  Fixed by promoting the matched
   phase token in `searchPhaseInContent` to a capture group and
   returning that as the canonical `phase_number`.  Same fix in the
   checklist-fallback branch so the malformed-roadmap diagnostic
   carries the as-written form too.

2. phase.complete built every ROADMAP-prose regex from
   `escapeRegex(phaseNum)` instead of the padding-tolerant
   `phaseMarkdownRegexSource(phaseNum)`.  Calling
   `phase complete 02.7` against the un-padded heading
   `### Phase 2.7:` matched nothing — checkbox didn't flip, plan
   count stayed at `0/1`, table row stayed `Planned`.  Promoted
   `phaseMarkdownRegexSource` to an exported helper in roadmap.ts
   and wired it into phaseComplete's roadmap mutation block.

3. roadmap.annotate-dependencies infinite-looped through the bridge.
   The SDK handler delegates to `spawnSync(gsd-tools.cjs roadmap
   annotate-dependencies …)`; the child re-entered the roadmap
   router; the router re-dispatched through executeForCjs; synckit
   spawned the same SDK worker; that worker spawned gsd-tools.cjs
   again; …  Recursion hit the 15s timeout and the test reported
   `code=null`.  Fixed with a `GSD_SDK_NESTED=1` env-var guard:
   the SDK handler sets it when spawning the child, and the CJS
   roadmap router refuses SDK dispatch when it sees the flag.

VERIFICATION (per-test)
  • bug-3537-padded-id-against-unpadded-roadmap.test.cjs — 6/6 pass
  • Phase 6 contract suite                                — 21/21 pass
  • phase/init/state/validate/verify/core/roadmap         — 570 pass
  • Full bug-* suite: 12 fail → 7 fail (5 fixed in this commit;
    cumulative 17 fixed across the wave-6/7/8 sequence).

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

* fix(3575): final-7 SDK parity — bugs #2787 #2268 #2526

Closes out the bug-suite tail.  Three independent fixes against three
independent regressions surfaced when Phase 6 routed read-only and
mutation paths through the SDK.

1. extractCurrentMilestone truncated at heading-like lines inside
   fenced code blocks — bug #2787.  The `^#{1,N}\\s+...vX.Y` scan
   ran with the `/m` flag, which matches `^` at every newline,
   including newlines inside ``` and ~~~ fences.  A snippet like
     ```bash
     # Ops runbook — v1.0 compat
     ```
   placed between Phase 2 and Phase 3 of a v1.1 milestone shortened
   the milestone slice and made phases 3, 4 invisible to
   roadmap.analyze / roadmap.get-phase.

   Added `isInsideFencedCodeBlock(content, offset)` — a GFM-aware
   walker that toggles a `fenceChar` cursor on each fence boundary
   (backticks and tildes; closing fences require the matching
   character and no info string — so ```js inside ```text does NOT
   close).  The nextMilestoneRegex loop now skips any match that
   falls inside an open fence.

2. init.manager only marked the FIRST undiscussed phase as
   `is_next_to_discuss` — bug #2268.  Two and five-phase fixtures
   both proved the regression: parallel-discuss capacity was lost,
   recommended_actions emitted at most one discuss action even
   when callers were free to take several.  Replaced the sliding-
   window loop with an unconditional `phase.is_next_to_discuss =
   (status === 'empty' || status === 'no_directory')`.

3. phase.complete didn't surface "REQ-IDs found in body but
   missing from Traceability table" warnings — bug #2526.  CJS
   phase.cjs:1140-1167 scans REQUIREMENTS.md for `**REQ-ID**`
   references in the body, intersects against the IDs that actually
   appear in the Traceability section table, and warns about the
   diff.  The SDK port only ran the per-roadmap-REQ checkbox
   update and never emitted the body-scan warning.  Added the
   missing scan + warning push; also routed the writeFile through a
   `reqContentChanged` flag so we only write when at least one
   substitution actually fired (parity with the implicit
   "every checkbox already complete" no-write CJS branch).

VERIFICATION
  • bug-2787-milestone-fenced-block-truncation.test.cjs — 4/4 pass
  • bug-2268-parallel-discuss.test.cjs                   — 4/4 pass
  • bug-2526-phase-complete-req-discovery.test.cjs       — 3/3 pass
  • Phase 6 contract suite                               — 21/21 pass
  • Major suites (phase/init/state/validate/verify/core/roadmap) — 570 pass
  • **Full bug-* suite: 2397/2397 pass — ZERO failures.**
  • Combined run (major + bug-*): 2967/2967 pass — zero failures.

Cumulative since the bridge-fix landing (PR #3577): 12 sub-test
regressions surfaced + every one resolved.  Phase 6 is now byte-for-
byte CJS-parity across every command family verified by the test
suite.

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

* fix(3575): preserve codex runtime command shape after router migration

* test(3575): pin agent-install-validation init tests to GSD_AGENTS_DIR

PR #3577 routed init.execute-phase and init.plan-phase through executeForCjs
to the SDK handlers. The SDK side's resolveAgentsDir (sdk/src/query/helpers.ts)
honors GSD_AGENTS_DIR or falls back to <runtimeConfigDir>/agents; it does not
walk up from cwd to find <repo>/agents/ like the CJS-era code did. The two
init-suite tests that asserted agents_installed=true relied on that implicit
walk and only passed on dev machines where ~/.claude/agents/ already had the
33 agents installed — Linux CI runners have neither.

Match the pattern every passing sibling in this file already uses: pass
{ GSD_AGENTS_DIR: REPO_AGENTS_DIR } through runGsdTools so the SDK resolver
points at the repo's agents/ dir explicitly. No production code change.

Refs sdk/src/query/QUERY-HANDLERS.md ("subprocess vs in-process path
resolution") and CONTEXT.md DEFECT.PORT-DRIFT.cjs-sdk.

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

* fix(3577): Phase 6 config-* SDK port parity carve-outs

Restored the legacy contract for four CLI tests broken by the Phase 6
router migration:

1. `config-ensure-section` was bound to the new SDK `configEnsureSection`
   handler which requires `args[0]=sectionName`. Every real CLI caller
   uses the no-arg form expecting full default config.json creation.
   Reverted the dispatch case to call `config.cmdConfigEnsureSection`
   directly (matches the precedent in 7d5dfa9d for `codex` runtime).

2. SDK `configNewProject` `commit_docs` and `parallelization` defaults
   set to `true`/`true` (was `false`/`1`) — aligned with
   `sdk/shared/config-defaults.manifest.json` and the CJS
   `buildNewProjectConfig` `hardcoded` block.

3. SDK `configNewProject` returns the project-rooted relative path
   `.planning/config.json` instead of the absolute `paths.config`,
   matching the CJS `ensureConfigFile` output shape.

4. SDK error vocabulary aligned with CJS: `Unknown config key: <key>`
   (no surrounding quotes), and config-get's malformed-JSON message
   leads with `Failed to read config.json:` so legacy substring
   assertions in `tests/config.test.cjs` keep matching.

Local: 132/132 across `tests/{config,agent-skills,ai-evals}.test.cjs`.

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

* fix(3631): family routers forward --raw to SDK bridge as mode:'raw'

#3577 routed every family subcommand through the SDK bridge with a
hardcoded mode:'json'. With --raw set, the bridge returned the typed JSON
IR and routers called `output(result.data)` — bypassing output()'s
rawValue branch. Shell consumers expecting scalar tokens
(`gsd-tools phase next-decimal --raw 1` → `1.1`) received the JSON-
stringified IR instead.

Each `*-command-router.cjs` SDK dispatch path now requests
`mode: raw ? 'raw' : 'json'` from the bridge. The sync-bridge worker is
wired to `formatNativeRaw = formatQueryRawOutput` so the bridge returns
the per-command scalar projection. Routers route the formatted string
through `output(null, true, str)` (rawValue branch) so it lands on
stdout verbatim.

formatQueryRawOutput extended for the two commands covered by the issue
acceptance criteria — phase.next-decimal (→ data.next) and
roadmap.get-phase (→ data.section). Other registered raw projections
(state.load, commit, config-set, state.begin-phase) are unaffected; the
default `safeStringify` branch still applies to unprojected commands.

state-command-router already had a dispatchViaSdk helper that selected
mode based on a rawFormatter. The trailing fallthrough `output(result.data)`
when no rawFormatter was present is the same regression and was patched
to use the rawValue branch under --raw.

Regression test `tests/bug-3631-router-raw-flag.test.cjs` exercises
end-to-end:
  - `phase next-decimal --raw 1` emits a scalar phase token (not JSON).
  - `roadmap get-phase --raw 2` emits the section text (not JSON).

The fix targets `feat/3575-enforcement-hardening` (PR #3577, open) —
not origin/main as the issue body asserted. The #3577 regression lives
on that branch and the fix needs to land there before merge.

Fixes #3631

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

* test(3631): force CJS dispatch path in router unit tests via GSD_WORKSTREAM

phases-command-router.test.cjs and roadmap-command-router.test.cjs
mock the CJS-side `phase`/`milestone`/`roadmap` handlers and assert
they are called with the parsed args. Since #3577 the router prefers
SDK dispatch when sdk/dist is present — the mocks are then bypassed
and the SDK side fails because the test cwd `/tmp/proj` has no
`.planning/` fixture.

The router already gates SDK dispatch on `process.env.GSD_WORKSTREAM`
being unset (workstream-scoped requests fall through to CJS). Setting
GSD_WORKSTREAM in before()/after() deterministically routes through
the CJS handlers the tests were written against, without weakening
the assertions.

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

* fix(3632): report each ts sibling independently in lint-shared-module-handsync

The cooperatingPairs lookup ran inside `.some()` over all ts candidates for
a given cjs. When two ts siblings shared the same basename (e.g.
`sdk/src/foo.ts` and `sdk/src/query/foo.ts`) and only one pair was
allowlisted, `.some()` short-circuited and the unallowlisted sibling
silently passed through CI.

Classify each ts sibling independently against the allowlist so partially-
allowlisted multi-sibling drift surfaces. Added regression test
`reports unallowlisted ts sibling when another ts sibling for the same cjs
IS allowlisted (#3632)`.

Real-tree lint output unchanged on `feat/3575-enforcement-hardening`:
22 cooperating siblings, 0 unauthorized, 0 backlog pairs.

Fixes #3632

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

* fix(3577): ADR/PRD compliance + SDK port completeness for Phase 6

Multiple ADR/PRD violations in the Phase 6 cutover surfaced during
gsd-test-summary docker runs. Root causes traced to docs/adr/
3524-cjs-sdk-hard-seam.md §3 (out-of-seam module list) and
docs/prd/3524-cjs-sdk-hard-seam.md L160 (CJS-only verbs must not
route through the SDK runtime bridge), plus port-drift bugs the ADR
was specifically written to prevent (DEFECT.PORT-DRIFT.cjs-sdk).

Out-of-seam Module bindings removed from SDK catalog/manifests:
- verify.codebase-drift (drift is CJS-only; the SDK stub used
  execFileSync back to gsd-tools, recursing infinitely with the
  Phase 6 router rewrite — forked hundreds of node procs on the
  64 GiB plex2 docker host before manual kill)
- intel.* (8 verbs: diff, snapshot, validate, status, query,
  extract-exports, patch-meta, update — intel is CJS-only per ADR)
Both already have direct-CJS dispatch in gsd-tools.cjs (case
'intel') and verify-command-router.cjs (`'codebase-drift':` now
calls verify.cmdVerifyCodebaseDrift without going via sdkHandler).

config-ensure-section cutover restored via catalog rebind:
- 'config-ensure-section' in command-static-catalog-foundation.ts
  rebound from configEnsureSection (single-section semantics,
  requires args[0]=sectionName the CLI never passes) to
  configNewProject (whose no-args branch produces the full default
  config.json — matches the legacy ensureConfigFile contract).
- gsd-tools.cjs `case 'config-ensure-section'` restored to its
  Phase 6 _dispatchNonFamily form (no CJS fallback — the SDK
  handler now does the right thing).

configNewProject defaults from canonical manifest:
- Replaced the hardcoded duplicate `defaults` block with a
  derivation from CONFIG_DEFAULTS (sdk/src/configuration/index.ts,
  sourced from sdk/shared/config-defaults.manifest.json). The
  duplicate had drifted — omitted workflow.{ai_integration_phase,
  tdd_mode, human_verify_mode, pattern_mapper, plan_bounce*,
  auto_prune_state, subagent_timeout, security_*, post_planning_gaps},
  git.create_tag, claude_md_path, planning.*, graphify.*, mode,
  resolve_model_ids, context_window — every one of which had a
  test asserting the post-init value.

SDK configSet value-validation port (CJS cmdConfigSet parity):
- workflow.drift_action enum (warn|auto-remap)
- workflow.drift_threshold positive-integer
- workflow.human_verify_mode enum (mid-flight|end-of-phase)
- statusline.context_position enum (front|end)
- code_quality.fallow.scope enum (phase|repo)
- code_quality.fallow.profile enum (minimal|standard|strict)
- review.default_reviewers array shape + slug regex +
  lowercase-unique normalisation (matches
  bin/lib/review-reviewer-selection.cjs
  normalizeConfiguredDefaultReviewers, with the normalised value
  persisted to disk)

Init/roadmap/phase/workspace/frontmatter handler fixes:
- initExecutePhase + initPlanPhase parse --tdd boolean override
- initMapCodebase reads workflow.subagent_timeout with 300000
  default per manifest
- roadmapAnalyze surfaces `mode` per phase (parity with
  roadmapGetPhase)
- phaseComplete auto-prunes STATE.md when workflow.auto_prune_state
  is true (port of bin/lib/phase.cjs:1378-1390; #2087)
- initRemoveWorkspace throws GSDError on no-name and
  workspace-not-found instead of returning {data:{error}} which
  the CLI output path treated as success
- frontmatterGet parses --field <name> in addition to positional
  args[1]

Local: 150/150 across the failing-cluster test files
(review-default-reviewers-config, subagent-timeout, pattern-mapper,
tdd-mode, drift-detection, roadmap-mode-field, workspace,
phase-complete-auto-prune, frontmatter-cli). Docker gsd-test-summary
re-run in progress for full validation.

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

* fix(3577): clear 12 ubuntu-only regressions surfaced by gsd-test-summary

Docker test pass 3 (holodeck) surfaced 12 real bugs after the earlier
ADR/PRD-compliance commit (cf4dd0cb). Every one is a SDK-side bug —
fix-forward, not "pre-existing":

bug-3599 (2 subtests) — roadmap.get-phase project-code-prefix lookup:
  Ported phaseMarkdownRegexSourceExact from CJS (core.cjs:704-708) so
  `PROJ-42` queries try the exact escaped form FIRST before falling
  back to the padding-tolerant numeric. searchPhaseInContent now does
  two-pass lookup. Without this, `roadmap get-phase PROJ-42` returned
  not-found even when ROADMAP contains `### Phase PROJ-42:`, and
  bare `42` queries cross-matched the PROJ-42 heading.

roadmap-mode-field (1) — roadmapAnalyze surfaces `mode` per phase:
  Extracts the same `**Mode:**` field that roadmapGetPhase already
  parses (CONTEXT.md "MVP Mode" glossary). Without this, downstream
  consumers reading roadmap.analyze output couldn't tell which phases
  were MVP-mode.

bug-3601 (2 subtests) — phase.remove preserves peer-depth decimals:
  Ported the depth-aware end-of-section regex from CJS phase.cjs
  (named capture `(?<h>#{2,4})` + `\k<h>(?!#)` backreference). Now
  removing `### Phase 2:` stops at `### Phase 2.1:` (same depth, peer
  decimal) while continuing past `#### Phase 27.1:` (child depth).

bug-3602 (1 subtest) — phase.remove renumbers slugged plan refs:
  Extended the padded-plan-reference pattern with optional kebab-case
  slug segments `(?:-[A-Za-z][A-Za-z0-9-]*)*` between NN-NN and the
  PLAN/SUMMARY suffix, matching CJS phase.cjs:#3602 fix. Without this,
  `07-01-cherry-pick-foundation-PLAN.md` references stayed at `07-01-`
  after Phase 7 was removed, while the file on disk was already
  `06-01-...`.

config.test (1) — config-get git.base_branch returns "Key not found":
  configNewProject now filters out manifest keys legacy CJS init does
  NOT materialize: top-level `resolve_model_ids`, `context_window`,
  `mode`, `planning`, `graphify`; nested `git.base_branch`. These have
  their own resolution paths (origin/HEAD auto-detect for base_branch,
  feature opt-in for planning/graphify) and materializing the manifest
  defaults would suppress them. Manifest stays the schema source of
  truth per ADR §6; init shape stays minimal per legacy CJS contract.

gsd-sdk-query-registry-integration (1) — agents/gsd-intel-updater.md
references retargeted from `gsd-sdk query intel.*` to `gsd-tools intel
<subcommand>`. intel is out-of-seam per ADR §3 / PRD L160 ("CJS-only
Module handlers ... keep their in-process CJS implementations").
Removing the SDK catalog entries (cf4dd0cb) made the SDK route invalid;
the agent now correctly invokes the CJS handler via gsd-tools, which
routes through Shell Command Projection for cross-platform formatting.

Local: 79/79 across the failing test files. Docker re-run in progress.

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

* fix(3577): regenerate command-aliases + retarget workflow drift-gate

CI ubuntu-24 surfaced two remaining ADR-compliance gaps after the
previous push:

1. `sdk/src/query/command-aliases.generated.{ts,cjs}` still listed
   verify.codebase-drift + intel.{snapshot,patch-meta} from before the
   manifest-side removal. Ran `npx tsx sdk/scripts/gen-command-aliases.ts`
   to regenerate; both files now match the manifest source of truth.
   Closes the `command-seam-coverage.test.ts` "missing registry
   canonical verify.codebase-drift" failure (its assertion is correct —
   the SDK does NOT register codebase-drift, so the alias entry must
   not be present either).

2. `get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md`
   invoked `gsd-sdk query verify.codebase-drift` — drift is out-of-seam
   (CJS-only) per ADR §3 / PRD L160, so there is no SDK handler to
   route through. Retargeted to `gsd-tools verify codebase-drift` which
   dispatches direct to bin/lib/drift.cjs (the canonical implementation)
   via the CJS router. Closes the
   `gsd-sdk-query-registry-integration.test.cjs` failure.

Local: docker gsd-test-summary 11383/0 on plex2.

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

* fix(3575): raise Node heap for coverage in CI matrix

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: ci <ci@gsd-build>
2026-05-16 13:14:24 -04:00

2069 lines
82 KiB
TypeScript

/**
* Phase lifecycle handlers — add, insert, scaffold operations.
*
* Ported from get-shit-done/bin/lib/phase.cjs and commands.cjs.
* Provides phaseAdd (append phase), phaseAddBatch (append multiple phases),
* phaseInsert (decimal phase insertion), and phaseScaffold (template file/directory creation).
*
* Shared helpers replaceInCurrentMilestone and readModifyWriteRoadmapMd
* are exported for use by downstream handlers (phaseComplete in Plan 03).
*
* @example
* ```typescript
* import { phaseAdd, phaseInsert, phaseScaffold } from './phase-lifecycle.js';
*
* await phaseAdd(['New Feature'], '/project');
* await phaseInsert(['10', 'Urgent Fix'], '/project');
* await phaseScaffold(['context', '9'], '/project');
* ```
*/
import { readFile, writeFile, mkdir, readdir, rename, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, relative } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import {
escapeRegex,
normalizeMd,
normalizePhaseName,
comparePhaseNum,
phaseTokenMatches,
toPosixPath,
planningPaths,
} from './helpers.js';
import { extractFrontmatter } from './frontmatter.js';
import { extractCurrentMilestone, phaseMarkdownRegexSource } from './roadmap.js';
import { getMilestonePhaseFilter } from './state.js';
import { isCanonicalPlanFile, describeNonCanonicalPlans } from './phase.js';
import {
acquireStateLock,
readModifyWriteStateMdFull,
releaseStateLock,
stateReplaceField,
} from './state-mutation.js';
import { stateExtractField, stateReplaceFieldWithFallback } from './state-document.js';
import type { QueryHandler } from './utils.js';
import {
assertNoNullBytes,
assertSafePhaseDirName,
assertSafeProjectCode,
buildPhaseRoadmapEntry,
collectDecimalSuffixesFromDirNames,
collectDecimalSuffixesFromRoadmap,
computeNextDecimalPhase,
computeNextSequentialPhaseId,
computePhaseDirectory,
extractOneLinerFromBody,
generatePhaseSlug,
parseMultiwordArg,
} from './phase-lifecycle-policy.js';
import {
archiveDirectories,
ensureDirectoryWithGitkeep,
listDirectories,
} from './phase-filesystem-adapter.js';
import {
readModifyWriteRoadmapMd,
replaceInCurrentMilestone,
} from './phase-roadmap-mutation.js';
export { readModifyWriteRoadmapMd, replaceInCurrentMilestone };
// ─── phaseAdd handler ───────────────────────────────────────────────────
/**
* Query handler for phase.add.
*
* Port of cmdPhaseAdd from phase.cjs lines 312-392.
* Creates a new phase directory with .gitkeep, appends a phase section
* to ROADMAP.md before the last "---" separator.
*
* @param args - description (required), optional customId, optional --dry-run flag.
* Recognized flags: --dry-run (compute result without writing to disk).
* Any other --flag argument is rejected with a validation error.
* @param projectDir - Project root directory
* @returns QueryResult with { phase_number, padded, name, slug, directory, naming_mode }
* In --dry-run mode also includes { dry_run: true, roadmap_entry: string }
*/
export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
// ── Flag parsing ────────────────────────────────────────────────────────
// Mirrors the CJS phase add router (phase-command-router.cjs): recognise
// --dry-run and --id <value>; reject every other --flag; ignore --raw so it
// never leaks into the description; join the remaining positional tokens
// with a single space so multi-word descriptions like `phase add User
// Dashboard` produce description "User Dashboard". customId comes from the
// --id flag, never from positional[1].
let dryRun = false;
let customIdArg: string | null = null;
const positional: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--raw') {
// CJS router strips --raw before invoking the handler; preserve parity
// so a stray --raw never poisons the description.
continue;
}
if (arg === '--dry-run') {
dryRun = true;
continue;
}
if (arg === '--id') {
const id = args[i + 1];
if (!id || id.startsWith('--')) {
throw new GSDError('--id requires a value', ErrorClassification.Validation);
}
customIdArg = id;
i++;
continue;
}
if (arg.startsWith('--')) {
throw new GSDError(`phase add does not support ${arg}`, ErrorClassification.Validation);
}
positional.push(arg);
}
const description = positional.join(' ').trim();
if (!description) {
throw new GSDError('description required for phase add', ErrorClassification.Validation);
}
assertNoNullBytes(description, 'description');
const configPath = planningPaths(projectDir, workstream).config;
let config: Record<string, unknown> = {};
try {
config = JSON.parse(await readFile(configPath, 'utf-8'));
} catch { /* use defaults */ }
const slug = generatePhaseSlug(description);
// customId always comes from the --id flag; positional tokens are reserved
// for the description (which is joined above).
const customId = customIdArg;
// Optional project code prefix (e.g., 'CK' -> 'CK-01-foundation')
const projectCode = (config.project_code as string) || '';
assertSafeProjectCode(projectCode);
const prefix = projectCode ? `${projectCode}-` : '';
// ── Helper: compute newPhaseId / dirName / computedPhaseEntry from raw ROADMAP content ──
// Extracted as a local async function so it can be called both inside the
// roadmap lock (non-dry-run) and outside (dry-run, where no write occurs and
// there is no race condition to guard against).
const computePhaseFields = async (rawRoadmapContent: string) => {
const milestoneContent = await extractCurrentMilestone(rawRoadmapContent, projectDir);
const phasesDir = planningPaths(projectDir, workstream).phases;
const dirNames = await listDirectories(phasesDir);
const nextSequentialPhaseId = computeNextSequentialPhaseId(milestoneContent, dirNames);
const { phaseId: resolvedPhaseId, dirName: resolvedDirName } = computePhaseDirectory(
config.phase_naming,
slug,
prefix,
nextSequentialPhaseId,
customId || undefined,
);
if (!resolvedDirName) {
throw new GSDError('Phase directory name was not computed', ErrorClassification.Execution);
}
if (resolvedPhaseId === '') {
throw new GSDError('Phase ID was not computed', ErrorClassification.Execution);
}
const resolvedEntry = buildPhaseRoadmapEntry(resolvedPhaseId, description, config.phase_naming);
return { resolvedPhaseId, resolvedDirName, resolvedEntry };
};
let newPhaseId: number | string = '';
let dirName = '';
let computedPhaseEntry = '';
if (dryRun) {
// Dry-run: no write, no race condition — compute outside the lock.
const roadmapPath = planningPaths(projectDir, workstream).roadmap;
let rawRoadmapContent = '';
try {
rawRoadmapContent = await readFile(roadmapPath, 'utf-8');
} catch { /* ROADMAP.md may not exist yet */ }
const { resolvedPhaseId, resolvedDirName, resolvedEntry } = await computePhaseFields(rawRoadmapContent);
newPhaseId = resolvedPhaseId;
dirName = resolvedDirName;
computedPhaseEntry = resolvedEntry;
} else {
// Real write path: hold the roadmap lock across the entire read → compute → write
// cycle so that two concurrent phase.add calls cannot both observe the same
// maxPhase and produce duplicate phase IDs.
await readModifyWriteRoadmapMd(projectDir, async (roadmapRaw) => {
const { resolvedPhaseId, resolvedDirName, resolvedEntry } = await computePhaseFields(roadmapRaw);
newPhaseId = resolvedPhaseId;
dirName = resolvedDirName;
computedPhaseEntry = resolvedEntry;
const dirPath = join(planningPaths(projectDir, workstream).phases, dirName);
// Create directory with .gitkeep so git tracks empty folders
await ensureDirectoryWithGitkeep(dirPath);
// Find insertion point: before last "---" or at end
const lastSeparator = roadmapRaw.lastIndexOf('\n---');
if (lastSeparator > 0) {
return roadmapRaw.slice(0, lastSeparator) + computedPhaseEntry + roadmapRaw.slice(lastSeparator);
}
return roadmapRaw + computedPhaseEntry;
}, workstream);
}
const result: Record<string, unknown> = {
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
name: description,
slug,
directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir, workstream).phases, dirName))),
naming_mode: config.phase_naming || 'sequential',
};
if (dryRun) {
result.dry_run = true;
result.roadmap_entry = computedPhaseEntry;
}
return { data: result };
};
// ─── phaseAddBatch handler ────────────────────────────────────────────────
/**
* Query handler for phase.add-batch.
*
* Port of cmdPhaseAddBatch from phase.cjs lines 411-478.
* Appends multiple phases in one locked ROADMAP pass (sequential or custom naming).
*
* @param args - Either `--descriptions` followed by a JSON array string, or one description per arg (`--raw` ignored)
*/
export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream) => {
let descriptions: string[];
const descIdx = args.indexOf('--descriptions');
if (descIdx !== -1) {
// CJS router parity (phase-command-router.cjs): a dangling --descriptions
// or one whose value is another flag must surface the same JSON-array error
// string, not silently fall through to positional parsing or throw a
// different "valid JSON" variant.
const rawValue = args[descIdx + 1];
if (rawValue === undefined || rawValue.startsWith('--')) {
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
}
let parsed: unknown;
try {
parsed = JSON.parse(rawValue);
} catch {
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
}
if (!Array.isArray(parsed)) {
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
}
descriptions = parsed.map((x) => String(x));
} else {
descriptions = args.filter((a) => a !== '--raw');
}
if (descriptions.length === 0) {
throw new GSDError('descriptions array required for phase add-batch', ErrorClassification.Validation);
}
for (const d of descriptions) {
assertNoNullBytes(d, 'description');
if (!d.trim()) {
throw new GSDError('description must be non-empty', ErrorClassification.Validation);
}
}
const roadmapPath = planningPaths(projectDir, workstream).roadmap;
if (!existsSync(roadmapPath)) {
throw new GSDError('ROADMAP.md not found', ErrorClassification.Validation);
}
let config: Record<string, unknown> = {};
try {
config = JSON.parse(await readFile(planningPaths(projectDir, workstream).config, 'utf-8'));
} catch { /* use defaults */ }
const projectCode = (config.project_code as string) || '';
assertSafeProjectCode(projectCode);
const prefix = projectCode ? `${projectCode}-` : '';
const added: Array<{
phase_number: string | number;
padded: string;
name: string;
slug: string;
directory: string;
naming_mode: unknown;
}> = [];
await readModifyWriteRoadmapMd(projectDir, async (initialContent) => {
let rawContent = initialContent;
const content = await extractCurrentMilestone(rawContent, projectDir);
let maxPhase = 0;
if (config.phase_naming !== 'custom') {
const phasesOnDisk = planningPaths(projectDir, workstream).phases;
const dirNames = await listDirectories(phasesOnDisk);
maxPhase = computeNextSequentialPhaseId(content, dirNames) - 1;
}
for (const description of descriptions) {
const slug = generatePhaseSlug(description);
let newPhaseId: number | string;
let dirName: string;
if (config.phase_naming === 'custom') {
// Match CJS cmdPhaseAddBatch: slug.toUpperCase().replace(/-/g, '-') (identity on hyphens)
newPhaseId = slug.toUpperCase();
dirName = `${prefix}${newPhaseId}-${slug}`;
} else {
maxPhase += 1;
newPhaseId = maxPhase;
dirName = `${prefix}${String(newPhaseId).padStart(2, '0')}-${slug}`;
}
assertSafePhaseDirName(dirName);
const dirPath = join(planningPaths(projectDir, workstream).phases, dirName);
await ensureDirectoryWithGitkeep(dirPath);
const phaseEntry =
buildPhaseRoadmapEntry(newPhaseId, description, config.phase_naming);
const lastSeparator = rawContent.lastIndexOf('\n---');
rawContent =
lastSeparator > 0
? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator)
: rawContent + phaseEntry;
added.push({
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
name: description,
slug,
directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir, workstream).phases, dirName))),
naming_mode: config.phase_naming || 'sequential',
});
}
return rawContent;
}, workstream);
return { data: { phases: added, count: added.length } };
};
// ─── phaseInsert handler ────────────────────────────────────────────────
/**
* Query handler for phase.insert.
*
* Port of cmdPhaseInsert from phase.cjs lines 394-492.
* Creates a decimal phase directory after a target phase, inserting
* the phase section in ROADMAP.md after the target.
*
* @param args - args[0]: afterPhase (required), args[1]: description (required)
* @param projectDir - Project root directory
* @returns QueryResult with { phase_number, after_phase, name, slug, directory }
*/
export const phaseInsert: QueryHandler = async (args, projectDir, workstream) => {
// CJS router parity (phase-command-router.cjs): explicitly reject
// --dry-run (insert is destructive on disk + roadmap and has no preview
// path), strip --raw, and join all positional args after `afterPhase` into
// a single space-delimited description so `phase insert 1 Fix Critical Bug`
// produces description "Fix Critical Bug" instead of just "Fix".
const positional: string[] = [];
for (const arg of args) {
if (arg === '--dry-run') {
throw new GSDError('phase insert does not support --dry-run', ErrorClassification.Validation);
}
if (arg === '--raw') continue;
if (arg.startsWith('--')) {
throw new GSDError(`phase insert does not support ${arg}`, ErrorClassification.Validation);
}
positional.push(arg);
}
const afterPhase = positional[0];
const description = positional.slice(1).join(' ').trim();
if (!afterPhase || !description) {
throw new GSDError('after-phase and description required for phase insert', ErrorClassification.Validation);
}
assertNoNullBytes(afterPhase, 'afterPhase');
assertNoNullBytes(description, 'description');
const slug = generatePhaseSlug(description);
let decimalPhase = '';
let dirName = '';
await readModifyWriteRoadmapMd(projectDir, async (rawContent) => {
const content = await extractCurrentMilestone(rawContent, projectDir);
// Normalize input then strip leading zeros for flexible matching
const normalizedAfter = normalizePhaseName(afterPhase);
const unpadded = normalizedAfter.replace(/^0+/, '');
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
if (!targetPattern.test(content)) {
// Bug #3098 parity: when only the summary checklist exists for this
// phase (no `### Phase N:` detail section), point the user at the
// missing detail section rather than implying the phase is absent.
const checklistPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+0*${afterPhaseEscaped}:`,
'i',
);
if (checklistPattern.test(content)) {
throw new GSDError(
`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`,
ErrorClassification.Validation,
);
}
throw new GSDError(`Phase ${afterPhase} not found in ROADMAP.md`, ErrorClassification.Validation);
}
// Calculate next decimal by scanning both directories AND ROADMAP.md entries
const phasesDir = planningPaths(projectDir, workstream).phases;
const normalizedBase = normalizePhaseName(afterPhase);
const decimalSet = new Set<number>();
try {
const dirs = await listDirectories(phasesDir);
for (const suffix of collectDecimalSuffixesFromDirNames(normalizedBase, dirs)) {
decimalSet.add(suffix);
}
} catch { /* intentionally empty */ }
// Also scan ROADMAP.md content for decimal entries
for (const suffix of collectDecimalSuffixesFromRoadmap(normalizedBase, rawContent)) {
decimalSet.add(suffix);
}
decimalPhase = computeNextDecimalPhase(normalizedBase, decimalSet).next;
// Optional project code prefix
let insertConfig: Record<string, unknown> = {};
try {
insertConfig = JSON.parse(await readFile(planningPaths(projectDir, workstream).config, 'utf-8'));
} catch { /* use defaults */ }
const projectCode = (insertConfig.project_code as string) || '';
assertSafeProjectCode(projectCode);
const pfx = projectCode ? `${projectCode}-` : '';
dirName = `${pfx}${decimalPhase}-${slug}`;
assertSafePhaseDirName(dirName);
const dirPath = join(phasesDir, dirName);
// Create directory with .gitkeep
await ensureDirectoryWithGitkeep(dirPath);
// Build phase entry
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${decimalPhase} to break down)\n`;
// Insert after the target phase section
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
const headerMatch = rawContent.match(headerPattern);
if (!headerMatch) {
throw new GSDError(`Could not find Phase ${afterPhase} header`, ErrorClassification.Execution);
}
const headerIdx = rawContent.indexOf(headerMatch[0]);
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
let insertIdx: number;
if (nextPhaseMatch && nextPhaseMatch.index !== undefined) {
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
} else {
insertIdx = rawContent.length;
}
return rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
}, workstream);
if (!decimalPhase) {
throw new GSDError('Decimal phase was not computed', ErrorClassification.Execution);
}
if (!dirName) {
throw new GSDError('Phase directory name was not computed', ErrorClassification.Execution);
}
const result = {
phase_number: decimalPhase,
after_phase: afterPhase,
name: description,
slug,
directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir, workstream).phases, dirName))),
};
return { data: result };
};
// ─── phaseScaffold handler ──────────────────────────────────────────────
/**
* Internal helper: find phase directory matching a phase identifier.
*
* Reuses the same logic as findPhase handler but returns just the directory info.
*/
async function findPhaseDir(
projectDir: string,
phase: string,
workstream?: string,
): Promise<{ dirPath: string; dirName: string; phaseName: string | null } | null> {
const phasesDir = planningPaths(projectDir, workstream).phases;
const normalized = normalizePhaseName(phase);
const dirs = await listDirectories(phasesDir);
const match = dirs.find((d) => phaseTokenMatches(d, normalized));
if (!match) return null;
// Extract phase name from directory
const dirMatch = match.match(/^(?:[A-Z]{1,6}-)?\d+[A-Z]?(?:\.\d+)*-(.+)/i);
const phaseName = dirMatch ? dirMatch[1] : null;
return {
dirPath: join(phasesDir, match),
dirName: match,
phaseName,
};
}
/**
* Query handler for phase.scaffold.
*
* Port of cmdScaffold from commands.cjs lines 750-806.
* Creates template files (context, uat, verification) or phase directories.
*
* @param args - Positional `[type, phase, name?]` **or** gsd-tools style
* `[type, '--phase', N, '--name', title]` (name may be multiple words).
* @param projectDir - Project root directory
* @returns QueryResult with { created, path } or { created: false, reason: 'already_exists' }
*/
function normalizeScaffoldArgs(args: string[]): string[] {
const type = args[0];
if (!type || !args.includes('--phase')) {
return args;
}
const phaseIdx = args.indexOf('--phase');
const phase = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
? args[phaseIdx + 1]
: '';
const nameIdx = args.indexOf('--name');
let name: string | undefined;
if (nameIdx !== -1) {
const tail = args.slice(nameIdx + 1);
const stop = tail.findIndex(a => a.startsWith('--'));
const parts = stop === -1 ? tail : tail.slice(0, stop);
name = parts.join(' ').trim() || undefined;
}
return [type, phase, ...(name !== undefined && name !== '' ? [name] : [])];
}
export const phaseScaffold: QueryHandler = async (args, projectDir, workstream) => {
const normalized = normalizeScaffoldArgs(args);
const type = normalized[0];
const phase = normalized[1];
const name = normalized[2] || undefined;
if (!type) {
throw new GSDError('type required for scaffold', ErrorClassification.Validation);
}
const validTypes = new Set(['context', 'uat', 'verification', 'phase-dir']);
if (!validTypes.has(type)) {
throw new GSDError(
`Unknown scaffold type: ${type}. Available: context, uat, verification, phase-dir`,
ErrorClassification.Validation,
);
}
if (phase) {
assertNoNullBytes(phase, 'phase');
}
if (name) {
assertNoNullBytes(name, 'name');
}
const padded = phase ? normalizePhaseName(phase) : '00';
const today = new Date().toISOString().split('T')[0];
// Handle phase-dir type separately
if (type === 'phase-dir') {
if (!phase || !name) {
throw new GSDError('phase and name required for phase-dir scaffold', ErrorClassification.Validation);
}
const slug = generatePhaseSlug(name);
// #3287: apply project_code prefix to stay consistent with phase.add/phase.insert
let scaffoldConfig: Record<string, unknown> = {};
try {
scaffoldConfig = JSON.parse(await readFile(planningPaths(projectDir, workstream).config, 'utf-8'));
} catch { /* use defaults */ }
const scaffoldProjectCode = (scaffoldConfig.project_code as string) || '';
assertSafeProjectCode(scaffoldProjectCode);
const scaffoldPrefix = scaffoldProjectCode ? `${scaffoldProjectCode}-` : '';
const dirNameNew = `${scaffoldPrefix}${padded}-${slug}`;
assertSafePhaseDirName(dirNameNew, 'scaffold phase directory');
const phasesParent = planningPaths(projectDir, workstream).phases;
await mkdir(phasesParent, { recursive: true });
const dirPath = join(phasesParent, dirNameNew);
await ensureDirectoryWithGitkeep(dirPath);
return {
data: {
created: true,
directory: toPosixPath(relative(projectDir, dirPath)),
path: dirPath,
},
};
}
// For context/uat/verification types, find the phase directory
const phaseInfo = phase ? await findPhaseDir(projectDir, phase, workstream) : null;
if (phase && !phaseInfo) {
throw new GSDError(`Phase ${phase} directory not found`, ErrorClassification.Blocked);
}
const phaseDir = phaseInfo!.dirPath;
const phaseName = name || phaseInfo?.phaseName || 'Unnamed';
let filePath: string;
let content: string;
switch (type) {
case 'context': {
filePath = join(phaseDir, `${padded}-CONTEXT.md`);
content = `---\nphase: "${padded}"\nname: "${phaseName}"\ncreated: ${today}\n---\n\n# Phase ${phase}: ${phaseName} — Context\n\n## Decisions\n\n_Decisions will be captured during /gsd-discuss-phase ${phase}_\n\n## Discretion Areas\n\n_Areas where the executor can use judgment_\n\n## Deferred Ideas\n\n_Ideas to consider later_\n`;
break;
}
case 'uat': {
filePath = join(phaseDir, `${padded}-UAT.md`);
content = `---\nphase: "${padded}"\nname: "${phaseName}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${phaseName} — User Acceptance Testing\n\n## Test Results\n\n| # | Test | Status | Notes |\n|---|------|--------|-------|\n\n## Summary\n\n_Pending UAT_\n`;
break;
}
case 'verification': {
filePath = join(phaseDir, `${padded}-VERIFICATION.md`);
content = `---\nphase: "${padded}"\nname: "${phaseName}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${phaseName} — Verification\n\n## Goal-Backward Verification\n\n**Phase Goal:** [From ROADMAP.md]\n\n## Checks\n\n| # | Requirement | Status | Evidence |\n|---|------------|--------|----------|\n\n## Result\n\n_Pending verification_\n`;
break;
}
default:
throw new GSDError(`Unknown scaffold type: ${type}`, ErrorClassification.Validation);
}
// Check if file already exists
if (existsSync(filePath)) {
return {
data: {
created: false,
reason: 'already_exists',
path: filePath,
},
};
}
await writeFile(filePath, content, 'utf-8');
const relPath = toPosixPath(relative(projectDir, filePath));
return { data: { created: true, path: relPath } };
};
// ─── renameDecimalPhases ───────────────────────────────────────────────
/**
* Renumber sibling decimal phases after a decimal phase is removed.
*
* Port of renameDecimalPhases from phase.cjs lines 499-524.
* e.g. removing 06.2 -> 06.3 becomes 06.2, 06.4 becomes 06.3, etc.
* Renames directories AND files inside them that contain the old phase ID.
*
* CRITICAL: Sorted in DESCENDING order to avoid rename conflicts.
*
* @param phasesDir - Path to the phases directory
* @param baseInt - The integer part of the decimal phase (e.g. "06")
* @param removedDecimal - The decimal part that was removed (e.g. 2 for 06.2)
* @returns { renamedDirs, renamedFiles }
*/
async function renameDecimalPhases(
phasesDir: string,
baseInt: string,
removedDecimal: number,
): Promise<{ renamedDirs: Array<{ from: string; to: string }>; renamedFiles: Array<{ from: string; to: string }> }> {
const renamedDirs: Array<{ from: string; to: string }> = [];
const renamedFiles: Array<{ from: string; to: string }> = [];
const decPattern = new RegExp(`^${escapeRegex(baseInt)}\\.(\\d+)-(.+)$`);
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const toRename = dirs
.map(dir => {
const m = dir.match(decPattern);
return m ? { dir, oldDecimal: parseInt(m[1], 10), slug: m[2] } : null;
})
.filter((item): item is NonNullable<typeof item> => item !== null && item.oldDecimal > removedDecimal)
.sort((a, b) => b.oldDecimal - a.oldDecimal); // DESCENDING to avoid conflicts
for (const item of toRename) {
const newDecimal = item.oldDecimal - 1;
const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
const newPhaseId = `${baseInt}.${newDecimal}`;
const newDirName = `${baseInt}.${newDecimal}-${item.slug}`;
await rename(join(phasesDir, item.dir), join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
// Rename files inside that contain the old phase ID
const files = await readdir(join(phasesDir, newDirName));
for (const f of files) {
if (f.includes(oldPhaseId)) {
const newFileName = f.replace(oldPhaseId, newPhaseId);
await rename(join(phasesDir, newDirName, f), join(phasesDir, newDirName, newFileName));
renamedFiles.push({ from: f, to: newFileName });
}
}
}
return { renamedDirs, renamedFiles };
}
// ─── renameIntegerPhases ───────────────────────────────────────────────
/**
* Renumber all integer phases after a removed integer phase.
*
* Port of renameIntegerPhases from phase.cjs lines 531-564.
* e.g. removing phase 5 -> phase 6 becomes 5, phase 7 becomes 6, etc.
* Handles letter suffixes (12A) and decimals (6.1).
*
* CRITICAL: Sorted in DESCENDING order to avoid rename conflicts.
*
* @param phasesDir - Path to the phases directory
* @param removedInt - The integer phase number that was removed
* @returns { renamedDirs, renamedFiles }
*/
async function renameIntegerPhases(
phasesDir: string,
removedInt: number,
): Promise<{ renamedDirs: Array<{ from: string; to: string }>; renamedFiles: Array<{ from: string; to: string }> }> {
const renamedDirs: Array<{ from: string; to: string }> = [];
const renamedFiles: Array<{ from: string; to: string }> = [];
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const toRename = dirs
.map(dir => {
const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
if (!m) return null;
const dirInt = parseInt(m[1], 10);
// CJS parity: skip backlog phases (999.x). These are parked ideas with a
// numbering convention that lives outside the active sequence; renumbering
// them would clobber the convention and corrupt downstream lookups.
// (bug-2434)
if (dirInt <= removedInt || dirInt >= 999) return null;
return {
dir,
oldInt: dirInt,
letter: m[2] ? m[2].toUpperCase() : '',
decimal: m[3] !== undefined ? parseInt(m[3], 10) : null,
slug: m[4],
};
})
.filter((item): item is NonNullable<typeof item> => item !== null)
.sort((a, b) => a.oldInt !== b.oldInt
? b.oldInt - a.oldInt
: (b.decimal ?? 0) - (a.decimal ?? 0)); // DESCENDING
for (const item of toRename) {
const newInt = item.oldInt - 1;
const newPadded = String(newInt).padStart(2, '0');
const oldPadded = String(item.oldInt).padStart(2, '0');
const letterSuffix = item.letter || '';
const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
const newDirName = `${newPrefix}-${item.slug}`;
await rename(join(phasesDir, item.dir), join(phasesDir, newDirName));
renamedDirs.push({ from: item.dir, to: newDirName });
// Rename files that start with the old prefix
const files = await readdir(join(phasesDir, newDirName));
for (const f of files) {
if (f.startsWith(oldPrefix)) {
const newFileName = newPrefix + f.slice(oldPrefix.length);
await rename(join(phasesDir, newDirName, f), join(phasesDir, newDirName, newFileName));
renamedFiles.push({ from: f, to: newFileName });
}
}
}
return { renamedDirs, renamedFiles };
}
// ─── updateRoadmapAfterPhaseRemoval ────────────────────────────────────
/**
* Decrement integer phase number while skipping non-renumbered ranges. Mirrors
* `decrementRoadmapPhaseNumber` in phase.cjs lines 860-864.
*
* Skips when:
* • not an integer
* • num <= removedInt (already-renumbered phases stay put)
* • num >= 999 (backlog/parked-idea numbering range)
*
* Returns the original raw string when the guards trip so the regex pass
* leaves dates and unrelated numerics intact.
*/
function decrementRoadmapPhaseNumber(raw: string, removedInt: number): string {
const num = parseInt(raw, 10);
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
return String(num - 1);
}
/**
* Decrement integer or decimal phase token (e.g. "5" or "5.2"). Mirrors
* `decrementRoadmapPhaseToken` in phase.cjs lines 866-872 — preserves the
* decimal suffix when present and applies the same guards.
*/
function decrementRoadmapPhaseToken(raw: string, removedInt: number): string {
const match = String(raw).match(/^(\d+)(\.\d+)?$/);
if (!match) return raw;
const num = parseInt(match[1]!, 10);
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
return `${num - 1}${match[2] || ''}`;
}
/**
* Decrement zero-padded phase number while preserving the original pad width.
* Mirrors `decrementRoadmapPaddedPhaseNumber` in phase.cjs lines 874-878.
*/
function decrementRoadmapPaddedPhaseNumber(raw: string, removedInt: number): string {
const num = parseInt(raw, 10);
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
return String(num - 1).padStart(raw.length, '0');
}
/**
* Remove a phase section from ROADMAP.md and renumber subsequent integer phases.
*
* Port of updateRoadmapAfterPhaseRemoval from phase.cjs lines 880-922.
* Uses readModifyWriteRoadmapMd for atomic writes.
*
* The renumbering pass uses **5 targeted regex replacements** (not a loop)
* because the loop approach is dangerous:
* • It can match YYYY-MM-DD substrings and corrupt dates (bug-2435).
* • It can rename backlog phases (999.x) that should stay frozen (bug-2434).
* • It can renumber the same phase multiple times if the regex matches
* overlap (bug-3355 — phase 7 → 6 → 5 → ...).
*
* The CJS pattern uses negative lookbehind/ahead on the padded-prefix regex
* to skip dates and decrement helpers that guard against `num >= 999`. Keep
* this implementation byte-for-byte in lockstep with phase.cjs:880-922 —
* deviations are how the three bugs above slipped in.
*
* @param projectDir - Project root directory
* @param targetPhase - Phase identifier that was removed
* @param isDecimal - Whether the removed phase was a decimal phase
* @param removedInt - The integer part of the removed phase
*/
async function updateRoadmapAfterPhaseRemoval(
projectDir: string,
targetPhase: string,
isDecimal: boolean,
removedInt: number,
workstream?: string,
): Promise<void> {
await readModifyWriteRoadmapMd(projectDir, (content) => {
const escaped = escapeRegex(targetPhase);
// Remove the phase section (header + body until next phase header or end).
//
// #3601: the end-of-section lookahead is DEPTH-AWARE. The named capture
// (?<h>#{2,4}) records the hash count of the header being removed and the
// lookahead requires the same depth via \k<h>(?!#). Two contracts are
// preserved:
//
// (#3601 case) Remove `### Phase 2:` and stop at `### Phase 2.1:` —
// Phase 2.1 is a peer-level decimal phase (depth 3) and must survive.
//
// (#3355 case) Remove `### Phase 27:` and CONTINUE past
// `#### Phase 27.1:` (depth 4 — child of Phase 27) until the next
// depth-3 header. The child decimal is part of the integer phase
// being removed.
//
// The `(?!#)` negative lookahead after the backreference prevents the
// depth-3 match from being satisfied by a depth-4+ header that starts
// with the same three hashes. `[^\n:]+` accepts numeric, decimal, AND
// custom phase IDs (PROJ-42) as terminators.
content = content.replace(
new RegExp(
`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`,
'i',
),
'',
);
// Remove checkbox lines referencing the phase
content = content.replace(
new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'),
'',
);
// Remove table rows referencing the phase
content = content.replace(
new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'),
'',
);
if (!isDecimal) {
// Phase headers: ### Phase N: / ### Phase N.M:
content = content.replace(
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi,
(_match, prefix: string, num: string, suffix: string) =>
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`,
);
// Checkbox-list summary references: `- [ ] Phase N:`
content = content.replace(
/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi,
(_match, prefix: string, num: string, suffix: string) =>
`${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`,
);
// Table-row phase numbers: `| N. ` — bare integer in a cell.
content = content.replace(
/(\|\s*)(\d+)(\.\s)/g,
(_match, prefix: string, num: string, suffix: string) =>
`${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`,
);
// Padded plan references: NN-NN (optionally followed by an arbitrary
// kebab-case slug, then -PLAN.md / -SUMMARY.md).
//
// #2435: negative lookbehind `(?<![0-9-])` and negative lookahead
// `(?![0-9-])` exclude YYYY-MM-DD substrings.
//
// #3602: the original pattern only allowed a compact `-(PLAN|SUMMARY).md`
// immediately after the plan number; a slug between the number and the
// `-PLAN.md` / `-SUMMARY.md` suffix (e.g.
// `07-01-cherry-pick-foundation-PLAN.md`) made the lookahead fail and
// left the stale `07-01-` prefix in ROADMAP text while the on-disk file
// was already renumbered to `06-01-…`. The slug segment
// `(?:-[A-Za-z][A-Za-z0-9-]*)*` allows any number of kebab-case tokens
// before the canonical PLAN/SUMMARY suffix.
content = content.replace(
/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)*-(?:PLAN|SUMMARY)\.md)?(?![0-9-]))/g,
(_match, phaseNum: string, planNum: string) =>
`${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`,
);
// Depends-on references — two bold-colon variants in the wild.
content = content.replace(
/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
(_match, prefix: string, num: string) =>
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`,
);
content = content.replace(
/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
(_match, prefix: string, num: string) =>
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`,
);
}
return content;
}, workstream);
}
// ─── phaseRemove handler ───────────────────────────────────────────────
/**
* Query handler for phase.remove.
*
* Port of cmdPhaseRemove from phase.cjs lines 597-661.
* Deletes phase directory, renumbers subsequent phases on disk,
* updates ROADMAP.md (removes section + renumbers), and decrements
* STATE.md total_phases count.
*
* @param args - args[0]: targetPhase (required), args[1]: '--force' (optional)
* @param projectDir - Project root directory
* @returns QueryResult with { removed, directory_deleted, renamed_directories, renamed_files, roadmap_updated, state_updated }
*/
export const phaseRemove: QueryHandler = async (args, projectDir, workstream) => {
let force = false;
const positional: string[] = [];
for (const token of args) {
if (token === '--force') {
force = true;
continue;
}
if (token.startsWith('--')) {
throw new GSDError(`phase remove does not support ${token}`, ErrorClassification.Validation);
}
positional.push(token);
}
if (positional.length > 1) {
throw new GSDError('phase remove accepts exactly one phase number', ErrorClassification.Validation);
}
const targetPhase = positional[0];
if (!targetPhase) {
throw new GSDError('phase number required for phase remove', ErrorClassification.Validation);
}
assertNoNullBytes(targetPhase, 'targetPhase');
const paths = planningPaths(projectDir, workstream);
const phasesDir = paths.phases;
if (!existsSync(paths.roadmap)) {
throw new GSDError('ROADMAP.md not found', ErrorClassification.Validation);
}
const normalized = normalizePhaseName(targetPhase);
const isDecimal = targetPhase.includes('.');
// Find target directory
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const targetDir = dirs.find(d => phaseTokenMatches(d, normalized)) ?? null;
if (!targetDir) {
throw new GSDError(`Phase ${targetPhase} not found`, ErrorClassification.Validation);
}
// Guard against removing executed work
if (!force) {
const files = await readdir(join(phasesDir, targetDir));
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
if (summaries.length > 0) {
throw new GSDError(
`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`,
ErrorClassification.Validation,
);
}
}
// Delete directory
await rm(join(phasesDir, targetDir), { recursive: true, force: true });
// Renumber subsequent phases on disk
let renamedDirs: Array<{ from: string; to: string }> = [];
let renamedFiles: Array<{ from: string; to: string }> = [];
try {
let renamed: { renamedDirs: Array<{ from: string; to: string }>; renamedFiles: Array<{ from: string; to: string }> };
if (isDecimal) {
const parts = normalized.split('.');
if (parts.length < 2 || !parts[1]) {
throw new GSDError(`Invalid decimal phase identifier: ${targetPhase}`, ErrorClassification.Validation);
}
const decimalPart = parseInt(parts[1], 10);
if (isNaN(decimalPart)) {
throw new GSDError(`Invalid decimal part in phase: ${targetPhase}`, ErrorClassification.Validation);
}
renamed = await renameDecimalPhases(phasesDir, parts[0], decimalPart);
} else {
renamed = await renameIntegerPhases(phasesDir, parseInt(normalized, 10));
}
renamedDirs = renamed.renamedDirs;
renamedFiles = renamed.renamedFiles;
} catch { /* intentionally empty — renaming is best-effort */ }
// Update ROADMAP.md
await updateRoadmapAfterPhaseRemoval(projectDir, targetPhase, isDecimal, parseInt(normalized, 10), workstream);
// Update STATE.md: decrement total_phases
let stateUpdated = false;
const statePath = paths.state;
if (existsSync(statePath)) {
const lockPath = await acquireStateLock(statePath);
try {
let stateContent = await readFile(statePath, 'utf-8');
// Decrement total_phases in frontmatter
const totalPhasesMatch = stateContent.match(/total_phases:\s*(\d+)/);
if (totalPhasesMatch) {
const oldTotal = parseInt(totalPhasesMatch[1], 10);
stateContent = stateContent.replace(
/total_phases:\s*\d+/,
`total_phases: ${oldTotal - 1}`,
);
}
// Decrement "of N" pattern in body (e.g., "Plan: 2 of 3")
const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
if (ofMatch) {
stateContent = stateContent.replace(
/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i,
`$1${parseInt(ofMatch[2], 10) - 1}$3`,
);
}
// Also try stateReplaceField for "Total Phases" field
const totalRaw = stateExtractField(stateContent, 'Total Phases');
if (totalRaw) {
const replaced = stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1));
if (replaced) stateContent = replaced;
}
await writeFile(statePath, stateContent, 'utf-8');
stateUpdated = true;
} finally {
await releaseStateLock(lockPath);
}
}
return {
data: {
removed: targetPhase,
directory_deleted: targetDir,
renamed_directories: renamedDirs,
renamed_files: renamedFiles,
roadmap_updated: true,
state_updated: stateUpdated,
},
};
};
// ─── updatePerformanceMetricsSection ───────────────────────────────────────
/**
* Update the Performance Metrics section in STATE.md content.
*
* Port of updatePerformanceMetricsSection from state.cjs lines 1125-1156.
* Updates "Total plans completed" counter and upserts a row in the By Phase table.
*
* @param content - STATE.md content
* @param phaseNum - Phase number being completed
* @param planCount - Total number of plans in the phase
* @param summaryCount - Number of completed summaries
* @returns Modified content
*/
function updatePerformanceMetricsSection(
content: string,
phaseNum: string,
planCount: number,
summaryCount: number,
): string {
// Update Velocity: Total plans completed
const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/);
const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0;
const newTotal = prevTotal + summaryCount;
content = content.replace(
/Total plans completed:\s*(\d+|\[N\])/,
`Total plans completed: ${newTotal}`,
);
// Update By Phase table — upsert row for this phase
const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i;
const byPhaseMatch = content.match(byPhaseTablePattern);
if (byPhaseMatch) {
let tableBody = byPhaseMatch[2].trim();
const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm');
const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
if (phaseRowPattern.test(tableBody)) {
// Update existing row
tableBody = tableBody.replace(new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm'), newRow);
} else {
// Remove placeholder row and add new row
tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
}
content = content.replace(byPhaseTablePattern, `$1${tableBody}\n`);
}
return content;
}
// ─── phaseComplete handler ────────────────────────────────────────────────
/**
* Query handler for phase.complete.
*
* Port of cmdPhaseComplete from phase.cjs lines 663-932.
* Marks a phase as done — updates ROADMAP.md (checkbox, progress table,
* plan count, plan checkboxes), REQUIREMENTS.md (requirement checkboxes,
* traceability table), and STATE.md (current phase, status, progress,
* performance metrics) atomically with per-file locks.
*
* @param args - args[0]: phaseNum (required)
* @param projectDir - Project root directory
* @returns QueryResult with completion details and warnings
*/
export const phaseComplete: QueryHandler = async (args, projectDir, workstream) => {
const phaseNum = args[0];
if (!phaseNum) {
throw new GSDError('phase number required for phase complete', ErrorClassification.Validation);
}
assertNoNullBytes(phaseNum, 'phaseNum');
const paths = planningPaths(projectDir, workstream);
const today = new Date().toISOString().split('T')[0];
// Step A: Validate phase exists and get info
const phaseInfo = await findPhaseDir(projectDir, phaseNum, workstream);
if (!phaseInfo) {
throw new GSDError(`Phase ${phaseNum} not found`, ErrorClassification.Validation);
}
const phaseDir = phaseInfo.dirPath;
let phaseFiles: string[];
try {
phaseFiles = await readdir(phaseDir);
} catch {
phaseFiles = [];
}
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const planCount = plans.length;
const summaryCount = summaries.length;
let requirementsUpdated = false;
// Step B: Check for verification warnings (non-blocking)
const warnings: string[] = [];
for (const file of phaseFiles.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
try {
const content = await readFile(join(phaseDir, file), 'utf-8');
if (/result: pending/.test(content)) warnings.push(`${file}: has pending tests`);
if (/result: blocked/.test(content)) warnings.push(`${file}: has blocked tests`);
if (/status: partial/.test(content)) warnings.push(`${file}: testing incomplete (partial)`);
if (/status: diagnosed/.test(content)) warnings.push(`${file}: has diagnosed gaps`);
} catch { /* intentionally empty */ }
}
for (const file of phaseFiles.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
try {
const content = await readFile(join(phaseDir, file), 'utf-8');
if (/status: human_needed/.test(content)) warnings.push(`${file}: needs human verification`);
if (/status: gaps_found/.test(content)) warnings.push(`${file}: has unresolved gaps`);
} catch { /* intentionally empty */ }
}
// Step C: Update ROADMAP.md atomically
if (existsSync(paths.roadmap)) {
await readModifyWriteRoadmapMd(projectDir, async (roadmapContent) => {
// Padding-tolerant fragment so a padded input like "02.7" still matches
// un-padded ROADMAP prose ("### Phase 2.7:"). CJS routes every phase-
// number ROADMAP regex through phaseMarkdownRegexSource (#3537) —
// mirror that contract here so phase.complete with the padded form
// produces the same ROADMAP as the un-padded form.
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
// Checkbox: - [ ] Phase N: -> - [x] Phase N: (...completed DATE)
// CJS parity (phase.cjs): direct replace, NOT scoped through
// replaceInCurrentMilestone. Same reasoning as the plan-count
// update below — milestone wrapped in <details> would otherwise be
// skipped (bug-2005).
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
'i',
);
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
// Progress table: update Status to Complete, add date
const tableRowPattern = new RegExp(
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
'im',
);
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
const cells = fullRow.split('|').slice(1, -1);
if (cells.length === 5) {
cells[2] = ` ${summaryCount}/${planCount} `;
cells[3] = ' Complete ';
cells[4] = ` ${today} `;
} else if (cells.length === 4) {
cells[1] = ` ${summaryCount}/${planCount} `;
cells[2] = ' Complete ';
cells[3] = ` ${today} `;
}
return '|' + cells.join('|') + '|';
});
// Update plan count in phase section.
// CJS parity (phase.cjs:1076-1083): direct replace, NOT scoped through
// replaceInCurrentMilestone. Scoping to "after last </details>" fails
// when the current milestone itself is wrapped in <details open>...
// </details> — there's no content after the close tag, so the regex
// never matches and **Plans:** stays at 0/N (bug-2005).
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`,
'i',
);
roadmapContent = roadmapContent.replace(
planCountPattern,
`$1${summaryCount}/${planCount} plans complete`,
);
// Mark completed plan checkboxes
for (const summaryFile of summaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
'i',
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
// Step D: Update REQUIREMENTS.md
const reqPath = paths.requirements;
if (existsSync(reqPath)) {
const currentMilestoneRoadmap = await extractCurrentMilestone(roadmapContent, projectDir);
const phaseSectionMatch = currentMilestoneRoadmap.match(
new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i'),
);
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
const reqMatch = sectionText.match(/\*\*Requirements\*?\*?:?\s*([^\n]+)/i);
let reqContent = await readFile(reqPath, 'utf-8');
let reqContentChanged = false;
if (reqMatch) {
const reqIds = reqMatch[1].replace(/[[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
for (const reqId of reqIds) {
const reqEscaped = escapeRegex(reqId);
const before = reqContent;
// Update checkbox: - [ ] **REQ-ID** -> - [x] **REQ-ID**
reqContent = reqContent.replace(
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
'$1x$2',
);
// Update traceability table: Pending/In Progress -> Complete
reqContent = reqContent.replace(
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
'$1 Complete $2',
);
if (reqContent !== before) reqContentChanged = true;
}
}
// Bug #2526 parity (phase.cjs:1140-1167): independent of whether the
// roadmap declared a Requirements: line, scan the REQUIREMENTS.md
// body for `**REQ-ID**` references and compare against the IDs that
// actually appear in the Traceability table. Surface every body
// ID that has no traceability row so the operator can keep the
// table in sync.
const bodyReqIds: string[] = [];
const bodyReqPattern = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
let bodyMatch: RegExpExecArray | null;
while ((bodyMatch = bodyReqPattern.exec(reqContent)) !== null) {
if (!bodyReqIds.includes(bodyMatch[1]!)) bodyReqIds.push(bodyMatch[1]!);
}
const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);
const traceabilitySection = traceabilityHeadingMatch
? reqContent.slice(traceabilityHeadingMatch.index!)
: '';
const tableReqIds = new Set<string>();
const tableRowPattern = /^\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/gm;
let tableMatch: RegExpExecArray | null;
while ((tableMatch = tableRowPattern.exec(traceabilitySection)) !== null) {
tableReqIds.add(tableMatch[1]!);
}
const unregistered = bodyReqIds.filter((id) => !tableReqIds.has(id));
if (unregistered.length > 0) {
warnings.push(
`REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`,
);
}
if (reqContentChanged) {
await writeFile(reqPath, reqContent, 'utf-8');
requirementsUpdated = true;
}
}
return roadmapContent;
}, workstream);
}
// Step E: Find next phase — filesystem first, then ROADMAP.md fallback
let nextPhaseNum: string | null = null;
let nextPhaseName: string | null = null;
let isLastPhase = true;
// Tracks whether the completed phase belongs to the primary milestone in STATE.md.
// When false (parallel-milestone case, Bug #2676), the milestone filter is bypassed
// for next-phase detection so phases from the same secondary milestone are visible.
let completedPhaseInPrimaryMilestone = true;
try {
const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream);
const entries = await readdir(paths.phases, { withFileTypes: true });
const allDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
// Guard: if the completed phase's directory is not in the current-milestone filter
// set, the filter was built from a different (primary) milestone in STATE.md.
// In that case skip the filter so we can find the true next phase on disk.
// This handles parallel-milestone workflows where STATE.md's `milestone:` field
// points at the primary milestone but the phase being completed belongs to a
// secondary in-flight milestone. (Bug #2676)
const completedDirInFilter = allDirs.some((d) => {
const dm = d.match(/^(\d+[A-Z]?(?:\.\d+)*)-?/i);
return dm && comparePhaseNum(dm[1], phaseNum) === 0 && isDirInMilestone(d);
});
completedPhaseInPrimaryMilestone = completedDirInFilter;
const effectiveFilter = completedDirInFilter ? isDirInMilestone : (_d: string) => true;
const dirs = allDirs
.filter(effectiveFilter)
.sort((a, b) => comparePhaseNum(a, b));
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
if (dm) {
// Bug #2129 parity: skip backlog phases (999.x). They are parked
// ideas with reserved numbering, not part of the active sequence.
// Without this, completing phase 2 in a project that has a 999.1
// backlog directory would jump next_phase to 999.1 instead of the
// intended Phase 3 from ROADMAP.
if (/^999(?:\.|$)/.test(dm[1]!)) continue;
if (comparePhaseNum(dm[1], phaseNum) > 0) {
nextPhaseNum = dm[1];
nextPhaseName = dm[2] || null;
isLastPhase = false;
break;
}
}
}
} catch { /* intentionally empty */ }
// Fallback: check ROADMAP.md for phases not yet scaffolded.
// When the completed phase is from a parallel (non-primary) milestone, scan the
// full ROADMAP rather than the primary-milestone slice so 41.3 is visible when
// completing 41.2 for a secondary milestone. (Bug #2676)
if (isLastPhase && existsSync(paths.roadmap)) {
try {
const roadmapContent = await readFile(paths.roadmap, 'utf-8');
const roadmapForPhases = completedPhaseInPrimaryMilestone
? await extractCurrentMilestone(roadmapContent, projectDir)
: roadmapContent;
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
let pm: RegExpExecArray | null;
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
if (comparePhaseNum(pm[1], phaseNum) > 0) {
nextPhaseNum = pm[1];
nextPhaseName = pm[2].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-');
isLastPhase = false;
break;
}
}
} catch { /* intentionally empty */ }
}
// Step F: Update STATE.md atomically
let stateUpdated = false;
if (existsSync(paths.state)) {
const lockPath = await acquireStateLock(paths.state);
try {
const rawState = await readFile(paths.state, 'utf-8');
// Split into frontmatter and body to prevent field replacement from
// matching YAML keys (e.g., `status:` in frontmatter vs `Status:` in body).
// Pattern 11: Strip frontmatter before modifier (from Phase 11 decisions).
const fmMatch = rawState.match(/^(---\r?\n[\s\S]*?\r?\n---)\s*/);
let frontmatter = fmMatch ? fmMatch[1] : '';
let body = fmMatch ? rawState.slice(fmMatch[0].length) : rawState;
// Update Current Phase — preserve "X of Y (Name)" compound format
const phaseValue = nextPhaseNum || phaseNum;
const existingPhaseField = stateExtractField(body, 'Current Phase')
|| stateExtractField(body, 'Phase');
let newPhaseValue = String(phaseValue);
if (existingPhaseField) {
const totalMatch = existingPhaseField.match(/of\s+(\d+)/);
const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
if (totalMatch) {
const total = totalMatch[1];
const nameStr = nextPhaseName
? ` (${nextPhaseName.replace(/-/g, ' ')})`
: (nameMatch ? ` (${nameMatch[1]})` : '');
newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
}
}
body = stateReplaceFieldWithFallback(body, 'Current Phase', 'Phase', newPhaseValue);
// Update Status
body = stateReplaceFieldWithFallback(body, 'Status', null,
isLastPhase ? 'Milestone complete' : 'Ready to plan');
// Update Current Plan
body = stateReplaceFieldWithFallback(body, 'Current Plan', 'Plan', 'Not started');
// Update Last Activity
body = stateReplaceFieldWithFallback(body, 'Last Activity', 'Last activity', today);
// Update Performance Metrics section (operates on body only)
body = updatePerformanceMetricsSection(body, phaseNum, planCount, summaryCount);
// ── Root cause 1 fix: derive completed_phases from ROADMAP, not blind increment ──
// Read the freshly-updated ROADMAP (after Step C) to count Complete rows.
// This makes phase.complete idempotent: running it twice on the same phase
// produces the same completed_phases value.
let derivedCompletedPhases: number | null = null;
let derivedTotalPhases: number | null = null;
let derivedTotalPlans: number | null = null;
if (existsSync(paths.roadmap)) {
try {
const freshRoadmap = await readFile(paths.roadmap, 'utf-8');
// Count Complete rows in the progress table (Status column = "Complete")
const tableCompletePattern = /\|\s*\d+[A-Z]?\S*\s*\|[^|]*\|\s*Complete\s*\|/gi;
const completeMatches = freshRoadmap.match(tableCompletePattern);
derivedCompletedPhases = completeMatches ? completeMatches.length : null;
// Count total phase rows in progress table (header + separator + data rows)
// Identify the progress table by looking for Phase|Plans|Status|Completed header
const progressTableMatch = freshRoadmap.match(
/\|\s*Phase\s*\|\s*Plans\s*\|\s*Status\s*\|\s*Completed\s*\|(.*\n)*?(?:\n|$)/i,
);
if (progressTableMatch) {
const tableText = progressTableMatch[0];
const dataRowPattern = /^\|\s*\d+[A-Z]?\S*\s*\|/gm;
const dataRows = tableText.match(dataRowPattern);
derivedTotalPhases = dataRows ? dataRows.length : null;
}
// Sum plan counts from M/N or 0/N columns in the progress table
let totalPlansSum = 0;
const planCellPattern = /\|\s*\d+[A-Z]?\S*\s*\|\s*(\d+)\/(\d+)\s*\|/gi;
let pm: RegExpExecArray | null;
while ((pm = planCellPattern.exec(freshRoadmap)) !== null) {
totalPlansSum += parseInt(pm[2], 10);
}
if (totalPlansSum > 0) derivedTotalPlans = totalPlansSum;
} catch { /* intentionally empty — fall through to existing values */ }
}
// Count completed plans from all SUMMARY files across phase dirs
let derivedCompletedPlans: number | null = null;
try {
const phaseEntries = await readdir(paths.phases, { withFileTypes: true });
let summaryTotal = 0;
for (const entry of phaseEntries) {
if (!entry.isDirectory()) continue;
try {
const files = await readdir(join(paths.phases, entry.name));
summaryTotal += files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
} catch { /* intentionally empty */ }
}
derivedCompletedPlans = summaryTotal;
} catch { /* intentionally empty */ }
// ── Root cause 2 fix: update all stale frontmatter fields ──
// completed_phases — derived from ROADMAP (idempotent)
if (derivedCompletedPhases !== null && frontmatter.includes('completed_phases:')) {
frontmatter = frontmatter.replace(
/completed_phases:\s*\d+/,
`completed_phases: ${derivedCompletedPhases}`,
);
}
// total_phases — keep in sync with ROADMAP
if (derivedTotalPhases !== null && frontmatter.includes('total_phases:')) {
frontmatter = frontmatter.replace(
/total_phases:\s*\d+/,
`total_phases: ${derivedTotalPhases}`,
);
}
// total_plans — derived from ROADMAP plan column sums
if (derivedTotalPlans !== null && frontmatter.includes('total_plans:')) {
frontmatter = frontmatter.replace(
/total_plans:\s*\d+/,
`total_plans: ${derivedTotalPlans}`,
);
}
// completed_plans — count of SUMMARY files on disk
if (derivedCompletedPlans !== null && frontmatter.includes('completed_plans:')) {
frontmatter = frontmatter.replace(
/completed_plans:\s*\d+/,
`completed_plans: ${derivedCompletedPlans}`,
);
}
// percent — recompute from fresh derived values
const effectiveCompleted = derivedCompletedPhases ?? parseInt(frontmatter.match(/completed_phases:\s*(\d+)/)?.[1] ?? '0', 10);
const effectiveTotal = derivedTotalPhases ?? parseInt(frontmatter.match(/total_phases:\s*(\d+)/)?.[1] ?? '0', 10);
if (effectiveTotal > 0 && frontmatter.includes('percent:')) {
const newPercent = Math.round((effectiveCompleted / effectiveTotal) * 100);
frontmatter = frontmatter.replace(/(percent:\s*)\d+/, `$1${newPercent}`);
}
// last_updated — refresh to current timestamp
const nowIso = new Date().toISOString();
if (frontmatter.includes('last_updated:')) {
frontmatter = frontmatter.replace(/last_updated:\s*\S+/, `last_updated: ${nowIso}`);
}
// stopped_at — set to phase completion message
const stoppedAtValue = isLastPhase
? `Milestone complete (Phase ${phaseNum} was final phase)`
: `Phase ${phaseNum} complete (${summaryCount}/${planCount}) — ready to discuss Phase ${nextPhaseNum}`;
if (frontmatter.includes('stopped_at:')) {
frontmatter = frontmatter.replace(/stopped_at:\s*.+/, `stopped_at: ${stoppedAtValue}`);
} else {
// Insert stopped_at before closing ---
frontmatter = frontmatter.replace(/(---\s*)$/, `stopped_at: ${stoppedAtValue}\n$1`);
}
// ── Root cause 2 fix: update body Current focus ──
const focusValue = isLastPhase
? 'Milestone complete'
: (nextPhaseName
? `Phase ${nextPhaseNum} — ${nextPhaseName.replace(/-/g, ' ')}`
: `Phase ${nextPhaseNum}`);
const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
if (focusPattern.test(body)) {
body = body.replace(focusPattern, (_m: string, prefix: string) => `${prefix}${focusValue}`);
}
// Update frontmatter status field
frontmatter = frontmatter.replace(
/status:\s*.+/,
`status: ${isLastPhase ? 'milestone_complete' : 'ready_to_plan'}`,
);
// Reassemble and write
const stateContent = frontmatter + '\n\n' + body;
await writeFile(paths.state, stateContent, 'utf-8');
stateUpdated = true;
} finally {
await releaseStateLock(lockPath);
}
}
// Step F2: Auto-prune STATE.md decisions when `workflow.auto_prune_state`
// is true. Mirrors CJS cmdPhaseComplete (bin/lib/phase.cjs:1378-1390) which
// calls cmdStatePrune({keepRecent:'3', dryRun:false, silent:true}). Without
// this, completing phase N with auto_prune_state=true leaves stale [Phase
// 1..N-3] decisions in STATE.md forever. (#2087)
let autoPruned = false;
try {
if (existsSync(paths.config)) {
const rawConfig = JSON.parse(await readFile(paths.config, 'utf-8')) as Record<string, unknown>;
const wf = rawConfig.workflow as Record<string, unknown> | undefined;
if (wf && wf.auto_prune_state === true && existsSync(paths.state)) {
const { statePrune } = await import('./state-mutation.js');
await statePrune(['--keep-recent', '3', '--silent'], projectDir, workstream);
autoPruned = true;
}
}
} catch { /* best-effort, matches CJS */ }
// Step G: Return result
return {
data: {
completed_phase: phaseNum,
phase_name: phaseInfo.phaseName,
plans_executed: `${summaryCount}/${planCount}`,
next_phase: nextPhaseNum,
next_phase_name: nextPhaseName,
is_last_phase: isLastPhase,
date: today,
roadmap_updated: existsSync(paths.roadmap),
state_updated: stateUpdated,
requirements_updated: requirementsUpdated,
auto_pruned: autoPruned,
warnings,
has_warnings: warnings.length > 0,
},
};
};
// ─── phasesClear handler ──────────────────────────────────────────────────
/**
* Query handler for phases.clear.
*
* Port of cmdPhasesClear from milestone.cjs lines 250-277.
* Deletes all phase directories except 999.x backlog phases.
* Requires --confirm flag to proceed.
*
* @param args - args[0]: '--confirm' to proceed (optional)
* @param projectDir - Project root directory
* @returns QueryResult with { cleared: count }
*/
export const phasesClear: QueryHandler = async (args, projectDir, workstream) => {
const phasesDir = planningPaths(projectDir, workstream).phases;
const confirm = Array.isArray(args) && args.includes('--confirm');
let cleared = 0;
if (existsSync(phasesDir)) {
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory() && !/^999(?:\.|$)/.test(e.name));
if (dirs.length > 0 && !confirm) {
throw new GSDError(
`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` +
`Pass --confirm to proceed.`,
ErrorClassification.Validation,
);
}
for (const entry of dirs) {
await rm(join(phasesDir, entry.name), { recursive: true, force: true });
cleared++;
}
}
return { data: { cleared } };
};
// ─── phasesArchive handler ────────────────────────────────────────────────
/**
* Query handler for phases.archive.
*
* Extracted from cmdMilestoneComplete, milestone.cjs lines 210-227.
* Moves milestone phase directories to milestones/{version}-phases/.
*
* @param args - args[0]: version string (e.g., "v3.0")
* @param projectDir - Project root directory
* @returns QueryResult with { archived: count, version, archive_directory }
*/
export const phasesList: QueryHandler = async (args, projectDir, workstream) => {
const paths = planningPaths(projectDir, workstream);
const phasesDir = paths.phases;
const typeIdx = args.indexOf('--type');
const phaseIdx = args.indexOf('--phase');
const type = typeIdx !== -1 ? args[typeIdx + 1] : null;
const phase = phaseIdx !== -1 ? args[phaseIdx + 1] : null;
const includeArchived = args.includes('--include-archived');
if (!existsSync(phasesDir)) {
return { data: type ? { files: [], count: 0 } : { directories: [], count: 0 } };
}
const entries = await readdir(phasesDir, { withFileTypes: true });
let dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
if (includeArchived) {
const milestonesDir = join(paths.planning, 'milestones');
if (existsSync(milestonesDir)) {
const milestoneEntries = await readdir(milestonesDir, { withFileTypes: true });
for (const mDir of milestoneEntries.filter(e => e.isDirectory() && e.name.endsWith('-phases'))) {
const milestone = mDir.name.replace(/-phases$/, '');
const archivedEntries = await readdir(join(milestonesDir, mDir.name), { withFileTypes: true });
for (const a of archivedEntries.filter(e => e.isDirectory())) {
dirs.push(`${a.name} [${milestone}]`);
}
}
}
}
dirs.sort((a, b) => comparePhaseNum(a, b));
if (phase) {
const normalized = normalizePhaseName(phase);
const match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) {
return { data: { files: [], count: 0, phase_dir: null, error: 'Phase not found' } };
}
dirs = [match];
}
if (type) {
const files: string[] = [];
const warnings: string[] = [];
for (const dir of dirs) {
const dirPath = join(phasesDir, dir);
if (!existsSync(dirPath)) continue;
const dirFiles = await readdir(dirPath);
let filtered: string[];
if (type === 'plans') {
filtered = dirFiles.filter(isCanonicalPlanFile);
// #2893 parity — surface plan-shaped files the canonical filter
// rejected so callers (executor init, etc.) don't silently see zero
// plans. Per-dir prefix mirrors phase.cjs:120.
const w = describeNonCanonicalPlans(dirFiles, filtered);
if (w) warnings.push(`${dir}: ${w}`);
} else if (type === 'summaries') {
filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
} else {
filtered = dirFiles;
}
files.push(...filtered.sort());
}
const result: Record<string, unknown> = {
files,
count: files.length,
phase_dir: phase ? dirs[0]?.replace(/^\d+(?:\.\d+)*-?/, '') : null,
};
if (warnings.length) result['warning'] = warnings.join(' | ');
return { data: result };
}
return { data: { directories: dirs, count: dirs.length } };
};
export const phaseNextDecimal: QueryHandler = async (args, projectDir, workstream) => {
const basePhase = args[0];
if (!basePhase) {
throw new GSDError('base phase number required', ErrorClassification.Validation);
}
assertNoNullBytes(basePhase, 'basePhase');
const paths = planningPaths(projectDir, workstream);
const phasesDir = paths.phases;
const normalized = normalizePhaseName(basePhase);
const decimalSet = new Set<number>();
let baseExists = false;
const dirNames = await listDirectories(phasesDir);
baseExists = dirNames.some((d) => phaseTokenMatches(d, normalized));
for (const suffix of collectDecimalSuffixesFromDirNames(normalized, dirNames)) {
decimalSet.add(suffix);
}
const roadmapPath = paths.roadmap;
if (existsSync(roadmapPath)) {
try {
const roadmapContent = await readFile(roadmapPath, 'utf-8');
for (const suffix of collectDecimalSuffixesFromRoadmap(normalized, roadmapContent)) {
decimalSet.add(suffix);
}
} catch { /* ROADMAP.md read failure is non-fatal */ }
}
const { next: nextDecimal, existing: existingDecimals } = computeNextDecimalPhase(normalized, decimalSet);
return {
data: {
found: baseExists,
base_phase: normalized,
next: nextDecimal,
existing: existingDecimals,
},
};
};
export const phasesArchive: QueryHandler = async (args, projectDir, workstream) => {
const version = args[0];
if (!version) {
throw new GSDError('version required for phases archive', ErrorClassification.Validation);
}
assertNoNullBytes(version, 'version');
const paths = planningPaths(projectDir, workstream);
const phasesDir = paths.phases;
const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream);
const archiveDir = join(paths.planning, 'milestones', `${version}-phases`);
const archivedCount = await archiveDirectories(phasesDir, archiveDir, (dirName) => isDirInMilestone(dirName));
return {
data: {
archived: archivedCount,
version,
archive_directory: toPosixPath(relative(projectDir, archiveDir)),
},
};
};
// ─── milestoneComplete ────────────────────────────────────────────────────
/**
* Query handler for `milestone.complete` — port of `cmdMilestoneComplete` from `milestone.cjs`.
*/
export const milestoneComplete: QueryHandler = async (args, projectDir, workstream) => {
const version = args[0];
if (!version) {
throw new GSDError('version required for milestone complete (e.g., v1.0)', ErrorClassification.Validation);
}
// #3259: defense-in-depth — reject --help / -h as a version value before
// any disk write, regardless of whether the dispatcher guard intercepted first.
if (version === '--help' || version === '-h') {
throw new GSDError(
`"${version}" is not a valid milestone version; see \`gsd-sdk query --help\` for command list`,
ErrorClassification.Validation,
);
}
assertNoNullBytes(version, 'version');
const nameOpt = parseMultiwordArg(args, 'name');
const archivePhases = args.includes('--archive-phases');
const paths = planningPaths(projectDir, workstream);
const roadmapPath = paths.roadmap;
const reqPath = paths.requirements;
const statePath = paths.state;
const milestonesPath = join(paths.planning, 'MILESTONES.md');
const archiveDir = join(paths.planning, 'milestones');
const phasesDir = paths.phases;
const today = new Date().toISOString().split('T')[0]!;
const milestoneName = nameOpt || version;
await mkdir(archiveDir, { recursive: true });
const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream);
let phaseCount = 0;
let totalPlans = 0;
let totalTasks = 0;
const accomplishments: string[] = [];
try {
const dirs = (await listDirectories(phasesDir)).sort();
for (const dir of dirs) {
if (!isDirInMilestone(dir)) continue;
phaseCount++;
const phaseFiles = await readdir(join(phasesDir, dir));
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
totalPlans += plans.length;
for (const s of summaries) {
try {
const content = await readFile(join(phasesDir, dir, s), 'utf-8');
const fm = extractFrontmatter(content);
const oneLiner =
(fm['one-liner'] as string | undefined) || extractOneLinerFromBody(content);
if (oneLiner) {
accomplishments.push(oneLiner);
}
const tasksFieldMatch = content.match(/\*\*Tasks:\*\*\s*(\d+)/);
if (tasksFieldMatch) {
totalTasks += parseInt(tasksFieldMatch[1]!, 10);
} else {
const xmlTaskMatches = content.match(/<task[\s>]/gi) || [];
const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
totalTasks += xmlTaskMatches.length || mdTaskMatches.length;
}
} catch {
/* intentionally empty */
}
}
}
} catch {
/* intentionally empty */
}
if (existsSync(roadmapPath)) {
const roadmapContent = await readFile(roadmapPath, 'utf-8');
await writeFile(join(archiveDir, `${version}-ROADMAP.md`), roadmapContent, 'utf-8');
}
if (existsSync(reqPath)) {
const reqContent = await readFile(reqPath, 'utf-8');
const archiveHeader =
`# Requirements Archive: ${version} ${milestoneName}\n\n` +
`**Archived:** ${today}\n**Status:** SHIPPED\n\n` +
`For current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`;
await writeFile(join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent, 'utf-8');
}
const auditFile = join(projectDir, '.planning', `${version}-MILESTONE-AUDIT.md`);
if (existsSync(auditFile)) {
await rename(auditFile, join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
}
const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
const milestoneEntry =
`## ${version} ${milestoneName} (Shipped: ${today})\n\n` +
`**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n` +
`**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
if (existsSync(milestonesPath)) {
const existing = await readFile(milestonesPath, 'utf-8');
if (!existing.trim()) {
await writeFile(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`), 'utf-8');
} else {
const headerMatch = existing.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
if (headerMatch) {
const header = headerMatch[1]!;
const rest = existing.slice(header.length);
await writeFile(milestonesPath, normalizeMd(header + milestoneEntry + rest), 'utf-8');
} else {
await writeFile(milestonesPath, normalizeMd(milestoneEntry + existing), 'utf-8');
}
}
} else {
await writeFile(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`), 'utf-8');
}
if (existsSync(statePath)) {
await readModifyWriteStateMdFull(projectDir, (stateContent) => {
let next = stateReplaceFieldWithFallback(
stateContent,
'Status',
null,
`${version} milestone complete`,
);
next = stateReplaceFieldWithFallback(next, 'Last Activity', 'Last activity', today);
next = stateReplaceFieldWithFallback(
next,
'Last Activity Description',
null,
`${version} milestone completed and archived`,
);
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const closedPositionBody =
`\nPhase: Milestone ${version} complete\n` +
`Plan: —\n` +
`Status: Awaiting next milestone\n` +
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
if (positionPattern.test(next)) {
next = next.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`);
} else {
next = `${next.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
}
const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i;
if (operatorPattern.test(next)) {
next = next.replace(
operatorPattern,
`$1\n- Start the next milestone with /gsd-new-milestone\n\n`,
);
} else {
next = `${next.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with /gsd-new-milestone\n`;
}
return next;
}, workstream);
}
let phasesArchived = false;
if (archivePhases) {
try {
const phaseArchiveDir = join(archiveDir, `${version}-phases`);
const archivedCount = await archiveDirectories(
phasesDir,
phaseArchiveDir,
(dirName) => isDirInMilestone(dirName),
);
phasesArchived = archivedCount > 0;
} catch {
/* intentionally empty */
}
}
return {
data: {
version,
name: milestoneName,
date: today,
phases: phaseCount,
plans: totalPlans,
tasks: totalTasks,
accomplishments,
archived: {
roadmap: existsSync(join(archiveDir, `${version}-ROADMAP.md`)),
requirements: existsSync(join(archiveDir, `${version}-REQUIREMENTS.md`)),
audit: existsSync(join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
phases: phasesArchived,
},
milestones_updated: true,
state_updated: existsSync(statePath),
},
};
};