Files
msd-core/sdk/src/query/state-mutation.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

1811 lines
66 KiB
TypeScript

/**
* STATE.md mutation handlers — write operations with lockfile atomicity.
*
* Ported from get-shit-done/bin/lib/state.cjs.
* Provides STATE.md mutation commands: update, patch, begin-phase,
* advance-plan, record-metric, update-progress, add-decision, add-blocker,
* resolve-blocker, record-session, validate, sync, prune, signal-waiting, signal-resume.
*
* All writes go through readModifyWriteStateMd which acquires a lockfile,
* applies the modifier, syncs frontmatter, normalizes markdown, and writes.
*
* @example
* ```typescript
* import { stateUpdate, stateBeginPhase } from './state-mutation.js';
*
* await stateUpdate(['Status', 'executing'], '/project');
* await stateBeginPhase(['11', 'State Mutations', '3'], '/project');
* ```
*/
import { open, unlink, stat, readFile, writeFile, readdir } from 'node:fs/promises';
import {
constants, unlinkSync, existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync,
realpathSync,
} from 'node:fs';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter, stripFrontmatter } from './frontmatter.js';
import { reconstructFrontmatter, spliceFrontmatter } from './frontmatter-mutation.js';
import {
comparePhaseNum,
normalizePhaseName,
phaseTokenMatches,
planningPaths,
normalizeMd,
} from './helpers.js';
import { buildStateFrontmatter, getMilestonePhaseFilter } from './state.js';
import { scanPhasePlans } from './plan-scan.js';
import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, computeProgressPercent } from './state-document.js';
import type { QueryHandler } from './utils.js';
const PROGRESS_FRONTMATTER_FIELDS = new Set(['Progress', 'Total Plans in Phase', 'Total Phases']);
// ─── Process exit lock cleanup (D2 — match CJS state.cjs:16-23) ─────────
/**
* Module-level set tracking held locks for process.on('exit') cleanup.
* Exported for test access only.
*/
export const _heldStateLocks = new Set<string>();
process.on('exit', () => {
for (const lockPath of _heldStateLocks) {
try { unlinkSync(lockPath); } catch { /* already gone */ }
}
});
export { stateReplaceField };
/**
* Update fields within the ## Current Position section.
*
* Only updates fields that already exist in the section.
*/
function updateCurrentPositionFields(content: string, fields: Record<string, string | undefined>): string {
const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const posMatch = content.match(posPattern);
if (!posMatch) return content;
let posBody = posMatch[2];
if (fields.status && /^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
}
if (fields.lastActivity && /^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
}
if (fields.plan && /^Plan:/m.test(posBody)) {
posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
}
return content.replace(posPattern, () => `${posMatch[1]}${posBody}`);
}
/** Port of `readTextArgOrFile` from `state.cjs` — inline text or file path under project root. */
function readTextArgOrFile(
projectDir: string,
value: string | null | undefined,
filePath: string | null | undefined,
label: string,
): string {
if (!filePath) {
return (value ?? '').trim();
}
// Resolve symlinks on both the project root and the target path before
// comparing — matches CJS `validatePath` in security.cjs. On macOS,
// `os.tmpdir()` returns `/var/folders/...` but the realpath is
// `/private/var/folders/...`; without realpath normalization, the
// `relative()` check sees `/private/var/...` vs `/var/...` as different
// tree roots and rejects safe in-project files. Symlink resolution falls
// back to logical resolve() when the path doesn't exist yet (e.g., file
// about to be created).
function realpathOrResolve(p: string): string {
try { return realpathSync(p); } catch { return resolve(p); }
}
const resolvedBase = realpathOrResolve(resolve(projectDir));
const targetLogical = isAbsolute(filePath) ? resolve(filePath) : resolve(resolvedBase, filePath);
const resolvedTarget = realpathOrResolve(targetLogical);
const rel = relative(resolvedBase, resolvedTarget);
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error(`${label} path rejected: outside project directory`);
}
try {
return readFileSync(resolvedTarget, 'utf-8').trimEnd();
} catch {
throw new Error(`${label} file not found: ${filePath}`);
}
}
// ─── Lockfile helpers ─────────────────────────────────────────────────────
/**
* If the lock file contains a PID, return whether that process is gone (stolen
* locks after SIGKILL/crash). Null if the file could not be read.
*/
async function isLockProcessDead(lockPath: string): Promise<boolean | null> {
try {
const raw = await readFile(lockPath, 'utf-8');
const pid = parseInt(raw.trim(), 10);
if (!Number.isFinite(pid) || pid <= 0) return true;
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
} catch {
return null;
}
}
/**
* Acquire a lockfile for STATE.md operations.
*
* Uses O_CREAT|O_EXCL for atomic creation. Retries up to 10 times with
* 200ms + jitter delay. Cleans stale locks when the holder PID is dead, or when
* the lock file is older than 10 seconds (existing heuristic).
*
* @param statePath - Path to STATE.md
* @returns Path to the lockfile
*/
export async function acquireStateLock(statePath: string): Promise<string> {
const lockPath = statePath + '.lock';
const maxRetries = 10;
const retryDelay = 200;
for (let i = 0; i < maxRetries; i++) {
try {
const fd = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
await fd.writeFile(String(process.pid));
await fd.close();
_heldStateLocks.add(lockPath);
return lockPath;
} catch (err: unknown) {
if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') {
try {
const dead = await isLockProcessDead(lockPath);
if (dead === true) {
await unlink(lockPath);
continue;
}
const s = await stat(lockPath);
if (Date.now() - s.mtimeMs > 10000) {
await unlink(lockPath);
continue;
}
} catch { /* lock released between check */ }
if (i === maxRetries - 1) {
try { await unlink(lockPath); } catch { /* ignore */ }
return lockPath;
}
await new Promise<void>(r => setTimeout(r, retryDelay + Math.floor(Math.random() * 50)));
} else {
// D3: Graceful degradation on non-EEXIST errors (match CJS state.cjs:889)
return lockPath;
}
}
}
return lockPath;
}
/**
* Release a lockfile.
*
* @param lockPath - Path to the lockfile to release
*/
export async function releaseStateLock(lockPath: string): Promise<void> {
_heldStateLocks.delete(lockPath);
try { await unlink(lockPath); } catch { /* already gone */ }
}
// ─── Frontmatter sync + write helpers ─────────────────────────────────────
/**
* Sync STATE.md content with rebuilt YAML frontmatter.
*
* Strips existing frontmatter, rebuilds from body + disk, and splices back.
* Preserves existing status when body-derived status is 'unknown'.
*/
async function syncStateFrontmatter(
content: string,
projectDir: string,
workstream?: string,
options: { preserveExistingProgress?: boolean } = {},
): Promise<string> {
const existingFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
const derivedFm = await buildStateFrontmatter(body, projectDir, workstream, options);
// Preserve existing status when body-derived is 'unknown'
if (derivedFm.status === 'unknown' && existingFm.status && existingFm.status !== 'unknown') {
derivedFm.status = existingFm.status;
}
const yamlStr = reconstructFrontmatter(derivedFm);
return `---\n${yamlStr}\n---\n\n${body}`;
}
/**
* Atomic read-modify-write for STATE.md.
*
* Holds lock across the entire read -> transform -> write cycle.
*
* @param projectDir - Project root directory
* @param modifier - Function to transform STATE.md content
* @returns The final written content
*/
async function readModifyWriteStateMd(
projectDir: string,
modifier: (content: string) => string | Promise<string>,
workstream?: string,
options: { resync?: boolean; preserveExistingProgress?: boolean } = {},
): Promise<string> {
const statePath = planningPaths(projectDir, workstream).state;
const resync = options.resync !== false;
const lockPath = await acquireStateLock(statePath);
try {
let content: string;
try {
content = await readFile(statePath, 'utf-8');
} catch {
content = '';
}
// Strip frontmatter before passing to modifier so that regex replacements
// operate on body fields only (not on YAML frontmatter keys like 'status:').
// syncStateFrontmatter rebuilds frontmatter from the modified body + disk.
const preFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
const modified = await modifier(body);
let synced = await syncStateFrontmatter(modified, projectDir, workstream, {
preserveExistingProgress: options.preserveExistingProgress,
});
if (!resync && preFm && preFm.progress) {
const postFm = extractFrontmatter(synced);
postFm.progress = preFm.progress;
const yamlStr = reconstructFrontmatter(postFm);
synced = `---\n${yamlStr}\n---\n\n${stripFrontmatter(synced)}`;
}
const normalized = normalizeMd(synced);
await writeFile(statePath, normalized, 'utf-8');
return normalized;
} finally {
await releaseStateLock(lockPath);
}
}
/**
* Full-file read-modify-write for STATE.md — matches CJS `readModifyWriteStateMd` in `state.cjs`
* (modifier receives entire file content including YAML frontmatter).
* Used by milestone completion and other flows that replace body fields the same way as the CLI.
*/
export async function readModifyWriteStateMdFull(
projectDir: string,
modifier: (content: string) => string | Promise<string>,
workstream?: string,
): Promise<void> {
const statePath = planningPaths(projectDir, workstream).state;
const lockPath = await acquireStateLock(statePath);
try {
let content = '';
try {
content = await readFile(statePath, 'utf-8');
} catch {
/* missing */
}
const modified = await modifier(content);
const synced = await syncStateFrontmatter(modified, projectDir, workstream);
await writeFile(statePath, normalizeMd(synced), 'utf-8');
} finally {
await releaseStateLock(lockPath);
}
}
// ─── Exported handlers ────────────────────────────────────────────────────
/**
* Query handler for state.update command.
*
* Replaces a single field in STATE.md.
*
* @param args - args[0]: field name, args[1]: new value
* @param projectDir - Project root directory
* @returns QueryResult with { updated: true/false }
*/
export const stateUpdate: QueryHandler = async (args, projectDir, workstream) => {
const field = args[0];
const value = args[1];
if (!field || value === undefined) {
throw new GSDError('field and value required for state update', ErrorClassification.Validation);
}
// Match CJS `cmdStateUpdate` contract: caller receives `{ updated: false,
// reason: '...' }` when the operation is a no-op so shell-script consumers
// can JSON.parse output and branch on the reason. Without an explicit
// STATE.md check up front, readModifyWriteStateMd's auto-create behavior
// would mask "STATE.md missing" as a successful no-op write.
const statePath = planningPaths(projectDir, workstream).state;
try {
await readFile(statePath, 'utf-8');
} catch {
return { data: { updated: false, reason: 'STATE.md not found' } };
}
let updated = false;
const shouldResync = PROGRESS_FRONTMATTER_FIELDS.has(field);
await readModifyWriteStateMd(projectDir, (content) => {
const result = stateReplaceField(content, field, value);
if (result) {
updated = true;
return result;
}
return content;
}, workstream, {
resync: shouldResync,
preserveExistingProgress: !shouldResync,
});
if (!updated) {
return { data: { updated: false, reason: `Field "${field}" not found in STATE.md` } };
}
return { data: { updated: true } };
};
/**
* Query handler for state.patch command.
*
* Replaces multiple fields atomically in one lock cycle.
*
* @param args - Either `--field value` pairs (CLI / gsd-tools) or a single JSON object string (SDK).
* @param projectDir - Project root directory
* @returns QueryResult with `{ updated, failed }` matching `cmdStatePatch` in `state.cjs`
*/
export const statePatch: QueryHandler = async (args, projectDir, workstream) => {
let patches: Record<string, string>;
if (args.length >= 2 && args[0]?.startsWith('--')) {
patches = {};
for (let i = 0; i < args.length; i += 2) {
const key = args[i]?.replace(/^--/, '');
const value = args[i + 1];
if (key && value !== undefined) patches[key] = value;
}
} else {
const jsonString = args[0];
if (!jsonString) {
throw new GSDError('JSON patches required', ErrorClassification.Validation);
}
try {
patches = JSON.parse(jsonString) as Record<string, string>;
} catch {
throw new GSDError('Invalid JSON for patches', ErrorClassification.Validation);
}
}
const updated: string[] = [];
const failed: string[] = [];
const shouldResync = Object.keys(patches).some(field => PROGRESS_FRONTMATTER_FIELDS.has(field));
await readModifyWriteStateMd(projectDir, (content) => {
for (const [field, value] of Object.entries(patches)) {
const result = stateReplaceField(content, field, String(value));
if (result) {
content = result;
updated.push(field);
} else {
failed.push(field);
}
}
return content;
}, workstream, {
resync: shouldResync,
preserveExistingProgress: !shouldResync,
});
return { data: { updated, failed } };
};
/**
* Query handler for state.begin-phase command.
*
* Sets phase, plan, status, progress, and current focus fields.
* Rewrites the Current Position section.
*
* Accepts gsd-tools-style argv: `--phase N [--name S] [--plans C]` or positional
* `[phase, name?, planCount?]` (tests and direct handler calls).
*
* @param args - Named or positional phase / name / plan count
* @param projectDir - Project root directory
* @returns QueryResult with phase metadata and `updated` field names (for raw parity)
*/
export const stateBeginPhase: QueryHandler = async (args, projectDir, workstream) => {
const named = parseNamedArgs(args, ['phase', 'name', 'plans']);
let phaseNumber = (named.phase as string | null) || '';
let phaseName = (named.name as string | null) || '';
let plansStr = named.plans as string | null;
const positionalMode = args.length > 0 && !String(args[0]).startsWith('--');
if (positionalMode) {
if (!phaseNumber) phaseNumber = args[0] ?? '';
if (!phaseName) phaseName = (args[1] as string) ?? '';
if (plansStr === null && args[2] !== undefined && !String(args[2]).startsWith('--')) {
plansStr = args[2];
}
}
const plansParsed =
plansStr !== null && plansStr !== '' ? parseInt(String(plansStr), 10) : NaN;
const planNum =
Number.isFinite(plansParsed) && !Number.isNaN(plansParsed) && plansParsed > 0
? plansParsed
: null;
if (!phaseNumber) {
throw new GSDError('phase number required', ErrorClassification.Validation);
}
const today = new Date().toISOString().split('T')[0];
const updated: string[] = [];
await readModifyWriteStateMd(projectDir, (content) => {
// Update bold/plain fields
const statusValue = `Executing Phase ${phaseNumber}`;
let u = stateReplaceField(content, 'Status', statusValue);
if (u) {
content = u;
updated.push('Status');
}
u = stateReplaceField(content, 'Last Activity', today);
if (u) {
content = u;
updated.push('Last Activity');
}
const activityDesc = `Phase ${phaseNumber} execution started`;
u = stateReplaceField(content, 'Last Activity Description', activityDesc);
if (u) {
content = u;
updated.push('Last Activity Description');
}
u = stateReplaceField(content, 'Current Phase', String(phaseNumber));
if (u) {
content = u;
updated.push('Current Phase');
}
if (phaseName) {
u = stateReplaceField(content, 'Current Phase Name', phaseName);
if (u) {
content = u;
updated.push('Current Phase Name');
}
}
u = stateReplaceField(content, 'Current Plan', '1');
if (u) {
content = u;
updated.push('Current Plan');
}
if (planNum !== null && !Number.isNaN(planNum)) {
u = stateReplaceField(content, 'Total Plans in Phase', String(planNum));
if (u) {
content = u;
updated.push('Total Plans in Phase');
}
}
// Update **Current focus:**
const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
if (focusPattern.test(content)) {
content = content.replace(focusPattern, (_match, prefix: string) => `${prefix}${focusLabel}`);
updated.push('Current focus');
}
// Update ## Current Position section
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const positionMatch = content.match(positionPattern);
if (positionMatch) {
const header = positionMatch[1];
let posBody = positionMatch[2];
const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
if (/^Phase:/m.test(posBody)) {
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
} else {
posBody = newPhase + '\n' + posBody;
}
const newPlan = `Plan: 1 of ${planNum ?? '?'}`;
if (/^Plan:/m.test(posBody)) {
posBody = posBody.replace(/^Plan:.*$/m, newPlan);
} else {
posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
}
const newStatus = `Status: Executing Phase ${phaseNumber}`;
if (/^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, newStatus);
}
const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
if (/^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
}
content = content.replace(positionPattern, () => `${header}${posBody}`);
updated.push('Current Position');
}
return content;
}, workstream);
return {
data: {
updated,
phase: phaseNumber,
phase_name: phaseName || null,
plan_count: planNum !== null && !Number.isNaN(planNum) ? planNum : null,
},
};
};
/**
* Query handler for state.advance-plan command.
*
* Increments plan counter. Detects phase completion when at last plan.
*
* @param args - unused
* @param projectDir - Project root directory
* @returns QueryResult with { advanced, current_plan, total_plans }
*/
export const stateAdvancePlan: QueryHandler = async (_args, projectDir, workstream) => {
const today = new Date().toISOString().split('T')[0];
let result: Record<string, unknown> = { error: 'STATE.md not found' };
await readModifyWriteStateMd(projectDir, (content) => {
// Parse current plan info (content already has frontmatter stripped)
const legacyPlan = stateExtractField(content, 'Current Plan');
const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
const planField = stateExtractField(content, 'Plan');
let currentPlan: number;
let totalPlans: number;
let useCompoundFormat = false;
let compoundPlanField: string | null = null;
if (legacyPlan && legacyTotal) {
currentPlan = parseInt(legacyPlan, 10);
totalPlans = parseInt(legacyTotal, 10);
} else if (planField) {
currentPlan = parseInt(planField, 10);
const ofMatch = planField.match(/of\s+(\d+)/);
totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
useCompoundFormat = true;
compoundPlanField = planField;
} else {
result = { error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' };
return content;
}
if (isNaN(currentPlan) || isNaN(totalPlans)) {
result = { error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' };
return content;
}
if (currentPlan >= totalPlans) {
// Phase complete
content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification');
content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
content = updateCurrentPositionFields(content, {
status: 'Phase complete — ready for verification',
lastActivity: today,
});
result = {
advanced: false,
reason: 'last_plan',
current_plan: currentPlan,
total_plans: totalPlans,
status: 'ready_for_verification',
};
return content;
}
// Advance to next plan
const newPlan = currentPlan + 1;
let planDisplayValue: string;
if (useCompoundFormat && compoundPlanField) {
planDisplayValue = compoundPlanField.replace(/^\d+/, String(newPlan));
content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
} else {
planDisplayValue = `${newPlan} of ${totalPlans}`;
content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
}
content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute');
content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
content = updateCurrentPositionFields(content, {
status: 'Ready to execute',
lastActivity: today,
plan: planDisplayValue,
});
result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
return content;
}, workstream);
return { data: result };
};
/**
* Query handler for state.record-metric command.
*
* Appends a row to the Performance Metrics table.
*
* @param args - gsd-tools argv: `--phase`, `--plan`, `--duration`, `--tasks`, `--files`
* @param projectDir - Project root directory
* @returns QueryResult with { recorded: true/false }
*/
export const stateRecordMetric: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']);
const phase = parsed.phase as string | null;
const plan = parsed.plan as string | null;
const duration = parsed.duration as string | null;
const tasks = (parsed.tasks as string | null) || '-';
const files = (parsed.files as string | null) || '-';
if (!phase || !plan || !duration) {
return { data: { error: 'phase, plan, and duration required' } };
}
// CJS `cmdStateRecordMetric` contract: error out if STATE.md doesn't exist
// rather than auto-creating it (which `readModifyWriteStateMd` would do).
const statePath = planningPaths(projectDir, workstream).state;
try {
await readFile(statePath, 'utf-8');
} catch {
return { data: { error: 'STATE.md not found' } };
}
let recorded = false;
let created = false;
await readModifyWriteStateMd(projectDir, (content) => {
const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
const metricsMatch = content.match(metricsPattern);
const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks} tasks | ${files} files |`;
if (metricsMatch) {
let tableBody = metricsMatch[2].trimEnd();
if (tableBody.trim() === '' || tableBody.includes('None yet')) {
tableBody = newRow;
} else {
tableBody = tableBody + '\n' + newRow;
}
content = content.replace(metricsPattern, (_match, header: string) => `${header}${tableBody}\n`);
recorded = true;
} else {
// Section absent — DWIM: auto-create canonical ## Performance Metrics scaffold,
// then append the row. Matches CJS state.cjs DWIM behavior.
const scaffold = [
'',
'## Performance Metrics',
'',
'| Phase | Plan | Duration | Notes |',
'|-------|------|----------|-------|',
newRow,
'',
].join('\n');
content = content.trimEnd() + '\n' + scaffold;
recorded = true;
created = true;
}
return content;
}, workstream);
const result: Record<string, unknown> = { recorded: true, phase, plan, duration };
if (created) result.created = true;
return { data: result };
};
/**
* Query handler for state.update-progress command.
*
* Scans disk to count completed/total plans and updates progress bar.
*
* @param args - unused
* @param projectDir - Project root directory
* @returns QueryResult with { updated, percent, completed, total }
*/
export const stateUpdateProgress: QueryHandler = async (_args, projectDir, workstream) => {
// CJS `cmdStateUpdateProgress` contract: error out when STATE.md is missing.
// Without this check the SDK silently returns `{ updated: false }` with no
// STATE.md-aware reason, masking the missing-file condition.
const statePath = planningPaths(projectDir, workstream).state;
try {
await readFile(statePath, 'utf-8');
} catch {
return { data: { error: 'STATE.md not found' } };
}
const phasesDir = planningPaths(projectDir, workstream).phases;
let totalPlans = 0;
let totalSummaries = 0;
try {
const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream);
const entries = await readdir(phasesDir, { withFileTypes: true });
const phaseDirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.filter(isDirInMilestone);
for (const dir of phaseDirs) {
const files = await readdir(join(phasesDir, dir));
totalPlans += files.filter(f => /-PLAN\.md$/i.test(f)).length;
totalSummaries += files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
}
} catch { /* phases dir may not exist */ }
const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
const barWidth = 10;
const filled = Math.round(percent / 100 * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const progressStr = `[${bar}] ${percent}%`;
let updated = false;
await readModifyWriteStateMd(projectDir, (content) => {
const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
const plainProgressPattern = /^(Progress:\s*).*/im;
if (boldProgressPattern.test(content)) {
updated = true;
return content.replace(boldProgressPattern, (_match, prefix: string) => `${prefix}${progressStr}`);
}
if (plainProgressPattern.test(content)) {
updated = true;
return content.replace(plainProgressPattern, (_match, prefix: string) => `${prefix}${progressStr}`);
}
return content;
}, workstream);
if (updated) {
return { data: { updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr } };
}
return { data: { updated: false, reason: 'Progress field not found in STATE.md' } };
};
/**
* Query handler for state.add-decision command.
*
* Appends a decision to the Decisions section. Removes placeholder text.
* argv matches `gsd-tools.cjs`: `--phase`, `--summary`, `--rationale`, etc.
*/
export const stateAddDecision: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']);
const phase = parsed.phase as string | null;
let summaryText: string | null = null;
let rationaleText = '';
try {
summaryText = readTextArgOrFile(
projectDir,
(parsed.summary as string | null) ?? null,
(parsed['summary-file'] as string | null) ?? null,
'summary',
);
rationaleText = readTextArgOrFile(
projectDir,
(parsed.rationale as string | null) || '',
(parsed['rationale-file'] as string | null) ?? null,
'rationale',
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { data: { added: false, reason: msg } };
}
if (!summaryText) {
return { data: { error: 'summary required' } };
}
const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
let created = false;
await readModifyWriteStateMd(projectDir, (content) => {
const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const match = content.match(sectionPattern);
if (match) {
let sectionBody = match[2];
sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`);
}
// Section absent — DWIM (CJS state.cjs:481-492): auto-create the
// canonical `## Decisions` scaffold and append the entry. Matches the
// begin-phase / advance-plan DWIM behavior. Without this, callers that
// never touched the Decisions section see `{added: false}` even though
// STATE.md is writable. Bug #3286.
const scaffold = ['', '## Decisions', '', entry, ''].join('\n');
created = true;
return content.trimEnd() + '\n' + scaffold;
}, workstream);
const result: Record<string, unknown> = { added: true, decision: entry };
if (created) result['created'] = true;
return { data: result };
};
/**
* Query handler for state.add-blocker command.
* argv: `--text`, `--text-file` (see `gsd-tools.cjs`).
*/
export const stateAddBlocker: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['text', 'text-file']);
let blockerText: string | null = null;
try {
blockerText = readTextArgOrFile(
projectDir,
(parsed.text as string | null) ?? null,
(parsed['text-file'] as string | null) ?? null,
'blocker',
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { data: { added: false, reason: msg } };
}
if (!blockerText) {
return { data: { error: 'text required' } };
}
const entry = `- ${blockerText}`;
let created = false;
await readModifyWriteStateMd(projectDir, (content) => {
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const match = content.match(sectionPattern);
if (match) {
let sectionBody = match[2];
sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`);
}
// Section absent — DWIM (CJS state.cjs:532-542): auto-create the
// canonical `### Blockers` scaffold and append the entry. Bug #3286
// parity — matches stateAddDecision DWIM above.
const scaffold = ['', '### Blockers', '', entry, ''].join('\n');
created = true;
return content.trimEnd() + '\n' + scaffold;
}, workstream);
const result: Record<string, unknown> = { added: true, blocker: blockerText };
if (created) result['created'] = true;
return { data: result };
};
/**
* Query handler for state.resolve-blocker command.
* argv: `--text` (see `gsd-tools.cjs`).
*/
export const stateResolveBlocker: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['text']);
const searchText = parsed.text as string | null;
if (!searchText) {
return { data: { error: 'text required' } };
}
// CJS `cmdStateResolveBlocker` contract: error out when STATE.md is missing.
const statePath = planningPaths(projectDir, workstream).state;
try {
await readFile(statePath, 'utf-8');
} catch {
return { data: { error: 'STATE.md not found' } };
}
let removedMatchingLine = false;
let blockersSectionFound = false;
await readModifyWriteStateMd(projectDir, (content) => {
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const match = content.match(sectionPattern);
if (match) {
blockersSectionFound = true;
const sectionBody = match[2];
const lines = sectionBody.split('\n');
const filtered = lines.filter(line => {
if (!line.startsWith('- ')) return true;
const matches = line.toLowerCase().includes(searchText.toLowerCase());
if (matches) removedMatchingLine = true;
return !matches;
});
if (!removedMatchingLine) {
return content;
}
let newBody = filtered.join('\n');
if (!newBody.trim() || !newBody.includes('- ')) {
newBody = 'None\n';
}
content = content.replace(sectionPattern, (_match, header: string) => `${header}${newBody}`);
}
return content;
}, workstream);
// CJS `cmdStateResolveBlocker` contract: `resolved: true` whenever the
// Blockers section was found, even if no line matched. The semantic is
// "the resolve operation ran against a Blockers section" rather than "a
// specific line was found and removed". Only `resolved: false` when the
// Blockers section itself is missing.
if (blockersSectionFound) {
return { data: { resolved: true, blocker: searchText } };
}
return { data: { resolved: false, reason: 'Blockers section not found in STATE.md' } };
};
// ─── state.add-roadmap-evolution ─────────────────────────────────────────
const VALID_ROADMAP_EVOLUTION_ACTIONS = new Set([
'inserted', 'removed', 'moved', 'edited', 'added',
]);
/**
* Format a canonical Roadmap Evolution entry line.
*
* Shapes match existing workflow templates (`insert-phase.md`, `add-phase.md`):
* - inserted: `- Phase {phase} inserted after Phase {after}: {note} (URGENT)`
* - added: `- Phase {phase} added: {note}`
* - removed: `- Phase {phase} removed: {note}`
* - moved: `- Phase {phase} moved: {note}`
* - edited: `- Phase {phase} edited: {note}`
*/
function formatRoadmapEvolutionEntry(opts: {
phase: string;
action: string;
note?: string | null;
after?: string | null;
urgent?: boolean;
}): string {
const { phase, action, note, after, urgent } = opts;
const trimmedNote = note ? note.trim() : '';
let line: string;
if (action === 'inserted') {
const afterClause = after ? ` after Phase ${after}` : '';
line = `- Phase ${phase} inserted${afterClause}`;
if (trimmedNote) line += `: ${trimmedNote}`;
if (urgent) line += ' (URGENT)';
} else {
// added | removed | moved | edited
line = `- Phase ${phase} ${action}`;
if (trimmedNote) line += `: ${trimmedNote}`;
}
return line;
}
/**
* Query handler for `state.add-roadmap-evolution`.
*
* Appends a single entry to the `### Roadmap Evolution` subsection under
* `## Accumulated Context` in STATE.md. Creates the subsection if missing.
* Deduplicates on exact line match against existing entries.
*
* Canonical replacement for the raw `Edit`/`Write` instructions in
* `insert-phase.md` / `add-phase.md` step "update_project_state" so that
* projects with a `protect-files.sh` PreToolUse hook blocking direct
* STATE.md writes still update the Roadmap Evolution log.
*
* argv: `--phase`, `--action` (inserted|removed|moved|edited|added),
* `--note` (optional), `--after` (optional, for `inserted`),
* `--urgent` (boolean flag, appends "(URGENT)" when action=inserted).
*
* Returns `{ added: true, entry }` on success, or
* `{ added: false, reason: 'duplicate', entry }` when an identical line
* already exists.
*
* Throws `GSDError` with `ErrorClassification.Validation` when required
* inputs are missing or `--action` is not in the allowed set.
*
* Atomicity: goes through `readModifyWriteStateMd` which holds a lockfile
* across read -> transform -> write. Matches sibling mutation handlers.
*/
export const stateAddRoadmapEvolution: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['phase', 'action', 'note', 'after'], ['urgent']);
const phase = (parsed.phase as string | null) ?? null;
const action = (parsed.action as string | null) ?? null;
const note = (parsed.note as string | null) ?? null;
const after = (parsed.after as string | null) ?? null;
const urgent = Boolean(parsed.urgent);
if (!phase) {
throw new GSDError('phase required for state.add-roadmap-evolution', ErrorClassification.Validation);
}
if (!action) {
throw new GSDError('action required for state.add-roadmap-evolution', ErrorClassification.Validation);
}
if (!VALID_ROADMAP_EVOLUTION_ACTIONS.has(action)) {
throw new GSDError(
`invalid action "${action}" (expected one of: ${Array.from(VALID_ROADMAP_EVOLUTION_ACTIONS).join(', ')})`,
ErrorClassification.Validation,
);
}
const entry = formatRoadmapEvolutionEntry({ phase, action, note, after, urgent });
let added = false;
let duplicate = false;
await readModifyWriteStateMd(projectDir, (content) => {
// Match `### Roadmap Evolution` subsection up to the next heading or EOF.
const subsectionPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?\s|\n##[^#]|$)/i;
const match = content.match(subsectionPattern);
if (match) {
let sectionBody = match[2];
// Dedupe: exact line match against any existing entry line.
const existingLines = sectionBody.split('\n').map(l => l.trim());
if (existingLines.some(l => l === entry.trim())) {
duplicate = true;
return content;
}
// Strip placeholder "None" / "None yet." lines.
sectionBody = sectionBody.replace(/^None(?:\s+yet)?\.?\s*$/gim, '');
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
content = content.replace(subsectionPattern, (_m, header: string) => `${header}${sectionBody}`);
added = true;
return content;
}
// Subsection missing — create it.
const accumulatedPattern = /(##\s*Accumulated Context\s*\n)/i;
const newSubsection = `\n### Roadmap Evolution\n\n${entry}\n`;
if (accumulatedPattern.test(content)) {
// Insert immediately after the "## Accumulated Context" header.
content = content.replace(accumulatedPattern, (_m, header: string) => `${header}${newSubsection}`);
added = true;
return content;
}
// No Accumulated Context section either — append both at EOF.
const suffix = `\n## Accumulated Context\n${newSubsection}`;
content = content.trimEnd() + suffix + '\n';
added = true;
return content;
}, workstream);
if (duplicate) {
return { data: { added: false, reason: 'duplicate', entry } };
}
if (added) {
return { data: { added: true, entry } };
}
// Unreachable given the logic above, but defensive.
return { data: { added: false, reason: 'unknown', entry } };
};
/**
* Query handler for state.record-session command.
* argv: `--stopped-at`, `--resume-file` (see `cmdStateRecordSession` in `state.cjs`).
*/
export const stateRecordSession: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['stopped-at', 'resume-file']);
const stoppedAt = parsed['stopped-at'] as string | null | undefined;
const resumeFile = ((parsed['resume-file'] as string | null) ?? 'None');
// CJS `cmdStateRecordSession` contract: error out when STATE.md is missing.
const statePath = planningPaths(projectDir, workstream).state;
try {
await readFile(statePath, 'utf-8');
} catch {
return { data: { error: 'STATE.md not found' } };
}
const now = new Date().toISOString();
const updated: string[] = [];
await readModifyWriteStateMd(projectDir, (content) => {
let result = stateReplaceField(content, 'Last session', now);
if (result) { content = result; updated.push('Last session'); }
result = stateReplaceField(content, 'Last Date', now);
if (result) { content = result; updated.push('Last Date'); }
if (stoppedAt) {
result = stateReplaceField(content, 'Stopped At', stoppedAt);
if (!result) result = stateReplaceField(content, 'Stopped at', stoppedAt);
if (result) { content = result; updated.push('Stopped At'); }
}
result = stateReplaceField(content, 'Resume File', resumeFile);
if (!result) result = stateReplaceField(content, 'Resume file', resumeFile);
if (result) { content = result; updated.push('Resume File'); }
return content;
}, workstream);
if (updated.length > 0) {
return { data: { recorded: true, updated } };
}
return { data: { recorded: false, reason: 'No session fields found in STATE.md' } };
};
/**
* Query handler for state.planned-phase — port of `cmdStatePlannedPhase` from `state.cjs`.
*/
export const statePlannedPhase: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['phase', 'name', 'plans']);
const phaseNumber = parsed.phase as string | null;
const plansRaw = parsed.plans as string | null;
const parsedPlanCount = plansRaw !== null && plansRaw !== '' ? parseInt(String(plansRaw), 10) : null;
const planCount =
parsedPlanCount !== null &&
!Number.isNaN(parsedPlanCount) &&
Number.isFinite(parsedPlanCount) &&
parsedPlanCount > 0
? parsedPlanCount
: null;
if (!phaseNumber || String(phaseNumber).trim() === '') {
return { data: { error: 'phase required (--phase <n>)' } };
}
const phaseLabel = String(phaseNumber).trim();
const statePath = planningPaths(projectDir, workstream).state;
if (!existsSync(statePath)) {
return { data: { error: 'STATE.md not found' } };
}
const today = new Date().toISOString().split('T')[0];
const updated: string[] = [];
await readModifyWriteStateMd(projectDir, (content) => {
let result = stateReplaceField(content, 'Status', 'Ready to execute');
if (result) { content = result; updated.push('Status'); }
if (planCount !== null) {
result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
if (result) { content = result; updated.push('Total Plans in Phase'); }
}
result = stateReplaceField(content, 'Last Activity', today);
if (result) { content = result; updated.push('Last Activity'); }
result = stateReplaceField(
content,
'Last Activity Description',
`Phase ${phaseLabel} planning complete — ${planCount ?? '?'} plans ready`,
);
if (result) { content = result; updated.push('Last Activity Description'); }
content = updateCurrentPositionFields(content, {
status: 'Ready to execute',
lastActivity: `${today} -- Phase ${phaseLabel} planning complete`,
});
return content;
}, workstream);
return { data: { updated, phase: phaseNumber, plan_count: planCount } };
};
// ─── stateMilestoneSwitch (bug #2630) ─────────────────────────────────────
/**
* Query handler for `state.milestone-switch` — resets STATE.md for a new
* milestone cycle (bug #2630 regression guard).
*
* The `/gsd-new-milestone` workflow only rewrote STATE.md's body (Current
* Position section). The YAML frontmatter (`milestone`, `milestone_name`,
* `status`, `progress.*`) was never touched on a mid-flight switch, so queries
* that read frontmatter (`state.json`, `getMilestoneInfo`, every handler that
* calls `buildStateFrontmatter`) kept reporting the old milestone and stale
* progress counters until the first phase advance forced a resync.
*
* This handler performs the reset atomically under the STATE.md lock:
* - Stomps frontmatter milestone/milestone_name with the caller-supplied
* values so `parseMilestoneFromState` reports the new milestone immediately.
* - Resets `status` to `'planning'` (workflow is at "Defining requirements").
* - Resets `progress` counters to zero (new milestone, nothing executed yet).
* - Rewrites the `## Current Position` body to the new-milestone template so
* subsequent body-derived field extraction stays consistent with frontmatter.
* - Preserves Accumulated Context (decisions, todos, blockers) — symmetric
* with `milestone.complete` which also keeps history.
*
* Args (named, matches gsd-tools style):
* - `--version <vX.Y>` (required)
* - `--name <milestone name>` (optional; defaults to 'milestone')
*
* Sibling CJS parity: `cmdInitNewMilestone` in `init.cjs` is read-only (like
* the TS `initNewMilestone`). The workflow-level fix is to call
* `state.milestone-switch` from `/gsd-new-milestone` Step 5 in place of the
* manual body rewrite.
*/
export const stateMilestoneSwitch: QueryHandler = async (args, projectDir, workstream) => {
// NOTE: the CLI flag is `--milestone` (not `--version`). gsd-tools reserves
// `--version` as a globally-invalid help flag, so the workflow invokes this
// handler with `--milestone vX.Y`. The internal variable is still `version`
// because the value is a milestone version string.
const parsed = parseNamedArgs(args, ['milestone', 'name']);
const version = (parsed.milestone as string | null)?.trim();
const name = ((parsed.name as string | null) ?? 'milestone').trim() || 'milestone';
if (!version) {
return { data: { error: 'milestone required (--milestone <vX.Y>)' } };
}
const today = new Date().toISOString().split('T')[0]!;
const statePath = planningPaths(projectDir, workstream).state;
const lockPath = await acquireStateLock(statePath);
try {
let content = '';
try {
content = await readFile(statePath, 'utf-8');
} catch { /* STATE.md may not exist yet */ }
const existingFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
// Reset Current Position section body so body-derived extraction stays
// consistent with the new frontmatter.
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const resetPositionBody =
`\nPhase: Not started (defining requirements)\n` +
`Plan: —\n` +
`Status: Defining requirements\n` +
`Last activity: ${today} — Milestone ${version} started\n\n`;
let newBody: string;
if (positionPattern.test(body)) {
newBody = body.replace(positionPattern, (_m, header: string) => `${header}${resetPositionBody}`);
} else {
// Preserve any existing body but prepend a Current Position section.
const preface = body.trim().length > 0 ? body : '# Project State\n';
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
}
// Build fresh frontmatter explicitly — do NOT rely on buildStateFrontmatter
// here, because getMilestoneInfo reads the ON-DISK STATE.md and would
// return the OLD milestone until we write it first. This is the crux of
// bug #2630: any sync-based approach races against the very file it is
// about to rewrite.
const fm: Record<string, unknown> = {
gsd_state_version: '1.0',
milestone: version,
milestone_name: name,
status: 'planning',
last_updated: new Date().toISOString(),
last_activity: today,
progress: {
total_phases: 0,
completed_phases: 0,
total_plans: 0,
completed_plans: 0,
percent: 0,
},
};
// Preserve frontmatter-only fields the caller may still care about
// (paused_at cleared deliberately — a new milestone is a fresh start).
if (existingFm.gsd_state_version) {
fm.gsd_state_version = existingFm.gsd_state_version;
}
const yamlStr = reconstructFrontmatter(fm);
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
await writeFile(statePath, normalizeMd(assembled), 'utf-8');
return {
data: {
switched: true,
version,
name,
status: 'planning',
},
};
} finally {
await releaseStateLock(lockPath);
}
};
// ─── parseNamedArgs (matches gsd-tools.cjs) ───────────────────────────────
function parseNamedArgs(
args: string[],
valueFlags: string[] = [],
booleanFlags: string[] = [],
): Record<string, string | boolean | null> {
const result: Record<string, string | boolean | null> = {};
for (const flag of valueFlags) {
const idx = args.indexOf(`--${flag}`);
if (idx === -1) {
result[flag] = null;
continue;
}
const value = args[idx + 1];
if (value === undefined || value.startsWith('--')) {
throw new GSDError(`missing value for --${flag}`, ErrorClassification.Validation);
}
result[flag] = value;
}
for (const flag of booleanFlags) {
result[flag] = args.includes(`--${flag}`);
}
return result;
}
// ─── Human gate signals (WAITING.json) ───────────────────────────────────
/**
* Port of `cmdSignalWaiting` from state.cjs.
* Args: `--type`, `--question`, `--options` (pipe-separated), `--phase`.
*
* Writes `WAITING.json` under both `.gsd/` and `.planning/` so readers that only
* watch one location (e.g. init workflows) still observe the signal.
*/
export const stateSignalWaiting: QueryHandler = async (args, projectDir, _workstream) => {
const parsed = parseNamedArgs(args, ['type', 'question', 'options', 'phase']);
const type = (parsed.type as string | null) || 'decision_point';
const question = (parsed.question as string | null) || null;
const optionsRaw = parsed.options as string | null;
const phase = (parsed.phase as string | null) || null;
const waitingPaths = [
join(projectDir, '.gsd', 'WAITING.json'),
join(projectDir, '.planning', 'WAITING.json'),
];
const signal = {
status: 'waiting',
type,
question,
options: optionsRaw ? optionsRaw.split('|').map(o => o.trim()) : [],
since: new Date().toISOString(),
phase,
};
try {
const payload = JSON.stringify(signal, null, 2);
mkdirSync(join(projectDir, '.gsd'), { recursive: true });
mkdirSync(join(projectDir, '.planning'), { recursive: true });
for (const p of waitingPaths) {
writeFileSync(p, payload, 'utf-8');
}
return { data: { signaled: true, path: waitingPaths[0], paths: waitingPaths } };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { data: { signaled: false, error: msg } };
}
};
/**
* Port of `cmdSignalResume` from state.cjs.
*/
export const stateSignalResume: QueryHandler = async (_args, projectDir, _workstream) => {
const paths = [
join(projectDir, '.gsd', 'WAITING.json'),
join(projectDir, '.planning', 'WAITING.json'),
];
let removed = false;
for (const p of paths) {
if (existsSync(p)) {
try {
unlinkSync(p);
removed = true;
} catch { /* ignore */ }
}
}
return { data: { resumed: true, removed } };
};
// ─── stateValidate ───────────────────────────────────────────────────────
/**
* Port of `cmdStateValidate` from state.cjs.
*/
export const stateValidate: QueryHandler = async (_args, projectDir, workstream) => {
const paths = planningPaths(projectDir, workstream);
const statePath = paths.state;
if (!existsSync(statePath)) {
return { data: { error: 'STATE.md not found' } };
}
const content = await readFile(statePath, 'utf-8');
const warnings: string[] = [];
const drift: Record<string, unknown> = {};
const status = stateExtractField(content, 'Status') || '';
const currentPhase = stateExtractField(content, 'Current Phase');
const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase');
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
const phasesDir = paths.phases;
if (currentPhase && existsSync(phasesDir)) {
const normalized = normalizePhaseName(currentPhase.replace(/\s+of\s+\d+.*/, '').trim());
try {
const entries = readdirSync(phasesDir, { withFileTypes: true });
const phaseDir = entries.find(
e => e.isDirectory() && phaseTokenMatches(e.name, normalized),
);
if (phaseDir) {
const phaseDirPath = join(phasesDir, phaseDir.name);
const files = readdirSync(phaseDirPath);
// Bug #3257 parity: count nested plans/ subdirectory via scanPhasePlans
// so /executing/i status checks below see the full plan count
// regardless of whether the planner used the flat or nested layout.
const { planCount: diskPlans, summaryCount: diskSummaries } = scanPhasePlans(phaseDirPath);
if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
warnings.push(
`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`,
);
drift.plan_count = { state: totalPlansInPhase, disk: diskPlans };
}
const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
for (const vf of verificationFiles) {
try {
const vContent = readFileSync(join(phaseDirPath, vf), 'utf-8');
if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
warnings.push(
`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`,
);
drift.verification_status = { state_status: status, verification: 'passed' };
}
} catch { /* skip */ }
}
if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
if (verificationFiles.length === 0) {
warnings.push(
`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`,
);
}
}
}
} catch { /* skip */ }
}
const valid = warnings.length === 0;
return { data: { valid, warnings, drift } };
};
// ─── stateSync ─────────────────────────────────────────────────────────────
/**
* Port of `cmdStateSync` from state.cjs. Supports `--verify` dry-run.
*/
export const stateSync: QueryHandler = async (args, projectDir, workstream) => {
const verify = args.includes('--verify');
const paths = planningPaths(projectDir, workstream);
const statePath = paths.state;
if (!existsSync(statePath)) {
return { data: { error: 'STATE.md not found' } };
}
const content = await readFile(statePath, 'utf-8');
const changes: string[] = [];
const today = new Date().toISOString().split('T')[0];
const phasesDir = paths.phases;
if (!existsSync(phasesDir)) {
return { data: { synced: true, changes: [], dry_run: verify } };
}
let entries: string[];
try {
entries = readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort((a, b) => comparePhaseNum(a, b));
} catch {
return { data: { synced: true, changes: [], dry_run: verify } };
}
let totalDiskPlans = 0;
let totalDiskSummaries = 0;
let diskCompletedPhases = 0;
let highestIncompletePhase: string | null = null;
let highestIncompletePhaseplanCount = 0;
for (const dir of entries) {
const dirPath = join(phasesDir, dir);
// Bug #3257 parity: scanPhasePlans handles nested plans/ subdirectories
// and the extended filename forms (e.g. 5-PLAN-01-setup.md). Without
// this, state.sync sees 0 plans for canonical nested layouts and emits
// bogus "Total Plans in Phase 0 -> 0" sync updates.
const { planCount: plans, summaryCount: summaries, completed } = scanPhasePlans(dirPath);
totalDiskPlans += plans;
totalDiskSummaries += summaries;
if (completed) diskCompletedPhases++;
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (phaseMatch && plans > 0 && summaries < plans) {
highestIncompletePhase = dir;
highestIncompletePhaseplanCount = plans;
}
}
// CJS parity: total_phases for the percent calculation is the count of
// phase directories in the active milestone (or the actual count on disk
// if no milestone filter is configured). Required so the phase-fraction
// cap in computeProgressPercent (#3242 Bug B) sees the right denominator.
const syncTotalPhases = entries.length;
const runModifier = (modified: string): string => {
let m = modified;
if (highestIncompletePhase) {
const currentPlansField = stateExtractField(m, 'Total Plans in Phase');
if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
const result = stateReplaceField(m, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
if (result) m = result;
}
}
// Use min(plan_fraction, phase_fraction) so ROADMAP-declared-but-
// unrealized future phases cap the reported percent (CJS bug #3242 Bug B
// parity). Fall back to 0 when computeProgressPercent returns null
// (totalDiskPlans === 0 case).
const computedPercent = computeProgressPercent(
totalDiskSummaries,
totalDiskPlans,
diskCompletedPhases,
syncTotalPhases,
);
const percent = computedPercent !== null ? computedPercent : 0;
const currentProgress = stateExtractField(m, 'Progress');
if (currentProgress) {
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
if (currentPercent !== percent) {
const barWidth = 10;
const filled = Math.round(percent / 100 * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const progressStr = `[${bar}] ${percent}%`;
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
const result = stateReplaceField(m, 'Progress', progressStr);
if (result) m = result;
}
}
const oldActivity = stateExtractField(m, 'Last Activity');
const r = stateReplaceField(m, 'Last Activity', today);
if (r) {
if (oldActivity !== today) {
changes.push(`Last Activity: ${oldActivity} -> ${today}`);
}
m = r;
}
return m;
};
if (verify) {
const body = stripFrontmatter(content);
runModifier(body);
return { data: { synced: false, changes, dry_run: true } };
}
await readModifyWriteStateMd(projectDir, (body) => runModifier(body), workstream);
return { data: { synced: true, changes, dry_run: false } };
};
// ─── statePrune ────────────────────────────────────────────────────────────
/**
* Parse phase number from a Performance Metrics table data row.
* Supports `stateRecordMetric` rows (`| Phase 3 P1 | ...`) and legacy `| 3 | ...` rows.
*/
function extractPerformanceMetricsRowPhase(line: string): number | null {
const phaseNamed = line.match(/^\|\s*Phase\s+(\d+)/i);
if (phaseNamed) return parseInt(phaseNamed[1], 10);
const legacy = line.match(/^\|\s*(\d+)\s*\|/);
if (legacy) return parseInt(legacy[1], 10);
return null;
}
interface PruneSection {
section: string;
count: number;
lines: string[];
}
/**
* Port of inner `prunePass` from state.cjs — mutates content string for sections
* older than `cutoff` phase number.
*/
function prunePass(content: string, cutoff: number): { newContent: string; archivedSections: PruneSection[] } {
const archivedSections: PruneSection[] = [];
let contentWork = content;
const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const decMatch = contentWork.match(decisionPattern);
if (decMatch) {
const lines = decMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of lines) {
const pm = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
if (pm && parseInt(pm[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
archivedSections.push({ section: 'Decisions', count: archive.length, lines: archive });
contentWork = contentWork.replace(decisionPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
const recentPattern = /(###?\s*Recently Completed\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const recMatch = contentWork.match(recentPattern);
if (recMatch) {
const lines = recMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of lines) {
const pm = line.match(/Phase\s+(\d+)/i);
if (pm && parseInt(pm[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
archivedSections.push({ section: 'Recently Completed', count: archive.length, lines: archive });
contentWork = contentWork.replace(recentPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const blockersMatch = contentWork.match(blockersPattern);
if (blockersMatch) {
const lines = blockersMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of lines) {
const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
const pm = line.match(/Phase\s+(\d+)/i);
if (isResolved && pm && parseInt(pm[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
archivedSections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive });
contentWork = contentWork.replace(blockersPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
const metricsPattern = /(###?\s*Performance Metrics\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const metricsMatch = contentWork.match(metricsPattern);
if (metricsMatch) {
const sectionLines = metricsMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of sectionLines) {
const rowPhase = extractPerformanceMetricsRowPhase(line);
if (rowPhase !== null) {
if (rowPhase <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
} else {
keep.push(line);
}
}
if (archive.length > 0) {
archivedSections.push({ section: 'Performance Metrics', count: archive.length, lines: archive });
contentWork = contentWork.replace(metricsPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
return { newContent: contentWork, archivedSections };
}
/**
* Port of `cmdStatePrune` from state.cjs.
* Args: `--keep-recent N` (default 3), `--dry-run`, `--silent` (omit extra logging fields — no-op in SDK JSON).
*/
export const statePrune: QueryHandler = async (args, projectDir, workstream) => {
const parsed = parseNamedArgs(args, ['keep-recent'], ['dry-run', 'silent']);
const parsedKeepRecent = Number.parseInt(String(parsed['keep-recent'] ?? '3'), 10);
if (!Number.isInteger(parsedKeepRecent) || parsedKeepRecent < 0) {
return { data: { error: 'keep-recent must be a non-negative integer' } };
}
const keepRecent = parsedKeepRecent;
const dryRun = parsed['dry-run'] === true;
const paths = planningPaths(projectDir, workstream);
const statePath = paths.state;
if (!existsSync(statePath)) {
return { data: { error: 'STATE.md not found' } };
}
const fullContent = await readFile(statePath, 'utf-8');
// Align with CJS state.cjs:1615 — read Current Phase from the body text first,
// fall back to 0 (same as CJS `parseInt(..., 10) || 0`).
const currentPhaseRaw = stateExtractField(fullContent, 'Current Phase');
const currentPhase = parseInt(String(currentPhaseRaw ?? '').trim(), 10) || 0;
const cutoff = currentPhase - keepRecent;
if (cutoff <= 0) {
return {
data: {
pruned: false,
reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}`,
},
};
}
const body = stripFrontmatter(fullContent);
if (dryRun) {
const result = prunePass(body, cutoff);
const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
return {
data: {
pruned: false,
dry_run: true,
cutoff_phase: cutoff,
keep_recent: keepRecent,
sections: result.archivedSections.map(s => ({
section: s.section,
entries_would_archive: s.count,
})),
total_would_archive: totalPruned,
note: totalPruned > 0 ? 'Run without --dry-run to actually prune' : 'Nothing to prune',
},
};
}
const archived: PruneSection[] = [];
await readModifyWriteStateMd(projectDir, (b) => {
const result = prunePass(b, cutoff);
archived.push(...result.archivedSections);
return result.newContent;
}, workstream);
const archivePath = join(paths.planning, 'STATE-ARCHIVE.md');
const totalPruned = archived.reduce((sum, s) => sum + s.count, 0);
if (archived.length > 0) {
const timestamp = new Date().toISOString().split('T')[0];
let archiveContent = '';
if (existsSync(archivePath)) {
archiveContent = readFileSync(archivePath, 'utf-8');
} else {
archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
}
archiveContent += `## Pruned ${timestamp} (phases 1-${cutoff}, kept recent ${keepRecent})\n\n`;
for (const section of archived) {
archiveContent += `### ${section.section}\n\n${section.lines.join('\n')}\n\n`;
}
writeFileSync(archivePath, archiveContent, 'utf-8');
}
return {
data: {
pruned: totalPruned > 0,
cutoff_phase: cutoff,
keep_recent: keepRecent,
sections: archived.map(s => ({ section: s.section, entries_archived: s.count })),
total_archived: totalPruned,
archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
},
};
};