Merge origin/main into feat/3597-split-suites-node-matrix
Resolves conflict in .github/workflows/test.yml: keep the 6 new drift-check steps from main (plan-scan, secrets, schema-detect, decisions, workstream-name-policy, Shared Module hand-sync) before the split-lane test runs from this PR. PR's dedicated `coverage` job replaces main's per-matrix `Run tests with coverage` step. Other conflicting files (CONTRIBUTING.md, get-shit-done/bin/lib/init.cjs, package.json) auto-merged cleanly. Changeset files (.changeset/*) brought in from main as adds.
This commit is contained in:
21
.changeset/3577-adr-violations-and-validation-port.md
Normal file
21
.changeset/3577-adr-violations-and-validation-port.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3577
|
||||
---
|
||||
**Phase 6 ADR/PRD compliance: out-of-seam Modules removed from SDK catalog; CJS-only verbs dispatch direct** — `verify.codebase-drift` and the eight `intel.*` verbs were wrongly bound in the SDK catalog/manifests, in violation of `docs/adr/3524-cjs-sdk-hard-seam.md` §3 and `docs/prd/3524-cjs-sdk-hard-seam.md` L160 which list `drift`, `intel`, `graphify`, `gsd2-import`, `schema-detect`, `fallow-runner`, `installer-migrations` as CJS-only ("...keep their in-process CJS implementations because no SDK counterpart exists"). The `verifyCodebaseDrift` SDK stub then `execFileSync`'d back to `gsd-tools verify codebase-drift`, which the router routed back through the SDK bridge — an infinite recursion that forked hundreds of node processes on the remote 64 GiB docker host before manual kill. All wrongly-bound entries removed; the CJS router and `gsd-tools.cjs` already had direct CJS dispatch paths for these verbs that are now the only path.
|
||||
|
||||
**Phase 6 `config-ensure-section` cutover via catalog rebind, not CJS fallback** — restored the legacy "no-arg full default config.json init" contract on the SDK path by binding the catalog entry `'config-ensure-section'` to `configNewProject` (whose no-args branch produces the same shape as the legacy `ensureConfigFile → buildNewProjectConfig` chain). The original Phase 6 binding to the new `configEnsureSection` handler (single-section ensure, requires `args[0]=sectionName`) broke every CLI caller, which all invoke the no-arg form.
|
||||
|
||||
**`configNewProject` defaults sourced from canonical Configuration Module manifest** — replaced the hardcoded duplicate `defaults` object with a derivation from `sdk/shared/config-defaults.manifest.json` (exported as `CONFIG_DEFAULTS` from `sdk/src/configuration/index.ts`). The previous duplicate had drifted from the manifest — omitted `workflow.{ai_integration_phase, tdd_mode, human_verify_mode, pattern_mapper, plan_bounce*, auto_prune_state, subagent_timeout, security_*, post_planning_gaps}`, `git.create_tag`, `claude_md_path`, `planning.*`, `graphify.*`, `mode`, `resolve_model_ids`, `context_window`. Closes the same `DEFECT.PORT-DRIFT.cjs-sdk` family the ADR was written to prevent.
|
||||
|
||||
**SDK `configSet` value-validation port from CJS `cmdConfigSet`** — added the missing enum/shape validators that the CJS handler enforced: `workflow.drift_action` (warn|auto-remap), `workflow.drift_threshold` (positive integer), `workflow.human_verify_mode` (mid-flight|end-of-phase), `statusline.context_position` (front|end), `code_quality.fallow.scope` (phase|repo), `code_quality.fallow.profile` (minimal|standard|strict), and `review.default_reviewers` (array of slug strings matching `^[a-zA-Z0-9_-]+$`, normalized to lowercase-unique, with the normalized value persisted to disk).
|
||||
|
||||
**Init handlers honor `--tdd` flag and `workflow.subagent_timeout`** — `initExecutePhase` and `initPlanPhase` now parse the `--tdd` boolean override (matching `parseNamedArgs(args, [], ['validate', 'tdd'])` in the CJS router and `options.tdd || config.tdd_mode || false` in the CJS handler), and `initMapCodebase` reads `subagent_timeout` from the canonical `workflow.subagent_timeout` location with the manifest-mandated 300000 default instead of an undefined fallback.
|
||||
|
||||
**`roadmap.analyze` surfaces `mode` per phase** — extracts the same `**Mode:**` field that `roadmapGetPhase` already parses, so consumers can read MVP-mode flagging from either query handler without divergence.
|
||||
|
||||
**SDK `phaseComplete` performs auto-prune of STATE.md when configured** — ported the `workflow.auto_prune_state === true` branch from CJS `cmdPhaseComplete`, calling `statePrune(['--keep-recent', '3', '--silent'], ...)` so completing phase N actually removes stale `[Phase 1..N-3]` decisions instead of leaving them forever. (#2087)
|
||||
|
||||
**SDK `initRemoveWorkspace` errors via thrown `GSDError`** — returning `{ data: { error } }` was treated as success by the CLI output path; the no-name and workspace-not-found branches now throw `GSDError(..., ErrorClassification.Validation)` so the CLI returns non-zero and writes the message to stderr.
|
||||
|
||||
**SDK `frontmatterGet` parses `--field <name>`** — the CLI invocation `frontmatter get <file> --field phase` was passing `args = [file, '--field', 'phase']`; the handler treated `args[1]` as the field name and saw the literal string `--field`. Now handles both `--field <name>` and positional `args[1]`.
|
||||
5
.changeset/3577-config-ensure-section-parity.md
Normal file
5
.changeset/3577-config-ensure-section-parity.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3577
|
||||
---
|
||||
**Phase 6 `config-*` SDK port parity carve-outs** — restored the legacy contract for four CLI tests broken by the Phase 6 router migration. `config-ensure-section` no longer routes through the new SDK `configEnsureSection` handler (which expected a positional `<section>` arg the CLI never passes) and instead keeps the `cmdConfigEnsureSection → ensureConfigFile → buildNewProjectConfig` CJS path that produces the full default `.planning/config.json`. The SDK `configNewProject` defaults now match `sdk/shared/config-defaults.manifest.json` (`commit_docs: true`, `parallelization: true`) and report the project-rooted relative path `.planning/config.json` to mirror the CJS shape. SDK error vocabulary is aligned with CJS: `Unknown config key: <key>` (no quotes), and config-get's malformed-JSON error is led by `Failed to read config.json:` so legacy regression tests keep matching. Closes the `Usage: config-ensure-section <section>` regression seen in `tests/{config,agent-skills,ai-evals}.test.cjs`.
|
||||
11
.changeset/3577-docker-test-fixup.md
Normal file
11
.changeset/3577-docker-test-fixup.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3577
|
||||
---
|
||||
**Docker test fix-forward: 12 ubuntu-only regressions surfaced by `gsd-test-summary` cleared** —
|
||||
- `agents/gsd-intel-updater.md` retargeted from `gsd-sdk query intel.*` to `gsd-tools intel <subcommand>` (intel is out-of-seam per ADR §3 / PRD L160; the SDK has no handler for it, so the agent's CLI calls were broken).
|
||||
- `roadmap.get-phase` two-pass lookup for project-code-prefixed IDs (port of CJS `phaseMarkdownRegexSourceExact`, #3599): a `PROJ-42` query now matches `### Phase PROJ-42:` directly without cross-matching a bare `### Phase 42:` that happens to share the trailing integer.
|
||||
- `roadmap.analyze` extracts the `**Mode:**` field per phase (parity with `roadmap.get-phase`).
|
||||
- `phase.remove` depth-aware end-of-section regex (port of CJS #3601 fix): removing `### Phase 2:` stops at `### Phase 2.1:` (peer-depth decimal preserved) but continues past `#### Phase 27.1:` (child-depth decimal of `### Phase 27:`). Named capture `(?<h>#{2,4})` + backreference `\k<h>(?!#)` enforces same-depth termination.
|
||||
- `phase.remove` slugged-plan reference renumbering (port of CJS #3602 fix): the padded-plan-reference pattern now allows arbitrary kebab-case slug segments between `NN-NN` and the `-PLAN.md` / `-SUMMARY.md` suffix, so references like `07-01-cherry-pick-foundation-PLAN.md` get renumbered to `06-01-…` when Phase 7 is removed.
|
||||
- `configNewProject` filters out manifest keys that legacy CJS init does not materialize (`git.base_branch`, `resolve_model_ids`, `context_window`, `mode`, `planning`, `graphify`): these have their own resolution paths (auto-detect, opt-in) and materializing manifest values would suppress them. `config-get git.base_branch` correctly returns "Key not found" so workflows can fall back to `origin/HEAD` resolution.
|
||||
5
.changeset/3643-resolve-model-claude-runtime.md
Normal file
5
.changeset/3643-resolve-model-claude-runtime.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3648
|
||||
---
|
||||
**`gsd-sdk query resolve-model` now honors `resolve_model_ids: true` under `runtime: "claude"`** — previously the resolver bailed out of `resolveRuntimeTier` for Claude (because Claude is the implicit/default runtime in `config-query.ts:149`) and fell through to the alias-return path on line 243 without consulting the catalog. Consumers asking for resolved model IDs received the tier alias (`opus` / `sonnet` / `haiku`) instead of the full Claude model ID (`claude-opus-4-7` / `claude-sonnet-4-6` / `claude-haiku-4-5`). The CJS branch at `get-shit-done/bin/lib/core.cjs:1348-1350` (`if (config.resolve_model_ids) return MODEL_ALIAS_MAP[alias] || alias;`) was missing from the TS port. Added a `runtime === 'claude' && resolveModelIds === true` branch that calls `resolveRuntimeTierDefault('claude', tier)` from the shared model-catalog so both runtimes derive Claude IDs from the same source of truth. `model_overrides`, the phase-type tier override (`config.models[phaseType]`), and `resolve_model_ids: "omit"` all retain their existing precedence. (#3643)
|
||||
5
.changeset/clever-zebras-snooze.md
Normal file
5
.changeset/clever-zebras-snooze.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3581
|
||||
---
|
||||
**`/gsd:plan-phase` now refuses to replan closed phases** — `init.plan-phase` exposes a new `phase_status` field and the workflow short-circuits on `Complete` phases (use `--force` to override; `--reviews` has no override).
|
||||
5
.changeset/fix-3406-detect-stale-sdk-shadow.md
Normal file
5
.changeset/fix-3406-detect-stale-sdk-shadow.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3641
|
||||
---
|
||||
**Install-time warning when a stale `@gsd-build/sdk` shadows the bundled `gsd-sdk` shim** — global installs now run `npm ls -g @gsd-build/sdk` and, if the standalone 0.1.0 package is present (it never received `query` subcommand support), print a clear remediation block before the install completes. Detection is fail-closed: any npm/exec error silently returns no-stale. Gated by `GSD_SKIP_STALE_SDK_CHECK=1` for CI/test environments. Resolves #3406.
|
||||
5
.changeset/fix-3579-graphify-hook-publish.md
Normal file
5
.changeset/fix-3579-graphify-hook-publish.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3579
|
||||
---
|
||||
**Graphify auto-update hook now ships to install targets** — `gsd-graphify-update.sh` was missing from `scripts/build-hooks.js` `HOOKS_TO_COPY`, so it never landed in `hooks/dist/` and the installer's flat-readdir loop never copied it to `~/.claude/hooks/`. The hook's detached rebuild helper at `hooks/lib/gsd-graphify-rebuild.sh` was also dropped because both `build-hooks.js` and `bin/install.js` only walked top-level files. Both gaps are fixed: the allowlist now includes the hook, `build-hooks.js` copies whitelisted hook subdirectories into `hooks/dist/`, and `bin/install.js` mirrors hook subdirs to the target. Added a coverage drift guard so every top-level `hooks/*.sh` must be listed in `HOOKS_TO_COPY` going forward (#3579).
|
||||
5
.changeset/fix-3588-npm-audit-clean.md
Normal file
5
.changeset/fix-3588-npm-audit-clean.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Security
|
||||
pr: 3588
|
||||
---
|
||||
**`npm audit --omit=dev` is clean** — bumped lockfile-pinned transitive versions of `fast-uri`, `@anthropic-ai/sdk`, `hono`, `ip-address`, and `express-rate-limit` (pulled in through `@anthropic-ai/claude-agent-sdk` and `@modelcontextprotocol/sdk`) to patched releases. Same pass applied to `sdk/package-lock.json` (was clean for production already; the test now locks it in). Resolves #3588.
|
||||
5
.changeset/fix-3631-sdk-raw-flag-routers.md
Normal file
5
.changeset/fix-3631-sdk-raw-flag-routers.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3631
|
||||
---
|
||||
**SDK dispatch path in family routers now honours `--raw`** — `phase next-decimal --raw`, `roadmap get-phase --raw`, and other family-router commands that route through the SDK bridge now emit the same scalar string the CJS path emitted before #3577. Routers request `mode: 'raw'` from the bridge under `--raw`; the sync-bridge worker wires `formatNativeRaw` to `formatQueryRawOutput` so the bridge returns the per-command projection. Routers then pass the formatted string through `output()`'s rawValue branch instead of JSON-stringifying it.
|
||||
5
.changeset/fix-3632-lint-handsync-pair-fanout.md
Normal file
5
.changeset/fix-3632-lint-handsync-pair-fanout.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3632
|
||||
---
|
||||
**`lint-shared-module-handsync` now reports unauthorized ts siblings even when a co-named sibling is allowlisted** — when a `bin/lib/<name>.cjs` had two ts candidates on disk (e.g. `sdk/src/<name>.ts` and `sdk/src/query/<name>.ts`) and only one pair was in the allowlist, the `.some()` short-circuit silently skipped the unallowlisted sibling. Each ts candidate is now classified independently so partial-allowlist drift surfaces correctly.
|
||||
5
.changeset/graceful-tigers-fly.md
Normal file
5
.changeset/graceful-tigers-fly.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3583
|
||||
---
|
||||
Claude skill install (convertClaudeCommandToClaudeSkill + copyCommandsAsClaudeSkills) now normalizes retired /gsd:<cmd> references in SKILL.md bodies to the canonical gsd-<cmd> hyphen form using the new transformContentToHyphen from the shared fix-slash-commands.cjs transformer. Frontmatter name: was already correct since #2808; body leakage is now eliminated for Claude, Qwen, and Hermes. Added regression guard in bug-2808 test. Fixes #3583.
|
||||
8
.changeset/steady-zebras-click.md
Normal file
8
.changeset/steady-zebras-click.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 3213
|
||||
---
|
||||
**Added: `lint:docs` enforcement.** New `scripts/lint-docs-required.cjs` + `Docs Required` CI workflow fail any PR whose changeset fragment is typed `Added` / `Changed` / `Deprecated` / `Removed` without modifying at least one file under `docs/`. Escape hatches: the `no-docs` PR label, or a per-fragment HTML-comment marker on its own line at the end of the fragment body (extracted at the `parseFragment` seam so it never bleeds into CHANGELOG.md or GitHub release-notes output). `Fixed` and `Security` fragments are not gated. Malformed fragments now fail closed via the new `FAIL_MALFORMED_FRAGMENT` verdict — a triggering fragment with bad frontmatter cannot silently bypass docs enforcement. PR templates (`enhancement.md`, `feature.md`) gain a Documentation checklist; `CONTRIBUTING.md` adds a `Documentation Updates` section codifying the which-doc-to-update matrix and English-canonical language policy.
|
||||
|
||||
<!-- docs-exempt: bootstrap of docs-required lint itself; contributor-facing enforcement lives in CONTRIBUTING.md and the PR templates, while docs/ is reserved for end-user documentation -->
|
||||
|
||||
5
.changeset/sturdy-geese-glide.md
Normal file
5
.changeset/sturdy-geese-glide.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3577
|
||||
---
|
||||
**SDK validation errors no longer surface as native_failure** — the runtime-bridge-sync worker now unwraps GSDError causes wrapped in GSDToolsError. Empty/invalid command arguments produce errorKind: 'validation_error' (exit 10) as the SyncErrorKind taxonomy promises, instead of the misleading errorKind: 'native_failure'. Detected by new Phase 6 behavioral contract tests.
|
||||
5
.changeset/sturdy-pandas-rest.md
Normal file
5
.changeset/sturdy-pandas-rest.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 3577
|
||||
---
|
||||
**Shared Module hand-sync drift lint** — `scripts/lint-shared-module-handsync.cjs` runs in CI on every PR and fails when a new `bin/lib/<name>.cjs` and `sdk/src/<name>.ts` (or `sdk/src/query/<name>.ts`) pair is introduced without an entry in `scripts/shared-module-handsync-allowlist.json`. The allowlist documents 14 legitimate cooperating-sibling pairs (Adapters over generated Modules, Readers over shared Builders, runtime-distinct routing) plus 8 known drift pairs (`config`, `decisions`, `intel`, `model-catalog`, `plan-scan`, `schema-detect`, `secrets`, `workstream-name-policy`) flagged as future Shared-Module migration backlog. Phase 6 of #3524 also adds path-specific CODEOWNERS rules requiring architecture-team review for source-of-truth files (`sdk/src/<module>/`, `sdk/shared/*.manifest.json`, `sdk/src/runtime-bridge-sync/`, the lint script itself), publishes `docs/agents/cjs-sdk-seam.md` mapping all 15 historical drift bugs (#1535 … #3523) to the enforcement layer that would have blocked each, and adds a contributor guide for adding new Shared Modules and new canonical commands. After this PR the CJS↔SDK seam migration (#3524) is feature-complete. Closes #3575.
|
||||
@@ -20,3 +20,23 @@ fi
|
||||
if git diff --cached --name-only | grep -Eq "^sdk/src/project-root/|^get-shit-done/bin/lib/project-root\.generated\.cjs$|^sdk/scripts/gen-project-root\.mjs$|^sdk/scripts/check-project-root-fresh\.mjs$"; then
|
||||
npm run check:project-root-fresh
|
||||
fi
|
||||
|
||||
if git diff --cached --name-only | grep -Eq "^sdk/src/query/plan-scan\.ts$|^get-shit-done/bin/lib/plan-scan\.generated\.cjs$|^sdk/scripts/gen-plan-scan\.mjs$|^sdk/scripts/check-plan-scan-fresh\.mjs$"; then
|
||||
npm run check:plan-scan-fresh
|
||||
fi
|
||||
|
||||
if git diff --cached --name-only | grep -Eq "^sdk/src/query/secrets\.ts$|^get-shit-done/bin/lib/secrets\.generated\.cjs$|^sdk/scripts/gen-secrets\.mjs$|^sdk/scripts/check-secrets-fresh\.mjs$"; then
|
||||
npm run check:secrets-fresh
|
||||
fi
|
||||
|
||||
if git diff --cached --name-only | grep -Eq "^sdk/src/query/schema-detect\.ts$|^get-shit-done/bin/lib/schema-detect\.generated\.cjs$|^sdk/scripts/gen-schema-detect\.mjs$|^sdk/scripts/check-schema-detect-fresh\.mjs$"; then
|
||||
npm run check:schema-detect-fresh
|
||||
fi
|
||||
|
||||
if git diff --cached --name-only | grep -Eq "^sdk/src/query/decisions\.ts$|^get-shit-done/bin/lib/decisions\.generated\.cjs$|^sdk/scripts/gen-decisions\.mjs$|^sdk/scripts/check-decisions-fresh\.mjs$"; then
|
||||
npm run check:decisions-fresh
|
||||
fi
|
||||
|
||||
if git diff --cached --name-only | grep -Eq "^sdk/src/workstream-name-policy\.ts$|^get-shit-done/bin/lib/workstream-name-policy\.generated\.cjs$|^sdk/scripts/gen-workstream-name-policy\.mjs$|^sdk/scripts/check-workstream-name-policy-fresh\.mjs$"; then
|
||||
npm run check:workstream-name-policy-fresh
|
||||
fi
|
||||
|
||||
19
.github/CODEOWNERS
vendored
19
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
17
.github/PULL_REQUEST_TEMPLATE/enhancement.md
vendored
17
.github/PULL_REQUEST_TEMPLATE/enhancement.md
vendored
@@ -66,6 +66,22 @@ Closes #
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
> CI enforces this — `lint:docs` fails any PR with an `Added` / `Changed` / `Deprecated` / `Removed`
|
||||
> changeset fragment that does not also touch at least one file under `docs/`.
|
||||
> See [CONTRIBUTING.md → Documentation Updates](../../CONTRIBUTING.md#documentation-updates-update-the-relevant-docs).
|
||||
|
||||
- [ ] Updated the relevant file(s) under `docs/` to reflect this change
|
||||
- Behavior or output change → `docs/USER-GUIDE.md` and/or `docs/COMMANDS.md`
|
||||
- Configuration / schema change → `docs/CONFIGURATION.md`
|
||||
- Architectural change → `docs/ARCHITECTURE.md` and/or `docs/adr/`
|
||||
- Agent or skill change → `docs/AGENTS.md`
|
||||
- [ ] All `docs/` content added in this PR is written in English
|
||||
- [ ] If genuinely no user-facing docs impact (infrastructure / internal refactor / test-only),
|
||||
apply the `no-docs` label **or** add `<!-- docs-exempt: <reason> -->` inside each
|
||||
triggering changeset fragment and leave a comment explaining why.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Issue linked above with `Closes #NNN` — **PR will be auto-closed if missing**
|
||||
@@ -74,7 +90,6 @@ Closes #
|
||||
- [ ] All existing tests pass (`npm test`)
|
||||
- [ ] New or updated tests cover the enhanced behavior
|
||||
- [ ] `.changeset/` fragment added (`npm run changeset -- --type Changed --pr <NNN> --body "..."`) — or `no-changelog` label applied if not user-facing
|
||||
- [ ] Documentation updated if behavior or output changed
|
||||
- [ ] No unnecessary dependencies added
|
||||
|
||||
## Breaking changes
|
||||
|
||||
21
.github/PULL_REQUEST_TEMPLATE/feature.md
vendored
21
.github/PULL_REQUEST_TEMPLATE/feature.md
vendored
@@ -86,6 +86,26 @@ Closes #
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
> CI enforces this — `lint:docs` fails any PR with an `Added` / `Changed` / `Deprecated` / `Removed`
|
||||
> changeset fragment that does not also touch at least one file under `docs/`. Features almost
|
||||
> always trigger `Added`. See
|
||||
> [CONTRIBUTING.md → Documentation Updates](../../CONTRIBUTING.md#documentation-updates-update-the-relevant-docs).
|
||||
|
||||
- [ ] Updated the relevant file(s) under `docs/` to reflect this feature
|
||||
- New command or flag → `docs/COMMANDS.md` and `docs/FEATURES.md`
|
||||
- New workflow or behavior → `docs/USER-GUIDE.md`
|
||||
- Configuration / schema change → `docs/CONFIGURATION.md`
|
||||
- Architectural change → `docs/ARCHITECTURE.md` and/or `docs/adr/`
|
||||
- Agent or skill change → `docs/AGENTS.md`
|
||||
- [ ] All `docs/` content added in this PR is written in English
|
||||
(translated READMEs `README.pt-BR.md` / `README.zh-CN.md` / `README.ja-JP.md` / `README.ko-KR.md`
|
||||
are community-maintained and do not need to be updated in this PR)
|
||||
- [ ] If genuinely no user-facing docs impact (rare for features — explain in PR), apply the
|
||||
`no-docs` label **or** add `<!-- docs-exempt: <reason> -->` inside each triggering
|
||||
changeset fragment.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Issue linked above with `Closes #NNN` — **PR will be auto-closed if missing**
|
||||
@@ -95,7 +115,6 @@ Closes #
|
||||
- [ ] All existing tests pass (`npm test`)
|
||||
- [ ] New tests cover the happy path, error cases, and edge cases
|
||||
- [ ] `.changeset/` fragment added with a user-facing description of the feature (`npm run changeset -- --type Added --pr <NNN> --body "..."`)
|
||||
- [ ] Documentation updated — commands, workflows, references, README if applicable
|
||||
- [ ] No unnecessary external dependencies added
|
||||
- [ ] Works on Windows (backslash paths handled)
|
||||
|
||||
|
||||
24
.github/workflows/docs-required.yml
vendored
Normal file
24
.github/workflows/docs-required.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Docs Required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
docs-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Run docs-required lint
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: node scripts/lint-docs-required.cjs
|
||||
31
.github/workflows/test.yml
vendored
31
.github/workflows/test.yml
vendored
@@ -127,6 +127,36 @@ jobs:
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-project-root-fresh.mjs
|
||||
|
||||
- name: SDK generated plan-scan artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-plan-scan-fresh.mjs
|
||||
|
||||
- name: SDK generated secrets artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-secrets-fresh.mjs
|
||||
|
||||
- name: SDK generated schema-detect artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-schema-detect-fresh.mjs
|
||||
|
||||
- name: SDK generated decisions artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-decisions-fresh.mjs
|
||||
|
||||
- name: SDK generated workstream-name-policy artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-workstream-name-policy-fresh.mjs
|
||||
|
||||
- name: Shared Module hand-sync drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node scripts/lint-shared-module-handsync.cjs
|
||||
|
||||
# Split lanes (issue #3597). Unit is the fast default lane; integration
|
||||
# and security run alongside it on every PR. `install` and `slow` are
|
||||
# skipped on PR CI by design — they run on the weekly windows-compat
|
||||
@@ -157,6 +187,7 @@ jobs:
|
||||
# Dedicated coverage job. Runs only on ubuntu/Node 24 (the canonical lane)
|
||||
# because c8 coverage of one suite on one OS is enough signal — running it
|
||||
# across the full matrix would 9x the cost for no extra coverage data.
|
||||
# Replaces main's per-matrix `Run tests with coverage` step.
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
41
AGENTS.md
Normal file
41
AGENTS.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Active Discussions
|
||||
|
||||
For current work on **Grok Build compatibility** and multi-runtime synchronization across Grok Build, Claude Code, Gemini CLI, and Codex, see:
|
||||
|
||||
- `docs/discussions/grok-build-support-2026-05.md`
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
This repository ships GSD as a Node.js CLI and SDK. Root package entry points live in `bin/`, scripts in `scripts/`, runtime hooks in `hooks/`, command definitions in `commands/gsd/`, and workflow/template content in `get-shit-done/`. Agent role files are in `agents/`; docs are in `docs/`; logos and terminal images are in `assets/`. Root tests are in `tests/*.test.cjs`. The TypeScript SDK is isolated under `sdk/`, with source and Vitest tests in `sdk/src/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Use Node.js `>=22`.
|
||||
|
||||
- `npm install`: install root dependencies.
|
||||
- `npm test`: builds the SDK first, then runs root `node:test` suites via `scripts/run-tests.cjs`.
|
||||
- `npm run test:coverage`: runs root tests with `c8` and enforces 70% line coverage for included CommonJS library files.
|
||||
- `npm run build:hooks`: rebuilds generated hook artifacts.
|
||||
- `npm run build:sdk`: installs SDK dependencies and builds TypeScript.
|
||||
- `cd sdk && npm test`: runs SDK Vitest unit and integration projects.
|
||||
- `cd sdk && npm run build`: type-checks and emits `sdk/dist/`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Match the existing style in the edited area. Root JavaScript is CommonJS, generally strict-mode, two-space indentation, semicolons, `const`/`let`, and `node:` imports for built-ins. SDK code is strict TypeScript using ESM/`NodeNext`. Keep command, workflow, and test filenames kebab-case, for example `commands/gsd/plan-phase.md` and `tests/bug-2396-makefile-test-priority.test.cjs`. Agent files use `gsd-*.md`. Avoid unrelated formatting and unnecessary dependencies.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Root tests use Node’s built-in `node:test` and `node:assert/strict`; do not add Jest, Mocha, or Chai. Prefer helpers from `tests/helpers.cjs` for temporary projects, cleanup, and CLI execution. Name root tests `*.test.cjs`; run one with `node --test tests/name.test.cjs`. SDK tests use Vitest with `*.test.ts` for unit tests and `*.integration.test.ts` for integration tests.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Recent history follows Conventional Commit prefixes such as `fix:`, `feat:`, and `ci:`, often with issue references: `fix(#2623): resolve parent .planning root...`. Keep commits scoped and descriptive.
|
||||
|
||||
Every PR must link an approved or confirmed issue with `Closes #123`, `Fixes #123`, or `Resolves #123`. Use the matching template in `.github/PULL_REQUEST_TEMPLATE/`. Include behavior changes, root cause when relevant, test evidence, affected platforms/runtimes, and update `CHANGELOG.md` or docs for user-facing changes.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Do not commit secrets, local config, or generated worktree artifacts. Before release-facing changes, run the relevant scan scripts in `scripts/`, especially `secret-scan.sh`, `base64-scan.sh`, and `prompt-injection-scan.sh`.
|
||||
@@ -122,6 +122,8 @@ Contributor requirements (summary):
|
||||
- Do not rewrite maintainer intent in `CONTEXT.md`/ADRs as part of drive-by cleanup; propose focused updates tied to approved scope.
|
||||
- If using an AI assistant, prompt it to read `CONTEXT.md` and the relevant ADRs before writing any code or docs, and verify it used the correct vocabulary before opening the PR.
|
||||
|
||||
**CJS↔SDK seam.** When working on `bin/lib/*.cjs` or `sdk/src/**`, read [`docs/agents/cjs-sdk-seam.md`](docs/agents/cjs-sdk-seam.md). It documents the canonical pattern for Shared Modules (data manifest + source-of-truth file + generator + freshness check + Adapters) and the hand-sync pair lint that blocks new drift. New `<name>.cjs` ↔ `<name>.ts` pairs require either migration to a Shared Module or an explicit allowlist entry with justification in `scripts/shared-module-handsync-allowlist.json`. Adding an allowlist entry requires maintainer review via CODEOWNERS.
|
||||
|
||||
**Every PR must link to an approved issue.** PRs without a linked issue are closed without review, no exceptions.
|
||||
|
||||
- **No draft PRs** — draft PRs are automatically closed. Only open a PR when it is complete, tested, and ready for review. If your work is not finished, keep it on your local branch until it is.
|
||||
@@ -150,6 +152,40 @@ Fragments are consolidated into `CHANGELOG.md` at release time by the release wo
|
||||
|
||||
**Opt-out:** PRs with no user-facing impact (test refactors, lint config changes, CI tweaks, formatting-only changes) can add the `no-changelog` label. The lint honors it. When unsure whether a change is user-facing, **add the fragment**.
|
||||
|
||||
## Documentation Updates — Update the Relevant Docs
|
||||
|
||||
If your PR adds, changes, deprecates, or removes user-visible behavior, you **must** update the relevant documentation in `docs/`. CI will fail any PR whose changeset fragment is typed `Added`, `Changed`, `Deprecated`, or `Removed` without also modifying at least one file under `docs/` ([#3213](https://github.com/gsd-build/get-shit-done/issues/3213)).
|
||||
|
||||
`Fixed` and `Security` fragments do not trigger this lint — bug fixes restore documented behavior, they do not introduce new behavior to document. (Edit the docs anyway if a fix corrects something the docs got wrong.)
|
||||
|
||||
### Which docs to update
|
||||
|
||||
| Change type | Required doc updates |
|
||||
|---|---|
|
||||
| New command or flag | `docs/COMMANDS.md`, `docs/FEATURES.md` |
|
||||
| Changed command behavior or output | `docs/USER-GUIDE.md`, `docs/COMMANDS.md` |
|
||||
| Configuration / schema change | `docs/CONFIGURATION.md` |
|
||||
| Architectural change | `docs/ARCHITECTURE.md`, `docs/adr/` |
|
||||
| Agent or skill change | `docs/AGENTS.md` |
|
||||
| Removed command, flag, or workflow | All docs that referenced it |
|
||||
|
||||
### Language policy
|
||||
|
||||
All content in `docs/` and the root `README.md` **must be written in English**. English is the canonical source. The translated READMEs (`README.pt-BR.md`, `README.zh-CN.md`, `README.ja-JP.md`, `README.ko-KR.md`) are community-maintained translations and do not need to be updated by every PR.
|
||||
|
||||
### CI enforcement
|
||||
|
||||
The `Docs Required` workflow (`scripts/lint-docs-required.cjs`) reads the changeset fragments touched in the PR diff. If any has type `Added` / `Changed` / `Deprecated` / `Removed`, it requires at least one file under `docs/` to also appear in the diff.
|
||||
|
||||
### Opt-outs (with paper trail)
|
||||
|
||||
When a change genuinely has no user-facing documentation impact (infrastructure rewrite, internal refactor, test-only addition, CI fix), use one of:
|
||||
|
||||
- **Label:** add the `no-docs` label to the PR. Leave a comment explaining why no docs update was needed.
|
||||
- **Per-fragment marker:** add `<!-- docs-exempt: <reason> -->` **on its own line** inside the body of each triggering changeset fragment (typically at the end). The reason is **required and must be non-empty** — a bare `<!-- docs-exempt -->` or `<!-- docs-exempt: -->` is rejected (no audit trail = no exemption). The marker is extracted at parse time by `scripts/changeset/parse.cjs` and stripped from the body before the CHANGELOG.md and GitHub release-notes serializers see it — it leaves a paper trail in the source fragment without leaking into published release notes. Inline mentions of the marker syntax (e.g. inside backticks) are intentionally ignored; the parser only acts on a marker that occupies its own line. Both routes leave a paper trail; the label is global, the marker is per-fragment for mixed PRs.
|
||||
|
||||
When unsure whether a change is user-facing, **update the docs**.
|
||||
|
||||
## Testing Standards
|
||||
|
||||
All tests use Node.js built-in test runner (`node:test`) and assertion library (`node:assert`). **Do not use Jest, Mocha, Chai, or any external test framework.**
|
||||
|
||||
73
QUICK-WINS-CONFIRMED-BUGS.md
Normal file
73
QUICK-WINS-CONFIRMED-BUGS.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Quick Wins: Confirmed-Bug Fixes
|
||||
|
||||
**Status**: Active
|
||||
**Started**: 2026-05-16
|
||||
**Owner**: Current session (Grok + user)
|
||||
**Context**: Follow-up to `/gsd-inbox` triage on 2026-05-16
|
||||
|
||||
## Goal
|
||||
|
||||
Land 6 high-signal, confirmed-bug issues that currently have **zero open pull requests**. These are the cleanest quick-win opportunities available in the public GitHub inbox right now.
|
||||
|
||||
All six issues carry the `confirmed-bug` label, meaning the bug has been verified and a fix is explicitly welcome.
|
||||
|
||||
## The 6 Issues (Prioritized)
|
||||
|
||||
| # | Issue | Short Title | Type | Recommended Flow | Est. Effort | Status | Notes |
|
||||
|---|-------|-------------|------|------------------|-------------|--------|-------|
|
||||
| 1 | [#3583](https://github.com/gsd-build/get-shit-done/issues/3583) | Claude skill install leaves `/gsd:<cmd>` in `SKILL.md` body | Installer / Command namespace | PR 3629 (our branch) + competing 3586 | Small (1 file + test) | PR opened / Review | **Leading PR: 3629** (cristianuibar) — reviewed + hardened with CodeRabbit feedback (left-boundary regex + body-scoped guard). Competing PR 3586 has "needs changes" + "ci: failing". Issue still carries `confirmed-bug`. |
|
||||
| 2 | [#3579](https://github.com/gsd-build/get-shit-done/issues/3579) | `build-hooks.js` + npm publish omit graphify auto-update hook | Packaging / Build | `/gsd-quick` | Small | Not started | Classic "new feature missed in release artifact". Easy local verification. |
|
||||
| 3 | [#3496](https://github.com/gsd-build/get-shit-done/issues/3496) | `/gsd:update` changelog extraction skips intermediate versions | Workflow / Update logic | `/gsd-quick` or lightweight plan | Medium-small | Not started | Needs deterministic version-range helper. |
|
||||
| 4 | [#3588](https://github.com/gsd-build/get-shit-done/issues/3588) | Production `npm audit` has 1 high + 5 moderate advisories | Security / Dependencies | Direct + careful review | Medium | Not started | Transitive via `@anthropic-ai/claude-agent-sdk`. May need overrides. |
|
||||
| 5 | [#3584](https://github.com/gsd-build/get-shit-done/issues/3584) | Runtime `bin/lib/*.cjs` still emit `/gsd:<cmd>` (larger piece deferred from #3583) | Runtime output / Slash formatter | Short plan first, then execute | Medium-Large | Not started | 16+ files. Design a centralized runtime-aware formatter. Do after #3583. |
|
||||
| 6 | [#3340](https://github.com/gsd-build/get-shit-done/issues/3340) | SDK publish lag — agent dir fix never shipped in `@gsd-build/sdk@0.1.0` | Release / SDK publishing | Plan + coordination | Medium (release-focused) | Not started | Oldest. Mostly a publishing/versioning task. |
|
||||
|
||||
## Execution Rules for This Batch
|
||||
|
||||
- **Branch naming**: `fix/NNNN-short-description` (enforced by CI)
|
||||
- **PR template**: Must use `.github/PULL_REQUEST_TEMPLATE/fix.md`
|
||||
- **Linking**: `Fixes #NNNN` (or `Closes`) in the PR body
|
||||
- **Changeset**: Required for all user-facing or security fixes
|
||||
- **Testing**: All existing tests must pass + new coverage where the issue describes a gap
|
||||
- **Clean context windows**: Each fix should preferably be driven from a fresh session using the prepared prompts (see session notes or ask for them)
|
||||
- **GSD self-use**: For the small ones (#3583, #3579, #3496), using `/gsd-quick` (or `/gsd-fast`) inside the fix session is encouraged and appropriate. For #3584, a short planning step is recommended.
|
||||
|
||||
## Status Legend
|
||||
|
||||
- **Not started** — Issue claimed for this batch, no work begun
|
||||
- **In progress** — Active work in a clean window
|
||||
- **PR opened** — Pull request created and linked
|
||||
- **Review** — Awaiting review / CI / merge fixes
|
||||
- **Merged** — Landed on main
|
||||
- **Blocked** — Needs input from maintainers or upstream
|
||||
|
||||
## Current Status
|
||||
|
||||
- [x] #3583 — **PR opened** (3629 leading after CodeRabbit review + hardening push; competing 3586 needs changes + CI failing)
|
||||
- [ ] #3579 — Not started (cleanest next target — 0 PRs)
|
||||
- [ ] #3496 — PR 3497 open (changes requested)
|
||||
- [ ] #3588 — Not started
|
||||
- [ ] #3584 — Not started (larger; deferred runtime cjs colon emissions)
|
||||
- [ ] #3340 — Not started
|
||||
|
||||
**Progress**: 0 / 6 merged (1 in active review)
|
||||
|
||||
## Process Notes
|
||||
|
||||
- These issues were identified during a `/gsd-inbox` run on 2026-05-16.
|
||||
- At the time of creation of this file, zero of the six had open PRs.
|
||||
- 2026-05-16 Grok session: Reviewed PR 3629 (our #3583 fix) for CodeRabbit comments. 1 critical was false-positive (scripts/ *is* published per package.json "files" + npm pack). Applied the 2 valid suggestions (bidirectional word-boundary lookbehind in `buildColonPattern` + body-only scope for the colon-ref regression guard in the test). Tests pass. Pushed hardening commit to the fork branch. Competing PR 3586 exists but is behind on CI/review status.
|
||||
- Work is intended to be done in **parallel clean context windows** (one issue per fresh Claude/Codex/Gemini session) using dedicated prompts.
|
||||
- After each fix is complete in its window, the resulting branch + PR description should be brought back here for final review and opening.
|
||||
- This file serves as the single source of truth for the current batch while execution is in progress. It can be deleted or moved to `docs/archive/` once all six PRs are merged.
|
||||
|
||||
## Related Artifacts
|
||||
|
||||
- Inbox triage report: `/tmp/GSD-INBOX-TRIAGE-2026-05-16.md` (from the `/gsd-inbox` run)
|
||||
- Full issue list with `confirmed-bug` label: `gh issue list --state open --label confirmed-bug`
|
||||
|
||||
---
|
||||
|
||||
**Next action**: #3583 now has active PR(s) under review. Next clean quick win (0 PRs, small packaging effort, high value for recently-landed graphify feature): **#3579**. Validated via GitHub search: no PRs mention 3579. Ready for `/gsd-quick` or direct fix (update `scripts/build-hooks.js` HOOKS_TO_COPY + ensure `hooks/lib/` copy in installer + fix any publish filter).
|
||||
|
||||
This document will be updated as status changes.
|
||||
@@ -37,7 +37,7 @@ Write machine-parseable, evidence-based intelligence. Every claim references act
|
||||
- **Always include file paths.** Every claim must reference the actual code location.
|
||||
- **Write current state only.** No temporal language ("recently added", "will be changed").
|
||||
- **Evidence-based.** Read the actual files. Do not guess from file names or directory structures.
|
||||
- **Cross-platform.** Use Glob, Read, and Grep tools -- not Bash `ls`, `find`, or `cat`. Bash file commands fail on Windows. Only use Bash for `gsd-sdk query intel` CLI calls.
|
||||
- **Cross-platform.** Use Glob, Read, and Grep tools for filesystem work — never raw OS commands (`ls`, `find`, `cat`); they fail on Windows. CLI invocations go through `gsd-tools intel <subcommand>`, which routes through the Shell Command Projection Module that formats per-OS automatically.
|
||||
- **ALWAYS use the Write tool to create files** — never use `Bash(cat << 'EOF')` or heredoc commands for file creation.
|
||||
</role>
|
||||
|
||||
@@ -123,7 +123,7 @@ All JSON files include a `_meta` object with `updated_at` (ISO timestamp) and `v
|
||||
}
|
||||
```
|
||||
|
||||
**exports constraint:** Array of ACTUAL exported symbol names extracted from `module.exports` or `export` statements. MUST be real identifiers (e.g., `"configLoad"`, `"stateUpdate"`), NOT descriptions (e.g., `"config operations"`). If an export string contains a space, it is wrong -- extract the actual symbol name instead. Use `gsd-sdk query intel.extract-exports <file>` to get accurate exports.
|
||||
**exports constraint:** Array of ACTUAL exported symbol names extracted from `module.exports` or `export` statements. MUST be real identifiers (e.g., `"configLoad"`, `"stateUpdate"`), NOT descriptions (e.g., `"config operations"`). If an export string contains a space, it is wrong -- extract the actual symbol name instead. Use `gsd-tools intel extract-exports <file>` to get accurate exports.
|
||||
|
||||
Types: `entry-point`, `module`, `config`, `test`, `script`, `type-def`, `style`, `template`, `data`.
|
||||
|
||||
@@ -219,7 +219,7 @@ Glob for project structure indicators:
|
||||
|
||||
Read package.json, configs, and build files. Write `stack.json`. Then patch its timestamp:
|
||||
```bash
|
||||
gsd-sdk query intel.patch-meta .planning/intel/stack.json --cwd <project_root>
|
||||
gsd-tools intel patch-meta .planning/intel/stack.json
|
||||
```
|
||||
|
||||
### Step 3: File Graph
|
||||
@@ -228,7 +228,7 @@ Glob source files (`**/*.ts`, `**/*.js`, `**/*.py`, etc., excluding node_modules
|
||||
Read key files (entry points, configs, core modules) for imports/exports.
|
||||
Write `files.json`. Then patch its timestamp:
|
||||
```bash
|
||||
gsd-sdk query intel.patch-meta .planning/intel/files.json --cwd <project_root>
|
||||
gsd-tools intel patch-meta .planning/intel/files.json
|
||||
```
|
||||
|
||||
Focus on files that matter -- entry points, core modules, configs. Skip test files and generated code unless they reveal architecture.
|
||||
@@ -239,7 +239,7 @@ Grep for route definitions, endpoint declarations, CLI command registrations.
|
||||
Patterns to search: `app.get(`, `router.post(`, `@GetMapping`, `def route`, express route patterns.
|
||||
Write `apis.json`. If no API endpoints found, write an empty entries object. Then patch its timestamp:
|
||||
```bash
|
||||
gsd-sdk query intel.patch-meta .planning/intel/apis.json --cwd <project_root>
|
||||
gsd-tools intel patch-meta .planning/intel/apis.json
|
||||
```
|
||||
|
||||
### Step 5: Dependencies
|
||||
@@ -248,7 +248,7 @@ Read package.json (dependencies, devDependencies), requirements.txt, go.mod, Car
|
||||
Cross-reference with actual imports to populate `used_by`.
|
||||
Write `deps.json`. Then patch its timestamp:
|
||||
```bash
|
||||
gsd-sdk query intel.patch-meta .planning/intel/deps.json --cwd <project_root>
|
||||
gsd-tools intel patch-meta .planning/intel/deps.json
|
||||
```
|
||||
|
||||
### Step 6: Architecture
|
||||
@@ -258,7 +258,7 @@ Write `arch.md`.
|
||||
|
||||
### Step 6.5: Self-Check
|
||||
|
||||
Run: `gsd-sdk query intel.validate --cwd <project_root>`
|
||||
Run: `gsd-tools intel validate`
|
||||
|
||||
Review the output:
|
||||
|
||||
@@ -270,7 +270,7 @@ This step is MANDATORY -- do not skip it.
|
||||
|
||||
### Step 7: Snapshot
|
||||
|
||||
Run: `gsd-sdk query intel.snapshot --cwd <project_root>`
|
||||
Run: `gsd-tools intel snapshot`
|
||||
|
||||
This writes `.last-refresh.json` with accurate timestamps and hashes. Do NOT write `.last-refresh.json` manually.
|
||||
</execution_flow>
|
||||
|
||||
278
bin/install.js
278
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 = '<!-- GSD Configuration \u2014 managed by get-shit-done installer -->';
|
||||
const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
|
||||
|
||||
// GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
|
||||
// git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
|
||||
const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
|
||||
|
||||
const CODEX_AGENT_SANDBOX = {
|
||||
'gsd-executor': 'workspace-write',
|
||||
'gsd-planner': 'workspace-write',
|
||||
@@ -1665,10 +1678,18 @@ function skillFrontmatterName(skillDirName) {
|
||||
* Emits `name: gsd-<cmd>` (hyphen) so Skill(skill="gsd-<cmd>") calls and
|
||||
* tab autocomplete use the canonical command namespace.
|
||||
*/
|
||||
function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null) {
|
||||
function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, cmdNames = null) {
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(content);
|
||||
if (!frontmatter) return content;
|
||||
|
||||
// #3583: rewrite any /gsd:<cmd> or gsd:<cmd> in the body to the canonical
|
||||
// hyphen form (gsd-<cmd>) so installed SKILL.md bodies match the hyphen
|
||||
// `name:` Claude Code (and Qwen/Hermes) register under (#2808). `cmdNames`
|
||||
// is optional and pre-computed by the caller for performance; direct test
|
||||
// calls fall back to reading the list.
|
||||
const names = cmdNames || readGsdCommandNames();
|
||||
const normalizedBody = transformContentToHyphen(body, names);
|
||||
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
|
||||
const agent = extractFrontmatterField(frontmatter, 'agent');
|
||||
@@ -1694,7 +1715,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null) {
|
||||
if (toolsBlock) fm += toolsBlock;
|
||||
fm += '---';
|
||||
|
||||
return `${fm}\n${body}`;
|
||||
return `${fm}\n${normalizedBody}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5895,6 +5916,11 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti
|
||||
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
|
||||
// Live command names for the colon→hyphen body transform (#3583), computed
|
||||
// once per install instead of inside convertClaudeCommandToClaudeSkill where
|
||||
// it would re-scan commands/gsd for every skill.
|
||||
const cmdNames = readGsdCommandNames();
|
||||
|
||||
// #2973 (CR follow-up on #3003): preserve user-generated skills across the
|
||||
// wipe-and-replace. `gsd-dev-preferences/SKILL.md` is written by the user
|
||||
// via `/gsd-profile-user --refresh`; it is NOT shipped by the npm package,
|
||||
@@ -5985,7 +6011,7 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti
|
||||
content = content.replace(/\.claude\//g, '.hermes/');
|
||||
}
|
||||
content = processAttribution(content, getCommitAttribution(runtime));
|
||||
content = convertClaudeCommandToClaudeSkill(content, skillName, runtime);
|
||||
content = convertClaudeCommandToClaudeSkill(content, skillName, runtime, cmdNames);
|
||||
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
||||
}
|
||||
@@ -6867,6 +6893,33 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
removedCount++;
|
||||
console.log(` ${green}✓${reset} Removed ${hookCount} GSD hooks`);
|
||||
}
|
||||
|
||||
// Remove only the GSD-managed files from hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
|
||||
// hooks/lib/ lives inside the user's runtime hooks directory (shared space) and
|
||||
// may contain user-owned custom helpers. We must not recursively delete the dir.
|
||||
const hooksLibDir = path.join(hooksDir, 'lib');
|
||||
if (fs.existsSync(hooksLibDir)) {
|
||||
let removedLibFiles = 0;
|
||||
for (const file of GSD_HOOK_LIB_FILES) {
|
||||
const filePath = path.join(hooksLibDir, file);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
removedLibFiles++;
|
||||
} catch (_) {
|
||||
// Ignore missing files (best effort, non-fatal)
|
||||
}
|
||||
}
|
||||
// Only remove the directory itself if it is now empty (preserve any user files)
|
||||
try {
|
||||
fs.rmdirSync(hooksLibDir);
|
||||
} catch (_) {
|
||||
// Directory not empty or other error — leave it alone
|
||||
}
|
||||
if (removedLibFiles > 0) {
|
||||
removedCount++;
|
||||
console.log(` ${green}✓${reset} Removed ${removedLibFiles} hooks/lib/ helper(s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Remove GSD package.json (CommonJS mode marker)
|
||||
@@ -7486,6 +7539,16 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
|
||||
manifest.files['hooks/' + file] = fileHash(path.join(hooksDir, file));
|
||||
}
|
||||
}
|
||||
// Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
|
||||
// to git-cmd.js (validate-commit classifier) and gsd-graphify-rebuild.sh.
|
||||
const hooksLibDir = path.join(hooksDir, 'lib');
|
||||
if (fs.existsSync(hooksLibDir)) {
|
||||
for (const file of fs.readdirSync(hooksLibDir)) {
|
||||
if (GSD_HOOK_LIB_FILES.includes(file)) {
|
||||
manifest.files['hooks/lib/' + file] = fileHash(path.join(hooksLibDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7749,6 +7812,36 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
const dirName = getDirName(runtime);
|
||||
const src = path.join(__dirname, '..');
|
||||
|
||||
// Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
|
||||
// Defined early so it is visible to both the main and Codex code paths.
|
||||
// `allowlist` (when non-empty) restricts copying to the named top-level entries,
|
||||
// keeping install scope aligned with GSD_HOOK_LIB_FILES (which uninstall/manifest manage).
|
||||
const copyLibDir = (sDir, dDir, allowlist = []) => {
|
||||
const allowed = allowlist.length > 0 ? new Set(allowlist) : null;
|
||||
for (const entry of fs.readdirSync(sDir)) {
|
||||
if (allowed && !allowed.has(entry)) continue;
|
||||
const s = path.join(sDir, entry);
|
||||
const d = path.join(dDir, entry);
|
||||
let st;
|
||||
try { st = fs.lstatSync(s); } catch (_) { continue; }
|
||||
if (st.isSymbolicLink()) continue; // defense-in-depth
|
||||
if (st.isDirectory()) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
copyLibDir(s, d);
|
||||
} else if (entry.endsWith('.sh')) {
|
||||
let content = fs.readFileSync(s, 'utf8');
|
||||
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
|
||||
fs.writeFileSync(d, content);
|
||||
try { fs.chmodSync(d, 0o755); } catch (_) { /* Windows */ }
|
||||
} else {
|
||||
fs.copyFileSync(s, d);
|
||||
if (entry.endsWith('.js')) {
|
||||
try { fs.chmodSync(d, 0o755); } catch (_) { /* Windows */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get the target directory based on runtime and install type.
|
||||
// Cline local installs write to the project root (like Claude Code) — .clinerules
|
||||
// lives at the root, not inside a .cline/ subdirectory.
|
||||
@@ -7762,6 +7855,45 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
? targetDir.replace(os.homedir(), '~')
|
||||
: targetDir.replace(process.cwd(), '.');
|
||||
|
||||
// #3406: warn if a stale standalone `@gsd-build/sdk` is globally installed
|
||||
// and shadows the `gsd-sdk` shim this installer wires up. Only meaningful
|
||||
// for global installs (the shim collision lives in the global node_modules
|
||||
// bin dir). Guarded by GSD_SKIP_STALE_SDK_CHECK so CI/tests can silence it.
|
||||
// #3406 CR: opt-out only on explicit "1" / "true" / "yes" rather than any
|
||||
// non-empty value. Without this guard `GSD_SKIP_STALE_SDK_CHECK=0` and
|
||||
// `GSD_SKIP_STALE_SDK_CHECK=false` would silently disable the check.
|
||||
const skipRaw = process.env.GSD_SKIP_STALE_SDK_CHECK;
|
||||
const skipStaleCheck = skipRaw === '1' || skipRaw === 'true' || skipRaw === 'yes';
|
||||
if (isGlobal && !skipStaleCheck) {
|
||||
try {
|
||||
const { execFileSync } = require('child_process');
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const staleInfo = detectStaleStandaloneSdk(() => {
|
||||
try {
|
||||
return execFileSync(
|
||||
npmCmd,
|
||||
['ls', '-g', '@gsd-build/sdk', '--json', '--depth=0'],
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }
|
||||
);
|
||||
} catch (e) {
|
||||
// `npm ls -g <missing>` exits 1 with the JSON still on stdout when
|
||||
// the package is absent. execFileSync throws on non-zero exit but
|
||||
// attaches stdout to the error. Recover the JSON in that case so
|
||||
// the detector classifies "absent" correctly.
|
||||
if (e && typeof e.stdout !== 'undefined') {
|
||||
return Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
if (staleInfo.stale) {
|
||||
console.warn(`\n${yellow}${formatStaleStandaloneSdkWarning(staleInfo)}${reset}\n`);
|
||||
}
|
||||
} catch {
|
||||
// Detection is best-effort; never block install on its failure.
|
||||
}
|
||||
}
|
||||
|
||||
// Path prefix for file references in markdown content (e.g. gsd-tools.cjs).
|
||||
// Replaces $HOME/.claude/ or ~/.claude/ so the result is <pathPrefix>get-shit-done/bin/...
|
||||
// For global installs: use $HOME/ so paths expand correctly inside double-quoted
|
||||
@@ -8661,6 +8793,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
fs.copyFileSync(srcFile, destFile);
|
||||
}
|
||||
}
|
||||
} else if (fs.statSync(srcFile).isDirectory()) {
|
||||
// #3579: recurse one level into hook subdirs (lib/ etc.). The
|
||||
// graphify auto-update hook's rebuild helper lives at
|
||||
// hooks/dist/lib/gsd-graphify-rebuild.sh and must land at the
|
||||
// mirrored target path so the hook's REBUILD_SCRIPT lookup resolves.
|
||||
const subDest = path.join(hooksDest, entry);
|
||||
fs.mkdirSync(subDest, { recursive: true });
|
||||
const subEntries = fs.readdirSync(srcFile);
|
||||
for (const subEntry of subEntries) {
|
||||
const subSrcFile = path.join(srcFile, subEntry);
|
||||
if (!fs.statSync(subSrcFile).isFile()) continue;
|
||||
const subDestFile = path.join(subDest, subEntry);
|
||||
if (subEntry.endsWith('.sh')) {
|
||||
let content = fs.readFileSync(subSrcFile, 'utf8');
|
||||
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
|
||||
fs.writeFileSync(subDestFile, content);
|
||||
try { fs.chmodSync(subDestFile, 0o755); } catch (e) { /* Windows */ }
|
||||
} else {
|
||||
fs.copyFileSync(subSrcFile, subDestFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (verifyInstalled(hooksDest, 'hooks')) {
|
||||
@@ -8678,6 +8831,18 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
|
||||
// Codex/Copilot/Cursor/Windsurf/Trae/Cline skip hooks entirely, so they must not
|
||||
// receive the hooks/lib/ helpers either — otherwise the Codex comment downstream
|
||||
// ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
|
||||
const hooksLibSrc = path.join(src, 'hooks', 'lib');
|
||||
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && fs.existsSync(hooksLibSrc)) {
|
||||
const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
|
||||
fs.mkdirSync(hooksLibDest, { recursive: true });
|
||||
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
|
||||
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
|
||||
}
|
||||
|
||||
// Clear stale update cache so next session re-evaluates hook versions
|
||||
// Cache lives at ~/.cache/gsd/ (see hooks/gsd-check-update.js line 35-36)
|
||||
const updateCacheFile = path.join(os.homedir(), '.cache', 'gsd', 'gsd-update-check.json');
|
||||
@@ -8938,15 +9103,18 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
|
||||
}
|
||||
|
||||
// Copy hook files that are referenced by Codex hook configuration (#2153)
|
||||
// The main hook-copy block is gated to non-Codex runtimes, but Codex registers
|
||||
// gsd-check-update.js through hooks config — the file must physically exist.
|
||||
// Copy only the hook files that Codex actually registers via its hook configuration (#2153).
|
||||
// Codex primarily needs gsd-check-update.js for the SessionStart update-check hook.
|
||||
// We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
|
||||
// in this change (graphify auto-update support for Codex is out of scope for #3579).
|
||||
const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js'];
|
||||
const codexHooksSrc = path.join(src, 'hooks', 'dist');
|
||||
if (fs.existsSync(codexHooksSrc)) {
|
||||
const codexHooksDest = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(codexHooksDest, { recursive: true });
|
||||
const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
|
||||
for (const entry of fs.readdirSync(codexHooksSrc)) {
|
||||
if (!CODEX_HOOKS_TO_COPY.includes(entry)) continue;
|
||||
const srcFile = path.join(codexHooksSrc, entry);
|
||||
if (!fs.statSync(srcFile).isFile()) continue;
|
||||
const destFile = path.join(codexHooksDest, entry);
|
||||
@@ -8958,18 +9126,20 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
|
||||
fs.writeFileSync(destFile, content);
|
||||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
||||
} else {
|
||||
if (entry.endsWith('.sh')) {
|
||||
let content = fs.readFileSync(srcFile, 'utf8');
|
||||
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
|
||||
fs.writeFileSync(destFile, content);
|
||||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
||||
} else {
|
||||
fs.copyFileSync(srcFile, destFile);
|
||||
}
|
||||
} else if (entry.endsWith('.sh')) {
|
||||
// #2136: any .sh hook reaching this loop must have {{GSD_VERSION}}
|
||||
// stamped so installed scripts carry a concrete version header and
|
||||
// stale-hook detection keeps working across upgrades. The current
|
||||
// CODEX_HOOKS_TO_COPY allowlist excludes .sh files, so this branch
|
||||
// is defensive — it preserves the invariant if the allowlist is
|
||||
// extended later (e.g. to ship gsd-graphify-update.sh for Codex).
|
||||
let content = fs.readFileSync(srcFile, 'utf8');
|
||||
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
|
||||
fs.writeFileSync(destFile, content);
|
||||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
||||
}
|
||||
}
|
||||
console.log(` ${green}✓${reset} Installed hooks`);
|
||||
console.log(` ${green}✓${reset} Installed hooks (Codex)`);
|
||||
}
|
||||
|
||||
// Add Codex hooks (SessionStart for update checking) — requires codex_hooks feature flag
|
||||
@@ -10487,6 +10657,80 @@ function installSdkIfNeeded(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #3406 helper: detect a stale globally-installed `@gsd-build/sdk` package
|
||||
* shadowing the `gsd-sdk` shim that `get-shit-done-cc` installs.
|
||||
*
|
||||
* Background: `@gsd-build/sdk@0.1.0` was published once and never updated
|
||||
* (the SDK now ships embedded in `get-shit-done-cc`). When a user has the
|
||||
* 0.1.0 standalone package installed globally, its `gsd-sdk` bin shadows
|
||||
* the one `get-shit-done-cc` provides — and the 0.1.0 binary only knows
|
||||
* `run | auto | init` (no `query`), so every `gsd-sdk query <command>`
|
||||
* call from skills/hooks fails until the user runs
|
||||
* `npm uninstall -g @gsd-build/sdk`.
|
||||
*
|
||||
* Pure function: takes an injected `runNpmLs` executor that returns
|
||||
* `npm ls -g @gsd-build/sdk --json --depth=0` stdout. Returns:
|
||||
* `{ stale: true, version }` when the package is present.
|
||||
* `{ stale: false }` for every other input — including:
|
||||
* - executor throws (npm missing / EACCES / network),
|
||||
* - executor returns null/undefined/non-string,
|
||||
* - stdout is not parseable JSON,
|
||||
* - the JSON has no `.dependencies['@gsd-build/sdk']` field.
|
||||
*
|
||||
* Fail-closed conservative: we'd rather miss a detection than fire a
|
||||
* false-positive warning that confuses users who have a fine install.
|
||||
*/
|
||||
function detectStaleStandaloneSdk(runNpmLs) {
|
||||
if (typeof runNpmLs !== 'function') return { stale: false };
|
||||
let out;
|
||||
try {
|
||||
out = runNpmLs();
|
||||
} catch {
|
||||
return { stale: false };
|
||||
}
|
||||
if (typeof out !== 'string' || out.length === 0) return { stale: false };
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(out);
|
||||
} catch {
|
||||
return { stale: false };
|
||||
}
|
||||
const deps = parsed && typeof parsed === 'object' ? parsed.dependencies : null;
|
||||
if (!deps || typeof deps !== 'object') return { stale: false };
|
||||
const entry = deps['@gsd-build/sdk'];
|
||||
if (!entry || typeof entry !== 'object') return { stale: false };
|
||||
const version = typeof entry.version === 'string' ? entry.version : '(unknown)';
|
||||
// #3406 CR: scope stale detection to the known-bad version (0.1.0). Any
|
||||
// newer @gsd-build/sdk version is an intentional install (or a future
|
||||
// republish) and should not be flagged as a shim shadow. Without this
|
||||
// narrowing, a maintainer's local-link or a legitimate future publish
|
||||
// would trigger a misleading "stale shadow" warning on every install.
|
||||
if (version !== '0.1.0') return { stale: false };
|
||||
return { stale: true, version };
|
||||
}
|
||||
|
||||
/**
|
||||
* #3406 helper: format the install-time warning emitted when
|
||||
* `detectStaleStandaloneSdk` reports a stale shadow. Separated from the
|
||||
* detection so the message contract is testable independently of npm.
|
||||
*/
|
||||
function formatStaleStandaloneSdkWarning(info) {
|
||||
const version = info && info.version ? info.version : '(unknown)';
|
||||
return [
|
||||
'⚠ A stale globally-installed @gsd-build/sdk@' + version + ' is shadowing the',
|
||||
' `gsd-sdk` shim that get-shit-done-cc provides. The standalone package',
|
||||
' only knows `run | auto | init` — every `gsd-sdk query <cmd>` call from',
|
||||
' skills and hooks will fail until you remove it.',
|
||||
'',
|
||||
' Remediation:',
|
||||
' npm uninstall -g @gsd-build/sdk',
|
||||
' npx -y get-shit-done-cc@latest --<runtime> --global',
|
||||
'',
|
||||
' Tracking: #3406 — https://github.com/gsd-build/get-shit-done/issues/3406',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* #3231 helper: detect whether a `gsd-sdk` binary is the legacy deprecated
|
||||
* shim pointing at `gsd-tools.cjs`.
|
||||
@@ -11105,6 +11349,8 @@ if (process.env.GSD_TEST_MODE) {
|
||||
installAllRuntimes,
|
||||
uninstall,
|
||||
installSdkIfNeeded,
|
||||
detectStaleStandaloneSdk,
|
||||
formatStaleStandaloneSdkWarning,
|
||||
buildSdkFailFastReport,
|
||||
renderSdkFailFastReport,
|
||||
classifySdkInstall,
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -361,7 +361,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
|
||||
---
|
||||
|
||||
## CLI Modules (64 shipped)
|
||||
## CLI Modules (70 shipped)
|
||||
|
||||
Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
|
||||
@@ -372,6 +372,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
| `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint |
|
||||
| `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers |
|
||||
| `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers |
|
||||
| `cjs-sdk-bridge.cjs` | Shared SDK runtime-bridge loader (`tryLoadSdk`/`getExecuteForCjs`); consumed by every CJS router and `gsd-tools.cjs` to delegate canonical commands to the SDK in-process |
|
||||
| `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) |
|
||||
| `command-aliases.generated.cjs` | Generated CJS alias/subcommand metadata for manifest-backed family routers |
|
||||
| `commands.cjs` | Misc CLI commands (slug, timestamp, todos, scaffolding, stats) |
|
||||
@@ -380,7 +381,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
| `configuration.generated.cjs` | Generated Configuration Module — canonical config loading, legacy-key normalization, defaults merge, and explicit on-disk migration; source of truth for both SDK and CJS consumers |
|
||||
| `context-utilization.cjs` | Pure classifier for `gsd-health --context` — turns (tokensUsed, contextWindow) into a `{ percent, state }` triage result against the 60%/70% fracture-point thresholds (#2792) |
|
||||
| `core.cjs` | Error handling, output formatting, shared utilities, runtime fallbacks; compatibility re-exports for planning-workspace helpers |
|
||||
| `decisions.cjs` | Shared parser for CONTEXT.md `<decisions>` blocks (D-NN entries); used by `gap-checker.cjs` and intended for #2492 plan/verify decision gates |
|
||||
| `decisions.cjs` | CJS shim adapter — re-exports from `decisions.generated.cjs` (Phase 6/#3575 Shared Module migration) |
|
||||
| `decisions.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/decisions.ts` via `sdk/scripts/gen-decisions.mjs`; parses CONTEXT.md `<decisions>` blocks, accepts numeric (D-42) and alphanumeric (D-INFRA-01) IDs, returns `{id, text, category, tags, trackable}`; do not edit directly |
|
||||
| `docs.cjs` | Docs-update workflow init, Markdown scanning, monorepo detection |
|
||||
| `drift.cjs` | Post-execute codebase structural drift detector (#2003): classifies file changes into new-dir/barrel/migration/route categories and round-trips `last_mapped_commit` frontmatter |
|
||||
| `fallow-runner.cjs` | Fallow audit adapter for `/gsd-code-review`: binary resolution (`PATH` then `node_modules/.bin`), actionable missing-binary errors, and structural findings normalization |
|
||||
@@ -402,7 +404,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
| `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` |
|
||||
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |
|
||||
| `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` |
|
||||
| `plan-scan.cjs` | Canonical phase-plan scanner — shared helper for detecting plan and summary files in flat and nested layouts (k014); consumed by state, roadmap, init, and workstream inventory paths |
|
||||
| `plan-scan.cjs` | CJS shim adapter — re-exports from `plan-scan.generated.cjs` (Phase 6/#3575 Shared Module migration) |
|
||||
| `plan-scan.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/plan-scan.ts` via `sdk/scripts/gen-plan-scan.mjs`; canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014); do not edit directly |
|
||||
| `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) |
|
||||
| `project-root.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/project-root/index.ts` via `sdk/scripts/gen-project-root.mjs`; resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic); do not edit directly |
|
||||
| `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation |
|
||||
@@ -412,8 +415,10 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
| `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress |
|
||||
| `runtime-homes.cjs` | Canonical runtime → global config/skills directory mapping; first-class support for all 15 runtimes including Hermes nested layout and Cline rules-based exclusion (#3126) |
|
||||
| `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-<cmd>` (skills-based runtimes) and `$gsd-<cmd>` (codex) in user-facing output and persisted artifacts (#3584) |
|
||||
| `schema-detect.cjs` | Schema-drift detection for ORM patterns (Prisma, Drizzle, etc.) |
|
||||
| `secrets.cjs` | Secret-config masking convention (`****<last-4>`) for integration keys managed by `/gsd-config --integrations` — keeps plaintext out of `config-set` output |
|
||||
| `schema-detect.cjs` | CJS shim adapter — re-exports from `schema-detect.generated.cjs` (Phase 6/#3575 Shared Module migration) |
|
||||
| `schema-detect.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/schema-detect.ts` via `sdk/scripts/gen-schema-detect.mjs`; schema-drift detection for ORM patterns (Prisma, Drizzle, Supabase, TypeORM, Payload); exports `detectSchemaFiles`, `detectSchemaOrm`, `checkSchemaDrift`, `SCHEMA_PATTERNS`, `ORM_INFO`; do not edit directly |
|
||||
| `secrets.cjs` | CJS shim adapter — re-exports from `secrets.generated.cjs` (Phase 6/#3575 Shared Module migration) |
|
||||
| `secrets.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/secrets.ts` via `sdk/scripts/gen-secrets.mjs`; secret-config masking convention (`****<last-4>`) for integration keys; exports `SECRET_CONFIG_KEYS`, `isSecretKey`, `maskSecret`, `maskIfSecret`; do not edit directly |
|
||||
| `security.cjs` | Path traversal prevention, prompt injection detection, safe JSON/shell helpers |
|
||||
| `shell-command-projection.cjs` | Runtime-aware shell command projection for managed hook serialization: decides PowerShell call-operator usage by runtime/platform and normalizes Windows script path tokens |
|
||||
| `state-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools state` |
|
||||
@@ -428,7 +433,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
| `verify.cjs` | Plan structure, phase completeness, reference, commit validation |
|
||||
| `workstream-inventory-builder.generated.cjs` | GENERATED — pure workstream inventory projection builder; CJS artifact emitted from `sdk/src/workstream-inventory/builder.ts` via `sdk/scripts/gen-workstream-inventory-builder.mjs`; do not edit directly |
|
||||
| `workstream-inventory.cjs` | Shared workstream inventory projection: state fields, phase/plan/summary counts, roadmap phase count, and active marker — thin orchestrator that delegates pure projection to `workstream-inventory-builder.generated.cjs` |
|
||||
| `workstream-name-policy.cjs` | Canonical workstream name validation (`isValidActiveWorkstreamName`) and slug normalization (`toWorkstreamSlug`); shared by all workstream callers |
|
||||
| `workstream-name-policy.cjs` | CJS shim adapter — re-exports from `workstream-name-policy.generated.cjs` (Phase 6/#3575 Shared Module migration) |
|
||||
| `workstream-name-policy.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/workstream-name-policy.ts` via `sdk/scripts/gen-workstream-name-policy.mjs`; canonical workstream name validation (`isValidActiveWorkstreamName`, `hasInvalidPathSegment`, `validateWorkstreamName`) and slug normalization (`toWorkstreamSlug`); do not edit directly |
|
||||
| `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer |
|
||||
| `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic |
|
||||
|
||||
|
||||
269
docs/agents/cjs-sdk-seam.md
Normal file
269
docs/agents/cjs-sdk-seam.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# CJS↔SDK Hard-Seam Migration: Complete Reference
|
||||
## Issue #3575 (Parent: #3524)
|
||||
|
||||
---
|
||||
|
||||
## Migration overview
|
||||
|
||||
The CJS↔SDK hard-seam migration (#3524) eliminates a class of config-schema drift bugs by introducing single sources of truth at every decision point where CJS and SDK code previously diverged. The migration proceeded in six phases:
|
||||
|
||||
| Phase | PR | Summary |
|
||||
|-------|----|---------|
|
||||
| Phase 1 | [#3531](https://github.com/gsd-build/get-shit-done/pull/3531) | `state-document` Shared Module — source-of-truth at `sdk/src/state-document/`, generator, freshness check, CJS Adapter (`state-document.generated.cjs`). Worked example for the pattern. |
|
||||
| Phase 2 | [#3540](https://github.com/gsd-build/get-shit-done/pull/3540) | `configuration` Shared Module — `sdk/shared/config-schema.manifest.json` + `sdk/shared/config-defaults.manifest.json` as data manifests; generator + freshness check + CJS Adapter. |
|
||||
| Phase 3 | [#3548](https://github.com/gsd-build/get-shit-done/pull/3548) | `workstream-inventory` Shared Module — source-of-truth at `sdk/src/workstream-inventory/`, builder, generator, freshness check, CJS Adapter. |
|
||||
| Phase 4 | [#3554](https://github.com/gsd-build/get-shit-done/pull/3554) | `project-root` Shared Module — source-of-truth at `sdk/src/project-root/`, generator, freshness check, CJS Adapter. |
|
||||
| Phase 5.0 | [#3558](https://github.com/gsd-build/get-shit-done/pull/3558) | `runtime-bridge-sync` worker — enables CJS-side execution of SDK native handlers; state.* family initial router delegation via `executeForCjs`. |
|
||||
| Phase 5.1 | [#3574](https://github.com/gsd-build/get-shit-done/pull/3574) | `state.*` router delegation complete — all known state subcommands delegated via `executeForCjs`; Phase 5.0 worker bug fix. |
|
||||
| Phase 6 | [#3577](https://github.com/gsd-build/get-shit-done/pull/3577) (closes [#3575](https://github.com/gsd-build/get-shit-done/issues/3575)) | Enforcement hardening + Final completion — hand-sync drift lint, CODEOWNERS, 6 family-router migrations, 5 Shared Module migrations (plan-scan, secrets, schema-detect, decisions, workstream-name-policy), workstream native support, parity fixes. Migration feature-complete: 22 cooperating siblings, 0 backlog pairs. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 Retrospective: 15 config-schema drift bugs
|
||||
|
||||
This section captures 15 recurring config-schema drift bugs that motivated the migration. For each, we record what drifted, the surgical fix, and which Phase 6 enforcement layer would have prevented it.
|
||||
|
||||
---
|
||||
|
||||
### #1535 — Silent failure on unrecognized config.json keys
|
||||
- **Drifted:** `loadConfig` silently ignored any top-level key in `.planning/config.json` not in `VALID_CONFIG_KEYS`, giving users no feedback when hand-edited or external-tool-added keys had no effect.
|
||||
- **Fix landed:** PR #1542 — added stderr warning listing unrecognized keys.
|
||||
- **Would have been blocked by:** **handsync lint** — a seam-aware linter would forbid having parallel hand-authored config validators (CJS `config.cjs` and SDK `config-mutation.ts`) that could silently diverge.
|
||||
|
||||
---
|
||||
|
||||
### #1542 — fix(config): warn on unrecognized keys in config.json instead of silent drop
|
||||
- **Drifted:** No drift in this bug itself; it *fixed* #1535's silent-drop behavior by adding the warning.
|
||||
- **Fix landed:** PR #1542 — merged as the direct fix for #1535.
|
||||
- **Would have been blocked by:** **per-Module drift lint** (freshness check on config validation) — both CJS and SDK config paths would be regenerated from a single source-of-truth schema module, eliminating the silent-drop risk.
|
||||
|
||||
---
|
||||
|
||||
### #2047 — bug: config-set rejects intel.enabled despite being a documented config key
|
||||
- **Drifted:** `intel.enabled` was documented in workflows and gated in runtime code (`intel.cjs:58`), but missing from `VALID_CONFIG_KEYS` in `config.cjs`, so `config-set` rejected it.
|
||||
- **Fix landed:** PR #2021 — added `intel.enabled` to `VALID_CONFIG_KEYS` in CJS.
|
||||
- **Would have been blocked by:** **handsync lint** — linter would enforce that every config key gated in runtime code or documented in workflows must appear in the validator allowlist.
|
||||
|
||||
---
|
||||
|
||||
### #2052 — fix(config): add intel.enabled to VALID_CONFIG_KEYS
|
||||
- **Drifted:** Same as #2047 (missing from allowlist).
|
||||
- **Fix landed:** PR #2021 (same PR as #2047 fix).
|
||||
- **Would have been blocked by:** **handsync lint** — same as #2047.
|
||||
|
||||
---
|
||||
|
||||
### #2638 — bug: loadConfig writes sub_repos to top-level, then warns it's unknown
|
||||
- **Drifted:** After #2561 canonicalized `sub_repos` to `planning.sub_repos`, the legacy migration and filesystem auto-sync in `loadConfig` still wrote to top-level `parsed.sub_repos`, which was then flagged as unknown.
|
||||
- **Fix landed:** PR #2668 — rewrote both paths to target `parsed.planning.sub_repos` and deleted stale top-level copy.
|
||||
- **Would have been blocked by:** **per-Module drift lint** (freshness check for config shape) — the canonical location for `sub_repos` would be codified in a schema, and any code path writing to it would be verified against that schema at lint time.
|
||||
|
||||
---
|
||||
|
||||
### #2655 — fix(core): write sub_repos to planning.sub_repos, not top-level
|
||||
- **Drifted:** Same as #2638.
|
||||
- **Fix landed:** PR #2668 (same as #2638 fix).
|
||||
- **Would have been blocked by:** **per-Module drift lint** — same as #2638.
|
||||
|
||||
---
|
||||
|
||||
### #2653 — bug: SDK config-set rejects documented config keys accepted by CJS config-set
|
||||
- **Drifted:** SDK's `config-mutation.ts` had a hand-maintained `VALID_CONFIG_KEYS` set that had drifted **28 keys** behind CJS's `config-schema.cjs`, so documented commands like `gsd-sdk query config-set planning.sub_repos` were rejected.
|
||||
- **Fix landed:** PR #2670 — extracted shared `sdk/src/query/config-schema.ts` module mirroring CJS exactly; added parity test to fail on future drift.
|
||||
- **Would have been blocked by:** **manifest data isolation** — the config schema would live in one place (e.g., `sdk/shared/config.manifest.json`), and both CJS and SDK would read it, eliminating the possibility of independent drift.
|
||||
|
||||
---
|
||||
|
||||
### #2670 — fix(#2653): eliminate SDK↔CJS config-schema drift
|
||||
- **Drifted:** Same as #2653 (28-key drift).
|
||||
- **Fix landed:** PR #2670 (same as #2653 fix).
|
||||
- **Would have been blocked by:** **manifest data isolation** — same as #2653.
|
||||
|
||||
---
|
||||
|
||||
### #2687 — bug: loadConfig warns on valid dynamic-pattern containers in .planning/config.json
|
||||
- **Drifted:** Keys like `review.models.<cli>` were registered in `config-schema.cjs`'s `DYNAMIC_KEY_PATTERNS` but absent from the hand-maintained `KNOWN_TOP_LEVEL` set in `core.cjs`, causing false-positive "unknown key" warnings.
|
||||
- **Fix landed:** PR #2706 — added `topLevel` field to `DYNAMIC_KEY_PATTERNS` entries; derived `KNOWN_TOP_LEVEL` from schema instead of maintaining it manually.
|
||||
- **Would have been blocked by:** **per-Module drift lint** — the validator that builds `KNOWN_TOP_LEVEL` would be regenerated from the schema each run, not hand-maintained.
|
||||
|
||||
---
|
||||
|
||||
### #2706 — fix(#2687): loadConfig no longer warns on valid dynamic-pattern containers
|
||||
- **Drifted:** Same as #2687 (false warnings on valid dynamic keys).
|
||||
- **Fix landed:** PR #2706 (same as #2687 fix).
|
||||
- **Would have been blocked by:** **per-Module drift lint** — same as #2687.
|
||||
|
||||
---
|
||||
|
||||
### #2798 — context_window missing from VALID_CONFIG_KEYS
|
||||
- **Drifted:** `context_window` was documented in workflows and read in SDK runtime (`init.js:190`, `validate.js:575`), but missing from allowlists in both `config-mutation.ts` and `config-schema.cjs`, so writes were rejected.
|
||||
- **Fix landed:** PR #2816 — added `context_window` to `VALID_CONFIG_KEYS` in both SDK and CJS.
|
||||
- **Would have been blocked by:** **handsync lint** — linter would enforce that every key read at runtime must be in the allowlist.
|
||||
|
||||
---
|
||||
|
||||
### #2816 — fix(#2798): add context_window to VALID_CONFIG_KEYS allowlist
|
||||
- **Drifted:** Same as #2798 (missing from allowlists).
|
||||
- **Fix landed:** PR #2816 (same as #2798 fix).
|
||||
- **Would have been blocked by:** **handsync lint** — same as #2798.
|
||||
|
||||
---
|
||||
|
||||
### #3055 — bug: top-level branching_strategy silently becomes "none"
|
||||
- **Drifted:** `.planning/config.json` with top-level `branching_strategy: "phase"` was flagged as unknown and dropped by validator, causing `loadConfig` to fall back to the `"none"` default, so phase commits landed on the operator's current branch instead of creating `gsd/phase-{N}` branches.
|
||||
- **Fix landed:** PR #3116 — SDK-side only; added legacy normalization in `mergeDefaults()` to graft top-level value into canonical `git.branching_strategy` slot before validation.
|
||||
- **Would have been blocked by:** **per-Module drift lint** — the canonical location for `branching_strategy` would be codified in schema; validator would not strip the value before migrations had a chance to run, or CJS and SDK would share the same migration code.
|
||||
|
||||
---
|
||||
|
||||
### #3116 — fix: normalize legacy top-level branching_strategy into git config
|
||||
- **Drifted:** Same as #3055 (legacy top-level shape not normalized before validator strips it).
|
||||
- **Fix landed:** PR #3116 (SDK-side normalization in `mergeDefaults()`).
|
||||
- **Would have been blocked by:** **per-Module drift lint** — same as #3055, but SDK-side fix would be shared with CJS via seam layer instead of being ported separately.
|
||||
|
||||
---
|
||||
|
||||
### #3523 — bug: CJS loadConfig warns top-level branching_strategy 'will be ignored', but actively reads it
|
||||
- **Drifted:** After PR #3116 fixed the SDK side, the CJS path still emitted false "will be ignored" warnings on the same legacy top-level key, because `KNOWN_TOP_LEVEL` derivation extracted top-level names from `VALID_CONFIG_KEYS` (which contains `'git.branching_strategy'` but not `'branching_strategy'`), and the warning was factually incorrect — `core.cjs:485` does read the legacy value via fallback logic.
|
||||
- **Fix landed:** PR #3527 — added `'branching_strategy'` to the `KNOWN_TOP_LEVEL` hand-maintained list under the deprecated-keys bucket, suppressing the false warning.
|
||||
- **Would have been blocked by:** **runtime-bridge delegation** — if CJS and SDK config loading shared a common normalization routine (via `executeForCjs` or a shared seam module), the SDK fix in #3116 would automatically apply to CJS; no separate CJS-side warning would be possible.
|
||||
|
||||
---
|
||||
|
||||
## Surprises
|
||||
|
||||
None. All 15 bugs are genuine CJS↔SDK schema/validation drift, exactly the class the seam migration prevents.
|
||||
|
||||
## Phase 6 Enforcement Summary
|
||||
|
||||
The seam migration introduces these layers:
|
||||
|
||||
1. **handsync lint** (`scripts/lint-shared-module-handsync.cjs`) — Forbids parallel hand-authored validator modules; catches #1535, #2047, #2798.
|
||||
2. **freshness check** (`sdk/scripts/check-<module>-fresh.mjs`) — Regenerates config validators from schema each run; catches #2687, #3055.
|
||||
3. **manifest data isolation** (`sdk/shared/*.manifest.json`) — Single source-of-truth for schema; catches #2653.
|
||||
4. **per-Module drift lint** — Combination of freshness checks and schema-derived allowlists; catches #2638, #2687, #3055.
|
||||
5. **runtime-bridge delegation** (`executeForCjs` + shared seam modules) — Eliminates parallel CJS/SDK implementations; catches #3523 by preventing separate CJS warning logic.
|
||||
|
||||
Together, these layers eliminate the 15-bug class by enforcing single sources of truth at each decision point.
|
||||
|
||||
---
|
||||
|
||||
## Guide: Adding a new Shared Module
|
||||
|
||||
Use this when you want to extract a new piece of data or logic that both CJS and SDK currently duplicate hand-by-hand. Phase 1's `state-document` migration is the worked example.
|
||||
|
||||
**Step 1 — Create the source-of-truth file**
|
||||
|
||||
```text
|
||||
sdk/src/<module>/index.ts
|
||||
```
|
||||
|
||||
This is the canonical definition. It may export a schema, a set of keys, a type, or a data object. It must not import from CJS or from generated files.
|
||||
|
||||
**Step 2 — Write the generator script**
|
||||
|
||||
```text
|
||||
sdk/scripts/gen-<module>.mjs
|
||||
```
|
||||
|
||||
The generator reads `sdk/src/<module>/index.ts` (or `sdk/shared/<module>.manifest.json` for pure-data manifests), produces a generated output file (either `sdk/src/<module>.generated.ts` or `get-shit-done/bin/lib/<module>.generated.cjs`), and exits 0. It must be idempotent: running it twice produces the same output.
|
||||
|
||||
**Step 3 — Write the freshness check**
|
||||
|
||||
```text
|
||||
sdk/scripts/check-<module>-fresh.mjs
|
||||
```
|
||||
|
||||
The freshness check re-runs the generator into a temp location, diffs against the committed file, and exits 1 with a clear message if they diverge. This is what CI runs.
|
||||
|
||||
**Step 4 — Write the parity test** (optional but recommended)
|
||||
|
||||
```text
|
||||
tests/<module>-parity.test.cjs
|
||||
```
|
||||
|
||||
Assert that the CJS Adapter and the SDK source-of-truth agree on every field that matters (key sets, defaults, schema shape). This test catches generator bugs that the freshness check cannot.
|
||||
|
||||
**Step 5 — Wire CI**
|
||||
|
||||
Add a step in `.github/workflows/test.yml` after the existing freshness-check block (before "Run tests with coverage"), gated on `matrix.os == 'ubuntu-latest' && matrix.node-version == 24`:
|
||||
|
||||
```yaml
|
||||
- name: SDK generated <module> artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-<module>-fresh.mjs
|
||||
```
|
||||
|
||||
**Step 6 — Run inventory regen**
|
||||
|
||||
If the module affects `CONTEXT.md`'s module inventory, update that section. Also update `scripts/shared-module-handsync-allowlist.json`: move any matching entry from `migrateMeBacklog` to `cooperatingSiblings` (or remove it entirely if the CJS hand-copy is now deleted).
|
||||
|
||||
**Step 7 — Update CODEOWNERS**
|
||||
|
||||
Add the new source-of-truth path to `.github/CODEOWNERS` under the Phase 6 block to make the architectural ownership explicit.
|
||||
|
||||
**Reference:** Phase 1 PR [#3531](https://github.com/gsd-build/get-shit-done/pull/3531) — `state-document` migration.
|
||||
|
||||
---
|
||||
|
||||
## Guide: Adding a new canonical command
|
||||
|
||||
Use this when adding a new `gsd-sdk query <family>.<subcommand>` that should be handled natively in the SDK (not delegated to CJS). Phase 5.1's `state.update` migration (PR [#3574](https://github.com/gsd-build/get-shit-done/pull/3574)) is the worked example.
|
||||
|
||||
**Step 1 — Declare in the command manifest**
|
||||
|
||||
Add the command definition to `sdk/src/query/command-manifest.<family>.ts`. Include the full argument schema and a `handler` reference.
|
||||
|
||||
**Step 2 — Implement the SDK handler**
|
||||
|
||||
Write the handler in `sdk/src/query/<subcommand>.ts` (or inline in the manifest file for simple cases). The handler receives validated args and the runtime context; it must not shell out to CJS.
|
||||
|
||||
**Step 3 — Add CJS router delegate (Phase 5.1+ pattern)**
|
||||
|
||||
In the family's CJS command router (e.g. `get-shit-done/bin/lib/state-command-router.cjs`), add a delegate case that calls `executeForCjs(subcommand, args)` from `cjs-command-router-adapter.cjs`. This ensures the CJS binary dispatches to the SDK native handler rather than re-implementing the logic.
|
||||
|
||||
**Step 4 — Add a golden parity test**
|
||||
|
||||
Add a test in `tests/<family>-command-router.test.cjs` (or a new file if the family has no test yet) that:
|
||||
1. Invokes the command via the SDK query path.
|
||||
2. Invokes the command via the CJS router path.
|
||||
3. Asserts both produce identical output.
|
||||
|
||||
This test enforces that the delegate and the native handler stay aligned.
|
||||
|
||||
**Reference:** Phase 5.1 PR [#3574](https://github.com/gsd-build/get-shit-done/pull/3574) — `state.update` delegation.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 Final Completion Summary
|
||||
|
||||
Phase 6 (issue #3575, PR #3577) is feature-complete. The migration is done.
|
||||
|
||||
**What shipped in Phase 6:**
|
||||
|
||||
- **Shared Modules migrated (5 total in Phase 6):** `plan-scan`, `secrets`, `schema-detect`, `decisions`, `workstream-name-policy`. Each follows the full pattern: SDK source-of-truth, generator (`gen-<name>.mjs`), freshness check (`check-<name>-fresh.mjs`), generated CJS artifact (`<name>.generated.cjs`), CJS shim re-export, parity test, CI step, pre-commit hook, CODEOWNERS entry.
|
||||
- **Workstream native support:** The sync bridge worker now correctly threads `workstream` through to `registry.dispatch()`. `GSDTransport` no longer forces subprocess for workstream-scoped requests. Workstream-scoped state commands execute natively.
|
||||
- **State parity divergences resolved:** `state.record-metric` and `state.prune` SDK handlers now match CJS semantics exactly.
|
||||
- **MIGRATE_ME pairs resolved:** `decisions` and `workstream-name-policy` migrated from `migrateMeBacklog` to `cooperatingSiblings` as ADAPTER-OVER-MODULE.
|
||||
- **Lint final state:** 22 cooperating siblings, 0 backlog pairs.
|
||||
|
||||
**Decisions migration specifics (B1):**
|
||||
- SDK `decisions.ts` regex aligned to CJS: `D-([A-Za-z0-9_-]+)` (alphanumeric IDs like `D-INFRA-01` accepted).
|
||||
- SDK returns richer `{id, text, category, tags, trackable}`; CJS callers using only `{id, text}` safely ignore extras.
|
||||
- Parity test: `tests/decisions-generator.test.cjs` (15 tests covering numeric IDs, alphanumeric IDs, richer schema fields).
|
||||
|
||||
**Workstream-name-policy migration specifics (B2):**
|
||||
- Added `hasInvalidPathSegment` and `isValidActiveWorkstreamName` to SDK `workstream-name-policy.ts`.
|
||||
- `validateWorkstreamName` is now an alias for `isValidActiveWorkstreamName` (consistent with CJS semantics).
|
||||
- Parity test: `tests/workstream-name-policy-generator.test.cjs` (19 tests covering all four exports).
|
||||
|
||||
---
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
No migration items remain. The following are future quality candidates, not defects:
|
||||
|
||||
- **`config.cjs` / `sdk/src/config.ts`** — These files are CJS-CLI-ONLY (per allowlist classification). The `config.cjs` file contains only CLI command handlers that use sync CJS APIs; `sdk/src/config.ts` provides the async SDK layer. They serve disjoint surfaces. A future migration would require converting the CLI handlers to async + SDK patterns, which is a larger refactor out of scope for this migration cycle.
|
||||
- **`intel.cjs` / `sdk/src/query/intel.ts`** — Intentional architectural divergence (different file naming conventions between CJS and SDK; documented in allowlist). A future migration would require reconciling INTEL_FILES naming, which is a breaking change for existing consumers.
|
||||
- **`model-catalog.cjs` / `sdk/src/model-catalog.ts`** — Both sides read from `sdk/shared/model-catalog.json` independently (ADAPTER-OVER-MODULE pattern). This is intentional; the shared JSON is the source-of-truth. No duplication of logic between CJS and SDK consumers.
|
||||
244
docs/discussions/grok-build-support-2026-05.md
Normal file
244
docs/discussions/grok-build-support-2026-05.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Grok Build + GSD Compatibility & Local Multi-Runtime Sync (May 2026)
|
||||
|
||||
**Date:** 2026-05-16
|
||||
**Status:** Discussion active on closed issue. Awaiting maintainer response.
|
||||
**Purpose of this document:** Serve as the primary context file for future Grok (or other) agent sessions started inside this repository (`/home/cristian/bum/get-shit-done`) so they can work on local Grok Build support and improved synchronization across multiple AI coding harnesses.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary & Goals
|
||||
|
||||
**Goal:** Achieve reliable, first-class GSD support when using **Grok Build**, while maintaining excellent compatibility and low-friction synchronization across the four runtimes the author uses daily:
|
||||
|
||||
- Grok Build (current primary TUI)
|
||||
- Claude Code
|
||||
- Gemini CLI
|
||||
- Codex
|
||||
|
||||
Currently, Grok Build is only supported via its Claude compatibility layer. This creates daily friction in paths, skill discovery, command surfaces, hooks, `grok inspect` output, and mental models.
|
||||
|
||||
**Long-term vision:**
|
||||
- Run GSD natively and cleanly inside Grok Build.
|
||||
- Maintain a single source of truth in this repository.
|
||||
- Have a robust, automated (or semi-automated) sync mechanism that deploys adapted skills/agents/hooks to all four runtime environments (`~/.agents/`, `~/.claude/`, `~/.grok/`, Gemini location, Codex location).
|
||||
- Keep the work clean enough that high-quality pieces can eventually be contributed upstream.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current Multi-Runtime Setup (as of May 2026)
|
||||
|
||||
### Development Source (Single Source of Truth)
|
||||
- **Path:** `/home/cristian/bum/get-shit-done` (this repo — your working fork of `gsd-build/get-shit-done`)
|
||||
|
||||
### Installed Locations
|
||||
- `~/.agents/get-shit-done/` — Core workflows, references, templates, `gsd-tools.cjs`, `bin/`
|
||||
- `~/.agents/skills/gsd-*` — ~125 skills (heavily GSD + many large reference skills like `userinterface-wiki`, `react-best-practices`, etc.)
|
||||
- `~/.agents/agents/` — 22 GSD sub-agents (with `.md` + `.toml`)
|
||||
- `~/.claude/skills/gsd-*` + `~/.claude/get-shit-done/` + `~/.claude/agents/` — Parallel Claude Code install (~208 skills total)
|
||||
- `~/.grok/skills/` — Mostly empty (only the 7 official bundled Grok skills)
|
||||
- `~/.grok/` — Not yet properly used by GSD
|
||||
|
||||
### Existing Sync Tooling
|
||||
- `gsd-sync-skills` skill exists in `~/.agents/skills/gsd-sync-skills/`
|
||||
- Its stated purpose: "Sync managed GSD skills across runtime roots so multi-runtime users stay aligned after an update"
|
||||
- Currently uses a combination of manual processes + this skill.
|
||||
|
||||
### Codex-Style Adaptations Already in Use
|
||||
- Many `gsd-*` skills in `~/.agents/skills/` contain a `<codex_skill_adapter>` section at the top.
|
||||
- This adapter translates Claude Code patterns (`AskUserQuestion`, `Task()`) into Codex/Grok-compatible ones (`request_user_input`, `spawn_agent`).
|
||||
- This pattern was developed because Grok Build / Codex use a different skill invocation and subagent model than Claude Code.
|
||||
|
||||
---
|
||||
|
||||
## 3. History & Prior Art
|
||||
|
||||
### Previous Upstream Attempt (May 2026)
|
||||
- **Issue #3603**: "Add Grok Build (`--grok`) as a first-class runtime"
|
||||
- **PR #3604** (by `lordgraysith`): Very large, high-quality implementation attempt.
|
||||
|
||||
The PR included:
|
||||
- Full `--grok` installer support
|
||||
- Conversion functions (`convertClaudeToGrokMarkdown`, `convertClaudeCommandToGrokSkill`, `convertClaudeAgentToGrokAgent`)
|
||||
- JSON hook manifest generation for Grok
|
||||
- Model catalog entries for Grok models
|
||||
- `GROK_CONFIG_DIR` support
|
||||
- Extensive tests and documentation
|
||||
- A complete planning folder: `docs/grok-build-support/` containing 7 detailed documents:
|
||||
- 01-runtime-detection-and-config.md
|
||||
- 02-installer-logic.md
|
||||
- 03-model-catalog-and-profiles.md
|
||||
- 04-skills-agents-hooks-conversion.md
|
||||
- 05-documentation-and-user-experience.md
|
||||
- 06-testing-strategy.md
|
||||
- 07-phased-implementation-and-rollout.md
|
||||
|
||||
**Outcome:** Both issue and PR were closed the next day by `trek-e` as `not_planned`.
|
||||
|
||||
### Maintainer’s Position (Critical Context)
|
||||
From `trek-e`’s detailed closing comment on #3603:
|
||||
|
||||
- **Biggest blockers right now:**
|
||||
- Grok Build is extremely new (launched May 14, 2026) and still in early beta. Hook events and config surface are unstable.
|
||||
- Strong desire to protect GSD’s reputation for stability ("vibe coded piece of junk" reputation risk).
|
||||
- Recent painful experience with Codex making breaking changes shortly after GSD stabilized something.
|
||||
- **Price was clarified as low priority** ("honestly price is probably the lowest of the factors").
|
||||
- **Claude-compat already works**, so native support is seen as polish rather than a must-have.
|
||||
- **Process concern:** Implementation was done before the issue was properly approved.
|
||||
- **Recommendation:** Revisit once Grok Build has stabilized (versioned config surface held for at least one minor release) **and** is reachable on a more realistic solo-dev price tier.
|
||||
|
||||
**Important:** The 7 planning documents in `docs/grok-build-support/` remain excellent reference material even if the code from that PR is not merged.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended Work Plan for Future Grok Sessions in This Repo
|
||||
|
||||
When a new Grok session is started inside `/home/cristian/bum/get-shit-done`, it should follow something like this phased approach:
|
||||
|
||||
### Phase 0 — Orientation (Current Session Context)
|
||||
- Read this document fully.
|
||||
- Read the 7 files in `docs/grok-build-support/`.
|
||||
- Explore the current local multi-runtime setup (see Section 2 above).
|
||||
- Understand the existing `gsd-sync-skills` skill and any Codex adapters already present.
|
||||
|
||||
### Phase 1 — Audit Current State
|
||||
- Map exactly what is installed where across `~/.agents/`, `~/.claude/`, `~/.grok/`, and Gemini/Codex locations.
|
||||
- Identify duplication, drift, and friction points when using GSD in Grok Build today.
|
||||
- Run `grok inspect` and analyze what it shows for GSD skills.
|
||||
- Document gaps specific to Grok Build (command surface, hooks, `grok inspect` cleanliness, agent spawning, etc.).
|
||||
|
||||
### Phase 2 — Study Prior Art
|
||||
- Deeply study the conversion specifications in `docs/grok-build-support/04-skills-agents-hooks-conversion.md`.
|
||||
- Understand what a proper Grok `SKILL.md` should look like (frontmatter, description style, runtime hints).
|
||||
- Understand Grok hook JSON manifest requirements.
|
||||
- Review how the previous PR handled model catalog and runtime homes.
|
||||
- Look for any existing local experiments or partial adapters in this fork.
|
||||
|
||||
### Phase 3 — Design Local Grok Adapter (MVP)
|
||||
Design a practical local solution that works for **this user’s four-runtime reality**, not necessarily a full upstream `--grok` installer yet.
|
||||
|
||||
Possible components:
|
||||
- A local Grok conversion layer (or extension of existing Codex adapters).
|
||||
- Proper `gsd-*` skills under `~/.grok/skills/` with correct Grok frontmatter + `codex_skill_adapter` sections where needed.
|
||||
- Grok-compatible agent definitions (`.md` + any required TOML/config).
|
||||
- JSON hook manifests in `~/.grok/hooks/`.
|
||||
- Updates to the sync mechanism (`gsd-sync-skills` or a new `gsd-multi-runtime-sync` tool) so one source can deploy cleanly to all four targets.
|
||||
|
||||
**Key principle:** Prefer extending/improving the existing sync tooling rather than creating yet another parallel install path.
|
||||
|
||||
### Phase 4 — Implementation & Testing
|
||||
- Implement the MVP Grok adapter in this local fork.
|
||||
- Create or enhance sync logic.
|
||||
- Test end-to-end inside an actual Grok Build session:
|
||||
- `grok inspect` cleanliness
|
||||
- Command discovery (`/gsd-*` or Grok-native form)
|
||||
- Agent spawning
|
||||
- Hook firing
|
||||
- Full `gsd-new-project` → `gsd-progress` → `gsd-execute-phase` flow
|
||||
- Verify no regression in Claude / Gemini / Codex usage.
|
||||
|
||||
### Phase 5 — Documentation & Future Upstream Path
|
||||
- Update this discussion note and any relevant docs in the repo.
|
||||
- Document the local sync architecture clearly.
|
||||
- Identify which pieces of the local solution would be good candidates for upstream contribution later (when Grok Build is more mature).
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Files & Areas to Study
|
||||
|
||||
**In this repo:**
|
||||
- `docs/grok-build-support/` (all 7 documents — highest priority)
|
||||
- `bin/install.js` (installer logic, especially runtime handling and conversion functions)
|
||||
- `get-shit-done/bin/lib/runtime-homes.cjs`
|
||||
- `get-shit-done/bin/lib/shell-command-projection.cjs` (hook projection)
|
||||
- `sdk/shared/model-catalog.json`
|
||||
- Existing `gsd-sync-skills` skill (in `~/.agents/skills/gsd-sync-skills/`)
|
||||
- Any skills that already contain `<codex_skill_adapter>` sections (study the pattern)
|
||||
|
||||
**External / Prior Art:**
|
||||
- The original PR #3604 (study the actual conversion code if accessible via the author’s fork)
|
||||
- Grok Build documentation on skill format, agent format, and hook JSON manifests (as of the session date)
|
||||
|
||||
---
|
||||
|
||||
## 6. How to Test Grok Build Compatibility Locally
|
||||
|
||||
Useful commands and checks when working on this:
|
||||
|
||||
- `grok inspect` (and `grok inspect --json`) — check skill discovery, sources, and token counts.
|
||||
- `grok` TUI inside a real project that uses GSD.
|
||||
- Full workflow test: `/gsd-progress`, `/gsd-discuss-phase`, `/gsd-plan-phase`, `/gsd-execute-phase`, etc.
|
||||
- Verify hooks fire correctly via Grok’s JSON hook system.
|
||||
- Check that subagents (the 22 GSD agents in `~/.agents/agents/`) can be spawned from Grok.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sync Strategy Principles (for Multi-Runtime)
|
||||
|
||||
When designing improvements to sync:
|
||||
|
||||
- Single source of truth = this repository (`/home/cristian/bum/get-shit-done`).
|
||||
- Runtime-specific transformations should be as declarative and maintainable as possible.
|
||||
- The `<codex_skill_adapter>` pattern is already proven for Grok/Codex — extend it rather than reinvent.
|
||||
- Prefer generating the runtime-specific artifacts during sync rather than maintaining four separate copies.
|
||||
- Make it easy to add a fifth runtime later if needed.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open Questions & Decisions to Make (for Future Sessions)
|
||||
|
||||
- Should we aim for a full local `--grok` installer equivalent, or just excellent skill/agent/hook generation + sync?
|
||||
- How much of the previous PR’s conversion logic can/should be reused locally?
|
||||
- What is the right balance between “make Grok work great for me now” vs “keep it clean for potential upstream contribution”?
|
||||
- Should the sync tool become a first-class GSD skill (`gsd-multi-runtime-sync` or similar)?
|
||||
- How do we handle model profiles and agent routing differences for Grok models?
|
||||
|
||||
---
|
||||
|
||||
## 9. How to Resume This Work
|
||||
|
||||
When starting a new Grok session in this repository, begin by reading:
|
||||
|
||||
1. This file: `docs/discussions/grok-build-support-2026-05.md`
|
||||
2. All files in `docs/grok-build-support/`
|
||||
3. The existing `gsd-sync-skills` skill
|
||||
|
||||
Then follow the phased plan in Section 4.
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** 2026-05-16 (by Grok, in this session)
|
||||
|
||||
---
|
||||
|
||||
## 10. Progress — May 2026 Session (Current)
|
||||
|
||||
### Audit Findings (Phase 1)
|
||||
- **Version drift confirmed**: `~/.agents/get-shit-done/` (Grok Build primary) was on 1.38.4; `~/.claude/` on 1.42.2; `~/.codex/` and `~/.gemini/` on 1.41.2.
|
||||
- `~/.agents/hooks/` was empty (no hooks active for Grok Build sessions).
|
||||
- `grok inspect` successfully discovers 80+ `gsd-*` skills via the `~/.agents/skills/` layout + the existing `<codex_skill_adapter>` blocks.
|
||||
- No `grok` or `agents` runtime existed in installer or sync logic.
|
||||
- `~/.grok/` itself contains only the 7 official bundled skills; GSD lives entirely in the shared `~/.agents/` layout.
|
||||
|
||||
### Immediate Actions Taken
|
||||
- **Engine drift fixed ASAP**: Backed up old `~/.agents/get-shit-done/` to `.backup-1.38.4/`, then rsynced the current source `get-shit-done/` tree into `~/.agents/get-shit-done/`. Now running the latest from this repo (v1.50.0-canary.0). New modules (active-workstream-store, adr-parser, etc.) and updated workflows are live for Grok Build sessions.
|
||||
- **First-class 'grok' runtime added** (pragmatic choice: maps to `~/.agents/`):
|
||||
- [get-shit-done/bin/lib/runtime-homes.cjs](/home/cristian/bum/get-shit-done/get-shit-done/bin/lib/runtime-homes.cjs): Added `grok` case (honors `GROK_AGENTS_HOME` env, defaults to `~/.agents`).
|
||||
- [bin/install.js](/home/cristian/bum/get-shit-done/bin/install.js): Added `--grok` flag, `hasGrok`, `getDirName('grok') → '.agents'`, `getGlobalDir('grok')`, `getConfigDirFromHome`, inclusion in `--all` and help text. Reuses existing Codex conversion logic (skill adapters + agent .toml generation) because Grok Build uses the same invocation model.
|
||||
- [get-shit-done/workflows/sync-skills.md](/home/cristian/bum/get-shit-done/get-shit-done/workflows/sync-skills.md): Added `grok` to supported runtimes and the `--to all` list.
|
||||
- Verified: `node bin/install.js --skills-root grok` correctly returns `~/.agents/skills`.
|
||||
|
||||
### Next Steps (for follow-up sessions)
|
||||
- Full `gsd install --grok --global` end-to-end (hook projection, agent .toml generation with correct sandbox, skill wrapping with adapters, statusline, etc.). Currently the flag is recognized but some codex-specific install branches may need `|| runtime === 'grok'`.
|
||||
- Run `gsd update --sync --from claude --to grok --apply` (or `--from grok --to claude`) once the runtime is fully wired, to keep the 4 harnesses in sync without manual rsync.
|
||||
- Slim the `<codex_skill_adapter>` blocks (currently ~60 lines inlined in every gsd-* SKILL.md). Options: extract detailed mapping to a shared `@reference/codex-skill-adapter.md` that skills include, or make the adapter header shorter/optional for lower `grok inspect` token cost.
|
||||
- Investigate Grok Build native hook support (JSON manifests under `~/.grok/hooks/` vs the shell hooks in `~/.agents/hooks/`).
|
||||
- Update `grok inspect` output cleanliness (remove "unknown tool prefix: Skill(gsd:*)" warnings if possible via settings or skill manifest).
|
||||
- Consider whether to also populate a native `~/.grok/skills/gsd-*` tree in addition to the working `.agents` layout.
|
||||
|
||||
This session delivered working `grok` runtime resolution + immediate version parity for the user's primary Grok Build harness.
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** 2026-05-16 (by Grok, in this session)
|
||||
|
||||
This document is intended to be living. Update it as the local Grok Build work progresses.
|
||||
@@ -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.
|
||||
|
||||
136
get-shit-done/bin/lib/cjs-sdk-bridge.cjs
Normal file
136
get-shit-done/bin/lib/cjs-sdk-bridge.cjs
Normal file
@@ -0,0 +1,136 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* CJS↔SDK Sync Runtime Bridge Adapter — Phase 5/6 of #3524.
|
||||
*
|
||||
* Single shared loader for the synchronous SDK runtime bridge that every CJS
|
||||
* command-router family file and `gsd-tools.cjs` non-family dispatcher
|
||||
* delegates through. Centralizing the load prevents the seven-fold duplicated
|
||||
* `tryLoadSdk` blocks that existed across the routers from drifting against
|
||||
* each other (the exact anti-pattern the Phase 6 hand-sync lint is meant to
|
||||
* stop, applied to the SDK-load logic itself).
|
||||
*
|
||||
* Load path policy: the bridge resolves the bundled SDK by package-relative
|
||||
* filesystem path, NOT by the `@gsd-build/sdk` package name. The package name
|
||||
* is not installed in the root `node_modules` (it lives as a sibling workspace
|
||||
* package, not a dependency), and the SDK's public entry doesn't re-export
|
||||
* `executeForCjs` or `formatStateLoadRawStdout` anyway. Using the relative
|
||||
* path means the loader works identically in (a) the development checkout
|
||||
* (`<repo>/sdk/dist/...`) and (b) the published package layout
|
||||
* (`node_modules/get-shit-done-cc/sdk/dist/...`) because the `files` array in
|
||||
* `package.json` keeps `sdk/dist` at the same path inside the published
|
||||
* tarball.
|
||||
*
|
||||
* The previous implementation used `require('@gsd-build/sdk')`, which always
|
||||
* failed because the package was unresolvable from the consumer location.
|
||||
* That cached `_loadFailed = true` for the lifetime of the process and made
|
||||
* every router silently fall through to CJS — defeating Phase 5/6's entire
|
||||
* goal. The integration test at `tests/cjs-sdk-bridge-integration.test.cjs`
|
||||
* locks the load-success invariant so this regression cannot recur.
|
||||
*
|
||||
* Usage:
|
||||
* const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
|
||||
* if (tryLoadSdk()) {
|
||||
* const result = getExecuteForCjs()({ ... });
|
||||
* }
|
||||
*
|
||||
* Plus `getFormatStateLoadRawStdout()` for the `state load --raw` adapter and
|
||||
* `getSdkModule()` for routers that need the raw runtime-bridge-sync module.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Computed once at module load. Resolves the bundled SDK relative to this
|
||||
// file's on-disk location, so both dev and post-install layouts work.
|
||||
// <root>/get-shit-done/bin/lib/cjs-sdk-bridge.cjs
|
||||
// <root>/sdk/dist/runtime-bridge-sync/index.js
|
||||
// <root>/sdk/dist/query/state-project-load.js
|
||||
const RUNTIME_BRIDGE_PATH = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'sdk',
|
||||
'dist',
|
||||
'runtime-bridge-sync',
|
||||
'index.js',
|
||||
);
|
||||
const STATE_PROJECT_LOAD_PATH = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'sdk',
|
||||
'dist',
|
||||
'query',
|
||||
'state-project-load.js',
|
||||
);
|
||||
|
||||
let _runtimeBridge = null;
|
||||
let _formatStateLoadRawStdout = null;
|
||||
let _loadFailed = false;
|
||||
|
||||
/**
|
||||
* Load the bundled SDK runtime bridge once and cache the result. Returns true
|
||||
* on success, false if the dist artifacts are missing (e.g. `npm run
|
||||
* build:sdk` has not been executed in a fresh dev checkout) or if the
|
||||
* expected `executeForCjs` export is absent. Cached result is reused on
|
||||
* subsequent calls.
|
||||
*/
|
||||
function tryLoadSdk() {
|
||||
if (_runtimeBridge) return true;
|
||||
if (_loadFailed) return false;
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const bridge = require(RUNTIME_BRIDGE_PATH);
|
||||
if (typeof bridge.executeForCjs !== 'function') {
|
||||
_loadFailed = true;
|
||||
return false;
|
||||
}
|
||||
// eslint-disable-next-line global-require
|
||||
const stateProjectLoad = require(STATE_PROJECT_LOAD_PATH);
|
||||
if (typeof stateProjectLoad.formatStateLoadRawStdout !== 'function') {
|
||||
_loadFailed = true;
|
||||
return false;
|
||||
}
|
||||
_runtimeBridge = bridge;
|
||||
_formatStateLoadRawStdout = stateProjectLoad.formatStateLoadRawStdout;
|
||||
return true;
|
||||
} catch {
|
||||
_loadFailed = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached `executeForCjs` function, or null if `tryLoadSdk()` has
|
||||
* not been called or returned false. Callers must check `tryLoadSdk()` first.
|
||||
*/
|
||||
function getExecuteForCjs() {
|
||||
return _runtimeBridge ? _runtimeBridge.executeForCjs : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached `formatStateLoadRawStdout` function, or null. Used by
|
||||
* the state command router for the `state load --raw` adapter that projects
|
||||
* SDK return data into the legacy key=value lines format.
|
||||
*/
|
||||
function getFormatStateLoadRawStdout() {
|
||||
return _formatStateLoadRawStdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached runtime-bridge-sync module object after a successful
|
||||
* `tryLoadSdk()`, or null. Provided for callers that need additional named
|
||||
* exports beyond `executeForCjs`.
|
||||
*/
|
||||
function getSdkModule() {
|
||||
return _runtimeBridge;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
tryLoadSdk,
|
||||
getExecuteForCjs,
|
||||
getFormatStateLoadRawStdout,
|
||||
getSdkModule,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1012,6 +1012,7 @@ function cmdCheckCommit(cwd, raw) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
determinePhaseStatus,
|
||||
cmdGenerateSlug,
|
||||
cmdCurrentTimestamp,
|
||||
cmdListTodos,
|
||||
|
||||
@@ -1,48 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared parser for CONTEXT.md `<decisions>` blocks.
|
||||
* Decisions Module — CJS adapter.
|
||||
*
|
||||
* Used by:
|
||||
* - gap-checker.cjs (#2493 post-planning gap analysis)
|
||||
* - intended for #2492 (plan-phase decision gate, verify-phase decision validator)
|
||||
* The implementation is generated from sdk/src/query/decisions.ts and
|
||||
* lives in decisions.generated.cjs. This file is a thin re-export so
|
||||
* that existing call sites (gap-checker.cjs, tests) can continue to
|
||||
* require('./decisions') unchanged.
|
||||
*
|
||||
* Format produced by discuss-phase.md:
|
||||
* Exports (from generated file):
|
||||
* - parseDecisions(content) — parse <decisions> blocks, returns {id, text, category, tags, trackable}[]
|
||||
* CJS callers using only {id, text} safely ignore the extra fields.
|
||||
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
|
||||
*
|
||||
* <decisions>
|
||||
* ## Implementation Decisions
|
||||
*
|
||||
* ### Category
|
||||
* - **D-01:** Decision text
|
||||
* - **D-02:** Another decision
|
||||
* </decisions>
|
||||
*
|
||||
* D-IDs outside the <decisions> block are ignored. Missing block returns [].
|
||||
* Regenerate: cd sdk && npm run gen:decisions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse the <decisions> section of a CONTEXT.md string.
|
||||
*
|
||||
* @param {string|null|undefined} contextMd - File contents, may be empty/missing.
|
||||
* @returns {Array<{id: string, text: string}>}
|
||||
*/
|
||||
function parseDecisions(contextMd) {
|
||||
if (!contextMd || typeof contextMd !== 'string') return [];
|
||||
const blockMatch = contextMd.match(/<decisions>([\s\S]*?)<\/decisions>/);
|
||||
if (!blockMatch) return [];
|
||||
const block = blockMatch[1];
|
||||
|
||||
const decisionRe = /^\s*-\s*\*\*(D-[A-Za-z0-9_-]+):\*\*\s*(.+?)\s*$/gm;
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
let m;
|
||||
while ((m = decisionRe.exec(block)) !== null) {
|
||||
const id = m[1];
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push({ id, text: m[2] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { parseDecisions };
|
||||
module.exports = require('./decisions.generated.cjs');
|
||||
|
||||
121
get-shit-done/bin/lib/decisions.generated.cjs
Normal file
121
get-shit-done/bin/lib/decisions.generated.cjs
Normal file
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/decisions.ts
|
||||
* Regenerate: cd sdk && npm run gen:decisions
|
||||
*
|
||||
* Shared parser for CONTEXT.md <decisions> blocks.
|
||||
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
|
||||
* Returns {id, text, category, tags, trackable} per decision.
|
||||
* CJS callers that only use {id, text} safely ignore the extra fields.
|
||||
*/
|
||||
|
||||
const DISCRETION_HEADINGS = new Set([
|
||||
"claude's discretion",
|
||||
'claudes discretion',
|
||||
'claude discretion',
|
||||
]);
|
||||
const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
|
||||
/**
|
||||
* Strip fenced code blocks from `content` so example `<decisions>` snippets
|
||||
* inside ```` ``` ```` do not pollute the parser (review F11).
|
||||
*/
|
||||
function stripFencedCode(content) {
|
||||
return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' ');
|
||||
}
|
||||
/**
|
||||
* Extract the inner text of EVERY `<decisions>...</decisions>` block in
|
||||
* order, concatenated by `\n\n`. Returns null when no block is present.
|
||||
*
|
||||
* CONTEXT.md may legitimately contain more than one block (for example, a
|
||||
* "current decisions" block plus a "carry-over from prior phase" block);
|
||||
* dropping all-but-the-first silently lost the second batch (review F13).
|
||||
*/
|
||||
function extractDecisionsBlock(content) {
|
||||
const cleaned = stripFencedCode(content);
|
||||
const matches = [...cleaned.matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)];
|
||||
if (matches.length === 0)
|
||||
return null;
|
||||
return matches.map((m) => m[1]).join('\n\n');
|
||||
}
|
||||
/**
|
||||
* Parse trackable decisions from CONTEXT.md content.
|
||||
*
|
||||
* Returns ALL D-NN decisions found inside `<decisions>` (including
|
||||
* non-trackable ones, with `trackable: false`). Callers that only want the
|
||||
* gate-enforced decisions should filter `.filter(d => d.trackable)`.
|
||||
*/
|
||||
function parseDecisions(content) {
|
||||
if (!content || typeof content !== 'string')
|
||||
return [];
|
||||
const block = extractDecisionsBlock(content);
|
||||
if (block === null)
|
||||
return [];
|
||||
const lines = block.split(/\r?\n/);
|
||||
const out = [];
|
||||
let category = '';
|
||||
let inDiscretion = false;
|
||||
// Bullet line: `- **D-NN[ [tags]]:** text`
|
||||
// Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR)
|
||||
// in addition to numeric-only IDs (D-42). The first character after `D-` must
|
||||
// be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
|
||||
// CJS callers consume {id, text} and ignore the optional extras.
|
||||
const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/;
|
||||
let current = null;
|
||||
const flush = () => {
|
||||
if (current) {
|
||||
current.text = current.text.trim();
|
||||
out.push(current);
|
||||
current = null;
|
||||
}
|
||||
};
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Track category headings (`### Heading`)
|
||||
const headingMatch = trimmed.match(/^###\s+(.+?)\s*$/);
|
||||
if (headingMatch) {
|
||||
flush();
|
||||
category = headingMatch[1];
|
||||
// Strip the full unicode-quote family so any rendering of "Claude's
|
||||
// Discretion" (ASCII apostrophe, curly U+2019, U+2018, U+201A, U+201B,
|
||||
// double-quote variants U+201C/D/E/F, etc.) collapses to the same key
|
||||
// (review F20).
|
||||
const normalized = category
|
||||
.toLowerCase()
|
||||
.replace(/[\u2018\u2019\u201A\u201B\u201C\u201D\u201E\u201F'"`]/g, '')
|
||||
.trim();
|
||||
inDiscretion = DISCRETION_HEADINGS.has(normalized);
|
||||
continue;
|
||||
}
|
||||
const bulletMatch = line.match(bulletRe);
|
||||
if (bulletMatch) {
|
||||
flush();
|
||||
const id = `D-${bulletMatch[1]}`;
|
||||
const tags = bulletMatch[2]
|
||||
? bulletMatch[2]
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
|
||||
current = { id, text: bulletMatch[3], category, tags, trackable };
|
||||
continue;
|
||||
}
|
||||
// Continuation line for current decision (indented with space OR tab,
|
||||
// non-bullet, non-empty) — tab indentation must work too (review F12).
|
||||
if (current && trimmed !== '' && !trimmed.startsWith('-') && /^[ \t]/.test(line)) {
|
||||
current.text += ' ' + trimmed;
|
||||
continue;
|
||||
}
|
||||
// Blank line or unrelated content terminates the current decision
|
||||
if (trimmed === '') {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { parseDecisions };
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,138 +1,26 @@
|
||||
'use strict';
|
||||
/**
|
||||
* plan-scan — canonical phase-plan scanner (k014)
|
||||
*
|
||||
* Single source of truth for detecting plan and summary files in a phase
|
||||
* directory, replacing four divergent copies in state.cjs, roadmap.cjs,
|
||||
* init.cjs, and phase.cjs (#3262).
|
||||
*
|
||||
* Layout support:
|
||||
* Flat (pre-#3139): phases/<N>/*-PLAN.md, *-SUMMARY.md
|
||||
* Nested (post-#3139): phases/<N>/plans/PLAN-<NN>-*.md, SUMMARY-<NN>-*.md
|
||||
*
|
||||
* @module plan-scan
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Excluded derivative files — present alongside real plans but must not be
|
||||
// counted. OUTLINE exclusion catches both flat (-PLAN-OUTLINE.md) and nested
|
||||
// (PLAN-NN-OUTLINE.md) forms via a broad -OUTLINE.md$ pattern. The
|
||||
// pre-bounce pattern is intentionally broad (matches any *.pre-bounce.md) so
|
||||
// stale bounce files never inflate plan counts (#3257 regression root cause).
|
||||
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
|
||||
const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
|
||||
|
||||
/**
|
||||
* Determine whether a filename from the flat phase root is a plan file.
|
||||
* Plan Scan Module — CJS adapter.
|
||||
*
|
||||
* Accepts:
|
||||
* - Bare PLAN.md
|
||||
* - Canonical padded 01-01-PLAN.md
|
||||
* - Extended layout 5-PLAN-01-setup.md (the format gsd-plan-phase writes;
|
||||
* looksLikePlanFile in phase.cjs / isPlanFile in roadmap.cjs)
|
||||
* The implementation is generated from sdk/src/query/plan-scan.ts and
|
||||
* lives in plan-scan.generated.cjs. This file is a thin re-export so
|
||||
* that existing call sites (state.cjs, roadmap.cjs, init.cjs,
|
||||
* workstream-inventory.cjs, and tests) can continue to require('./plan-scan')
|
||||
* unchanged.
|
||||
*
|
||||
* Rejects: -PLAN-OUTLINE.md, *.pre-bounce.md
|
||||
*/
|
||||
function isRootPlanFile(f) {
|
||||
if (PLAN_OUTLINE_RE.test(f)) return false;
|
||||
if (PLAN_PRE_BOUNCE_RE.test(f)) return false;
|
||||
// Canonical suffix or bare name
|
||||
if (f.endsWith('-PLAN.md') || f === 'PLAN.md') return true;
|
||||
// Extended layout: any .md that contains PLAN (case-insensitive) in the name
|
||||
return /\.md$/i.test(f) && /PLAN/i.test(f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a filename from the nested plans/ subdir is a plan file.
|
||||
* Exports (from generated file):
|
||||
* - scanPhasePlans(phaseDir) — canonical phase-plan scanner
|
||||
* - isRootPlanFile(fileName) — extended filter including /PLAN/i slug layouts
|
||||
* - isNestedPlanFile(fileName) — nested plans/ subdir filter
|
||||
* - isRootSummaryFile(fileName) — flat summary file filter
|
||||
* - isNestedSummaryFile(fileName) — nested summary file filter
|
||||
*
|
||||
* Nested layout names: PLAN-NN-slug.md or N-PLAN-NN-slug.md.
|
||||
* Excludes OUTLINE and pre-bounce suffixes.
|
||||
*/
|
||||
function isNestedPlanFile(f) {
|
||||
if (PLAN_OUTLINE_RE.test(f)) return false;
|
||||
if (PLAN_PRE_BOUNCE_RE.test(f)) return false;
|
||||
return /^PLAN-\d+.*\.md$/i.test(f) || /-PLAN-\d+.*\.md$/i.test(f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a filename from the flat phase root is a summary file.
|
||||
*/
|
||||
function isRootSummaryFile(f) {
|
||||
return f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a filename from the nested plans/ subdir is a summary.
|
||||
*/
|
||||
function isNestedSummaryFile(f) {
|
||||
return /^SUMMARY-\d+.*\.md$/i.test(f) || /-SUMMARY-\d+.*\.md$/i.test(f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single phase directory for plan and summary files.
|
||||
* The isRootPlanFile helper uses /PLAN/i to match the extended slug layout
|
||||
* (e.g. 5-PLAN-01-setup-database.md) in addition to bare and canonical forms.
|
||||
* This was the fix for bug #3128 (roadmap.cjs plan-count regression).
|
||||
*
|
||||
* @param {string} phaseDir — absolute path to the phase directory
|
||||
* @returns {{
|
||||
* planCount: number,
|
||||
* summaryCount: number,
|
||||
* completed: boolean,
|
||||
* hasNestedPlans: boolean,
|
||||
* planFiles: string[],
|
||||
* summaryFiles: string[],
|
||||
* }}
|
||||
* Regenerate: cd sdk && npm run gen:plan-scan
|
||||
*/
|
||||
function scanPhasePlans(phaseDir) {
|
||||
let rootFiles;
|
||||
try {
|
||||
rootFiles = fs.readdirSync(phaseDir);
|
||||
} catch {
|
||||
return {
|
||||
planCount: 0,
|
||||
summaryCount: 0,
|
||||
completed: false,
|
||||
hasNestedPlans: false,
|
||||
planFiles: [],
|
||||
summaryFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
const rootPlanFiles = rootFiles.filter(isRootPlanFile);
|
||||
const rootSummaryFiles = rootFiles.filter(isRootSummaryFile);
|
||||
|
||||
let nestedPlanFiles = [];
|
||||
let nestedSummaryFiles = [];
|
||||
let hasNestedPlans = false;
|
||||
|
||||
const nestedDir = path.join(phaseDir, 'plans');
|
||||
if (fs.existsSync(nestedDir)) {
|
||||
try {
|
||||
const nested = fs.readdirSync(nestedDir);
|
||||
nestedPlanFiles = nested.filter(isNestedPlanFile);
|
||||
nestedSummaryFiles = nested.filter(isNestedSummaryFile);
|
||||
hasNestedPlans = nestedPlanFiles.length > 0;
|
||||
} catch { /* ignore if plans/ is not a readable directory */ }
|
||||
}
|
||||
|
||||
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
|
||||
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
|
||||
const planCount = planFiles.length;
|
||||
const summaryCount = summaryFiles.length;
|
||||
|
||||
return {
|
||||
planCount,
|
||||
summaryCount,
|
||||
completed: planCount > 0 && summaryCount >= planCount,
|
||||
hasNestedPlans,
|
||||
planFiles,
|
||||
summaryFiles,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = scanPhasePlans;
|
||||
module.exports.scanPhasePlans = scanPhasePlans;
|
||||
module.exports.isRootPlanFile = isRootPlanFile;
|
||||
module.exports.isNestedPlanFile = isNestedPlanFile;
|
||||
module.exports.isRootSummaryFile = isRootSummaryFile;
|
||||
module.exports.isNestedSummaryFile = isNestedSummaryFile;
|
||||
module.exports = require('./plan-scan.generated.cjs');
|
||||
|
||||
97
get-shit-done/bin/lib/plan-scan.generated.cjs
Normal file
97
get-shit-done/bin/lib/plan-scan.generated.cjs
Normal file
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/plan-scan.ts
|
||||
* Regenerate: cd sdk && npm run gen:plan-scan
|
||||
*
|
||||
* Plan Scan Module — detects plan and summary files in a phase directory.
|
||||
* Supports both flat (pre-#3139) and nested (post-#3139) layouts.
|
||||
*/
|
||||
|
||||
const { existsSync, readdirSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
|
||||
// Excluded derivative files
|
||||
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
|
||||
const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
|
||||
|
||||
function isRootPlanFile(fileName) {
|
||||
if (PLAN_OUTLINE_RE.test(fileName))
|
||||
return false;
|
||||
if (PLAN_PRE_BOUNCE_RE.test(fileName))
|
||||
return false;
|
||||
if (fileName.endsWith('-PLAN.md') || fileName === 'PLAN.md')
|
||||
return true;
|
||||
return /\.md$/i.test(fileName) && /PLAN/i.test(fileName);
|
||||
}
|
||||
|
||||
function isNestedPlanFile(fileName) {
|
||||
if (PLAN_OUTLINE_RE.test(fileName))
|
||||
return false;
|
||||
if (PLAN_PRE_BOUNCE_RE.test(fileName))
|
||||
return false;
|
||||
return /^PLAN-\d+.*\.md$/i.test(fileName) || /-PLAN-\d+.*\.md$/i.test(fileName);
|
||||
}
|
||||
|
||||
function isRootSummaryFile(fileName) {
|
||||
return fileName.endsWith('-SUMMARY.md') || fileName === 'SUMMARY.md';
|
||||
}
|
||||
|
||||
function isNestedSummaryFile(fileName) {
|
||||
return /^SUMMARY-\d+.*\.md$/i.test(fileName) || /-SUMMARY-\d+.*\.md$/i.test(fileName);
|
||||
}
|
||||
|
||||
function scanPhasePlans(phaseDir) {
|
||||
let rootFiles;
|
||||
try {
|
||||
rootFiles = readdirSync(phaseDir);
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
planCount: 0,
|
||||
summaryCount: 0,
|
||||
completed: false,
|
||||
hasNestedPlans: false,
|
||||
planFiles: [],
|
||||
summaryFiles: [],
|
||||
};
|
||||
}
|
||||
const rootPlanFiles = rootFiles.filter(isRootPlanFile);
|
||||
const rootSummaryFiles = rootFiles.filter(isRootSummaryFile);
|
||||
let nestedPlanFiles = [];
|
||||
let nestedSummaryFiles = [];
|
||||
let hasNestedPlans = false;
|
||||
const nestedDir = join(phaseDir, 'plans');
|
||||
if (existsSync(nestedDir)) {
|
||||
try {
|
||||
const nestedFiles = readdirSync(nestedDir);
|
||||
nestedPlanFiles = nestedFiles.filter(isNestedPlanFile);
|
||||
nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile);
|
||||
hasNestedPlans = nestedPlanFiles.length > 0;
|
||||
}
|
||||
catch { /* ignore unreadable nested layout */ }
|
||||
}
|
||||
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
|
||||
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
|
||||
const planCount = planFiles.length;
|
||||
const summaryCount = summaryFiles.length;
|
||||
return {
|
||||
planCount,
|
||||
summaryCount,
|
||||
completed: planCount > 0 && summaryCount >= planCount,
|
||||
hasNestedPlans,
|
||||
planFiles,
|
||||
summaryFiles,
|
||||
};
|
||||
}
|
||||
|
||||
// CJS callers do: const scanPhasePlans = require('./plan-scan.cjs')
|
||||
// and also destructure named exports — support both call styles.
|
||||
module.exports = scanPhasePlans;
|
||||
module.exports.scanPhasePlans = scanPhasePlans;
|
||||
module.exports.isRootPlanFile = isRootPlanFile;
|
||||
module.exports.isNestedPlanFile = isNestedPlanFile;
|
||||
module.exports.isRootSummaryFile = isRootSummaryFile;
|
||||
module.exports.isNestedSummaryFile = isNestedSummaryFile;
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
170
get-shit-done/bin/lib/schema-detect.generated.cjs
Normal file
170
get-shit-done/bin/lib/schema-detect.generated.cjs
Normal file
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/schema-detect.ts
|
||||
* Regenerate: cd sdk && npm run gen:schema-detect
|
||||
*
|
||||
* Schema Drift Detection — detects schema-relevant file changes and verifies
|
||||
* that the appropriate database push command was executed during a phase.
|
||||
* This module does not read the filesystem directly.
|
||||
*/
|
||||
|
||||
// ─── ORM Patterns ───────────────────────────────────────────────────────────
|
||||
const SCHEMA_PATTERNS = [
|
||||
{ pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' },
|
||||
{ pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' },
|
||||
{ pattern: /^prisma\/schema\.prisma$/, orm: 'prisma' },
|
||||
{ pattern: /^prisma\/schema\/.*\.prisma$/, orm: 'prisma' },
|
||||
{ pattern: /^drizzle\/schema\.ts$/, orm: 'drizzle' },
|
||||
{ pattern: /^src\/db\/schema\.ts$/, orm: 'drizzle' },
|
||||
{ pattern: /^drizzle\/.*\.ts$/, orm: 'drizzle' },
|
||||
{ pattern: /^supabase\/migrations\/.*\.sql$/, orm: 'supabase' },
|
||||
{ pattern: /^src\/entities\/.*\.ts$/, orm: 'typeorm' },
|
||||
{ pattern: /^src\/migrations\/.*\.ts$/, orm: 'typeorm' },
|
||||
];
|
||||
|
||||
// ─── Push Commands & Evidence Patterns ──────────────────────────────────────
|
||||
const ORM_INFO = {
|
||||
payload: {
|
||||
pushCommand: 'npx payload migrate',
|
||||
envHint: 'CI=true PAYLOAD_MIGRATING=true npx payload migrate',
|
||||
interactiveWarning: 'Payload migrate may require interactive prompts — use CI=true PAYLOAD_MIGRATING=true to suppress',
|
||||
evidencePatterns: [/payload\s+migrate/i, /PAYLOAD_MIGRATING/],
|
||||
},
|
||||
prisma: {
|
||||
pushCommand: 'npx prisma db push',
|
||||
envHint: 'npx prisma db push --accept-data-loss (if destructive changes are intended)',
|
||||
interactiveWarning: 'Prisma db push may prompt for confirmation on destructive changes — use --accept-data-loss to bypass',
|
||||
evidencePatterns: [/prisma\s+db\s+push/i, /prisma\s+migrate\s+deploy/i, /prisma\s+migrate\s+dev/i],
|
||||
},
|
||||
drizzle: {
|
||||
pushCommand: 'npx drizzle-kit push',
|
||||
envHint: 'npx drizzle-kit push',
|
||||
interactiveWarning: null,
|
||||
evidencePatterns: [/drizzle-kit\s+push/i, /drizzle-kit\s+migrate/i],
|
||||
},
|
||||
supabase: {
|
||||
pushCommand: 'supabase db push',
|
||||
envHint: 'supabase db push',
|
||||
interactiveWarning: 'Supabase db push may require authentication — ensure SUPABASE_ACCESS_TOKEN is set',
|
||||
evidencePatterns: [/supabase\s+db\s+push/i, /supabase\s+migration\s+up/i],
|
||||
},
|
||||
typeorm: {
|
||||
pushCommand: 'npx typeorm migration:run',
|
||||
envHint: 'npx typeorm migration:run -d src/data-source.ts',
|
||||
interactiveWarning: null,
|
||||
evidencePatterns: [/typeorm\s+migration:run/i, /typeorm\s+schema:sync/i],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
function detectSchemaFiles(files) {
|
||||
const matches = [];
|
||||
const orms = new Set();
|
||||
for (const rawFile of files) {
|
||||
const file = rawFile.replace(/\\/g, '/');
|
||||
for (const { pattern, orm } of SCHEMA_PATTERNS) {
|
||||
if (pattern.test(file)) {
|
||||
matches.push(rawFile);
|
||||
orms.add(orm);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
detected: matches.length > 0,
|
||||
matches,
|
||||
orms: [...orms],
|
||||
};
|
||||
}
|
||||
|
||||
function detectSchemaOrm(ormName) {
|
||||
return ORM_INFO[ormName] || null;
|
||||
}
|
||||
|
||||
function checkSchemaDrift(changedFiles, executionLog, options = {}) {
|
||||
const { skipCheck = false } = options;
|
||||
const detection = detectSchemaFiles(changedFiles);
|
||||
if (!detection.detected) {
|
||||
return {
|
||||
driftDetected: false,
|
||||
blocking: false,
|
||||
schemaFiles: [],
|
||||
orms: [],
|
||||
unpushedOrms: [],
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
const pushedOrms = new Set();
|
||||
const unpushedOrms = [];
|
||||
for (const orm of detection.orms) {
|
||||
const info = ORM_INFO[orm];
|
||||
if (!info)
|
||||
continue;
|
||||
const hasPushEvidence = info.evidencePatterns.some(p => p.test(executionLog));
|
||||
if (hasPushEvidence) {
|
||||
pushedOrms.add(orm);
|
||||
}
|
||||
else {
|
||||
unpushedOrms.push(orm);
|
||||
}
|
||||
}
|
||||
const driftDetected = unpushedOrms.length > 0;
|
||||
if (!driftDetected) {
|
||||
return {
|
||||
driftDetected: false,
|
||||
blocking: false,
|
||||
schemaFiles: detection.matches,
|
||||
orms: detection.orms,
|
||||
unpushedOrms: [],
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
const pushCommands = unpushedOrms
|
||||
.map(orm => {
|
||||
const info = ORM_INFO[orm];
|
||||
return info ? ` ${orm}: ${info.envHint || info.pushCommand}` : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const message = [
|
||||
'Schema drift detected: schema-relevant files changed but no database push was executed.',
|
||||
'',
|
||||
`Schema files changed: ${detection.matches.join(', ')}`,
|
||||
`ORMs requiring push: ${unpushedOrms.join(', ')}`,
|
||||
'',
|
||||
'Required push commands:',
|
||||
pushCommands,
|
||||
'',
|
||||
'Run the appropriate push command, or set GSD_SKIP_SCHEMA_CHECK=true to bypass this gate.',
|
||||
].join('\n');
|
||||
if (skipCheck) {
|
||||
return {
|
||||
driftDetected: true,
|
||||
blocking: false,
|
||||
skipped: true,
|
||||
schemaFiles: detection.matches,
|
||||
orms: detection.orms,
|
||||
unpushedOrms,
|
||||
message: 'Schema drift detected but check was skipped (GSD_SKIP_SCHEMA_CHECK=true).',
|
||||
};
|
||||
}
|
||||
return {
|
||||
driftDetected: true,
|
||||
blocking: true,
|
||||
schemaFiles: detection.matches,
|
||||
orms: detection.orms,
|
||||
unpushedOrms,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SCHEMA_PATTERNS,
|
||||
ORM_INFO,
|
||||
detectSchemaFiles,
|
||||
detectSchemaOrm,
|
||||
checkSchemaDrift,
|
||||
};
|
||||
@@ -1,33 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Secrets handling — masking convention for API keys and other
|
||||
* credentials managed via /gsd-settings-integrations.
|
||||
* Secrets Module — CJS adapter.
|
||||
*
|
||||
* Convention: strings 8+ chars long render as `****<last-4>`; shorter
|
||||
* strings render as `****` with no tail (to avoid leaking a meaningful
|
||||
* fraction of a short secret). null/empty renders as `(unset)`.
|
||||
* The implementation is generated from sdk/src/query/secrets.ts and
|
||||
* lives in secrets.generated.cjs. This file is a thin re-export so
|
||||
* that existing call sites (config.cjs, init.cjs, and tests) can
|
||||
* continue to require('./secrets') unchanged.
|
||||
*
|
||||
* Keys considered sensitive are listed in SECRET_CONFIG_KEYS and matched
|
||||
* at the exact key-path level. The list is intentionally narrow — these
|
||||
* are the fields documented as secrets in docs/CONFIGURATION.md.
|
||||
* Exports (from generated file):
|
||||
* - SECRET_CONFIG_KEYS — Set of secret key paths
|
||||
* - isSecretKey(keyPath) — returns true if keyPath is a secret
|
||||
* - maskSecret(value) — masks a secret value
|
||||
* - maskIfSecret(keyPath, value) — masks value only if keyPath is secret
|
||||
*
|
||||
* Regenerate: cd sdk && npm run gen:secrets
|
||||
*/
|
||||
|
||||
const SECRET_CONFIG_KEYS = new Set([
|
||||
'brave_search',
|
||||
'firecrawl',
|
||||
'exa_search',
|
||||
]);
|
||||
|
||||
function isSecretKey(keyPath) {
|
||||
return SECRET_CONFIG_KEYS.has(keyPath);
|
||||
}
|
||||
|
||||
function maskSecret(value) {
|
||||
if (value === null || value === undefined || value === '') return '(unset)';
|
||||
const s = String(value);
|
||||
if (s.length < 8) return '****';
|
||||
return '****' + s.slice(-4);
|
||||
}
|
||||
|
||||
module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret };
|
||||
module.exports = require('./secrets.generated.cjs');
|
||||
|
||||
37
get-shit-done/bin/lib/secrets.generated.cjs
Normal file
37
get-shit-done/bin/lib/secrets.generated.cjs
Normal file
@@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/secrets.ts
|
||||
* Regenerate: cd sdk && npm run gen:secrets
|
||||
*
|
||||
* Secrets handling — masking convention for API keys and other
|
||||
* credentials managed via /gsd-settings-integrations.
|
||||
* This module does not read the filesystem.
|
||||
*/
|
||||
|
||||
const SECRET_CONFIG_KEYS = new Set([
|
||||
'brave_search',
|
||||
'firecrawl',
|
||||
'exa_search',
|
||||
]);
|
||||
|
||||
function isSecretKey(keyPath) {
|
||||
return SECRET_CONFIG_KEYS.has(keyPath);
|
||||
}
|
||||
|
||||
function maskSecret(value) {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return '(unset)';
|
||||
const s = String(value);
|
||||
if (s.length < 8)
|
||||
return '****';
|
||||
return '****' + s.slice(-4);
|
||||
}
|
||||
|
||||
function maskIfSecret(keyPath, value) {
|
||||
return isSecretKey(keyPath) ? maskSecret(value) : value;
|
||||
}
|
||||
|
||||
module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret };
|
||||
@@ -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(
|
||||
|
||||
@@ -2,54 +2,126 @@
|
||||
|
||||
const { VALIDATE_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
|
||||
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
|
||||
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
|
||||
const { output } = require('./core.cjs');
|
||||
|
||||
function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output, error }) {
|
||||
const subcommand = args[1];
|
||||
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
|
||||
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
|
||||
|
||||
if (subcommand === 'consistency') {
|
||||
verify.cmdValidateConsistency(cwd, raw);
|
||||
} else if (subcommand === 'health') {
|
||||
const repairFlag = args.includes('--repair');
|
||||
const backfillFlag = args.includes('--backfill');
|
||||
verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw);
|
||||
} else if (subcommand === 'agents') {
|
||||
verify.cmdValidateAgents(cwd, raw);
|
||||
} else if (subcommand === 'context') {
|
||||
const opts = parseNamedArgs(args, ['tokens-used', 'context-window']);
|
||||
if (opts['tokens-used'] === null) {
|
||||
error('--tokens-used <integer> is required for `validate context`');
|
||||
return;
|
||||
}
|
||||
if (opts['context-window'] === null) {
|
||||
error('--context-window <integer> is required for `validate context`');
|
||||
return;
|
||||
}
|
||||
const { classifyContextUtilization, STATES } = require('./context-utilization.cjs');
|
||||
const threadCmd = formatGsdSlash('thread', resolveRuntime(cwd));
|
||||
const RECOMMENDATIONS = {
|
||||
[STATES.HEALTHY]: null,
|
||||
[STATES.WARNING]: `Context is approaching the fracture zone — consider ${threadCmd} to continue in a fresh window.`,
|
||||
[STATES.CRITICAL]: `Reasoning quality may degrade past 70% utilization (fracture point). Run ${threadCmd} now to preserve output quality.`,
|
||||
/**
|
||||
* Manifest-backed validate subcommand router.
|
||||
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
|
||||
*
|
||||
* Phase 6: validate.consistency, validate.health, validate.agents are
|
||||
* dispatched via executeForCjs when the SDK is available. CJS fallback
|
||||
* retained when:
|
||||
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
|
||||
* - SDK is unavailable (build not present).
|
||||
*
|
||||
* CJS-only subcommands:
|
||||
* - context: complex inline logic using classifyContextUtilization and
|
||||
* output formatting that has no direct SDK counterpart. Remains CJS-native.
|
||||
*
|
||||
* SDK-only (unsupported in CJS router): none.
|
||||
*/
|
||||
function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output: outputFn, error }) {
|
||||
const activeWorkstream = process.env.GSD_WORKSTREAM;
|
||||
const sdkAvailable = !activeWorkstream && tryLoadSdk();
|
||||
|
||||
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
|
||||
if (!sdkAvailable) return cjsFallback;
|
||||
return () => {
|
||||
const result = getExecuteForCjs()({
|
||||
registryCommand,
|
||||
registryArgs,
|
||||
legacyCommand: 'validate',
|
||||
legacyArgs,
|
||||
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
|
||||
// raw projection (formatQueryRawOutput) and returns the scalar string
|
||||
// CJS callers used to print. We then bypass output()'s JSON-stringify
|
||||
// path by passing rawValue (the third positional). With mode:'json',
|
||||
// output() emits the JSON IR as before.
|
||||
mode: raw ? 'raw' : 'json',
|
||||
projectDir: cwd,
|
||||
});
|
||||
if (!result.ok) {
|
||||
error(result.errorDetails && result.errorDetails.message
|
||||
? result.errorDetails.message
|
||||
: `validate ${registryCommand} failed (${result.errorKind})`);
|
||||
return;
|
||||
}
|
||||
if (raw) {
|
||||
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
|
||||
} else {
|
||||
output(result.data);
|
||||
}
|
||||
};
|
||||
let classified;
|
||||
try {
|
||||
classified = classifyContextUtilization(Number(opts['tokens-used']), Number(opts['context-window']));
|
||||
} catch (e) {
|
||||
const flag = /tokensUsed/.test(e.message) ? '--tokens-used' : '--context-window';
|
||||
error(`${flag} must be a non-negative integer (window > 0), got the values supplied`);
|
||||
return;
|
||||
}
|
||||
const result = { ...classified, recommendation: RECOMMENDATIONS[classified.state] };
|
||||
if (args.includes('--json')) {
|
||||
output(result, raw);
|
||||
} else {
|
||||
const lines = [`Context utilization: ${result.percent}% (${result.state})`];
|
||||
if (result.recommendation) lines.push(result.recommendation);
|
||||
output(result, true, lines.join('\n'));
|
||||
}
|
||||
} else {
|
||||
error(`Unknown validate subcommand. Available: ${VALIDATE_SUBCOMMANDS.join(', ')}`);
|
||||
}
|
||||
|
||||
routeCjsCommandFamily({
|
||||
args,
|
||||
subcommands: VALIDATE_SUBCOMMANDS,
|
||||
unsupported: {},
|
||||
error,
|
||||
unknownMessage: (_subcommand, available) => `Unknown validate subcommand. Available: ${available.join(', ')}`,
|
||||
handlers: {
|
||||
consistency: sdkHandler(
|
||||
'validate.consistency',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
() => verify.cmdValidateConsistency(cwd, raw),
|
||||
),
|
||||
// Keep health on CJS for now so fix hints are rendered via runtime-slash
|
||||
// helpers (codex expects $gsd-* command shape).
|
||||
health: () => {
|
||||
const repairFlag = args.includes('--repair');
|
||||
const backfillFlag = args.includes('--backfill');
|
||||
verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw);
|
||||
},
|
||||
agents: sdkHandler(
|
||||
'validate.agents',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
() => verify.cmdValidateAgents(cwd, raw),
|
||||
),
|
||||
// context: CJS-only — complex inline logic using classifyContextUtilization
|
||||
// with custom output formatting that has no direct SDK counterpart.
|
||||
context: () => {
|
||||
const opts = parseNamedArgs(args, ['tokens-used', 'context-window']);
|
||||
if (opts['tokens-used'] === null) {
|
||||
error('--tokens-used <integer> is required for `validate context`');
|
||||
return;
|
||||
}
|
||||
if (opts['context-window'] === null) {
|
||||
error('--context-window <integer> is required for `validate context`');
|
||||
return;
|
||||
}
|
||||
const { classifyContextUtilization, STATES } = require('./context-utilization.cjs');
|
||||
const threadCmd = formatGsdSlash('thread', resolveRuntime(cwd));
|
||||
const RECOMMENDATIONS = {
|
||||
[STATES.HEALTHY]: null,
|
||||
[STATES.WARNING]: `Context is approaching the fracture zone — consider ${threadCmd} to continue in a fresh window.`,
|
||||
[STATES.CRITICAL]: `Reasoning quality may degrade past 70% utilization (fracture point). Run ${threadCmd} now to preserve output quality.`,
|
||||
};
|
||||
let classified;
|
||||
try {
|
||||
classified = classifyContextUtilization(Number(opts['tokens-used']), Number(opts['context-window']));
|
||||
} catch (e) {
|
||||
const flag = /tokensUsed/.test(e.message) ? '--tokens-used' : '--context-window';
|
||||
error(`${flag} must be a non-negative integer (window > 0), got the values supplied`);
|
||||
return;
|
||||
}
|
||||
const result = { ...classified, recommendation: RECOMMENDATIONS[classified.state] };
|
||||
if (args.includes('--json')) {
|
||||
outputFn(result, raw);
|
||||
} else {
|
||||
const lines = [`Context utilization: ${result.percent}% (${result.state})`];
|
||||
if (result.recommendation) lines.push(result.recommendation);
|
||||
outputFn(result, true, lines.join('\n'));
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
61
get-shit-done/bin/lib/workstream-name-policy.generated.cjs
Normal file
61
get-shit-done/bin/lib/workstream-name-policy.generated.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/workstream-name-policy.ts
|
||||
* Regenerate: cd sdk && npm run gen:workstream-name-policy
|
||||
*
|
||||
* Canonical workstream name validation and slug normalization.
|
||||
* Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs.
|
||||
*/
|
||||
|
||||
const ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
||||
/**
|
||||
* Validate a workstream name.
|
||||
* Allowed: alphanumeric, hyphens, underscores, dots.
|
||||
* Disallowed: empty, spaces, slashes, special chars, path traversal.
|
||||
*
|
||||
* Alias for isValidActiveWorkstreamName; provided for SDK-layer callers.
|
||||
*/
|
||||
function validateWorkstreamName(name) {
|
||||
return isValidActiveWorkstreamName(name);
|
||||
}
|
||||
/**
|
||||
* Convert a display name to a URL/filesystem-safe workstream slug.
|
||||
* Lowercases, collapses non-alphanumeric runs to hyphens, strips leading/trailing hyphens.
|
||||
*/
|
||||
function toWorkstreamSlug(name) {
|
||||
return String(name || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
/**
|
||||
* Returns true when `name` contains a path separator, a bare dot, or a
|
||||
* dot-dot sequence — any of which would make the name unsafe for use as a
|
||||
* filesystem path segment.
|
||||
*/
|
||||
function hasInvalidPathSegment(name) {
|
||||
const value = String(name || '');
|
||||
return /[/\\]/.test(value) || value === '.' || value === '..' || value.includes('..');
|
||||
}
|
||||
/**
|
||||
* Returns true when `name` is a valid active workstream name:
|
||||
* - Must start with alphanumeric
|
||||
* - May contain alphanumeric, dots, underscores, hyphens
|
||||
* - Must not contain path traversal sequences (..)
|
||||
*/
|
||||
function isValidActiveWorkstreamName(name) {
|
||||
const value = String(name || '');
|
||||
if (value === '..' || value.startsWith('../') || value.includes('..'))
|
||||
return false;
|
||||
return ACTIVE_WORKSTREAM_RE.test(value);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateWorkstreamName,
|
||||
toWorkstreamSlug,
|
||||
hasInvalidPathSegment,
|
||||
isValidActiveWorkstreamName,
|
||||
};
|
||||
@@ -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`,
|
||||
|
||||
@@ -45,7 +45,7 @@ When `TDD_MODE` is `true`, the planner agent is instructed to apply `type: tdd`
|
||||
|
||||
When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior phase CONTEXT.md and SUMMARY.md files PLUS any phases explicitly listed in the current phase's `Depends on:` field in ROADMAP.md. Explicit dependencies always load regardless of recency (e.g., Phase 7 declaring `Depends on: Phase 2` always sees Phase 2's context). Bounded recency keeps the planner's context budget focused on recent work.
|
||||
|
||||
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
|
||||
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
|
||||
|
||||
**If `response_language` is set:** Include `response_language: {value}` in all spawned subagent prompts so any user-facing output stays in the configured language.
|
||||
|
||||
@@ -53,9 +53,52 @@ Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_
|
||||
|
||||
**If `planning_exists` is false:** Error — run `/gsd:new-project` first.
|
||||
|
||||
## 1.5. Closed-Phase Gate (#3569)
|
||||
|
||||
The init JSON includes `phase_status` — one of `Pending | Planned | In Progress | Executed | Complete | Needs Review`. `Complete` means the phase has all summaries AND a `VERIFICATION.md` with `status: passed`. Replanning a closed phase silently rewrites plan docs that no longer match the shipped code, so the workflow must hard-stop here unless the operator explicitly overrides.
|
||||
|
||||
Parse `phase_status` from the init JSON, then:
|
||||
|
||||
```bash
|
||||
FORCE_REPLAN=false
|
||||
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--force([[:space:]]|$) ]]; then
|
||||
FORCE_REPLAN=true
|
||||
fi
|
||||
|
||||
if [ "${phase_status}" = "Complete" ]; then
|
||||
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--reviews([[:space:]]|$) ]]; then
|
||||
# --reviews on a closed phase is never legitimate — concerns belong in a
|
||||
# new phase or issue against the closed phase's commits.
|
||||
cat <<EOF >&2
|
||||
Phase ${phase_number} (${phase_name}) is already CLOSED (VERIFICATION status: passed).
|
||||
/gsd:plan-phase --reviews cannot replan a closed phase. If the review surfaced
|
||||
real concerns, open a follow-up phase or file an issue against the closed
|
||||
phase's commits. There is no --force override for --reviews on a closed phase.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
if [ "$FORCE_REPLAN" != "true" ]; then
|
||||
cat <<EOF >&2
|
||||
Phase ${phase_number} (${phase_name}) is already CLOSED (VERIFICATION status: passed).
|
||||
Replanning a closed phase will overwrite plan docs that no longer match the
|
||||
shipped code. If you intentionally want to replan over closed work, re-run
|
||||
with: /gsd:plan-phase ${phase_number} --force
|
||||
|
||||
Otherwise, to view what shipped, see: ${verification_path}
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
# FORCE_REPLAN=true: continue, but emit a banner so the operator sees the
|
||||
# decision in the transcript and in any committed plan docs.
|
||||
echo "WARNING: Replanning CLOSED phase ${phase_number} under --force. Verify the closeout was wrong before committing new plan docs." >&2
|
||||
fi
|
||||
```
|
||||
|
||||
The gate fires only on `Complete`. `Executed` and `Needs Review` are not gated — those states mean planning was finished but verification did not pass, and replanning is a legitimate next step.
|
||||
|
||||
## 2. Parse and Normalize Arguments
|
||||
|
||||
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`).
|
||||
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--force` (override closed-phase gate, see §1.5)).
|
||||
|
||||
**`--research-phase <N>` — research-only mode (#3042 + #3044).** When this flag is present, parse `<N>` as the phase number (overrides any positional phase argument), set `RESEARCH_ONLY=true`, and treat the rest of this workflow as a research-dispatch only — the planner spawn (step 8), plan-checker, verification, gaps, bounce, and post-planning-gaps blocks all skip on `RESEARCH_ONLY`. Use this for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops. Replaces the deleted `/gsd-research-phase` command.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Sync managed `gsd-*` skill directories from one canonical runtime's skills root
|
||||
|
||||
If neither `--dry-run` nor `--apply` is specified, dry-run is the default.
|
||||
|
||||
**Supported runtime names:** `claude`, `codex`, `copilot`, `cursor`, `windsurf`, `opencode`, `gemini`, `kilo`, `augment`, `trae`, `qwen`, `codebuddy`, `cline`, `antigravity`
|
||||
**Supported runtime names:** `claude`, `codex`, `grok`, `copilot`, `cursor`, `windsurf`, `opencode`, `gemini`, `kilo`, `augment`, `trae`, `qwen`, `codebuddy`, `cline`, `antigravity` (grok uses the `~/.agents` layout)
|
||||
|
||||
---
|
||||
|
||||
@@ -35,7 +35,7 @@ fi
|
||||
|
||||
# Parse --to
|
||||
if [[ "$@" == *"--to all"* ]]; then
|
||||
TO_RUNTIMES=(claude codex copilot cursor windsurf opencode gemini kilo augment trae qwen codebuddy cline antigravity)
|
||||
TO_RUNTIMES=(claude codex grok copilot cursor windsurf opencode gemini kilo augment trae qwen codebuddy cline antigravity)
|
||||
elif [[ "$@" == *"--to"* ]]; then
|
||||
TO_RUNTIMES=( $(echo "$@" | grep -oP '(?<=--to )\S+') )
|
||||
fi
|
||||
|
||||
116
package-lock.json
generated
116
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -36,9 +36,18 @@ const HOOKS_TO_COPY = [
|
||||
// Community hooks (bash, opt-in via .planning/config.json hooks.community)
|
||||
'gsd-session-state.sh',
|
||||
'gsd-validate-commit.sh',
|
||||
'gsd-phase-boundary.sh'
|
||||
'gsd-phase-boundary.sh',
|
||||
// Graphify auto-update hook (#3347 / PR #3557 / #3579). Opt-in via
|
||||
// .planning/config.json graphify.auto_update; off by default.
|
||||
'gsd-graphify-update.sh'
|
||||
];
|
||||
|
||||
// Subdirectories under hooks/ whose contents must also ship to dist. Each
|
||||
// entry is copied as `hooks/<dir>/*` → `hooks/dist/<dir>/*` so detached
|
||||
// helpers (e.g. hooks/lib/gsd-graphify-rebuild.sh) resolve from the hook's
|
||||
// installed runtime path. See #3579.
|
||||
const HOOKS_SUBDIRS_TO_COPY = ['lib'];
|
||||
|
||||
// Sync millisecond sleep using Atomics.wait on a throwaway SharedArrayBuffer.
|
||||
// Used between Windows rename retries; this script is sync end-to-end so
|
||||
// setTimeout would not work. Total worst-case backoff across MAX_ATTEMPTS
|
||||
@@ -169,6 +178,37 @@ function build() {
|
||||
renameAtomicWithRetry(stagedDest, dest, hook);
|
||||
}
|
||||
|
||||
// Copy whitelisted hook subdirectories (e.g. hooks/lib/) into dist so the
|
||||
// installer's readdir-and-isFile loop in bin/install.js sees them and
|
||||
// detached hook helpers resolve from the installed runtime path (#3579).
|
||||
for (const subdir of HOOKS_SUBDIRS_TO_COPY) {
|
||||
const srcDir = path.join(HOOKS_DIR, subdir);
|
||||
if (!fs.existsSync(srcDir)) continue;
|
||||
const destDir = path.join(DIST_DIR, subdir);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
||||
for (const ent of entries) {
|
||||
if (!ent.isFile()) continue;
|
||||
const srcFile = path.join(srcDir, ent.name);
|
||||
const destFile = path.join(destDir, ent.name);
|
||||
if (ent.name.endsWith('.js')) {
|
||||
const syntaxError = validateSyntax(srcFile);
|
||||
if (syntaxError) {
|
||||
console.error(`\x1b[31m✗ ${subdir}/${ent.name}: SyntaxError — ${syntaxError}\x1b[0m`);
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
console.log(`\x1b[32m✓\x1b[0m Copying ${subdir}/${ent.name}...`);
|
||||
const stagedDest = path.join(STAGE_DIR, `${subdir}__${ent.name}.${Date.now()}`);
|
||||
fs.copyFileSync(srcFile, stagedDest);
|
||||
if (ent.name.endsWith('.sh')) {
|
||||
try { fs.chmodSync(stagedDest, 0o755); } catch (e) { /* Windows */ }
|
||||
}
|
||||
renameAtomicWithRetry(stagedDest, destFile, `${subdir}/${ent.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort cleanup of this process's own staging dir. Since STAGE_DIR
|
||||
// is per-PID (`.dist-staging-<pid>/`), no other builder touches it — so
|
||||
// rmSync with recursive:true is safe and leaves no race window.
|
||||
|
||||
@@ -9,9 +9,15 @@
|
||||
* ---
|
||||
* <markdown body>
|
||||
*
|
||||
* Returns { ok: true, fragment: { type, pr, body } } on success,
|
||||
* Returns { ok: true, fragment: { type, pr, body, docsExempt } } on success,
|
||||
* { ok: false, reason: FRAGMENT_ERROR.X, detail } on failure.
|
||||
*
|
||||
* `docsExempt` is `null` when the body contains no docs-exempt marker, or the
|
||||
* trimmed reason string when the body contains `<!-- docs-exempt: <reason> -->`
|
||||
* (#3213). The marker is stripped from `body` at parse time so it never bleeds
|
||||
* into the CHANGELOG.md or GitHub release-notes serializers, which append the
|
||||
* `(#NNNN)` PR suffix verbatim to the body's last line.
|
||||
*
|
||||
* The reason field is a frozen enum so tests assert on stable codes,
|
||||
* not free-text error messages (CONTRIBUTING.md: "Prohibited: Raw
|
||||
* Text Matching on Test Outputs").
|
||||
@@ -27,6 +33,46 @@ const FRAGMENT_ERROR = Object.freeze({
|
||||
|
||||
const ALLOWED_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']);
|
||||
|
||||
// HTML comment marking a fragment as exempt from the docs-required lint (#3213).
|
||||
// Form: `<!-- docs-exempt: <reason> -->`. The reason is the *required* human
|
||||
// audit trail — without it the exemption has no paper-trail value, so a bare
|
||||
// `<!-- docs-exempt -->` or empty `<!-- docs-exempt: -->` is intentionally
|
||||
// rejected (the colon and a non-whitespace first reason char are mandatory).
|
||||
//
|
||||
// Anchored with `^...$` + `m` flag so the marker only counts when it occupies
|
||||
// its own line. Inline mentions inside paragraphs (e.g. backtick-wrapped
|
||||
// syntax examples in documentation) are not matched — they cannot
|
||||
// accidentally exempt a fragment.
|
||||
//
|
||||
// The trailing `\r?` consumes the CR character of a CRLF line terminator,
|
||||
// which the `$` boundary (multiline mode) does not — so Windows-authored
|
||||
// fragments produce the same `body` shape as LF-authored ones. The reason
|
||||
// character class `[^\r\n>]` excludes `\r` for the same reason: a CRLF
|
||||
// fragment's reason text never carries a trailing `\r`.
|
||||
//
|
||||
// Bounded character class `[^\r\n>]` keeps the regex linear-time — no
|
||||
// catastrophic backtracking on adversarial input. The leading `\S` anchor
|
||||
// inside the capture group forces at least one non-whitespace character in
|
||||
// the reason; trailing whitespace before `-->` is consumed by the outer
|
||||
// `[ \t]*-->` and is not part of the captured reason.
|
||||
const DOCS_EXEMPT_RE = /^[ \t]*<!--[ \t]*docs-exempt[ \t]*:[ \t]*(\S[^\r\n>]*?)[ \t]*-->[ \t]*\r?$/im;
|
||||
|
||||
function extractDocsExempt(body) {
|
||||
const m = body.match(DOCS_EXEMPT_RE);
|
||||
if (!m) return { docsExempt: null, body };
|
||||
const reason = (m[1] || '').trim();
|
||||
// Strip the marker line and tidy up the surrounding whitespace. The cleanup
|
||||
// is CRLF-aware so Windows-authored fragments don't leave residual `\r`
|
||||
// characters that would shift the `(#NNNN)` PR suffix to a blank line in
|
||||
// the rendered CHANGELOG.md / GitHub release-notes bullet.
|
||||
const cleaned = body
|
||||
.replace(DOCS_EXEMPT_RE, '')
|
||||
.replace(/[ \t\r]+$/gm, '') // strip trailing \r/spaces on each line
|
||||
.replace(/(?:\r?\n){3,}/g, '\n\n') // collapse 3+ blank lines (CRLF-aware)
|
||||
.replace(/[\r\n]+$/, ''); // strip every trailing line terminator
|
||||
return { docsExempt: reason, body: cleaned };
|
||||
}
|
||||
|
||||
function parseFragment(src) {
|
||||
const fmMatch = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!fmMatch) return { ok: false, reason: FRAGMENT_ERROR.MISSING_FRONTMATTER };
|
||||
@@ -49,12 +95,20 @@ function parseFragment(src) {
|
||||
}
|
||||
// Use trim() only for the emptiness check; preserve the body verbatim
|
||||
// (including significant leading/trailing whitespace, code blocks, etc.)
|
||||
// so render → serialize round-trips exactly. Strip only a single trailing
|
||||
// newline added by editors so byte-equality holds for typical fragments.
|
||||
// so render → serialize round-trips exactly. Strip the single trailing
|
||||
// line terminator added by editors so byte-equality holds for typical
|
||||
// fragments. CRLF-aware: a Windows-authored fragment trims `\r\n` so the
|
||||
// marker line in extractDocsExempt does not leave residual `\r` characters
|
||||
// for downstream serializers to attach `(#NNNN)` to (#3213).
|
||||
if (!body.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY };
|
||||
const verbatimBody = body.endsWith('\n') ? body.slice(0, -1) : body;
|
||||
let verbatimBody;
|
||||
if (body.endsWith('\r\n')) verbatimBody = body.slice(0, -2);
|
||||
else if (body.endsWith('\n')) verbatimBody = body.slice(0, -1);
|
||||
else verbatimBody = body;
|
||||
const { docsExempt, body: visibleBody } = extractDocsExempt(verbatimBody);
|
||||
if (!visibleBody.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY };
|
||||
|
||||
return { ok: true, fragment: { type: fields.type, pr, body: verbatimBody } };
|
||||
return { ok: true, fragment: { type: fields.type, pr, body: visibleBody, docsExempt } };
|
||||
}
|
||||
|
||||
module.exports = { parseFragment, FRAGMENT_ERROR, ALLOWED_TYPES };
|
||||
module.exports = { parseFragment, extractDocsExempt, FRAGMENT_ERROR, ALLOWED_TYPES, DOCS_EXEMPT_RE };
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
'use strict';
|
||||
/**
|
||||
* One-shot script: replace retired /gsd-<cmd> with /gsd:<cmd> for known command names.
|
||||
* Only replaces when followed by a word boundary (space, newline, quote, backtick, ), end).
|
||||
* One-shot script + library: bidirectional GSD slash-command namespace normalizer.
|
||||
*
|
||||
* The transform is exported as a pure function so it can be unit-tested directly
|
||||
* (see tests/bug-2543-gsd-slash-namespace.test.cjs) without needing fixture files.
|
||||
* - Default direction (transformContent): retired /gsd-<cmd> → /gsd:<cmd>
|
||||
* (keeps monorepo sources, docs, and workflows in the active colon form).
|
||||
* - Reverse direction (transformContentToHyphen): /gsd:<cmd> / gsd:<cmd> → gsd-<cmd>
|
||||
* (used during skill installation for runtimes that register skills under the
|
||||
* canonical hyphen form established in #2808).
|
||||
*
|
||||
* Both directions only rewrite known commands from `commands/gsd/*.md` (longest-first
|
||||
* matching + word-boundary safety). Non-commands (gsd-sdk, gsd-tools, etc.) are
|
||||
* intentionally left untouched.
|
||||
*
|
||||
* The transforms are pure and exported for use by the installer and tests.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
@@ -57,6 +65,32 @@ function transformContent(src, cmdNames) {
|
||||
return src.replace(pattern, (_, cmd) => `/gsd:${cmd}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build regex for the reverse direction (colon form → hyphen form).
|
||||
* Matches both "gsd:cmd" and "/gsd:cmd" (the leading / is preserved automatically
|
||||
* because it is not part of the match). Uses longest-first ordering plus
|
||||
* bidirectional word-boundary safety (negative lookbehind on the left, lookahead
|
||||
* on the right) so matches only occur at token boundaries.
|
||||
*/
|
||||
function buildColonPattern(cmdNames) {
|
||||
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
|
||||
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
|
||||
return new RegExp(`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transform (reverse): rewrite `/gsd:<cmd>` / `gsd:<cmd>` to hyphen form
|
||||
* for known GSD commands.
|
||||
*
|
||||
* Non-command identifiers (e.g. gsd-sdk, gsd-tools) are left untouched, matching
|
||||
* the safety contract of the forward transform.
|
||||
*/
|
||||
function transformContentToHyphen(src, cmdNames) {
|
||||
const pattern = buildColonPattern(cmdNames);
|
||||
if (!pattern) return src;
|
||||
return src.replace(pattern, (_, cmd) => `gsd-${cmd}`);
|
||||
}
|
||||
|
||||
function readCmdNames() {
|
||||
return fs.readdirSync(COMMANDS_DIR)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
@@ -103,4 +137,11 @@ if (require.main === module) {
|
||||
console.log('Done.');
|
||||
}
|
||||
|
||||
module.exports = { transformContent, buildPattern, SKIP_DIRS };
|
||||
module.exports = {
|
||||
transformContent,
|
||||
transformContentToHyphen,
|
||||
buildPattern,
|
||||
buildColonPattern,
|
||||
readCmdNames,
|
||||
SKIP_DIRS
|
||||
};
|
||||
|
||||
222
scripts/lint-docs-required.cjs
Executable file
222
scripts/lint-docs-required.cjs
Executable file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Docs-required lint (#3213).
|
||||
*
|
||||
* Mirrors scripts/changeset/lint.cjs. Pure verdict function
|
||||
* evaluateLint({ changedFiles, fragments, labels, malformed }) returns
|
||||
* { ok, reason, triggering } using the LINT_REASON enum. The CLI wrapper
|
||||
* reads the PR diff (`git diff --name-only origin/${base}...HEAD`), parses
|
||||
* each touched `.changeset/*.md` fragment, then calls evaluateLint.
|
||||
*
|
||||
* Tests assert on the structured verdict, never on free text.
|
||||
*/
|
||||
|
||||
const { parseFragment, FRAGMENT_ERROR } = require('./changeset/parse.cjs');
|
||||
|
||||
const LINT_REASON = Object.freeze({
|
||||
OK_NO_TRIGGERING_FRAGMENTS: 'ok_no_triggering_fragments',
|
||||
OK_DOCS_UPDATED: 'ok_docs_updated',
|
||||
OK_OPT_OUT_LABEL: 'ok_opt_out_label',
|
||||
OK_FRAGMENTS_EXEMPT: 'ok_fragments_exempt',
|
||||
FAIL_DOCS_MISSING: 'fail_docs_missing',
|
||||
FAIL_MALFORMED_FRAGMENT: 'fail_malformed_fragment',
|
||||
});
|
||||
|
||||
const OPT_OUT_LABEL = 'no-docs';
|
||||
|
||||
// Fragment types that require a docs update. `Fixed` and `Security` are
|
||||
// bug-class — they describe regressions or vulnerabilities, not new
|
||||
// behavior to document.
|
||||
const TRIGGERING_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed']);
|
||||
|
||||
const DOCS_PREFIX = 'docs/';
|
||||
|
||||
function isFragmentPath(file) {
|
||||
return /^\.changeset\/[^/]+\.md$/.test(file) && !file.endsWith('/README.md');
|
||||
}
|
||||
|
||||
function isDocsFile(file) {
|
||||
return file.startsWith(DOCS_PREFIX);
|
||||
}
|
||||
|
||||
// Per-fragment escape hatch: parse.cjs extracts `<!-- docs-exempt: <reason> -->`
|
||||
// from the body into `fragment.docsExempt` (a non-empty reason string when the
|
||||
// marker was present and well-formed; `null` otherwise). A non-empty audit
|
||||
// trail is required — the lint defends in depth here too: even if a caller
|
||||
// constructs a fragment with `docsExempt: ''`, that does not count as exempt.
|
||||
function isExemptFragment(fragment) {
|
||||
return typeof fragment.docsExempt === 'string' && fragment.docsExempt.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure verdict — no fs, no git.
|
||||
*
|
||||
* Malformed fragments fail closed: a triggering fragment with bad frontmatter
|
||||
* cannot silently bypass docs enforcement. The changeset-required lint only
|
||||
* checks fragment _presence_, not _validity_, so docs lint takes responsibility
|
||||
* for any fragment it tries to consume.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string[]} args.changedFiles - file paths changed in the PR
|
||||
* @param {Array<{ path: string, type: string, body: string, docsExempt: string|null }>} args.fragments
|
||||
* - parsed records for well-formed `.changeset/*.md` files in `changedFiles`
|
||||
* @param {Array<{ path: string, reason: string }>} [args.malformed]
|
||||
* - records for `.changeset/*.md` files that failed `parseFragment`
|
||||
* @param {string[]} args.labels - PR labels
|
||||
* @returns {{ ok: boolean, reason: string, triggering: string[], malformed?: Array<{path:string,reason:string}> }}
|
||||
*/
|
||||
function evaluateLint({ changedFiles, fragments, labels, malformed = [] }) {
|
||||
if (malformed.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: LINT_REASON.FAIL_MALFORMED_FRAGMENT,
|
||||
triggering: [],
|
||||
malformed,
|
||||
};
|
||||
}
|
||||
|
||||
const triggering = fragments.filter((f) => TRIGGERING_TYPES.has(f.type));
|
||||
const triggeringPaths = triggering.map((f) => f.path);
|
||||
|
||||
if (triggering.length === 0) {
|
||||
return { ok: true, reason: LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS, triggering: [] };
|
||||
}
|
||||
|
||||
// Per-fragment exempt path: every triggering fragment must carry the marker.
|
||||
// Partial exemption fails closed — one un-marked Added fragment still requires docs.
|
||||
if (triggering.every(isExemptFragment)) {
|
||||
return { ok: true, reason: LINT_REASON.OK_FRAGMENTS_EXEMPT, triggering: triggeringPaths };
|
||||
}
|
||||
|
||||
if (labels.includes(OPT_OUT_LABEL)) {
|
||||
return { ok: true, reason: LINT_REASON.OK_OPT_OUT_LABEL, triggering: triggeringPaths };
|
||||
}
|
||||
|
||||
if (changedFiles.some(isDocsFile)) {
|
||||
return { ok: true, reason: LINT_REASON.OK_DOCS_UPDATED, triggering: triggeringPaths };
|
||||
}
|
||||
|
||||
return { ok: false, reason: LINT_REASON.FAIL_DOCS_MISSING, triggering: triggeringPaths };
|
||||
}
|
||||
|
||||
function readFragmentsFromDisk(changedFiles, rootDir) {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fragments = [];
|
||||
const malformed = [];
|
||||
for (const rel of changedFiles) {
|
||||
if (!isFragmentPath(rel)) continue;
|
||||
const abs = path.join(rootDir, rel);
|
||||
if (!fs.existsSync(abs)) continue; // fragment deleted in PR — skip
|
||||
let src;
|
||||
try {
|
||||
src = fs.readFileSync(abs, 'utf8');
|
||||
} catch (e) {
|
||||
malformed.push({ path: rel, reason: 'read_error', detail: e.code || e.message });
|
||||
continue;
|
||||
}
|
||||
const parsed = parseFragment(src);
|
||||
if (!parsed.ok) {
|
||||
malformed.push({ path: rel, reason: parsed.reason, detail: parsed.detail || null });
|
||||
continue;
|
||||
}
|
||||
fragments.push({
|
||||
path: rel,
|
||||
type: parsed.fragment.type,
|
||||
body: parsed.fragment.body,
|
||||
docsExempt: parsed.fragment.docsExempt,
|
||||
});
|
||||
}
|
||||
return { fragments, malformed };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const fs = require('node:fs');
|
||||
const cp = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
|
||||
const eventPath = process.env.GITHUB_EVENT_PATH;
|
||||
let labels = [];
|
||||
if (eventPath && fs.existsSync(eventPath)) {
|
||||
try {
|
||||
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
|
||||
labels = (event.pull_request?.labels || []).map((l) => l.name);
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
const base = process.env.GITHUB_BASE_REF || 'main';
|
||||
let changedFiles = [];
|
||||
try {
|
||||
// execFileSync with argv — no shell, so a malicious GITHUB_BASE_REF
|
||||
// cannot inject shell syntax. Git's own ref-name validator rejects
|
||||
// any metacharacters it would otherwise interpret.
|
||||
const out = cp.execFileSync(
|
||||
'git',
|
||||
['diff', '--name-only', `origin/${base}...HEAD`],
|
||||
{ encoding: 'utf8', cwd: rootDir },
|
||||
);
|
||||
changedFiles = out.split('\n').filter(Boolean);
|
||||
} catch (e) {
|
||||
process.stderr.write(`could not compute diff: ${e.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const { fragments, malformed } = readFragmentsFromDisk(changedFiles, rootDir);
|
||||
const verdict = evaluateLint({ changedFiles, fragments, labels, malformed });
|
||||
|
||||
if (process.argv.includes('--json')) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ ...verdict, changedFiles, fragments, malformed, labels }, null, 2) + '\n',
|
||||
);
|
||||
} else if (verdict.ok) {
|
||||
process.stdout.write(`ok docs-lint: ${verdict.reason}\n`);
|
||||
} else if (verdict.reason === LINT_REASON.FAIL_MALFORMED_FRAGMENT) {
|
||||
process.stderr.write(`\nERROR docs-lint: ${verdict.reason}\n`);
|
||||
process.stderr.write(
|
||||
`${malformed.length} changeset fragment(s) failed to parse — docs lint cannot consume them:\n`,
|
||||
);
|
||||
for (const m of malformed) {
|
||||
process.stderr.write(` ${m.path} (reason: ${m.reason}${m.detail ? `, detail: ${m.detail}` : ''})\n`);
|
||||
}
|
||||
process.stderr.write(
|
||||
`\nFix the fragment frontmatter (\`type:\` + \`pr:\`) before this PR can pass.\n`,
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(`\nERROR docs-lint: ${verdict.reason}\n`);
|
||||
process.stderr.write(
|
||||
`${verdict.triggering.length} changeset fragment(s) require documentation updates:\n`,
|
||||
);
|
||||
for (const f of fragments.filter((f) => TRIGGERING_TYPES.has(f.type))) {
|
||||
process.stderr.write(` ${f.path} (type: ${f.type})\n`);
|
||||
}
|
||||
process.stderr.write(`\nNo files under docs/ were modified in this PR.\n\n`);
|
||||
process.stderr.write(
|
||||
`Update the relevant docs/ file(s), or add the \`${OPT_OUT_LABEL}\` label if this change\n`,
|
||||
);
|
||||
process.stderr.write(
|
||||
`is genuinely internal-only (infrastructure, refactor, test-only). Per-fragment\n`,
|
||||
);
|
||||
process.stderr.write(
|
||||
`exemption via \`<!-- docs-exempt: <reason> -->\` inside the fragment body also works.\n`,
|
||||
);
|
||||
}
|
||||
process.exit(verdict.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = {
|
||||
evaluateLint,
|
||||
readFragmentsFromDisk,
|
||||
LINT_REASON,
|
||||
OPT_OUT_LABEL,
|
||||
TRIGGERING_TYPES,
|
||||
FRAGMENT_ERROR,
|
||||
isFragmentPath,
|
||||
isDocsFile,
|
||||
isExemptFragment,
|
||||
};
|
||||
331
scripts/lint-shared-module-handsync.cjs
Normal file
331
scripts/lint-shared-module-handsync.cjs
Normal file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared Module hand-sync drift lint — Phase 6 of #3524 (#3575).
|
||||
*
|
||||
* Scans get-shit-done/bin/lib/ for .cjs files and checks whether a matching
|
||||
* TypeScript file exists in sdk/src/<name>.ts, sdk/src/query/<name>.ts, or
|
||||
* sdk/src/<name>/index.ts (excluding *.generated.ts and *.test.ts).
|
||||
*
|
||||
* Allowlist entries are keyed by the (cjs, ts) PAIR. An entry with cjs
|
||||
* `bin/lib/foo.cjs` and ts `sdk/src/foo.ts` only allow-throughs that exact
|
||||
* pair — a sibling at `sdk/src/query/foo.ts` is still flagged.
|
||||
*
|
||||
* If a pair is found:
|
||||
* - cooperatingSiblings (matching cjs + ts): accepted silently (exit 0).
|
||||
* - migrateMeBacklog (matching cjs + ts): emits a WARNING only when
|
||||
* --warn-all is set; otherwise the pair passes silently. Backlog
|
||||
* pairs never fail CI.
|
||||
* - Unlisted pairs (cjs or ts not on either list): ERROR — exit 1.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/lint-shared-module-handsync.cjs
|
||||
* node scripts/lint-shared-module-handsync.cjs --root /path/to/repo
|
||||
* node scripts/lint-shared-module-handsync.cjs --warn-all
|
||||
* node scripts/lint-shared-module-handsync.cjs --cjs-dir custom/bin/lib --sdk-src custom/sdk/src
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argument parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
let ROOT = path.resolve(__dirname, '..');
|
||||
let CJS_DIR = null; // resolved below
|
||||
let SDK_SRC = null; // resolved below
|
||||
let ALLOWLIST_OVERRIDE = null; // resolved below
|
||||
let WARN_ALL = false;
|
||||
let JSON_OUTPUT = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--root' && args[i + 1]) {
|
||||
ROOT = path.resolve(args[++i]);
|
||||
} else if (args[i] === '--cjs-dir' && args[i + 1]) {
|
||||
CJS_DIR = path.resolve(args[++i]);
|
||||
} else if (args[i] === '--sdk-src' && args[i + 1]) {
|
||||
SDK_SRC = path.resolve(args[++i]);
|
||||
} else if (args[i] === '--allowlist' && args[i + 1]) {
|
||||
ALLOWLIST_OVERRIDE = path.resolve(args[++i]);
|
||||
} else if (args[i] === '--warn-all') {
|
||||
WARN_ALL = true;
|
||||
} else if (args[i] === '--json') {
|
||||
JSON_OUTPUT = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!CJS_DIR) CJS_DIR = path.join(ROOT, 'get-shit-done', 'bin', 'lib');
|
||||
if (!SDK_SRC) SDK_SRC = path.join(ROOT, 'sdk', 'src');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load allowlist
|
||||
// When --root is given (e.g. in tests), prefer <ROOT>/scripts/allowlist.json
|
||||
// so fixture trees can supply their own allowlist. Fall back to the copy
|
||||
// co-located with this script (default production path).
|
||||
// ---------------------------------------------------------------------------
|
||||
const ALLOWLIST_PATH = ALLOWLIST_OVERRIDE
|
||||
? ALLOWLIST_OVERRIDE
|
||||
: fs.existsSync(path.join(ROOT, 'scripts', 'shared-module-handsync-allowlist.json'))
|
||||
? path.join(ROOT, 'scripts', 'shared-module-handsync-allowlist.json')
|
||||
: path.join(__dirname, 'shared-module-handsync-allowlist.json');
|
||||
let allowlist;
|
||||
try {
|
||||
allowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8'));
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`lint-shared-module-handsync: failed to read allowlist at ${ALLOWLIST_PATH}: ${err.message}\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair identity = `${cjs}::${ts}`. Keying on the pair (not just cjs)
|
||||
* prevents an allowlisted entry from silently passing an unintended
|
||||
* sibling at a different ts path with the same basename.
|
||||
*
|
||||
* @type {Set<string>} pair identities in cooperatingSiblings
|
||||
*/
|
||||
const cooperatingPairs = new Set(
|
||||
(allowlist.cooperatingSiblings || []).map((e) => `${e.cjs}::${e.ts}`)
|
||||
);
|
||||
|
||||
/** @type {Map<string, object>} pair identity -> entry for migrateMeBacklog */
|
||||
const migrateMap = new Map(
|
||||
(allowlist.migrateMeBacklog || []).map((e) => [`${e.cjs}::${e.ts}`, e])
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build SDK name index: name -> array of absolute TS paths
|
||||
// (excludes *.generated.ts and *.test.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildSdkIndex(sdkSrc) {
|
||||
const index = new Map(); // name -> [absPath, ...]
|
||||
|
||||
function addEntry(name, absPath) {
|
||||
if (!index.has(name)) index.set(name, []);
|
||||
index.get(name).push(absPath);
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const abs = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
walk(abs);
|
||||
} else if (ent.isFile() && ent.name.endsWith('.ts') &&
|
||||
!ent.name.endsWith('.generated.ts') &&
|
||||
!ent.name.endsWith('.test.ts')) {
|
||||
const rel = path.relative(sdkSrc, abs);
|
||||
const parts = rel.split(path.sep);
|
||||
|
||||
// sdk/src/<name>.ts (direct child, not in a subdir)
|
||||
if (parts.length === 1) {
|
||||
const name = parts[0].slice(0, -3); // strip .ts
|
||||
addEntry(name, abs);
|
||||
}
|
||||
// sdk/src/<name>/index.ts (one subdir deep, file is index.ts)
|
||||
else if (parts.length === 2 && parts[1] === 'index.ts') {
|
||||
const name = parts[0];
|
||||
addEntry(name, abs);
|
||||
}
|
||||
// sdk/src/query/<name>.ts (exactly: query/<something>.ts)
|
||||
else if (parts.length === 2 && parts[0] === 'query' && parts[1] !== 'index.ts') {
|
||||
const name = parts[1].slice(0, -3); // strip .ts
|
||||
addEntry(name, abs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(sdkSrc);
|
||||
return index;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan CJS files (direct children only; exclude *.generated.cjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
function scanCjsFiles(cjsDir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(cjsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`lint-shared-module-handsync: cannot read CJS dir ${cjsDir}: ${err.message}\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return entries
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isFile() &&
|
||||
e.name.endsWith('.cjs') &&
|
||||
!e.name.endsWith('.generated.cjs')
|
||||
)
|
||||
.map((e) => ({
|
||||
name: e.name.slice(0, -4), // strip .cjs
|
||||
absPath: path.join(cjsDir, e.name),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function emitJson(payload) {
|
||||
process.stdout.write(JSON.stringify(payload) + '\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Check that the directories exist
|
||||
if (!fs.existsSync(CJS_DIR)) {
|
||||
if (JSON_OUTPUT) {
|
||||
emitJson({ ok: false, reason: 'cjs_dir_missing', path: CJS_DIR });
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`lint-shared-module-handsync: CJS dir not found: ${CJS_DIR}\n` +
|
||||
` Pass --root <repo-root> or --cjs-dir <path> to override.\n`
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(SDK_SRC)) {
|
||||
if (JSON_OUTPUT) {
|
||||
emitJson({ ok: false, reason: 'sdk_src_missing', path: SDK_SRC });
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`lint-shared-module-handsync: SDK src dir not found: ${SDK_SRC}\n` +
|
||||
` Pass --root <repo-root> or --sdk-src <path> to override.\n`
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sdkIndex = buildSdkIndex(SDK_SRC);
|
||||
const cjsFiles = scanCjsFiles(CJS_DIR);
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const { name, absPath } of cjsFiles) {
|
||||
// Is there a matching TS file?
|
||||
if (!sdkIndex.has(name)) continue;
|
||||
|
||||
// Compute the relative paths the allowlist uses
|
||||
const relCjs = path.relative(ROOT, absPath).replace(/\\/g, '/');
|
||||
const tsPaths = sdkIndex.get(name).map((p) => path.relative(ROOT, p).replace(/\\/g, '/'));
|
||||
|
||||
// Pair-aware matching, per ts sibling. Each ts candidate is classified
|
||||
// independently against the allowlist so a partially-allowlisted set of
|
||||
// siblings still surfaces the unauthorized ones. See #3632.
|
||||
const unauthorizedTs = [];
|
||||
const backlogTsForCjs = [];
|
||||
for (const relTs of tsPaths) {
|
||||
const pairKey = `${relCjs}::${relTs}`;
|
||||
if (cooperatingPairs.has(pairKey)) continue;
|
||||
if (migrateMap.has(pairKey)) {
|
||||
backlogTsForCjs.push(relTs);
|
||||
continue;
|
||||
}
|
||||
unauthorizedTs.push(relTs);
|
||||
}
|
||||
|
||||
if (unauthorizedTs.length > 0) {
|
||||
errors.push({ relCjs, tsPaths: unauthorizedTs });
|
||||
}
|
||||
if (backlogTsForCjs.length > 0) {
|
||||
const entry = migrateMap.get(`${relCjs}::${backlogTsForCjs[0]}`);
|
||||
warnings.push({ relCjs, tsPaths: backlogTsForCjs, entry });
|
||||
}
|
||||
}
|
||||
|
||||
// Count cjs files whose pair identity (cjs+ts) is on cooperatingSiblings.
|
||||
// A file with multiple ts candidates is counted once if any pair matches.
|
||||
const cooperatingCount = cjsFiles.filter((f) => {
|
||||
if (!sdkIndex.has(f.name)) return false;
|
||||
const relCjs = path.relative(ROOT, f.absPath).replace(/\\/g, '/');
|
||||
return sdkIndex.get(f.name).some((tsAbs) => {
|
||||
const relTs = path.relative(ROOT, tsAbs).replace(/\\/g, '/');
|
||||
return cooperatingPairs.has(`${relCjs}::${relTs}`);
|
||||
});
|
||||
}).length;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Report errors (exit 1)
|
||||
// -------------------------------------------------------------------------
|
||||
if (errors.length > 0) {
|
||||
if (JSON_OUTPUT) {
|
||||
emitJson({
|
||||
ok: false,
|
||||
reason: 'unauthorized_pairs',
|
||||
errors,
|
||||
warnings,
|
||||
cooperatingCount,
|
||||
});
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`\nERROR lint-shared-module-handsync: ${errors.length} unauthorized hand-sync pair(s) found.\n\n`
|
||||
);
|
||||
for (const { relCjs, tsPaths } of errors) {
|
||||
process.stderr.write(` CJS: ${relCjs}\n`);
|
||||
for (const ts of tsPaths) {
|
||||
process.stderr.write(` TS: ${ts}\n`);
|
||||
}
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
process.stderr.write(
|
||||
'To resolve, choose one of:\n' +
|
||||
' 1. Migrate to a Shared Module (preferred): create sdk/src/<name>/index.ts as the\n' +
|
||||
' source-of-truth, write a generator script (sdk/scripts/gen-<name>.mjs), add a\n' +
|
||||
' freshness check, and update CI. See docs/agents/cjs-sdk-seam.md for the pattern.\n' +
|
||||
' 2. Add an explicit allowlist entry to scripts/shared-module-handsync-allowlist.json\n' +
|
||||
' with a justification explaining why this pair is a legitimate cooperating sibling\n' +
|
||||
' rather than a drift anti-pattern. Requires maintainer review via CODEOWNERS.\n\n'
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Report warnings (no exit code change)
|
||||
// -------------------------------------------------------------------------
|
||||
if (warnings.length > 0 && WARN_ALL && !JSON_OUTPUT) {
|
||||
process.stderr.write(
|
||||
`\nWARNING lint-shared-module-handsync: ${warnings.length} known drift anti-pattern pair(s) in migrateMeBacklog.\n` +
|
||||
`These are tracked for future Shared Module migration but do not block CI.\n\n`
|
||||
);
|
||||
for (const { relCjs, tsPaths, entry } of warnings) {
|
||||
process.stderr.write(` CJS: ${relCjs}\n`);
|
||||
for (const ts of tsPaths) {
|
||||
process.stderr.write(` TS: ${ts}\n`);
|
||||
}
|
||||
process.stderr.write(` Tracked: ${entry.trackedIn}\n`);
|
||||
process.stderr.write(` Hint: ${entry.justification}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Success
|
||||
// -------------------------------------------------------------------------
|
||||
if (JSON_OUTPUT) {
|
||||
emitJson({
|
||||
ok: true,
|
||||
cooperatingCount,
|
||||
backlogCount: warnings.length,
|
||||
warnings,
|
||||
});
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`ok lint-shared-module-handsync: no unauthorized hand-sync pairs found` +
|
||||
` (${cooperatingCount} cooperating sibling(s), ${warnings.length} backlog pair(s))\n`
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
139
scripts/shared-module-handsync-allowlist.json
Normal file
139
scripts/shared-module-handsync-allowlist.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"_comment": "Allowlist for scripts/lint-shared-module-handsync.cjs — Phase 6 of #3524 (#3575). Two categories: cooperatingSiblings (legitimate pairs, lint accepts silently) and migrateMeBacklog (known drift anti-patterns, lint warns but does not fail). All entries require cjs + ts path + classification + justification.",
|
||||
"cooperatingSiblings": [
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/active-workstream-store.cjs",
|
||||
"ts": "sdk/src/query/active-workstream-store.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS manages filesystem-backed workstream store; SDK layer wraps via Adapter for query dispatch. Different responsibilities, not drift."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/config-schema.cjs",
|
||||
"ts": "sdk/src/query/config-schema.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "SDK config-schema.ts is the generated source-of-truth derived from sdk/shared/config-schema.manifest.json (Phase 2/#3540). CJS config-schema.cjs is the Adapter that reads from that manifest. Not a hand-sync pair; freshness check enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/frontmatter.cjs",
|
||||
"ts": "sdk/src/query/frontmatter.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS implements full frontmatter parsing/mutation; SDK frontmatter.ts is the native SDK query handler delegating to the CJS runtime via the seam bridge. Not duplicating logic."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/init.cjs",
|
||||
"ts": "sdk/src/query/init.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS init.cjs is the authoritative initializer; SDK init.ts provides the native handler layer for the SDK query seam. Phase 5.2+ will migrate remaining subcommands, but current architecture is intentional."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/phase.cjs",
|
||||
"ts": "sdk/src/query/phase.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS phase.cjs is the full phase lifecycle implementation; SDK phase.ts provides the native query handler. The SDK delegates to CJS for most subcommands. Phase 5.2+ candidate for further migration."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/profile-output.cjs",
|
||||
"ts": "sdk/src/query/profile-output.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS profile-output.cjs handles profiling output rendering; SDK profile-output.ts is the corresponding SDK query handler. Separate responsibilities across the seam."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/roadmap.cjs",
|
||||
"ts": "sdk/src/query/roadmap.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS roadmap.cjs is the full roadmap implementation; SDK roadmap.ts provides the native handler for SDK query dispatch. Phase 5.2+ candidate."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/state.cjs",
|
||||
"ts": "sdk/src/query/state.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS state.cjs is the full state implementation; SDK state.ts routes known subcommands via executeForCjs (Phase 5.0/#3558, Phase 5.1/#3574). Intentional seam delegation pattern."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/state-document.cjs",
|
||||
"ts": "sdk/src/query/state-document.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS state-document.cjs is the generated Adapter reading from sdk/src/state-document/ Shared Module (Phase 1/#3531). SDK state-document.ts is the corresponding source-of-truth query handler. Freshness check enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/template.cjs",
|
||||
"ts": "sdk/src/query/template.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS template.cjs handles template operations; SDK template.ts is the corresponding SDK native handler. Separate responsibilities across the seam."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/uat.cjs",
|
||||
"ts": "sdk/src/query/uat.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS uat.cjs implements UAT workflows; SDK uat.ts provides the SDK query handler layer. Separate responsibilities."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/verify.cjs",
|
||||
"ts": "sdk/src/query/verify.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS verify.cjs is the full verify implementation; SDK verify.ts provides the native handler. Phase 5.2+ candidate for further delegation."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/workstream.cjs",
|
||||
"ts": "sdk/src/query/workstream.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS workstream.cjs handles workstream management; SDK workstream.ts provides the SDK query handler. Workstream support inside sync bridge is an open follow-up item."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/workstream-inventory.cjs",
|
||||
"ts": "sdk/src/query/workstream-inventory.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS workstream-inventory.cjs is the generated Adapter for the workstream-inventory Shared Module (Phase 3/#3548). SDK workstream-inventory.ts is the source-of-truth query handler. Freshness check enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/config.cjs",
|
||||
"ts": "sdk/src/config.ts",
|
||||
"classification": "CJS-CLI-ONLY",
|
||||
"justification": "Phase 2 (#3536) already migrated CONFIG_DEFAULTS and loadConfig/mergeDefaults to the Configuration Module and sdk/src/config.ts. What remains in config.cjs is exclusively CLI command handlers (cmdConfigGet, cmdConfigSet, cmdConfigNewProject, cmdConfigEnsureSection, cmdConfigSetModelProfile, cmdConfigPath, cmdMigrateConfig, buildNewProjectConfig, setConfigValue, ensureConfigFile) that depend on CJS-only APIs (withPlanningLock, platformWriteSync/ReadSync/EnsureDir, sync fs ops, process.exit). sdk/src/config.ts provides only the async loadConfig/mergeDefaults SDK layer. The two files serve disjoint surfaces with no logical overlap — not a hand-sync drift anti-pattern."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/intel.cjs",
|
||||
"ts": "sdk/src/query/intel.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS intel.cjs is the synchronous runtime implementation used by gsd-tools.cjs; sdk/src/query/intel.ts is the async QueryHandler port for the SDK query seam (explicitly documented as a port in its file header). The two files intentionally diverge on INTEL_FILES naming (CJS: file-roles.json/api-map.json/dependency-graph.json/arch-decisions.json; SDK: files.json/apis.json/deps.json/arch.md) — existing CJS tests are locked to the old naming. Not a hand-sync drift pattern; separate runtime responsibilities across the seam."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/model-catalog.cjs",
|
||||
"ts": "sdk/src/model-catalog.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "Both files read from sdk/shared/model-catalog.json (ADR-0003 precedent) as independent consumers of the shared manifest. CJS exposes VALID_AGENT_TIERS, MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, nextTier, formatAgentToModelMapAsTable for core.cjs and model-profiles.cjs consumers. SDK exposes resolveRuntimeTierDefault, runtimesWithReasoningEffort for session-runner.ts and query handlers. The shared JSON is the single source-of-truth; both adapters derive their exports from it without duplicating any logic between themselves."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/plan-scan.cjs",
|
||||
"ts": "sdk/src/query/plan-scan.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "CJS plan-scan.cjs is the generated Adapter reading from sdk/src/query/plan-scan.ts Shared Module (Phase 6/#3575). SDK plan-scan.ts is the source-of-truth. Freshness check (check-plan-scan-fresh.mjs) enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/secrets.cjs",
|
||||
"ts": "sdk/src/query/secrets.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "CJS secrets.cjs is the generated Adapter reading from sdk/src/query/secrets.ts Shared Module (Phase 6/#3575). SDK secrets.ts is the source-of-truth. Freshness check (check-secrets-fresh.mjs) enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/schema-detect.cjs",
|
||||
"ts": "sdk/src/query/schema-detect.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "CJS schema-detect.cjs is the generated Adapter reading from sdk/src/query/schema-detect.ts Shared Module (Phase 6/#3575). SDK schema-detect.ts is the source-of-truth. Generated CJS adds detectSchemaOrm compat export (not in SDK) and exports SCHEMA_PATTERNS/ORM_INFO for backward compatibility. Freshness check (check-schema-detect-fresh.mjs) enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/decisions.cjs",
|
||||
"ts": "sdk/src/query/decisions.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "Phase 6 (#3575): CJS decisions.cjs is the generated Adapter reading from sdk/src/query/decisions.ts Shared Module. SDK source-of-truth; regex aligned to accept alphanumeric IDs (D-INFRA-01). CJS callers (gap-checker.cjs) use {id, text} subset; extra fields {category, tags, trackable} are present but ignored. Freshness check (check-decisions-fresh.mjs) enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/workstream-name-policy.cjs",
|
||||
"ts": "sdk/src/workstream-name-policy.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "Phase 6 (#3575): CJS workstream-name-policy.cjs is the generated Adapter reading from sdk/src/workstream-name-policy.ts Shared Module. SDK source-of-truth now exports all three functions used by CJS callers (toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName) plus validateWorkstreamName alias. Freshness check (check-workstream-name-policy-fresh.mjs) enforces alignment."
|
||||
}
|
||||
],
|
||||
"migrateMeBacklog": []
|
||||
}
|
||||
12
sdk/package-lock.json
generated
12
sdk/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
31
sdk/scripts/check-decisions-fresh.mjs
Normal file
31
sdk/scripts/check-decisions-fresh.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for decisions.generated.cjs.
|
||||
*
|
||||
* Regenerates the expected CJS content in-memory (without writing to disk) and
|
||||
* compares it to the committed file. Exits 0 if they match, 1 if stale.
|
||||
*
|
||||
* Run: node sdk/scripts/check-decisions-fresh.mjs
|
||||
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildDecisionsCjs } from './gen-decisions.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildDecisionsCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'decisions.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('decisions.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('decisions.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:decisions');
|
||||
process.exit(1);
|
||||
}
|
||||
31
sdk/scripts/check-plan-scan-fresh.mjs
Normal file
31
sdk/scripts/check-plan-scan-fresh.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for plan-scan.generated.cjs.
|
||||
*
|
||||
* Regenerates the expected CJS content in-memory (without writing to disk) and
|
||||
* compares it to the committed file. Exits 0 if they match, 1 if stale.
|
||||
*
|
||||
* Run: node sdk/scripts/check-plan-scan-fresh.mjs
|
||||
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildPlanScanCjs } from './gen-plan-scan.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildPlanScanCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'plan-scan.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('plan-scan.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('plan-scan.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:plan-scan');
|
||||
process.exit(1);
|
||||
}
|
||||
31
sdk/scripts/check-schema-detect-fresh.mjs
Normal file
31
sdk/scripts/check-schema-detect-fresh.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for schema-detect.generated.cjs.
|
||||
*
|
||||
* Regenerates the expected CJS content in-memory (without writing to disk) and
|
||||
* compares it to the committed file. Exits 0 if they match, 1 if stale.
|
||||
*
|
||||
* Run: node sdk/scripts/check-schema-detect-fresh.mjs
|
||||
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildSchemaDetectCjs } from './gen-schema-detect.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildSchemaDetectCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'schema-detect.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('schema-detect.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('schema-detect.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:schema-detect');
|
||||
process.exit(1);
|
||||
}
|
||||
31
sdk/scripts/check-secrets-fresh.mjs
Normal file
31
sdk/scripts/check-secrets-fresh.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for secrets.generated.cjs.
|
||||
*
|
||||
* Regenerates the expected CJS content in-memory (without writing to disk) and
|
||||
* compares it to the committed file. Exits 0 if they match, 1 if stale.
|
||||
*
|
||||
* Run: node sdk/scripts/check-secrets-fresh.mjs
|
||||
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildSecretsCjs } from './gen-secrets.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildSecretsCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'secrets.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('secrets.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('secrets.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:secrets');
|
||||
process.exit(1);
|
||||
}
|
||||
31
sdk/scripts/check-workstream-name-policy-fresh.mjs
Normal file
31
sdk/scripts/check-workstream-name-policy-fresh.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for workstream-name-policy.generated.cjs.
|
||||
*
|
||||
* Regenerates the expected CJS content in-memory (without writing to disk) and
|
||||
* compares it to the committed file. Exits 0 if they match, 1 if stale.
|
||||
*
|
||||
* Run: node sdk/scripts/check-workstream-name-policy-fresh.mjs
|
||||
* (Requires sdk/dist to be built first — `npm run build` in sdk/.)
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildWorkstreamNamePolicyCjs } from './gen-workstream-name-policy.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const expected = await buildWorkstreamNamePolicyCjs();
|
||||
|
||||
const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'workstream-name-policy.generated.cjs');
|
||||
const committed = await readFile(committedPath, 'utf-8');
|
||||
|
||||
if (expected === committed) {
|
||||
console.log('workstream-name-policy.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('workstream-name-policy.generated.cjs is STALE.');
|
||||
console.error('Regenerate: cd sdk && npm run gen:workstream-name-policy');
|
||||
process.exit(1);
|
||||
}
|
||||
100
sdk/scripts/gen-decisions.mjs
Normal file
100
sdk/scripts/gen-decisions.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Decisions CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/query/decisions.js,
|
||||
* extracts the relevant function bodies via text transformation,
|
||||
* then emits get-shit-done/bin/lib/decisions.generated.cjs.
|
||||
*
|
||||
* Source-of-truth: sdk/src/query/decisions.ts
|
||||
*
|
||||
* Run: cd sdk && npm run gen:decisions
|
||||
* Freshness check: node sdk/scripts/check-decisions-fresh.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/decisions.ts
|
||||
* Regenerate: cd sdk && npm run gen:decisions
|
||||
*
|
||||
* Shared parser for CONTEXT.md <decisions> blocks.
|
||||
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
|
||||
* Returns {id, text, category, tags, trackable} per decision.
|
||||
* CJS callers that only use {id, text} safely ignore the extra fields.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
export async function buildDecisionsCjs() {
|
||||
// Read the compiled ESM source and transform to CJS.
|
||||
// We extract only the pure logic (no Node.js imports, no query handler).
|
||||
const distPath = fileURLToPath(new URL('../dist/query/decisions.js', import.meta.url));
|
||||
const src = await readFile(distPath, 'utf-8');
|
||||
|
||||
// Strip the ESM-specific header lines (import statements, jsdoc at top)
|
||||
// and the query handler (which uses Node async fs — not needed in CJS shim).
|
||||
// We keep: DISCRETION_HEADINGS, NON_TRACKABLE_TAGS, stripFencedCode,
|
||||
// extractDecisionsBlock, parseDecisions.
|
||||
|
||||
// Extract the module body between the imports and the query handler.
|
||||
// Strategy: strip the leading imports and the trailing export const decisionsParse block.
|
||||
let body = src;
|
||||
|
||||
// Remove leading import statements
|
||||
body = body.replace(/^import\s+.*?;[\r\n]*/gm, '');
|
||||
|
||||
// Remove trailing query handler (from the `export const decisionsParse` line to end)
|
||||
const handlerStart = body.indexOf('// ─── Query handler');
|
||||
if (handlerStart !== -1) {
|
||||
body = body.slice(0, handlerStart);
|
||||
}
|
||||
|
||||
// Remove ESM export keywords (keep the function/const declarations)
|
||||
body = body.replace(/^export (function|const) /gm, '$1 ');
|
||||
|
||||
// Remove source map comment
|
||||
body = body.replace(/\/\/# sourceMappingURL=.*$/gm, '');
|
||||
|
||||
// Remove leading file-level jsdoc comment (keep module-level logic only)
|
||||
body = body.replace(/^\/\*\*[\s\S]*?\*\/\n/m, '');
|
||||
|
||||
// Trim extra blank lines at start/end
|
||||
body = body.trim();
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
body,
|
||||
'',
|
||||
'module.exports = { parseDecisions };',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildDecisionsCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/decisions.generated.cjs', import.meta.url),
|
||||
);
|
||||
await writeFile(outPath, content, 'utf-8');
|
||||
console.log(`Written: ${outPath}`);
|
||||
}
|
||||
|
||||
// Only run main() when this file is the entry point, not when imported.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
100
sdk/scripts/gen-plan-scan.mjs
Normal file
100
sdk/scripts/gen-plan-scan.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Plan Scan CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/query/plan-scan.js,
|
||||
* extracts function source via Function.prototype.toString() for exports,
|
||||
* then emits get-shit-done/bin/lib/plan-scan.generated.cjs.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:plan-scan
|
||||
* Freshness check: node sdk/scripts/check-plan-scan-fresh.mjs
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/plan-scan.ts
|
||||
* Regenerate: cd sdk && npm run gen:plan-scan
|
||||
*
|
||||
* Plan Scan Module — detects plan and summary files in a phase directory.
|
||||
* Supports both flat (pre-#3139) and nested (post-#3139) layouts.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
export async function buildPlanScanCjs() {
|
||||
// Load the compiled ESM module to get exports via Function.prototype.toString()
|
||||
const distUrl = new URL('../dist/query/plan-scan.js', import.meta.url);
|
||||
const {
|
||||
isRootPlanFile,
|
||||
isNestedPlanFile,
|
||||
isRootSummaryFile,
|
||||
isNestedSummaryFile,
|
||||
scanPhasePlans,
|
||||
} = await import(distUrl.href);
|
||||
|
||||
// Get exported function bodies via Function.prototype.toString()
|
||||
const isRootPlanFileBody = isRootPlanFile.toString();
|
||||
const isNestedPlanFileBody = isNestedPlanFile.toString();
|
||||
const isRootSummaryFileBody = isRootSummaryFile.toString();
|
||||
const isNestedSummaryFileBody = isNestedSummaryFile.toString();
|
||||
const scanPhasePlansBody = scanPhasePlans.toString();
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
"const { existsSync, readdirSync } = require('node:fs');",
|
||||
"const { join } = require('node:path');",
|
||||
'',
|
||||
'// Excluded derivative files',
|
||||
'const PLAN_OUTLINE_RE = /-OUTLINE\\.md$/i;',
|
||||
'const PLAN_PRE_BOUNCE_RE = /\\.pre-bounce\\.md$/i;',
|
||||
'',
|
||||
isRootPlanFileBody,
|
||||
'',
|
||||
isNestedPlanFileBody,
|
||||
'',
|
||||
isRootSummaryFileBody,
|
||||
'',
|
||||
isNestedSummaryFileBody,
|
||||
'',
|
||||
scanPhasePlansBody,
|
||||
'',
|
||||
'// CJS callers do: const scanPhasePlans = require(\'./plan-scan.cjs\')',
|
||||
'// and also destructure named exports — support both call styles.',
|
||||
'module.exports = scanPhasePlans;',
|
||||
'module.exports.scanPhasePlans = scanPhasePlans;',
|
||||
'module.exports.isRootPlanFile = isRootPlanFile;',
|
||||
'module.exports.isNestedPlanFile = isNestedPlanFile;',
|
||||
'module.exports.isRootSummaryFile = isRootSummaryFile;',
|
||||
'module.exports.isNestedSummaryFile = isNestedSummaryFile;',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildPlanScanCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/plan-scan.generated.cjs', import.meta.url),
|
||||
);
|
||||
await writeFile(outPath, content, 'utf-8');
|
||||
console.log(`Written: ${outPath}`);
|
||||
}
|
||||
|
||||
// Only run main() when this file is the entry point, not when imported.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
146
sdk/scripts/gen-schema-detect.mjs
Normal file
146
sdk/scripts/gen-schema-detect.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Schema Detect CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/query/schema-detect.js,
|
||||
* extracts function source via Function.prototype.toString() for exports
|
||||
* and via source-text extraction for internal constants, then emits
|
||||
* get-shit-done/bin/lib/schema-detect.generated.cjs.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:schema-detect
|
||||
* Freshness check: node sdk/scripts/check-schema-detect-fresh.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/schema-detect.ts
|
||||
* Regenerate: cd sdk && npm run gen:schema-detect
|
||||
*
|
||||
* Schema Drift Detection — detects schema-relevant file changes and verifies
|
||||
* that the appropriate database push command was executed during a phase.
|
||||
* This module does not read the filesystem directly.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
/**
|
||||
* Extract a top-level const declaration block (array or object literal)
|
||||
* from a JS source string. Scans for `const <name> = [` or `const <name> = {`
|
||||
* and captures through the balanced closing brace/bracket.
|
||||
*/
|
||||
function extractConstFromSource(source, name) {
|
||||
// Try array form: const NAME = [
|
||||
let arrayMarker = `const ${name} = [`;
|
||||
let start = source.indexOf(arrayMarker);
|
||||
let openChar = '[';
|
||||
let closeChar = ']';
|
||||
|
||||
if (start === -1) {
|
||||
// Try object form: const NAME = {
|
||||
const objectMarker = `const ${name} = {`;
|
||||
start = source.indexOf(objectMarker);
|
||||
openChar = '{';
|
||||
closeChar = '}';
|
||||
if (start === -1) {
|
||||
throw new Error(`Could not find const ${name} in compiled source`);
|
||||
}
|
||||
}
|
||||
|
||||
const braceOpen = source.indexOf(openChar, start);
|
||||
if (braceOpen === -1) throw new Error(`Could not find opening ${openChar} for const ${name}`);
|
||||
|
||||
let depth = 0;
|
||||
let i = braceOpen;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === openChar) depth++;
|
||||
else if (source[i] === closeChar) {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
if (depth !== 0) throw new Error(`Could not find closing ${closeChar} for const ${name}`);
|
||||
|
||||
// Return the full `const NAME = [...];` or `const NAME = {...};`
|
||||
// Find the semicolon after the closing bracket
|
||||
const afterClose = source.indexOf(';', i);
|
||||
const end = afterClose !== -1 ? afterClose + 1 : i + 1;
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
export async function buildSchemaDetectCjs() {
|
||||
const distUrl = new URL('../dist/query/schema-detect.js', import.meta.url);
|
||||
const {
|
||||
detectSchemaFiles,
|
||||
checkSchemaDrift,
|
||||
} = await import(distUrl.href);
|
||||
|
||||
const compiledSource = await readFile(fileURLToPath(distUrl), 'utf-8');
|
||||
|
||||
// Extract non-exported constants from source text
|
||||
const schemaPatternsDecl = extractConstFromSource(compiledSource, 'SCHEMA_PATTERNS');
|
||||
const ormInfoDecl = extractConstFromSource(compiledSource, 'ORM_INFO');
|
||||
|
||||
// Get exported function bodies via Function.prototype.toString()
|
||||
const detectSchemaFilesBody = detectSchemaFiles.toString();
|
||||
const checkSchemaDriftBody = checkSchemaDrift.toString();
|
||||
|
||||
// detectSchemaOrm is not in the SDK but CJS callers may use it.
|
||||
// Reconstruct it as a simple ORM_INFO lookup (same as original secrets.cjs).
|
||||
const detectSchemaOrmBody = `function detectSchemaOrm(ormName) {
|
||||
return ORM_INFO[ormName] || null;
|
||||
}`;
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
'// ─── ORM Patterns ───────────────────────────────────────────────────────────',
|
||||
schemaPatternsDecl,
|
||||
'',
|
||||
'// ─── Push Commands & Evidence Patterns ──────────────────────────────────────',
|
||||
ormInfoDecl,
|
||||
'',
|
||||
'// ─── Public API ──────────────────────────────────────────────────────────────',
|
||||
detectSchemaFilesBody,
|
||||
'',
|
||||
detectSchemaOrmBody,
|
||||
'',
|
||||
checkSchemaDriftBody,
|
||||
'',
|
||||
'module.exports = {',
|
||||
' SCHEMA_PATTERNS,',
|
||||
' ORM_INFO,',
|
||||
' detectSchemaFiles,',
|
||||
' detectSchemaOrm,',
|
||||
' checkSchemaDrift,',
|
||||
'};',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildSchemaDetectCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/schema-detect.generated.cjs', import.meta.url),
|
||||
);
|
||||
await writeFile(outPath, content, 'utf-8');
|
||||
console.log(`Written: ${outPath}`);
|
||||
}
|
||||
|
||||
// Only run main() when this file is the entry point, not when imported.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
88
sdk/scripts/gen-secrets.mjs
Normal file
88
sdk/scripts/gen-secrets.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Secrets CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/query/secrets.js,
|
||||
* extracts function source via Function.prototype.toString() for exports,
|
||||
* then emits get-shit-done/bin/lib/secrets.generated.cjs.
|
||||
*
|
||||
* Run: cd sdk && npm run gen:secrets
|
||||
* Freshness check: node sdk/scripts/check-secrets-fresh.mjs
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/query/secrets.ts
|
||||
* Regenerate: cd sdk && npm run gen:secrets
|
||||
*
|
||||
* Secrets handling — masking convention for API keys and other
|
||||
* credentials managed via /gsd-settings-integrations.
|
||||
* This module does not read the filesystem.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
export async function buildSecretsCjs() {
|
||||
// Load the compiled ESM module to get exports via Function.prototype.toString()
|
||||
const distUrl = new URL('../dist/query/secrets.js', import.meta.url);
|
||||
const {
|
||||
SECRET_CONFIG_KEYS,
|
||||
isSecretKey,
|
||||
maskSecret,
|
||||
maskIfSecret,
|
||||
} = await import(distUrl.href);
|
||||
|
||||
// Get exported function bodies via Function.prototype.toString()
|
||||
const isSecretKeyBody = isSecretKey.toString();
|
||||
const maskSecretBody = maskSecret.toString();
|
||||
const maskIfSecretBody = maskIfSecret.toString();
|
||||
|
||||
// SECRET_CONFIG_KEYS is a Set — reconstruct it as a constant declaration
|
||||
const secretKeys = [...SECRET_CONFIG_KEYS];
|
||||
const secretKeysLiteral = secretKeys.map(k => ` '${k}',`).join('\n');
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
'const SECRET_CONFIG_KEYS = new Set([',
|
||||
secretKeysLiteral,
|
||||
']);',
|
||||
'',
|
||||
isSecretKeyBody,
|
||||
'',
|
||||
maskSecretBody,
|
||||
'',
|
||||
maskIfSecretBody,
|
||||
'',
|
||||
'module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret };',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildSecretsCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/secrets.generated.cjs', import.meta.url),
|
||||
);
|
||||
await writeFile(outPath, content, 'utf-8');
|
||||
console.log(`Written: ${outPath}`);
|
||||
}
|
||||
|
||||
// Only run main() when this file is the entry point, not when imported.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -132,9 +132,10 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// Only run main() when this file is the entry point, not when imported.
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const entryPath = process.argv[1] ? new URL(process.argv[1], 'file://').pathname : '';
|
||||
if (scriptPath === entryPath || process.argv[1] === scriptPath) {
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -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);
|
||||
|
||||
96
sdk/scripts/gen-workstream-name-policy.mjs
Normal file
96
sdk/scripts/gen-workstream-name-policy.mjs
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for the Workstream Name Policy CJS artifact.
|
||||
*
|
||||
* Reads the compiled ESM output from sdk/dist/workstream-name-policy.js,
|
||||
* extracts function source via text transformation,
|
||||
* then emits get-shit-done/bin/lib/workstream-name-policy.generated.cjs.
|
||||
*
|
||||
* Source-of-truth: sdk/src/workstream-name-policy.ts
|
||||
*
|
||||
* Run: cd sdk && npm run gen:workstream-name-policy
|
||||
* Freshness check: node sdk/scripts/check-workstream-name-policy-fresh.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const BANNER = `'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/workstream-name-policy.ts
|
||||
* Regenerate: cd sdk && npm run gen:workstream-name-policy
|
||||
*
|
||||
* Canonical workstream name validation and slug normalization.
|
||||
* Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
export async function buildWorkstreamNamePolicyCjs() {
|
||||
// Read the compiled ESM source and transform to CJS.
|
||||
const distPath = fileURLToPath(new URL('../dist/workstream-name-policy.js', import.meta.url));
|
||||
const src = await readFile(distPath, 'utf-8');
|
||||
|
||||
// Transform ESM to CJS:
|
||||
// 1. Remove import statements (none expected in this file)
|
||||
// 2. Remove ESM export keywords
|
||||
// 3. Remove source map comment
|
||||
// 4. Remove leading jsdoc comment
|
||||
// 5. Add module.exports at end
|
||||
|
||||
let body = src;
|
||||
|
||||
// Remove leading import statements (if any)
|
||||
body = body.replace(/^import\s+.*?;[\r\n]*/gm, '');
|
||||
|
||||
// Remove ESM export keywords (keep function/const declarations)
|
||||
body = body.replace(/^export (function|const) /gm, '$1 ');
|
||||
|
||||
// Remove source map comment
|
||||
body = body.replace(/\/\/# sourceMappingURL=.*$/gm, '');
|
||||
|
||||
// Remove leading file-level jsdoc comment (keep module-level logic only)
|
||||
body = body.replace(/^\/\*\*[\s\S]*?\*\/\n/m, '');
|
||||
|
||||
// Trim extra blank lines at start/end
|
||||
body = body.trim();
|
||||
|
||||
const parts = [
|
||||
BANNER.trimEnd(),
|
||||
'',
|
||||
body,
|
||||
'',
|
||||
'module.exports = {',
|
||||
' validateWorkstreamName,',
|
||||
' toWorkstreamSlug,',
|
||||
' hasInvalidPathSegment,',
|
||||
' isValidActiveWorkstreamName,',
|
||||
'};',
|
||||
'',
|
||||
];
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await buildWorkstreamNamePolicyCjs();
|
||||
const outPath = fileURLToPath(
|
||||
new URL('../../get-shit-done/bin/lib/workstream-name-policy.generated.cjs', import.meta.url),
|
||||
);
|
||||
await writeFile(outPath, content, 'utf-8');
|
||||
console.log(`Written: ${outPath}`);
|
||||
}
|
||||
|
||||
// Only run main() when this file is the entry point, not when imported.
|
||||
// process.argv[1] is already an absolute filesystem path on every platform Node
|
||||
// supports; comparing directly avoids the Windows URL-parsing bug where
|
||||
// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -476,16 +476,12 @@ describe('Golden file tests', () => {
|
||||
});
|
||||
|
||||
it('state.prune dry-run matches gsd-tools.cjs', async () => {
|
||||
// Prune needs a parseable current_phase. Use fresh dirs with a STATE.md
|
||||
// whose frontmatter includes current_phase so both CJS and SDK agree.
|
||||
// CJS extracts current phase from disk-counted phases (result: 0 phases → "Only 0 phases..."),
|
||||
// SDK extracts from frontmatter current_phase field.
|
||||
// Use only 2 keepRecent phases, leaving phases dir empty so CJS reports "Only 0 phases"
|
||||
// and SDK also bails early (current_phase=10, cutoff=7, but no phases to scan → same reason).
|
||||
// Align via a fixture that has current_phase in frontmatter AND no phases on disk.
|
||||
// Both CJS and SDK read `Current Phase` from the STATE.md body text
|
||||
// (CJS: stateExtractField(content, 'Current Phase'), SDK: same).
|
||||
// MINIMAL_STATE has no `Current Phase:` field → both default to 0 →
|
||||
// cutoff = 0 - 3 = -3 ≤ 0 → "Only 0 phases — nothing to prune with --keep-recent 3".
|
||||
const gsdDir2 = join(tmpdir(), `gsd-golden-prune-gsd-${Date.now()}`);
|
||||
const sdkDir2 = join(tmpdir(), `gsd-golden-prune-sdk-${Date.now()}`);
|
||||
// Minimal state — no phases on disk, prune returns "Only N phases — nothing to prune"
|
||||
try {
|
||||
await setupMinimalStateProject(gsdDir2);
|
||||
await setupMinimalStateProject(sdkDir2);
|
||||
@@ -493,41 +489,28 @@ describe('Golden file tests', () => {
|
||||
const gsdOutput = await captureGsdToolsOutput('state', argv, gsdDir2);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('state.prune', ['--keep-recent', '3', '--dry-run'], sdkDir2);
|
||||
// Both should return pruned:false. Exact reason may differ (CJS: phase count from disk;
|
||||
// SDK: phase count from frontmatter). Compare just the structural result.
|
||||
const sdkData = sdkResult.data as Record<string, unknown>;
|
||||
const gsdData = gsdOutput as Record<string, unknown>;
|
||||
expect(sdkData.pruned).toBe(false);
|
||||
expect(gsdData.pruned).toBe(false);
|
||||
expect(typeof sdkData.reason).toBe('string');
|
||||
expect(typeof gsdData.reason).toBe('string');
|
||||
// Exact equality — both CJS and SDK now use the same phase extraction logic.
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
} finally {
|
||||
await rm(gsdDir2, { recursive: true, force: true });
|
||||
await rm(sdkDir2, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('state.record-metric matches gsd-tools.cjs (no-metrics-section → divergence documented)', async () => {
|
||||
// Divergence: CJS auto-creates the Performance Metrics section when absent;
|
||||
// SDK returns { recorded: false, reason: '...' }. We test both via fresh dirs
|
||||
// and add a metrics section to align behavior for parity.
|
||||
it('state.record-metric matches gsd-tools.cjs (no-metrics-section → SDK auto-creates like CJS)', async () => {
|
||||
// SDK now auto-creates the ## Performance Metrics section when absent,
|
||||
// matching CJS DWIM behavior. Test with no pre-seeded section to exercise
|
||||
// the auto-create path on both sides.
|
||||
const gsdDir2 = join(tmpdir(), `gsd-golden-state-metric-gsd-${Date.now()}`);
|
||||
const sdkDir2 = join(tmpdir(), `gsd-golden-state-metric-sdk-${Date.now()}`);
|
||||
try {
|
||||
const metricsState = MINIMAL_STATE + [
|
||||
'',
|
||||
'## Performance Metrics',
|
||||
'',
|
||||
'| Phase | Plan | Duration | Notes |',
|
||||
'|-------|------|----------|-------|',
|
||||
'',
|
||||
].join('\n');
|
||||
// Use MINIMAL_STATE (no metrics section) — both sides should auto-create it.
|
||||
await mkdir(join(gsdDir2, '.planning', 'phases'), { recursive: true });
|
||||
await writeFile(join(gsdDir2, '.planning', 'STATE.md'), metricsState, 'utf-8');
|
||||
await writeFile(join(gsdDir2, '.planning', 'STATE.md'), MINIMAL_STATE, 'utf-8');
|
||||
await writeFile(join(gsdDir2, '.planning', 'ROADMAP.md'), '# Roadmap\n', 'utf-8');
|
||||
await writeFile(join(gsdDir2, '.planning', 'config.json'), '{"model_profile":"balanced"}', 'utf-8');
|
||||
await mkdir(join(sdkDir2, '.planning', 'phases'), { recursive: true });
|
||||
await writeFile(join(sdkDir2, '.planning', 'STATE.md'), metricsState, 'utf-8');
|
||||
await writeFile(join(sdkDir2, '.planning', 'STATE.md'), MINIMAL_STATE, 'utf-8');
|
||||
await writeFile(join(sdkDir2, '.planning', 'ROADMAP.md'), '# Roadmap\n', 'utf-8');
|
||||
await writeFile(join(sdkDir2, '.planning', 'config.json'), '{"model_profile":"balanced"}', 'utf-8');
|
||||
|
||||
@@ -535,6 +518,7 @@ describe('Golden file tests', () => {
|
||||
const gsdOutput = await captureGsdToolsOutput('state', argv, gsdDir2);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('state.record-metric', ['--phase', '10', '--plan', '1', '--duration', '45m', '--tasks', '12', '--files', '8'], sdkDir2);
|
||||
// Exact equality — SDK now auto-creates Performance Metrics section like CJS.
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
} finally {
|
||||
await rm(gsdDir2, { recursive: true, force: true });
|
||||
@@ -903,4 +887,145 @@ describe('Golden file tests', () => {
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 6: verify.* parity tests ────────────────────────────────────────
|
||||
|
||||
describe('verify.references', () => {
|
||||
it('SDK JSON matches gsd-tools.cjs', async () => {
|
||||
const testPhase = '9';
|
||||
const gsdOutput = await captureGsdToolsOutput('verify', ['references', testPhase], REPO_ROOT);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('verify.references', [testPhase], REPO_ROOT);
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify.commits', () => {
|
||||
it('SDK JSON matches gsd-tools.cjs', async () => {
|
||||
const testPhase = '9';
|
||||
const gsdOutput = await captureGsdToolsOutput('verify', ['commits', testPhase], REPO_ROOT);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('verify.commits', [testPhase], REPO_ROOT);
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify.artifacts', () => {
|
||||
it('SDK JSON matches gsd-tools.cjs', async () => {
|
||||
const testPhase = '9';
|
||||
const gsdOutput = await captureGsdToolsOutput('verify', ['artifacts', testPhase], REPO_ROOT);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('verify.artifacts', [testPhase], REPO_ROOT);
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify.key-links', () => {
|
||||
it('SDK JSON matches gsd-tools.cjs', async () => {
|
||||
const testPhase = '9';
|
||||
const gsdOutput = await captureGsdToolsOutput('verify', ['key-links', testPhase], REPO_ROOT);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('verify.key-links', [testPhase], REPO_ROOT);
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify.schema-drift', () => {
|
||||
it('SDK JSON matches gsd-tools.cjs', async () => {
|
||||
const testPhase = '9';
|
||||
const gsdOutput = await captureGsdToolsOutput('verify', ['schema-drift', testPhase], REPO_ROOT);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('verify.schema-drift', [testPhase], REPO_ROOT);
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify.codebase-drift', () => {
|
||||
it('SDK JSON matches gsd-tools.cjs', async () => {
|
||||
const gsdOutput = await captureGsdToolsOutput('verify', ['codebase-drift'], REPO_ROOT);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('verify.codebase-drift', [], REPO_ROOT);
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 6: roadmap.* parity tests ───────────────────────────────────────
|
||||
|
||||
describe('roadmap.annotate-dependencies', () => {
|
||||
it('roadmap.annotate-dependencies matches gsd-tools.cjs on fixture', async () => {
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const gsdDir = join(tmpdir(), `gsd-golden-roadmap-annotate-gsd-${suffix}`);
|
||||
const sdkDir = join(tmpdir(), `gsd-golden-roadmap-annotate-sdk-${suffix}`);
|
||||
try {
|
||||
await setupPhasesFixture(gsdDir);
|
||||
await setupPhasesFixture(sdkDir);
|
||||
const gsdOutput = await captureGsdToolsOutput('roadmap', ['annotate-dependencies', '10'], gsdDir);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('roadmap.annotate-dependencies', ['10'], sdkDir);
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
} finally {
|
||||
await rm(gsdDir, { recursive: true, force: true });
|
||||
await rm(sdkDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 6: phase.* parity tests ────────────────────────────────────────
|
||||
|
||||
describe('phase.next-decimal', () => {
|
||||
it('phase.next-decimal matches gsd-tools.cjs on fixture', async () => {
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const gsdDir = join(tmpdir(), `gsd-golden-phase-nd-gsd-${suffix}`);
|
||||
const sdkDir = join(tmpdir(), `gsd-golden-phase-nd-sdk-${suffix}`);
|
||||
try {
|
||||
await setupMinimalStateProject(gsdDir);
|
||||
await setupMinimalStateProject(sdkDir);
|
||||
const gsdOutput = await captureGsdToolsOutput('phase', ['next-decimal', '10'], gsdDir);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('phase.next-decimal', ['10'], sdkDir);
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
} finally {
|
||||
await rm(gsdDir, { recursive: true, force: true });
|
||||
await rm(sdkDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase.remove and phase.complete', () => {
|
||||
it('phase.remove matches gsd-tools.cjs on fixture', async () => {
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const gsdDir = join(tmpdir(), `gsd-golden-phase-rm-gsd-${suffix}`);
|
||||
const sdkDir = join(tmpdir(), `gsd-golden-phase-rm-sdk-${suffix}`);
|
||||
try {
|
||||
await setupPhasesFixture(gsdDir);
|
||||
await setupPhasesFixture(sdkDir);
|
||||
// Both remove phase 11 (complete in fixture, safe to remove with --force)
|
||||
const gsdOutput = await captureGsdToolsOutput('phase', ['remove', '11', '--force'], gsdDir);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('phase.remove', ['11', '--force'], sdkDir);
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
} finally {
|
||||
await rm(gsdDir, { recursive: true, force: true });
|
||||
await rm(sdkDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('phase.complete matches gsd-tools.cjs on fixture', async () => {
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const gsdDir = join(tmpdir(), `gsd-golden-phase-complete-gsd-${suffix}`);
|
||||
const sdkDir = join(tmpdir(), `gsd-golden-phase-complete-sdk-${suffix}`);
|
||||
try {
|
||||
await setupPhasesFixture(gsdDir);
|
||||
await setupPhasesFixture(sdkDir);
|
||||
// Both complete phase 10 (which is in the fixture ROADMAP)
|
||||
const gsdOutput = await captureGsdToolsOutput('phase', ['complete', '10'], gsdDir);
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('phase.complete', ['10'], sdkDir);
|
||||
expect(sdkResult.data).toEqual(gsdOutput);
|
||||
} finally {
|
||||
await rm(gsdDir, { recursive: true, force: true });
|
||||
await rm(sdkDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,25 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st
|
||||
return Array.isArray(u) && u.length > 0 ? 'true' : 'false';
|
||||
}
|
||||
|
||||
// #3631: CJS handlers projected these to a scalar under --raw. Mirror that
|
||||
// here so SDK dispatch matches CJS behaviour when family routers request
|
||||
// mode: 'raw' on the bridge.
|
||||
if (registryCommand === 'phase.next-decimal') {
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
const next = (data as Record<string, unknown>).next;
|
||||
if (typeof next === 'string') return next;
|
||||
}
|
||||
return safeStringify(data);
|
||||
}
|
||||
|
||||
if (registryCommand === 'roadmap.get-phase') {
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
const section = (data as Record<string, unknown>).section;
|
||||
if (typeof section === 'string') return section;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ export const VERIFY_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [
|
||||
{ canonical: 'verify.artifacts', aliases: ['verify artifacts'], subcommand: 'artifacts', mutation: false },
|
||||
{ canonical: 'verify.key-links', aliases: ['verify key-links'], subcommand: 'key-links', mutation: false },
|
||||
{ canonical: 'verify.schema-drift', aliases: ['verify schema-drift'], subcommand: 'schema-drift', mutation: false },
|
||||
{ canonical: 'verify.codebase-drift', aliases: ['verify codebase-drift'], subcommand: 'codebase-drift', mutation: false },
|
||||
] as const;
|
||||
|
||||
export const INIT_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [
|
||||
@@ -122,8 +121,6 @@ export const NON_FAMILY_COMMAND_ALIASES: readonly NonFamilyCommandAlias[] = [
|
||||
{ canonical: 'generate-claude-md', aliases: [], mutation: true },
|
||||
{ canonical: 'generate-claude-profile', aliases: [], mutation: true },
|
||||
{ canonical: 'generate-dev-preferences', aliases: [], mutation: true },
|
||||
{ canonical: 'intel.patch-meta', aliases: ['intel patch-meta'], mutation: true },
|
||||
{ canonical: 'intel.snapshot', aliases: ['intel snapshot'], mutation: true },
|
||||
{ canonical: 'learnings.copy', aliases: ['learnings copy'], mutation: true },
|
||||
{ canonical: 'learnings.delete', aliases: ['learnings delete'], mutation: true },
|
||||
{ canonical: 'learnings.prune', aliases: ['learnings prune'], mutation: true },
|
||||
|
||||
@@ -15,8 +15,11 @@ import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
|
||||
import {
|
||||
verifyPlanStructure, verifyPhaseCompleteness, verifyReferences,
|
||||
verifyCommits, verifyArtifacts, verifySchemaDrift,
|
||||
verifyCodebaseDrift,
|
||||
} from './verify.js';
|
||||
// verifyCodebaseDrift intentionally NOT imported — drift is out-of-seam
|
||||
// (CJS-only) per ADR/PRD docs/adr/3524-cjs-sdk-hard-seam.md §3 and
|
||||
// docs/prd/3524-cjs-sdk-hard-seam.md L160. The CJS router dispatches
|
||||
// verify codebase-drift directly to bin/lib/drift.cjs / verify.cjs.
|
||||
import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, validateContext } from './validate.js';
|
||||
import {
|
||||
phaseListPlans, phaseListArtifacts,
|
||||
@@ -71,7 +74,8 @@ export const FAMILY_HANDLERS: Record<string, Readonly<Record<string, QueryHandle
|
||||
'verify.artifacts': verifyArtifacts,
|
||||
'verify.key-links': verifyKeyLinks,
|
||||
'verify.schema-drift': verifySchemaDrift,
|
||||
'verify.codebase-drift': verifyCodebaseDrift,
|
||||
// 'verify.codebase-drift' intentionally omitted — out-of-seam CJS-only
|
||||
// per ADR/PRD 3524 §3 / L160. Router dispatches direct to CJS handler.
|
||||
},
|
||||
validate: {
|
||||
'validate.consistency': validateConsistency,
|
||||
|
||||
@@ -71,8 +71,9 @@ export const NON_FAMILY_COMMAND_MANIFEST: readonly NonFamilyCommandManifestEntry
|
||||
{ canonical: 'learnings.prune', aliases: ['learnings prune'], mutation: true, outputMode: 'json' },
|
||||
{ canonical: 'learnings.delete', aliases: ['learnings delete'], mutation: true, outputMode: 'json' },
|
||||
|
||||
{ canonical: 'intel.snapshot', aliases: ['intel snapshot'], mutation: true, outputMode: 'json' },
|
||||
{ canonical: 'intel.patch-meta', aliases: ['intel patch-meta'], mutation: true, outputMode: 'json' },
|
||||
// intel.* entries intentionally NOT in this manifest — intel is out-of-seam
|
||||
// (CJS-only) per ADR/PRD 3524 §3 / L160. Dispatch is direct from
|
||||
// get-shit-done/bin/gsd-tools.cjs `case 'intel':` to bin/lib/intel.cjs.
|
||||
|
||||
{ canonical: 'write-profile', aliases: [], mutation: true, outputMode: 'json' },
|
||||
{ canonical: 'generate-claude-profile', aliases: [], mutation: true, outputMode: 'json' },
|
||||
|
||||
@@ -11,5 +11,7 @@ export const VERIFY_COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
||||
{ family: 'verify', canonical: 'verify.artifacts', aliases: ['verify artifacts'], mutation: false, outputMode: 'json' },
|
||||
{ family: 'verify', canonical: 'verify.key-links', aliases: ['verify key-links'], mutation: false, outputMode: 'json' },
|
||||
{ family: 'verify', canonical: 'verify.schema-drift', aliases: ['verify schema-drift'], mutation: false, outputMode: 'json' },
|
||||
{ family: 'verify', canonical: 'verify.codebase-drift', aliases: ['verify codebase-drift'], mutation: false, outputMode: 'json' },
|
||||
// verify.codebase-drift intentionally NOT in this manifest — drift is
|
||||
// out-of-seam (CJS-only) per ADR/PRD 3524 §3 / L160. It dispatches
|
||||
// direct to bin/lib/drift.cjs via the CJS router.
|
||||
] as const;
|
||||
|
||||
@@ -13,7 +13,11 @@ import { skillManifest } from './skill-manifest.js';
|
||||
import { auditOpen } from './audit-open.js';
|
||||
import { detectCustomFiles } from './detect-custom-files.js';
|
||||
import { uatRenderCheckpoint, auditUat } from './uat.js';
|
||||
import { intelStatus, intelDiff, intelSnapshot, intelValidate, intelQuery, intelExtractExports, intelPatchMeta, intelUpdate } from './intel.js';
|
||||
// intel.* handlers intentionally NOT imported — intel (bin/lib/intel.cjs) is
|
||||
// out-of-seam (CJS-only) per ADR/PRD docs/adr/3524-cjs-sdk-hard-seam.md §3
|
||||
// and docs/prd/3524-cjs-sdk-hard-seam.md L160. Dispatch is handled directly
|
||||
// by the `case 'intel':` branch in get-shit-done/bin/gsd-tools.cjs which
|
||||
// requires('./lib/intel.cjs') and calls the CJS functions in-process.
|
||||
import { writeProfile, generateClaudeProfile, generateDevPreferences, generateClaudeMd } from './profile-output.js';
|
||||
import { phaseMvpMode, taskIsBehaviorAdding, userStoryValidate } from './mvp.js';
|
||||
import { worktreeCleanupWave } from './worktree.js';
|
||||
@@ -84,22 +88,8 @@ export const DOMAIN_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler
|
||||
['audit-uat', auditUat],
|
||||
['uat.render-checkpoint', uatRenderCheckpoint],
|
||||
['uat render-checkpoint', uatRenderCheckpoint],
|
||||
['intel.diff', intelDiff],
|
||||
['intel diff', intelDiff],
|
||||
['intel.snapshot', intelSnapshot],
|
||||
['intel snapshot', intelSnapshot],
|
||||
['intel.validate', intelValidate],
|
||||
['intel validate', intelValidate],
|
||||
['intel.status', intelStatus],
|
||||
['intel status', intelStatus],
|
||||
['intel.query', intelQuery],
|
||||
['intel query', intelQuery],
|
||||
['intel.extract-exports', intelExtractExports],
|
||||
['intel extract-exports', intelExtractExports],
|
||||
['intel.patch-meta', intelPatchMeta],
|
||||
['intel patch-meta', intelPatchMeta],
|
||||
['intel.update', intelUpdate],
|
||||
['intel update', intelUpdate],
|
||||
// intel.* entries removed — see import-section comment above. Intel verbs
|
||||
// dispatch via bin/gsd-tools.cjs `case 'intel':` direct to bin/lib/intel.cjs.
|
||||
['generate-claude-profile', generateClaudeProfile],
|
||||
['generate-dev-preferences', generateDevPreferences],
|
||||
['write-profile', writeProfile],
|
||||
|
||||
@@ -55,7 +55,15 @@ export const MUTATION_SURFACES_STATIC_CATALOG: ReadonlyArray<readonly [string, Q
|
||||
['config-set', configSet],
|
||||
['config-set-model-profile', configSetModelProfile],
|
||||
['config-new-project', configNewProject],
|
||||
['config-ensure-section', configEnsureSection],
|
||||
// Legacy contract: `config-ensure-section` (no positional arg) initialises
|
||||
// the full default .planning/config.json — semantically identical to
|
||||
// `config-new-project` with empty userChoices. Phase 6 originally bound
|
||||
// this to the `configEnsureSection` single-section-ensure handler, which
|
||||
// requires args[0]=sectionName the CLI never passes. Re-binding to
|
||||
// configNewProject restores the legacy contract on the SDK path.
|
||||
// `configEnsureSection` itself remains exported for any future SDK caller
|
||||
// that wants the single-section semantics.
|
||||
['config-ensure-section', configNewProject],
|
||||
['commit', commit],
|
||||
['check-commit', checkCommit],
|
||||
['template.fill', templateFill],
|
||||
|
||||
@@ -24,6 +24,7 @@ import { join } from 'node:path';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import { VALID_PROFILES, getAgentToModelMapForProfile } from './config-query.js';
|
||||
import { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS } from './config-schema.js';
|
||||
import { CONFIG_DEFAULTS } from '../configuration/index.js';
|
||||
import { planningPaths } from './helpers.js';
|
||||
import { acquireStateLock, releaseStateLock } from './state-mutation.js';
|
||||
import { maskIfSecret } from './secrets.js';
|
||||
@@ -281,7 +282,7 @@ export const configSet: QueryHandler = async (args, projectDir, workstream) => {
|
||||
if (!validation.valid) {
|
||||
const suggestion = validation.suggestion ? `. Did you mean: ${validation.suggestion}?` : '';
|
||||
throw new GSDError(
|
||||
`Unknown config key: "${keyPath}"${suggestion}`,
|
||||
`Unknown config key: ${keyPath}${suggestion}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
@@ -301,6 +302,123 @@ export const configSet: QueryHandler = async (args, projectDir, workstream) => {
|
||||
validateShipPrBodySections(parsedValue);
|
||||
}
|
||||
|
||||
// CJS parity (config.cjs:430-441): boolean-only keys must reject non-boolean
|
||||
// input. Without this, `config-set git.create_tag maybe` silently writes
|
||||
// "maybe" to disk under SDK dispatch even though the CJS path correctly
|
||||
// rejects it. Bug #3086.
|
||||
if (keyPath === 'workflow.post_planning_gaps' && typeof parsedValue !== 'boolean') {
|
||||
throw new GSDError(
|
||||
`Invalid workflow.post_planning_gaps '${rawValue}'. Must be a boolean (true or false).`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (keyPath === 'git.create_tag' && typeof parsedValue !== 'boolean') {
|
||||
throw new GSDError(
|
||||
`Invalid git.create_tag '${rawValue}'. Must be a boolean (true or false).`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
// Codebase drift detector value validation — port of config.cjs:430-437. (#2003)
|
||||
const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
|
||||
if (keyPath === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
|
||||
throw new GSDError(
|
||||
`Invalid workflow.drift_action '${rawValue}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (keyPath === 'workflow.drift_threshold') {
|
||||
if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) {
|
||||
throw new GSDError(
|
||||
`Invalid workflow.drift_threshold '${rawValue}'. Must be a positive integer.`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Human verification checkpoint mode (#3309) — port of config.cjs:457-460.
|
||||
const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase'];
|
||||
if (keyPath === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
|
||||
throw new GSDError(
|
||||
`Invalid workflow.human_verify_mode '${rawValue}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
// Context position enum validation (#2937) — port of config.cjs:463-466.
|
||||
const VALID_CONTEXT_POSITIONS = ['front', 'end'];
|
||||
if (keyPath === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
|
||||
throw new GSDError(
|
||||
`Invalid statusline.context_position '${rawValue}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallow scope + profile enum validation (#3424) — port of config.cjs:469-477.
|
||||
const VALID_FALLOW_SCOPES = ['phase', 'repo'];
|
||||
if (keyPath === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) {
|
||||
throw new GSDError(
|
||||
`Invalid code_quality.fallow.scope '${rawValue}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict'];
|
||||
if (keyPath === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) {
|
||||
throw new GSDError(
|
||||
`Invalid code_quality.fallow.profile '${rawValue}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
// review.default_reviewers (#3079) — port of normalizeConfiguredDefaultReviewers
|
||||
// from bin/lib/review-reviewer-selection.cjs. Validates array shape, rejects
|
||||
// empties, requires string slugs matching ^[a-zA-Z0-9_-]+$, and normalizes to
|
||||
// lowercase-unique order. `parsedValue` is rewritten in place so the persisted
|
||||
// value carries the normalized form (matching CJS config.cjs:479-483 behavior).
|
||||
let normalizedValue: unknown = parsedValue;
|
||||
if (keyPath === 'review.default_reviewers') {
|
||||
if (parsedValue === null || parsedValue === undefined) {
|
||||
throw new GSDError(
|
||||
'review.default_reviewers must be a JSON array of reviewer slugs',
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(parsedValue)) {
|
||||
throw new GSDError(
|
||||
'review.default_reviewers must be a JSON array of reviewer slugs',
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (parsedValue.length === 0) {
|
||||
throw new GSDError(
|
||||
'review.default_reviewers cannot be empty',
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const item of parsedValue) {
|
||||
if (typeof item !== 'string') {
|
||||
throw new GSDError(
|
||||
'review.default_reviewers must contain only string slugs',
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(item)) {
|
||||
throw new GSDError(
|
||||
`invalid reviewer slug in review.default_reviewers: ${item}`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
const slug = item.toLowerCase();
|
||||
if (!seen.has(slug)) {
|
||||
seen.add(slug);
|
||||
normalized.push(slug);
|
||||
}
|
||||
}
|
||||
normalizedValue = normalized;
|
||||
}
|
||||
|
||||
// D6: Lock protection for read-modify-write (match CJS config.cjs:296)
|
||||
const paths = planningPaths(projectDir, workstream);
|
||||
const lockPath = await acquireStateLock(paths.config);
|
||||
@@ -315,7 +433,7 @@ export const configSet: QueryHandler = async (args, projectDir, workstream) => {
|
||||
}
|
||||
|
||||
previousValue = getValueAtPath(config, keyPath);
|
||||
setConfigValue(config, keyPath, parsedValue);
|
||||
setConfigValue(config, keyPath, normalizedValue);
|
||||
await atomicWriteConfig(paths.config, config);
|
||||
} finally {
|
||||
await releaseStateLock(lockPath);
|
||||
@@ -449,47 +567,57 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea
|
||||
const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || existsSync(join(homeDir, '.gsd', 'firecrawl_api_key')));
|
||||
const hasExaSearch = !!(process.env.EXA_API_KEY || existsSync(join(homeDir, '.gsd', 'exa_api_key')));
|
||||
|
||||
// Build default config
|
||||
// Build default config. Source is the canonical Configuration Module manifest
|
||||
// at sdk/shared/config-defaults.manifest.json (CONFIG_DEFAULTS from
|
||||
// sdk/src/configuration/index.ts) — but ONLY a subset is materialized at
|
||||
// init time. Legacy CJS `buildNewProjectConfig` (bin/lib/config.cjs:155-210)
|
||||
// intentionally omits keys whose value is meaningful only when set
|
||||
// explicitly so config-get returns "Key not found" and workflows fall back
|
||||
// to auto-detect (e.g. git.base_branch falls back to origin/HEAD
|
||||
// resolution). Keeping the SDK init shape aligned with CJS preserves that
|
||||
// workflow contract while the manifest remains the schema-wide source of
|
||||
// truth for validation and key existence (per ADR §6).
|
||||
//
|
||||
// Runtime API-key detection overrides the manifest's `false` defaults for
|
||||
// the three search providers — manifest comment explicitly notes this.
|
||||
const manifestDefaults = CONFIG_DEFAULTS as Record<string, unknown>;
|
||||
// Strip the metadata-only "_comment" key before it gets persisted.
|
||||
const { _comment: _ignoredComment, ...sanitizedManifest } = manifestDefaults;
|
||||
void _ignoredComment;
|
||||
|
||||
// Top-level keys present in the manifest but NOT in CJS init output. Each
|
||||
// either has its own resolution path (resolve_model_ids, context_window,
|
||||
// mode) or lives under a non-init heading (planning.*, graphify.* are
|
||||
// opt-in features users configure separately).
|
||||
const TOP_LEVEL_OMITTED_FROM_INIT = new Set([
|
||||
'resolve_model_ids', 'context_window', 'mode', 'planning', 'graphify',
|
||||
]);
|
||||
// Nested git keys omitted by CJS init. `git.base_branch` triggers
|
||||
// origin/HEAD auto-detect when absent — materializing `null` here would
|
||||
// suppress that and break ship-ready preflight (#3079).
|
||||
const GIT_KEYS_OMITTED_FROM_INIT = new Set(['base_branch']);
|
||||
|
||||
const filteredTopLevel: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(sanitizedManifest)) {
|
||||
if (TOP_LEVEL_OMITTED_FROM_INIT.has(k)) continue;
|
||||
filteredTopLevel[k] = v;
|
||||
}
|
||||
const manifestGit = (filteredTopLevel.git as Record<string, unknown>) || {};
|
||||
const filteredGit: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(manifestGit)) {
|
||||
if (GIT_KEYS_OMITTED_FROM_INIT.has(k)) continue;
|
||||
filteredGit[k] = v;
|
||||
}
|
||||
|
||||
const defaults: Record<string, unknown> = {
|
||||
model_profile: 'balanced',
|
||||
commit_docs: false,
|
||||
parallelization: 1,
|
||||
search_gitignored: false,
|
||||
...filteredTopLevel,
|
||||
git: filteredGit,
|
||||
brave_search: hasBraveSearch,
|
||||
firecrawl: hasFirecrawl,
|
||||
exa_search: hasExaSearch,
|
||||
git: {
|
||||
branching_strategy: 'none',
|
||||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||||
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
||||
quick_branch_template: null,
|
||||
},
|
||||
workflow: {
|
||||
research: true,
|
||||
plan_check: true,
|
||||
verifier: true,
|
||||
nyquist_validation: true,
|
||||
auto_advance: false,
|
||||
node_repair: true,
|
||||
node_repair_budget: 2,
|
||||
ui_phase: true,
|
||||
ui_safety_gate: true,
|
||||
text_mode: false,
|
||||
research_before_questions: false,
|
||||
discuss_mode: 'discuss',
|
||||
skip_discuss: false,
|
||||
code_review: true,
|
||||
code_review_depth: 'standard',
|
||||
},
|
||||
ship: {
|
||||
pr_body_sections: [],
|
||||
},
|
||||
hooks: {
|
||||
context_warnings: true,
|
||||
},
|
||||
project_code: null,
|
||||
phase_naming: 'sequential',
|
||||
agent_skills: {},
|
||||
// CJS `buildNewProjectConfig` includes `features: {}` as a hardcoded
|
||||
// top-level slot; the manifest doesn't yet — keep parity until the
|
||||
// manifest is amended in a separate enhancement.
|
||||
features: {},
|
||||
};
|
||||
|
||||
@@ -535,7 +663,9 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea
|
||||
|
||||
await atomicWriteConfig(paths.config, config);
|
||||
|
||||
return { data: { created: true, path: paths.config } };
|
||||
// Match CJS `ensureConfigFile` shape: report the relative project-rooted
|
||||
// path so output stays workspace-portable.
|
||||
return { data: { created: true, path: '.planning/config.json' } };
|
||||
};
|
||||
|
||||
// ─── configEnsureSection ──────────────────────────────────────────────────
|
||||
|
||||
@@ -259,6 +259,111 @@ describe('resolveModel', () => {
|
||||
expect(planner).not.toHaveProperty('reasoning_effort');
|
||||
});
|
||||
|
||||
// ─── #3643: runtime:claude + resolve_model_ids:true must return full IDs ──
|
||||
// Symptom: aliases (opus/sonnet/haiku) leaked through to consumers that asked
|
||||
// for resolved model IDs because resolveRuntimeTier bails for runtime:claude
|
||||
// and the alias-return fall-through ignored resolve_model_ids. CJS branch at
|
||||
// get-shit-done/bin/lib/core.cjs:1348-1350 has the missing guard.
|
||||
it('#3643: runtime:claude + resolve_model_ids:true + balanced returns full sonnet id', async () => {
|
||||
const { resolveModel } = await import('./config-query.js');
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
runtime: 'claude',
|
||||
resolve_model_ids: true,
|
||||
}),
|
||||
);
|
||||
const result = await resolveModel(['gsd-executor'], tmpDir);
|
||||
expect(result.data).toEqual({ model: 'claude-sonnet-4-6', profile: 'balanced' });
|
||||
});
|
||||
|
||||
it('#3643: runtime:claude + resolve_model_ids:true + quality returns full opus id', async () => {
|
||||
const { resolveModel } = await import('./config-query.js');
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
model_profile: 'quality',
|
||||
runtime: 'claude',
|
||||
resolve_model_ids: true,
|
||||
}),
|
||||
);
|
||||
const result = await resolveModel(['gsd-planner'], tmpDir);
|
||||
expect(result.data).toEqual({ model: 'claude-opus-4-7', profile: 'quality' });
|
||||
});
|
||||
|
||||
it('#3643: runtime:claude + resolve_model_ids:true + budget returns full haiku id', async () => {
|
||||
const { resolveModel } = await import('./config-query.js');
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
model_profile: 'budget',
|
||||
runtime: 'claude',
|
||||
resolve_model_ids: true,
|
||||
}),
|
||||
);
|
||||
// gsd-verifier maps to 'haiku' under budget profile per model-catalog.json.
|
||||
const result = await resolveModel(['gsd-verifier'], tmpDir);
|
||||
expect(result.data).toEqual({ model: 'claude-haiku-4-5', profile: 'budget' });
|
||||
});
|
||||
|
||||
it('#3643: phase-type tier override (models.execution=opus) wins under claude+resolve_model_ids', async () => {
|
||||
const { resolveModel } = await import('./config-query.js');
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
model_profile: 'budget',
|
||||
runtime: 'claude',
|
||||
resolve_model_ids: true,
|
||||
models: { execution: 'opus' },
|
||||
}),
|
||||
);
|
||||
const result = await resolveModel(['gsd-executor'], tmpDir);
|
||||
expect(result.data).toEqual({ model: 'claude-opus-4-7', profile: 'budget' });
|
||||
});
|
||||
|
||||
it('#3643 regression-guard: runtime:claude WITHOUT resolve_model_ids still returns alias', async () => {
|
||||
const { resolveModel } = await import('./config-query.js');
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
runtime: 'claude',
|
||||
}),
|
||||
);
|
||||
const result = await resolveModel(['gsd-executor'], tmpDir);
|
||||
expect(result.data).toEqual({ model: 'sonnet', profile: 'balanced' });
|
||||
});
|
||||
|
||||
it('#3643 regression-guard: runtime:claude + resolve_model_ids:"omit" still wins over alias mapping', async () => {
|
||||
const { resolveModel } = await import('./config-query.js');
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
runtime: 'claude',
|
||||
resolve_model_ids: 'omit',
|
||||
}),
|
||||
);
|
||||
const result = await resolveModel(['gsd-executor'], tmpDir);
|
||||
expect(result.data).toEqual({ model: '', profile: 'balanced' });
|
||||
});
|
||||
|
||||
it('#3643 regression-guard: model_overrides[agent] beats claude+resolve_model_ids:true', async () => {
|
||||
const { resolveModel } = await import('./config-query.js');
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
runtime: 'claude',
|
||||
resolve_model_ids: true,
|
||||
model_overrides: { 'gsd-executor': 'custom-anthropic-id' },
|
||||
}),
|
||||
);
|
||||
const result = await resolveModel(['gsd-executor'], tmpDir);
|
||||
expect((result.data as Record<string, unknown>).model).toBe('custom-anthropic-id');
|
||||
});
|
||||
|
||||
it('resolveModel uses workstream config when --ws is specified', async () => {
|
||||
const { resolveModel } = await import('./config-query.js');
|
||||
// Root config: balanced profile → gsd-executor resolves to 'sonnet'
|
||||
|
||||
@@ -35,6 +35,29 @@ import {
|
||||
|
||||
const RUNTIMES_WITH_REASONING_EFFORT = runtimesWithReasoningEffort();
|
||||
|
||||
/**
|
||||
* Schema-level defaults for well-known config keys.
|
||||
*
|
||||
* Mirrors the CJS table at get-shit-done/bin/lib/config.cjs:505-510 byte-for-
|
||||
* byte. When `config-get` lookups fall off the dot path and no `--default`
|
||||
* was supplied, the handler consults this map before throwing
|
||||
* `Key not found`. Without parity here, the SDK path emits
|
||||
* CONFIG_KEY_NOT_FOUND for keys the CJS path returns transparently — every
|
||||
* skill that reads `context_window`, `git.create_tag`, or executor stall
|
||||
* thresholds breaks under SDK dispatch.
|
||||
*
|
||||
* Bugs #2943, #3086, executor-stall-defaults tests — RED→GREEN via this
|
||||
* map. Keep this in lockstep with config.cjs:SCHEMA_DEFAULTS. Drift is
|
||||
* detected by the bug-2943 and #3086 behavioral suites: when the table
|
||||
* grows, both sides must grow together or those tests fail.
|
||||
*/
|
||||
const SCHEMA_DEFAULTS: Readonly<Record<string, string | number | boolean>> = Object.freeze({
|
||||
context_window: 200000,
|
||||
'executor.stall_detect_interval_minutes': 5,
|
||||
'executor.stall_threshold_minutes': 10,
|
||||
'git.create_tag': true,
|
||||
});
|
||||
|
||||
// ─── configGet ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -72,14 +95,29 @@ export const configGet: QueryHandler = async (args, projectDir, workstream) => {
|
||||
try {
|
||||
raw = await readFile(paths.config, 'utf-8');
|
||||
} catch {
|
||||
throw new GSDError(`No config.json found at ${paths.config}`, ErrorClassification.Validation);
|
||||
// config.json missing — CJS parity (config.cjs:524-533):
|
||||
// 1. --default beats everything
|
||||
// 2. else SCHEMA_DEFAULTS supply a documented value (#2943)
|
||||
// 3. else CONFIG_NO_FILE error
|
||||
if (defaultValue !== undefined) return { data: defaultValue };
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
return { data: SCHEMA_DEFAULTS[keyPath] };
|
||||
}
|
||||
const err = new GSDError(`No config.json found at ${paths.config}`, ErrorClassification.Validation);
|
||||
(err as GSDError & { reason?: string }).reason = 'config_no_file';
|
||||
throw err;
|
||||
}
|
||||
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new GSDError(`Malformed config.json at ${paths.config}`, ErrorClassification.Validation);
|
||||
// Lead the message with "Failed to read config.json" — matches the CJS
|
||||
// `cmdConfigGet` / `setConfigValue` error vocabulary so tests written
|
||||
// against the legacy contract keep matching.
|
||||
const err = new GSDError(`Failed to read config.json: malformed JSON at ${paths.config}`, ErrorClassification.Validation);
|
||||
(err as GSDError & { reason?: string }).reason = 'config_parse_failed';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const keys = keyPath.split('.');
|
||||
@@ -88,14 +126,26 @@ export const configGet: QueryHandler = async (args, projectDir, workstream) => {
|
||||
if (current === undefined || current === null || typeof current !== 'object') {
|
||||
// UNIX convention (cf. `git config --get`): missing key exits 1, not 10.
|
||||
// See issue #2544 — callers use `if ! gsd-sdk query config-get k; then` patterns.
|
||||
// CJS parity ordering (config.cjs:543-551): --default first, then
|
||||
// SCHEMA_DEFAULTS, then CONFIG_KEY_NOT_FOUND.
|
||||
if (defaultValue !== undefined) return { data: defaultValue };
|
||||
throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
return { data: SCHEMA_DEFAULTS[keyPath] };
|
||||
}
|
||||
const err = new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
|
||||
(err as GSDError & { reason?: string }).reason = 'config_key_not_found';
|
||||
throw err;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
if (current === undefined) {
|
||||
if (defaultValue !== undefined) return { data: defaultValue };
|
||||
throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
return { data: SCHEMA_DEFAULTS[keyPath] };
|
||||
}
|
||||
const err = new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution);
|
||||
(err as GSDError & { reason?: string }).reason = 'config_key_not_found';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Mask plaintext for keys in SECRET_CONFIG_KEYS to match CJS behavior at
|
||||
@@ -240,5 +290,25 @@ export const resolveModel: QueryHandler = async (args, projectDir, workstream) =
|
||||
return { data: { model: '', profile } };
|
||||
}
|
||||
|
||||
// #3643: runtime:claude bails out of resolveRuntimeTier (line 149) because
|
||||
// Claude is the implicit/default runtime, but consumers that asked for
|
||||
// resolved model IDs still need the full ID (e.g. "claude-sonnet-4-6"), not
|
||||
// the tier alias. Mirror the CJS branch at get-shit-done/bin/lib/core.cjs
|
||||
// (`if (config.resolve_model_ids) return MODEL_ALIAS_MAP[alias] || alias;`)
|
||||
// by consulting the catalog's claude runtime defaults for the resolved tier.
|
||||
const runtime = typeof (config as Record<string, unknown>).runtime === 'string'
|
||||
? ((config as Record<string, unknown>).runtime as string)
|
||||
: '';
|
||||
// Empty/missing runtime is implicit Claude (per the resolveRuntimeTier bail-out
|
||||
// at line ~149); without this branch the resolved-IDs path silently fell
|
||||
// through to the alias return for projects that never set `runtime` explicitly.
|
||||
const isClaudeRuntime = runtime === '' || runtime === 'claude';
|
||||
if (resolveModelIds === true && isClaudeRuntime && isRuntimeTierName(tier)) {
|
||||
const claudeDefault = resolveRuntimeTierDefault('claude', tier);
|
||||
if (claudeDefault?.model) {
|
||||
return { data: { model: claudeDefault.model, profile } };
|
||||
}
|
||||
}
|
||||
|
||||
return { data: { model: alias, profile } };
|
||||
};
|
||||
|
||||
@@ -97,9 +97,12 @@ describe('parseDecisions (#2492)', () => {
|
||||
});
|
||||
|
||||
it('does not crash on malformed bullet lines', () => {
|
||||
// Phase 6 (#3575): regex now accepts alphanumeric IDs (D-[A-Za-z0-9_-]+).
|
||||
// D-bogus IS now valid (pure alpha segment); only truly malformed patterns
|
||||
// (no D- prefix, wrong bullet syntax) are rejected.
|
||||
const malformed = `<decisions>
|
||||
- not a decision (no D-NN)
|
||||
- **D-bogus:** wrong id format
|
||||
- **D-bogus:** alphanumeric id — now accepted since Phase 6
|
||||
- **D-7:** single digit allowed
|
||||
- **D-10:** ten
|
||||
</decisions>`;
|
||||
@@ -107,7 +110,10 @@ describe('parseDecisions (#2492)', () => {
|
||||
const ids = decisions.map((d) => d.id);
|
||||
expect(ids).toContain('D-7');
|
||||
expect(ids).toContain('D-10');
|
||||
expect(ids).not.toContain('D-bogus');
|
||||
// D-bogus IS now accepted — alphanumeric IDs are valid since Phase 6 (#3575)
|
||||
expect(ids).toContain('D-bogus');
|
||||
// Pure non-bullet text is still not parsed as a decision
|
||||
expect(ids).not.toContain('D-NN');
|
||||
});
|
||||
|
||||
it('preserves multi-line decision text continuations', () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ import { isAbsolute, join } from 'node:path';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
export interface ParsedDecision {
|
||||
/** Stable id: `D-01`, `D-7`, `D-42`. */
|
||||
/** Stable id: `D-01`, `D-42`, `D-INFRA-01`, `D-FOO_BAR`. Numeric or alphanumeric. */
|
||||
id: string;
|
||||
/** Body text (everything after `**D-NN[ tags]:**` up to next bullet/blank). */
|
||||
text: string;
|
||||
@@ -93,7 +93,11 @@ export function parseDecisions(content: string): ParsedDecision[] {
|
||||
let inDiscretion = false;
|
||||
|
||||
// Bullet line: `- **D-NN[ [tags]]:** text`
|
||||
const bulletRe = /^\s*-\s+\*\*D-(\d+)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/;
|
||||
// Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR)
|
||||
// in addition to numeric-only IDs (D-42). The first character after `D-` must
|
||||
// be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
|
||||
// CJS callers consume {id, text} and ignore the optional extras.
|
||||
const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/;
|
||||
|
||||
let current: ParsedDecision | null = null;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import { extractFrontmatter } from './frontmatter.js';
|
||||
import { normalizeMd, resolvePathUnderProject } from './helpers.js';
|
||||
import { normalizeMd, resolveFrontmatterPath } from './helpers.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
// ─── FRONTMATTER_SCHEMAS ──────────────────────────────────────────────────
|
||||
@@ -193,15 +193,10 @@ export const frontmatterSet: QueryHandler = async (args, projectDir) => {
|
||||
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
let fullPath: string;
|
||||
try {
|
||||
fullPath = await resolvePathUnderProject(projectDir, filePath);
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
return { data: { error: err.message, path: filePath } };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// CJS parity (frontmatter.cjs:340/354/369): no project-root prefix check.
|
||||
// Bug #3509 — accept absolute paths outside the project (tmpdirs whose
|
||||
// names include spaces fail the under-project test on macOS).
|
||||
const fullPath = resolveFrontmatterPath(projectDir, filePath);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
@@ -245,15 +240,10 @@ export const frontmatterMerge: QueryHandler = async (args, projectDir) => {
|
||||
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
let fullPath: string;
|
||||
try {
|
||||
fullPath = await resolvePathUnderProject(projectDir, filePath);
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
return { data: { error: err.message, path: filePath } };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// CJS parity (frontmatter.cjs:340/354/369): no project-root prefix check.
|
||||
// Bug #3509 — accept absolute paths outside the project (tmpdirs whose
|
||||
// names include spaces fail the under-project test on macOS).
|
||||
const fullPath = resolveFrontmatterPath(projectDir, filePath);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
@@ -318,15 +308,10 @@ export const frontmatterValidate: QueryHandler = async (args, projectDir) => {
|
||||
);
|
||||
}
|
||||
|
||||
let fullPath: string;
|
||||
try {
|
||||
fullPath = await resolvePathUnderProject(projectDir, filePath);
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
return { data: { error: err.message, path: filePath } };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// CJS parity (frontmatter.cjs:340/354/369): no project-root prefix check.
|
||||
// Bug #3509 — accept absolute paths outside the project (tmpdirs whose
|
||||
// names include spaces fail the under-project test on macOS).
|
||||
const fullPath = resolveFrontmatterPath(projectDir, filePath);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
import { escapeRegex, resolvePathUnderProject } from './helpers.js';
|
||||
import { escapeRegex, resolveFrontmatterPath } from './helpers.js';
|
||||
|
||||
// ─── splitInlineArray ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -363,15 +363,10 @@ export const frontmatterGet: QueryHandler = async (args, projectDir) => {
|
||||
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
let fullPath: string;
|
||||
try {
|
||||
fullPath = await resolvePathUnderProject(projectDir, filePath);
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
return { data: { error: err.message, path: filePath } };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// CJS parity (frontmatter.cjs:323): no project-root prefix check — accept
|
||||
// any absolute path (and macOS tmpdir paths whose names contain spaces).
|
||||
// Bug #3509.
|
||||
const fullPath = resolveFrontmatterPath(projectDir, filePath);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
@@ -381,7 +376,12 @@ export const frontmatterGet: QueryHandler = async (args, projectDir) => {
|
||||
}
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const field = args[1];
|
||||
// CLI invocation is `frontmatter get <file> --field <name>`; the CJS router
|
||||
// passes args.slice(2) = [file, '--field', name] to the SDK. Previously the
|
||||
// handler treated args[1] as the field name and saw `'--field'`. Parse the
|
||||
// flag so both invocation shapes work (positional second arg AND --field).
|
||||
const fieldFlagIdx = args.indexOf('--field');
|
||||
const field = fieldFlagIdx >= 0 ? args[fieldFlagIdx + 1] : args[1];
|
||||
|
||||
if (field) {
|
||||
const value = fm[field];
|
||||
|
||||
@@ -493,6 +493,26 @@ export async function resolvePathUnderProject(projectDir: string, userPath: stri
|
||||
return realCandidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied file path the way CJS frontmatter handlers do.
|
||||
*
|
||||
* Mirrors `path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath)`
|
||||
* from get-shit-done/bin/lib/frontmatter.cjs (lines 323, 340, 354, 369).
|
||||
* Does NOT enforce the "under project root" prefix check — frontmatter
|
||||
* verbs accept arbitrary absolute paths (the user is naming a file outside
|
||||
* `.planning/`, often a phase-scoped plan in an external location, or a
|
||||
* tmpdir inside `/var/folders` whose path includes spaces).
|
||||
*
|
||||
* Bug #3509 parity: tests on macOS use `os.tmpdir()` directories that
|
||||
* resolve outside the project root; the project-scoped variant was
|
||||
* rejecting them with "path escapes project directory". Use this helper
|
||||
* for the frontmatter family. Use `resolvePathUnderProject` for commands
|
||||
* that must stay inside the project (e.g. template output, decisions).
|
||||
*/
|
||||
export function resolveFrontmatterPath(projectDir: string, userPath: string): string {
|
||||
return isAbsolute(userPath) ? normalize(userPath) : resolve(projectDir, userPath);
|
||||
}
|
||||
|
||||
// ─── sanitizeForDisplay (security.cjs) ───────────────────────────────────────
|
||||
|
||||
/** Port of `sanitizeForPrompt` from `security.cjs`. */
|
||||
|
||||
@@ -642,16 +642,13 @@ export const initManager: QueryHandler = async (_args, projectDir, workstream) =
|
||||
}
|
||||
}
|
||||
|
||||
// Sliding window: only first undiscussed phase is available to discuss
|
||||
let foundNextToDiscuss = false;
|
||||
// Bug #2268: mark EVERY undiscussed phase as is_next_to_discuss, not just
|
||||
// the first one. Multiple independent phases can be discussed in parallel
|
||||
// — the sliding-window pattern made the manager only recommend one
|
||||
// discuss action even when callers had free capacity to discuss several.
|
||||
for (const phase of phases) {
|
||||
const status = phase.disk_status as string;
|
||||
if (!foundNextToDiscuss && (status === 'empty' || status === 'no_directory')) {
|
||||
phase.is_next_to_discuss = true;
|
||||
foundNextToDiscuss = true;
|
||||
} else {
|
||||
phase.is_next_to_discuss = false;
|
||||
}
|
||||
phase.is_next_to_discuss = (status === 'empty' || status === 'no_directory');
|
||||
}
|
||||
|
||||
// Check WAITING.json signal
|
||||
|
||||
@@ -443,6 +443,54 @@ describe('initPlanPhase', () => {
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
|
||||
// #3569: init.plan-phase must surface a phase_status field so the
|
||||
// /gsd-plan-phase workflow can short-circuit on closed phases instead of
|
||||
// happily replanning over shipped code. Reuses the project-wide phase
|
||||
// lifecycle vocabulary from determinePhaseStatus (Pending | Planned |
|
||||
// In Progress | Executed | Complete | Needs Review).
|
||||
describe('phase_status (#3569)', () => {
|
||||
it('reports "Complete" when summaries match plans and VERIFICATION.md status: passed', async () => {
|
||||
// Phase 9 fixture already has 1 plan + 1 summary; add a passing VERIFICATION.
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'phases', '09-foundation', '09-VERIFICATION.md'),
|
||||
['---', 'phase: 09', 'status: passed', 'score: 100', 'verified: true', '---', '# Verification'].join('\n'),
|
||||
);
|
||||
|
||||
const result = await initPlanPhase(['9'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.phase_status).toBe('Complete');
|
||||
});
|
||||
|
||||
it('reports "Planned" when plans exist but no summaries written', async () => {
|
||||
// Phase 10 has no plan files in the beforeEach fixture. Add a plan to flip
|
||||
// it from "Pending" (no plans) to "Planned" (plans, no summaries).
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'phases', '10-read-only-queries', '10-01-PLAN.md'),
|
||||
['---', 'phase: 10-read-only-queries', 'plan: 01', '---', '<objective>x</objective>'].join('\n'),
|
||||
);
|
||||
|
||||
const result = await initPlanPhase(['10'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.phase_status).toBe('Planned');
|
||||
});
|
||||
|
||||
it('reports "Pending" when phase has no plans yet', async () => {
|
||||
const result = await initPlanPhase(['10'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.phase_status).toBe('Pending');
|
||||
});
|
||||
|
||||
it('reports "Executed" when summaries match plans but VERIFICATION.md is absent', async () => {
|
||||
// Phase 9 fixture: 1 plan, 1 summary, no VERIFICATION yet — executed but
|
||||
// not closed. This is the regression hot zone: pre-fix, init.plan-phase
|
||||
// gave no signal here, so the workflow couldn't distinguish this from
|
||||
// an already-closed phase either.
|
||||
const result = await initPlanPhase(['9'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.phase_status).toBe('Executed');
|
||||
});
|
||||
});
|
||||
|
||||
// #2769: extractReqIds must accept all bold/colon variants of the
|
||||
// Requirements header. The forms render identically in markdown but differ
|
||||
// textually; the previous regex only matched **Requirements**: (colon
|
||||
|
||||
@@ -22,12 +22,15 @@ import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join, relative, basename } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
|
||||
import { loadConfig, type GSDConfig } from '../config.js';
|
||||
import { resolveModel, MODEL_PROFILES } from './config-query.js';
|
||||
import { maskIfSecret } from './secrets.js';
|
||||
import { findPhase } from './phase.js';
|
||||
import { getMilestonePhaseFilter } from './state.js';
|
||||
import { roadmapGetPhase, getMilestoneInfo, extractCurrentMilestone, extractPhasesFromSection } from './roadmap.js';
|
||||
import { determinePhaseStatus } from './progress.js';
|
||||
import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from './helpers.js';
|
||||
import { generatePhaseSlug, assertSafeProjectCode } from './phase-lifecycle-policy.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
@@ -140,13 +143,16 @@ function computeExpectedPhaseDirName(
|
||||
async function shouldDropArchivedPhaseMatch(
|
||||
phaseInfo: Record<string, unknown> | null,
|
||||
roadmapPhase: Record<string, unknown> | null,
|
||||
projectDir: string,
|
||||
workstream?: string,
|
||||
_projectDir: string,
|
||||
_workstream?: string,
|
||||
): Promise<boolean> {
|
||||
if (!phaseInfo?.archived || !roadmapPhase || !roadmapPhase.found) return false;
|
||||
const archivedTag = String(phaseInfo.archived ?? '');
|
||||
const milestone = await getMilestoneInfo(projectDir, workstream);
|
||||
if (milestone?.version && archivedTag === milestone.version) return false;
|
||||
// Matches CJS cmdInitPlanPhase / cmdInitExecutePhase / cmdInitVerifyWork:
|
||||
// if (phaseInfo?.archived && roadmapPhase?.found) phaseInfo = null;
|
||||
// Unconditional drop — the ROADMAP is authoritative for the current milestone,
|
||||
// regardless of what archived milestone the on-disk match came from. Do NOT add
|
||||
// a milestone-version equality check (#2391 regression risk).
|
||||
if (!phaseInfo?.archived) return false;
|
||||
if (!roadmapPhase || !roadmapPhase.found) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -366,6 +372,13 @@ export const initExecutePhase: QueryHandler = async (args, projectDir, workstrea
|
||||
return { data: { error: 'phase required for init execute-phase' } };
|
||||
}
|
||||
|
||||
// --tdd is a boolean override of config.workflow.tdd_mode — matches the CJS
|
||||
// path's parseNamedArgs(args, [], ['validate', 'tdd']) projection
|
||||
// (bin/lib/init-command-router.cjs handler block) which passes options.tdd
|
||||
// through to cmdInitExecutePhase. Without parsing here, `gsd-tools init
|
||||
// execute-phase 1 --tdd` would never override a false config value.
|
||||
const tddFlag = args.includes('--tdd');
|
||||
|
||||
const config = await loadConfig(projectDir);
|
||||
const paths = planningPaths(projectDir, workstream);
|
||||
const planningDir = paths.planning;
|
||||
@@ -393,7 +406,7 @@ export const initExecutePhase: QueryHandler = async (args, projectDir, workstrea
|
||||
const result: Record<string, unknown> = {
|
||||
executor_model: executorModel,
|
||||
verifier_model: verifierModel,
|
||||
tdd_mode: config.workflow.tdd_mode ?? false,
|
||||
tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false),
|
||||
commit_docs: config.commit_docs,
|
||||
sub_repos: (config as Record<string, unknown>).sub_repos ?? [],
|
||||
parallelization: config.parallelization,
|
||||
@@ -449,6 +462,10 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
return { data: { error: 'phase required for init plan-phase' } };
|
||||
}
|
||||
|
||||
// --tdd boolean override (parity with CJS router's parseNamedArgs + the
|
||||
// legacy cmdInitPlanPhase `options.tdd || config.tdd_mode || false`).
|
||||
const tddFlag = args.includes('--tdd');
|
||||
|
||||
const config = await loadConfig(projectDir);
|
||||
const paths = planningPaths(projectDir, workstream);
|
||||
const planningDir = paths.planning;
|
||||
@@ -469,6 +486,17 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
const phaseName = (phaseInfo?.phase_name as string) ?? null;
|
||||
const phaseDir = (phaseInfo?.directory as string) ?? null;
|
||||
const plans = (phaseInfo?.plans || []) as string[];
|
||||
const summaries = (phaseInfo?.summaries || []) as string[];
|
||||
|
||||
// #3569: surface phase lifecycle status so /gsd-plan-phase can short-circuit
|
||||
// on closed (Complete) phases instead of silently replanning over shipped
|
||||
// code. Reuses determinePhaseStatus — the project-wide vocabulary used by
|
||||
// `progress` (Pending | Planned | In Progress | Executed | Complete |
|
||||
// Needs Review). When the phase has no directory on disk yet, treat it as
|
||||
// Pending (it has not been started).
|
||||
const phaseStatus = phaseDir
|
||||
? await determinePhaseStatus(plans.length, summaries.length, join(projectDir, phaseDir))
|
||||
: 'Pending';
|
||||
|
||||
// #3287: compute the canonical directory name with project_code prefix so
|
||||
// the first-touch mkdir in /gsd-plan-phase stays consistent with phase.add.
|
||||
@@ -486,7 +514,7 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
researcher_model: researcherModel,
|
||||
planner_model: plannerModel,
|
||||
checker_model: checkerModel,
|
||||
tdd_mode: config.workflow.tdd_mode ?? false,
|
||||
tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false),
|
||||
research_enabled: config.workflow.research,
|
||||
plan_checker_enabled: config.workflow.plan_check,
|
||||
nyquist_validation_enabled: config.workflow.nyquist_validation,
|
||||
@@ -503,6 +531,7 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
|
||||
phase_slug: (phaseInfo?.phase_slug as string) ?? null,
|
||||
padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
|
||||
phase_req_ids,
|
||||
phase_status: phaseStatus,
|
||||
has_research: (phaseInfo?.has_research as boolean) || false,
|
||||
has_context: (phaseInfo?.has_context as boolean) || false,
|
||||
has_reviews: (phaseInfo?.has_reviews as boolean) || false,
|
||||
@@ -555,8 +584,14 @@ export const initNewMilestone: QueryHandler = async (_args, projectDir) => {
|
||||
let phaseDirCount = 0;
|
||||
try {
|
||||
if (existsSync(phasesDir)) {
|
||||
// Bug #2445 parity with CJS `cmdInitNewMilestone`: filter phase dirs
|
||||
// to the current milestone so stale dirs from a prior milestone that
|
||||
// weren't archived don't inflate the count. Without this filter the
|
||||
// SDK returns the full directory count, which the new-milestone
|
||||
// workflow then uses to gate "is this a fresh start" decisions.
|
||||
const isDirInMilestone = await getMilestonePhaseFilter(projectDir);
|
||||
phaseDirCount = readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.filter(entry => entry.isDirectory() && isDirInMilestone(entry.name))
|
||||
.length;
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
@@ -1039,7 +1074,12 @@ export const initMapCodebase: QueryHandler = async (_args, projectDir) => {
|
||||
commit_docs: config.commit_docs,
|
||||
search_gitignored: config.search_gitignored,
|
||||
parallelization: config.parallelization,
|
||||
subagent_timeout: (config as Record<string, unknown>).subagent_timeout ?? undefined,
|
||||
// subagent_timeout lives at workflow.subagent_timeout per the canonical
|
||||
// Configuration manifest (sdk/shared/config-defaults.manifest.json). Reading
|
||||
// the top-level config.subagent_timeout returned undefined, so the workflow
|
||||
// step that consumes this value had to invent its own fallback. Default to
|
||||
// 300000 (5 min) per the manifest. (#1472)
|
||||
subagent_timeout: (((config as Record<string, unknown>).workflow as Record<string, unknown> | undefined)?.subagent_timeout as number | undefined) ?? 300000,
|
||||
date: now.toISOString().split('T')[0],
|
||||
timestamp: now.toISOString(),
|
||||
codebase_dir: '.planning/codebase',
|
||||
@@ -1161,12 +1201,18 @@ export const initListWorkspaces: QueryHandler = async (_args, _projectDir) => {
|
||||
export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => {
|
||||
const name = args[0];
|
||||
if (!name) {
|
||||
return { data: { error: 'workspace name required for init remove-workspace' } };
|
||||
// Throw so the CLI dispatcher projects a non-zero exit + writes the message
|
||||
// to stderr — returning `{ data: { error } }` was treated as success by
|
||||
// the CLI output path, hiding the validation failure from callers.
|
||||
throw new GSDError('workspace name required for init remove-workspace', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
// T-14-01: Reject path traversal attempts
|
||||
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||||
return { data: { error: `Invalid workspace name: ${name} (path separators not allowed)` } };
|
||||
throw new GSDError(
|
||||
`Invalid workspace name: ${name} (path separators not allowed)`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
const home = process.env.HOME || homedir();
|
||||
@@ -1175,7 +1221,7 @@ export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => {
|
||||
const manifestPath = join(wsPath, 'WORKSPACE.md');
|
||||
|
||||
if (!existsSync(wsPath)) {
|
||||
return { data: { error: `Workspace not found: ${wsPath}` } };
|
||||
throw new GSDError(`Workspace not found: ${wsPath}`, ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
const repos: Array<Record<string, unknown>> = [];
|
||||
|
||||
@@ -32,8 +32,9 @@ import {
|
||||
planningPaths,
|
||||
} from './helpers.js';
|
||||
import { extractFrontmatter } from './frontmatter.js';
|
||||
import { extractCurrentMilestone } from './roadmap.js';
|
||||
import { extractCurrentMilestone, phaseMarkdownRegexSource } from './roadmap.js';
|
||||
import { getMilestonePhaseFilter } from './state.js';
|
||||
import { isCanonicalPlanFile, describeNonCanonicalPlans } from './phase.js';
|
||||
import {
|
||||
acquireStateLock,
|
||||
readModifyWriteStateMdFull,
|
||||
@@ -86,27 +87,43 @@ export { readModifyWriteRoadmapMd, replaceInCurrentMilestone };
|
||||
*/
|
||||
export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
|
||||
// ── Flag parsing ────────────────────────────────────────────────────────
|
||||
// Separate recognized flags from positional args. Any unrecognized --flag
|
||||
// is rejected immediately so it is never silently absorbed into positional slots.
|
||||
const RECOGNIZED_FLAGS = new Set(['--dry-run']);
|
||||
// Mirrors the CJS phase add router (phase-command-router.cjs): recognise
|
||||
// --dry-run and --id <value>; reject every other --flag; ignore --raw so it
|
||||
// never leaks into the description; join the remaining positional tokens
|
||||
// with a single space so multi-word descriptions like `phase add User
|
||||
// Dashboard` produce description "User Dashboard". customId comes from the
|
||||
// --id flag, never from positional[1].
|
||||
let dryRun = false;
|
||||
let customIdArg: string | null = null;
|
||||
const positional: string[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith('--')) {
|
||||
if (!RECOGNIZED_FLAGS.has(arg)) {
|
||||
throw new GSDError(
|
||||
`Unknown flag ${arg} for phase.add`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
if (arg === '--dry-run') dryRun = true;
|
||||
} else {
|
||||
positional.push(arg);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
if (arg === '--raw') {
|
||||
// CJS router strips --raw before invoking the handler; preserve parity
|
||||
// so a stray --raw never poisons the description.
|
||||
continue;
|
||||
}
|
||||
if (arg === '--dry-run') {
|
||||
dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--id') {
|
||||
const id = args[i + 1];
|
||||
if (!id || id.startsWith('--')) {
|
||||
throw new GSDError('--id requires a value', ErrorClassification.Validation);
|
||||
}
|
||||
customIdArg = id;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--')) {
|
||||
throw new GSDError(`phase add does not support ${arg}`, ErrorClassification.Validation);
|
||||
}
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
const description = positional[0];
|
||||
const description = positional.join(' ').trim();
|
||||
if (!description) {
|
||||
throw new GSDError('description required for phase add', ErrorClassification.Validation);
|
||||
}
|
||||
@@ -119,8 +136,9 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
|
||||
} catch { /* use defaults */ }
|
||||
|
||||
const slug = generatePhaseSlug(description);
|
||||
// positional[1] is the optional customId — flags are already stripped
|
||||
const customId = positional[1] || null;
|
||||
// customId always comes from the --id flag; positional tokens are reserved
|
||||
// for the description (which is joined above).
|
||||
const customId = customIdArg;
|
||||
|
||||
// Optional project code prefix (e.g., 'CK' -> 'CK-01-foundation')
|
||||
const projectCode = (config.project_code as string) || '';
|
||||
@@ -227,17 +245,25 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
|
||||
export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream) => {
|
||||
let descriptions: string[];
|
||||
const descIdx = args.indexOf('--descriptions');
|
||||
if (descIdx !== -1 && args[descIdx + 1] !== undefined) {
|
||||
try {
|
||||
const parsed = JSON.parse(args[descIdx + 1]) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
|
||||
}
|
||||
descriptions = parsed.map((x) => String(x));
|
||||
} catch (e) {
|
||||
if (e instanceof GSDError) throw e;
|
||||
throw new GSDError('--descriptions must be a valid JSON array', ErrorClassification.Validation);
|
||||
if (descIdx !== -1) {
|
||||
// CJS router parity (phase-command-router.cjs): a dangling --descriptions
|
||||
// or one whose value is another flag must surface the same JSON-array error
|
||||
// string, not silently fall through to positional parsing or throw a
|
||||
// different "valid JSON" variant.
|
||||
const rawValue = args[descIdx + 1];
|
||||
if (rawValue === undefined || rawValue.startsWith('--')) {
|
||||
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawValue);
|
||||
} catch {
|
||||
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
|
||||
}
|
||||
descriptions = parsed.map((x) => String(x));
|
||||
} else {
|
||||
descriptions = args.filter((a) => a !== '--raw');
|
||||
}
|
||||
@@ -345,8 +371,25 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream)
|
||||
* @returns QueryResult with { phase_number, after_phase, name, slug, directory }
|
||||
*/
|
||||
export const phaseInsert: QueryHandler = async (args, projectDir, workstream) => {
|
||||
const afterPhase = args[0];
|
||||
const description = args[1];
|
||||
// CJS router parity (phase-command-router.cjs): explicitly reject
|
||||
// --dry-run (insert is destructive on disk + roadmap and has no preview
|
||||
// path), strip --raw, and join all positional args after `afterPhase` into
|
||||
// a single space-delimited description so `phase insert 1 Fix Critical Bug`
|
||||
// produces description "Fix Critical Bug" instead of just "Fix".
|
||||
const positional: string[] = [];
|
||||
for (const arg of args) {
|
||||
if (arg === '--dry-run') {
|
||||
throw new GSDError('phase insert does not support --dry-run', ErrorClassification.Validation);
|
||||
}
|
||||
if (arg === '--raw') continue;
|
||||
if (arg.startsWith('--')) {
|
||||
throw new GSDError(`phase insert does not support ${arg}`, ErrorClassification.Validation);
|
||||
}
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
const afterPhase = positional[0];
|
||||
const description = positional.slice(1).join(' ').trim();
|
||||
|
||||
if (!afterPhase || !description) {
|
||||
throw new GSDError('after-phase and description required for phase insert', ErrorClassification.Validation);
|
||||
@@ -367,6 +410,19 @@ export const phaseInsert: QueryHandler = async (args, projectDir, workstream) =>
|
||||
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
|
||||
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
|
||||
if (!targetPattern.test(content)) {
|
||||
// Bug #3098 parity: when only the summary checklist exists for this
|
||||
// phase (no `### Phase N:` detail section), point the user at the
|
||||
// missing detail section rather than implying the phase is absent.
|
||||
const checklistPattern = new RegExp(
|
||||
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+0*${afterPhaseEscaped}:`,
|
||||
'i',
|
||||
);
|
||||
if (checklistPattern.test(content)) {
|
||||
throw new GSDError(
|
||||
`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
throw new GSDError(`Phase ${afterPhase} not found in ROADMAP.md`, ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
@@ -699,7 +755,11 @@ async function renameIntegerPhases(
|
||||
const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
|
||||
if (!m) return null;
|
||||
const dirInt = parseInt(m[1], 10);
|
||||
if (dirInt <= removedInt) return null;
|
||||
// CJS parity: skip backlog phases (999.x). These are parked ideas with a
|
||||
// numbering convention that lives outside the active sequence; renumbering
|
||||
// them would clobber the convention and corrupt downstream lookups.
|
||||
// (bug-2434)
|
||||
if (dirInt <= removedInt || dirInt >= 999) return null;
|
||||
return {
|
||||
dir,
|
||||
oldInt: dirInt,
|
||||
@@ -742,12 +802,65 @@ async function renameIntegerPhases(
|
||||
|
||||
// ─── updateRoadmapAfterPhaseRemoval ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decrement integer phase number while skipping non-renumbered ranges. Mirrors
|
||||
* `decrementRoadmapPhaseNumber` in phase.cjs lines 860-864.
|
||||
*
|
||||
* Skips when:
|
||||
* • not an integer
|
||||
* • num <= removedInt (already-renumbered phases stay put)
|
||||
* • num >= 999 (backlog/parked-idea numbering range)
|
||||
*
|
||||
* Returns the original raw string when the guards trip so the regex pass
|
||||
* leaves dates and unrelated numerics intact.
|
||||
*/
|
||||
function decrementRoadmapPhaseNumber(raw: string, removedInt: number): string {
|
||||
const num = parseInt(raw, 10);
|
||||
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
|
||||
return String(num - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement integer or decimal phase token (e.g. "5" or "5.2"). Mirrors
|
||||
* `decrementRoadmapPhaseToken` in phase.cjs lines 866-872 — preserves the
|
||||
* decimal suffix when present and applies the same guards.
|
||||
*/
|
||||
function decrementRoadmapPhaseToken(raw: string, removedInt: number): string {
|
||||
const match = String(raw).match(/^(\d+)(\.\d+)?$/);
|
||||
if (!match) return raw;
|
||||
const num = parseInt(match[1]!, 10);
|
||||
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
|
||||
return `${num - 1}${match[2] || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement zero-padded phase number while preserving the original pad width.
|
||||
* Mirrors `decrementRoadmapPaddedPhaseNumber` in phase.cjs lines 874-878.
|
||||
*/
|
||||
function decrementRoadmapPaddedPhaseNumber(raw: string, removedInt: number): string {
|
||||
const num = parseInt(raw, 10);
|
||||
if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw;
|
||||
return String(num - 1).padStart(raw.length, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a phase section from ROADMAP.md and renumber subsequent integer phases.
|
||||
*
|
||||
* Port of updateRoadmapAfterPhaseRemoval from phase.cjs lines 569-595.
|
||||
* Port of updateRoadmapAfterPhaseRemoval from phase.cjs lines 880-922.
|
||||
* Uses readModifyWriteRoadmapMd for atomic writes.
|
||||
*
|
||||
* The renumbering pass uses **5 targeted regex replacements** (not a loop)
|
||||
* because the loop approach is dangerous:
|
||||
* • It can match YYYY-MM-DD substrings and corrupt dates (bug-2435).
|
||||
* • It can rename backlog phases (999.x) that should stay frozen (bug-2434).
|
||||
* • It can renumber the same phase multiple times if the regex matches
|
||||
* overlap (bug-3355 — phase 7 → 6 → 5 → ...).
|
||||
*
|
||||
* The CJS pattern uses negative lookbehind/ahead on the padded-prefix regex
|
||||
* to skip dates and decrement helpers that guard against `num >= 999`. Keep
|
||||
* this implementation byte-for-byte in lockstep with phase.cjs:880-922 —
|
||||
* deviations are how the three bugs above slipped in.
|
||||
*
|
||||
* @param projectDir - Project root directory
|
||||
* @param targetPhase - Phase identifier that was removed
|
||||
* @param isDecimal - Whether the removed phase was a decimal phase
|
||||
@@ -763,9 +876,30 @@ async function updateRoadmapAfterPhaseRemoval(
|
||||
await readModifyWriteRoadmapMd(projectDir, (content) => {
|
||||
const escaped = escapeRegex(targetPhase);
|
||||
|
||||
// Remove the phase section (header + body until next phase header or end)
|
||||
// Remove the phase section (header + body until next phase header or end).
|
||||
//
|
||||
// #3601: the end-of-section lookahead is DEPTH-AWARE. The named capture
|
||||
// (?<h>#{2,4}) records the hash count of the header being removed and the
|
||||
// lookahead requires the same depth via \k<h>(?!#). Two contracts are
|
||||
// preserved:
|
||||
//
|
||||
// (#3601 case) Remove `### Phase 2:` and stop at `### Phase 2.1:` —
|
||||
// Phase 2.1 is a peer-level decimal phase (depth 3) and must survive.
|
||||
//
|
||||
// (#3355 case) Remove `### Phase 27:` and CONTINUE past
|
||||
// `#### Phase 27.1:` (depth 4 — child of Phase 27) until the next
|
||||
// depth-3 header. The child decimal is part of the integer phase
|
||||
// being removed.
|
||||
//
|
||||
// The `(?!#)` negative lookahead after the backreference prevents the
|
||||
// depth-3 match from being satisfied by a depth-4+ header that starts
|
||||
// with the same three hashes. `[^\n:]+` accepts numeric, decimal, AND
|
||||
// custom phase IDs (PROJ-42) as terminators.
|
||||
content = content.replace(
|
||||
new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'),
|
||||
new RegExp(
|
||||
`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`,
|
||||
'i',
|
||||
),
|
||||
'',
|
||||
);
|
||||
|
||||
@@ -781,46 +915,59 @@ async function updateRoadmapAfterPhaseRemoval(
|
||||
'',
|
||||
);
|
||||
|
||||
// For integer phase removal, renumber all subsequent phases in ROADMAP text
|
||||
if (!isDecimal) {
|
||||
const MAX_PHASE = 99;
|
||||
for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) {
|
||||
const newNum = oldNum - 1;
|
||||
const oldStr = String(oldNum);
|
||||
const newStr = String(newNum);
|
||||
const oldPad = oldStr.padStart(2, '0');
|
||||
const newPad = newStr.padStart(2, '0');
|
||||
// Phase headers: ### Phase N: / ### Phase N.M:
|
||||
content = content.replace(
|
||||
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi,
|
||||
(_match, prefix: string, num: string, suffix: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`,
|
||||
);
|
||||
|
||||
// Renumber phase headers: ### Phase N:
|
||||
content = content.replace(
|
||||
new RegExp(`(#{2,4}\\s*Phase\\s+)${escapeRegex(oldStr)}(\\s*:)`, 'gi'),
|
||||
`$1${newStr}$2`,
|
||||
);
|
||||
// Checkbox-list summary references: `- [ ] Phase N:`
|
||||
content = content.replace(
|
||||
/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi,
|
||||
(_match, prefix: string, num: string, suffix: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`,
|
||||
);
|
||||
|
||||
// Renumber inline Phase N references
|
||||
content = content.replace(
|
||||
new RegExp(`(Phase\\s+)${escapeRegex(oldStr)}([:\\s])`, 'g'),
|
||||
`$1${newStr}$2`,
|
||||
);
|
||||
// Table-row phase numbers: `| N. ` — bare integer in a cell.
|
||||
content = content.replace(
|
||||
/(\|\s*)(\d+)(\.\s)/g,
|
||||
(_match, prefix: string, num: string, suffix: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`,
|
||||
);
|
||||
|
||||
// Renumber padded plan references: 07-01 -> 06-01
|
||||
content = content.replace(
|
||||
new RegExp(`${escapeRegex(oldPad)}-(\\d{2})`, 'g'),
|
||||
`${newPad}-$1`,
|
||||
);
|
||||
// Padded plan references: NN-NN (optionally followed by an arbitrary
|
||||
// kebab-case slug, then -PLAN.md / -SUMMARY.md).
|
||||
//
|
||||
// #2435: negative lookbehind `(?<![0-9-])` and negative lookahead
|
||||
// `(?![0-9-])` exclude YYYY-MM-DD substrings.
|
||||
//
|
||||
// #3602: the original pattern only allowed a compact `-(PLAN|SUMMARY).md`
|
||||
// immediately after the plan number; a slug between the number and the
|
||||
// `-PLAN.md` / `-SUMMARY.md` suffix (e.g.
|
||||
// `07-01-cherry-pick-foundation-PLAN.md`) made the lookahead fail and
|
||||
// left the stale `07-01-` prefix in ROADMAP text while the on-disk file
|
||||
// was already renumbered to `06-01-…`. The slug segment
|
||||
// `(?:-[A-Za-z][A-Za-z0-9-]*)*` allows any number of kebab-case tokens
|
||||
// before the canonical PLAN/SUMMARY suffix.
|
||||
content = content.replace(
|
||||
/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)*-(?:PLAN|SUMMARY)\.md)?(?![0-9-]))/g,
|
||||
(_match, phaseNum: string, planNum: string) =>
|
||||
`${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`,
|
||||
);
|
||||
|
||||
// Renumber table row phase numbers: | 7. -> | 6.
|
||||
content = content.replace(
|
||||
new RegExp(`(\\|\\s*)${escapeRegex(oldStr)}\\.\\s`, 'g'),
|
||||
`$1${newStr}. `,
|
||||
);
|
||||
|
||||
// Renumber depends-on references
|
||||
content = content.replace(
|
||||
new RegExp(`(\\*\\*Depends on:\\*\\*\\s*Phase\\s+)${escapeRegex(oldStr)}\\b`, 'gi'),
|
||||
`$1${newStr}`,
|
||||
);
|
||||
}
|
||||
// Depends-on references — two bold-colon variants in the wild.
|
||||
content = content.replace(
|
||||
/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
|
||||
(_match, prefix: string, num: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`,
|
||||
);
|
||||
content = content.replace(
|
||||
/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
|
||||
(_match, prefix: string, num: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
@@ -1095,14 +1242,23 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
// Step C: Update ROADMAP.md atomically
|
||||
if (existsSync(paths.roadmap)) {
|
||||
await readModifyWriteRoadmapMd(projectDir, async (roadmapContent) => {
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
// Padding-tolerant fragment so a padded input like "02.7" still matches
|
||||
// un-padded ROADMAP prose ("### Phase 2.7:"). CJS routes every phase-
|
||||
// number ROADMAP regex through phaseMarkdownRegexSource (#3537) —
|
||||
// mirror that contract here so phase.complete with the padded form
|
||||
// produces the same ROADMAP as the un-padded form.
|
||||
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
|
||||
|
||||
// Checkbox: - [ ] Phase N: -> - [x] Phase N: (...completed DATE)
|
||||
// CJS parity (phase.cjs): direct replace, NOT scoped through
|
||||
// replaceInCurrentMilestone. Same reasoning as the plan-count
|
||||
// update below — milestone wrapped in <details> would otherwise be
|
||||
// skipped (bug-2005).
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
|
||||
'i',
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
|
||||
// Progress table: update Status to Complete, add date
|
||||
const tableRowPattern = new RegExp(
|
||||
@@ -1123,13 +1279,18 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
return '|' + cells.join('|') + '|';
|
||||
});
|
||||
|
||||
// Update plan count in phase section
|
||||
// Update plan count in phase section.
|
||||
// CJS parity (phase.cjs:1076-1083): direct replace, NOT scoped through
|
||||
// replaceInCurrentMilestone. Scoping to "after last </details>" fails
|
||||
// when the current milestone itself is wrapped in <details open>...
|
||||
// </details> — there's no content after the close tag, so the regex
|
||||
// never matches and **Plans:** stays at 0/N (bug-2005).
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`,
|
||||
'i',
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(
|
||||
roadmapContent, planCountPattern,
|
||||
roadmapContent = roadmapContent.replace(
|
||||
planCountPattern,
|
||||
`$1${summaryCount}/${planCount} plans complete`,
|
||||
);
|
||||
|
||||
@@ -1156,12 +1317,15 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
|
||||
const reqMatch = sectionText.match(/\*\*Requirements\*?\*?:?\s*([^\n]+)/i);
|
||||
|
||||
let reqContent = await readFile(reqPath, 'utf-8');
|
||||
let reqContentChanged = false;
|
||||
|
||||
if (reqMatch) {
|
||||
const reqIds = reqMatch[1].replace(/[[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
|
||||
let reqContent = await readFile(reqPath, 'utf-8');
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
const reqEscaped = escapeRegex(reqId);
|
||||
const before = reqContent;
|
||||
// Update checkbox: - [ ] **REQ-ID** -> - [x] **REQ-ID**
|
||||
reqContent = reqContent.replace(
|
||||
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
|
||||
@@ -1172,8 +1336,42 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
|
||||
'$1 Complete $2',
|
||||
);
|
||||
if (reqContent !== before) reqContentChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Bug #2526 parity (phase.cjs:1140-1167): independent of whether the
|
||||
// roadmap declared a Requirements: line, scan the REQUIREMENTS.md
|
||||
// body for `**REQ-ID**` references and compare against the IDs that
|
||||
// actually appear in the Traceability table. Surface every body
|
||||
// ID that has no traceability row so the operator can keep the
|
||||
// table in sync.
|
||||
const bodyReqIds: string[] = [];
|
||||
const bodyReqPattern = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
|
||||
let bodyMatch: RegExpExecArray | null;
|
||||
while ((bodyMatch = bodyReqPattern.exec(reqContent)) !== null) {
|
||||
if (!bodyReqIds.includes(bodyMatch[1]!)) bodyReqIds.push(bodyMatch[1]!);
|
||||
}
|
||||
|
||||
const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);
|
||||
const traceabilitySection = traceabilityHeadingMatch
|
||||
? reqContent.slice(traceabilityHeadingMatch.index!)
|
||||
: '';
|
||||
const tableReqIds = new Set<string>();
|
||||
const tableRowPattern = /^\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/gm;
|
||||
let tableMatch: RegExpExecArray | null;
|
||||
while ((tableMatch = tableRowPattern.exec(traceabilitySection)) !== null) {
|
||||
tableReqIds.add(tableMatch[1]!);
|
||||
}
|
||||
|
||||
const unregistered = bodyReqIds.filter((id) => !tableReqIds.has(id));
|
||||
if (unregistered.length > 0) {
|
||||
warnings.push(
|
||||
`REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`,
|
||||
);
|
||||
}
|
||||
|
||||
if (reqContentChanged) {
|
||||
await writeFile(reqPath, reqContent, 'utf-8');
|
||||
requirementsUpdated = true;
|
||||
}
|
||||
@@ -1217,6 +1415,12 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
for (const dir of dirs) {
|
||||
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
||||
if (dm) {
|
||||
// Bug #2129 parity: skip backlog phases (999.x). They are parked
|
||||
// ideas with reserved numbering, not part of the active sequence.
|
||||
// Without this, completing phase 2 in a project that has a 999.1
|
||||
// backlog directory would jump next_phase to 999.1 instead of the
|
||||
// intended Phase 3 from ROADMAP.
|
||||
if (/^999(?:\.|$)/.test(dm[1]!)) continue;
|
||||
if (comparePhaseNum(dm[1], phaseNum) > 0) {
|
||||
nextPhaseNum = dm[1];
|
||||
nextPhaseName = dm[2] || null;
|
||||
@@ -1433,6 +1637,24 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
}
|
||||
}
|
||||
|
||||
// Step F2: Auto-prune STATE.md decisions when `workflow.auto_prune_state`
|
||||
// is true. Mirrors CJS cmdPhaseComplete (bin/lib/phase.cjs:1378-1390) which
|
||||
// calls cmdStatePrune({keepRecent:'3', dryRun:false, silent:true}). Without
|
||||
// this, completing phase N with auto_prune_state=true leaves stale [Phase
|
||||
// 1..N-3] decisions in STATE.md forever. (#2087)
|
||||
let autoPruned = false;
|
||||
try {
|
||||
if (existsSync(paths.config)) {
|
||||
const rawConfig = JSON.parse(await readFile(paths.config, 'utf-8')) as Record<string, unknown>;
|
||||
const wf = rawConfig.workflow as Record<string, unknown> | undefined;
|
||||
if (wf && wf.auto_prune_state === true && existsSync(paths.state)) {
|
||||
const { statePrune } = await import('./state-mutation.js');
|
||||
await statePrune(['--keep-recent', '3', '--silent'], projectDir, workstream);
|
||||
autoPruned = true;
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort, matches CJS */ }
|
||||
|
||||
// Step G: Return result
|
||||
return {
|
||||
data: {
|
||||
@@ -1446,6 +1668,7 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
roadmap_updated: existsSync(paths.roadmap),
|
||||
state_updated: stateUpdated,
|
||||
requirements_updated: requirementsUpdated,
|
||||
auto_pruned: autoPruned,
|
||||
warnings,
|
||||
has_warnings: warnings.length > 0,
|
||||
},
|
||||
@@ -1547,13 +1770,19 @@ export const phasesList: QueryHandler = async (args, projectDir, workstream) =>
|
||||
|
||||
if (type) {
|
||||
const files: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
const dirPath = join(phasesDir, dir);
|
||||
if (!existsSync(dirPath)) continue;
|
||||
const dirFiles = await readdir(dirPath);
|
||||
let filtered: string[];
|
||||
if (type === 'plans') {
|
||||
filtered = dirFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
||||
filtered = dirFiles.filter(isCanonicalPlanFile);
|
||||
// #2893 parity — surface plan-shaped files the canonical filter
|
||||
// rejected so callers (executor init, etc.) don't silently see zero
|
||||
// plans. Per-dir prefix mirrors phase.cjs:120.
|
||||
const w = describeNonCanonicalPlans(dirFiles, filtered);
|
||||
if (w) warnings.push(`${dir}: ${w}`);
|
||||
} else if (type === 'summaries') {
|
||||
filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
} else {
|
||||
@@ -1561,7 +1790,13 @@ export const phasesList: QueryHandler = async (args, projectDir, workstream) =>
|
||||
}
|
||||
files.push(...filtered.sort());
|
||||
}
|
||||
return { data: { files, count: files.length, phase_dir: phase ? dirs[0]?.replace(/^\d+(?:\.\d+)*-?/, '') : null } };
|
||||
const result: Record<string, unknown> = {
|
||||
files,
|
||||
count: files.length,
|
||||
phase_dir: phase ? dirs[0]?.replace(/^\d+(?:\.\d+)*-?/, '') : null,
|
||||
};
|
||||
if (warnings.length) result['warning'] = warnings.join(' | ');
|
||||
return { data: result };
|
||||
}
|
||||
|
||||
return { data: { directories: dirs, count: dirs.length } };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user