* 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>
This commit is contained in:
@@ -42,7 +42,6 @@ export const VERIFY_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [
|
||||
{ canonical: 'verify.artifacts', aliases: ['verify artifacts'], subcommand: 'artifacts', mutation: false },
|
||||
{ canonical: 'verify.key-links', aliases: ['verify key-links'], subcommand: 'key-links', mutation: false },
|
||||
{ canonical: 'verify.schema-drift', aliases: ['verify schema-drift'], subcommand: 'schema-drift', mutation: false },
|
||||
{ canonical: 'verify.codebase-drift', aliases: ['verify codebase-drift'], subcommand: 'codebase-drift', mutation: false },
|
||||
] as const;
|
||||
|
||||
export const INIT_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [
|
||||
@@ -122,8 +121,6 @@ export const NON_FAMILY_COMMAND_ALIASES: readonly NonFamilyCommandAlias[] = [
|
||||
{ canonical: 'generate-claude-md', aliases: [], mutation: true },
|
||||
{ canonical: 'generate-claude-profile', aliases: [], mutation: true },
|
||||
{ canonical: 'generate-dev-preferences', aliases: [], mutation: true },
|
||||
{ canonical: 'intel.patch-meta', aliases: ['intel patch-meta'], mutation: true },
|
||||
{ canonical: 'intel.snapshot', aliases: ['intel snapshot'], mutation: true },
|
||||
{ canonical: 'learnings.copy', aliases: ['learnings copy'], mutation: true },
|
||||
{ canonical: 'learnings.delete', aliases: ['learnings delete'], mutation: true },
|
||||
{ canonical: 'learnings.prune', aliases: ['learnings prune'], mutation: true },
|
||||
|
||||
@@ -15,8 +15,11 @@ import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
|
||||
import {
|
||||
verifyPlanStructure, verifyPhaseCompleteness, verifyReferences,
|
||||
verifyCommits, verifyArtifacts, verifySchemaDrift,
|
||||
verifyCodebaseDrift,
|
||||
} from './verify.js';
|
||||
// verifyCodebaseDrift intentionally NOT imported — drift is out-of-seam
|
||||
// (CJS-only) per ADR/PRD docs/adr/3524-cjs-sdk-hard-seam.md §3 and
|
||||
// docs/prd/3524-cjs-sdk-hard-seam.md L160. The CJS router dispatches
|
||||
// verify codebase-drift directly to bin/lib/drift.cjs / verify.cjs.
|
||||
import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, validateContext } from './validate.js';
|
||||
import {
|
||||
phaseListPlans, phaseListArtifacts,
|
||||
@@ -71,7 +74,8 @@ export const FAMILY_HANDLERS: Record<string, Readonly<Record<string, QueryHandle
|
||||
'verify.artifacts': verifyArtifacts,
|
||||
'verify.key-links': verifyKeyLinks,
|
||||
'verify.schema-drift': verifySchemaDrift,
|
||||
'verify.codebase-drift': verifyCodebaseDrift,
|
||||
// 'verify.codebase-drift' intentionally omitted — out-of-seam CJS-only
|
||||
// per ADR/PRD 3524 §3 / L160. Router dispatches direct to CJS handler.
|
||||
},
|
||||
validate: {
|
||||
'validate.consistency': validateConsistency,
|
||||
|
||||
@@ -71,8 +71,9 @@ export const NON_FAMILY_COMMAND_MANIFEST: readonly NonFamilyCommandManifestEntry
|
||||
{ canonical: 'learnings.prune', aliases: ['learnings prune'], mutation: true, outputMode: 'json' },
|
||||
{ canonical: 'learnings.delete', aliases: ['learnings delete'], mutation: true, outputMode: 'json' },
|
||||
|
||||
{ canonical: 'intel.snapshot', aliases: ['intel snapshot'], mutation: true, outputMode: 'json' },
|
||||
{ canonical: 'intel.patch-meta', aliases: ['intel patch-meta'], mutation: true, outputMode: 'json' },
|
||||
// intel.* entries intentionally NOT in this manifest — intel is out-of-seam
|
||||
// (CJS-only) per ADR/PRD 3524 §3 / L160. Dispatch is direct from
|
||||
// get-shit-done/bin/gsd-tools.cjs `case 'intel':` to bin/lib/intel.cjs.
|
||||
|
||||
{ canonical: 'write-profile', aliases: [], mutation: true, outputMode: 'json' },
|
||||
{ canonical: 'generate-claude-profile', aliases: [], mutation: true, outputMode: 'json' },
|
||||
|
||||
@@ -11,5 +11,7 @@ export const VERIFY_COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
||||
{ family: 'verify', canonical: 'verify.artifacts', aliases: ['verify artifacts'], mutation: false, outputMode: 'json' },
|
||||
{ family: 'verify', canonical: 'verify.key-links', aliases: ['verify key-links'], mutation: false, outputMode: 'json' },
|
||||
{ family: 'verify', canonical: 'verify.schema-drift', aliases: ['verify schema-drift'], mutation: false, outputMode: 'json' },
|
||||
{ family: 'verify', canonical: 'verify.codebase-drift', aliases: ['verify codebase-drift'], mutation: false, outputMode: 'json' },
|
||||
// verify.codebase-drift intentionally NOT in this manifest — drift is
|
||||
// out-of-seam (CJS-only) per ADR/PRD 3524 §3 / L160. It dispatches
|
||||
// direct to bin/lib/drift.cjs via the CJS router.
|
||||
] as const;
|
||||
|
||||
@@ -13,7 +13,11 @@ import { skillManifest } from './skill-manifest.js';
|
||||
import { auditOpen } from './audit-open.js';
|
||||
import { detectCustomFiles } from './detect-custom-files.js';
|
||||
import { uatRenderCheckpoint, auditUat } from './uat.js';
|
||||
import { intelStatus, intelDiff, intelSnapshot, intelValidate, intelQuery, intelExtractExports, intelPatchMeta, intelUpdate } from './intel.js';
|
||||
// intel.* handlers intentionally NOT imported — intel (bin/lib/intel.cjs) is
|
||||
// out-of-seam (CJS-only) per ADR/PRD docs/adr/3524-cjs-sdk-hard-seam.md §3
|
||||
// and docs/prd/3524-cjs-sdk-hard-seam.md L160. Dispatch is handled directly
|
||||
// by the `case 'intel':` branch in get-shit-done/bin/gsd-tools.cjs which
|
||||
// requires('./lib/intel.cjs') and calls the CJS functions in-process.
|
||||
import { writeProfile, generateClaudeProfile, generateDevPreferences, generateClaudeMd } from './profile-output.js';
|
||||
import { phaseMvpMode, taskIsBehaviorAdding, userStoryValidate } from './mvp.js';
|
||||
import { worktreeCleanupWave } from './worktree.js';
|
||||
@@ -84,22 +88,8 @@ export const DOMAIN_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler
|
||||
['audit-uat', auditUat],
|
||||
['uat.render-checkpoint', uatRenderCheckpoint],
|
||||
['uat render-checkpoint', uatRenderCheckpoint],
|
||||
['intel.diff', intelDiff],
|
||||
['intel diff', intelDiff],
|
||||
['intel.snapshot', intelSnapshot],
|
||||
['intel snapshot', intelSnapshot],
|
||||
['intel.validate', intelValidate],
|
||||
['intel validate', intelValidate],
|
||||
['intel.status', intelStatus],
|
||||
['intel status', intelStatus],
|
||||
['intel.query', intelQuery],
|
||||
['intel query', intelQuery],
|
||||
['intel.extract-exports', intelExtractExports],
|
||||
['intel extract-exports', intelExtractExports],
|
||||
['intel.patch-meta', intelPatchMeta],
|
||||
['intel patch-meta', intelPatchMeta],
|
||||
['intel.update', intelUpdate],
|
||||
['intel update', intelUpdate],
|
||||
// intel.* entries removed — see import-section comment above. Intel verbs
|
||||
// dispatch via bin/gsd-tools.cjs `case 'intel':` direct to bin/lib/intel.cjs.
|
||||
['generate-claude-profile', generateClaudeProfile],
|
||||
['generate-dev-preferences', generateDevPreferences],
|
||||
['write-profile', writeProfile],
|
||||
|
||||
@@ -55,7 +55,15 @@ export const MUTATION_SURFACES_STATIC_CATALOG: ReadonlyArray<readonly [string, Q
|
||||
['config-set', configSet],
|
||||
['config-set-model-profile', configSetModelProfile],
|
||||
['config-new-project', configNewProject],
|
||||
['config-ensure-section', configEnsureSection],
|
||||
// Legacy contract: `config-ensure-section` (no positional arg) initialises
|
||||
// the full default .planning/config.json — semantically identical to
|
||||
// `config-new-project` with empty userChoices. Phase 6 originally bound
|
||||
// this to the `configEnsureSection` single-section-ensure handler, which
|
||||
// requires args[0]=sectionName the CLI never passes. Re-binding to
|
||||
// configNewProject restores the legacy contract on the SDK path.
|
||||
// `configEnsureSection` itself remains exported for any future SDK caller
|
||||
// that wants the single-section semantics.
|
||||
['config-ensure-section', configNewProject],
|
||||
['commit', commit],
|
||||
['check-commit', checkCommit],
|
||||
['template.fill', templateFill],
|
||||
|
||||
@@ -24,6 +24,7 @@ import { join } from 'node:path';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import { VALID_PROFILES, getAgentToModelMapForProfile } from './config-query.js';
|
||||
import { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS } from './config-schema.js';
|
||||
import { CONFIG_DEFAULTS } from '../configuration/index.js';
|
||||
import { planningPaths } from './helpers.js';
|
||||
import { acquireStateLock, releaseStateLock } from './state-mutation.js';
|
||||
import { maskIfSecret } from './secrets.js';
|
||||
@@ -281,7 +282,7 @@ export const configSet: QueryHandler = async (args, projectDir, workstream) => {
|
||||
if (!validation.valid) {
|
||||
const suggestion = validation.suggestion ? `. Did you mean: ${validation.suggestion}?` : '';
|
||||
throw new GSDError(
|
||||
`Unknown config key: "${keyPath}"${suggestion}`,
|
||||
`Unknown config key: ${keyPath}${suggestion}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
@@ -301,6 +302,123 @@ export const configSet: QueryHandler = async (args, projectDir, workstream) => {
|
||||
validateShipPrBodySections(parsedValue);
|
||||
}
|
||||
|
||||
// CJS parity (config.cjs:430-441): boolean-only keys must reject non-boolean
|
||||
// input. Without this, `config-set git.create_tag maybe` silently writes
|
||||
// "maybe" to disk under SDK dispatch even though the CJS path correctly
|
||||
// rejects it. Bug #3086.
|
||||
if (keyPath === 'workflow.post_planning_gaps' && typeof parsedValue !== 'boolean') {
|
||||
throw new GSDError(
|
||||
`Invalid workflow.post_planning_gaps '${rawValue}'. Must be a boolean (true or false).`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (keyPath === 'git.create_tag' && typeof parsedValue !== 'boolean') {
|
||||
throw new GSDError(
|
||||
`Invalid git.create_tag '${rawValue}'. Must be a boolean (true or false).`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
// Codebase drift detector value validation — port of config.cjs:430-437. (#2003)
|
||||
const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
|
||||
if (keyPath === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
|
||||
throw new GSDError(
|
||||
`Invalid workflow.drift_action '${rawValue}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (keyPath === 'workflow.drift_threshold') {
|
||||
if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) {
|
||||
throw new GSDError(
|
||||
`Invalid workflow.drift_threshold '${rawValue}'. Must be a positive integer.`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Human verification checkpoint mode (#3309) — port of config.cjs:457-460.
|
||||
const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase'];
|
||||
if (keyPath === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
|
||||
throw new GSDError(
|
||||
`Invalid workflow.human_verify_mode '${rawValue}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
// Context position enum validation (#2937) — port of config.cjs:463-466.
|
||||
const VALID_CONTEXT_POSITIONS = ['front', 'end'];
|
||||
if (keyPath === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
|
||||
throw new GSDError(
|
||||
`Invalid statusline.context_position '${rawValue}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallow scope + profile enum validation (#3424) — port of config.cjs:469-477.
|
||||
const VALID_FALLOW_SCOPES = ['phase', 'repo'];
|
||||
if (keyPath === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) {
|
||||
throw new GSDError(
|
||||
`Invalid code_quality.fallow.scope '${rawValue}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict'];
|
||||
if (keyPath === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) {
|
||||
throw new GSDError(
|
||||
`Invalid code_quality.fallow.profile '${rawValue}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
// review.default_reviewers (#3079) — port of normalizeConfiguredDefaultReviewers
|
||||
// from bin/lib/review-reviewer-selection.cjs. Validates array shape, rejects
|
||||
// empties, requires string slugs matching ^[a-zA-Z0-9_-]+$, and normalizes to
|
||||
// lowercase-unique order. `parsedValue` is rewritten in place so the persisted
|
||||
// value carries the normalized form (matching CJS config.cjs:479-483 behavior).
|
||||
let normalizedValue: unknown = parsedValue;
|
||||
if (keyPath === 'review.default_reviewers') {
|
||||
if (parsedValue === null || parsedValue === undefined) {
|
||||
throw new GSDError(
|
||||
'review.default_reviewers must be a JSON array of reviewer slugs',
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(parsedValue)) {
|
||||
throw new GSDError(
|
||||
'review.default_reviewers must be a JSON array of reviewer slugs',
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (parsedValue.length === 0) {
|
||||
throw new GSDError(
|
||||
'review.default_reviewers cannot be empty',
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const item of parsedValue) {
|
||||
if (typeof item !== 'string') {
|
||||
throw new GSDError(
|
||||
'review.default_reviewers must contain only string slugs',
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(item)) {
|
||||
throw new GSDError(
|
||||
`invalid reviewer slug in review.default_reviewers: ${item}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
const slug = item.toLowerCase();
|
||||
if (!seen.has(slug)) {
|
||||
seen.add(slug);
|
||||
normalized.push(slug);
|
||||
}
|
||||
}
|
||||
normalizedValue = normalized;
|
||||
}
|
||||
|
||||
// D6: Lock protection for read-modify-write (match CJS config.cjs:296)
|
||||
const paths = planningPaths(projectDir, workstream);
|
||||
const lockPath = await acquireStateLock(paths.config);
|
||||
@@ -315,7 +433,7 @@ export const configSet: QueryHandler = async (args, projectDir, workstream) => {
|
||||
}
|
||||
|
||||
previousValue = getValueAtPath(config, keyPath);
|
||||
setConfigValue(config, keyPath, parsedValue);
|
||||
setConfigValue(config, keyPath, normalizedValue);
|
||||
await atomicWriteConfig(paths.config, config);
|
||||
} finally {
|
||||
await releaseStateLock(lockPath);
|
||||
@@ -449,47 +567,57 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea
|
||||
const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || existsSync(join(homeDir, '.gsd', 'firecrawl_api_key')));
|
||||
const hasExaSearch = !!(process.env.EXA_API_KEY || existsSync(join(homeDir, '.gsd', 'exa_api_key')));
|
||||
|
||||
// Build default config
|
||||
// Build default config. Source is the canonical Configuration Module manifest
|
||||
// at sdk/shared/config-defaults.manifest.json (CONFIG_DEFAULTS from
|
||||
// sdk/src/configuration/index.ts) — but ONLY a subset is materialized at
|
||||
// init time. Legacy CJS `buildNewProjectConfig` (bin/lib/config.cjs:155-210)
|
||||
// intentionally omits keys whose value is meaningful only when set
|
||||
// explicitly so config-get returns "Key not found" and workflows fall back
|
||||
// to auto-detect (e.g. git.base_branch falls back to origin/HEAD
|
||||
// resolution). Keeping the SDK init shape aligned with CJS preserves that
|
||||
// workflow contract while the manifest remains the schema-wide source of
|
||||
// truth for validation and key existence (per ADR §6).
|
||||
//
|
||||
// Runtime API-key detection overrides the manifest's `false` defaults for
|
||||
// the three search providers — manifest comment explicitly notes this.
|
||||
const manifestDefaults = CONFIG_DEFAULTS as Record<string, unknown>;
|
||||
// Strip the metadata-only "_comment" key before it gets persisted.
|
||||
const { _comment: _ignoredComment, ...sanitizedManifest } = manifestDefaults;
|
||||
void _ignoredComment;
|
||||
|
||||
// Top-level keys present in the manifest but NOT in CJS init output. Each
|
||||
// either has its own resolution path (resolve_model_ids, context_window,
|
||||
// mode) or lives under a non-init heading (planning.*, graphify.* are
|
||||
// opt-in features users configure separately).
|
||||
const TOP_LEVEL_OMITTED_FROM_INIT = new Set([
|
||||
'resolve_model_ids', 'context_window', 'mode', 'planning', 'graphify',
|
||||
]);
|
||||
// Nested git keys omitted by CJS init. `git.base_branch` triggers
|
||||
// origin/HEAD auto-detect when absent — materializing `null` here would
|
||||
// suppress that and break ship-ready preflight (#3079).
|
||||
const GIT_KEYS_OMITTED_FROM_INIT = new Set(['base_branch']);
|
||||
|
||||
const filteredTopLevel: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(sanitizedManifest)) {
|
||||
if (TOP_LEVEL_OMITTED_FROM_INIT.has(k)) continue;
|
||||
filteredTopLevel[k] = v;
|
||||
}
|
||||
const manifestGit = (filteredTopLevel.git as Record<string, unknown>) || {};
|
||||
const filteredGit: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(manifestGit)) {
|
||||
if (GIT_KEYS_OMITTED_FROM_INIT.has(k)) continue;
|
||||
filteredGit[k] = v;
|
||||
}
|
||||
|
||||
const defaults: Record<string, unknown> = {
|
||||
model_profile: 'balanced',
|
||||
commit_docs: false,
|
||||
parallelization: 1,
|
||||
search_gitignored: false,
|
||||
...filteredTopLevel,
|
||||
git: filteredGit,
|
||||
brave_search: hasBraveSearch,
|
||||
firecrawl: hasFirecrawl,
|
||||
exa_search: hasExaSearch,
|
||||
git: {
|
||||
branching_strategy: 'none',
|
||||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||||
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
||||
quick_branch_template: null,
|
||||
},
|
||||
workflow: {
|
||||
research: true,
|
||||
plan_check: true,
|
||||
verifier: true,
|
||||
nyquist_validation: true,
|
||||
auto_advance: false,
|
||||
node_repair: true,
|
||||
node_repair_budget: 2,
|
||||
ui_phase: true,
|
||||
ui_safety_gate: true,
|
||||
text_mode: false,
|
||||
research_before_questions: false,
|
||||
discuss_mode: 'discuss',
|
||||
skip_discuss: false,
|
||||
code_review: true,
|
||||
code_review_depth: 'standard',
|
||||
},
|
||||
ship: {
|
||||
pr_body_sections: [],
|
||||
},
|
||||
hooks: {
|
||||
context_warnings: true,
|
||||
},
|
||||
project_code: null,
|
||||
phase_naming: 'sequential',
|
||||
agent_skills: {},
|
||||
// CJS `buildNewProjectConfig` includes `features: {}` as a hardcoded
|
||||
// top-level slot; the manifest doesn't yet — keep parity until the
|
||||
// manifest is amended in a separate enhancement.
|
||||
features: {},
|
||||
};
|
||||
|
||||
@@ -535,7 +663,9 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea
|
||||
|
||||
await atomicWriteConfig(paths.config, config);
|
||||
|
||||
return { data: { created: true, path: paths.config } };
|
||||
// Match CJS `ensureConfigFile` shape: report the relative project-rooted
|
||||
// path so output stays workspace-portable.
|
||||
return { data: { created: true, path: '.planning/config.json' } };
|
||||
};
|
||||
|
||||
// ─── configEnsureSection ──────────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +35,29 @@ import {
|
||||
|
||||
const RUNTIMES_WITH_REASONING_EFFORT = runtimesWithReasoningEffort();
|
||||
|
||||
/**
|
||||
* Schema-level defaults for well-known config keys.
|
||||
*
|
||||
* Mirrors the CJS table at get-shit-done/bin/lib/config.cjs:505-510 byte-for-
|
||||
* byte. When `config-get` lookups fall off the dot path and no `--default`
|
||||
* was supplied, the handler consults this map before throwing
|
||||
* `Key not found`. Without parity here, the SDK path emits
|
||||
* CONFIG_KEY_NOT_FOUND for keys the CJS path returns transparently — every
|
||||
* skill that reads `context_window`, `git.create_tag`, or executor stall
|
||||
* thresholds breaks under SDK dispatch.
|
||||
*
|
||||
* Bugs #2943, #3086, executor-stall-defaults tests — RED→GREEN via this
|
||||
* map. Keep this in lockstep with config.cjs:SCHEMA_DEFAULTS. Drift is
|
||||
* detected by the bug-2943 and #3086 behavioral suites: when the table
|
||||
* grows, both sides must grow together or those tests fail.
|
||||
*/
|
||||
const SCHEMA_DEFAULTS: Readonly<Record<string, string | number | boolean>> = Object.freeze({
|
||||
context_window: 200000,
|
||||
'executor.stall_detect_interval_minutes': 5,
|
||||
'executor.stall_threshold_minutes': 10,
|
||||
'git.create_tag': true,
|
||||
});
|
||||
|
||||
// ─── configGet ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -72,14 +95,29 @@ export const configGet: QueryHandler = async (args, projectDir, workstream) => {
|
||||
try {
|
||||
raw = await readFile(paths.config, 'utf-8');
|
||||
} catch {
|
||||
throw new GSDError(`No config.json found at ${paths.config}`, ErrorClassification.Validation);
|
||||
// config.json missing — CJS parity (config.cjs:524-533):
|
||||
// 1. --default beats everything
|
||||
// 2. else SCHEMA_DEFAULTS supply a documented value (#2943)
|
||||
// 3. else CONFIG_NO_FILE error
|
||||
if (defaultValue !== undefined) return { data: defaultValue };
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
return { data: SCHEMA_DEFAULTS[keyPath] };
|
||||
}
|
||||
const err = new GSDError(`No config.json found at ${paths.config}`, ErrorClassification.Validation);
|
||||
(err as GSDError & { reason?: string }).reason = 'config_no_file';
|
||||
throw err;
|
||||
}
|
||||
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new GSDError(`Malformed config.json at ${paths.config}`, ErrorClassification.Validation);
|
||||
// Lead the message with "Failed to read config.json" — matches the CJS
|
||||
// `cmdConfigGet` / `setConfigValue` error vocabulary so tests written
|
||||
// against the legacy contract keep matching.
|
||||
const err = new GSDError(`Failed to read config.json: malformed JSON at ${paths.config}`, ErrorClassification.Validation);
|
||||
(err as GSDError & { reason?: string }).reason = 'config_parse_failed';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const keys = keyPath.split('.');
|
||||
@@ -88,14 +126,26 @@ export const configGet: QueryHandler = async (args, projectDir, workstream) => {
|
||||
if (current === undefined || current === null || typeof current !== 'object') {
|
||||
// UNIX convention (cf. `git config --get`): missing key exits 1, not 10.
|
||||
// See issue #2544 — callers use `if ! gsd-sdk query config-get k; then` patterns.
|
||||
// CJS parity ordering (config.cjs:543-551): --default first, then
|
||||
// SCHEMA_DEFAULTS, then CONFIG_KEY_NOT_FOUND.
|
||||
if (defaultValue !== undefined) return { data: defaultValue };
|
||||
throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
return { data: SCHEMA_DEFAULTS[keyPath] };
|
||||
}
|
||||
const err = new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
|
||||
(err as GSDError & { reason?: string }).reason = 'config_key_not_found';
|
||||
throw err;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
if (current === undefined) {
|
||||
if (defaultValue !== undefined) return { data: defaultValue };
|
||||
throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
return { data: SCHEMA_DEFAULTS[keyPath] };
|
||||
}
|
||||
const err = new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
|
||||
(err as GSDError & { reason?: string }).reason = 'config_key_not_found';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Mask plaintext for keys in SECRET_CONFIG_KEYS to match CJS behavior at
|
||||
|
||||
@@ -97,9 +97,12 @@ describe('parseDecisions (#2492)', () => {
|
||||
});
|
||||
|
||||
it('does not crash on malformed bullet lines', () => {
|
||||
// Phase 6 (#3575): regex now accepts alphanumeric IDs (D-[A-Za-z0-9_-]+).
|
||||
// D-bogus IS now valid (pure alpha segment); only truly malformed patterns
|
||||
// (no D- prefix, wrong bullet syntax) are rejected.
|
||||
const malformed = `<decisions>
|
||||
- not a decision (no D-NN)
|
||||
- **D-bogus:** wrong id format
|
||||
- **D-bogus:** alphanumeric id — now accepted since Phase 6
|
||||
- **D-7:** single digit allowed
|
||||
- **D-10:** ten
|
||||
</decisions>`;
|
||||
@@ -107,7 +110,10 @@ describe('parseDecisions (#2492)', () => {
|
||||
const ids = decisions.map((d) => d.id);
|
||||
expect(ids).toContain('D-7');
|
||||
expect(ids).toContain('D-10');
|
||||
expect(ids).not.toContain('D-bogus');
|
||||
// D-bogus IS now accepted — alphanumeric IDs are valid since Phase 6 (#3575)
|
||||
expect(ids).toContain('D-bogus');
|
||||
// Pure non-bullet text is still not parsed as a decision
|
||||
expect(ids).not.toContain('D-NN');
|
||||
});
|
||||
|
||||
it('preserves multi-line decision text continuations', () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ import { isAbsolute, join } from 'node:path';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
export interface ParsedDecision {
|
||||
/** Stable id: `D-01`, `D-7`, `D-42`. */
|
||||
/** Stable id: `D-01`, `D-42`, `D-INFRA-01`, `D-FOO_BAR`. Numeric or alphanumeric. */
|
||||
id: string;
|
||||
/** Body text (everything after `**D-NN[ tags]:**` up to next bullet/blank). */
|
||||
text: string;
|
||||
@@ -93,7 +93,11 @@ export function parseDecisions(content: string): ParsedDecision[] {
|
||||
let inDiscretion = false;
|
||||
|
||||
// Bullet line: `- **D-NN[ [tags]]:** text`
|
||||
const bulletRe = /^\s*-\s+\*\*D-(\d+)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/;
|
||||
// Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR)
|
||||
// in addition to numeric-only IDs (D-42). The first character after `D-` must
|
||||
// be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
|
||||
// CJS callers consume {id, text} and ignore the optional extras.
|
||||
const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/;
|
||||
|
||||
let current: ParsedDecision | null = null;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import { extractFrontmatter } from './frontmatter.js';
|
||||
import { normalizeMd, resolvePathUnderProject } from './helpers.js';
|
||||
import { normalizeMd, resolveFrontmatterPath } from './helpers.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
// ─── FRONTMATTER_SCHEMAS ──────────────────────────────────────────────────
|
||||
@@ -193,15 +193,10 @@ export const frontmatterSet: QueryHandler = async (args, projectDir) => {
|
||||
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
let fullPath: string;
|
||||
try {
|
||||
fullPath = await resolvePathUnderProject(projectDir, filePath);
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
return { data: { error: err.message, path: filePath } };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// CJS parity (frontmatter.cjs:340/354/369): no project-root prefix check.
|
||||
// Bug #3509 — accept absolute paths outside the project (tmpdirs whose
|
||||
// names include spaces fail the under-project test on macOS).
|
||||
const fullPath = resolveFrontmatterPath(projectDir, filePath);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
@@ -245,15 +240,10 @@ export const frontmatterMerge: QueryHandler = async (args, projectDir) => {
|
||||
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
let fullPath: string;
|
||||
try {
|
||||
fullPath = await resolvePathUnderProject(projectDir, filePath);
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
return { data: { error: err.message, path: filePath } };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// CJS parity (frontmatter.cjs:340/354/369): no project-root prefix check.
|
||||
// Bug #3509 — accept absolute paths outside the project (tmpdirs whose
|
||||
// names include spaces fail the under-project test on macOS).
|
||||
const fullPath = resolveFrontmatterPath(projectDir, filePath);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
@@ -318,15 +308,10 @@ export const frontmatterValidate: QueryHandler = async (args, projectDir) => {
|
||||
);
|
||||
}
|
||||
|
||||
let fullPath: string;
|
||||
try {
|
||||
fullPath = await resolvePathUnderProject(projectDir, filePath);
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
return { data: { error: err.message, path: filePath } };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// CJS parity (frontmatter.cjs:340/354/369): no project-root prefix check.
|
||||
// Bug #3509 — accept absolute paths outside the project (tmpdirs whose
|
||||
// names include spaces fail the under-project test on macOS).
|
||||
const fullPath = resolveFrontmatterPath(projectDir, filePath);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
import { escapeRegex, resolvePathUnderProject } from './helpers.js';
|
||||
import { escapeRegex, resolveFrontmatterPath } from './helpers.js';
|
||||
|
||||
// ─── splitInlineArray ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -363,15 +363,10 @@ export const frontmatterGet: QueryHandler = async (args, projectDir) => {
|
||||
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
let fullPath: string;
|
||||
try {
|
||||
fullPath = await resolvePathUnderProject(projectDir, filePath);
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
return { data: { error: err.message, path: filePath } };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// CJS parity (frontmatter.cjs:323): no project-root prefix check — accept
|
||||
// any absolute path (and macOS tmpdir paths whose names contain spaces).
|
||||
// Bug #3509.
|
||||
const fullPath = resolveFrontmatterPath(projectDir, filePath);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
@@ -381,7 +376,12 @@ export const frontmatterGet: QueryHandler = async (args, projectDir) => {
|
||||
}
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const field = args[1];
|
||||
// CLI invocation is `frontmatter get <file> --field <name>`; the CJS router
|
||||
// passes args.slice(2) = [file, '--field', name] to the SDK. Previously the
|
||||
// handler treated args[1] as the field name and saw `'--field'`. Parse the
|
||||
// flag so both invocation shapes work (positional second arg AND --field).
|
||||
const fieldFlagIdx = args.indexOf('--field');
|
||||
const field = fieldFlagIdx >= 0 ? args[fieldFlagIdx + 1] : args[1];
|
||||
|
||||
if (field) {
|
||||
const value = fm[field];
|
||||
|
||||
@@ -493,6 +493,26 @@ export async function resolvePathUnderProject(projectDir: string, userPath: stri
|
||||
return realCandidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied file path the way CJS frontmatter handlers do.
|
||||
*
|
||||
* Mirrors `path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath)`
|
||||
* from get-shit-done/bin/lib/frontmatter.cjs (lines 323, 340, 354, 369).
|
||||
* Does NOT enforce the "under project root" prefix check — frontmatter
|
||||
* verbs accept arbitrary absolute paths (the user is naming a file outside
|
||||
* `.planning/`, often a phase-scoped plan in an external location, or a
|
||||
* tmpdir inside `/var/folders` whose path includes spaces).
|
||||
*
|
||||
* Bug #3509 parity: tests on macOS use `os.tmpdir()` directories that
|
||||
* resolve outside the project root; the project-scoped variant was
|
||||
* rejecting them with "path escapes project directory". Use this helper
|
||||
* for the frontmatter family. Use `resolvePathUnderProject` for commands
|
||||
* that must stay inside the project (e.g. template output, decisions).
|
||||
*/
|
||||
export function resolveFrontmatterPath(projectDir: string, userPath: string): string {
|
||||
return isAbsolute(userPath) ? normalize(userPath) : resolve(projectDir, userPath);
|
||||
}
|
||||
|
||||
// ─── sanitizeForDisplay (security.cjs) ───────────────────────────────────────
|
||||
|
||||
/** Port of `sanitizeForPrompt` from `security.cjs`. */
|
||||
|
||||
@@ -642,16 +642,13 @@ export const initManager: QueryHandler = async (_args, projectDir, workstream) =
|
||||
}
|
||||
}
|
||||
|
||||
// Sliding window: only first undiscussed phase is available to discuss
|
||||
let foundNextToDiscuss = false;
|
||||
// Bug #2268: mark EVERY undiscussed phase as is_next_to_discuss, not just
|
||||
// the first one. Multiple independent phases can be discussed in parallel
|
||||
// — the sliding-window pattern made the manager only recommend one
|
||||
// discuss action even when callers had free capacity to discuss several.
|
||||
for (const phase of phases) {
|
||||
const status = phase.disk_status as string;
|
||||
if (!foundNextToDiscuss && (status === 'empty' || status === 'no_directory')) {
|
||||
phase.is_next_to_discuss = true;
|
||||
foundNextToDiscuss = true;
|
||||
} else {
|
||||
phase.is_next_to_discuss = false;
|
||||
}
|
||||
phase.is_next_to_discuss = (status === 'empty' || status === 'no_directory');
|
||||
}
|
||||
|
||||
// Check WAITING.json signal
|
||||
|
||||
@@ -22,11 +22,13 @@ import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join, relative, basename } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
|
||||
import { loadConfig, type GSDConfig } from '../config.js';
|
||||
import { resolveModel, MODEL_PROFILES } from './config-query.js';
|
||||
import { maskIfSecret } from './secrets.js';
|
||||
import { findPhase } from './phase.js';
|
||||
import { getMilestonePhaseFilter } from './state.js';
|
||||
import { roadmapGetPhase, getMilestoneInfo, extractCurrentMilestone, extractPhasesFromSection } from './roadmap.js';
|
||||
import { determinePhaseStatus } from './progress.js';
|
||||
import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from './helpers.js';
|
||||
@@ -141,13 +143,16 @@ function computeExpectedPhaseDirName(
|
||||
async function shouldDropArchivedPhaseMatch(
|
||||
phaseInfo: Record<string, unknown> | null,
|
||||
roadmapPhase: Record<string, unknown> | null,
|
||||
projectDir: string,
|
||||
workstream?: string,
|
||||
_projectDir: string,
|
||||
_workstream?: string,
|
||||
): Promise<boolean> {
|
||||
if (!phaseInfo?.archived || !roadmapPhase || !roadmapPhase.found) return false;
|
||||
const archivedTag = String(phaseInfo.archived ?? '');
|
||||
const milestone = await getMilestoneInfo(projectDir, workstream);
|
||||
if (milestone?.version && archivedTag === milestone.version) return false;
|
||||
// Matches CJS cmdInitPlanPhase / cmdInitExecutePhase / cmdInitVerifyWork:
|
||||
// if (phaseInfo?.archived && roadmapPhase?.found) phaseInfo = null;
|
||||
// Unconditional drop — the ROADMAP is authoritative for the current milestone,
|
||||
// regardless of what archived milestone the on-disk match came from. Do NOT add
|
||||
// a milestone-version equality check (#2391 regression risk).
|
||||
if (!phaseInfo?.archived) return false;
|
||||
if (!roadmapPhase || !roadmapPhase.found) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -367,6 +372,13 @@ export const initExecutePhase: QueryHandler = async (args, projectDir, workstrea
|
||||
return { data: { error: 'phase required for init execute-phase' } };
|
||||
}
|
||||
|
||||
// --tdd is a boolean override of config.workflow.tdd_mode — matches the CJS
|
||||
// path's parseNamedArgs(args, [], ['validate', 'tdd']) projection
|
||||
// (bin/lib/init-command-router.cjs handler block) which passes options.tdd
|
||||
// through to cmdInitExecutePhase. Without parsing here, `gsd-tools init
|
||||
// execute-phase 1 --tdd` would never override a false config value.
|
||||
const tddFlag = args.includes('--tdd');
|
||||
|
||||
const config = await loadConfig(projectDir);
|
||||
const paths = planningPaths(projectDir, workstream);
|
||||
const planningDir = paths.planning;
|
||||
@@ -394,7 +406,7 @@ export const initExecutePhase: QueryHandler = async (args, projectDir, workstrea
|
||||
const result: Record<string, unknown> = {
|
||||
executor_model: executorModel,
|
||||
verifier_model: verifierModel,
|
||||
tdd_mode: config.workflow.tdd_mode ?? false,
|
||||
tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false),
|
||||
commit_docs: config.commit_docs,
|
||||
sub_repos: (config as Record<string, unknown>).sub_repos ?? [],
|
||||
parallelization: config.parallelization,
|
||||
@@ -450,6 +462,10 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
return { data: { error: 'phase required for init plan-phase' } };
|
||||
}
|
||||
|
||||
// --tdd boolean override (parity with CJS router's parseNamedArgs + the
|
||||
// legacy cmdInitPlanPhase `options.tdd || config.tdd_mode || false`).
|
||||
const tddFlag = args.includes('--tdd');
|
||||
|
||||
const config = await loadConfig(projectDir);
|
||||
const paths = planningPaths(projectDir, workstream);
|
||||
const planningDir = paths.planning;
|
||||
@@ -498,7 +514,7 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
researcher_model: researcherModel,
|
||||
planner_model: plannerModel,
|
||||
checker_model: checkerModel,
|
||||
tdd_mode: config.workflow.tdd_mode ?? false,
|
||||
tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false),
|
||||
research_enabled: config.workflow.research,
|
||||
plan_checker_enabled: config.workflow.plan_check,
|
||||
nyquist_validation_enabled: config.workflow.nyquist_validation,
|
||||
@@ -568,8 +584,14 @@ export const initNewMilestone: QueryHandler = async (_args, projectDir) => {
|
||||
let phaseDirCount = 0;
|
||||
try {
|
||||
if (existsSync(phasesDir)) {
|
||||
// Bug #2445 parity with CJS `cmdInitNewMilestone`: filter phase dirs
|
||||
// to the current milestone so stale dirs from a prior milestone that
|
||||
// weren't archived don't inflate the count. Without this filter the
|
||||
// SDK returns the full directory count, which the new-milestone
|
||||
// workflow then uses to gate "is this a fresh start" decisions.
|
||||
const isDirInMilestone = await getMilestonePhaseFilter(projectDir);
|
||||
phaseDirCount = readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.filter(entry => entry.isDirectory() && isDirInMilestone(entry.name))
|
||||
.length;
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
@@ -1052,7 +1074,12 @@ export const initMapCodebase: QueryHandler = async (_args, projectDir) => {
|
||||
commit_docs: config.commit_docs,
|
||||
search_gitignored: config.search_gitignored,
|
||||
parallelization: config.parallelization,
|
||||
subagent_timeout: (config as Record<string, unknown>).subagent_timeout ?? undefined,
|
||||
// subagent_timeout lives at workflow.subagent_timeout per the canonical
|
||||
// Configuration manifest (sdk/shared/config-defaults.manifest.json). Reading
|
||||
// the top-level config.subagent_timeout returned undefined, so the workflow
|
||||
// step that consumes this value had to invent its own fallback. Default to
|
||||
// 300000 (5 min) per the manifest. (#1472)
|
||||
subagent_timeout: (((config as Record<string, unknown>).workflow as Record<string, unknown> | undefined)?.subagent_timeout as number | undefined) ?? 300000,
|
||||
date: now.toISOString().split('T')[0],
|
||||
timestamp: now.toISOString(),
|
||||
codebase_dir: '.planning/codebase',
|
||||
@@ -1174,12 +1201,18 @@ export const initListWorkspaces: QueryHandler = async (_args, _projectDir) => {
|
||||
export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => {
|
||||
const name = args[0];
|
||||
if (!name) {
|
||||
return { data: { error: 'workspace name required for init remove-workspace' } };
|
||||
// Throw so the CLI dispatcher projects a non-zero exit + writes the message
|
||||
// to stderr — returning `{ data: { error } }` was treated as success by
|
||||
// the CLI output path, hiding the validation failure from callers.
|
||||
throw new GSDError('workspace name required for init remove-workspace', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
// T-14-01: Reject path traversal attempts
|
||||
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||||
return { data: { error: `Invalid workspace name: ${name} (path separators not allowed)` } };
|
||||
throw new GSDError(
|
||||
`Invalid workspace name: ${name} (path separators not allowed)`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
const home = process.env.HOME || homedir();
|
||||
@@ -1188,7 +1221,7 @@ export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => {
|
||||
const manifestPath = join(wsPath, 'WORKSPACE.md');
|
||||
|
||||
if (!existsSync(wsPath)) {
|
||||
return { data: { error: `Workspace not found: ${wsPath}` } };
|
||||
throw new GSDError(`Workspace not found: ${wsPath}`, ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
const repos: Array<Record<string, unknown>> = [];
|
||||
|
||||
@@ -32,8 +32,9 @@ import {
|
||||
planningPaths,
|
||||
} from './helpers.js';
|
||||
import { extractFrontmatter } from './frontmatter.js';
|
||||
import { extractCurrentMilestone } from './roadmap.js';
|
||||
import { extractCurrentMilestone, phaseMarkdownRegexSource } from './roadmap.js';
|
||||
import { getMilestonePhaseFilter } from './state.js';
|
||||
import { isCanonicalPlanFile, describeNonCanonicalPlans } from './phase.js';
|
||||
import {
|
||||
acquireStateLock,
|
||||
readModifyWriteStateMdFull,
|
||||
@@ -86,27 +87,43 @@ export { readModifyWriteRoadmapMd, replaceInCurrentMilestone };
|
||||
*/
|
||||
export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
|
||||
// ── Flag parsing ────────────────────────────────────────────────────────
|
||||
// Separate recognized flags from positional args. Any unrecognized --flag
|
||||
// is rejected immediately so it is never silently absorbed into positional slots.
|
||||
const RECOGNIZED_FLAGS = new Set(['--dry-run']);
|
||||
// Mirrors the CJS phase add router (phase-command-router.cjs): recognise
|
||||
// --dry-run and --id <value>; reject every other --flag; ignore --raw so it
|
||||
// never leaks into the description; join the remaining positional tokens
|
||||
// with a single space so multi-word descriptions like `phase add User
|
||||
// Dashboard` produce description "User Dashboard". customId comes from the
|
||||
// --id flag, never from positional[1].
|
||||
let dryRun = false;
|
||||
let customIdArg: string | null = null;
|
||||
const positional: string[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith('--')) {
|
||||
if (!RECOGNIZED_FLAGS.has(arg)) {
|
||||
throw new GSDError(
|
||||
`Unknown flag ${arg} for phase.add`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (arg === '--dry-run') dryRun = true;
|
||||
} else {
|
||||
positional.push(arg);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
if (arg === '--raw') {
|
||||
// CJS router strips --raw before invoking the handler; preserve parity
|
||||
// so a stray --raw never poisons the description.
|
||||
continue;
|
||||
}
|
||||
if (arg === '--dry-run') {
|
||||
dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--id') {
|
||||
const id = args[i + 1];
|
||||
if (!id || id.startsWith('--')) {
|
||||
throw new GSDError('--id requires a value', ErrorClassification.Validation);
|
||||
}
|
||||
customIdArg = id;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--')) {
|
||||
throw new GSDError(`phase add does not support ${arg}`, ErrorClassification.Validation);
|
||||
}
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
const description = positional[0];
|
||||
const description = positional.join(' ').trim();
|
||||
if (!description) {
|
||||
throw new GSDError('description required for phase add', ErrorClassification.Validation);
|
||||
}
|
||||
@@ -119,8 +136,9 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
|
||||
} catch { /* use defaults */ }
|
||||
|
||||
const slug = generatePhaseSlug(description);
|
||||
// positional[1] is the optional customId — flags are already stripped
|
||||
const customId = positional[1] || null;
|
||||
// customId always comes from the --id flag; positional tokens are reserved
|
||||
// for the description (which is joined above).
|
||||
const customId = customIdArg;
|
||||
|
||||
// Optional project code prefix (e.g., 'CK' -> 'CK-01-foundation')
|
||||
const projectCode = (config.project_code as string) || '';
|
||||
@@ -227,17 +245,25 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
|
||||
export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream) => {
|
||||
let descriptions: string[];
|
||||
const descIdx = args.indexOf('--descriptions');
|
||||
if (descIdx !== -1 && args[descIdx + 1] !== undefined) {
|
||||
try {
|
||||
const parsed = JSON.parse(args[descIdx + 1]) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
|
||||
}
|
||||
descriptions = parsed.map((x) => String(x));
|
||||
} catch (e) {
|
||||
if (e instanceof GSDError) throw e;
|
||||
throw new GSDError('--descriptions must be a valid JSON array', ErrorClassification.Validation);
|
||||
if (descIdx !== -1) {
|
||||
// CJS router parity (phase-command-router.cjs): a dangling --descriptions
|
||||
// or one whose value is another flag must surface the same JSON-array error
|
||||
// string, not silently fall through to positional parsing or throw a
|
||||
// different "valid JSON" variant.
|
||||
const rawValue = args[descIdx + 1];
|
||||
if (rawValue === undefined || rawValue.startsWith('--')) {
|
||||
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawValue);
|
||||
} catch {
|
||||
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
|
||||
}
|
||||
descriptions = parsed.map((x) => String(x));
|
||||
} else {
|
||||
descriptions = args.filter((a) => a !== '--raw');
|
||||
}
|
||||
@@ -345,8 +371,25 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream)
|
||||
* @returns QueryResult with { phase_number, after_phase, name, slug, directory }
|
||||
*/
|
||||
export const phaseInsert: QueryHandler = async (args, projectDir, workstream) => {
|
||||
const afterPhase = args[0];
|
||||
const description = args[1];
|
||||
// CJS router parity (phase-command-router.cjs): explicitly reject
|
||||
// --dry-run (insert is destructive on disk + roadmap and has no preview
|
||||
// path), strip --raw, and join all positional args after `afterPhase` into
|
||||
// a single space-delimited description so `phase insert 1 Fix Critical Bug`
|
||||
// produces description "Fix Critical Bug" instead of just "Fix".
|
||||
const positional: string[] = [];
|
||||
for (const arg of args) {
|
||||
if (arg === '--dry-run') {
|
||||
throw new GSDError('phase insert does not support --dry-run', ErrorClassification.Validation);
|
||||
}
|
||||
if (arg === '--raw') continue;
|
||||
if (arg.startsWith('--')) {
|
||||
throw new GSDError(`phase insert does not support ${arg}`, ErrorClassification.Validation);
|
||||
}
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
const afterPhase = positional[0];
|
||||
const description = positional.slice(1).join(' ').trim();
|
||||
|
||||
if (!afterPhase || !description) {
|
||||
throw new GSDError('after-phase and description required for phase insert', ErrorClassification.Validation);
|
||||
@@ -367,6 +410,19 @@ export const phaseInsert: QueryHandler = async (args, projectDir, workstream) =>
|
||||
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
|
||||
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
|
||||
if (!targetPattern.test(content)) {
|
||||
// Bug #3098 parity: when only the summary checklist exists for this
|
||||
// phase (no `### Phase N:` detail section), point the user at the
|
||||
// missing detail section rather than implying the phase is absent.
|
||||
const checklistPattern = new RegExp(
|
||||
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+0*${afterPhaseEscaped}:`,
|
||||
'i',
|
||||
);
|
||||
if (checklistPattern.test(content)) {
|
||||
throw new GSDError(
|
||||
`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
throw new GSDError(`Phase ${afterPhase} not found in ROADMAP.md`, ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
@@ -699,7 +755,11 @@ async function renameIntegerPhases(
|
||||
const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
|
||||
if (!m) return null;
|
||||
const dirInt = parseInt(m[1], 10);
|
||||
if (dirInt <= removedInt) return null;
|
||||
// CJS parity: skip backlog phases (999.x). These are parked ideas with a
|
||||
// numbering convention that lives outside the active sequence; renumbering
|
||||
// them would clobber the convention and corrupt downstream lookups.
|
||||
// (bug-2434)
|
||||
if (dirInt <= removedInt || dirInt >= 999) return null;
|
||||
return {
|
||||
dir,
|
||||
oldInt: dirInt,
|
||||
@@ -742,12 +802,65 @@ async function renameIntegerPhases(
|
||||
|
||||
// ─── updateRoadmapAfterPhaseRemoval ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decrement integer phase number while skipping non-renumbered ranges. Mirrors
|
||||
* `decrementRoadmapPhaseNumber` in phase.cjs lines 860-864.
|
||||
*
|
||||
* Skips when:
|
||||
* • not an integer
|
||||
* • num <= removedInt (already-renumbered phases stay put)
|
||||
* • num >= 999 (backlog/parked-idea numbering range)
|
||||
*
|
||||
* Returns the original raw string when the guards trip so the regex pass
|
||||
* leaves dates and unrelated numerics intact.
|
||||
*/
|
||||
function decrementRoadmapPhaseNumber(raw: string, removedInt: number): string {
|
||||
const num = parseInt(raw, 10);
|
||||
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
|
||||
return String(num - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement integer or decimal phase token (e.g. "5" or "5.2"). Mirrors
|
||||
* `decrementRoadmapPhaseToken` in phase.cjs lines 866-872 — preserves the
|
||||
* decimal suffix when present and applies the same guards.
|
||||
*/
|
||||
function decrementRoadmapPhaseToken(raw: string, removedInt: number): string {
|
||||
const match = String(raw).match(/^(\d+)(\.\d+)?$/);
|
||||
if (!match) return raw;
|
||||
const num = parseInt(match[1]!, 10);
|
||||
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
|
||||
return `${num - 1}${match[2] || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement zero-padded phase number while preserving the original pad width.
|
||||
* Mirrors `decrementRoadmapPaddedPhaseNumber` in phase.cjs lines 874-878.
|
||||
*/
|
||||
function decrementRoadmapPaddedPhaseNumber(raw: string, removedInt: number): string {
|
||||
const num = parseInt(raw, 10);
|
||||
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
|
||||
return String(num - 1).padStart(raw.length, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a phase section from ROADMAP.md and renumber subsequent integer phases.
|
||||
*
|
||||
* Port of updateRoadmapAfterPhaseRemoval from phase.cjs lines 569-595.
|
||||
* Port of updateRoadmapAfterPhaseRemoval from phase.cjs lines 880-922.
|
||||
* Uses readModifyWriteRoadmapMd for atomic writes.
|
||||
*
|
||||
* The renumbering pass uses **5 targeted regex replacements** (not a loop)
|
||||
* because the loop approach is dangerous:
|
||||
* • It can match YYYY-MM-DD substrings and corrupt dates (bug-2435).
|
||||
* • It can rename backlog phases (999.x) that should stay frozen (bug-2434).
|
||||
* • It can renumber the same phase multiple times if the regex matches
|
||||
* overlap (bug-3355 — phase 7 → 6 → 5 → ...).
|
||||
*
|
||||
* The CJS pattern uses negative lookbehind/ahead on the padded-prefix regex
|
||||
* to skip dates and decrement helpers that guard against `num >= 999`. Keep
|
||||
* this implementation byte-for-byte in lockstep with phase.cjs:880-922 —
|
||||
* deviations are how the three bugs above slipped in.
|
||||
*
|
||||
* @param projectDir - Project root directory
|
||||
* @param targetPhase - Phase identifier that was removed
|
||||
* @param isDecimal - Whether the removed phase was a decimal phase
|
||||
@@ -763,9 +876,30 @@ async function updateRoadmapAfterPhaseRemoval(
|
||||
await readModifyWriteRoadmapMd(projectDir, (content) => {
|
||||
const escaped = escapeRegex(targetPhase);
|
||||
|
||||
// Remove the phase section (header + body until next phase header or end)
|
||||
// Remove the phase section (header + body until next phase header or end).
|
||||
//
|
||||
// #3601: the end-of-section lookahead is DEPTH-AWARE. The named capture
|
||||
// (?<h>#{2,4}) records the hash count of the header being removed and the
|
||||
// lookahead requires the same depth via \k<h>(?!#). Two contracts are
|
||||
// preserved:
|
||||
//
|
||||
// (#3601 case) Remove `### Phase 2:` and stop at `### Phase 2.1:` —
|
||||
// Phase 2.1 is a peer-level decimal phase (depth 3) and must survive.
|
||||
//
|
||||
// (#3355 case) Remove `### Phase 27:` and CONTINUE past
|
||||
// `#### Phase 27.1:` (depth 4 — child of Phase 27) until the next
|
||||
// depth-3 header. The child decimal is part of the integer phase
|
||||
// being removed.
|
||||
//
|
||||
// The `(?!#)` negative lookahead after the backreference prevents the
|
||||
// depth-3 match from being satisfied by a depth-4+ header that starts
|
||||
// with the same three hashes. `[^\n:]+` accepts numeric, decimal, AND
|
||||
// custom phase IDs (PROJ-42) as terminators.
|
||||
content = content.replace(
|
||||
new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'),
|
||||
new RegExp(
|
||||
`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`,
|
||||
'i',
|
||||
),
|
||||
'',
|
||||
);
|
||||
|
||||
@@ -781,46 +915,59 @@ async function updateRoadmapAfterPhaseRemoval(
|
||||
'',
|
||||
);
|
||||
|
||||
// For integer phase removal, renumber all subsequent phases in ROADMAP text
|
||||
if (!isDecimal) {
|
||||
const MAX_PHASE = 99;
|
||||
for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) {
|
||||
const newNum = oldNum - 1;
|
||||
const oldStr = String(oldNum);
|
||||
const newStr = String(newNum);
|
||||
const oldPad = oldStr.padStart(2, '0');
|
||||
const newPad = newStr.padStart(2, '0');
|
||||
// Phase headers: ### Phase N: / ### Phase N.M:
|
||||
content = content.replace(
|
||||
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi,
|
||||
(_match, prefix: string, num: string, suffix: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`,
|
||||
);
|
||||
|
||||
// Renumber phase headers: ### Phase N:
|
||||
content = content.replace(
|
||||
new RegExp(`(#{2,4}\\s*Phase\\s+)${escapeRegex(oldStr)}(\\s*:)`, 'gi'),
|
||||
`$1${newStr}$2`,
|
||||
);
|
||||
// Checkbox-list summary references: `- [ ] Phase N:`
|
||||
content = content.replace(
|
||||
/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi,
|
||||
(_match, prefix: string, num: string, suffix: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`,
|
||||
);
|
||||
|
||||
// Renumber inline Phase N references
|
||||
content = content.replace(
|
||||
new RegExp(`(Phase\\s+)${escapeRegex(oldStr)}([:\\s])`, 'g'),
|
||||
`$1${newStr}$2`,
|
||||
);
|
||||
// Table-row phase numbers: `| N. ` — bare integer in a cell.
|
||||
content = content.replace(
|
||||
/(\|\s*)(\d+)(\.\s)/g,
|
||||
(_match, prefix: string, num: string, suffix: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`,
|
||||
);
|
||||
|
||||
// Renumber padded plan references: 07-01 -> 06-01
|
||||
content = content.replace(
|
||||
new RegExp(`${escapeRegex(oldPad)}-(\\d{2})`, 'g'),
|
||||
`${newPad}-$1`,
|
||||
);
|
||||
// Padded plan references: NN-NN (optionally followed by an arbitrary
|
||||
// kebab-case slug, then -PLAN.md / -SUMMARY.md).
|
||||
//
|
||||
// #2435: negative lookbehind `(?<![0-9-])` and negative lookahead
|
||||
// `(?![0-9-])` exclude YYYY-MM-DD substrings.
|
||||
//
|
||||
// #3602: the original pattern only allowed a compact `-(PLAN|SUMMARY).md`
|
||||
// immediately after the plan number; a slug between the number and the
|
||||
// `-PLAN.md` / `-SUMMARY.md` suffix (e.g.
|
||||
// `07-01-cherry-pick-foundation-PLAN.md`) made the lookahead fail and
|
||||
// left the stale `07-01-` prefix in ROADMAP text while the on-disk file
|
||||
// was already renumbered to `06-01-…`. The slug segment
|
||||
// `(?:-[A-Za-z][A-Za-z0-9-]*)*` allows any number of kebab-case tokens
|
||||
// before the canonical PLAN/SUMMARY suffix.
|
||||
content = content.replace(
|
||||
/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)*-(?:PLAN|SUMMARY)\.md)?(?![0-9-]))/g,
|
||||
(_match, phaseNum: string, planNum: string) =>
|
||||
`${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`,
|
||||
);
|
||||
|
||||
// Renumber table row phase numbers: | 7. -> | 6.
|
||||
content = content.replace(
|
||||
new RegExp(`(\\|\\s*)${escapeRegex(oldStr)}\\.\\s`, 'g'),
|
||||
`$1${newStr}. `,
|
||||
);
|
||||
|
||||
// Renumber depends-on references
|
||||
content = content.replace(
|
||||
new RegExp(`(\\*\\*Depends on:\\*\\*\\s*Phase\\s+)${escapeRegex(oldStr)}\\b`, 'gi'),
|
||||
`$1${newStr}`,
|
||||
);
|
||||
}
|
||||
// Depends-on references — two bold-colon variants in the wild.
|
||||
content = content.replace(
|
||||
/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
|
||||
(_match, prefix: string, num: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`,
|
||||
);
|
||||
content = content.replace(
|
||||
/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
|
||||
(_match, prefix: string, num: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
@@ -1095,14 +1242,23 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
// Step C: Update ROADMAP.md atomically
|
||||
if (existsSync(paths.roadmap)) {
|
||||
await readModifyWriteRoadmapMd(projectDir, async (roadmapContent) => {
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
// Padding-tolerant fragment so a padded input like "02.7" still matches
|
||||
// un-padded ROADMAP prose ("### Phase 2.7:"). CJS routes every phase-
|
||||
// number ROADMAP regex through phaseMarkdownRegexSource (#3537) —
|
||||
// mirror that contract here so phase.complete with the padded form
|
||||
// produces the same ROADMAP as the un-padded form.
|
||||
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
|
||||
|
||||
// Checkbox: - [ ] Phase N: -> - [x] Phase N: (...completed DATE)
|
||||
// CJS parity (phase.cjs): direct replace, NOT scoped through
|
||||
// replaceInCurrentMilestone. Same reasoning as the plan-count
|
||||
// update below — milestone wrapped in <details> would otherwise be
|
||||
// skipped (bug-2005).
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
|
||||
'i',
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
|
||||
// Progress table: update Status to Complete, add date
|
||||
const tableRowPattern = new RegExp(
|
||||
@@ -1123,13 +1279,18 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
return '|' + cells.join('|') + '|';
|
||||
});
|
||||
|
||||
// Update plan count in phase section
|
||||
// Update plan count in phase section.
|
||||
// CJS parity (phase.cjs:1076-1083): direct replace, NOT scoped through
|
||||
// replaceInCurrentMilestone. Scoping to "after last </details>" fails
|
||||
// when the current milestone itself is wrapped in <details open>...
|
||||
// </details> — there's no content after the close tag, so the regex
|
||||
// never matches and **Plans:** stays at 0/N (bug-2005).
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`,
|
||||
'i',
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(
|
||||
roadmapContent, planCountPattern,
|
||||
roadmapContent = roadmapContent.replace(
|
||||
planCountPattern,
|
||||
`$1${summaryCount}/${planCount} plans complete`,
|
||||
);
|
||||
|
||||
@@ -1156,12 +1317,15 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
|
||||
const reqMatch = sectionText.match(/\*\*Requirements\*?\*?:?\s*([^\n]+)/i);
|
||||
|
||||
let reqContent = await readFile(reqPath, 'utf-8');
|
||||
let reqContentChanged = false;
|
||||
|
||||
if (reqMatch) {
|
||||
const reqIds = reqMatch[1].replace(/[[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
|
||||
let reqContent = await readFile(reqPath, 'utf-8');
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
const reqEscaped = escapeRegex(reqId);
|
||||
const before = reqContent;
|
||||
// Update checkbox: - [ ] **REQ-ID** -> - [x] **REQ-ID**
|
||||
reqContent = reqContent.replace(
|
||||
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
|
||||
@@ -1172,8 +1336,42 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
|
||||
'$1 Complete $2',
|
||||
);
|
||||
if (reqContent !== before) reqContentChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Bug #2526 parity (phase.cjs:1140-1167): independent of whether the
|
||||
// roadmap declared a Requirements: line, scan the REQUIREMENTS.md
|
||||
// body for `**REQ-ID**` references and compare against the IDs that
|
||||
// actually appear in the Traceability table. Surface every body
|
||||
// ID that has no traceability row so the operator can keep the
|
||||
// table in sync.
|
||||
const bodyReqIds: string[] = [];
|
||||
const bodyReqPattern = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
|
||||
let bodyMatch: RegExpExecArray | null;
|
||||
while ((bodyMatch = bodyReqPattern.exec(reqContent)) !== null) {
|
||||
if (!bodyReqIds.includes(bodyMatch[1]!)) bodyReqIds.push(bodyMatch[1]!);
|
||||
}
|
||||
|
||||
const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);
|
||||
const traceabilitySection = traceabilityHeadingMatch
|
||||
? reqContent.slice(traceabilityHeadingMatch.index!)
|
||||
: '';
|
||||
const tableReqIds = new Set<string>();
|
||||
const tableRowPattern = /^\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/gm;
|
||||
let tableMatch: RegExpExecArray | null;
|
||||
while ((tableMatch = tableRowPattern.exec(traceabilitySection)) !== null) {
|
||||
tableReqIds.add(tableMatch[1]!);
|
||||
}
|
||||
|
||||
const unregistered = bodyReqIds.filter((id) => !tableReqIds.has(id));
|
||||
if (unregistered.length > 0) {
|
||||
warnings.push(
|
||||
`REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`,
|
||||
);
|
||||
}
|
||||
|
||||
if (reqContentChanged) {
|
||||
await writeFile(reqPath, reqContent, 'utf-8');
|
||||
requirementsUpdated = true;
|
||||
}
|
||||
@@ -1217,6 +1415,12 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
for (const dir of dirs) {
|
||||
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
||||
if (dm) {
|
||||
// Bug #2129 parity: skip backlog phases (999.x). They are parked
|
||||
// ideas with reserved numbering, not part of the active sequence.
|
||||
// Without this, completing phase 2 in a project that has a 999.1
|
||||
// backlog directory would jump next_phase to 999.1 instead of the
|
||||
// intended Phase 3 from ROADMAP.
|
||||
if (/^999(?:\.|$)/.test(dm[1]!)) continue;
|
||||
if (comparePhaseNum(dm[1], phaseNum) > 0) {
|
||||
nextPhaseNum = dm[1];
|
||||
nextPhaseName = dm[2] || null;
|
||||
@@ -1433,6 +1637,24 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
}
|
||||
}
|
||||
|
||||
// Step F2: Auto-prune STATE.md decisions when `workflow.auto_prune_state`
|
||||
// is true. Mirrors CJS cmdPhaseComplete (bin/lib/phase.cjs:1378-1390) which
|
||||
// calls cmdStatePrune({keepRecent:'3', dryRun:false, silent:true}). Without
|
||||
// this, completing phase N with auto_prune_state=true leaves stale [Phase
|
||||
// 1..N-3] decisions in STATE.md forever. (#2087)
|
||||
let autoPruned = false;
|
||||
try {
|
||||
if (existsSync(paths.config)) {
|
||||
const rawConfig = JSON.parse(await readFile(paths.config, 'utf-8')) as Record<string, unknown>;
|
||||
const wf = rawConfig.workflow as Record<string, unknown> | undefined;
|
||||
if (wf && wf.auto_prune_state === true && existsSync(paths.state)) {
|
||||
const { statePrune } = await import('./state-mutation.js');
|
||||
await statePrune(['--keep-recent', '3', '--silent'], projectDir, workstream);
|
||||
autoPruned = true;
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort, matches CJS */ }
|
||||
|
||||
// Step G: Return result
|
||||
return {
|
||||
data: {
|
||||
@@ -1446,6 +1668,7 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
roadmap_updated: existsSync(paths.roadmap),
|
||||
state_updated: stateUpdated,
|
||||
requirements_updated: requirementsUpdated,
|
||||
auto_pruned: autoPruned,
|
||||
warnings,
|
||||
has_warnings: warnings.length > 0,
|
||||
},
|
||||
@@ -1547,13 +1770,19 @@ export const phasesList: QueryHandler = async (args, projectDir, workstream) =>
|
||||
|
||||
if (type) {
|
||||
const files: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
const dirPath = join(phasesDir, dir);
|
||||
if (!existsSync(dirPath)) continue;
|
||||
const dirFiles = await readdir(dirPath);
|
||||
let filtered: string[];
|
||||
if (type === 'plans') {
|
||||
filtered = dirFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
||||
filtered = dirFiles.filter(isCanonicalPlanFile);
|
||||
// #2893 parity — surface plan-shaped files the canonical filter
|
||||
// rejected so callers (executor init, etc.) don't silently see zero
|
||||
// plans. Per-dir prefix mirrors phase.cjs:120.
|
||||
const w = describeNonCanonicalPlans(dirFiles, filtered);
|
||||
if (w) warnings.push(`${dir}: ${w}`);
|
||||
} else if (type === 'summaries') {
|
||||
filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
} else {
|
||||
@@ -1561,7 +1790,13 @@ export const phasesList: QueryHandler = async (args, projectDir, workstream) =>
|
||||
}
|
||||
files.push(...filtered.sort());
|
||||
}
|
||||
return { data: { files, count: files.length, phase_dir: phase ? dirs[0]?.replace(/^\d+(?:\.\d+)*-?/, '') : null } };
|
||||
const result: Record<string, unknown> = {
|
||||
files,
|
||||
count: files.length,
|
||||
phase_dir: phase ? dirs[0]?.replace(/^\d+(?:\.\d+)*-?/, '') : null,
|
||||
};
|
||||
if (warnings.length) result['warning'] = warnings.join(' | ');
|
||||
return { data: result };
|
||||
}
|
||||
|
||||
return { data: { directories: dirs, count: dirs.length } };
|
||||
|
||||
@@ -5,7 +5,21 @@ import { acquireStateLock, releaseStateLock } from './state-mutation.js';
|
||||
/**
|
||||
* Replace a pattern only in the current milestone section of ROADMAP.md.
|
||||
*
|
||||
* Port of replaceInCurrentMilestone from core.cjs line 1197-1206.
|
||||
* Port of replaceInCurrentMilestone from core.cjs lines 1013-1022.
|
||||
*
|
||||
* Semantics (byte-for-byte CJS parity):
|
||||
* • No `</details>` in the content → plain `content.replace(pattern, replacement)`.
|
||||
* • Otherwise → split at the last `</details>` and replace only in the
|
||||
* content AFTER it.
|
||||
*
|
||||
* INTENTIONALLY DOES NOT fall back to "search the last <details> block when
|
||||
* the after-slice didn't match." That fallback existed in an earlier SDK
|
||||
* port and would silently corrupt shipped-milestone content when the current
|
||||
* milestone is itself wrapped in `<details open>...</details>` and there's
|
||||
* nothing after the close tag. CJS callers handle the "milestone inside
|
||||
* <details>" case by passing the unscoped `content.replace(...)` directly
|
||||
* (see phase.cjs:1080 for plan-count update). Keep this function in
|
||||
* lockstep with core.cjs — deviations are how bug-2005 slipped in.
|
||||
*/
|
||||
export function replaceInCurrentMilestone(
|
||||
content: string,
|
||||
@@ -19,30 +33,7 @@ export function replaceInCurrentMilestone(
|
||||
const offset = lastDetailsClose + '</details>'.length;
|
||||
const before = content.slice(0, offset);
|
||||
const after = content.slice(offset);
|
||||
|
||||
const replacedAfter = after.replace(pattern, replacement);
|
||||
if (replacedAfter !== after) {
|
||||
return before + replacedAfter;
|
||||
}
|
||||
|
||||
const detailsBlockRe = /<details>[\s\S]*?<\/details>/gi;
|
||||
const spans: { start: number; end: number; text: string }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = detailsBlockRe.exec(content)) !== null) {
|
||||
spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
|
||||
}
|
||||
|
||||
if (spans.length === 0) {
|
||||
return content.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
const lastSpan = spans[spans.length - 1];
|
||||
const updatedLastBlock = lastSpan.text.replace(pattern, replacement);
|
||||
return (
|
||||
content.slice(0, lastSpan.start) +
|
||||
updatedLastBlock +
|
||||
content.slice(lastSpan.end)
|
||||
);
|
||||
return before + after.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
@@ -47,10 +48,54 @@ interface PhaseInfo {
|
||||
has_verification: boolean;
|
||||
has_reviews: boolean;
|
||||
archived?: string;
|
||||
/**
|
||||
* #2893 — non-canonical plan filename warning (singular). Present only when
|
||||
* a plan-shaped file in this phase dir is not the canonical
|
||||
* `{padded_phase}-{NN}-PLAN.md` shape; the executor surfaces this so users
|
||||
* see a loud signal instead of plan_count: 0 with no clue why.
|
||||
*/
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* #2893 — canonical plan filename predicate and the diagnostic "looks like a
|
||||
* plan but isn't canonical" net. Centralised so every read site (find-phase,
|
||||
* phase-plan-index, phases list --type plans) emits the same warning message.
|
||||
*
|
||||
* Mirrors get-shit-done/bin/lib/phase.cjs lines 17–52.
|
||||
*/
|
||||
export const isCanonicalPlanFile = (f: string): boolean => f.endsWith('-PLAN.md') || f === 'PLAN.md';
|
||||
|
||||
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
|
||||
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
|
||||
const looksLikePlanFile = (f: string): boolean =>
|
||||
/\.md$/i.test(f)
|
||||
&& /PLAN/i.test(f)
|
||||
&& !PLAN_OUTLINE_RE.test(f)
|
||||
&& !PLAN_PRE_BOUNCE_RE.test(f);
|
||||
|
||||
/**
|
||||
* Build the canonical "non-canonical plan files" warning string used by every
|
||||
* SDK read site. Returns null when there are no offenders.
|
||||
*
|
||||
* Format mirrors describeNonCanonicalPlans in phase.cjs so consumers see the
|
||||
* same message regardless of which entry point they call.
|
||||
*/
|
||||
export function describeNonCanonicalPlans(dirFiles: string[], matchedFiles: string[]): string | null {
|
||||
const matched = new Set(matchedFiles);
|
||||
const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
|
||||
if (offenders.length === 0) return null;
|
||||
return (
|
||||
`Found ${offenders.length} plan-shaped file(s) in this phase that don't match the canonical `
|
||||
+ `naming convention "{padded_phase}-{NN}-PLAN.md" (or bare "PLAN.md") and were skipped: `
|
||||
+ offenders.map((f) => `"${f}"`).join(', ')
|
||||
+ `. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. `
|
||||
+ `See agents/gsd-planner.md write_phase_prompt step for the full contract.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file stats for a phase directory.
|
||||
*
|
||||
@@ -63,15 +108,17 @@ async function getPhaseFileStats(phaseDir: string): Promise<{
|
||||
hasContext: boolean;
|
||||
hasVerification: boolean;
|
||||
hasReviews: boolean;
|
||||
allFiles: string[];
|
||||
}> {
|
||||
const files = await readdir(phaseDir);
|
||||
return {
|
||||
plans: files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'),
|
||||
plans: files.filter(isCanonicalPlanFile),
|
||||
summaries: files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'),
|
||||
hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
|
||||
hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'),
|
||||
hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
|
||||
hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
|
||||
allFiles: files,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,9 +158,12 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st
|
||||
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
||||
const phaseDir = join(baseDir, match);
|
||||
|
||||
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = await getPhaseFileStats(phaseDir);
|
||||
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews, allFiles } = await getPhaseFileStats(phaseDir);
|
||||
const plans = unsortedPlans.sort();
|
||||
const summaries = unsortedSummaries.sort();
|
||||
// #2893 parity — emit the same warning shape as cmdPhasePlanIndex when a
|
||||
// plan-shaped file would be skipped by the canonical filter.
|
||||
const planNamingWarning = describeNonCanonicalPlans(allFiles, plans);
|
||||
|
||||
const completedPlanIds = new Set(
|
||||
summaries.flatMap((s) => {
|
||||
@@ -128,7 +178,7 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st
|
||||
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
|
||||
});
|
||||
|
||||
return {
|
||||
const result: PhaseInfo = {
|
||||
found: true,
|
||||
directory: toPosixPath(join(relBase, match)),
|
||||
phase_number: phaseNumber,
|
||||
@@ -142,6 +192,8 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st
|
||||
has_verification: hasVerification,
|
||||
has_reviews: hasReviews,
|
||||
};
|
||||
if (planNamingWarning) result.warning = planNamingWarning;
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -180,23 +232,15 @@ export const findPhase: QueryHandler = async (args, projectDir, workstream) => {
|
||||
const phasesDir = planningPaths(projectDir, workstream).phases;
|
||||
const normalized = normalizePhaseName(phase);
|
||||
|
||||
const notFound: PhaseInfo = {
|
||||
found: false,
|
||||
directory: null,
|
||||
phase_number: null,
|
||||
phase_name: null,
|
||||
phase_slug: null,
|
||||
plans: [],
|
||||
summaries: [],
|
||||
incomplete_plans: [],
|
||||
has_research: false,
|
||||
has_context: false,
|
||||
has_verification: false,
|
||||
has_reviews: false,
|
||||
};
|
||||
// Track every directory we actually probed so the not-found payload can
|
||||
// surface them to the caller for diagnostics (#3164 acceptance criterion).
|
||||
const searchedDirectories: string[] = [];
|
||||
|
||||
// Search current phases first
|
||||
const relPhasesDir = relPlanningPath(workstream) + '/phases';
|
||||
if (existsSync(phasesDir)) {
|
||||
searchedDirectories.push(relPhasesDir);
|
||||
}
|
||||
const current = await searchPhaseInDir(phasesDir, relPhasesDir, normalized);
|
||||
if (current) return { data: current };
|
||||
|
||||
@@ -215,6 +259,7 @@ export const findPhase: QueryHandler = async (args, projectDir, workstream) => {
|
||||
const version = versionMatch ? versionMatch[1] : archiveName;
|
||||
const archivePath = join(milestonesDir, archiveName);
|
||||
const relBase = '.planning/milestones/' + archiveName;
|
||||
searchedDirectories.push(relBase);
|
||||
const result = await searchPhaseInDir(archivePath, relBase, normalized);
|
||||
if (result) {
|
||||
result.archived = version;
|
||||
@@ -223,6 +268,21 @@ export const findPhase: QueryHandler = async (args, projectDir, workstream) => {
|
||||
}
|
||||
} catch { /* milestones dir doesn't exist */ }
|
||||
|
||||
const notFound: PhaseInfo & { searched_directories: string[] } = {
|
||||
found: false,
|
||||
directory: null,
|
||||
phase_number: null,
|
||||
phase_name: null,
|
||||
phase_slug: null,
|
||||
plans: [],
|
||||
summaries: [],
|
||||
incomplete_plans: [],
|
||||
has_research: false,
|
||||
has_context: false,
|
||||
has_verification: false,
|
||||
has_reviews: false,
|
||||
searched_directories: searchedDirectories,
|
||||
};
|
||||
return { data: notFound };
|
||||
};
|
||||
|
||||
@@ -285,13 +345,11 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
|
||||
|
||||
// Get all files in phase directory
|
||||
const phaseFiles = await readdir(phaseDir);
|
||||
const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
|
||||
const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort();
|
||||
const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
const nonCanonicalPlanFiles = phaseFiles.filter((f) => (
|
||||
f.toLowerCase().endsWith('.md')
|
||||
&& /(^|-)plan(-|\.)/i.test(f)
|
||||
&& !(f.endsWith('-PLAN.md') || f === 'PLAN.md')
|
||||
)).sort();
|
||||
// #2893 parity — same diagnostic format as find-phase / phases-list. Use the
|
||||
// centralised helper so the message shape never drifts between read sites.
|
||||
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles);
|
||||
|
||||
// Build set of plan IDs with summaries — match the planId derivation logic
|
||||
const completedPlanIds = new Set(
|
||||
@@ -483,10 +541,6 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
|
||||
let hasCheckpoints = false;
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (nonCanonicalPlanFiles.length > 0) {
|
||||
warnings.push(`Ignored noncanonical plan files: ${nonCanonicalPlanFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
// Surface unresolved depends_on references from Pass 2 — without this, a dropped
|
||||
// short-form edge silently collapses the dependent plan into wave 1 and the only
|
||||
// signal is a misleading "declared wave: N but depends_on DAG places it in wave 1"
|
||||
@@ -542,6 +596,12 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
|
||||
incomplete,
|
||||
has_checkpoints: hasCheckpoints,
|
||||
};
|
||||
// #2893 — non-canonical plan filename warning is a singular `warning` field;
|
||||
// see describeNonCanonicalPlans above. Other diagnostics (unresolved deps,
|
||||
// wave-declaration mismatches) flow through the existing `warnings` array.
|
||||
if (planNamingWarning) {
|
||||
result['warning'] = planNamingWarning;
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
result['warnings'] = warnings;
|
||||
}
|
||||
|
||||
@@ -309,6 +309,15 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
|
||||
const matchedVersion = m[1];
|
||||
// Skip headings that reference the same version (e.g. "## v2.0 Phase Details").
|
||||
if (matchedVersion && currentVersionStr && matchedVersion === currentVersionStr) continue;
|
||||
// Bug #2787: skip "heading-like" lines that sit inside a fenced code
|
||||
// block. GFM fences toggle on a line starting with ``` or ~~~ (with
|
||||
// optional info string); the closing fence must be the same char with
|
||||
// no info string. Walk forward from the start of restContent up to
|
||||
// the match index, toggling fenceChar. If we're inside a fence at the
|
||||
// match, ignore this match and continue scanning. Without this, a
|
||||
// line like `# Ops runbook — v1.0 compat` inside ```bash truncates the
|
||||
// milestone slice and hides every phase that follows.
|
||||
if (isInsideFencedCodeBlock(restContent, m.index)) continue;
|
||||
sectionEnd = sectionStart + sectionMatch[0].length + m.index;
|
||||
break;
|
||||
}
|
||||
@@ -366,6 +375,46 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
|
||||
return content.slice(sectionStart, sectionEnd) + phaseDetailsTail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when `offset` falls inside an open GFM fenced code block
|
||||
* within the provided `content`.
|
||||
*
|
||||
* GFM fence semantics (bug #2787):
|
||||
* - Opening fence: a line starting with at least 3 backticks or 3 tildes,
|
||||
* optionally followed by an info string (e.g. ```bash, ~~~markdown).
|
||||
* - Closing fence: a line starting with at least 3 of the SAME char as
|
||||
* the opener, with NO info string — so ```js inside an open ```text
|
||||
* fence does NOT close it.
|
||||
*
|
||||
* We walk lines from the start of `content` to `offset`, toggling a
|
||||
* `fenceChar` cursor on each fence boundary. Returns true when the
|
||||
* cursor is non-null at `offset`.
|
||||
*/
|
||||
function isInsideFencedCodeBlock(content: string, offset: number): boolean {
|
||||
let fenceChar: '`' | '~' | null = null;
|
||||
let lineStart = 0;
|
||||
for (let i = 0; i <= offset; i++) {
|
||||
if (i === content.length || content[i] === '\n') {
|
||||
const line = content.slice(lineStart, i);
|
||||
const openMatch = line.match(/^(`{3,}|~{3,})(\s*)([^\n]*)$/);
|
||||
if (openMatch) {
|
||||
const fenceRun = openMatch[1]!;
|
||||
const ch = fenceRun[0] === '`' ? '`' : '~';
|
||||
const info = openMatch[3]!.trim();
|
||||
if (fenceChar === null) {
|
||||
// Opening fence — info string allowed.
|
||||
fenceChar = ch;
|
||||
} else if (ch === fenceChar && info.length === 0) {
|
||||
// Closing fence must match opener and carry no info string.
|
||||
fenceChar = null;
|
||||
}
|
||||
}
|
||||
lineStart = i + 1;
|
||||
}
|
||||
}
|
||||
return fenceChar !== null;
|
||||
}
|
||||
|
||||
// ─── Next-milestone helpers (issue #2497) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -484,41 +533,89 @@ export async function extractNextMilestoneSection(
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Padding-tolerant regex fragment for a phase number — emits `0*<integer>` so
|
||||
* the fragment matches both `Phase 3` and `Phase 03` (bug #2391 / #3537).
|
||||
*
|
||||
* Mirrors `phaseMarkdownRegexSource` in core.cjs and the local copy in
|
||||
* roadmap-update-plan-progress.ts. Falls back to `escapeRegex(phaseNum)` for
|
||||
* non-numeric IDs (custom project codes like `PROJ-42`).
|
||||
*/
|
||||
export function phaseMarkdownRegexSource(phaseNum: string): string {
|
||||
const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i);
|
||||
if (!match) return escapeRegex(phaseNum);
|
||||
|
||||
const integer = match[1]!.replace(/^0+/, '') || '0';
|
||||
const letter = match[2] ? escapeRegex(match[2]) : '';
|
||||
const decimal = match[3] ? escapeRegex(match[3]) : '';
|
||||
return `0*${escapeRegex(integer)}${letter}${decimal}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3599 (parity with core.cjs phaseMarkdownRegexSourceExact, lines 691-708):
|
||||
* when the caller passed a project-code-prefixed ID like `PROJ-42`, return
|
||||
* the exact-escaped form so the caller can search the ROADMAP for
|
||||
* `### Phase PROJ-42:` BEFORE falling back to the padding-tolerant numeric
|
||||
* form. Returns null when the input has no project-code prefix — in that
|
||||
* case `phaseMarkdownRegexSource` is the only form the caller needs.
|
||||
*
|
||||
* Two-pass at the call site preserves the #3537 contract (`CK-01` directory
|
||||
* names mapping to `Phase 1:` prose) while letting `PROJ-42` resolve to its
|
||||
* own prefixed heading without cross-matching a bare `### Phase 42:` that
|
||||
* happens to share the trailing integer.
|
||||
*/
|
||||
export function phaseMarkdownRegexSourceExact(phaseNum: string): string | null {
|
||||
const raw = String(phaseNum);
|
||||
if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw)) return null;
|
||||
return escapeRegex(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a phase section in roadmap content.
|
||||
*
|
||||
* Port of searchPhaseInContent from roadmap.cjs lines 14-73.
|
||||
*/
|
||||
function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSection | null {
|
||||
// Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
|
||||
// Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name.
|
||||
// Uses the padding-tolerant fragment so zero-padded inputs ("03") match
|
||||
// unpadded ROADMAP headings ("### Phase 3:"). See #2391 / #3537.
|
||||
// Capture group 1 = the as-written phase token from the heading so callers
|
||||
// get the canonical form (matching the ROADMAP source-of-truth), not the
|
||||
// padded input the user typed. Without this, `roadmap get-phase 02.7`
|
||||
// and `roadmap get-phase 2.7` produce divergent payloads for the same
|
||||
// heading, breaking bug-3537 parity.
|
||||
const phasePattern = new RegExp(
|
||||
`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
|
||||
`#{2,4}\\s*Phase\\s+(${escapedPhase}):\\s*([^\\n]+)`,
|
||||
'i'
|
||||
);
|
||||
const headerMatch = content.match(phasePattern);
|
||||
|
||||
if (!headerMatch) {
|
||||
// Fallback: check if phase exists in summary list but missing detail section
|
||||
// Fallback: check if phase exists in summary list but missing detail section.
|
||||
// Same canonical-token capture: surface the as-written checklist form.
|
||||
const checklistPattern = new RegExp(
|
||||
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`,
|
||||
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+(${escapedPhase}):\\s*([^*]+)\\*\\*`,
|
||||
'i'
|
||||
);
|
||||
const checklistMatch = content.match(checklistPattern);
|
||||
|
||||
if (checklistMatch) {
|
||||
const canonicalChecklistPhase = checklistMatch[1];
|
||||
return {
|
||||
found: false,
|
||||
phase_number: phaseNum,
|
||||
phase_name: checklistMatch[1].trim(),
|
||||
phase_number: canonicalChecklistPhase,
|
||||
phase_name: checklistMatch[2].trim(),
|
||||
error: 'malformed_roadmap',
|
||||
message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`,
|
||||
message: `Phase ${canonicalChecklistPhase} exists in summary list but missing "### Phase ${canonicalChecklistPhase}:" detail section. ROADMAP.md needs both formats.`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const phaseName = headerMatch[1].trim();
|
||||
const canonicalPhaseNum = headerMatch[1];
|
||||
const phaseName = headerMatch[2].trim();
|
||||
const headerIndex = headerMatch.index!;
|
||||
|
||||
// Find the end of this section (next ## or ### phase header, or end of file)
|
||||
@@ -546,9 +643,13 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s
|
||||
? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
// Suppress unused-arg warning — `phaseNum` is retained as the function
|
||||
// signature so future callers can reintroduce input-mirroring if needed.
|
||||
void phaseNum;
|
||||
|
||||
return {
|
||||
found: true,
|
||||
phase_number: phaseNum,
|
||||
phase_number: canonicalPhaseNum,
|
||||
phase_name: phaseName,
|
||||
goal,
|
||||
mode,
|
||||
@@ -609,14 +710,38 @@ export const roadmapGetPhase: QueryHandler = async (args, projectDir, workstream
|
||||
}
|
||||
|
||||
const milestoneContent = await extractCurrentMilestone(rawContent, projectDir, workstream);
|
||||
const escapedPhase = escapeRegex(phaseNum);
|
||||
|
||||
// Search the current milestone slice first, then fall back to full roadmap.
|
||||
const fullContent = stripShippedMilestones(rawContent);
|
||||
const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
|
||||
|
||||
// Two-pass lookup (parity with bin/lib/roadmap.cjs #3599 path): if the input
|
||||
// carries a project-code prefix like `PROJ-42`, try the EXACT escaped form
|
||||
// first so we match `### Phase PROJ-42:` without cross-matching `### Phase 42:`.
|
||||
// Only fall back to the padding-tolerant numeric form (which strips the
|
||||
// prefix per the #3537 contract for CK-01 → Phase 1 directory layout) when
|
||||
// the exact form misses.
|
||||
const exactEscaped = phaseMarkdownRegexSourceExact(phaseNum);
|
||||
// Padding-tolerant fragment (bug #2391): caller may pass "03" — match against
|
||||
// unpadded ROADMAP headings ("Phase 3:") without forcing the caller to normalize.
|
||||
const numericEscaped = phaseMarkdownRegexSource(phaseNum);
|
||||
|
||||
// Try exact-prefixed match first when applicable.
|
||||
let milestoneResult: PhaseSection | null = null;
|
||||
let fallbackFromFullContent: PhaseSection | null = null;
|
||||
if (exactEscaped) {
|
||||
milestoneResult = searchPhaseInContent(milestoneContent, exactEscaped, phaseNum);
|
||||
if (!milestoneResult || milestoneResult.error) {
|
||||
fallbackFromFullContent = searchPhaseInContent(fullContent, exactEscaped, phaseNum);
|
||||
}
|
||||
}
|
||||
// Padding-tolerant fallback (#3537) — also covers the no-prefix case.
|
||||
if (!milestoneResult || milestoneResult.error) {
|
||||
milestoneResult = milestoneResult || searchPhaseInContent(milestoneContent, numericEscaped, phaseNum);
|
||||
}
|
||||
if (!fallbackFromFullContent) {
|
||||
fallbackFromFullContent = searchPhaseInContent(fullContent, numericEscaped, phaseNum);
|
||||
}
|
||||
const result = (milestoneResult && !milestoneResult.error)
|
||||
? milestoneResult
|
||||
: searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
|
||||
: fallbackFromFullContent || milestoneResult;
|
||||
|
||||
if (!result) {
|
||||
return { data: { found: false, phase_number: phaseNum } };
|
||||
@@ -670,6 +795,12 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream
|
||||
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
|
||||
|
||||
// **Mode:** field — vertical-MVP slice flag per CONTEXT.md "MVP Mode"
|
||||
// glossary. Pattern mirrors the roadmapGetPhase extraction above so the
|
||||
// analyze output surfaces the same value the get-phase handler returns.
|
||||
const modeMatchPhase = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
const mode = modeMatchPhase ? modeMatchPhase[1].trim().toLowerCase() : null;
|
||||
|
||||
// Check completion on disk
|
||||
const normalized = normalizePhaseName(phaseNum);
|
||||
let diskStatus = 'no_directory';
|
||||
@@ -714,6 +845,7 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream
|
||||
name: phaseName,
|
||||
goal,
|
||||
depends_on,
|
||||
mode,
|
||||
plan_count: planCount,
|
||||
summary_count: summaryCount,
|
||||
has_context: hasContext,
|
||||
@@ -788,12 +920,21 @@ export const roadmapAnnotateDependencies: QueryHandler = async (args, projectDir
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const toolsPath = resolveGsdToolsPath(projectDir);
|
||||
|
||||
// CRITICAL: set GSD_SDK_NESTED=1 so the CJS router in the child process
|
||||
// detects nesting and routes directly to cmdRoadmapAnnotateDependencies
|
||||
// instead of dispatching back through executeForCjs. Without this guard,
|
||||
// SDK→spawn(gsd-tools)→router→SDK→spawn(gsd-tools)→… loops until the
|
||||
// synckit 15s timeout fires and bug-3537's annotate test surfaces a
|
||||
// misleading "code=null" failure.
|
||||
const childEnv: NodeJS.ProcessEnv = { ...process.env, GSD_SDK_NESTED: '1' };
|
||||
|
||||
const result = spawnSync(process.execPath, [toolsPath, 'roadmap', 'annotate-dependencies', phase], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 15000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: childEnv,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
|
||||
@@ -1147,7 +1147,12 @@ describe('statePrune current phase extraction (#3471)', () => {
|
||||
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses frontmatter progress.completed_phases when body Current Phase field is absent', async () => {
|
||||
it('reads Current Phase from body text (CJS-aligned); frontmatter progress fields are not used', async () => {
|
||||
// Phase 6 alignment: SDK now uses stateExtractField(content, 'Current Phase')
|
||||
// as the primary/only source, matching CJS state.cjs:1615. Frontmatter
|
||||
// progress.completed_phases is no longer consulted.
|
||||
// STATE.md below has progress.completed_phases:12 but no body "Current Phase:"
|
||||
// field → currentPhase = 0 → cutoff = -3 ≤ 0 → "Only 0 phases" (no-op).
|
||||
const stateContent = `---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.1
|
||||
@@ -1171,13 +1176,18 @@ Phase 12 execution in progress.
|
||||
const result = await statePrune(['--keep-recent', '3', '--dry-run'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
|
||||
// No body "Current Phase:" field → defaults to 0 → cutoff ≤ 0 → early exit.
|
||||
expect(data.pruned).toBe(false);
|
||||
expect(data.dry_run).toBe(true);
|
||||
expect(data.cutoff_phase).toBe(9);
|
||||
expect(data.reason).toBeUndefined();
|
||||
expect(typeof data.reason).toBe('string');
|
||||
expect(String(data.reason)).toContain('Only 0 phases');
|
||||
expect(data.dry_run).toBeUndefined();
|
||||
expect(data.cutoff_phase).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a targeted reason when no current phase source can be parsed', async () => {
|
||||
// Phase 6 alignment: when no body "Current Phase:" field exists, currentPhase
|
||||
// defaults to 0 (like CJS `parseInt(...) || 0`). The reason message matches
|
||||
// CJS: "Only 0 phases — nothing to prune with --keep-recent N".
|
||||
const stateContent = `---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.1
|
||||
@@ -1193,6 +1203,8 @@ status: executing
|
||||
|
||||
expect(data.pruned).toBe(false);
|
||||
expect(typeof data.reason).toBe('string');
|
||||
expect(String(data.reason)).toContain('Could not determine current phase');
|
||||
// Matches CJS: "Only 0 phases — nothing to prune with --keep-recent 3"
|
||||
expect(String(data.reason)).toContain('Only 0 phases');
|
||||
expect(String(data.reason)).toContain('nothing to prune');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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';
|
||||
@@ -34,7 +35,8 @@ import {
|
||||
normalizeMd,
|
||||
} from './helpers.js';
|
||||
import { buildStateFrontmatter, getMilestonePhaseFilter } from './state.js';
|
||||
import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } from './state-document.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']);
|
||||
@@ -90,14 +92,26 @@ function readTextArgOrFile(
|
||||
if (!filePath) {
|
||||
return (value ?? '').trim();
|
||||
}
|
||||
const root = resolve(projectDir);
|
||||
const resolved = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
||||
const rel = relative(root, resolved);
|
||||
// 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(resolved, 'utf-8').trimEnd();
|
||||
return readFileSync(resolvedTarget, 'utf-8').trimEnd();
|
||||
} catch {
|
||||
throw new Error(`${label} file not found: ${filePath}`);
|
||||
}
|
||||
@@ -307,6 +321,18 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) =>
|
||||
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) => {
|
||||
@@ -321,7 +347,10 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) =>
|
||||
preserveExistingProgress: !shouldResync,
|
||||
});
|
||||
|
||||
return { data: { updated } };
|
||||
if (!updated) {
|
||||
return { data: { updated: false, reason: `Field "${field}" not found in STATE.md` } };
|
||||
}
|
||||
return { data: { updated: true } };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -631,14 +660,25 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir, workstre
|
||||
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();
|
||||
const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks} tasks | ${files} files |`;
|
||||
|
||||
if (tableBody.trim() === '' || tableBody.includes('None yet')) {
|
||||
tableBody = newRow;
|
||||
@@ -648,14 +688,28 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir, workstre
|
||||
|
||||
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);
|
||||
|
||||
if (recorded) {
|
||||
return { data: { recorded: true, phase, plan, duration } };
|
||||
}
|
||||
return { data: { recorded: false, reason: 'Performance Metrics section not found in STATE.md' } };
|
||||
const result: Record<string, unknown> = { recorded: true, phase, plan, duration };
|
||||
if (created) result.created = true;
|
||||
return { data: result };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -668,6 +722,16 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir, workstre
|
||||
* @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;
|
||||
@@ -749,7 +813,7 @@ export const stateAddDecision: QueryHandler = async (args, projectDir, workstrea
|
||||
}
|
||||
|
||||
const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
|
||||
let added = false;
|
||||
let created = false;
|
||||
|
||||
await readModifyWriteStateMd(projectDir, (content) => {
|
||||
const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
||||
@@ -759,16 +823,22 @@ export const stateAddDecision: QueryHandler = async (args, projectDir, workstrea
|
||||
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';
|
||||
content = content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`);
|
||||
added = true;
|
||||
return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`);
|
||||
}
|
||||
return content;
|
||||
|
||||
// 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);
|
||||
|
||||
if (added) {
|
||||
return { data: { added: true, decision: entry } };
|
||||
}
|
||||
return { data: { added: false, reason: 'Decisions section not found in STATE.md' } };
|
||||
const result: Record<string, unknown> = { added: true, decision: entry };
|
||||
if (created) result['created'] = true;
|
||||
return { data: result };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -796,7 +866,7 @@ export const stateAddBlocker: QueryHandler = async (args, projectDir, workstream
|
||||
}
|
||||
|
||||
const entry = `- ${blockerText}`;
|
||||
let added = false;
|
||||
let created = false;
|
||||
|
||||
await readModifyWriteStateMd(projectDir, (content) => {
|
||||
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
||||
@@ -806,16 +876,20 @@ export const stateAddBlocker: QueryHandler = async (args, projectDir, workstream
|
||||
let sectionBody = match[2];
|
||||
sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
|
||||
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
|
||||
content = content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`);
|
||||
added = true;
|
||||
return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`);
|
||||
}
|
||||
return content;
|
||||
|
||||
// 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);
|
||||
|
||||
if (added) {
|
||||
return { data: { added: true, blocker: blockerText } };
|
||||
}
|
||||
return { data: { added: false, reason: 'Blockers section not found in STATE.md' } };
|
||||
const result: Record<string, unknown> = { added: true, blocker: blockerText };
|
||||
if (created) result['created'] = true;
|
||||
return { data: result };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -829,6 +903,14 @@ export const stateResolveBlocker: QueryHandler = async (args, projectDir, workst
|
||||
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;
|
||||
|
||||
@@ -861,13 +943,15 @@ export const stateResolveBlocker: QueryHandler = async (args, projectDir, workst
|
||||
return content;
|
||||
}, workstream);
|
||||
|
||||
if (removedMatchingLine) {
|
||||
// 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: blockersSectionFound
|
||||
? 'Blocker text not found in STATE.md'
|
||||
: 'Blockers section not found in STATE.md'
|
||||
} };
|
||||
return { data: { resolved: false, reason: 'Blockers section not found in STATE.md' } };
|
||||
};
|
||||
|
||||
// ─── state.add-roadmap-evolution ─────────────────────────────────────────
|
||||
@@ -1019,6 +1103,14 @@ export const stateRecordSession: QueryHandler = async (args, projectDir, workstr
|
||||
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[] = [];
|
||||
|
||||
@@ -1347,8 +1439,10 @@ export const stateValidate: QueryHandler = async (_args, projectDir, workstream)
|
||||
if (phaseDir) {
|
||||
const phaseDirPath = join(phasesDir, phaseDir.name);
|
||||
const files = readdirSync(phaseDirPath);
|
||||
const diskPlans = files.filter(f => /-PLAN\.md$/i.test(f)).length;
|
||||
const diskSummaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
|
||||
// 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(
|
||||
@@ -1419,16 +1513,20 @@ export const stateSync: QueryHandler = async (args, projectDir, workstream) => {
|
||||
|
||||
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);
|
||||
const files = readdirSync(dirPath);
|
||||
const plans = files.filter(f => /-PLAN\.md$/i.test(f)).length;
|
||||
const summaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
|
||||
// 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) {
|
||||
@@ -1437,6 +1535,12 @@ export const stateSync: QueryHandler = async (args, projectDir, workstream) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -1448,7 +1552,17 @@ export const stateSync: QueryHandler = async (args, projectDir, workstream) => {
|
||||
}
|
||||
}
|
||||
|
||||
const percent = totalDiskPlans > 0 ? Math.min(100, Math.round((totalDiskSummaries / totalDiskPlans) * 100)) : 0;
|
||||
// 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);
|
||||
@@ -1621,32 +1735,10 @@ export const statePrune: QueryHandler = async (args, projectDir, workstream) =>
|
||||
}
|
||||
|
||||
const fullContent = await readFile(statePath, 'utf-8');
|
||||
const fm = extractFrontmatter(fullContent);
|
||||
const fmProgress = (typeof fm.progress === 'object' && fm.progress !== null)
|
||||
? fm.progress as Record<string, unknown>
|
||||
: null;
|
||||
const phaseCandidates: unknown[] = [
|
||||
fm.current_phase,
|
||||
stateExtractField(fullContent, 'Current Phase'),
|
||||
fmProgress?.completed_phases,
|
||||
fmProgress?.total_phases,
|
||||
];
|
||||
let currentPhase: number | null = null;
|
||||
for (const candidate of phaseCandidates) {
|
||||
const parsed = parseInt(String(candidate ?? '').trim(), 10);
|
||||
if (Number.isInteger(parsed) && parsed > 0) {
|
||||
currentPhase = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentPhase === null) {
|
||||
return {
|
||||
data: {
|
||||
pruned: false,
|
||||
reason: 'Could not determine current phase from STATE.md. Add **Current Phase:** N, frontmatter current_phase: N, progress.completed_phases, or progress.total_phases.',
|
||||
},
|
||||
};
|
||||
}
|
||||
// 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) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
stateExtractField,
|
||||
} from './state-document.js';
|
||||
import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js';
|
||||
import { scanPhasePlans } from './plan-scan.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────────────
|
||||
@@ -110,7 +111,17 @@ export async function buildStateFrontmatter(
|
||||
const status = stateExtractField(bodyContent, 'Status');
|
||||
const progressRaw = stateExtractField(bodyContent, 'Progress');
|
||||
const lastActivity = stateExtractField(bodyContent, 'Last Activity');
|
||||
const stoppedAt = stateExtractField(bodyContent, 'Stopped At') || stateExtractField(bodyContent, 'Stopped at');
|
||||
// Bug #2444 parity with CJS `buildStateFrontmatter`: scope `Stopped At`
|
||||
// extraction to the `## Session` section so historical plain-text mentions
|
||||
// in earlier prose (e.g. "## Previous Session Notes / Stopped at: …") don't
|
||||
// promote into the frontmatter. CJS scopes the regex to the section match;
|
||||
// `stateExtractField` on the whole body would return the first plain match,
|
||||
// which is the stale historical value.
|
||||
const sessionMatch = bodyContent.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||
const sessionSection = sessionMatch ? sessionMatch[1] : '';
|
||||
const stoppedAt = sessionSection
|
||||
? (stateExtractField(sessionSection, 'Stopped At') || stateExtractField(sessionSection, 'Stopped at'))
|
||||
: null;
|
||||
const pausedAt = stateExtractField(bodyContent, 'Paused At');
|
||||
|
||||
// Bug #2613: read existing STATE.md frontmatter as preservation backstop.
|
||||
@@ -153,12 +164,14 @@ export async function buildStateFrontmatter(
|
||||
let diskCompletedPhases = 0;
|
||||
|
||||
for (const dir of phaseDirs) {
|
||||
const files = await readdir(join(phasesDir, dir));
|
||||
const plans = files.filter(f => /-PLAN\.md$/i.test(f)).length;
|
||||
const summaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
|
||||
diskTotalPlans += plans;
|
||||
diskTotalSummaries += summaries;
|
||||
if (plans > 0 && summaries >= plans) diskCompletedPhases++;
|
||||
// Bug #3257 parity: route through scanPhasePlans so nested plans/
|
||||
// subdirectories (the planner default layout) get counted. The naive
|
||||
// top-level `-PLAN.md` filter undercounts every phase that uses the
|
||||
// canonical `phases/NN-name/plans/<NN>-PLAN-MM-slug.md` shape.
|
||||
const { planCount, summaryCount, completed } = scanPhasePlans(join(phasesDir, dir));
|
||||
diskTotalPlans += planCount;
|
||||
diskTotalSummaries += summaryCount;
|
||||
if (completed) diskCompletedPhases++;
|
||||
}
|
||||
|
||||
totalPhases = isDirInMilestone.phaseCount > 0
|
||||
|
||||
@@ -29,6 +29,72 @@ import { resolveBundledAgentsDir } from '../sdk-package-compatibility.js';
|
||||
/** Max length for key_links regex patterns (ReDoS mitigation). */
|
||||
const MAX_KEY_LINK_PATTERN_LEN = 512;
|
||||
|
||||
const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i;
|
||||
const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
|
||||
|
||||
/**
|
||||
* List milestone-archive directories under `.planning/milestones/`, sorted by
|
||||
* version (numeric — `v1.10` after `v1.2`). Mirrors `listMilestoneArchiveDirs`
|
||||
* in verify.cjs.
|
||||
*/
|
||||
async function listMilestoneArchiveDirs(planBase: string): Promise<string[]> {
|
||||
const milestonesDir = join(planBase, 'milestones');
|
||||
try {
|
||||
const entries = await readdir(milestonesDir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && MILESTONE_ARCHIVE_DIR_RE.test(e.name))
|
||||
.map((e) => join(milestonesDir, e.name))
|
||||
.sort((a, b) => {
|
||||
const an = a.slice(a.lastIndexOf('/') + 1);
|
||||
const bn = b.slice(b.lastIndexOf('/') + 1);
|
||||
return an.localeCompare(bn, undefined, { numeric: true });
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the active milestone archive dir, preferring the version named in
|
||||
* STATE.md when it maps to an on-disk archive; falling back to the highest
|
||||
* (most recent) version-ish name. Mirrors `getActiveMilestoneArchiveDir`
|
||||
* in verify.cjs.
|
||||
*/
|
||||
async function getActiveMilestoneArchiveDir(planBase: string): Promise<string | null> {
|
||||
const archiveDirs = await listMilestoneArchiveDirs(planBase);
|
||||
if (archiveDirs.length === 0) return null;
|
||||
|
||||
try {
|
||||
const statePath = join(planBase, 'STATE.md');
|
||||
if (existsSync(statePath)) {
|
||||
const state = await readFile(statePath, 'utf-8');
|
||||
const m = state.match(/^\s*(?:\*\*)?milestone(?:\*\*)?:\s*([^\s\r\n#]+).*$/mi);
|
||||
if (m && m[1]) {
|
||||
const milestone = m[1].trim();
|
||||
const candidate = join(planBase, 'milestones', `${milestone}-phases`);
|
||||
if (archiveDirs.includes(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
return archiveDirs[archiveDirs.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the active phase roots to validate against. When the flat
|
||||
* `.planning/phases/` directory exists, it counts. When an active
|
||||
* milestone archive (e.g. `.planning/milestones/v1.7-phases/`) exists, it
|
||||
* counts as well. Mirrors `collectPhaseRoots` in verify.cjs:437. Bug #3164.
|
||||
*/
|
||||
async function collectPhaseRoots(planBase: string): Promise<string[]> {
|
||||
const roots: string[] = [];
|
||||
const flatPhasesDir = join(planBase, 'phases');
|
||||
if (existsSync(flatPhasesDir)) roots.push(flatPhasesDir);
|
||||
const activeArchive = await getActiveMilestoneArchiveDir(planBase);
|
||||
if (activeArchive) roots.push(activeArchive);
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical plan stem used for PLAN/SUMMARY matching.
|
||||
* Example: `68-01-scaffolding` -> `68-01`.
|
||||
@@ -219,23 +285,40 @@ export const validateConsistency: QueryHandler = async (_args, projectDir, works
|
||||
roadmapPhases.add(m[1]);
|
||||
}
|
||||
|
||||
// Get phases on disk
|
||||
// Get phases on disk — flat layout AND active milestone archive (bug #3164).
|
||||
// CJS uses `collectDiskPhases(planBase)` + `collectPhaseRoots(planBase)`.
|
||||
// Each root contributes its phase tokens to diskPhases. Plan-level scans
|
||||
// below walk every root, not just the flat one.
|
||||
const diskPhases = new Set<string>();
|
||||
let diskDirs: string[] = [];
|
||||
try {
|
||||
const entries = await readdir(paths.phases, { withFileTypes: true });
|
||||
diskDirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
||||
for (const dir of diskDirs) {
|
||||
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
if (dm) diskPhases.add(dm[1]);
|
||||
const phaseRoots = await collectPhaseRoots(paths.planning);
|
||||
/** Map of root → its phase-directory entries (for downstream plan scans). */
|
||||
const rootDirs = new Map<string, string[]>();
|
||||
for (const root of phaseRoots) {
|
||||
try {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
||||
rootDirs.set(root, dirs);
|
||||
for (const dir of dirs) {
|
||||
const dm = dir.match(PHASE_TOKEN_FROM_DIR_RE);
|
||||
if (dm) diskPhases.add(dm[1]);
|
||||
}
|
||||
} catch {
|
||||
rootDirs.set(root, []);
|
||||
}
|
||||
} catch {
|
||||
// phases directory doesn't exist
|
||||
}
|
||||
|
||||
// Check: phases in ROADMAP but not on disk
|
||||
// Check: phases in ROADMAP but not on disk. CJS parity: compare against
|
||||
// both the as-written form and the canonical normalized form, AND strip the
|
||||
// optional project-code prefix on disk dirs (handled by
|
||||
// PHASE_TOKEN_FROM_DIR_RE above) so `CK-64-…` is recognised as phase 64.
|
||||
for (const p of roadmapPhases) {
|
||||
if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) {
|
||||
const normalizedP = normalizePhaseName(p);
|
||||
const unpaddedP = String(parseInt(p, 10));
|
||||
if (
|
||||
!diskPhases.has(p) &&
|
||||
!diskPhases.has(normalizedP) &&
|
||||
!diskPhases.has(unpaddedP)
|
||||
) {
|
||||
warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`);
|
||||
}
|
||||
}
|
||||
@@ -270,60 +353,63 @@ export const validateConsistency: QueryHandler = async (_args, projectDir, works
|
||||
}
|
||||
}
|
||||
|
||||
// Check plan numbering and summaries within each phase
|
||||
for (const dir of diskDirs) {
|
||||
let phaseFiles: string[];
|
||||
try {
|
||||
phaseFiles = await readdir(join(paths.phases, dir));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Check plan numbering and summaries within each phase across every active
|
||||
// phase root. Bug #3164 \u2014 projects on the milestone-archive layout have
|
||||
// phases under `.planning/milestones/<version>-phases/<phase>/`, not the
|
||||
// flat `.planning/phases/` directory.
|
||||
for (const root of phaseRoots) {
|
||||
const dirs = rootDirs.get(root) ?? [];
|
||||
// Label paths relative to planning/ so warnings carry the archive prefix
|
||||
// (e.g. `milestones/v1.7-phases/65-current`) instead of bare phase names.
|
||||
const relRoot = root.startsWith(paths.planning + '/')
|
||||
? root.slice(paths.planning.length + 1)
|
||||
: root;
|
||||
|
||||
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort();
|
||||
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md'));
|
||||
|
||||
// Extract plan numbers and check for gaps
|
||||
const planNums = plans.map(p => {
|
||||
const pm = p.match(/-(\d{2})-PLAN\.md$/);
|
||||
return pm ? parseInt(pm[1], 10) : null;
|
||||
}).filter((n): n is number => n !== null);
|
||||
|
||||
for (let i = 1; i < planNums.length; i++) {
|
||||
if (planNums[i] !== planNums[i - 1] + 1) {
|
||||
warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} \u2192 ${planNums[i]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check: summaries without matching plans
|
||||
const planIds = new Set(plans.map(p => p.replace('-PLAN.md', '')));
|
||||
const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', '')));
|
||||
|
||||
for (const sid of summaryIds) {
|
||||
if (!planIds.has(sid)) {
|
||||
warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check frontmatter completeness in plans
|
||||
for (const dir of diskDirs) {
|
||||
let phaseFiles: string[];
|
||||
try {
|
||||
phaseFiles = await readdir(join(paths.phases, dir));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md'));
|
||||
for (const plan of plans) {
|
||||
for (const dir of dirs) {
|
||||
const phaseLabel = relRoot === 'phases' ? dir : `${relRoot}/${dir}`;
|
||||
let phaseFiles: string[];
|
||||
try {
|
||||
const content = await readFile(join(paths.phases, dir, plan), 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
if (!fm.wave) {
|
||||
warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`);
|
||||
}
|
||||
phaseFiles = await readdir(join(root, dir));
|
||||
} catch {
|
||||
// Cannot read plan file
|
||||
continue;
|
||||
}
|
||||
|
||||
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort();
|
||||
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md'));
|
||||
|
||||
// Extract plan numbers and check for gaps
|
||||
const planNums = plans.map(p => {
|
||||
const pm = p.match(/-(\d{2})-PLAN\.md$/);
|
||||
return pm ? parseInt(pm[1], 10) : null;
|
||||
}).filter((n): n is number => n !== null);
|
||||
|
||||
for (let i = 1; i < planNums.length; i++) {
|
||||
if (planNums[i] !== planNums[i - 1] + 1) {
|
||||
warnings.push(`Gap in plan numbering in ${phaseLabel}: plan ${planNums[i - 1]} \u2192 ${planNums[i]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check: summaries without matching plans
|
||||
const planIds = new Set(plans.map(p => p.replace('-PLAN.md', '')));
|
||||
const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', '')));
|
||||
|
||||
for (const sid of summaryIds) {
|
||||
if (!planIds.has(sid)) {
|
||||
warnings.push(`Summary ${sid}-SUMMARY.md in ${phaseLabel} has no matching PLAN.md`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check frontmatter completeness in plans (same scope as above).
|
||||
for (const plan of plans) {
|
||||
try {
|
||||
const content = await readFile(join(root, dir, plan), 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
if (!fm.wave) {
|
||||
warnings.push(`${phaseLabel}/${plan}: missing 'wave' in frontmatter`);
|
||||
}
|
||||
} catch {
|
||||
// Cannot read plan file
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
planningPaths,
|
||||
} from './helpers.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
import { resolveGsdToolsPath } from '../sdk-package-compatibility.js';
|
||||
|
||||
// ─── verifyPlanStructure ───────────────────────────────────────────────────
|
||||
|
||||
@@ -645,48 +644,13 @@ export const verifySchemaDrift: QueryHandler = async (args, projectDir, workstre
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* verify.codebase-drift — structural drift detector (#2003).
|
||||
*
|
||||
* Non-blocking by contract: every failure mode returns a successful response
|
||||
* with `{ skipped: true, reason }`. The post-execute drift gate in
|
||||
* `/gsd-execute-phase` relies on this guarantee.
|
||||
*
|
||||
* Delegates to the Node-side implementation in `bin/lib/drift.cjs` and
|
||||
* `bin/lib/verify.cjs` via a child process so the drift logic stays in one
|
||||
* canonical place (see `cmdVerifyCodebaseDrift`).
|
||||
*/
|
||||
export const verifyCodebaseDrift: QueryHandler = async (_args, projectDir) => {
|
||||
try {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const toolsPath = resolveGsdToolsPath(projectDir);
|
||||
const out = execFileSync(process.execPath, [toolsPath, 'verify', 'codebase-drift'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
try {
|
||||
return { data: JSON.parse(out) };
|
||||
} catch {
|
||||
return {
|
||||
data: {
|
||||
skipped: true,
|
||||
reason: 'sdk-parse-failed',
|
||||
action_required: false,
|
||||
directive: 'none',
|
||||
elements: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
data: {
|
||||
skipped: true,
|
||||
reason: 'sdk-exception: ' + (err instanceof Error ? err.message : String(err)),
|
||||
action_required: false,
|
||||
directive: 'none',
|
||||
elements: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
// verify.codebase-drift handler intentionally NOT exported from the SDK.
|
||||
// drift (bin/lib/drift.cjs) is out-of-seam, CJS-only per ADR/PRD
|
||||
// docs/adr/3524-cjs-sdk-hard-seam.md §3 and docs/prd/3524-cjs-sdk-hard-seam.md
|
||||
// L160: "CJS-only Module handlers (...drift...) keep their in-process CJS
|
||||
// implementations because no SDK counterpart exists." Previous Phase 6 stub
|
||||
// (which execFileSync'd back to gsd-tools) created an infinite SDK→CLI→SDK
|
||||
// recursion when the CJS verify-command-router dispatched through the SDK
|
||||
// bridge — observed forking hundreds of node processes on a 64 GiB host.
|
||||
// The router now dispatches `verify codebase-drift` direct to
|
||||
// `verify.cmdVerifyCodebaseDrift`, which is the canonical implementation.
|
||||
|
||||
Reference in New Issue
Block a user