Merge origin/main into feat/3597-split-suites-node-matrix

Resolves conflict in .github/workflows/test.yml: keep the 6 new drift-check
steps from main (plan-scan, secrets, schema-detect, decisions,
workstream-name-policy, Shared Module hand-sync) before the split-lane test
runs from this PR. PR's dedicated `coverage` job replaces main's per-matrix
`Run tests with coverage` step.

Other conflicting files (CONTRIBUTING.md, get-shit-done/bin/lib/init.cjs,
package.json) auto-merged cleanly. Changeset files (.changeset/*) brought in
from main as adds.
This commit is contained in:
Tom Boucher
2026-05-16 13:22:07 -04:00
132 changed files with 9379 additions and 1405 deletions

View File

@@ -0,0 +1,21 @@
---
type: Fixed
pr: 3577
---
**Phase 6 ADR/PRD compliance: out-of-seam Modules removed from SDK catalog; CJS-only verbs dispatch direct** — `verify.codebase-drift` and the eight `intel.*` verbs were wrongly bound in the SDK catalog/manifests, in violation of `docs/adr/3524-cjs-sdk-hard-seam.md` §3 and `docs/prd/3524-cjs-sdk-hard-seam.md` L160 which list `drift`, `intel`, `graphify`, `gsd2-import`, `schema-detect`, `fallow-runner`, `installer-migrations` as CJS-only ("...keep their in-process CJS implementations because no SDK counterpart exists"). The `verifyCodebaseDrift` SDK stub then `execFileSync`'d back to `gsd-tools verify codebase-drift`, which the router routed back through the SDK bridge — an infinite recursion that forked hundreds of node processes on the remote 64 GiB docker host before manual kill. All wrongly-bound entries removed; the CJS router and `gsd-tools.cjs` already had direct CJS dispatch paths for these verbs that are now the only path.
**Phase 6 `config-ensure-section` cutover via catalog rebind, not CJS fallback** — restored the legacy "no-arg full default config.json init" contract on the SDK path by binding the catalog entry `'config-ensure-section'` to `configNewProject` (whose no-args branch produces the same shape as the legacy `ensureConfigFile → buildNewProjectConfig` chain). The original Phase 6 binding to the new `configEnsureSection` handler (single-section ensure, requires `args[0]=sectionName`) broke every CLI caller, which all invoke the no-arg form.
**`configNewProject` defaults sourced from canonical Configuration Module manifest** — replaced the hardcoded duplicate `defaults` object with a derivation from `sdk/shared/config-defaults.manifest.json` (exported as `CONFIG_DEFAULTS` from `sdk/src/configuration/index.ts`). The previous duplicate had drifted from the manifest — 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`. Closes the same `DEFECT.PORT-DRIFT.cjs-sdk` family the ADR was written to prevent.
**SDK `configSet` value-validation port from CJS `cmdConfigSet`** — added the missing enum/shape validators that the CJS handler enforced: `workflow.drift_action` (warn|auto-remap), `workflow.drift_threshold` (positive integer), `workflow.human_verify_mode` (mid-flight|end-of-phase), `statusline.context_position` (front|end), `code_quality.fallow.scope` (phase|repo), `code_quality.fallow.profile` (minimal|standard|strict), and `review.default_reviewers` (array of slug strings matching `^[a-zA-Z0-9_-]+$`, normalized to lowercase-unique, with the normalized value persisted to disk).
**Init handlers honor `--tdd` flag and `workflow.subagent_timeout`** — `initExecutePhase` and `initPlanPhase` now parse the `--tdd` boolean override (matching `parseNamedArgs(args, [], ['validate', 'tdd'])` in the CJS router and `options.tdd || config.tdd_mode || false` in the CJS handler), and `initMapCodebase` reads `subagent_timeout` from the canonical `workflow.subagent_timeout` location with the manifest-mandated 300000 default instead of an undefined fallback.
**`roadmap.analyze` surfaces `mode` per phase** — extracts the same `**Mode:**` field that `roadmapGetPhase` already parses, so consumers can read MVP-mode flagging from either query handler without divergence.
**SDK `phaseComplete` performs auto-prune of STATE.md when configured** — ported the `workflow.auto_prune_state === true` branch from CJS `cmdPhaseComplete`, calling `statePrune(['--keep-recent', '3', '--silent'], ...)` so completing phase N actually removes stale `[Phase 1..N-3]` decisions instead of leaving them forever. (#2087)
**SDK `initRemoveWorkspace` errors via thrown `GSDError`** — returning `{ data: { error } }` was treated as success by the CLI output path; the no-name and workspace-not-found branches now throw `GSDError(..., ErrorClassification.Validation)` so the CLI returns non-zero and writes the message to stderr.
**SDK `frontmatterGet` parses `--field <name>`** — the CLI invocation `frontmatter get <file> --field phase` was passing `args = [file, '--field', 'phase']`; the handler treated `args[1]` as the field name and saw the literal string `--field`. Now handles both `--field <name>` and positional `args[1]`.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3577
---
**Phase 6 `config-*` SDK port parity carve-outs** — restored the legacy contract for four CLI tests broken by the Phase 6 router migration. `config-ensure-section` no longer routes through the new SDK `configEnsureSection` handler (which expected a positional `<section>` arg the CLI never passes) and instead keeps the `cmdConfigEnsureSection → ensureConfigFile → buildNewProjectConfig` CJS path that produces the full default `.planning/config.json`. The SDK `configNewProject` defaults now match `sdk/shared/config-defaults.manifest.json` (`commit_docs: true`, `parallelization: true`) and report the project-rooted relative path `.planning/config.json` to mirror the CJS shape. SDK error vocabulary is aligned with CJS: `Unknown config key: <key>` (no quotes), and config-get's malformed-JSON error is led by `Failed to read config.json:` so legacy regression tests keep matching. Closes the `Usage: config-ensure-section <section>` regression seen in `tests/{config,agent-skills,ai-evals}.test.cjs`.

View File

@@ -0,0 +1,11 @@
---
type: Fixed
pr: 3577
---
**Docker test fix-forward: 12 ubuntu-only regressions surfaced by `gsd-test-summary` cleared** —
- `agents/gsd-intel-updater.md` retargeted from `gsd-sdk query intel.*` to `gsd-tools intel <subcommand>` (intel is out-of-seam per ADR §3 / PRD L160; the SDK has no handler for it, so the agent's CLI calls were broken).
- `roadmap.get-phase` two-pass lookup for project-code-prefixed IDs (port of CJS `phaseMarkdownRegexSourceExact`, #3599): a `PROJ-42` query now matches `### Phase PROJ-42:` directly without cross-matching a bare `### Phase 42:` that happens to share the trailing integer.
- `roadmap.analyze` extracts the `**Mode:**` field per phase (parity with `roadmap.get-phase`).
- `phase.remove` depth-aware end-of-section regex (port of CJS #3601 fix): removing `### Phase 2:` stops at `### Phase 2.1:` (peer-depth decimal preserved) but continues past `#### Phase 27.1:` (child-depth decimal of `### Phase 27:`). Named capture `(?<h>#{2,4})` + backreference `\k<h>(?!#)` enforces same-depth termination.
- `phase.remove` slugged-plan reference renumbering (port of CJS #3602 fix): the padded-plan-reference pattern now allows arbitrary kebab-case slug segments between `NN-NN` and the `-PLAN.md` / `-SUMMARY.md` suffix, so references like `07-01-cherry-pick-foundation-PLAN.md` get renumbered to `06-01-…` when Phase 7 is removed.
- `configNewProject` filters out manifest keys that legacy CJS init does not materialize (`git.base_branch`, `resolve_model_ids`, `context_window`, `mode`, `planning`, `graphify`): these have their own resolution paths (auto-detect, opt-in) and materializing manifest values would suppress them. `config-get git.base_branch` correctly returns "Key not found" so workflows can fall back to `origin/HEAD` resolution.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3648
---
**`gsd-sdk query resolve-model` now honors `resolve_model_ids: true` under `runtime: "claude"`** — previously the resolver bailed out of `resolveRuntimeTier` for Claude (because Claude is the implicit/default runtime in `config-query.ts:149`) and fell through to the alias-return path on line 243 without consulting the catalog. Consumers asking for resolved model IDs received the tier alias (`opus` / `sonnet` / `haiku`) instead of the full Claude model ID (`claude-opus-4-7` / `claude-sonnet-4-6` / `claude-haiku-4-5`). The CJS branch at `get-shit-done/bin/lib/core.cjs:1348-1350` (`if (config.resolve_model_ids) return MODEL_ALIAS_MAP[alias] || alias;`) was missing from the TS port. Added a `runtime === 'claude' && resolveModelIds === true` branch that calls `resolveRuntimeTierDefault('claude', tier)` from the shared model-catalog so both runtimes derive Claude IDs from the same source of truth. `model_overrides`, the phase-type tier override (`config.models[phaseType]`), and `resolve_model_ids: "omit"` all retain their existing precedence. (#3643)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3581
---
**`/gsd:plan-phase` now refuses to replan closed phases** — `init.plan-phase` exposes a new `phase_status` field and the workflow short-circuits on `Complete` phases (use `--force` to override; `--reviews` has no override).

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3641
---
**Install-time warning when a stale `@gsd-build/sdk` shadows the bundled `gsd-sdk` shim** — global installs now run `npm ls -g @gsd-build/sdk` and, if the standalone 0.1.0 package is present (it never received `query` subcommand support), print a clear remediation block before the install completes. Detection is fail-closed: any npm/exec error silently returns no-stale. Gated by `GSD_SKIP_STALE_SDK_CHECK=1` for CI/test environments. Resolves #3406.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3579
---
**Graphify auto-update hook now ships to install targets** — `gsd-graphify-update.sh` was missing from `scripts/build-hooks.js` `HOOKS_TO_COPY`, so it never landed in `hooks/dist/` and the installer's flat-readdir loop never copied it to `~/.claude/hooks/`. The hook's detached rebuild helper at `hooks/lib/gsd-graphify-rebuild.sh` was also dropped because both `build-hooks.js` and `bin/install.js` only walked top-level files. Both gaps are fixed: the allowlist now includes the hook, `build-hooks.js` copies whitelisted hook subdirectories into `hooks/dist/`, and `bin/install.js` mirrors hook subdirs to the target. Added a coverage drift guard so every top-level `hooks/*.sh` must be listed in `HOOKS_TO_COPY` going forward (#3579).

View File

@@ -0,0 +1,5 @@
---
type: Security
pr: 3588
---
**`npm audit --omit=dev` is clean** — bumped lockfile-pinned transitive versions of `fast-uri`, `@anthropic-ai/sdk`, `hono`, `ip-address`, and `express-rate-limit` (pulled in through `@anthropic-ai/claude-agent-sdk` and `@modelcontextprotocol/sdk`) to patched releases. Same pass applied to `sdk/package-lock.json` (was clean for production already; the test now locks it in). Resolves #3588.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3631
---
**SDK dispatch path in family routers now honours `--raw`** — `phase next-decimal --raw`, `roadmap get-phase --raw`, and other family-router commands that route through the SDK bridge now emit the same scalar string the CJS path emitted before #3577. Routers request `mode: 'raw'` from the bridge under `--raw`; the sync-bridge worker wires `formatNativeRaw` to `formatQueryRawOutput` so the bridge returns the per-command projection. Routers then pass the formatted string through `output()`'s rawValue branch instead of JSON-stringifying it.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3632
---
**`lint-shared-module-handsync` now reports unauthorized ts siblings even when a co-named sibling is allowlisted** — when a `bin/lib/<name>.cjs` had two ts candidates on disk (e.g. `sdk/src/<name>.ts` and `sdk/src/query/<name>.ts`) and only one pair was in the allowlist, the `.some()` short-circuit silently skipped the unallowlisted sibling. Each ts candidate is now classified independently so partial-allowlist drift surfaces correctly.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3583
---
Claude skill install (convertClaudeCommandToClaudeSkill + copyCommandsAsClaudeSkills) now normalizes retired /gsd:<cmd> references in SKILL.md bodies to the canonical gsd-<cmd> hyphen form using the new transformContentToHyphen from the shared fix-slash-commands.cjs transformer. Frontmatter name: was already correct since #2808; body leakage is now eliminated for Claude, Qwen, and Hermes. Added regression guard in bug-2808 test. Fixes #3583.

View File

@@ -0,0 +1,8 @@
---
type: Added
pr: 3213
---
**Added: `lint:docs` enforcement.** New `scripts/lint-docs-required.cjs` + `Docs Required` CI workflow fail any PR whose changeset fragment is typed `Added` / `Changed` / `Deprecated` / `Removed` without modifying at least one file under `docs/`. Escape hatches: the `no-docs` PR label, or a per-fragment HTML-comment marker on its own line at the end of the fragment body (extracted at the `parseFragment` seam so it never bleeds into CHANGELOG.md or GitHub release-notes output). `Fixed` and `Security` fragments are not gated. Malformed fragments now fail closed via the new `FAIL_MALFORMED_FRAGMENT` verdict — a triggering fragment with bad frontmatter cannot silently bypass docs enforcement. PR templates (`enhancement.md`, `feature.md`) gain a Documentation checklist; `CONTRIBUTING.md` adds a `Documentation Updates` section codifying the which-doc-to-update matrix and English-canonical language policy.
<!-- docs-exempt: bootstrap of docs-required lint itself; contributor-facing enforcement lives in CONTRIBUTING.md and the PR templates, while docs/ is reserved for end-user documentation -->

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3577
---
**SDK validation errors no longer surface as native_failure** — the runtime-bridge-sync worker now unwraps GSDError causes wrapped in GSDToolsError. Empty/invalid command arguments produce errorKind: 'validation_error' (exit 10) as the SyncErrorKind taxonomy promises, instead of the misleading errorKind: 'native_failure'. Detected by new Phase 6 behavioral contract tests.

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3577
---
**Shared Module hand-sync drift lint** — `scripts/lint-shared-module-handsync.cjs` runs in CI on every PR and fails when a new `bin/lib/<name>.cjs` and `sdk/src/<name>.ts` (or `sdk/src/query/<name>.ts`) pair is introduced without an entry in `scripts/shared-module-handsync-allowlist.json`. The allowlist documents 14 legitimate cooperating-sibling pairs (Adapters over generated Modules, Readers over shared Builders, runtime-distinct routing) plus 8 known drift pairs (`config`, `decisions`, `intel`, `model-catalog`, `plan-scan`, `schema-detect`, `secrets`, `workstream-name-policy`) flagged as future Shared-Module migration backlog. Phase 6 of #3524 also adds path-specific CODEOWNERS rules requiring architecture-team review for source-of-truth files (`sdk/src/<module>/`, `sdk/shared/*.manifest.json`, `sdk/src/runtime-bridge-sync/`, the lint script itself), publishes `docs/agents/cjs-sdk-seam.md` mapping all 15 historical drift bugs (#1535 … #3523) to the enforcement layer that would have blocked each, and adds a contributor guide for adding new Shared Modules and new canonical commands. After this PR the CJS↔SDK seam migration (#3524) is feature-complete. Closes #3575.

View File

@@ -20,3 +20,23 @@ fi
if git diff --cached --name-only | grep -Eq "^sdk/src/project-root/|^get-shit-done/bin/lib/project-root\.generated\.cjs$|^sdk/scripts/gen-project-root\.mjs$|^sdk/scripts/check-project-root-fresh\.mjs$"; then
npm run check:project-root-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/query/plan-scan\.ts$|^get-shit-done/bin/lib/plan-scan\.generated\.cjs$|^sdk/scripts/gen-plan-scan\.mjs$|^sdk/scripts/check-plan-scan-fresh\.mjs$"; then
npm run check:plan-scan-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/query/secrets\.ts$|^get-shit-done/bin/lib/secrets\.generated\.cjs$|^sdk/scripts/gen-secrets\.mjs$|^sdk/scripts/check-secrets-fresh\.mjs$"; then
npm run check:secrets-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/query/schema-detect\.ts$|^get-shit-done/bin/lib/schema-detect\.generated\.cjs$|^sdk/scripts/gen-schema-detect\.mjs$|^sdk/scripts/check-schema-detect-fresh\.mjs$"; then
npm run check:schema-detect-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/query/decisions\.ts$|^get-shit-done/bin/lib/decisions\.generated\.cjs$|^sdk/scripts/gen-decisions\.mjs$|^sdk/scripts/check-decisions-fresh\.mjs$"; then
npm run check:decisions-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/workstream-name-policy\.ts$|^get-shit-done/bin/lib/workstream-name-policy\.generated\.cjs$|^sdk/scripts/gen-workstream-name-policy\.mjs$|^sdk/scripts/check-workstream-name-policy-fresh\.mjs$"; then
npm run check:workstream-name-policy-fresh
fi

19
.github/CODEOWNERS vendored
View File

@@ -1,2 +1,21 @@
# All changes require review from project owner
* @glittercowboy
# Phase 6 of #3524 — source-of-truth files require architecture-team review.
# See docs/agents/cjs-sdk-seam.md for context.
# The blanket rule above already covers everything; these specific rules make
# the architectural intent explicit and would still apply if the blanket rule
# is later relaxed.
/sdk/src/state-document/ @glittercowboy
/sdk/src/configuration/ @glittercowboy
/sdk/src/workstream-inventory/ @glittercowboy
/sdk/src/project-root/ @glittercowboy
/sdk/src/runtime-bridge-sync/ @glittercowboy
/sdk/shared/config-defaults.manifest.json @glittercowboy
/sdk/shared/config-schema.manifest.json @glittercowboy
/sdk/shared/model-catalog.json @glittercowboy
/sdk/src/query/query-runtime-bridge.ts @glittercowboy
/scripts/lint-shared-module-handsync.cjs @glittercowboy
/scripts/shared-module-handsync-allowlist.json @glittercowboy
/sdk/src/query/decisions.ts @glittercowboy
/sdk/src/workstream-name-policy.ts @glittercowboy

View File

@@ -66,6 +66,22 @@ Closes #
---
## Documentation
> CI enforces this — `lint:docs` fails any PR with an `Added` / `Changed` / `Deprecated` / `Removed`
> changeset fragment that does not also touch at least one file under `docs/`.
> See [CONTRIBUTING.md → Documentation Updates](../../CONTRIBUTING.md#documentation-updates-update-the-relevant-docs).
- [ ] Updated the relevant file(s) under `docs/` to reflect this change
- Behavior or output change → `docs/USER-GUIDE.md` and/or `docs/COMMANDS.md`
- Configuration / schema change → `docs/CONFIGURATION.md`
- Architectural change → `docs/ARCHITECTURE.md` and/or `docs/adr/`
- Agent or skill change → `docs/AGENTS.md`
- [ ] All `docs/` content added in this PR is written in English
- [ ] If genuinely no user-facing docs impact (infrastructure / internal refactor / test-only),
apply the `no-docs` label **or** add `<!-- docs-exempt: <reason> -->` inside each
triggering changeset fragment and leave a comment explaining why.
## Checklist
- [ ] Issue linked above with `Closes #NNN` — **PR will be auto-closed if missing**
@@ -74,7 +90,6 @@ Closes #
- [ ] All existing tests pass (`npm test`)
- [ ] New or updated tests cover the enhanced behavior
- [ ] `.changeset/` fragment added (`npm run changeset -- --type Changed --pr <NNN> --body "..."`) — or `no-changelog` label applied if not user-facing
- [ ] Documentation updated if behavior or output changed
- [ ] No unnecessary dependencies added
## Breaking changes

View File

@@ -86,6 +86,26 @@ Closes #
---
## Documentation
> CI enforces this — `lint:docs` fails any PR with an `Added` / `Changed` / `Deprecated` / `Removed`
> changeset fragment that does not also touch at least one file under `docs/`. Features almost
> always trigger `Added`. See
> [CONTRIBUTING.md → Documentation Updates](../../CONTRIBUTING.md#documentation-updates-update-the-relevant-docs).
- [ ] Updated the relevant file(s) under `docs/` to reflect this feature
- New command or flag → `docs/COMMANDS.md` and `docs/FEATURES.md`
- New workflow or behavior → `docs/USER-GUIDE.md`
- Configuration / schema change → `docs/CONFIGURATION.md`
- Architectural change → `docs/ARCHITECTURE.md` and/or `docs/adr/`
- Agent or skill change → `docs/AGENTS.md`
- [ ] All `docs/` content added in this PR is written in English
(translated READMEs `README.pt-BR.md` / `README.zh-CN.md` / `README.ja-JP.md` / `README.ko-KR.md`
are community-maintained and do not need to be updated in this PR)
- [ ] If genuinely no user-facing docs impact (rare for features — explain in PR), apply the
`no-docs` label **or** add `<!-- docs-exempt: <reason> -->` inside each triggering
changeset fragment.
## Checklist
- [ ] Issue linked above with `Closes #NNN` — **PR will be auto-closed if missing**
@@ -95,7 +115,6 @@ Closes #
- [ ] All existing tests pass (`npm test`)
- [ ] New tests cover the happy path, error cases, and edge cases
- [ ] `.changeset/` fragment added with a user-facing description of the feature (`npm run changeset -- --type Added --pr <NNN> --body "..."`)
- [ ] Documentation updated — commands, workflows, references, README if applicable
- [ ] No unnecessary external dependencies added
- [ ] Works on Windows (backslash paths handled)

24
.github/workflows/docs-required.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Docs Required
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
jobs:
docs-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Run docs-required lint
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: node scripts/lint-docs-required.cjs

View File

@@ -127,6 +127,36 @@ jobs:
shell: bash
run: node sdk/scripts/check-project-root-fresh.mjs
- name: SDK generated plan-scan artifact drift check
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
shell: bash
run: node sdk/scripts/check-plan-scan-fresh.mjs
- name: SDK generated secrets artifact drift check
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
shell: bash
run: node sdk/scripts/check-secrets-fresh.mjs
- name: SDK generated schema-detect artifact drift check
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
shell: bash
run: node sdk/scripts/check-schema-detect-fresh.mjs
- name: SDK generated decisions artifact drift check
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
shell: bash
run: node sdk/scripts/check-decisions-fresh.mjs
- name: SDK generated workstream-name-policy artifact drift check
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
shell: bash
run: node sdk/scripts/check-workstream-name-policy-fresh.mjs
- name: Shared Module hand-sync drift check
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
shell: bash
run: node scripts/lint-shared-module-handsync.cjs
# Split lanes (issue #3597). Unit is the fast default lane; integration
# and security run alongside it on every PR. `install` and `slow` are
# skipped on PR CI by design — they run on the weekly windows-compat
@@ -157,6 +187,7 @@ jobs:
# Dedicated coverage job. Runs only on ubuntu/Node 24 (the canonical lane)
# because c8 coverage of one suite on one OS is enough signal — running it
# across the full matrix would 9x the cost for no extra coverage data.
# Replaces main's per-matrix `Run tests with coverage` step.
coverage:
runs-on: ubuntu-latest
timeout-minutes: 15

41
AGENTS.md Normal file
View File

@@ -0,0 +1,41 @@
# Repository Guidelines
## Active Discussions
For current work on **Grok Build compatibility** and multi-runtime synchronization across Grok Build, Claude Code, Gemini CLI, and Codex, see:
- `docs/discussions/grok-build-support-2026-05.md`
## Project Structure & Module Organization
This repository ships GSD as a Node.js CLI and SDK. Root package entry points live in `bin/`, scripts in `scripts/`, runtime hooks in `hooks/`, command definitions in `commands/gsd/`, and workflow/template content in `get-shit-done/`. Agent role files are in `agents/`; docs are in `docs/`; logos and terminal images are in `assets/`. Root tests are in `tests/*.test.cjs`. The TypeScript SDK is isolated under `sdk/`, with source and Vitest tests in `sdk/src/`.
## Build, Test, and Development Commands
Use Node.js `>=22`.
- `npm install`: install root dependencies.
- `npm test`: builds the SDK first, then runs root `node:test` suites via `scripts/run-tests.cjs`.
- `npm run test:coverage`: runs root tests with `c8` and enforces 70% line coverage for included CommonJS library files.
- `npm run build:hooks`: rebuilds generated hook artifacts.
- `npm run build:sdk`: installs SDK dependencies and builds TypeScript.
- `cd sdk && npm test`: runs SDK Vitest unit and integration projects.
- `cd sdk && npm run build`: type-checks and emits `sdk/dist/`.
## Coding Style & Naming Conventions
Match the existing style in the edited area. Root JavaScript is CommonJS, generally strict-mode, two-space indentation, semicolons, `const`/`let`, and `node:` imports for built-ins. SDK code is strict TypeScript using ESM/`NodeNext`. Keep command, workflow, and test filenames kebab-case, for example `commands/gsd/plan-phase.md` and `tests/bug-2396-makefile-test-priority.test.cjs`. Agent files use `gsd-*.md`. Avoid unrelated formatting and unnecessary dependencies.
## Testing Guidelines
Root tests use Node’s built-in `node:test` and `node:assert/strict`; do not add Jest, Mocha, or Chai. Prefer helpers from `tests/helpers.cjs` for temporary projects, cleanup, and CLI execution. Name root tests `*.test.cjs`; run one with `node --test tests/name.test.cjs`. SDK tests use Vitest with `*.test.ts` for unit tests and `*.integration.test.ts` for integration tests.
## Commit & Pull Request Guidelines
Recent history follows Conventional Commit prefixes such as `fix:`, `feat:`, and `ci:`, often with issue references: `fix(#2623): resolve parent .planning root...`. Keep commits scoped and descriptive.
Every PR must link an approved or confirmed issue with `Closes #123`, `Fixes #123`, or `Resolves #123`. Use the matching template in `.github/PULL_REQUEST_TEMPLATE/`. Include behavior changes, root cause when relevant, test evidence, affected platforms/runtimes, and update `CHANGELOG.md` or docs for user-facing changes.
## Security & Configuration Tips
Do not commit secrets, local config, or generated worktree artifacts. Before release-facing changes, run the relevant scan scripts in `scripts/`, especially `secret-scan.sh`, `base64-scan.sh`, and `prompt-injection-scan.sh`.

View File

@@ -122,6 +122,8 @@ Contributor requirements (summary):
- Do not rewrite maintainer intent in `CONTEXT.md`/ADRs as part of drive-by cleanup; propose focused updates tied to approved scope.
- If using an AI assistant, prompt it to read `CONTEXT.md` and the relevant ADRs before writing any code or docs, and verify it used the correct vocabulary before opening the PR.
**CJS↔SDK seam.** When working on `bin/lib/*.cjs` or `sdk/src/**`, read [`docs/agents/cjs-sdk-seam.md`](docs/agents/cjs-sdk-seam.md). It documents the canonical pattern for Shared Modules (data manifest + source-of-truth file + generator + freshness check + Adapters) and the hand-sync pair lint that blocks new drift. New `<name>.cjs` ↔ `<name>.ts` pairs require either migration to a Shared Module or an explicit allowlist entry with justification in `scripts/shared-module-handsync-allowlist.json`. Adding an allowlist entry requires maintainer review via CODEOWNERS.
**Every PR must link to an approved issue.** PRs without a linked issue are closed without review, no exceptions.
- **No draft PRs** — draft PRs are automatically closed. Only open a PR when it is complete, tested, and ready for review. If your work is not finished, keep it on your local branch until it is.
@@ -150,6 +152,40 @@ Fragments are consolidated into `CHANGELOG.md` at release time by the release wo
**Opt-out:** PRs with no user-facing impact (test refactors, lint config changes, CI tweaks, formatting-only changes) can add the `no-changelog` label. The lint honors it. When unsure whether a change is user-facing, **add the fragment**.
## Documentation Updates — Update the Relevant Docs
If your PR adds, changes, deprecates, or removes user-visible behavior, you **must** update the relevant documentation in `docs/`. CI will fail any PR whose changeset fragment is typed `Added`, `Changed`, `Deprecated`, or `Removed` without also modifying at least one file under `docs/` ([#3213](https://github.com/gsd-build/get-shit-done/issues/3213)).
`Fixed` and `Security` fragments do not trigger this lint — bug fixes restore documented behavior, they do not introduce new behavior to document. (Edit the docs anyway if a fix corrects something the docs got wrong.)
### Which docs to update
| Change type | Required doc updates |
|---|---|
| New command or flag | `docs/COMMANDS.md`, `docs/FEATURES.md` |
| Changed command behavior or output | `docs/USER-GUIDE.md`, `docs/COMMANDS.md` |
| Configuration / schema change | `docs/CONFIGURATION.md` |
| Architectural change | `docs/ARCHITECTURE.md`, `docs/adr/` |
| Agent or skill change | `docs/AGENTS.md` |
| Removed command, flag, or workflow | All docs that referenced it |
### Language policy
All content in `docs/` and the root `README.md` **must be written in English**. English is the canonical source. The translated READMEs (`README.pt-BR.md`, `README.zh-CN.md`, `README.ja-JP.md`, `README.ko-KR.md`) are community-maintained translations and do not need to be updated by every PR.
### CI enforcement
The `Docs Required` workflow (`scripts/lint-docs-required.cjs`) reads the changeset fragments touched in the PR diff. If any has type `Added` / `Changed` / `Deprecated` / `Removed`, it requires at least one file under `docs/` to also appear in the diff.
### Opt-outs (with paper trail)
When a change genuinely has no user-facing documentation impact (infrastructure rewrite, internal refactor, test-only addition, CI fix), use one of:
- **Label:** add the `no-docs` label to the PR. Leave a comment explaining why no docs update was needed.
- **Per-fragment marker:** add `<!-- docs-exempt: <reason> -->` **on its own line** inside the body of each triggering changeset fragment (typically at the end). The reason is **required and must be non-empty** — a bare `<!-- docs-exempt -->` or `<!-- docs-exempt: -->` is rejected (no audit trail = no exemption). The marker is extracted at parse time by `scripts/changeset/parse.cjs` and stripped from the body before the CHANGELOG.md and GitHub release-notes serializers see it — it leaves a paper trail in the source fragment without leaking into published release notes. Inline mentions of the marker syntax (e.g. inside backticks) are intentionally ignored; the parser only acts on a marker that occupies its own line. Both routes leave a paper trail; the label is global, the marker is per-fragment for mixed PRs.
When unsure whether a change is user-facing, **update the docs**.
## Testing Standards
All tests use Node.js built-in test runner (`node:test`) and assertion library (`node:assert`). **Do not use Jest, Mocha, Chai, or any external test framework.**

View File

@@ -0,0 +1,73 @@
# Quick Wins: Confirmed-Bug Fixes
**Status**: Active
**Started**: 2026-05-16
**Owner**: Current session (Grok + user)
**Context**: Follow-up to `/gsd-inbox` triage on 2026-05-16
## Goal
Land 6 high-signal, confirmed-bug issues that currently have **zero open pull requests**. These are the cleanest quick-win opportunities available in the public GitHub inbox right now.
All six issues carry the `confirmed-bug` label, meaning the bug has been verified and a fix is explicitly welcome.
## The 6 Issues (Prioritized)
| # | Issue | Short Title | Type | Recommended Flow | Est. Effort | Status | Notes |
|---|-------|-------------|------|------------------|-------------|--------|-------|
| 1 | [#3583](https://github.com/gsd-build/get-shit-done/issues/3583) | Claude skill install leaves `/gsd:<cmd>` in `SKILL.md` body | Installer / Command namespace | PR 3629 (our branch) + competing 3586 | Small (1 file + test) | PR opened / Review | **Leading PR: 3629** (cristianuibar) — reviewed + hardened with CodeRabbit feedback (left-boundary regex + body-scoped guard). Competing PR 3586 has "needs changes" + "ci: failing". Issue still carries `confirmed-bug`. |
| 2 | [#3579](https://github.com/gsd-build/get-shit-done/issues/3579) | `build-hooks.js` + npm publish omit graphify auto-update hook | Packaging / Build | `/gsd-quick` | Small | Not started | Classic "new feature missed in release artifact". Easy local verification. |
| 3 | [#3496](https://github.com/gsd-build/get-shit-done/issues/3496) | `/gsd:update` changelog extraction skips intermediate versions | Workflow / Update logic | `/gsd-quick` or lightweight plan | Medium-small | Not started | Needs deterministic version-range helper. |
| 4 | [#3588](https://github.com/gsd-build/get-shit-done/issues/3588) | Production `npm audit` has 1 high + 5 moderate advisories | Security / Dependencies | Direct + careful review | Medium | Not started | Transitive via `@anthropic-ai/claude-agent-sdk`. May need overrides. |
| 5 | [#3584](https://github.com/gsd-build/get-shit-done/issues/3584) | Runtime `bin/lib/*.cjs` still emit `/gsd:<cmd>` (larger piece deferred from #3583) | Runtime output / Slash formatter | Short plan first, then execute | Medium-Large | Not started | 16+ files. Design a centralized runtime-aware formatter. Do after #3583. |
| 6 | [#3340](https://github.com/gsd-build/get-shit-done/issues/3340) | SDK publish lag — agent dir fix never shipped in `@gsd-build/sdk@0.1.0` | Release / SDK publishing | Plan + coordination | Medium (release-focused) | Not started | Oldest. Mostly a publishing/versioning task. |
## Execution Rules for This Batch
- **Branch naming**: `fix/NNNN-short-description` (enforced by CI)
- **PR template**: Must use `.github/PULL_REQUEST_TEMPLATE/fix.md`
- **Linking**: `Fixes #NNNN` (or `Closes`) in the PR body
- **Changeset**: Required for all user-facing or security fixes
- **Testing**: All existing tests must pass + new coverage where the issue describes a gap
- **Clean context windows**: Each fix should preferably be driven from a fresh session using the prepared prompts (see session notes or ask for them)
- **GSD self-use**: For the small ones (#3583, #3579, #3496), using `/gsd-quick` (or `/gsd-fast`) inside the fix session is encouraged and appropriate. For #3584, a short planning step is recommended.
## Status Legend
- **Not started** — Issue claimed for this batch, no work begun
- **In progress** — Active work in a clean window
- **PR opened** — Pull request created and linked
- **Review** — Awaiting review / CI / merge fixes
- **Merged** — Landed on main
- **Blocked** — Needs input from maintainers or upstream
## Current Status
- [x] #3583 — **PR opened** (3629 leading after CodeRabbit review + hardening push; competing 3586 needs changes + CI failing)
- [ ] #3579 — Not started (cleanest next target — 0 PRs)
- [ ] #3496 — PR 3497 open (changes requested)
- [ ] #3588 — Not started
- [ ] #3584 — Not started (larger; deferred runtime cjs colon emissions)
- [ ] #3340 — Not started
**Progress**: 0 / 6 merged (1 in active review)
## Process Notes
- These issues were identified during a `/gsd-inbox` run on 2026-05-16.
- At the time of creation of this file, zero of the six had open PRs.
- 2026-05-16 Grok session: Reviewed PR 3629 (our #3583 fix) for CodeRabbit comments. 1 critical was false-positive (scripts/ *is* published per package.json "files" + npm pack). Applied the 2 valid suggestions (bidirectional word-boundary lookbehind in `buildColonPattern` + body-only scope for the colon-ref regression guard in the test). Tests pass. Pushed hardening commit to the fork branch. Competing PR 3586 exists but is behind on CI/review status.
- Work is intended to be done in **parallel clean context windows** (one issue per fresh Claude/Codex/Gemini session) using dedicated prompts.
- After each fix is complete in its window, the resulting branch + PR description should be brought back here for final review and opening.
- This file serves as the single source of truth for the current batch while execution is in progress. It can be deleted or moved to `docs/archive/` once all six PRs are merged.
## Related Artifacts
- Inbox triage report: `/tmp/GSD-INBOX-TRIAGE-2026-05-16.md` (from the `/gsd-inbox` run)
- Full issue list with `confirmed-bug` label: `gh issue list --state open --label confirmed-bug`
---
**Next action**: #3583 now has active PR(s) under review. Next clean quick win (0 PRs, small packaging effort, high value for recently-landed graphify feature): **#3579**. Validated via GitHub search: no PRs mention 3579. Ready for `/gsd-quick` or direct fix (update `scripts/build-hooks.js` HOOKS_TO_COPY + ensure `hooks/lib/` copy in installer + fix any publish filter).
This document will be updated as status changes.

View File

@@ -37,7 +37,7 @@ Write machine-parseable, evidence-based intelligence. Every claim references act
- **Always include file paths.** Every claim must reference the actual code location.
- **Write current state only.** No temporal language ("recently added", "will be changed").
- **Evidence-based.** Read the actual files. Do not guess from file names or directory structures.
- **Cross-platform.** Use Glob, Read, and Grep tools -- not Bash `ls`, `find`, or `cat`. Bash file commands fail on Windows. Only use Bash for `gsd-sdk query intel` CLI calls.
- **Cross-platform.** Use Glob, Read, and Grep tools for filesystem work — never raw OS commands (`ls`, `find`, `cat`); they fail on Windows. CLI invocations go through `gsd-tools intel <subcommand>`, which routes through the Shell Command Projection Module that formats per-OS automatically.
- **ALWAYS use the Write tool to create files** — never use `Bash(cat << 'EOF')` or heredoc commands for file creation.
</role>
@@ -123,7 +123,7 @@ All JSON files include a `_meta` object with `updated_at` (ISO timestamp) and `v
}
```
**exports constraint:** Array of ACTUAL exported symbol names extracted from `module.exports` or `export` statements. MUST be real identifiers (e.g., `"configLoad"`, `"stateUpdate"`), NOT descriptions (e.g., `"config operations"`). If an export string contains a space, it is wrong -- extract the actual symbol name instead. Use `gsd-sdk query intel.extract-exports <file>` to get accurate exports.
**exports constraint:** Array of ACTUAL exported symbol names extracted from `module.exports` or `export` statements. MUST be real identifiers (e.g., `"configLoad"`, `"stateUpdate"`), NOT descriptions (e.g., `"config operations"`). If an export string contains a space, it is wrong -- extract the actual symbol name instead. Use `gsd-tools intel extract-exports <file>` to get accurate exports.
Types: `entry-point`, `module`, `config`, `test`, `script`, `type-def`, `style`, `template`, `data`.
@@ -219,7 +219,7 @@ Glob for project structure indicators:
Read package.json, configs, and build files. Write `stack.json`. Then patch its timestamp:
```bash
gsd-sdk query intel.patch-meta .planning/intel/stack.json --cwd <project_root>
gsd-tools intel patch-meta .planning/intel/stack.json
```
### Step 3: File Graph
@@ -228,7 +228,7 @@ Glob source files (`**/*.ts`, `**/*.js`, `**/*.py`, etc., excluding node_modules
Read key files (entry points, configs, core modules) for imports/exports.
Write `files.json`. Then patch its timestamp:
```bash
gsd-sdk query intel.patch-meta .planning/intel/files.json --cwd <project_root>
gsd-tools intel patch-meta .planning/intel/files.json
```
Focus on files that matter -- entry points, core modules, configs. Skip test files and generated code unless they reveal architecture.
@@ -239,7 +239,7 @@ Grep for route definitions, endpoint declarations, CLI command registrations.
Patterns to search: `app.get(`, `router.post(`, `@GetMapping`, `def route`, express route patterns.
Write `apis.json`. If no API endpoints found, write an empty entries object. Then patch its timestamp:
```bash
gsd-sdk query intel.patch-meta .planning/intel/apis.json --cwd <project_root>
gsd-tools intel patch-meta .planning/intel/apis.json
```
### Step 5: Dependencies
@@ -248,7 +248,7 @@ Read package.json (dependencies, devDependencies), requirements.txt, go.mod, Car
Cross-reference with actual imports to populate `used_by`.
Write `deps.json`. Then patch its timestamp:
```bash
gsd-sdk query intel.patch-meta .planning/intel/deps.json --cwd <project_root>
gsd-tools intel patch-meta .planning/intel/deps.json
```
### Step 6: Architecture
@@ -258,7 +258,7 @@ Write `arch.md`.
### Step 6.5: Self-Check
Run: `gsd-sdk query intel.validate --cwd <project_root>`
Run: `gsd-tools intel validate`
Review the output:
@@ -270,7 +270,7 @@ This step is MANDATORY -- do not skip it.
### Step 7: Snapshot
Run: `gsd-sdk query intel.snapshot --cwd <project_root>`
Run: `gsd-tools intel snapshot`
This writes `.last-refresh.json` with accurate timestamps and hashes. Do NOT write `.last-refresh.json` manually.
</execution_flow>

View File

@@ -20,6 +20,15 @@ const {
projectCodexHookTomlCommand,
} = require('../get-shit-done/bin/lib/shell-command-projection.cjs');
// Bidirectional GSD slash-command namespace transformer (#3583).
// Required at module scope so the command list can be computed once per install
// and passed down to convertClaudeCommandToClaudeSkill, avoiding repeated
// fs.readdirSync + RegExp work for every skill.
const {
transformContentToHyphen,
readCmdNames: readGsdCommandNames,
} = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs'));
// Colors
const cyan = '\x1b[36m';
const green = '\x1b[32m';
@@ -50,6 +59,10 @@ function isCodexHooksFeatureKey(key) {
const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by get-shit-done installer -->';
const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
// GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
// git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
const CODEX_AGENT_SANDBOX = {
'gsd-executor': 'workspace-write',
'gsd-planner': 'workspace-write',
@@ -1665,10 +1678,18 @@ function skillFrontmatterName(skillDirName) {
* Emits `name: gsd-<cmd>` (hyphen) so Skill(skill="gsd-<cmd>") calls and
* tab autocomplete use the canonical command namespace.
*/
function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null) {
function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, cmdNames = null) {
const { frontmatter, body } = extractFrontmatterAndBody(content);
if (!frontmatter) return content;
// #3583: rewrite any /gsd:<cmd> or gsd:<cmd> in the body to the canonical
// hyphen form (gsd-<cmd>) so installed SKILL.md bodies match the hyphen
// `name:` Claude Code (and Qwen/Hermes) register under (#2808). `cmdNames`
// is optional and pre-computed by the caller for performance; direct test
// calls fall back to reading the list.
const names = cmdNames || readGsdCommandNames();
const normalizedBody = transformContentToHyphen(body, names);
const description = extractFrontmatterField(frontmatter, 'description') || '';
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
const agent = extractFrontmatterField(frontmatter, 'agent');
@@ -1694,7 +1715,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null) {
if (toolsBlock) fm += toolsBlock;
fm += '---';
return `${fm}\n${body}`;
return `${fm}\n${normalizedBody}`;
}
/**
@@ -5895,6 +5916,11 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti
fs.mkdirSync(skillsDir, { recursive: true });
// Live command names for the colon→hyphen body transform (#3583), computed
// once per install instead of inside convertClaudeCommandToClaudeSkill where
// it would re-scan commands/gsd for every skill.
const cmdNames = readGsdCommandNames();
// #2973 (CR follow-up on #3003): preserve user-generated skills across the
// wipe-and-replace. `gsd-dev-preferences/SKILL.md` is written by the user
// via `/gsd-profile-user --refresh`; it is NOT shipped by the npm package,
@@ -5985,7 +6011,7 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti
content = content.replace(/\.claude\//g, '.hermes/');
}
content = processAttribution(content, getCommitAttribution(runtime));
content = convertClaudeCommandToClaudeSkill(content, skillName, runtime);
content = convertClaudeCommandToClaudeSkill(content, skillName, runtime, cmdNames);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
}
@@ -6867,6 +6893,33 @@ function uninstall(isGlobal, runtime = 'claude') {
removedCount++;
console.log(` ${green}✓${reset} Removed ${hookCount} GSD hooks`);
}
// Remove only the GSD-managed files from hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
// hooks/lib/ lives inside the user's runtime hooks directory (shared space) and
// may contain user-owned custom helpers. We must not recursively delete the dir.
const hooksLibDir = path.join(hooksDir, 'lib');
if (fs.existsSync(hooksLibDir)) {
let removedLibFiles = 0;
for (const file of GSD_HOOK_LIB_FILES) {
const filePath = path.join(hooksLibDir, file);
try {
fs.unlinkSync(filePath);
removedLibFiles++;
} catch (_) {
// Ignore missing files (best effort, non-fatal)
}
}
// Only remove the directory itself if it is now empty (preserve any user files)
try {
fs.rmdirSync(hooksLibDir);
} catch (_) {
// Directory not empty or other error — leave it alone
}
if (removedLibFiles > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${removedLibFiles} hooks/lib/ helper(s)`);
}
}
}
// 5. Remove GSD package.json (CommonJS mode marker)
@@ -7486,6 +7539,16 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
manifest.files['hooks/' + file] = fileHash(path.join(hooksDir, file));
}
}
// Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
// to git-cmd.js (validate-commit classifier) and gsd-graphify-rebuild.sh.
const hooksLibDir = path.join(hooksDir, 'lib');
if (fs.existsSync(hooksLibDir)) {
for (const file of fs.readdirSync(hooksLibDir)) {
if (GSD_HOOK_LIB_FILES.includes(file)) {
manifest.files['hooks/lib/' + file] = fileHash(path.join(hooksLibDir, file));
}
}
}
}
}
@@ -7749,6 +7812,36 @@ function install(isGlobal, runtime = 'claude', options = {}) {
const dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
// Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
// Defined early so it is visible to both the main and Codex code paths.
// `allowlist` (when non-empty) restricts copying to the named top-level entries,
// keeping install scope aligned with GSD_HOOK_LIB_FILES (which uninstall/manifest manage).
const copyLibDir = (sDir, dDir, allowlist = []) => {
const allowed = allowlist.length > 0 ? new Set(allowlist) : null;
for (const entry of fs.readdirSync(sDir)) {
if (allowed && !allowed.has(entry)) continue;
const s = path.join(sDir, entry);
const d = path.join(dDir, entry);
let st;
try { st = fs.lstatSync(s); } catch (_) { continue; }
if (st.isSymbolicLink()) continue; // defense-in-depth
if (st.isDirectory()) {
fs.mkdirSync(d, { recursive: true });
copyLibDir(s, d);
} else if (entry.endsWith('.sh')) {
let content = fs.readFileSync(s, 'utf8');
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(d, content);
try { fs.chmodSync(d, 0o755); } catch (_) { /* Windows */ }
} else {
fs.copyFileSync(s, d);
if (entry.endsWith('.js')) {
try { fs.chmodSync(d, 0o755); } catch (_) { /* Windows */ }
}
}
}
};
// Get the target directory based on runtime and install type.
// Cline local installs write to the project root (like Claude Code) — .clinerules
// lives at the root, not inside a .cline/ subdirectory.
@@ -7762,6 +7855,45 @@ function install(isGlobal, runtime = 'claude', options = {}) {
? targetDir.replace(os.homedir(), '~')
: targetDir.replace(process.cwd(), '.');
// #3406: warn if a stale standalone `@gsd-build/sdk` is globally installed
// and shadows the `gsd-sdk` shim this installer wires up. Only meaningful
// for global installs (the shim collision lives in the global node_modules
// bin dir). Guarded by GSD_SKIP_STALE_SDK_CHECK so CI/tests can silence it.
// #3406 CR: opt-out only on explicit "1" / "true" / "yes" rather than any
// non-empty value. Without this guard `GSD_SKIP_STALE_SDK_CHECK=0` and
// `GSD_SKIP_STALE_SDK_CHECK=false` would silently disable the check.
const skipRaw = process.env.GSD_SKIP_STALE_SDK_CHECK;
const skipStaleCheck = skipRaw === '1' || skipRaw === 'true' || skipRaw === 'yes';
if (isGlobal && !skipStaleCheck) {
try {
const { execFileSync } = require('child_process');
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const staleInfo = detectStaleStandaloneSdk(() => {
try {
return execFileSync(
npmCmd,
['ls', '-g', '@gsd-build/sdk', '--json', '--depth=0'],
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }
);
} catch (e) {
// `npm ls -g <missing>` exits 1 with the JSON still on stdout when
// the package is absent. execFileSync throws on non-zero exit but
// attaches stdout to the error. Recover the JSON in that case so
// the detector classifies "absent" correctly.
if (e && typeof e.stdout !== 'undefined') {
return Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
}
throw e;
}
});
if (staleInfo.stale) {
console.warn(`\n${yellow}${formatStaleStandaloneSdkWarning(staleInfo)}${reset}\n`);
}
} catch {
// Detection is best-effort; never block install on its failure.
}
}
// Path prefix for file references in markdown content (e.g. gsd-tools.cjs).
// Replaces $HOME/.claude/ or ~/.claude/ so the result is <pathPrefix>get-shit-done/bin/...
// For global installs: use $HOME/ so paths expand correctly inside double-quoted
@@ -8661,6 +8793,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
fs.copyFileSync(srcFile, destFile);
}
}
} else if (fs.statSync(srcFile).isDirectory()) {
// #3579: recurse one level into hook subdirs (lib/ etc.). The
// graphify auto-update hook's rebuild helper lives at
// hooks/dist/lib/gsd-graphify-rebuild.sh and must land at the
// mirrored target path so the hook's REBUILD_SCRIPT lookup resolves.
const subDest = path.join(hooksDest, entry);
fs.mkdirSync(subDest, { recursive: true });
const subEntries = fs.readdirSync(srcFile);
for (const subEntry of subEntries) {
const subSrcFile = path.join(srcFile, subEntry);
if (!fs.statSync(subSrcFile).isFile()) continue;
const subDestFile = path.join(subDest, subEntry);
if (subEntry.endsWith('.sh')) {
let content = fs.readFileSync(subSrcFile, 'utf8');
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(subDestFile, content);
try { fs.chmodSync(subDestFile, 0o755); } catch (e) { /* Windows */ }
} else {
fs.copyFileSync(subSrcFile, subDestFile);
}
}
}
}
if (verifyInstalled(hooksDest, 'hooks')) {
@@ -8678,6 +8831,18 @@ function install(isGlobal, runtime = 'claude', options = {}) {
}
}
// Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
// Codex/Copilot/Cursor/Windsurf/Trae/Cline skip hooks entirely, so they must not
// receive the hooks/lib/ helpers either — otherwise the Codex comment downstream
// ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
const hooksLibSrc = path.join(src, 'hooks', 'lib');
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && fs.existsSync(hooksLibSrc)) {
const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
fs.mkdirSync(hooksLibDest, { recursive: true });
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
}
// Clear stale update cache so next session re-evaluates hook versions
// Cache lives at ~/.cache/gsd/ (see hooks/gsd-check-update.js line 35-36)
const updateCacheFile = path.join(os.homedir(), '.cache', 'gsd', 'gsd-update-check.json');
@@ -8938,15 +9103,18 @@ function install(isGlobal, runtime = 'claude', options = {}) {
console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
}
// Copy hook files that are referenced by Codex hook configuration (#2153)
// The main hook-copy block is gated to non-Codex runtimes, but Codex registers
// gsd-check-update.js through hooks config — the file must physically exist.
// Copy only the hook files that Codex actually registers via its hook configuration (#2153).
// Codex primarily needs gsd-check-update.js for the SessionStart update-check hook.
// We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
// in this change (graphify auto-update support for Codex is out of scope for #3579).
const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js'];
const codexHooksSrc = path.join(src, 'hooks', 'dist');
if (fs.existsSync(codexHooksSrc)) {
const codexHooksDest = path.join(targetDir, 'hooks');
fs.mkdirSync(codexHooksDest, { recursive: true });
const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
for (const entry of fs.readdirSync(codexHooksSrc)) {
if (!CODEX_HOOKS_TO_COPY.includes(entry)) continue;
const srcFile = path.join(codexHooksSrc, entry);
if (!fs.statSync(srcFile).isFile()) continue;
const destFile = path.join(codexHooksDest, entry);
@@ -8958,18 +9126,20 @@ function install(isGlobal, runtime = 'claude', options = {}) {
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
} else {
if (entry.endsWith('.sh')) {
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
} else {
fs.copyFileSync(srcFile, destFile);
}
} else if (entry.endsWith('.sh')) {
// #2136: any .sh hook reaching this loop must have {{GSD_VERSION}}
// stamped so installed scripts carry a concrete version header and
// stale-hook detection keeps working across upgrades. The current
// CODEX_HOOKS_TO_COPY allowlist excludes .sh files, so this branch
// is defensive — it preserves the invariant if the allowlist is
// extended later (e.g. to ship gsd-graphify-update.sh for Codex).
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
}
}
console.log(` ${green}✓${reset} Installed hooks`);
console.log(` ${green}✓${reset} Installed hooks (Codex)`);
}
// Add Codex hooks (SessionStart for update checking) — requires codex_hooks feature flag
@@ -10487,6 +10657,80 @@ function installSdkIfNeeded(opts) {
}
}
/**
* #3406 helper: detect a stale globally-installed `@gsd-build/sdk` package
* shadowing the `gsd-sdk` shim that `get-shit-done-cc` installs.
*
* Background: `@gsd-build/sdk@0.1.0` was published once and never updated
* (the SDK now ships embedded in `get-shit-done-cc`). When a user has the
* 0.1.0 standalone package installed globally, its `gsd-sdk` bin shadows
* the one `get-shit-done-cc` provides — and the 0.1.0 binary only knows
* `run | auto | init` (no `query`), so every `gsd-sdk query <command>`
* call from skills/hooks fails until the user runs
* `npm uninstall -g @gsd-build/sdk`.
*
* Pure function: takes an injected `runNpmLs` executor that returns
* `npm ls -g @gsd-build/sdk --json --depth=0` stdout. Returns:
* `{ stale: true, version }` when the package is present.
* `{ stale: false }` for every other input — including:
* - executor throws (npm missing / EACCES / network),
* - executor returns null/undefined/non-string,
* - stdout is not parseable JSON,
* - the JSON has no `.dependencies['@gsd-build/sdk']` field.
*
* Fail-closed conservative: we'd rather miss a detection than fire a
* false-positive warning that confuses users who have a fine install.
*/
function detectStaleStandaloneSdk(runNpmLs) {
if (typeof runNpmLs !== 'function') return { stale: false };
let out;
try {
out = runNpmLs();
} catch {
return { stale: false };
}
if (typeof out !== 'string' || out.length === 0) return { stale: false };
let parsed;
try {
parsed = JSON.parse(out);
} catch {
return { stale: false };
}
const deps = parsed && typeof parsed === 'object' ? parsed.dependencies : null;
if (!deps || typeof deps !== 'object') return { stale: false };
const entry = deps['@gsd-build/sdk'];
if (!entry || typeof entry !== 'object') return { stale: false };
const version = typeof entry.version === 'string' ? entry.version : '(unknown)';
// #3406 CR: scope stale detection to the known-bad version (0.1.0). Any
// newer @gsd-build/sdk version is an intentional install (or a future
// republish) and should not be flagged as a shim shadow. Without this
// narrowing, a maintainer's local-link or a legitimate future publish
// would trigger a misleading "stale shadow" warning on every install.
if (version !== '0.1.0') return { stale: false };
return { stale: true, version };
}
/**
* #3406 helper: format the install-time warning emitted when
* `detectStaleStandaloneSdk` reports a stale shadow. Separated from the
* detection so the message contract is testable independently of npm.
*/
function formatStaleStandaloneSdkWarning(info) {
const version = info && info.version ? info.version : '(unknown)';
return [
'⚠ A stale globally-installed @gsd-build/sdk@' + version + ' is shadowing the',
' `gsd-sdk` shim that get-shit-done-cc provides. The standalone package',
' only knows `run | auto | init` — every `gsd-sdk query <cmd>` call from',
' skills and hooks will fail until you remove it.',
'',
' Remediation:',
' npm uninstall -g @gsd-build/sdk',
' npx -y get-shit-done-cc@latest --<runtime> --global',
'',
' Tracking: #3406 — https://github.com/gsd-build/get-shit-done/issues/3406',
].join('\n');
}
/**
* #3231 helper: detect whether a `gsd-sdk` binary is the legacy deprecated
* shim pointing at `gsd-tools.cjs`.
@@ -11105,6 +11349,8 @@ if (process.env.GSD_TEST_MODE) {
installAllRuntimes,
uninstall,
installSdkIfNeeded,
detectStaleStandaloneSdk,
formatStaleStandaloneSdkWarning,
buildSdkFailFastReport,
renderSdkFailFastReport,
classifySdkInstall,

View File

@@ -264,6 +264,7 @@
"artifacts.cjs",
"audit.cjs",
"cjs-command-router-adapter.cjs",
"cjs-sdk-bridge.cjs",
"clusters.cjs",
"command-aliases.generated.cjs",
"commands.cjs",
@@ -273,6 +274,7 @@
"context-utilization.cjs",
"core.cjs",
"decisions.cjs",
"decisions.generated.cjs",
"docs.cjs",
"drift.cjs",
"fallow-runner.cjs",
@@ -295,6 +297,7 @@
"phase.cjs",
"phases-command-router.cjs",
"plan-scan.cjs",
"plan-scan.generated.cjs",
"planning-workspace.cjs",
"profile-output.cjs",
"profile-pipeline.cjs",
@@ -305,7 +308,9 @@
"runtime-homes.cjs",
"runtime-slash.cjs",
"schema-detect.cjs",
"schema-detect.generated.cjs",
"secrets.cjs",
"secrets.generated.cjs",
"security.cjs",
"shell-command-projection.cjs",
"state-command-router.cjs",
@@ -321,6 +326,7 @@
"workstream-inventory-builder.generated.cjs",
"workstream-inventory.cjs",
"workstream-name-policy.cjs",
"workstream-name-policy.generated.cjs",
"workstream.cjs",
"worktree-safety.cjs"
],

View File

@@ -361,7 +361,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (64 shipped)
## CLI Modules (70 shipped)
Full listing: `get-shit-done/bin/lib/*.cjs`.
@@ -372,6 +372,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint |
| `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers |
| `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers |
| `cjs-sdk-bridge.cjs` | Shared SDK runtime-bridge loader (`tryLoadSdk`/`getExecuteForCjs`); consumed by every CJS router and `gsd-tools.cjs` to delegate canonical commands to the SDK in-process |
| `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) |
| `command-aliases.generated.cjs` | Generated CJS alias/subcommand metadata for manifest-backed family routers |
| `commands.cjs` | Misc CLI commands (slug, timestamp, todos, scaffolding, stats) |
@@ -380,7 +381,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `configuration.generated.cjs` | Generated Configuration Module — canonical config loading, legacy-key normalization, defaults merge, and explicit on-disk migration; source of truth for both SDK and CJS consumers |
| `context-utilization.cjs` | Pure classifier for `gsd-health --context` — turns (tokensUsed, contextWindow) into a `{ percent, state }` triage result against the 60%/70% fracture-point thresholds (#2792) |
| `core.cjs` | Error handling, output formatting, shared utilities, runtime fallbacks; compatibility re-exports for planning-workspace helpers |
| `decisions.cjs` | Shared parser for CONTEXT.md `<decisions>` blocks (D-NN entries); used by `gap-checker.cjs` and intended for #2492 plan/verify decision gates |
| `decisions.cjs` | CJS shim adapter — re-exports from `decisions.generated.cjs` (Phase 6/#3575 Shared Module migration) |
| `decisions.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/decisions.ts` via `sdk/scripts/gen-decisions.mjs`; parses CONTEXT.md `<decisions>` blocks, accepts numeric (D-42) and alphanumeric (D-INFRA-01) IDs, returns `{id, text, category, tags, trackable}`; do not edit directly |
| `docs.cjs` | Docs-update workflow init, Markdown scanning, monorepo detection |
| `drift.cjs` | Post-execute codebase structural drift detector (#2003): classifies file changes into new-dir/barrel/migration/route categories and round-trips `last_mapped_commit` frontmatter |
| `fallow-runner.cjs` | Fallow audit adapter for `/gsd-code-review`: binary resolution (`PATH` then `node_modules/.bin`), actionable missing-binary errors, and structural findings normalization |
@@ -402,7 +404,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` |
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |
| `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` |
| `plan-scan.cjs` | Canonical phase-plan scanner — shared helper for detecting plan and summary files in flat and nested layouts (k014); consumed by state, roadmap, init, and workstream inventory paths |
| `plan-scan.cjs` | CJS shim adapter — re-exports from `plan-scan.generated.cjs` (Phase 6/#3575 Shared Module migration) |
| `plan-scan.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/plan-scan.ts` via `sdk/scripts/gen-plan-scan.mjs`; canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014); do not edit directly |
| `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) |
| `project-root.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/project-root/index.ts` via `sdk/scripts/gen-project-root.mjs`; resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic); do not edit directly |
| `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation |
@@ -412,8 +415,10 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress |
| `runtime-homes.cjs` | Canonical runtime → global config/skills directory mapping; first-class support for all 15 runtimes including Hermes nested layout and Cline rules-based exclusion (#3126) |
| `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-<cmd>` (skills-based runtimes) and `$gsd-<cmd>` (codex) in user-facing output and persisted artifacts (#3584) |
| `schema-detect.cjs` | Schema-drift detection for ORM patterns (Prisma, Drizzle, etc.) |
| `secrets.cjs` | Secret-config masking convention (`****<last-4>`) for integration keys managed by `/gsd-config --integrations` — keeps plaintext out of `config-set` output |
| `schema-detect.cjs` | CJS shim adapter — re-exports from `schema-detect.generated.cjs` (Phase 6/#3575 Shared Module migration) |
| `schema-detect.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/schema-detect.ts` via `sdk/scripts/gen-schema-detect.mjs`; schema-drift detection for ORM patterns (Prisma, Drizzle, Supabase, TypeORM, Payload); exports `detectSchemaFiles`, `detectSchemaOrm`, `checkSchemaDrift`, `SCHEMA_PATTERNS`, `ORM_INFO`; do not edit directly |
| `secrets.cjs` | CJS shim adapter — re-exports from `secrets.generated.cjs` (Phase 6/#3575 Shared Module migration) |
| `secrets.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/secrets.ts` via `sdk/scripts/gen-secrets.mjs`; secret-config masking convention (`****<last-4>`) for integration keys; exports `SECRET_CONFIG_KEYS`, `isSecretKey`, `maskSecret`, `maskIfSecret`; do not edit directly |
| `security.cjs` | Path traversal prevention, prompt injection detection, safe JSON/shell helpers |
| `shell-command-projection.cjs` | Runtime-aware shell command projection for managed hook serialization: decides PowerShell call-operator usage by runtime/platform and normalizes Windows script path tokens |
| `state-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools state` |
@@ -428,7 +433,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `verify.cjs` | Plan structure, phase completeness, reference, commit validation |
| `workstream-inventory-builder.generated.cjs` | GENERATED — pure workstream inventory projection builder; CJS artifact emitted from `sdk/src/workstream-inventory/builder.ts` via `sdk/scripts/gen-workstream-inventory-builder.mjs`; do not edit directly |
| `workstream-inventory.cjs` | Shared workstream inventory projection: state fields, phase/plan/summary counts, roadmap phase count, and active marker — thin orchestrator that delegates pure projection to `workstream-inventory-builder.generated.cjs` |
| `workstream-name-policy.cjs` | Canonical workstream name validation (`isValidActiveWorkstreamName`) and slug normalization (`toWorkstreamSlug`); shared by all workstream callers |
| `workstream-name-policy.cjs` | CJS shim adapter — re-exports from `workstream-name-policy.generated.cjs` (Phase 6/#3575 Shared Module migration) |
| `workstream-name-policy.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/workstream-name-policy.ts` via `sdk/scripts/gen-workstream-name-policy.mjs`; canonical workstream name validation (`isValidActiveWorkstreamName`, `hasInvalidPathSegment`, `validateWorkstreamName`) and slug normalization (`toWorkstreamSlug`); do not edit directly |
| `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer |
| `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic |

269
docs/agents/cjs-sdk-seam.md Normal file
View File

@@ -0,0 +1,269 @@
# CJS↔SDK Hard-Seam Migration: Complete Reference
## Issue #3575 (Parent: #3524)
---
## Migration overview
The CJS↔SDK hard-seam migration (#3524) eliminates a class of config-schema drift bugs by introducing single sources of truth at every decision point where CJS and SDK code previously diverged. The migration proceeded in six phases:
| Phase | PR | Summary |
|-------|----|---------|
| Phase 1 | [#3531](https://github.com/gsd-build/get-shit-done/pull/3531) | `state-document` Shared Module — source-of-truth at `sdk/src/state-document/`, generator, freshness check, CJS Adapter (`state-document.generated.cjs`). Worked example for the pattern. |
| Phase 2 | [#3540](https://github.com/gsd-build/get-shit-done/pull/3540) | `configuration` Shared Module — `sdk/shared/config-schema.manifest.json` + `sdk/shared/config-defaults.manifest.json` as data manifests; generator + freshness check + CJS Adapter. |
| Phase 3 | [#3548](https://github.com/gsd-build/get-shit-done/pull/3548) | `workstream-inventory` Shared Module — source-of-truth at `sdk/src/workstream-inventory/`, builder, generator, freshness check, CJS Adapter. |
| Phase 4 | [#3554](https://github.com/gsd-build/get-shit-done/pull/3554) | `project-root` Shared Module — source-of-truth at `sdk/src/project-root/`, generator, freshness check, CJS Adapter. |
| Phase 5.0 | [#3558](https://github.com/gsd-build/get-shit-done/pull/3558) | `runtime-bridge-sync` worker — enables CJS-side execution of SDK native handlers; state.* family initial router delegation via `executeForCjs`. |
| Phase 5.1 | [#3574](https://github.com/gsd-build/get-shit-done/pull/3574) | `state.*` router delegation complete — all known state subcommands delegated via `executeForCjs`; Phase 5.0 worker bug fix. |
| Phase 6 | [#3577](https://github.com/gsd-build/get-shit-done/pull/3577) (closes [#3575](https://github.com/gsd-build/get-shit-done/issues/3575)) | Enforcement hardening + Final completion — hand-sync drift lint, CODEOWNERS, 6 family-router migrations, 5 Shared Module migrations (plan-scan, secrets, schema-detect, decisions, workstream-name-policy), workstream native support, parity fixes. Migration feature-complete: 22 cooperating siblings, 0 backlog pairs. |
---
## Phase 6 Retrospective: 15 config-schema drift bugs
This section captures 15 recurring config-schema drift bugs that motivated the migration. For each, we record what drifted, the surgical fix, and which Phase 6 enforcement layer would have prevented it.
---
### #1535 — Silent failure on unrecognized config.json keys
- **Drifted:** `loadConfig` silently ignored any top-level key in `.planning/config.json` not in `VALID_CONFIG_KEYS`, giving users no feedback when hand-edited or external-tool-added keys had no effect.
- **Fix landed:** PR #1542 — added stderr warning listing unrecognized keys.
- **Would have been blocked by:** **handsync lint** — a seam-aware linter would forbid having parallel hand-authored config validators (CJS `config.cjs` and SDK `config-mutation.ts`) that could silently diverge.
---
### #1542 — fix(config): warn on unrecognized keys in config.json instead of silent drop
- **Drifted:** No drift in this bug itself; it *fixed* #1535's silent-drop behavior by adding the warning.
- **Fix landed:** PR #1542 — merged as the direct fix for #1535.
- **Would have been blocked by:** **per-Module drift lint** (freshness check on config validation) — both CJS and SDK config paths would be regenerated from a single source-of-truth schema module, eliminating the silent-drop risk.
---
### #2047 — bug: config-set rejects intel.enabled despite being a documented config key
- **Drifted:** `intel.enabled` was documented in workflows and gated in runtime code (`intel.cjs:58`), but missing from `VALID_CONFIG_KEYS` in `config.cjs`, so `config-set` rejected it.
- **Fix landed:** PR #2021 — added `intel.enabled` to `VALID_CONFIG_KEYS` in CJS.
- **Would have been blocked by:** **handsync lint** — linter would enforce that every config key gated in runtime code or documented in workflows must appear in the validator allowlist.
---
### #2052 — fix(config): add intel.enabled to VALID_CONFIG_KEYS
- **Drifted:** Same as #2047 (missing from allowlist).
- **Fix landed:** PR #2021 (same PR as #2047 fix).
- **Would have been blocked by:** **handsync lint** — same as #2047.
---
### #2638 — bug: loadConfig writes sub_repos to top-level, then warns it's unknown
- **Drifted:** After #2561 canonicalized `sub_repos` to `planning.sub_repos`, the legacy migration and filesystem auto-sync in `loadConfig` still wrote to top-level `parsed.sub_repos`, which was then flagged as unknown.
- **Fix landed:** PR #2668 — rewrote both paths to target `parsed.planning.sub_repos` and deleted stale top-level copy.
- **Would have been blocked by:** **per-Module drift lint** (freshness check for config shape) — the canonical location for `sub_repos` would be codified in a schema, and any code path writing to it would be verified against that schema at lint time.
---
### #2655 — fix(core): write sub_repos to planning.sub_repos, not top-level
- **Drifted:** Same as #2638.
- **Fix landed:** PR #2668 (same as #2638 fix).
- **Would have been blocked by:** **per-Module drift lint** — same as #2638.
---
### #2653 — bug: SDK config-set rejects documented config keys accepted by CJS config-set
- **Drifted:** SDK's `config-mutation.ts` had a hand-maintained `VALID_CONFIG_KEYS` set that had drifted **28 keys** behind CJS's `config-schema.cjs`, so documented commands like `gsd-sdk query config-set planning.sub_repos` were rejected.
- **Fix landed:** PR #2670 — extracted shared `sdk/src/query/config-schema.ts` module mirroring CJS exactly; added parity test to fail on future drift.
- **Would have been blocked by:** **manifest data isolation** — the config schema would live in one place (e.g., `sdk/shared/config.manifest.json`), and both CJS and SDK would read it, eliminating the possibility of independent drift.
---
### #2670 — fix(#2653): eliminate SDK↔CJS config-schema drift
- **Drifted:** Same as #2653 (28-key drift).
- **Fix landed:** PR #2670 (same as #2653 fix).
- **Would have been blocked by:** **manifest data isolation** — same as #2653.
---
### #2687 — bug: loadConfig warns on valid dynamic-pattern containers in .planning/config.json
- **Drifted:** Keys like `review.models.<cli>` were registered in `config-schema.cjs`'s `DYNAMIC_KEY_PATTERNS` but absent from the hand-maintained `KNOWN_TOP_LEVEL` set in `core.cjs`, causing false-positive "unknown key" warnings.
- **Fix landed:** PR #2706 — added `topLevel` field to `DYNAMIC_KEY_PATTERNS` entries; derived `KNOWN_TOP_LEVEL` from schema instead of maintaining it manually.
- **Would have been blocked by:** **per-Module drift lint** — the validator that builds `KNOWN_TOP_LEVEL` would be regenerated from the schema each run, not hand-maintained.
---
### #2706 — fix(#2687): loadConfig no longer warns on valid dynamic-pattern containers
- **Drifted:** Same as #2687 (false warnings on valid dynamic keys).
- **Fix landed:** PR #2706 (same as #2687 fix).
- **Would have been blocked by:** **per-Module drift lint** — same as #2687.
---
### #2798 — context_window missing from VALID_CONFIG_KEYS
- **Drifted:** `context_window` was documented in workflows and read in SDK runtime (`init.js:190`, `validate.js:575`), but missing from allowlists in both `config-mutation.ts` and `config-schema.cjs`, so writes were rejected.
- **Fix landed:** PR #2816 — added `context_window` to `VALID_CONFIG_KEYS` in both SDK and CJS.
- **Would have been blocked by:** **handsync lint** — linter would enforce that every key read at runtime must be in the allowlist.
---
### #2816 — fix(#2798): add context_window to VALID_CONFIG_KEYS allowlist
- **Drifted:** Same as #2798 (missing from allowlists).
- **Fix landed:** PR #2816 (same as #2798 fix).
- **Would have been blocked by:** **handsync lint** — same as #2798.
---
### #3055 — bug: top-level branching_strategy silently becomes "none"
- **Drifted:** `.planning/config.json` with top-level `branching_strategy: "phase"` was flagged as unknown and dropped by validator, causing `loadConfig` to fall back to the `"none"` default, so phase commits landed on the operator's current branch instead of creating `gsd/phase-{N}` branches.
- **Fix landed:** PR #3116 — SDK-side only; added legacy normalization in `mergeDefaults()` to graft top-level value into canonical `git.branching_strategy` slot before validation.
- **Would have been blocked by:** **per-Module drift lint** — the canonical location for `branching_strategy` would be codified in schema; validator would not strip the value before migrations had a chance to run, or CJS and SDK would share the same migration code.
---
### #3116 — fix: normalize legacy top-level branching_strategy into git config
- **Drifted:** Same as #3055 (legacy top-level shape not normalized before validator strips it).
- **Fix landed:** PR #3116 (SDK-side normalization in `mergeDefaults()`).
- **Would have been blocked by:** **per-Module drift lint** — same as #3055, but SDK-side fix would be shared with CJS via seam layer instead of being ported separately.
---
### #3523 — bug: CJS loadConfig warns top-level branching_strategy 'will be ignored', but actively reads it
- **Drifted:** After PR #3116 fixed the SDK side, the CJS path still emitted false "will be ignored" warnings on the same legacy top-level key, because `KNOWN_TOP_LEVEL` derivation extracted top-level names from `VALID_CONFIG_KEYS` (which contains `'git.branching_strategy'` but not `'branching_strategy'`), and the warning was factually incorrect — `core.cjs:485` does read the legacy value via fallback logic.
- **Fix landed:** PR #3527 — added `'branching_strategy'` to the `KNOWN_TOP_LEVEL` hand-maintained list under the deprecated-keys bucket, suppressing the false warning.
- **Would have been blocked by:** **runtime-bridge delegation** — if CJS and SDK config loading shared a common normalization routine (via `executeForCjs` or a shared seam module), the SDK fix in #3116 would automatically apply to CJS; no separate CJS-side warning would be possible.
---
## Surprises
None. All 15 bugs are genuine CJS↔SDK schema/validation drift, exactly the class the seam migration prevents.
## Phase 6 Enforcement Summary
The seam migration introduces these layers:
1. **handsync lint** (`scripts/lint-shared-module-handsync.cjs`) — Forbids parallel hand-authored validator modules; catches #1535, #2047, #2798.
2. **freshness check** (`sdk/scripts/check-<module>-fresh.mjs`) — Regenerates config validators from schema each run; catches #2687, #3055.
3. **manifest data isolation** (`sdk/shared/*.manifest.json`) — Single source-of-truth for schema; catches #2653.
4. **per-Module drift lint** — Combination of freshness checks and schema-derived allowlists; catches #2638, #2687, #3055.
5. **runtime-bridge delegation** (`executeForCjs` + shared seam modules) — Eliminates parallel CJS/SDK implementations; catches #3523 by preventing separate CJS warning logic.
Together, these layers eliminate the 15-bug class by enforcing single sources of truth at each decision point.
---
## Guide: Adding a new Shared Module
Use this when you want to extract a new piece of data or logic that both CJS and SDK currently duplicate hand-by-hand. Phase 1's `state-document` migration is the worked example.
**Step 1 — Create the source-of-truth file**
```text
sdk/src/<module>/index.ts
```
This is the canonical definition. It may export a schema, a set of keys, a type, or a data object. It must not import from CJS or from generated files.
**Step 2 — Write the generator script**
```text
sdk/scripts/gen-<module>.mjs
```
The generator reads `sdk/src/<module>/index.ts` (or `sdk/shared/<module>.manifest.json` for pure-data manifests), produces a generated output file (either `sdk/src/<module>.generated.ts` or `get-shit-done/bin/lib/<module>.generated.cjs`), and exits 0. It must be idempotent: running it twice produces the same output.
**Step 3 — Write the freshness check**
```text
sdk/scripts/check-<module>-fresh.mjs
```
The freshness check re-runs the generator into a temp location, diffs against the committed file, and exits 1 with a clear message if they diverge. This is what CI runs.
**Step 4 — Write the parity test** (optional but recommended)
```text
tests/<module>-parity.test.cjs
```
Assert that the CJS Adapter and the SDK source-of-truth agree on every field that matters (key sets, defaults, schema shape). This test catches generator bugs that the freshness check cannot.
**Step 5 — Wire CI**
Add a step in `.github/workflows/test.yml` after the existing freshness-check block (before "Run tests with coverage"), gated on `matrix.os == 'ubuntu-latest' && matrix.node-version == 24`:
```yaml
- name: SDK generated <module> artifact drift check
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
shell: bash
run: node sdk/scripts/check-<module>-fresh.mjs
```
**Step 6 — Run inventory regen**
If the module affects `CONTEXT.md`'s module inventory, update that section. Also update `scripts/shared-module-handsync-allowlist.json`: move any matching entry from `migrateMeBacklog` to `cooperatingSiblings` (or remove it entirely if the CJS hand-copy is now deleted).
**Step 7 — Update CODEOWNERS**
Add the new source-of-truth path to `.github/CODEOWNERS` under the Phase 6 block to make the architectural ownership explicit.
**Reference:** Phase 1 PR [#3531](https://github.com/gsd-build/get-shit-done/pull/3531) — `state-document` migration.
---
## Guide: Adding a new canonical command
Use this when adding a new `gsd-sdk query <family>.<subcommand>` that should be handled natively in the SDK (not delegated to CJS). Phase 5.1's `state.update` migration (PR [#3574](https://github.com/gsd-build/get-shit-done/pull/3574)) is the worked example.
**Step 1 — Declare in the command manifest**
Add the command definition to `sdk/src/query/command-manifest.<family>.ts`. Include the full argument schema and a `handler` reference.
**Step 2 — Implement the SDK handler**
Write the handler in `sdk/src/query/<subcommand>.ts` (or inline in the manifest file for simple cases). The handler receives validated args and the runtime context; it must not shell out to CJS.
**Step 3 — Add CJS router delegate (Phase 5.1+ pattern)**
In the family's CJS command router (e.g. `get-shit-done/bin/lib/state-command-router.cjs`), add a delegate case that calls `executeForCjs(subcommand, args)` from `cjs-command-router-adapter.cjs`. This ensures the CJS binary dispatches to the SDK native handler rather than re-implementing the logic.
**Step 4 — Add a golden parity test**
Add a test in `tests/<family>-command-router.test.cjs` (or a new file if the family has no test yet) that:
1. Invokes the command via the SDK query path.
2. Invokes the command via the CJS router path.
3. Asserts both produce identical output.
This test enforces that the delegate and the native handler stay aligned.
**Reference:** Phase 5.1 PR [#3574](https://github.com/gsd-build/get-shit-done/pull/3574) — `state.update` delegation.
---
## Phase 6 Final Completion Summary
Phase 6 (issue #3575, PR #3577) is feature-complete. The migration is done.
**What shipped in Phase 6:**
- **Shared Modules migrated (5 total in Phase 6):** `plan-scan`, `secrets`, `schema-detect`, `decisions`, `workstream-name-policy`. Each follows the full pattern: SDK source-of-truth, generator (`gen-<name>.mjs`), freshness check (`check-<name>-fresh.mjs`), generated CJS artifact (`<name>.generated.cjs`), CJS shim re-export, parity test, CI step, pre-commit hook, CODEOWNERS entry.
- **Workstream native support:** The sync bridge worker now correctly threads `workstream` through to `registry.dispatch()`. `GSDTransport` no longer forces subprocess for workstream-scoped requests. Workstream-scoped state commands execute natively.
- **State parity divergences resolved:** `state.record-metric` and `state.prune` SDK handlers now match CJS semantics exactly.
- **MIGRATE_ME pairs resolved:** `decisions` and `workstream-name-policy` migrated from `migrateMeBacklog` to `cooperatingSiblings` as ADAPTER-OVER-MODULE.
- **Lint final state:** 22 cooperating siblings, 0 backlog pairs.
**Decisions migration specifics (B1):**
- SDK `decisions.ts` regex aligned to CJS: `D-([A-Za-z0-9_-]+)` (alphanumeric IDs like `D-INFRA-01` accepted).
- SDK returns richer `{id, text, category, tags, trackable}`; CJS callers using only `{id, text}` safely ignore extras.
- Parity test: `tests/decisions-generator.test.cjs` (15 tests covering numeric IDs, alphanumeric IDs, richer schema fields).
**Workstream-name-policy migration specifics (B2):**
- Added `hasInvalidPathSegment` and `isValidActiveWorkstreamName` to SDK `workstream-name-policy.ts`.
- `validateWorkstreamName` is now an alias for `isValidActiveWorkstreamName` (consistent with CJS semantics).
- Parity test: `tests/workstream-name-policy-generator.test.cjs` (19 tests covering all four exports).
---
## Open follow-ups
No migration items remain. The following are future quality candidates, not defects:
- **`config.cjs` / `sdk/src/config.ts`** — These files are CJS-CLI-ONLY (per allowlist classification). The `config.cjs` file contains only CLI command handlers that use sync CJS APIs; `sdk/src/config.ts` provides the async SDK layer. They serve disjoint surfaces. A future migration would require converting the CLI handlers to async + SDK patterns, which is a larger refactor out of scope for this migration cycle.
- **`intel.cjs` / `sdk/src/query/intel.ts`** — Intentional architectural divergence (different file naming conventions between CJS and SDK; documented in allowlist). A future migration would require reconciling INTEL_FILES naming, which is a breaking change for existing consumers.
- **`model-catalog.cjs` / `sdk/src/model-catalog.ts`** — Both sides read from `sdk/shared/model-catalog.json` independently (ADAPTER-OVER-MODULE pattern). This is intentional; the shared JSON is the source-of-truth. No duplication of logic between CJS and SDK consumers.

View File

@@ -0,0 +1,244 @@
# Grok Build + GSD Compatibility & Local Multi-Runtime Sync (May 2026)
**Date:** 2026-05-16
**Status:** Discussion active on closed issue. Awaiting maintainer response.
**Purpose of this document:** Serve as the primary context file for future Grok (or other) agent sessions started inside this repository (`/home/cristian/bum/get-shit-done`) so they can work on local Grok Build support and improved synchronization across multiple AI coding harnesses.
---
## 1. Executive Summary & Goals
**Goal:** Achieve reliable, first-class GSD support when using **Grok Build**, while maintaining excellent compatibility and low-friction synchronization across the four runtimes the author uses daily:
- Grok Build (current primary TUI)
- Claude Code
- Gemini CLI
- Codex
Currently, Grok Build is only supported via its Claude compatibility layer. This creates daily friction in paths, skill discovery, command surfaces, hooks, `grok inspect` output, and mental models.
**Long-term vision:**
- Run GSD natively and cleanly inside Grok Build.
- Maintain a single source of truth in this repository.
- Have a robust, automated (or semi-automated) sync mechanism that deploys adapted skills/agents/hooks to all four runtime environments (`~/.agents/`, `~/.claude/`, `~/.grok/`, Gemini location, Codex location).
- Keep the work clean enough that high-quality pieces can eventually be contributed upstream.
---
## 2. Current Multi-Runtime Setup (as of May 2026)
### Development Source (Single Source of Truth)
- **Path:** `/home/cristian/bum/get-shit-done` (this repo — your working fork of `gsd-build/get-shit-done`)
### Installed Locations
- `~/.agents/get-shit-done/` — Core workflows, references, templates, `gsd-tools.cjs`, `bin/`
- `~/.agents/skills/gsd-*` — ~125 skills (heavily GSD + many large reference skills like `userinterface-wiki`, `react-best-practices`, etc.)
- `~/.agents/agents/` — 22 GSD sub-agents (with `.md` + `.toml`)
- `~/.claude/skills/gsd-*` + `~/.claude/get-shit-done/` + `~/.claude/agents/` — Parallel Claude Code install (~208 skills total)
- `~/.grok/skills/` — Mostly empty (only the 7 official bundled Grok skills)
- `~/.grok/` — Not yet properly used by GSD
### Existing Sync Tooling
- `gsd-sync-skills` skill exists in `~/.agents/skills/gsd-sync-skills/`
- Its stated purpose: "Sync managed GSD skills across runtime roots so multi-runtime users stay aligned after an update"
- Currently uses a combination of manual processes + this skill.
### Codex-Style Adaptations Already in Use
- Many `gsd-*` skills in `~/.agents/skills/` contain a `<codex_skill_adapter>` section at the top.
- This adapter translates Claude Code patterns (`AskUserQuestion`, `Task()`) into Codex/Grok-compatible ones (`request_user_input`, `spawn_agent`).
- This pattern was developed because Grok Build / Codex use a different skill invocation and subagent model than Claude Code.
---
## 3. History & Prior Art
### Previous Upstream Attempt (May 2026)
- **Issue #3603**: "Add Grok Build (`--grok`) as a first-class runtime"
- **PR #3604** (by `lordgraysith`): Very large, high-quality implementation attempt.
The PR included:
- Full `--grok` installer support
- Conversion functions (`convertClaudeToGrokMarkdown`, `convertClaudeCommandToGrokSkill`, `convertClaudeAgentToGrokAgent`)
- JSON hook manifest generation for Grok
- Model catalog entries for Grok models
- `GROK_CONFIG_DIR` support
- Extensive tests and documentation
- A complete planning folder: `docs/grok-build-support/` containing 7 detailed documents:
- 01-runtime-detection-and-config.md
- 02-installer-logic.md
- 03-model-catalog-and-profiles.md
- 04-skills-agents-hooks-conversion.md
- 05-documentation-and-user-experience.md
- 06-testing-strategy.md
- 07-phased-implementation-and-rollout.md
**Outcome:** Both issue and PR were closed the next day by `trek-e` as `not_planned`.
### Maintainer’s Position (Critical Context)
From `trek-e`’s detailed closing comment on #3603:
- **Biggest blockers right now:**
- Grok Build is extremely new (launched May 14, 2026) and still in early beta. Hook events and config surface are unstable.
- Strong desire to protect GSD’s reputation for stability ("vibe coded piece of junk" reputation risk).
- Recent painful experience with Codex making breaking changes shortly after GSD stabilized something.
- **Price was clarified as low priority** ("honestly price is probably the lowest of the factors").
- **Claude-compat already works**, so native support is seen as polish rather than a must-have.
- **Process concern:** Implementation was done before the issue was properly approved.
- **Recommendation:** Revisit once Grok Build has stabilized (versioned config surface held for at least one minor release) **and** is reachable on a more realistic solo-dev price tier.
**Important:** The 7 planning documents in `docs/grok-build-support/` remain excellent reference material even if the code from that PR is not merged.
---
## 4. Recommended Work Plan for Future Grok Sessions in This Repo
When a new Grok session is started inside `/home/cristian/bum/get-shit-done`, it should follow something like this phased approach:
### Phase 0 — Orientation (Current Session Context)
- Read this document fully.
- Read the 7 files in `docs/grok-build-support/`.
- Explore the current local multi-runtime setup (see Section 2 above).
- Understand the existing `gsd-sync-skills` skill and any Codex adapters already present.
### Phase 1 — Audit Current State
- Map exactly what is installed where across `~/.agents/`, `~/.claude/`, `~/.grok/`, and Gemini/Codex locations.
- Identify duplication, drift, and friction points when using GSD in Grok Build today.
- Run `grok inspect` and analyze what it shows for GSD skills.
- Document gaps specific to Grok Build (command surface, hooks, `grok inspect` cleanliness, agent spawning, etc.).
### Phase 2 — Study Prior Art
- Deeply study the conversion specifications in `docs/grok-build-support/04-skills-agents-hooks-conversion.md`.
- Understand what a proper Grok `SKILL.md` should look like (frontmatter, description style, runtime hints).
- Understand Grok hook JSON manifest requirements.
- Review how the previous PR handled model catalog and runtime homes.
- Look for any existing local experiments or partial adapters in this fork.
### Phase 3 — Design Local Grok Adapter (MVP)
Design a practical local solution that works for **this user’s four-runtime reality**, not necessarily a full upstream `--grok` installer yet.
Possible components:
- A local Grok conversion layer (or extension of existing Codex adapters).
- Proper `gsd-*` skills under `~/.grok/skills/` with correct Grok frontmatter + `codex_skill_adapter` sections where needed.
- Grok-compatible agent definitions (`.md` + any required TOML/config).
- JSON hook manifests in `~/.grok/hooks/`.
- Updates to the sync mechanism (`gsd-sync-skills` or a new `gsd-multi-runtime-sync` tool) so one source can deploy cleanly to all four targets.
**Key principle:** Prefer extending/improving the existing sync tooling rather than creating yet another parallel install path.
### Phase 4 — Implementation & Testing
- Implement the MVP Grok adapter in this local fork.
- Create or enhance sync logic.
- Test end-to-end inside an actual Grok Build session:
- `grok inspect` cleanliness
- Command discovery (`/gsd-*` or Grok-native form)
- Agent spawning
- Hook firing
- Full `gsd-new-project` → `gsd-progress` → `gsd-execute-phase` flow
- Verify no regression in Claude / Gemini / Codex usage.
### Phase 5 — Documentation & Future Upstream Path
- Update this discussion note and any relevant docs in the repo.
- Document the local sync architecture clearly.
- Identify which pieces of the local solution would be good candidates for upstream contribution later (when Grok Build is more mature).
---
## 5. Key Files & Areas to Study
**In this repo:**
- `docs/grok-build-support/` (all 7 documents — highest priority)
- `bin/install.js` (installer logic, especially runtime handling and conversion functions)
- `get-shit-done/bin/lib/runtime-homes.cjs`
- `get-shit-done/bin/lib/shell-command-projection.cjs` (hook projection)
- `sdk/shared/model-catalog.json`
- Existing `gsd-sync-skills` skill (in `~/.agents/skills/gsd-sync-skills/`)
- Any skills that already contain `<codex_skill_adapter>` sections (study the pattern)
**External / Prior Art:**
- The original PR #3604 (study the actual conversion code if accessible via the author’s fork)
- Grok Build documentation on skill format, agent format, and hook JSON manifests (as of the session date)
---
## 6. How to Test Grok Build Compatibility Locally
Useful commands and checks when working on this:
- `grok inspect` (and `grok inspect --json`) — check skill discovery, sources, and token counts.
- `grok` TUI inside a real project that uses GSD.
- Full workflow test: `/gsd-progress`, `/gsd-discuss-phase`, `/gsd-plan-phase`, `/gsd-execute-phase`, etc.
- Verify hooks fire correctly via Grok’s JSON hook system.
- Check that subagents (the 22 GSD agents in `~/.agents/agents/`) can be spawned from Grok.
---
## 7. Sync Strategy Principles (for Multi-Runtime)
When designing improvements to sync:
- Single source of truth = this repository (`/home/cristian/bum/get-shit-done`).
- Runtime-specific transformations should be as declarative and maintainable as possible.
- The `<codex_skill_adapter>` pattern is already proven for Grok/Codex — extend it rather than reinvent.
- Prefer generating the runtime-specific artifacts during sync rather than maintaining four separate copies.
- Make it easy to add a fifth runtime later if needed.
---
## 8. Open Questions & Decisions to Make (for Future Sessions)
- Should we aim for a full local `--grok` installer equivalent, or just excellent skill/agent/hook generation + sync?
- How much of the previous PR’s conversion logic can/should be reused locally?
- What is the right balance between “make Grok work great for me now” vs “keep it clean for potential upstream contribution”?
- Should the sync tool become a first-class GSD skill (`gsd-multi-runtime-sync` or similar)?
- How do we handle model profiles and agent routing differences for Grok models?
---
## 9. How to Resume This Work
When starting a new Grok session in this repository, begin by reading:
1. This file: `docs/discussions/grok-build-support-2026-05.md`
2. All files in `docs/grok-build-support/`
3. The existing `gsd-sync-skills` skill
Then follow the phased plan in Section 4.
---
**Last updated:** 2026-05-16 (by Grok, in this session)
---
## 10. Progress — May 2026 Session (Current)
### Audit Findings (Phase 1)
- **Version drift confirmed**: `~/.agents/get-shit-done/` (Grok Build primary) was on 1.38.4; `~/.claude/` on 1.42.2; `~/.codex/` and `~/.gemini/` on 1.41.2.
- `~/.agents/hooks/` was empty (no hooks active for Grok Build sessions).
- `grok inspect` successfully discovers 80+ `gsd-*` skills via the `~/.agents/skills/` layout + the existing `<codex_skill_adapter>` blocks.
- No `grok` or `agents` runtime existed in installer or sync logic.
- `~/.grok/` itself contains only the 7 official bundled skills; GSD lives entirely in the shared `~/.agents/` layout.
### Immediate Actions Taken
- **Engine drift fixed ASAP**: Backed up old `~/.agents/get-shit-done/` to `.backup-1.38.4/`, then rsynced the current source `get-shit-done/` tree into `~/.agents/get-shit-done/`. Now running the latest from this repo (v1.50.0-canary.0). New modules (active-workstream-store, adr-parser, etc.) and updated workflows are live for Grok Build sessions.
- **First-class 'grok' runtime added** (pragmatic choice: maps to `~/.agents/`):
- [get-shit-done/bin/lib/runtime-homes.cjs](/home/cristian/bum/get-shit-done/get-shit-done/bin/lib/runtime-homes.cjs): Added `grok` case (honors `GROK_AGENTS_HOME` env, defaults to `~/.agents`).
- [bin/install.js](/home/cristian/bum/get-shit-done/bin/install.js): Added `--grok` flag, `hasGrok`, `getDirName('grok') → '.agents'`, `getGlobalDir('grok')`, `getConfigDirFromHome`, inclusion in `--all` and help text. Reuses existing Codex conversion logic (skill adapters + agent .toml generation) because Grok Build uses the same invocation model.
- [get-shit-done/workflows/sync-skills.md](/home/cristian/bum/get-shit-done/get-shit-done/workflows/sync-skills.md): Added `grok` to supported runtimes and the `--to all` list.
- Verified: `node bin/install.js --skills-root grok` correctly returns `~/.agents/skills`.
### Next Steps (for follow-up sessions)
- Full `gsd install --grok --global` end-to-end (hook projection, agent .toml generation with correct sandbox, skill wrapping with adapters, statusline, etc.). Currently the flag is recognized but some codex-specific install branches may need `|| runtime === 'grok'`.
- Run `gsd update --sync --from claude --to grok --apply` (or `--from grok --to claude`) once the runtime is fully wired, to keep the 4 harnesses in sync without manual rsync.
- Slim the `<codex_skill_adapter>` blocks (currently ~60 lines inlined in every gsd-* SKILL.md). Options: extract detailed mapping to a shared `@reference/codex-skill-adapter.md` that skills include, or make the adapter header shorter/optional for lower `grok inspect` token cost.
- Investigate Grok Build native hook support (JSON manifests under `~/.grok/hooks/` vs the shell hooks in `~/.agents/hooks/`).
- Update `grok inspect` output cleanliness (remove "unknown tool prefix: Skill(gsd:*)" warnings if possible via settings or skill manifest).
- Consider whether to also populate a native `~/.grok/skills/gsd-*` tree in addition to the working `.agents` layout.
This session delivered working `grok` runtime resolution + immediate version parity for the user's primary Grok Build harness.
---
**Last updated:** 2026-05-16 (by Grok, in this session)
This document is intended to be living. Update it as the local Grok Build work progresses.

View File

@@ -199,6 +199,82 @@ const { routePhasesCommand } = require('./lib/phases-command-router.cjs');
const { routeValidateCommand } = require('./lib/validate-command-router.cjs');
const { routeRoadmapCommand } = require('./lib/roadmap-command-router.cjs');
// ─── SDK bridge (Phase 6 inline family / non-family delegation) ───────────────
// For inline case blocks that have SDK counterparts (frontmatter, config, and
// non-family commands), we attempt to dispatch via executeForCjs (the sync
// bridge). CJS handlers are retained as fallback when SDK is unavailable.
//
// NOTE: migrate-config, detect-custom-files, config-path, and find-phase
// are CJS-native special cases; see comments inline.
// Shared loader for the synchronous SDK runtime bridge; see
// `bin/lib/cjs-sdk-bridge.cjs`. All canonical-command CJS dispatchers (the
// per-family routers and the non-family helper below) consume the same loader
// so a change to the SDK-load contract lands in one place.
const { tryLoadSdk: _tryLoadSdkBridge, getExecuteForCjs } = require('./lib/cjs-sdk-bridge.cjs');
/**
* Attempt SDK dispatch for a non-family command.
*
* Returns true when the SDK was available and handled the command (success or
* typed error). Returns false when the SDK is unavailable, signalling the
* caller to fall through to the CJS handler.
*
* @param {object} opts
* @param {string} opts.registryCommand - canonical command name in the SDK registry
* @param {string[]} opts.registryArgs - args to pass to the SDK handler
* @param {string} opts.legacyCommand - original gsd-tools command name (for error messages)
* @param {string[]} opts.legacyArgs - original args (for error messages)
* @param {string} opts.cwd - project dir
* @param {boolean} opts.raw - raw output mode
* @param {Function} opts.error - error reporter
* @param {Function} opts.output - output emitter (core.output)
*/
function _dispatchNonFamily({ registryCommand, registryArgs, legacyCommand, legacyArgs, cwd, raw, error, output }) {
if (!_tryLoadSdkBridge()) return false;
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand,
legacyArgs,
// Always request typed JSON from the bridge; CJS `output(data, raw)` handles
// user-facing rendering. Passing `mode: 'raw'` would make the bridge
// pre-render result.data to a JSON string that the CJS output path then
// double-stringifies (returning a JSON string of a JSON string).
mode: 'json',
projectDir: cwd,
workstream: process.env.GSD_WORKSTREAM || undefined,
});
if (!result.ok) {
const message = (result.errorDetails && result.errorDetails.message)
|| `${legacyCommand} (${registryCommand}) failed (${result.errorKind})`;
// Propagate the structured reason code through to CJS `error()` so the
// `--json-errors` JSON-shaped stderr carries the typed reason (e.g.
// 'config_key_not_found') instead of the generic 'unknown'. Handlers
// tag the GSDError with `.reason` and the worker forwards it via
// errorDetails.reason. (Bugs #2943, #3086.)
const reason = result.errorDetails && result.errorDetails.reason;
if (reason) {
error(message, reason);
} else {
error(message);
}
return true; // handled (error reported)
}
// CJS parity for --raw output (config.cjs:525 `output(value, raw, String(value))`):
// when the caller asked for --raw and the SDK returned a scalar, pass that
// scalar through as `rawValue` so core.output() emits the bare string
// representation instead of JSON-stringifying it. Non-scalar shapes fall
// through to the structured JSON path, matching `output(obj, raw)`.
const data = result.data;
if (raw && (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean')) {
output(data, raw, String(data));
} else {
output(data, raw);
}
return true;
}
// ─── Arg parsing helpers ──────────────────────────────────────────────────────
/**
@@ -524,7 +600,19 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
case 'find-phase': {
phase.cmdFindPhase(cwd, args[1], raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: findPhase in sdk/src/query/phase.ts.
const handled = _dispatchNonFamily({
registryCommand: 'find-phase',
registryArgs: args.slice(1),
legacyCommand: 'find-phase',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) phase.cmdFindPhase(cwd, args[1], raw);
break;
}
@@ -590,8 +678,31 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
case 'frontmatter': {
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: sdk/src/query/frontmatter.ts + frontmatter-mutation.ts.
// CJS fallback: frontmatter.cjs (cooperating sibling).
const subcommand = args[1];
const file = args[2];
const FRONTMATTER_SDK_MAP = {
get: 'frontmatter.get',
set: 'frontmatter.set',
merge: 'frontmatter.merge',
validate: 'frontmatter.validate',
};
if (subcommand in FRONTMATTER_SDK_MAP) {
const handled = _dispatchNonFamily({
registryCommand: FRONTMATTER_SDK_MAP[subcommand],
registryArgs: args.slice(2),
legacyCommand: 'frontmatter',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (handled) break;
}
// CJS fallback (SDK unavailable or unknown subcommand)
if (subcommand === 'get') {
frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw);
} else if (subcommand === 'set') {
@@ -619,12 +730,36 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
case 'generate-slug': {
commands.cmdGenerateSlug(args[1], raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: generateSlug in sdk/src/query/utils.ts.
const handled = _dispatchNonFamily({
registryCommand: 'generate-slug',
registryArgs: args.slice(1),
legacyCommand: 'generate-slug',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) commands.cmdGenerateSlug(args[1], raw);
break;
}
case 'current-timestamp': {
commands.cmdCurrentTimestamp(args[1] || 'full', raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: currentTimestamp in sdk/src/query/utils.ts.
const handled = _dispatchNonFamily({
registryCommand: 'current-timestamp',
registryArgs: args.slice(1),
legacyCommand: 'current-timestamp',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) commands.cmdCurrentTimestamp(args[1] || 'full', raw);
break;
}
@@ -639,38 +774,112 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
case 'config-ensure-section': {
config.cmdConfigEnsureSection(cwd, raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs. The catalog rebinds
// 'config-ensure-section' to configNewProject in
// sdk/src/query/command-static-catalog-foundation.ts, restoring the
// legacy "no-arg full default init" contract on the SDK path
// (configEnsureSection itself stays available as an unbound single-
// section helper for future SDK callers).
const handled = _dispatchNonFamily({
registryCommand: 'config-ensure-section',
registryArgs: args.slice(1),
legacyCommand: 'config-ensure-section',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) config.cmdConfigEnsureSection(cwd, raw);
break;
}
case 'config-set': {
config.cmdConfigSet(cwd, args[1], args[2], raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
const handled = _dispatchNonFamily({
registryCommand: 'config-set',
registryArgs: args.slice(1),
legacyCommand: 'config-set',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw);
break;
}
case "config-set-model-profile": {
config.cmdConfigSetModelProfile(cwd, args[1], raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
const handled = _dispatchNonFamily({
registryCommand: 'config-set-model-profile',
registryArgs: args.slice(1),
legacyCommand: 'config-set-model-profile',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) config.cmdConfigSetModelProfile(cwd, args[1], raw);
break;
}
case 'config-get': {
config.cmdConfigGet(cwd, args[1], raw, defaultValue);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// The SDK handler supports --default via the registry args (args.slice(1)
// contains the key; defaultValue is handled by the SDK via the --default
// flag which was already stripped from args and held in defaultValue).
// Pass the full original args.slice(1) so the SDK sees the key; the
// defaultValue from the flag is in the global defaultValue variable above.
// Since the SDK handler reads --default from registryArgs, re-inject it.
const configGetSdkArgs = defaultValue !== undefined
? [args[1], '--default', defaultValue]
: args.slice(1);
const handled = _dispatchNonFamily({
registryCommand: 'config-get',
registryArgs: configGetSdkArgs,
legacyCommand: 'config-get',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) config.cmdConfigGet(cwd, args[1], raw, defaultValue);
break;
}
case 'config-new-project': {
config.cmdConfigNewProject(cwd, args[1], raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
const handled = _dispatchNonFamily({
registryCommand: 'config-new-project',
registryArgs: args.slice(1),
legacyCommand: 'config-new-project',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) config.cmdConfigNewProject(cwd, args[1], raw);
break;
}
case 'config-path': {
// CJS-native: config-path returns the filesystem path to config.json.
// The SDK handler (configPath) also exists but requires a projectDir that
// is already resolved. Both produce identical output; keeping CJS here is
// simpler and avoids sync-bridge overhead for a trivial path lookup.
config.cmdConfigPath(cwd, raw);
break;
}
case 'migrate-config': {
// Explicit on-disk migration of legacy config keys to canonical shape (#3536).
// Wraps Configuration Module migrateOnDisk(); idempotent. async — must await.
// CJS-native: migrate-config wraps the Configuration Module migrateOnDisk()
// which is async and mutates the filesystem. No SDK counterpart exists in
// the command registry (it's a one-shot migration utility). Must await.
await config.cmdMigrateConfig(cwd, raw);
break;
}
@@ -1077,7 +1286,19 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
// ─── Documentation ────────────────────────────────────────────────────
case 'docs-init': {
docs.cmdDocsInit(cwd, raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: docsInit in sdk/src/query/docs-init.ts.
const handled = _dispatchNonFamily({
registryCommand: 'docs-init',
registryArgs: args.slice(1),
legacyCommand: 'docs-init',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) docs.cmdDocsInit(cwd, raw);
break;
}
@@ -1110,6 +1331,11 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
// ─── detect-custom-files ───────────────────────────────────────────────
// CJS-native: no SDK counterpart exists in the command registry.
// detect-custom-files reads a gsd-file-manifest.json against the
// live filesystem to identify user-added files. It is installer-specific
// logic that has no async query equivalent in the SDK.
//
// Detect user-added files inside GSD-managed directories that are not
// tracked in gsd-file-manifest.json. Used by the update workflow to back
// up custom files before the installer wipes those directories.

View File

@@ -0,0 +1,136 @@
'use strict';
/**
* CJS↔SDK Sync Runtime Bridge Adapter — Phase 5/6 of #3524.
*
* Single shared loader for the synchronous SDK runtime bridge that every CJS
* command-router family file and `gsd-tools.cjs` non-family dispatcher
* delegates through. Centralizing the load prevents the seven-fold duplicated
* `tryLoadSdk` blocks that existed across the routers from drifting against
* each other (the exact anti-pattern the Phase 6 hand-sync lint is meant to
* stop, applied to the SDK-load logic itself).
*
* Load path policy: the bridge resolves the bundled SDK by package-relative
* filesystem path, NOT by the `@gsd-build/sdk` package name. The package name
* is not installed in the root `node_modules` (it lives as a sibling workspace
* package, not a dependency), and the SDK's public entry doesn't re-export
* `executeForCjs` or `formatStateLoadRawStdout` anyway. Using the relative
* path means the loader works identically in (a) the development checkout
* (`<repo>/sdk/dist/...`) and (b) the published package layout
* (`node_modules/get-shit-done-cc/sdk/dist/...`) because the `files` array in
* `package.json` keeps `sdk/dist` at the same path inside the published
* tarball.
*
* The previous implementation used `require('@gsd-build/sdk')`, which always
* failed because the package was unresolvable from the consumer location.
* That cached `_loadFailed = true` for the lifetime of the process and made
* every router silently fall through to CJS — defeating Phase 5/6's entire
* goal. The integration test at `tests/cjs-sdk-bridge-integration.test.cjs`
* locks the load-success invariant so this regression cannot recur.
*
* Usage:
* const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
* if (tryLoadSdk()) {
* const result = getExecuteForCjs()({ ... });
* }
*
* Plus `getFormatStateLoadRawStdout()` for the `state load --raw` adapter and
* `getSdkModule()` for routers that need the raw runtime-bridge-sync module.
*/
const path = require('path');
// Computed once at module load. Resolves the bundled SDK relative to this
// file's on-disk location, so both dev and post-install layouts work.
// <root>/get-shit-done/bin/lib/cjs-sdk-bridge.cjs
// <root>/sdk/dist/runtime-bridge-sync/index.js
// <root>/sdk/dist/query/state-project-load.js
const RUNTIME_BRIDGE_PATH = path.resolve(
__dirname,
'..',
'..',
'..',
'sdk',
'dist',
'runtime-bridge-sync',
'index.js',
);
const STATE_PROJECT_LOAD_PATH = path.resolve(
__dirname,
'..',
'..',
'..',
'sdk',
'dist',
'query',
'state-project-load.js',
);
let _runtimeBridge = null;
let _formatStateLoadRawStdout = null;
let _loadFailed = false;
/**
* Load the bundled SDK runtime bridge once and cache the result. Returns true
* on success, false if the dist artifacts are missing (e.g. `npm run
* build:sdk` has not been executed in a fresh dev checkout) or if the
* expected `executeForCjs` export is absent. Cached result is reused on
* subsequent calls.
*/
function tryLoadSdk() {
if (_runtimeBridge) return true;
if (_loadFailed) return false;
try {
// eslint-disable-next-line global-require
const bridge = require(RUNTIME_BRIDGE_PATH);
if (typeof bridge.executeForCjs !== 'function') {
_loadFailed = true;
return false;
}
// eslint-disable-next-line global-require
const stateProjectLoad = require(STATE_PROJECT_LOAD_PATH);
if (typeof stateProjectLoad.formatStateLoadRawStdout !== 'function') {
_loadFailed = true;
return false;
}
_runtimeBridge = bridge;
_formatStateLoadRawStdout = stateProjectLoad.formatStateLoadRawStdout;
return true;
} catch {
_loadFailed = true;
return false;
}
}
/**
* Returns the cached `executeForCjs` function, or null if `tryLoadSdk()` has
* not been called or returned false. Callers must check `tryLoadSdk()` first.
*/
function getExecuteForCjs() {
return _runtimeBridge ? _runtimeBridge.executeForCjs : null;
}
/**
* Returns the cached `formatStateLoadRawStdout` function, or null. Used by
* the state command router for the `state load --raw` adapter that projects
* SDK return data into the legacy key=value lines format.
*/
function getFormatStateLoadRawStdout() {
return _formatStateLoadRawStdout;
}
/**
* Returns the cached runtime-bridge-sync module object after a successful
* `tryLoadSdk()`, or null. Provided for callers that need additional named
* exports beyond `executeForCjs`.
*/
function getSdkModule() {
return _runtimeBridge;
}
module.exports = {
tryLoadSdk,
getExecuteForCjs,
getFormatStateLoadRawStdout,
getSdkModule,
};

View File

@@ -230,14 +230,6 @@ const VERIFY_COMMAND_ALIASES = [
],
"subcommand": "schema-drift",
"mutation": false
},
{
"canonical": "verify.codebase-drift",
"aliases": [
"verify codebase-drift"
],
"subcommand": "codebase-drift",
"mutation": false
}
];
@@ -651,20 +643,6 @@ const NON_FAMILY_COMMAND_ALIASES = [
"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": [
@@ -835,4 +813,4 @@ module.exports = {
PHASES_SUBCOMMANDS,
VALIDATE_SUBCOMMANDS,
ROADMAP_SUBCOMMANDS,
};
};

View File

@@ -1012,6 +1012,7 @@ function cmdCheckCommit(cwd, raw) {
}
module.exports = {
determinePhaseStatus,
cmdGenerateSlug,
cmdCurrentTimestamp,
cmdListTodos,

View File

@@ -1,48 +1,19 @@
'use strict';
/**
* Shared parser for CONTEXT.md `<decisions>` blocks.
* Decisions Module — CJS adapter.
*
* Used by:
* - gap-checker.cjs (#2493 post-planning gap analysis)
* - intended for #2492 (plan-phase decision gate, verify-phase decision validator)
* The implementation is generated from sdk/src/query/decisions.ts and
* lives in decisions.generated.cjs. This file is a thin re-export so
* that existing call sites (gap-checker.cjs, tests) can continue to
* require('./decisions') unchanged.
*
* Format produced by discuss-phase.md:
* Exports (from generated file):
* - parseDecisions(content) — parse <decisions> blocks, returns {id, text, category, tags, trackable}[]
* CJS callers using only {id, text} safely ignore the extra fields.
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
*
* <decisions>
* ## Implementation Decisions
*
* ### Category
* - **D-01:** Decision text
* - **D-02:** Another decision
* </decisions>
*
* D-IDs outside the <decisions> block are ignored. Missing block returns [].
* Regenerate: cd sdk && npm run gen:decisions
*/
/**
* Parse the <decisions> section of a CONTEXT.md string.
*
* @param {string|null|undefined} contextMd - File contents, may be empty/missing.
* @returns {Array<{id: string, text: string}>}
*/
function parseDecisions(contextMd) {
if (!contextMd || typeof contextMd !== 'string') return [];
const blockMatch = contextMd.match(/<decisions>([\s\S]*?)<\/decisions>/);
if (!blockMatch) return [];
const block = blockMatch[1];
const decisionRe = /^\s*-\s*\*\*(D-[A-Za-z0-9_-]+):\*\*\s*(.+?)\s*$/gm;
const out = [];
const seen = new Set();
let m;
while ((m = decisionRe.exec(block)) !== null) {
const id = m[1];
if (seen.has(id)) continue;
seen.add(id);
out.push({ id, text: m[2] });
}
return out;
}
module.exports = { parseDecisions };
module.exports = require('./decisions.generated.cjs');

View File

@@ -0,0 +1,121 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/decisions.ts
* Regenerate: cd sdk && npm run gen:decisions
*
* Shared parser for CONTEXT.md <decisions> blocks.
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
* Returns {id, text, category, tags, trackable} per decision.
* CJS callers that only use {id, text} safely ignore the extra fields.
*/
const DISCRETION_HEADINGS = new Set([
"claude's discretion",
'claudes discretion',
'claude discretion',
]);
const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
/**
* Strip fenced code blocks from `content` so example `<decisions>` snippets
* inside ```` ``` ```` do not pollute the parser (review F11).
*/
function stripFencedCode(content) {
return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' ');
}
/**
* Extract the inner text of EVERY `<decisions>...</decisions>` block in
* order, concatenated by `\n\n`. Returns null when no block is present.
*
* CONTEXT.md may legitimately contain more than one block (for example, a
* "current decisions" block plus a "carry-over from prior phase" block);
* dropping all-but-the-first silently lost the second batch (review F13).
*/
function extractDecisionsBlock(content) {
const cleaned = stripFencedCode(content);
const matches = [...cleaned.matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)];
if (matches.length === 0)
return null;
return matches.map((m) => m[1]).join('\n\n');
}
/**
* Parse trackable decisions from CONTEXT.md content.
*
* Returns ALL D-NN decisions found inside `<decisions>` (including
* non-trackable ones, with `trackable: false`). Callers that only want the
* gate-enforced decisions should filter `.filter(d => d.trackable)`.
*/
function parseDecisions(content) {
if (!content || typeof content !== 'string')
return [];
const block = extractDecisionsBlock(content);
if (block === null)
return [];
const lines = block.split(/\r?\n/);
const out = [];
let category = '';
let inDiscretion = false;
// Bullet line: `- **D-NN[ [tags]]:** text`
// 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 = null;
const flush = () => {
if (current) {
current.text = current.text.trim();
out.push(current);
current = null;
}
};
for (const line of lines) {
const trimmed = line.trim();
// Track category headings (`### Heading`)
const headingMatch = trimmed.match(/^###\s+(.+?)\s*$/);
if (headingMatch) {
flush();
category = headingMatch[1];
// Strip the full unicode-quote family so any rendering of "Claude's
// Discretion" (ASCII apostrophe, curly U+2019, U+2018, U+201A, U+201B,
// double-quote variants U+201C/D/E/F, etc.) collapses to the same key
// (review F20).
const normalized = category
.toLowerCase()
.replace(/[\u2018\u2019\u201A\u201B\u201C\u201D\u201E\u201F'"`]/g, '')
.trim();
inDiscretion = DISCRETION_HEADINGS.has(normalized);
continue;
}
const bulletMatch = line.match(bulletRe);
if (bulletMatch) {
flush();
const id = `D-${bulletMatch[1]}`;
const tags = bulletMatch[2]
? bulletMatch[2]
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
: [];
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
current = { id, text: bulletMatch[3], category, tags, trackable };
continue;
}
// Continuation line for current decision (indented with space OR tab,
// non-bullet, non-empty) — tab indentation must work too (review F12).
if (current && trimmed !== '' && !trimmed.startsWith('-') && /^[ \t]/.test(line)) {
current.text += ' ' + trimmed;
continue;
}
// Blank line or unrelated content terminates the current decision
if (trimmed === '') {
flush();
}
}
flush();
return out;
}
module.exports = { parseDecisions };

View File

@@ -1,68 +1,172 @@
'use strict';
const { INIT_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
/**
* Manifest-backed init subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: all init.* subcommands have SDK equivalents and are dispatched
* via executeForCjs (the sync bridge). CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* CJS-only subcommands: none.
* SDK-only (unsupported in CJS router): none.
*/
function routeInitCommand({ init, args, cwd, raw, parseNamedArgs, error }) {
const workflow = args[1];
switch (workflow) {
case 'execute-phase': {
const { validate: epValidate, tdd: epTdd } = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: epValidate, tdd: epTdd });
break;
}
case 'plan-phase': {
const { validate: ppValidate, tdd: ppTdd } = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: ppValidate, tdd: ppTdd });
break;
}
case 'new-project':
init.cmdInitNewProject(cwd, raw);
break;
case 'new-milestone':
init.cmdInitNewMilestone(cwd, raw);
break;
case 'quick':
init.cmdInitQuick(cwd, args.slice(2).join(' '), raw);
break;
case 'ingest-docs':
init.cmdInitIngestDocs(cwd, raw);
break;
case 'resume':
init.cmdInitResume(cwd, raw);
break;
case 'verify-work':
init.cmdInitVerifyWork(cwd, args[2], raw);
break;
case 'phase-op':
init.cmdInitPhaseOp(cwd, args[2], raw);
break;
case 'todos':
init.cmdInitTodos(cwd, args[2], raw);
break;
case 'milestone-op':
init.cmdInitMilestoneOp(cwd, raw);
break;
case 'map-codebase':
init.cmdInitMapCodebase(cwd, raw);
break;
case 'progress':
init.cmdInitProgress(cwd, raw);
break;
case 'manager':
init.cmdInitManager(cwd, raw);
break;
case 'new-workspace':
init.cmdInitNewWorkspace(cwd, raw);
break;
case 'list-workspaces':
init.cmdInitListWorkspaces(cwd, raw);
break;
case 'remove-workspace':
init.cmdInitRemoveWorkspace(cwd, args[2], raw);
break;
default:
error(`Unknown init workflow: ${workflow}\nAvailable: ${INIT_SUBCOMMANDS.join(', ')}`);
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'init',
legacyArgs,
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `init ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
}
routeCjsCommandFamily({
args,
subcommands: INIT_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand, available) => `Unknown init workflow: ${_subcommand}\nAvailable: ${available.join(', ')}`,
handlers: {
'execute-phase': sdkHandler(
'init.execute-phase',
args.slice(2),
args.slice(1),
() => {
const { validate: epValidate, tdd: epTdd } = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: epValidate, tdd: epTdd });
},
),
'plan-phase': sdkHandler(
'init.plan-phase',
args.slice(2),
args.slice(1),
() => {
const { validate: ppValidate, tdd: ppTdd } = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: ppValidate, tdd: ppTdd });
},
),
'new-project': sdkHandler(
'init.new-project',
args.slice(2),
args.slice(1),
() => init.cmdInitNewProject(cwd, raw),
),
'new-milestone': sdkHandler(
'init.new-milestone',
args.slice(2),
args.slice(1),
() => init.cmdInitNewMilestone(cwd, raw),
),
quick: sdkHandler(
'init.quick',
args.slice(2),
args.slice(1),
() => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw),
),
'ingest-docs': sdkHandler(
'init.ingest-docs',
args.slice(2),
args.slice(1),
() => init.cmdInitIngestDocs(cwd, raw),
),
resume: sdkHandler(
'init.resume',
args.slice(2),
args.slice(1),
() => init.cmdInitResume(cwd, raw),
),
'verify-work': sdkHandler(
'init.verify-work',
args.slice(2),
args.slice(1),
() => init.cmdInitVerifyWork(cwd, args[2], raw),
),
'phase-op': sdkHandler(
'init.phase-op',
args.slice(2),
args.slice(1),
() => init.cmdInitPhaseOp(cwd, args[2], raw),
),
todos: sdkHandler(
'init.todos',
args.slice(2),
args.slice(1),
() => init.cmdInitTodos(cwd, args[2], raw),
),
'milestone-op': sdkHandler(
'init.milestone-op',
args.slice(2),
args.slice(1),
() => init.cmdInitMilestoneOp(cwd, raw),
),
'map-codebase': sdkHandler(
'init.map-codebase',
args.slice(2),
args.slice(1),
() => init.cmdInitMapCodebase(cwd, raw),
),
progress: sdkHandler(
'init.progress',
args.slice(2),
args.slice(1),
() => init.cmdInitProgress(cwd, raw),
),
// Keep manager on CJS for now so runtime-specific command rendering
// (e.g. $gsd-* for codex) stays consistent with runtime-slash helpers.
manager: () => init.cmdInitManager(cwd, raw),
'new-workspace': sdkHandler(
'init.new-workspace',
args.slice(2),
args.slice(1),
() => init.cmdInitNewWorkspace(cwd, raw),
),
'list-workspaces': sdkHandler(
'init.list-workspaces',
args.slice(2),
args.slice(1),
() => init.cmdInitListWorkspaces(cwd, raw),
),
'remove-workspace': sdkHandler(
'init.remove-workspace',
args.slice(2),
args.slice(1),
() => init.cmdInitRemoveWorkspace(cwd, args[2], raw),
),
},
});
}
module.exports = {

View File

@@ -11,6 +11,7 @@ const { maskIfSecret } = require('./secrets.cjs');
const scanPhasePlans = require('./plan-scan.cjs');
const { stateExtractField } = require('./state-document.cjs');
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
const { determinePhaseStatus } = require('./commands.cjs');
// Accept all bold/colon variants of the Requirements header (#2769):
// **Requirements:** / **Requirements**: / **Requirements** : render the
@@ -296,6 +297,20 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
padded_phase: phaseNumberPlan ? normalizePhaseName(phaseNumberPlan) : null,
phase_req_ids,
// #3569: surface phase lifecycle status so /gsd:plan-phase can short-circuit
// on closed (Complete) phases instead of silently replanning over shipped
// code. Reuses determinePhaseStatus — the project-wide vocabulary
// (Pending | Planned | In Progress | Executed | Complete | Needs Review).
// No directory yet → Pending (phase has not been started).
phase_status: phaseDirPlan
? determinePhaseStatus(
phaseInfo?.plans?.length || 0,
phaseInfo?.summaries?.length || 0,
path.join(cwd, phaseDirPlan),
'Pending',
)
: 'Pending',
// Existing artifacts
has_research: phaseInfo?.has_research || false,
has_context: phaseInfo?.has_context || false,

View File

@@ -2,8 +2,61 @@
const { PHASE_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
/**
* Manifest-backed phase subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: all CJS-handled phase subcommands are dispatched via executeForCjs
* when the SDK is available. CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* SDK-only (unsupported in CJS router):
* - list-plans: SDK-only.
* - list-artifacts: SDK-only.
* - scaffold: routed through top-level scaffold command.
*
* CJS-only subcommands: none.
*/
function routePhaseCommand({ phase, args, cwd, raw, error }) {
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'phase',
legacyArgs,
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `phase ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
}
routeCjsCommandFamily({
args,
subcommands: PHASE_SUBCOMMANDS,
@@ -16,77 +69,115 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
unknownMessage: (_subcommand, available) => `Unknown phase subcommand. Available: ${available.join(', ')}`,
handlers: {
'mvp-mode': () => phase.cmdPhaseMvpMode(cwd, args.slice(2), raw),
'next-decimal': () => phase.cmdPhaseNextDecimal(cwd, args[2], raw),
add: () => {
let customId = null;
const descArgs = [];
for (let i = 2; i < args.length; i++) {
const token = args[i];
if (token === '--raw') {
continue;
}
if (token === '--id') {
const id = args[i + 1];
if (!id || id.startsWith('--')) {
error('--id requires a value');
'next-decimal': sdkHandler(
'phase.next-decimal',
args.slice(2),
args.slice(1),
() => phase.cmdPhaseNextDecimal(cwd, args[2], raw),
),
add: sdkHandler(
'phase.add',
args.slice(2),
args.slice(1),
() => {
let customId = null;
const descArgs = [];
for (let i = 2; i < args.length; i++) {
const token = args[i];
if (token === '--raw') {
continue;
}
if (token === '--id') {
const id = args[i + 1];
if (!id || id.startsWith('--')) {
error('--id requires a value');
return;
}
customId = id;
i++;
} else if (token.startsWith('--')) {
error(`phase add does not support ${token}`);
return;
} else {
descArgs.push(token);
}
}
phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId);
},
),
'add-batch': sdkHandler(
'phase.add-batch',
args.slice(2),
args.slice(1),
() => {
const descFlagIdx = args.indexOf('--descriptions');
let descriptions;
if (descFlagIdx !== -1) {
const rawDescriptions = args[descFlagIdx + 1];
if (!rawDescriptions || rawDescriptions.startsWith('--')) {
error('--descriptions must be a JSON array');
return;
}
try {
descriptions = JSON.parse(rawDescriptions);
} catch {
error('--descriptions must be a JSON array');
return;
}
if (!Array.isArray(descriptions)) {
error('--descriptions must be a JSON array');
return;
}
customId = id;
i++;
} else if (token.startsWith('--')) {
error(`phase add does not support ${token}`);
} else {
descArgs.push(token);
descriptions = args.slice(2).filter(a => a !== '--raw');
}
}
phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId);
},
'add-batch': () => {
const descFlagIdx = args.indexOf('--descriptions');
let descriptions;
if (descFlagIdx !== -1) {
const rawDescriptions = args[descFlagIdx + 1];
if (!rawDescriptions || rawDescriptions.startsWith('--')) {
error('--descriptions must be a JSON array');
phase.cmdPhaseAddBatch(cwd, descriptions, raw);
},
),
insert: sdkHandler(
'phase.insert',
args.slice(2),
args.slice(1),
() => {
if (args.includes('--dry-run')) {
error('phase insert does not support --dry-run');
return;
}
try {
descriptions = JSON.parse(rawDescriptions);
} catch {
error('--descriptions must be a JSON array');
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
},
),
remove: sdkHandler(
'phase.remove',
args.slice(2),
args.slice(1),
() => {
const removeArgs = args.slice(2).filter(token => token !== '--raw');
let forceFlag = false;
const positional = [];
for (const token of removeArgs) {
if (token === '--force') {
forceFlag = true;
continue;
}
if (token.startsWith('--')) {
error(`phase remove does not support ${token}`);
return;
}
positional.push(token);
}
if (!Array.isArray(descriptions)) {
error('--descriptions must be a JSON array');
if (positional.length !== 1) {
error('phase remove accepts exactly one phase number');
return;
}
} else {
descriptions = args.slice(2).filter(a => a !== '--raw');
}
phase.cmdPhaseAddBatch(cwd, descriptions, raw);
},
insert: () => {
if (args.includes('--dry-run')) {
error('phase insert does not support --dry-run');
}
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
},
remove: () => {
const removeArgs = args.slice(2).filter(token => token !== '--raw');
let forceFlag = false;
const positional = [];
for (const token of removeArgs) {
if (token === '--force') {
forceFlag = true;
continue;
}
if (token.startsWith('--')) {
error(`phase remove does not support ${token}`);
}
positional.push(token);
}
if (positional.length > 1) {
error('phase remove accepts exactly one phase number');
}
phase.cmdPhaseRemove(cwd, positional[0], { force: forceFlag }, raw);
},
complete: () => phase.cmdPhaseComplete(cwd, args[2], raw),
phase.cmdPhaseRemove(cwd, positional[0], { force: forceFlag }, raw);
},
),
complete: sdkHandler(
'phase.complete',
args.slice(2),
args.slice(1),
() => phase.cmdPhaseComplete(cwd, args[2], raw),
),
},
});
}

View File

@@ -2,34 +2,92 @@
const { PHASES_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
/**
* Manifest-backed phases subcommand router.
* Keeps gsd-tools.cjs thin while preserving current CJS semantics:
* - list
* - clear
* Keeps gsd-tools.cjs thin while preserving current CJS semantics.
*
* Note: `archive` is currently SDK-only (`phases.archive` handler in SDK query
* registry). CJS `gsd-tools phases` intentionally supports list/clear only.
* Phase 6: phases.list and phases.clear are dispatched via executeForCjs when
* the SDK is available. CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* SDK-only (not in CJS router, treated as unknown):
* - archive: `phases archive` is SDK-only (`phases.archive` handler in SDK
* query registry). CJS `gsd-tools phases` intentionally supports list/clear only.
* `archive` is excluded from the subcommands list so it falls through to the
* "unknown subcommand" error path (matching pre-Phase 6 behavior).
*
* CJS-only subcommands: none.
*/
function routePhasesCommand({ phase, milestone, args, cwd, raw, error }) {
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'phases',
legacyArgs,
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `phases ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
}
routeCjsCommandFamily({
args,
// Exclude 'archive' — it's SDK-only and not supported in CJS. Excluding
// from this list causes it to hit the unknownMessage path, preserving the
// pre-Phase 6 error message for callers that pass 'archive'.
subcommands: PHASES_SUBCOMMANDS.filter((s) => s !== 'archive'),
error,
unknownMessage: (_subcommand, available) => `Unknown phases subcommand. Available: ${available.join(', ')}`,
handlers: {
list: () => {
const typeIndex = args.indexOf('--type');
const phaseIndex = args.indexOf('--phase');
const options = {
type: typeIndex !== -1 ? args[typeIndex + 1] : null,
phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null,
includeArchived: args.includes('--include-archived'),
};
phase.cmdPhasesList(cwd, options, raw);
},
clear: () => milestone.cmdPhasesClear(cwd, raw, args.slice(2)),
list: sdkHandler(
'phases.list',
args.slice(2),
args.slice(1),
() => {
const typeIndex = args.indexOf('--type');
const phaseIndex = args.indexOf('--phase');
const options = {
type: typeIndex !== -1 ? args[typeIndex + 1] : null,
phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null,
includeArchived: args.includes('--include-archived'),
};
phase.cmdPhasesList(cwd, options, raw);
},
),
clear: sdkHandler(
'phases.clear',
args.slice(2),
args.slice(1),
() => milestone.cmdPhasesClear(cwd, raw, args.slice(2)),
),
},
});
}

View File

@@ -1,138 +1,26 @@
'use strict';
/**
* plan-scan — canonical phase-plan scanner (k014)
*
* Single source of truth for detecting plan and summary files in a phase
* directory, replacing four divergent copies in state.cjs, roadmap.cjs,
* init.cjs, and phase.cjs (#3262).
*
* Layout support:
* Flat (pre-#3139): phases/<N>/*-PLAN.md, *-SUMMARY.md
* Nested (post-#3139): phases/<N>/plans/PLAN-<NN>-*.md, SUMMARY-<NN>-*.md
*
* @module plan-scan
*/
const fs = require('fs');
const path = require('path');
// Excluded derivative files — present alongside real plans but must not be
// counted. OUTLINE exclusion catches both flat (-PLAN-OUTLINE.md) and nested
// (PLAN-NN-OUTLINE.md) forms via a broad -OUTLINE.md$ pattern. The
// pre-bounce pattern is intentionally broad (matches any *.pre-bounce.md) so
// stale bounce files never inflate plan counts (#3257 regression root cause).
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
/**
* Determine whether a filename from the flat phase root is a plan file.
* Plan Scan Module — CJS adapter.
*
* Accepts:
* - Bare PLAN.md
* - Canonical padded 01-01-PLAN.md
* - Extended layout 5-PLAN-01-setup.md (the format gsd-plan-phase writes;
* looksLikePlanFile in phase.cjs / isPlanFile in roadmap.cjs)
* The implementation is generated from sdk/src/query/plan-scan.ts and
* lives in plan-scan.generated.cjs. This file is a thin re-export so
* that existing call sites (state.cjs, roadmap.cjs, init.cjs,
* workstream-inventory.cjs, and tests) can continue to require('./plan-scan')
* unchanged.
*
* Rejects: -PLAN-OUTLINE.md, *.pre-bounce.md
*/
function isRootPlanFile(f) {
if (PLAN_OUTLINE_RE.test(f)) return false;
if (PLAN_PRE_BOUNCE_RE.test(f)) return false;
// Canonical suffix or bare name
if (f.endsWith('-PLAN.md') || f === 'PLAN.md') return true;
// Extended layout: any .md that contains PLAN (case-insensitive) in the name
return /\.md$/i.test(f) && /PLAN/i.test(f);
}
/**
* Determine whether a filename from the nested plans/ subdir is a plan file.
* Exports (from generated file):
* - scanPhasePlans(phaseDir) — canonical phase-plan scanner
* - isRootPlanFile(fileName) — extended filter including /PLAN/i slug layouts
* - isNestedPlanFile(fileName) — nested plans/ subdir filter
* - isRootSummaryFile(fileName) — flat summary file filter
* - isNestedSummaryFile(fileName) — nested summary file filter
*
* Nested layout names: PLAN-NN-slug.md or N-PLAN-NN-slug.md.
* Excludes OUTLINE and pre-bounce suffixes.
*/
function isNestedPlanFile(f) {
if (PLAN_OUTLINE_RE.test(f)) return false;
if (PLAN_PRE_BOUNCE_RE.test(f)) return false;
return /^PLAN-\d+.*\.md$/i.test(f) || /-PLAN-\d+.*\.md$/i.test(f);
}
/**
* Determine whether a filename from the flat phase root is a summary file.
*/
function isRootSummaryFile(f) {
return f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md';
}
/**
* Determine whether a filename from the nested plans/ subdir is a summary.
*/
function isNestedSummaryFile(f) {
return /^SUMMARY-\d+.*\.md$/i.test(f) || /-SUMMARY-\d+.*\.md$/i.test(f);
}
/**
* Scan a single phase directory for plan and summary files.
* The isRootPlanFile helper uses /PLAN/i to match the extended slug layout
* (e.g. 5-PLAN-01-setup-database.md) in addition to bare and canonical forms.
* This was the fix for bug #3128 (roadmap.cjs plan-count regression).
*
* @param {string} phaseDir — absolute path to the phase directory
* @returns {{
* planCount: number,
* summaryCount: number,
* completed: boolean,
* hasNestedPlans: boolean,
* planFiles: string[],
* summaryFiles: string[],
* }}
* Regenerate: cd sdk && npm run gen:plan-scan
*/
function scanPhasePlans(phaseDir) {
let rootFiles;
try {
rootFiles = fs.readdirSync(phaseDir);
} catch {
return {
planCount: 0,
summaryCount: 0,
completed: false,
hasNestedPlans: false,
planFiles: [],
summaryFiles: [],
};
}
const rootPlanFiles = rootFiles.filter(isRootPlanFile);
const rootSummaryFiles = rootFiles.filter(isRootSummaryFile);
let nestedPlanFiles = [];
let nestedSummaryFiles = [];
let hasNestedPlans = false;
const nestedDir = path.join(phaseDir, 'plans');
if (fs.existsSync(nestedDir)) {
try {
const nested = fs.readdirSync(nestedDir);
nestedPlanFiles = nested.filter(isNestedPlanFile);
nestedSummaryFiles = nested.filter(isNestedSummaryFile);
hasNestedPlans = nestedPlanFiles.length > 0;
} catch { /* ignore if plans/ is not a readable directory */ }
}
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
const planCount = planFiles.length;
const summaryCount = summaryFiles.length;
return {
planCount,
summaryCount,
completed: planCount > 0 && summaryCount >= planCount,
hasNestedPlans,
planFiles,
summaryFiles,
};
}
module.exports = scanPhasePlans;
module.exports.scanPhasePlans = scanPhasePlans;
module.exports.isRootPlanFile = isRootPlanFile;
module.exports.isNestedPlanFile = isNestedPlanFile;
module.exports.isRootSummaryFile = isRootSummaryFile;
module.exports.isNestedSummaryFile = isNestedSummaryFile;
module.exports = require('./plan-scan.generated.cjs');

View File

@@ -0,0 +1,97 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/plan-scan.ts
* Regenerate: cd sdk && npm run gen:plan-scan
*
* Plan Scan Module — detects plan and summary files in a phase directory.
* Supports both flat (pre-#3139) and nested (post-#3139) layouts.
*/
const { existsSync, readdirSync } = require('node:fs');
const { join } = require('node:path');
// Excluded derivative files
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
function isRootPlanFile(fileName) {
if (PLAN_OUTLINE_RE.test(fileName))
return false;
if (PLAN_PRE_BOUNCE_RE.test(fileName))
return false;
if (fileName.endsWith('-PLAN.md') || fileName === 'PLAN.md')
return true;
return /\.md$/i.test(fileName) && /PLAN/i.test(fileName);
}
function isNestedPlanFile(fileName) {
if (PLAN_OUTLINE_RE.test(fileName))
return false;
if (PLAN_PRE_BOUNCE_RE.test(fileName))
return false;
return /^PLAN-\d+.*\.md$/i.test(fileName) || /-PLAN-\d+.*\.md$/i.test(fileName);
}
function isRootSummaryFile(fileName) {
return fileName.endsWith('-SUMMARY.md') || fileName === 'SUMMARY.md';
}
function isNestedSummaryFile(fileName) {
return /^SUMMARY-\d+.*\.md$/i.test(fileName) || /-SUMMARY-\d+.*\.md$/i.test(fileName);
}
function scanPhasePlans(phaseDir) {
let rootFiles;
try {
rootFiles = readdirSync(phaseDir);
}
catch {
return {
planCount: 0,
summaryCount: 0,
completed: false,
hasNestedPlans: false,
planFiles: [],
summaryFiles: [],
};
}
const rootPlanFiles = rootFiles.filter(isRootPlanFile);
const rootSummaryFiles = rootFiles.filter(isRootSummaryFile);
let nestedPlanFiles = [];
let nestedSummaryFiles = [];
let hasNestedPlans = false;
const nestedDir = join(phaseDir, 'plans');
if (existsSync(nestedDir)) {
try {
const nestedFiles = readdirSync(nestedDir);
nestedPlanFiles = nestedFiles.filter(isNestedPlanFile);
nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile);
hasNestedPlans = nestedPlanFiles.length > 0;
}
catch { /* ignore unreadable nested layout */ }
}
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
const planCount = planFiles.length;
const summaryCount = summaryFiles.length;
return {
planCount,
summaryCount,
completed: planCount > 0 && summaryCount >= planCount,
hasNestedPlans,
planFiles,
summaryFiles,
};
}
// CJS callers do: const scanPhasePlans = require('./plan-scan.cjs')
// and also destructure named exports — support both call styles.
module.exports = scanPhasePlans;
module.exports.scanPhasePlans = scanPhasePlans;
module.exports.isRootPlanFile = isRootPlanFile;
module.exports.isNestedPlanFile = isNestedPlanFile;
module.exports.isRootSummaryFile = isRootSummaryFile;
module.exports.isNestedSummaryFile = isNestedSummaryFile;

View File

@@ -1,21 +1,97 @@
'use strict';
const { ROADMAP_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
/**
* Manifest-backed roadmap subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: all roadmap.* subcommands have SDK equivalents and are dispatched
* via executeForCjs (the sync bridge). CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* CJS-only subcommands: none.
* SDK-only (unsupported in CJS router): none.
*/
function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
const subcommand = args[1];
const activeWorkstream = process.env.GSD_WORKSTREAM;
// GSD_SDK_NESTED is set by SDK handlers that spawn gsd-tools.cjs as a
// child process (e.g. roadmapAnnotateDependencies). Without this guard
// the child process re-dispatches through the SDK bridge, which spawns
// again, ad infinitum until the synckit 15s timeout fires. Bug #3537
// annotate-dependencies parity.
const nested = process.env.GSD_SDK_NESTED === '1';
const sdkAvailable = !activeWorkstream && !nested && tryLoadSdk();
if (subcommand === 'get-phase') {
roadmap.cmdRoadmapGetPhase(cwd, args[2], raw);
} else if (subcommand === 'analyze') {
roadmap.cmdRoadmapAnalyze(cwd, raw);
} else if (subcommand === 'update-plan-progress') {
roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw);
} else if (subcommand === 'annotate-dependencies') {
roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw);
} else {
error(`Unknown roadmap subcommand. Available: ${ROADMAP_SUBCOMMANDS.join(', ')}`);
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'roadmap',
legacyArgs,
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `roadmap ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
}
routeCjsCommandFamily({
args,
subcommands: ROADMAP_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand, available) => `Unknown roadmap subcommand. Available: ${available.join(', ')}`,
handlers: {
'get-phase': sdkHandler(
'roadmap.get-phase',
args.slice(2),
args.slice(1),
() => roadmap.cmdRoadmapGetPhase(cwd, args[2], raw),
),
analyze: sdkHandler(
'roadmap.analyze',
args.slice(2),
args.slice(1),
() => roadmap.cmdRoadmapAnalyze(cwd, raw),
),
'update-plan-progress': sdkHandler(
'roadmap.update-plan-progress',
args.slice(2),
args.slice(1),
() => roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw),
),
'annotate-dependencies': sdkHandler(
'roadmap.annotate-dependencies',
args.slice(2),
args.slice(1),
() => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw),
),
},
});
}
module.exports = {

View File

@@ -62,6 +62,13 @@ function getGlobalConfigDir(runtime) {
case 'codex':
return env.CODEX_HOME ? expandTilde(env.CODEX_HOME) : path.join(home, '.codex');
// ── Grok Build ───────────────────────────────────────────────────────────
// Uses the unified ~/.agents layout (skills + agents + engine) shared with
// Codex-style harnesses. This is the pragmatic primary target for users
// running GSD inside Grok Build.
case 'grok':
return env.GROK_AGENTS_HOME ? expandTilde(env.GROK_AGENTS_HOME) : path.join(home, '.agents');
// ── Copilot (VS Code) ────────────────────────────────────────────────────
case 'copilot':
return env.COPILOT_CONFIG_DIR ? expandTilde(env.COPILOT_CONFIG_DIR) : path.join(home, '.copilot');

View File

@@ -1,238 +1,21 @@
/**
* Schema Drift Detection — Detects schema-relevant file changes and verifies
* that the appropriate database push command was executed during a phase.
*
* Prevents false-positive verification when schema files change but no push
* occurs — TypeScript types come from config, not the live database, so
* build/types pass on a broken state.
*/
'use strict';
// ─── ORM Patterns ────────────────────────────────────────────────────────────
//
// Each entry maps a glob-like pattern to an ORM name. Patterns use forward
// slashes internally — Windows backslash paths are normalized before matching.
const SCHEMA_PATTERNS = [
// Payload CMS
{ pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' },
{ pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' },
// Prisma
{ pattern: /^prisma\/schema\.prisma$/, orm: 'prisma' },
{ pattern: /^prisma\/schema\/.*\.prisma$/, orm: 'prisma' },
// Drizzle
{ pattern: /^drizzle\/schema\.ts$/, orm: 'drizzle' },
{ pattern: /^src\/db\/schema\.ts$/, orm: 'drizzle' },
{ pattern: /^drizzle\/.*\.ts$/, orm: 'drizzle' },
// Supabase
{ pattern: /^supabase\/migrations\/.*\.sql$/, orm: 'supabase' },
// TypeORM
{ pattern: /^src\/entities\/.*\.ts$/, orm: 'typeorm' },
{ pattern: /^src\/migrations\/.*\.ts$/, orm: 'typeorm' },
];
// ─── Push Commands & Evidence Patterns ───────────────────────────────────────
//
// For each ORM, the push command that agents should run, plus regex patterns
// that indicate the push was actually executed (matched against execution logs,
// SUMMARY.md content, and git commit messages).
const ORM_INFO = {
payload: {
pushCommand: 'npx payload migrate',
envHint: 'CI=true PAYLOAD_MIGRATING=true npx payload migrate',
interactiveWarning: 'Payload migrate may require interactive prompts — use CI=true PAYLOAD_MIGRATING=true to suppress',
evidencePatterns: [
/payload\s+migrate/i,
/PAYLOAD_MIGRATING/,
],
},
prisma: {
pushCommand: 'npx prisma db push',
envHint: 'npx prisma db push --accept-data-loss (if destructive changes are intended)',
interactiveWarning: 'Prisma db push may prompt for confirmation on destructive changes — use --accept-data-loss to bypass',
evidencePatterns: [
/prisma\s+db\s+push/i,
/prisma\s+migrate\s+deploy/i,
/prisma\s+migrate\s+dev/i,
],
},
drizzle: {
pushCommand: 'npx drizzle-kit push',
envHint: 'npx drizzle-kit push',
interactiveWarning: null,
evidencePatterns: [
/drizzle-kit\s+push/i,
/drizzle-kit\s+migrate/i,
],
},
supabase: {
pushCommand: 'supabase db push',
envHint: 'supabase db push',
interactiveWarning: 'Supabase db push may require authentication — ensure SUPABASE_ACCESS_TOKEN is set',
evidencePatterns: [
/supabase\s+db\s+push/i,
/supabase\s+migration\s+up/i,
],
},
typeorm: {
pushCommand: 'npx typeorm migration:run',
envHint: 'npx typeorm migration:run -d src/data-source.ts',
interactiveWarning: null,
evidencePatterns: [
/typeorm\s+migration:run/i,
/typeorm\s+schema:sync/i,
],
},
};
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Detect schema-relevant files in a list of file paths.
* Schema Detect Module — CJS adapter.
*
* @param {string[]} files - List of file paths (relative to project root)
* @returns {{ detected: boolean, matches: string[], orms: string[] }}
*/
function detectSchemaFiles(files) {
const matches = [];
const orms = new Set();
for (const rawFile of files) {
// Normalize Windows backslash paths
const file = rawFile.replace(/\\/g, '/');
for (const { pattern, orm } of SCHEMA_PATTERNS) {
if (pattern.test(file)) {
matches.push(rawFile);
orms.add(orm);
break; // One match per file is enough
}
}
}
return {
detected: matches.length > 0,
matches,
orms: Array.from(orms),
};
}
/**
* Get ORM-specific push command info.
* The implementation is generated from sdk/src/query/schema-detect.ts and
* lives in schema-detect.generated.cjs. This file is a thin re-export so
* that existing call sites (verify.cjs and tests) can continue to
* require('./schema-detect') unchanged.
*
* @param {string} ormName - ORM identifier (payload, prisma, drizzle, supabase, typeorm)
* @returns {{ pushCommand: string, envHint: string, interactiveWarning: string|null, evidencePatterns: RegExp[] } | null}
*/
function detectSchemaOrm(ormName) {
return ORM_INFO[ormName] || null;
}
/**
* Check for schema drift: schema files changed but no push evidence found.
* Exports (from generated file):
* - SCHEMA_PATTERNS — ORM file pattern list
* - ORM_INFO — ORM push commands and evidence patterns
* - detectSchemaFiles(files) — detect schema-relevant files
* - detectSchemaOrm(ormName) — get ORM-specific push command info
* - checkSchemaDrift(changedFiles, executionLog, options) — check for drift
*
* @param {string[]} changedFiles - Files changed during the phase
* @param {string} executionLog - Combined text from SUMMARY.md, commit messages, and execution logs
* @param {{ skipCheck?: boolean }} [options] - Options
* @returns {{ driftDetected: boolean, blocking: boolean, schemaFiles: string[], orms: string[], unpushedOrms: string[], message: string, skipped?: boolean }}
* Regenerate: cd sdk && npm run gen:schema-detect
*/
function checkSchemaDrift(changedFiles, executionLog, options = {}) {
const { skipCheck = false } = options;
const detection = detectSchemaFiles(changedFiles);
if (!detection.detected) {
return {
driftDetected: false,
blocking: false,
schemaFiles: [],
orms: [],
unpushedOrms: [],
message: '',
};
}
// Check which ORMs have push evidence in the execution log
const pushedOrms = new Set();
const unpushedOrms = [];
for (const orm of detection.orms) {
const info = ORM_INFO[orm];
if (!info) continue;
const hasPushEvidence = info.evidencePatterns.some(p => p.test(executionLog));
if (hasPushEvidence) {
pushedOrms.add(orm);
} else {
unpushedOrms.push(orm);
}
}
const driftDetected = unpushedOrms.length > 0;
if (!driftDetected) {
return {
driftDetected: false,
blocking: false,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms: [],
message: '',
};
}
// Build actionable message
const pushCommands = unpushedOrms
.map(orm => {
const info = ORM_INFO[orm];
return info ? ` ${orm}: ${info.envHint || info.pushCommand}` : null;
})
.filter(Boolean)
.join('\n');
const message = [
'Schema drift detected: schema-relevant files changed but no database push was executed.',
'',
`Schema files changed: ${detection.matches.join(', ')}`,
`ORMs requiring push: ${unpushedOrms.join(', ')}`,
'',
'Required push commands:',
pushCommands,
'',
'Run the appropriate push command, or set GSD_SKIP_SCHEMA_CHECK=true to bypass this gate.',
].join('\n');
if (skipCheck) {
return {
driftDetected: true,
blocking: false,
skipped: true,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms,
message: 'Schema drift detected but check was skipped (GSD_SKIP_SCHEMA_CHECK=true).',
};
}
return {
driftDetected: true,
blocking: true,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms,
message,
};
}
module.exports = {
SCHEMA_PATTERNS,
ORM_INFO,
detectSchemaFiles,
detectSchemaOrm,
checkSchemaDrift,
};
module.exports = require('./schema-detect.generated.cjs');

View File

@@ -0,0 +1,170 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/schema-detect.ts
* Regenerate: cd sdk && npm run gen:schema-detect
*
* Schema Drift Detection — detects schema-relevant file changes and verifies
* that the appropriate database push command was executed during a phase.
* This module does not read the filesystem directly.
*/
// ─── ORM Patterns ───────────────────────────────────────────────────────────
const SCHEMA_PATTERNS = [
{ pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' },
{ pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' },
{ pattern: /^prisma\/schema\.prisma$/, orm: 'prisma' },
{ pattern: /^prisma\/schema\/.*\.prisma$/, orm: 'prisma' },
{ pattern: /^drizzle\/schema\.ts$/, orm: 'drizzle' },
{ pattern: /^src\/db\/schema\.ts$/, orm: 'drizzle' },
{ pattern: /^drizzle\/.*\.ts$/, orm: 'drizzle' },
{ pattern: /^supabase\/migrations\/.*\.sql$/, orm: 'supabase' },
{ pattern: /^src\/entities\/.*\.ts$/, orm: 'typeorm' },
{ pattern: /^src\/migrations\/.*\.ts$/, orm: 'typeorm' },
];
// ─── Push Commands & Evidence Patterns ──────────────────────────────────────
const ORM_INFO = {
payload: {
pushCommand: 'npx payload migrate',
envHint: 'CI=true PAYLOAD_MIGRATING=true npx payload migrate',
interactiveWarning: 'Payload migrate may require interactive prompts — use CI=true PAYLOAD_MIGRATING=true to suppress',
evidencePatterns: [/payload\s+migrate/i, /PAYLOAD_MIGRATING/],
},
prisma: {
pushCommand: 'npx prisma db push',
envHint: 'npx prisma db push --accept-data-loss (if destructive changes are intended)',
interactiveWarning: 'Prisma db push may prompt for confirmation on destructive changes — use --accept-data-loss to bypass',
evidencePatterns: [/prisma\s+db\s+push/i, /prisma\s+migrate\s+deploy/i, /prisma\s+migrate\s+dev/i],
},
drizzle: {
pushCommand: 'npx drizzle-kit push',
envHint: 'npx drizzle-kit push',
interactiveWarning: null,
evidencePatterns: [/drizzle-kit\s+push/i, /drizzle-kit\s+migrate/i],
},
supabase: {
pushCommand: 'supabase db push',
envHint: 'supabase db push',
interactiveWarning: 'Supabase db push may require authentication — ensure SUPABASE_ACCESS_TOKEN is set',
evidencePatterns: [/supabase\s+db\s+push/i, /supabase\s+migration\s+up/i],
},
typeorm: {
pushCommand: 'npx typeorm migration:run',
envHint: 'npx typeorm migration:run -d src/data-source.ts',
interactiveWarning: null,
evidencePatterns: [/typeorm\s+migration:run/i, /typeorm\s+schema:sync/i],
},
};
// ─── Public API ──────────────────────────────────────────────────────────────
function detectSchemaFiles(files) {
const matches = [];
const orms = new Set();
for (const rawFile of files) {
const file = rawFile.replace(/\\/g, '/');
for (const { pattern, orm } of SCHEMA_PATTERNS) {
if (pattern.test(file)) {
matches.push(rawFile);
orms.add(orm);
break;
}
}
}
return {
detected: matches.length > 0,
matches,
orms: [...orms],
};
}
function detectSchemaOrm(ormName) {
return ORM_INFO[ormName] || null;
}
function checkSchemaDrift(changedFiles, executionLog, options = {}) {
const { skipCheck = false } = options;
const detection = detectSchemaFiles(changedFiles);
if (!detection.detected) {
return {
driftDetected: false,
blocking: false,
schemaFiles: [],
orms: [],
unpushedOrms: [],
message: '',
};
}
const pushedOrms = new Set();
const unpushedOrms = [];
for (const orm of detection.orms) {
const info = ORM_INFO[orm];
if (!info)
continue;
const hasPushEvidence = info.evidencePatterns.some(p => p.test(executionLog));
if (hasPushEvidence) {
pushedOrms.add(orm);
}
else {
unpushedOrms.push(orm);
}
}
const driftDetected = unpushedOrms.length > 0;
if (!driftDetected) {
return {
driftDetected: false,
blocking: false,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms: [],
message: '',
};
}
const pushCommands = unpushedOrms
.map(orm => {
const info = ORM_INFO[orm];
return info ? ` ${orm}: ${info.envHint || info.pushCommand}` : null;
})
.filter(Boolean)
.join('\n');
const message = [
'Schema drift detected: schema-relevant files changed but no database push was executed.',
'',
`Schema files changed: ${detection.matches.join(', ')}`,
`ORMs requiring push: ${unpushedOrms.join(', ')}`,
'',
'Required push commands:',
pushCommands,
'',
'Run the appropriate push command, or set GSD_SKIP_SCHEMA_CHECK=true to bypass this gate.',
].join('\n');
if (skipCheck) {
return {
driftDetected: true,
blocking: false,
skipped: true,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms,
message: 'Schema drift detected but check was skipped (GSD_SKIP_SCHEMA_CHECK=true).',
};
}
return {
driftDetected: true,
blocking: true,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms,
message,
};
}
module.exports = {
SCHEMA_PATTERNS,
ORM_INFO,
detectSchemaFiles,
detectSchemaOrm,
checkSchemaDrift,
};

View File

@@ -1,33 +1,20 @@
'use strict';
/**
* Secrets handling — masking convention for API keys and other
* credentials managed via /gsd-settings-integrations.
* Secrets Module — CJS adapter.
*
* Convention: strings 8+ chars long render as `****<last-4>`; shorter
* strings render as `****` with no tail (to avoid leaking a meaningful
* fraction of a short secret). null/empty renders as `(unset)`.
* The implementation is generated from sdk/src/query/secrets.ts and
* lives in secrets.generated.cjs. This file is a thin re-export so
* that existing call sites (config.cjs, init.cjs, and tests) can
* continue to require('./secrets') unchanged.
*
* Keys considered sensitive are listed in SECRET_CONFIG_KEYS and matched
* at the exact key-path level. The list is intentionally narrow — these
* are the fields documented as secrets in docs/CONFIGURATION.md.
* Exports (from generated file):
* - SECRET_CONFIG_KEYS — Set of secret key paths
* - isSecretKey(keyPath) — returns true if keyPath is a secret
* - maskSecret(value) — masks a secret value
* - maskIfSecret(keyPath, value) — masks value only if keyPath is secret
*
* Regenerate: cd sdk && npm run gen:secrets
*/
const SECRET_CONFIG_KEYS = new Set([
'brave_search',
'firecrawl',
'exa_search',
]);
function isSecretKey(keyPath) {
return SECRET_CONFIG_KEYS.has(keyPath);
}
function maskSecret(value) {
if (value === null || value === undefined || value === '') return '(unset)';
const s = String(value);
if (s.length < 8) return '****';
return '****' + s.slice(-4);
}
module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret };
module.exports = require('./secrets.generated.cjs');

View File

@@ -0,0 +1,37 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/secrets.ts
* Regenerate: cd sdk && npm run gen:secrets
*
* Secrets handling — masking convention for API keys and other
* credentials managed via /gsd-settings-integrations.
* This module does not read the filesystem.
*/
const SECRET_CONFIG_KEYS = new Set([
'brave_search',
'firecrawl',
'exa_search',
]);
function isSecretKey(keyPath) {
return SECRET_CONFIG_KEYS.has(keyPath);
}
function maskSecret(value) {
if (value === null || value === undefined || value === '')
return '(unset)';
const s = String(value);
if (s.length < 8)
return '****';
return '****' + s.slice(-4);
}
function maskIfSecret(keyPath, value) {
return isSecretKey(keyPath) ? maskSecret(value) : value;
}
module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret };

View File

@@ -3,29 +3,25 @@
const { STATE_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
const {
tryLoadSdk,
getExecuteForCjs,
getFormatStateLoadRawStdout,
} = require('./cjs-sdk-bridge.cjs');
// ─── SDK bridge (Phase 5.1) ─────────────────────────────────────────────────
// executeForCjs is loaded lazily from the SDK public package export so this
// router does not rely on private dist subpaths that are not exported.
let _executeForCjs = null;
let _formatStateLoadRawStdout = null;
// Subcommands whose CJS contract is exit-non-zero (stderr) ONLY when the
// underlying STATE.md is missing — not for in-state errors like
// "field not found". CJS `cmdStateGet` calls `error('STATE.md not found')` →
// exit 1 for the missing-file case but `output({ error: 'Section or field
// "X" not found' }, raw)` → exit 0 for the missing-field case. Mutation
// commands always use output() (exit 0) even when STATE.md is missing, so
// they are absent from this set entirely.
const EXIT_ON_STATE_MD_MISSING = new Set(['state.get']);
const STATE_MD_MISSING_MESSAGE = 'STATE.md not found';
function tryLoadSdk() {
if (_executeForCjs !== null) return true;
try {
const sdkModule = require('@gsd-build/sdk');
_executeForCjs = sdkModule.executeForCjs;
_formatStateLoadRawStdout = sdkModule.formatStateLoadRawStdout;
if (typeof _executeForCjs !== 'function' || typeof _formatStateLoadRawStdout !== 'function') {
_executeForCjs = null;
_formatStateLoadRawStdout = null;
return false;
}
return true;
} catch {
return false;
}
}
// The bridge loader verifies both `executeForCjs` and `formatStateLoadRawStdout`
// are present before returning success, so this router can call `tryLoadSdk()`
// directly without an additional capability check.
/**
* Dispatch a subcommand via the SDK sync bridge.
@@ -44,16 +40,25 @@ function tryLoadSdk() {
function dispatchViaSdk(registryCommand, registryArgs, legacyArgs, cwd, raw, error, rawFormatter) {
if (!tryLoadSdk()) return false;
const result = _executeForCjs({
// When a CJS-side rawFormatter is supplied (e.g. state.load --raw → key=value
// lines), always request 'json' from the bridge so the SDK returns the typed
// data object. Passing mode: 'raw' would make the bridge pre-render to a
// string and the formatter would no-op. For subcommands without a rawFormatter,
// honor the user's --raw flag and let the bridge do default rendering.
const bridgeMode = rawFormatter ? 'json' : (raw ? 'raw' : 'json');
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'state',
legacyArgs,
mode: raw ? 'raw' : 'json',
mode: bridgeMode,
projectDir: cwd,
// workstream: not threaded here — GSDTransport forces subprocess for workstream
// requests and subprocess is disabled in the worker. Workstream commands fall
// back to the CJS path (see routeStateCommand guard below).
// Phase 6 fix: workstream is now threaded through to the native handler.
// GSDTransport no longer forces subprocess for workstream-scoped requests —
// the worker's dispatchNative closure correctly passes workstream to
// registry.dispatch() (Phase 5.1 fix), enabling native workstream dispatch.
workstream: process.env.GSD_WORKSTREAM || undefined,
});
if (!result.ok) {
@@ -63,10 +68,31 @@ function dispatchViaSdk(registryCommand, registryArgs, legacyArgs, cwd, raw, err
return true; // handled (error was reported)
}
// Surface STATE.md-missing as a CJS-style fatal error (exit non-zero,
// stderr) for the specific subcommands whose CJS contract uses error() not
// output() for that case. The exact "STATE.md not found" message is the
// canonical signal both CJS and SDK use — other "error" shapes (e.g.
// "Section or field X not found" from state.get with present STATE.md)
// stay as exit-0 JSON output so shell-script consumers JSON.parse the
// output and branch on the error field without process-exit handling.
if (
EXIT_ON_STATE_MD_MISSING.has(registryCommand)
&& result.data
&& typeof result.data === 'object'
&& result.data.error === STATE_MD_MISSING_MESSAGE
) {
error(result.data.error);
return true;
}
if (raw && rawFormatter) {
const rawText = rawFormatter(result.data);
const fs = require('fs');
fs.writeSync(1, rawText);
} else if (raw) {
// #3631: bridge was called with mode:'raw', so result.data is the scalar
// string the CJS path would have printed. Bypass output()'s JSON path.
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
@@ -94,12 +120,10 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) {
return parsedPlans;
};
// Workstream guard: if GSD_WORKSTREAM is set, the sync bridge worker cannot
// handle the request (GSDTransport.subprocessReason returns 'workstream_forced'
// and subprocess is disabled in the worker). Fall back to CJS path for all
// workstream-scoped state commands.
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
// Phase 6 fix: workstream commands are now handled natively in the sync bridge
// worker. GSDTransport no longer forces subprocess for workstream-scoped requests;
// the worker threads workstream through to registry.dispatch() correctly.
const sdkAvailable = tryLoadSdk();
// Helper: build SDK-backed handler that falls through to CJS on SDK failure.
// cjsFallback is called when SDK is unavailable or when the subcommand has no
@@ -128,7 +152,11 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) {
'state.load',
[],
args.slice(1),
_formatStateLoadRawStdout,
// Resolved lazily — the formatter getter returns null until
// tryLoadSdk() runs inside dispatchViaSdk. sdkHandler only invokes
// this formatter when SDK dispatch succeeds, so by then the bridge
// has cached the formatter and the getter returns the real function.
(...formatterArgs) => getFormatStateLoadRawStdout()(...formatterArgs),
() => state.cmdStateLoad(cwd, raw),
),
json: sdkHandler(

View File

@@ -2,54 +2,126 @@
const { VALIDATE_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output, error }) {
const subcommand = args[1];
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
if (subcommand === 'consistency') {
verify.cmdValidateConsistency(cwd, raw);
} else if (subcommand === 'health') {
const repairFlag = args.includes('--repair');
const backfillFlag = args.includes('--backfill');
verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw);
} else if (subcommand === 'agents') {
verify.cmdValidateAgents(cwd, raw);
} else if (subcommand === 'context') {
const opts = parseNamedArgs(args, ['tokens-used', 'context-window']);
if (opts['tokens-used'] === null) {
error('--tokens-used <integer> is required for `validate context`');
return;
}
if (opts['context-window'] === null) {
error('--context-window <integer> is required for `validate context`');
return;
}
const { classifyContextUtilization, STATES } = require('./context-utilization.cjs');
const threadCmd = formatGsdSlash('thread', resolveRuntime(cwd));
const RECOMMENDATIONS = {
[STATES.HEALTHY]: null,
[STATES.WARNING]: `Context is approaching the fracture zone — consider ${threadCmd} to continue in a fresh window.`,
[STATES.CRITICAL]: `Reasoning quality may degrade past 70% utilization (fracture point). Run ${threadCmd} now to preserve output quality.`,
/**
* Manifest-backed validate subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: validate.consistency, validate.health, validate.agents are
* dispatched via executeForCjs when the SDK is available. CJS fallback
* retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* CJS-only subcommands:
* - context: complex inline logic using classifyContextUtilization and
* output formatting that has no direct SDK counterpart. Remains CJS-native.
*
* SDK-only (unsupported in CJS router): none.
*/
function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output: outputFn, error }) {
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'validate',
legacyArgs,
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `validate ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
let classified;
try {
classified = classifyContextUtilization(Number(opts['tokens-used']), Number(opts['context-window']));
} catch (e) {
const flag = /tokensUsed/.test(e.message) ? '--tokens-used' : '--context-window';
error(`${flag} must be a non-negative integer (window > 0), got the values supplied`);
return;
}
const result = { ...classified, recommendation: RECOMMENDATIONS[classified.state] };
if (args.includes('--json')) {
output(result, raw);
} else {
const lines = [`Context utilization: ${result.percent}% (${result.state})`];
if (result.recommendation) lines.push(result.recommendation);
output(result, true, lines.join('\n'));
}
} else {
error(`Unknown validate subcommand. Available: ${VALIDATE_SUBCOMMANDS.join(', ')}`);
}
routeCjsCommandFamily({
args,
subcommands: VALIDATE_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand, available) => `Unknown validate subcommand. Available: ${available.join(', ')}`,
handlers: {
consistency: sdkHandler(
'validate.consistency',
args.slice(2),
args.slice(1),
() => verify.cmdValidateConsistency(cwd, raw),
),
// Keep health on CJS for now so fix hints are rendered via runtime-slash
// helpers (codex expects $gsd-* command shape).
health: () => {
const repairFlag = args.includes('--repair');
const backfillFlag = args.includes('--backfill');
verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw);
},
agents: sdkHandler(
'validate.agents',
args.slice(2),
args.slice(1),
() => verify.cmdValidateAgents(cwd, raw),
),
// context: CJS-only — complex inline logic using classifyContextUtilization
// with custom output formatting that has no direct SDK counterpart.
context: () => {
const opts = parseNamedArgs(args, ['tokens-used', 'context-window']);
if (opts['tokens-used'] === null) {
error('--tokens-used <integer> is required for `validate context`');
return;
}
if (opts['context-window'] === null) {
error('--context-window <integer> is required for `validate context`');
return;
}
const { classifyContextUtilization, STATES } = require('./context-utilization.cjs');
const threadCmd = formatGsdSlash('thread', resolveRuntime(cwd));
const RECOMMENDATIONS = {
[STATES.HEALTHY]: null,
[STATES.WARNING]: `Context is approaching the fracture zone — consider ${threadCmd} to continue in a fresh window.`,
[STATES.CRITICAL]: `Reasoning quality may degrade past 70% utilization (fracture point). Run ${threadCmd} now to preserve output quality.`,
};
let classified;
try {
classified = classifyContextUtilization(Number(opts['tokens-used']), Number(opts['context-window']));
} catch (e) {
const flag = /tokensUsed/.test(e.message) ? '--tokens-used' : '--context-window';
error(`${flag} must be a non-negative integer (window > 0), got the values supplied`);
return;
}
const result = { ...classified, recommendation: RECOMMENDATIONS[classified.state] };
if (args.includes('--json')) {
outputFn(result, raw);
} else {
const lines = [`Context utilization: ${result.percent}% (${result.state})`];
if (result.recommendation) lines.push(result.recommendation);
outputFn(result, true, lines.join('\n'));
}
},
},
});
}
module.exports = {

View File

@@ -1,32 +1,120 @@
'use strict';
const { VERIFY_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
/**
* Manifest-backed verify subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: all verify.* subcommands have SDK equivalents and are dispatched
* via executeForCjs (the sync bridge). CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* CJS-only subcommands: none.
* SDK-only (unsupported in CJS router): none.
*/
function routeVerifyCommand({ verify, args, cwd, raw, error }) {
const subcommand = args[1];
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
if (subcommand === 'plan-structure') {
verify.cmdVerifyPlanStructure(cwd, args[2], raw);
} else if (subcommand === 'phase-completeness') {
verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw);
} else if (subcommand === 'references') {
verify.cmdVerifyReferences(cwd, args[2], raw);
} else if (subcommand === 'commits') {
verify.cmdVerifyCommits(cwd, args.slice(2), raw);
} else if (subcommand === 'artifacts') {
verify.cmdVerifyArtifacts(cwd, args[2], raw);
} else if (subcommand === 'key-links') {
verify.cmdVerifyKeyLinks(cwd, args[2], raw);
} else if (subcommand === 'schema-drift') {
const rest = args.slice(2);
const skipFlag = rest.includes('--skip');
const phaseArg = rest.find((arg) => !arg.startsWith('-'));
verify.cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw);
} else if (subcommand === 'codebase-drift') {
verify.cmdVerifyCodebaseDrift(cwd, raw);
} else {
error(`Unknown verify subcommand. Available: ${VERIFY_SUBCOMMANDS.join(', ')}`);
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'verify',
legacyArgs,
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `verify ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
}
routeCjsCommandFamily({
args,
subcommands: VERIFY_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand, available) => `Unknown verify subcommand. Available: ${available.join(', ')}`,
handlers: {
'plan-structure': sdkHandler(
'verify.plan-structure',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyPlanStructure(cwd, args[2], raw),
),
'phase-completeness': sdkHandler(
'verify.phase-completeness',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw),
),
references: sdkHandler(
'verify.references',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyReferences(cwd, args[2], raw),
),
commits: sdkHandler(
'verify.commits',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyCommits(cwd, args.slice(2), raw),
),
artifacts: sdkHandler(
'verify.artifacts',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyArtifacts(cwd, args[2], raw),
),
'key-links': sdkHandler(
'verify.key-links',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyKeyLinks(cwd, args[2], raw),
),
'schema-drift': sdkHandler(
'verify.schema-drift',
args.slice(2),
args.slice(1),
() => {
const rest = args.slice(2);
const skipFlag = rest.includes('--skip');
const phaseArg = rest.find((arg) => !arg.startsWith('-'));
verify.cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw);
},
),
// verify codebase-drift dispatches direct to CJS — drift is out-of-seam
// per ADR/PRD 3524 §3 / L160 (CJS-only by design). Routing through
// sdkHandler would re-enter the SDK bridge, and Phase 6's removed
// verifyCodebaseDrift stub used to execFileSync back to the CLI,
// creating an infinite spawn loop.
'codebase-drift': () => verify.cmdVerifyCodebaseDrift(cwd, raw),
},
});
}
module.exports = {

View File

@@ -1,33 +1,19 @@
/**
* Workstream Name Policy Module
* Workstream Name Policy Module — CJS adapter.
*
* Owns canonical name validation and slug normalization used by workstream and
* active-pointer callers.
* The implementation is generated from sdk/src/workstream-name-policy.ts and
* lives in workstream-name-policy.generated.cjs. This file is a thin re-export
* so that existing call sites (active-workstream-store.cjs,
* planning-workspace.cjs, workstream.cjs, and tests) can continue to
* require('./workstream-name-policy') unchanged.
*
* Exports (from generated file):
* - toWorkstreamSlug(name) — normalize to URL/filesystem slug
* - hasInvalidPathSegment(name) — true if name has slashes or dot-dot
* - isValidActiveWorkstreamName(name) — true if name passes all policy rules
* - validateWorkstreamName(name) — SDK alias for isValidActiveWorkstreamName
*
* Regenerate: cd sdk && npm run gen:workstream-name-policy
*/
const ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
function toWorkstreamSlug(name) {
return String(name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function hasInvalidPathSegment(name) {
const value = String(name || '');
return /[/\\]/.test(value) || value === '.' || value === '..' || value.includes('..');
}
function isValidActiveWorkstreamName(name) {
const value = String(name || '');
if (value === '..' || value.startsWith('../') || value.includes('..')) return false;
return ACTIVE_WORKSTREAM_RE.test(value);
}
module.exports = {
toWorkstreamSlug,
hasInvalidPathSegment,
isValidActiveWorkstreamName,
};
module.exports = require('./workstream-name-policy.generated.cjs');

View File

@@ -0,0 +1,61 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/workstream-name-policy.ts
* Regenerate: cd sdk && npm run gen:workstream-name-policy
*
* Canonical workstream name validation and slug normalization.
* Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs.
*/
const ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
/**
* Validate a workstream name.
* Allowed: alphanumeric, hyphens, underscores, dots.
* Disallowed: empty, spaces, slashes, special chars, path traversal.
*
* Alias for isValidActiveWorkstreamName; provided for SDK-layer callers.
*/
function validateWorkstreamName(name) {
return isValidActiveWorkstreamName(name);
}
/**
* Convert a display name to a URL/filesystem-safe workstream slug.
* Lowercases, collapses non-alphanumeric runs to hyphens, strips leading/trailing hyphens.
*/
function toWorkstreamSlug(name) {
return String(name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Returns true when `name` contains a path separator, a bare dot, or a
* dot-dot sequence — any of which would make the name unsafe for use as a
* filesystem path segment.
*/
function hasInvalidPathSegment(name) {
const value = String(name || '');
return /[/\\]/.test(value) || value === '.' || value === '..' || value.includes('..');
}
/**
* Returns true when `name` is a valid active workstream name:
* - Must start with alphanumeric
* - May contain alphanumeric, dots, underscores, hyphens
* - Must not contain path traversal sequences (..)
*/
function isValidActiveWorkstreamName(name) {
const value = String(name || '');
if (value === '..' || value.startsWith('../') || value.includes('..'))
return false;
return ACTIVE_WORKSTREAM_RE.test(value);
}
module.exports = {
validateWorkstreamName,
toWorkstreamSlug,
hasInvalidPathSegment,
isValidActiveWorkstreamName,
};

View File

@@ -6,7 +6,7 @@ error here MUST fall through and continue to `verify_phase_goal`. The phase
is never failed by this gate.
```bash
DRIFT=$(gsd-sdk query verify.codebase-drift 2>/dev/null || echo '{"skipped":true,"reason":"sdk-failed"}')
DRIFT=$(gsd-tools verify codebase-drift 2>/dev/null || echo '{"skipped":true,"reason":"sdk-failed"}')
```
Parse JSON for: `skipped`, `reason`, `action_required`, `directive`,

View File

@@ -45,7 +45,7 @@ When `TDD_MODE` is `true`, the planner agent is instructed to apply `type: tdd`
When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior phase CONTEXT.md and SUMMARY.md files PLUS any phases explicitly listed in the current phase's `Depends on:` field in ROADMAP.md. Explicit dependencies always load regardless of recency (e.g., Phase 7 declaring `Depends on: Phase 2` always sees Phase 2's context). Bounded recency keeps the planner's context budget focused on recent work.
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
**If `response_language` is set:** Include `response_language: {value}` in all spawned subagent prompts so any user-facing output stays in the configured language.
@@ -53,9 +53,52 @@ Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_
**If `planning_exists` is false:** Error — run `/gsd:new-project` first.
## 1.5. Closed-Phase Gate (#3569)
The init JSON includes `phase_status` — one of `Pending | Planned | In Progress | Executed | Complete | Needs Review`. `Complete` means the phase has all summaries AND a `VERIFICATION.md` with `status: passed`. Replanning a closed phase silently rewrites plan docs that no longer match the shipped code, so the workflow must hard-stop here unless the operator explicitly overrides.
Parse `phase_status` from the init JSON, then:
```bash
FORCE_REPLAN=false
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--force([[:space:]]|$) ]]; then
FORCE_REPLAN=true
fi
if [ "${phase_status}" = "Complete" ]; then
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--reviews([[:space:]]|$) ]]; then
# --reviews on a closed phase is never legitimate — concerns belong in a
# new phase or issue against the closed phase's commits.
cat <<EOF >&2
Phase ${phase_number} (${phase_name}) is already CLOSED (VERIFICATION status: passed).
/gsd:plan-phase --reviews cannot replan a closed phase. If the review surfaced
real concerns, open a follow-up phase or file an issue against the closed
phase's commits. There is no --force override for --reviews on a closed phase.
EOF
exit 1
fi
if [ "$FORCE_REPLAN" != "true" ]; then
cat <<EOF >&2
Phase ${phase_number} (${phase_name}) is already CLOSED (VERIFICATION status: passed).
Replanning a closed phase will overwrite plan docs that no longer match the
shipped code. If you intentionally want to replan over closed work, re-run
with: /gsd:plan-phase ${phase_number} --force
Otherwise, to view what shipped, see: ${verification_path}
EOF
exit 1
fi
# FORCE_REPLAN=true: continue, but emit a banner so the operator sees the
# decision in the transcript and in any committed plan docs.
echo "WARNING: Replanning CLOSED phase ${phase_number} under --force. Verify the closeout was wrong before committing new plan docs." >&2
fi
```
The gate fires only on `Complete`. `Executed` and `Needs Review` are not gated — those states mean planning was finished but verification did not pass, and replanning is a legitimate next step.
## 2. Parse and Normalize Arguments
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`).
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--force` (override closed-phase gate, see §1.5)).
**`--research-phase <N>` — research-only mode (#3042 + #3044).** When this flag is present, parse `<N>` as the phase number (overrides any positional phase argument), set `RESEARCH_ONLY=true`, and treat the rest of this workflow as a research-dispatch only — the planner spawn (step 8), plan-checker, verification, gaps, bounce, and post-planning-gaps blocks all skip on `RESEARCH_ONLY`. Use this for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops. Replaces the deleted `/gsd-research-phase` command.

View File

@@ -17,7 +17,7 @@ Sync managed `gsd-*` skill directories from one canonical runtime's skills root
If neither `--dry-run` nor `--apply` is specified, dry-run is the default.
**Supported runtime names:** `claude`, `codex`, `copilot`, `cursor`, `windsurf`, `opencode`, `gemini`, `kilo`, `augment`, `trae`, `qwen`, `codebuddy`, `cline`, `antigravity`
**Supported runtime names:** `claude`, `codex`, `grok`, `copilot`, `cursor`, `windsurf`, `opencode`, `gemini`, `kilo`, `augment`, `trae`, `qwen`, `codebuddy`, `cline`, `antigravity` (grok uses the `~/.agents` layout)
---
@@ -35,7 +35,7 @@ fi
# Parse --to
if [[ "$@" == *"--to all"* ]]; then
TO_RUNTIMES=(claude codex copilot cursor windsurf opencode gemini kilo augment trae qwen codebuddy cline antigravity)
TO_RUNTIMES=(claude codex grok copilot cursor windsurf opencode gemini kilo augment trae qwen codebuddy cline antigravity)
elif [[ "$@" == *"--to"* ]]; then
TO_RUNTIMES=( $(echo "$@" | grep -oP '(?<=--to )\S+') )
fi

116
package-lock.json generated
View File

@@ -28,35 +28,35 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.2.119",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.119.tgz",
"integrity": "sha512-6AvthpsaOTlkn514brSGOcCSLHDXODnU+ExN1O3CJCjxr5RBcmzR057C9EIM0G7IchnXsRfMZgRO1QKsjTXdbA==",
"version": "0.2.141",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.141.tgz",
"integrity": "sha512-AIBacMWGcZIUcXlUoObqjwJ6pmJI3BayAqPAFXuvSq3DHJXdiuZVs7l/zTB5l3nRhRv5cqSrI2XbiDeHgZWizw==",
"license": "SEE LICENSE IN README.md",
"dependencies": {
"@anthropic-ai/sdk": "^0.81.0",
"@anthropic-ai/sdk": "^0.93.0",
"@modelcontextprotocol/sdk": "^1.29.0"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.119",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.119",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.119",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.119",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.119",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.119",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.119",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.119"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.141",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.141",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.141",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.141",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.141",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.141",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.141",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.141"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.2.119",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.119.tgz",
"integrity": "sha512-kxnG37SZqUata2Jcp/YQ0n9Y7o/sinE/8LdG4ltM1gePh+z+0Mfa4vBUUTEBMBFth9PTovKoesIuVuyFpvO/Cw==",
"version": "0.2.141",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.141.tgz",
"integrity": "sha512-9HZ0ot6+FwOfQ1aeMqQLH4IJGMm/DcP08SysDxscVjBm6l2JjqleHohxi3zid0DurfGweqT+4x9GScJffwg55g==",
"cpu": [
"arm64"
],
@@ -67,9 +67,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.2.119",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.119.tgz",
"integrity": "sha512-9Aj8g3ELsmZuOFg17TCkikeg/Wt2ucVT8hOOPQUatzLd7BKhydrHLA0RP42nBpWECO1B/n/mPdQ4iS/LS3s2Fg==",
"version": "0.2.141",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.141.tgz",
"integrity": "sha512-4iAdarJaQ+2R58s6QJswZCzUdz2WQmL5lYG7Y+FLzWbRSROFfcH0QYpmOqSaPXd2KRQhIJwEacqecDZd/Q1XKQ==",
"cpu": [
"x64"
],
@@ -80,12 +80,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.2.119",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.119.tgz",
"integrity": "sha512-v3o464XkiYehp/OKidQQirxdVb+aGSvdJvHF2zH9p33W8M/NC21zwwh4dhwDnKsyrtBIgkt2CcMwzIl30r0OtA==",
"version": "0.2.141",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.141.tgz",
"integrity": "sha512-Jdf0ZEwJzOP8sE6rPqdJN+SxMb0/L8sxJg4twCv/7S+Qzk0hJtls+wxSi+0Tjh6EEMaNxJqEGc7S3fx99Wi99Q==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -93,12 +96,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.2.119",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.119.tgz",
"integrity": "sha512-IPGWgtz+gGnD7fxKAvSf913EUT/lYBTBE8EZ7lh3+x5ZP2859LWLmrCm053Lf3nMWo/CWikZsVPwkDVwpz6tIQ==",
"version": "0.2.141",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.141.tgz",
"integrity": "sha512-6H1AJ/AVaWNnV22kubUPkOTRzZFH0+qP9k7WlhriHMN9gtgZcVAsITMddDeGjQsQJMCAdhXFd6sgi7TM1LdeOQ==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -106,12 +112,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.2.119",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.119.tgz",
"integrity": "sha512-9ePt4ZN+hsqDw4AgS4KtcWIGKfL9Oq28kwkrTER/QAcSrVKxiLonp81cCLzg7Ok/IUJu4Cfd71GZbFv/WE54zw==",
"version": "0.2.141",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.141.tgz",
"integrity": "sha512-DVjp72f3HmrRYpbneWZZWIqkUht5kTZXS7wXGFiwzLz6eNYEgjjh+GcsnhIi8UOwZUtNiKUrjZnoP38ovFqV8A==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -119,12 +128,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.2.119",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.119.tgz",
"integrity": "sha512-QYxFNAe4FFridPkKhGlNcNBJ0TaIygWYyvfI9g4kX0i+RVbresUWuZVkWY06ioJ0fXoixFJ+HNQBMB7dLrIp8Q==",
"version": "0.2.141",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.141.tgz",
"integrity": "sha512-fTI1YuM4cxOa4nSgsyMAdB5ELizkWp+w5Ispo4JnnYtcczMAL4D9GBNjWPW0sUzKvjsJOUVim68SmWLWhUOpXQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -132,9 +144,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.2.119",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.119.tgz",
"integrity": "sha512-p/TjcKQvkCYtXGPlR+mdyNwqCmvRcQL34Wtq0yUZ+iqmI/eyCe59IJ3AZrE0EZoqmiAevEYzatPIt9sncC9uxw==",
"version": "0.2.141",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.141.tgz",
"integrity": "sha512-Wm10J6kfbufbPGFELokiJ/7Y5Oqug4Uag3HXFsV8g7TWCpaItx/oqVaJoiGptuAtXQB7xGLQVTuk082wER+Y5w==",
"cpu": [
"arm64"
],
@@ -145,9 +157,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.2.119",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.119.tgz",
"integrity": "sha512-k98Ju0wtktm6FhqTE/cXlVr6K4kGqBolVjEGzeKkW6ZILc7124euwNapAvkQCwMAavAxS/ZnO3jdKMtHtwTVTA==",
"version": "0.2.141",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.141.tgz",
"integrity": "sha512-IXuP29YJuWbR5Q6xOHrjFVGG54V2s1FC61UVNwEN5fpxL09MwPnbwtQL6fqgzt/U1MP7vWAwpXZriYAklkH/mg==",
"cpu": [
"x64"
],
@@ -158,9 +170,9 @@
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz",
"integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==",
"version": "0.93.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.93.0.tgz",
"integrity": "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
@@ -893,12 +905,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
@@ -946,9 +958,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -1155,9 +1167,9 @@
}
},
"node_modules/hono": {
"version": "4.12.15",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz",
"integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==",
"version": "4.12.18",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -1213,9 +1225,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"

View File

@@ -65,6 +65,11 @@
"check:configuration-fresh": "cd sdk && npm run check:configuration-fresh",
"check:workstream-inventory-builder-fresh": "cd sdk && npm run check:workstream-inventory-builder-fresh",
"check:project-root-fresh": "cd sdk && npm run check:project-root-fresh",
"check:plan-scan-fresh": "cd sdk && npm run check:plan-scan-fresh",
"check:secrets-fresh": "cd sdk && npm run check:secrets-fresh",
"check:schema-detect-fresh": "cd sdk && npm run check:schema-detect-fresh",
"check:decisions-fresh": "cd sdk && npm run check:decisions-fresh",
"check:workstream-name-policy-fresh": "cd sdk && npm run check:workstream-name-policy-fresh",
"prepublishOnly": "npm run build:hooks && npm run build:sdk",
"pretest": "npm run build:sdk && npm run lint:skill-deps",
"pretest:coverage": "npm run build:sdk",
@@ -73,6 +78,7 @@
"lint:tests": "node scripts/lint-no-source-grep.cjs",
"lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs",
"lint:changeset": "node scripts/changeset/lint.cjs",
"lint:docs": "node scripts/lint-docs-required.cjs",
"changeset": "node scripts/changeset/new.cjs",
"changelog:render": "node scripts/changeset/cli.cjs render",
"test": "node scripts/run-tests.cjs",

View File

@@ -36,9 +36,18 @@ const HOOKS_TO_COPY = [
// Community hooks (bash, opt-in via .planning/config.json hooks.community)
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh'
'gsd-phase-boundary.sh',
// Graphify auto-update hook (#3347 / PR #3557 / #3579). Opt-in via
// .planning/config.json graphify.auto_update; off by default.
'gsd-graphify-update.sh'
];
// Subdirectories under hooks/ whose contents must also ship to dist. Each
// entry is copied as `hooks/<dir>/*` → `hooks/dist/<dir>/*` so detached
// helpers (e.g. hooks/lib/gsd-graphify-rebuild.sh) resolve from the hook's
// installed runtime path. See #3579.
const HOOKS_SUBDIRS_TO_COPY = ['lib'];
// Sync millisecond sleep using Atomics.wait on a throwaway SharedArrayBuffer.
// Used between Windows rename retries; this script is sync end-to-end so
// setTimeout would not work. Total worst-case backoff across MAX_ATTEMPTS
@@ -169,6 +178,37 @@ function build() {
renameAtomicWithRetry(stagedDest, dest, hook);
}
// Copy whitelisted hook subdirectories (e.g. hooks/lib/) into dist so the
// installer's readdir-and-isFile loop in bin/install.js sees them and
// detached hook helpers resolve from the installed runtime path (#3579).
for (const subdir of HOOKS_SUBDIRS_TO_COPY) {
const srcDir = path.join(HOOKS_DIR, subdir);
if (!fs.existsSync(srcDir)) continue;
const destDir = path.join(DIST_DIR, subdir);
fs.mkdirSync(destDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const ent of entries) {
if (!ent.isFile()) continue;
const srcFile = path.join(srcDir, ent.name);
const destFile = path.join(destDir, ent.name);
if (ent.name.endsWith('.js')) {
const syntaxError = validateSyntax(srcFile);
if (syntaxError) {
console.error(`\x1b[31m✗ ${subdir}/${ent.name}: SyntaxError — ${syntaxError}\x1b[0m`);
hasErrors = true;
continue;
}
}
console.log(`\x1b[32m✓\x1b[0m Copying ${subdir}/${ent.name}...`);
const stagedDest = path.join(STAGE_DIR, `${subdir}__${ent.name}.${Date.now()}`);
fs.copyFileSync(srcFile, stagedDest);
if (ent.name.endsWith('.sh')) {
try { fs.chmodSync(stagedDest, 0o755); } catch (e) { /* Windows */ }
}
renameAtomicWithRetry(stagedDest, destFile, `${subdir}/${ent.name}`);
}
}
// Best-effort cleanup of this process's own staging dir. Since STAGE_DIR
// is per-PID (`.dist-staging-<pid>/`), no other builder touches it — so
// rmSync with recursive:true is safe and leaves no race window.

View File

@@ -9,9 +9,15 @@
* ---
* <markdown body>
*
* Returns { ok: true, fragment: { type, pr, body } } on success,
* Returns { ok: true, fragment: { type, pr, body, docsExempt } } on success,
* { ok: false, reason: FRAGMENT_ERROR.X, detail } on failure.
*
* `docsExempt` is `null` when the body contains no docs-exempt marker, or the
* trimmed reason string when the body contains `<!-- docs-exempt: <reason> -->`
* (#3213). The marker is stripped from `body` at parse time so it never bleeds
* into the CHANGELOG.md or GitHub release-notes serializers, which append the
* `(#NNNN)` PR suffix verbatim to the body's last line.
*
* The reason field is a frozen enum so tests assert on stable codes,
* not free-text error messages (CONTRIBUTING.md: "Prohibited: Raw
* Text Matching on Test Outputs").
@@ -27,6 +33,46 @@ const FRAGMENT_ERROR = Object.freeze({
const ALLOWED_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']);
// HTML comment marking a fragment as exempt from the docs-required lint (#3213).
// Form: `<!-- docs-exempt: <reason> -->`. The reason is the *required* human
// audit trail — without it the exemption has no paper-trail value, so a bare
// `<!-- docs-exempt -->` or empty `<!-- docs-exempt: -->` is intentionally
// rejected (the colon and a non-whitespace first reason char are mandatory).
//
// Anchored with `^...$` + `m` flag so the marker only counts when it occupies
// its own line. Inline mentions inside paragraphs (e.g. backtick-wrapped
// syntax examples in documentation) are not matched — they cannot
// accidentally exempt a fragment.
//
// The trailing `\r?` consumes the CR character of a CRLF line terminator,
// which the `$` boundary (multiline mode) does not — so Windows-authored
// fragments produce the same `body` shape as LF-authored ones. The reason
// character class `[^\r\n>]` excludes `\r` for the same reason: a CRLF
// fragment's reason text never carries a trailing `\r`.
//
// Bounded character class `[^\r\n>]` keeps the regex linear-time — no
// catastrophic backtracking on adversarial input. The leading `\S` anchor
// inside the capture group forces at least one non-whitespace character in
// the reason; trailing whitespace before `-->` is consumed by the outer
// `[ \t]*-->` and is not part of the captured reason.
const DOCS_EXEMPT_RE = /^[ \t]*<!--[ \t]*docs-exempt[ \t]*:[ \t]*(\S[^\r\n>]*?)[ \t]*-->[ \t]*\r?$/im;
function extractDocsExempt(body) {
const m = body.match(DOCS_EXEMPT_RE);
if (!m) return { docsExempt: null, body };
const reason = (m[1] || '').trim();
// Strip the marker line and tidy up the surrounding whitespace. The cleanup
// is CRLF-aware so Windows-authored fragments don't leave residual `\r`
// characters that would shift the `(#NNNN)` PR suffix to a blank line in
// the rendered CHANGELOG.md / GitHub release-notes bullet.
const cleaned = body
.replace(DOCS_EXEMPT_RE, '')
.replace(/[ \t\r]+$/gm, '') // strip trailing \r/spaces on each line
.replace(/(?:\r?\n){3,}/g, '\n\n') // collapse 3+ blank lines (CRLF-aware)
.replace(/[\r\n]+$/, ''); // strip every trailing line terminator
return { docsExempt: reason, body: cleaned };
}
function parseFragment(src) {
const fmMatch = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!fmMatch) return { ok: false, reason: FRAGMENT_ERROR.MISSING_FRONTMATTER };
@@ -49,12 +95,20 @@ function parseFragment(src) {
}
// Use trim() only for the emptiness check; preserve the body verbatim
// (including significant leading/trailing whitespace, code blocks, etc.)
// so render → serialize round-trips exactly. Strip only a single trailing
// newline added by editors so byte-equality holds for typical fragments.
// so render → serialize round-trips exactly. Strip the single trailing
// line terminator added by editors so byte-equality holds for typical
// fragments. CRLF-aware: a Windows-authored fragment trims `\r\n` so the
// marker line in extractDocsExempt does not leave residual `\r` characters
// for downstream serializers to attach `(#NNNN)` to (#3213).
if (!body.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY };
const verbatimBody = body.endsWith('\n') ? body.slice(0, -1) : body;
let verbatimBody;
if (body.endsWith('\r\n')) verbatimBody = body.slice(0, -2);
else if (body.endsWith('\n')) verbatimBody = body.slice(0, -1);
else verbatimBody = body;
const { docsExempt, body: visibleBody } = extractDocsExempt(verbatimBody);
if (!visibleBody.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY };
return { ok: true, fragment: { type: fields.type, pr, body: verbatimBody } };
return { ok: true, fragment: { type: fields.type, pr, body: visibleBody, docsExempt } };
}
module.exports = { parseFragment, FRAGMENT_ERROR, ALLOWED_TYPES };
module.exports = { parseFragment, extractDocsExempt, FRAGMENT_ERROR, ALLOWED_TYPES, DOCS_EXEMPT_RE };

View File

@@ -1,10 +1,18 @@
'use strict';
/**
* One-shot script: replace retired /gsd-<cmd> with /gsd:<cmd> for known command names.
* Only replaces when followed by a word boundary (space, newline, quote, backtick, ), end).
* One-shot script + library: bidirectional GSD slash-command namespace normalizer.
*
* The transform is exported as a pure function so it can be unit-tested directly
* (see tests/bug-2543-gsd-slash-namespace.test.cjs) without needing fixture files.
* - Default direction (transformContent): retired /gsd-<cmd> → /gsd:<cmd>
* (keeps monorepo sources, docs, and workflows in the active colon form).
* - Reverse direction (transformContentToHyphen): /gsd:<cmd> / gsd:<cmd> → gsd-<cmd>
* (used during skill installation for runtimes that register skills under the
* canonical hyphen form established in #2808).
*
* Both directions only rewrite known commands from `commands/gsd/*.md` (longest-first
* matching + word-boundary safety). Non-commands (gsd-sdk, gsd-tools, etc.) are
* intentionally left untouched.
*
* The transforms are pure and exported for use by the installer and tests.
*/
const fs = require('node:fs');
@@ -57,6 +65,32 @@ function transformContent(src, cmdNames) {
return src.replace(pattern, (_, cmd) => `/gsd:${cmd}`);
}
/**
* Build regex for the reverse direction (colon form → hyphen form).
* Matches both "gsd:cmd" and "/gsd:cmd" (the leading / is preserved automatically
* because it is not part of the match). Uses longest-first ordering plus
* bidirectional word-boundary safety (negative lookbehind on the left, lookahead
* on the right) so matches only occur at token boundaries.
*/
function buildColonPattern(cmdNames) {
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
return new RegExp(`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
}
/**
* Pure transform (reverse): rewrite `/gsd:<cmd>` / `gsd:<cmd>` to hyphen form
* for known GSD commands.
*
* Non-command identifiers (e.g. gsd-sdk, gsd-tools) are left untouched, matching
* the safety contract of the forward transform.
*/
function transformContentToHyphen(src, cmdNames) {
const pattern = buildColonPattern(cmdNames);
if (!pattern) return src;
return src.replace(pattern, (_, cmd) => `gsd-${cmd}`);
}
function readCmdNames() {
return fs.readdirSync(COMMANDS_DIR)
.filter(f => f.endsWith('.md'))
@@ -103,4 +137,11 @@ if (require.main === module) {
console.log('Done.');
}
module.exports = { transformContent, buildPattern, SKIP_DIRS };
module.exports = {
transformContent,
transformContentToHyphen,
buildPattern,
buildColonPattern,
readCmdNames,
SKIP_DIRS
};

222
scripts/lint-docs-required.cjs Executable file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env node
'use strict';
/**
* Docs-required lint (#3213).
*
* Mirrors scripts/changeset/lint.cjs. Pure verdict function
* evaluateLint({ changedFiles, fragments, labels, malformed }) returns
* { ok, reason, triggering } using the LINT_REASON enum. The CLI wrapper
* reads the PR diff (`git diff --name-only origin/${base}...HEAD`), parses
* each touched `.changeset/*.md` fragment, then calls evaluateLint.
*
* Tests assert on the structured verdict, never on free text.
*/
const { parseFragment, FRAGMENT_ERROR } = require('./changeset/parse.cjs');
const LINT_REASON = Object.freeze({
OK_NO_TRIGGERING_FRAGMENTS: 'ok_no_triggering_fragments',
OK_DOCS_UPDATED: 'ok_docs_updated',
OK_OPT_OUT_LABEL: 'ok_opt_out_label',
OK_FRAGMENTS_EXEMPT: 'ok_fragments_exempt',
FAIL_DOCS_MISSING: 'fail_docs_missing',
FAIL_MALFORMED_FRAGMENT: 'fail_malformed_fragment',
});
const OPT_OUT_LABEL = 'no-docs';
// Fragment types that require a docs update. `Fixed` and `Security` are
// bug-class — they describe regressions or vulnerabilities, not new
// behavior to document.
const TRIGGERING_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed']);
const DOCS_PREFIX = 'docs/';
function isFragmentPath(file) {
return /^\.changeset\/[^/]+\.md$/.test(file) && !file.endsWith('/README.md');
}
function isDocsFile(file) {
return file.startsWith(DOCS_PREFIX);
}
// Per-fragment escape hatch: parse.cjs extracts `<!-- docs-exempt: <reason> -->`
// from the body into `fragment.docsExempt` (a non-empty reason string when the
// marker was present and well-formed; `null` otherwise). A non-empty audit
// trail is required — the lint defends in depth here too: even if a caller
// constructs a fragment with `docsExempt: ''`, that does not count as exempt.
function isExemptFragment(fragment) {
return typeof fragment.docsExempt === 'string' && fragment.docsExempt.trim().length > 0;
}
/**
* Pure verdict — no fs, no git.
*
* Malformed fragments fail closed: a triggering fragment with bad frontmatter
* cannot silently bypass docs enforcement. The changeset-required lint only
* checks fragment _presence_, not _validity_, so docs lint takes responsibility
* for any fragment it tries to consume.
*
* @param {object} args
* @param {string[]} args.changedFiles - file paths changed in the PR
* @param {Array<{ path: string, type: string, body: string, docsExempt: string|null }>} args.fragments
* - parsed records for well-formed `.changeset/*.md` files in `changedFiles`
* @param {Array<{ path: string, reason: string }>} [args.malformed]
* - records for `.changeset/*.md` files that failed `parseFragment`
* @param {string[]} args.labels - PR labels
* @returns {{ ok: boolean, reason: string, triggering: string[], malformed?: Array<{path:string,reason:string}> }}
*/
function evaluateLint({ changedFiles, fragments, labels, malformed = [] }) {
if (malformed.length > 0) {
return {
ok: false,
reason: LINT_REASON.FAIL_MALFORMED_FRAGMENT,
triggering: [],
malformed,
};
}
const triggering = fragments.filter((f) => TRIGGERING_TYPES.has(f.type));
const triggeringPaths = triggering.map((f) => f.path);
if (triggering.length === 0) {
return { ok: true, reason: LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS, triggering: [] };
}
// Per-fragment exempt path: every triggering fragment must carry the marker.
// Partial exemption fails closed — one un-marked Added fragment still requires docs.
if (triggering.every(isExemptFragment)) {
return { ok: true, reason: LINT_REASON.OK_FRAGMENTS_EXEMPT, triggering: triggeringPaths };
}
if (labels.includes(OPT_OUT_LABEL)) {
return { ok: true, reason: LINT_REASON.OK_OPT_OUT_LABEL, triggering: triggeringPaths };
}
if (changedFiles.some(isDocsFile)) {
return { ok: true, reason: LINT_REASON.OK_DOCS_UPDATED, triggering: triggeringPaths };
}
return { ok: false, reason: LINT_REASON.FAIL_DOCS_MISSING, triggering: triggeringPaths };
}
function readFragmentsFromDisk(changedFiles, rootDir) {
const fs = require('node:fs');
const path = require('node:path');
const fragments = [];
const malformed = [];
for (const rel of changedFiles) {
if (!isFragmentPath(rel)) continue;
const abs = path.join(rootDir, rel);
if (!fs.existsSync(abs)) continue; // fragment deleted in PR — skip
let src;
try {
src = fs.readFileSync(abs, 'utf8');
} catch (e) {
malformed.push({ path: rel, reason: 'read_error', detail: e.code || e.message });
continue;
}
const parsed = parseFragment(src);
if (!parsed.ok) {
malformed.push({ path: rel, reason: parsed.reason, detail: parsed.detail || null });
continue;
}
fragments.push({
path: rel,
type: parsed.fragment.type,
body: parsed.fragment.body,
docsExempt: parsed.fragment.docsExempt,
});
}
return { fragments, malformed };
}
function main() {
const fs = require('node:fs');
const cp = require('node:child_process');
const path = require('node:path');
const rootDir = path.join(__dirname, '..');
const eventPath = process.env.GITHUB_EVENT_PATH;
let labels = [];
if (eventPath && fs.existsSync(eventPath)) {
try {
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
labels = (event.pull_request?.labels || []).map((l) => l.name);
} catch { /* fall through */ }
}
const base = process.env.GITHUB_BASE_REF || 'main';
let changedFiles = [];
try {
// execFileSync with argv — no shell, so a malicious GITHUB_BASE_REF
// cannot inject shell syntax. Git's own ref-name validator rejects
// any metacharacters it would otherwise interpret.
const out = cp.execFileSync(
'git',
['diff', '--name-only', `origin/${base}...HEAD`],
{ encoding: 'utf8', cwd: rootDir },
);
changedFiles = out.split('\n').filter(Boolean);
} catch (e) {
process.stderr.write(`could not compute diff: ${e.message}\n`);
process.exit(2);
}
const { fragments, malformed } = readFragmentsFromDisk(changedFiles, rootDir);
const verdict = evaluateLint({ changedFiles, fragments, labels, malformed });
if (process.argv.includes('--json')) {
process.stdout.write(
JSON.stringify({ ...verdict, changedFiles, fragments, malformed, labels }, null, 2) + '\n',
);
} else if (verdict.ok) {
process.stdout.write(`ok docs-lint: ${verdict.reason}\n`);
} else if (verdict.reason === LINT_REASON.FAIL_MALFORMED_FRAGMENT) {
process.stderr.write(`\nERROR docs-lint: ${verdict.reason}\n`);
process.stderr.write(
`${malformed.length} changeset fragment(s) failed to parse — docs lint cannot consume them:\n`,
);
for (const m of malformed) {
process.stderr.write(` ${m.path} (reason: ${m.reason}${m.detail ? `, detail: ${m.detail}` : ''})\n`);
}
process.stderr.write(
`\nFix the fragment frontmatter (\`type:\` + \`pr:\`) before this PR can pass.\n`,
);
} else {
process.stderr.write(`\nERROR docs-lint: ${verdict.reason}\n`);
process.stderr.write(
`${verdict.triggering.length} changeset fragment(s) require documentation updates:\n`,
);
for (const f of fragments.filter((f) => TRIGGERING_TYPES.has(f.type))) {
process.stderr.write(` ${f.path} (type: ${f.type})\n`);
}
process.stderr.write(`\nNo files under docs/ were modified in this PR.\n\n`);
process.stderr.write(
`Update the relevant docs/ file(s), or add the \`${OPT_OUT_LABEL}\` label if this change\n`,
);
process.stderr.write(
`is genuinely internal-only (infrastructure, refactor, test-only). Per-fragment\n`,
);
process.stderr.write(
`exemption via \`<!-- docs-exempt: <reason> -->\` inside the fragment body also works.\n`,
);
}
process.exit(verdict.ok ? 0 : 1);
}
if (require.main === module) main();
module.exports = {
evaluateLint,
readFragmentsFromDisk,
LINT_REASON,
OPT_OUT_LABEL,
TRIGGERING_TYPES,
FRAGMENT_ERROR,
isFragmentPath,
isDocsFile,
isExemptFragment,
};

View File

@@ -0,0 +1,331 @@
#!/usr/bin/env node
'use strict';
/**
* Shared Module hand-sync drift lint — Phase 6 of #3524 (#3575).
*
* Scans get-shit-done/bin/lib/ for .cjs files and checks whether a matching
* TypeScript file exists in sdk/src/<name>.ts, sdk/src/query/<name>.ts, or
* sdk/src/<name>/index.ts (excluding *.generated.ts and *.test.ts).
*
* Allowlist entries are keyed by the (cjs, ts) PAIR. An entry with cjs
* `bin/lib/foo.cjs` and ts `sdk/src/foo.ts` only allow-throughs that exact
* pair — a sibling at `sdk/src/query/foo.ts` is still flagged.
*
* If a pair is found:
* - cooperatingSiblings (matching cjs + ts): accepted silently (exit 0).
* - migrateMeBacklog (matching cjs + ts): emits a WARNING only when
* --warn-all is set; otherwise the pair passes silently. Backlog
* pairs never fail CI.
* - Unlisted pairs (cjs or ts not on either list): ERROR — exit 1.
*
* Usage:
* node scripts/lint-shared-module-handsync.cjs
* node scripts/lint-shared-module-handsync.cjs --root /path/to/repo
* node scripts/lint-shared-module-handsync.cjs --warn-all
* node scripts/lint-shared-module-handsync.cjs --cjs-dir custom/bin/lib --sdk-src custom/sdk/src
*/
const fs = require('fs');
const path = require('path');
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
let ROOT = path.resolve(__dirname, '..');
let CJS_DIR = null; // resolved below
let SDK_SRC = null; // resolved below
let ALLOWLIST_OVERRIDE = null; // resolved below
let WARN_ALL = false;
let JSON_OUTPUT = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--root' && args[i + 1]) {
ROOT = path.resolve(args[++i]);
} else if (args[i] === '--cjs-dir' && args[i + 1]) {
CJS_DIR = path.resolve(args[++i]);
} else if (args[i] === '--sdk-src' && args[i + 1]) {
SDK_SRC = path.resolve(args[++i]);
} else if (args[i] === '--allowlist' && args[i + 1]) {
ALLOWLIST_OVERRIDE = path.resolve(args[++i]);
} else if (args[i] === '--warn-all') {
WARN_ALL = true;
} else if (args[i] === '--json') {
JSON_OUTPUT = true;
}
}
if (!CJS_DIR) CJS_DIR = path.join(ROOT, 'get-shit-done', 'bin', 'lib');
if (!SDK_SRC) SDK_SRC = path.join(ROOT, 'sdk', 'src');
// ---------------------------------------------------------------------------
// Load allowlist
// When --root is given (e.g. in tests), prefer <ROOT>/scripts/allowlist.json
// so fixture trees can supply their own allowlist. Fall back to the copy
// co-located with this script (default production path).
// ---------------------------------------------------------------------------
const ALLOWLIST_PATH = ALLOWLIST_OVERRIDE
? ALLOWLIST_OVERRIDE
: fs.existsSync(path.join(ROOT, 'scripts', 'shared-module-handsync-allowlist.json'))
? path.join(ROOT, 'scripts', 'shared-module-handsync-allowlist.json')
: path.join(__dirname, 'shared-module-handsync-allowlist.json');
let allowlist;
try {
allowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8'));
} catch (err) {
process.stderr.write(
`lint-shared-module-handsync: failed to read allowlist at ${ALLOWLIST_PATH}: ${err.message}\n`
);
process.exit(1);
}
/**
* Pair identity = `${cjs}::${ts}`. Keying on the pair (not just cjs)
* prevents an allowlisted entry from silently passing an unintended
* sibling at a different ts path with the same basename.
*
* @type {Set<string>} pair identities in cooperatingSiblings
*/
const cooperatingPairs = new Set(
(allowlist.cooperatingSiblings || []).map((e) => `${e.cjs}::${e.ts}`)
);
/** @type {Map<string, object>} pair identity -> entry for migrateMeBacklog */
const migrateMap = new Map(
(allowlist.migrateMeBacklog || []).map((e) => [`${e.cjs}::${e.ts}`, e])
);
// ---------------------------------------------------------------------------
// Build SDK name index: name -> array of absolute TS paths
// (excludes *.generated.ts and *.test.ts)
// ---------------------------------------------------------------------------
function buildSdkIndex(sdkSrc) {
const index = new Map(); // name -> [absPath, ...]
function addEntry(name, absPath) {
if (!index.has(name)) index.set(name, []);
index.get(name).push(absPath);
}
function walk(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (_) {
return;
}
for (const ent of entries) {
const abs = path.join(dir, ent.name);
if (ent.isDirectory()) {
walk(abs);
} else if (ent.isFile() && ent.name.endsWith('.ts') &&
!ent.name.endsWith('.generated.ts') &&
!ent.name.endsWith('.test.ts')) {
const rel = path.relative(sdkSrc, abs);
const parts = rel.split(path.sep);
// sdk/src/<name>.ts (direct child, not in a subdir)
if (parts.length === 1) {
const name = parts[0].slice(0, -3); // strip .ts
addEntry(name, abs);
}
// sdk/src/<name>/index.ts (one subdir deep, file is index.ts)
else if (parts.length === 2 && parts[1] === 'index.ts') {
const name = parts[0];
addEntry(name, abs);
}
// sdk/src/query/<name>.ts (exactly: query/<something>.ts)
else if (parts.length === 2 && parts[0] === 'query' && parts[1] !== 'index.ts') {
const name = parts[1].slice(0, -3); // strip .ts
addEntry(name, abs);
}
}
}
}
walk(sdkSrc);
return index;
}
// ---------------------------------------------------------------------------
// Scan CJS files (direct children only; exclude *.generated.cjs)
// ---------------------------------------------------------------------------
function scanCjsFiles(cjsDir) {
let entries;
try {
entries = fs.readdirSync(cjsDir, { withFileTypes: true });
} catch (err) {
process.stderr.write(
`lint-shared-module-handsync: cannot read CJS dir ${cjsDir}: ${err.message}\n`
);
process.exit(1);
}
return entries
.filter(
(e) =>
e.isFile() &&
e.name.endsWith('.cjs') &&
!e.name.endsWith('.generated.cjs')
)
.map((e) => ({
name: e.name.slice(0, -4), // strip .cjs
absPath: path.join(cjsDir, e.name),
}));
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function emitJson(payload) {
process.stdout.write(JSON.stringify(payload) + '\n');
}
function main() {
// Check that the directories exist
if (!fs.existsSync(CJS_DIR)) {
if (JSON_OUTPUT) {
emitJson({ ok: false, reason: 'cjs_dir_missing', path: CJS_DIR });
} else {
process.stderr.write(
`lint-shared-module-handsync: CJS dir not found: ${CJS_DIR}\n` +
` Pass --root <repo-root> or --cjs-dir <path> to override.\n`
);
}
process.exit(1);
}
if (!fs.existsSync(SDK_SRC)) {
if (JSON_OUTPUT) {
emitJson({ ok: false, reason: 'sdk_src_missing', path: SDK_SRC });
} else {
process.stderr.write(
`lint-shared-module-handsync: SDK src dir not found: ${SDK_SRC}\n` +
` Pass --root <repo-root> or --sdk-src <path> to override.\n`
);
}
process.exit(1);
}
const sdkIndex = buildSdkIndex(SDK_SRC);
const cjsFiles = scanCjsFiles(CJS_DIR);
const errors = [];
const warnings = [];
for (const { name, absPath } of cjsFiles) {
// Is there a matching TS file?
if (!sdkIndex.has(name)) continue;
// Compute the relative paths the allowlist uses
const relCjs = path.relative(ROOT, absPath).replace(/\\/g, '/');
const tsPaths = sdkIndex.get(name).map((p) => path.relative(ROOT, p).replace(/\\/g, '/'));
// Pair-aware matching, per ts sibling. Each ts candidate is classified
// independently against the allowlist so a partially-allowlisted set of
// siblings still surfaces the unauthorized ones. See #3632.
const unauthorizedTs = [];
const backlogTsForCjs = [];
for (const relTs of tsPaths) {
const pairKey = `${relCjs}::${relTs}`;
if (cooperatingPairs.has(pairKey)) continue;
if (migrateMap.has(pairKey)) {
backlogTsForCjs.push(relTs);
continue;
}
unauthorizedTs.push(relTs);
}
if (unauthorizedTs.length > 0) {
errors.push({ relCjs, tsPaths: unauthorizedTs });
}
if (backlogTsForCjs.length > 0) {
const entry = migrateMap.get(`${relCjs}::${backlogTsForCjs[0]}`);
warnings.push({ relCjs, tsPaths: backlogTsForCjs, entry });
}
}
// Count cjs files whose pair identity (cjs+ts) is on cooperatingSiblings.
// A file with multiple ts candidates is counted once if any pair matches.
const cooperatingCount = cjsFiles.filter((f) => {
if (!sdkIndex.has(f.name)) return false;
const relCjs = path.relative(ROOT, f.absPath).replace(/\\/g, '/');
return sdkIndex.get(f.name).some((tsAbs) => {
const relTs = path.relative(ROOT, tsAbs).replace(/\\/g, '/');
return cooperatingPairs.has(`${relCjs}::${relTs}`);
});
}).length;
// -------------------------------------------------------------------------
// Report errors (exit 1)
// -------------------------------------------------------------------------
if (errors.length > 0) {
if (JSON_OUTPUT) {
emitJson({
ok: false,
reason: 'unauthorized_pairs',
errors,
warnings,
cooperatingCount,
});
} else {
process.stderr.write(
`\nERROR lint-shared-module-handsync: ${errors.length} unauthorized hand-sync pair(s) found.\n\n`
);
for (const { relCjs, tsPaths } of errors) {
process.stderr.write(` CJS: ${relCjs}\n`);
for (const ts of tsPaths) {
process.stderr.write(` TS: ${ts}\n`);
}
process.stderr.write('\n');
}
process.stderr.write(
'To resolve, choose one of:\n' +
' 1. Migrate to a Shared Module (preferred): create sdk/src/<name>/index.ts as the\n' +
' source-of-truth, write a generator script (sdk/scripts/gen-<name>.mjs), add a\n' +
' freshness check, and update CI. See docs/agents/cjs-sdk-seam.md for the pattern.\n' +
' 2. Add an explicit allowlist entry to scripts/shared-module-handsync-allowlist.json\n' +
' with a justification explaining why this pair is a legitimate cooperating sibling\n' +
' rather than a drift anti-pattern. Requires maintainer review via CODEOWNERS.\n\n'
);
}
process.exit(1);
}
// -------------------------------------------------------------------------
// Report warnings (no exit code change)
// -------------------------------------------------------------------------
if (warnings.length > 0 && WARN_ALL && !JSON_OUTPUT) {
process.stderr.write(
`\nWARNING lint-shared-module-handsync: ${warnings.length} known drift anti-pattern pair(s) in migrateMeBacklog.\n` +
`These are tracked for future Shared Module migration but do not block CI.\n\n`
);
for (const { relCjs, tsPaths, entry } of warnings) {
process.stderr.write(` CJS: ${relCjs}\n`);
for (const ts of tsPaths) {
process.stderr.write(` TS: ${ts}\n`);
}
process.stderr.write(` Tracked: ${entry.trackedIn}\n`);
process.stderr.write(` Hint: ${entry.justification}\n\n`);
}
}
// -------------------------------------------------------------------------
// Success
// -------------------------------------------------------------------------
if (JSON_OUTPUT) {
emitJson({
ok: true,
cooperatingCount,
backlogCount: warnings.length,
warnings,
});
} else {
process.stdout.write(
`ok lint-shared-module-handsync: no unauthorized hand-sync pairs found` +
` (${cooperatingCount} cooperating sibling(s), ${warnings.length} backlog pair(s))\n`
);
}
process.exit(0);
}
main();

View File

@@ -0,0 +1,139 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"_comment": "Allowlist for scripts/lint-shared-module-handsync.cjs — Phase 6 of #3524 (#3575). Two categories: cooperatingSiblings (legitimate pairs, lint accepts silently) and migrateMeBacklog (known drift anti-patterns, lint warns but does not fail). All entries require cjs + ts path + classification + justification.",
"cooperatingSiblings": [
{
"cjs": "get-shit-done/bin/lib/active-workstream-store.cjs",
"ts": "sdk/src/query/active-workstream-store.ts",
"classification": "cooperating-sibling",
"justification": "CJS manages filesystem-backed workstream store; SDK layer wraps via Adapter for query dispatch. Different responsibilities, not drift."
},
{
"cjs": "get-shit-done/bin/lib/config-schema.cjs",
"ts": "sdk/src/query/config-schema.ts",
"classification": "cooperating-sibling",
"justification": "SDK config-schema.ts is the generated source-of-truth derived from sdk/shared/config-schema.manifest.json (Phase 2/#3540). CJS config-schema.cjs is the Adapter that reads from that manifest. Not a hand-sync pair; freshness check enforces alignment."
},
{
"cjs": "get-shit-done/bin/lib/frontmatter.cjs",
"ts": "sdk/src/query/frontmatter.ts",
"classification": "cooperating-sibling",
"justification": "CJS implements full frontmatter parsing/mutation; SDK frontmatter.ts is the native SDK query handler delegating to the CJS runtime via the seam bridge. Not duplicating logic."
},
{
"cjs": "get-shit-done/bin/lib/init.cjs",
"ts": "sdk/src/query/init.ts",
"classification": "cooperating-sibling",
"justification": "CJS init.cjs is the authoritative initializer; SDK init.ts provides the native handler layer for the SDK query seam. Phase 5.2+ will migrate remaining subcommands, but current architecture is intentional."
},
{
"cjs": "get-shit-done/bin/lib/phase.cjs",
"ts": "sdk/src/query/phase.ts",
"classification": "cooperating-sibling",
"justification": "CJS phase.cjs is the full phase lifecycle implementation; SDK phase.ts provides the native query handler. The SDK delegates to CJS for most subcommands. Phase 5.2+ candidate for further migration."
},
{
"cjs": "get-shit-done/bin/lib/profile-output.cjs",
"ts": "sdk/src/query/profile-output.ts",
"classification": "cooperating-sibling",
"justification": "CJS profile-output.cjs handles profiling output rendering; SDK profile-output.ts is the corresponding SDK query handler. Separate responsibilities across the seam."
},
{
"cjs": "get-shit-done/bin/lib/roadmap.cjs",
"ts": "sdk/src/query/roadmap.ts",
"classification": "cooperating-sibling",
"justification": "CJS roadmap.cjs is the full roadmap implementation; SDK roadmap.ts provides the native handler for SDK query dispatch. Phase 5.2+ candidate."
},
{
"cjs": "get-shit-done/bin/lib/state.cjs",
"ts": "sdk/src/query/state.ts",
"classification": "cooperating-sibling",
"justification": "CJS state.cjs is the full state implementation; SDK state.ts routes known subcommands via executeForCjs (Phase 5.0/#3558, Phase 5.1/#3574). Intentional seam delegation pattern."
},
{
"cjs": "get-shit-done/bin/lib/state-document.cjs",
"ts": "sdk/src/query/state-document.ts",
"classification": "cooperating-sibling",
"justification": "CJS state-document.cjs is the generated Adapter reading from sdk/src/state-document/ Shared Module (Phase 1/#3531). SDK state-document.ts is the corresponding source-of-truth query handler. Freshness check enforces alignment."
},
{
"cjs": "get-shit-done/bin/lib/template.cjs",
"ts": "sdk/src/query/template.ts",
"classification": "cooperating-sibling",
"justification": "CJS template.cjs handles template operations; SDK template.ts is the corresponding SDK native handler. Separate responsibilities across the seam."
},
{
"cjs": "get-shit-done/bin/lib/uat.cjs",
"ts": "sdk/src/query/uat.ts",
"classification": "cooperating-sibling",
"justification": "CJS uat.cjs implements UAT workflows; SDK uat.ts provides the SDK query handler layer. Separate responsibilities."
},
{
"cjs": "get-shit-done/bin/lib/verify.cjs",
"ts": "sdk/src/query/verify.ts",
"classification": "cooperating-sibling",
"justification": "CJS verify.cjs is the full verify implementation; SDK verify.ts provides the native handler. Phase 5.2+ candidate for further delegation."
},
{
"cjs": "get-shit-done/bin/lib/workstream.cjs",
"ts": "sdk/src/query/workstream.ts",
"classification": "cooperating-sibling",
"justification": "CJS workstream.cjs handles workstream management; SDK workstream.ts provides the SDK query handler. Workstream support inside sync bridge is an open follow-up item."
},
{
"cjs": "get-shit-done/bin/lib/workstream-inventory.cjs",
"ts": "sdk/src/query/workstream-inventory.ts",
"classification": "cooperating-sibling",
"justification": "CJS workstream-inventory.cjs is the generated Adapter for the workstream-inventory Shared Module (Phase 3/#3548). SDK workstream-inventory.ts is the source-of-truth query handler. Freshness check enforces alignment."
},
{
"cjs": "get-shit-done/bin/lib/config.cjs",
"ts": "sdk/src/config.ts",
"classification": "CJS-CLI-ONLY",
"justification": "Phase 2 (#3536) already migrated CONFIG_DEFAULTS and loadConfig/mergeDefaults to the Configuration Module and sdk/src/config.ts. What remains in config.cjs is exclusively CLI command handlers (cmdConfigGet, cmdConfigSet, cmdConfigNewProject, cmdConfigEnsureSection, cmdConfigSetModelProfile, cmdConfigPath, cmdMigrateConfig, buildNewProjectConfig, setConfigValue, ensureConfigFile) that depend on CJS-only APIs (withPlanningLock, platformWriteSync/ReadSync/EnsureDir, sync fs ops, process.exit). sdk/src/config.ts provides only the async loadConfig/mergeDefaults SDK layer. The two files serve disjoint surfaces with no logical overlap — not a hand-sync drift anti-pattern."
},
{
"cjs": "get-shit-done/bin/lib/intel.cjs",
"ts": "sdk/src/query/intel.ts",
"classification": "cooperating-sibling",
"justification": "CJS intel.cjs is the synchronous runtime implementation used by gsd-tools.cjs; sdk/src/query/intel.ts is the async QueryHandler port for the SDK query seam (explicitly documented as a port in its file header). The two files intentionally diverge on INTEL_FILES naming (CJS: file-roles.json/api-map.json/dependency-graph.json/arch-decisions.json; SDK: files.json/apis.json/deps.json/arch.md) — existing CJS tests are locked to the old naming. Not a hand-sync drift pattern; separate runtime responsibilities across the seam."
},
{
"cjs": "get-shit-done/bin/lib/model-catalog.cjs",
"ts": "sdk/src/model-catalog.ts",
"classification": "ADAPTER-OVER-MODULE",
"justification": "Both files read from sdk/shared/model-catalog.json (ADR-0003 precedent) as independent consumers of the shared manifest. CJS exposes VALID_AGENT_TIERS, MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, nextTier, formatAgentToModelMapAsTable for core.cjs and model-profiles.cjs consumers. SDK exposes resolveRuntimeTierDefault, runtimesWithReasoningEffort for session-runner.ts and query handlers. The shared JSON is the single source-of-truth; both adapters derive their exports from it without duplicating any logic between themselves."
},
{
"cjs": "get-shit-done/bin/lib/plan-scan.cjs",
"ts": "sdk/src/query/plan-scan.ts",
"classification": "ADAPTER-OVER-MODULE",
"justification": "CJS plan-scan.cjs is the generated Adapter reading from sdk/src/query/plan-scan.ts Shared Module (Phase 6/#3575). SDK plan-scan.ts is the source-of-truth. Freshness check (check-plan-scan-fresh.mjs) enforces alignment."
},
{
"cjs": "get-shit-done/bin/lib/secrets.cjs",
"ts": "sdk/src/query/secrets.ts",
"classification": "ADAPTER-OVER-MODULE",
"justification": "CJS secrets.cjs is the generated Adapter reading from sdk/src/query/secrets.ts Shared Module (Phase 6/#3575). SDK secrets.ts is the source-of-truth. Freshness check (check-secrets-fresh.mjs) enforces alignment."
},
{
"cjs": "get-shit-done/bin/lib/schema-detect.cjs",
"ts": "sdk/src/query/schema-detect.ts",
"classification": "ADAPTER-OVER-MODULE",
"justification": "CJS schema-detect.cjs is the generated Adapter reading from sdk/src/query/schema-detect.ts Shared Module (Phase 6/#3575). SDK schema-detect.ts is the source-of-truth. Generated CJS adds detectSchemaOrm compat export (not in SDK) and exports SCHEMA_PATTERNS/ORM_INFO for backward compatibility. Freshness check (check-schema-detect-fresh.mjs) enforces alignment."
},
{
"cjs": "get-shit-done/bin/lib/decisions.cjs",
"ts": "sdk/src/query/decisions.ts",
"classification": "ADAPTER-OVER-MODULE",
"justification": "Phase 6 (#3575): CJS decisions.cjs is the generated Adapter reading from sdk/src/query/decisions.ts Shared Module. SDK source-of-truth; regex aligned to accept alphanumeric IDs (D-INFRA-01). CJS callers (gap-checker.cjs) use {id, text} subset; extra fields {category, tags, trackable} are present but ignored. Freshness check (check-decisions-fresh.mjs) enforces alignment."
},
{
"cjs": "get-shit-done/bin/lib/workstream-name-policy.cjs",
"ts": "sdk/src/workstream-name-policy.ts",
"classification": "ADAPTER-OVER-MODULE",
"justification": "Phase 6 (#3575): CJS workstream-name-policy.cjs is the generated Adapter reading from sdk/src/workstream-name-policy.ts Shared Module. SDK source-of-truth now exports all three functions used by CJS callers (toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName) plus validateWorkstreamName alias. Freshness check (check-workstream-name-policy-fresh.mjs) enforces alignment."
}
],
"migrateMeBacklog": []
}

12
sdk/package-lock.json generated
View File

@@ -1590,9 +1590,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -2308,9 +2308,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -44,6 +44,16 @@
"check:workstream-inventory-builder-fresh": "npm run build && node scripts/check-workstream-inventory-builder-fresh.mjs",
"gen:project-root": "npm run build && node scripts/gen-project-root.mjs",
"check:project-root-fresh": "npm run build && node scripts/check-project-root-fresh.mjs",
"gen:plan-scan": "npm run build && node scripts/gen-plan-scan.mjs",
"check:plan-scan-fresh": "npm run build && node scripts/check-plan-scan-fresh.mjs",
"gen:secrets": "npm run build && node scripts/gen-secrets.mjs",
"check:secrets-fresh": "npm run build && node scripts/check-secrets-fresh.mjs",
"gen:schema-detect": "npm run build && node scripts/gen-schema-detect.mjs",
"check:schema-detect-fresh": "npm run build && node scripts/check-schema-detect-fresh.mjs",
"gen:decisions": "npm run build && node scripts/gen-decisions.mjs",
"check:decisions-fresh": "npm run build && node scripts/check-decisions-fresh.mjs",
"gen:workstream-name-policy": "npm run build && node scripts/gen-workstream-name-policy.mjs",
"check:workstream-name-policy-fresh": "npm run build && node scripts/check-workstream-name-policy-fresh.mjs",
"prepublishOnly": "rm -rf dist && tsc && chmod +x dist/cli.js",
"test": "vitest run",
"test:unit": "vitest run --project unit",

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Freshness check for decisions.generated.cjs.
*
* Regenerates the expected CJS content in-memory (without writing to disk) and
* compares it to the committed file. Exits 0 if they match, 1 if stale.
*
* Run: node sdk/scripts/check-decisions-fresh.mjs
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
*/
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildDecisionsCjs } from './gen-decisions.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const expected = await buildDecisionsCjs();
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'decisions.generated.cjs');
const committed = await readFile(committedPath, 'utf-8');
if (expected === committed) {
console.log('decisions.generated.cjs is fresh');
process.exit(0);
} else {
console.error('decisions.generated.cjs is STALE.');
console.error('Regenerate: cd sdk && npm run gen:decisions');
process.exit(1);
}

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Freshness check for plan-scan.generated.cjs.
*
* Regenerates the expected CJS content in-memory (without writing to disk) and
* compares it to the committed file. Exits 0 if they match, 1 if stale.
*
* Run: node sdk/scripts/check-plan-scan-fresh.mjs
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
*/
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildPlanScanCjs } from './gen-plan-scan.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const expected = await buildPlanScanCjs();
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'plan-scan.generated.cjs');
const committed = await readFile(committedPath, 'utf-8');
if (expected === committed) {
console.log('plan-scan.generated.cjs is fresh');
process.exit(0);
} else {
console.error('plan-scan.generated.cjs is STALE.');
console.error('Regenerate: cd sdk && npm run gen:plan-scan');
process.exit(1);
}

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Freshness check for schema-detect.generated.cjs.
*
* Regenerates the expected CJS content in-memory (without writing to disk) and
* compares it to the committed file. Exits 0 if they match, 1 if stale.
*
* Run: node sdk/scripts/check-schema-detect-fresh.mjs
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
*/
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildSchemaDetectCjs } from './gen-schema-detect.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const expected = await buildSchemaDetectCjs();
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'schema-detect.generated.cjs');
const committed = await readFile(committedPath, 'utf-8');
if (expected === committed) {
console.log('schema-detect.generated.cjs is fresh');
process.exit(0);
} else {
console.error('schema-detect.generated.cjs is STALE.');
console.error('Regenerate: cd sdk && npm run gen:schema-detect');
process.exit(1);
}

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Freshness check for secrets.generated.cjs.
*
* Regenerates the expected CJS content in-memory (without writing to disk) and
* compares it to the committed file. Exits 0 if they match, 1 if stale.
*
* Run: node sdk/scripts/check-secrets-fresh.mjs
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
*/
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildSecretsCjs } from './gen-secrets.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const expected = await buildSecretsCjs();
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'secrets.generated.cjs');
const committed = await readFile(committedPath, 'utf-8');
if (expected === committed) {
console.log('secrets.generated.cjs is fresh');
process.exit(0);
} else {
console.error('secrets.generated.cjs is STALE.');
console.error('Regenerate: cd sdk && npm run gen:secrets');
process.exit(1);
}

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Freshness check for workstream-name-policy.generated.cjs.
*
* Regenerates the expected CJS content in-memory (without writing to disk) and
* compares it to the committed file. Exits 0 if they match, 1 if stale.
*
* Run: node sdk/scripts/check-workstream-name-policy-fresh.mjs
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
*/
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildWorkstreamNamePolicyCjs } from './gen-workstream-name-policy.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const expected = await buildWorkstreamNamePolicyCjs();
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'workstream-name-policy.generated.cjs');
const committed = await readFile(committedPath, 'utf-8');
if (expected === committed) {
console.log('workstream-name-policy.generated.cjs is fresh');
process.exit(0);
} else {
console.error('workstream-name-policy.generated.cjs is STALE.');
console.error('Regenerate: cd sdk && npm run gen:workstream-name-policy');
process.exit(1);
}

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Generator for the Decisions CJS artifact.
*
* Reads the compiled ESM output from sdk/dist/query/decisions.js,
* extracts the relevant function bodies via text transformation,
* then emits get-shit-done/bin/lib/decisions.generated.cjs.
*
* Source-of-truth: sdk/src/query/decisions.ts
*
* Run: cd sdk && npm run gen:decisions
* Freshness check: node sdk/scripts/check-decisions-fresh.mjs
*/
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
export const BANNER = `'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/decisions.ts
* Regenerate: cd sdk && npm run gen:decisions
*
* Shared parser for CONTEXT.md <decisions> blocks.
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
* Returns {id, text, category, tags, trackable} per decision.
* CJS callers that only use {id, text} safely ignore the extra fields.
*/
`;
export async function buildDecisionsCjs() {
// Read the compiled ESM source and transform to CJS.
// We extract only the pure logic (no Node.js imports, no query handler).
const distPath = fileURLToPath(new URL('../dist/query/decisions.js', import.meta.url));
const src = await readFile(distPath, 'utf-8');
// Strip the ESM-specific header lines (import statements, jsdoc at top)
// and the query handler (which uses Node async fs — not needed in CJS shim).
// We keep: DISCRETION_HEADINGS, NON_TRACKABLE_TAGS, stripFencedCode,
// extractDecisionsBlock, parseDecisions.
// Extract the module body between the imports and the query handler.
// Strategy: strip the leading imports and the trailing export const decisionsParse block.
let body = src;
// Remove leading import statements
body = body.replace(/^import\s+.*?;[\r\n]*/gm, '');
// Remove trailing query handler (from the `export const decisionsParse` line to end)
const handlerStart = body.indexOf('// ─── Query handler');
if (handlerStart !== -1) {
body = body.slice(0, handlerStart);
}
// Remove ESM export keywords (keep the function/const declarations)
body = body.replace(/^export (function|const) /gm, '$1 ');
// Remove source map comment
body = body.replace(/\/\/# sourceMappingURL=.*$/gm, '');
// Remove leading file-level jsdoc comment (keep module-level logic only)
body = body.replace(/^\/\*\*[\s\S]*?\*\/\n/m, '');
// Trim extra blank lines at start/end
body = body.trim();
const parts = [
BANNER.trimEnd(),
'',
body,
'',
'module.exports = { parseDecisions };',
'',
];
return parts.join('\n');
}
async function main() {
const content = await buildDecisionsCjs();
const outPath = fileURLToPath(
new URL('../../get-shit-done/bin/lib/decisions.generated.cjs', import.meta.url),
);
await writeFile(outPath, content, 'utf-8');
console.log(`Written: ${outPath}`);
}
// Only run main() when this file is the entry point, not when imported.
// process.argv[1] is already an absolute filesystem path on every platform Node
// supports; comparing directly avoids the Windows URL-parsing bug where
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Generator for the Plan Scan CJS artifact.
*
* Reads the compiled ESM output from sdk/dist/query/plan-scan.js,
* extracts function source via Function.prototype.toString() for exports,
* then emits get-shit-done/bin/lib/plan-scan.generated.cjs.
*
* Run: cd sdk && npm run gen:plan-scan
* Freshness check: node sdk/scripts/check-plan-scan-fresh.mjs
*/
import { writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
export const BANNER = `'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/plan-scan.ts
* Regenerate: cd sdk && npm run gen:plan-scan
*
* Plan Scan Module — detects plan and summary files in a phase directory.
* Supports both flat (pre-#3139) and nested (post-#3139) layouts.
*/
`;
export async function buildPlanScanCjs() {
// Load the compiled ESM module to get exports via Function.prototype.toString()
const distUrl = new URL('../dist/query/plan-scan.js', import.meta.url);
const {
isRootPlanFile,
isNestedPlanFile,
isRootSummaryFile,
isNestedSummaryFile,
scanPhasePlans,
} = await import(distUrl.href);
// Get exported function bodies via Function.prototype.toString()
const isRootPlanFileBody = isRootPlanFile.toString();
const isNestedPlanFileBody = isNestedPlanFile.toString();
const isRootSummaryFileBody = isRootSummaryFile.toString();
const isNestedSummaryFileBody = isNestedSummaryFile.toString();
const scanPhasePlansBody = scanPhasePlans.toString();
const parts = [
BANNER.trimEnd(),
'',
"const { existsSync, readdirSync } = require('node:fs');",
"const { join } = require('node:path');",
'',
'// Excluded derivative files',
'const PLAN_OUTLINE_RE = /-OUTLINE\\.md$/i;',
'const PLAN_PRE_BOUNCE_RE = /\\.pre-bounce\\.md$/i;',
'',
isRootPlanFileBody,
'',
isNestedPlanFileBody,
'',
isRootSummaryFileBody,
'',
isNestedSummaryFileBody,
'',
scanPhasePlansBody,
'',
'// CJS callers do: const scanPhasePlans = require(\'./plan-scan.cjs\')',
'// and also destructure named exports — support both call styles.',
'module.exports = scanPhasePlans;',
'module.exports.scanPhasePlans = scanPhasePlans;',
'module.exports.isRootPlanFile = isRootPlanFile;',
'module.exports.isNestedPlanFile = isNestedPlanFile;',
'module.exports.isRootSummaryFile = isRootSummaryFile;',
'module.exports.isNestedSummaryFile = isNestedSummaryFile;',
'',
];
return parts.join('\n');
}
async function main() {
const content = await buildPlanScanCjs();
const outPath = fileURLToPath(
new URL('../../get-shit-done/bin/lib/plan-scan.generated.cjs', import.meta.url),
);
await writeFile(outPath, content, 'utf-8');
console.log(`Written: ${outPath}`);
}
// Only run main() when this file is the entry point, not when imported.
// process.argv[1] is already an absolute filesystem path on every platform Node
// supports; comparing directly avoids the Windows URL-parsing bug where
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -85,9 +85,10 @@ async function main() {
}
// Only run main() when this file is the entry point, not when imported.
const scriptPath = fileURLToPath(import.meta.url);
const entryPath = process.argv[1] ? new URL(process.argv[1], 'file://').pathname : '';
if (scriptPath === entryPath || process.argv[1] === scriptPath) {
// process.argv[1] is already an absolute filesystem path on every platform Node
// supports; comparing directly avoids the Windows URL-parsing bug where
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err);
process.exit(1);

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env node
/**
* Generator for the Schema Detect CJS artifact.
*
* Reads the compiled ESM output from sdk/dist/query/schema-detect.js,
* extracts function source via Function.prototype.toString() for exports
* and via source-text extraction for internal constants, then emits
* get-shit-done/bin/lib/schema-detect.generated.cjs.
*
* Run: cd sdk && npm run gen:schema-detect
* Freshness check: node sdk/scripts/check-schema-detect-fresh.mjs
*/
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
export const BANNER = `'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/schema-detect.ts
* Regenerate: cd sdk && npm run gen:schema-detect
*
* Schema Drift Detection — detects schema-relevant file changes and verifies
* that the appropriate database push command was executed during a phase.
* This module does not read the filesystem directly.
*/
`;
/**
* Extract a top-level const declaration block (array or object literal)
* from a JS source string. Scans for `const <name> = [` or `const <name> = {`
* and captures through the balanced closing brace/bracket.
*/
function extractConstFromSource(source, name) {
// Try array form: const NAME = [
let arrayMarker = `const ${name} = [`;
let start = source.indexOf(arrayMarker);
let openChar = '[';
let closeChar = ']';
if (start === -1) {
// Try object form: const NAME = {
const objectMarker = `const ${name} = {`;
start = source.indexOf(objectMarker);
openChar = '{';
closeChar = '}';
if (start === -1) {
throw new Error(`Could not find const ${name} in compiled source`);
}
}
const braceOpen = source.indexOf(openChar, start);
if (braceOpen === -1) throw new Error(`Could not find opening ${openChar} for const ${name}`);
let depth = 0;
let i = braceOpen;
for (; i < source.length; i++) {
if (source[i] === openChar) depth++;
else if (source[i] === closeChar) {
depth--;
if (depth === 0) break;
}
}
if (depth !== 0) throw new Error(`Could not find closing ${closeChar} for const ${name}`);
// Return the full `const NAME = [...];` or `const NAME = {...};`
// Find the semicolon after the closing bracket
const afterClose = source.indexOf(';', i);
const end = afterClose !== -1 ? afterClose + 1 : i + 1;
return source.slice(start, end);
}
export async function buildSchemaDetectCjs() {
const distUrl = new URL('../dist/query/schema-detect.js', import.meta.url);
const {
detectSchemaFiles,
checkSchemaDrift,
} = await import(distUrl.href);
const compiledSource = await readFile(fileURLToPath(distUrl), 'utf-8');
// Extract non-exported constants from source text
const schemaPatternsDecl = extractConstFromSource(compiledSource, 'SCHEMA_PATTERNS');
const ormInfoDecl = extractConstFromSource(compiledSource, 'ORM_INFO');
// Get exported function bodies via Function.prototype.toString()
const detectSchemaFilesBody = detectSchemaFiles.toString();
const checkSchemaDriftBody = checkSchemaDrift.toString();
// detectSchemaOrm is not in the SDK but CJS callers may use it.
// Reconstruct it as a simple ORM_INFO lookup (same as original secrets.cjs).
const detectSchemaOrmBody = `function detectSchemaOrm(ormName) {
return ORM_INFO[ormName] || null;
}`;
const parts = [
BANNER.trimEnd(),
'',
'// ─── ORM Patterns ───────────────────────────────────────────────────────────',
schemaPatternsDecl,
'',
'// ─── Push Commands & Evidence Patterns ──────────────────────────────────────',
ormInfoDecl,
'',
'// ─── Public API ──────────────────────────────────────────────────────────────',
detectSchemaFilesBody,
'',
detectSchemaOrmBody,
'',
checkSchemaDriftBody,
'',
'module.exports = {',
' SCHEMA_PATTERNS,',
' ORM_INFO,',
' detectSchemaFiles,',
' detectSchemaOrm,',
' checkSchemaDrift,',
'};',
'',
];
return parts.join('\n');
}
async function main() {
const content = await buildSchemaDetectCjs();
const outPath = fileURLToPath(
new URL('../../get-shit-done/bin/lib/schema-detect.generated.cjs', import.meta.url),
);
await writeFile(outPath, content, 'utf-8');
console.log(`Written: ${outPath}`);
}
// Only run main() when this file is the entry point, not when imported.
// process.argv[1] is already an absolute filesystem path on every platform Node
// supports; comparing directly avoids the Windows URL-parsing bug where
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Generator for the Secrets CJS artifact.
*
* Reads the compiled ESM output from sdk/dist/query/secrets.js,
* extracts function source via Function.prototype.toString() for exports,
* then emits get-shit-done/bin/lib/secrets.generated.cjs.
*
* Run: cd sdk && npm run gen:secrets
* Freshness check: node sdk/scripts/check-secrets-fresh.mjs
*/
import { writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
export const BANNER = `'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/secrets.ts
* Regenerate: cd sdk && npm run gen:secrets
*
* Secrets handling — masking convention for API keys and other
* credentials managed via /gsd-settings-integrations.
* This module does not read the filesystem.
*/
`;
export async function buildSecretsCjs() {
// Load the compiled ESM module to get exports via Function.prototype.toString()
const distUrl = new URL('../dist/query/secrets.js', import.meta.url);
const {
SECRET_CONFIG_KEYS,
isSecretKey,
maskSecret,
maskIfSecret,
} = await import(distUrl.href);
// Get exported function bodies via Function.prototype.toString()
const isSecretKeyBody = isSecretKey.toString();
const maskSecretBody = maskSecret.toString();
const maskIfSecretBody = maskIfSecret.toString();
// SECRET_CONFIG_KEYS is a Set — reconstruct it as a constant declaration
const secretKeys = [...SECRET_CONFIG_KEYS];
const secretKeysLiteral = secretKeys.map(k => ` '${k}',`).join('\n');
const parts = [
BANNER.trimEnd(),
'',
'const SECRET_CONFIG_KEYS = new Set([',
secretKeysLiteral,
']);',
'',
isSecretKeyBody,
'',
maskSecretBody,
'',
maskIfSecretBody,
'',
'module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret };',
'',
];
return parts.join('\n');
}
async function main() {
const content = await buildSecretsCjs();
const outPath = fileURLToPath(
new URL('../../get-shit-done/bin/lib/secrets.generated.cjs', import.meta.url),
);
await writeFile(outPath, content, 'utf-8');
console.log(`Written: ${outPath}`);
}
// Only run main() when this file is the entry point, not when imported.
// process.argv[1] is already an absolute filesystem path on every platform Node
// supports; comparing directly avoids the Windows URL-parsing bug where
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -132,9 +132,10 @@ async function main(): Promise<void> {
}
// Only run main() when this file is the entry point, not when imported.
const scriptPath = fileURLToPath(import.meta.url);
const entryPath = process.argv[1] ? new URL(process.argv[1], 'file://').pathname : '';
if (scriptPath === entryPath || process.argv[1] === scriptPath) {
// process.argv[1] is already an absolute filesystem path on every platform Node
// supports; comparing directly avoids the Windows URL-parsing bug where
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err);
process.exit(1);

View File

@@ -109,9 +109,10 @@ async function main() {
}
// Only run main() when this file is the entry point, not when imported.
const scriptPath = fileURLToPath(import.meta.url);
const entryPath = process.argv[1] ? new URL(process.argv[1], 'file://').pathname : '';
if (scriptPath === entryPath || process.argv[1] === scriptPath) {
// process.argv[1] is already an absolute filesystem path on every platform Node
// supports; comparing directly avoids the Windows URL-parsing bug where
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err);
process.exit(1);

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* Generator for the Workstream Name Policy CJS artifact.
*
* Reads the compiled ESM output from sdk/dist/workstream-name-policy.js,
* extracts function source via text transformation,
* then emits get-shit-done/bin/lib/workstream-name-policy.generated.cjs.
*
* Source-of-truth: sdk/src/workstream-name-policy.ts
*
* Run: cd sdk && npm run gen:workstream-name-policy
* Freshness check: node sdk/scripts/check-workstream-name-policy-fresh.mjs
*/
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
export const BANNER = `'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/workstream-name-policy.ts
* Regenerate: cd sdk && npm run gen:workstream-name-policy
*
* Canonical workstream name validation and slug normalization.
* Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs.
*/
`;
export async function buildWorkstreamNamePolicyCjs() {
// Read the compiled ESM source and transform to CJS.
const distPath = fileURLToPath(new URL('../dist/workstream-name-policy.js', import.meta.url));
const src = await readFile(distPath, 'utf-8');
// Transform ESM to CJS:
// 1. Remove import statements (none expected in this file)
// 2. Remove ESM export keywords
// 3. Remove source map comment
// 4. Remove leading jsdoc comment
// 5. Add module.exports at end
let body = src;
// Remove leading import statements (if any)
body = body.replace(/^import\s+.*?;[\r\n]*/gm, '');
// Remove ESM export keywords (keep function/const declarations)
body = body.replace(/^export (function|const) /gm, '$1 ');
// Remove source map comment
body = body.replace(/\/\/# sourceMappingURL=.*$/gm, '');
// Remove leading file-level jsdoc comment (keep module-level logic only)
body = body.replace(/^\/\*\*[\s\S]*?\*\/\n/m, '');
// Trim extra blank lines at start/end
body = body.trim();
const parts = [
BANNER.trimEnd(),
'',
body,
'',
'module.exports = {',
' validateWorkstreamName,',
' toWorkstreamSlug,',
' hasInvalidPathSegment,',
' isValidActiveWorkstreamName,',
'};',
'',
];
return parts.join('\n');
}
async function main() {
const content = await buildWorkstreamNamePolicyCjs();
const outPath = fileURLToPath(
new URL('../../get-shit-done/bin/lib/workstream-name-policy.generated.cjs', import.meta.url),
);
await writeFile(outPath, content, 'utf-8');
console.log(`Written: ${outPath}`);
}
// Only run main() when this file is the entry point, not when imported.
// process.argv[1] is already an absolute filesystem path on every platform Node
// supports; comparing directly avoids the Windows URL-parsing bug where
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -476,16 +476,12 @@ describe('Golden file tests', () => {
});
it('state.prune dry-run matches gsd-tools.cjs', async () => {
// Prune needs a parseable current_phase. Use fresh dirs with a STATE.md
// whose frontmatter includes current_phase so both CJS and SDK agree.
// CJS extracts current phase from disk-counted phases (result: 0 phases → "Only 0 phases..."),
// SDK extracts from frontmatter current_phase field.
// Use only 2 keepRecent phases, leaving phases dir empty so CJS reports "Only 0 phases"
// and SDK also bails early (current_phase=10, cutoff=7, but no phases to scan → same reason).
// Align via a fixture that has current_phase in frontmatter AND no phases on disk.
// Both CJS and SDK read `Current Phase` from the STATE.md body text
// (CJS: stateExtractField(content, 'Current Phase'), SDK: same).
// MINIMAL_STATE has no `Current Phase:` field → both default to 0 →
// cutoff = 0 - 3 = -3 ≤ 0 → "Only 0 phases — nothing to prune with --keep-recent 3".
const gsdDir2 = join(tmpdir(), `gsd-golden-prune-gsd-${Date.now()}`);
const sdkDir2 = join(tmpdir(), `gsd-golden-prune-sdk-${Date.now()}`);
// Minimal state — no phases on disk, prune returns "Only N phases — nothing to prune"
try {
await setupMinimalStateProject(gsdDir2);
await setupMinimalStateProject(sdkDir2);
@@ -493,41 +489,28 @@ describe('Golden file tests', () => {
const gsdOutput = await captureGsdToolsOutput('state', argv, gsdDir2);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.prune', ['--keep-recent', '3', '--dry-run'], sdkDir2);
// Both should return pruned:false. Exact reason may differ (CJS: phase count from disk;
// SDK: phase count from frontmatter). Compare just the structural result.
const sdkData = sdkResult.data as Record<string, unknown>;
const gsdData = gsdOutput as Record<string, unknown>;
expect(sdkData.pruned).toBe(false);
expect(gsdData.pruned).toBe(false);
expect(typeof sdkData.reason).toBe('string');
expect(typeof gsdData.reason).toBe('string');
// Exact equality — both CJS and SDK now use the same phase extraction logic.
expect(sdkResult.data).toEqual(gsdOutput);
} finally {
await rm(gsdDir2, { recursive: true, force: true });
await rm(sdkDir2, { recursive: true, force: true });
}
});
it('state.record-metric matches gsd-tools.cjs (no-metrics-section → divergence documented)', async () => {
// Divergence: CJS auto-creates the Performance Metrics section when absent;
// SDK returns { recorded: false, reason: '...' }. We test both via fresh dirs
// and add a metrics section to align behavior for parity.
it('state.record-metric matches gsd-tools.cjs (no-metrics-section → SDK auto-creates like CJS)', async () => {
// SDK now auto-creates the ## Performance Metrics section when absent,
// matching CJS DWIM behavior. Test with no pre-seeded section to exercise
// the auto-create path on both sides.
const gsdDir2 = join(tmpdir(), `gsd-golden-state-metric-gsd-${Date.now()}`);
const sdkDir2 = join(tmpdir(), `gsd-golden-state-metric-sdk-${Date.now()}`);
try {
const metricsState = MINIMAL_STATE + [
'',
'## Performance Metrics',
'',
'| Phase | Plan | Duration | Notes |',
'|-------|------|----------|-------|',
'',
].join('\n');
// Use MINIMAL_STATE (no metrics section) — both sides should auto-create it.
await mkdir(join(gsdDir2, '.planning', 'phases'), { recursive: true });
await writeFile(join(gsdDir2, '.planning', 'STATE.md'), metricsState, 'utf-8');
await writeFile(join(gsdDir2, '.planning', 'STATE.md'), MINIMAL_STATE, 'utf-8');
await writeFile(join(gsdDir2, '.planning', 'ROADMAP.md'), '# Roadmap\n', 'utf-8');
await writeFile(join(gsdDir2, '.planning', 'config.json'), '{"model_profile":"balanced"}', 'utf-8');
await mkdir(join(sdkDir2, '.planning', 'phases'), { recursive: true });
await writeFile(join(sdkDir2, '.planning', 'STATE.md'), metricsState, 'utf-8');
await writeFile(join(sdkDir2, '.planning', 'STATE.md'), MINIMAL_STATE, 'utf-8');
await writeFile(join(sdkDir2, '.planning', 'ROADMAP.md'), '# Roadmap\n', 'utf-8');
await writeFile(join(sdkDir2, '.planning', 'config.json'), '{"model_profile":"balanced"}', 'utf-8');
@@ -535,6 +518,7 @@ describe('Golden file tests', () => {
const gsdOutput = await captureGsdToolsOutput('state', argv, gsdDir2);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.record-metric', ['--phase', '10', '--plan', '1', '--duration', '45m', '--tasks', '12', '--files', '8'], sdkDir2);
// Exact equality — SDK now auto-creates Performance Metrics section like CJS.
expect(sdkResult.data).toEqual(gsdOutput);
} finally {
await rm(gsdDir2, { recursive: true, force: true });
@@ -903,4 +887,145 @@ describe('Golden file tests', () => {
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── Phase 6: verify.* parity tests ────────────────────────────────────────
describe('verify.references', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const testPhase = '9';
const gsdOutput = await captureGsdToolsOutput('verify', ['references', testPhase], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.references', [testPhase], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('verify.commits', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const testPhase = '9';
const gsdOutput = await captureGsdToolsOutput('verify', ['commits', testPhase], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.commits', [testPhase], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('verify.artifacts', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const testPhase = '9';
const gsdOutput = await captureGsdToolsOutput('verify', ['artifacts', testPhase], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.artifacts', [testPhase], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('verify.key-links', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const testPhase = '9';
const gsdOutput = await captureGsdToolsOutput('verify', ['key-links', testPhase], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.key-links', [testPhase], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('verify.schema-drift', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const testPhase = '9';
const gsdOutput = await captureGsdToolsOutput('verify', ['schema-drift', testPhase], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.schema-drift', [testPhase], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('verify.codebase-drift', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('verify', ['codebase-drift'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.codebase-drift', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── Phase 6: roadmap.* parity tests ───────────────────────────────────────
describe('roadmap.annotate-dependencies', () => {
it('roadmap.annotate-dependencies matches gsd-tools.cjs on fixture', async () => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const gsdDir = join(tmpdir(), `gsd-golden-roadmap-annotate-gsd-${suffix}`);
const sdkDir = join(tmpdir(), `gsd-golden-roadmap-annotate-sdk-${suffix}`);
try {
await setupPhasesFixture(gsdDir);
await setupPhasesFixture(sdkDir);
const gsdOutput = await captureGsdToolsOutput('roadmap', ['annotate-dependencies', '10'], gsdDir);
const registry = createRegistry();
const sdkResult = await registry.dispatch('roadmap.annotate-dependencies', ['10'], sdkDir);
expect(sdkResult.data).toEqual(gsdOutput);
} finally {
await rm(gsdDir, { recursive: true, force: true });
await rm(sdkDir, { recursive: true, force: true });
}
});
});
// ─── Phase 6: phase.* parity tests ────────────────────────────────────────
describe('phase.next-decimal', () => {
it('phase.next-decimal matches gsd-tools.cjs on fixture', async () => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const gsdDir = join(tmpdir(), `gsd-golden-phase-nd-gsd-${suffix}`);
const sdkDir = join(tmpdir(), `gsd-golden-phase-nd-sdk-${suffix}`);
try {
await setupMinimalStateProject(gsdDir);
await setupMinimalStateProject(sdkDir);
const gsdOutput = await captureGsdToolsOutput('phase', ['next-decimal', '10'], gsdDir);
const registry = createRegistry();
const sdkResult = await registry.dispatch('phase.next-decimal', ['10'], sdkDir);
expect(sdkResult.data).toEqual(gsdOutput);
} finally {
await rm(gsdDir, { recursive: true, force: true });
await rm(sdkDir, { recursive: true, force: true });
}
});
});
describe('phase.remove and phase.complete', () => {
it('phase.remove matches gsd-tools.cjs on fixture', async () => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const gsdDir = join(tmpdir(), `gsd-golden-phase-rm-gsd-${suffix}`);
const sdkDir = join(tmpdir(), `gsd-golden-phase-rm-sdk-${suffix}`);
try {
await setupPhasesFixture(gsdDir);
await setupPhasesFixture(sdkDir);
// Both remove phase 11 (complete in fixture, safe to remove with --force)
const gsdOutput = await captureGsdToolsOutput('phase', ['remove', '11', '--force'], gsdDir);
const registry = createRegistry();
const sdkResult = await registry.dispatch('phase.remove', ['11', '--force'], sdkDir);
expect(sdkResult.data).toEqual(gsdOutput);
} finally {
await rm(gsdDir, { recursive: true, force: true });
await rm(sdkDir, { recursive: true, force: true });
}
});
it('phase.complete matches gsd-tools.cjs on fixture', async () => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const gsdDir = join(tmpdir(), `gsd-golden-phase-complete-gsd-${suffix}`);
const sdkDir = join(tmpdir(), `gsd-golden-phase-complete-sdk-${suffix}`);
try {
await setupPhasesFixture(gsdDir);
await setupPhasesFixture(sdkDir);
// Both complete phase 10 (which is in the fixture ROADMAP)
const gsdOutput = await captureGsdToolsOutput('phase', ['complete', '10'], gsdDir);
const registry = createRegistry();
const sdkResult = await registry.dispatch('phase.complete', ['10'], sdkDir);
expect(sdkResult.data).toEqual(gsdOutput);
} finally {
await rm(gsdDir, { recursive: true, force: true });
await rm(sdkDir, { recursive: true, force: true });
}
});
});
});

View File

@@ -205,7 +205,10 @@ describe('GSDTransport', () => {
expect(result).toBe('');
expect(adapters.execSubprocessRaw).not.toHaveBeenCalled();
});
it('forces subprocess when workstream present', async () => {
it('routes natively when workstream present (Phase 6 fix)', async () => {
// Phase 6 fix: GSDTransport no longer forces subprocess for workstream-scoped
// requests. The per-request dispatchNative closure (Phase 5.1) correctly
// threads workstream to registry.dispatch(), so native dispatch is used.
const registry = new QueryRegistry();
registry.register('state.load', async () => ({ data: { ok: true } }));
@@ -229,9 +232,10 @@ describe('GSDTransport', () => {
allowFallbackToSubprocess: true,
});
expect(result).toEqual({ ok: 'ws-subprocess' });
expect(adapters.dispatchNative).not.toHaveBeenCalled();
expect(adapters.execSubprocessJson).toHaveBeenCalledOnce();
// Native dispatch is used — subprocess is NOT called.
expect(result).toEqual({ ok: true });
expect(adapters.dispatchNative).toHaveBeenCalledOnce();
expect(adapters.execSubprocessJson).not.toHaveBeenCalled();
});
it('fails when command is unregistered and subprocess fallback is disabled', async () => {
@@ -260,7 +264,9 @@ describe('GSDTransport', () => {
expect(adapters.execSubprocessJson).not.toHaveBeenCalled();
});
it('forces raw subprocess path when workstream present and mode is raw', async () => {
it('routes natively when workstream present and mode is raw (Phase 6 fix)', async () => {
// Phase 6 fix: workstream no longer forces subprocess. Native dispatch is used
// even in raw mode — formatNativeRaw (if set) handles the output projection.
const registry = new QueryRegistry();
registry.register('commit', async () => ({ data: { hash: 'abc' } }));
@@ -284,9 +290,10 @@ describe('GSDTransport', () => {
allowFallbackToSubprocess: true,
});
expect(result).toBe('raw-subprocess');
expect(adapters.dispatchNative).not.toHaveBeenCalled();
expect(adapters.execSubprocessRaw).toHaveBeenCalledOnce();
// Native dispatch is used — toRaw serializes data to JSON.
expect(typeof result).toBe('string');
expect(adapters.dispatchNative).toHaveBeenCalledOnce();
expect(adapters.execSubprocessRaw).not.toHaveBeenCalled();
expect(adapters.execSubprocessJson).not.toHaveBeenCalled();
});
});

View File

@@ -28,7 +28,7 @@ export interface TransportPolicyLike {
export interface TransportDecision {
dispatchMode: 'native' | 'subprocess';
reason?: 'workstream_forced' | 'native_not_preferred' | 'native_unregistered' | 'native_failure_fallback';
reason?: 'native_not_preferred' | 'native_unregistered' | 'native_failure_fallback';
}
export class GSDTransport {
@@ -69,17 +69,18 @@ export class GSDTransport {
}
private shouldUseNative(request: TransportRequest, policy: TransportPolicyLike): boolean {
const forceSubprocess = Boolean(request.workstream);
return !forceSubprocess && policy.preferNative && this.registry.has(request.registryCommand);
// Phase 5.0 worker fix: dispatchNative now correctly threads projectDir and
// workstream per-request (see worker.ts dispatchNative closure). Workstream
// commands no longer need to force subprocess — native dispatch handles them.
return policy.preferNative && this.registry.has(request.registryCommand);
}
private subprocessReason(request: TransportRequest, policy: TransportPolicyLike): TransportDecision['reason'] {
if (request.workstream) return 'workstream_forced';
if (!policy.preferNative) return 'native_not_preferred';
if (!this.registry.has(request.registryCommand)) return 'native_unregistered';
throw new Error(
`Unexpected subprocess reason state for command '${request.registryCommand}' with preferNative=${String(policy.preferNative)} and workstream=${String(request.workstream)}`,
`Unexpected subprocess reason state for command '${request.registryCommand}' with preferNative=${String(policy.preferNative)}`,
);
}

View File

@@ -67,6 +67,25 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st
return Array.isArray(u) && u.length > 0 ? 'true' : 'false';
}
// #3631: CJS handlers projected these to a scalar under --raw. Mirror that
// here so SDK dispatch matches CJS behaviour when family routers request
// mode: 'raw' on the bridge.
if (registryCommand === 'phase.next-decimal') {
if (data && typeof data === 'object' && !Array.isArray(data)) {
const next = (data as Record<string, unknown>).next;
if (typeof next === 'string') return next;
}
return safeStringify(data);
}
if (registryCommand === 'roadmap.get-phase') {
if (data && typeof data === 'object' && !Array.isArray(data)) {
const section = (data as Record<string, unknown>).section;
if (typeof section === 'string') return section;
}
return '';
}
if (typeof data === 'string') {
return data;
}

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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' },

View File

@@ -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;

View File

@@ -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],

View File

@@ -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],

View File

@@ -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 ──────────────────────────────────────────────────

View File

@@ -259,6 +259,111 @@ describe('resolveModel', () => {
expect(planner).not.toHaveProperty('reasoning_effort');
});
// ─── #3643: runtime:claude + resolve_model_ids:true must return full IDs ──
// Symptom: aliases (opus/sonnet/haiku) leaked through to consumers that asked
// for resolved model IDs because resolveRuntimeTier bails for runtime:claude
// and the alias-return fall-through ignored resolve_model_ids. CJS branch at
// get-shit-done/bin/lib/core.cjs:1348-1350 has the missing guard.
it('#3643: runtime:claude + resolve_model_ids:true + balanced returns full sonnet id', async () => {
const { resolveModel } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'balanced',
runtime: 'claude',
resolve_model_ids: true,
}),
);
const result = await resolveModel(['gsd-executor'], tmpDir);
expect(result.data).toEqual({ model: 'claude-sonnet-4-6', profile: 'balanced' });
});
it('#3643: runtime:claude + resolve_model_ids:true + quality returns full opus id', async () => {
const { resolveModel } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'quality',
runtime: 'claude',
resolve_model_ids: true,
}),
);
const result = await resolveModel(['gsd-planner'], tmpDir);
expect(result.data).toEqual({ model: 'claude-opus-4-7', profile: 'quality' });
});
it('#3643: runtime:claude + resolve_model_ids:true + budget returns full haiku id', async () => {
const { resolveModel } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'budget',
runtime: 'claude',
resolve_model_ids: true,
}),
);
// gsd-verifier maps to 'haiku' under budget profile per model-catalog.json.
const result = await resolveModel(['gsd-verifier'], tmpDir);
expect(result.data).toEqual({ model: 'claude-haiku-4-5', profile: 'budget' });
});
it('#3643: phase-type tier override (models.execution=opus) wins under claude+resolve_model_ids', async () => {
const { resolveModel } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'budget',
runtime: 'claude',
resolve_model_ids: true,
models: { execution: 'opus' },
}),
);
const result = await resolveModel(['gsd-executor'], tmpDir);
expect(result.data).toEqual({ model: 'claude-opus-4-7', profile: 'budget' });
});
it('#3643 regression-guard: runtime:claude WITHOUT resolve_model_ids still returns alias', async () => {
const { resolveModel } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'balanced',
runtime: 'claude',
}),
);
const result = await resolveModel(['gsd-executor'], tmpDir);
expect(result.data).toEqual({ model: 'sonnet', profile: 'balanced' });
});
it('#3643 regression-guard: runtime:claude + resolve_model_ids:"omit" still wins over alias mapping', async () => {
const { resolveModel } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'balanced',
runtime: 'claude',
resolve_model_ids: 'omit',
}),
);
const result = await resolveModel(['gsd-executor'], tmpDir);
expect(result.data).toEqual({ model: '', profile: 'balanced' });
});
it('#3643 regression-guard: model_overrides[agent] beats claude+resolve_model_ids:true', async () => {
const { resolveModel } = await import('./config-query.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'balanced',
runtime: 'claude',
resolve_model_ids: true,
model_overrides: { 'gsd-executor': 'custom-anthropic-id' },
}),
);
const result = await resolveModel(['gsd-executor'], tmpDir);
expect((result.data as Record<string, unknown>).model).toBe('custom-anthropic-id');
});
it('resolveModel uses workstream config when --ws is specified', async () => {
const { resolveModel } = await import('./config-query.js');
// Root config: balanced profile → gsd-executor resolves to 'sonnet'

View File

@@ -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
@@ -240,5 +290,25 @@ export const resolveModel: QueryHandler = async (args, projectDir, workstream) =
return { data: { model: '', profile } };
}
// #3643: runtime:claude bails out of resolveRuntimeTier (line 149) because
// Claude is the implicit/default runtime, but consumers that asked for
// resolved model IDs still need the full ID (e.g. "claude-sonnet-4-6"), not
// the tier alias. Mirror the CJS branch at get-shit-done/bin/lib/core.cjs
// (`if (config.resolve_model_ids) return MODEL_ALIAS_MAP[alias] || alias;`)
// by consulting the catalog's claude runtime defaults for the resolved tier.
const runtime = typeof (config as Record<string, unknown>).runtime === 'string'
? ((config as Record<string, unknown>).runtime as string)
: '';
// Empty/missing runtime is implicit Claude (per the resolveRuntimeTier bail-out
// at line ~149); without this branch the resolved-IDs path silently fell
// through to the alias return for projects that never set `runtime` explicitly.
const isClaudeRuntime = runtime === '' || runtime === 'claude';
if (resolveModelIds === true && isClaudeRuntime && isRuntimeTierName(tier)) {
const claudeDefault = resolveRuntimeTierDefault('claude', tier);
if (claudeDefault?.model) {
return { data: { model: claudeDefault.model, profile } };
}
}
return { data: { model: alias, profile } };
};

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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];

View File

@@ -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`. */

View File

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

View File

@@ -443,6 +443,54 @@ describe('initPlanPhase', () => {
expect(data.error).toBeDefined();
});
// #3569: init.plan-phase must surface a phase_status field so the
// /gsd-plan-phase workflow can short-circuit on closed phases instead of
// happily replanning over shipped code. Reuses the project-wide phase
// lifecycle vocabulary from determinePhaseStatus (Pending | Planned |
// In Progress | Executed | Complete | Needs Review).
describe('phase_status (#3569)', () => {
it('reports "Complete" when summaries match plans and VERIFICATION.md status: passed', async () => {
// Phase 9 fixture already has 1 plan + 1 summary; add a passing VERIFICATION.
await writeFile(
join(tmpDir, '.planning', 'phases', '09-foundation', '09-VERIFICATION.md'),
['---', 'phase: 09', 'status: passed', 'score: 100', 'verified: true', '---', '# Verification'].join('\n'),
);
const result = await initPlanPhase(['9'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.phase_status).toBe('Complete');
});
it('reports "Planned" when plans exist but no summaries written', async () => {
// Phase 10 has no plan files in the beforeEach fixture. Add a plan to flip
// it from "Pending" (no plans) to "Planned" (plans, no summaries).
await writeFile(
join(tmpDir, '.planning', 'phases', '10-read-only-queries', '10-01-PLAN.md'),
['---', 'phase: 10-read-only-queries', 'plan: 01', '---', '<objective>x</objective>'].join('\n'),
);
const result = await initPlanPhase(['10'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.phase_status).toBe('Planned');
});
it('reports "Pending" when phase has no plans yet', async () => {
const result = await initPlanPhase(['10'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.phase_status).toBe('Pending');
});
it('reports "Executed" when summaries match plans but VERIFICATION.md is absent', async () => {
// Phase 9 fixture: 1 plan, 1 summary, no VERIFICATION yet — executed but
// not closed. This is the regression hot zone: pre-fix, init.plan-phase
// gave no signal here, so the workflow couldn't distinguish this from
// an already-closed phase either.
const result = await initPlanPhase(['9'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.phase_status).toBe('Executed');
});
});
// #2769: extractReqIds must accept all bold/colon variants of the
// Requirements header. The forms render identically in markdown but differ
// textually; the previous regex only matched **Requirements**: (colon

View File

@@ -22,12 +22,15 @@ 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';
import { generatePhaseSlug, assertSafeProjectCode } from './phase-lifecycle-policy.js';
import type { QueryHandler } from './utils.js';
@@ -140,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;
}
@@ -366,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;
@@ -393,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,
@@ -449,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;
@@ -469,6 +486,17 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
const phaseName = (phaseInfo?.phase_name as string) ?? null;
const phaseDir = (phaseInfo?.directory as string) ?? null;
const plans = (phaseInfo?.plans || []) as string[];
const summaries = (phaseInfo?.summaries || []) as string[];
// #3569: surface phase lifecycle status so /gsd-plan-phase can short-circuit
// on closed (Complete) phases instead of silently replanning over shipped
// code. Reuses determinePhaseStatus — the project-wide vocabulary used by
// `progress` (Pending | Planned | In Progress | Executed | Complete |
// Needs Review). When the phase has no directory on disk yet, treat it as
// Pending (it has not been started).
const phaseStatus = phaseDir
? await determinePhaseStatus(plans.length, summaries.length, join(projectDir, phaseDir))
: 'Pending';
// #3287: compute the canonical directory name with project_code prefix so
// the first-touch mkdir in /gsd-plan-phase stays consistent with phase.add.
@@ -486,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,
@@ -503,6 +531,7 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
phase_slug: (phaseInfo?.phase_slug as string) ?? null,
padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
phase_req_ids,
phase_status: phaseStatus,
has_research: (phaseInfo?.has_research as boolean) || false,
has_context: (phaseInfo?.has_context as boolean) || false,
has_reviews: (phaseInfo?.has_reviews as boolean) || false,
@@ -555,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 */ }
@@ -1039,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',
@@ -1161,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();
@@ -1175,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>> = [];

View File

@@ -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 } };

Some files were not shown because too many files have changed in this diff Show More