diff --git a/.changeset/3577-adr-violations-and-validation-port.md b/.changeset/3577-adr-violations-and-validation-port.md new file mode 100644 index 000000000..8a398a2d2 --- /dev/null +++ b/.changeset/3577-adr-violations-and-validation-port.md @@ -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 `** — the CLI invocation `frontmatter get --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 ` and positional `args[1]`. diff --git a/.changeset/3577-config-ensure-section-parity.md b/.changeset/3577-config-ensure-section-parity.md new file mode 100644 index 000000000..1547e2726 --- /dev/null +++ b/.changeset/3577-config-ensure-section-parity.md @@ -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 `
` 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: ` (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
` regression seen in `tests/{config,agent-skills,ai-evals}.test.cjs`. diff --git a/.changeset/3577-docker-test-fixup.md b/.changeset/3577-docker-test-fixup.md new file mode 100644 index 000000000..dd61eb3fa --- /dev/null +++ b/.changeset/3577-docker-test-fixup.md @@ -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 ` (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 `(?#{2,4})` + backreference `\k(?!#)` 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. diff --git a/.changeset/3643-resolve-model-claude-runtime.md b/.changeset/3643-resolve-model-claude-runtime.md new file mode 100644 index 000000000..0aa2238db --- /dev/null +++ b/.changeset/3643-resolve-model-claude-runtime.md @@ -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) diff --git a/.changeset/clever-zebras-snooze.md b/.changeset/clever-zebras-snooze.md new file mode 100644 index 000000000..6fb27d3b5 --- /dev/null +++ b/.changeset/clever-zebras-snooze.md @@ -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). diff --git a/.changeset/fix-3406-detect-stale-sdk-shadow.md b/.changeset/fix-3406-detect-stale-sdk-shadow.md new file mode 100644 index 000000000..989a9fbd7 --- /dev/null +++ b/.changeset/fix-3406-detect-stale-sdk-shadow.md @@ -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. diff --git a/.changeset/fix-3579-graphify-hook-publish.md b/.changeset/fix-3579-graphify-hook-publish.md new file mode 100644 index 000000000..d90ec03e2 --- /dev/null +++ b/.changeset/fix-3579-graphify-hook-publish.md @@ -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). diff --git a/.changeset/fix-3588-npm-audit-clean.md b/.changeset/fix-3588-npm-audit-clean.md new file mode 100644 index 000000000..feaffc6d6 --- /dev/null +++ b/.changeset/fix-3588-npm-audit-clean.md @@ -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. diff --git a/.changeset/fix-3631-sdk-raw-flag-routers.md b/.changeset/fix-3631-sdk-raw-flag-routers.md new file mode 100644 index 000000000..e781bfd4c --- /dev/null +++ b/.changeset/fix-3631-sdk-raw-flag-routers.md @@ -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. diff --git a/.changeset/fix-3632-lint-handsync-pair-fanout.md b/.changeset/fix-3632-lint-handsync-pair-fanout.md new file mode 100644 index 000000000..806eb4e21 --- /dev/null +++ b/.changeset/fix-3632-lint-handsync-pair-fanout.md @@ -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/.cjs` had two ts candidates on disk (e.g. `sdk/src/.ts` and `sdk/src/query/.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. diff --git a/.changeset/graceful-tigers-fly.md b/.changeset/graceful-tigers-fly.md new file mode 100644 index 000000000..274dc3bb8 --- /dev/null +++ b/.changeset/graceful-tigers-fly.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3583 +--- +Claude skill install (convertClaudeCommandToClaudeSkill + copyCommandsAsClaudeSkills) now normalizes retired /gsd: references in SKILL.md bodies to the canonical gsd- 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. diff --git a/.changeset/steady-zebras-click.md b/.changeset/steady-zebras-click.md new file mode 100644 index 000000000..881950e46 --- /dev/null +++ b/.changeset/steady-zebras-click.md @@ -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. + + + diff --git a/.changeset/sturdy-geese-glide.md b/.changeset/sturdy-geese-glide.md new file mode 100644 index 000000000..e7bdd234a --- /dev/null +++ b/.changeset/sturdy-geese-glide.md @@ -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. diff --git a/.changeset/sturdy-pandas-rest.md b/.changeset/sturdy-pandas-rest.md new file mode 100644 index 000000000..7d2d0e06b --- /dev/null +++ b/.changeset/sturdy-pandas-rest.md @@ -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/.cjs` and `sdk/src/.ts` (or `sdk/src/query/.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//`, `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. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index e699feda5..dc81b8d71 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 67fc79c3b..018c809f9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/PULL_REQUEST_TEMPLATE/enhancement.md b/.github/PULL_REQUEST_TEMPLATE/enhancement.md index d79e11205..4c952ffe6 100644 --- a/.github/PULL_REQUEST_TEMPLATE/enhancement.md +++ b/.github/PULL_REQUEST_TEMPLATE/enhancement.md @@ -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 `` 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 --body "..."`) — or `no-changelog` label applied if not user-facing -- [ ] Documentation updated if behavior or output changed - [ ] No unnecessary dependencies added ## Breaking changes diff --git a/.github/PULL_REQUEST_TEMPLATE/feature.md b/.github/PULL_REQUEST_TEMPLATE/feature.md index 47d232a60..798d0e717 100644 --- a/.github/PULL_REQUEST_TEMPLATE/feature.md +++ b/.github/PULL_REQUEST_TEMPLATE/feature.md @@ -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 `` 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 --body "..."`) -- [ ] Documentation updated — commands, workflows, references, README if applicable - [ ] No unnecessary external dependencies added - [ ] Works on Windows (backslash paths handled) diff --git a/.github/workflows/docs-required.yml b/.github/workflows/docs-required.yml new file mode 100644 index 000000000..05e88bf3f --- /dev/null +++ b/.github/workflows/docs-required.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9aea5be00..ca1bf511d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..f3cc2ed7d --- /dev/null +++ b/AGENTS.md @@ -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`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b998863e..a0570474f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `.cjs` ↔ `.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 `` **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 `` or `` 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.** diff --git a/QUICK-WINS-CONFIRMED-BUGS.md b/QUICK-WINS-CONFIRMED-BUGS.md new file mode 100644 index 000000000..9d58de599 --- /dev/null +++ b/QUICK-WINS-CONFIRMED-BUGS.md @@ -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:` 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:` (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. \ No newline at end of file diff --git a/agents/gsd-intel-updater.md b/agents/gsd-intel-updater.md index 54eb593b4..f51d6ad22 100644 --- a/agents/gsd-intel-updater.md +++ b/agents/gsd-intel-updater.md @@ -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 `, 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. @@ -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 ` 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 ` 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 +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 +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 +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 +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 ` +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 ` +Run: `gsd-tools intel snapshot` This writes `.last-refresh.json` with accurate timestamps and hashes. Do NOT write `.last-refresh.json` manually. diff --git a/bin/install.js b/bin/install.js index 0062e2b52..6f50db6eb 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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 = ''; const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = ''; +// 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-` (hyphen) so Skill(skill="gsd-") 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: or gsd: in the body to the canonical + // hyphen form (gsd-) 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 ` 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 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 ` + * 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 ` 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 -- --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, diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 6a47aa7f8..7c662d9cd 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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" ], diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 181c2beeb..85dfd6d0e 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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 `` 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 `` 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-` (skills-based runtimes) and `$gsd-` (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 (`****`) 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 (`****`) 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 | diff --git a/docs/agents/cjs-sdk-seam.md b/docs/agents/cjs-sdk-seam.md new file mode 100644 index 000000000..3d29276cf --- /dev/null +++ b/docs/agents/cjs-sdk-seam.md @@ -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.` 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--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//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-.mjs +``` + +The generator reads `sdk/src//index.ts` (or `sdk/shared/.manifest.json` for pure-data manifests), produces a generated output file (either `sdk/src/.generated.ts` or `get-shit-done/bin/lib/.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--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/-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 artifact drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node sdk/scripts/check--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 .` 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..ts`. Include the full argument schema and a `handler` reference. + +**Step 2 — Implement the SDK handler** + +Write the handler in `sdk/src/query/.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/-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-.mjs`), freshness check (`check--fresh.mjs`), generated CJS artifact (`.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. diff --git a/docs/discussions/grok-build-support-2026-05.md b/docs/discussions/grok-build-support-2026-05.md new file mode 100644 index 000000000..6bf659336 --- /dev/null +++ b/docs/discussions/grok-build-support-2026-05.md @@ -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 `` 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 `` 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 `` 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 `` 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 `` 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. \ No newline at end of file diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 5a20db37b..b17708ebf 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -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. diff --git a/get-shit-done/bin/lib/cjs-sdk-bridge.cjs b/get-shit-done/bin/lib/cjs-sdk-bridge.cjs new file mode 100644 index 000000000..0e9ef003f --- /dev/null +++ b/get-shit-done/bin/lib/cjs-sdk-bridge.cjs @@ -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 + * (`/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. +// /get-shit-done/bin/lib/cjs-sdk-bridge.cjs +// /sdk/dist/runtime-bridge-sync/index.js +// /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, +}; diff --git a/get-shit-done/bin/lib/command-aliases.generated.cjs b/get-shit-done/bin/lib/command-aliases.generated.cjs index ce67b8146..7c48ef134 100644 --- a/get-shit-done/bin/lib/command-aliases.generated.cjs +++ b/get-shit-done/bin/lib/command-aliases.generated.cjs @@ -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, -}; +}; \ No newline at end of file diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index b8233e192..2b1dcd41c 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -1012,6 +1012,7 @@ function cmdCheckCommit(cwd, raw) { } module.exports = { + determinePhaseStatus, cmdGenerateSlug, cmdCurrentTimestamp, cmdListTodos, diff --git a/get-shit-done/bin/lib/decisions.cjs b/get-shit-done/bin/lib/decisions.cjs index c71a6c2e4..68e3ee959 100644 --- a/get-shit-done/bin/lib/decisions.cjs +++ b/get-shit-done/bin/lib/decisions.cjs @@ -1,48 +1,19 @@ 'use strict'; /** - * Shared parser for CONTEXT.md `` 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 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. * - * - * ## Implementation Decisions - * - * ### Category - * - **D-01:** Decision text - * - **D-02:** Another decision - * - * - * D-IDs outside the block are ignored. Missing block returns []. + * Regenerate: cd sdk && npm run gen:decisions */ -/** - * Parse the 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(/([\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'); diff --git a/get-shit-done/bin/lib/decisions.generated.cjs b/get-shit-done/bin/lib/decisions.generated.cjs new file mode 100644 index 000000000..efb2c3f13 --- /dev/null +++ b/get-shit-done/bin/lib/decisions.generated.cjs @@ -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 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 `` 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 `...` 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(/([\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 `` (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 }; diff --git a/get-shit-done/bin/lib/init-command-router.cjs b/get-shit-done/bin/lib/init-command-router.cjs index b756311e7..ec21ebfd3 100644 --- a/get-shit-done/bin/lib/init-command-router.cjs +++ b/get-shit-done/bin/lib/init-command-router.cjs @@ -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 = { diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 6502da042..672a86484 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -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, diff --git a/get-shit-done/bin/lib/phase-command-router.cjs b/get-shit-done/bin/lib/phase-command-router.cjs index c3db5f14b..1330cf4bd 100644 --- a/get-shit-done/bin/lib/phase-command-router.cjs +++ b/get-shit-done/bin/lib/phase-command-router.cjs @@ -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), + ), }, }); } diff --git a/get-shit-done/bin/lib/phases-command-router.cjs b/get-shit-done/bin/lib/phases-command-router.cjs index 724253ddc..84407869b 100644 --- a/get-shit-done/bin/lib/phases-command-router.cjs +++ b/get-shit-done/bin/lib/phases-command-router.cjs @@ -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)), + ), }, }); } diff --git a/get-shit-done/bin/lib/plan-scan.cjs b/get-shit-done/bin/lib/plan-scan.cjs index 6952f419e..ece997d85 100644 --- a/get-shit-done/bin/lib/plan-scan.cjs +++ b/get-shit-done/bin/lib/plan-scan.cjs @@ -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//*-PLAN.md, *-SUMMARY.md - * Nested (post-#3139): phases//plans/PLAN--*.md, SUMMARY--*.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'); diff --git a/get-shit-done/bin/lib/plan-scan.generated.cjs b/get-shit-done/bin/lib/plan-scan.generated.cjs new file mode 100644 index 000000000..e58004a82 --- /dev/null +++ b/get-shit-done/bin/lib/plan-scan.generated.cjs @@ -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; diff --git a/get-shit-done/bin/lib/roadmap-command-router.cjs b/get-shit-done/bin/lib/roadmap-command-router.cjs index 060443bcb..7f8427f3c 100644 --- a/get-shit-done/bin/lib/roadmap-command-router.cjs +++ b/get-shit-done/bin/lib/roadmap-command-router.cjs @@ -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 = { diff --git a/get-shit-done/bin/lib/runtime-homes.cjs b/get-shit-done/bin/lib/runtime-homes.cjs index 9a4bd7943..4909a4ce5 100644 --- a/get-shit-done/bin/lib/runtime-homes.cjs +++ b/get-shit-done/bin/lib/runtime-homes.cjs @@ -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'); diff --git a/get-shit-done/bin/lib/schema-detect.cjs b/get-shit-done/bin/lib/schema-detect.cjs index 40d800eb6..27cca4b16 100644 --- a/get-shit-done/bin/lib/schema-detect.cjs +++ b/get-shit-done/bin/lib/schema-detect.cjs @@ -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'); diff --git a/get-shit-done/bin/lib/schema-detect.generated.cjs b/get-shit-done/bin/lib/schema-detect.generated.cjs new file mode 100644 index 000000000..b1652a6c9 --- /dev/null +++ b/get-shit-done/bin/lib/schema-detect.generated.cjs @@ -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, +}; diff --git a/get-shit-done/bin/lib/secrets.cjs b/get-shit-done/bin/lib/secrets.cjs index 0c1704251..7e28d4bc3 100644 --- a/get-shit-done/bin/lib/secrets.cjs +++ b/get-shit-done/bin/lib/secrets.cjs @@ -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 `****`; 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'); diff --git a/get-shit-done/bin/lib/secrets.generated.cjs b/get-shit-done/bin/lib/secrets.generated.cjs new file mode 100644 index 000000000..af6ed35c2 --- /dev/null +++ b/get-shit-done/bin/lib/secrets.generated.cjs @@ -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 }; diff --git a/get-shit-done/bin/lib/state-command-router.cjs b/get-shit-done/bin/lib/state-command-router.cjs index 0eadad42a..caca7376d 100644 --- a/get-shit-done/bin/lib/state-command-router.cjs +++ b/get-shit-done/bin/lib/state-command-router.cjs @@ -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( diff --git a/get-shit-done/bin/lib/validate-command-router.cjs b/get-shit-done/bin/lib/validate-command-router.cjs index f97c8e9c1..e38bd8333 100644 --- a/get-shit-done/bin/lib/validate-command-router.cjs +++ b/get-shit-done/bin/lib/validate-command-router.cjs @@ -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 is required for `validate context`'); - return; - } - if (opts['context-window'] === null) { - error('--context-window 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 is required for `validate context`'); + return; + } + if (opts['context-window'] === null) { + error('--context-window 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 = { diff --git a/get-shit-done/bin/lib/verify-command-router.cjs b/get-shit-done/bin/lib/verify-command-router.cjs index 806b2ddd0..e42809f54 100644 --- a/get-shit-done/bin/lib/verify-command-router.cjs +++ b/get-shit-done/bin/lib/verify-command-router.cjs @@ -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 = { diff --git a/get-shit-done/bin/lib/workstream-name-policy.cjs b/get-shit-done/bin/lib/workstream-name-policy.cjs index 7cc4cf20e..61c58e7e8 100644 --- a/get-shit-done/bin/lib/workstream-name-policy.cjs +++ b/get-shit-done/bin/lib/workstream-name-policy.cjs @@ -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'); diff --git a/get-shit-done/bin/lib/workstream-name-policy.generated.cjs b/get-shit-done/bin/lib/workstream-name-policy.generated.cjs new file mode 100644 index 000000000..27f1ec23e --- /dev/null +++ b/get-shit-done/bin/lib/workstream-name-policy.generated.cjs @@ -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, +}; diff --git a/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md b/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md index 2081502e2..bb4066e01 100644 --- a/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md +++ b/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md @@ -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`, diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index dc21ea48c..87f219516 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -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 <&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 <&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 `, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd `, `--ingest `, `--ingest-format `, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`). +Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase `, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd `, `--ingest `, `--ingest-format `, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--force` (override closed-phase gate, see §1.5)). **`--research-phase ` — research-only mode (#3042 + #3044).** When this flag is present, parse `` 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. diff --git a/get-shit-done/workflows/sync-skills.md b/get-shit-done/workflows/sync-skills.md index d22447cf1..a828b67e8 100644 --- a/get-shit-done/workflows/sync-skills.md +++ b/get-shit-done/workflows/sync-skills.md @@ -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 diff --git a/package-lock.json b/package-lock.json index ad5726444..544809ac1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index dc0835f1c..0ee97b037 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index 47e5bc131..c82edef58 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -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//*` → `hooks/dist//*` 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-/`), no other builder touches it — so // rmSync with recursive:true is safe and leaves no race window. diff --git a/scripts/changeset/parse.cjs b/scripts/changeset/parse.cjs index b35ceacff..4787b0cfc 100644 --- a/scripts/changeset/parse.cjs +++ b/scripts/changeset/parse.cjs @@ -9,9 +9,15 @@ * --- * * - * 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 `` + * (#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: ``. The reason is the *required* human +// audit trail — without it the exemption has no paper-trail value, so a bare +// `` or empty `` 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]*\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 }; diff --git a/scripts/fix-slash-commands.cjs b/scripts/fix-slash-commands.cjs index 079751f12..612f73f53 100644 --- a/scripts/fix-slash-commands.cjs +++ b/scripts/fix-slash-commands.cjs @@ -1,10 +1,18 @@ 'use strict'; /** - * One-shot script: replace retired /gsd- with /gsd: 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- → /gsd: + * (keeps monorepo sources, docs, and workflows in the active colon form). + * - Reverse direction (transformContentToHyphen): /gsd: / gsd: → gsd- + * (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(`(?` / `gsd:` 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 +}; diff --git a/scripts/lint-docs-required.cjs b/scripts/lint-docs-required.cjs new file mode 100755 index 000000000..752685b03 --- /dev/null +++ b/scripts/lint-docs-required.cjs @@ -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 `` +// 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 \`\` 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, +}; diff --git a/scripts/lint-shared-module-handsync.cjs b/scripts/lint-shared-module-handsync.cjs new file mode 100644 index 000000000..174bde6d9 --- /dev/null +++ b/scripts/lint-shared-module-handsync.cjs @@ -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/.ts, sdk/src/query/.ts, or + * sdk/src//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 /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} pair identities in cooperatingSiblings + */ +const cooperatingPairs = new Set( + (allowlist.cooperatingSiblings || []).map((e) => `${e.cjs}::${e.ts}`) +); + +/** @type {Map} 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/.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//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/.ts (exactly: query/.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 or --cjs-dir 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 or --sdk-src 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//index.ts as the\n' + + ' source-of-truth, write a generator script (sdk/scripts/gen-.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(); diff --git a/scripts/shared-module-handsync-allowlist.json b/scripts/shared-module-handsync-allowlist.json new file mode 100644 index 000000000..a5829603d --- /dev/null +++ b/scripts/shared-module-handsync-allowlist.json @@ -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": [] +} diff --git a/sdk/package-lock.json b/sdk/package-lock.json index cf1ca2662..07056e4f2 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -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": { diff --git a/sdk/package.json b/sdk/package.json index 9bd6c29b0..78814f24e 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -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", diff --git a/sdk/scripts/check-decisions-fresh.mjs b/sdk/scripts/check-decisions-fresh.mjs new file mode 100644 index 000000000..338dc37ae --- /dev/null +++ b/sdk/scripts/check-decisions-fresh.mjs @@ -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); +} diff --git a/sdk/scripts/check-plan-scan-fresh.mjs b/sdk/scripts/check-plan-scan-fresh.mjs new file mode 100644 index 000000000..4f01d2e15 --- /dev/null +++ b/sdk/scripts/check-plan-scan-fresh.mjs @@ -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); +} diff --git a/sdk/scripts/check-schema-detect-fresh.mjs b/sdk/scripts/check-schema-detect-fresh.mjs new file mode 100644 index 000000000..7d53d3a03 --- /dev/null +++ b/sdk/scripts/check-schema-detect-fresh.mjs @@ -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); +} diff --git a/sdk/scripts/check-secrets-fresh.mjs b/sdk/scripts/check-secrets-fresh.mjs new file mode 100644 index 000000000..1e82977ea --- /dev/null +++ b/sdk/scripts/check-secrets-fresh.mjs @@ -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); +} diff --git a/sdk/scripts/check-workstream-name-policy-fresh.mjs b/sdk/scripts/check-workstream-name-policy-fresh.mjs new file mode 100644 index 000000000..2db5d6475 --- /dev/null +++ b/sdk/scripts/check-workstream-name-policy-fresh.mjs @@ -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); +} diff --git a/sdk/scripts/gen-decisions.mjs b/sdk/scripts/gen-decisions.mjs new file mode 100644 index 000000000..f3e83e54e --- /dev/null +++ b/sdk/scripts/gen-decisions.mjs @@ -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 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); + }); +} diff --git a/sdk/scripts/gen-plan-scan.mjs b/sdk/scripts/gen-plan-scan.mjs new file mode 100644 index 000000000..7955288d6 --- /dev/null +++ b/sdk/scripts/gen-plan-scan.mjs @@ -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); + }); +} diff --git a/sdk/scripts/gen-project-root.mjs b/sdk/scripts/gen-project-root.mjs index 2967ef5dc..13968ee8e 100644 --- a/sdk/scripts/gen-project-root.mjs +++ b/sdk/scripts/gen-project-root.mjs @@ -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); diff --git a/sdk/scripts/gen-schema-detect.mjs b/sdk/scripts/gen-schema-detect.mjs new file mode 100644 index 000000000..9fef117ce --- /dev/null +++ b/sdk/scripts/gen-schema-detect.mjs @@ -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 = [` or `const = {` + * 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); + }); +} diff --git a/sdk/scripts/gen-secrets.mjs b/sdk/scripts/gen-secrets.mjs new file mode 100644 index 000000000..cabd38e79 --- /dev/null +++ b/sdk/scripts/gen-secrets.mjs @@ -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); + }); +} diff --git a/sdk/scripts/gen-state-document.ts b/sdk/scripts/gen-state-document.ts index 874d23090..0f09855c0 100644 --- a/sdk/scripts/gen-state-document.ts +++ b/sdk/scripts/gen-state-document.ts @@ -132,9 +132,10 @@ async function main(): Promise { } // 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); diff --git a/sdk/scripts/gen-workstream-inventory-builder.mjs b/sdk/scripts/gen-workstream-inventory-builder.mjs index 26b8da3a5..0c8f1fdad 100644 --- a/sdk/scripts/gen-workstream-inventory-builder.mjs +++ b/sdk/scripts/gen-workstream-inventory-builder.mjs @@ -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); diff --git a/sdk/scripts/gen-workstream-name-policy.mjs b/sdk/scripts/gen-workstream-name-policy.mjs new file mode 100644 index 000000000..d531f53b5 --- /dev/null +++ b/sdk/scripts/gen-workstream-name-policy.mjs @@ -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); + }); +} diff --git a/sdk/src/golden/golden.integration.test.ts b/sdk/src/golden/golden.integration.test.ts index 47216490d..a319a5e99 100644 --- a/sdk/src/golden/golden.integration.test.ts +++ b/sdk/src/golden/golden.integration.test.ts @@ -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; - const gsdData = gsdOutput as Record; - 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 }); + } + }); + }); }); diff --git a/sdk/src/gsd-transport.test.ts b/sdk/src/gsd-transport.test.ts index 2a0f3a2b0..2d6095198 100644 --- a/sdk/src/gsd-transport.test.ts +++ b/sdk/src/gsd-transport.test.ts @@ -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(); }); }); diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index 26f436b66..d944e3ac5 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -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)}`, ); } diff --git a/sdk/src/query-raw-output-projection.ts b/sdk/src/query-raw-output-projection.ts index 7dcd3d6e1..c0474393e 100644 --- a/sdk/src/query-raw-output-projection.ts +++ b/sdk/src/query-raw-output-projection.ts @@ -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).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).section; + if (typeof section === 'string') return section; + } + return ''; + } + if (typeof data === 'string') { return data; } diff --git a/sdk/src/query/command-aliases.generated.ts b/sdk/src/query/command-aliases.generated.ts index 17268030e..6c79b91e6 100644 --- a/sdk/src/query/command-aliases.generated.ts +++ b/sdk/src/query/command-aliases.generated.ts @@ -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 }, diff --git a/sdk/src/query/command-family-handlers.ts b/sdk/src/query/command-family-handlers.ts index 97f0df283..11470e8c5 100644 --- a/sdk/src/query/command-family-handlers.ts +++ b/sdk/src/query/command-family-handlers.ts @@ -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 { 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(); + 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; + // 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 = {}; + 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) || {}; + const filteredGit: Record = {}; + for (const [k, v] of Object.entries(manifestGit)) { + if (GIT_KEYS_OMITTED_FROM_INIT.has(k)) continue; + filteredGit[k] = v; + } + const defaults: Record = { - 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 ────────────────────────────────────────────────── diff --git a/sdk/src/query/config-query.test.ts b/sdk/src/query/config-query.test.ts index 5faa8ede6..9722861d0 100644 --- a/sdk/src/query/config-query.test.ts +++ b/sdk/src/query/config-query.test.ts @@ -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).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' diff --git a/sdk/src/query/config-query.ts b/sdk/src/query/config-query.ts index e5b8c1c17..09668f89e 100644 --- a/sdk/src/query/config-query.ts +++ b/sdk/src/query/config-query.ts @@ -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> = 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; try { config = JSON.parse(raw) as Record; } 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)[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).runtime === 'string' + ? ((config as Record).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 } }; }; diff --git a/sdk/src/query/decisions.test.ts b/sdk/src/query/decisions.test.ts index ca7b5637e..ff8cdac20 100644 --- a/sdk/src/query/decisions.test.ts +++ b/sdk/src/query/decisions.test.ts @@ -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 = ` - 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 `; @@ -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', () => { diff --git a/sdk/src/query/decisions.ts b/sdk/src/query/decisions.ts index b8edda27d..9c5be6296 100644 --- a/sdk/src/query/decisions.ts +++ b/sdk/src/query/decisions.ts @@ -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; diff --git a/sdk/src/query/frontmatter-mutation.ts b/sdk/src/query/frontmatter-mutation.ts index 36948033f..b5e1b4c7d 100644 --- a/sdk/src/query/frontmatter-mutation.ts +++ b/sdk/src/query/frontmatter-mutation.ts @@ -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 { diff --git a/sdk/src/query/frontmatter.ts b/sdk/src/query/frontmatter.ts index 3a4b87049..6ff4f4c50 100644 --- a/sdk/src/query/frontmatter.ts +++ b/sdk/src/query/frontmatter.ts @@ -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 --field `; 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]; diff --git a/sdk/src/query/helpers.ts b/sdk/src/query/helpers.ts index c23a8602b..3f41a6615 100644 --- a/sdk/src/query/helpers.ts +++ b/sdk/src/query/helpers.ts @@ -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`. */ diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts index 4f7d1473f..cc323cf73 100644 --- a/sdk/src/query/init-complex.ts +++ b/sdk/src/query/init-complex.ts @@ -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 diff --git a/sdk/src/query/init.test.ts b/sdk/src/query/init.test.ts index 16bb97a00..67113e603 100644 --- a/sdk/src/query/init.test.ts +++ b/sdk/src/query/init.test.ts @@ -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; + 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', '---', 'x'].join('\n'), + ); + + const result = await initPlanPhase(['10'], tmpDir); + const data = result.data as Record; + 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; + 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; + 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 diff --git a/sdk/src/query/init.ts b/sdk/src/query/init.ts index c415910f9..771238895 100644 --- a/sdk/src/query/init.ts +++ b/sdk/src/query/init.ts @@ -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 | null, roadmapPhase: Record | null, - projectDir: string, - workstream?: string, + _projectDir: string, + _workstream?: string, ): Promise { - 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 = { 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).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).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).workflow as Record | 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> = []; diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts index 1dc4e83f3..d2a4176d2 100644 --- a/sdk/src/query/phase-lifecycle.ts +++ b/sdk/src/query/phase-lifecycle.ts @@ -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 ; 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 + // (?#{2,4}) records the hash count of the header being removed and the + // lookahead requires the same depth via \k(?!#). 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?(?#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k(?!#)\\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 `(? + `${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
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
" fails + // when the current milestone itself is wrapped in
... + //
— 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(); + 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; + const wf = rawConfig.workflow as Record | 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 = { + 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 } }; diff --git a/sdk/src/query/phase-roadmap-mutation.ts b/sdk/src/query/phase-roadmap-mutation.ts index 6b62f2405..2bb53e62e 100644 --- a/sdk/src/query/phase-roadmap-mutation.ts +++ b/sdk/src/query/phase-roadmap-mutation.ts @@ -5,7 +5,21 @@ import { acquireStateLock, releaseStateLock } from './state-mutation.js'; /** * Replace a pattern only in the current milestone section of ROADMAP.md. * - * Port of replaceInCurrentMilestone from core.cjs line 1197-1206. + * Port of replaceInCurrentMilestone from core.cjs lines 1013-1022. + * + * Semantics (byte-for-byte CJS parity): + * • No `` in the content → plain `content.replace(pattern, replacement)`. + * • Otherwise → split at the last `` and replace only in the + * content AFTER it. + * + * INTENTIONALLY DOES NOT fall back to "search the last
block when + * the after-slice didn't match." That fallback existed in an earlier SDK + * port and would silently corrupt shipped-milestone content when the current + * milestone is itself wrapped in `
...
` and there's + * nothing after the close tag. CJS callers handle the "milestone inside + *
" case by passing the unscoped `content.replace(...)` directly + * (see phase.cjs:1080 for plan-count update). Keep this function in + * lockstep with core.cjs — deviations are how bug-2005 slipped in. */ export function replaceInCurrentMilestone( content: string, @@ -19,30 +33,7 @@ export function replaceInCurrentMilestone( const offset = lastDetailsClose + '
'.length; const before = content.slice(0, offset); const after = content.slice(offset); - - const replacedAfter = after.replace(pattern, replacement); - if (replacedAfter !== after) { - return before + replacedAfter; - } - - const detailsBlockRe = /
[\s\S]*?<\/details>/gi; - const spans: { start: number; end: number; text: string }[] = []; - let m: RegExpExecArray | null; - while ((m = detailsBlockRe.exec(content)) !== null) { - spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); - } - - if (spans.length === 0) { - return content.replace(pattern, replacement); - } - - const lastSpan = spans[spans.length - 1]; - const updatedLastBlock = lastSpan.text.replace(pattern, replacement); - return ( - content.slice(0, lastSpan.start) + - updatedLastBlock + - content.slice(lastSpan.end) - ); + return before + after.replace(pattern, replacement); } /** diff --git a/sdk/src/query/phase.ts b/sdk/src/query/phase.ts index 0a7511416..d6e23839d 100644 --- a/sdk/src/query/phase.ts +++ b/sdk/src/query/phase.ts @@ -17,6 +17,7 @@ * ``` */ +import { existsSync } from 'node:fs'; import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { GSDError, ErrorClassification } from '../errors.js'; @@ -47,10 +48,54 @@ interface PhaseInfo { has_verification: boolean; has_reviews: boolean; archived?: string; + /** + * #2893 — non-canonical plan filename warning (singular). Present only when + * a plan-shaped file in this phase dir is not the canonical + * `{padded_phase}-{NN}-PLAN.md` shape; the executor surfaces this so users + * see a loud signal instead of plan_count: 0 with no clue why. + */ + warning?: string; } // ─── Internal helpers ────────────────────────────────────────────────────── +/** + * #2893 — canonical plan filename predicate and the diagnostic "looks like a + * plan but isn't canonical" net. Centralised so every read site (find-phase, + * phase-plan-index, phases list --type plans) emits the same warning message. + * + * Mirrors get-shit-done/bin/lib/phase.cjs lines 17–52. + */ +export const isCanonicalPlanFile = (f: string): boolean => f.endsWith('-PLAN.md') || f === 'PLAN.md'; + +const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i; +const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i; +const looksLikePlanFile = (f: string): boolean => + /\.md$/i.test(f) + && /PLAN/i.test(f) + && !PLAN_OUTLINE_RE.test(f) + && !PLAN_PRE_BOUNCE_RE.test(f); + +/** + * Build the canonical "non-canonical plan files" warning string used by every + * SDK read site. Returns null when there are no offenders. + * + * Format mirrors describeNonCanonicalPlans in phase.cjs so consumers see the + * same message regardless of which entry point they call. + */ +export function describeNonCanonicalPlans(dirFiles: string[], matchedFiles: string[]): string | null { + const matched = new Set(matchedFiles); + const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f)); + if (offenders.length === 0) return null; + return ( + `Found ${offenders.length} plan-shaped file(s) in this phase that don't match the canonical ` + + `naming convention "{padded_phase}-{NN}-PLAN.md" (or bare "PLAN.md") and were skipped: ` + + offenders.map((f) => `"${f}"`).join(', ') + + `. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` + + `See agents/gsd-planner.md write_phase_prompt step for the full contract.` + ); +} + /** * Get file stats for a phase directory. * @@ -63,15 +108,17 @@ async function getPhaseFileStats(phaseDir: string): Promise<{ hasContext: boolean; hasVerification: boolean; hasReviews: boolean; + allFiles: string[]; }> { const files = await readdir(phaseDir); return { - plans: files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'), + plans: files.filter(isCanonicalPlanFile), summaries: files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'), hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'), hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'), hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'), hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'), + allFiles: files, }; } @@ -111,9 +158,12 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; const phaseDir = join(baseDir, match); - const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = await getPhaseFileStats(phaseDir); + const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews, allFiles } = await getPhaseFileStats(phaseDir); const plans = unsortedPlans.sort(); const summaries = unsortedSummaries.sort(); + // #2893 parity — emit the same warning shape as cmdPhasePlanIndex when a + // plan-shaped file would be skipped by the canonical filter. + const planNamingWarning = describeNonCanonicalPlans(allFiles, plans); const completedPlanIds = new Set( summaries.flatMap((s) => { @@ -128,7 +178,7 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical); }); - return { + const result: PhaseInfo = { found: true, directory: toPosixPath(join(relBase, match)), phase_number: phaseNumber, @@ -142,6 +192,8 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st has_verification: hasVerification, has_reviews: hasReviews, }; + if (planNamingWarning) result.warning = planNamingWarning; + return result; } catch { return null; } @@ -180,23 +232,15 @@ export const findPhase: QueryHandler = async (args, projectDir, workstream) => { const phasesDir = planningPaths(projectDir, workstream).phases; const normalized = normalizePhaseName(phase); - const notFound: PhaseInfo = { - found: false, - directory: null, - phase_number: null, - phase_name: null, - phase_slug: null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - has_reviews: false, - }; + // Track every directory we actually probed so the not-found payload can + // surface them to the caller for diagnostics (#3164 acceptance criterion). + const searchedDirectories: string[] = []; // Search current phases first const relPhasesDir = relPlanningPath(workstream) + '/phases'; + if (existsSync(phasesDir)) { + searchedDirectories.push(relPhasesDir); + } const current = await searchPhaseInDir(phasesDir, relPhasesDir, normalized); if (current) return { data: current }; @@ -215,6 +259,7 @@ export const findPhase: QueryHandler = async (args, projectDir, workstream) => { const version = versionMatch ? versionMatch[1] : archiveName; const archivePath = join(milestonesDir, archiveName); const relBase = '.planning/milestones/' + archiveName; + searchedDirectories.push(relBase); const result = await searchPhaseInDir(archivePath, relBase, normalized); if (result) { result.archived = version; @@ -223,6 +268,21 @@ export const findPhase: QueryHandler = async (args, projectDir, workstream) => { } } catch { /* milestones dir doesn't exist */ } + const notFound: PhaseInfo & { searched_directories: string[] } = { + found: false, + directory: null, + phase_number: null, + phase_name: null, + phase_slug: null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + has_reviews: false, + searched_directories: searchedDirectories, + }; return { data: notFound }; }; @@ -285,13 +345,11 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) // Get all files in phase directory const phaseFiles = await readdir(phaseDir); - const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); + const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort(); const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - const nonCanonicalPlanFiles = phaseFiles.filter((f) => ( - f.toLowerCase().endsWith('.md') - && /(^|-)plan(-|\.)/i.test(f) - && !(f.endsWith('-PLAN.md') || f === 'PLAN.md') - )).sort(); + // #2893 parity — same diagnostic format as find-phase / phases-list. Use the + // centralised helper so the message shape never drifts between read sites. + const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles); // Build set of plan IDs with summaries — match the planId derivation logic const completedPlanIds = new Set( @@ -483,10 +541,6 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) let hasCheckpoints = false; const warnings: string[] = []; - if (nonCanonicalPlanFiles.length > 0) { - warnings.push(`Ignored noncanonical plan files: ${nonCanonicalPlanFiles.join(', ')}`); - } - // Surface unresolved depends_on references from Pass 2 — without this, a dropped // short-form edge silently collapses the dependent plan into wave 1 and the only // signal is a misleading "declared wave: N but depends_on DAG places it in wave 1" @@ -542,6 +596,12 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) incomplete, has_checkpoints: hasCheckpoints, }; + // #2893 — non-canonical plan filename warning is a singular `warning` field; + // see describeNonCanonicalPlans above. Other diagnostics (unresolved deps, + // wave-declaration mismatches) flow through the existing `warnings` array. + if (planNamingWarning) { + result['warning'] = planNamingWarning; + } if (warnings.length > 0) { result['warnings'] = warnings; } diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index 6e3f14cad..04a77f0fd 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -309,6 +309,15 @@ export async function extractCurrentMilestone(content: string, projectDir: strin const matchedVersion = m[1]; // Skip headings that reference the same version (e.g. "## v2.0 Phase Details"). if (matchedVersion && currentVersionStr && matchedVersion === currentVersionStr) continue; + // Bug #2787: skip "heading-like" lines that sit inside a fenced code + // block. GFM fences toggle on a line starting with ``` or ~~~ (with + // optional info string); the closing fence must be the same char with + // no info string. Walk forward from the start of restContent up to + // the match index, toggling fenceChar. If we're inside a fence at the + // match, ignore this match and continue scanning. Without this, a + // line like `# Ops runbook — v1.0 compat` inside ```bash truncates the + // milestone slice and hides every phase that follows. + if (isInsideFencedCodeBlock(restContent, m.index)) continue; sectionEnd = sectionStart + sectionMatch[0].length + m.index; break; } @@ -366,6 +375,46 @@ export async function extractCurrentMilestone(content: string, projectDir: strin return content.slice(sectionStart, sectionEnd) + phaseDetailsTail; } +/** + * Return true when `offset` falls inside an open GFM fenced code block + * within the provided `content`. + * + * GFM fence semantics (bug #2787): + * - Opening fence: a line starting with at least 3 backticks or 3 tildes, + * optionally followed by an info string (e.g. ```bash, ~~~markdown). + * - Closing fence: a line starting with at least 3 of the SAME char as + * the opener, with NO info string — so ```js inside an open ```text + * fence does NOT close it. + * + * We walk lines from the start of `content` to `offset`, toggling a + * `fenceChar` cursor on each fence boundary. Returns true when the + * cursor is non-null at `offset`. + */ +function isInsideFencedCodeBlock(content: string, offset: number): boolean { + let fenceChar: '`' | '~' | null = null; + let lineStart = 0; + for (let i = 0; i <= offset; i++) { + if (i === content.length || content[i] === '\n') { + const line = content.slice(lineStart, i); + const openMatch = line.match(/^(`{3,}|~{3,})(\s*)([^\n]*)$/); + if (openMatch) { + const fenceRun = openMatch[1]!; + const ch = fenceRun[0] === '`' ? '`' : '~'; + const info = openMatch[3]!.trim(); + if (fenceChar === null) { + // Opening fence — info string allowed. + fenceChar = ch; + } else if (ch === fenceChar && info.length === 0) { + // Closing fence must match opener and carry no info string. + fenceChar = null; + } + } + lineStart = i + 1; + } + } + return fenceChar !== null; +} + // ─── Next-milestone helpers (issue #2497) ───────────────────────────────── /** @@ -484,41 +533,89 @@ export async function extractNextMilestoneSection( // ─── Internal helpers ───────────────────────────────────────────────────── +/** + * Padding-tolerant regex fragment for a phase number — emits `0*` so + * the fragment matches both `Phase 3` and `Phase 03` (bug #2391 / #3537). + * + * Mirrors `phaseMarkdownRegexSource` in core.cjs and the local copy in + * roadmap-update-plan-progress.ts. Falls back to `escapeRegex(phaseNum)` for + * non-numeric IDs (custom project codes like `PROJ-42`). + */ +export function phaseMarkdownRegexSource(phaseNum: string): string { + const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i); + if (!match) return escapeRegex(phaseNum); + + const integer = match[1]!.replace(/^0+/, '') || '0'; + const letter = match[2] ? escapeRegex(match[2]) : ''; + const decimal = match[3] ? escapeRegex(match[3]) : ''; + return `0*${escapeRegex(integer)}${letter}${decimal}`; +} + +/** + * #3599 (parity with core.cjs phaseMarkdownRegexSourceExact, lines 691-708): + * when the caller passed a project-code-prefixed ID like `PROJ-42`, return + * the exact-escaped form so the caller can search the ROADMAP for + * `### Phase PROJ-42:` BEFORE falling back to the padding-tolerant numeric + * form. Returns null when the input has no project-code prefix — in that + * case `phaseMarkdownRegexSource` is the only form the caller needs. + * + * Two-pass at the call site preserves the #3537 contract (`CK-01` directory + * names mapping to `Phase 1:` prose) while letting `PROJ-42` resolve to its + * own prefixed heading without cross-matching a bare `### Phase 42:` that + * happens to share the trailing integer. + */ +export function phaseMarkdownRegexSourceExact(phaseNum: string): string | null { + const raw = String(phaseNum); + if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw)) return null; + return escapeRegex(raw); +} + /** * Search for a phase section in roadmap content. * * Port of searchPhaseInContent from roadmap.cjs lines 14-73. */ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSection | null { - // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name + // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name. + // Uses the padding-tolerant fragment so zero-padded inputs ("03") match + // unpadded ROADMAP headings ("### Phase 3:"). See #2391 / #3537. + // Capture group 1 = the as-written phase token from the heading so callers + // get the canonical form (matching the ROADMAP source-of-truth), not the + // padded input the user typed. Without this, `roadmap get-phase 02.7` + // and `roadmap get-phase 2.7` produce divergent payloads for the same + // heading, breaking bug-3537 parity. const phasePattern = new RegExp( - `#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, + `#{2,4}\\s*Phase\\s+(${escapedPhase}):\\s*([^\\n]+)`, 'i' ); const headerMatch = content.match(phasePattern); if (!headerMatch) { - // Fallback: check if phase exists in summary list but missing detail section + // Fallback: check if phase exists in summary list but missing detail section. + // Same canonical-token capture: surface the as-written checklist form. const checklistPattern = new RegExp( - `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`, + `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+(${escapedPhase}):\\s*([^*]+)\\*\\*`, 'i' ); const checklistMatch = content.match(checklistPattern); if (checklistMatch) { + const canonicalChecklistPhase = checklistMatch[1]; return { found: false, - phase_number: phaseNum, - phase_name: checklistMatch[1].trim(), + phase_number: canonicalChecklistPhase, + phase_name: checklistMatch[2].trim(), error: 'malformed_roadmap', - message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`, + message: `Phase ${canonicalChecklistPhase} exists in summary list but missing "### Phase ${canonicalChecklistPhase}:" detail section. ROADMAP.md needs both formats.`, }; } return null; } - const phaseName = headerMatch[1].trim(); + const canonicalPhaseNum = headerMatch[1]; + const phaseName = headerMatch[2].trim(); const headerIndex = headerMatch.index!; // Find the end of this section (next ## or ### phase header, or end of file) @@ -546,9 +643,13 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s ? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean) : []; + // Suppress unused-arg warning — `phaseNum` is retained as the function + // signature so future callers can reintroduce input-mirroring if needed. + void phaseNum; + return { found: true, - phase_number: phaseNum, + phase_number: canonicalPhaseNum, phase_name: phaseName, goal, mode, @@ -609,14 +710,38 @@ export const roadmapGetPhase: QueryHandler = async (args, projectDir, workstream } const milestoneContent = await extractCurrentMilestone(rawContent, projectDir, workstream); - const escapedPhase = escapeRegex(phaseNum); - - // Search the current milestone slice first, then fall back to full roadmap. const fullContent = stripShippedMilestones(rawContent); - const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum); + + // Two-pass lookup (parity with bin/lib/roadmap.cjs #3599 path): if the input + // carries a project-code prefix like `PROJ-42`, try the EXACT escaped form + // first so we match `### Phase PROJ-42:` without cross-matching `### Phase 42:`. + // Only fall back to the padding-tolerant numeric form (which strips the + // prefix per the #3537 contract for CK-01 → Phase 1 directory layout) when + // the exact form misses. + const exactEscaped = phaseMarkdownRegexSourceExact(phaseNum); + // Padding-tolerant fragment (bug #2391): caller may pass "03" — match against + // unpadded ROADMAP headings ("Phase 3:") without forcing the caller to normalize. + const numericEscaped = phaseMarkdownRegexSource(phaseNum); + + // Try exact-prefixed match first when applicable. + let milestoneResult: PhaseSection | null = null; + let fallbackFromFullContent: PhaseSection | null = null; + if (exactEscaped) { + milestoneResult = searchPhaseInContent(milestoneContent, exactEscaped, phaseNum); + if (!milestoneResult || milestoneResult.error) { + fallbackFromFullContent = searchPhaseInContent(fullContent, exactEscaped, phaseNum); + } + } + // Padding-tolerant fallback (#3537) — also covers the no-prefix case. + if (!milestoneResult || milestoneResult.error) { + milestoneResult = milestoneResult || searchPhaseInContent(milestoneContent, numericEscaped, phaseNum); + } + if (!fallbackFromFullContent) { + fallbackFromFullContent = searchPhaseInContent(fullContent, numericEscaped, phaseNum); + } const result = (milestoneResult && !milestoneResult.error) ? milestoneResult - : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult; + : fallbackFromFullContent || milestoneResult; if (!result) { return { data: { found: false, phase_number: phaseNum } }; @@ -670,6 +795,12 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i); const depends_on = dependsMatch ? dependsMatch[1].trim() : null; + // **Mode:** field — vertical-MVP slice flag per CONTEXT.md "MVP Mode" + // glossary. Pattern mirrors the roadmapGetPhase extraction above so the + // analyze output surfaces the same value the get-phase handler returns. + const modeMatchPhase = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i); + const mode = modeMatchPhase ? modeMatchPhase[1].trim().toLowerCase() : null; + // Check completion on disk const normalized = normalizePhaseName(phaseNum); let diskStatus = 'no_directory'; @@ -714,6 +845,7 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream name: phaseName, goal, depends_on, + mode, plan_count: planCount, summary_count: summaryCount, has_context: hasContext, @@ -788,12 +920,21 @@ export const roadmapAnnotateDependencies: QueryHandler = async (args, projectDir const { spawnSync } = await import('node:child_process'); const toolsPath = resolveGsdToolsPath(projectDir); + // CRITICAL: set GSD_SDK_NESTED=1 so the CJS router in the child process + // detects nesting and routes directly to cmdRoadmapAnnotateDependencies + // instead of dispatching back through executeForCjs. Without this guard, + // SDK→spawn(gsd-tools)→router→SDK→spawn(gsd-tools)→… loops until the + // synckit 15s timeout fires and bug-3537's annotate test surfaces a + // misleading "code=null" failure. + const childEnv: NodeJS.ProcessEnv = { ...process.env, GSD_SDK_NESTED: '1' }; + const result = spawnSync(process.execPath, [toolsPath, 'roadmap', 'annotate-dependencies', phase], { cwd: projectDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000, maxBuffer: 1024 * 1024, + env: childEnv, }); if (result.error) { diff --git a/sdk/src/query/state-mutation.test.ts b/sdk/src/query/state-mutation.test.ts index bef63a2a1..e4c69b08d 100644 --- a/sdk/src/query/state-mutation.test.ts +++ b/sdk/src/query/state-mutation.test.ts @@ -1147,7 +1147,12 @@ describe('statePrune current phase extraction (#3471)', () => { if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); }); - it('uses frontmatter progress.completed_phases when body Current Phase field is absent', async () => { + it('reads Current Phase from body text (CJS-aligned); frontmatter progress fields are not used', async () => { + // Phase 6 alignment: SDK now uses stateExtractField(content, 'Current Phase') + // as the primary/only source, matching CJS state.cjs:1615. Frontmatter + // progress.completed_phases is no longer consulted. + // STATE.md below has progress.completed_phases:12 but no body "Current Phase:" + // field → currentPhase = 0 → cutoff = -3 ≤ 0 → "Only 0 phases" (no-op). const stateContent = `--- gsd_state_version: 1.0 milestone: v1.1 @@ -1171,13 +1176,18 @@ Phase 12 execution in progress. const result = await statePrune(['--keep-recent', '3', '--dry-run'], tmpDir); const data = result.data as Record; + // No body "Current Phase:" field → defaults to 0 → cutoff ≤ 0 → early exit. expect(data.pruned).toBe(false); - expect(data.dry_run).toBe(true); - expect(data.cutoff_phase).toBe(9); - expect(data.reason).toBeUndefined(); + expect(typeof data.reason).toBe('string'); + expect(String(data.reason)).toContain('Only 0 phases'); + expect(data.dry_run).toBeUndefined(); + expect(data.cutoff_phase).toBeUndefined(); }); it('returns a targeted reason when no current phase source can be parsed', async () => { + // Phase 6 alignment: when no body "Current Phase:" field exists, currentPhase + // defaults to 0 (like CJS `parseInt(...) || 0`). The reason message matches + // CJS: "Only 0 phases — nothing to prune with --keep-recent N". const stateContent = `--- gsd_state_version: 1.0 milestone: v1.1 @@ -1193,6 +1203,8 @@ status: executing expect(data.pruned).toBe(false); expect(typeof data.reason).toBe('string'); - expect(String(data.reason)).toContain('Could not determine current phase'); + // Matches CJS: "Only 0 phases — nothing to prune with --keep-recent 3" + expect(String(data.reason)).toContain('Only 0 phases'); + expect(String(data.reason)).toContain('nothing to prune'); }); }); diff --git a/sdk/src/query/state-mutation.ts b/sdk/src/query/state-mutation.ts index 8ba3f80c8..d9355bc33 100644 --- a/sdk/src/query/state-mutation.ts +++ b/sdk/src/query/state-mutation.ts @@ -21,6 +21,7 @@ import { open, unlink, stat, readFile, writeFile, readdir } from 'node:fs/promises'; import { constants, unlinkSync, existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, + realpathSync, } from 'node:fs'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { GSDError, ErrorClassification } from '../errors.js'; @@ -34,7 +35,8 @@ import { normalizeMd, } from './helpers.js'; import { buildStateFrontmatter, getMilestonePhaseFilter } from './state.js'; -import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } from './state-document.js'; +import { scanPhasePlans } from './plan-scan.js'; +import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, computeProgressPercent } from './state-document.js'; import type { QueryHandler } from './utils.js'; const PROGRESS_FRONTMATTER_FIELDS = new Set(['Progress', 'Total Plans in Phase', 'Total Phases']); @@ -90,14 +92,26 @@ function readTextArgOrFile( if (!filePath) { return (value ?? '').trim(); } - const root = resolve(projectDir); - const resolved = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath); - const rel = relative(root, resolved); + // Resolve symlinks on both the project root and the target path before + // comparing — matches CJS `validatePath` in security.cjs. On macOS, + // `os.tmpdir()` returns `/var/folders/...` but the realpath is + // `/private/var/folders/...`; without realpath normalization, the + // `relative()` check sees `/private/var/...` vs `/var/...` as different + // tree roots and rejects safe in-project files. Symlink resolution falls + // back to logical resolve() when the path doesn't exist yet (e.g., file + // about to be created). + function realpathOrResolve(p: string): string { + try { return realpathSync(p); } catch { return resolve(p); } + } + const resolvedBase = realpathOrResolve(resolve(projectDir)); + const targetLogical = isAbsolute(filePath) ? resolve(filePath) : resolve(resolvedBase, filePath); + const resolvedTarget = realpathOrResolve(targetLogical); + const rel = relative(resolvedBase, resolvedTarget); if (rel.startsWith('..') || isAbsolute(rel)) { throw new Error(`${label} path rejected: outside project directory`); } try { - return readFileSync(resolved, 'utf-8').trimEnd(); + return readFileSync(resolvedTarget, 'utf-8').trimEnd(); } catch { throw new Error(`${label} file not found: ${filePath}`); } @@ -307,6 +321,18 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) => throw new GSDError('field and value required for state update', ErrorClassification.Validation); } + // Match CJS `cmdStateUpdate` contract: caller receives `{ updated: false, + // reason: '...' }` when the operation is a no-op so shell-script consumers + // can JSON.parse output and branch on the reason. Without an explicit + // STATE.md check up front, readModifyWriteStateMd's auto-create behavior + // would mask "STATE.md missing" as a successful no-op write. + const statePath = planningPaths(projectDir, workstream).state; + try { + await readFile(statePath, 'utf-8'); + } catch { + return { data: { updated: false, reason: 'STATE.md not found' } }; + } + let updated = false; const shouldResync = PROGRESS_FRONTMATTER_FIELDS.has(field); await readModifyWriteStateMd(projectDir, (content) => { @@ -321,7 +347,10 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) => preserveExistingProgress: !shouldResync, }); - return { data: { updated } }; + if (!updated) { + return { data: { updated: false, reason: `Field "${field}" not found in STATE.md` } }; + } + return { data: { updated: true } }; }; /** @@ -631,14 +660,25 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir, workstre return { data: { error: 'phase, plan, and duration required' } }; } + // CJS `cmdStateRecordMetric` contract: error out if STATE.md doesn't exist + // rather than auto-creating it (which `readModifyWriteStateMd` would do). + const statePath = planningPaths(projectDir, workstream).state; + try { + await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + let recorded = false; + let created = false; await readModifyWriteStateMd(projectDir, (content) => { const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i; const metricsMatch = content.match(metricsPattern); + const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks} tasks | ${files} files |`; + if (metricsMatch) { let tableBody = metricsMatch[2].trimEnd(); - const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks} tasks | ${files} files |`; if (tableBody.trim() === '' || tableBody.includes('None yet')) { tableBody = newRow; @@ -648,14 +688,28 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir, workstre content = content.replace(metricsPattern, (_match, header: string) => `${header}${tableBody}\n`); recorded = true; + } else { + // Section absent — DWIM: auto-create canonical ## Performance Metrics scaffold, + // then append the row. Matches CJS state.cjs DWIM behavior. + const scaffold = [ + '', + '## Performance Metrics', + '', + '| Phase | Plan | Duration | Notes |', + '|-------|------|----------|-------|', + newRow, + '', + ].join('\n'); + content = content.trimEnd() + '\n' + scaffold; + recorded = true; + created = true; } return content; }, workstream); - if (recorded) { - return { data: { recorded: true, phase, plan, duration } }; - } - return { data: { recorded: false, reason: 'Performance Metrics section not found in STATE.md' } }; + const result: Record = { recorded: true, phase, plan, duration }; + if (created) result.created = true; + return { data: result }; }; /** @@ -668,6 +722,16 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir, workstre * @returns QueryResult with { updated, percent, completed, total } */ export const stateUpdateProgress: QueryHandler = async (_args, projectDir, workstream) => { + // CJS `cmdStateUpdateProgress` contract: error out when STATE.md is missing. + // Without this check the SDK silently returns `{ updated: false }` with no + // STATE.md-aware reason, masking the missing-file condition. + const statePath = planningPaths(projectDir, workstream).state; + try { + await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + const phasesDir = planningPaths(projectDir, workstream).phases; let totalPlans = 0; let totalSummaries = 0; @@ -749,7 +813,7 @@ export const stateAddDecision: QueryHandler = async (args, projectDir, workstrea } const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`; - let added = false; + let created = false; await readModifyWriteStateMd(projectDir, (content) => { const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; @@ -759,16 +823,22 @@ export const stateAddDecision: QueryHandler = async (args, projectDir, workstrea let sectionBody = match[2]; sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, ''); sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; - content = content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); - added = true; + return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); } - return content; + + // Section absent — DWIM (CJS state.cjs:481-492): auto-create the + // canonical `## Decisions` scaffold and append the entry. Matches the + // begin-phase / advance-plan DWIM behavior. Without this, callers that + // never touched the Decisions section see `{added: false}` even though + // STATE.md is writable. Bug #3286. + const scaffold = ['', '## Decisions', '', entry, ''].join('\n'); + created = true; + return content.trimEnd() + '\n' + scaffold; }, workstream); - if (added) { - return { data: { added: true, decision: entry } }; - } - return { data: { added: false, reason: 'Decisions section not found in STATE.md' } }; + const result: Record = { added: true, decision: entry }; + if (created) result['created'] = true; + return { data: result }; }; /** @@ -796,7 +866,7 @@ export const stateAddBlocker: QueryHandler = async (args, projectDir, workstream } const entry = `- ${blockerText}`; - let added = false; + let created = false; await readModifyWriteStateMd(projectDir, (content) => { const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; @@ -806,16 +876,20 @@ export const stateAddBlocker: QueryHandler = async (args, projectDir, workstream let sectionBody = match[2]; sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, ''); sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; - content = content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); - added = true; + return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); } - return content; + + // Section absent — DWIM (CJS state.cjs:532-542): auto-create the + // canonical `### Blockers` scaffold and append the entry. Bug #3286 + // parity — matches stateAddDecision DWIM above. + const scaffold = ['', '### Blockers', '', entry, ''].join('\n'); + created = true; + return content.trimEnd() + '\n' + scaffold; }, workstream); - if (added) { - return { data: { added: true, blocker: blockerText } }; - } - return { data: { added: false, reason: 'Blockers section not found in STATE.md' } }; + const result: Record = { added: true, blocker: blockerText }; + if (created) result['created'] = true; + return { data: result }; }; /** @@ -829,6 +903,14 @@ export const stateResolveBlocker: QueryHandler = async (args, projectDir, workst return { data: { error: 'text required' } }; } + // CJS `cmdStateResolveBlocker` contract: error out when STATE.md is missing. + const statePath = planningPaths(projectDir, workstream).state; + try { + await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + let removedMatchingLine = false; let blockersSectionFound = false; @@ -861,13 +943,15 @@ export const stateResolveBlocker: QueryHandler = async (args, projectDir, workst return content; }, workstream); - if (removedMatchingLine) { + // CJS `cmdStateResolveBlocker` contract: `resolved: true` whenever the + // Blockers section was found, even if no line matched. The semantic is + // "the resolve operation ran against a Blockers section" rather than "a + // specific line was found and removed". Only `resolved: false` when the + // Blockers section itself is missing. + if (blockersSectionFound) { return { data: { resolved: true, blocker: searchText } }; } - return { data: { resolved: false, reason: blockersSectionFound - ? 'Blocker text not found in STATE.md' - : 'Blockers section not found in STATE.md' - } }; + return { data: { resolved: false, reason: 'Blockers section not found in STATE.md' } }; }; // ─── state.add-roadmap-evolution ───────────────────────────────────────── @@ -1019,6 +1103,14 @@ export const stateRecordSession: QueryHandler = async (args, projectDir, workstr const stoppedAt = parsed['stopped-at'] as string | null | undefined; const resumeFile = ((parsed['resume-file'] as string | null) ?? 'None'); + // CJS `cmdStateRecordSession` contract: error out when STATE.md is missing. + const statePath = planningPaths(projectDir, workstream).state; + try { + await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + const now = new Date().toISOString(); const updated: string[] = []; @@ -1347,8 +1439,10 @@ export const stateValidate: QueryHandler = async (_args, projectDir, workstream) if (phaseDir) { const phaseDirPath = join(phasesDir, phaseDir.name); const files = readdirSync(phaseDirPath); - const diskPlans = files.filter(f => /-PLAN\.md$/i.test(f)).length; - const diskSummaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length; + // Bug #3257 parity: count nested plans/ subdirectory via scanPhasePlans + // so /executing/i status checks below see the full plan count + // regardless of whether the planner used the flat or nested layout. + const { planCount: diskPlans, summaryCount: diskSummaries } = scanPhasePlans(phaseDirPath); if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) { warnings.push( @@ -1419,16 +1513,20 @@ export const stateSync: QueryHandler = async (args, projectDir, workstream) => { let totalDiskPlans = 0; let totalDiskSummaries = 0; + let diskCompletedPhases = 0; let highestIncompletePhase: string | null = null; let highestIncompletePhaseplanCount = 0; for (const dir of entries) { const dirPath = join(phasesDir, dir); - const files = readdirSync(dirPath); - const plans = files.filter(f => /-PLAN\.md$/i.test(f)).length; - const summaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length; + // Bug #3257 parity: scanPhasePlans handles nested plans/ subdirectories + // and the extended filename forms (e.g. 5-PLAN-01-setup.md). Without + // this, state.sync sees 0 plans for canonical nested layouts and emits + // bogus "Total Plans in Phase 0 -> 0" sync updates. + const { planCount: plans, summaryCount: summaries, completed } = scanPhasePlans(dirPath); totalDiskPlans += plans; totalDiskSummaries += summaries; + if (completed) diskCompletedPhases++; const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); if (phaseMatch && plans > 0 && summaries < plans) { @@ -1437,6 +1535,12 @@ export const stateSync: QueryHandler = async (args, projectDir, workstream) => { } } + // CJS parity: total_phases for the percent calculation is the count of + // phase directories in the active milestone (or the actual count on disk + // if no milestone filter is configured). Required so the phase-fraction + // cap in computeProgressPercent (#3242 Bug B) sees the right denominator. + const syncTotalPhases = entries.length; + const runModifier = (modified: string): string => { let m = modified; if (highestIncompletePhase) { @@ -1448,7 +1552,17 @@ export const stateSync: QueryHandler = async (args, projectDir, workstream) => { } } - const percent = totalDiskPlans > 0 ? Math.min(100, Math.round((totalDiskSummaries / totalDiskPlans) * 100)) : 0; + // Use min(plan_fraction, phase_fraction) so ROADMAP-declared-but- + // unrealized future phases cap the reported percent (CJS bug #3242 Bug B + // parity). Fall back to 0 when computeProgressPercent returns null + // (totalDiskPlans === 0 case). + const computedPercent = computeProgressPercent( + totalDiskSummaries, + totalDiskPlans, + diskCompletedPhases, + syncTotalPhases, + ); + const percent = computedPercent !== null ? computedPercent : 0; const currentProgress = stateExtractField(m, 'Progress'); if (currentProgress) { const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10); @@ -1621,32 +1735,10 @@ export const statePrune: QueryHandler = async (args, projectDir, workstream) => } const fullContent = await readFile(statePath, 'utf-8'); - const fm = extractFrontmatter(fullContent); - const fmProgress = (typeof fm.progress === 'object' && fm.progress !== null) - ? fm.progress as Record - : null; - const phaseCandidates: unknown[] = [ - fm.current_phase, - stateExtractField(fullContent, 'Current Phase'), - fmProgress?.completed_phases, - fmProgress?.total_phases, - ]; - let currentPhase: number | null = null; - for (const candidate of phaseCandidates) { - const parsed = parseInt(String(candidate ?? '').trim(), 10); - if (Number.isInteger(parsed) && parsed > 0) { - currentPhase = parsed; - break; - } - } - if (currentPhase === null) { - return { - data: { - pruned: false, - reason: 'Could not determine current phase from STATE.md. Add **Current Phase:** N, frontmatter current_phase: N, progress.completed_phases, or progress.total_phases.', - }, - }; - } + // Align with CJS state.cjs:1615 — read Current Phase from the body text first, + // fall back to 0 (same as CJS `parseInt(..., 10) || 0`). + const currentPhaseRaw = stateExtractField(fullContent, 'Current Phase'); + const currentPhase = parseInt(String(currentPhaseRaw ?? '').trim(), 10) || 0; const cutoff = currentPhase - keepRecent; if (cutoff <= 0) { diff --git a/sdk/src/query/state.ts b/sdk/src/query/state.ts index 1c97659a7..2b6cc59ae 100644 --- a/sdk/src/query/state.ts +++ b/sdk/src/query/state.ts @@ -32,6 +32,7 @@ import { stateExtractField, } from './state-document.js'; import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js'; +import { scanPhasePlans } from './plan-scan.js'; import type { QueryHandler } from './utils.js'; // ─── Internal helpers ────────────────────────────────────────────────────── @@ -110,7 +111,17 @@ export async function buildStateFrontmatter( const status = stateExtractField(bodyContent, 'Status'); const progressRaw = stateExtractField(bodyContent, 'Progress'); const lastActivity = stateExtractField(bodyContent, 'Last Activity'); - const stoppedAt = stateExtractField(bodyContent, 'Stopped At') || stateExtractField(bodyContent, 'Stopped at'); + // Bug #2444 parity with CJS `buildStateFrontmatter`: scope `Stopped At` + // extraction to the `## Session` section so historical plain-text mentions + // in earlier prose (e.g. "## Previous Session Notes / Stopped at: …") don't + // promote into the frontmatter. CJS scopes the regex to the section match; + // `stateExtractField` on the whole body would return the first plain match, + // which is the stale historical value. + const sessionMatch = bodyContent.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); + const sessionSection = sessionMatch ? sessionMatch[1] : ''; + const stoppedAt = sessionSection + ? (stateExtractField(sessionSection, 'Stopped At') || stateExtractField(sessionSection, 'Stopped at')) + : null; const pausedAt = stateExtractField(bodyContent, 'Paused At'); // Bug #2613: read existing STATE.md frontmatter as preservation backstop. @@ -153,12 +164,14 @@ export async function buildStateFrontmatter( let diskCompletedPhases = 0; for (const dir of phaseDirs) { - const files = await readdir(join(phasesDir, dir)); - const plans = files.filter(f => /-PLAN\.md$/i.test(f)).length; - const summaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length; - diskTotalPlans += plans; - diskTotalSummaries += summaries; - if (plans > 0 && summaries >= plans) diskCompletedPhases++; + // Bug #3257 parity: route through scanPhasePlans so nested plans/ + // subdirectories (the planner default layout) get counted. The naive + // top-level `-PLAN.md` filter undercounts every phase that uses the + // canonical `phases/NN-name/plans/-PLAN-MM-slug.md` shape. + const { planCount, summaryCount, completed } = scanPhasePlans(join(phasesDir, dir)); + diskTotalPlans += planCount; + diskTotalSummaries += summaryCount; + if (completed) diskCompletedPhases++; } totalPhases = isDirInMilestone.phaseCount > 0 diff --git a/sdk/src/query/validate.ts b/sdk/src/query/validate.ts index 1a0fe4a17..db21706fe 100644 --- a/sdk/src/query/validate.ts +++ b/sdk/src/query/validate.ts @@ -29,6 +29,72 @@ import { resolveBundledAgentsDir } from '../sdk-package-compatibility.js'; /** Max length for key_links regex patterns (ReDoS mitigation). */ const MAX_KEY_LINK_PATTERN_LEN = 512; +const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i; +const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i; + +/** + * List milestone-archive directories under `.planning/milestones/`, sorted by + * version (numeric — `v1.10` after `v1.2`). Mirrors `listMilestoneArchiveDirs` + * in verify.cjs. + */ +async function listMilestoneArchiveDirs(planBase: string): Promise { + const milestonesDir = join(planBase, 'milestones'); + try { + const entries = await readdir(milestonesDir, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory() && MILESTONE_ARCHIVE_DIR_RE.test(e.name)) + .map((e) => join(milestonesDir, e.name)) + .sort((a, b) => { + const an = a.slice(a.lastIndexOf('/') + 1); + const bn = b.slice(b.lastIndexOf('/') + 1); + return an.localeCompare(bn, undefined, { numeric: true }); + }); + } catch { + return []; + } +} + +/** + * Pick the active milestone archive dir, preferring the version named in + * STATE.md when it maps to an on-disk archive; falling back to the highest + * (most recent) version-ish name. Mirrors `getActiveMilestoneArchiveDir` + * in verify.cjs. + */ +async function getActiveMilestoneArchiveDir(planBase: string): Promise { + const archiveDirs = await listMilestoneArchiveDirs(planBase); + if (archiveDirs.length === 0) return null; + + try { + const statePath = join(planBase, 'STATE.md'); + if (existsSync(statePath)) { + const state = await readFile(statePath, 'utf-8'); + const m = state.match(/^\s*(?:\*\*)?milestone(?:\*\*)?:\s*([^\s\r\n#]+).*$/mi); + if (m && m[1]) { + const milestone = m[1].trim(); + const candidate = join(planBase, 'milestones', `${milestone}-phases`); + if (archiveDirs.includes(candidate)) return candidate; + } + } + } catch { /* intentionally empty */ } + + return archiveDirs[archiveDirs.length - 1]; +} + +/** + * Collect the active phase roots to validate against. When the flat + * `.planning/phases/` directory exists, it counts. When an active + * milestone archive (e.g. `.planning/milestones/v1.7-phases/`) exists, it + * counts as well. Mirrors `collectPhaseRoots` in verify.cjs:437. Bug #3164. + */ +async function collectPhaseRoots(planBase: string): Promise { + const roots: string[] = []; + const flatPhasesDir = join(planBase, 'phases'); + if (existsSync(flatPhasesDir)) roots.push(flatPhasesDir); + const activeArchive = await getActiveMilestoneArchiveDir(planBase); + if (activeArchive) roots.push(activeArchive); + return roots; +} + /** * Canonical plan stem used for PLAN/SUMMARY matching. * Example: `68-01-scaffolding` -> `68-01`. @@ -219,23 +285,40 @@ export const validateConsistency: QueryHandler = async (_args, projectDir, works roadmapPhases.add(m[1]); } - // Get phases on disk + // Get phases on disk — flat layout AND active milestone archive (bug #3164). + // CJS uses `collectDiskPhases(planBase)` + `collectPhaseRoots(planBase)`. + // Each root contributes its phase tokens to diskPhases. Plan-level scans + // below walk every root, not just the flat one. const diskPhases = new Set(); - let diskDirs: string[] = []; - try { - const entries = await readdir(paths.phases, { withFileTypes: true }); - diskDirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - for (const dir of diskDirs) { - const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); - if (dm) diskPhases.add(dm[1]); + const phaseRoots = await collectPhaseRoots(paths.planning); + /** Map of root → its phase-directory entries (for downstream plan scans). */ + const rootDirs = new Map(); + for (const root of phaseRoots) { + try { + const entries = await readdir(root, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + rootDirs.set(root, dirs); + for (const dir of dirs) { + const dm = dir.match(PHASE_TOKEN_FROM_DIR_RE); + if (dm) diskPhases.add(dm[1]); + } + } catch { + rootDirs.set(root, []); } - } catch { - // phases directory doesn't exist } - // Check: phases in ROADMAP but not on disk + // Check: phases in ROADMAP but not on disk. CJS parity: compare against + // both the as-written form and the canonical normalized form, AND strip the + // optional project-code prefix on disk dirs (handled by + // PHASE_TOKEN_FROM_DIR_RE above) so `CK-64-…` is recognised as phase 64. for (const p of roadmapPhases) { - if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) { + const normalizedP = normalizePhaseName(p); + const unpaddedP = String(parseInt(p, 10)); + if ( + !diskPhases.has(p) && + !diskPhases.has(normalizedP) && + !diskPhases.has(unpaddedP) + ) { warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`); } } @@ -270,60 +353,63 @@ export const validateConsistency: QueryHandler = async (_args, projectDir, works } } - // Check plan numbering and summaries within each phase - for (const dir of diskDirs) { - let phaseFiles: string[]; - try { - phaseFiles = await readdir(join(paths.phases, dir)); - } catch { - continue; - } + // Check plan numbering and summaries within each phase across every active + // phase root. Bug #3164 \u2014 projects on the milestone-archive layout have + // phases under `.planning/milestones/-phases//`, not the + // flat `.planning/phases/` directory. + for (const root of phaseRoots) { + const dirs = rootDirs.get(root) ?? []; + // Label paths relative to planning/ so warnings carry the archive prefix + // (e.g. `milestones/v1.7-phases/65-current`) instead of bare phase names. + const relRoot = root.startsWith(paths.planning + '/') + ? root.slice(paths.planning.length + 1) + : root; - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort(); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md')); - - // Extract plan numbers and check for gaps - const planNums = plans.map(p => { - const pm = p.match(/-(\d{2})-PLAN\.md$/); - return pm ? parseInt(pm[1], 10) : null; - }).filter((n): n is number => n !== null); - - for (let i = 1; i < planNums.length; i++) { - if (planNums[i] !== planNums[i - 1] + 1) { - warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} \u2192 ${planNums[i]}`); - } - } - - // Check: summaries without matching plans - const planIds = new Set(plans.map(p => p.replace('-PLAN.md', ''))); - const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', ''))); - - for (const sid of summaryIds) { - if (!planIds.has(sid)) { - warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`); - } - } - } - - // Check frontmatter completeness in plans - for (const dir of diskDirs) { - let phaseFiles: string[]; - try { - phaseFiles = await readdir(join(paths.phases, dir)); - } catch { - continue; - } - - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')); - for (const plan of plans) { + for (const dir of dirs) { + const phaseLabel = relRoot === 'phases' ? dir : `${relRoot}/${dir}`; + let phaseFiles: string[]; try { - const content = await readFile(join(paths.phases, dir, plan), 'utf-8'); - const fm = extractFrontmatter(content); - if (!fm.wave) { - warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`); - } + phaseFiles = await readdir(join(root, dir)); } catch { - // Cannot read plan file + continue; + } + + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort(); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md')); + + // Extract plan numbers and check for gaps + const planNums = plans.map(p => { + const pm = p.match(/-(\d{2})-PLAN\.md$/); + return pm ? parseInt(pm[1], 10) : null; + }).filter((n): n is number => n !== null); + + for (let i = 1; i < planNums.length; i++) { + if (planNums[i] !== planNums[i - 1] + 1) { + warnings.push(`Gap in plan numbering in ${phaseLabel}: plan ${planNums[i - 1]} \u2192 ${planNums[i]}`); + } + } + + // Check: summaries without matching plans + const planIds = new Set(plans.map(p => p.replace('-PLAN.md', ''))); + const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', ''))); + + for (const sid of summaryIds) { + if (!planIds.has(sid)) { + warnings.push(`Summary ${sid}-SUMMARY.md in ${phaseLabel} has no matching PLAN.md`); + } + } + + // Check frontmatter completeness in plans (same scope as above). + for (const plan of plans) { + try { + const content = await readFile(join(root, dir, plan), 'utf-8'); + const fm = extractFrontmatter(content); + if (!fm.wave) { + warnings.push(`${phaseLabel}/${plan}: missing 'wave' in frontmatter`); + } + } catch { + // Cannot read plan file + } } } } diff --git a/sdk/src/query/verify.ts b/sdk/src/query/verify.ts index 9eb55c945..764ddac33 100644 --- a/sdk/src/query/verify.ts +++ b/sdk/src/query/verify.ts @@ -26,7 +26,6 @@ import { planningPaths, } from './helpers.js'; import type { QueryHandler } from './utils.js'; -import { resolveGsdToolsPath } from '../sdk-package-compatibility.js'; // ─── verifyPlanStructure ─────────────────────────────────────────────────── @@ -645,48 +644,13 @@ export const verifySchemaDrift: QueryHandler = async (args, projectDir, workstre }; }; -/** - * verify.codebase-drift — structural drift detector (#2003). - * - * Non-blocking by contract: every failure mode returns a successful response - * with `{ skipped: true, reason }`. The post-execute drift gate in - * `/gsd-execute-phase` relies on this guarantee. - * - * Delegates to the Node-side implementation in `bin/lib/drift.cjs` and - * `bin/lib/verify.cjs` via a child process so the drift logic stays in one - * canonical place (see `cmdVerifyCodebaseDrift`). - */ -export const verifyCodebaseDrift: QueryHandler = async (_args, projectDir) => { - try { - const { execFileSync } = await import('node:child_process'); - const toolsPath = resolveGsdToolsPath(projectDir); - const out = execFileSync(process.execPath, [toolsPath, 'verify', 'codebase-drift'], { - cwd: projectDir, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - try { - return { data: JSON.parse(out) }; - } catch { - return { - data: { - skipped: true, - reason: 'sdk-parse-failed', - action_required: false, - directive: 'none', - elements: [], - }, - }; - } - } catch (err) { - return { - data: { - skipped: true, - reason: 'sdk-exception: ' + (err instanceof Error ? err.message : String(err)), - action_required: false, - directive: 'none', - elements: [], - }, - }; - } -}; +// verify.codebase-drift handler intentionally NOT exported from the SDK. +// drift (bin/lib/drift.cjs) is out-of-seam, CJS-only per ADR/PRD +// docs/adr/3524-cjs-sdk-hard-seam.md §3 and docs/prd/3524-cjs-sdk-hard-seam.md +// L160: "CJS-only Module handlers (...drift...) keep their in-process CJS +// implementations because no SDK counterpart exists." Previous Phase 6 stub +// (which execFileSync'd back to gsd-tools) created an infinite SDK→CLI→SDK +// recursion when the CJS verify-command-router dispatched through the SDK +// bridge — observed forking hundreds of node processes on a 64 GiB host. +// The router now dispatches `verify codebase-drift` direct to +// `verify.cmdVerifyCodebaseDrift`, which is the canonical implementation. diff --git a/sdk/src/runtime-bridge-sync/projectdir-regression.test.ts b/sdk/src/runtime-bridge-sync/projectdir-regression.test.ts index 3ae741446..e8c68432a 100644 --- a/sdk/src/runtime-bridge-sync/projectdir-regression.test.ts +++ b/sdk/src/runtime-bridge-sync/projectdir-regression.test.ts @@ -118,19 +118,17 @@ describe('executeForCjs projectDir regression (Phase 5.0 bug)', () => { expect(String(data.error)).toMatch(/STATE\.md not found/i); }); - it('workstream transport contract: GSDTransport forces subprocess for workstream requests (subprocess disabled in worker → ok:false)', () => { - // This test documents an architectural constraint, not a bug. + it('workstream support: GSDTransport routes workstream requests natively (Phase 6 fix)', () => { + // Phase 6 fix: GSDTransport no longer forces subprocess for workstream-scoped + // requests. The worker's dispatchNative closure (Phase 5.1 fix) correctly + // threads request.workstream through to registry.dispatch(), so native handlers + // route to the workstream-scoped .planning/workstreams// directory. // - // GSDTransport.subprocessReason() returns 'workstream_forced' when - // request.workstream is set (gsd-transport.ts line ~72). The worker has - // subprocess disabled (allowFallbackToSubprocess=false), so a workstream - // request always surfaces as ok:false / internal_error. - // - // This is the expected contract for the sync bridge worker: workstream - // scoped commands cannot run natively in the worker and must be invoked - // via the async bridge or gsd-tools.cjs subprocess fallback instead. - // - // This test is here to document + pin the behavior, not to assert a fix. + // The workstream 'some-workstream' has no separate STATE.md in tmpDir/ + // .planning/workstreams/some-workstream/, so the handler returns a domain-level + // "not found" error (ok:true with {error:...}) — exactly like the nonexistent + // projectDir case. This confirms native dispatch was used (subprocess would + // have returned ok:false / errorKind). const result = executeForCjs({ registryCommand: 'state.json', registryArgs: [], @@ -141,11 +139,12 @@ describe('executeForCjs projectDir regression (Phase 5.0 bug)', () => { workstream: 'some-workstream', }); - // Workstream forces subprocess; subprocess disabled → ok:false. - expect(result.ok).toBe(false); - if (result.ok) return; - // The error surfaces as internal_error because 'Subprocess fallback disabled' - // does not match the unknown_command classifier pattern. - expect(['internal_error', 'unknown_command']).toContain(result.errorKind); + // Native dispatch used → ok:true (handler-level not-found, not a dispatch error). + expect(result.ok).toBe(true); + if (!result.ok) return; + const data = result.data as Record; + // Domain-level not-found: workstream's STATE.md doesn't exist in the fixture. + expect(data).toHaveProperty('error'); + expect(String(data.error)).toMatch(/STATE\.md not found/i); }); }); diff --git a/sdk/src/runtime-bridge-sync/worker.ts b/sdk/src/runtime-bridge-sync/worker.ts index b3c5f0e21..c8d26f0fe 100644 --- a/sdk/src/runtime-bridge-sync/worker.ts +++ b/sdk/src/runtime-bridge-sync/worker.ts @@ -21,6 +21,7 @@ import { QueryRuntimeBridge } from '../query-runtime-bridge.js'; import { GSDToolsError } from '../gsd-tools-error.js'; import { GSDError, ErrorClassification } from '../errors.js'; import { createQueryNativeErrorFactory } from '../query-tools-error-factory.js'; +import { formatQueryRawOutput } from '../query-raw-output-projection.js'; import type { RuntimeBridgeExecuteInput } from '../query-runtime-bridge.js'; import type { RuntimeBridgeSyncResult, SyncErrorKind } from './index.js'; @@ -57,6 +58,12 @@ function getBridge(): QueryRuntimeBridge { request.registryArgs, ); }, + // #3631: forward raw-mode projection so mode:'raw' returns the per-command + // scalar string (next-decimal token, get-phase section, etc.) instead of + // falling back to generic JSON-stringify. Without this, family-router + // sdkHandlers requesting mode:'raw' under --raw receive a stringified + // JSON IR — the regression #3577 introduced for every family router. + formatNativeRaw: (registryCommand, data) => formatQueryRawOutput(registryCommand, data), // Subprocess fallback stubs — never called because allowFallbackToSubprocess=false execSubprocessJson: () => Promise.reject(new Error('Subprocess fallback disabled in sync bridge worker')), @@ -114,7 +121,20 @@ function getBridge(): QueryRuntimeBridge { * - GSDToolsError failure → native_failure * - Unknown Error → internal_error */ -function classifyError(error: unknown): { kind: SyncErrorKind; exitCode: number; message: string } { +function readReason(error: unknown): string | undefined { + // Handlers can pin a CJS-style ERROR_REASON snake_case code on the GSDError + // they throw (e.g. configGet → 'config_key_not_found'). The worker + // propagates it through errorDetails so the CJS dispatcher can call + // `error(msg, reason)` and `--json-errors` clients see a typed reason + // rather than the generic 'unknown'. (Bugs #2943, #3086.) + if (error && typeof error === 'object' && 'reason' in error) { + const r = (error as { reason?: unknown }).reason; + if (typeof r === 'string' && r.length > 0) return r; + } + return undefined; +} + +function classifyError(error: unknown): { kind: SyncErrorKind; exitCode: number; message: string; reason?: string } { if (error instanceof GSDToolsError) { const { classification, exitCode, message } = error; @@ -131,8 +151,28 @@ function classifyError(error: unknown): { kind: SyncErrorKind; exitCode: number; return { kind: 'native_timeout', exitCode: exitCode ?? 1, message }; } - // Check if cause is a TypeError → internal_error + // Unwrap the cause once. The native direct adapter wraps every non- + // GSDToolsError thrown by a handler in a GSDToolsError via + // `createNativeFailureError`, preserving the original via `cause`. + // Classification of validation / blocked errors therefore has to walk + // through to the cause — otherwise every GSDError validation surfaces + // as `native_failure` and callers cannot distinguish "you gave me bad + // input" from "the SDK crashed." (Phase 6 / #3592 contract bug.) const cause = (error as NodeJS.ErrnoException & { cause?: unknown }).cause; + if (cause instanceof GSDError) { + const reason = readReason(cause); + if ( + cause.classification === ErrorClassification.Validation || + cause.classification === ErrorClassification.Blocked + ) { + return { kind: 'validation_error', exitCode: 10, message: cause.message, reason }; + } + // Execution-classified GSDError is a 'handler said no' result — + // exitCode 1, internal_error kind for taxonomy purposes, but pass + // the structured reason through so the CJS dispatcher can render + // the proper `--json-errors` shape. + return { kind: 'internal_error', exitCode: 1, message: cause.message, reason }; + } if (cause instanceof TypeError) { return { kind: 'internal_error', exitCode: exitCode ?? 1, message }; } @@ -142,13 +182,14 @@ function classifyError(error: unknown): { kind: SyncErrorKind; exitCode: number; if (error instanceof GSDError) { const { classification, message } = error; + const reason = readReason(error); if ( classification === ErrorClassification.Validation || classification === ErrorClassification.Blocked ) { - return { kind: 'validation_error', exitCode: 10, message }; + return { kind: 'validation_error', exitCode: 10, message, reason }; } - return { kind: 'internal_error', exitCode: 1, message }; + return { kind: 'internal_error', exitCode: 1, message, reason }; } if (error instanceof TypeError) { @@ -169,12 +210,14 @@ runAsWorker(async (input: RuntimeBridgeExecuteInput): Promise { cleanup(tmpDir); }); + // Point the SDK at the repo's agents/ dir (sibling of get-shit-done/) via the + // GSD_AGENTS_DIR override. The SDK side of init resolves agents from + // GSD_AGENTS_DIR or the runtime config dir (~/.claude/agents for Claude); it + // does NOT walk up from cwd like the CJS-era code did. Without this override + // these tests would only pass on a dev machine with ~/.claude/agents/ + // populated — which masked the divergence on Linux CI where that path is + // absent. See sdk/src/query/QUERY-HANDLERS.md ("subprocess vs in-process + // path resolution") and sdk/src/query/helpers.ts:resolveAgentsDir. + const REPO_AGENTS_DIR = path.resolve(__dirname, '..', 'agents'); + test('init execute-phase includes agents_installed=true when agents exist', () => { - // Create phase dir for init const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(phaseDir, { recursive: true }); - // Create agents dir as sibling of get-shit-done/ (the installed layout) - // gsd-tools.cjs resolves agents from GSD_INSTALL_DIR or __dirname/../../agents - const gsdInstallDir = path.resolve(__dirname, '..', 'get-shit-done', 'bin'); - const configDir = path.resolve(gsdInstallDir, '..', '..'); - const agentsDir = path.join(configDir, 'agents'); - - // Agents already exist in the repo root /agents/ dir which is sibling to get-shit-done/ - const result = runGsdTools('init execute-phase 1 --raw', tmpDir); + const result = runGsdTools('init execute-phase 1 --raw', tmpDir, { GSD_AGENTS_DIR: REPO_AGENTS_DIR }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(typeof output.agents_installed, 'boolean', 'init execute-phase must include agents_installed field'); - // The repo has agents/ dir with all gsd-*.md files, so this should be true assert.strictEqual(output.agents_installed, true, - 'agents_installed should be true when agents directory has gsd-*.md files'); + 'agents_installed should be true when GSD_AGENTS_DIR has gsd-*.md files'); }); test('init plan-phase includes agents_installed=true when agents exist', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(phaseDir, { recursive: true }); - const result = runGsdTools('init plan-phase 1 --raw', tmpDir); + const result = runGsdTools('init plan-phase 1 --raw', tmpDir, { GSD_AGENTS_DIR: REPO_AGENTS_DIR }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); diff --git a/tests/bug-2808-skill-hyphen-name.test.cjs b/tests/bug-2808-skill-hyphen-name.test.cjs index 581885e02..6b387191f 100644 --- a/tests/bug-2808-skill-hyphen-name.test.cjs +++ b/tests/bug-2808-skill-hyphen-name.test.cjs @@ -87,6 +87,25 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => { name.startsWith('gsd-'), `${cmd}: SKILL.md name should start with gsd-, got "${name}"` ); + + // #3583 regression guard: the *body* must not leak retired colon-form + // command references (e.g. /gsd:plan-phase or gsd:review). The converter + // now uses transformContentToHyphen from the shared transformer. + // + // We explicitly scope to the body (after stripping the leading frontmatter + // block) so that descriptions or other frontmatter fields containing example + // gsd: references do not cause spurious failures. + // + // gsd:sdk and gsd:tools are intentionally excluded: they are not slash commands + // (no commands/gsd/sdk.md or tools.md exist), so the transformer correctly leaves + // them alone. They are benign and should not trigger this assertion. + const bodyContent = skillContent.replace(/^---\n[\s\S]*?\n---\n?/, ''); + const colonRefs = (bodyContent.match(/\bgsd:[a-z][a-z0-9-]*\b/g) || []) + .filter(r => !/gsd:(sdk|tools)/.test(r)); + assert.strictEqual( + colonRefs.length, 0, + `${cmd}: generated SKILL.md body must not contain gsd: command references (found: ${colonRefs.join(', ')})` + ); } }); @@ -178,4 +197,52 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => { assert.ok(!name.includes('_'), `${skillDir}: autocomplete name must not contain underscore, got ${name}`); } }); + + test('transformContentToHyphen (from fix-slash-commands.cjs) rewrites colon to hyphen for known commands', () => { + const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs')); + const { transformContentToHyphen, readCmdNames } = transformer; + const liveCmdNames = readCmdNames(); + + const input = 'Run /gsd:plan-phase then gsd:execute-phase. Also see /gsd:review and gsd-sdk query.'; + const out = transformContentToHyphen(input, liveCmdNames); + + assert.ok(out.includes('/gsd-plan-phase'), 'leading-/ colon form must become hyphen'); + assert.ok(out.includes('gsd-execute-phase'), 'bare colon form must become hyphen'); + assert.ok(out.includes('/gsd-review'), 'another command reference must be rewritten'); + assert.ok(out.includes('gsd-sdk'), 'non-command gsd-sdk must be left untouched'); + assert.ok(!out.match(/\bgsd:[a-z]/), 'no colon-form command reference may survive'); + }); + + test('respects word boundary — does not rewrite gsd:plan-phase-extra (partial match guard)', () => { + const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs')); + const { transformContentToHyphen, readCmdNames } = transformer; + const liveCmdNames = readCmdNames(); + + const out = transformContentToHyphen('gsd:plan-phase-extra and /gsd:execute-phase-extra', liveCmdNames); + assert.strictEqual(out, 'gsd:plan-phase-extra and /gsd:execute-phase-extra', + 'word-boundary lookahead must prevent partial matches on the reverse transform'); + }); + + test('respects left word boundary — does not rewrite inside larger tokens (e.g. mygsd:cmd)', () => { + const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs')); + const { transformContentToHyphen, readCmdNames } = transformer; + const liveCmdNames = readCmdNames(); + + const input = 'See mygsd:plan-phase or prefix-gsd:execute in the docs.'; + const out = transformContentToHyphen(input, liveCmdNames); + assert.strictEqual(out, input, 'negative lookbehind must prevent left-side in-word matches'); + }); + + test('leaves already-hyphen-form references untouched (idempotent on output)', () => { + const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs')); + const { transformContentToHyphen, readCmdNames } = transformer; + const liveCmdNames = readCmdNames(); + + const input = 'Run gsd-plan-phase and /gsd-execute-phase then gsd:review.'; // mixed, only colon should change + const out = transformContentToHyphen(input, liveCmdNames); + assert.ok(out.includes('gsd-plan-phase'), 'pre-existing hyphen stays'); + assert.ok(out.includes('/gsd-execute-phase'), 'pre-existing hyphen stays'); + assert.ok(out.includes('gsd-review'), 'colon form was normalized'); + assert.ok(!out.includes('gsd:review'), 'no colon form remains'); + }); }); diff --git a/tests/bug-3406-stale-sdk-shadow-detect.test.cjs b/tests/bug-3406-stale-sdk-shadow-detect.test.cjs new file mode 100644 index 000000000..3f836a02c --- /dev/null +++ b/tests/bug-3406-stale-sdk-shadow-detect.test.cjs @@ -0,0 +1,169 @@ +'use strict'; + +/** + * Regression tests for #3406 — stale globally-installed `@gsd-build/sdk@0.1.0` + * shadows the `gsd-sdk` shim that `get-shit-done-cc` installs. The standalone + * 0.1.0 binary only knows `run | auto | init` (no `query` subcommand), so + * every workflow that calls `gsd-sdk query ` fails until the user + * runs `npm uninstall -g @gsd-build/sdk`. + * + * Maintainer decision (per triage): option 2 — detect-and-warn during + * install. This test pins the pure detection helper so the install-time + * warning fires on the right input and stays silent otherwise. + * + * Test surface is the exported helper `detectStaleStandaloneSdk(runNpmLs)`. + * `runNpmLs` is an injected executor: in production it spawns + * `npm ls -g @gsd-build/sdk --json --depth=0`; in tests we hand it a stub + * that returns canned stdout / throws, so the test never touches the host + * npm state. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +process.env.GSD_TEST_MODE = '1'; +const installer = require('../bin/install.js'); +const { detectStaleStandaloneSdk } = installer; + +describe('#3406: detectStaleStandaloneSdk', () => { + test('is exported from bin/install.js under GSD_TEST_MODE', () => { + assert.strictEqual( + typeof detectStaleStandaloneSdk, + 'function', + 'detectStaleStandaloneSdk must be exported for install-time wiring + tests' + ); + }); + + test('returns { stale: false } when npm ls reports the package is not installed', () => { + // `npm ls -g @gsd-build/sdk --json --depth=0` exit code 1 with this + // JSON shape is the standard "not present" signal. + const stub = () => JSON.stringify({ + name: 'lib', + dependencies: {}, + }); + const result = detectStaleStandaloneSdk(stub); + assert.deepStrictEqual(result, { stale: false }); + }); + + test('returns { stale: true, version, path? } when @gsd-build/sdk is present', () => { + const stub = () => JSON.stringify({ + name: 'lib', + dependencies: { + '@gsd-build/sdk': { + version: '0.1.0', + resolved: 'file:/Users/REDACTED/.nvm/versions/node/v24.15.0/lib/node_modules/@gsd-build/sdk', + }, + }, + }); + const result = detectStaleStandaloneSdk(stub); + assert.strictEqual(result.stale, true); + assert.strictEqual(result.version, '0.1.0'); + }); + + test('returns { stale: false } when runNpmLs throws (npm missing / EACCES)', () => { + const stub = () => { throw new Error('npm: command not found'); }; + const result = detectStaleStandaloneSdk(stub); + assert.deepStrictEqual(result, { stale: false }); + }); + + test('returns { stale: false } when runNpmLs returns malformed JSON', () => { + const stub = () => 'not-json-at-all'; + const result = detectStaleStandaloneSdk(stub); + assert.deepStrictEqual(result, { stale: false }); + }); + + test('returns { stale: false } when the JSON has no dependencies field', () => { + const stub = () => JSON.stringify({ name: 'lib' }); + const result = detectStaleStandaloneSdk(stub); + assert.deepStrictEqual(result, { stale: false }); + }); + + test('returns { stale: false } when runNpmLs returns null/undefined', () => { + const resultNull = detectStaleStandaloneSdk(() => null); + const resultUndef = detectStaleStandaloneSdk(() => undefined); + assert.deepStrictEqual(resultNull, { stale: false }); + assert.deepStrictEqual(resultUndef, { stale: false }); + }); + + test('returns { stale: false } for non-0.1.0 versions (CR #3406)', () => { + // Only 0.1.0 is the known-bad shadow. Any newer or unrelated published + // version is an intentional install (or a future republish) and must + // NOT be flagged. Without this gate, every maintainer with a local-link + // or any future publish would trigger a misleading warning on install. + const stubNewer = () => JSON.stringify({ dependencies: { '@gsd-build/sdk': { version: '1.50.0-canary.0' } } }); + const stubFuture = () => JSON.stringify({ dependencies: { '@gsd-build/sdk': { version: '2.0.0' } } }); + assert.deepStrictEqual(detectStaleStandaloneSdk(stubNewer), { stale: false }); + assert.deepStrictEqual(detectStaleStandaloneSdk(stubFuture), { stale: false }); + }); +}); + +describe('#3406: install-time wiring stays silent when no stale package is found', () => { + // We can't easily stub npm inside a spawned install subprocess without + // shelling around it, so the install-side coverage here verifies the + // negative case: when @gsd-build/sdk is NOT installed globally (the npm + // dependency tree on CI is irrelevant — we use a doctored PATH that points + // npm at an empty prefix), the install run prints NO #3406 warning. The + // positive case is exhaustively covered by detectStaleStandaloneSdk above. + const path = require('node:path'); + const fs = require('node:fs'); + const os = require('node:os'); + const { execFileSync } = require('node:child_process'); + + test('install does not emit the stale-SDK warning on a clean npm prefix', () => { + const installScript = path.resolve(__dirname, '..', 'bin', 'install.js'); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3406-home-')); + const tmpPrefix = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3406-npm-')); + try { + const stdout = execFileSync( + process.execPath, + [installScript, '--claude', '--global', '--yes', '--no-sdk'], + { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + CLAUDE_CONFIG_DIR: tmpHome, + npm_config_prefix: tmpPrefix, + // Detect-and-warn must execute, just find nothing — so do NOT set + // GSD_SKIP_STALE_SDK_CHECK here. + }, + timeout: 60_000, + } + ); + assert.ok( + !stdout.includes('@gsd-build/sdk'), + 'install output must not mention @gsd-build/sdk when the package is absent' + ); + assert.ok( + !stdout.includes('#3406'), + 'install output must not reference #3406 when no stale shadow is present' + ); + } finally { + try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ } + try { fs.rmSync(tmpPrefix, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); +}); + +describe('#3406: formatStaleStandaloneSdkWarning', () => { + const { formatStaleStandaloneSdkWarning } = installer; + + test('is exported from bin/install.js under GSD_TEST_MODE', () => { + assert.strictEqual( + typeof formatStaleStandaloneSdkWarning, + 'function', + 'formatStaleStandaloneSdkWarning must be exported for tests' + ); + }); + + test('message names the stale package, the version, and the uninstall command', () => { + const out = formatStaleStandaloneSdkWarning({ stale: true, version: '0.1.0' }); + assert.ok(out.includes('@gsd-build/sdk'), 'must name the shadowing package'); + assert.ok(out.includes('0.1.0'), 'must show the stale version'); + assert.ok( + out.includes('npm uninstall -g @gsd-build/sdk'), + 'must include the remediation command verbatim' + ); + assert.ok(out.includes('#3406'), 'must reference the issue for traceability'); + }); +}); diff --git a/tests/bug-3579-graphify-hook-publish.test.cjs b/tests/bug-3579-graphify-hook-publish.test.cjs new file mode 100644 index 000000000..218a53b40 --- /dev/null +++ b/tests/bug-3579-graphify-hook-publish.test.cjs @@ -0,0 +1,150 @@ +'use strict'; + +/** + * Regression tests for #3579 — graphify auto-update hook (#3347 / PR #3557) + * was dead-on-arrival in 1.50.0-canary.x because: + * + * Gap 1: scripts/build-hooks.js HOOKS_TO_COPY did not include + * gsd-graphify-update.sh, so it never landed in hooks/dist/ — the + * installer's bin/install.js readdir loop then never copied it to + * ~/.claude/hooks/. + * Gap 2: build-hooks.js (flat allowlist) and bin/install.js (readdir + + * isFile filter) never copied hooks/lib/gsd-graphify-rebuild.sh. + * Without the helper the hook resolves rebuild script → not found → + * exit 0 — feature silently dead. + * + * Beyond these two gaps the issue body lists a Gap 3 (npm tarball missing + * the source files). Inspection of `npm pack --dry-run --json` on origin/main + * shows both files are now present in the tarball, so the tarball-side + * regression is not reproduced; only Gaps 1 & 2 are in scope here. + * + * Test strategy — three layers, each independent: + * 1. build-hooks.js HOOKS_TO_COPY includes every top-level .sh under hooks/ + * (allowlist-coverage drift guard). This generalizes beyond graphify so + * the next .sh added cannot drift back into the gap. + * 2. After running scripts/build-hooks.js, hooks/dist/gsd-graphify-update.sh + * and hooks/dist/lib/gsd-graphify-rebuild.sh both exist. + * 3. After installing into a temp config dir, both files land at + * hooks/gsd-graphify-update.sh and hooks/lib/gsd-graphify-rebuild.sh + * and the installer does not emit the "Missing expected hook" warning + * for gsd-graphify-update.sh. + */ + +const { test, describe, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const HOOKS_DIR = path.join(REPO_ROOT, 'hooks'); +const DIST_DIR = path.join(HOOKS_DIR, 'dist'); +const BUILD_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); +const INSTALL_SCRIPT = path.join(REPO_ROOT, 'bin', 'install.js'); + +// ─── Coverage guard ───────────────────────────────────────────────────────── + +describe('#3579 Gap 1: build-hooks.js packages every top-level hooks/*.sh into dist', () => { + // Behavior-based drift guard: rather than parsing the HOOKS_TO_COPY literal + // out of scripts/build-hooks.js as text (a source-grep that breaks under + // harmless refactors and fails to catch any other reason a file might get + // dropped on the floor), we run the actual build and assert the actual + // filesystem outcome: every top-level hooks/*.sh has a corresponding file + // in hooks/dist/. This catches the original gap (missing allowlist entry) + // AND any future regression that silently drops a hook for any other + // reason (e.g. a copy that swallows errors, a syntax-validator bug, etc.). + before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + }); + + test('every top-level hooks/*.sh is emitted to hooks/dist/ by the build', () => { + const topLevelSh = fs + .readdirSync(HOOKS_DIR, { withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith('.sh')) + .map((e) => e.name); + + assert.ok(topLevelSh.length > 0, 'expected at least one top-level hooks/*.sh in source'); + + const missing = topLevelSh.filter( + (sh) => !fs.existsSync(path.join(DIST_DIR, sh)) + ); + assert.deepStrictEqual( + missing, + [], + `every top-level hooks/*.sh must be emitted to hooks/dist/ by scripts/build-hooks.js; missing from dist: ${JSON.stringify(missing)}` + ); + }); +}); + +// ─── build-hooks emits dist/ files ────────────────────────────────────────── + +describe('#3579 Gap 1 + Gap 2: build-hooks.js populates dist with graphify hook + lib helper', () => { + before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + }); + + test('hooks/dist/gsd-graphify-update.sh exists after build', () => { + assert.ok( + fs.existsSync(path.join(DIST_DIR, 'gsd-graphify-update.sh')), + 'expected hooks/dist/gsd-graphify-update.sh to exist after build (Gap 1)' + ); + }); + + test('hooks/dist/lib/gsd-graphify-rebuild.sh exists after build', () => { + assert.ok( + fs.existsSync(path.join(DIST_DIR, 'lib', 'gsd-graphify-rebuild.sh')), + 'expected hooks/dist/lib/gsd-graphify-rebuild.sh to exist after build (Gap 2)' + ); + }); +}); + +// ─── install lands the files at the target ────────────────────────────────── + +describe('#3579: installer deploys graphify hook + lib helper to target', () => { + let tmpDir; + let installStdout; + + before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3579-install-')); + installStdout = execFileSync( + process.execPath, + [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], + { + encoding: 'utf-8', + stdio: 'pipe', + env: { ...process.env, CLAUDE_CONFIG_DIR: tmpDir }, + } + ); + }); + + after(() => { + if (tmpDir) { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); + + test('hooks/gsd-graphify-update.sh present at install target', () => { + const dest = path.join(tmpDir, 'hooks', 'gsd-graphify-update.sh'); + assert.ok(fs.existsSync(dest), `expected ${dest} to exist after install`); + }); + + test('hooks/lib/gsd-graphify-rebuild.sh present at install target', () => { + const dest = path.join(tmpDir, 'hooks', 'lib', 'gsd-graphify-rebuild.sh'); + assert.ok(fs.existsSync(dest), `expected ${dest} to exist after install`); + }); + + test('installer does not warn about missing gsd-graphify-update.sh', () => { + assert.ok( + !installStdout.includes('Missing expected hook: gsd-graphify-update.sh'), + `installer output must not warn about missing graphify hook; got:\n${installStdout}` + ); + assert.ok( + !installStdout.includes( + 'Skipped graphify auto-update hook — gsd-graphify-update.sh not found' + ), + `installer must not skip graphify hook configuration; got:\n${installStdout}` + ); + }); +}); diff --git a/tests/bug-3588-npm-audit-clean.test.cjs b/tests/bug-3588-npm-audit-clean.test.cjs new file mode 100644 index 000000000..20cd82a99 --- /dev/null +++ b/tests/bug-3588-npm-audit-clean.test.cjs @@ -0,0 +1,90 @@ +'use strict'; + +/** + * Regression test for #3588 — production dependency tree must not carry + * high or moderate npm-audit advisories. + * + * Strategy: run `npm audit --omit=dev --json` against both the root + * workspace and the embedded SDK package and assert that the metadata + * vulnerability counts are zero across info/low/moderate/high/critical. + * + * The test is intentionally strict — any advisory of any severity (other + * than 'low' if the maintainer accepts it; that branch is left explicit + * here) blocks CI. If a future advisory lands without an upstream patch, + * either bump the patched transitive (preferred), or annotate the + * acceptance below with a justification AND a link to the upstream tracker. + * + * Skips automatically when `node_modules/` is absent (a fresh checkout + * before `npm install`) so the test does not falsely report on developer + * machines mid-setup. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); +const { execFileSync } = require('node:child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const SDK = path.join(ROOT, 'sdk'); + +function auditProductionVulns(cwd) { + if (!fs.existsSync(path.join(cwd, 'node_modules'))) { + return null; // signal "skip" to caller + } + const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + let out; + try { + out = execFileSync( + npmCmd, + ['audit', '--omit=dev', '--json'], + { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 } + ); + } catch (e) { + // `npm audit` exits non-zero when advisories are present; the JSON is + // still on stdout in that case. Recover and let the assertion classify. + if (e && typeof e.stdout !== 'undefined') { + out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout); + } else { + throw e; + } + } + const parsed = JSON.parse(out); + // `null` is reserved for the "node_modules missing → skip" signal above. + // Any other unexpected JSON shape is a real failure of the audit harness + // (npm changed its output format, audit aborted before metadata, etc.) — + // throw so the test fails loudly instead of skipping silently. + if (parsed && parsed.metadata && parsed.metadata.vulnerabilities) { + return parsed.metadata.vulnerabilities; + } + throw new Error(`Unexpected npm audit JSON shape in ${cwd}: missing metadata.vulnerabilities`); +} + +describe('#3588: npm audit --omit=dev reports zero advisories', () => { + test('root workspace production tree has no advisories', { timeout: 90_000 }, (t) => { + const vulns = auditProductionVulns(ROOT); + if (vulns === null) { + t.skip('node_modules/ not present — run `npm install` before this test'); + return; + } + assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`); + assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`); + assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`); + // Low advisories are not explicitly forbidden by the #3588 acceptance + // criterion but the issue listed only high/moderate as actual findings — + // tighten if any future low advisory is introduced. + assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`); + }); + + test('sdk/ production tree has no advisories', { timeout: 90_000 }, (t) => { + const vulns = auditProductionVulns(SDK); + if (vulns === null) { + t.skip('sdk/node_modules/ not present — run `npm ci` inside sdk/ before this test'); + return; + } + assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`); + assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`); + assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`); + assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`); + }); +}); diff --git a/tests/bug-3631-router-raw-flag.test.cjs b/tests/bug-3631-router-raw-flag.test.cjs new file mode 100644 index 000000000..1ef627513 --- /dev/null +++ b/tests/bug-3631-router-raw-flag.test.cjs @@ -0,0 +1,134 @@ +'use strict'; + +/** + * Regression tests for #3631 — SDK dispatch path in family routers must + * forward the `--raw` flag through to `output()`. + * + * Before the fix, every `*-command-router.cjs` `sdkHandler` called + * `output(result.data)` without the second positional `raw` argument or the + * third positional `rawValue`. With `--raw` set, the SDK path therefore + * emitted JSON-stringified data ({"next":"2.1",...}) instead of the scalar + * the CJS path used to print (e.g. `2.1`). + * + * Both tests below exercise the live SDK path: + * 1. `phase next-decimal --raw ` must emit the next-decimal token. + * 2. `roadmap get-phase --raw ` must emit the phase's roadmap section. + * + * Per CONTRIBUTING.md: assertions are on structured (scalar) tokens, not + * substring grep against full JSON. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); + +const GSD_TOOLS = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); + +function run(args, cwd) { + try { + return { + ok: true, + stdout: execFileSync(process.execPath, [GSD_TOOLS, ...args], { + cwd, + encoding: 'utf-8', + timeout: 15000, + }), + }; + } catch (e) { + return { + ok: false, + stdout: (e.stdout && e.stdout.toString()) || '', + stderr: (e.stderr && e.stderr.toString()) || '', + code: e.status, + }; + } +} + +function makeFixture() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3631-')); + const planning = path.join(tmp, '.planning'); + fs.mkdirSync(path.join(planning, 'phases'), { recursive: true }); + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Project Roadmap', + '', + '## v1', + '', + '### Phase 1: First', + '', + 'Body of phase 1.', + '', + '### Phase 2: Second', + '', + 'Body of phase 2.', + '', + ].join('\n') + ); + // PROJECT.md anchors the planning root for callers that resolve it. + fs.writeFileSync(path.join(planning, 'PROJECT.md'), '# Test\n'); + return tmp; +} + +describe('bug #3631 — SDK family routers forward --raw to output()', () => { + test('phase next-decimal --raw emits the scalar next-decimal token (not JSON)', () => { + const tmp = makeFixture(); + try { + const res = run(['phase', 'next-decimal', '--raw', '1'], tmp); + assert.ok( + res.ok, + `command must succeed; got code=${res.code} stderr=${res.stderr}` + ); + const trimmed = res.stdout.trim(); + // Scalar form — must be a phase id token like "1.1", not a JSON object. + assert.doesNotMatch( + trimmed, + /^\{/, + `--raw must not emit JSON; got: ${trimmed}` + ); + assert.match( + trimmed, + /^0*\d+(?:\.\d+)?$/, + `--raw must emit a scalar phase id; got: ${trimmed}` + ); + // SDK and CJS both normalize the base phase before computing the next- + // decimal token; CJS emits "1.1" while SDK normalizes "1"→"01" and emits + // "01.1". Both are valid scalar projections — assert on parity with the + // computed-next semantics rather than the exact padding form. + assert.ok( + trimmed === '1.1' || trimmed === '01.1', + `expected next-decimal of base "1" to be 1.1 or 01.1; got: ${trimmed}` + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('roadmap get-phase --raw emits the phase section (not JSON)', () => { + const tmp = makeFixture(); + try { + const res = run(['roadmap', 'get-phase', '--raw', '2'], tmp); + assert.ok( + res.ok, + `command must succeed; got code=${res.code} stderr=${res.stderr}` + ); + const trimmed = res.stdout.trim(); + assert.doesNotMatch( + trimmed, + /^\{/, + `--raw must not emit JSON; got: ${trimmed.slice(0, 80)}` + ); + // Section text starts with the heading. + assert.match( + trimmed, + /Phase 2:\s*Second/, + `--raw must emit the section body containing the Phase 2 heading; got: ${trimmed.slice(0, 80)}` + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/changeset-new.test.cjs b/tests/changeset-new.test.cjs index 9bc3bd5ce..eb2adbddd 100644 --- a/tests/changeset-new.test.cjs +++ b/tests/changeset-new.test.cjs @@ -52,6 +52,7 @@ describe('changeset new: name generator + scaffold writer (#2975)', () => { type: 'Fixed', pr: 9999, body: 'this is a placeholder body that the contributor will replace.', + docsExempt: null, }); }); diff --git a/tests/changeset-parse.test.cjs b/tests/changeset-parse.test.cjs index ff3642d41..4c71721e0 100644 --- a/tests/changeset-parse.test.cjs +++ b/tests/changeset-parse.test.cjs @@ -5,7 +5,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); -const { parseFragment, FRAGMENT_ERROR } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'parse.cjs')); +const { parseFragment, extractDocsExempt, FRAGMENT_ERROR, DOCS_EXEMPT_RE } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'parse.cjs')); describe('changeset parse: fragment file → typed record (#2975)', () => { test('returns { ok: true, fragment } for a well-formed fragment', () => { @@ -16,6 +16,7 @@ describe('changeset parse: fragment file → typed record (#2975)', () => { type: 'Fixed', pr: 2975, body: 'fix the thing.', + docsExempt: null, }); }); @@ -54,3 +55,140 @@ describe('changeset parse: fragment file → typed record (#2975)', () => { }); } }); + +describe('changeset parse: docs-exempt extraction (#3213)', () => { + test('extractDocsExempt returns { docsExempt: null, body } when no marker present', () => { + const out = extractDocsExempt('plain body text'); + assert.deepEqual(out, { docsExempt: null, body: 'plain body text' }); + }); + + test('extractDocsExempt captures the reason and strips the marker from body', () => { + const out = extractDocsExempt('feature note.\n\n'); + assert.equal(out.docsExempt, 'internal-only'); + assert.doesNotMatch(out.body, /docs-exempt/); + assert.match(out.body, /feature note\./); + }); + + test('extractDocsExempt REJECTS bare marker without colon — reason is required (CodeRabbit finding)', () => { + // A bare `` provides no audit trail; intentionally + // not extracted so the lint requires either docs/ updates or a marker + // with a real reason. + const out = extractDocsExempt('body\n'); + assert.equal(out.docsExempt, null); + assert.match(out.body, /docs-exempt/); // unchanged — bare marker stays in body + }); + + test('extractDocsExempt REJECTS marker with empty reason ()', () => { + const out = extractDocsExempt('body\n'); + assert.equal(out.docsExempt, null); + }); + + test('extractDocsExempt REJECTS marker with whitespace-only reason', () => { + const out = extractDocsExempt('body\n'); + assert.equal(out.docsExempt, null); + }); + + test('extractDocsExempt is case-insensitive on the marker token', () => { + const out = extractDocsExempt('body\n'); + assert.equal(out.docsExempt, 'shouty reason'); + }); + + test('parseFragment surfaces docsExempt on the fragment record', () => { + const src = '---\ntype: Added\npr: 3213\n---\nbootstrap.\n\n\n'; + const r = parseFragment(src); + assert.equal(r.ok, true); + assert.equal(r.fragment.docsExempt, 'bootstrap'); + // Marker must not appear in the rendered body. CHANGELOG and GitHub + // release-notes serializers append `(#NNNN)` to the body's last line; + // a trailing comment line would attach the suffix to the wrong content. + assert.doesNotMatch(r.fragment.body, /docs-exempt/); + assert.match(r.fragment.body, /bootstrap\./); + }); + + test('parseFragment fails EMPTY_BODY when the body is only a docs-exempt marker', () => { + const src = '---\ntype: Added\npr: 1\n---\n\n'; + const r = parseFragment(src); + assert.equal(r.ok, false); + assert.equal(r.reason, FRAGMENT_ERROR.EMPTY_BODY); + }); + + test('DOCS_EXEMPT_RE is exposed and matches the documented shape (colon + non-empty reason required)', () => { + assert.ok(DOCS_EXEMPT_RE instanceof RegExp); + assert.match('', DOCS_EXEMPT_RE); + assert.match('', DOCS_EXEMPT_RE); + assert.doesNotMatch('docs-exempt: not in a comment', DOCS_EXEMPT_RE); + assert.doesNotMatch('', DOCS_EXEMPT_RE); // no colon + assert.doesNotMatch('', DOCS_EXEMPT_RE); // empty reason + assert.doesNotMatch('', DOCS_EXEMPT_RE); // whitespace-only reason + }); + + test('inline mention inside backticks does NOT count as a marker (false-positive guard)', () => { + // Fragment body documents the marker syntax inline as part of release notes. + // Without the line-anchor, the regex would mis-identify this as an actual + // exemption and strip release-note content. + const src = + '---\ntype: Added\npr: 3213\n---\n' + + 'New escape hatch: `` on its own line at the end of a fragment body exempts that fragment from docs lint.\n'; + const r = parseFragment(src); + assert.equal(r.ok, true); + assert.equal(r.fragment.docsExempt, null); + // The literal syntax example must remain in the rendered body — it is + // legitimate release-note content explaining the new feature. + assert.match(r.fragment.body, /docs-exempt/); + }); + + test('CRLF-authored fragments: marker is stripped cleanly without residual \\r (Codex finding)', () => { + // Codex's exact repro from the second review pass: + // Feature.\r\n\r\n\r\n + // Before the fix this parsed to body `Feature.\r\n\r\n\r`, which made + // serializeChangelog emit `- Feature.\r\n\r\n\r (#1)` — the PR suffix + // landed on a blank line instead of attached to the visible bullet. + const src = '---\r\ntype: Added\r\npr: 1\r\n---\r\nFeature.\r\n\r\n\r\n'; + const r = parseFragment(src); + assert.equal(r.ok, true); + assert.equal(r.fragment.docsExempt, 'x'); + assert.doesNotMatch(r.fragment.body, /[\r]/); // no residual CR characters + assert.doesNotMatch(r.fragment.body, /docs-exempt/); + // End-to-end: round-trip through serialize → parse to assert on the + // structured changelog IR, not rendered text (CONTRIBUTING.md: + // "Prohibited: Raw Text Matching on Test Outputs"). The buggy pre-fix + // body shape (`Feature.\r\n\r\n\r`) breaks `parseChangelog`'s bullet + // regex — it returns an empty `bullets: []` — so this round-trip is + // a stronger regression check than a substring match. + const { serializeChangelog, parseChangelog } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'serialize.cjs')); + const out = serializeChangelog({ + releaseHeader: { version: '1.0.0', date: '2026-01-01' }, + sections: [{ type: 'Added', bullets: [{ pr: r.fragment.pr, body: r.fragment.body }] }], + priorChangelog: null, + }); + const parsed = parseChangelog(out); + assert.equal(parsed.releases.length, 1); + assert.deepEqual(parsed.releases[0].sections, [ + { type: 'Added', bullets: [{ body: 'Feature.', pr: 1 }] }, + ]); + }); + + test('CRLF-authored fragment without marker: no stripping needed, body unchanged in semantics', () => { + const src = '---\r\ntype: Fixed\r\npr: 5\r\n---\r\nbug fix.\r\n'; + const r = parseFragment(src); + assert.equal(r.ok, true); + assert.equal(r.fragment.docsExempt, null); + assert.match(r.fragment.body, /bug fix\./); + }); + + test('marker on its own line trailing a fragment body still wins (real-marker positive case)', () => { + const src = + '---\ntype: Added\npr: 3213\n---\n' + + 'New escape hatch: `` documents the syntax.\n' + + '\n' + + '\n'; + const r = parseFragment(src); + assert.equal(r.ok, true); + assert.equal(r.fragment.docsExempt, 'bootstrap of the lint itself'); + // The trailing real-marker line is stripped — the "bootstrap" reason + // should not appear anywhere in the rendered body. + assert.doesNotMatch(r.fragment.body, /bootstrap of the lint itself/); + // … but the inline syntax example is preserved. + assert.match(r.fragment.body, /docs-exempt: /); + }); +}); diff --git a/tests/cjs-sdk-bridge-integration.test.cjs b/tests/cjs-sdk-bridge-integration.test.cjs new file mode 100644 index 000000000..18ce9eb11 --- /dev/null +++ b/tests/cjs-sdk-bridge-integration.test.cjs @@ -0,0 +1,93 @@ +'use strict'; + +/** + * Integration test for `get-shit-done/bin/lib/cjs-sdk-bridge.cjs` — locks the + * load-success invariant that Phase 5/6 silently violated before this PR. + * + * Original bug: the bridge used `require('@gsd-build/sdk')` to load the + * runtime-bridge module. That package name is not resolvable from the root + * `node_modules` (the SDK lives at `./sdk/` as a sibling, not a dependency), + * and even if it were, the public entry didn't expose `executeForCjs` or + * `formatStateLoadRawStdout`. `tryLoadSdk()` always returned false, + * `_loadFailed` was cached for the process lifetime, and every CJS router + * silently fell through to the CJS fallback path — making the entire + * CJS→SDK delegation in Phase 5/6 dead code. CI passed because the fallback + * still executed CJS handlers, masking the regression. + * + * This test proves: + * 1. `tryLoadSdk()` returns true on the current checkout. + * 2. `getExecuteForCjs()` returns a real function (not null). + * 3. `getFormatStateLoadRawStdout()` returns a real function (not null). + * 4. Calling `executeForCjs` with a real canonical registry command + * produces a successful SDK result — proving the bridge actually + * dispatches through the runtime bridge rather than failing/falling back. + * + * Requires `sdk/dist/` to exist (i.e. `npm run build:sdk` has run). The + * project's `pretest` hook runs `build:sdk` before tests, so this is met by + * default. If `dist/` is missing, the assertion failures in this file + * surface the cause directly rather than silently masking under fallback. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const BRIDGE_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'cjs-sdk-bridge.cjs'); + +describe('cjs-sdk-bridge: SDK runtime bridge integration', () => { + test('tryLoadSdk() resolves the bundled SDK on the current checkout', () => { + // Fresh require each run so module-level caches reset. + delete require.cache[require.resolve(BRIDGE_PATH)]; + const bridge = require(BRIDGE_PATH); + const loaded = bridge.tryLoadSdk(); + assert.strictEqual( + loaded, + true, + 'tryLoadSdk() must return true; if false, the bridge can no longer ' + + 'locate sdk/dist/runtime-bridge-sync/index.js or its exports — every ' + + 'CJS router will fall back to the per-side CJS handler.', + ); + }); + + test('getExecuteForCjs() returns a function after a successful load', () => { + const bridge = require(BRIDGE_PATH); + bridge.tryLoadSdk(); + assert.strictEqual(typeof bridge.getExecuteForCjs(), 'function'); + }); + + test('getFormatStateLoadRawStdout() returns a function after a successful load', () => { + const bridge = require(BRIDGE_PATH); + bridge.tryLoadSdk(); + assert.strictEqual(typeof bridge.getFormatStateLoadRawStdout(), 'function'); + }); + + test('executeForCjs() actually dispatches a canonical registry command (not a fallback)', () => { + const bridge = require(BRIDGE_PATH); + assert.strictEqual(bridge.tryLoadSdk(), true); + const executeForCjs = bridge.getExecuteForCjs(); + + // `generate-slug` is a canonical, project-independent command in the SDK + // registry. It does not require a `.planning/` fixture, so its success + // proves the bridge dispatch path works end-to-end without confounding + // it with project-state setup. Same command Phase 5.0's smoke test uses. + const result = executeForCjs({ + registryCommand: 'generate-slug', + registryArgs: ['Phase 6 Bridge Wired'], + legacyCommand: 'generate-slug', + legacyArgs: ['Phase 6 Bridge Wired'], + mode: 'json', + projectDir: process.cwd(), + }); + + assert.strictEqual( + result.ok, + true, + `executeForCjs result.ok must be true; got: ${JSON.stringify(result)}. ` + + 'If this fails, the bridge loaded but registry.dispatch did not return ' + + 'a typed-ok result for a known-canonical command — the seam is broken.', + ); + assert.ok(result.data && typeof result.data === 'object', 'result.data must be an object'); + assert.strictEqual(result.data.slug, 'phase-6-bridge-wired'); + assert.strictEqual(result.exitCode, 0); + }); +}); diff --git a/tests/claude-skills-migration.test.cjs b/tests/claude-skills-migration.test.cjs index 370b16efb..aaef37dd1 100644 --- a/tests/claude-skills-migration.test.cjs +++ b/tests/claude-skills-migration.test.cjs @@ -89,8 +89,10 @@ describe('convertClaudeCommandToClaudeSkill', () => { assert.ok(result.includes('name: gsd-next'), 'frontmatter name uses hyphen form (#2808)'); }); - test('preserves body content unchanged', () => { - const body = '\n\nDo the thing.\n\n\n\nStep 1.\nStep 2.\n\n'; + test('preserves body content while normalizing gsd: command references (#3583)', () => { + // The body transformer now rewrites gsd: references (colon → hyphen) but must + // leave all other custom prose, tags, and structure intact. + const body = '\n\nSee /gsd:plan-phase and gsd:review for details.\n\n\n\nStep 1.\nStep 2.\n\n'; const input = [ '---', 'name: gsd:test', @@ -100,10 +102,17 @@ describe('convertClaudeCommandToClaudeSkill', () => { ].join(''); const result = convertClaudeCommandToClaudeSkill(input, 'gsd-test'); + // Custom structure preserved assert.ok(result.includes(''), 'objective tag preserved'); - assert.ok(result.includes('Do the thing.'), 'body text preserved'); + assert.ok(result.includes('See /gsd-plan-phase'), 'rewritten command reference visible'); assert.ok(result.includes(''), 'process tag preserved'); assert.ok(result.includes('Step 1.'), 'step text preserved'); + + // #3583: gsd: references in body are normalized to hyphen form + assert.ok(result.includes('/gsd-plan-phase'), 'colon command ref rewritten to hyphen'); + assert.ok(result.includes('gsd-review'), 'bare colon ref rewritten to hyphen'); + assert.ok(!result.includes('gsd:plan-phase'), 'no colon form should survive in body'); + assert.ok(!result.includes('gsd:review'), 'no colon form should survive in body'); }); test('preserves agent field', () => { diff --git a/tests/decisions-generator.test.cjs b/tests/decisions-generator.test.cjs new file mode 100644 index 000000000..267dd1270 --- /dev/null +++ b/tests/decisions-generator.test.cjs @@ -0,0 +1,217 @@ +'use strict'; + +/** + * Parity test: decisions.generated.cjs vs sdk/src/query/decisions.ts + * + * Verifies that the generated CJS artifact matches the SDK source-of-truth + * for all supported ID formats (numeric and alphanumeric) and edge cases. + * + * Covers: Phase 6 (#3575) MIGRATE_ME resolution for decisions.cjs. + */ + +const assert = require('assert'); +const { describe, test } = require('node:test'); +const { parseDecisions } = require('../get-shit-done/bin/lib/decisions.cjs'); + +// ─── Core parity: numeric IDs (legacy format) ──────────────────────────────── + +describe('decisions-generator parity — numeric IDs (legacy)', () => { + test('extracts D-NN entries with {id, text}', () => { + const md = ` + +## Implementation Decisions + +### Auth +- **D-01:** Use OAuth 2.0 with PKCE +- **D-02:** Session storage in Redis + +### Storage +- **D-03:** Postgres 15 with pgvector + +`; + const ds = parseDecisions(md); + assert.deepStrictEqual(ds.map(d => d.id), ['D-01', 'D-02', 'D-03']); + assert.strictEqual(ds[0].text, 'Use OAuth 2.0 with PKCE'); + }); + + test('returns [] when no block is present', () => { + assert.deepStrictEqual(parseDecisions('# Just a header\nno decisions here'), []); + }); + + test('returns [] for empty / null / undefined input', () => { + assert.deepStrictEqual(parseDecisions(''), []); + assert.deepStrictEqual(parseDecisions(null), []); + assert.deepStrictEqual(parseDecisions(undefined), []); + }); + + test('ignores D-IDs outside the block', () => { + const md = ` +Top of file. - **D-99:** Not a real decision (outside block). + +- **D-01:** Real decision + +After the block. - **D-77:** Also not real. +`; + const ds = parseDecisions(md); + assert.deepStrictEqual(ds.map(d => d.id), ['D-01']); + }); +}); + +// ─── Phase 6 extension: alphanumeric IDs ───────────────────────────────────── + +describe('decisions-generator parity — alphanumeric IDs (Phase 6 extension)', () => { + test('accepts alphanumeric IDs: D-INFRA-01', () => { + const md = ` + +### Infrastructure +- **D-INFRA-01:** Use Kubernetes for orchestration + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds.length, 1); + assert.strictEqual(ds[0].id, 'D-INFRA-01'); + assert.strictEqual(ds[0].text, 'Use Kubernetes for orchestration'); + }); + + test('accepts alphanumeric IDs: D-42 (single numeric)', () => { + const md = ` + +### Architecture +- **D-42:** Use microservices + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds[0].id, 'D-42'); + }); + + test('accepts mixed numeric and alphanumeric IDs in same block', () => { + const md = ` + +### Planning +- **D-01:** First numeric decision +- **D-FOO_BAR:** Alphanumeric with underscore +- **D-ARCH-123:** Mixed alphanumeric with hyphen + +`; + const ds = parseDecisions(md); + const ids = ds.map(d => d.id); + assert.ok(ids.includes('D-01'), 'should have D-01'); + assert.ok(ids.includes('D-FOO_BAR'), 'should have D-FOO_BAR'); + assert.ok(ids.includes('D-ARCH-123'), 'should have D-ARCH-123'); + }); + + test('CJS callers can use {id, text} shape — extra fields present but safe to ignore', () => { + const md = ` + +### Category +- **D-INFRA-01:** Database selection + +`; + const ds = parseDecisions(md); + const d = ds[0]; + // Verify {id, text} is present as CJS callers expect + assert.strictEqual(typeof d.id, 'string'); + assert.strictEqual(typeof d.text, 'string'); + // Extra SDK fields are present but can be ignored + assert.ok('category' in d, 'category field present'); + assert.ok('tags' in d, 'tags field present'); + assert.ok('trackable' in d, 'trackable field present'); + }); +}); + +// ─── Richer schema fields (SDK extension) ──────────────────────────────────── + +describe('decisions-generator parity — richer schema', () => { + test('marks decisions under "Claude\'s Discretion" as non-trackable', () => { + const md = ` + +### Claude's Discretion +- **D-50:** Internal naming is flexible + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds[0].trackable, false); + }); + + test('marks [informational] tagged decisions as non-trackable', () => { + const md = ` + +### Info +- **D-03 [informational]:** Background context only + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds[0].trackable, false); + assert.ok(ds[0].tags.includes('informational')); + }); + + test('marks [folded] tagged decisions as non-trackable', () => { + const md = ` + +### Deferred +- **D-05 [folded]:** Will handle later + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds[0].trackable, false); + }); + + test('extracts category from ### heading', () => { + const md = ` + +### Storage Backend +- **D-01:** Use PostgreSQL + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds[0].category, 'Storage Backend'); + }); + + test('parses ALL blocks (not just first)', () => { + const md = ` + +### One +- **D-01:** First batch + + +Some prose. + + +### Two +- **D-02:** Second batch + +`; + const ids = parseDecisions(md).map(d => d.id); + assert.ok(ids.includes('D-01')); + assert.ok(ids.includes('D-02')); + }); + + test('strips fenced code blocks before parsing', () => { + const md = ` +\`\`\` + +### Fake +- **D-99:** Should not be parsed + +\`\`\` + + +### Real +- **D-01:** Real decision + +`; + const ds = parseDecisions(md); + const ids = ds.map(d => d.id); + assert.ok(ids.includes('D-01')); + assert.ok(!ids.includes('D-99')); + }); + + test('curly-quote "Claude’s Discretion" variant is non-trackable', () => { + const content = + '\n### Claude’s Discretion\n- **D-50:** Should be non-trackable\n'; + const ds = parseDecisions(content); + const d50 = ds.find(d => d.id === 'D-50'); + assert.ok(d50, 'D-50 should be found'); + assert.strictEqual(d50.trackable, false); + }); +}); diff --git a/tests/docs-parity-live-registry.test.cjs b/tests/docs-parity-live-registry.test.cjs index 6ad1e8e2c..e5ca01238 100644 --- a/tests/docs-parity-live-registry.test.cjs +++ b/tests/docs-parity-live-registry.test.cjs @@ -139,6 +139,14 @@ const INTERNAL_COMPONENT_SLUGS = new Set([ // a belt-and-suspenders guard against the pattern returning in other locale docs. 'alternative-1', 'alternative-2', + + // gsd-sync-skills — installed Claude skill directory name (also a workflow + // under get-shit-done/workflows/sync-skills.md), but NOT a registered + // slash command (no commands/gsd/sync-skills.md). Docs reference it as a + // filesystem path component, e.g. "~/.agents/skills/gsd-sync-skills/" in + // docs/discussions/grok-build-support-2026-05.md. The regex captures + // "/gsd-sync-skills" from the path. Invoked via Skill(skill="gsd-sync-skills"). + 'sync-skills', ]); /** diff --git a/tests/lint-docs-required.test.cjs b/tests/lint-docs-required.test.cjs new file mode 100644 index 000000000..adf8dc6b6 --- /dev/null +++ b/tests/lint-docs-required.test.cjs @@ -0,0 +1,374 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + evaluateLint, + readFragmentsFromDisk, + LINT_REASON, + OPT_OUT_LABEL, + TRIGGERING_TYPES, + isFragmentPath, + isDocsFile, + isExemptFragment, +} = require(path.join(__dirname, '..', 'scripts', 'lint-docs-required.cjs')); + +// evaluateLint is pure over the resolved inputs (changedFiles, fragments, +// labels, malformed). Tests assert on the structured verdict: +// { ok, reason: LINT_REASON.X, triggering: string[], malformed? }. + +describe('docs-required lint: pure verdict (#3213)', () => { + test('LINT_REASON enum exposes the documented codes', () => { + assert.deepEqual( + Object.keys(LINT_REASON).sort(), + [ + 'FAIL_DOCS_MISSING', + 'FAIL_MALFORMED_FRAGMENT', + 'OK_DOCS_UPDATED', + 'OK_FRAGMENTS_EXEMPT', + 'OK_NO_TRIGGERING_FRAGMENTS', + 'OK_OPT_OUT_LABEL', + ].sort(), + ); + }); + + test('TRIGGERING_TYPES covers the four user-facing non-fix types', () => { + assert.deepEqual( + [...TRIGGERING_TYPES].sort(), + ['Added', 'Changed', 'Deprecated', 'Removed'].sort(), + ); + }); + + test('OPT_OUT_LABEL is no-docs (matches CONTRIBUTING)', () => { + assert.equal(OPT_OUT_LABEL, 'no-docs'); + }); + + test('OK_NO_TRIGGERING_FRAGMENTS when no fragments touched at all', () => { + const verdict = evaluateLint({ + changedFiles: ['bin/install.js'], + fragments: [], + labels: [], + }); + assert.equal(verdict.ok, true); + assert.equal(verdict.reason, LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS); + assert.deepEqual(verdict.triggering, []); + }); + + test('OK_NO_TRIGGERING_FRAGMENTS for Fixed-only fragments (bug-class)', () => { + const verdict = evaluateLint({ + changedFiles: ['bin/install.js', '.changeset/silly-bears-dance.md'], + fragments: [ + { path: '.changeset/silly-bears-dance.md', type: 'Fixed', body: 'fix typo', docsExempt: null }, + ], + labels: [], + }); + assert.deepEqual(verdict, { + ok: true, + reason: LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS, + triggering: [], + }); + }); + + test('OK_NO_TRIGGERING_FRAGMENTS for Security-only fragments', () => { + const verdict = evaluateLint({ + changedFiles: [], + fragments: [{ path: '.changeset/a.md', type: 'Security', body: 'cve', docsExempt: null }], + labels: [], + }); + assert.equal(verdict.ok, true); + assert.equal(verdict.reason, LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS); + }); + + test('OK_DOCS_UPDATED when Added fragment ships alongside a docs/ change', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/a.md', 'docs/COMMANDS.md'], + fragments: [{ path: '.changeset/a.md', type: 'Added', body: 'new cmd', docsExempt: null }], + labels: [], + }); + assert.equal(verdict.ok, true); + assert.equal(verdict.reason, LINT_REASON.OK_DOCS_UPDATED); + assert.deepEqual(verdict.triggering, ['.changeset/a.md']); + }); + + test('OK_DOCS_UPDATED for nested docs/ paths (docs/adr/, docs/agents/)', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/a.md', 'docs/adr/0099-new.md'], + fragments: [{ path: '.changeset/a.md', type: 'Changed', body: '...', docsExempt: null }], + labels: [], + }); + assert.equal(verdict.reason, LINT_REASON.OK_DOCS_UPDATED); + }); + + for (const type of ['Added', 'Changed', 'Deprecated', 'Removed']) { + test(`FAIL_DOCS_MISSING when ${type} fragment has no docs/ change and no escape hatch`, () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/a.md', 'bin/install.js'], + fragments: [{ path: '.changeset/a.md', type, body: '...', docsExempt: null }], + labels: [], + }); + assert.equal(verdict.ok, false); + assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING); + assert.deepEqual(verdict.triggering, ['.changeset/a.md']); + }); + } + + test('OK_OPT_OUT_LABEL when no-docs label present overrides triggering fragments', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/a.md', 'bin/install.js'], + fragments: [{ path: '.changeset/a.md', type: 'Added', body: '...', docsExempt: null }], + labels: ['no-docs'], + }); + assert.equal(verdict.ok, true); + assert.equal(verdict.reason, LINT_REASON.OK_OPT_OUT_LABEL); + }); + + test('per-fragment docsExempt reason exempts that fragment', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/a.md', 'bin/install.js'], + fragments: [ + { path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: 'internal-only' }, + ], + labels: [], + }); + assert.equal(verdict.ok, true); + assert.equal(verdict.reason, LINT_REASON.OK_FRAGMENTS_EXEMPT); + assert.deepEqual(verdict.triggering, ['.changeset/a.md']); + }); + + test('docsExempt empty string does NOT exempt — defense-in-depth (CodeRabbit finding)', () => { + // parse.cjs no longer produces empty-string docsExempt (the marker regex + // requires a non-empty reason). evaluateLint defends against any caller + // that constructs a fragment with `docsExempt: ''` directly — empty or + // whitespace-only reasons are not a valid audit trail. + const verdict = evaluateLint({ + changedFiles: ['.changeset/a.md', 'bin/install.js'], + fragments: [ + { path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: '' }, + ], + labels: [], + }); + assert.equal(verdict.ok, false); + assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING); + }); + + test('docsExempt whitespace-only does NOT exempt — defense-in-depth', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/a.md', 'bin/install.js'], + fragments: [ + { path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: ' \t' }, + ], + labels: [], + }); + assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING); + }); + + test('partial exemption fails — one un-marked triggering fragment is enough to require docs', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/a.md', '.changeset/b.md', 'bin/install.js'], + fragments: [ + { path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: 'x' }, + { path: '.changeset/b.md', type: 'Changed', body: 'no marker here', docsExempt: null }, + ], + labels: [], + }); + assert.equal(verdict.ok, false); + assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING); + assert.deepEqual(verdict.triggering.sort(), ['.changeset/a.md', '.changeset/b.md']); + }); + + test('mixed Fixed + Added with no docs still fails — Added triggers', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/a.md', '.changeset/b.md'], + fragments: [ + { path: '.changeset/a.md', type: 'Fixed', body: '...', docsExempt: null }, + { path: '.changeset/b.md', type: 'Added', body: '...', docsExempt: null }, + ], + labels: [], + }); + assert.equal(verdict.ok, false); + assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING); + assert.deepEqual(verdict.triggering, ['.changeset/b.md']); + }); +}); + +describe('docs-required lint: malformed fragments fail closed (#3213, Codex finding)', () => { + test('FAIL_MALFORMED_FRAGMENT when a touched fragment failed to parse', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/bad.md'], + fragments: [], + labels: [], + malformed: [{ path: '.changeset/bad.md', reason: 'missing_frontmatter' }], + }); + assert.equal(verdict.ok, false); + assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT); + assert.deepEqual(verdict.malformed, [{ path: '.changeset/bad.md', reason: 'missing_frontmatter' }]); + }); + + test('FAIL_MALFORMED_FRAGMENT outranks OK_DOCS_UPDATED — malformed must be fixed first', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/bad.md', '.changeset/ok.md', 'docs/USER-GUIDE.md'], + fragments: [{ path: '.changeset/ok.md', type: 'Added', body: 'fine', docsExempt: null }], + labels: ['no-docs'], + malformed: [{ path: '.changeset/bad.md', reason: 'invalid_type', detail: 'Bogus' }], + }); + assert.equal(verdict.ok, false); + assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT); + }); + + test('no-docs label cannot bypass FAIL_MALFORMED_FRAGMENT', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/bad.md'], + fragments: [], + labels: ['no-docs'], + malformed: [{ path: '.changeset/bad.md', reason: 'missing_pr' }], + }); + assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT); + }); + + test('malformed defaults to [] when omitted — back-compat with simple test inputs', () => { + const verdict = evaluateLint({ + changedFiles: [], + fragments: [], + labels: [], + }); + assert.equal(verdict.ok, true); + assert.equal(verdict.reason, LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS); + }); +}); + +describe('docs-required lint: helpers', () => { + test('isFragmentPath accepts .changeset/.md, rejects README', () => { + assert.equal(isFragmentPath('.changeset/foo.md'), true); + assert.equal(isFragmentPath('.changeset/silly-bears-dance.md'), true); + assert.equal(isFragmentPath('.changeset/README.md'), false); + assert.equal(isFragmentPath('.changeset/nested/foo.md'), false); + assert.equal(isFragmentPath('docs/COMMANDS.md'), false); + assert.equal(isFragmentPath('bin/install.js'), false); + }); + + test('isDocsFile matches docs/ prefix only', () => { + assert.equal(isDocsFile('docs/COMMANDS.md'), true); + assert.equal(isDocsFile('docs/adr/0001-foo.md'), true); + assert.equal(isDocsFile('docs/agents/triage-labels.md'), true); + assert.equal(isDocsFile('docs'), false); // exact 'docs' without slash is not a file under docs/ + assert.equal(isDocsFile('CONTRIBUTING.md'), false); + assert.equal(isDocsFile('README.md'), false); + }); + + test('isExemptFragment checks docsExempt is a non-empty string, not body content', () => { + assert.equal(isExemptFragment({ docsExempt: 'reason' }), true); + assert.equal(isExemptFragment({ docsExempt: 'a' }), true); + // Empty/whitespace-only reason → no audit trail → not exempt. + assert.equal(isExemptFragment({ docsExempt: '' }), false); + assert.equal(isExemptFragment({ docsExempt: ' \t' }), false); + assert.equal(isExemptFragment({ docsExempt: null }), false); + assert.equal(isExemptFragment({ docsExempt: undefined }), false); + assert.equal(isExemptFragment({}), false); + // Body content is irrelevant — parse.cjs extracts the marker into docsExempt. + assert.equal( + isExemptFragment({ body: '', docsExempt: null }), + false, + ); + }); +}); + +describe('docs-required lint: readFragmentsFromDisk', () => { + function withTempRepo(fn) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-docs-lint-')); + try { + fs.mkdirSync(path.join(tmp, '.changeset'), { recursive: true }); + fn(tmp); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + + test('returns { fragments, malformed } shape', () => { + withTempRepo((tmp) => { + const out = readFragmentsFromDisk([], tmp); + assert.ok('fragments' in out, 'has fragments'); + assert.ok('malformed' in out, 'has malformed'); + assert.deepEqual(out.fragments, []); + assert.deepEqual(out.malformed, []); + }); + }); + + test('parses valid fragments and skips non-fragment paths', () => { + withTempRepo((tmp) => { + fs.writeFileSync( + path.join(tmp, '.changeset', 'a.md'), + '---\ntype: Added\npr: 1\n---\nnew feature\n', + ); + fs.writeFileSync( + path.join(tmp, '.changeset', 'b.md'), + '---\ntype: Fixed\npr: 2\n---\nbug fix\n', + ); + const { fragments, malformed } = readFragmentsFromDisk( + ['.changeset/a.md', '.changeset/b.md', 'bin/x.js'], + tmp, + ); + assert.equal(fragments.length, 2); + assert.equal(fragments[0].path, '.changeset/a.md'); + assert.equal(fragments[0].type, 'Added'); + assert.equal(fragments[0].docsExempt, null); + assert.equal(fragments[1].type, 'Fixed'); + assert.deepEqual(malformed, []); + }); + }); + + test('skips deleted fragments (path in diff but file gone)', () => { + withTempRepo((tmp) => { + const { fragments, malformed } = readFragmentsFromDisk(['.changeset/deleted.md'], tmp); + assert.deepEqual(fragments, []); + assert.deepEqual(malformed, []); + }); + }); + + test('routes malformed fragments to the malformed list with typed reason', () => { + withTempRepo((tmp) => { + fs.writeFileSync(path.join(tmp, '.changeset', 'bad.md'), 'no frontmatter here\n'); + const { fragments, malformed } = readFragmentsFromDisk(['.changeset/bad.md'], tmp); + assert.deepEqual(fragments, []); + assert.equal(malformed.length, 1); + assert.equal(malformed[0].path, '.changeset/bad.md'); + assert.equal(malformed[0].reason, 'missing_frontmatter'); + }); + }); + + test('Added fragment with bad pr surfaces as malformed (Codex finding regression test)', () => { + withTempRepo((tmp) => { + fs.writeFileSync( + path.join(tmp, '.changeset', 'a.md'), + '---\ntype: Added\n---\nbody but no pr field\n', + ); + const { fragments, malformed } = readFragmentsFromDisk(['.changeset/a.md'], tmp); + assert.deepEqual(fragments, []); + assert.equal(malformed.length, 1); + assert.equal(malformed[0].reason, 'missing_pr'); + // End-to-end: feed straight into evaluateLint and confirm fail-closed. + const verdict = evaluateLint({ changedFiles: ['.changeset/a.md'], fragments, malformed, labels: [] }); + assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT); + }); + }); + + test('extracts docs-exempt marker into typed field and strips it from body', () => { + withTempRepo((tmp) => { + fs.writeFileSync( + path.join(tmp, '.changeset', 'a.md'), + '---\ntype: Added\npr: 3\n---\nnew thing\n\n\n', + ); + const { fragments } = readFragmentsFromDisk(['.changeset/a.md'], tmp); + assert.equal(fragments.length, 1); + assert.equal(fragments[0].docsExempt, 'internal-only'); + // The marker no longer appears in the rendered body — renderers append + // `(#NNNN)` to body's last line, so the marker would otherwise leak into + // CHANGELOG.md / GitHub release notes. + assert.doesNotMatch(fragments[0].body, /docs-exempt/); + }); + }); +}); diff --git a/tests/lint-shared-module-handsync.test.cjs b/tests/lint-shared-module-handsync.test.cjs new file mode 100644 index 000000000..497159499 --- /dev/null +++ b/tests/lint-shared-module-handsync.test.cjs @@ -0,0 +1,369 @@ +'use strict'; + +/** + * Tests for scripts/lint-shared-module-handsync.cjs — Phase 6 of #3524 (#3575). + * + * Three cases: + * 1. No new drift pair: lint exits 0 on the current repo tree (all cooperating + * siblings on the allowlist; migrateMeBacklog pairs do not fail). + * 2. Intentional new drift: synthesize a fixture tree with an unlisted + * foo-test.cjs / foo-test.ts pair, assert exit 1 + typed error JSON. + * 3. Allowlist entry honored: same pair as case 2, but with a cooperatingSiblings + * allowlist entry present, assert exit 0. + * + * Assertions use the lint's --json mode: the production code emits a typed IR + * (ok / reason / errors / warnings / counts), and tests parse and assert on + * structured fields rather than substring-matching stderr/stdout (per + * CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs"). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); + +const LINT_SCRIPT = path.join(__dirname, '..', 'scripts', 'lint-shared-module-handsync.cjs'); +const ALLOWLIST_PATH = path.join(__dirname, '..', 'scripts', 'shared-module-handsync-allowlist.json'); +const REPO_ROOT = path.join(__dirname, '..'); + +// --------------------------------------------------------------------------- +// Helper: run the lint script in --json mode and parse the result. +// Returns { status, payload } where payload is the parsed JSON IR (or null +// if the lint emitted no JSON, which would be a test-infrastructure bug). +// --------------------------------------------------------------------------- +function runLintJson(extraArgs = []) { + const result = spawnSync(process.execPath, [LINT_SCRIPT, '--json', ...extraArgs], { + encoding: 'utf8', + cwd: REPO_ROOT, + }); + let payload = null; + try { + payload = JSON.parse(result.stdout.trim()); + } catch { + // Leave payload as null; tests assert on payload presence. + } + return { status: result.status, payload }; +} + +// --------------------------------------------------------------------------- +// Helper: create an isolated fixture tree for testing +// +// Layout: +// / +// get-shit-done/bin/lib/.cjs +// sdk/src/query/.ts (if tsInQuery === true) +// sdk/src/.ts (if tsInQuery === false) +// scripts/shared-module-handsync-allowlist.json (custom allowlist) +// --------------------------------------------------------------------------- +function createFixture({ cjsName, tsName, tsInQuery = true, allowlistExtra = {} }) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-handsync-')); + + const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib'); + fs.mkdirSync(cjsDir, { recursive: true }); + + const tsDir = tsInQuery + ? path.join(tmpDir, 'sdk', 'src', 'query') + : path.join(tmpDir, 'sdk', 'src'); + fs.mkdirSync(tsDir, { recursive: true }); + + const scriptsDir = path.join(tmpDir, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + + fs.writeFileSync(path.join(cjsDir, `${cjsName}.cjs`), `'use strict';\n// fixture cjs\n`); + fs.writeFileSync(path.join(tsDir, `${tsName}.ts`), `// fixture ts\nexport {};\n`); + + const realAllowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8')); + const fixtureAllowlist = { + cooperatingSiblings: [ + ...(realAllowlist.cooperatingSiblings || []), + ...(allowlistExtra.cooperatingSiblings || []), + ], + migrateMeBacklog: [ + ...(realAllowlist.migrateMeBacklog || []), + ...(allowlistExtra.migrateMeBacklog || []), + ], + }; + fs.writeFileSync( + path.join(scriptsDir, 'shared-module-handsync-allowlist.json'), + JSON.stringify(fixtureAllowlist, null, 2) + ); + + return tmpDir; +} + +function cleanupFixture(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// Case 1: No new drift pair — exits 0 on current repo tree +// --------------------------------------------------------------------------- +describe('lint-shared-module-handsync: current repo tree', () => { + test('exits 0 with the real allowlist and current repo tree', () => { + const { status, payload } = runLintJson(); + assert.strictEqual(status, 0); + assert.ok(payload, 'expected JSON payload on stdout'); + assert.strictEqual(payload.ok, true); + }); + + test('reports cooperating sibling count and zero unauthorized pairs', () => { + const { payload } = runLintJson(); + assert.ok(payload); + assert.strictEqual(typeof payload.cooperatingCount, 'number'); + assert.ok(payload.cooperatingCount > 0, 'expected at least one cooperating sibling'); + // No errors field on success — only warnings (backlog) may be present + assert.strictEqual(payload.ok, true); + }); + + test('script has no syntax errors', () => { + const result = spawnSync(process.execPath, ['--check', LINT_SCRIPT], { encoding: 'utf8' }); + assert.strictEqual(result.status, 0); + }); +}); + +// --------------------------------------------------------------------------- +// Case 2: Intentional new drift — exits 1 with informative typed error +// --------------------------------------------------------------------------- +describe('lint-shared-module-handsync: intentional new drift pair', () => { + test('exits 1 when an unlisted cjs/ts pair exists', () => { + const tmpDir = createFixture({ cjsName: 'foo-test', tsName: 'foo-test', tsInQuery: true }); + try { + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 1); + assert.ok(payload); + assert.strictEqual(payload.ok, false); + assert.strictEqual(payload.reason, 'unauthorized_pairs'); + } finally { + cleanupFixture(tmpDir); + } + }); + + test('typed error payload names the unauthorized pair', () => { + const tmpDir = createFixture({ cjsName: 'foo-test', tsName: 'foo-test', tsInQuery: true }); + try { + const { payload } = runLintJson(['--root', tmpDir]); + assert.ok(payload && Array.isArray(payload.errors)); + assert.strictEqual(payload.errors.length, 1); + const [entry] = payload.errors; + assert.match(entry.relCjs, /foo-test\.cjs$/); + assert.ok(Array.isArray(entry.tsPaths)); + assert.ok(entry.tsPaths.some((p) => /foo-test\.ts$/.test(p))); + } finally { + cleanupFixture(tmpDir); + } + }); + + test('exits 1 for unlisted pair in sdk/src/.ts (non-query) position', () => { + const tmpDir = createFixture({ cjsName: 'bar-test', tsName: 'bar-test', tsInQuery: false }); + try { + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 1); + assert.ok(payload); + assert.strictEqual(payload.ok, false); + assert.strictEqual(payload.reason, 'unauthorized_pairs'); + } finally { + cleanupFixture(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// Case 3: Allowlist entry honored — exits 0 when pair IS on cooperatingSiblings +// --------------------------------------------------------------------------- +describe('lint-shared-module-handsync: allowlist entry honored', () => { + test('exits 0 when pair is in cooperatingSiblings allowlist', () => { + const cjsName = 'baz-cooperating'; + const tsName = 'baz-cooperating'; + const tmpDir = createFixture({ + cjsName, + tsName, + tsInQuery: true, + allowlistExtra: { + cooperatingSiblings: [ + { + cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, + ts: `sdk/src/query/${tsName}.ts`, + classification: 'cooperating-sibling', + justification: 'Test fixture: synthetic cooperating sibling for lint test.', + }, + ], + }, + }); + try { + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 0); + assert.ok(payload); + assert.strictEqual(payload.ok, true); + } finally { + cleanupFixture(tmpDir); + } + }); + + // Regression guard: the lint matches on the (cjs, ts) PAIR, not on the + // cjs path alone. An allowlist entry whose ts points to a different path + // than the actual ts sibling on disk must NOT silently pass the pair. + test('rejects pair when TS path differs from allowlist entry', () => { + const cjsName = 'foo-wrong-ts'; + const tsName = 'foo-wrong-ts'; + const tmpDir = createFixture({ + cjsName, + tsName, + tsInQuery: true, // creates sdk/src/query/foo-wrong-ts.ts on disk + allowlistExtra: { + cooperatingSiblings: [ + { + cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, + // Allowlist points at sdk/src/.ts — different location. + // Lint must reject because the on-disk pair is unauthorized. + ts: `sdk/src/${tsName}.ts`, + classification: 'cooperating-sibling', + justification: 'Test: validates pair-aware matching enforces ts path.', + }, + ], + }, + }); + try { + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 1, 'must fail when ts path mismatches'); + assert.ok(payload); + assert.strictEqual(payload.ok, false); + assert.strictEqual(payload.reason, 'unauthorized_pairs'); + } finally { + cleanupFixture(tmpDir); + } + }); + + test('exits 0 (no error) when pair is in migrateMeBacklog allowlist', () => { + const cjsName = 'qux-backlog'; + const tsName = 'qux-backlog'; + const tmpDir = createFixture({ + cjsName, + tsName, + tsInQuery: true, + allowlistExtra: { + migrateMeBacklog: [ + { + cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, + ts: `sdk/src/query/${tsName}.ts`, + classification: 'drift-anti-pattern', + justification: 'Test fixture: synthetic backlog pair for lint test.', + trackedIn: 'test only', + }, + ], + }, + }); + try { + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 0); + assert.ok(payload); + assert.strictEqual(payload.ok, true); + // The backlog pair should be reported in warnings (not errors) + assert.ok(Array.isArray(payload.warnings)); + assert.ok( + payload.warnings.some((w) => /qux-backlog\.cjs$/.test(w.relCjs)), + 'expected qux-backlog in warnings' + ); + } finally { + cleanupFixture(tmpDir); + } + }); + + // Regression guard for #3632: when a cjs has TWO ts siblings sharing the + // same basename (e.g. sdk/src/foo.ts AND sdk/src/query/foo.ts) and only + // ONE pair is allowlisted, the unallowlisted sibling must still be reported. + // Prior bug: .some() at the cjs level returned true on the allowlisted + // pair, short-circuiting and silently dropping the unallowlisted sibling. + test('reports unallowlisted ts sibling when another ts sibling for the same cjs IS allowlisted (#3632)', () => { + const cjsName = 'multi-sibling'; + const tsName = 'multi-sibling'; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-multi-')); + try { + const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib'); + fs.mkdirSync(cjsDir, { recursive: true }); + const tsDirRoot = path.join(tmpDir, 'sdk', 'src'); + const tsDirQuery = path.join(tmpDir, 'sdk', 'src', 'query'); + fs.mkdirSync(tsDirQuery, { recursive: true }); + const scriptsDir = path.join(tmpDir, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + + // One cjs, two ts siblings on disk (same basename, different paths). + fs.writeFileSync(path.join(cjsDir, `${cjsName}.cjs`), `'use strict';\n`); + fs.writeFileSync(path.join(tsDirRoot, `${tsName}.ts`), `export {};\n`); + fs.writeFileSync(path.join(tsDirQuery, `${tsName}.ts`), `export {};\n`); + + // Allowlist ONLY the sdk/src/.ts pair. The sdk/src/query/.ts + // sibling is intentionally NOT allowlisted and must be reported. + fs.writeFileSync( + path.join(scriptsDir, 'shared-module-handsync-allowlist.json'), + JSON.stringify( + { + cooperatingSiblings: [ + { + cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, + ts: `sdk/src/${tsName}.ts`, + classification: 'cooperating-sibling', + justification: 'Test: only the non-query sibling is allowlisted.', + }, + ], + migrateMeBacklog: [], + }, + null, + 2 + ) + ); + + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual( + status, + 1, + 'must fail: the sdk/src/query/.ts sibling is not allowlisted' + ); + assert.ok(payload); + assert.strictEqual(payload.ok, false); + assert.strictEqual(payload.reason, 'unauthorized_pairs'); + assert.ok(Array.isArray(payload.errors) && payload.errors.length >= 1); + const reportedTs = payload.errors.flatMap((e) => e.tsPaths); + assert.ok( + reportedTs.some((p) => /sdk\/src\/query\/multi-sibling\.ts$/.test(p)), + `expected query sibling in errors, got: ${JSON.stringify(reportedTs)}` + ); + // The allowlisted sibling must NOT appear in errors. + assert.ok( + !reportedTs.some((p) => /^sdk\/src\/multi-sibling\.ts$/.test(p)), + `allowlisted sibling sdk/src/multi-sibling.ts must not be flagged, got: ${JSON.stringify(reportedTs)}` + ); + } finally { + cleanupFixture(tmpDir); + } + }); + + test('generated .cjs files are excluded from pair detection', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-gen-')); + try { + const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib'); + fs.mkdirSync(cjsDir, { recursive: true }); + const tsDir = path.join(tmpDir, 'sdk', 'src', 'query'); + fs.mkdirSync(tsDir, { recursive: true }); + const scriptsDir = path.join(tmpDir, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + + // A .generated.cjs file + matching TS — should NOT trigger lint error + fs.writeFileSync(path.join(cjsDir, 'my-module.generated.cjs'), `'use strict';\n`); + fs.writeFileSync(path.join(tsDir, 'my-module.ts'), `export {};\n`); + + fs.writeFileSync( + path.join(scriptsDir, 'shared-module-handsync-allowlist.json'), + JSON.stringify({ cooperatingSiblings: [], migrateMeBacklog: [] }, null, 2) + ); + + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 0); + assert.ok(payload); + assert.strictEqual(payload.ok, true); + } finally { + cleanupFixture(tmpDir); + } + }); +}); diff --git a/tests/phase-6-cjs-sdk-seam-contracts.test.cjs b/tests/phase-6-cjs-sdk-seam-contracts.test.cjs new file mode 100644 index 000000000..b743f12a6 --- /dev/null +++ b/tests/phase-6-cjs-sdk-seam-contracts.test.cjs @@ -0,0 +1,507 @@ +'use strict'; + +/** + * Phase 6 (issue #3524 / PR #3577) — CJS↔SDK seam behavioral contract tests. + * + * Issue #3592 explicitly tracks the migration away from text-existence / + * source-grep tests onto behavioral contract tests. This file is the + * behavioral contract surface for everything Phase 6 introduced: + * + * • `get-shit-done/bin/lib/cjs-sdk-bridge.cjs` — load + cache + surface + * • `sdk/src/runtime-bridge-sync/index.ts` — sync dispatch primitive + * (returns `RuntimeBridgeSyncResult`, a discriminated union with a + * fixed `SyncErrorKind` taxonomy) + * • The 7 family routers (`init|phase|phases|roadmap|state|validate| + * verify-command-router.cjs`) + top-level `gsd-tools.cjs` dispatch — + * each must route a canonical registry command through the bridge + * and emit a JSON-shaped result on stdout. + * • Workstream-scoped commands — Phase 6 made these native; the + * bridge must accept a `workstream` field and the CLI must still + * fall back to CJS when `GSD_WORKSTREAM` is set (the gate the + * routers use to defer to per-side CJS handlers). + * + * Test rules in force (from `CONTRIBUTING.md` § Testing Standards and + * issue #3592): + * + * 1. No `readFileSync` of any `.cjs` source file to assert text + * content. Every assertion is on a parsed JSON object, a + * filesystem fact, an exit code, or a frozen enum value. + * 2. No `assert.match`/`.includes` on free-form child-process stdout + * or stderr. Either parse JSON, or assert on a structured field + * via the bridge API directly. + * 3. Frozen enums describe the canonical taxonomies the production + * code MUST emit. Drift between production and test fails the + * object-shape lock test, not a substring lookup. + * 4. Filesystem assertions use `fs.statSync().isFile()` / `.size` — + * never read the file content back as a substring assertion. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const BRIDGE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'cjs-sdk-bridge.cjs'); + +// ─── Frozen taxonomies ──────────────────────────────────────────────────────── +// +// These describe the canonical shapes Phase 6 ships. Tests assert against the +// enum values, not against substring matches. Adding a new error kind or a +// new bridge export requires updating BOTH the production code AND the +// matching frozen set below — that's three coordinated edits, which is the +// drift-prevention property the new contract pattern is meant to provide. + +/** SDK runtime-bridge-sync `SyncErrorKind` taxonomy (sdk/src/runtime-bridge-sync/index.ts:62-68). */ +const SYNC_ERROR_KIND = Object.freeze({ + UNKNOWN_COMMAND: 'unknown_command', + NATIVE_FAILURE: 'native_failure', + NATIVE_TIMEOUT: 'native_timeout', + FALLBACK_FAILURE: 'fallback_failure', + VALIDATION_ERROR: 'validation_error', + INTERNAL_ERROR: 'internal_error', +}); + +const SYNC_ERROR_KIND_VALUES = Object.freeze(new Set(Object.values(SYNC_ERROR_KIND))); + +/** Surface of `cjs-sdk-bridge.cjs`. Adding an export requires updating both. */ +const BRIDGE_EXPORTS = Object.freeze([ + 'tryLoadSdk', + 'getExecuteForCjs', + 'getFormatStateLoadRawStdout', + 'getSdkModule', +]); + +/** TransportMode values accepted by executeForCjs. Bridge must support both. */ +const TRANSPORT_MODE = Object.freeze({ JSON: 'json', RAW: 'raw' }); + +// ─── Bridge module helper ───────────────────────────────────────────────────── +// +// Fresh-require the bridge once per describe block so each test sees an +// isolated load state. `delete require.cache[...]` is the canonical +// reset; never patch internals. + +function freshBridge() { + delete require.cache[require.resolve(BRIDGE_PATH)]; + return require(BRIDGE_PATH); +} + +// ─── 1. Bridge module surface contract ───────────────────────────────────────── + +describe('phase 6: cjs-sdk-bridge surface', () => { + test('exposes exactly the documented exports — frozen set', () => { + const bridge = freshBridge(); + const actual = Object.keys(bridge).sort(); + assert.deepStrictEqual( + actual, + [...BRIDGE_EXPORTS].sort(), + 'bridge surface drifted from BRIDGE_EXPORTS — update both production code and the frozen set together', + ); + }); + + test('every documented export is a function', () => { + const bridge = freshBridge(); + for (const name of BRIDGE_EXPORTS) { + assert.strictEqual(typeof bridge[name], 'function', `${name} must be a function`); + } + }); +}); + +// ─── 2. Bridge load + cache contract ────────────────────────────────────────── + +describe('phase 6: cjs-sdk-bridge load lifecycle', () => { + test('tryLoadSdk resolves the bundled SDK on a working checkout', () => { + const bridge = freshBridge(); + assert.strictEqual(bridge.tryLoadSdk(), true); + }); + + test('post-load getters return non-null when tryLoadSdk succeeded', () => { + const bridge = freshBridge(); + bridge.tryLoadSdk(); + assert.strictEqual(typeof bridge.getExecuteForCjs(), 'function'); + assert.strictEqual(typeof bridge.getFormatStateLoadRawStdout(), 'function'); + const mod = bridge.getSdkModule(); + assert.ok(mod && typeof mod === 'object', 'getSdkModule must return the cached module object'); + assert.strictEqual(typeof mod.executeForCjs, 'function'); + }); + + test('repeated tryLoadSdk calls return the cached result (same reference)', () => { + const bridge = freshBridge(); + bridge.tryLoadSdk(); + const fn1 = bridge.getExecuteForCjs(); + bridge.tryLoadSdk(); + const fn2 = bridge.getExecuteForCjs(); + assert.strictEqual(fn1, fn2, 'getExecuteForCjs must return the same cached function'); + }); + + test('pre-load getters return null', () => { + const bridge = freshBridge(); + assert.strictEqual(bridge.getExecuteForCjs(), null); + assert.strictEqual(bridge.getFormatStateLoadRawStdout(), null); + assert.strictEqual(bridge.getSdkModule(), null); + }); +}); + +// ─── 3. executeForCjs discriminated-union result shape ──────────────────────── + +describe('phase 6: executeForCjs RuntimeBridgeSyncResult shape', () => { + let bridge; + let executeForCjs; + let tmpDir; + + beforeEach(() => { + bridge = freshBridge(); + bridge.tryLoadSdk(); + executeForCjs = bridge.getExecuteForCjs(); + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('ok:true result shape — { ok, data, exitCode }', () => { + const result = executeForCjs({ + registryCommand: 'generate-slug', + registryArgs: ['Phase 6 Seam Contract'], + legacyCommand: 'generate-slug', + legacyArgs: ['Phase 6 Seam Contract'], + mode: TRANSPORT_MODE.JSON, + projectDir: tmpDir, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.exitCode, 0); + assert.ok(result.data && typeof result.data === 'object', 'data must be an object on ok:true'); + assert.strictEqual(typeof result.data.slug, 'string'); + }); + + test('ok:false result for unknown command — errorKind ∈ SyncErrorKind, exitCode ≠ 0', () => { + const result = executeForCjs({ + registryCommand: 'totally.unknown.command.xyz', + registryArgs: [], + legacyCommand: 'totally.unknown.command.xyz', + legacyArgs: [], + mode: TRANSPORT_MODE.JSON, + projectDir: tmpDir, + }); + assert.strictEqual(result.ok, false); + assert.notStrictEqual(result.exitCode, 0); + assert.ok( + SYNC_ERROR_KIND_VALUES.has(result.errorKind), + `errorKind "${result.errorKind}" must be one of ${[...SYNC_ERROR_KIND_VALUES].join(', ')}`, + ); + assert.ok(Array.isArray(result.stderrLines), 'stderrLines must be an array on ok:false'); + }); + + test('mode:"json" returns parsed data, never a JSON-encoded string', () => { + // Regression for the Wave-1 bug where routers passed `mode: 'raw'` and the + // bridge pre-rendered to a JSON string that CJS output() then double- + // stringified. result.data MUST be a structured object/array/primitive + // — never a string that itself parses as JSON. + const result = executeForCjs({ + registryCommand: 'generate-slug', + registryArgs: ['Mode Json Check'], + legacyCommand: 'generate-slug', + legacyArgs: ['Mode Json Check'], + mode: TRANSPORT_MODE.JSON, + projectDir: tmpDir, + }); + assert.strictEqual(result.ok, true); + assert.notStrictEqual(typeof result.data, 'string', + 'mode:"json" must hand callers parsed data, not a serialized JSON blob'); + }); +}); + +// ─── 4. CLI family-router dispatch contracts ────────────────────────────────── +// +// One representative read-only command per family. Each test: +// 1. Invokes the CLI through `runGsdTools` (real child process). +// 2. Asserts exit success. +// 3. Parses stdout as JSON. +// 4. Asserts on a structured field, not on prose. +// +// This is the byte-for-byte parity contract Phase 6 promised: SDK-routed +// commands emit the same JSON shape as the legacy CJS handlers used to. + +describe('phase 6: CLI family-router dispatch emits structured JSON', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + // Minimal ROADMAP fixture for any family that scans it. + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# v1.0 Roadmap', + '', + '### Phase 1: Foundation', + '**Goal:** Setup', + '**Requirements**: REQ-01', + '**Plans:** 0 plans', + '', + ].join('\n'), + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + [ + '# State', + '', + '**Current Phase:** 01', + '**Status:** In progress', + '**Total Plans in Phase:** 0', + '**Progress:** [░░░░░░░░░░] 0%', + '**Last Activity:** 2026-05-15', + '', + ].join('\n'), + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('roadmap.get-phase emits found:true with structured phase fields', () => { + const result = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir); + assert.ok(result.success, `roadmap get-phase failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.found, true); + assert.strictEqual(payload.phase_number, '1'); + assert.strictEqual(payload.phase_name, 'Foundation'); + }); + + test('roadmap.analyze emits a milestones array', () => { + const result = runGsdTools(['roadmap', 'analyze'], tmpDir); + assert.ok(result.success, `roadmap analyze failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.ok(Array.isArray(payload.phases), 'phases must be an array'); + }); + + test('phase next-decimal emits a structured next/base shape', () => { + const result = runGsdTools(['phase', 'next-decimal', '1'], tmpDir); + assert.ok(result.success, `phase next-decimal failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.base_phase, '01'); + assert.strictEqual(typeof payload.next, 'string'); + assert.ok(Array.isArray(payload.existing), 'existing must be an array'); + }); + + test('phases list emits a directories array with count', () => { + const result = runGsdTools(['phases', 'list'], tmpDir); + assert.ok(result.success, `phases list failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.ok(Array.isArray(payload.directories), 'directories must be an array'); + assert.strictEqual(typeof payload.count, 'number'); + }); + + test('state json emits a frontmatter object with progress', () => { + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.gsd_state_version, '1.0'); + assert.ok(payload.progress && typeof payload.progress === 'object', + 'progress must be a structured object, not a serialized string'); + }); + + test('init plan-phase emits phase_found + model fields', () => { + const result = runGsdTools(['init', 'plan-phase', '1'], tmpDir); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.phase_found, true); + assert.strictEqual(payload.phase_number, '1'); + assert.strictEqual(typeof payload.researcher_model, 'string'); + }); + + test('validate consistency emits valid + warnings array', () => { + const result = runGsdTools(['validate', 'consistency'], tmpDir); + assert.ok(result.success, `validate consistency failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.ok(typeof payload.valid === 'boolean' || Array.isArray(payload.warnings), + 'validate consistency must emit either {valid, warnings} shape'); + }); + + test('find-phase for non-existent phase emits found:false (not a process error)', () => { + const result = runGsdTools(['find-phase', '99'], tmpDir); + assert.ok(result.success, `find-phase should not error on missing phase: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.found, false); + }); +}); + +// ─── 5. mode:"json" prevents double-stringify (Wave 1 bug regression) ───────── + +describe('phase 6: mode:"json" never double-stringifies the data', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# v1.0', + '', + '### Phase 1: Setup', + '**Goal:** Initial setup', + '', + ].join('\n'), + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // The Wave-1 bug shape: stdout looked like JSON of JSON, e.g. + // "\"{\\n \\\"found\\\": true\"". + // After the fix, stdout is a single JSON object that parses to an object — + // never a string that itself parses to an object. + test('roadmap get-phase stdout parses to an object, not a JSON-encoded string', () => { + const result = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir); + assert.ok(result.success, `command failed: ${result.error}`); + const first = JSON.parse(result.output); + assert.strictEqual( + typeof first, + 'object', + 'CLI stdout for a JSON-mode command must parse directly to an object', + ); + assert.notStrictEqual( + typeof first, + 'string', + 'double-stringify regression: stdout parsed to a string that would itself parse as JSON', + ); + }); +}); + +// ─── 6. Workstream-scoped CJS fallback gate ──────────────────────────────────── +// +// Phase 6 made workstream-scoped commands native in the SDK transport, BUT the +// CJS routers still force CJS fallback when `GSD_WORKSTREAM` is set in the +// environment, so workstream-aware tests and inspections can target a +// specific workstream's `.planning/` slice without round-tripping through +// the synckit worker. Both modes must work and must produce the same JSON +// shape for the same input fixture. + +describe('phase 6: GSD_WORKSTREAM gate routes through CJS fallback consistently', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + ['# v1.0', '', '### Phase 1: Setup', '**Goal:** Setup', ''].join('\n'), + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('roadmap get-phase produces identical structured output with and without GSD_WORKSTREAM unset', () => { + const sdkPath = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir); + assert.ok(sdkPath.success, `SDK dispatch failed: ${sdkPath.error}`); + const sdkPayload = JSON.parse(sdkPath.output); + + // When GSD_WORKSTREAM is set, the router falls through to CJS. For the + // primary planning slice (no workstream subdir yet), passing the env var + // should still parse the same ROADMAP.md and emit the same fields. + const cjsPath = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir, { GSD_WORKSTREAM: '' }); + assert.ok(cjsPath.success, `CJS fallback dispatch failed: ${cjsPath.error}`); + const cjsPayload = JSON.parse(cjsPath.output); + + // Compare structured fields, never the rendered text. + assert.strictEqual(sdkPayload.found, cjsPayload.found); + assert.strictEqual(sdkPayload.phase_number, cjsPayload.phase_number); + assert.strictEqual(sdkPayload.phase_name, cjsPayload.phase_name); + }); +}); + +// ─── 7. Validation-error contract for malformed input ────────────────────────── +// +// When a registry command receives an invalid argument, the bridge must map +// the error to `validation_error` in the SyncErrorKind taxonomy and surface a +// non-zero exit code. This is the "negative path" coverage that #3592 +// explicitly calls out as required. + +describe('phase 6: validation errors map to SyncErrorKind.validation_error', () => { + let bridge; + let executeForCjs; + let tmpDir; + + beforeEach(() => { + bridge = freshBridge(); + bridge.tryLoadSdk(); + executeForCjs = bridge.getExecuteForCjs(); + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('find-phase with empty phase identifier returns ok:false + validation_error', () => { + const result = executeForCjs({ + registryCommand: 'find-phase', + registryArgs: [], + legacyCommand: 'find-phase', + legacyArgs: [], + mode: TRANSPORT_MODE.JSON, + projectDir: tmpDir, + }); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.errorKind, SYNC_ERROR_KIND.VALIDATION_ERROR, + `validation errors must map to ${SYNC_ERROR_KIND.VALIDATION_ERROR}, got ${result.errorKind}`); + assert.notStrictEqual(result.exitCode, 0, 'validation_error must produce a non-zero exit code'); + }); +}); + +// ─── 8. Filesystem-fact write contract ───────────────────────────────────────── +// +// Phase 6 routes phase.add through the SDK. After a successful add, the +// phase directory and ROADMAP entry must be on disk. Test asserts on +// filesystem facts (`existsSync`, `statSync().isDirectory()`, file size > 0) +// — never reads the file content back as a substring assertion. + +describe('phase 6: phase.add SDK dispatch writes the expected filesystem facts', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# v1.0 Roadmap', + '', + '### Phase 1: Foundation', + '**Goal:** Setup', + '', + '---', + '', + ].join('\n'), + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('phase add User Dashboard creates phase 2 directory + appends ROADMAP entry', () => { + const before = fs.statSync(path.join(tmpDir, '.planning', 'ROADMAP.md')); + const result = runGsdTools(['phase', 'add', 'User', 'Dashboard'], tmpDir); + assert.ok(result.success, `phase add failed: ${result.error}`); + + const payload = JSON.parse(result.output); + assert.strictEqual(payload.phase_number, 2); + assert.strictEqual(payload.slug, 'user-dashboard'); + + // Filesystem facts: the directory exists and is a directory; the roadmap + // file grew (write happened). We do not read the file back to look for + // substrings — that's the prohibited pattern. + const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-user-dashboard'); + assert.ok(fs.existsSync(phaseDir), 'new phase directory must exist on disk'); + assert.ok(fs.statSync(phaseDir).isDirectory(), 'phase path must be a directory'); + + const after = fs.statSync(path.join(tmpDir, '.planning', 'ROADMAP.md')); + assert.ok(after.size > before.size, 'ROADMAP.md must grow when phase add appends an entry'); + }); +}); diff --git a/tests/phases-command-router.test.cjs b/tests/phases-command-router.test.cjs index 6f61ecc2a..5164c304b 100644 --- a/tests/phases-command-router.test.cjs +++ b/tests/phases-command-router.test.cjs @@ -1,10 +1,25 @@ 'use strict'; -const { describe, test } = require('node:test'); +const { describe, test, before, after } = require('node:test'); const assert = require('node:assert/strict'); const { routePhasesCommand } = require('../get-shit-done/bin/lib/phases-command-router.cjs'); +// These tests exercise the CJS dispatch path of the router. Since #3577 the +// router prefers the SDK bridge when sdk/dist is present, which would bypass +// the mocked `phase`/`milestone` handlers below. The router gates SDK +// dispatch on `process.env.GSD_WORKSTREAM` being unset, so set it for these +// tests to deterministically take the CJS path that the mocks model. +let _prevWorkstream; +before(() => { + _prevWorkstream = process.env.GSD_WORKSTREAM; + process.env.GSD_WORKSTREAM = 'test-unit'; +}); +after(() => { + if (_prevWorkstream === undefined) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = _prevWorkstream; +}); + describe('phases-command-router', () => { test('routes phases list with parsed options', () => { const calls = []; diff --git a/tests/plan-scan-generator.test.cjs b/tests/plan-scan-generator.test.cjs new file mode 100644 index 000000000..980453f5e --- /dev/null +++ b/tests/plan-scan-generator.test.cjs @@ -0,0 +1,196 @@ +'use strict'; + +/** + * Parity test — verifies that plan-scan.generated.cjs produces identical + * results to the compiled SDK ESM output for all exported functions. + * + * SDK side: import('../sdk/dist/query/plan-scan.js') + * CJS side: require('../get-shit-done/bin/lib/plan-scan.generated.cjs') + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const crypto = require('crypto'); + +/** + * Build a unique-to-this-run path that is guaranteed not to exist. Hardcoded + * `/tmp/...` paths are a flake source on shared CI runners where the path can + * be left over from a prior run. We synthesize a random suffix under + * `os.tmpdir()` and force-remove the path first. + */ +function uniqueMissingPath(prefix = 'gsd-missing') { + const suffix = `${prefix}-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`; + const p = path.join(os.tmpdir(), suffix); + // The probability of collision is negligible, but force-clean anyway to make + // the precondition explicit. Errors swallowed (path didn't exist — desired). + try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* noop */ } + return p; +} + +const requireFromRoot = createRequire(__filename); + +// CJS side — direct require works fine +const cjs = requireFromRoot('../get-shit-done/bin/lib/plan-scan.generated.cjs'); + +// ── isRootPlanFile ──────────────────────────────────────────────────────── + +describe('plan-scan-generator parity: isRootPlanFile', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + const fixtures = [ + { label: 'accepts bare PLAN.md', name: 'PLAN.md', expected: true }, + { label: 'accepts canonical -PLAN.md', name: '01-01-PLAN.md', expected: true }, + { label: 'accepts extended PLAN-01-setup.md', name: 'PLAN-01-setup.md', expected: true }, + { label: 'rejects -PLAN-OUTLINE.md', name: 'something-PLAN-OUTLINE.md', expected: false }, + { label: 'rejects .pre-bounce.md', name: 'PLAN.pre-bounce.md', expected: false }, + { label: 'rejects SUMMARY.md', name: 'SUMMARY.md', expected: false }, + { label: 'rejects unrelated file', name: 'README.md', expected: false }, + ]; + + for (const { label, name, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.isRootPlanFile(name); + const cjsResult = cjs.isRootPlanFile(name); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── isNestedPlanFile ────────────────────────────────────────────────────── + +describe('plan-scan-generator parity: isNestedPlanFile', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + const fixtures = [ + { label: 'accepts PLAN-01-setup.md', name: 'PLAN-01-setup.md', expected: true }, + { label: 'accepts 1-PLAN-01-setup.md', name: '1-PLAN-01-setup.md', expected: true }, + { label: 'rejects PLAN-OUTLINE.md', name: 'PLAN-01-OUTLINE.md', expected: false }, + { label: 'rejects .pre-bounce.md', name: 'PLAN-01.pre-bounce.md', expected: false }, + { label: 'rejects bare PLAN.md', name: 'PLAN.md', expected: false }, + { label: 'rejects unrelated file', name: 'SUMMARY-01-setup.md', expected: false }, + ]; + + for (const { label, name, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.isNestedPlanFile(name); + const cjsResult = cjs.isNestedPlanFile(name); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── isRootSummaryFile ───────────────────────────────────────────────────── + +describe('plan-scan-generator parity: isRootSummaryFile', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + const fixtures = [ + { label: 'accepts bare SUMMARY.md', name: 'SUMMARY.md', expected: true }, + { label: 'accepts 01-01-SUMMARY.md', name: '01-01-SUMMARY.md', expected: true }, + { label: 'rejects PLAN.md', name: 'PLAN.md', expected: false }, + { label: 'rejects unrelated file', name: 'README.md', expected: false }, + ]; + + for (const { label, name, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.isRootSummaryFile(name); + const cjsResult = cjs.isRootSummaryFile(name); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── isNestedSummaryFile ─────────────────────────────────────────────────── + +describe('plan-scan-generator parity: isNestedSummaryFile', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + const fixtures = [ + { label: 'accepts SUMMARY-01-summary.md', name: 'SUMMARY-01-summary.md', expected: true }, + { label: 'accepts 1-SUMMARY-01.md', name: '1-SUMMARY-01.md', expected: true }, + { label: 'rejects bare SUMMARY.md', name: 'SUMMARY.md', expected: false }, + { label: 'rejects PLAN file', name: 'PLAN-01-setup.md', expected: false }, + ]; + + for (const { label, name, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.isNestedSummaryFile(name); + const cjsResult = cjs.isNestedSummaryFile(name); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── scanPhasePlans ──────────────────────────────────────────────────────── + +describe('plan-scan-generator parity: scanPhasePlans (non-existent dir)', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + test('returns zero counts for non-existent directory', () => { + const nonExistent = uniqueMissingPath('gsd-plan-scan-nonexistent'); + const sdkResult = sdk.scanPhasePlans(nonExistent); + const cjsResult = cjs.scanPhasePlans(nonExistent); + assert.deepStrictEqual(sdkResult, { + planCount: 0, + summaryCount: 0, + completed: false, + hasNestedPlans: false, + planFiles: [], + summaryFiles: [], + }); + assert.deepStrictEqual(sdkResult, cjsResult, 'SDK/CJS parity: non-existent dir'); + }); +}); + +describe('plan-scan-generator parity: scanPhasePlans (flat layout)', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + test('detects flat plan and summary files', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plan-scan-test-')); + try { + fs.writeFileSync(path.join(tmpDir, '01-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(tmpDir, '01-01-SUMMARY.md'), '# Summary'); + fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Readme'); + + const sdkResult = sdk.scanPhasePlans(tmpDir); + const cjsResult = cjs.scanPhasePlans(tmpDir); + + assert.strictEqual(sdkResult.planCount, 1, 'SDK: planCount'); + assert.strictEqual(sdkResult.summaryCount, 1, 'SDK: summaryCount'); + assert.strictEqual(sdkResult.completed, true, 'SDK: completed'); + assert.strictEqual(sdkResult.hasNestedPlans, false, 'SDK: hasNestedPlans'); + assert.deepStrictEqual(sdkResult, cjsResult, 'SDK/CJS parity: flat layout'); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); +}); + +describe('plan-scan-generator parity: module.exports call style', async () => { + test('default export is callable as function (CJS caller pattern)', () => { + // CJS callers do: const scanPhasePlans = require('./plan-scan.cjs') + // then call it directly: scanPhasePlans(phaseDir) + assert.strictEqual(typeof cjs, 'function', 'default export is a function'); + const result = cjs(uniqueMissingPath('gsd-plan-scan-cjs-default')); + assert.deepStrictEqual(result, { + planCount: 0, + summaryCount: 0, + completed: false, + hasNestedPlans: false, + planFiles: [], + summaryFiles: [], + }); + }); +}); diff --git a/tests/roadmap-command-router.test.cjs b/tests/roadmap-command-router.test.cjs index 14d2e7fcb..47e652e2b 100644 --- a/tests/roadmap-command-router.test.cjs +++ b/tests/roadmap-command-router.test.cjs @@ -1,10 +1,25 @@ 'use strict'; -const { describe, test } = require('node:test'); +const { describe, test, before, after } = require('node:test'); const assert = require('node:assert/strict'); const { routeRoadmapCommand } = require('../get-shit-done/bin/lib/roadmap-command-router.cjs'); +// These tests exercise the CJS dispatch path of the router. Since #3577 the +// router prefers the SDK bridge when sdk/dist is present, which would bypass +// the mocked `roadmap` handlers below. The router gates SDK dispatch on +// `process.env.GSD_WORKSTREAM` being unset, so set it here to deterministically +// take the CJS path that the mocks model. +let _prevWorkstream; +before(() => { + _prevWorkstream = process.env.GSD_WORKSTREAM; + process.env.GSD_WORKSTREAM = 'test-unit'; +}); +after(() => { + if (_prevWorkstream === undefined) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = _prevWorkstream; +}); + describe('roadmap-command-router', () => { test('routes roadmap analyze', () => { const calls = []; diff --git a/tests/schema-detect-generator.test.cjs b/tests/schema-detect-generator.test.cjs new file mode 100644 index 000000000..e8a2e2149 --- /dev/null +++ b/tests/schema-detect-generator.test.cjs @@ -0,0 +1,195 @@ +'use strict'; + +/** + * Parity test — verifies that schema-detect.generated.cjs produces identical + * results to the compiled SDK ESM output for all exported functions. + * + * SDK side: import('../sdk/dist/query/schema-detect.js') + * CJS side: require('../get-shit-done/bin/lib/schema-detect.generated.cjs') + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); + +const requireFromRoot = createRequire(__filename); + +// CJS side — direct require works fine +const cjs = requireFromRoot('../get-shit-done/bin/lib/schema-detect.generated.cjs'); + +// ── detectSchemaFiles ───────────────────────────────────────────────────── + +describe('schema-detect-generator parity: detectSchemaFiles', async () => { + const sdk = await import('../sdk/dist/query/schema-detect.js'); + + const fixtures = [ + { + label: 'detects prisma schema', + files: ['prisma/schema.prisma'], + expectedDetected: true, + expectedOrms: ['prisma'], + }, + { + label: 'detects drizzle schema', + files: ['drizzle/schema.ts'], + expectedDetected: true, + expectedOrms: ['drizzle'], + }, + { + label: 'detects supabase migration', + files: ['supabase/migrations/001_init.sql'], + expectedDetected: true, + expectedOrms: ['supabase'], + }, + { + label: 'detects payload collection', + files: ['src/collections/Users.ts'], + expectedDetected: true, + expectedOrms: ['payload'], + }, + { + label: 'detects typeorm entity', + files: ['src/entities/User.ts'], + expectedDetected: true, + expectedOrms: ['typeorm'], + }, + { + label: 'no schema files returns not detected', + files: ['src/components/Button.tsx', 'src/styles/main.css'], + expectedDetected: false, + expectedOrms: [], + }, + { + label: 'multiple ORMs detected', + files: ['prisma/schema.prisma', 'drizzle/schema.ts'], + expectedDetected: true, + expectedOrms: ['prisma', 'drizzle'], + }, + { + label: 'normalizes Windows backslash paths', + files: ['prisma\\schema.prisma'], + expectedDetected: true, + expectedOrms: ['prisma'], + }, + { + label: 'empty file list returns not detected', + files: [], + expectedDetected: false, + expectedOrms: [], + }, + ]; + + for (const { label, files, expectedDetected, expectedOrms } of fixtures) { + test(label, () => { + const sdkResult = sdk.detectSchemaFiles(files); + const cjsResult = cjs.detectSchemaFiles(files); + + assert.strictEqual(sdkResult.detected, expectedDetected, `SDK detected: ${label}`); + assert.deepStrictEqual(sdkResult.orms.sort(), expectedOrms.sort(), `SDK orms: ${label}`); + + assert.strictEqual(cjsResult.detected, expectedDetected, `CJS detected: ${label}`); + assert.deepStrictEqual(cjsResult.orms.sort(), expectedOrms.sort(), `CJS orms: ${label}`); + + // SDK and CJS must agree on detected and orms + assert.strictEqual(sdkResult.detected, cjsResult.detected, `SDK/CJS parity detected: ${label}`); + assert.deepStrictEqual(sdkResult.orms.sort(), cjsResult.orms.sort(), `SDK/CJS parity orms: ${label}`); + }); + } +}); + +// ── checkSchemaDrift ────────────────────────────────────────────────────── + +describe('schema-detect-generator parity: checkSchemaDrift', async () => { + const sdk = await import('../sdk/dist/query/schema-detect.js'); + + const fixtures = [ + { + label: 'no schema files — no drift', + changedFiles: ['src/components/Button.tsx'], + executionLog: '', + options: {}, + expectedDriftDetected: false, + expectedBlocking: false, + }, + { + label: 'prisma changed with push evidence — no drift', + changedFiles: ['prisma/schema.prisma'], + executionLog: 'running: npx prisma db push --accept-data-loss', + options: {}, + expectedDriftDetected: false, + expectedBlocking: false, + }, + { + label: 'prisma changed without push — drift blocking', + changedFiles: ['prisma/schema.prisma'], + executionLog: 'tsc && vitest run', + options: {}, + expectedDriftDetected: true, + expectedBlocking: true, + }, + { + label: 'drift with skipCheck=true — not blocking', + changedFiles: ['prisma/schema.prisma'], + executionLog: 'tsc && vitest run', + options: { skipCheck: true }, + expectedDriftDetected: true, + expectedBlocking: false, + }, + ]; + + for (const { label, changedFiles, executionLog, options, expectedDriftDetected, expectedBlocking } of fixtures) { + test(label, () => { + const sdkResult = sdk.checkSchemaDrift(changedFiles, executionLog, options); + const cjsResult = cjs.checkSchemaDrift(changedFiles, executionLog, options); + + assert.strictEqual(sdkResult.driftDetected, expectedDriftDetected, `SDK driftDetected: ${label}`); + assert.strictEqual(sdkResult.blocking, expectedBlocking, `SDK blocking: ${label}`); + + assert.strictEqual(cjsResult.driftDetected, expectedDriftDetected, `CJS driftDetected: ${label}`); + assert.strictEqual(cjsResult.blocking, expectedBlocking, `CJS blocking: ${label}`); + + // Full structural parity between SDK and CJS + assert.deepStrictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── detectSchemaOrm (CJS-only compat export) ────────────────────────────── + +describe('schema-detect-generator parity: detectSchemaOrm (CJS compat)', () => { + test('returns ORM info for known orm', () => { + const info = cjs.detectSchemaOrm('prisma'); + assert.ok(info !== null, 'prisma orm info should not be null'); + assert.ok(typeof info.pushCommand === 'string', 'pushCommand should be string'); + assert.ok(Array.isArray(info.evidencePatterns), 'evidencePatterns should be array'); + }); + + test('returns null for unknown orm', () => { + const info = cjs.detectSchemaOrm('unknown_orm'); + assert.strictEqual(info, null, 'unknown orm should return null'); + }); + + test('returns info for all 5 known ORMs', () => { + const orms = ['payload', 'prisma', 'drizzle', 'supabase', 'typeorm']; + for (const orm of orms) { + const info = cjs.detectSchemaOrm(orm); + assert.ok(info !== null, `${orm} info should not be null`); + } + }); +}); + +// ── SCHEMA_PATTERNS and ORM_INFO exports (compat) ──────────────────────── + +describe('schema-detect-generator: SCHEMA_PATTERNS and ORM_INFO exported', () => { + test('SCHEMA_PATTERNS is an array', () => { + assert.ok(Array.isArray(cjs.SCHEMA_PATTERNS), 'SCHEMA_PATTERNS should be an array'); + assert.ok(cjs.SCHEMA_PATTERNS.length > 0, 'SCHEMA_PATTERNS should not be empty'); + }); + + test('ORM_INFO has known orm keys', () => { + const orms = ['payload', 'prisma', 'drizzle', 'supabase', 'typeorm']; + for (const orm of orms) { + assert.ok(orm in cjs.ORM_INFO, `ORM_INFO should have key: ${orm}`); + } + }); +}); diff --git a/tests/secrets-generator.test.cjs b/tests/secrets-generator.test.cjs new file mode 100644 index 000000000..7b7cd9e1e --- /dev/null +++ b/tests/secrets-generator.test.cjs @@ -0,0 +1,136 @@ +'use strict'; + +/** + * Parity test — verifies that secrets.generated.cjs produces identical + * results to the compiled SDK ESM output for all exported functions. + * + * SDK side: import('../sdk/dist/query/secrets.js') + * CJS side: require('../get-shit-done/bin/lib/secrets.generated.cjs') + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); + +const requireFromRoot = createRequire(__filename); + +// CJS side — direct require works fine +const cjs = requireFromRoot('../get-shit-done/bin/lib/secrets.generated.cjs'); + +// ── SECRET_CONFIG_KEYS ──────────────────────────────────────────────────── + +describe('secrets-generator parity: SECRET_CONFIG_KEYS', async () => { + const sdk = await import('../sdk/dist/query/secrets.js'); + + test('contains same keys as SDK', () => { + const sdkKeys = [...sdk.SECRET_CONFIG_KEYS].sort(); + const cjsKeys = [...cjs.SECRET_CONFIG_KEYS].sort(); + assert.deepStrictEqual(cjsKeys, sdkKeys, 'SDK/CJS parity: SECRET_CONFIG_KEYS'); + }); + + test('contains brave_search', () => { + assert.ok(cjs.SECRET_CONFIG_KEYS.has('brave_search')); + assert.ok(sdk.SECRET_CONFIG_KEYS.has('brave_search')); + }); + + test('contains firecrawl', () => { + assert.ok(cjs.SECRET_CONFIG_KEYS.has('firecrawl')); + assert.ok(sdk.SECRET_CONFIG_KEYS.has('firecrawl')); + }); + + test('contains exa_search', () => { + assert.ok(cjs.SECRET_CONFIG_KEYS.has('exa_search')); + assert.ok(sdk.SECRET_CONFIG_KEYS.has('exa_search')); + }); +}); + +// ── isSecretKey ──────────────────────────────────────────────────────────── + +describe('secrets-generator parity: isSecretKey', async () => { + const sdk = await import('../sdk/dist/query/secrets.js'); + + const fixtures = [ + { label: 'brave_search is secret', key: 'brave_search', expected: true }, + { label: 'firecrawl is secret', key: 'firecrawl', expected: true }, + { label: 'exa_search is secret', key: 'exa_search', expected: true }, + { label: 'non-secret key returns false', key: 'model', expected: false }, + { label: 'empty string returns false', key: '', expected: false }, + { label: 'unrelated string returns false', key: 'api_key', expected: false }, + ]; + + for (const { label, key, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.isSecretKey(key); + const cjsResult = cjs.isSecretKey(key); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── maskSecret ──────────────────────────────────────────────────────────── + +describe('secrets-generator parity: maskSecret', async () => { + const sdk = await import('../sdk/dist/query/secrets.js'); + + const fixtures = [ + { label: 'null returns (unset)', value: null, expected: '(unset)' }, + { label: 'undefined returns (unset)', value: undefined, expected: '(unset)' }, + { label: 'empty string returns (unset)', value: '', expected: '(unset)' }, + { label: 'short string (< 8) returns ****', value: 'abc', expected: '****' }, + { label: '7-char string returns ****', value: '1234567', expected: '****' }, + { label: '8-char string returns ****', value: '12345678', expected: '****5678' }, + { label: 'long string returns ****', value: 'sk-ant-abc123def456', expected: '****f456' }, + ]; + + for (const { label, value, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.maskSecret(value); + const cjsResult = cjs.maskSecret(value); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── maskIfSecret ────────────────────────────────────────────────────────── + +describe('secrets-generator parity: maskIfSecret', async () => { + const sdk = await import('../sdk/dist/query/secrets.js'); + + const fixtures = [ + { + label: 'secret key gets masked', + key: 'brave_search', + value: 'sk-ant-12345678', + expectedType: 'string', + expectedValue: '****5678', + }, + { + label: 'non-secret key returns value unchanged', + key: 'model', + value: 'claude-opus-4-5', + expectedType: 'string', + expectedValue: 'claude-opus-4-5', + }, + { + label: 'secret key with null value returns (unset)', + key: 'firecrawl', + value: null, + expectedType: 'string', + expectedValue: '(unset)', + }, + ]; + + for (const { label, key, value, expectedValue } of fixtures) { + test(label, () => { + const sdkResult = sdk.maskIfSecret(key, value); + const cjsResult = cjs.maskIfSecret(key, value); + assert.strictEqual(sdkResult, expectedValue, `SDK: ${label}`); + assert.strictEqual(cjsResult, expectedValue, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); diff --git a/tests/workstream-name-policy-generator.test.cjs b/tests/workstream-name-policy-generator.test.cjs new file mode 100644 index 000000000..f1301a9d0 --- /dev/null +++ b/tests/workstream-name-policy-generator.test.cjs @@ -0,0 +1,145 @@ +'use strict'; + +/** + * Parity test: workstream-name-policy.generated.cjs vs sdk/src/workstream-name-policy.ts + * + * Verifies that the generated CJS artifact matches the SDK source-of-truth + * for all exports: toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName, + * validateWorkstreamName. + * + * Covers: Phase 6 (#3575) MIGRATE_ME resolution for workstream-name-policy.cjs. + */ + +const assert = require('assert'); +const { describe, test } = require('node:test'); +const { + toWorkstreamSlug, + hasInvalidPathSegment, + isValidActiveWorkstreamName, + validateWorkstreamName, +} = require('../get-shit-done/bin/lib/workstream-name-policy.cjs'); + +// ─── toWorkstreamSlug ──────────────────────────────────────────────────────── + +describe('workstream-name-policy — toWorkstreamSlug', () => { + test('lowercases and collapses non-alphanumeric to hyphens', () => { + assert.strictEqual(toWorkstreamSlug('My Feature Branch'), 'my-feature-branch'); + assert.strictEqual(toWorkstreamSlug('hello_world'), 'hello-world'); + assert.strictEqual(toWorkstreamSlug('API v2'), 'api-v2'); + }); + + test('strips leading/trailing hyphens', () => { + assert.strictEqual(toWorkstreamSlug('--foo--'), 'foo'); + assert.strictEqual(toWorkstreamSlug(' spaces '), 'spaces'); + }); + + test('handles empty and nullish values', () => { + assert.strictEqual(toWorkstreamSlug(''), ''); + assert.strictEqual(toWorkstreamSlug(null), ''); + assert.strictEqual(toWorkstreamSlug(undefined), ''); + }); + + test('handles already-valid slug', () => { + assert.strictEqual(toWorkstreamSlug('my-feature'), 'my-feature'); + assert.strictEqual(toWorkstreamSlug('v2'), 'v2'); + }); +}); + +// ─── hasInvalidPathSegment ─────────────────────────────────────────────────── + +describe('workstream-name-policy — hasInvalidPathSegment', () => { + test('returns true for names with forward slash', () => { + assert.strictEqual(hasInvalidPathSegment('foo/bar'), true); + }); + + test('returns true for names with backslash', () => { + assert.strictEqual(hasInvalidPathSegment('foo\\bar'), true); + }); + + test('returns true for bare dot', () => { + assert.strictEqual(hasInvalidPathSegment('.'), true); + }); + + test('returns true for double dot', () => { + assert.strictEqual(hasInvalidPathSegment('..'), true); + }); + + test('returns true for names containing dot-dot sequence', () => { + assert.strictEqual(hasInvalidPathSegment('foo..bar'), true); + assert.strictEqual(hasInvalidPathSegment('../etc'), true); + }); + + test('returns false for valid workstream names', () => { + assert.strictEqual(hasInvalidPathSegment('my-feature'), false); + assert.strictEqual(hasInvalidPathSegment('v2'), false); + assert.strictEqual(hasInvalidPathSegment('feature.experimental'), false); + assert.strictEqual(hasInvalidPathSegment('alpha_1'), false); + }); + + test('handles empty and nullish values', () => { + assert.strictEqual(hasInvalidPathSegment(''), false); + assert.strictEqual(hasInvalidPathSegment(null), false); + assert.strictEqual(hasInvalidPathSegment(undefined), false); + }); +}); + +// ─── isValidActiveWorkstreamName ───────────────────────────────────────────── + +describe('workstream-name-policy — isValidActiveWorkstreamName', () => { + test('returns true for valid alphanumeric names', () => { + assert.strictEqual(isValidActiveWorkstreamName('feature'), true); + assert.strictEqual(isValidActiveWorkstreamName('v2'), true); + assert.strictEqual(isValidActiveWorkstreamName('my-branch'), true); + assert.strictEqual(isValidActiveWorkstreamName('feature.experimental'), true); + assert.strictEqual(isValidActiveWorkstreamName('alpha_1'), true); + assert.strictEqual(isValidActiveWorkstreamName('A1'), true); + }); + + test('returns false for names starting with non-alphanumeric', () => { + assert.strictEqual(isValidActiveWorkstreamName('-feature'), false); + assert.strictEqual(isValidActiveWorkstreamName('.feature'), false); + assert.strictEqual(isValidActiveWorkstreamName('_feature'), false); + }); + + test('returns false for names with path traversal', () => { + assert.strictEqual(isValidActiveWorkstreamName('..'), false); + assert.strictEqual(isValidActiveWorkstreamName('../etc'), false); + assert.strictEqual(isValidActiveWorkstreamName('foo..bar'), false); + }); + + test('returns false for names with slashes', () => { + assert.strictEqual(isValidActiveWorkstreamName('foo/bar'), false); + assert.strictEqual(isValidActiveWorkstreamName('foo\\bar'), false); + }); + + test('returns false for names with spaces', () => { + assert.strictEqual(isValidActiveWorkstreamName('my feature'), false); + }); + + test('returns false for empty string', () => { + assert.strictEqual(isValidActiveWorkstreamName(''), false); + }); + + test('returns false for nullish values', () => { + assert.strictEqual(isValidActiveWorkstreamName(null), false); + assert.strictEqual(isValidActiveWorkstreamName(undefined), false); + }); +}); + +// ─── validateWorkstreamName (SDK alias) ────────────────────────────────────── + +describe('workstream-name-policy — validateWorkstreamName (SDK alias)', () => { + test('is an alias for isValidActiveWorkstreamName', () => { + const testCases = [ + 'feature', 'v2', 'my-branch', '-bad', '', null, undefined, + 'foo/bar', '..', 'foo..bar', 'A1', 'alpha_1', + ]; + for (const tc of testCases) { + assert.strictEqual( + validateWorkstreamName(tc), + isValidActiveWorkstreamName(tc), + `validateWorkstreamName and isValidActiveWorkstreamName should agree on: ${JSON.stringify(tc)}`, + ); + } + }); +});