enhancement(#537): migrate all hand-written bin/lib/*.cjs to TypeScript source of truth (ADR-457) (#602)

* enhancement(#537): migrate code-review-flags to TS source of truth

Collapse the hand-written get-shit-done/bin/lib/code-review-flags.cjs to a
TypeScript source of truth (src/code-review-flags.cts), compiled by tsc to a
gitignored .cjs build artifact at the same path, per ADR-457 (build-at-publish).
Second module after the semver-compare pilot (#541).

Behaviour is preserved byte-for-behaviour (characterization test added in
tests/code-review-flags.test.cjs locks the parser quirks). Adds compile-time
type checking: CodeReviewFlags interface + CodeReviewWorkflow literal union.
The require() path is unchanged, so code-review.md and the bug-3727 test keep
working. The emitted .cjs is gitignored and eslint-ignored, mirroring the pilot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 9 leaf bin/lib modules to TS source of truth

ADR-457 build-at-publish, batch 1 (pure leaf modules, 0 sibling-deps):
001-legacy-orphan-files, context-utilization, redaction, artifacts,
command-arg-projection, clock, ui-safety-gate, review-reviewer-selection,
clusters. Each moves to src/*.cts (strict TS, typed), compiled by tsc to a
gitignored .cjs at the same require() path; behaviour preserved byte-for-
behaviour. Adds src/node-globals.d.ts (minimal ambient shim; "types":[]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#537): add @types/node, drop hand-rolled node-globals shim

ADR-457 migration infra: replace the temporary src/node-globals.d.ts ambient
shim with @types/node@22 + "types":["node"] in tsconfig.build.json. Unblocks
migrating the ~49 remaining bin/lib modules that use node:fs/path/os/
child_process. Build + full suite (3030 pass) + lint all green; no .cts type
changes were needed (real Node types matched the shim).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 9 more bin/lib modules to TS (batch 2)

ADR-457 build-at-publish. Clean leaves: installer-migration-report,
prompt-budget. Type-error-prone leaves (were tsconfig.lint-excluded; now
strict-typed and removed from that exclude list): secrets, phase-lifecycle,
workstream-name-policy, decisions, validate, schema-detect. Plus
runtime-name-policy. Strict type fixes narrow unknown->concrete domain types
(no any/ts-ignore); behaviour preserved. Full suite green, lint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate runtime-slash to TS (cross-import proof)

ADR-457. First cross-module TS->TS import: src/runtime-slash.cts imports
./runtime-name-policy.cjs and tsc resolves the sibling .cts types under strict
(no declaration files; NodeNext .cjs->.cts mapping), emitting a correct
require("./runtime-name-policy.cjs"). Confirms the recipe for coupled modules,
which must be migrated in dependency order (leaves-up). Suite green, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 10 more bin/lib modules to TS (batch 3)

ADR-457 build-at-publish, Wave-1 leaves: event, workstream-inventory-builder,
plan-scan, fallow-runner, project-root, installer-migration-authoring,
update-context, 000-first-time-baseline, runtime-homes, model-catalog. Strict
typing fixed real issues (narrowing unknown, qualified fs/path calls, removed
unnecessary casts); plan-scan/project-root/workstream-inventory-builder dropped
from tsconfig.lint exclude. Behaviour preserved; suite green, lint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 5 large Wave-1 leaves to TS (batch 4)

ADR-457 build-at-publish: configuration, state-document, shell-command-
projection (42 dependents), security, command-aliases. shell-command-
projection keeps a namespace child_process import for mock-intercept
testability. loadConfig/migrateOnDisk emit synchronously (every caller uses
them sync; the one awaited migrateOnDisk caller tolerates a non-Promise) —
full suite (3030 pass) confirms behaviour preserved. configuration/
state-document/command-aliases dropped from tsconfig.lint exclude. Also fixes
the malformed batch-3 changeset frontmatter (type/pr) that failed lint:docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 6 Wave-2 modules to TS (batch 5)

ADR-457 build-at-publish: config-schema, model-profiles,
002-codex-legacy-hooks-json, logger, active-workstream-store, adr-parser.
First batch importing already-migrated siblings (configuration, model-catalog,
shell-command-projection, redaction, security) via ./sibling.cjs specifiers.
Strict type narrowing (typeof guards over String(unknown)); behaviour
preserved; suite 3030 pass, lint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 5 large Wave-2 modules to TS (batch 6)

ADR-457 build-at-publish: graphify, install-profiles, intel,
installer-migrations, worktree-safety. installer-migrations preserves its
dynamic require() loader for numbered migration modules (scoped lint
suppressions). Strict typing (typeof guards over String(unknown)); behaviour
preserved; suite 3030 pass, lint 0 errors. Wave 2 complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate Wave-3 modules to TS (batch 7)

ADR-457 build-at-publish: planning-workspace, runtime-artifact-layout,
command-routing-hub, drift. Uses `import x = require()` for export= siblings;
drift's lazy require of runtime-slash hoisted to a top-level import (verified
non-circular). Behaviour preserved; suite 3030 pass, lint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate small Wave-4 modules to TS (batch 8)

ADR-457 build-at-publish: cjs-command-router-adapter, phase-command-router,
surface, roadmap-upgrade. Typed the hub router handler results as the HubResult
discriminated union; surface drops 4 genuinely-unused imports. Behaviour
preserved; suite 3030 pass, lint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate core hub (2.5k LOC, 68 dependents) to TS (batch 9)

ADR-457 build-at-publish: get-shit-done/bin/lib/core.cjs -> src/core.cts,
preserving all 63 exports via export=. All sibling deps already migrated
(shell-command-projection, model-profiles, model-catalog, worktree-safety,
planning-workspace, project-root, configuration, config-schema). Strict types,
no any/ts-ignore; config-schema lazy require hoisted (non-circular). Behaviour
preserved (independently verified: core's shard 3030 pass / 0 fail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#537): make ESLint-coverage + test-sprawl checks migration-aware

#551 test hardcoded 12 now-migrated modules as "hand-written, must be linted";
that invariant is obsoleted by the ADR-457 migration. Rewrite it to a
filesystem-driven invariant that holds at every stage: a bin/lib/*.cjs must be
eslint-ignored IFF it has a src/*.cts source (tsc-generated), else linted
(covers package-identity, which has no TS source). Also eslint-ignore
config-types.cjs (has a src counterpart) and drop the redundant
tests/clock.test.cjs (clock already covered by clock-seam + bug-474 tests),
which tripped the lint-test-file-count ratchet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 9 Wave-5 router/inventory modules to TS (batch 10)

ADR-457 build-at-publish: phases/verify/init/agent/task/validate/roadmap/state
command routers + workstream-inventory. Router handler results typed against
core's exported shapes; behaviour preserved (caught+fixed a --verify boolean
flag regression mid-migration). Full suite green across all shards (only the 4
local gpg-env changeset-notes failures remain; CI passes them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 7 Wave-5 modules to TS (batch 11)

ADR-457 build-at-publish: gap-checker, docs, check-command-router, frontmatter,
learnings, gsd2-import, profile-pipeline. Behaviour preserved; full suite green
across all shards (only the 4 local gpg-env failures remain). Also broadens
atomic-write-coverage.test.cjs to accept the tsc-compiled namespace-import form
while still asserting platformWriteSync is called (safety guard intact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate config + profile-output to TS (batch 12)

ADR-457 build-at-publish: config (729 LOC), profile-output (1142 LOC). All
exports preserved; cmdMigrateConfig de-asynced (migrateOnDisk is sync, awaited
caller tolerates it). Behaviour preserved; suite green across all shards
(only the 4 local gpg-env failures). Wave 5 complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate 5 Wave-6 modules to TS (batch 13)

ADR-457 build-at-publish: template, uat, workstream, roadmap, audit. Behaviour
preserved (dead toPosixPath import dropped from audit; inline requires hoisted).
Suite green across all shards (only the 4 local gpg-env failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate commands + state hubs to TS (batch 14)

ADR-457 build-at-publish: commands (1305 LOC), state (2074 LOC, 17 dependents).
All exports preserved; inner requires kept non-hoisted where load-order matters
(install.js, per-call security); acquireStateLock cast inlined to preserve the
err.code source token a structural test inspects. Behaviour preserved; suite
green across all shards (only the 4 local gpg-env failures). Wave 6 complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate milestone to TS (batch 15a, hand-authored)

ADR-457 build-at-publish: milestone -> src/milestone.cts. Authored directly
(subagent capacity was unavailable). Also relaxes core.output()'s 3rd param to
optional, matching its real always-optional call contract (unblocks remaining
2-arg output callers). Behaviour preserved; suite green across all shards
(only the 4 local gpg-env failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537): migrate phase, verify, init to TS (batch 15, final modules)

ADR-457 build-at-publish, Wave 7 (the last hubs): phase (1608 LOC), verify
(1615), init (2113). Adds src/package-identity.d.cts so verify can import the
permanently value-baked package-identity.cjs under strict TS.

Fixes two regressions the migration introduced in verify: restore
cmdValidateHealth's `return result` (callers/tests read result.warnings — it is
NOT side-effect-only), and make the bug-3384 source-pattern test tolerant of the
tsc-compiled bracket-notation form of the git_list_failed->W020 branch (behaviour
intact). Full suite green across all shards (only the 4 local gpg-env failures);
lint 0 errors. All 86 migratable bin/lib modules are now TypeScript sources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#537): finalize ADR-457 migration — retire tsconfig.lint.json

All hand-written bin/lib/*.cjs are now src/*.cts sources, so the checkJs
stopgap tsconfig.lint.json (unused; not wired into eslint, scripts, or CI) is
deleted per ADR-457's final step. Also gitignore the tsc-generated
config-types.cjs (was still committed) for consistency with every other
emitted artifact. package-identity.cjs stays value-baked (declared via
src/package-identity.d.cts). Suite green; #551 ESLint-coverage test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): add prepare script so unpacked/git installs build bin/lib artifacts

ADR-457 build-at-publish: bin/lib/*.cjs are now gitignored, built by tsc. The
prepack/prepublishOnly hooks cover `npm pack`/publish, but `npm install -g
<dir>` and git installs run the `prepare` lifecycle — which was missing — so the
unpacked install shipped without the compiled .cjs and failed at startup with
"Cannot find module './lib/core.cjs'" (caught by the smoke-unpacked CI job).
Add `prepare` mirroring prepublishOnly (build:lib + build:hooks). prepare does
NOT run for registry consumers (they get the pre-built tarball), only for
source/local/pack installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): make CI build/lockfile checks work with gitignored bin/lib artifacts

ADR-457 build-at-publish exposed two CI assumptions that bin/lib/*.cjs are
always present on disk:
- check:env's lockfile-sync ran `npm ci --dry-run`, which now triggers the
  `prepare` build (tsc) — but it runs before deps are installed, so tsc is
  absent and it misreported the lockfile as out of sync. Add --ignore-scripts
  (a lockfile check must not build).
- the lint-tests job installs with --ignore-scripts (no prepare build), but
  lint:skill-deps require()s the built install-profiles.cjs. Add an explicit
  `npm run build:lib` step after install.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): narrow prepare to build:lib only (unbreak packed-smoke pack step)

prepare running build:hooks emitted "✓ Copying ..." stdout during `npm pack`,
which the install-smoke "Pack root tarball" step captures into $GITHUB_OUTPUT —
breaking it with "Invalid format". build:lib (tsc) is silent on success and is
all the unpacked/source install needs (the smoke-unpacked assertions exercise
gsd-tools, i.e. bin/lib, and tolerate hook setup with `|| true`). Matches
prepack. build:hooks still runs on prepublishOnly for real publishes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): wire Stryker mutation gate to build-at-publish layout

The gate scored 0.00 because it mutated changed bin/lib/*.cjs that (a) were
generated artifacts and (b) included modules with no coverage in the command's
test set. Rework: mutation.yml now derives changed COVERED modules from
src/*.cts and maps them to their built bin/lib/*.cjs; Stryker mutates those
built artifacts with a no-rebuild command (mutating src/*.cts + per-mutant tsc
was ~3x over the 30-min CI budget).

NOTE: with the gate now correctly measuring the covered modules, their actual
mutation score is 42.94% (< break 50) — a pre-existing test-coverage gap
(adr-parser/prompt-budget/etc.), not introduced by this behaviour-preserving
migration. Reaching 50 needs more tests, a threshold/scope change, or a waiver —
a maintainer decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#537): raise mutation coverage of covered modules above the 50 gate

Adds focused example-based unit tests that kill surviving mutants in the two
lowest-scoring covered modules:
- tests/prompt-budget.unit.test.cjs (112 tests): 17.9% -> 97.9%
- tests/adr-parser.unit.test.cjs (205 tests): 44.7% -> 89.4%
Both wired into stryker.config.mjs's command. Fresh full run over the 6 covered
modules now scores 82.25% (>= break 50); every covered module is >= 68%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* enhancement(#537,#609): parallelize mutation gate via dynamic per-module matrix

The serial Stryker run timed out at 30 min once the migration's added tests
made every mutant re-run ~300 tests. Replace it with a dynamic matrix so the
gate completes well under budget — folded into this PR (was tracked as #609)
because it's a prerequisite for this PR's mutation gate to pass.

- scripts/mutation-matrix.cjs: single source of truth (covered-module -> test
  files) computing changed covered modules from git diff -> {has_work, matrix}.
- mutation.yml: detect -> dynamic `matrix: fromJSON(...)` mutate job (one
  parallel shard per changed module, scoped via MUTATION_TEST_CMD to only that
  module's tests, 15-min/shard) -> summary job that KEEPS the legacy check name
  "Stryker mutation score (changed files only)" so branch protection is
  unchanged. Per-shard jobs report as "Stryker (<module>)".
- stryker.config.mjs: commandRunner.command reads MUTATION_TEST_CMD (falls back
  to the full command locally).

Closes #609.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#537,#609): give each mutation shard ≥50% on its own tests; drop blacksmith note

Per-module sharding revealed that active-workstream-store (46.5%) and
frontmatter (7.4%) only cleared 50% in the old serial run via timeout-noise from
the bloated 300-test command; on their own tests they were below the gate. Add
focused unit tests:
- tests/active-workstream-store.unit.test.cjs (115 tests): 46.5% -> 81.9%
- tests/frontmatter.unit.test.cjs (165 tests): 7.4% -> 63.4%
Both wired into scripts/mutation-matrix.cjs (per-module test map) and
stryker.config.mjs DEFAULT_TEST_CMD. All 6 covered modules now clear break:50
with only their own tests (config-schema/context-utilization/prompt-budget/
adr-parser already did). Also removes the leftover blacksmith TODO comment —
GitHub-hosted runners only; speed comes from parallel per-module shards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#537,#609): strengthen prompt-budget tests to clear the gate on its own tests

prompt-budget scored 39.58% when mutation-tested with ONLY its own tests (the
way the per-module CI shard runs it) — an earlier ~98% reading was inflated by
accidentally running the full multi-module command. Add 96 targeted tests to
tests/prompt-budget.unit.test.cjs (exact note-template text, plan-truncation
arithmetic/percentages, drop-block strings, noteInjected/hardFailed booleans):
scoped score 39.58% -> 68.75% (>= break 50). All 6 covered modules now clear
the gate on their own tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-02 11:45:01 -04:00
committed by GitHub
parent 3bb2f8f1c5
commit df04aae5e4
149 changed files with 20885 additions and 11261 deletions

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate code-review-flags to a TypeScript source of truth (`src/code-review-flags.cts`), compiled to a gitignored `.cjs` build artifact per ADR-457 (#537). Behaviour is preserved byte-for-behaviour from the prior hand-written `.cjs`; adds compile-time type checking via strict TypeScript with `CodeReviewFlags` interface and `CodeReviewWorkflow` union type.
<!-- docs-exempt: Internal build-at-publish source migration (ADR-457). The hand-written .cjs is collapsed to a TS source compiled to a behaviourally-identical gitignored artifact at the same require() path. No user-facing command, output, behaviour, or configuration change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 9 pure leaf modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `context-utilization`, `artifacts`, `command-arg-projection`, `clock`, `ui-safety-gate`, `review-reviewer-selection`, `clusters`, `installer-migrations/001-legacy-orphan-files`, and `observability/redaction`. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only types are added. A minimal `src/node-globals.d.ts` ambient declaration covers `process`, `require`, and `module` globals for modules that use them (since `"types": []` is set in `tsconfig.build.json`).
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; hand-written .cjs collapsed to TS sources compiled to behaviourally-identical gitignored artifacts at the same require() paths. No user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 9 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `phases-command-router`, `verify-command-router`, `init-command-router`, `agent-command-router`, `task-command-router`, `validate-command-router`, `workstream-inventory`, `roadmap-command-router`, and `state-command-router`. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 7 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `gap-checker`, `docs`, `check-command-router`, `frontmatter`, `learnings`, `gsd2-import`, and `profile-pipeline`. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 2 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `config` and `profile-output`. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added. Note: `cmdMigrateConfig` async dropped (migrateOnDisk is synchronous; caller's `await` is safe on a sync return value).
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 5 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `template`, `uat`, `workstream`, `roadmap`, and `audit`. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 2 hub modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `commands` (~1305 LOC, 17 exported functions including `cmdCommit`, `cmdStats`, `cmdWebsearch`, `cmdEffortSync`, etc.) and `state` (~2074 LOC, 28 exported functions including `readModifyWriteStateMd`, `acquireStateLock`, `cmdStateBeginPhase`, `cmdStateSync`, etc.). Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 3 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `phase` (~1608 LOC, 11 exported functions including `cmdPhasesList`, `cmdPhaseAdd`, `cmdPhaseInsert`, `cmdPhaseRemove`, `cmdPhaseComplete`, `computeDependencyLevels`, etc.), `verify` (~1615 LOC, 12 exported functions including `cmdValidateHealth`, `cmdValidateConsistency`, `cmdVerifyCodebaseDrift`, `cmdVerifySchemaDrift`, `cmdValidateAgents`, etc.), and `init` (~2113 LOC, 20 exported functions including `cmdInitExecutePhase`, `cmdInitPlanPhase`, `cmdInitManager`, `cmdInitProgress`, `cmdAgentSkills`, `buildSkillManifest`, etc.). Also adds `src/package-identity.d.cts` declaration file for the permanently hand-written `package-identity.cjs` module so strict `.cts` sources can import it under nodenext moduleResolution. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 10 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `installer-migration-report` (Group A), `prompt-budget` (Group A), `secrets` (Group B), `phase-lifecycle` (Group B), `workstream-name-policy` (Group B), `decisions` (Group B), `validate` (Group B), `schema-detect` (Group B), `runtime-name-policy` (Group C), and `runtime-slash` (Group C — first cross-module TS import, depends on `runtime-name-policy`). Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added. Group B entries removed from `tsconfig.lint.json` excludes now that they are first-class TypeScript.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 10 more `get-shit-done/bin/lib` runtime modules to TypeScript sources of truth (ADR-457 build-at-publish, batch 3): event, workstream-inventory-builder, plan-scan, fallow-runner, project-root, installer-migration-authoring, update-context, 000-first-time-baseline, runtime-homes, model-catalog. Each moves to `src/*.cts` (strict TS), compiled by `tsc` to a gitignored `.cjs` at the same `require()` path; behaviour preserved byte-for-behaviour.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 5 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `configuration`, `state-document`, `shell-command-projection`, `security`, and `command-aliases`. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added. The three modules previously in `tsconfig.lint.json` excludes (`configuration`, `state-document`, `command-aliases`) are now removed from that list.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 6 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `config-schema`, `model-profiles`, `installer-migrations/002-codex-legacy-hooks-json`, `observability/logger`, `active-workstream-store`, and `adr-parser`. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 5 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `graphify`, `install-profiles`, `intel`, `installer-migrations`, and `worktree-safety`. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 4 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `planning-workspace`, `runtime-artifact-layout`, `command-routing-hub`, and `drift`. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate 4 modules from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). Modules migrated: `cjs-command-router-adapter`, `phase-command-router`, `surface`, and `roadmap-upgrade`. Each `src/<m>.cts` compiles to a gitignored `get-shit-done/bin/lib/<m>.cjs` with behaviour preserved byte-for-behaviour; only strict types are added.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifacts at same require() paths; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate `core` (the most depended-upon module, ~68 internal dependents) from hand-written CommonJS to TypeScript source of truth per ADR-457 (#537). `src/core.cts` compiles to a gitignored `get-shit-done/bin/lib/core.cjs` with behaviour preserved byte-for-behaviour; only strict types are added.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifact at same require() path; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Finalize the ADR-457 `bin/lib` TypeScript migration (#537): retire the `tsconfig.lint.json` `checkJs` stopgap now that every hand-written `bin/lib/*.cjs` has been collapsed to a `src/*.cts` source of truth, and treat the tsc-generated `config-types.cjs` as a gitignored build artifact like the rest. `package-identity.cjs` remains value-baked (declared via `src/package-identity.d.cts`).
<!-- docs-exempt: Internal ADR-457 build-at-publish migration finalization; removes an unused stopgap tsconfig and gitignores a generated artifact; no user-facing change. -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 537
---
Migrate `get-shit-done/bin/lib/milestone.cjs` to a TypeScript source of truth (`src/milestone.cts`) per ADR-457 build-at-publish; compiled by `tsc` to a gitignored `.cjs` at the same `require()` path. Behaviour preserved byte-for-behaviour. Also relaxes `core`'s `output()` 3rd parameter to optional, matching its real (always-optional) call contract.
<!-- docs-exempt: Internal ADR-457 build-at-publish source migration; behaviourally-identical gitignored artifact at same require() path; no user-facing change. -->

View File

@@ -1,10 +1,11 @@
name: Mutation Testing
# PR-GATING: runs on every pull_request targeting `next` or `main`.
# Computes changed core lib files via git diff and passes them to --mutate,
# so only mutants in CHANGED files are tested — keeps the job bounded.
# If no core lib files changed, the gate passes trivially (skip+exit 0).
# Full-repo mutation runs are reserved for local exploration (npm run test:mutation).
# scripts/mutation-matrix.cjs is the single source of truth for which modules
# are "covered" (have meaningful test coverage for mutation). It computes
# changed modules from git diff and emits a GitHub Actions matrix so each
# changed module gets its own Stryker shard running in parallel.
# If no covered modules changed the gate passes trivially (has_work: false).
on:
pull_request:
@@ -12,10 +13,15 @@ on:
- next
- main
paths:
# Only run when lib source or property tests change
# Only run when lib source, property/unit tests, or mutation config change
- 'src/**/*.cts'
- 'get-shit-done/bin/lib/**/*.cjs'
- 'tests/**/*.property.test.cjs'
- 'tests/**/*.unit.test.cjs'
- 'tests/adr-parser.test.cjs'
- 'tests/active-workstream-store.test.cjs'
- 'stryker.config.mjs'
- 'scripts/mutation-matrix.cjs'
workflow_dispatch:
concurrency:
@@ -26,10 +32,60 @@ permissions:
contents: read
jobs:
mutation:
name: Stryker mutation score (changed files only)
# ── Job 1: detect ─────────────────────────────────────────────────────────
# Computes which covered modules changed and emits a matrix for the mutate job.
# Intentionally does NOT run npm ci — it only needs git + Node builtins.
detect:
name: Detect changed covered modules
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
has_work: ${{ steps.matrix.outputs.has_work }}
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: true
- name: Fetch base ref for diff
# Ensure the base branch tip is available for the git diff below.
# fetch-depth: 0 above gets all history, but the remote ref name must exist.
# For workflow_dispatch (no base_ref) we fall back to `next`.
run: git fetch origin ${{ github.base_ref || 'next' }} --depth=1
- name: Compute mutation matrix
id: matrix
run: |
BASE_REF="origin/${{ github.base_ref || 'next' }}"
# Run the matrix script; capture the JSON output.
JSON=$(node scripts/mutation-matrix.cjs --base "${BASE_REF}")
# Extract has_work and the compact matrix string for GITHUB_OUTPUT.
HAS_WORK=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).has_work)" <<< "${JSON}")
MATRIX=$(node -e "process.stdout.write(JSON.stringify(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).matrix))" <<< "${JSON}")
echo "has_work=${HAS_WORK}" >> "${GITHUB_OUTPUT}"
echo "matrix=${MATRIX}" >> "${GITHUB_OUTPUT}"
# Human-readable summary for the step log.
echo "has_work=${HAS_WORK}"
echo "${JSON}"
# ── Job 2: mutate ──────────────────────────────────────────────────────────
# One shard per changed covered module, each running only that module's tests.
# Skipped entirely when detect reports no covered files changed.
mutate:
name: Stryker (${{ matrix.name }})
needs: detect
if: needs.detect.outputs.has_work == 'true'
# GitHub-hosted runners only. Speed comes from running shards in PARALLEL
# (one job per changed module), not from larger/3rd-party runners.
runs-on: ubuntu-latest
timeout-minutes: 15 # per-shard; lower than the old 30-min serial budget
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -43,52 +99,69 @@ jobs:
node-version-file: .nvmrc
cache: npm
- name: Install dependencies
- name: Install dependencies (builds .cjs via prepare)
run: npm ci
- name: Fetch base ref for diff
# Ensure the base branch tip is available for the git diff below.
# fetch-depth: 0 above gets all history, but the remote ref name must exist.
run: git fetch origin ${{ github.base_ref }} --depth=1
- name: Compute changed core lib files
id: changed
run: |
BASE_REF="origin/${{ github.base_ref }}"
# Find changed non-test, non-generated .cjs files in bin/lib
CHANGED=$(git diff --name-only "${BASE_REF}...HEAD" -- 'get-shit-done/bin/lib/**/*.cjs' \
| grep -v '\.test\.cjs$' \
| grep -v -E '/(configuration|command-aliases|commands|core|install-profiles|installer-migrations|phase|profile-output|state|verify|init|audit|gsd2-import)\.cjs$' \
|| true)
if [ -z "$CHANGED" ]; then
echo "changed=" >> "$GITHUB_OUTPUT"
else
# Join with commas for --mutate
MUTATE_LIST=$(echo "$CHANGED" | paste -sd, -)
echo "changed=${MUTATE_LIST}" >> "$GITHUB_OUTPUT"
fi
- name: Skip mutation gate (no core lib files changed)
if: steps.changed.outputs.changed == ''
run: echo "No core lib files changed; skipping mutation gate"
- name: Run Stryker (incremental, changed files only)
if: steps.changed.outputs.changed != ''
# --mutate scopes mutation to only the changed production files.
- name: Run Stryker — ${{ matrix.name }}
# MUTATION_TEST_CMD scopes the command runner to only this module's tests.
# --mutate scopes mutation to only the changed module's built artifact.
# --incremental reuses cached results for unchanged mutants.
# The break threshold (50) is read from stryker.config.mjs and causes
# Stryker to exit non-zero when mutation score < 50%, failing the PR check.
# The break threshold (50) is read from stryker.config.mjs.
env:
NODE_OPTIONS: '--max-old-space-size=4096'
MUTATION_TEST_CMD: node --test ${{ matrix.tests }}
run: |
MUTATE_LIST="${{ steps.changed.outputs.changed }}"
echo "Running Stryker --mutate '${MUTATE_LIST}'"
npx stryker run --incremental --mutate "${MUTATE_LIST}"
echo "Module: ${{ matrix.name }}"
echo "Mutate: ${{ matrix.mutate }}"
echo "Tests: ${{ matrix.tests }}"
npx stryker run --incremental --mutate "${{ matrix.mutate }}"
- name: Upload mutation HTML report
- name: Upload mutation report — ${{ matrix.name }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() # upload even on failure so the score is visible
with:
name: mutation-report-${{ github.run_number }}
name: mutation-report-${{ matrix.name }}-${{ github.run_number }}
path: reports/mutation/mutation.html
retention-days: 14
# ── Job 3: mutation-gate ───────────────────────────────────────────────────
# Stable required-check name for branch protection. Passes when:
# • has_work is false (nothing to mutate — trivial pass), OR
# • all mutate shards succeeded.
# Fails when any shard failed.
# Summary job keeps the LEGACY check name so existing branch protection
# (which requires "Stryker mutation score (changed files only)") needs no
# change. The per-module shards report as "Stryker (<module>)".
mutation-gate:
name: Stryker mutation score (changed files only)
needs: [detect, mutate]
if: always()
runs-on: ubuntu-latest
steps:
- name: Evaluate gate
run: |
DETECT="${{ needs.detect.result }}"
MUTATE="${{ needs.mutate.result }}"
HAS_WORK="${{ needs.detect.outputs.has_work }}"
echo "detect result : ${DETECT}"
echo "mutate result : ${MUTATE}"
echo "has_work : ${HAS_WORK}"
if [ "${DETECT}" != "success" ]; then
echo "FAIL: detect job did not succeed (${DETECT})"
exit 1
fi
if [ "${HAS_WORK}" = "false" ]; then
echo "PASS: no covered modules changed — gate trivially green"
exit 0
fi
if [ "${MUTATE}" = "success" ]; then
echo "PASS: all mutation shards passed"
exit 0
fi
echo "FAIL: one or more mutation shards failed or were cancelled (${MUTATE})"
exit 1

View File

@@ -96,6 +96,11 @@ jobs:
node-version: 24
- name: Install dev dependencies
run: npm ci --ignore-scripts
# ADR-457 build-at-publish: bin/lib/*.cjs are gitignored, built by tsc.
# --ignore-scripts skips the prepare build, but lint:skill-deps (and other
# lint scripts) require() the built modules — so build them explicitly.
- name: Build runtime lib (required by lint scripts)
run: npm run build:lib
- name: Lint — ESLint (source-grep + timing + no-only-tests + quality)
run: npx eslint . --cache --cache-location node_modules/.cache/eslint/
- name: Lint — skill dependency graph

85
.gitignore vendored
View File

@@ -67,6 +67,91 @@ build/
# by `npm run build:lib`). Source of truth is src/; these are emitted, never edited.
# Published via prepublishOnly; built before test via pretest. Grows as modules migrate.
/get-shit-done/bin/lib/semver-compare.cjs
/get-shit-done/bin/lib/config-types.cjs
/get-shit-done/bin/lib/code-review-flags.cjs
/get-shit-done/bin/lib/context-utilization.cjs
/get-shit-done/bin/lib/artifacts.cjs
/get-shit-done/bin/lib/command-arg-projection.cjs
/get-shit-done/bin/lib/clock.cjs
/get-shit-done/bin/lib/ui-safety-gate.cjs
/get-shit-done/bin/lib/review-reviewer-selection.cjs
/get-shit-done/bin/lib/clusters.cjs
/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs
/get-shit-done/bin/lib/observability/redaction.cjs
/get-shit-done/bin/lib/installer-migration-report.cjs
/get-shit-done/bin/lib/prompt-budget.cjs
/get-shit-done/bin/lib/secrets.cjs
/get-shit-done/bin/lib/phase-lifecycle.cjs
/get-shit-done/bin/lib/workstream-name-policy.cjs
/get-shit-done/bin/lib/decisions.cjs
/get-shit-done/bin/lib/validate.cjs
/get-shit-done/bin/lib/schema-detect.cjs
/get-shit-done/bin/lib/runtime-name-policy.cjs
/get-shit-done/bin/lib/runtime-slash.cjs
/get-shit-done/bin/lib/observability/event.cjs
/get-shit-done/bin/lib/workstream-inventory-builder.cjs
/get-shit-done/bin/lib/plan-scan.cjs
/get-shit-done/bin/lib/fallow-runner.cjs
/get-shit-done/bin/lib/project-root.cjs
/get-shit-done/bin/lib/installer-migration-authoring.cjs
/get-shit-done/bin/lib/update-context.cjs
/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs
/get-shit-done/bin/lib/runtime-homes.cjs
/get-shit-done/bin/lib/model-catalog.cjs
/get-shit-done/bin/lib/configuration.cjs
/get-shit-done/bin/lib/state-document.cjs
/get-shit-done/bin/lib/shell-command-projection.cjs
/get-shit-done/bin/lib/security.cjs
/get-shit-done/bin/lib/command-aliases.cjs
/get-shit-done/bin/lib/config-schema.cjs
/get-shit-done/bin/lib/model-profiles.cjs
/get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs
/get-shit-done/bin/lib/observability/logger.cjs
/get-shit-done/bin/lib/active-workstream-store.cjs
/get-shit-done/bin/lib/adr-parser.cjs
/get-shit-done/bin/lib/graphify.cjs
/get-shit-done/bin/lib/install-profiles.cjs
/get-shit-done/bin/lib/intel.cjs
/get-shit-done/bin/lib/installer-migrations.cjs
/get-shit-done/bin/lib/worktree-safety.cjs
/get-shit-done/bin/lib/planning-workspace.cjs
/get-shit-done/bin/lib/runtime-artifact-layout.cjs
/get-shit-done/bin/lib/command-routing-hub.cjs
/get-shit-done/bin/lib/core.cjs
/get-shit-done/bin/lib/drift.cjs
/get-shit-done/bin/lib/cjs-command-router-adapter.cjs
/get-shit-done/bin/lib/phase-command-router.cjs
/get-shit-done/bin/lib/surface.cjs
/get-shit-done/bin/lib/gap-checker.cjs
/get-shit-done/bin/lib/docs.cjs
/get-shit-done/bin/lib/check-command-router.cjs
/get-shit-done/bin/lib/frontmatter.cjs
/get-shit-done/bin/lib/learnings.cjs
/get-shit-done/bin/lib/gsd2-import.cjs
/get-shit-done/bin/lib/profile-pipeline.cjs
/get-shit-done/bin/lib/roadmap-upgrade.cjs
/get-shit-done/bin/lib/phases-command-router.cjs
/get-shit-done/bin/lib/verify-command-router.cjs
/get-shit-done/bin/lib/init-command-router.cjs
/get-shit-done/bin/lib/agent-command-router.cjs
/get-shit-done/bin/lib/task-command-router.cjs
/get-shit-done/bin/lib/validate-command-router.cjs
/get-shit-done/bin/lib/workstream-inventory.cjs
/get-shit-done/bin/lib/roadmap-command-router.cjs
/get-shit-done/bin/lib/state-command-router.cjs
/get-shit-done/bin/lib/config.cjs
/get-shit-done/bin/lib/profile-output.cjs
/get-shit-done/bin/lib/template.cjs
/get-shit-done/bin/lib/commands.cjs
/get-shit-done/bin/lib/state.cjs
/get-shit-done/bin/lib/milestone.cjs
/get-shit-done/bin/lib/phase.cjs
/get-shit-done/bin/lib/verify.cjs
/get-shit-done/bin/lib/init.cjs
/get-shit-done/bin/lib/uat.cjs
/get-shit-done/bin/lib/workstream.cjs
/get-shit-done/bin/lib/roadmap.cjs
/get-shit-done/bin/lib/audit.cjs
__pycache__/
*.pyc
.venv/

View File

@@ -35,6 +35,91 @@ export default tseslint.config(
'**/*.generated.cjs',
// ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs.
'get-shit-done/bin/lib/semver-compare.cjs',
'get-shit-done/bin/lib/code-review-flags.cjs',
'get-shit-done/bin/lib/context-utilization.cjs',
'get-shit-done/bin/lib/artifacts.cjs',
'get-shit-done/bin/lib/command-arg-projection.cjs',
'get-shit-done/bin/lib/clock.cjs',
'get-shit-done/bin/lib/ui-safety-gate.cjs',
'get-shit-done/bin/lib/review-reviewer-selection.cjs',
'get-shit-done/bin/lib/clusters.cjs',
'get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs',
'get-shit-done/bin/lib/observability/redaction.cjs',
'get-shit-done/bin/lib/installer-migration-report.cjs',
'get-shit-done/bin/lib/prompt-budget.cjs',
'get-shit-done/bin/lib/secrets.cjs',
'get-shit-done/bin/lib/phase-lifecycle.cjs',
'get-shit-done/bin/lib/workstream-name-policy.cjs',
'get-shit-done/bin/lib/decisions.cjs',
'get-shit-done/bin/lib/validate.cjs',
'get-shit-done/bin/lib/schema-detect.cjs',
'get-shit-done/bin/lib/runtime-name-policy.cjs',
'get-shit-done/bin/lib/runtime-slash.cjs',
'get-shit-done/bin/lib/observability/event.cjs',
'get-shit-done/bin/lib/workstream-inventory-builder.cjs',
'get-shit-done/bin/lib/plan-scan.cjs',
'get-shit-done/bin/lib/fallow-runner.cjs',
'get-shit-done/bin/lib/project-root.cjs',
'get-shit-done/bin/lib/installer-migration-authoring.cjs',
'get-shit-done/bin/lib/update-context.cjs',
'get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs',
'get-shit-done/bin/lib/runtime-homes.cjs',
'get-shit-done/bin/lib/model-catalog.cjs',
'get-shit-done/bin/lib/configuration.cjs',
'get-shit-done/bin/lib/state-document.cjs',
'get-shit-done/bin/lib/shell-command-projection.cjs',
'get-shit-done/bin/lib/security.cjs',
'get-shit-done/bin/lib/command-aliases.cjs',
'get-shit-done/bin/lib/config-schema.cjs',
'get-shit-done/bin/lib/model-profiles.cjs',
'get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs',
'get-shit-done/bin/lib/observability/logger.cjs',
'get-shit-done/bin/lib/active-workstream-store.cjs',
'get-shit-done/bin/lib/adr-parser.cjs',
'get-shit-done/bin/lib/graphify.cjs',
'get-shit-done/bin/lib/install-profiles.cjs',
'get-shit-done/bin/lib/intel.cjs',
'get-shit-done/bin/lib/installer-migrations.cjs',
'get-shit-done/bin/lib/worktree-safety.cjs',
'get-shit-done/bin/lib/planning-workspace.cjs',
'get-shit-done/bin/lib/runtime-artifact-layout.cjs',
'get-shit-done/bin/lib/command-routing-hub.cjs',
'get-shit-done/bin/lib/core.cjs',
'get-shit-done/bin/lib/drift.cjs',
'get-shit-done/bin/lib/cjs-command-router-adapter.cjs',
'get-shit-done/bin/lib/phase-command-router.cjs',
'get-shit-done/bin/lib/surface.cjs',
'get-shit-done/bin/lib/roadmap-upgrade.cjs',
'get-shit-done/bin/lib/config-types.cjs',
'get-shit-done/bin/lib/phases-command-router.cjs',
'get-shit-done/bin/lib/verify-command-router.cjs',
'get-shit-done/bin/lib/init-command-router.cjs',
'get-shit-done/bin/lib/agent-command-router.cjs',
'get-shit-done/bin/lib/task-command-router.cjs',
'get-shit-done/bin/lib/validate-command-router.cjs',
'get-shit-done/bin/lib/workstream-inventory.cjs',
'get-shit-done/bin/lib/roadmap-command-router.cjs',
'get-shit-done/bin/lib/state-command-router.cjs',
'get-shit-done/bin/lib/gap-checker.cjs',
'get-shit-done/bin/lib/config.cjs',
'get-shit-done/bin/lib/profile-output.cjs',
'get-shit-done/bin/lib/commands.cjs',
'get-shit-done/bin/lib/state.cjs',
'get-shit-done/bin/lib/milestone.cjs',
'get-shit-done/bin/lib/phase.cjs',
'get-shit-done/bin/lib/verify.cjs',
'get-shit-done/bin/lib/init.cjs',
'get-shit-done/bin/lib/docs.cjs',
'get-shit-done/bin/lib/check-command-router.cjs',
'get-shit-done/bin/lib/frontmatter.cjs',
'get-shit-done/bin/lib/learnings.cjs',
'get-shit-done/bin/lib/gsd2-import.cjs',
'get-shit-done/bin/lib/profile-pipeline.cjs',
'get-shit-done/bin/lib/template.cjs',
'get-shit-done/bin/lib/uat.cjs',
'get-shit-done/bin/lib/workstream.cjs',
'get-shit-done/bin/lib/roadmap.cjs',
'get-shit-done/bin/lib/audit.cjs',
],
},

View File

@@ -1,65 +0,0 @@
'use strict';
const { output, error, ERROR_REASON } = require('./core.cjs');
const QUOTA_SENTINELS = [
'429',
'usage_limit_reached',
'usage limit',
'rate limit',
'rate-limited',
'rate_limit',
'resource_exhausted',
'quota',
'too many requests',
'exceeded your',
];
const CLASSIFY_HANDOFF_SENTINEL = 'classifyhandoffifneeded is not defined';
function parseRetryAfter(body) {
const match = String(body || '').match(/\bretry[-_ ]after[:\s]+(\d+)\b/i);
if (!match) return undefined;
const seconds = Number.parseInt(match[1], 10);
return Number.isFinite(seconds) ? seconds : undefined;
}
function classifyAgentFailure(body) {
const normalized = String(body || '').toLowerCase();
if (normalized.trim() === '') {
return { class: 'unknown-failure' };
}
for (const sentinel of QUOTA_SENTINELS) {
if (normalized.includes(sentinel)) {
const retryAfterSeconds = parseRetryAfter(body);
return retryAfterSeconds === undefined
? { class: 'quota-exceeded', sentinel }
: { class: 'quota-exceeded', sentinel, retryAfterSeconds };
}
}
if (normalized.includes(CLASSIFY_HANDOFF_SENTINEL)) {
return {
class: 'classify-handoff-bug',
sentinel: CLASSIFY_HANDOFF_SENTINEL,
};
}
return { class: 'unknown-failure' };
}
function routeAgentCommand({ args, raw }) {
const subcommand = args[1];
if (subcommand !== 'classify-failure') {
error('Unknown agent subcommand. Available: classify-failure', ERROR_REASON.SDK_UNKNOWN_COMMAND);
}
const bodyArgs = args.slice(2).filter((arg) => arg !== '--');
output(classifyAgentFailure(bodyArgs.join(' ')), raw);
}
module.exports = {
classifyAgentFailure,
routeAgentCommand,
};

View File

@@ -1,74 +0,0 @@
'use strict';
/**
* Typed flag parser for the /gsd:code-review command.
*
* This is the canonical IR for code-review argument parsing. The workflow
* (code-review.md) delegates flag dispatch logic to this module so that:
* 1. Tests assert on a structured IR rather than on rendered bash text.
* 2. The dispatch decision is testable without instantiating the workflow.
*
* @typedef {Object} CodeReviewFlags
* @property {boolean} fix - true when --fix is present in argv
* @property {boolean} all - true when --all is present in argv (implies fix)
* @property {boolean} auto - true when --auto is present in argv (implies fix)
* @property {string} depth - depth override value, or '' if not supplied
* @property {string} files - files override value, or '' if not supplied
*/
/**
* Parse code-review flags from an argv array.
*
* The first positional argument (phase number) is ignored by this function —
* phase validation is handled by `gsd-tools query init.phase-op`.
*
* @param {string[]} argv - Array of argument strings, e.g. ['2', '--fix', '--all']
* @returns {CodeReviewFlags}
*/
function parseCodeReviewFlags(argv) {
const flags = {
fix: false,
all: false,
auto: false,
depth: '',
files: '',
};
for (const arg of argv) {
if (arg === '--fix') {
flags.fix = true;
} else if (arg === '--all') {
flags.all = true;
} else if (arg === '--auto') {
flags.auto = true;
} else if (arg.startsWith('--depth=')) {
flags.depth = arg.slice('--depth='.length);
} else if (arg.startsWith('--files=')) {
flags.files = arg.slice('--files='.length);
}
}
// --all and --auto imply --fix
if (flags.all || flags.auto) {
flags.fix = true;
}
return flags;
}
/**
* Determine which workflow to dispatch based on parsed flags.
*
* Returns the workflow filename (relative to workflows/) that the orchestrator
* should load:
* - 'code-review-fix.md' when fix=true (--fix, --all, or --auto present)
* - 'code-review.md' otherwise (review-only pass)
*
* @param {CodeReviewFlags} flags
* @returns {'code-review.md' | 'code-review-fix.md'}
*/
function resolveCodeReviewWorkflow(flags) {
return flags.fix ? 'code-review-fix.md' : 'code-review.md';
}
module.exports = { parseCodeReviewFlags, resolveCodeReviewWorkflow };

View File

@@ -1,19 +0,0 @@
"use strict";
/**
* TypeScript type definitions for GSD project config — model_policy block.
*
* These types reflect the model_policy config shape consumed by
* resolveModelPolicy in core.cjs and validated by config-schema.cjs.
*
* See feat #49 (model_policy presets) and config-schema.manifest.json.
* Added under ADR-457: TS sources in src/ compile to CJS artifacts in
* get-shit-done/bin/lib/ at publish time.
*
* Resolution precedence (highest → lowest):
* 1. model_overrides[agent]
* 2. model_policy.runtime_tiers[runtime][tier] (Sub-path A)
* 3. model_policy provider preset + budget (Sub-path B)
* 4. model_profile_overrides
* 5. resolve_model_ids / profile fallback
*/
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -1,246 +0,0 @@
'use strict';
/**
* Configuration Module — single source of truth for config loading,
* legacy-key normalization, defaults merge, and explicit on-disk migration.
*/
const { readFileSync, writeFileSync, existsSync, readdirSync } = require('node:fs');
const { join } = require('node:path');
// ─── Manifest requires ───────────────────────────────────────────────────────
function loadConfigurationManifest(fileName) {
const candidates = [
// Installed runtime layout: get-shit-done/bin/shared/*.manifest.json
join(__dirname, '..', 'shared', fileName),
];
let lastErr = null;
for (const candidate of candidates) {
try {
return require(candidate);
} catch (err) {
const isMissingCandidate =
err && err.code === 'MODULE_NOT_FOUND' && String(err.message || '').includes(candidate);
if (!isMissingCandidate) throw err;
lastErr = err;
}
}
throw new Error(
`${fileName} not found. Tried:\n${candidates.map((p) => ` ${p}`).join('\n')}\nLast error: ${lastErr?.message}`
);
}
const CONFIG_DEFAULTS = loadConfigurationManifest('config-defaults.manifest.json');
const SCHEMA_MANIFEST = loadConfigurationManifest('config-schema.manifest.json');
const VALID_CONFIG_KEYS = new Set(SCHEMA_MANIFEST.validKeys);
const RUNTIME_STATE_KEYS = new Set(SCHEMA_MANIFEST.runtimeStateKeys);
const DYNAMIC_KEY_PATTERNS = SCHEMA_MANIFEST.dynamicKeyPatterns.map((p) => {
const pattern = new RegExp(p.source);
return {
...p,
test: (key) => {
pattern.lastIndex = 0;
return pattern.test(key);
},
};
});
// ─── Depth → Granularity mapping ─────────────────────────────────────────────
const DEPTH_TO_GRANULARITY = {
quick: 'coarse',
standard: 'standard',
comprehensive: 'fine',
};
// ─── Internal helpers ─────────────────────────────────────────────────────────
function planningDir(cwd, workstream) {
if (!workstream)
return join(cwd, '.planning');
return join(cwd, '.planning', 'workstreams', workstream);
}
function detectSubRepos(cwd) {
const results = [];
try {
const entries = readdirSync(cwd, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory())
continue;
if (entry.name.startsWith('.') || entry.name === 'node_modules')
continue;
const gitPath = join(cwd, entry.name, '.git');
try {
if (existsSync(gitPath)) {
results.push(entry.name);
}
}
catch { /* ignore */ }
}
}
catch { /* ignore */ }
return results.sort();
}
function deepMergeConfig(base, overlay) {
const result = { ...base };
for (const key of Object.keys(overlay)) {
const ov = overlay[key];
if (ov !== null && ov !== undefined && typeof ov === 'object' && !Array.isArray(ov)) {
const bv = base[key];
if (bv !== null && bv !== undefined && typeof bv === 'object' && !Array.isArray(bv)) {
result[key] = deepMergeConfig(bv, ov);
}
else {
result[key] = deepMergeConfig({}, ov);
}
}
else {
result[key] = ov;
}
}
return result;
}
// ─── Exported functions ───────────────────────────────────────────────────────
function normalizeLegacyKeys(parsed) {
const result = { ...parsed };
const normalizations = [];
// 1. branching_strategy → git.branching_strategy
if (Object.prototype.hasOwnProperty.call(result, 'branching_strategy')) {
const value = result.branching_strategy;
const git = result.git ?? {};
if (git.branching_strategy === undefined) {
result.git = { ...git, branching_strategy: value };
}
else {
// canonical nested wins — just delete the stale top-level
result.git = { ...git };
}
delete result.branching_strategy;
normalizations.push({ from: 'branching_strategy', to: 'git.branching_strategy', value });
}
// 2. top-level sub_repos → planning.sub_repos
if (Object.prototype.hasOwnProperty.call(result, 'sub_repos')) {
const value = result.sub_repos;
const planning = result.planning ?? {};
if (planning.sub_repos === undefined) {
result.planning = { ...planning, sub_repos: value };
}
else {
// canonical nested wins — just drop the stale top-level
result.planning = { ...planning };
}
delete result.sub_repos;
normalizations.push({ from: 'sub_repos', to: 'planning.sub_repos', value });
}
// 3. multiRepo: true → marker (filesystem detection deferred to migrateOnDisk / caller)
if (result.multiRepo === true) {
delete result.multiRepo;
normalizations.push({ from: 'multiRepo', to: 'planning.sub_repos', value: true, requiresFilesystem: true });
}
// 4. top-level depth → granularity
if (Object.prototype.hasOwnProperty.call(result, 'depth') && !Object.prototype.hasOwnProperty.call(result, 'granularity')) {
const rawDepth = result.depth;
const mapped = DEPTH_TO_GRANULARITY[rawDepth] ?? rawDepth;
result.granularity = mapped;
delete result.depth;
normalizations.push({ from: 'depth', to: 'granularity', value: mapped });
}
return { parsed: result, normalizations };
}
function mergeDefaults(parsed) {
// Start with a deep clone of defaults, then overlay parsed
const defaults = structuredClone(CONFIG_DEFAULTS);
return deepMergeConfig(defaults, parsed);
}
async function loadConfig(cwd, options) {
const configPath = join(planningDir(cwd, options?.workstream), 'config.json');
let raw;
try {
raw = readFileSync(configPath, 'utf-8');
}
catch {
// File missing — return defaults
return mergeDefaults({});
}
const trimmed = raw.trim();
if (trimmed === '') {
return mergeDefaults({});
}
let parsed;
try {
parsed = JSON.parse(trimmed);
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`Config at ${configPath} must be a JSON object`);
}
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
if (options?.onNormalizations && normalizations.length > 0) {
options.onNormalizations(normalizations);
}
return mergeDefaults(normalized);
}
async function migrateOnDisk(cwd, workstream) {
const configPath = join(planningDir(cwd, workstream), 'config.json');
let raw;
try {
raw = readFileSync(configPath, 'utf-8');
}
catch {
// File missing — nothing to migrate
return { migrated: false, normalizations: [], wrote: null };
}
const trimmed = raw.trim();
if (trimmed === '') {
return { migrated: false, normalizations: [], wrote: null };
}
let parsed;
try {
parsed = JSON.parse(trimmed);
}
catch {
// Malformed — can't migrate
return { migrated: false, normalizations: [], wrote: null };
}
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
if (normalizations.length === 0) {
return { migrated: false, normalizations: [], wrote: null };
}
// Resolve multiRepo filesystem detection
const result = { ...normalized };
for (const norm of normalizations) {
if (norm.requiresFilesystem) {
const detected = detectSubRepos(cwd);
if (detected.length > 0) {
const planning = result.planning ?? {};
result.planning = { ...planning, sub_repos: detected, commit_docs: false };
}
}
}
try {
writeFileSync(configPath, JSON.stringify(result, null, 2));
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to write migrated config at ${configPath}: ${msg}`);
}
return { migrated: true, normalizations, wrote: configPath };
}
module.exports = {
loadConfig,
normalizeLegacyKeys,
mergeDefaults,
migrateOnDisk,
CONFIG_DEFAULTS,
VALID_CONFIG_KEYS,
RUNTIME_STATE_KEYS,
DYNAMIC_KEY_PATTERNS,
};

View File

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

View File

@@ -1,109 +0,0 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function candidateNames() {
return process.platform === 'win32'
? ['fallow.exe', 'fallow.cmd', 'fallow.bat', 'fallow']
: ['fallow'];
}
function isExecutableFile(filePath) {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return false;
if (process.platform === 'win32') return true;
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function findInPath(envPath) {
if (!envPath) return null;
const names = candidateNames();
const segments = envPath.split(path.delimiter).filter(Boolean);
for (const segment of segments) {
for (const name of names) {
const candidate = path.join(segment, name);
if (isExecutableFile(candidate)) return candidate;
}
}
return null;
}
function findInNodeModules(cwd) {
const names = candidateNames();
const binDir = path.join(cwd, 'node_modules', '.bin');
for (const name of names) {
const candidate = path.join(binDir, name);
if (isExecutableFile(candidate)) return candidate;
}
return null;
}
function resolveFallowBinary({ cwd, envPath = process.env.PATH || '' }) {
return findInNodeModules(cwd) || findInPath(envPath) || null;
}
function requireFallowBinary({ cwd, envPath = process.env.PATH || '' }) {
const binary = resolveFallowBinary({ cwd, envPath });
if (binary) return binary;
throw new Error(
'Fallow is enabled but no binary was found. Please install fallow via `npm install -D fallow` or `cargo install fallow`.',
);
}
function normalizeFallowReport(report) {
const unused = Array.isArray(report?.unusedExports) ? report.unusedExports : [];
const duplicates = Array.isArray(report?.duplicates) ? report.duplicates : [];
const circular = Array.isArray(report?.circularDependencies) ? report.circularDependencies : [];
const findings = [];
for (const item of unused) {
findings.push({
type: 'unused_export',
message: `Unused export ${item.symbol || '<unknown>'}`,
file: item.file || '',
line: item.line ?? null,
});
}
for (const item of duplicates) {
findings.push({
type: 'duplicate_block',
message: `Duplicate block (${Math.round((item.similarity || 0) * 100)}% similarity)`,
file: item.left?.file || '',
line: item.left?.start ?? null,
related_file: item.right?.file || '',
});
}
for (const item of circular) {
findings.push({
type: 'circular_dependency',
message: `Circular dependency: ${(item.cycle || []).join(' -> ')}`,
file: Array.isArray(item.cycle) && item.cycle.length > 0 ? item.cycle[0] : '',
line: null,
});
}
return {
summary: {
unused_exports: unused.length,
duplicates: duplicates.length,
circular_dependencies: circular.length,
total: findings.length,
},
findings,
};
}
module.exports = {
normalizeFallowReport,
requireFallowBinary,
resolveFallowBinary,
};

View File

@@ -1,58 +0,0 @@
'use strict';
const { INIT_SUBCOMMANDS } = require('./command-aliases.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { parseNamedArgs } = require('./command-arg-projection.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, error }) {
routeCjsCommandFamily({
args,
subcommands: INIT_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand, available) => `Unknown init workflow: ${_subcommand}\nAvailable: ${available.join(', ')}`,
handlers: {
'execute-phase': () => {
const { validate: epValidate, tdd: epTdd } = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: epValidate, tdd: epTdd });
},
'plan-phase': () => {
const { validate: ppValidate, tdd: ppTdd } = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: ppValidate, tdd: ppTdd });
},
'new-project': () => init.cmdInitNewProject(cwd, raw),
'new-milestone': () => init.cmdInitNewMilestone(cwd, raw),
quick: () => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw),
'ingest-docs': () => init.cmdInitIngestDocs(cwd, raw),
resume: () => init.cmdInitResume(cwd, raw),
'verify-work': () => init.cmdInitVerifyWork(cwd, args[2], raw),
'phase-op': () => init.cmdInitPhaseOp(cwd, args[2], raw),
todos: () => init.cmdInitTodos(cwd, args[2], raw),
'milestone-op': () => init.cmdInitMilestoneOp(cwd, raw),
'map-codebase': () => init.cmdInitMapCodebase(cwd, raw),
progress: () => 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': () => init.cmdInitNewWorkspace(cwd, raw),
'list-workspaces': () => init.cmdInitListWorkspaces(cwd, raw),
'remove-workspace': () => init.cmdInitRemoveWorkspace(cwd, args[2], raw),
},
});
}
module.exports = {
routeInitCommand,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,117 +0,0 @@
'use strict';
const path = require('path');
function requireNonEmptyString(record, field, source) {
if (typeof record[field] !== 'string' || record[field].trim() === '') {
throw new Error(`migration record must include a non-empty ${field}: ${source}`);
}
}
function validateStringArray(record, field, source) {
if (record[field] === undefined) return;
if (
!Array.isArray(record[field]) ||
record[field].length === 0 ||
record[field].some((value) => typeof value !== 'string' || value.trim() === '')
) {
throw new Error(`migration record ${field} must be a non-empty string array when provided: ${source}`);
}
}
function requireStringArray(record, field, source) {
if (
!Array.isArray(record[field]) ||
record[field].length === 0 ||
record[field].some((value) => typeof value !== 'string' || value.trim() === '')
) {
throw new Error(`migration record ${field} must be a non-empty string array: ${source}`);
}
}
function recordSource(record, fallback) {
return fallback || (record && typeof record.id === 'string' && record.id.trim() ? record.id : '<unknown>');
}
function validateInstallerMigrationRecord(record, source) {
const displaySource = recordSource(record, source);
if (!record || typeof record !== 'object') {
throw new Error(`migration record must export an object: ${displaySource}`);
}
// Authoring contract follows docs/installer-migrations.md#authoring-workflow
// and docs/adr/0008-installer-migration-module.md#decision.
requireNonEmptyString(record, 'id', displaySource);
requireNonEmptyString(record, 'title', displaySource);
requireNonEmptyString(record, 'description', displaySource);
requireNonEmptyString(record, 'introducedIn', displaySource);
if (typeof record.destructive !== 'boolean') {
throw new Error(`migration record must declare destructive as a boolean: ${displaySource}`);
}
validateStringArray(record, 'runtimes', displaySource);
requireStringArray(record, 'scopes', displaySource);
if (typeof record.plan !== 'function') {
throw new Error(`migration record must include a plan function: ${displaySource}`);
}
return record;
}
function actionSource(migration, action) {
const migrationId = migration && typeof migration.id === 'string' ? migration.id : '<unknown>';
const relPath = action && typeof action.relPath === 'string' ? action.relPath : '<unknown>';
return `${migrationId} ${relPath}`;
}
function requireActionEvidence(action, field, migration) {
if (typeof action[field] !== 'string' || action[field].trim() === '') {
throw new Error(`migration action ${action.type} must include ${field}: ${actionSource(migration, action)}`);
}
}
function validateSafeRelPath(relPath, migration, actionType) {
const source = actionSource(migration, { relPath });
const normalized = relPath.replace(/\\/g, '/');
if (path.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
throw new Error(`migration action ${actionType} relPath must stay inside configDir: ${source}`);
}
const segments = normalized.split('/');
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
throw new Error(`migration action ${actionType} relPath must stay inside configDir: ${source}`);
}
}
function validateInstallerMigrationActions(actions, migration) {
if (!Array.isArray(actions)) {
throw new Error(`migration ${migration.id} plan must return an array`);
}
for (const action of actions) {
if (!action || typeof action !== 'object') {
throw new Error(`migration action must be an object: ${migration.id}`);
}
if (typeof action.type !== 'string' || action.type.trim() === '') {
throw new Error(`migration action must include a non-empty type: ${migration.id}`);
}
if (typeof action.relPath !== 'string' || action.relPath.trim() === '') {
throw new Error(`migration action ${action.type} must include a non-empty relPath: ${migration.id}`);
}
validateSafeRelPath(action.relPath, migration, action.type);
// Ownership and runtime-contract evidence are required by
// docs/installer-migrations.md#action-types and
// docs/adr/0008-installer-migration-module.md#runtime-contract-decision.
if (action.type === 'remove-managed' || action.type === 'rewrite-json') {
requireActionEvidence(action, 'ownershipEvidence', migration);
}
if (action.type === 'rewrite-json' && (typeof migration.runtimeContract !== 'string' || migration.runtimeContract.trim() === '')) {
throw new Error(`migration action rewrite-json requires migration runtimeContract: ${actionSource(migration, action)}`);
}
}
return actions;
}
module.exports = {
validateInstallerMigrationActions,
validateInstallerMigrationRecord,
};

View File

@@ -1,229 +0,0 @@
'use strict';
const path = require('node:path');
// Resolve model-catalog.json via a prioritised candidate list so the module
// works in every layout:
//
// 1. Co-located install path — get-shit-done/bin/shared/model-catalog.json
// Written by bin/install.js (#3288 fix). This is the canonical post-install
// location across all runtimes (Claude Code, Codex, OpenCode, etc.).
//
// 2. Source-repo dev path — sdk/shared/model-catalog.json
// Three levels up from bin/lib/: works when running directly from the
// open-gsd/gsd-core clone (the original path introduced by #3230).
//
// 3. GSD_MODEL_CATALOG env override — allows test harnesses and custom
// deployments to point at an arbitrary catalog file.
//
// Throws with a diagnostic message that lists all candidates when none resolve,
// so MODULE_NOT_FOUND surfaces as a clear actionable error (PRED.k301).
const _catalogCandidates = [
path.resolve(__dirname, '..', 'shared', 'model-catalog.json'),
path.resolve(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'),
process.env.GSD_MODEL_CATALOG ? path.resolve(process.env.GSD_MODEL_CATALOG) : null,
].filter(Boolean);
let catalog = null;
let _catalogLastErr = null;
for (const _p of _catalogCandidates) {
try {
catalog = require(_p);
break;
} catch (e) {
// Only treat missing-file errors as recoverable — rethrow parse errors,
// permission errors, and any other real failures so they surface clearly
// instead of being silently swallowed (CR finding, PR #3293).
const isMissingCandidate =
(e && e.code === 'MODULE_NOT_FOUND' && String(e.message || '').includes(_p)) ||
(e && e.code === 'ENOENT');
if (!isMissingCandidate) throw e;
_catalogLastErr = e;
}
}
if (!catalog) {
throw new Error(
`model-catalog.json not found. Tried:\n${_catalogCandidates.map((p) => ` ${p}`).join('\n')}\nLast error: ${_catalogLastErr?.message}`
);
}
const VALID_PROFILES = [...catalog.profiles];
const VALID_PHASE_TYPES = new Set(catalog.phaseTypes);
const VALID_AGENT_TIERS = new Set(Object.keys(catalog.adaptiveTierMap));
const MODEL_PROFILES = Object.fromEntries(
Object.entries(catalog.agents).map(([agent, meta]) => [agent, {
quality: meta.golden,
balanced: meta.balanced,
budget: meta.budget,
adaptive: catalog.adaptiveTierMap[meta.routingTier],
}])
);
const AGENT_TO_PHASE_TYPE = Object.fromEntries(
Object.entries(catalog.agents).map(([agent, meta]) => [agent, meta.phaseType])
);
const AGENT_DEFAULT_TIERS = Object.fromEntries(
Object.entries(catalog.agents).map(([agent, meta]) => [agent, meta.routingTier])
);
const MODEL_ALIAS_MAP = Object.fromEntries(
Object.entries(catalog.runtimeTierDefaults.claude).map(([tier, entry]) => [tier, entry?.model])
);
const RUNTIME_PROFILE_MAP = Object.fromEntries(
Object.entries(catalog.runtimeTierDefaults)
.map(([runtime, tiers]) => [
runtime,
Object.fromEntries(
Object.entries(tiers).filter(([, entry]) => entry).map(([tier, entry]) => [tier, entry])
),
])
.filter(([, tiers]) => Object.keys(tiers).length > 0)
);
const KNOWN_RUNTIMES = new Set(Object.keys(catalog.runtimeTierDefaults));
const RUNTIMES_WITH_REASONING_EFFORT = new Set(
Object.entries(catalog.runtimeTierDefaults)
.filter(([, tiers]) => Object.values(tiers).some((entry) => entry && entry.reasoning_effort))
.map(([runtime]) => runtime)
);
const PROVIDER_PRESETS = catalog.providerPresets || {};
// KNOWN_PROVIDERS excludes 'generic' — it is a sentinel (all null entries) that
// forces users to supply model IDs via model_profile_overrides. It is not a
// real catalog-backed provider (#49).
const KNOWN_PROVIDERS = new Set(
Object.entries(PROVIDER_PRESETS)
.filter(([, tiers]) =>
Object.values(tiers).some((budgets) =>
budgets && Object.values(budgets).some((entry) => entry && entry.model)
)
)
.map(([name]) => name)
);
function nextTier(currentTier) {
const order = ['light', 'standard', 'heavy'];
const idx = order.indexOf(String(currentTier));
if (idx === -1) return null;
return order[Math.min(idx + 1, order.length - 1)];
}
function formatAgentToModelMapAsTable(agentToModelMap) {
const agentWidth = Math.max('Agent'.length, ...Object.keys(agentToModelMap).map((a) => a.length));
const modelWidth = Math.max('Model'.length, ...Object.values(agentToModelMap).map((m) => m.length));
const sep = '─'.repeat(agentWidth + 2) + '┼' + '─'.repeat(modelWidth + 2);
const header = ` ${'Agent'.padEnd(agentWidth)} │ ${'Model'.padEnd(modelWidth)}`;
let out = `${header}\n${sep}\n`;
for (const [agent, model] of Object.entries(agentToModelMap)) {
out += ` ${agent.padEnd(agentWidth)} │ ${model.padEnd(modelWidth)}\n`;
}
return out;
}
function getAgentToModelMapForProfile(normalizedProfile) {
const profile = VALID_PROFILES.includes(normalizedProfile) ? normalizedProfile : 'balanced';
const out = {};
for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) {
out[agent] = profile === 'inherit' ? 'inherit' : (profiles[profile] ?? profiles.balanced);
}
return out;
}
// ─── Effort rendering ────────────────────────────────────────────────────────
//
// Universal effort ladder: minimal < low < medium < high < xhigh < max
//
// Each runtime supports a subset. The unique tails must be clamped when emitting
// to a runtime that does not support them:
// - 'max' is Anthropic-only: Codex does not support it -> clamp to 'xhigh'
// - 'minimal' is Codex-only: Claude does not support it -> clamp to 'low'
//
// Rendering maps the universal effort string to the runtime's native parameter.
const EFFORT_RENDERING = {
// Claude Code subagent effort: output_config.effort frontmatter key /
// CLAUDE_CODE_EFFORT_LEVEL env. Supports: low, medium, high, xhigh, max.
// Does NOT support 'minimal' (Codex-only) -> clamp to 'low'.
claude: {
param: 'output_config.effort',
channel: 'frontmatter',
supported: new Set(['low', 'medium', 'high', 'xhigh', 'max']),
clamp(level) {
if (level === 'minimal') return 'low';
return level;
},
},
// Codex Responses API reasoning.effort. Supports: minimal, low, medium, high, xhigh.
// Does NOT support 'max' (Anthropic-only) -> clamp to 'xhigh'.
codex: {
param: 'model_reasoning_effort',
channel: 'api',
supported: new Set(['minimal', 'low', 'medium', 'high', 'xhigh']),
clamp(level) {
if (level === 'max') return 'xhigh';
return level;
},
},
};
/**
* Render a universal effort string for a specific runtime.
*
* Returns { value (clamped), param, channel } where:
* - value: the clamped effort string safe to pass to the runtime
* - param: the native parameter name (e.g. 'output_config.effort')
* - channel: how the value is propagated ('frontmatter', 'api', null)
*
* Unknown runtimes return { value: universalEffort, param: null, channel: null }
* so callers can always read .value safely.
*/
function renderEffortForRuntime(runtime, universalEffort) {
const spec = EFFORT_RENDERING[runtime];
if (!spec) {
return { value: universalEffort, param: null, channel: null };
}
return {
value: spec.clamp(universalEffort),
param: spec.param,
channel: spec.channel,
};
}
// ─── Fast mode propagation ───────────────────────────────────────────────────
//
// RUNTIMES_WITH_FAST_MODE is the set of runtimes where fast_mode=true can be
// propagated to a SPAWNED SUBAGENT via a native mechanism.
//
// Claude Code has NO per-subagent fast-mode mechanism — /fast is a session-level
// toggle only. Emitting a `fast_mode: true` frontmatter key on a Claude subagent
// would be a SILENT NO-OP, which is why 'claude' is deliberately excluded here.
//
// Only API-direct runtimes ('api') accept a speed:"fast" field in the request.
// Codex and other runtimes do not expose per-call fast_mode either.
const RUNTIMES_WITH_FAST_MODE = new Set(['api']);
module.exports = {
catalog,
MODEL_PROFILES,
VALID_PROFILES,
AGENT_TO_PHASE_TYPE,
VALID_PHASE_TYPES,
AGENT_DEFAULT_TIERS,
VALID_AGENT_TIERS,
MODEL_ALIAS_MAP,
RUNTIME_PROFILE_MAP,
KNOWN_RUNTIMES,
RUNTIMES_WITH_REASONING_EFFORT,
PROVIDER_PRESETS,
KNOWN_PROVIDERS,
nextTier,
formatAgentToModelMapAsTable,
getAgentToModelMapForProfile,
EFFORT_RENDERING,
renderEffortForRuntime,
RUNTIMES_WITH_FAST_MODE,
};

View File

@@ -1,82 +0,0 @@
'use strict';
/**
* DispatchEvent shape factory — issue #177 (ADR-0174 P1.3), extended in #178 (P1.4).
*
* Creates a structured event record for every Hub dispatch, used by
* DispatchLogger to emit stderr errors and opt-in file audit trails.
*
* Shape:
* traceId: string — UUID v4, generated per dispatch
* parentTraceId: string|undefined — propagated from the caller when it is a canonical UUID v4
* (RFC 4122); invalid values are silently coerced to undefined.
* Enables a future init-composer (Phase 2) to correlate child
* dispatches to their parent via the audit file.
* command: string — the dispatched verb
* args?: unknown — only present when includeArgs === true
* result: { kind: 'ok' | 'UnknownCommand' | 'InvalidArgs' | 'HandlerRefusal' | 'HandlerFailure', ...payload }
* timestamp: string — ISO 8601
*/
const { randomUUID } = require('crypto');
/**
* Canonical UUID v4 regex (RFC 4122).
* - 36 characters total (32 hex + 4 hyphens)
* - Version nibble: 4
* - Variant bits: [89ab]
* - Case-insensitive: accepts both upper- and lowercase hex
*
* Used to validate parentTraceId before propagation. traceId is always
* generated internally by crypto.randomUUID() and is guaranteed valid.
*/
const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* Returns true only when value is a canonical UUID v4 string.
* Any other value (non-string, wrong format, wrong version/variant) → false.
*
* @param {unknown} value
* @returns {boolean}
*/
function isValidParentTraceId(value) {
return typeof value === 'string' && UUID_V4_REGEX.test(value);
}
/**
* Create a DispatchEvent.
*
* @param {object} opts
* @param {string} opts.command - The dispatched command verb.
* @param {unknown} [opts.args] - Raw args passed to the hub.
* @param {object} opts.result - The HubResult returned by the hub.
* @param {boolean} [opts.includeArgs=false] - When true, include args in the event.
* @param {string} [opts.parentTraceId] - Must be a canonical UUID v4 (RFC 4122).
* Invalid values (non-string, wrong format, wrong version/variant) are silently coerced
* to undefined — no stderr warn is emitted. This prevents correlation poisoning from
* unvalidated caller input while keeping the factory pure and side-effect-free.
* @returns {object} Immutable DispatchEvent record.
*/
function makeDispatchEvent({ command, args, result, includeArgs = false, parentTraceId }) {
// Validate parentTraceId against UUID v4 format before propagation.
// Invalid inputs (empty string, non-UUID, UUID v1, oversized, etc.) are silently
// coerced to undefined. Silent coercion keeps the factory pure — no side effects,
// no log spam on bad input, consistent with how non-string values already collapse.
const resolvedParentTraceId = isValidParentTraceId(parentTraceId) ? parentTraceId : undefined;
const event = {
traceId: randomUUID(),
parentTraceId: resolvedParentTraceId,
command: String(command),
result,
timestamp: new Date().toISOString(),
};
if (includeArgs && args !== undefined) {
event.args = args;
}
return Object.freeze(event);
}
module.exports = { makeDispatchEvent };

View File

@@ -1,39 +0,0 @@
'use strict';
const { PHASES_SUBCOMMANDS } = require('./command-aliases.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
/**
* Manifest-backed phases subcommand router.
* Keeps gsd-tools.cjs thin while preserving current CJS semantics.
*
* Unsupported in this router (treated as unknown):
* - archive: `phases archive` is excluded from the subcommands list so it
* falls through to the unknown-subcommand error path.
*/
function routePhasesCommand({ phase, milestone, args, cwd, raw, error }) {
routeCjsCommandFamily({
args,
// Exclude 'archive' so it hits the unknownMessage path.
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)),
},
});
}
module.exports = {
routePhasesCommand,
};

View File

@@ -1,97 +0,0 @@
'use strict';
/**
* 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;
// A summary is never a plan. Reject summaries before the loose /PLAN/i
// fallback so legacy `<N>-PLAN-<NN>-SUMMARY.md` names (which contain the
// substring "PLAN") are not double-counted as plans. (#500 RC2)
if (isRootSummaryFile(fileName))
return false;
return /\.md$/i.test(fileName) && /PLAN/i.test(fileName);
}
function isNestedPlanFile(fileName) {
if (PLAN_OUTLINE_RE.test(fileName))
return false;
if (PLAN_PRE_BOUNCE_RE.test(fileName))
return false;
return /^PLAN-\d+.*\.md$/i.test(fileName) || /-PLAN-\d+.*\.md$/i.test(fileName);
}
function isRootSummaryFile(fileName) {
return fileName.endsWith('-SUMMARY.md') || fileName === 'SUMMARY.md';
}
function isNestedSummaryFile(fileName) {
return /^SUMMARY-\d+.*\.md$/i.test(fileName) || /-SUMMARY-\d+.*\.md$/i.test(fileName);
}
function scanPhasePlans(phaseDir) {
let rootFiles;
try {
rootFiles = readdirSync(phaseDir);
}
catch {
return {
planCount: 0,
summaryCount: 0,
completed: false,
hasNestedPlans: false,
planFiles: [],
summaryFiles: [],
};
}
const rootPlanFiles = rootFiles.filter(isRootPlanFile);
const rootSummaryFiles = rootFiles.filter(isRootSummaryFile);
let nestedPlanFiles = [];
let nestedSummaryFiles = [];
let hasNestedPlans = false;
const nestedDir = join(phaseDir, 'plans');
if (existsSync(nestedDir)) {
try {
const nestedFiles = readdirSync(nestedDir);
nestedPlanFiles = nestedFiles.filter(isNestedPlanFile);
nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile);
hasNestedPlans = nestedPlanFiles.length > 0;
}
catch { /* ignore unreadable nested layout */ }
}
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
const planCount = planFiles.length;
const summaryCount = summaryFiles.length;
return {
planCount,
summaryCount,
completed: planCount > 0 && summaryCount >= planCount,
hasNestedPlans,
planFiles,
summaryFiles,
};
}
// CJS callers do: const scanPhasePlans = require('./plan-scan.cjs')
// and also destructure named exports — support both call styles.
module.exports = scanPhasePlans;
module.exports.scanPhasePlans = scanPhasePlans;
module.exports.isRootPlanFile = isRootPlanFile;
module.exports.isNestedPlanFile = isNestedPlanFile;
module.exports.isRootSummaryFile = isRootSummaryFile;
module.exports.isNestedSummaryFile = isNestedSummaryFile;

View File

@@ -1,112 +0,0 @@
'use strict';
/**
* Project-Root Resolution Module — resolves a project root from a starting
* directory by walking the ancestor chain and applying four heuristics:
* (0) own .planning/ guard (#1362)
* (1) parent .planning/config.json sub_repos
* (2) legacy multiRepo: true + ancestor .git
* (3) .git heuristic with parent .planning/
* Bounded by FIND_PROJECT_ROOT_MAX_DEPTH ancestors. Sync I/O.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { existsSync, readFileSync, statSync } = fs;
const { dirname, resolve, sep, relative, parse: parsePath } = path;
const { homedir } = os;
const FIND_PROJECT_ROOT_MAX_DEPTH = 10;
function findProjectRoot(startDir) {
let resolvedStart;
try {
resolvedStart = resolve(startDir);
}
catch {
return startDir;
}
const fsRoot = parsePath(resolvedStart).root;
const home = homedir();
// If startDir already contains .planning/, it IS the project root.
try {
const ownPlanningDir = resolvedStart + sep + '.planning';
if (existsSync(ownPlanningDir) && statSync(ownPlanningDir).isDirectory()) {
return startDir;
}
}
catch {
// fall through
}
// Walk upward, mirroring isInsideGitRepo from the CJS reference.
function isInsideGitRepo(candidateParent) {
let d = resolvedStart;
while (d !== fsRoot) {
try {
if (existsSync(d + sep + '.git'))
return true;
}
catch {
// ignore
}
if (d === candidateParent)
break;
const next = dirname(d);
if (next === d)
break;
d = next;
}
return false;
}
let dir = resolvedStart;
let depth = 0;
while (dir !== fsRoot && depth < FIND_PROJECT_ROOT_MAX_DEPTH) {
const parent = dirname(dir);
if (parent === dir)
break;
if (parent === home)
break;
const parentPlanning = parent + sep + '.planning';
let parentPlanningIsDir = false;
try {
parentPlanningIsDir = existsSync(parentPlanning) && statSync(parentPlanning).isDirectory();
}
catch {
parentPlanningIsDir = false;
}
if (parentPlanningIsDir) {
const configPath = parentPlanning + sep + 'config.json';
let matched = false;
try {
const raw = readFileSync(configPath, 'utf-8');
const config = JSON.parse(raw);
const subReposValue = config.sub_repos ?? (config.planning && config.planning.sub_repos);
const subRepos = Array.isArray(subReposValue) ? subReposValue : [];
if (subRepos.length > 0) {
const relPath = relative(parent, resolvedStart);
const topSegment = relPath.split(sep)[0];
if (subRepos.includes(topSegment)) {
return parent;
}
}
if (config.multiRepo === true && isInsideGitRepo(parent)) {
matched = true;
}
}
catch {
// config.json missing or unparseable — fall through to .git heuristic.
}
if (matched)
return parent;
// Heuristic: parent has .planning/ and we're inside a git repo.
if (isInsideGitRepo(parent)) {
return parent;
}
}
dir = parent;
depth += 1;
}
return startDir;
}
module.exports = { findProjectRoot };

View File

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

View File

@@ -1,32 +0,0 @@
'use strict';
/**
* Secrets handling — masking convention for API keys and other
* credentials managed via /gsd-settings-integrations.
* This module does not read the filesystem.
*/
const SECRET_CONFIG_KEYS = new Set([
'brave_search',
'firecrawl',
'exa_search',
]);
function isSecretKey(keyPath) {
return SECRET_CONFIG_KEYS.has(keyPath);
}
function maskSecret(value) {
if (value === null || value === undefined || value === '')
return '(unset)';
const s = String(value);
if (s.length < 8)
return '****';
return '****' + s.slice(-4);
}
function maskIfSecret(keyPath, value) {
return isSecretKey(keyPath) ? maskSecret(value) : value;
}
module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret };

View File

@@ -1,252 +0,0 @@
'use strict';
const { STATE_SUBCOMMANDS } = require('./command-aliases.cjs');
const { routeHubCommandFamily, cjsFallbackHandler } = require('./cjs-command-router-adapter.cjs');
const { parseNamedArgs } = require('./command-arg-projection.cjs');
/**
* Manifest-backed state subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 5.1: handlers that have SDK equivalents are dispatched via
* executeForCjs (the sync bridge). CJS fallback is retained for:
* - complete-phase: no SDK counterpart.
* - Any command when GSD_WORKSTREAM is active (GSDTransport forces subprocess
* for workstream requests; subprocess is disabled in the sync bridge worker).
* - Any command when the SDK is not available (build not present).
*/
function routeStateCommand({ state, args, cwd, raw, error }) {
const parsePlans = (plans) => {
const parsedPlans = plans == null ? null : Number.parseInt(plans, 10);
if (plans != null && Number.isNaN(parsedPlans)) {
error('Invalid --plans value. Expected an integer.');
return null;
}
return parsedPlans;
};
routeHubCommandFamily({
family: 'state',
args,
subcommands: ['load', 'complete-phase', ...STATE_SUBCOMMANDS.filter((s) => s !== 'load')],
defaultSubcommand: 'load',
unsupported: {
'add-roadmap-evolution': 'state add-roadmap-evolution is SDK-only. Use: gsd-tools query state.add-roadmap-evolution ...',
},
error,
cwd,
raw,
unknownMessage: (subcommand, available) => `Unknown state subcommand: "${subcommand}". Available: ${available.join(', ')}`,
handlers: {
load: cjsFallbackHandler(
'state.load',
[],
args.slice(1),
null,
() => state.cmdStateLoad(cwd, raw),
),
json: cjsFallbackHandler(
'state.json',
[],
args.slice(1),
null,
() => state.cmdStateJson(cwd, raw),
),
get: cjsFallbackHandler(
'state.get',
args.slice(2),
args.slice(1),
null,
() => state.cmdStateGet(cwd, args[2], raw),
),
update: cjsFallbackHandler(
'state.update',
args.slice(2),
args.slice(1),
null,
() => state.cmdStateUpdate(cwd, args[2], args[3]),
),
patch: cjsFallbackHandler(
'state.patch',
args.slice(2),
args.slice(1),
null,
() => {
const patches = {};
if (args.length === 3 && typeof args[2] === 'string' && args[2].trim().startsWith('{')) {
let parsed;
try {
parsed = JSON.parse(args[2]);
} catch (err) {
error(`state patch: invalid JSON object: ${err.message}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
error('state patch: JSON input must be an object of field/value pairs.');
}
for (const [key, value] of Object.entries(parsed)) {
if (key && value !== undefined) {
patches[key] = String(value);
}
}
} else {
for (let i = 2; i < args.length; i += 2) {
const key = args[i].replace(/^--/, '');
const value = args[i + 1];
if (key && value !== undefined) {
patches[key] = value;
}
}
}
state.cmdStatePatch(cwd, patches, raw);
},
),
'advance-plan': cjsFallbackHandler(
'state.advance-plan',
[],
args.slice(1),
null,
() => state.cmdStateAdvancePlan(cwd, raw),
),
'record-metric': cjsFallbackHandler(
'state.record-metric',
args.slice(2),
args.slice(1),
null,
() => {
const { phase: p, plan, duration, tasks, files } = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']);
state.cmdStateRecordMetric(cwd, { phase: p, plan, duration, tasks, files }, raw);
},
),
'update-progress': cjsFallbackHandler(
'state.update-progress',
[],
args.slice(1),
null,
() => state.cmdStateUpdateProgress(cwd, raw),
),
'add-decision': cjsFallbackHandler(
'state.add-decision',
args.slice(2),
args.slice(1),
null,
() => {
const { phase: p, summary, 'summary-file': summary_file, rationale, 'rationale-file': rationale_file } = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']);
state.cmdStateAddDecision(cwd, { phase: p, summary, summary_file, rationale: rationale || '', rationale_file }, raw);
},
),
'add-blocker': cjsFallbackHandler(
'state.add-blocker',
args.slice(2),
args.slice(1),
null,
() => {
const { text, 'text-file': text_file } = parseNamedArgs(args, ['text', 'text-file']);
state.cmdStateAddBlocker(cwd, { text, text_file }, raw);
},
),
'resolve-blocker': cjsFallbackHandler(
'state.resolve-blocker',
args.slice(2),
args.slice(1),
null,
() => state.cmdStateResolveBlocker(cwd, parseNamedArgs(args, ['text']).text, raw),
),
'record-session': cjsFallbackHandler(
'state.record-session',
args.slice(2),
args.slice(1),
null,
() => {
const { 'stopped-at': stopped_at, 'resume-file': resume_file } = parseNamedArgs(args, ['stopped-at', 'resume-file']);
// Pass resume_file as-is (undefined when --resume-file was not provided) so
// cmdStateRecordSession can distinguish "caller explicitly passed a value" from
// "option was not supplied" and apply the template-default-only replacement guard.
state.cmdStateRecordSession(cwd, { stopped_at, resume_file }, raw);
},
),
'begin-phase': cjsFallbackHandler(
'state.begin-phase',
args.slice(2),
args.slice(1),
null,
() => {
const { phase: p, name, plans } = parseNamedArgs(args, ['phase', 'name', 'plans']);
state.cmdStateBeginPhase(cwd, p, name, parsePlans(plans), raw);
},
),
'signal-waiting': cjsFallbackHandler(
'state.signal-waiting',
args.slice(2),
args.slice(1),
null,
() => {
const { type, question, options, phase: p } = parseNamedArgs(args, ['type', 'question', 'options', 'phase']);
state.cmdSignalWaiting(cwd, type, question, options, p, raw);
},
),
'signal-resume': cjsFallbackHandler(
'state.signal-resume',
[],
args.slice(1),
null,
() => state.cmdSignalResume(cwd, raw),
),
'planned-phase': cjsFallbackHandler(
'state.planned-phase',
args.slice(2),
args.slice(1),
null,
() => {
const { phase: p, plans } = parseNamedArgs(args, ['phase', 'name', 'plans']);
state.cmdStatePlannedPhase(cwd, p, parsePlans(plans), raw);
},
),
validate: cjsFallbackHandler(
'state.validate',
[],
args.slice(1),
null,
() => state.cmdStateValidate(cwd, raw),
),
sync: cjsFallbackHandler(
'state.sync',
args.slice(2),
args.slice(1),
null,
() => {
const { verify } = parseNamedArgs(args, [], ['verify']);
state.cmdStateSync(cwd, { verify }, raw);
},
),
prune: cjsFallbackHandler(
'state.prune',
args.slice(2),
args.slice(1),
null,
() => {
const { 'keep-recent': keepRecent, 'dry-run': dryRun } = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3', dryRun: !!dryRun }, raw);
},
),
// complete-phase: CJS-only — no SDK counterpart.
'complete-phase': () => {
const { phase: p } = parseNamedArgs(args, ['phase']);
state.cmdStateCompletePhase(cwd, raw, p || args[2]);
},
'milestone-switch': cjsFallbackHandler(
'state.milestone-switch',
args.slice(2),
args.slice(1),
null,
() => {
const { milestone, name } = parseNamedArgs(args, ['milestone', 'name']);
state.cmdStateMilestoneSwitch(cwd, milestone, name, raw);
},
),
},
});
}
module.exports = {
routeStateCommand,
};

View File

@@ -1,259 +0,0 @@
'use strict';
/**
* STATE.md Document Module — pure transforms for STATE.md text.
* This module does not read the filesystem and does not own persistence or locking.
*/
// Internal helpers
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function toFiniteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function existingProgressExceedsDerived(existingProgress, derivedProgress, key) {
const existing = toFiniteNumber(existingProgress[key]);
const derived = toFiniteNumber(derivedProgress[key]);
return existing !== null && derived !== null && existing > derived;
}
function stateExtractField(content, fieldName) {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch)
return boldMatch[1].trim();
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
return plainMatch ? plainMatch[1].trim() : null;
}
function stateReplaceField(content, fieldName, newValue) {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) {
return content.replace(boldPattern, (_match, prefix) => `${prefix}${newValue}`);
}
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(plainPattern, (_match, prefix) => `${prefix}${newValue}`);
}
return null;
}
function stateReplaceFieldWithFallback(content, primary, fallback, value) {
let result = stateReplaceField(content, primary, value);
if (result)
return result;
if (fallback) {
result = stateReplaceField(content, fallback, value);
if (result)
return result;
}
return content;
}
function normalizeStateStatus(status, pausedAt) {
let normalizedStatus = status || 'unknown';
const statusLower = (status || '').toLowerCase();
if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
normalizedStatus = 'paused';
}
else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
normalizedStatus = 'executing';
}
else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
normalizedStatus = 'planning';
}
else if (statusLower.includes('discussing')) {
normalizedStatus = 'discussing';
}
else if (statusLower.includes('verif')) {
normalizedStatus = 'verifying';
}
else if (statusLower.includes('complete') || statusLower.includes('done')) {
normalizedStatus = 'completed';
}
else if (statusLower.includes('ready to execute')) {
normalizedStatus = 'executing';
}
return normalizedStatus;
}
function computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases) {
const hasPlanData = totalPlans !== null && totalPlans > 0 && completedPlans !== null;
const hasPhaseData = totalPhases !== null && totalPhases > 0 && completedPhases !== null;
if (!hasPlanData && !hasPhaseData)
return null;
const planFraction = hasPlanData ? completedPlans / totalPlans : 1;
const phaseFraction = hasPhaseData ? completedPhases / totalPhases : 1;
return Math.min(100, Math.round(Math.min(planFraction, phaseFraction) * 100));
}
function shouldPreserveExistingProgress(existingProgress, derivedProgress) {
if (!existingProgress || typeof existingProgress !== 'object')
return false;
if (!derivedProgress || typeof derivedProgress !== 'object')
return false;
const existing = existingProgress;
const derived = derivedProgress;
return (existingProgressExceedsDerived(existing, derived, 'total_phases') ||
existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
existingProgressExceedsDerived(existing, derived, 'total_plans') ||
existingProgressExceedsDerived(existing, derived, 'completed_plans'));
}
function normalizeProgressNumbers(progress) {
if (!progress || typeof progress !== 'object')
return progress;
const normalized = { ...progress };
for (const key of ['total_phases', 'completed_phases', 'total_plans', 'completed_plans', 'percent']) {
const number = toFiniteNumber(normalized[key]);
if (number !== null)
normalized[key] = number;
}
return normalized;
}
/**
* KNOWN_TEMPLATE_DEFAULTS — per-field table of string values that were written
* by a GSD handler (not by an executor / human). A value that appears in this
* list is safe to overwrite on the next handler call. Any other value was
* authored by the executor and must be preserved (Knuth invariant:
* handler-owns-transition-between-known-template-defaults).
*
* Keys must match the canonical field name as it appears in STATE.md.
* Comparison is case-insensitive so "None" and "none" both match.
*
* For Status, exact strings are supplemented by a pattern list
* (KNOWN_STATUS_PATTERNS) that matches handler-generated values whose exact
* text is variable (e.g. "Executing Phase 5").
*/
const KNOWN_TEMPLATE_DEFAULTS = {
'Resume File': ['None'],
'Status': [
'Ready to execute',
'Phase complete — ready for verification',
'Ready to plan',
'Defining requirements',
'Planning complete',
// Legacy / abbreviated handler values present in older STATE.md files
'Executing',
'In progress',
'Planning',
'Verifying',
'Completed',
'Done',
'Active',
'Paused',
'unknown',
],
// Last Activity is a date field; ISO date-only strings (YYYY-MM-DD) are the
// handler-generated form. We detect them by shape rather than an exhaustive
// list because the date changes every day.
// NOTE: entries here are matched by isStateTemplateDefault using the date regex
// in addition to exact string equality.
'Last Activity': [],
'Last activity': [],
};
/**
* Regex patterns that match handler-generated Status values whose text includes
* a variable component (e.g. phase number). Checked after the KNOWN_TEMPLATE_DEFAULTS
* exact-match list in isStateTemplateDefault.
*/
const KNOWN_STATUS_PATTERNS = [
/^Executing Phase\s+\d+/i,
/^Planning Phase\s+\d+/i,
/^Phase\s+\d+\s+complete/i,
/^Verifying Phase\s+\d+/i,
/^Phase complete/i,
];
/**
* Returns true when the given value is a known template default for the field,
* meaning a GSD handler wrote it and a subsequent handler may replace it.
*
* A value is considered a template default when:
* (a) it appears in KNOWN_TEMPLATE_DEFAULTS[field] (exact, case-insensitive), OR
* (b) it matches the ISO date-only shape (YYYY-MM-DD) for Last Activity fields
* (handlers always write bare dates; executors write narrative prose).
*
* @param {string} field - Canonical field name (case-sensitive key lookup attempted
* first, then case-insensitive fallback).
* @param {string} value - The current value extracted from STATE.md.
* @returns {boolean}
*/
function isStateTemplateDefault(field, value) {
if (value === null || value === undefined) return true; // absent → initial write
const v = String(value).trim();
if (v === '') return true; // blank → treat as absent
// Look up the defaults list, trying exact key first then case-insensitive.
let defaults = KNOWN_TEMPLATE_DEFAULTS[field];
if (!defaults) {
const fieldLower = field.toLowerCase();
const matchKey = Object.keys(KNOWN_TEMPLATE_DEFAULTS).find(k => k.toLowerCase() === fieldLower);
defaults = matchKey ? KNOWN_TEMPLATE_DEFAULTS[matchKey] : null;
}
if (defaults && defaults.some(d => d.toLowerCase() === v.toLowerCase())) {
return true;
}
const fieldLower = field.toLowerCase();
// Status: also check pattern list for variable handler-generated values
// (e.g. "Executing Phase 5", "Planning Phase 3").
if (fieldLower === 'status') {
if (KNOWN_STATUS_PATTERNS.some(p => p.test(v))) return true;
}
// Last Activity / Last activity: bare ISO date (YYYY-MM-DD) is handler-generated.
if (fieldLower === 'last activity') {
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return true;
}
return false;
}
/**
* Replaces a field in STATE.md content only when the existing value is a known
* template default (or the field is absent). If the existing value is
* executor-authored, the content is returned unchanged.
*
* When `newValue` is null or undefined the function is a no-op (returns content).
*
* @param {string} content - Full STATE.md text.
* @param {string} field - Field name as it appears in STATE.md.
* @param {string[]} knownDefaults - The defaults list to check against (typically
* KNOWN_TEMPLATE_DEFAULTS[field]).
* @param {string} newValue - Value to write when replacement is permitted.
* @returns {string} - Updated content (or original if skipped).
*/
function stateReplaceFieldIfTemplate(content, field, knownDefaults, newValue) {
if (newValue === null || newValue === undefined) return content;
const existing = stateExtractField(content, field);
// Inline check: absent/blank → always write; in list → write; else → skip.
if (existing === null || existing === undefined || existing.trim() === '') {
return stateReplaceField(content, field, newValue) || content;
}
const v = existing.trim();
const inList = (knownDefaults || []).some(d => d.toLowerCase() === v.toLowerCase());
const fieldLower = field.toLowerCase();
// Special-case: Status pattern list for variable handler-generated values.
const matchesStatusPattern = (fieldLower === 'status') && KNOWN_STATUS_PATTERNS.some(p => p.test(v));
// Special-case: Last Activity bare ISO date (YYYY-MM-DD) is handler-generated.
const isDateShape = (fieldLower === 'last activity') && /^\d{4}-\d{2}-\d{2}$/.test(v);
if (inList || matchesStatusPattern || isDateShape) {
return stateReplaceField(content, field, newValue) || content;
}
// Executor-authored — preserve.
return content;
}
module.exports = { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, normalizeStateStatus, computeProgressPercent, shouldPreserveExistingProgress, normalizeProgressNumbers, KNOWN_TEMPLATE_DEFAULTS, KNOWN_STATUS_PATTERNS, isStateTemplateDefault, stateReplaceFieldIfTemplate };

View File

@@ -1,40 +0,0 @@
'use strict';
const { VERIFY_SUBCOMMANDS } = require('./command-aliases.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
/**
* Manifest-backed verify subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*/
function routeVerifyCommand({ verify, args, cwd, raw, error }) {
routeCjsCommandFamily({
args,
subcommands: VERIFY_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand, available) => `Unknown verify subcommand. Available: ${available.join(', ')}`,
handlers: {
'plan-structure': () => verify.cmdVerifyPlanStructure(cwd, args[2], raw),
'phase-completeness': () => verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw),
references: () => verify.cmdVerifyReferences(cwd, args[2], raw),
commits: () => verify.cmdVerifyCommits(cwd, args.slice(2), raw),
artifacts: () => verify.cmdVerifyArtifacts(cwd, args[2], raw),
'key-links': () => verify.cmdVerifyKeyLinks(cwd, args[2], raw),
'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);
},
// verify codebase-drift dispatches direct to CJS — drift is out-of-seam
// per ADR/PRD 3524 §3 / L160 (CJS-only by design). Routing through
// recursive dispatch would re-enter this router path.
'codebase-drift': () => verify.cmdVerifyCodebaseDrift(cwd, raw),
},
});
}
module.exports = {
routeVerifyCommand,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +0,0 @@
'use strict';
/**
* Workstream Inventory Builder — pure projection from pre-collected
* filesystem data to typed WorkstreamInventory. No I/O. No async.
*/
const path = require('path');
const relative = path.relative;
// Internal helpers
function toPosixPath(p) {
return p.split('\\').join('/');
}
function isCompletedInventory(status) {
const s = String(status ?? '').trim().toLowerCase();
return /\bmilestone\s+complete\b/.test(s) || /\barchived\b/.test(s);
}
function buildWorkstreamInventory(inputs) {
const { name, projectDir, workstreamDir, phaseDirNames, activeWorkstreamName, phaseFilesCounts, roadmapPhaseCount, stateProjection, filesExist, } = inputs;
// Index counts by directory for O(1) lookup during sort/iteration
const countsMap = new Map();
for (const entry of phaseFilesCounts) {
countsMap.set(entry.directory, { planCount: entry.planCount, summaryCount: entry.summaryCount });
}
const phases = [];
let completedPhases = 0;
let totalPlans = 0;
let completedPlans = 0;
for (const dir of [...phaseDirNames].sort()) {
const counts = countsMap.get(dir) ?? { planCount: 0, summaryCount: 0 };
const status = counts.summaryCount >= counts.planCount && counts.planCount > 0
? 'complete'
: counts.planCount > 0
? 'in_progress'
: 'pending';
totalPlans += counts.planCount;
completedPlans += Math.min(counts.summaryCount, counts.planCount);
if (status === 'complete')
completedPhases++;
phases.push({
directory: dir,
status,
plan_count: counts.planCount,
summary_count: counts.summaryCount,
});
}
return {
name,
path: toPosixPath(relative(projectDir, workstreamDir)),
active: name === activeWorkstreamName,
files: {
roadmap: filesExist.roadmap,
state: filesExist.state,
requirements: filesExist.requirements,
},
status: stateProjection.status,
current_phase: stateProjection.current_phase,
last_activity: stateProjection.last_activity,
phases,
phase_count: phases.length,
completed_phases: completedPhases,
roadmap_phase_count: roadmapPhaseCount,
total_plans: totalPlans,
completed_plans: completedPlans,
progress_percent: roadmapPhaseCount > 0
? Math.min(100, Math.round((completedPhases / roadmapPhaseCount) * 100))
: 0,
};
}
module.exports = { buildWorkstreamInventory, isCompletedInventory };

View File

@@ -1,94 +0,0 @@
'use strict';
/**
* 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._-]*$/;
const INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE = 'Invalid workstream name: must be alphanumeric, hyphens, underscores, or dots';
function normalizeWorkstreamNameInput(name) {
const value = String(name || '').trim();
return value || null;
}
function validateActiveWorkstreamName(name) {
const value = normalizeWorkstreamNameInput(name);
if (!value) {
return {
ok: false,
reason: 'empty',
value: null,
};
}
if (hasInvalidPathSegment(value) || !ACTIVE_WORKSTREAM_RE.test(value)) {
return {
ok: false,
reason: 'invalid',
value,
};
}
return {
ok: true,
reason: null,
value,
};
}
/**
* 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) {
return validateActiveWorkstreamName(name).ok;
}
function assertValidActiveWorkstreamName(name, errorMessage = INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE) {
const validation = validateActiveWorkstreamName(name);
if (!validation.ok) {
throw new Error(errorMessage);
}
return validation.value;
}
module.exports = {
INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE,
normalizeWorkstreamNameInput,
validateActiveWorkstreamName,
validateWorkstreamName,
toWorkstreamSlug,
hasInvalidPathSegment,
isValidActiveWorkstreamName,
assertValidActiveWorkstreamName,
};

18
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"devDependencies": {
"@eslint/js": "^9.39.4",
"@stryker-mutator/core": "^9.6.1",
"@types/node": "^22.19.19",
"c8": "^11.0.0",
"eslint": "^9.39.4",
"eslint-plugin-n": "^17.24.0",
@@ -1769,6 +1770,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
@@ -5060,6 +5071,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",

View File

@@ -51,6 +51,7 @@
"devDependencies": {
"@eslint/js": "^9.39.4",
"@stryker-mutator/core": "^9.6.1",
"@types/node": "^22.19.19",
"c8": "^11.0.0",
"eslint": "^9.39.4",
"eslint-plugin-n": "^17.24.0",
@@ -75,6 +76,7 @@
"build:lib": "tsc -p tsconfig.build.json",
"generate:identity": "node scripts/generate-package-identity.cjs",
"prepack": "npm run build:lib",
"prepare": "npm run build:lib",
"prepublishOnly": "npm run build:lib && npm run build:hooks",
"pretest": "npm run build:lib && npm run lint:skill-deps",
"pretest:coverage": "npm run build:lib && npm run lint:skill-deps",

View File

@@ -212,7 +212,11 @@ if (fs.existsSync(LOCKFILE)) {
// ---------------------------------------------------------------------------
if (fs.existsSync(LOCKFILE)) {
try {
const res = spawnSync(npmCmd, ['ci', '--dry-run'], {
// --ignore-scripts: this is a lockfile-vs-package.json sync check, not a
// build. Without it, npm would run the `prepare` lifecycle (build:lib via
// tsc) — which fails when check:env runs before deps are installed (tsc
// absent), misreporting an out-of-sync lockfile. ADR-457 build-at-publish.
const res = spawnSync(npmCmd, ['ci', '--dry-run', '--ignore-scripts'], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
shell: process.platform === 'win32',

219
scripts/mutation-matrix.cjs Normal file
View File

@@ -0,0 +1,219 @@
#!/usr/bin/env node
'use strict';
/**
* scripts/mutation-matrix.cjs
*
* Single source of truth for the ADR-457 Stryker mutation gate dynamic matrix.
*
* Computes which covered modules changed vs a base ref and emits a GitHub
* Actions matrix JSON so CI can run one Stryker shard per changed module in
* parallel rather than a single serial run over all modules.
*
* Usage:
* node scripts/mutation-matrix.cjs --base origin/next
* printf 'src/config-schema.cts\n' | node scripts/mutation-matrix.cjs
* node scripts/mutation-matrix.cjs --base origin/next --print
*
* Output (stdout, default): JSON object
* {
* "has_work": "true"|"false",
* "matrix": {
* "include": [
* { "name": "<module>", "mutate": "get-shit-done/bin/lib/<module>.cjs", "tests": "<space-joined test files>" },
* ...
* ]
* }
* }
*
* Exit codes: 0 always (empty matrix is not an error, has_work "false").
*/
const { execFileSync } = require('child_process');
const { readFileSync } = require('fs');
// ── Single source of truth: covered modules ───────────────────────────────────
// Each entry: { cjs: '<built artifact>', tests: ['tests/...', ...] }
// A module is "covered" iff its tests are wired into the Stryker command runner
// (stryker.config.mjs commandRunner.command). Mutating an uncovered module can
// only ever produce survived mutants — so we scope strictly to these 6.
const COVERED = {
'context-utilization': {
cjs: 'get-shit-done/bin/lib/context-utilization.cjs',
tests: [
'tests/context-utilization.property.test.cjs',
],
},
'prompt-budget': {
cjs: 'get-shit-done/bin/lib/prompt-budget.cjs',
tests: [
'tests/prompt-budget.property.test.cjs',
'tests/prompt-budget.unit.test.cjs',
],
},
frontmatter: {
cjs: 'get-shit-done/bin/lib/frontmatter.cjs',
tests: [
'tests/frontmatter.property.test.cjs',
'tests/frontmatter.unit.test.cjs',
],
},
'adr-parser': {
cjs: 'get-shit-done/bin/lib/adr-parser.cjs',
tests: [
'tests/adr-parser.property.test.cjs',
'tests/adr-parser.test.cjs',
'tests/adr-parser.unit.test.cjs',
],
},
'config-schema': {
cjs: 'get-shit-done/bin/lib/config-schema.cjs',
tests: [
'tests/config-schema.property.test.cjs',
],
},
'active-workstream-store': {
cjs: 'get-shit-done/bin/lib/active-workstream-store.cjs',
tests: [
'tests/active-workstream-store.test.cjs',
'tests/active-workstream-store.unit.test.cjs',
],
},
};
// ── Files that, when changed, invalidate ALL modules ─────────────────────────
// Changes to the Stryker config, this script itself, or any covered test file
// affect all mutation scores and must force a full re-run.
const GLOBAL_TRIGGERS = new Set([
'stryker.config.mjs',
'scripts/mutation-matrix.cjs',
]);
// Also flag all test files that belong to any covered module as global triggers.
for (const mod of Object.values(COVERED)) {
for (const t of mod.tests) {
GLOBAL_TRIGGERS.add(t);
}
}
// ── Argument parsing ──────────────────────────────────────────────────────────
function parseArgs(argv) {
const out = { base: null, print: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--base') {
out.base = argv[++i];
if (!out.base || out.base.startsWith('--')) {
throw new Error('--base requires a value');
}
} else if (arg.startsWith('--base=')) {
out.base = arg.slice('--base='.length);
if (!out.base) throw new Error('--base requires a value');
} else if (arg === '--print') {
out.print = true;
} else if (arg === '--help' || arg === '-h') {
console.log([
'Usage:',
' node scripts/mutation-matrix.cjs --base <ref> [--print]',
' printf "src/foo.cts\\n" | node scripts/mutation-matrix.cjs [--print]',
'',
'Options:',
' --base <ref> Git ref to diff against (default: origin/${GITHUB_BASE_REF:-next})',
' --print Human-readable output instead of JSON',
].join('\n'));
process.exit(0);
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
return out;
}
// ── Changed-file resolution ───────────────────────────────────────────────────
function resolveChangedFiles(args) {
// When --base is provided, always use git diff (regardless of stdin).
// When --base is absent AND stdin is not a TTY (isTTY is falsy / undefined),
// read a newline-delimited file list from stdin.
if (!args.base && process.stdin.isTTY !== true) {
const raw = readFileSync(process.stdin.fd, 'utf8');
return raw.split('\n').map(l => l.trim()).filter(Boolean);
}
// Otherwise (--base given, or stdin is a real TTY), diff against the base ref.
const defaultBase = `origin/${process.env.GITHUB_BASE_REF || 'next'}`;
const base = args.base || defaultBase;
const stdout = execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], {
encoding: 'utf8',
});
return stdout.split('\n').map(l => l.trim()).filter(Boolean);
}
// ── Module classification ─────────────────────────────────────────────────────
function computeMatrix(changedFiles) {
// Check for global triggers first — if any hit, include every covered module.
const allModuleNames = Object.keys(COVERED);
for (const f of changedFiles) {
if (GLOBAL_TRIGGERS.has(f)) {
return allModuleNames;
}
}
// Otherwise find which modules have their src/*.cts changed.
const changed = new Set();
for (const f of changedFiles) {
// Match src/<module>.cts (top-level src/, not nested)
const m = f.match(/^src\/([^/]+)\.cts$/);
if (m && COVERED[m[1]]) {
changed.add(m[1]);
}
}
return [...changed];
}
// ── Output formatting ─────────────────────────────────────────────────────────
function buildResult(moduleNames) {
const include = moduleNames.map(name => ({
name,
mutate: COVERED[name].cjs,
tests: COVERED[name].tests.join(' '),
}));
return {
has_work: include.length > 0 ? 'true' : 'false',
matrix: { include },
};
}
function printHuman(result, changedFiles) {
console.log(`Changed files (${changedFiles.length}):`);
for (const f of changedFiles) console.log(` ${f}`);
console.log('');
console.log(`has_work: ${result.has_work}`);
console.log(`Shards (${result.matrix.include.length}):`);
for (const shard of result.matrix.include) {
console.log(` [${shard.name}]`);
console.log(` mutate: ${shard.mutate}`);
console.log(` tests: ${shard.tests}`);
}
}
// ── Main ──────────────────────────────────────────────────────────────────────
function main() {
try {
const args = parseArgs(process.argv.slice(2));
const changedFiles = resolveChangedFiles(args);
const moduleNames = computeMatrix(changedFiles);
const result = buildResult(moduleNames);
if (args.print) {
printHuman(result, changedFiles);
} else {
console.log(JSON.stringify(result, null, 2));
}
} catch (err) {
console.error(`mutation-matrix: ${err.message}`);
process.exit(2);
}
}
main();

View File

@@ -3,16 +3,20 @@
*
* Owns active workstream source precedence, session identity, and pointer IO:
* CLI --ws > GSD_WORKSTREAM env > stored active workstream pointer.
*
* ADR-457 build-at-publish: the hand-written bin/lib/active-workstream-store.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { probeTty, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
const { isValidActiveWorkstreamName } = require('./workstream-name-policy.cjs');
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { probeTty, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
import { isValidActiveWorkstreamName } from './workstream-name-policy.cjs';
const WORKSTREAM_SESSION_ENV_KEYS = [
const WORKSTREAM_SESSION_ENV_KEYS: ReadonlyArray<string> = [
'GSD_SESSION_KEY',
'CODEX_THREAD_ID',
'CLAUDE_SESSION_ID',
@@ -27,24 +31,25 @@ const WORKSTREAM_SESSION_ENV_KEYS = [
'ZELLIJ_SESSION_NAME',
];
let cachedControllingTtyToken = null;
let cachedControllingTtyToken: string | null = null;
let didProbeControllingTtyToken = false;
function planningRoot(cwd) {
function planningRoot(cwd: string): string {
return path.join(cwd, '.planning');
}
function validateWorkstreamName(name) {
function validateWorkstreamName(name: string | null | undefined): boolean {
return isValidActiveWorkstreamName(name);
}
function sanitizeWorkstreamSessionToken(value) {
function sanitizeWorkstreamSessionToken(value: unknown): string | null {
if (value === null || value === undefined) return null;
const token = String(value).trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
const raw = typeof value === 'string' ? value : `${value as number | boolean}`;
const token = raw.trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
return token ? token.slice(0, 160) : null;
}
function probeControllingTtyToken() {
function probeControllingTtyToken(): string | null {
if (didProbeControllingTtyToken) return cachedControllingTtyToken;
didProbeControllingTtyToken = true;
@@ -61,7 +66,7 @@ function probeControllingTtyToken() {
return cachedControllingTtyToken;
}
function getControllingTtyToken() {
function getControllingTtyToken(): string | null {
for (const envKey of ['TTY', 'SSH_TTY']) {
const token = sanitizeWorkstreamSessionToken(process.env[envKey]);
if (token) return `tty-${token.replace(/^dev_/, '')}`;
@@ -70,7 +75,7 @@ function getControllingTtyToken() {
return probeControllingTtyToken();
}
function getWorkstreamSessionKey() {
function getWorkstreamSessionKey(): string | null {
for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) {
const raw = process.env[envKey];
const token = sanitizeWorkstreamSessionToken(raw);
@@ -80,11 +85,17 @@ function getWorkstreamSessionKey() {
return getControllingTtyToken();
}
function getSessionScopedWorkstreamFile(cwd, fixedSessionKey) {
interface SessionScopedWorkstreamFile {
sessionKey: string;
dirPath: string;
filePath: string;
}
function getSessionScopedWorkstreamFile(cwd: string, fixedSessionKey?: string | null): SessionScopedWorkstreamFile | null {
const sessionKey = fixedSessionKey || getWorkstreamSessionKey();
if (!sessionKey) return null;
let planningAbs;
let planningAbs: string;
try {
planningAbs = fs.realpathSync.native(planningRoot(cwd));
} catch {
@@ -104,36 +115,42 @@ function getSessionScopedWorkstreamFile(cwd, fixedSessionKey) {
};
}
function createSharedPointerAdapter(cwd) {
interface WorkstreamPointerAdapter {
read(): string | null;
write(name: string): void;
clear(): void;
}
function createSharedPointerAdapter(cwd: string): WorkstreamPointerAdapter {
const filePath = path.join(planningRoot(cwd), 'active-workstream');
return {
read() {
read(): string | null {
const raw = platformReadSync(filePath);
return raw ? raw.trim() || null : null;
},
write(name) {
write(name: string): void {
platformWriteSync(filePath, name + '\n');
},
clear() {
clear(): void {
try { fs.unlinkSync(filePath); } catch {}
},
};
}
function createSessionScopedPointerAdapter(cwd, fixedSessionKey) {
function createSessionScopedPointerAdapter(cwd: string, fixedSessionKey?: string | null): WorkstreamPointerAdapter | null {
const scoped = getSessionScopedWorkstreamFile(cwd, fixedSessionKey);
if (!scoped) return null;
return {
read() {
read(): string | null {
const raw = platformReadSync(scoped.filePath);
return raw ? raw.trim() || null : null;
},
write(name) {
write(name: string): void {
platformEnsureDir(scoped.dirPath);
platformWriteSync(scoped.filePath, name + '\n');
},
clear() {
clear(): void {
try { fs.unlinkSync(scoped.filePath); } catch {}
try {
const remaining = fs.readdirSync(scoped.dirPath);
@@ -145,22 +162,33 @@ function createSessionScopedPointerAdapter(cwd, fixedSessionKey) {
};
}
function createMemoryPointerAdapter(initialName = null) {
let value = initialName;
function createMemoryPointerAdapter(initialName: string | null = null): WorkstreamPointerAdapter {
let value: string | null = initialName;
return {
read() {
read(): string | null {
return value;
},
write(name) {
write(name: string): void {
value = name;
},
clear() {
clear(): void {
value = null;
},
};
}
function pickActiveWorkstreamAdapter(cwd, opts = {}) {
interface ActiveWorkstreamAdapters {
session?: WorkstreamPointerAdapter;
shared?: WorkstreamPointerAdapter;
}
interface ActiveWorkstreamOpts {
activeWorkstreamAdapter?: WorkstreamPointerAdapter;
activeWorkstreamAdapters?: ActiveWorkstreamAdapters;
getStored?: (dir: string) => string | null;
}
function pickActiveWorkstreamAdapter(cwd: string, opts: ActiveWorkstreamOpts = {}): WorkstreamPointerAdapter | null {
if (opts.activeWorkstreamAdapter) {
return opts.activeWorkstreamAdapter;
}
@@ -179,7 +207,7 @@ function pickActiveWorkstreamAdapter(cwd, opts = {}) {
return createSharedPointerAdapter(cwd);
}
function getActiveWorkstream(cwd, opts = {}) {
function getActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): string | null {
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
if (!adapter) return null;
@@ -198,7 +226,7 @@ function getActiveWorkstream(cwd, opts = {}) {
return name;
}
function setActiveWorkstream(cwd, name, opts = {}) {
function setActiveWorkstream(cwd: string, name: string | null | undefined, opts: ActiveWorkstreamOpts = {}): void {
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
if (!adapter) return;
@@ -215,14 +243,20 @@ function setActiveWorkstream(cwd, name, opts = {}) {
adapter.write(name);
}
function clearActiveWorkstream(cwd, opts = {}) {
function clearActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): void {
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
if (!adapter) return;
adapter.clear();
}
function parseCliWorkstream(args) {
const wsEqArg = args.find(arg => arg.startsWith('--ws='));
interface ParsedCliWorkstream {
value: string | null;
source: string | null;
args: string[];
}
function parseCliWorkstream(args: string[]): ParsedCliWorkstream {
const wsEqArg = args.find((arg) => arg.startsWith('--ws='));
const wsIdx = args.indexOf('--ws');
if (wsEqArg) {
@@ -231,7 +265,7 @@ function parseCliWorkstream(args) {
return {
value,
source: 'cli',
args: args.filter(arg => arg !== wsEqArg),
args: args.filter((arg) => arg !== wsEqArg),
};
}
@@ -241,7 +275,7 @@ function parseCliWorkstream(args) {
return {
value,
source: 'cli',
args: args.filter((_, idx) => idx !== wsIdx && idx !== wsIdx + 1),
args: args.filter((_: string, idx: number) => idx !== wsIdx && idx !== wsIdx + 1),
};
}
@@ -252,18 +286,29 @@ function parseCliWorkstream(args) {
};
}
function resolveActiveWorkstream(cwd, args, env = process.env, deps = {}) {
const parsed = parseCliWorkstream(args);
const getStored = deps.getStored || ((dir) => getActiveWorkstream(dir, deps));
interface ResolvedWorkstream {
ws: string | null;
source: string;
args: string[];
}
let ws = null;
function resolveActiveWorkstream(
cwd: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
deps: ActiveWorkstreamOpts = {}
): ResolvedWorkstream {
const parsed = parseCliWorkstream(args);
const getStored = deps.getStored || ((dir: string) => getActiveWorkstream(dir, deps));
let ws: string | null = null;
let source = 'none';
if (parsed.value) {
ws = parsed.value;
source = parsed.source;
} else if (env && typeof env.GSD_WORKSTREAM === 'string' && env.GSD_WORKSTREAM.trim()) {
ws = env.GSD_WORKSTREAM.trim();
source = parsed.source ?? 'cli';
} else if (env && typeof env['GSD_WORKSTREAM'] === 'string' && env['GSD_WORKSTREAM'].trim()) {
ws = env['GSD_WORKSTREAM'].trim();
source = 'env';
} else {
ws = getStored(cwd) || null;
@@ -281,12 +326,15 @@ function resolveActiveWorkstream(cwd, args, env = process.env, deps = {}) {
};
}
function applyResolvedWorkstreamEnv(resolution, env = process.env) {
function applyResolvedWorkstreamEnv(
resolution: ResolvedWorkstream | null | undefined,
env: NodeJS.ProcessEnv = process.env
): void {
if (!resolution || !resolution.ws) return;
env.GSD_WORKSTREAM = resolution.ws;
env['GSD_WORKSTREAM'] = resolution.ws;
}
module.exports = {
export = {
validateWorkstreamName,
getWorkstreamSessionKey,
createSharedPointerAdapter,

View File

@@ -1,12 +1,34 @@
'use strict';
/**
* ADR Markdown parser — parses Architecture Decision Record documents into
* structured objects for downstream processing (adr command, gap checker, etc.).
*
* ADR-457 build-at-publish: the hand-written bin/lib/adr-parser.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
const fs = require('fs');
const path = require('path');
const { requireSafePath } = require('./security.cjs');
import fs from 'node:fs';
import path from 'node:path';
import { requireSafePath } from './security.cjs';
const STATUS_REJECT_SET = new Set(['superseded', 'rejected', 'deprecated']);
const CANONICAL_HEADERS = {
type CanonicalHeader =
| 'status'
| 'goal'
| 'decisions'
| 'considered_options'
| 'risks'
| 'success_criteria'
| 'plan_sequence'
| 'key_files'
| 'out_of_scope'
| 'deferred'
| 'dependencies'
| 'update'
| 'consequences';
const CANONICAL_HEADERS: Record<CanonicalHeader, string[]> = {
status: ['status', 'state', 'lifecycle', 'stage'],
goal: [
'context',
@@ -154,7 +176,7 @@ const CANONICAL_HEADERS = {
],
};
const CONSEQUENCE_NEGATIVE_HINTS = [
const CONSEQUENCE_NEGATIVE_HINTS: string[] = [
'negative',
'drawback',
'risk',
@@ -165,7 +187,7 @@ const CONSEQUENCE_NEGATIVE_HINTS = [
'side effect',
];
const CONSEQUENCE_POSITIVE_HINTS = [
const CONSEQUENCE_POSITIVE_HINTS: string[] = [
'positive',
'success',
'metric',
@@ -175,8 +197,9 @@ const CONSEQUENCE_POSITIVE_HINTS = [
'benefit',
];
function normalizeAdrHeader(raw) {
return String(raw || '')
function normalizeAdrHeader(raw: unknown): string {
const s = typeof raw === 'string' ? raw : '';
return s
.trim()
.toLowerCase()
.replace(/[\s:._-]+/g, ' ')
@@ -184,8 +207,8 @@ function normalizeAdrHeader(raw) {
.trim();
}
function classifyHeader(normalizedHeader) {
for (const [canonical, synonyms] of Object.entries(CANONICAL_HEADERS)) {
function classifyHeader(normalizedHeader: string): CanonicalHeader | null {
for (const [canonical, synonyms] of Object.entries(CANONICAL_HEADERS) as Array<[CanonicalHeader, string[]]>) {
for (const synonym of synonyms) {
if (normalizedHeader === synonym) return canonical;
if (normalizedHeader.startsWith(`${synonym} `)) return canonical;
@@ -194,8 +217,8 @@ function classifyHeader(normalizedHeader) {
return null;
}
function splitEntries(blockText) {
return String(blockText || '')
function splitEntries(blockText: unknown): string[] {
return (typeof blockText === 'string' ? blockText : '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
@@ -203,10 +226,15 @@ function splitEntries(blockText) {
.filter(Boolean);
}
function parseSections(markdown) {
const lines = String(markdown || '').split(/\r?\n/);
const sections = [];
let current = { heading: null, body: [] };
interface MarkdownSection {
heading: string | null;
body: string[];
}
function parseSections(markdown: unknown): MarkdownSection[] {
const lines = (typeof markdown === 'string' ? markdown : '').split(/\r?\n/);
const sections: MarkdownSection[] = [];
let current: MarkdownSection = { heading: null, body: [] };
for (const line of lines) {
const m = line.match(/^#{1,6}\s+(.*)$/);
@@ -222,7 +250,7 @@ function parseSections(markdown) {
return sections;
}
function parseStatusFromSections(sections) {
function parseStatusFromSections(sections: MarkdownSection[]): string {
for (const section of sections) {
const canonical = classifyHeader(normalizeAdrHeader(section.heading));
if (canonical !== 'status') continue;
@@ -239,7 +267,7 @@ function parseStatusFromSections(sections) {
return '';
}
function pushUnique(target, values) {
function pushUnique(target: string[], values: string[]): void {
const seen = new Set(target);
for (const value of values) {
if (!seen.has(value)) {
@@ -249,7 +277,26 @@ function pushUnique(target, values) {
}
}
function parseConsequences(lines, out) {
interface AdrOut {
title: string;
status: string;
context: string;
decisions: string[];
options_considered: string[];
consequences_positive: string[];
consequences_negative: string[];
out_of_scope: string[];
deferred: string[];
dependencies: string[];
updates: Array<{ heading: string; entries: string[] }>;
source_path: string;
key_files: string[];
plan_sequence: string[];
format: string;
unmapped_headers: string[];
}
function parseConsequences(lines: string[], out: AdrOut): void {
for (const entry of lines) {
const lower = entry.toLowerCase();
if (CONSEQUENCE_NEGATIVE_HINTS.some((hint) => lower.includes(hint))) {
@@ -264,12 +311,17 @@ function parseConsequences(lines, out) {
}
}
function parseAdrMarkdown(markdown, { sourcePath = '', format = 'auto' } = {}) {
interface ParseAdrMarkdownOptions {
sourcePath?: string;
format?: string;
}
function parseAdrMarkdown(markdown: unknown, { sourcePath = '', format = 'auto' }: ParseAdrMarkdownOptions = {}): AdrOut {
const sections = parseSections(markdown);
const titleLine = String(markdown || '').split(/\r?\n/).find((line) => /^#\s+/.test(line)) || '';
const titleLine = (typeof markdown === 'string' ? markdown : '').split(/\r?\n/).find((line) => /^#\s+/.test(line)) || '';
const title = titleLine.replace(/^#\s+/, '').trim();
const out = {
const out: AdrOut = {
title,
status: parseStatusFromSections(sections) || 'accepted',
context: '',
@@ -345,12 +397,18 @@ function parseAdrMarkdown(markdown, { sourcePath = '', format = 'auto' } = {}) {
return out;
}
function shouldRejectAdrStatus(status) {
function shouldRejectAdrStatus(status: string): boolean {
return STATUS_REJECT_SET.has(normalizeAdrHeader(status));
}
function parseCliArgs(argv) {
const opts = { input: null, format: 'auto', projectDir: process.cwd() };
interface CliOpts {
input: string | null;
format: string;
projectDir: string;
}
function parseCliArgs(argv: string[]): CliOpts {
const opts: CliOpts = { input: null, format: 'auto', projectDir: process.cwd() };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--input') {
@@ -369,11 +427,11 @@ function parseCliArgs(argv) {
return opts;
}
function main(argv) {
function main(argv: string[]): void {
const opts = parseCliArgs(argv);
const safePath = requireSafePath(opts.input, path.resolve(opts.projectDir), 'ADR input path', { allowAbsolute: true });
const content = fs.readFileSync(safePath, 'utf8');
const parsed = parseAdrMarkdown(content, { sourcePath: opts.input, format: opts.format });
const parsed = parseAdrMarkdown(content, { sourcePath: opts.input ?? undefined, format: opts.format });
process.stdout.write(JSON.stringify(parsed, null, 2));
}
@@ -381,12 +439,12 @@ if (require.main === module) {
try {
main(process.argv.slice(2));
} catch (error) {
process.stderr.write(`Error: ${error.message}\n`);
process.stderr.write(`Error: ${(error as Error).message}\n`);
process.exit(1);
}
}
module.exports = {
export = {
CANONICAL_HEADERS,
normalizeAdrHeader,
parseAdrMarkdown,

View File

@@ -0,0 +1,103 @@
/**
* Agent command router — classify-failure subcommand handler.
*
* ADR-457 build-at-publish: the hand-written bin/lib/agent-command-router.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { output, error, ERROR_REASON } = core;
// ─── Types ────────────────────────────────────────────────────────────────────
type QuotaExceededResult = {
class: 'quota-exceeded';
sentinel: string;
retryAfterSeconds?: number;
};
type ClassifyHandoffBugResult = {
class: 'classify-handoff-bug';
sentinel: string;
};
type UnknownFailureResult = {
class: 'unknown-failure';
};
type AgentFailureResult = QuotaExceededResult | ClassifyHandoffBugResult | UnknownFailureResult;
interface RouteAgentCommandOptions {
args: string[];
raw: boolean;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const QUOTA_SENTINELS: string[] = [
'429',
'usage_limit_reached',
'usage limit',
'rate limit',
'rate-limited',
'rate_limit',
'resource_exhausted',
'quota',
'too many requests',
'exceeded your',
];
const CLASSIFY_HANDOFF_SENTINEL = 'classifyhandoffifneeded is not defined';
// ─── Implementation ───────────────────────────────────────────────────────────
function parseRetryAfter(body: unknown): number | undefined {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const match = String(body ?? '').match(/\bretry[-_ ]after[:\s]+(\d+)\b/i);
if (!match) return undefined;
const seconds = Number.parseInt(match[1], 10);
return Number.isFinite(seconds) ? seconds : undefined;
}
function classifyAgentFailure(body: unknown): AgentFailureResult {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const normalized = String(body ?? '').toLowerCase();
if (normalized.trim() === '') {
return { class: 'unknown-failure' };
}
for (const sentinel of QUOTA_SENTINELS) {
if (normalized.includes(sentinel)) {
const retryAfterSeconds = parseRetryAfter(body);
return retryAfterSeconds === undefined
? { class: 'quota-exceeded', sentinel }
: { class: 'quota-exceeded', sentinel, retryAfterSeconds };
}
}
if (normalized.includes(CLASSIFY_HANDOFF_SENTINEL)) {
return {
class: 'classify-handoff-bug',
sentinel: CLASSIFY_HANDOFF_SENTINEL,
};
}
return { class: 'unknown-failure' };
}
function routeAgentCommand({ args, raw }: RouteAgentCommandOptions): void {
const subcommand = args[1];
if (subcommand !== 'classify-failure') {
error('Unknown agent subcommand. Available: classify-failure', ERROR_REASON.SDK_UNKNOWN_COMMAND);
}
const bodyArgs = args.slice(2).filter((arg) => arg !== '--');
output(classifyAgentFailure(bodyArgs.join(' ')), raw, undefined);
}
export = {
classifyAgentFailure,
routeAgentCommand,
};

View File

@@ -1,5 +1,8 @@
/**
* Canonical GSD artifact registry.
* Canonical GSD artifact registry (ADR-457 build-at-publish: the hand-written
* bin/lib/artifacts.cjs collapsed to a TypeScript source of truth). Behaviour
* is preserved byte-for-behaviour from the prior hand-written .cjs; only types
* are added.
*
* Enumerates the file names that gsd workflows officially produce at the
* .planning/ root level. Used by gsd-health (W019) to flag unrecognized files
@@ -8,10 +11,8 @@
* Add entries here whenever a new workflow produces a .planning/ root file.
*/
'use strict';
// Exact-match canonical file names at .planning/ root
const CANONICAL_EXACT = new Set([
export const CANONICAL_EXACT: ReadonlySet<string> = new Set([
'PROJECT.md',
'ROADMAP.md',
'STATE.md',
@@ -27,7 +28,7 @@ const CANONICAL_EXACT = new Set([
// Pattern-match canonical file names (regex tests on the basename)
// Each pattern includes the name of the workflow that produces it as a comment.
const CANONICAL_PATTERNS = [
export const CANONICAL_PATTERNS: ReadonlyArray<RegExp> = [
/^v\d+\.\d+(?:\.\d+)?-MILESTONE-AUDIT\.md$/i, // gsd-complete-milestone (pre-archive)
/^v\d+\.\d+(?:\.\d+)?-.*\.md$/i, // other version-stamped planning docs
];
@@ -36,18 +37,12 @@ const CANONICAL_PATTERNS = [
* Return true if `filename` (basename only, no path) matches a canonical
* .planning/ root artifact — either an exact name or a known pattern.
*
* @param {string} filename - Basename of the file (e.g. "STATE.md")
* @param filename - Basename of the file (e.g. "STATE.md")
*/
function isCanonicalPlanningFile(filename) {
export function isCanonicalPlanningFile(filename: string): boolean {
if (CANONICAL_EXACT.has(filename)) return true;
for (const pattern of CANONICAL_PATTERNS) {
if (pattern.test(filename)) return true;
}
return false;
}
module.exports = {
CANONICAL_EXACT,
CANONICAL_PATTERNS,
isCanonicalPlanningFile,
};

View File

@@ -5,33 +5,139 @@
* Returns structured JSON for workflow consumption.
* Called by: gsd-tools.cjs audit-open
* Used by: /gsd:complete-milestone pre-close gate
*
* ADR-457 build-at-publish: the hand-written bin/lib/audit.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
'use strict';
import fs from 'node:fs';
import path from 'node:path';
import { platformReadSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatter;
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
const fs = require('fs');
const path = require('path');
const { toPosixPath } = require('./core.cjs');
const { platformReadSync } = require('./shell-command-projection.cjs');
const { planningDir } = require('./planning-workspace.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { requireSafePath, sanitizeForDisplay } = require('./security.cjs');
// ─── Types ────────────────────────────────────────────────────────────────────
interface DebugSessionItem {
slug: string;
status: string;
updated: string;
hypothesis: string;
scan_error?: boolean;
}
interface QuickTaskItem {
slug: string;
date: string;
status: string;
description: string;
scan_error?: boolean;
}
interface ThreadItem {
slug: string;
status: string;
updated: string;
title: string;
scan_error?: boolean;
}
interface TodoItem {
filename: string;
priority: string;
area: string;
summary: string;
scan_error?: boolean;
_remainder_count?: number;
}
interface SeedItem {
seed_id: string;
slug: string;
status: string;
title: string;
scan_error?: boolean;
}
interface UatGapItem {
phase: string;
file: string;
status: string;
open_scenario_count: number;
scan_error?: boolean;
}
interface VerificationGapItem {
phase: string;
file: string;
status: string;
scan_error?: boolean;
}
interface ContextQuestionItem {
phase: string;
file: string;
question_count: number;
questions: string[];
scan_error?: boolean;
}
interface AuditCounts {
debug_sessions: number;
quick_tasks: number;
threads: number;
todos: number;
seeds: number;
uat_gaps: number;
verification_gaps: number;
context_questions: number;
total: number;
}
interface AuditResult {
scanned_at: string;
has_open_items: boolean;
counts: AuditCounts;
items: {
debug_sessions: DebugSessionItem[];
quick_tasks: QuickTaskItem[];
threads: ThreadItem[];
todos: TodoItem[];
seeds: SeedItem[];
uat_gaps: UatGapItem[];
verification_gaps: VerificationGapItem[];
context_questions: ContextQuestionItem[];
};
}
// Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure
// per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
// not recreated on each loop iteration.
const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
// ─── scanDebugSessions ────────────────────────────────────────────────────────
/**
* Scan .planning/debug/ for open sessions.
* Open = status NOT in ['resolved', 'complete'].
* Ignores the resolved/ subdirectory.
*/
function scanDebugSessions(planDir) {
function scanDebugSessions(planDir: string): DebugSessionItem[] {
const debugDir = path.join(planDir, 'debug');
if (!fs.existsSync(debugDir)) return [];
const results = [];
let files;
const results: DebugSessionItem[] = [];
let files: fs.Dirent[];
try {
files = fs.readdirSync(debugDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
}
for (const entry of files) {
@@ -40,7 +146,7 @@ function scanDebugSessions(planDir) {
const filePath = path.join(debugDir, entry.name);
let safeFilePath;
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'debug session file', { allowAbsolute: true });
} catch {
@@ -51,7 +157,7 @@ function scanDebugSessions(planDir) {
if (content === null) continue;
const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toLowerCase();
const status = ((fm.status as string) || 'unknown').toLowerCase();
if (status === 'resolved' || status === 'complete') continue;
// Extract hypothesis from "Current Focus" block if parseable
@@ -66,7 +172,7 @@ function scanDebugSessions(planDir) {
results.push({
slug: sanitizeForDisplay(slug),
status: sanitizeForDisplay(status),
updated: sanitizeForDisplay(String(fm.updated || fm.date || '')),
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
hypothesis,
});
}
@@ -74,29 +180,31 @@ function scanDebugSessions(planDir) {
return results;
}
// ─── scanQuickTasks ───────────────────────────────────────────────────────────
/**
* Scan .planning/quick/ for incomplete tasks.
* Incomplete if SUMMARY.md missing or status !== 'complete'.
*/
function scanQuickTasks(planDir) {
function scanQuickTasks(planDir: string): QuickTaskItem[] {
const quickDir = path.join(planDir, 'quick');
if (!fs.existsSync(quickDir)) return [];
let entries;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(quickDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
}
const results = [];
const results: QuickTaskItem[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirName = entry.name;
const taskDir = path.join(quickDir, dirName);
let safeTaskDir;
let safeTaskDir: string;
try {
safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', { allowAbsolute: true });
} catch {
@@ -105,7 +213,7 @@ function scanQuickTasks(planDir) {
// workflows/quick.md mandates `${quick_id}-SUMMARY.md`; older flows used
// bare `SUMMARY.md`. Accept either to avoid false-positive "missing".
let summaryPath = null;
let summaryPath: string | null = null;
try {
const summaryFiles = fs.readdirSync(safeTaskDir, { withFileTypes: true })
.filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
@@ -124,7 +232,7 @@ function scanQuickTasks(planDir) {
const description = '';
if (summaryPath && fs.existsSync(summaryPath)) {
let safeSum;
let safeSum: string;
try {
safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true });
} catch {
@@ -135,7 +243,7 @@ function scanQuickTasks(planDir) {
status = 'unreadable';
} else {
const fm = extractFrontmatter(content);
status = (fm.status || 'unknown').toLowerCase();
status = ((fm.status as string) || 'unknown').toLowerCase();
}
}
@@ -161,23 +269,25 @@ function scanQuickTasks(planDir) {
return results;
}
// ─── scanThreads ──────────────────────────────────────────────────────────────
/**
* Scan .planning/threads/ for open threads.
* Open if status in ['open', 'in_progress', 'in progress'] (case-insensitive).
*/
function scanThreads(planDir) {
function scanThreads(planDir: string): ThreadItem[] {
const threadsDir = path.join(planDir, 'threads');
if (!fs.existsSync(threadsDir)) return [];
let files;
let files: fs.Dirent[];
try {
files = fs.readdirSync(threadsDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
}
const openStatuses = new Set(['open', 'in_progress', 'in progress']);
const results = [];
const results: ThreadItem[] = [];
for (const entry of files) {
if (!entry.isFile()) continue;
@@ -185,7 +295,7 @@ function scanThreads(planDir) {
const filePath = path.join(threadsDir, entry.name);
let safeFilePath;
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'thread file', { allowAbsolute: true });
} catch {
@@ -196,7 +306,7 @@ function scanThreads(planDir) {
if (content === null) continue;
const fm = extractFrontmatter(content);
let status = (fm.status || '').toLowerCase().trim();
let status = ((fm.status as string) || '').toLowerCase().trim();
// Fall back to scanning body for ## Status: OPEN / IN PROGRESS
if (!status) {
@@ -209,7 +319,7 @@ function scanThreads(planDir) {
if (!openStatuses.has(status)) continue;
// Extract title from # Thread: heading or frontmatter title
let title = sanitizeForDisplay(String(fm.title || ''));
let title = sanitizeForDisplay(fm.title || '');
if (!title) {
const headingMatch = content.match(/^#\s*Thread:\s*(.+)$/m);
if (headingMatch) {
@@ -221,7 +331,7 @@ function scanThreads(planDir) {
results.push({
slug: sanitizeForDisplay(slug),
status: sanitizeForDisplay(status),
updated: sanitizeForDisplay(String(fm.updated || fm.date || '')),
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
title,
});
}
@@ -229,30 +339,32 @@ function scanThreads(planDir) {
return results;
}
// ─── scanTodos ────────────────────────────────────────────────────────────────
/**
* Scan .planning/todos/pending/ for pending todos.
* Returns array of { filename, priority, area, summary }.
* Display limited to first 5 + count of remainder.
*/
function scanTodos(planDir) {
function scanTodos(planDir: string): TodoItem[] {
const pendingDir = path.join(planDir, 'todos', 'pending');
if (!fs.existsSync(pendingDir)) return [];
let files;
let files: fs.Dirent[];
try {
files = fs.readdirSync(pendingDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
}
const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
const results = [];
const results: TodoItem[] = [];
const displayFiles = mdFiles.slice(0, 5);
for (const entry of displayFiles) {
const filePath = path.join(pendingDir, entry.name);
let safeFilePath;
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true });
} catch {
@@ -271,36 +383,38 @@ function scanTodos(planDir) {
results.push({
filename: sanitizeForDisplay(entry.name),
priority: sanitizeForDisplay(String(fm.priority || '')),
area: sanitizeForDisplay(String(fm.area || '')),
priority: sanitizeForDisplay(fm.priority || ''),
area: sanitizeForDisplay(fm.area || ''),
summary,
});
}
if (mdFiles.length > 5) {
results.push({ _remainder_count: mdFiles.length - 5 });
results.push({ _remainder_count: mdFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
}
return results;
}
// ─── scanSeeds ────────────────────────────────────────────────────────────────
/**
* Scan .planning/seeds/SEED-*.md for unimplemented seeds.
* Unimplemented if status in ['dormant', 'active', 'triggered'].
*/
function scanSeeds(planDir) {
function scanSeeds(planDir: string): SeedItem[] {
const seedsDir = path.join(planDir, 'seeds');
if (!fs.existsSync(seedsDir)) return [];
let files;
let files: fs.Dirent[];
try {
files = fs.readdirSync(seedsDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
}
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
const results = [];
const results: SeedItem[] = [];
for (const entry of files) {
if (!entry.isFile()) continue;
@@ -308,7 +422,7 @@ function scanSeeds(planDir) {
const filePath = path.join(seedsDir, entry.name);
let safeFilePath;
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'seed file', { allowAbsolute: true });
} catch {
@@ -319,7 +433,7 @@ function scanSeeds(planDir) {
if (content === null) continue;
const fm = extractFrontmatter(content);
const status = (fm.status || 'dormant').toLowerCase();
const status = ((fm.status as string) || 'dormant').toLowerCase();
if (!unimplementedStatuses.has(status)) continue;
@@ -328,7 +442,7 @@ function scanSeeds(planDir) {
const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
const slug = sanitizeForDisplay(seed_id.replace(/^SEED-/, ''));
let title = sanitizeForDisplay(String(fm.title || ''));
let title = sanitizeForDisplay(fm.title || '');
if (!title) {
const headingMatch = content.match(/^#\s*(.+)$/m);
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
@@ -345,36 +459,33 @@ function scanSeeds(planDir) {
return results;
}
// Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure
// per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
// not recreated on each loop iteration.
const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
// ─── scanUatGaps ──────────────────────────────────────────────────────────────
/**
* Scan .planning/phases for UAT gaps (UAT files with status != 'complete').
*/
function scanUatGaps(planDir) {
function scanUatGaps(planDir: string): UatGapItem[] {
const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return [];
let dirs;
let dirs: string[];
try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true }];
return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }];
}
const results = [];
const results: UatGapItem[] = [];
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files;
let files: string[];
try {
files = fs.readdirSync(phaseDir);
} catch {
@@ -384,7 +495,7 @@ function scanUatGaps(planDir) {
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file);
let safeFilePath;
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true });
} catch {
@@ -395,8 +506,8 @@ function scanUatGaps(planDir) {
if (content === null) continue;
const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toLowerCase();
const result = (fm.result || '').toString().toLowerCase();
const status = ((fm.status as string) || 'unknown').toLowerCase();
const result = ((fm.result as string) || '').toLowerCase();
// Also accept `result: all_pass` as a fallback when status is absent
// — covers UATs that omit `status:`.
@@ -418,31 +529,33 @@ function scanUatGaps(planDir) {
return results;
}
// ─── scanVerificationGaps ─────────────────────────────────────────────────────
/**
* Scan .planning/phases for VERIFICATION gaps.
*/
function scanVerificationGaps(planDir) {
function scanVerificationGaps(planDir: string): VerificationGapItem[] {
const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return [];
let dirs;
let dirs: string[];
try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true }];
return [{ scan_error: true, phase: '', file: '', status: '' }];
}
const results = [];
const results: VerificationGapItem[] = [];
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files;
let files: string[];
try {
files = fs.readdirSync(phaseDir);
} catch {
@@ -452,7 +565,7 @@ function scanVerificationGaps(planDir) {
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file);
let safeFilePath;
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
} catch {
@@ -463,7 +576,7 @@ function scanVerificationGaps(planDir) {
if (content === null) continue;
const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toLowerCase();
const status = ((fm.status as string) || 'unknown').toLowerCase();
if (status !== 'gaps_found' && status !== 'human_needed') continue;
@@ -478,31 +591,33 @@ function scanVerificationGaps(planDir) {
return results;
}
// ─── scanContextQuestions ─────────────────────────────────────────────────────
/**
* Scan .planning/phases for CONTEXT files with open_questions.
*/
function scanContextQuestions(planDir) {
function scanContextQuestions(planDir: string): ContextQuestionItem[] {
const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return [];
let dirs;
let dirs: string[];
try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true }];
return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
}
const results = [];
const results: ContextQuestionItem[] = [];
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files;
let files: string[];
try {
files = fs.readdirSync(phaseDir);
} catch {
@@ -512,7 +627,7 @@ function scanContextQuestions(planDir) {
for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file);
let safeFilePath;
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
} catch {
@@ -525,10 +640,10 @@ function scanContextQuestions(planDir) {
const fm = extractFrontmatter(content);
// Check frontmatter open_questions field
let questions = [];
let questions: string[] = [];
if (fm.open_questions) {
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
questions = fm.open_questions.map(q => sanitizeForDisplay(String(q).slice(0, 200)));
questions = (fm.open_questions as unknown[]).map(q => sanitizeForDisplay(String(q).slice(0, 200)));
}
}
@@ -539,10 +654,10 @@ function scanContextQuestions(planDir) {
const oqBody = oqMatch[1].trim();
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
const items = oqBody.split('\n')
.map(l => l.trim())
.filter(l => l && l !== '-' && l !== '*')
.filter(l => /^[-*\d]/.test(l) || l.includes('?'));
questions = items.slice(0, 3).map(q => sanitizeForDisplay(q.slice(0, 200)));
.map((l: string) => l.trim())
.filter((l: string) => l && l !== '-' && l !== '*')
.filter((l: string) => /^[-*\d]/.test(l) || l.includes('?'));
questions = items.slice(0, 3).map((q: string) => sanitizeForDisplay(q.slice(0, 200)));
}
}
}
@@ -561,51 +676,54 @@ function scanContextQuestions(planDir) {
return results;
}
// ─── auditOpenArtifacts ───────────────────────────────────────────────────────
/**
* Main audit function. Scans all .planning/ artifact categories.
*
* @param {string} cwd - Project root directory
* @returns {object} Structured audit result
* @param cwd - Project root directory
* @returns Structured audit result
*/
function auditOpenArtifacts(cwd) {
function auditOpenArtifacts(cwd: string): AuditResult {
const planDir = planningDir(cwd);
const debugSessions = (() => {
try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true }]; }
try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }]; }
})();
const quickTasks = (() => {
try { return scanQuickTasks(planDir); } catch { return [{ scan_error: true }]; }
try { return scanQuickTasks(planDir); } catch { return [{ scan_error: true, slug: '', date: '', status: '', description: '' }]; }
})();
const threads = (() => {
try { return scanThreads(planDir); } catch { return [{ scan_error: true }]; }
try { return scanThreads(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }]; }
})();
const todos = (() => {
try { return scanTodos(planDir); } catch { return [{ scan_error: true }]; }
try { return scanTodos(planDir); } catch { return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }]; }
})();
const seeds = (() => {
try { return scanSeeds(planDir); } catch { return [{ scan_error: true }]; }
try { return scanSeeds(planDir); } catch { return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }]; }
})();
const uatGaps = (() => {
try { return scanUatGaps(planDir); } catch { return [{ scan_error: true }]; }
try { return scanUatGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }]; }
})();
const verificationGaps = (() => {
try { return scanVerificationGaps(planDir); } catch { return [{ scan_error: true }]; }
try { return scanVerificationGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '' }]; }
})();
const contextQuestions = (() => {
try { return scanContextQuestions(planDir); } catch { return [{ scan_error: true }]; }
try { return scanContextQuestions(planDir); } catch { return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }]; }
})();
// Count real items (not scan_error sentinels)
const countReal = arr => arr.filter(i => !i.scan_error && !i._remainder_count).length;
const countReal = (arr: Array<{ scan_error?: boolean; _remainder_count?: number }>) =>
arr.filter(i => !i.scan_error && !i._remainder_count).length;
const counts = {
const counts: AuditCounts = {
debug_sessions: countReal(debugSessions),
quick_tasks: countReal(quickTasks),
threads: countReal(threads),
@@ -614,8 +732,9 @@ function auditOpenArtifacts(cwd) {
uat_gaps: countReal(uatGaps),
verification_gaps: countReal(verificationGaps),
context_questions: countReal(contextQuestions),
total: 0,
};
counts.total = Object.values(counts).reduce((s, n) => s + n, 0);
counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions;
return {
scanned_at: new Date().toISOString(),
@@ -634,15 +753,17 @@ function auditOpenArtifacts(cwd) {
};
}
// ─── formatAuditReport ────────────────────────────────────────────────────────
/**
* Format the audit result as a human-readable report.
*
* @param {object} auditResult - Result from auditOpenArtifacts()
* @returns {string} Formatted report
* @param auditResult - Result from auditOpenArtifacts()
* @returns Formatted report
*/
function formatAuditReport(auditResult) {
function formatAuditReport(auditResult: AuditResult): string {
const { counts, items, has_open_items } = auditResult;
const lines = [];
const lines: string[] = [];
const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
lines.push(hr);
@@ -752,4 +873,4 @@ function formatAuditReport(auditResult) {
return lines.join('\n');
}
module.exports = { auditOpenArtifacts, formatAuditReport };
export = { auditOpenArtifacts, formatAuditReport };

View File

@@ -1,12 +1,24 @@
'use strict';
/**
* Check subcommand router — auto-mode, decision-coverage-plan, decision-coverage-verify.
*
* ADR-457 build-at-publish: the hand-written bin/lib/check-command-router.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { output, error, ERROR_REASON } = require('./core.cjs');
const { parseDecisions } = require('./decisions.cjs');
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { output, error, ERROR_REASON } = core;
import { parseDecisions } from './decisions.cjs';
import type { Decision } from './decisions.cjs';
function normalizePhrase(text) {
// ─── Helpers ──────────────────────────────────────────────────────────────────
function normalizePhrase(text: unknown): string {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return String(text || '')
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
@@ -16,20 +28,20 @@ function normalizePhrase(text) {
const SOFT_PHRASE_MIN_WORDS = 6;
function softPhrase(text) {
function softPhrase(text: unknown): string {
const words = normalizePhrase(text).split(' ').filter(Boolean);
if (words.length < SOFT_PHRASE_MIN_WORDS) return '';
return words.slice(0, SOFT_PHRASE_MIN_WORDS).join(' ');
}
function decisionMentioned(haystack, decision) {
function decisionMentioned(haystack: string | null | undefined, decision: Decision): boolean {
if (!haystack) return false;
if (new RegExp(`\\b${decision.id}\\b`).test(haystack)) return true;
const phrase = softPhrase(decision.text);
return phrase ? normalizePhrase(haystack).includes(phrase) : false;
}
function readIfExists(filePath) {
function readIfExists(filePath: string): string {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
@@ -37,26 +49,33 @@ function readIfExists(filePath) {
}
}
function resolvePath(inputPath, projectDir) {
function resolvePath(inputPath: string, projectDir: string): string {
return path.isAbsolute(inputPath) ? inputPath : path.join(projectDir, inputPath);
}
function readWorkflowConfig(projectDir) {
interface WorkflowConfig {
auto_advance?: boolean;
_auto_chain_active?: boolean;
context_coverage_gate?: boolean | string;
}
function readWorkflowConfig(projectDir: string): WorkflowConfig {
const configPath = path.join(projectDir, '.planning', 'config.json');
try {
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
const wf = (parsed['workflow'] as Record<string, unknown> | undefined) || {};
return {
...(parsed.workflow || {}),
auto_advance: parsed.workflow?.auto_advance ?? parsed.auto_advance,
_auto_chain_active: parsed.workflow?._auto_chain_active ?? parsed._auto_chain_active,
context_coverage_gate: parsed.workflow?.context_coverage_gate ?? parsed.context_coverage_gate,
...wf,
auto_advance: (wf['auto_advance'] ?? parsed['auto_advance']) as boolean | undefined,
_auto_chain_active: (wf['_auto_chain_active'] ?? parsed['_auto_chain_active']) as boolean | undefined,
context_coverage_gate: (wf['context_coverage_gate'] ?? parsed['context_coverage_gate']) as boolean | string | undefined,
};
} catch {
return {};
}
}
function cmdAutoMode(projectDir, raw) {
function cmdAutoMode(projectDir: string, raw: boolean): void {
const workflow = readWorkflowConfig(projectDir);
const autoAdvance = Boolean(workflow.auto_advance ?? false);
const autoChainActive = Boolean(workflow._auto_chain_active ?? false);
@@ -70,10 +89,10 @@ function cmdAutoMode(projectDir, raw) {
source,
auto_chain_active: autoChainActive,
auto_advance: autoAdvance,
}, raw);
}, raw, undefined);
}
function gateEnabled(projectDir) {
function gateEnabled(projectDir: string): boolean {
const value = readWorkflowConfig(projectDir).context_coverage_gate;
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
@@ -83,7 +102,7 @@ function gateEnabled(projectDir) {
return true;
}
function loadPlanContents(phaseDir) {
function loadPlanContents(phaseDir: string): string[] {
if (!fs.existsSync(phaseDir)) return [];
try {
return fs.readdirSync(phaseDir)
@@ -97,14 +116,14 @@ function loadPlanContents(phaseDir) {
const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi;
function stripCommentsAndFences(text) {
function stripCommentsAndFences(text: string): string {
return text
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/~~~[\s\S]*?~~~/g, ' ');
}
function extractYamlBlock(frontmatter, key) {
function extractYamlBlock(frontmatter: string, key: string): string {
const match = frontmatter.match(new RegExp(`^${key}\\s*:(.*)$`, 'm'));
if (!match) return '';
const startIdx = (match.index || 0) + match[0].length;
@@ -117,28 +136,28 @@ function extractYamlBlock(frontmatter, key) {
return block.join('\n');
}
function extractXmlTagBodies(text) {
const parts = [];
function extractXmlTagBodies(text: string): string {
const parts: string[] = [];
for (const match of text.matchAll(XML_DECISION_TAGS_RE)) {
if (match[1]) parts.push(match[1]);
}
return parts.join('\n');
}
function extractPlanDesignatedSections(planContent) {
function extractPlanDesignatedSections(planContent: string | null | undefined): string {
if (!planContent) return '';
const cleaned = stripCommentsAndFences(planContent);
const fmMatch = cleaned.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
const frontmatter = fmMatch ? fmMatch[1] : '';
const body = fmMatch ? fmMatch[2] : cleaned;
const parts = [];
const parts: string[] = [];
for (const key of ['must_haves', 'truths', 'objective']) {
const block = extractYamlBlock(frontmatter, key);
if (block) parts.push(block);
}
const bodyParts = [];
const bodyParts: string[] = [];
let inDesignated = false;
for (const line of body.split(/\r?\n/)) {
const heading = /^#{1,6}\s+/.test(line);
@@ -154,7 +173,13 @@ function extractPlanDesignatedSections(planContent) {
return parts.join('\n\n');
}
function buildPlanMessage(uncovered) {
interface UncoveredItem {
id: string;
text: string;
category: string;
}
function buildPlanMessage(uncovered: UncoveredItem[]): string {
if (uncovered.length === 0) return 'All trackable CONTEXT.md decisions are covered by plans.';
return [
'## Decision Coverage Gap',
@@ -168,7 +193,7 @@ function buildPlanMessage(uncovered) {
].join('\n');
}
function buildVerifyMessage(notHonored) {
function buildVerifyMessage(notHonored: UncoveredItem[]): string {
if (notHonored.length === 0) return 'All trackable CONTEXT.md decisions are honored by shipped artifacts.';
return [
'### Decision Coverage (warning)',
@@ -181,31 +206,31 @@ function buildVerifyMessage(notHonored) {
].join('\n');
}
function loadTrackableDecisions(contextPath) {
function loadTrackableDecisions(contextPath: string): Decision[] {
return parseDecisions(readIfExists(contextPath)).filter((decision) => decision.trackable);
}
function cmdDecisionCoveragePlan(projectDir, args, raw) {
function cmdDecisionCoveragePlan(projectDir: string, args: string[], raw: boolean): void {
const phaseDir = args[2] ? resolvePath(args[2], projectDir) : '';
const contextPath = args[3] ? resolvePath(args[3], projectDir) : '';
if (!gateEnabled(projectDir)) {
output({ passed: true, skipped: true, reason: 'workflow.context_coverage_gate is false', total: 0, covered: 0, uncovered: [], message: 'Decision coverage gate disabled by config.' }, raw);
output({ passed: true, skipped: true, reason: 'workflow.context_coverage_gate is false', total: 0, covered: 0, uncovered: [], message: 'Decision coverage gate disabled by config.' }, raw, undefined);
return;
}
if (!contextPath || !fs.existsSync(contextPath)) {
output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw);
output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
return;
}
const decisions = loadTrackableDecisions(contextPath);
if (decisions.length === 0) {
output({ passed: true, skipped: true, reason: 'no trackable decisions', total: 0, covered: 0, uncovered: [], message: 'No trackable decisions in CONTEXT.md.' }, raw);
output({ passed: true, skipped: true, reason: 'no trackable decisions', total: 0, covered: 0, uncovered: [], message: 'No trackable decisions in CONTEXT.md.' }, raw, undefined);
return;
}
const sections = loadPlanContents(phaseDir).map(extractPlanDesignatedSections);
const uncovered = [];
const uncovered: UncoveredItem[] = [];
let covered = 0;
for (const decision of decisions) {
if (sections.some((section) => decisionMentioned(section, decision))) covered++;
@@ -219,10 +244,10 @@ function cmdDecisionCoveragePlan(projectDir, args, raw) {
covered,
uncovered,
message: buildPlanMessage(uncovered),
}, raw);
}, raw, undefined);
}
function recentCommitMessages(projectDir) {
function recentCommitMessages(projectDir: string): string {
try {
return execFileSync('git', ['log', '-n', '200', '--pretty=%s%n%b'], {
cwd: projectDir,
@@ -234,14 +259,14 @@ function recentCommitMessages(projectDir) {
}
}
function isInsideRoot(candidatePath, rootDir) {
function isInsideRoot(candidatePath: string, rootDir: string): boolean {
const root = path.resolve(rootDir);
const target = path.resolve(root, candidatePath);
return target === root || target.startsWith(`${root}${path.sep}`);
}
function readModifiedFilesContent(projectDir, summaries) {
const out = [];
function readModifiedFilesContent(projectDir: string, summaries: string[]): string {
const out: string[] = [];
let total = 0;
for (const summary of summaries) {
if (!summary) continue;
@@ -262,22 +287,22 @@ function readModifiedFilesContent(projectDir, summaries) {
return out.join('\n\n');
}
function cmdDecisionCoverageVerify(projectDir, args, raw) {
function cmdDecisionCoverageVerify(projectDir: string, args: string[], raw: boolean): void {
const phaseDir = args[2] ? resolvePath(args[2], projectDir) : '';
const contextPath = args[3] ? resolvePath(args[3], projectDir) : '';
if (!gateEnabled(projectDir)) {
output({ skipped: true, blocking: false, reason: 'workflow.context_coverage_gate is false', total: 0, honored: 0, not_honored: [], message: 'Decision coverage gate disabled by config.' }, raw);
output({ skipped: true, blocking: false, reason: 'workflow.context_coverage_gate is false', total: 0, honored: 0, not_honored: [], message: 'Decision coverage gate disabled by config.' }, raw, undefined);
return;
}
if (!contextPath || !fs.existsSync(contextPath)) {
output({ skipped: true, blocking: false, reason: 'CONTEXT.md missing', total: 0, honored: 0, not_honored: [], message: 'No CONTEXT.md - nothing to check.' }, raw);
output({ skipped: true, blocking: false, reason: 'CONTEXT.md missing', total: 0, honored: 0, not_honored: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
return;
}
const decisions = loadTrackableDecisions(contextPath);
if (decisions.length === 0) {
output({ skipped: true, blocking: false, reason: 'no trackable decisions', total: 0, honored: 0, not_honored: [], message: 'No trackable decisions in CONTEXT.md.' }, raw);
output({ skipped: true, blocking: false, reason: 'no trackable decisions', total: 0, honored: 0, not_honored: [], message: 'No trackable decisions in CONTEXT.md.' }, raw, undefined);
return;
}
@@ -292,7 +317,7 @@ function cmdDecisionCoverageVerify(projectDir, args, raw) {
recentCommitMessages(projectDir),
].join('\n\n');
const notHonored = [];
const notHonored: UncoveredItem[] = [];
let honored = 0;
for (const decision of decisions) {
if (decisionMentioned(haystack, decision)) honored++;
@@ -306,10 +331,16 @@ function cmdDecisionCoverageVerify(projectDir, args, raw) {
honored,
not_honored: notHonored,
message: buildVerifyMessage(notHonored),
}, raw);
}, raw, undefined);
}
function routeCheckCommand({ args, cwd, raw }) {
interface RouteCheckCommandOptions {
args: string[];
cwd: string;
raw: boolean;
}
function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
const subcommand = args[1];
if (subcommand === 'auto-mode') {
cmdAutoMode(cwd, raw);
@@ -326,7 +357,7 @@ function routeCheckCommand({ args, cwd, raw }) {
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify', ERROR_REASON.SDK_UNKNOWN_COMMAND);
}
module.exports = {
export = {
routeCheckCommand,
decisionMentioned,
extractPlanDesignatedSections,

View File

@@ -1,15 +1,50 @@
'use strict';
const { createHub, ERROR_KINDS } = require('./command-routing-hub.cjs');
/**
* CJS Command Router Adapter Module
*
* Compatibility routing for gsd-tools.cjs command families. Uses generated
* command metadata for availability and small family-local argument shapers for
* CJS handler calls.
*
* ADR-457 build-at-publish: the hand-written bin/lib/cjs-command-router-adapter.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
import commandRoutingHub = require('./command-routing-hub.cjs');
const { createHub, ERROR_KINDS } = commandRoutingHub;
// ─── Types ────────────────────────────────────────────────────────────────────
type Handler = () => unknown;
interface RouteCjsCommandFamilyOptions {
args: string[];
subcommands: string[];
handlers: Record<string, Handler>;
defaultSubcommand?: string;
unsupported?: Record<string, string>;
unknownMessage: (subcommand: string, available: string[]) => string;
error: (message: string) => void;
cwd?: string;
raw?: boolean;
}
interface RouteHubCommandFamilyOptions {
family: string;
args: string[];
subcommands: string[];
handlers: Record<string, Handler>;
defaultSubcommand?: string;
unsupported?: Record<string, string>;
unknownMessage: (subcommand: string, available: string[]) => string;
error: (message: string) => void;
cwd?: string;
raw?: boolean;
}
// ─── Implementation ───────────────────────────────────────────────────────────
function routeCjsCommandFamily({
args,
subcommands,
@@ -20,7 +55,7 @@ function routeCjsCommandFamily({
error,
cwd,
raw,
}) {
}: RouteCjsCommandFamilyOptions): void {
routeHubCommandFamily({
family: '__legacy_cjs_family__',
args,
@@ -53,7 +88,7 @@ function routeHubCommandFamily({
error,
cwd,
raw,
}) {
}: RouteHubCommandFamilyOptions): void {
const subcommand = args[1] || defaultSubcommand;
if (subcommand && unsupported[subcommand]) {
@@ -65,12 +100,12 @@ function routeHubCommandFamily({
const registryHandlers = Object.fromEntries(
Object.entries(handlers).map(([name, handler]) => [
name,
() => {
(): { ok: true; data: unknown } => {
const result = handler();
if (result && typeof result === 'object' && Object.prototype.hasOwnProperty.call(result, 'ok')) {
return result;
return result as { ok: true; data: unknown };
}
return { ok: true, data: null };
return { ok: true as const, data: null };
},
]),
);
@@ -90,14 +125,14 @@ function routeHubCommandFamily({
if (result.ok) return;
if (result.kind === ERROR_KINDS.UnknownCommand) {
error(unknownMessage(subcommand, available));
error(unknownMessage(subcommand ?? '', available));
return;
}
if (result.kind === ERROR_KINDS.InvalidArgs || result.kind === ERROR_KINDS.HandlerRefusal) {
error(result.reason);
error((result as { reason: string }).reason);
return;
}
error(result.message);
error((result as { message: string }).message);
}
/**
@@ -107,11 +142,11 @@ function routeHubCommandFamily({
* Accepts variable argument shapes so routers can pass legacy projection tuples
* (`registryCommand`, `registryArgs`, `legacyArgs`, optional `rawFormatter`, `cjsFallback`).
*/
function cjsFallbackHandler(...projectionArgs) {
function cjsFallbackHandler(...projectionArgs: unknown[]): unknown {
return projectionArgs[projectionArgs.length - 1];
}
module.exports = {
export = {
routeCjsCommandFamily,
routeHubCommandFamily,
cjsFallbackHandler,

View File

@@ -1,7 +1,8 @@
'use strict';
/**
* Deterministic clock seam for lock modules (issue #453).
* Deterministic clock seam for lock modules (ADR-457 build-at-publish: the
* hand-written bin/lib/clock.cjs collapsed to a TypeScript source of truth).
* Behaviour is preserved byte-for-behaviour from the prior hand-written .cjs;
* only types are added.
*
* Production code uses `realClock` (the default). Test code passes in a
* `makeFakeClock()` instance to drive lock timing without real wall-clock
@@ -14,6 +15,14 @@
* - sleep() → Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
*/
/** Clock interface implemented by both realClock and test fakes. */
export interface Clock {
now(): number;
nowIso(): string;
today(): string;
sleep(ms: number): void;
}
// Module-level Atomics.wait buffer reused across every realClock.sleep() call.
// The buffer value is always 0 (never written), so reuse is semantically
// identical to allocating a fresh buffer each time.
@@ -28,10 +37,8 @@ const _realSleepBuf = new Int32Array(new SharedArrayBuffer(4));
*
* Returns null (fall back to Date.now()) for any invalid or absent input.
* Returns null when GSD_TEST_MODE is not set.
*
* @returns {number|null}
*/
function _pinnedNowMs() {
function _pinnedNowMs(): number | null {
if (!process.env.GSD_TEST_MODE) return null;
const raw = process.env.GSD_NOW_MS;
if (typeof raw !== 'string') return null;
@@ -42,7 +49,7 @@ function _pinnedNowMs() {
return ms;
}
const realClock = {
export const realClock: Clock = {
/**
* Return current epoch milliseconds.
*
@@ -54,7 +61,7 @@ const realClock = {
* Any other value (empty string, float, scientific notation, out-of-range) falls
* back to Date.now() to prevent RangeError from new Date(ms).toISOString().
*/
now() {
now(): number {
const pinned = _pinnedNowMs();
if (pinned !== null) return pinned;
return Date.now();
@@ -64,9 +71,9 @@ const realClock = {
* Return the current instant as an ISO 8601 string (UTC).
* Uses this.now() so the subprocess time-pin adapter is honoured.
*
* @returns {string} e.g. "2020-06-15T12:00:00.000Z"
* @returns e.g. "2020-06-15T12:00:00.000Z"
*/
nowIso() {
nowIso(): string {
return new Date(this.now()).toISOString();
},
@@ -74,9 +81,9 @@ const realClock = {
* Return today's date as a YYYY-MM-DD string (UTC calendar day).
* Uses this.now() so the subprocess time-pin adapter is honoured.
*
* @returns {string} e.g. "2020-06-15"
* @returns e.g. "2020-06-15"
*/
today() {
today(): string {
return this.nowIso().split('T')[0];
},
@@ -86,11 +93,9 @@ const realClock = {
* inline before the seam. Atomics.wait on a shared buffer that is never
* notified times out after exactly `ms` milliseconds without spinning the CPU.
*
* @param {number} ms - milliseconds to sleep
* @param ms - milliseconds to sleep
*/
sleep(ms) {
sleep(ms: number): void {
Atomics.wait(_realSleepBuf, 0, 0, ms);
},
};
module.exports = { realClock };

View File

@@ -1,6 +1,8 @@
'use strict';
/**
* Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2).
* Skill cluster definitions for the runtime surface module (ADR-457
* build-at-publish: the hand-written bin/lib/clusters.cjs collapsed to a
* TypeScript source of truth). Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*
* Each cluster is a named group of skill stems. Clusters are used by /gsd:surface
* to enable/disable a cohesive group of skills without reinstall.
@@ -13,7 +15,21 @@
* against commands/gsd/ listing in surface-clusters.test.cjs).
*/
const CLUSTERS = Object.freeze({
export type ClusterName =
| 'core_loop'
| 'audit_review'
| 'milestone'
| 'research_ideate'
| 'workspace_state'
| 'docs'
| 'ui'
| 'ai_eval'
| 'ns_meta'
| 'utility';
export type ClusterMap = Readonly<Record<ClusterName, ReadonlyArray<string>>>;
export const CLUSTERS: ClusterMap = Object.freeze({
core_loop: Object.freeze([
'new-project',
'discuss-phase',
@@ -122,14 +138,11 @@ const CLUSTERS = Object.freeze({
/**
* Build a Set of all skill stems covered by at least one cluster.
* @returns {Set<string>}
*/
function allClusteredSkills() {
const result = new Set();
export function allClusteredSkills(): Set<string> {
const result = new Set<string>();
for (const skills of Object.values(CLUSTERS)) {
for (const s of skills) result.add(s);
}
return result;
}
module.exports = { CLUSTERS, allClusteredSkills };

73
src/code-review-flags.cts Normal file
View File

@@ -0,0 +1,73 @@
/**
* Typed flag parser for the /gsd:code-review command (ADR-457 build-at-publish:
* the hand-written bin/lib/code-review-flags.cjs collapsed to a TypeScript
* source of truth). Behaviour is preserved byte-for-behaviour from the prior
* hand-written .cjs; only types are added.
*
* This is the canonical IR for code-review argument parsing. The workflow
* (code-review.md) delegates flag dispatch to this module so that tests assert
* on a structured IR rather than rendered bash text, and the dispatch decision
* is testable without instantiating the workflow.
*/
/** Parsed code-review flags. `--all` and `--auto` both imply `--fix`. */
export interface CodeReviewFlags {
/** true when --fix is present (or implied by --all/--auto) */
fix: boolean;
/** true when --all is present (implies fix) */
all: boolean;
/** true when --auto is present (implies fix) */
auto: boolean;
/** --depth= override value, or '' if not supplied */
depth: string;
/** --files= override value, or '' if not supplied */
files: string;
}
/** Workflow filename the orchestrator should load. */
export type CodeReviewWorkflow = 'code-review.md' | 'code-review-fix.md';
/**
* Parse code-review flags from an argv array. The first positional argument
* (phase number) is ignored — phase validation is handled by
* `gsd-tools query init.phase-op`. Unknown flags are silently ignored.
*/
export function parseCodeReviewFlags(argv: string[]): CodeReviewFlags {
const flags: CodeReviewFlags = {
fix: false,
all: false,
auto: false,
depth: '',
files: '',
};
for (const arg of argv) {
if (arg === '--fix') {
flags.fix = true;
} else if (arg === '--all') {
flags.all = true;
} else if (arg === '--auto') {
flags.auto = true;
} else if (arg.startsWith('--depth=')) {
flags.depth = arg.slice('--depth='.length);
} else if (arg.startsWith('--files=')) {
flags.files = arg.slice('--files='.length);
}
}
// --all and --auto imply --fix
if (flags.all || flags.auto) {
flags.fix = true;
}
return flags;
}
/**
* Determine which workflow to dispatch based on parsed flags:
* - 'code-review-fix.md' when fix=true (--fix, --all, or --auto present)
* - 'code-review.md' otherwise (review-only pass)
*/
export function resolveCodeReviewWorkflow(flags: CodeReviewFlags): CodeReviewWorkflow {
return flags.fix ? 'code-review-fix.md' : 'code-review.md';
}

View File

@@ -1,10 +1,25 @@
'use strict';
/**
* state.*, verify.*, init.*, phase.*, phases.*, validate.*, roadmap.*, and non-family alias/subcommand metadata for CJS routing.
*
* ADR-457 build-at-publish: the hand-written bin/lib/command-aliases.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
const STATE_COMMAND_ALIASES = [
interface CommandAlias {
canonical: string;
aliases: string[];
subcommand: string;
mutation: boolean;
}
interface NonFamilyCommandAlias {
canonical: string;
aliases: string[];
mutation: boolean;
}
export const STATE_COMMAND_ALIASES: CommandAlias[] = [
{
"canonical": "state.load",
"aliases": [],
@@ -173,7 +188,7 @@ const STATE_COMMAND_ALIASES = [
}
];
const VERIFY_COMMAND_ALIASES = [
export const VERIFY_COMMAND_ALIASES: CommandAlias[] = [
{
"canonical": "verify.plan-structure",
"aliases": [
@@ -240,7 +255,7 @@ const VERIFY_COMMAND_ALIASES = [
}
];
const INIT_COMMAND_ALIASES = [
export const INIT_COMMAND_ALIASES: CommandAlias[] = [
{
"canonical": "init.execute-phase",
"aliases": [
@@ -379,7 +394,7 @@ const INIT_COMMAND_ALIASES = [
}
];
const PHASE_COMMAND_ALIASES = [
export const PHASE_COMMAND_ALIASES: CommandAlias[] = [
{
"canonical": "phase.uat-passed",
"aliases": [
@@ -446,7 +461,7 @@ const PHASE_COMMAND_ALIASES = [
}
];
const PHASES_COMMAND_ALIASES = [
export const PHASES_COMMAND_ALIASES: CommandAlias[] = [
{
"canonical": "phases.list",
"aliases": [
@@ -473,7 +488,7 @@ const PHASES_COMMAND_ALIASES = [
}
];
const VALIDATE_COMMAND_ALIASES = [
export const VALIDATE_COMMAND_ALIASES: CommandAlias[] = [
{
"canonical": "validate.consistency",
"aliases": [
@@ -508,7 +523,7 @@ const VALIDATE_COMMAND_ALIASES = [
}
];
const ROADMAP_COMMAND_ALIASES = [
export const ROADMAP_COMMAND_ALIASES: CommandAlias[] = [
{
"canonical": "roadmap.analyze",
"aliases": [
@@ -559,7 +574,7 @@ const ROADMAP_COMMAND_ALIASES = [
}
];
const NON_FAMILY_COMMAND_ALIASES = [
export const NON_FAMILY_COMMAND_ALIASES: NonFamilyCommandAlias[] = [
{
"canonical": "agent.classify-failure",
"aliases": [
@@ -804,28 +819,10 @@ const NON_FAMILY_COMMAND_ALIASES = [
}
];
const STATE_SUBCOMMANDS = STATE_COMMAND_ALIASES.map((entry) => entry.subcommand);
const VERIFY_SUBCOMMANDS = VERIFY_COMMAND_ALIASES.map((entry) => entry.subcommand);
const INIT_SUBCOMMANDS = INIT_COMMAND_ALIASES.map((entry) => entry.subcommand);
const PHASE_SUBCOMMANDS = PHASE_COMMAND_ALIASES.map((entry) => entry.subcommand);
const PHASES_SUBCOMMANDS = PHASES_COMMAND_ALIASES.map((entry) => entry.subcommand);
const VALIDATE_SUBCOMMANDS = VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand);
const ROADMAP_SUBCOMMANDS = ROADMAP_COMMAND_ALIASES.map((entry) => entry.subcommand);
module.exports = {
STATE_COMMAND_ALIASES,
VERIFY_COMMAND_ALIASES,
INIT_COMMAND_ALIASES,
PHASE_COMMAND_ALIASES,
PHASES_COMMAND_ALIASES,
VALIDATE_COMMAND_ALIASES,
ROADMAP_COMMAND_ALIASES,
NON_FAMILY_COMMAND_ALIASES,
STATE_SUBCOMMANDS,
VERIFY_SUBCOMMANDS,
INIT_SUBCOMMANDS,
PHASE_SUBCOMMANDS,
PHASES_SUBCOMMANDS,
VALIDATE_SUBCOMMANDS,
ROADMAP_SUBCOMMANDS,
};
export const STATE_SUBCOMMANDS: string[] = STATE_COMMAND_ALIASES.map((entry) => entry.subcommand);
export const VERIFY_SUBCOMMANDS: string[] = VERIFY_COMMAND_ALIASES.map((entry) => entry.subcommand);
export const INIT_SUBCOMMANDS: string[] = INIT_COMMAND_ALIASES.map((entry) => entry.subcommand);
export const PHASE_SUBCOMMANDS: string[] = PHASE_COMMAND_ALIASES.map((entry) => entry.subcommand);
export const PHASES_SUBCOMMANDS: string[] = PHASES_COMMAND_ALIASES.map((entry) => entry.subcommand);
export const VALIDATE_SUBCOMMANDS: string[] = VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand);
export const ROADMAP_SUBCOMMANDS: string[] = ROADMAP_COMMAND_ALIASES.map((entry) => entry.subcommand);

View File

@@ -1,7 +1,8 @@
'use strict';
/**
* Command Argument Projection Module
* Command Argument Projection Module (ADR-457 build-at-publish: the
* hand-written bin/lib/command-arg-projection.cjs collapsed to a TypeScript
* source of truth). Behaviour is preserved byte-for-behaviour from the prior
* hand-written .cjs; only types are added.
*
* Shared helpers for command-family adapters to project argv tokens into
* typed named values and multi-word segments.
@@ -11,24 +12,24 @@
* Extract named --flag <value> pairs from an args array.
* Returns an object mapping flag names to their values (null if absent).
* Flags listed in `booleanFlags` are treated as booleans.
*
* @param {string[]} args
* @param {string[]} [valueFlags]
* @param {string[]} [booleanFlags]
* @returns {Record<string, string|boolean|null>}
*/
function parseNamedArgs(args, valueFlags = [], booleanFlags = []) {
export function parseNamedArgs(
args: string[],
valueFlags: string[] = [],
booleanFlags: string[] = [],
): Record<string, string | boolean | null> {
// Index each token's first position once (firstIndex.get(t) ?? -1 === args.indexOf(t),
// firstIndex.has(t) === args.includes(t)) so the flag loops below don't each re-scan
// argv — O(argv + flags) instead of O(flags * argv). Semantics are unchanged. (#312)
const firstIndex = new Map();
const firstIndex = new Map<string, number>();
for (let i = 0; i < args.length; i++) {
if (!firstIndex.has(args[i])) firstIndex.set(args[i], i);
}
const result = {};
const result: Record<string, string | boolean | null> = {};
for (const flag of valueFlags) {
const idx = firstIndex.has(`--${flag}`) ? firstIndex.get(`--${flag}`) : -1;
result[flag] = idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--')
const idx = firstIndex.has(`--${flag}`) ? (firstIndex.get(`--${flag}`) as number) : -1;
result[flag] =
idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--')
? args[idx + 1]
: null;
}
@@ -40,23 +41,14 @@ function parseNamedArgs(args, valueFlags = [], booleanFlags = []) {
/**
* Collect all tokens after --flag until the next --flag or end of args.
*
* @param {string[]} args
* @param {string} flag
* @returns {string|null}
*/
function parseMultiwordArg(args, flag) {
export function parseMultiwordArg(args: string[], flag: string): string | null {
const idx = args.indexOf(`--${flag}`);
if (idx === -1) return null;
const tokens = [];
const tokens: string[] = [];
for (let i = idx + 1; i < args.length; i++) {
if (args[i].startsWith('--')) break;
tokens.push(args[i]);
}
return tokens.length > 0 ? tokens.join(' ') : null;
}
module.exports = {
parseNamedArgs,
parseMultiwordArg,
};

View File

@@ -25,8 +25,19 @@
* - The kind taxonomy is closed. Callers switch on ERROR_KINDS values.
* - Each error variant carries ONLY its own typed payload (#176).
* No cross-variant `message`/`details` escape hatches.
*
* ADR-457 build-at-publish: the hand-written bin/lib/command-routing-hub.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
import { makeDispatchEvent } from './observability/event.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import observabilityLogger = require('./observability/logger.cjs');
const { createNoOpLogger } = observabilityLogger;
// ─── Error kind constants ─────────────────────────────────────────────────────
/**
* Closed error-kind enum. Export as a frozen object so callers can switch on
* ERROR_KINDS.UnknownCommand etc. without relying on bare string literals.
@@ -45,20 +56,50 @@ const ERROR_KINDS = Object.freeze({
HandlerRefusal: 'HandlerRefusal',
/** A handler threw an unexpected exception. */
HandlerFailure: 'HandlerFailure',
});
} as const);
// ─── Observability imports ────────────────────────────────────────────────────
const { makeDispatchEvent } = require('./observability/event.cjs');
const { createNoOpLogger } = require('./observability/logger.cjs');
// ─── Result types ─────────────────────────────────────────────────────────────
interface OkResult {
ok: true;
data: unknown;
}
interface UnknownCommandResult {
ok: false;
kind: 'UnknownCommand';
command: string;
}
interface InvalidArgsResult {
ok: false;
kind: 'InvalidArgs';
arg: string;
reason: string;
}
interface HandlerRefusalResult {
ok: false;
kind: 'HandlerRefusal';
reason: string;
}
interface HandlerFailureResult {
ok: false;
kind: 'HandlerFailure';
message: string;
cause?: Error;
}
type ErrResult = UnknownCommandResult | InvalidArgsResult | HandlerRefusalResult | HandlerFailureResult;
type HubResult = OkResult | ErrResult;
// ─── Internal helpers ─────────────────────────────────────────────────────────
/**
* Safe JSON serialisation that never throws.
* @param {unknown} value
* @returns {string}
*/
function _safeJson(value) {
function _safeJson(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
@@ -72,46 +113,37 @@ function _safeJson(value) {
// Finding 3: all factory returns are Object.freeze'd so callers cannot mutate
// the variant invariant.
/**
* @param {string} command - The unrecognised command string (family or family+subcommand).
* @returns {Readonly<{ ok: false, kind: 'UnknownCommand', command: string }>}
*/
function makeUnknownCommand(command) {
return Object.freeze({ ok: false, kind: ERROR_KINDS.UnknownCommand, command });
function makeUnknownCommand(command: string): Readonly<UnknownCommandResult> {
return Object.freeze({ ok: false as const, kind: ERROR_KINDS.UnknownCommand, command });
}
function makeInvalidArgs(arg: string, reason: string): Readonly<InvalidArgsResult> {
return Object.freeze({ ok: false as const, kind: ERROR_KINDS.InvalidArgs, arg, reason });
}
function makeHandlerRefusal(reason: string): Readonly<HandlerRefusalResult> {
return Object.freeze({ ok: false as const, kind: ERROR_KINDS.HandlerRefusal, reason });
}
/**
* @param {string} arg - The argument token that failed validation.
* @param {string} reason - Human-readable explanation of the failure.
* @returns {Readonly<{ ok: false, kind: 'InvalidArgs', arg: string, reason: string }>}
*/
function makeInvalidArgs(arg, reason) {
return Object.freeze({ ok: false, kind: ERROR_KINDS.InvalidArgs, arg, reason });
}
/**
* @param {string} reason - Human-readable explanation for the refusal.
* @returns {Readonly<{ ok: false, kind: 'HandlerRefusal', reason: string }>}
*/
function makeHandlerRefusal(reason) {
return Object.freeze({ ok: false, kind: ERROR_KINDS.HandlerRefusal, reason });
}
/**
* @param {string} message - Human-readable description of the failure.
* @param {Error} [cause] - The original thrown Error, when available.
* @param message - Human-readable description of the failure.
* @param cause - The original thrown Error, when available.
* Non-Error values (strings, plain objects, etc.) are wrapped in an Error
* with `.thrown` set to the original value. null/undefined → no cause field.
* @returns {{ ok: false, kind: 'HandlerFailure', message: string, cause?: Error }}
*/
function makeHandlerFailure(message, cause) {
const obj = { ok: false, kind: ERROR_KINDS.HandlerFailure, message };
function makeHandlerFailure(message: string, cause?: unknown): HandlerFailureResult {
const obj: {
ok: false;
kind: 'HandlerFailure';
message: string;
cause?: Error;
} = { ok: false as const, kind: ERROR_KINDS.HandlerFailure, message };
if (cause != null) {
if (cause instanceof Error) {
obj.cause = cause;
} else {
// Finding 4: wrap non-Error cause so downstream .cause.stack never silently returns undefined
const wrapper = new Error('non-Error cause: ' + _safeJson(cause));
const wrapper = new Error('non-Error cause: ' + _safeJson(cause)) as Error & { thrown?: unknown };
wrapper.thrown = cause;
obj.cause = wrapper;
}
@@ -125,10 +157,8 @@ function makeHandlerFailure(message, cause) {
* Required payload fields per ok:false kind.
* `required` — fields that MUST be present (non-undefined) for the variant to be valid.
* `allowed` — the complete set of allowed fields (including ok, kind).
*
* @type {Record<string, { required: string[], allowed: Set<string> }>}
*/
const _VARIANT_SCHEMA = {
const _VARIANT_SCHEMA: Record<string, { required: string[]; allowed: Set<string> }> = {
UnknownCommand: {
required: ['command'],
allowed: new Set(['ok', 'kind', 'command']),
@@ -151,17 +181,14 @@ const _VARIANT_SCHEMA = {
* Validates a handler-returned { ok: false, ... } result against the typed schema.
*
* Returns null if valid, or a string describing the contract violation.
*
* @param {object} result
* @returns {string|null}
*/
function _validateErrResult(result) {
function _validateErrResult(result: Record<string, unknown>): string | null {
const { kind } = result;
const schema = _VARIANT_SCHEMA[kind];
const schema = _VARIANT_SCHEMA[kind as string];
// Unknown kind — not in the closed enum
if (!schema) {
return `handler returned unknown kind '${kind}': expected one of ${Object.keys(_VARIANT_SCHEMA).join(', ')}`;
return `handler returned unknown kind '${String(kind)}': expected one of ${Object.keys(_VARIANT_SCHEMA).join(', ')}`;
}
// Missing required fields
@@ -169,7 +196,7 @@ function _validateErrResult(result) {
if (result[field] === undefined) {
return (
`handler returned malformed Result variant: ` +
`kind '${kind}' requires field '${field}' but it is missing. ` +
`kind '${String(kind)}' requires field '${field}' but it is missing. ` +
`got: ${_safeJson(result)}`
);
}
@@ -180,7 +207,7 @@ function _validateErrResult(result) {
if (!schema.allowed.has(key)) {
return (
`handler returned malformed Result variant: ` +
`kind '${kind}' does not allow field '${key}'. ` +
`kind '${String(kind)}' does not allow field '${key}'. ` +
`expected fields: ${[...schema.allowed].join(', ')}. ` +
`got: ${_safeJson(result)}`
);
@@ -190,34 +217,29 @@ function _validateErrResult(result) {
return null; // valid
}
/**
* @typedef {{ ok: true, data: unknown }} OkResult
* @typedef {{ ok: false, kind: 'UnknownCommand', command: string }} UnknownCommandResult
* @typedef {{ ok: false, kind: 'InvalidArgs', arg: string, reason: string }} InvalidArgsResult
* @typedef {{ ok: false, kind: 'HandlerRefusal', reason: string }} HandlerRefusalResult
* @typedef {{ ok: false, kind: 'HandlerFailure', message: string, cause?: Error }} HandlerFailureResult
* @typedef {UnknownCommandResult | InvalidArgsResult | HandlerRefusalResult | HandlerFailureResult} ErrResult
* @typedef {OkResult | ErrResult} HubResult
*/
// ─── Hub options ──────────────────────────────────────────────────────────────
/**
* @typedef {object} HubOptions
* @property {Record<string, Record<string, (ctx: object) => HubResult>>} [cjsRegistry] -
* Nested map of family -> subcommand -> handler.
* @property {Record<string, string[]>} [manifest] - Map of family -> known subcommands.
* Used for UnknownCommand detection.
* @property {{ onEvent(event: object): void }} [logger] -
* DispatchLogger to receive a DispatchEvent after every dispatch.
* Defaults to a no-op logger (silent). Use createDefaultLogger() for the
* reference implementation (stderr on error, opt-in file audit).
*/
type Handler = (ctx: Record<string, unknown>) => HubResult;
interface HubOptions {
cjsRegistry?: Record<string, Record<string, Handler>>;
manifest?: Record<string, string[]>;
logger?: { onEvent(event: object): void };
}
interface DispatchRequest {
family: string;
subcommand?: string;
args?: unknown[];
cwd?: string;
raw?: boolean;
parentTraceId?: unknown;
}
/**
* Safe stringify for logger-failure warnings — avoids circular-ref crashes.
* @param {unknown} value
* @returns {string}
*/
function _safeJsonForWarn(value) {
function _safeJsonForWarn(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
@@ -227,11 +249,8 @@ function _safeJsonForWarn(value) {
/**
* Construct a CommandRoutingHub.
*
* @param {HubOptions} options
* @returns {{ dispatch: (req: object) => HubResult }}
*/
function createHub({ cjsRegistry, manifest, logger } = {}) {
function createHub({ cjsRegistry, manifest, logger }: HubOptions = {}): { dispatch: (req: DispatchRequest) => HubResult } {
const _cjsRegistry = cjsRegistry;
const _manifest = manifest;
// Default to no-op so callers that don't inject a logger get pure-silent behaviour.
@@ -245,28 +264,21 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
*
* HubResult ok path: { ok: true, data } → { kind: 'ok', data }
* HubResult err paths: { ok: false, kind, ...payload } → { kind, ...payload }
*
* @param {object} hubResult
* @returns {object}
*/
function _normaliseResult(hubResult) {
function _normaliseResult(hubResult: HubResult): Record<string, unknown> {
if (hubResult.ok) {
return { kind: 'ok', data: hubResult.data };
}
// err variant: already has kind + typed payload
return hubResult;
// Double-cast through unknown to satisfy strict index-signature check.
return hubResult as unknown as Record<string, unknown>; // eslint-disable-line @typescript-eslint/no-unsafe-return
}
/**
* Emit a DispatchEvent to the injected logger.
* Logger errors NEVER propagate — they are caught and emitted as a warn line to stderr.
*
* @param {string} command - The dispatched command string.
* @param {unknown} args - The raw args from the request.
* @param {object} hubResult - The HubResult.
* @param {string} [parentTraceId] - Optional parent trace ID from the request (P1.4).
*/
function _notifyLogger(command, args, hubResult, parentTraceId) {
function _notifyLogger(command: string, args: unknown, hubResult: HubResult, parentTraceId?: unknown): void {
try {
const eventResult = _normaliseResult(hubResult);
const event = makeDispatchEvent({ command, args, result: eventResult, parentTraceId });
@@ -278,7 +290,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
_safeJsonForWarn({
level: 'warn',
source: 'DispatchLogger',
message: 'logger.onEvent failed: ' + String(logErr && logErr.message || logErr),
message: 'logger.onEvent failed: ' + String((logErr as Error)?.message || logErr),
}) + '\n'
);
} catch {
@@ -289,15 +301,12 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
/**
* Dispatch a command through the hub.
*
* @param {{ family: string, subcommand: string, args?: unknown[], cwd?: string, raw?: boolean }} req
* @returns {HubResult}
*/
function dispatch(req) {
const { family, subcommand, args = [], parentTraceId } = req || {};
function dispatch(req: DispatchRequest): HubResult {
const { family, subcommand, args = [], parentTraceId } = req || {} as DispatchRequest;
const command = subcommand ? `${family} ${subcommand}` : String(family);
let result;
let result: HubResult;
try {
result = _dispatch(req);
} catch (err) {
@@ -305,7 +314,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
result = makeHandlerFailure(err.message, err);
} else {
// Finding 2: preserve non-Error throwables via a wrapper Error with .thrown
const wrapper = new Error('non-Error thrown: ' + _safeJson(err));
const wrapper = new Error('non-Error thrown: ' + _safeJson(err)) as Error & { thrown?: unknown };
wrapper.thrown = err;
result = makeHandlerFailure(String(err), wrapper);
}
@@ -315,7 +324,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
return result;
}
function _dispatch(req) {
function _dispatch(req: DispatchRequest): HubResult {
const { family, subcommand, args = [], cwd, raw } = req;
// ── manifest check ────────────────────────────────────────────────────────
@@ -332,7 +341,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
return _dispatchCjs({ family, subcommand, args, cwd, raw });
}
function _dispatchCjs({ family, subcommand, args, cwd, raw }) {
function _dispatchCjs({ family, subcommand, args, cwd, raw }: DispatchRequest): HubResult {
if (!_cjsRegistry) {
return makeUnknownCommand(String(family));
}
@@ -355,11 +364,12 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
if (result && typeof result === 'object' && 'ok' in result) {
if (!result.ok) {
// Finding 1: runtime-validate ok:false variant shape; coerce malformed to HandlerFailure
const violation = _validateErrResult(result);
const violation = _validateErrResult(result as unknown as Record<string, unknown>);
if (violation !== null) {
return makeHandlerFailure(
'handler returned malformed Result variant: ' + violation,
new Error('expected ' + (result.kind || '<no kind>') + ', got ' + _safeJson(result))
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-plus-operands
new Error('expected ' + ((result as unknown as Record<string, unknown>)['kind'] ?? '<no kind>') + ', got ' + _safeJson(result))
);
}
}
@@ -378,7 +388,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
return { dispatch };
}
module.exports = {
export = {
createHub,
ERROR_KINDS,
makeUnknownCommand,

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,3 @@
'use strict';
/**
* Thin adapter — sources schema data from the manifest via the generated
* Configuration Module. All inline literals have been removed; the manifest
@@ -11,21 +9,25 @@
* - many tests (config-schema.property.test.cjs, bug-*, feat-*, etc.)
*
* See Phase 2 Cycle 5 (#3536) — schema manifest migration.
*
* ADR-457 build-at-publish: the hand-written bin/lib/config-schema.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
const {
import {
VALID_CONFIG_KEYS,
RUNTIME_STATE_KEYS,
DYNAMIC_KEY_PATTERNS,
} = require('./configuration.cjs');
} from './configuration.cjs';
/**
* Returns true if keyPath is a valid config key (exact, dynamic pattern, or runtime state).
*/
function isValidConfigKey(keyPath) {
function isValidConfigKey(keyPath: string): boolean {
if (VALID_CONFIG_KEYS.has(keyPath)) return true;
if (RUNTIME_STATE_KEYS.has(keyPath)) return true;
return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));
}
module.exports = { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey };
export = { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey };

View File

@@ -1,22 +1,48 @@
/**
* Config — Planning config CRUD operations
*
* ADR-457 build-at-publish: the hand-written bin/lib/config.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
const fs = require('fs');
const path = require('path');
const { output, error, ERROR_REASON, CONFIG_DEFAULTS } = require('./core.cjs');
const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
const { planningDir, withPlanningLock } = require('./planning-workspace.cjs');
const {
VALID_PROFILES,
getAgentToModelMapForProfile,
formatAgentToModelMapAsTable,
} = require('./model-profiles.cjs');
const { VALID_CONFIG_KEYS, isValidConfigKey } = require('./config-schema.cjs');
const { isSecretKey, maskSecret } = require('./secrets.cjs');
const { normalizeConfiguredDefaultReviewers } = require('./review-reviewer-selection.cjs');
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { output, error, ERROR_REASON, CONFIG_DEFAULTS } = core;
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir, withPlanningLock } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import modelProfiles = require('./model-profiles.cjs');
const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configSchema = require('./config-schema.cjs');
const { VALID_CONFIG_KEYS, isValidConfigKey } = configSchema;
import { isSecretKey, maskSecret } from './secrets.cjs';
import { normalizeConfiguredDefaultReviewers } from './review-reviewer-selection.cjs';
import { migrateOnDisk } from './configuration.cjs';
const CONFIG_KEY_SUGGESTIONS = {
// ─── Types ────────────────────────────────────────────────────────────────────
interface SetConfigValueResult {
updated: boolean;
key: string;
value: unknown;
previousValue: unknown;
}
interface WorkstreamContext {
configPath?: string;
[key: string]: unknown;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const CONFIG_KEY_SUGGESTIONS: Record<string, string> = {
'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation',
'agents.nyquist_validation_enabled': 'workflow.nyquist_validation',
'nyquist.validation_enabled': 'workflow.nyquist_validation',
@@ -42,62 +68,78 @@ const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([
]);
const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/;
function validateKnownConfigKeyPath(keyPath) {
/**
* Schema-level defaults for well-known config keys.
* When a key is absent from config.json and no --default flag was supplied,
* cmdConfigGet checks here before emitting "Key not found".
*/
const SCHEMA_DEFAULTS: Record<string, unknown> = {
'context_window': 200000,
'executor.stall_detect_interval_minutes': 5,
'executor.stall_threshold_minutes': 10,
'git.create_tag': true,
};
// ─── Validation helpers ───────────────────────────────────────────────────────
function validateKnownConfigKeyPath(keyPath: string): void {
const suggested = CONFIG_KEY_SUGGESTIONS[keyPath];
if (suggested) {
error(`Unknown config key: ${keyPath}. Did you mean ${suggested}?`, ERROR_REASON.CONFIG_INVALID_KEY);
}
}
function validateShipPrBodySections(value) {
function validateShipPrBodySections(value: unknown): void {
if (!Array.isArray(value)) {
error('Invalid ship.pr_body_sections value. Expected a JSON array of section objects.');
}
value.forEach((section, index) => {
(value as unknown[]).forEach((section: unknown, index: number) => {
const prefix = `Invalid ship.pr_body_sections[${index}]`;
if (!section || typeof section !== 'object' || Array.isArray(section)) {
error(`${prefix}. Expected an object.`);
}
const unknownKeys = Object.keys(section).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key));
const sectionObj = section as Record<string, unknown>;
const unknownKeys = Object.keys(sectionObj).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key));
if (unknownKeys.length > 0) {
error(`${prefix}. Unknown field(s): ${unknownKeys.join(', ')}.`);
}
if (typeof section.heading !== 'string' || section.heading.trim() === '') {
if (typeof sectionObj['heading'] !== 'string' || sectionObj['heading'].trim() === '') {
error(`${prefix}. heading must be a non-empty string.`);
}
if (/[\r\n]/.test(section.heading)) {
if (/[\r\n]/.test(sectionObj['heading'] as string)) {
error(`${prefix}. heading must be a single line.`);
}
if ('enabled' in section && typeof section.enabled !== 'boolean') {
if ('enabled' in sectionObj && typeof sectionObj['enabled'] !== 'boolean') {
error(`${prefix}. enabled must be true or false.`);
}
for (const field of ['source', 'fallback', 'template']) {
if (field in section && typeof section[field] !== 'string') {
if (field in sectionObj && typeof sectionObj[field] !== 'string') {
error(`${prefix}. ${field} must be a string.`);
}
}
const hasContent = ['source', 'fallback', 'template'].some((field) => {
return typeof section[field] === 'string' && section[field].trim() !== '';
const v = sectionObj[field];
return typeof v === 'string' && v.trim() !== '';
});
if (!hasContent) {
error(`${prefix}. Provide at least one of source, fallback, or template.`);
}
if (typeof section.source === 'string' && section.source.trim() !== '') {
const selectors = section.source.split('||').map((selector) => selector.trim()).filter(Boolean);
if (typeof sectionObj['source'] === 'string' && sectionObj['source'].trim() !== '') {
const selectors = sectionObj['source'].split('||').map((selector) => selector.trim()).filter(Boolean);
if (selectors.length === 0 || selectors.some((selector) => !SHIP_PR_BODY_SOURCE_RE.test(selector))) {
error(`${prefix}. source must use selectors like "PLAN.md ## Risks", separated with "||".`);
}
}
if (typeof section.template === 'string') {
const tokens = section.template.matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g);
if (typeof sectionObj['template'] === 'string') {
const tokens = sectionObj['template'].matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g);
for (const match of tokens) {
if (!SHIP_PR_BODY_TEMPLATE_TOKENS.has(match[1])) {
error(`${prefix}. Unsupported template token: {${match[1]}}.`);
@@ -107,6 +149,8 @@ function validateShipPrBodySections(value) {
});
}
// ─── Core config operations ───────────────────────────────────────────────────
/**
* Build a fully-materialized config object for a new project.
*
@@ -121,29 +165,29 @@ function validateShipPrBodySections(value) {
*
* Returns a plain object — does NOT write any files.
*/
function buildNewProjectConfig(userChoices) {
function buildNewProjectConfig(userChoices: Record<string, unknown>): Record<string, unknown> {
const choices = userChoices || {};
const homedir = require('os').homedir();
const homedir = os.homedir();
// Detect API key availability
const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key');
const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile));
const hasBraveSearch = !!(process.env['BRAVE_API_KEY'] || fs.existsSync(braveKeyFile));
const firecrawlKeyFile = path.join(homedir, '.gsd', 'firecrawl_api_key');
const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || fs.existsSync(firecrawlKeyFile));
const hasFirecrawl = !!(process.env['FIRECRAWL_API_KEY'] || fs.existsSync(firecrawlKeyFile));
const exaKeyFile = path.join(homedir, '.gsd', 'exa_api_key');
const hasExaSearch = !!(process.env.EXA_API_KEY || fs.existsSync(exaKeyFile));
const hasExaSearch = !!(process.env['EXA_API_KEY'] || fs.existsSync(exaKeyFile));
// Load user-level defaults from ~/.gsd/defaults.json if available
const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json');
let userDefaults = {};
let userDefaults: Record<string, unknown> = {};
try {
if (fs.existsSync(globalDefaultsPath)) {
userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8'));
userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8')) as Record<string, unknown>;
// Migrate deprecated "depth" key to "granularity"
if ('depth' in userDefaults && !('granularity' in userDefaults)) {
const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
userDefaults.granularity = depthToGranularity[userDefaults.depth] || userDefaults.depth;
delete userDefaults.depth;
const depthToGranularity: Record<string, string> = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
userDefaults['granularity'] = depthToGranularity[userDefaults['depth'] as string] || userDefaults['depth'];
delete userDefaults['depth'];
try {
platformWriteSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2));
} catch { /* intentionally empty */ }
@@ -153,7 +197,7 @@ function buildNewProjectConfig(userChoices) {
// Ignore malformed global defaults
}
const hardcoded = {
const hardcoded: Record<string, unknown> = {
model_profile: CONFIG_DEFAULTS.model_profile,
commit_docs: CONFIG_DEFAULTS.commit_docs,
parallelization: CONFIG_DEFAULTS.parallelization,
@@ -214,44 +258,48 @@ function buildNewProjectConfig(userChoices) {
},
};
const ud = userDefaults as Record<string, Record<string, unknown>>;
const ch = choices as Record<string, Record<string, unknown>>;
const hd = hardcoded as Record<string, Record<string, unknown>>;
// Three-level deep merge: hardcoded <- userDefaults <- choices
const config = {
const config: Record<string, unknown> = {
...hardcoded,
...userDefaults,
...choices,
git: {
...hardcoded.git,
...(userDefaults.git || {}),
...(choices.git || {}),
...hd['git'],
...(ud['git'] || {}),
...(ch['git'] || {}),
},
workflow: {
...hardcoded.workflow,
...(userDefaults.workflow || {}),
...(choices.workflow || {}),
...hd['workflow'],
...(ud['workflow'] || {}),
...(ch['workflow'] || {}),
},
ship: {
...hardcoded.ship,
...(userDefaults.ship || {}),
...(choices.ship || {}),
...hd['ship'],
...(ud['ship'] || {}),
...(ch['ship'] || {}),
},
hooks: {
...hardcoded.hooks,
...(userDefaults.hooks || {}),
...(choices.hooks || {}),
...hd['hooks'],
...(ud['hooks'] || {}),
...(ch['hooks'] || {}),
},
agent_skills: {
...hardcoded.agent_skills,
...(userDefaults.agent_skills || {}),
...(choices.agent_skills || {}),
...hd['agent_skills'],
...(ud['agent_skills'] || {}),
...(ch['agent_skills'] || {}),
},
plan_review: {
...hardcoded.plan_review,
...(userDefaults.plan_review || {}),
...(choices.plan_review || {}),
...hd['plan_review'],
...(ud['plan_review'] || {}),
...(ch['plan_review'] || {}),
},
};
validateShipPrBodySections(config.ship.pr_body_sections);
validateShipPrBodySections((config['ship'] as Record<string, unknown>)['pr_body_sections']);
return config;
}
@@ -264,7 +312,7 @@ function buildNewProjectConfig(userChoices) {
*
* Idempotent: if config.json already exists, returns { created: false }.
*/
function cmdConfigNewProject(cwd, choicesJson, raw) {
function cmdConfigNewProject(cwd: string, choicesJson: string | undefined, raw: boolean): void {
const planningBase = planningDir(cwd);
const configPath = path.join(planningBase, 'config.json');
@@ -275,12 +323,12 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
}
// Parse user choices
let userChoices = {};
let userChoices: Record<string, unknown> = {};
if (choicesJson && choicesJson.trim() !== '') {
try {
userChoices = JSON.parse(choicesJson);
userChoices = JSON.parse(choicesJson) as Record<string, unknown>;
} catch (err) {
error('Invalid JSON for config-new-project: ' + err.message);
error('Invalid JSON for config-new-project: ' + (err as Error).message);
}
}
@@ -288,7 +336,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
try {
platformEnsureDir(planningBase);
} catch (err) {
error('Failed to create .planning directory: ' + err.message);
error('Failed to create .planning directory: ' + (err as Error).message);
}
const config = buildNewProjectConfig(userChoices);
@@ -297,7 +345,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
platformWriteSync(configPath, JSON.stringify(config, null, 2));
output({ created: true, path: '.planning/config.json' }, raw, 'created');
} catch (err) {
error('Failed to write config.json: ' + err.message);
error('Failed to write config.json: ' + (err as Error).message);
}
}
@@ -307,7 +355,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
* Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
* the happy path. But note that `error()` will still `exit(1)` out of the process.
*/
function ensureConfigFile(cwd) {
function ensureConfigFile(cwd: string): { created: boolean; reason?: string; path?: string } | undefined {
const planningBase = planningDir(cwd);
const configPath = path.join(planningBase, 'config.json');
@@ -315,7 +363,7 @@ function ensureConfigFile(cwd) {
try {
platformEnsureDir(planningBase);
} catch (err) {
error('Failed to create .planning directory: ' + err.message);
error('Failed to create .planning directory: ' + (err as Error).message);
}
// Check if config already exists
@@ -329,7 +377,7 @@ function ensureConfigFile(cwd) {
platformWriteSync(configPath, JSON.stringify(config, null, 2));
return { created: true, path: '.planning/config.json' };
} catch (err) {
error('Failed to create config.json: ' + err.message);
error('Failed to create config.json: ' + (err as Error).message);
}
}
@@ -339,9 +387,9 @@ function ensureConfigFile(cwd) {
* Note that this exits the process (via `output()`) even in the happy path; use
* `ensureConfigFile()` directly if you need to avoid this.
*/
function cmdConfigEnsureSection(cwd, raw) {
function cmdConfigEnsureSection(cwd: string, raw: boolean): void {
const ensureConfigFileResult = ensureConfigFile(cwd);
if (ensureConfigFileResult.created) {
if (ensureConfigFileResult && ensureConfigFileResult.created) {
output(ensureConfigFileResult, raw, 'created');
} else {
output(ensureConfigFileResult, raw, 'exists');
@@ -355,29 +403,29 @@ function cmdConfigEnsureSection(cwd, raw) {
* Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
* the happy path. But note that `error()` will still `exit(1)` out of the process.
*/
function setConfigValue(cwd, keyPath, parsedValue) {
function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): SetConfigValueResult {
const configPath = path.join(planningDir(cwd), 'config.json');
return withPlanningLock(cwd, () => {
// Load existing config or start with empty object
let config = {};
let config: Record<string, unknown> = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
}
} catch (err) {
error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
// Set nested value using dot notation (e.g., "workflow.research")
const keys = keyPath.split('.');
let current = config;
let current: Record<string, unknown> = config;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (current[key] === undefined || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key];
current = current[key] as Record<string, unknown>;
}
const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting
current[keys[keys.length - 1]] = parsedValue;
@@ -387,9 +435,9 @@ function setConfigValue(cwd, keyPath, parsedValue) {
platformWriteSync(configPath, JSON.stringify(config, null, 2));
return { updated: true, key: keyPath, value: parsedValue, previousValue };
} catch (err) {
error('Failed to write config.json: ' + err.message);
error('Failed to write config.json: ' + (err as Error).message);
}
});
}) as SetConfigValueResult;
}
/**
@@ -399,7 +447,7 @@ function setConfigValue(cwd, keyPath, parsedValue) {
* Note that this exits the process (via `output()`) even in the happy path; use `setConfigValue()`
* directly if you need to avoid this.
*/
function cmdConfigSet(cwd, keyPath, value, raw) {
function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | undefined, raw: boolean): void {
if (!keyPath) {
error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
}
@@ -414,91 +462,96 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
}
validateKnownConfigKeyPath(keyPath);
// After the two error() guards above, keyPath and value are narrowed to string.
// TypeScript doesn't always infer never-return narrowing through error(), so we assert.
const kp = keyPath!;
const val = value!;
if (!isValidConfigKey(keyPath)) {
error(`Unknown config key: "${keyPath}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
validateKnownConfigKeyPath(kp);
if (!isValidConfigKey(kp)) {
error(`Unknown config key: "${kp}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
}
// Parse value (handle booleans, numbers, and JSON arrays/objects)
let parsedValue = value;
if (value === 'true') parsedValue = true;
else if (value === 'false') parsedValue = false;
else if (!isNaN(value) && value !== '') parsedValue = Number(value);
else if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) {
try { parsedValue = JSON.parse(value); } catch { /* keep as string */ }
let parsedValue: unknown = val;
if (val === 'true') parsedValue = true;
else if (val === 'false') parsedValue = false;
else if (!isNaN(Number(val)) && val !== '') parsedValue = Number(val);
else if (typeof val === 'string' && (val.startsWith('[') || val.startsWith('{'))) {
try { parsedValue = JSON.parse(val); } catch { /* keep as string */ }
}
const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
if (keyPath === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) {
error(`Invalid context value '${value}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`);
if (kp === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) {
error(`Invalid context value '${val}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`);
}
// Codebase drift detector (#2003)
const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
if (keyPath === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
error(`Invalid workflow.drift_action '${value}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`);
if (kp === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
error(`Invalid workflow.drift_action '${val}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`);
}
if (keyPath === 'workflow.drift_threshold') {
if (kp === 'workflow.drift_threshold') {
if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) {
error(`Invalid workflow.drift_threshold '${value}'. Must be a positive integer.`);
error(`Invalid workflow.drift_threshold '${val}'. Must be a positive integer.`);
}
}
// Post-planning gap checker (#2493)
if (keyPath === 'workflow.post_planning_gaps') {
if (kp === 'workflow.post_planning_gaps') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid workflow.post_planning_gaps '${value}'. Must be a boolean (true or false).`);
error(`Invalid workflow.post_planning_gaps '${val}'. Must be a boolean (true or false).`);
}
}
// #3086 — git.create_tag: boolean only
if (keyPath === 'git.create_tag') {
if (kp === 'git.create_tag') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid git.create_tag '${value}'. Must be a boolean (true or false).`);
error(`Invalid git.create_tag '${val}'. Must be a boolean (true or false).`);
}
}
if (keyPath === 'ship.pr_body_sections') {
if (kp === 'ship.pr_body_sections') {
validateShipPrBodySections(parsedValue);
}
// Human verification checkpoint mode (#3309)
const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase'];
if (keyPath === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
error(`Invalid workflow.human_verify_mode '${value}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
if (kp === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
}
// Context position enum validation (#2937)
const VALID_CONTEXT_POSITIONS = ['front', 'end'];
if (keyPath === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
error(`Invalid statusline.context_position '${value}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`);
if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
error(`Invalid statusline.context_position '${val}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`);
}
// Fallow scope + profile enum validation (#3424)
const VALID_FALLOW_SCOPES = ['phase', 'repo'];
if (keyPath === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) {
error(`Invalid code_quality.fallow.scope '${value}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`);
if (kp === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) {
error(`Invalid code_quality.fallow.scope '${val}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`);
}
const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict'];
if (keyPath === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) {
error(`Invalid code_quality.fallow.profile '${value}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`);
if (kp === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) {
error(`Invalid code_quality.fallow.profile '${val}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`);
}
// plan_review.source_grounding (#22) — boolean only
if (keyPath === 'plan_review.source_grounding') {
if (kp === 'plan_review.source_grounding') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid plan_review.source_grounding '${value}'. Must be a boolean (true or false).`);
error(`Invalid plan_review.source_grounding '${val}'. Must be a boolean (true or false).`);
}
}
// plan_review.source_grounding_authority (#22) — enum
const VALID_SOURCE_GROUNDING_AUTHORITIES = ['grep', 'intel', 'treesitter', 'lsp', 'scip'];
if (keyPath === 'plan_review.source_grounding_authority' && !VALID_SOURCE_GROUNDING_AUTHORITIES.includes(String(parsedValue))) {
error(`Invalid plan_review.source_grounding_authority '${value}'. Valid values: ${VALID_SOURCE_GROUNDING_AUTHORITIES.join(', ')}`);
if (kp === 'plan_review.source_grounding_authority' && !VALID_SOURCE_GROUNDING_AUTHORITIES.includes(String(parsedValue))) {
error(`Invalid plan_review.source_grounding_authority '${val}'. Valid values: ${VALID_SOURCE_GROUNDING_AUTHORITIES.join(', ')}`);
}
if (keyPath === 'review.default_reviewers') {
if (kp === 'review.default_reviewers') {
const normalized = normalizeConfiguredDefaultReviewers(parsedValue);
if (normalized.errors.length > 0) {
error(normalized.errors[0]);
@@ -506,42 +559,31 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
parsedValue = normalized.values;
}
const setConfigValueResult = setConfigValue(cwd, keyPath, parsedValue);
const setConfigValueResult = setConfigValue(cwd, kp, parsedValue);
// Mask secrets in both JSON and text output. The plaintext is written
// to config.json (that's where secrets live on disk); the CLI output
// must never echo it. See lib/secrets.cjs.
if (isSecretKey(keyPath)) {
const masked = maskSecret(parsedValue);
if (isSecretKey(kp)) {
// parsedValue is unknown at this point; maskSecret accepts MaskableValue
const masked = maskSecret(parsedValue as Parameters<typeof maskSecret>[0]);
const maskedPrev = setConfigValueResult.previousValue === undefined
? undefined
: maskSecret(setConfigValueResult.previousValue);
: maskSecret(setConfigValueResult.previousValue as Parameters<typeof maskSecret>[0]);
const maskedResult = {
...setConfigValueResult,
value: masked,
previousValue: maskedPrev,
masked: true,
};
output(maskedResult, raw, `${keyPath}=${masked}`);
output(maskedResult, raw, `${kp}=${masked}`);
return;
}
output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`);
output(setConfigValueResult, raw, `${kp}=${String(parsedValue)}`);
}
/**
* Schema-level defaults for well-known config keys.
* When a key is absent from config.json and no --default flag was supplied,
* cmdConfigGet checks here before emitting "Key not found".
*/
const SCHEMA_DEFAULTS = {
'context_window': 200000,
'executor.stall_detect_interval_minutes': 5,
'executor.stall_threshold_minutes': 10,
'git.create_tag': true,
};
function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, defaultValue: unknown): void {
const configPath = path.join(planningDir(cwd), 'config.json');
const hasDefault = defaultValue !== undefined;
@@ -549,55 +591,61 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
error('Usage: config-get <key.path> [--default <value>]');
}
let config = {};
// After the error() guard, keyPath is narrowed to string.
const kp = keyPath!;
let config: Record<string, unknown> = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
} else if (hasDefault) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
output(defaultValue, raw, String(defaultValue));
return;
} else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
const def = SCHEMA_DEFAULTS[keyPath];
} else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def));
return;
} else {
error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
}
} catch (err) {
if (err.message.startsWith('No config.json')) throw err;
error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
if ((err as Error).message.startsWith('No config.json')) throw err;
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
// Traverse dot-notation path (e.g., "workflow.auto_advance")
const keys = keyPath.split('.');
let current = config;
const keys = kp.split('.');
let current: unknown = config;
for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
const def = SCHEMA_DEFAULTS[keyPath];
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def));
return;
}
error(`Key not found: ${keyPath}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
}
current = current[key];
current = (current as Record<string, unknown>)[key];
}
if (current === undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
const def = SCHEMA_DEFAULTS[keyPath];
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def));
return;
}
error(`Key not found: ${keyPath}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
}
// Never echo plaintext for sensitive keys via config-get. Plaintext lives
// in config.json on disk; the CLI surface always shows the masked form.
if (isSecretKey(keyPath)) {
const masked = maskSecret(current);
if (isSecretKey(kp)) {
const masked = maskSecret(current as Parameters<typeof maskSecret>[0]);
output(masked, raw, masked);
return;
}
@@ -610,22 +658,23 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
*
* Note that this exits the process (via `output()`) even in the happy path.
*/
function cmdConfigSetModelProfile(cwd, profile, raw) {
function cmdConfigSetModelProfile(cwd: string, profile: string | undefined, raw: boolean): void {
if (!profile) {
error(`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`);
}
const normalizedProfile = profile.toLowerCase().trim();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const normalizedProfile = profile!.toLowerCase().trim();
if (!VALID_PROFILES.includes(normalizedProfile)) {
error(`Invalid profile '${profile}'. Valid profiles: ${VALID_PROFILES.join(', ')}`);
error(`Invalid profile '${String(profile)}'. Valid profiles: ${VALID_PROFILES.join(', ')}`);
}
// Ensure config exists (create if needed)
ensureConfigFile(cwd);
// Set the model profile in the config
const { previousValue } = setConfigValue(cwd, 'model_profile', normalizedProfile, raw);
const previousProfile = previousValue || 'balanced';
const { previousValue } = setConfigValue(cwd, 'model_profile', normalizedProfile);
const previousProfile = typeof previousValue === 'string' ? previousValue : 'balanced';
// Build result value / message and return
const agentToModelMap = getAgentToModelMapForProfile(normalizedProfile);
@@ -648,10 +697,10 @@ function cmdConfigSetModelProfile(cwd, profile, raw) {
* displaying raw output.
*/
function getCmdConfigSetModelProfileResultMessage(
normalizedProfile,
previousProfile,
agentToModelMap
) {
normalizedProfile: string,
previousProfile: string,
agentToModelMap: Record<string, string>
): string {
const agentToModelTable = formatAgentToModelMapAsTable(agentToModelMap);
const didChange = previousProfile !== normalizedProfile;
const paragraphs = didChange
@@ -673,7 +722,7 @@ function getCmdConfigSetModelProfileResultMessage(
* Print the resolved config.json path (workstream-aware). Used by settings.md
* so the workflow writes/reads the correct file when a workstream is active (#2282).
*/
function cmdConfigPath(cwd, _raw, workstreamContext = null) {
function cmdConfigPath(cwd: string, _raw: boolean, workstreamContext: WorkstreamContext | null = null): void {
// Always emit as plain text — a file path is used via shell substitution,
// never consumed as JSON. Passing raw=true forces plain-text output.
const configPath = workstreamContext && workstreamContext.configPath
@@ -693,11 +742,14 @@ function cmdConfigPath(cwd, _raw, workstreamContext = null) {
*
* Output: JSON object with { migrated, normalizations, wrote } or a human-readable
* summary when --raw is set. Exits 0 in all cases (including no-op).
*
* Note: migrateOnDisk() is synchronous; the original CJS used async for
* forward-compatibility but no await is needed. Dropped async per ADR-457 policy
* (caller uses `await` which is safe on a sync return value).
*/
async function cmdMigrateConfig(cwd, raw) {
const { migrateOnDisk } = require('./configuration.cjs');
const ws = process.env.GSD_WORKSTREAM || null;
const report = await migrateOnDisk(cwd, ws || undefined);
function cmdMigrateConfig(cwd: string, raw: boolean): void {
const ws = process.env['GSD_WORKSTREAM'] || null;
const report = migrateOnDisk(cwd, ws || undefined);
if (raw) {
if (!report.migrated) {
@@ -705,8 +757,8 @@ async function cmdMigrateConfig(cwd, raw) {
output(msg, true, msg);
} else {
const lines = [
`Migrated: ${report.wrote}`,
...report.normalizations.map(n => ` ${n.from} → ${n.to}`),
`Migrated: ${String(report.wrote)}`,
...(report.normalizations as Array<{ from: string; to: string }>).map(n => ` ${n.from} → ${n.to}`),
].join('\n');
output(lines, true, lines);
}
@@ -716,7 +768,7 @@ async function cmdMigrateConfig(cwd, raw) {
}
}
module.exports = {
export = {
VALID_CONFIG_KEYS,
cmdConfigEnsureSection,
cmdConfigSet,

288
src/configuration.cts Normal file
View File

@@ -0,0 +1,288 @@
/**
* Configuration Module — single source of truth for config loading,
* legacy-key normalization, defaults merge, and explicit on-disk migration.
*
* ADR-457 build-at-publish: the hand-written bin/lib/configuration.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
// ─── Manifest requires ───────────────────────────────────────────────────────
function loadConfigurationManifest(fileName: string): Record<string, unknown> {
const candidates = [
// Installed runtime layout: get-shit-done/bin/shared/*.manifest.json
join(__dirname, '..', 'shared', fileName),
];
let lastErr: Error | null = null;
for (const candidate of candidates) {
try {
return _require(candidate) as Record<string, unknown>;
} catch (err) {
const e = err as NodeJS.ErrnoException;
const isMissingCandidate =
e && e.code === 'MODULE_NOT_FOUND' && String(e.message || '').includes(candidate);
if (!isMissingCandidate) throw err;
lastErr = e;
}
}
throw new Error(
`${fileName} not found. Tried:\n${candidates.map((p) => ` ${p}`).join('\n')}\nLast error: ${lastErr?.message}`
);
}
const CONFIG_DEFAULTS = loadConfigurationManifest('config-defaults.manifest.json');
const SCHEMA_MANIFEST = loadConfigurationManifest('config-schema.manifest.json') as {
validKeys: string[];
runtimeStateKeys: string[];
dynamicKeyPatterns: Array<{ source: string; [k: string]: unknown }>;
};
const VALID_CONFIG_KEYS = new Set<string>(SCHEMA_MANIFEST.validKeys);
const RUNTIME_STATE_KEYS = new Set<string>(SCHEMA_MANIFEST.runtimeStateKeys);
interface DynamicKeyPattern {
source: string;
test: (key: string) => boolean;
[k: string]: unknown;
}
const DYNAMIC_KEY_PATTERNS: DynamicKeyPattern[] = SCHEMA_MANIFEST.dynamicKeyPatterns.map((p) => {
const pattern = new RegExp(p.source);
return {
...p,
test: (key: string) => {
pattern.lastIndex = 0;
return pattern.test(key);
},
};
});
// ─── Depth → Granularity mapping ─────────────────────────────────────────────
const DEPTH_TO_GRANULARITY: Record<string, string> = {
quick: 'coarse',
standard: 'standard',
comprehensive: 'fine',
};
// ─── Internal helpers ─────────────────────────────────────────────────────────
function planningDir(cwd: string, workstream?: string): string {
if (!workstream)
return join(cwd, '.planning');
return join(cwd, '.planning', 'workstreams', workstream);
}
function detectSubRepos(cwd: string): string[] {
const results: string[] = [];
try {
const entries = readdirSync(cwd, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory())
continue;
if (entry.name.startsWith('.') || entry.name === 'node_modules')
continue;
const gitPath = join(cwd, entry.name, '.git');
try {
if (existsSync(gitPath)) {
results.push(entry.name);
}
}
catch { /* ignore */ }
}
}
catch { /* ignore */ }
return results.sort();
}
function deepMergeConfig(base: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = { ...base };
for (const key of Object.keys(overlay)) {
const ov = overlay[key];
if (ov !== null && ov !== undefined && typeof ov === 'object' && !Array.isArray(ov)) {
const bv = base[key];
if (bv !== null && bv !== undefined && typeof bv === 'object' && !Array.isArray(bv)) {
result[key] = deepMergeConfig(bv as Record<string, unknown>, ov as Record<string, unknown>);
}
else {
result[key] = deepMergeConfig({}, ov as Record<string, unknown>);
}
}
else {
result[key] = ov;
}
}
return result;
}
// ─── Exported types ───────────────────────────────────────────────────────────
interface Normalization {
from: string;
to: string;
value: unknown;
requiresFilesystem?: boolean;
}
interface NormalizeLegacyKeysResult {
parsed: Record<string, unknown>;
normalizations: Normalization[];
}
interface LoadConfigOptions {
workstream?: string;
onNormalizations?: (normalizations: Normalization[]) => void;
}
interface MigrateOnDiskResult {
migrated: boolean;
normalizations: Normalization[];
wrote: string | null;
}
// ─── Exported functions ───────────────────────────────────────────────────────
function normalizeLegacyKeys(parsed: Record<string, unknown>): NormalizeLegacyKeysResult {
const result: Record<string, unknown> = { ...parsed };
const normalizations: Normalization[] = [];
// 1. branching_strategy → git.branching_strategy
if (Object.prototype.hasOwnProperty.call(result, 'branching_strategy')) {
const value = result['branching_strategy'];
const git = (result['git'] ?? {}) as Record<string, unknown>;
if (git['branching_strategy'] === undefined) {
result['git'] = { ...git, branching_strategy: value };
}
else {
// canonical nested wins — just delete the stale top-level
result['git'] = { ...git };
}
delete result['branching_strategy'];
normalizations.push({ from: 'branching_strategy', to: 'git.branching_strategy', value });
}
// 2. top-level sub_repos → planning.sub_repos
if (Object.prototype.hasOwnProperty.call(result, 'sub_repos')) {
const value = result['sub_repos'];
const planning = (result['planning'] ?? {}) as Record<string, unknown>;
if (planning['sub_repos'] === undefined) {
result['planning'] = { ...planning, sub_repos: value };
}
else {
// canonical nested wins — just drop the stale top-level
result['planning'] = { ...planning };
}
delete result['sub_repos'];
normalizations.push({ from: 'sub_repos', to: 'planning.sub_repos', value });
}
// 3. multiRepo: true → marker (filesystem detection deferred to migrateOnDisk / caller)
if (result['multiRepo'] === true) {
delete result['multiRepo'];
normalizations.push({ from: 'multiRepo', to: 'planning.sub_repos', value: true, requiresFilesystem: true });
}
// 4. top-level depth → granularity
if (Object.prototype.hasOwnProperty.call(result, 'depth') && !Object.prototype.hasOwnProperty.call(result, 'granularity')) {
const rawDepth = result['depth'] as string;
const mapped = DEPTH_TO_GRANULARITY[rawDepth] ?? rawDepth;
result['granularity'] = mapped;
delete result['depth'];
normalizations.push({ from: 'depth', to: 'granularity', value: mapped });
}
return { parsed: result, normalizations };
}
function mergeDefaults(parsed: Record<string, unknown>): Record<string, unknown> {
// Start with a deep clone of defaults, then overlay parsed
const defaults = structuredClone(CONFIG_DEFAULTS);
return deepMergeConfig(defaults, parsed);
}
function loadConfig(cwd: string, options?: LoadConfigOptions): Record<string, unknown> {
const configPath = join(planningDir(cwd, options?.workstream), 'config.json');
let raw: string;
try {
raw = readFileSync(configPath, 'utf-8');
}
catch {
// File missing — return defaults
return mergeDefaults({});
}
const trimmed = raw.trim();
if (trimmed === '') {
return mergeDefaults({});
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`Config at ${configPath} must be a JSON object`);
}
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed as Record<string, unknown>);
if (options?.onNormalizations && normalizations.length > 0) {
options.onNormalizations(normalizations);
}
return mergeDefaults(normalized);
}
function migrateOnDisk(cwd: string, workstream?: string): MigrateOnDiskResult {
const configPath = join(planningDir(cwd, workstream), 'config.json');
let raw: string;
try {
raw = readFileSync(configPath, 'utf-8');
}
catch {
// File missing — nothing to migrate
return { migrated: false, normalizations: [], wrote: null };
}
const trimmed = raw.trim();
if (trimmed === '') {
return { migrated: false, normalizations: [], wrote: null };
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
}
catch {
// Malformed — can't migrate
return { migrated: false, normalizations: [], wrote: null };
}
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed as Record<string, unknown>);
if (normalizations.length === 0) {
return { migrated: false, normalizations: [], wrote: null };
}
// Resolve multiRepo filesystem detection
const result: Record<string, unknown> = { ...normalized };
for (const norm of normalizations) {
if (norm.requiresFilesystem) {
const detected = detectSubRepos(cwd);
if (detected.length > 0) {
const planning = (result['planning'] ?? {}) as Record<string, unknown>;
result['planning'] = { ...planning, sub_repos: detected, commit_docs: false };
}
}
}
try {
writeFileSync(configPath, JSON.stringify(result, null, 2));
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to write migrated config at ${configPath}: ${msg}`);
}
return { migrated: true, normalizations, wrote: configPath };
}
export {
loadConfig,
normalizeLegacyKeys,
mergeDefaults,
migrateOnDisk,
CONFIG_DEFAULTS,
VALID_CONFIG_KEYS,
RUNTIME_STATE_KEYS,
DYNAMIC_KEY_PATTERNS,
};

View File

@@ -1,7 +1,8 @@
'use strict';
/**
* Context-utilization classifier for `gsd-health --context`.
* Context-utilization classifier for `gsd-health --context` (ADR-457
* build-at-publish: the hand-written bin/lib/context-utilization.cjs collapsed
* to a TypeScript source of truth). Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*
* Pure function. Callers pass tokensUsed + contextWindow; the
* classifier returns the percent and one of three states. Recommendation
@@ -19,29 +20,42 @@
* edge cases (e.g. 59.999% displays as 60 but classifies as healthy).
*/
const STATES = Object.freeze({
HEALTHY: 'healthy',
WARNING: 'warning',
CRITICAL: 'critical',
});
export type ContextState = 'healthy' | 'warning' | 'critical';
function classifyContextUtilization(tokensUsed, contextWindow) {
export interface ContextUtilizationResult {
percent: number;
state: ContextState;
}
export const STATES: Readonly<{ HEALTHY: 'healthy'; WARNING: 'warning'; CRITICAL: 'critical' }> =
Object.freeze({
HEALTHY: 'healthy' as const,
WARNING: 'warning' as const,
CRITICAL: 'critical' as const,
});
export function classifyContextUtilization(
tokensUsed: number,
contextWindow: number,
): ContextUtilizationResult {
if (!Number.isInteger(tokensUsed) || tokensUsed < 0) {
throw new TypeError(`tokensUsed must be a non-negative integer, got: ${tokensUsed} (${typeof tokensUsed})`);
throw new TypeError(
`tokensUsed must be a non-negative integer, got: ${tokensUsed} (${typeof tokensUsed})`,
);
}
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
throw new TypeError(`contextWindow must be a positive integer, got: ${contextWindow} (${typeof contextWindow})`);
throw new TypeError(
`contextWindow must be a positive integer, got: ${contextWindow} (${typeof contextWindow})`,
);
}
const ratio = Math.min(tokensUsed / contextWindow, 1);
const percent = Math.min(Math.round(ratio * 100), 100);
let state;
let state: ContextState;
if (ratio < 0.60) state = STATES.HEALTHY;
else if (ratio < 0.70) state = STATES.WARNING;
else state = STATES.CRITICAL;
return { percent, state };
}
module.exports = { classifyContextUtilization, STATES };

File diff suppressed because it is too large Load Diff

127
src/decisions.cts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Shared parser for CONTEXT.md <decisions> blocks (ADR-457 build-at-publish:
* the hand-written bin/lib/decisions.cjs collapsed to a TypeScript source of
* truth). Behaviour is preserved byte-for-behaviour from the prior hand-written
* .cjs; only types are added.
*
* 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 interface Decision {
id: string;
text: string;
category: string;
tags: string[];
trackable: boolean;
}
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: string): string {
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: string): string | null {
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)`.
*/
export function parseDecisions(content: unknown): Decision[] {
if (!content || typeof content !== 'string')
return [];
const block = extractDecisionsBlock(content);
if (block === null)
return [];
const lines = block.split(/\r?\n/);
const out: Decision[] = [];
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: Decision | null = null;
const flush = (): void => {
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(/[‘’‚‛“”„‟'"`]/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;
}

View File

@@ -4,12 +4,18 @@
* Provides `cmdDocsInit` which returns project signals, existing doc inventory
* with GSD marker detection, doc tooling detection, monorepo awareness, and
* model resolution. Used by Phase 2 to route doc generation appropriately.
*
* ADR-457 build-at-publish: the hand-written bin/lib/docs.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
const fs = require('fs');
const path = require('path');
const { output, loadConfig, resolveModelInternal, pathExistsInternal, toPosixPath, checkAgentsInstalled } = require('./core.cjs');
const { platformReadSync } = require('./shell-command-projection.cjs');
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { output, loadConfig, resolveModelInternal, pathExistsInternal, toPosixPath, checkAgentsInstalled } = core;
import { platformReadSync } from './shell-command-projection.cjs';
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -26,11 +32,8 @@ const SKIP_DIRS = new Set([
/**
* Check whether a file begins with the GSD doc writer marker.
* Reads the first 500 bytes only — avoids loading large files.
*
* @param {string} filePath - Absolute path to the file
* @returns {boolean}
*/
function hasGsdMarker(filePath) {
function hasGsdMarker(filePath: string): boolean {
try {
const buf = Buffer.alloc(500);
const fd = fs.openSync(filePath, 'r');
@@ -42,23 +45,20 @@ function hasGsdMarker(filePath) {
}
}
interface DocEntry {
path: string;
has_gsd_marker: boolean;
}
/**
* Recursively scan the project root (immediate .md files) and docs/ directory
* (up to 4 levels deep) for Markdown files, excluding dirs in SKIP_DIRS.
*
* @param {string} cwd - Project root
* @returns {Array<{path: string, has_gsd_marker: boolean}>}
*/
function scanExistingDocs(cwd) {
function scanExistingDocs(cwd: string): DocEntry[] {
const MAX_DEPTH = 4;
const results = [];
const results: DocEntry[] = [];
/**
* Recursively walk a directory for .md files up to MAX_DEPTH levels.
* @param {string} dir - Directory to scan
* @param {number} depth - Current depth (1-based)
*/
function walkDir(dir, depth) {
function walkDir(dir: string, depth: number): void {
if (depth > MAX_DEPTH) return;
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -111,38 +111,49 @@ function scanExistingDocs(cwd) {
return results.sort((a, b) => a.path.localeCompare(b.path));
}
interface ProjectTypeSignals {
has_package_json: boolean;
has_api_routes: boolean;
has_cli_bin: boolean;
is_open_source: boolean;
has_deploy_config: boolean;
is_monorepo: boolean;
has_tests: boolean;
}
/**
* Detect project type signals from the filesystem and package.json.
* All checks are best-effort and never throw.
*
* @param {string} cwd - Project root
* @returns {Object} Boolean signal fields
*/
function detectProjectType(cwd) {
const exists = (rel) => {
function detectProjectType(cwd: string): ProjectTypeSignals {
const exists = (rel: string): boolean => {
try { return pathExistsInternal(cwd, rel); } catch { return false; }
};
// Read package.json once — used by has_cli_bin, is_monorepo, has_tests checks.
const pkgRaw = platformReadSync(path.join(cwd, 'package.json'));
let pkg = null;
let pkg: Record<string, unknown> | null = null;
if (pkgRaw) {
try { pkg = JSON.parse(pkgRaw); } catch { /* invalid JSON */ }
try { pkg = JSON.parse(pkgRaw) as Record<string, unknown>; } catch { /* invalid JSON */ }
}
// has_cli_bin: package.json has a `bin` field
const has_cli_bin = !!(pkg && pkg.bin && (typeof pkg.bin === 'string' || Object.keys(pkg.bin).length > 0));
const binField = pkg?.['bin'];
const has_cli_bin = !!(binField && (
typeof binField === 'string' ||
(typeof binField === 'object' && Object.keys(binField).length > 0)
));
// is_monorepo: pnpm-workspace.yaml, lerna.json, or package.json workspaces
let is_monorepo = exists('pnpm-workspace.yaml') || exists('lerna.json');
if (!is_monorepo && pkg) {
is_monorepo = Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0;
is_monorepo = Array.isArray(pkg['workspaces']) && (pkg['workspaces'] as unknown[]).length > 0;
}
// has_tests: common test directories or test frameworks in devDependencies
let has_tests = exists('test') || exists('tests') || exists('__tests__') || exists('spec');
if (!has_tests && pkg) {
const devDeps = Object.keys(pkg.devDependencies || {});
const devDeps = Object.keys((pkg['devDependencies'] as Record<string, unknown> | undefined) || {});
has_tests = devDeps.some(d => ['vitest', 'jest', 'mocha', 'jasmine', 'ava'].includes(d));
}
@@ -168,14 +179,18 @@ function detectProjectType(cwd) {
};
}
interface DocToolingSignals {
docusaurus: boolean;
vitepress: boolean;
mkdocs: boolean;
storybook: boolean;
}
/**
* Detect known documentation tooling in the project.
*
* @param {string} cwd - Project root
* @returns {Object} Boolean detection fields
*/
function detectDocTooling(cwd) {
const exists = (rel) => {
function detectDocTooling(cwd: string): DocToolingSignals {
const exists = (rel: string): boolean => {
try { return pathExistsInternal(cwd, rel); } catch { return false; }
};
@@ -194,15 +209,12 @@ function detectDocTooling(cwd) {
/**
* Extract monorepo workspace globs from pnpm-workspace.yaml, package.json
* workspaces, or lerna.json.
*
* @param {string} cwd - Project root
* @returns {string[]} Array of workspace glob patterns, or [] if not a monorepo
*/
function detectMonorepoWorkspaces(cwd) {
function detectMonorepoWorkspaces(cwd: string): string[] {
// pnpm-workspace.yaml
const pnpmRaw = platformReadSync(path.join(cwd, 'pnpm-workspace.yaml'));
if (pnpmRaw) {
const workspaces = [];
const workspaces: string[] = [];
for (const line of pnpmRaw.split('\n')) {
const m = line.match(/^\s*-\s+['"]?(.+?)['"]?\s*$/);
if (m) workspaces.push(m[1].trim());
@@ -214,9 +226,9 @@ function detectMonorepoWorkspaces(cwd) {
const pkgRaw = platformReadSync(path.join(cwd, 'package.json'));
if (pkgRaw) {
try {
const pkg = JSON.parse(pkgRaw);
if (Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0) {
return pkg.workspaces;
const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;
if (Array.isArray(pkg['workspaces']) && (pkg['workspaces'] as unknown[]).length > 0) {
return pkg['workspaces'] as string[];
}
} catch { /* invalid JSON */ }
}
@@ -225,9 +237,9 @@ function detectMonorepoWorkspaces(cwd) {
const lernaRaw = platformReadSync(path.join(cwd, 'lerna.json'));
if (lernaRaw) {
try {
const lerna = JSON.parse(lernaRaw);
if (Array.isArray(lerna.packages) && lerna.packages.length > 0) {
return lerna.packages;
const lerna = JSON.parse(lernaRaw) as Record<string, unknown>;
if (Array.isArray(lerna['packages']) && (lerna['packages'] as unknown[]).length > 0) {
return lerna['packages'] as string[];
}
} catch { /* invalid JSON */ }
}
@@ -244,13 +256,10 @@ function detectMonorepoWorkspaces(cwd) {
*
* @example
* node gsd-tools.cjs docs-init --raw
*
* @param {string} cwd - Project root directory
* @param {boolean} raw - Pass raw JSON flag through to output()
*/
function cmdDocsInit(cwd, raw) {
function cmdDocsInit(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const result = {
const result: Record<string, unknown> = {
doc_writer_model: resolveModelInternal(cwd, 'gsd-doc-writer'),
commit_docs: config.commit_docs,
existing_docs: scanExistingDocs(cwd),
@@ -260,11 +269,11 @@ function cmdDocsInit(cwd, raw) {
planning_exists: pathExistsInternal(cwd, '.planning'),
};
// Inject project_root and agent installation status (mirrors withProjectRoot in init.cjs)
result.project_root = cwd;
result['project_root'] = cwd;
const agentStatus = checkAgentsInstalled();
result.agents_installed = agentStatus.agents_installed;
result.missing_agents = agentStatus.missing_agents;
output(result, raw);
result['agents_installed'] = agentStatus.agents_installed;
result['missing_agents'] = agentStatus.missing_agents;
output(result, raw, undefined);
}
module.exports = { cmdDocsInit };
export = { cmdDocsInit };

View File

@@ -26,12 +26,17 @@
* - The detector NEVER throws on malformed input — it returns a
* `{ skipped: true }` result. The phase workflow depends on this
* non-blocking guarantee.
*
* ADR-457 build-at-publish: the hand-written bin/lib/drift.cjs collapsed to
* a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
'use strict';
const fs = require('node:fs');
const { platformWriteSync } = require('./shell-command-projection.cjs');
import fs from 'node:fs';
import { platformWriteSync } from './shell-command-projection.cjs';
import { formatGsdSlash } from './runtime-slash.cjs';
// ─── Constants ───────────────────────────────────────────────────────────────
@@ -39,7 +44,7 @@ const DRIFT_CATEGORIES = Object.freeze(['new_dir', 'barrel', 'migration', 'route
// Category priority when a single file matches multiple rules.
// Higher index = more specific = wins.
const CATEGORY_PRIORITY = { new_dir: 0, barrel: 1, route: 2, migration: 3 };
const CATEGORY_PRIORITY: Record<string, number> = { new_dir: 0, barrel: 1, route: 2, migration: 3 };
const BARREL_RE = /^(packages|apps)\/[^/]+\/src\/index\.(ts|tsx|js|mjs|cjs)$/;
@@ -67,13 +72,12 @@ const SAFE_PATH_RE = /^(?!.*\.\.)(?:[A-Za-z0-9_.][A-Za-z0-9_.\-]*)(?:\/[A-Za-z0-
// ─── Classification ──────────────────────────────────────────────────────────
type DriftCategory = 'barrel' | 'migration' | 'route' | 'new_dir';
/**
* Classify a single file path into a drift category or null.
*
* @param {string} file - repo-relative path, forward slashes.
* @returns {'barrel'|'migration'|'route'|null}
*/
function classifyFile(file) {
function classifyFile(file: unknown): DriftCategory | null {
if (typeof file !== 'string' || !file) return null;
const norm = file.replace(/\\/g, '/');
if (MIGRATION_RES.some((r) => r.test(norm))) return 'migration';
@@ -90,7 +94,7 @@ function classifyFile(file) {
* markdown, not a structured manifest. If the map mentions `src/lib/` the
* check `structureMd.includes('src/lib')` holds.
*/
function isPathMapped(file, structureMd) {
function isPathMapped(file: string, structureMd: string): boolean {
const norm = file.replace(/\\/g, '/');
const parts = norm.split('/');
// Check prefixes from longest to shortest; any hit means "mapped".
@@ -104,38 +108,72 @@ function isPathMapped(file, structureMd) {
return false;
}
// ─── Types ───────────────────────────────────────────────────────────────────
interface DriftElement {
category: string;
path: string;
}
interface DetectDriftInput {
addedFiles?: unknown[];
modifiedFiles?: unknown[];
deletedFiles?: unknown[];
structureMd?: string | null;
threshold?: number;
action?: string;
runtime?: string;
}
interface DetectDriftResult {
skipped: false;
elements: DriftElement[];
actionRequired: boolean;
directive: string;
spawnMapper: boolean;
affectedPaths: string[];
threshold: number;
action: string;
message: string;
counts: {
added: number;
modified: number;
deleted: number;
};
}
interface SkippedResult {
skipped: true;
reason: string;
elements: DriftElement[];
actionRequired: false;
directive: string;
spawnMapper: false;
affectedPaths: string[];
message: string;
}
// ─── Main detection ──────────────────────────────────────────────────────────
/**
* Detect codebase drift.
*
* @param {object} input
* @param {string[]} input.addedFiles - files with git status A (new)
* @param {string[]} input.modifiedFiles - files with git status M
* @param {string[]} input.deletedFiles - files with git status D
* @param {string|null|undefined} input.structureMd - contents of STRUCTURE.md
* @param {number} [input.threshold=3] - min number of drift elements that triggers action
* @param {'warn'|'auto-remap'} [input.action='warn']
* @param {string} [input.runtime='claude'] - runtime name (claude, codex, ...) used
* to format the slash-command in the remediation message. Caller resolves and
* passes this in to keep drift.cjs a pure library with no env/config reads.
* @returns {object} result
*/
function detectDrift(input) {
function detectDrift(input: unknown): DetectDriftResult | SkippedResult {
try {
if (!input || typeof input !== 'object') {
return skipped('invalid-input');
}
const inp = input as DetectDriftInput;
const {
addedFiles,
modifiedFiles,
deletedFiles,
structureMd,
} = input;
const threshold = Number.isInteger(input.threshold) && input.threshold >= 1
? input.threshold
} = inp;
const threshold = Number.isInteger(inp.threshold) && (inp.threshold as number) >= 1
? (inp.threshold as number)
: 3;
const action = input.action === 'auto-remap' ? 'auto-remap' : 'warn';
const action = inp.action === 'auto-remap' ? 'auto-remap' : 'warn';
if (structureMd === null || structureMd === undefined) {
return skipped('missing-structure-md');
@@ -144,19 +182,18 @@ function detectDrift(input) {
return skipped('invalid-structure-md');
}
const added = Array.isArray(addedFiles) ? addedFiles.filter((x) => typeof x === 'string') : [];
const added = Array.isArray(addedFiles) ? addedFiles.filter((x): x is string => typeof x === 'string') : [];
const modified = Array.isArray(modifiedFiles) ? modifiedFiles : [];
const deleted = Array.isArray(deletedFiles) ? deletedFiles : [];
// Build elements. One element per file, highest-priority category wins.
/** @type {{category: string, path: string}[]} */
const elements = [];
const seen = new Map();
const elements: DriftElement[] = [];
const seen = new Map<string, string>();
for (const rawFile of added) {
const file = rawFile.replace(/\\/g, '/');
const specific = classifyFile(file);
let category = specific;
let category: string | null = specific;
if (!category) {
if (!isPathMapped(file, structureMd)) {
category = 'new_dir';
@@ -184,7 +221,7 @@ function detectDrift(input) {
const actionRequired = elements.length >= threshold;
let directive = 'none';
let spawnMapper = false;
let affectedPaths = [];
let affectedPaths: string[] = [];
let message = '';
if (actionRequired) {
@@ -193,7 +230,7 @@ function detectDrift(input) {
if (action === 'auto-remap') {
spawnMapper = true;
}
message = buildMessage(elements, affectedPaths, action, input.runtime);
message = buildMessage(elements, affectedPaths, action, inp.runtime);
}
return {
@@ -214,11 +251,12 @@ function detectDrift(input) {
};
} catch (err) {
// Non-blocking: never throw from this function.
return skipped('exception:' + (err && err.message ? err.message : String(err)));
const errMsg = (err as Error)?.message ? (err as Error).message : String(err);
return skipped('exception:' + errMsg);
}
}
function skipped(reason) {
function skipped(reason: string): SkippedResult {
return {
skipped: true,
reason,
@@ -231,16 +269,17 @@ function skipped(reason) {
};
}
function buildMessage(elements, affectedPaths, action, runtime) {
const byCat = {};
function buildMessage(elements: DriftElement[], affectedPaths: string[], action: string, runtime: string | undefined): string {
const byCat: Record<string, string[]> = {};
for (const e of elements) {
(byCat[e.category] ||= []).push(e.path);
if (!byCat[e.category]) byCat[e.category] = [];
byCat[e.category].push(e.path);
}
const lines = [
const lines: string[] = [
`Codebase drift detected: ${elements.length} structural element(s) since last mapping.`,
'',
];
const labels = {
const labels: Record<string, string> = {
new_dir: 'New directories',
barrel: 'New barrel exports',
migration: 'New migrations',
@@ -256,14 +295,13 @@ function buildMessage(elements, affectedPaths, action, runtime) {
if (action === 'auto-remap') {
lines.push(`Auto-remap scheduled for paths: ${affectedPaths.join(', ')}`);
} else {
// drift.cjs is a pure library — it must never read env/config. The
// drift.cts is a pure library — it must never read env/config. The
// caller (verify.cmdVerifyCodebaseDrift) resolves the runtime once and
// passes it in via input.runtime so emitted commands match the project
// the caller is targeting, not the current process directory.
const { formatGsdSlash } = require('./runtime-slash.cjs');
const mapCmd = formatGsdSlash('map-codebase', runtime || 'claude');
lines.push(
`Run ${mapCmd} --paths ${affectedPaths.join(',')} to refresh planning context.`,
`Run ${String(mapCmd)} --paths ${affectedPaths.join(',')} to refresh planning context.`,
);
}
return lines.join('\n');
@@ -276,8 +314,8 @@ function buildMessage(elements, affectedPaths, action, runtime) {
* the top-level directory prefixes (depth 2 when the repo uses an
* `<apps|packages>/<name>/…` layout; depth 1 otherwise).
*/
function chooseAffectedPaths(paths) {
const out = new Set();
function chooseAffectedPaths(paths: string[]): string[] {
const out = new Set<string>();
for (const raw of paths || []) {
if (typeof raw !== 'string' || !raw) continue;
const file = raw.replace(/\\/g, '/');
@@ -298,9 +336,9 @@ function chooseAffectedPaths(paths) {
* Any path that is absolute, contains traversal, or includes shell
* metacharacters is dropped.
*/
function sanitizePaths(paths) {
function sanitizePaths(paths: unknown): string[] {
if (!Array.isArray(paths)) return [];
const out = [];
const out: string[] = [];
for (const p of paths) {
if (typeof p !== 'string') continue;
if (p.startsWith('/')) continue;
@@ -314,11 +352,16 @@ function sanitizePaths(paths) {
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
function parseFrontmatter(content) {
interface FrontmatterResult {
data: Record<string, string>;
body: string;
}
function parseFrontmatter(content: unknown): FrontmatterResult {
if (typeof content !== 'string') return { data: {}, body: '' };
const m = content.match(FRONTMATTER_RE);
if (!m) return { data: {}, body: content };
const data = {};
const data: Record<string, string> = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z0-9_][A-Za-z0-9_-]*):\s*(.*)$/);
if (!kv) continue;
@@ -327,7 +370,7 @@ function parseFrontmatter(content) {
return { data, body: content.slice(m[0].length) };
}
function serializeFrontmatter(data, body) {
function serializeFrontmatter(data: Record<string, string>, body: string): string {
const keys = Object.keys(data);
if (keys.length === 0) return body;
const lines = ['---'];
@@ -340,15 +383,15 @@ function serializeFrontmatter(data, body) {
* Read `last_mapped_commit` from the frontmatter of a `.planning/codebase/*.md`
* file. Returns null if the file does not exist or has no frontmatter.
*/
function readMappedCommit(filePath) {
let content;
function readMappedCommit(filePath: string): string | null {
let content: string;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch {
return null;
}
const { data } = parseFrontmatter(content);
const sha = data.last_mapped_commit;
const sha = data['last_mapped_commit'];
return typeof sha === 'string' && sha.length > 0 ? sha : null;
}
@@ -356,7 +399,7 @@ function readMappedCommit(filePath) {
* Upsert `last_mapped_commit` and `last_mapped_at` into the frontmatter of
* the given file, preserving any other frontmatter keys and the body.
*/
function writeMappedCommit(filePath, commitSha, isoDate) {
function writeMappedCommit(filePath: string, commitSha: string, isoDate?: string): void {
// Symmetric with readMappedCommit (which returns null on missing files):
// tolerate a missing target by creating a minimal frontmatter-only file
// rather than throwing ENOENT. This matters when a mapper produces a new
@@ -365,17 +408,17 @@ function writeMappedCommit(filePath, commitSha, isoDate) {
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (err) {
if (err.code !== 'ENOENT') throw err;
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
const { data, body } = parseFrontmatter(content);
data.last_mapped_commit = commitSha;
if (isoDate) data.last_mapped_at = isoDate;
data['last_mapped_commit'] = commitSha;
if (isoDate) data['last_mapped_at'] = isoDate;
platformWriteSync(filePath, serializeFrontmatter(data, body));
}
// ─── Exports ─────────────────────────────────────────────────────────────────
module.exports = {
export = {
DRIFT_CATEGORIES,
classifyFile,
detectDrift,

165
src/fallow-runner.cts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Fallow binary resolution and report normalisation.
*
* ADR-457 build-at-publish: the hand-written bin/lib/fallow-runner.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
function candidateNames(): string[] {
return process.platform === 'win32'
? ['fallow.exe', 'fallow.cmd', 'fallow.bat', 'fallow']
: ['fallow'];
}
function isExecutableFile(filePath: string): boolean {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return false;
if (process.platform === 'win32') return true;
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function findInPath(envPath: string | undefined): string | null {
if (!envPath) return null;
const names = candidateNames();
const segments = envPath.split(path.delimiter).filter(Boolean);
for (const segment of segments) {
for (const name of names) {
const candidate = path.join(segment, name);
if (isExecutableFile(candidate)) return candidate;
}
}
return null;
}
function findInNodeModules(cwd: string): string | null {
const names = candidateNames();
const binDir = path.join(cwd, 'node_modules', '.bin');
for (const name of names) {
const candidate = path.join(binDir, name);
if (isExecutableFile(candidate)) return candidate;
}
return null;
}
export interface ResolveFallowOpts {
cwd: string;
envPath?: string;
}
export function resolveFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }: ResolveFallowOpts): string | null {
return findInNodeModules(cwd) || findInPath(envPath) || null;
}
export function requireFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }: ResolveFallowOpts): string {
const binary = resolveFallowBinary({ cwd, envPath });
if (binary) return binary;
throw new Error(
'Fallow is enabled but no binary was found. Please install fallow via `npm install -D fallow` or `cargo install fallow`.',
);
}
interface FallowUnusedExport {
symbol?: string;
file?: string;
line?: number | null;
}
interface FallowDuplicateItem {
file?: string;
start?: number | null;
}
interface FallowDuplicate {
similarity?: number;
left?: FallowDuplicateItem;
right?: FallowDuplicateItem;
}
interface FallowCircular {
cycle?: string[];
}
interface FallowReport {
unusedExports?: unknown[];
duplicates?: unknown[];
circularDependencies?: unknown[];
}
export interface FallowFinding {
type: 'unused_export' | 'duplicate_block' | 'circular_dependency';
message: string;
file: string;
line: number | null;
related_file?: string;
}
export interface NormalizedFallowReport {
summary: {
unused_exports: number;
duplicates: number;
circular_dependencies: number;
total: number;
};
findings: FallowFinding[];
}
export function normalizeFallowReport(report: FallowReport | null | undefined): NormalizedFallowReport {
const unused: FallowUnusedExport[] = Array.isArray(report?.unusedExports)
? (report.unusedExports as FallowUnusedExport[])
: [];
const duplicates: FallowDuplicate[] = Array.isArray(report?.duplicates)
? (report.duplicates as FallowDuplicate[])
: [];
const circular: FallowCircular[] = Array.isArray(report?.circularDependencies)
? (report.circularDependencies as FallowCircular[])
: [];
const findings: FallowFinding[] = [];
for (const item of unused) {
findings.push({
type: 'unused_export',
message: `Unused export ${item.symbol ?? '<unknown>'}`,
file: item.file ?? '',
line: item.line ?? null,
});
}
for (const item of duplicates) {
findings.push({
type: 'duplicate_block',
message: `Duplicate block (${Math.round((item.similarity ?? 0) * 100)}% similarity)`,
file: item.left?.file ?? '',
line: item.left?.start ?? null,
related_file: item.right?.file ?? '',
});
}
for (const item of circular) {
findings.push({
type: 'circular_dependency',
message: `Circular dependency: ${(item.cycle ?? []).join(' -> ')}`,
file: Array.isArray(item.cycle) && item.cycle.length > 0 ? item.cycle[0] : '',
line: null,
});
}
return {
summary: {
unused_exports: unused.length,
duplicates: duplicates.length,
circular_dependencies: circular.length,
total: findings.length,
},
findings,
};
}

View File

@@ -1,11 +1,22 @@
/**
* Frontmatter — YAML frontmatter parsing, serialization, and CRUD commands
*
* ADR-457 build-at-publish: the hand-written bin/lib/frontmatter.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
const fs = require('fs');
const path = require('path');
const { output, error } = require('./core.cjs');
const { platformReadSync: safeReadFile, platformWriteSync } = require('./shell-command-projection.cjs');
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { output, error } = core;
import { platformReadSync as safeReadFile, platformWriteSync } from './shell-command-projection.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
type FrontmatterValue = string | string[] | Record<string, unknown>;
type Frontmatter = Record<string, FrontmatterValue>;
// ─── Parsing engine ───────────────────────────────────────────────────────────
@@ -13,10 +24,10 @@ const { platformReadSync: safeReadFile, platformWriteSync } = require('./shell-c
* Split a YAML inline array body on commas, respecting quoted strings.
* e.g. '"a, b", c' → ['a, b', 'c']
*/
function splitInlineArray(body) {
const items = [];
function splitInlineArray(body: string): string[] {
const items: string[] = [];
let current = '';
let inQuote = null; // null | '"' | "'"
let inQuote: string | null = null;
for (let i = 0; i < body.length; i++) {
const ch = body[i];
@@ -41,8 +52,8 @@ function splitInlineArray(body) {
return items;
}
function extractFrontmatter(content) {
const frontmatter = {};
function extractFrontmatter(content: string): Frontmatter {
const frontmatter: Frontmatter = {};
// Match frontmatter only at byte 0 — a `---` block later in the document
// body (YAML examples, horizontal rules) must never be treated as frontmatter.
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
@@ -52,8 +63,8 @@ function extractFrontmatter(content) {
const lines = yaml.split(/\r?\n/);
// Stack to track nested objects: [{obj, key, indent}]
// obj = object to write to, key = current key collecting array items, indent = indentation level
const stack = [{ obj: frontmatter, key: null, indent: -1 }];
type StackEntry = { obj: Record<string, unknown> | unknown[]; key: string | null; indent: number };
const stack: StackEntry[] = [{ obj: frontmatter, key: null, indent: -1 }];
for (const line of lines) {
// Skip empty lines
@@ -78,18 +89,18 @@ function extractFrontmatter(content) {
if (value === '' || value === '[') {
// Key with no value or opening bracket — could be nested object or array
// We'll determine based on next lines, for now create placeholder
current.obj[key] = value === '[' ? [] : {};
const newObj: Record<string, unknown> | unknown[] = value === '[' ? [] : {};
(current.obj as Record<string, unknown>)[key] = newObj;
current.key = null;
// Push new context for potential nested content
stack.push({ obj: current.obj[key], key: null, indent });
stack.push({ obj: newObj, key: null, indent });
} else if (value.startsWith('[') && value.endsWith(']')) {
// Inline array: key: [a, b, c] — quote-aware split (REG-04 fix)
current.obj[key] = splitInlineArray(value.slice(1, -1));
(current.obj as Record<string, unknown>)[key] = splitInlineArray(value.slice(1, -1));
current.key = null;
} else {
// Simple key: value
current.obj[key] = value.replace(/^["']|["']$/g, '');
(current.obj as Record<string, unknown>)[key] = value.replace(/^["']|["']$/g, '');
current.key = null;
}
} else if (line.trim().startsWith('- ')) {
@@ -102,9 +113,9 @@ function extractFrontmatter(content) {
const parent = stack.length > 1 ? stack[stack.length - 2] : null;
if (parent) {
for (const k of Object.keys(parent.obj)) {
if (parent.obj[k] === current.obj) {
parent.obj[k] = [itemValue];
current.obj = parent.obj[k];
if ((parent.obj as Record<string, unknown>)[k] === current.obj) {
(parent.obj as Record<string, unknown>)[k] = [itemValue];
current.obj = (parent.obj as Record<string, unknown>)[k] as unknown[];
break;
}
}
@@ -118,15 +129,15 @@ function extractFrontmatter(content) {
return frontmatter;
}
function reconstructFrontmatter(obj) {
const lines = [];
function reconstructFrontmatter(obj: Frontmatter): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) continue;
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${key}: []`);
} else if (value.every(v => typeof v === 'string') && value.length <= 3 && value.join(', ').length < 60) {
lines.push(`${key}: [${value.join(', ')}]`);
} else if (value.every(v => typeof v === 'string') && value.length <= 3 && (value).join(', ').length < 60) {
lines.push(`${key}: [${(value).join(', ')}]`);
} else {
lines.push(`${key}:`);
for (const item of value) {
@@ -140,8 +151,8 @@ function reconstructFrontmatter(obj) {
if (Array.isArray(subval)) {
if (subval.length === 0) {
lines.push(` ${subkey}: []`);
} else if (subval.every(v => typeof v === 'string') && subval.length <= 3 && subval.join(', ').length < 60) {
lines.push(` ${subkey}: [${subval.join(', ')}]`);
} else if (subval.every((v: unknown) => typeof v === 'string') && subval.length <= 3 && (subval).join(', ').length < 60) {
lines.push(` ${subkey}: [${(subval).join(', ')}]`);
} else {
lines.push(` ${subkey}:`);
for (const item of subval) {
@@ -150,7 +161,7 @@ function reconstructFrontmatter(obj) {
}
} else if (typeof subval === 'object') {
lines.push(` ${subkey}:`);
for (const [subsubkey, subsubval] of Object.entries(subval)) {
for (const [subsubkey, subsubval] of Object.entries(subval as Record<string, unknown>)) {
if (subsubval === null || subsubval === undefined) continue;
if (Array.isArray(subsubval)) {
if (subsubval.length === 0) {
@@ -162,10 +173,12 @@ function reconstructFrontmatter(obj) {
}
}
} else {
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
lines.push(` ${subsubkey}: ${subsubval}`);
}
}
} else {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const sv = String(subval);
lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`);
}
@@ -182,7 +195,7 @@ function reconstructFrontmatter(obj) {
return lines.join('\n');
}
function spliceFrontmatter(content, newObj) {
function spliceFrontmatter(content: string, newObj: Frontmatter): string {
const yamlStr = reconstructFrontmatter(newObj);
const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
if (match) {
@@ -191,7 +204,7 @@ function spliceFrontmatter(content, newObj) {
return `---\n${yamlStr}\n---\n\n` + content;
}
function parseMustHavesBlock(content, blockName) {
function parseMustHavesBlock(content: string, blockName: string): unknown[] {
// Extract a specific block from must_haves in raw frontmatter YAML
// Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
@@ -223,14 +236,15 @@ function parseMustHavesBlock(content, blockName) {
// List items are indented one level deeper than blockIndent
// Continuation KVs are indented one level deeper than list items
const items = [];
let current = null;
const items: unknown[] = [];
let current: string | Record<string, unknown> | null = null;
let listItemIndent = -1; // detected from first "- " line
for (const line of blockLines) {
// Skip empty lines
if (line.trim() === '') continue;
const indent = line.match(/^(\s*)/)[1].length;
const indentMatch = line.match(/^(\s*)/);
const indent = indentMatch ? indentMatch[1].length : 0;
// Stop at same or lower indent level than the block header
if (indent <= blockIndent && line.trim() !== '') break;
@@ -259,7 +273,7 @@ function parseMustHavesBlock(content, blockName) {
const kvMatch = afterDash.match(/^(\w+):\s+"?([^"]*)"?\s*$/);
if (kvMatch) {
current = {};
current[kvMatch[1]] = kvMatch[2];
(current)[kvMatch[1]] = kvMatch[2];
} else {
// Looks like KV but doesn't match — treat as plain string (#2757)
current = afterDash.replace(/^["']|["']$/g, '');
@@ -276,16 +290,17 @@ function parseMustHavesBlock(content, blockName) {
const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, '');
const keys = Object.keys(current);
const lastKey = keys[keys.length - 1];
if (lastKey && !Array.isArray(current[lastKey])) {
current[lastKey] = current[lastKey] ? [current[lastKey]] : [];
if (lastKey && !Array.isArray((current)[lastKey])) {
const existing = (current)[lastKey];
(current)[lastKey] = existing ? [existing] : [];
}
if (lastKey) current[lastKey].push(arrVal);
if (lastKey) ((current)[lastKey] as unknown[]).push(arrVal);
} else {
const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/);
if (kvMatch) {
const val = kvMatch[2];
// Try to parse as number
current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
(current)[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
}
}
}
@@ -310,73 +325,73 @@ function parseMustHavesBlock(content, blockName) {
// ─── Frontmatter CRUD commands ────────────────────────────────────────────────
const FRONTMATTER_SCHEMAS = {
const FRONTMATTER_SCHEMAS: Record<string, { required: string[] }> = {
plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] },
summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] },
verification: { required: ['phase', 'verified', 'status', 'score'] },
};
function cmdFrontmatterGet(cwd, filePath, field, raw) {
function cmdFrontmatterGet(cwd: string, filePath: string, field: string | undefined, raw: boolean): void {
if (!filePath) { error('file path required'); }
// Path traversal guard: reject null bytes
if (filePath.includes('\0')) { error('file path contains null bytes'); }
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
const content = safeReadFile(fullPath);
if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
if (!content) { output({ error: 'File not found', path: filePath }, raw, undefined); return; }
const fm = extractFrontmatter(content);
if (field) {
const value = fm[field];
if (value === undefined) { output({ error: 'Field not found', field }, raw); return; }
if (value === undefined) { output({ error: 'Field not found', field }, raw, undefined); return; }
output({ [field]: value }, raw, JSON.stringify(value));
} else {
output(fm, raw);
output(fm, raw, undefined);
}
}
function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
function cmdFrontmatterSet(cwd: string, filePath: string, field: string | undefined, value: string | undefined, raw: boolean): void {
if (!filePath || !field || value === undefined) { error('file, field, and value required'); }
// Path traversal guard: reject null bytes
if (filePath.includes('\0')) { error('file path contains null bytes'); }
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw, undefined); return; }
const content = fs.readFileSync(fullPath, 'utf-8');
const fm = extractFrontmatter(content);
let parsedValue;
try { parsedValue = JSON.parse(value); } catch { parsedValue = value; }
fm[field] = parsedValue;
let parsedValue: unknown;
try { parsedValue = JSON.parse(value as string); } catch { parsedValue = value; }
fm[field as string] = parsedValue as FrontmatterValue;
const newContent = spliceFrontmatter(content, fm);
platformWriteSync(fullPath, newContent);
output({ updated: true, field, value: parsedValue }, raw, 'true');
}
function cmdFrontmatterMerge(cwd, filePath, data, raw) {
function cmdFrontmatterMerge(cwd: string, filePath: string, data: string | undefined, raw: boolean): void {
if (!filePath || !data) { error('file and data required'); }
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw, undefined); return; }
const content = fs.readFileSync(fullPath, 'utf-8');
const fm = extractFrontmatter(content);
let mergeData;
try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; }
let mergeData: Record<string, FrontmatterValue>;
try { mergeData = JSON.parse(data as string) as Record<string, FrontmatterValue>; } catch { error('Invalid JSON for --data'); return; }
Object.assign(fm, mergeData);
const newContent = spliceFrontmatter(content, fm);
platformWriteSync(fullPath, newContent);
output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true');
}
function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
function cmdFrontmatterValidate(cwd: string, filePath: string, schemaName: string | undefined, raw: boolean): void {
if (!filePath || !schemaName) { error('file and schema required'); }
const schema = FRONTMATTER_SCHEMAS[schemaName];
const schema = FRONTMATTER_SCHEMAS[schemaName as string];
if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); }
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
const content = safeReadFile(fullPath);
if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
if (!content) { output({ error: 'File not found', path: filePath }, raw, undefined); return; }
const fm = extractFrontmatter(content);
const missing = schema.required.filter(f => fm[f] === undefined);
const present = schema.required.filter(f => fm[f] !== undefined);
output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
}
module.exports = {
export = {
extractFrontmatter,
reconstructFrontmatter,
spliceFrontmatter,

View File

@@ -1,5 +1,3 @@
'use strict';
/**
* Post-planning gap analysis (#2493).
*
@@ -12,13 +10,60 @@
*
* Coverage detection uses word-boundary regex matching to avoid false positives
* (REQ-1 must not match REQ-10).
*
* ADR-457 build-at-publish: the hand-written bin/lib/gap-checker.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
const fs = require('fs');
const path = require('path');
const { escapeRegex, output, error } = require('./core.cjs');
const { planningPaths, planningDir, findContextMdIn } = require('./planning-workspace.cjs');
const { parseDecisions } = require('./decisions.cjs');
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { escapeRegex, output, error } = core;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningPaths, planningDir, findContextMdIn } = planningWorkspace;
import { parseDecisions } from './decisions.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ReqItem {
id: string;
text: string;
}
interface RequirementItem extends ReqItem {
source: string;
}
type DecisionItem = ReturnType<typeof parseDecisions>[number] & { source: string };
type Item = RequirementItem | DecisionItem;
interface CoverageRow {
source: string;
item: string;
status: string;
}
interface GapCounts {
total: number;
covered: number;
uncovered: number;
}
interface GapResult {
enabled: boolean;
rows: CoverageRow[];
table: string;
summary: string;
counts: GapCounts;
}
interface RunGapAnalysisOptions {
phaseReqIds?: string | null | undefined;
}
/**
* Parse REQ-IDs from REQUIREMENTS.md content.
@@ -26,10 +71,10 @@ const { parseDecisions } = require('./decisions.cjs');
* Supports both checkbox (`- [ ] **REQ-NN** ...`) and traceability table
* (`| REQ-NN | ... |`) formats.
*/
function parseRequirements(reqMd) {
function parseRequirements(reqMd: unknown): ReqItem[] {
if (!reqMd || typeof reqMd !== 'string') return [];
const out = [];
const seen = new Set();
const out: ReqItem[] = [];
const seen = new Set<string>();
// Prefix-agnostic ID format: REQ-01, TST-01, BACK-07, INSP-04, etc.
const ID_PATTERN = '[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+';
@@ -69,7 +114,7 @@ function parseRequirements(reqMd) {
return out;
}
function detectCoverage(items, planText) {
function detectCoverage(items: Item[], planText: string): CoverageRow[] {
return items.map(it => {
const re = new RegExp('\\b' + escapeRegex(it.id) + '\\b');
return {
@@ -80,12 +125,12 @@ function detectCoverage(items, planText) {
});
}
function naturalKey(s) {
return String(s).replace(/(\d+)/g, (_, n) => n.padStart(8, '0'));
function naturalKey(s: unknown): string {
return String(s).replace(/(\d+)/g, (_, n: string) => n.padStart(8, '0'));
}
function sortRows(rows) {
const sourceOrder = { 'REQUIREMENTS.md': 0, 'CONTEXT.md': 1 };
function sortRows(rows: CoverageRow[]): CoverageRow[] {
const sourceOrder: Record<string, number> = { 'REQUIREMENTS.md': 0, 'CONTEXT.md': 1 };
return rows.slice().sort((a, b) => {
const so = (sourceOrder[a.source] ?? 99) - (sourceOrder[b.source] ?? 99);
if (so !== 0) return so;
@@ -93,26 +138,30 @@ function sortRows(rows) {
});
}
function formatGapTable(rows) {
function formatGapTable(rows: CoverageRow[]): string {
if (rows.length === 0) {
return '## Post-Planning Gap Analysis\n\nNo requirements or decisions to check.\n';
}
const header = '| Source | Item | Status |\n|--------|------|--------|';
const body = rows.map(r => {
const tick = r.status === 'Covered' ? '\u2713 Covered'
: r.status === 'Missing from REQUIREMENTS.md' ? '\u26a0 Missing from REQUIREMENTS.md'
: '\u2717 Not covered';
const tick = r.status === 'Covered' ? '✓ Covered'
: r.status === 'Missing from REQUIREMENTS.md' ? '⚠ Missing from REQUIREMENTS.md'
: '✗ Not covered';
return `| ${r.source} | ${r.item} | ${tick} |`;
}).join('\n');
return `## Post-Planning Gap Analysis\n\n${header}\n${body}\n`;
}
function readGate(cwd) {
function readGate(cwd: string): boolean {
const cfgPath = path.join(planningDir(cwd), 'config.json');
try {
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
if (raw && raw.workflow && typeof raw.workflow.post_planning_gaps === 'boolean') {
return raw.workflow.post_planning_gaps;
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) as unknown;
if (raw && typeof raw === 'object' && 'workflow' in raw) {
const wf = (raw as Record<string, unknown>)['workflow'];
if (wf && typeof wf === 'object' && 'post_planning_gaps' in wf) {
const val = (wf as Record<string, unknown>)['post_planning_gaps'];
if (typeof val === 'boolean') return val;
}
}
} catch { /* fall through */ }
return true;
@@ -129,9 +178,10 @@ function readGate(cwd) {
* Tolerates JSON-array-ish input (`["REQ-01","REQ-02"]`) since callers may pass
* the roadmap value through verbatim.
*/
function normalizePhaseReqIds(rawVal) {
function normalizePhaseReqIds(rawVal: unknown): string[] | null | undefined {
if (rawVal === undefined) return undefined;
if (rawVal === null) return null;
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const v = String(rawVal).replace(/["'[\]()]/g, '').trim();
if (v === '' || /^(null|tbd|none)$/i.test(v)) return null;
// Tolerate comma-, space-, or newline-separated lists (callers may pass the
@@ -140,7 +190,7 @@ function normalizePhaseReqIds(rawVal) {
return ids.length === 0 ? null : ids;
}
function runGapAnalysis(cwd, phaseDir, options = {}) {
function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOptions = {}): GapResult {
const phaseReqIds = normalizePhaseReqIds(options.phaseReqIds);
if (!readGate(cwd)) {
return {
@@ -156,13 +206,13 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
const reqPath = planningPaths(cwd).requirements;
const reqMd = fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf-8') : '';
let reqItems = parseRequirements(reqMd).map(r => ({ ...r, source: 'REQUIREMENTS.md' }));
let reqItems: RequirementItem[] = parseRequirements(reqMd).map(r => ({ ...r, source: 'REQUIREMENTS.md' }));
// Scope the requirements comparison to the phase's mapped REQ-IDs (#447).
// A phase that maps no requirements (phase_req_ids null/TBD) must not report
// every unrelated project REQ-ID as a gap — mirror §13's skip behavior.
// CONTEXT.md decisions (below) are always in scope regardless.
let ghostReqIds = [];
let ghostReqIds: string[] = [];
if (phaseReqIds === null) {
reqItems = [];
} else if (Array.isArray(phaseReqIds)) {
@@ -174,7 +224,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
// Read the phase directory once; reuse the listing for both context detection
// and plan-file enumeration (avoids redundant readdirSync calls).
let phaseDirFiles = [];
let phaseDirFiles: string[] = [];
try {
if (fs.existsSync(absPhaseDir)) phaseDirFiles = fs.readdirSync(absPhaseDir);
} catch { /* unreadable */ }
@@ -182,9 +232,9 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
const ctxFile = findContextMdIn(phaseDirFiles);
const ctxPath = ctxFile ? path.join(absPhaseDir, ctxFile) : null;
const ctxMd = ctxPath ? fs.readFileSync(ctxPath, 'utf-8') : '';
const dItems = parseDecisions(ctxMd).map(d => ({ ...d, source: 'CONTEXT.md' }));
const dItems: DecisionItem[] = parseDecisions(ctxMd).map(d => ({ ...d, source: 'CONTEXT.md' }));
const items = [...reqItems, ...dItems];
const items: Item[] = [...reqItems, ...dItems];
let planText = '';
try {
@@ -215,8 +265,8 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
const uncovered = rows.length - covered;
const summary = uncovered === 0
? `\u2713 All ${rows.length} items covered by plans`
: `\u26A0 ${uncovered} of ${rows.length} items not covered by any plan`;
? `✓ All ${rows.length} items covered by plans`
: `⚠ ${uncovered} of ${rows.length} items not covered by any plan`;
return {
enabled: true,
@@ -227,7 +277,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
};
}
function cmdGapAnalysis(cwd, args, raw) {
function cmdGapAnalysis(cwd: string, args: string[], raw: boolean): void {
const idx = args.indexOf('--phase-dir');
if (idx === -1 || !args[idx + 1]) {
error('Usage: gap-analysis --phase-dir <path-to-phase-directory>');
@@ -243,7 +293,7 @@ function cmdGapAnalysis(cwd, args, raw) {
output(result, raw, result.table || result.summary);
}
module.exports = {
export = {
parseRequirements,
detectCoverage,
formatGapTable,

View File

@@ -1,8 +1,15 @@
'use strict';
/**
* Graphify integration module — config gate, subprocess execution, knowledge-graph
* query, status, diff, build pipeline, and snapshot helpers.
*
* ADR-457 build-at-publish: the hand-written bin/lib/graphify.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
const fs = require('fs');
const path = require('path');
const { execTool, execGit, platformWriteSync } = require('./shell-command-projection.cjs');
import fs from 'node:fs';
import path from 'node:path';
import { execTool, execGit, platformWriteSync } from './shell-command-projection.cjs';
// ─── Config Gate ─────────────────────────────────────────────────────────────
@@ -10,40 +17,41 @@ const { execTool, execGit, platformWriteSync } = require('./shell-command-projec
* Check whether graphify is enabled in the project config.
* Reads config.json directly via fs. Returns false by default
* (when no config, no graphify key, or on error).
*
* @param {string} planningDir - Path to .planning directory
* @returns {boolean}
*/
function isGraphifyEnabled(planningDir) {
function isGraphifyEnabled(planningDir: string): boolean {
try {
const configPath = path.join(planningDir, 'config.json');
if (!fs.existsSync(configPath)) return false;
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (config && config.graphify && config.graphify.enabled === true) return true;
const config: unknown = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (
config &&
typeof config === 'object' &&
'graphify' in config &&
config.graphify &&
typeof config.graphify === 'object' &&
'enabled' in config.graphify &&
(config.graphify as Record<string, unknown>).enabled === true
) return true;
return false;
} catch (_e) {
return false;
}
}
interface DisabledResponse {
disabled: true;
message: string;
}
/**
* Return the standard disabled response object.
* @returns {{ disabled: true, message: string }}
*/
function disabledResponse() {
function disabledResponse(): DisabledResponse {
return { disabled: true, message: 'graphify is not enabled. Enable with: gsd-tools config-set graphify.enabled true' };
}
// ─── Subprocess Helper ───────────────────────────────────────────────────────
/**
* Execute graphify CLI as a subprocess with proper env and timeout handling.
*
* @param {string} cwd - Working directory for the subprocess
* @param {string[]} args - Arguments to pass to graphify
* @param {{ timeout?: number }} [options={}] - Options (timeout in ms, default 30000)
* @returns {{ exitCode: number, stdout: string, stderr: string }}
*/
/**
* Frozen enum of typed reason codes for execGraphify failures (#2974).
* Tests assert on result.reason instead of grepping stderr text.
@@ -53,9 +61,22 @@ const GRAPHIFY_REASON = Object.freeze({
ENOENT: 'graphify_not_found',
TIMEOUT: 'graphify_timed_out',
EXIT_NONZERO: 'graphify_exit_nonzero',
});
} as const);
function execGraphify(cwd, args, options = {}) {
type GraphifyReason = typeof GRAPHIFY_REASON[keyof typeof GRAPHIFY_REASON];
interface GraphifyExecResult {
exitCode: number;
stdout: string;
stderr: string;
reason: GraphifyReason;
timeout_ms?: number;
}
/**
* Execute graphify CLI as a subprocess with proper env and timeout handling.
*/
function execGraphify(cwd: string, args: string[], options: { timeout?: number } = {}): GraphifyExecResult {
const timeout = options.timeout ?? 30000;
const result = execTool('graphify', args, {
cwd,
@@ -64,7 +85,7 @@ function execGraphify(cwd, args, options = {}) {
});
// ENOENT — seam normalizes to exitCode 127. Surface as typed reason.
if (result.error && result.error.code === 'ENOENT') {
if (result.error && (result.error as NodeJS.ErrnoException).code === 'ENOENT') {
return {
exitCode: 127,
stdout: '',
@@ -94,13 +115,16 @@ function execGraphify(cwd, args, options = {}) {
// ─── Presence & Version ──────────────────────────────────────────────────────
interface InstalledResult {
installed: boolean;
message?: string;
}
/**
* Check whether the graphify CLI binary is installed and accessible on PATH.
* Uses --help (NOT --version, which graphify does not support).
*
* @returns {{ installed: boolean, message?: string }}
*/
function checkGraphifyInstalled() {
function checkGraphifyInstalled(): InstalledResult {
const result = execTool('graphify', ['--help'], { timeout: 5000 });
if (result.error) {
@@ -113,6 +137,12 @@ function checkGraphifyInstalled() {
return { installed: true };
}
interface VersionResult {
version: string | null;
compatible: boolean | null;
warning: string | null;
}
/**
* Detect graphify version and check compatibility.
* Tested range: >=0.4.0,<1.0
@@ -121,14 +151,12 @@ function checkGraphifyInstalled() {
* 1. Try `graphify --version` (works for most CLI installations, incl. venv installs)
* 2. Fall back to python3 importlib.metadata (legacy / system Python path)
* 3. Return null version gracefully if both fail
*
* @returns {{ version: string|null, compatible: boolean|null, warning: string|null }}
*/
function checkGraphifyVersion() {
function checkGraphifyVersion(): VersionResult {
// Strategy 1: try `graphify --version` directly (2s timeout -- fast path)
const versionResult = execTool('graphify', ['--version'], { timeout: 2000 });
let versionStr = null;
let versionStr: string | null = null;
if (!versionResult.error && versionResult.exitCode === 0) {
// graphify --version may emit "graphify 0.4.23" or just "0.4.23"
@@ -168,32 +196,57 @@ function checkGraphifyVersion() {
// ─── Internal Helpers ────────────────────────────────────────────────────────
interface GraphNode {
id: string;
label?: string;
description?: string;
[key: string]: unknown;
}
interface GraphEdge {
source: string;
target: string;
label?: string;
relation?: string;
confidence?: string;
confidence_score?: string;
[key: string]: unknown;
}
interface Graph {
nodes?: GraphNode[];
edges?: GraphEdge[];
links?: GraphEdge[];
hyperedges?: unknown[];
built_at_commit?: unknown;
[key: string]: unknown;
}
/**
* Safely read and parse a JSON file. Returns null on missing file or parse error.
* Prevents crashes on malformed JSON (T-02-01 mitigation).
*
* @param {string} filePath - Absolute path to JSON file
* @returns {object|null}
*/
function safeReadJson(filePath) {
function safeReadJson(filePath: string): Graph | null {
try {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as Graph;
} catch (_e) {
return null;
}
}
interface AdjEntry {
target: string;
edge: GraphEdge;
}
/**
* Build a bidirectional adjacency map from graph nodes and edges.
* Each node ID maps to an array of { target, edge } entries.
* Bidirectional: both source->target and target->source are added (Pitfall 3).
*
* @param {{ nodes: object[], edges: object[] }} graph
* @returns {Object.<string, Array<{ target: string, edge: object }>>}
*/
function buildAdjacencyMap(graph) {
const adj = {};
function buildAdjacencyMap(graph: Graph): Record<string, AdjEntry[]> {
const adj: Record<string, AdjEntry[]> = {};
for (const node of (graph.nodes || [])) {
adj[node.id] = [];
}
@@ -206,16 +259,18 @@ function buildAdjacencyMap(graph) {
return adj;
}
interface ExpandResult {
nodes: GraphNode[];
edges: GraphEdge[];
seeds: Set<string>;
trimmed?: string | null;
}
/**
* Seed-then-expand query: find nodes matching term, then BFS-expand up to maxHops.
* Matches on node label and description (case-insensitive substring, D-01).
*
* @param {{ nodes: object[], edges: object[] }} graph
* @param {string} term - Search term
* @param {number} [maxHops=2] - Maximum BFS hops from seed nodes
* @returns {{ nodes: object[], edges: object[], seeds: Set<string> }}
*/
function seedAndExpand(graph, term, maxHops = 2) {
function seedAndExpand(graph: Graph, term: string, maxHops = 2): ExpandResult {
const lowerTerm = term.toLowerCase();
const nodeMap = Object.fromEntries((graph.nodes || []).map(n => [n.id, n]));
const adj = buildAdjacencyMap(graph);
@@ -228,12 +283,12 @@ function seedAndExpand(graph, term, maxHops = 2) {
// BFS expand from seeds
const visitedNodes = new Set(seeds.map(n => n.id));
const collectedEdges = [];
const seenEdgeKeys = new Set();
const collectedEdges: GraphEdge[] = [];
const seenEdgeKeys = new Set<string>();
let frontier = seeds.map(n => n.id);
for (let hop = 0; hop < maxHops && frontier.length > 0; hop++) {
const nextFrontier = [];
const nextFrontier: string[] = [];
for (const nodeId of frontier) {
for (const entry of (adj[nodeId] || [])) {
// Deduplicate edges by source::target::label key
@@ -251,27 +306,31 @@ function seedAndExpand(graph, term, maxHops = 2) {
frontier = nextFrontier;
}
const resultNodes = [...visitedNodes].map(id => nodeMap[id]).filter(Boolean);
const resultNodes = [...visitedNodes].map(id => nodeMap[id]).filter((n): n is GraphNode => Boolean(n));
return { nodes: resultNodes, edges: collectedEdges, seeds: new Set(seeds.map(n => n.id)) };
}
interface BudgetResult {
nodes: GraphNode[];
edges: GraphEdge[];
trimmed: string | null;
total_nodes: number;
total_edges: number;
}
/**
* Apply token budget by dropping edges by confidence tier (D-04, D-05, D-06).
* Token estimation: Math.ceil(JSON.stringify(obj).length / 4).
* Drop order: AMBIGUOUS -> INFERRED -> EXTRACTED.
*
* @param {{ nodes: object[], edges: object[], seeds: Set<string> }} result
* @param {number|null} budgetTokens - Max tokens, or null/falsy for unlimited
* @returns {{ nodes: object[], edges: object[], trimmed: string|null, total_nodes: number, total_edges: number, term?: string }}
*/
function applyBudget(result, budgetTokens) {
function applyBudget(result: ExpandResult, budgetTokens: number | null): ExpandResult | BudgetResult {
if (!budgetTokens) return result;
const CONFIDENCE_ORDER = ['AMBIGUOUS', 'INFERRED', 'EXTRACTED'];
let edges = [...result.edges];
let omitted = 0;
const estimateTokens = (obj) => Math.ceil(JSON.stringify(obj).length / 4);
const estimateTokens = (obj: unknown) => Math.ceil(JSON.stringify(obj).length / 4);
for (const tier of CONFIDENCE_ORDER) {
if (estimateTokens({ nodes: result.nodes, edges }) <= budgetTokens) break;
@@ -282,7 +341,7 @@ function applyBudget(result, budgetTokens) {
}
// Find unreachable nodes after edge removal
const reachableNodes = new Set();
const reachableNodes = new Set<string>();
for (const edge of edges) {
reachableNodes.add(edge.source);
reachableNodes.add(edge.target);
@@ -302,16 +361,40 @@ function applyBudget(result, budgetTokens) {
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Strict 4-40 hex fence for graph.built_at_commit values (#3170). Anything
* else (dashed, prose, empty) is treated as absent so a hostile graph.json
* cannot smuggle a `--upload-pack=…` option into a `git` argv.
*/
const COMMIT_HASH_RE = /^[0-9a-f]{4,40}$/i;
/**
* Read git HEAD for the project at `cwd`. Returns the full commit hash on
* success, or null when cwd is not a git repo / `git` is not on PATH.
*/
function readGitHead(cwd: string): string | null {
const r = execGit(['rev-parse', 'HEAD'], { cwd });
if (r.exitCode !== 0) return null;
return r.stdout.trim() || null;
}
/**
* Count commits between `from` and `to` (exclusive..inclusive, like
* `git rev-list --count A..B`). Returns null when either ref is unreachable
* or the cwd is not a git repo.
*/
function countCommitsBetween(cwd: string, from: string, to: string): number | null {
const r = execGit(['rev-list', '--count', `${from}..${to}`], { cwd });
if (r.exitCode !== 0) return null;
const n = parseInt(r.stdout.trim(), 10);
return Number.isFinite(n) ? n : null;
}
/**
* Query the knowledge graph for nodes matching a term, with optional budget cap.
* Uses seed-then-expand BFS traversal (D-01).
*
* @param {string} cwd - Working directory
* @param {string} term - Search term
* @param {{ budget?: number|null }} [options={}]
* @returns {object}
*/
function graphifyQuery(cwd, term, options = {}) {
function graphifyQuery(cwd: string, term: string, options: { budget?: number | null } = {}): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
@@ -325,7 +408,7 @@ function graphifyQuery(cwd, term, options = {}) {
return { error: 'Failed to parse graph.json' };
}
let result = seedAndExpand(graph, term);
let result: ExpandResult | BudgetResult = seedAndExpand(graph, term);
if (options.budget) {
result = applyBudget(result, options.budget);
@@ -337,39 +420,10 @@ function graphifyQuery(cwd, term, options = {}) {
edges: result.edges,
total_nodes: result.nodes.length,
total_edges: result.edges.length,
trimmed: result.trimmed || null,
trimmed: 'trimmed' in result ? (result.trimmed || null) : null,
};
}
/**
* Strict 4-40 hex fence for graph.built_at_commit values (#3170). Anything
* else (dashed, prose, empty) is treated as absent so a hostile graph.json
* cannot smuggle a `--upload-pack=…` option into a `git` argv.
*/
const COMMIT_HASH_RE = /^[0-9a-f]{4,40}$/i;
/**
* Read git HEAD for the project at `cwd`. Returns the full commit hash on
* success, or null when cwd is not a git repo / `git` is not on PATH.
*/
function readGitHead(cwd) {
const r = execGit(['rev-parse', 'HEAD'], { cwd });
if (r.exitCode !== 0) return null;
return r.stdout.trim() || null;
}
/**
* Count commits between `from` and `to` (exclusive..inclusive, like
* `git rev-list --count A..B`). Returns null when either ref is unreachable
* or the cwd is not a git repo.
*/
function countCommitsBetween(cwd, from, to) {
const r = execGit(['rev-list', '--count', `${from}..${to}`], { cwd });
if (r.exitCode !== 0) return null;
const n = parseInt(r.stdout.trim(), 10);
return Number.isFinite(n) ? n : null;
}
/**
* Return status information about the knowledge graph (STAT-01, STAT-02).
*
@@ -378,11 +432,8 @@ function countCommitsBetween(cwd, from, to) {
* (#3170). Tri-state on commit_stale: null means "we don't know" (pre-v0.7
* graph, no git, or unreachable commit), distinct from false ("known
* fresh").
*
* @param {string} cwd - Working directory
* @returns {object}
*/
function graphifyStatus(cwd) {
function graphifyStatus(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
@@ -401,11 +452,12 @@ function graphifyStatus(cwd) {
const age = Date.now() - stat.mtimeMs;
// Commit-staleness signal (#3170). Validate before passing to git.
const rawBuilt = (graph.built_at_commit || '').toString().trim();
const builtAtCommit = graph.built_at_commit;
const rawBuilt = (typeof builtAtCommit === 'string' ? builtAtCommit : '').trim();
const builtAt = COMMIT_HASH_RE.test(rawBuilt) ? rawBuilt : null;
const head = readGitHead(cwd);
let commitsBehind = null;
let commitStale = null;
let commitsBehind: number | null = null;
let commitStale: boolean | null = null;
if (builtAt && head) {
commitsBehind = countCommitsBetween(cwd, builtAt, head);
if (commitsBehind !== null) commitStale = commitsBehind > 0;
@@ -443,11 +495,8 @@ function graphifyStatus(cwd) {
/**
* Compute topology-level diff between current graph and last build snapshot (D-07, D-08, D-09).
*
* @param {string} cwd - Working directory
* @returns {object}
*/
function graphifyDiff(cwd) {
function graphifyDiff(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
@@ -480,7 +529,7 @@ function graphifyDiff(cwd) {
);
// Diff edges (keyed by source+target+relation)
const edgeKey = (e) => `${e.source}::${e.target}::${e.relation || e.label || ''}`;
const edgeKey = (e: GraphEdge) => `${e.source}::${e.target}::${e.relation || e.label || ''}`;
const currentEdgeMap = Object.fromEntries((current.edges || current.links || []).map(e => [edgeKey(e), e]));
const snapshotEdgeMap = Object.fromEntries((snapshot.edges || snapshot.links || []).map(e => [edgeKey(e), e]));
@@ -502,11 +551,8 @@ function graphifyDiff(cwd) {
/**
* Pre-flight checks for graphify build (BUILD-01, BUILD-02, D-09).
* Does NOT invoke graphify -- returns structured JSON for the builder agent.
*
* @param {string} cwd - Working directory
* @returns {object}
*/
function graphifyBuild(cwd) {
function graphifyBuild(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
@@ -521,7 +567,8 @@ function graphifyBuild(cwd) {
// Read build timeout from config -- default 300s per D-02
const config = safeReadJson(path.join(planningDir, 'config.json')) || {};
const timeoutSec = (config.graphify && config.graphify.build_timeout) || 300;
const graphifyConfig = config.graphify as Record<string, unknown> | undefined;
const timeoutSec = (graphifyConfig && graphifyConfig.build_timeout) || 300;
return {
action: 'spawn_agent',
@@ -534,15 +581,19 @@ function graphifyBuild(cwd) {
};
}
interface SnapshotResult {
saved: boolean;
timestamp: string;
node_count: number;
edge_count: number;
}
/**
* Write a diff snapshot after successful build (D-06).
* Reads graph.json from .planning/graphs/ and writes .last-build-snapshot.json
* using platformWriteSync for crash safety.
*
* @param {string} cwd - Working directory
* @returns {object}
*/
function writeSnapshot(cwd) {
function writeSnapshot(cwd: string): SnapshotResult | { error: string } {
const graphPath = path.join(cwd, '.planning', 'graphs', 'graph.json');
const graph = safeReadJson(graphPath);
if (!graph) return { error: 'Cannot write snapshot: graph.json not parseable' };
@@ -566,7 +617,7 @@ function writeSnapshot(cwd) {
// ─── Exports ─────────────────────────────────────────────────────────────────
module.exports = {
export = {
// Config gate
isGraphifyEnabled,
disabledResponse,

View File

@@ -1,5 +1,3 @@
'use strict';
/**
* gsd2-import — Reverse migration from GSD-2 (.gsd/) to GSD v1 (.planning/)
*
@@ -15,23 +13,80 @@
* - Completed slices ([x] in ROADMAP) → [x] phases in ROADMAP.md
* - Tasks with a SUMMARY file → SUMMARY.md written
* - Slice RESEARCH.md → phase XX-RESEARCH.md
*
* ADR-457 build-at-publish: the hand-written bin/lib/gsd2-import.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
const fs = require('node:fs');
const path = require('node:path');
const { platformWriteSync } = require('./shell-command-projection.cjs');
import fs from 'node:fs';
import path from 'node:path';
import { platformWriteSync } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { output } = core;
// ─── Types ───────────────────────────────────────────────────────────────────
interface SliceInfo {
done: boolean;
id: string;
title: string;
}
interface TaskInfo {
id: string;
title: string;
description: string;
mustHaves: string[];
plan: string | null;
summary: string | null;
done: boolean;
}
interface Slice {
id: string;
title: string;
done: boolean;
plan: string | null;
summary: string | null;
research: string | null;
context: string | null;
tasks: TaskInfo[];
}
interface Milestone {
id: string;
title: string;
research: string | null;
slices: Slice[];
}
interface Gsd2Data {
projectContent: string | null;
requirements: string | null;
milestones: Milestone[];
}
interface PhaseMapEntry {
milestoneId: string;
milestoneTitle: string;
slice: Slice;
phaseNum: number;
}
// ─── Utilities ──────────────────────────────────────────────────────────────
function readOptional(filePath) {
function readOptional(filePath: string): string | null {
try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
}
function zeroPad(n, width = 2) {
function zeroPad(n: number, width = 2): string {
return String(n).padStart(width, '0');
}
function slugify(title) {
function slugify(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
@@ -41,7 +96,7 @@ function slugify(title) {
* Find the .gsd/ directory starting from a project root.
* Returns the absolute path or null if not found.
*/
function findGsd2Root(startPath) {
function findGsd2Root(startPath: string): string | null {
if (path.basename(startPath) === '.gsd' && fs.existsSync(startPath)) {
return startPath;
}
@@ -57,8 +112,8 @@ function findGsd2Root(startPath) {
* Each slice entry looks like:
* - [x] **S01: Title** `risk:medium` `depends:[S00]`
*/
function parseSlicesFromRoadmap(content) {
const slices = [];
function parseSlicesFromRoadmap(content: string): SliceInfo[] {
const slices: SliceInfo[] = [];
const sectionMatch = content.match(/## Slices\n([\s\S]*?)(?:\n## |\n# |$)/);
if (!sectionMatch) return slices;
@@ -74,7 +129,7 @@ function parseSlicesFromRoadmap(content) {
* Parse the milestone title from the first heading in a GSD-2 ROADMAP.md.
* Format: # M001: Title
*/
function parseMilestoneTitle(content) {
function parseMilestoneTitle(content: string): string | null {
const m = content.match(/^# \w+:\s*(.+)/m);
return m ? m[1].trim() : null;
}
@@ -83,7 +138,7 @@ function parseMilestoneTitle(content) {
* Parse a task title from a GSD-2 T##-PLAN.md.
* Format: # T01: Title
*/
function parseTaskTitle(content, fallback) {
function parseTaskTitle(content: string, fallback: string): string {
const m = content.match(/^# \w+:\s*(.+)/m);
return m ? m[1].trim() : fallback;
}
@@ -91,7 +146,7 @@ function parseTaskTitle(content, fallback) {
/**
* Parse the ## Description body from a GSD-2 task plan.
*/
function parseTaskDescription(content) {
function parseTaskDescription(content: string): string {
const m = content.match(/## Description\n+([\s\S]+?)(?:\n## |\n# |$)/);
return m ? m[1].trim() : '';
}
@@ -99,19 +154,19 @@ function parseTaskDescription(content) {
/**
* Parse ## Must-Haves items from a GSD-2 task plan.
*/
function parseTaskMustHaves(content) {
function parseTaskMustHaves(content: string): string[] {
const m = content.match(/## Must-Haves\n+([\s\S]+?)(?:\n## |\n# |$)/);
if (!m) return [];
return m[1].split('\n')
.map(l => l.match(/^- \[[ x]\]\s*(.+)/))
.filter(Boolean)
.filter((match): match is RegExpMatchArray => match !== null)
.map(match => match[1].trim());
}
/**
* Read all task plan files from a GSD-2 tasks/ directory.
*/
function readTasksDir(tasksDir) {
function readTasksDir(tasksDir: string): TaskInfo[] {
if (!fs.existsSync(tasksDir)) return [];
return fs.readdirSync(tasksDir)
@@ -136,8 +191,8 @@ function readTasksDir(tasksDir) {
/**
* Parse a complete GSD-2 .gsd/ directory into a structured representation.
*/
function parseGsd2(gsdDir) {
const data = {
function parseGsd2(gsdDir: string): Gsd2Data {
const data: Gsd2Data = {
projectContent: readOptional(path.join(gsdDir, 'PROJECT.md')),
requirements: readOptional(path.join(gsdDir, 'REQUIREMENTS.md')),
milestones: [],
@@ -157,7 +212,7 @@ function parseGsd2(gsdDir) {
const sliceInfos = roadmapContent ? parseSlicesFromRoadmap(roadmapContent) : [];
const slices = sliceInfos.map(info => {
const slices: Slice[] = sliceInfos.map(info => {
const sDir = path.join(slicesDir, info.id);
const hasSDir = fs.existsSync(sDir);
return {
@@ -188,7 +243,7 @@ function parseGsd2(gsdDir) {
/**
* Build a GSD v1 PLAN.md from a GSD-2 task.
*/
function buildPlanMd(task, phasePrefix, planPrefix, phaseSlug, milestoneTitle) {
function buildPlanMd(task: TaskInfo, phasePrefix: string, planPrefix: string, phaseSlug: string, milestoneTitle: string): string {
const lines = [
'---',
`phase: "${phasePrefix}"`,
@@ -225,7 +280,7 @@ function buildPlanMd(task, phasePrefix, planPrefix, phaseSlug, milestoneTitle) {
* Build a GSD v1 SUMMARY.md from a GSD-2 task summary.
* Strips the GSD-2 frontmatter and preserves the body.
*/
function buildSummaryMd(task, phasePrefix, planPrefix) {
function buildSummaryMd(task: TaskInfo, phasePrefix: string, planPrefix: string): string {
const raw = task.summary || '';
// Strip GSD-2 frontmatter block (--- ... ---) if present
const bodyMatch = raw.match(/^---[\s\S]*?---\n+([\s\S]*)$/);
@@ -245,7 +300,7 @@ function buildSummaryMd(task, phasePrefix, planPrefix) {
/**
* Build a GSD v1 XX-CONTEXT.md from a GSD-2 slice.
*/
function buildContextMd(slice, phasePrefix) {
function buildContextMd(slice: Slice, phasePrefix: string): string {
const lines = [
`# Phase ${phasePrefix} Context`,
'',
@@ -263,7 +318,7 @@ function buildContextMd(slice, phasePrefix) {
/**
* Build the GSD v1 ROADMAP.md with milestone-sectioned format.
*/
function buildRoadmapMd(milestones, phaseMap) {
function buildRoadmapMd(milestones: Milestone[], phaseMap: PhaseMapEntry[]): string {
const lines = ['# Roadmap', ''];
for (const milestone of milestones) {
@@ -284,7 +339,7 @@ function buildRoadmapMd(milestones, phaseMap) {
/**
* Build the GSD v1 STATE.md reflecting the current position in the project.
*/
function buildStateMd(phaseMap) {
function buildStateMd(phaseMap: PhaseMapEntry[]): string {
const currentEntry = phaseMap.find(p => !p.slice.done);
const totalPhases = phaseMap.length;
const donePhases = phaseMap.filter(p => p.slice.done).length;
@@ -340,8 +395,8 @@ function buildStateMd(phaseMap) {
* Convert parsed GSD-2 data into a map of relative path → file content.
* All paths are relative to the .planning/ root.
*/
function buildPlanningArtifacts(gsd2Data) {
const artifacts = new Map();
function buildPlanningArtifacts(gsd2Data: Gsd2Data): Map<string, string> {
const artifacts = new Map<string, string>();
// Passthrough files
artifacts.set('PROJECT.md', gsd2Data.projectContent || '# Project\n\n(Migrated from GSD-2)\n');
@@ -353,7 +408,7 @@ function buildPlanningArtifacts(gsd2Data) {
artifacts.set('config.json', JSON.stringify({ version: 1 }, null, 2) + '\n');
// Build sequential phase map: flatten Milestones → Slices into numbered phases
const phaseMap = [];
const phaseMap: PhaseMapEntry[] = [];
let phaseNum = 1;
for (const milestone of gsd2Data.milestones) {
for (const slice of milestone.slices) {
@@ -365,8 +420,8 @@ function buildPlanningArtifacts(gsd2Data) {
artifacts.set('ROADMAP.md', buildRoadmapMd(gsd2Data.milestones, phaseMap));
artifacts.set('STATE.md', buildStateMd(phaseMap));
for (const { slice, phaseNum, milestoneTitle } of phaseMap) {
const prefix = zeroPad(phaseNum);
for (const { slice, phaseNum: pNum, milestoneTitle } of phaseMap) {
const prefix = zeroPad(pNum);
const slug = slugify(slice.title);
const dir = `phases/${prefix}-${slug}`;
@@ -402,7 +457,7 @@ function buildPlanningArtifacts(gsd2Data) {
/**
* Format a dry-run preview string for display before writing.
*/
function buildPreview(gsd2Data, artifacts, projectDir) {
function buildPreview(gsd2Data: Gsd2Data, artifacts: Map<string, string>, projectDir: string): string {
const lines = ['Preview — files that will be created in .planning/:'];
for (const rel of artifacts.keys()) {
@@ -421,8 +476,7 @@ function buildPreview(gsd2Data, artifacts, projectDir) {
lines.push('');
lines.push('Cannot migrate automatically:');
lines.push(' - GSD-2 cost/token ledger (no v1 equivalent)');
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
lines.push(` - GSD-2 database state (rebuilt from files on first ${formatGsdSlash('health', resolveRuntime(projectDir))})`);
lines.push(` - GSD-2 database state (rebuilt from files on first ${formatGsdSlash('health', resolveRuntime(projectDir)) as string})`);
lines.push(' - VS Code extension state');
return lines.join('\n');
@@ -433,7 +487,7 @@ function buildPreview(gsd2Data, artifacts, projectDir) {
/**
* Write all artifacts to the .planning/ directory.
*/
function writePlanningDir(artifacts, planningRoot) {
function writePlanningDir(artifacts: Map<string, string>, planningRoot: string): void {
for (const [rel, content] of artifacts) {
const absPath = path.join(planningRoot, rel);
platformWriteSync(absPath, content);
@@ -446,9 +500,7 @@ function writePlanningDir(artifacts, planningRoot) {
* Entry point called from gsd-tools.cjs.
* Supports: --force, --dry-run, --path <dir>
*/
function cmdFromGsd2(args, cwd, raw) {
const { output, error } = require('./core.cjs');
function cmdFromGsd2(args: string[], cwd: string, raw: boolean): void {
const force = args.includes('--force');
const dryRun = args.includes('--dry-run');
@@ -459,15 +511,17 @@ function cmdFromGsd2(args, cwd, raw) {
const gsdDir = findGsd2Root(projectDir);
if (!gsdDir) {
return output({ success: false, error: `No .gsd/ directory found in ${projectDir}` }, raw);
output({ success: false, error: `No .gsd/ directory found in ${projectDir}` }, raw, undefined);
return;
}
const planningRoot = path.join(path.dirname(gsdDir), '.planning');
if (fs.existsSync(planningRoot) && !force) {
return output({
output({
success: false,
error: `.planning/ already exists at ${planningRoot}. Pass --force to overwrite.`,
}, raw);
}, raw, undefined);
return;
}
const gsd2Data = parseGsd2(gsdDir);
@@ -477,21 +531,22 @@ function cmdFromGsd2(args, cwd, raw) {
const preview = buildPreview(gsd2Data, artifacts, projectDir);
if (dryRun) {
return output({ success: true, dryRun: true, preview }, raw);
output({ success: true, dryRun: true, preview }, raw, undefined);
return;
}
writePlanningDir(artifacts, planningRoot);
return output({
output({
success: true,
planningDir: planningRoot,
filesWritten: artifacts.size,
milestones: gsd2Data.milestones.length,
preview,
}, raw);
}, raw, undefined);
}
module.exports = {
export = {
findGsd2Root,
parseGsd2,
buildPlanningArtifacts,

View File

@@ -0,0 +1,95 @@
/**
* 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.
*
* ADR-457 build-at-publish: the hand-written bin/lib/init-command-router.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
import { INIT_SUBCOMMANDS } from './command-aliases.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
import { parseNamedArgs } from './command-arg-projection.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface InitModule {
cmdInitExecutePhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record<string, string | boolean | null>): void;
cmdInitPlanPhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record<string, string | boolean | null>): void;
cmdInitNewProject(cwd: string, raw: boolean): void;
cmdInitNewMilestone(cwd: string, raw: boolean): void;
cmdInitQuick(cwd: string, name: string, raw: boolean): void;
cmdInitIngestDocs(cwd: string, raw: boolean): void;
cmdInitResume(cwd: string, raw: boolean): void;
cmdInitVerifyWork(cwd: string, phase: string | undefined, raw: boolean): void;
cmdInitPhaseOp(cwd: string, phase: string | undefined, raw: boolean): void;
cmdInitTodos(cwd: string, phase: string | undefined, raw: boolean): void;
cmdInitMilestoneOp(cwd: string, raw: boolean): void;
cmdInitMapCodebase(cwd: string, raw: boolean): void;
cmdInitProgress(cwd: string, raw: boolean): void;
cmdInitManager(cwd: string, raw: boolean): void;
cmdInitNewWorkspace(cwd: string, raw: boolean): void;
cmdInitListWorkspaces(cwd: string, raw: boolean): void;
cmdInitRemoveWorkspace(cwd: string, name: string | undefined, raw: boolean): void;
}
interface RouteInitCommandOptions {
init: InitModule;
args: string[];
cwd: string;
raw: boolean;
error: (message: string) => void;
}
// ─── Implementation ───────────────────────────────────────────────────────────
function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptions): void {
routeCjsCommandFamily({
args,
subcommands: INIT_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand: string, available: string[]) => `Unknown init workflow: ${_subcommand}\nAvailable: ${available.join(', ')}`,
handlers: {
'execute-phase': () => {
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
},
'plan-phase': () => {
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
},
'new-project': () => init.cmdInitNewProject(cwd, raw),
'new-milestone': () => init.cmdInitNewMilestone(cwd, raw),
quick: () => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw),
'ingest-docs': () => init.cmdInitIngestDocs(cwd, raw),
resume: () => init.cmdInitResume(cwd, raw),
'verify-work': () => init.cmdInitVerifyWork(cwd, args[2], raw),
'phase-op': () => init.cmdInitPhaseOp(cwd, args[2], raw),
todos: () => init.cmdInitTodos(cwd, args[2], raw),
'milestone-op': () => init.cmdInitMilestoneOp(cwd, raw),
'map-codebase': () => init.cmdInitMapCodebase(cwd, raw),
progress: () => 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': () => init.cmdInitNewWorkspace(cwd, raw),
'list-workspaces': () => init.cmdInitListWorkspaces(cwd, raw),
'remove-workspace': () => init.cmdInitRemoveWorkspace(cwd, args[2], raw),
},
});
}
export = {
routeInitCommand,
};

2231
src/init.cts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,46 +2,15 @@
* Skill Surface Budget Module — single source of truth for which skills/agents
* are written to the runtime config dirs (ADR-0011).
*
* Background: every installed `gsd-*` skill costs eager system-prompt tokens
* because runtimes (Claude Code, opencode, etc.) enumerate skill descriptions
* in `<available_skills>` on every turn. With 66 skills + 33 agents GSD alone
* consumes ~60% of the default 1%-of-context skill-listing budget, causing
* dropped skills when users stack multiple plugins (#3408).
*
* Profile model: three named profiles replace the old minimal/full binary:
* - core — eight skills covering the main project loop (includes surface for ADR-0011 expand contract)
* - standard — core + phase management and workspace skills
* - full — all skills (previous default, '*' sentinel)
* Profiles compose: --profile=core,audit resolves to union(closure(core), closure(audit)).
* Back-compat aliases: --minimal / --core-only both map to --profile=core.
*
* This module owns:
* - PROFILES map: named profile → base skill set (or '*' sentinel for full)
* - loadSkillsManifest: parse requires: frontmatter from commands/gsd/*.md
* - resolveProfile: compute transitive closure of profile base over manifest
* - stageSkillsForProfile / stageAgentsForProfile: filesystem staging
* - readActiveProfile / writeActiveProfile: .gsd-profile marker persistence
* - resolveEffectiveProfile: explicit flag > .gsd-profile marker > full
* - mostRestrictiveProfile: resolve multi-runtime disagreement to smallest set
*
* Companion module (Phase 2): get-shit-done/bin/lib/surface.cjs owns the
* runtime /gsd:surface command. It reuses stageSkillsForProfile and
* stageAgentsForProfile from this module for cluster-level enable/disable
* without reinstall, persisting state in <runtimeConfigDir>/.gsd-surface.json.
*
* Legacy back-compat exports (deprecated, kept for existing callers):
* - MINIMAL_SKILL_ALLOWLIST — derived from PROFILES.core
* - isMinimalMode(mode) — returns true for 'minimal'
* - shouldInstallSkill(name, mode|resolvedProfile) — overloaded
* - stageSkillsForMode(srcDir, mode) — wraps stageSkillsForProfile
* ADR-457 build-at-publish: the hand-written bin/lib/install-profiles.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { platformWriteSync } = require('./shell-command-projection.cjs');
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { platformWriteSync } from './shell-command-projection.cjs';
// ---------------------------------------------------------------------------
// Profile definitions
@@ -85,8 +54,10 @@ const PROFILES = Object.freeze({
'pause-work',
'workspace',
]),
full: '*',
});
full: '*' as const,
} as const);
type ProfileName = keyof typeof PROFILES;
// ---------------------------------------------------------------------------
// Manifest parsing
@@ -99,11 +70,8 @@ const PROFILES = Object.freeze({
*
* No external YAML parser dependency — hand-parse the single line
* since GSD enforces flow-style arrays for requires:.
*
* @param {string} content full file content
* @returns {string[]}
*/
function parseRequires(content) {
function parseRequires(content: string): string[] {
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/m);
if (!fmMatch) return [];
const fm = fmMatch[1];
@@ -127,11 +95,8 @@ function parseRequires(content) {
*
* The caller is responsible for filtering by which agents actually exist —
* this function returns all syntactically valid `gsd-*` matches.
*
* @param {string} content full file content
* @returns {string[]} deduplicated agent stems like ['gsd-planner', 'gsd-executor']
*/
function parseCallsAgents(content) {
function parseCallsAgents(content: string): string[] {
// Match word-boundary gsd-<stem> patterns; stems are lowercase letters and hyphens.
// We use a regex that matches `gsd-` followed by one or more lowercase-alpha-or-hyphen chars.
// This catches `gsd-planner`, `gsd-plan-checker`, etc. in prose and code.
@@ -146,12 +111,9 @@ function parseCallsAgents(content) {
* Also derives calls_agents for each skill by scanning the body text for
* `gsd-*` agent name references. Agent stems are stored under the special
* key `_calls_agents_<stem>` so they don't conflict with skill stems.
*
* @param {string} commandsDir absolute path to commands/gsd/
* @returns {Map<string, string[]>} stem → [required stem, ...] plus _calls_agents_<stem> entries
*/
function loadSkillsManifest(commandsDir) {
const manifest = new Map();
function loadSkillsManifest(commandsDir: string): Map<string, string[]> {
const manifest = new Map<string, string[]>();
if (!fs.existsSync(commandsDir)) return manifest;
const entries = fs.readdirSync(commandsDir, { withFileTypes: true });
for (const entry of entries) {
@@ -178,16 +140,12 @@ function loadSkillsManifest(commandsDir) {
/**
* Compute the transitive closure of a set of skill stems over the manifest.
*
* @param {Iterable<string>} base initial set of stems
* @param {Map<string, string[]>} manifest skill → [required stems]
* @returns {Set<string>}
*/
function computeClosure(base, manifest) {
function computeClosure(base: Iterable<string>, manifest: Map<string, string[]>): Set<string> {
const closed = new Set(base);
const queue = [...closed];
while (queue.length > 0) {
const stem = queue.pop();
const stem = queue.pop()!;
const deps = manifest.get(stem) || [];
for (const dep of deps) {
if (!closed.has(dep)) {
@@ -199,17 +157,23 @@ function computeClosure(base, manifest) {
return closed;
}
interface ResolvedProfile {
name: string;
skills: Set<string> | '*';
agents: Set<string>;
}
interface ResolveProfileOpts {
modes?: string[];
manifest?: Map<string, string[]>;
_profilesOverride?: Record<string, string | readonly string[]>;
}
/**
* Resolve a profile (or composed profiles) to a typed result object.
*
* @param {object} opts
* @param {string[]} [opts.modes=['full']] profile names to resolve and union
* @param {Map<string, string[]>} [opts.manifest] parsed requires: graph
* @param {object} [opts._profilesOverride] for testing — override PROFILES
* @returns {{ name: string, skills: Set<string>|'*', agents: Set<string> }}
*/
function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
const profiles = _profilesOverride || PROFILES;
function resolveProfile({ modes, manifest, _profilesOverride }: ResolveProfileOpts = {}): ResolvedProfile {
const profiles: Record<string, string | readonly string[]> = _profilesOverride || PROFILES;
const activeModes = (modes && modes.length > 0) ? modes : ['full'];
const normalizedModes = activeModes
.flatMap((mode) => String(mode).split(','))
@@ -228,8 +192,8 @@ function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
return { name: 'full', skills: '*', agents: new Set() };
}
const man = manifest || new Map();
const unionSkills = new Set();
const man = manifest || new Map<string, string[]>();
const unionSkills = new Set<string>();
for (const mode of validModes) {
const base = profiles[mode];
@@ -237,14 +201,14 @@ function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
// This profile is full — sentinel short-circuit
return { name: 'full', skills: '*', agents: new Set() };
}
const closure = computeClosure(base, man);
const closure = computeClosure(base as Iterable<string>, man);
for (const s of closure) unionSkills.add(s);
}
// Derive agents: union of all agent names referenced in the body text of
// every skill in unionSkills. Agent names are stored in the manifest under
// _calls_agents_<stem> keys (populated by loadSkillsManifest).
const unionAgents = new Set();
const unionAgents = new Set<string>();
for (const skillStem of unionSkills) {
const agentRefs = man.get(`_calls_agents_${skillStem}`) || [];
for (const agentStem of agentRefs) {
@@ -264,10 +228,10 @@ function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
// 13 runtime dispatch sites in install.js can each call stageSkillsForMode,
// so accumulating them in a single set avoids leaks without forcing each
// site to track its own cleanup handle.
const STAGED_DIRS = new Set();
const STAGED_DIRS = new Set<string>();
let exitHandlerRegistered = false;
function cleanupStagedSkills() {
function cleanupStagedSkills(): void {
for (const dir of STAGED_DIRS) {
try {
fs.rmSync(dir, { recursive: true, force: true });
@@ -283,9 +247,9 @@ function cleanupStagedSkills() {
// 'exit' event. `process.on('exit')` does NOT fire on these — an installer
// is exactly the kind of process users abort mid-run, so without explicit
// signal handling Ctrl+C would leave staged tmp dirs behind.
const CLEANUP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
const CLEANUP_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
function ensureExitCleanup() {
function ensureExitCleanup(): void {
if (exitHandlerRegistered) return;
exitHandlerRegistered = true;
process.on('exit', cleanupStagedSkills);
@@ -303,12 +267,8 @@ function ensureExitCleanup() {
/**
* Stage a filtered copy of commands/gsd for a resolved profile.
* In full mode (skills === '*') returns srcDir unchanged (no-op).
*
* @param {string} srcDir absolute path to commands/gsd
* @param {{ skills: Set<string>|'*' }} resolvedProfile
* @returns {string} path to staged dir (or srcDir for full)
*/
function stageSkillsForProfile(srcDir, resolvedProfile) {
function stageSkillsForProfile(srcDir: string, resolvedProfile: ResolvedProfile): string {
if (resolvedProfile.skills === '*') return srcDir;
if (!fs.existsSync(srcDir)) return srcDir;
@@ -319,14 +279,14 @@ function stageSkillsForProfile(srcDir, resolvedProfile) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3);
if (!resolvedProfile.skills.has(stem)) continue;
if (!(resolvedProfile.skills).has(stem)) continue;
fs.copyFileSync(
path.join(srcDir, entry.name),
path.join(stageDir, entry.name),
);
}
} catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err;
}
STAGED_DIRS.add(stageDir);
@@ -340,12 +300,8 @@ function stageSkillsForProfile(srcDir, resolvedProfile) {
* For tiered profiles, copies only agents whose full stem (e.g. 'gsd-planner')
* is in resolvedProfile.agents — which is populated by resolveProfile() from
* the _calls_agents_* entries in the manifest.
*
* @param {string} srcAgentsDir absolute path to agents/
* @param {{ agents: Set<string>, skills: Set<string>|'*' }} resolvedProfile
* @returns {string} path to staged dir (or srcAgentsDir for full)
*/
function stageAgentsForProfile(srcAgentsDir, resolvedProfile) {
function stageAgentsForProfile(srcAgentsDir: string, resolvedProfile: ResolvedProfile): string {
if (resolvedProfile.skills === '*') return srcAgentsDir;
if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir;
@@ -367,7 +323,7 @@ function stageAgentsForProfile(srcAgentsDir, resolvedProfile) {
}
// If agents is empty Set, we produce an empty stageDir (no agents for this profile)
} catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err;
}
STAGED_DIRS.add(stageDir);
@@ -375,7 +331,12 @@ function stageAgentsForProfile(srcAgentsDir, resolvedProfile) {
return stageDir;
}
function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix) {
function stageSkillsForRuntimeAsSkills(
srcCommandsDir: string,
resolvedProfile: ResolvedProfile,
converter: (content: string, skillName: string) => string,
prefix: string,
): string {
if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir;
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-skills-'));
@@ -385,7 +346,7 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3);
if (resolvedProfile.skills !== '*' && !resolvedProfile.skills.has(stem)) continue;
if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem)) continue;
const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
const skillName = `${prefix}${stem}`;
const converted = converter(content, skillName);
@@ -394,7 +355,7 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
}
} catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err;
}
STAGED_DIRS.add(stageDir);
@@ -410,11 +371,8 @@ const PROFILE_MARKER_NAME = '.gsd-profile';
/**
* Read the active profile from a runtime config directory.
*
* @param {string} runtimeConfigDir absolute path (e.g. ~/.claude/skills)
* @returns {string|null} profile name (e.g. 'core', 'standard', 'core,audit') or null
*/
function readActiveProfile(runtimeConfigDir) {
function readActiveProfile(runtimeConfigDir: string): string | null {
const markerPath = path.join(runtimeConfigDir, PROFILE_MARKER_NAME);
try {
const raw = fs.readFileSync(markerPath, 'utf8').trim();
@@ -429,11 +387,8 @@ function readActiveProfile(runtimeConfigDir) {
/**
* Persist the active profile to a runtime config directory.
*
* @param {string} runtimeConfigDir absolute path (e.g. ~/.claude/skills)
* @param {string} profileName e.g. 'core', 'standard', 'full'
*/
function writeActiveProfile(runtimeConfigDir, profileName) {
function writeActiveProfile(runtimeConfigDir: string, profileName: string): void {
platformWriteSync(path.join(runtimeConfigDir, PROFILE_MARKER_NAME), profileName + '\n');
}
@@ -445,7 +400,7 @@ function writeActiveProfile(runtimeConfigDir, profileName) {
* Rank ordering for profiles (lower index = more restrictive / smaller skill set).
* Unknown profiles default to the permissive end (treated as 'full').
*/
const PROFILE_RANK = Object.freeze(['core', 'standard', 'full']);
const PROFILE_RANK = Object.freeze(['core', 'standard', 'full'] as const);
/**
* Given an array of profile names (one per runtime), return the most-restrictive
@@ -454,17 +409,14 @@ const PROFILE_RANK = Object.freeze(['core', 'standard', 'full']);
* Ordering (most to least restrictive): core < standard < full.
* Composed profiles (e.g. 'core,audit') and unknown profiles are treated as
* 'full' for this comparison.
*
* @param {string[]} profileNames
* @returns {string}
*/
function mostRestrictiveProfile(profileNames) {
function mostRestrictiveProfile(profileNames: string[]): string {
if (!profileNames || profileNames.length === 0) return 'full';
// Initialize with the least-restrictive rank (one past the end of PROFILE_RANK)
let bestRank = PROFILE_RANK.length;
let bestRank: number = PROFILE_RANK.length;
let bestName = 'full';
for (const name of profileNames) {
const rank = PROFILE_RANK.indexOf(name);
const rank = PROFILE_RANK.indexOf(name as ProfileName);
// Unknown/composed profiles are treated as the permissive 'full' rank.
const effectiveRank = rank === -1 ? PROFILE_RANK.indexOf('full') : rank;
if (effectiveRank < bestRank) {
@@ -475,6 +427,11 @@ function mostRestrictiveProfile(profileNames) {
return bestName;
}
interface ResolveEffectiveProfileOpts {
requestedProfileName: string | null;
targetDir: string;
}
/**
* Resolve the effective profile name for an install() run.
*
@@ -482,17 +439,8 @@ function mostRestrictiveProfile(profileNames) {
* 1. Explicit flag (requestedProfileName != null) → use it as-is.
* 2. Marker exists in targetDir and is not 'full' → use marker.
* 3. Else → 'full' (back-compat for fresh non-interactive installs).
*
* This is the single source-of-truth for the "which profile should this
* install() invocation use?" question. Extracted so it can be unit-tested
* independently of the bin/install.js megafile.
*
* @param {object} opts
* @param {string|null} opts.requestedProfileName explicit flag value (or null)
* @param {string} opts.targetDir runtime config dir (e.g. ~/.claude)
* @returns {string} profile name, e.g. 'core', 'standard', 'full'
*/
function resolveEffectiveProfile({ requestedProfileName, targetDir }) {
function resolveEffectiveProfile({ requestedProfileName, targetDir }: ResolveEffectiveProfileOpts): string {
// 1. Explicit flag overrides everything
if (requestedProfileName != null) return requestedProfileName;
// 2. Marker-driven (gsd update path)
@@ -517,7 +465,7 @@ const MINIMAL_ALLOWLIST_SET = new Set(MINIMAL_SKILL_ALLOWLIST);
/**
* @deprecated Use resolveProfile({ modes: ['core'] }) instead.
*/
function isMinimalMode(mode) {
function isMinimalMode(mode: string): boolean {
return mode === 'minimal' || mode === 'core-only';
}
@@ -528,7 +476,7 @@ function isMinimalMode(mode) {
*
* @deprecated String-mode form; use resolvedProfile object form instead.
*/
function shouldInstallSkill(skillBaseName, resolvedProfileOrMode) {
function shouldInstallSkill(skillBaseName: string, resolvedProfileOrMode: ResolvedProfile | string): boolean {
if (typeof resolvedProfileOrMode === 'object' && resolvedProfileOrMode !== null) {
const { skills } = resolvedProfileOrMode;
if (skills === '*') return true;
@@ -545,11 +493,8 @@ function shouldInstallSkill(skillBaseName, resolvedProfileOrMode) {
* Back-compat wrapper: maps 'minimal' → core profile, 'full' → full.
*
* @deprecated Use stageSkillsForProfile with a resolved profile instead.
* @param {string} srcDir absolute path to commands/gsd
* @param {string} mode 'full' | 'minimal'
* @returns {string} path to use (original or staged tmp)
*/
function stageSkillsForMode(srcDir, mode) {
function stageSkillsForMode(srcDir: string, mode: string): string {
if (!isMinimalMode(mode)) return srcDir;
if (!fs.existsSync(srcDir)) return srcDir;
@@ -567,7 +512,7 @@ function stageSkillsForMode(srcDir, mode) {
);
}
} catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err;
}
STAGED_DIRS.add(stageDir);
@@ -579,7 +524,7 @@ function stageSkillsForMode(srcDir, mode) {
// Exports
// ---------------------------------------------------------------------------
module.exports = {
export = {
// New profile API (ADR-0011)
PROFILES,
PROFILE_RANK,

View File

@@ -0,0 +1,136 @@
/**
* Installer Migration Authoring — validation helpers for installer migration records and actions.
*
* ADR-457 build-at-publish: the hand-written
* bin/lib/installer-migration-authoring.cjs collapsed to a TypeScript source
* of truth. Behaviour is preserved byte-for-behaviour from the prior
* hand-written .cjs; only types are added.
*/
import path from 'node:path';
/** An unvalidated migration record supplied by the caller. */
export type MigrationRecord = Record<string, unknown>;
/** A migration action (open shape). */
export type MigrationAction = Record<string, unknown>;
function getStr(record: MigrationRecord, field: string): string {
const v = record[field];
return typeof v === 'string' ? v : '';
}
function requireNonEmptyString(record: MigrationRecord, field: string, source: string): void {
const v = record[field];
if (typeof v !== 'string' || v.trim() === '') {
throw new Error(`migration record must include a non-empty ${field}: ${source}`);
}
}
function isNonEmptyStringArray(arr: unknown): arr is string[] {
return Array.isArray(arr) && arr.length > 0 && arr.every((v) => typeof v === 'string' && v.trim() !== '');
}
function validateStringArray(record: MigrationRecord, field: string, source: string): void {
if (record[field] === undefined) return;
if (!isNonEmptyStringArray(record[field])) {
throw new Error(`migration record ${field} must be a non-empty string array when provided: ${source}`);
}
}
function requireStringArray(record: MigrationRecord, field: string, source: string): void {
if (!isNonEmptyStringArray(record[field])) {
throw new Error(`migration record ${field} must be a non-empty string array: ${source}`);
}
}
function recordSource(record: MigrationRecord, fallback: string | undefined): string {
const id = getStr(record, 'id');
return fallback ?? (id.trim() ? id : '<unknown>');
}
function actionSource(migration: MigrationRecord, action: MigrationAction): string {
const migrationId = getStr(migration, 'id') || '<unknown>';
const relPath = getStr(action, 'relPath') || '<unknown>';
return `${migrationId} ${relPath}`;
}
function requireActionEvidence(action: MigrationAction, field: string, migration: MigrationRecord): void {
const v = action[field];
if (typeof v !== 'string' || v.trim() === '') {
throw new Error(`migration action ${getStr(action, 'type')} must include ${field}: ${actionSource(migration, action)}`);
}
}
function validateSafeRelPath(relPath: string, migration: MigrationRecord, actionType: string): void {
const source = actionSource(migration, { relPath });
const normalized = relPath.replace(/\\/g, '/');
if (path.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
throw new Error(`migration action ${actionType} relPath must stay inside configDir: ${source}`);
}
const segments = normalized.split('/');
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
throw new Error(`migration action ${actionType} relPath must stay inside configDir: ${source}`);
}
}
export function validateInstallerMigrationRecord(record: unknown, source?: string): MigrationRecord {
const rec = record as MigrationRecord;
const displaySource = recordSource(rec, source);
if (!record || typeof record !== 'object') {
throw new Error(`migration record must export an object: ${displaySource}`);
}
// Authoring contract follows docs/installer-migrations.md#authoring-workflow
// and docs/adr/0008-installer-migration-module.md#decision.
requireNonEmptyString(rec, 'id', displaySource);
requireNonEmptyString(rec, 'title', displaySource);
requireNonEmptyString(rec, 'description', displaySource);
requireNonEmptyString(rec, 'introducedIn', displaySource);
if (typeof rec['destructive'] !== 'boolean') {
throw new Error(`migration record must declare destructive as a boolean: ${displaySource}`);
}
validateStringArray(rec, 'runtimes', displaySource);
requireStringArray(rec, 'scopes', displaySource);
if (typeof rec['plan'] !== 'function') {
throw new Error(`migration record must include a plan function: ${displaySource}`);
}
return rec;
}
export function validateInstallerMigrationActions(actions: unknown, migration: MigrationRecord): MigrationAction[] {
if (!Array.isArray(actions)) {
throw new Error(`migration ${getStr(migration, 'id')} plan must return an array`);
}
for (const action of actions as unknown[]) {
if (!action || typeof action !== 'object') {
throw new Error(`migration action must be an object: ${getStr(migration, 'id')}`);
}
const act = action as MigrationAction;
const actType = getStr(act, 'type');
const actRelPath = getStr(act, 'relPath');
if (!actType || actType.trim() === '') {
throw new Error(`migration action must include a non-empty type: ${getStr(migration, 'id')}`);
}
if (!actRelPath || actRelPath.trim() === '') {
throw new Error(`migration action ${actType} must include a non-empty relPath: ${getStr(migration, 'id')}`);
}
validateSafeRelPath(actRelPath, migration, actType);
// Ownership and runtime-contract evidence are required by
// docs/installer-migrations.md#action-types and
// docs/adr/0008-installer-migration-module.md#runtime-contract-decision.
if (actType === 'remove-managed' || actType === 'rewrite-json') {
requireActionEvidence(act, 'ownershipEvidence', migration);
}
if (actType === 'rewrite-json') {
const rc = getStr(migration, 'runtimeContract');
if (!rc || rc.trim() === '') {
throw new Error(`migration action rewrite-json requires migration runtimeContract: ${actionSource(migration, act)}`);
}
}
}
return actions as MigrationAction[];
}

View File

@@ -1,21 +1,120 @@
'use strict';
/**
* Installer migration report utilities (ADR-457 build-at-publish: the
* hand-written bin/lib/installer-migration-report.cjs collapsed to a TypeScript
* source of truth). Behaviour is preserved byte-for-behaviour from the prior
* hand-written .cjs; only types are added.
*
* Resolution environment variable surface for #3541 — when the installer
* runs without a TTY (typical /gsd:update path via Claude Code or any
* scripted update), prompt-user migration actions cannot be answered
* interactively. Classification-based defaults apply; anything else falls
* through to the hard assertion with a grouped, actionable error message.
*
* docs/installer-migrations.md#prompt-user-resolution for the spec.
*/
// Resolution environment variable surface for #3541 — when the installer
// runs without a TTY (typical /gsd:update path via Claude Code or any
// scripted update), prompt-user migration actions cannot be answered
// interactively. We resolve them by classification:
// - Stale SDK build artifacts (get-shit-done/sdk/{dist,src}/gsd-*):
// default `remove`. Fresh install supplies replacements.
// - User-facing skill anchors (skills/gsd-*/SKILL.md): default `keep`.
// User-owned content is preserved.
// Anything else: fall through to the hard assertion with an improved,
// grouped, actionable error message.
export const RESOLUTION_ENV_VAR = 'GSD_INSTALLER_MIGRATION_RESOLVE';
const VALID_CHOICES: ReadonlyArray<string> = ['keep', 'remove'];
// #3628: explicit whitelist of bundled hook files shipped in the npm
// distribution under `hooks/`. The classifier-based auto-removal of these
// files at first-time-baseline scan (added in #3610) is restricted to this
// set — a shape regex like `^hooks/gsd-[^/]+\.(?:js|sh|cjs|mjs)$` also
// matches user-authored custom hooks and retired bundled hooks from prior
// versions, and auto-removing those is silent data loss.
//
// docs/installer-migrations.md#prompt-user-resolution for the spec.
const RESOLUTION_ENV_VAR = 'GSD_INSTALLER_MIGRATION_RESOLVE';
const VALID_CHOICES = ['keep', 'remove'];
// The bug-3628 regression guard asserts this Set stays aligned with the
// on-disk `hooks/` directory in both directions: whitelist-but-missing
// AND shipped-but-not-whitelisted both fail CI.
export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set([
'hooks/gsd-check-update-worker.js',
'hooks/gsd-check-update.js',
'hooks/gsd-context-monitor.js',
'hooks/gsd-graphify-update.sh',
'hooks/gsd-phase-boundary.sh',
'hooks/gsd-prompt-guard.js',
'hooks/gsd-read-guard.js',
'hooks/gsd-read-injection-scanner.js',
'hooks/gsd-session-state.sh',
'hooks/gsd-statusline.js',
'hooks/gsd-update-banner.js',
'hooks/gsd-validate-commit.sh',
'hooks/gsd-workflow-guard.js',
'hooks/gsd-worktree-path-guard.js',
]));
function installerMigrationActionLabel(action) {
// ── Internal action types ─────────────────────────────────────────────────────
interface MigrationAction {
type: string;
relPath?: string;
reason?: string;
migrationId?: string;
migrationChecksum?: string;
classification?: string;
originalHash?: string | null;
currentHash?: string | null;
requestedType?: string;
backupRelPath?: string | null;
choices?: string[];
deleteIfEmpty?: boolean;
count?: number;
actions?: MigrationAction[];
[key: string]: unknown;
}
interface MigrationPlan {
actions?: MigrationAction[];
blocked?: MigrationAction[];
[key: string]: unknown;
}
interface MigrationResult {
blocked?: MigrationAction[];
plan?: MigrationPlan;
[key: string]: unknown;
}
interface SummaryRow {
label: string;
relPath: string;
reason: string;
action: MigrationAction;
}
interface SummarizeResult {
hasReportableActions: boolean;
blocked: MigrationAction[];
rows: (SummaryRow | null)[];
}
interface Resolution {
relPath: string | undefined;
category: string;
choice: string;
reason: string | undefined;
resolvedActionType: string;
source: string;
}
interface ResolvePromptsResult {
result: MigrationResult;
resolutions: Resolution[];
}
interface ClassifyResult {
category: string;
choice: string;
}
interface ResolveOptions {
isTty?: boolean;
env?: Record<string, string | undefined>;
}
// ── Internal helpers ──────────────────────────────────────────────────────────
function installerMigrationActionLabel(action: MigrationAction | null | undefined): string {
if (!action || !action.type) return 'skipped';
if (action.type === 'backup-and-remove') return 'backed up and removed';
if (action.type === 'remove-managed') return 'removed';
@@ -27,18 +126,18 @@ function installerMigrationActionLabel(action) {
return 'skipped';
}
function blockedInstallerMigrationActions(result) {
function blockedInstallerMigrationActions(result: MigrationResult | null | undefined): MigrationAction[] {
if (result && Array.isArray(result.blocked)) return result.blocked;
const plan = result && result.plan;
if (plan && Array.isArray(plan.blocked)) return plan.blocked;
return [];
}
function baselineSummaryLabel(count, noun) {
function baselineSummaryLabel(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
function baselineSummaryRow(type, actions) {
function baselineSummaryRow(type: string, actions: MigrationAction[]): SummaryRow {
const count = actions.length;
if (type === 'record-baseline') {
return {
@@ -56,14 +155,14 @@ function baselineSummaryRow(type, actions) {
};
}
function summarizeInstallerMigrationResult(result) {
export function summarizeInstallerMigrationResult(result: MigrationResult | null | undefined): SummarizeResult {
const plan = result && result.plan;
const actions = plan && Array.isArray(plan.actions) ? plan.actions : [];
const actions: MigrationAction[] = plan && Array.isArray(plan.actions) ? plan.actions : [];
const blocked = blockedInstallerMigrationActions(result);
const blockedSet = new Set(blocked);
const rows = [];
const baselineIndexes = new Map();
const baselineActions = new Map();
const rows: (SummaryRow | null)[] = [];
const baselineIndexes = new Map<string, number>();
const baselineActions = new Map<string, MigrationAction[]>();
for (const action of actions) {
const type = action && action.type;
@@ -73,13 +172,13 @@ function summarizeInstallerMigrationResult(result) {
baselineIndexes.set(type, rows.length);
rows.push(null);
}
baselineActions.get(type).push(action);
baselineActions.get(type)!.push(action);
continue;
}
rows.push({
label: blockedSet.has(action) ? 'blocked' : installerMigrationActionLabel(action),
relPath: action.relPath,
relPath: action.relPath ?? '',
reason: action.reason || '',
action,
});
@@ -88,7 +187,7 @@ function summarizeInstallerMigrationResult(result) {
// Phase 4 requires action reporting without flooding first-time baseline installs:
// docs/installer-migrations.md#phase-4-installupdate-integration.
for (const [type, baselineRows] of baselineActions) {
rows[baselineIndexes.get(type)] = baselineSummaryRow(type, baselineRows);
rows[baselineIndexes.get(type)!] = baselineSummaryRow(type, baselineRows);
}
return {
@@ -98,33 +197,6 @@ function summarizeInstallerMigrationResult(result) {
};
}
// #3628: explicit whitelist of bundled hook files shipped in the npm
// distribution under `hooks/`. The classifier-based auto-removal of these
// files at first-time-baseline scan (added in #3610) is restricted to this
// set — a shape regex like `^hooks/gsd-[^/]+\.(?:js|sh|cjs|mjs)$` also
// matches user-authored custom hooks and retired bundled hooks from prior
// versions, and auto-removing those is silent data loss.
//
// The bug-3628 regression guard asserts this Set stays aligned with the
// on-disk `hooks/` directory in both directions: whitelist-but-missing
// AND shipped-but-not-whitelisted both fail CI.
const BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
'hooks/gsd-check-update-worker.js',
'hooks/gsd-check-update.js',
'hooks/gsd-context-monitor.js',
'hooks/gsd-graphify-update.sh',
'hooks/gsd-phase-boundary.sh',
'hooks/gsd-prompt-guard.js',
'hooks/gsd-read-guard.js',
'hooks/gsd-read-injection-scanner.js',
'hooks/gsd-session-state.sh',
'hooks/gsd-statusline.js',
'hooks/gsd-update-banner.js',
'hooks/gsd-validate-commit.sh',
'hooks/gsd-workflow-guard.js',
'hooks/gsd-worktree-path-guard.js',
]));
// Classify a blocked prompt-user action into one of the safe-default
// categories. Returns null when no safe default applies — caller must
// fall back to the hard assertion / interactive prompt for those.
@@ -133,7 +205,7 @@ const BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
// and are regenerated on every install, so removing them is lossless.
// User-facing skill anchors are the .md files that surface as commands
// to the user — these are user-owned and must be kept.
function classifyPromptUserAction(action) {
export function classifyPromptUserAction(action: MigrationAction): ClassifyResult | null {
const relPath = action && action.relPath;
if (typeof relPath !== 'string' || !relPath) return null;
if (/^get-shit-done\/sdk\/(dist|src)\//.test(relPath)) {
@@ -159,8 +231,9 @@ function classifyPromptUserAction(action) {
// `keep` → baseline-preserve-user (idempotent — already on disk).
// `remove` → backup-and-remove (safe: keeps a rollback copy in the
// migration journal under gsd-migration-journal/<runId>-backups/).
function materializeResolution(action, choice) {
const base = {
function materializeResolution(action: MigrationAction, choice: string): MigrationAction {
const base: MigrationAction = {
type: '', // overridden in each return branch below
migrationId: action.migrationId,
migrationChecksum: action.migrationChecksum,
relPath: action.relPath,
@@ -177,13 +250,13 @@ function materializeResolution(action, choice) {
return { ...base, type: 'backup-and-remove', backupRelPath: null };
}
function normalizeResolutionChoice(rawValue) {
function normalizeResolutionChoice(rawValue: unknown): string | null {
if (typeof rawValue !== 'string') return null;
const normalized = rawValue.trim().toLowerCase();
return VALID_CHOICES.includes(normalized) ? normalized : null;
}
function actionSupportsChoice(action, choice) {
function actionSupportsChoice(action: MigrationAction, choice: string): boolean {
if (!action || !choice) return false;
if (!Array.isArray(action.choices) || action.choices.length === 0) {
return VALID_CHOICES.includes(choice);
@@ -199,7 +272,10 @@ function actionSupportsChoice(action, choice) {
// could NOT be safely defaulted (caller must still handle those).
// Returns { result, resolutions } where `resolutions` is the structured
// log of every defaulted resolution.
function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
export function resolveInstallerMigrationPromptsForNonTty(
result: MigrationResult,
options: ResolveOptions = {},
): ResolvePromptsResult {
if (!result || typeof result !== 'object') {
return { result, resolutions: [] };
}
@@ -215,19 +291,19 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
return { result, resolutions: [] };
}
const env =
const env: Record<string, string | undefined> =
options && options.env && typeof options.env === 'object'
? options.env
: process.env;
const envChoice = normalizeResolutionChoice(env && env[RESOLUTION_ENV_VAR]);
const resolutions = [];
const unresolved = [];
const resolutions: Resolution[] = [];
const unresolved: MigrationAction[] = [];
for (const action of blocked) {
if (action && action.type === 'prompt-user') {
let category = null;
let choice = null;
let source = null;
let category: string | null = null;
let choice: string | null = null;
let source: string | null = null;
if (envChoice && actionSupportsChoice(action, envChoice)) {
category = 'operator-override';
choice = envChoice;
@@ -257,11 +333,11 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
}
resolutions.push({
relPath: action.relPath,
category,
choice,
category: category ?? '',
choice: choice ?? '',
reason: action.reason,
resolvedActionType: resolved.type,
source,
source: source ?? '',
});
continue;
}
@@ -285,18 +361,18 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
// Group blocked prompt-user actions by their `reason` so the operator
// sees one summary line per cause instead of N path lines for the
// same underlying issue.
function groupBlockedByReason(blocked) {
const byReason = new Map();
function groupBlockedByReason(blocked: MigrationAction[]): Map<string, MigrationAction[]> {
const byReason = new Map<string, MigrationAction[]>();
for (const action of blocked) {
const reason = (action && action.reason) || 'no reason given';
if (!byReason.has(reason)) byReason.set(reason, []);
byReason.get(reason).push(action);
byReason.get(reason)!.push(action);
}
return byReason;
}
function describeChoicesForActions(blocked) {
const choiceSet = new Set();
function describeChoicesForActions(blocked: MigrationAction[]): string[] {
const choiceSet = new Set<string>();
for (const action of blocked) {
if (action && Array.isArray(action.choices)) {
for (const choice of action.choices) choiceSet.add(choice);
@@ -308,12 +384,12 @@ function describeChoicesForActions(blocked) {
return [...choiceSet];
}
function buildBlockedErrorMessage(blocked) {
function buildBlockedErrorMessage(blocked: MigrationAction[]): string {
const byReason = groupBlockedByReason(blocked);
const totalFiles = blocked.length;
const choices = describeChoicesForActions(blocked);
const lines = [
const lines: string[] = [
`installer migration blocked pending user choice: ${totalFiles} file${totalFiles === 1 ? '' : 's'} need a decision`,
` choices: [${choices.join(', ')}]`,
];
@@ -334,22 +410,14 @@ function buildBlockedErrorMessage(blocked) {
return lines.join('\n');
}
function assertInstallerMigrationsUnblocked(result) {
export function assertInstallerMigrationsUnblocked(result: MigrationResult | null | undefined): void {
const blocked = blockedInstallerMigrationActions(result);
if (blocked.length === 0) return;
const message = buildBlockedErrorMessage(blocked);
const error = new Error(message);
error.blocked = blocked;
error.blockedByReason = Object.fromEntries(groupBlockedByReason(blocked));
error.resolutionEnvVar = RESOLUTION_ENV_VAR;
const error = Object.assign(new Error(message), {
blocked,
blockedByReason: Object.fromEntries(groupBlockedByReason(blocked)),
resolutionEnvVar: RESOLUTION_ENV_VAR,
});
throw error;
}
module.exports = {
RESOLUTION_ENV_VAR,
BUNDLED_GSD_HOOK_FILES,
assertInstallerMigrationsUnblocked,
classifyPromptUserAction,
resolveInstallerMigrationPromptsForNonTty,
summarizeInstallerMigrationResult,
};

View File

@@ -1,14 +1,23 @@
'use strict';
/**
* Installer migrations engine — plan, apply, and track filesystem-mutation
* migrations for GSD runtime config directories.
*
* ADR-457 build-at-publish: the hand-written bin/lib/installer-migrations.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const {
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import {
validateInstallerMigrationActions,
validateInstallerMigrationRecord,
} = require('./installer-migration-authoring.cjs');
const { platformWriteSync } = require('./shell-command-projection.cjs');
const { realClock } = require('./clock.cjs');
type MigrationRecord,
type MigrationAction,
} from './installer-migration-authoring.cjs';
import { platformWriteSync } from './shell-command-projection.cjs';
import { realClock, type Clock } from './clock.cjs';
const MANIFEST_NAME = 'gsd-file-manifest.json';
const INSTALL_STATE_NAME = 'gsd-install-state.json';
@@ -17,7 +26,7 @@ const DEFAULT_MIGRATIONS_DIR = path.join(__dirname, 'installer-migrations');
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
const STRICT_JSON = Symbol('strict-json');
function sha256File(filePath) {
function sha256File(filePath: string): string {
const hash = crypto.createHash('sha256');
const buffer = Buffer.allocUnsafe(1024 * 1024);
const fd = fs.openSync(filePath, 'r');
@@ -33,50 +42,64 @@ function sha256File(filePath) {
return hash.digest('hex');
}
function sha256Text(value) {
function sha256Text(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex');
}
function readJsonIfPresent(filePath, fallback) {
function readJsonIfPresent(filePath: string, fallback: unknown): unknown {
if (!fs.existsSync(filePath)) return fallback;
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
if (fallback === STRICT_JSON) {
throw new Error(`invalid installer migration state JSON: ${filePath}: ${error.message}`);
throw new Error(`invalid installer migration state JSON: ${filePath}: ${(error as Error).message}`);
}
return fallback;
}
}
function readInstallManifest(configDir) {
interface InstallManifest {
version: string | null;
timestamp: string | null;
mode: string | null;
files: Record<string, string>;
}
function readInstallManifest(configDir: string): InstallManifest {
const manifest = readJsonIfPresent(path.join(configDir, MANIFEST_NAME), null);
if (!manifest || typeof manifest !== 'object') {
return { version: null, timestamp: null, mode: null, files: {} };
}
const m = manifest as Record<string, unknown>;
return {
version: manifest.version || null,
timestamp: manifest.timestamp || null,
mode: manifest.mode || null,
files: manifest.files && typeof manifest.files === 'object' ? manifest.files : {},
version: typeof m.version === 'string' ? m.version : null,
timestamp: typeof m.timestamp === 'string' ? m.timestamp : null,
mode: typeof m.mode === 'string' ? m.mode : null,
files: m.files && typeof m.files === 'object' ? m.files as Record<string, string> : {},
};
}
function readInstallState(configDir) {
interface InstallState {
schemaVersion: number;
appliedMigrations: Array<Record<string, unknown>>;
}
function readInstallState(configDir: string): InstallState {
const state = readJsonIfPresent(path.join(configDir, INSTALL_STATE_NAME), STRICT_JSON);
if (!state || typeof state !== 'object') {
return { schemaVersion: 1, appliedMigrations: [] };
}
const s = state as Record<string, unknown>;
return {
schemaVersion: state.schemaVersion || 1,
appliedMigrations: Array.isArray(state.appliedMigrations) ? state.appliedMigrations : [],
schemaVersion: typeof s.schemaVersion === 'number' ? s.schemaVersion : 1,
appliedMigrations: Array.isArray(s.appliedMigrations) ? s.appliedMigrations as Array<Record<string, unknown>> : [],
};
}
// Strict atomic write for the install state: must never be left half-written.
// Bypasses the seam because platformWriteSync falls back to a direct write on
// rename failure, which would silently violate this invariant.
function atomicWriteInstallState(configDir, content) {
function atomicWriteInstallState(configDir: string, content: string): void {
fs.mkdirSync(configDir, { recursive: true });
const filePath = path.join(configDir, INSTALL_STATE_NAME);
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
@@ -89,12 +112,18 @@ function atomicWriteInstallState(configDir, content) {
}
}
function writeInstallState(configDir, state) {
function writeInstallState(configDir: string, state: InstallState): InstallState {
atomicWriteInstallState(configDir, JSON.stringify(state, null, 2) + '\n');
return state;
}
function readJson(configDir, relPath) {
interface ReadJsonResult {
exists: boolean;
value: unknown;
error: Error | null;
}
function readJson(configDir: string, relPath: string): ReadJsonResult {
const { fullPath } = ensureInsideConfig(configDir, relPath);
if (!fs.existsSync(fullPath)) {
return { exists: false, value: null, error: null };
@@ -102,11 +131,11 @@ function readJson(configDir, relPath) {
try {
return { exists: true, value: JSON.parse(fs.readFileSync(fullPath, 'utf8')), error: null };
} catch (error) {
return { exists: true, value: null, error };
return { exists: true, value: null, error: error as Error };
}
}
function normalizeRelPath(relPath) {
function normalizeRelPath(relPath: string): string {
if (typeof relPath !== 'string' || relPath.trim() === '') {
throw new Error('migration action relPath must be a non-empty string');
}
@@ -121,7 +150,13 @@ function normalizeRelPath(relPath) {
return segments.join('/');
}
function classifyArtifact(configDir, relPath, manifest) {
interface ArtifactClassification {
classification: string;
originalHash: string | null;
currentHash: string | null;
}
function classifyArtifact(configDir: string, relPath: string, manifest: InstallManifest): ArtifactClassification {
const normalized = normalizeRelPath(relPath);
const originalHash = manifest.files[normalized] || null;
const fullPath = path.join(configDir, normalized);
@@ -138,16 +173,16 @@ function classifyArtifact(configDir, relPath, manifest) {
return { classification: 'managed-modified', originalHash, currentHash };
}
function appliedMigrationIds(state) {
function appliedMigrationIds(state: InstallState): Set<string> {
return new Set(
state.appliedMigrations
.filter((entry) => entry && typeof entry.id === 'string')
.map((entry) => entry.id)
.map((entry) => entry.id as string)
);
}
function appliedMigrationEntries(state) {
const entries = new Map();
function appliedMigrationEntries(state: InstallState): Map<string, Record<string, unknown>> {
const entries = new Map<string, Record<string, unknown>>();
for (const entry of state.appliedMigrations) {
if (entry && typeof entry.id === 'string' && !entries.has(entry.id)) {
entries.set(entry.id, entry);
@@ -156,7 +191,7 @@ function appliedMigrationEntries(state) {
return entries;
}
function migrationChecksum(migration) {
function migrationChecksum(migration: MigrationRecord): string {
const checksum = migration.checksum;
if (typeof checksum === 'string' && checksum) return checksum;
const serializable = {
@@ -168,35 +203,35 @@ function migrationChecksum(migration) {
scopes: migration.scopes || null,
destructive: migration.destructive === true,
runtimeContract: migration.runtimeContract || null,
plan: typeof migration.plan === 'function' ? migration.plan.toString() : null,
plan: typeof migration.plan === 'function' ? (migration.plan as (...args: unknown[]) => unknown).toString() : null,
};
return `sha256:${sha256Text(JSON.stringify(serializable))}`;
}
function assertAppliedMigrationChecksums(applied, migrations) {
function assertAppliedMigrationChecksums(applied: Map<string, Record<string, unknown>>, migrations: MigrationRecord[]): void {
for (const migration of migrations) {
const entry = applied.get(migration.id);
const entry = applied.get(migration.id as string);
if (!entry || !entry.checksum) continue;
const checksum = migrationChecksum(migration);
if (entry.checksum !== checksum) {
throw new Error(
`applied migration checksum changed for ${migration.id}; create a new fix-forward migration id`
`applied migration checksum changed for ${migration.id as string}; create a new fix-forward migration id`
);
}
}
}
function migrationMatchesContext(migration, { runtime, scope }) {
if (Array.isArray(migration.runtimes) && migration.runtimes.length > 0) {
if (!runtime || !migration.runtimes.includes(runtime)) return false;
function migrationMatchesContext(migration: MigrationRecord, { runtime, scope }: { runtime: string | null; scope: string | null }): boolean {
if (Array.isArray(migration.runtimes) && (migration.runtimes as string[]).length > 0) {
if (!runtime || !(migration.runtimes as string[]).includes(runtime)) return false;
}
if (Array.isArray(migration.scopes) && migration.scopes.length > 0) {
if (!scope || !migration.scopes.includes(scope)) return false;
if (Array.isArray(migration.scopes) && (migration.scopes as string[]).length > 0) {
if (!scope || !(migration.scopes as string[]).includes(scope)) return false;
}
return true;
}
function discoverInstallerMigrations({ migrationsDir }) {
function discoverInstallerMigrations({ migrationsDir }: { migrationsDir: string }): MigrationRecord[] {
if (!migrationsDir || !fs.existsSync(migrationsDir)) return [];
return fs.readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.cjs'))
@@ -204,22 +239,24 @@ function discoverInstallerMigrations({ migrationsDir }) {
.sort()
.flatMap((fileName) => {
const source = path.join(migrationsDir, fileName);
delete require.cache[require.resolve(source)];
const exported = require(source);
// eslint-disable-next-line @typescript-eslint/no-require-imports
const exported: unknown = require(source);
const records = Array.isArray(exported) ? exported : [exported];
return records.map((record) => validateInstallerMigrationRecord(record, source));
return records.map((record) => validateInstallerMigrationRecord(record as MigrationRecord, source));
});
}
function journalTimestamp(now) {
function journalTimestamp(now: () => string): string {
return now().replace(/[:.]/g, '-');
}
function migrationRunId(appliedAt) {
function migrationRunId(appliedAt: string): string {
return `${journalTimestamp(() => appliedAt)}-${crypto.randomBytes(8).toString('hex')}`;
}
function sleepSync(ms) {
function sleepSync(ms: number): void {
const buffer = new SharedArrayBuffer(4);
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
}
@@ -231,26 +268,31 @@ function sleepSync(ms) {
* Returns true if alive or permission-denied (live but not ours),
* false if ESRCH (no such process).
*/
function isPidAlive(pid) {
function isPidAlive(pid: number): boolean {
if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true; // alive (or permission denied — treat as live)
} catch (err) {
return err.code !== 'ESRCH';
return (err as NodeJS.ErrnoException).code !== 'ESRCH';
}
}
interface LockFileData {
pid: number;
acquiredAt: string;
}
/**
* Try to read and parse the lock file JSON. Returns null on any error
* (missing, invalid JSON, I/O failure).
*/
function readLockFile(lockPath) {
function readLockFile(lockPath: string): LockFileData | null {
try {
const raw = fs.readFileSync(lockPath, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && typeof parsed.pid === 'number') {
return parsed;
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && typeof (parsed as Record<string, unknown>).pid === 'number') {
return parsed as LockFileData;
}
return null;
} catch {
@@ -258,13 +300,17 @@ function readLockFile(lockPath) {
}
}
function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEOUT_MS } = {}, clock = realClock) {
function acquireInstallMigrationLock(
configDir: string,
{ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS }: { timeoutMs?: number } = {},
clock: Clock = realClock,
): () => void {
fs.mkdirSync(configDir, { recursive: true });
const lockPath = path.join(configDir, INSTALL_MIGRATION_LOCK_NAME);
const started = clock.now();
while (true) {
let fd = null;
let fd: number | null = null;
let lockCreatedByUs = false;
try {
fd = fs.openSync(lockPath, 'wx');
@@ -281,14 +327,14 @@ function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEO
}) + '\n');
lockCreatedByUs = false; // release closure owns cleanup from here
return () => {
const failures = [];
const failures: Error[] = [];
// Use unlinkSync (not rmSync with { force: true }) so EPERM errors
// are NOT silently swallowed. On Windows, if the unlink fails
// transiently, the error surfaces via releaseError so the caller
// can observe and surface it rather than leaving a stale lock.
try { fs.unlinkSync(lockPath); } catch (error) { failures.push(error); }
try { fs.unlinkSync(lockPath); } catch (error) { failures.push(error as Error); }
if (failures.length > 0) {
const releaseError = new Error(`failed to release installer migration lock: ${lockPath}`);
const releaseError = new Error(`failed to release installer migration lock: ${lockPath}`) as Error & { failures: Error[] };
releaseError.failures = failures;
throw releaseError;
}
@@ -304,7 +350,8 @@ function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEO
// so it does not orphan as an unreadable (empty/invalid JSON) stale lock.
try { fs.unlinkSync(lockPath); } catch { /* best-effort */ }
}
if (error && error.code === 'EEXIST') {
const err = error as NodeJS.ErrnoException;
if (err && err.code === 'EEXIST') {
// Stale-lock reclamation: read the on-disk PID and check liveness.
// If the PID is dead (ESRCH) or is our own process (same-process
// re-entry caused by rmSync silently swallowing an unlink error on
@@ -337,7 +384,12 @@ function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEO
}
}
function ensureInsideConfig(configDir, relPath) {
interface EnsureInsideConfigResult {
normalized: string;
fullPath: string;
}
function ensureInsideConfig(configDir: string, relPath: string): EnsureInsideConfigResult {
const normalized = normalizeRelPath(relPath);
const fullPath = path.resolve(configDir, normalized);
const root = path.resolve(configDir);
@@ -347,18 +399,60 @@ function ensureInsideConfig(configDir, relPath) {
return { normalized, fullPath };
}
function isStructurallyEmpty(value) {
function isStructurallyEmpty(value: unknown): boolean {
if (value === null || value === undefined) return true;
if (Array.isArray(value)) return value.length === 0;
return typeof value === 'object' && Object.keys(value).length === 0;
}
interface JournalAction extends Record<string, unknown> {
status: string;
}
function journalAction(action, status, extras = {}) {
const { value, ...safeAction } = action;
function journalAction(action: MigrationAction, status: string, extras: Record<string, unknown> = {}): JournalAction {
const { value: _value, ...safeAction } = action;
return { ...safeAction, ...extras, status };
}
interface PlanContext {
configDir: string;
runtime: string | null;
scope: string | null;
manifest: InstallManifest;
state: InstallState;
baselineScan: boolean;
now: () => string;
classifyArtifact: (relPath: string) => ArtifactClassification;
readJson: (relPath: string) => ReadJsonResult;
}
interface PlannedAction extends MigrationAction {
migrationId: string;
migrationChecksum: string;
type: string;
relPath: string;
reason: string;
classification: string;
originalHash: string | null;
currentHash: string | null;
requestedType?: string;
backupRelPath?: string | null;
value?: unknown;
deleteIfEmpty?: boolean;
prompt?: unknown;
choices?: unknown[];
}
interface MigrationPlan {
generatedAt: string;
manifest: InstallManifest;
state: InstallState;
pendingMigrationIds: string[];
pendingMigrations: MigrationRecord[];
actions: PlannedAction[];
blocked: PlannedAction[];
}
function planInstallerMigrations({
configDir,
runtime = null,
@@ -366,7 +460,14 @@ function planInstallerMigrations({
migrations,
baselineScan = false,
now = () => new Date().toISOString(),
}) {
}: {
configDir: string;
runtime?: string | null;
scope?: string | null;
migrations: MigrationRecord[];
baselineScan?: boolean;
now?: () => string;
}): MigrationPlan {
if (!configDir) throw new Error('configDir is required');
if (!Array.isArray(migrations)) throw new Error('migrations must be an array');
@@ -380,20 +481,21 @@ function planInstallerMigrations({
);
const applied = appliedMigrationEntries(state);
assertAppliedMigrationChecksums(applied, scopedMigrations);
const pending = scopedMigrations.filter((migration) => !applied.has(migration.id));
const actions = [];
const blocked = [];
const classifications = new Map();
const classify = (relPath) => {
const pending = scopedMigrations.filter((migration) => !applied.has(migration.id as string));
const actions: PlannedAction[] = [];
const blocked: PlannedAction[] = [];
const classifications = new Map<string, ArtifactClassification>();
const classify = (relPath: string): ArtifactClassification => {
const normalized = normalizeRelPath(relPath);
if (!classifications.has(normalized)) {
classifications.set(normalized, classifyArtifact(configDir, normalized, manifest));
}
return classifications.get(normalized);
return classifications.get(normalized)!;
};
for (const migration of pending) {
const plannedActions = migration.plan({
const planFn = migration.plan as (ctx: PlanContext) => unknown[];
const plannedActions = planFn({
configDir,
runtime,
scope,
@@ -406,34 +508,34 @@ function planInstallerMigrations({
});
validateInstallerMigrationActions(plannedActions, migration);
const checksum = migrationChecksum(migration);
for (const rawAction of plannedActions) {
const relPath = normalizeRelPath(rawAction.relPath);
for (const rawAction of plannedActions as MigrationAction[]) {
const relPath = normalizeRelPath(rawAction.relPath as string);
const classification = rawAction.classification
? {
classification: rawAction.classification,
originalHash: rawAction.originalHash || null,
currentHash: rawAction.currentHash || null,
classification: rawAction.classification as string,
originalHash: rawAction.originalHash as string | null || null,
currentHash: rawAction.currentHash as string | null || null,
}
: classify(relPath);
let protectedType = rawAction.type;
let protectedType = rawAction.type as string;
if (rawAction.type === 'remove-managed' && classification.classification === 'managed-modified') {
protectedType = 'backup-and-remove';
}
if (rawAction.type === 'remove-managed' && classification.classification === 'unknown') {
protectedType = 'preserve-user';
}
const action = {
migrationId: migration.id,
const action: PlannedAction = {
migrationId: migration.id as string,
migrationChecksum: checksum,
type: protectedType,
relPath,
reason: rawAction.reason || migration.description || '',
reason: rawAction.reason as string || migration.description as string || '',
classification: classification.classification,
originalHash: classification.originalHash,
currentHash: classification.currentHash,
};
if (action.type !== rawAction.type) {
action.requestedType = rawAction.type;
action.requestedType = rawAction.type as string | undefined;
}
if (action.type === 'backup-and-remove') {
action.backupRelPath = null;
@@ -443,7 +545,7 @@ function planInstallerMigrations({
action.deleteIfEmpty = rawAction.deleteIfEmpty === true;
}
if (rawAction.prompt) action.prompt = rawAction.prompt;
if (Array.isArray(rawAction.choices)) action.choices = rawAction.choices;
if (Array.isArray(rawAction.choices)) action.choices = rawAction.choices as unknown[];
if (action.type === 'prompt-user') {
blocked.push(action);
} else if (
@@ -462,34 +564,43 @@ function planInstallerMigrations({
generatedAt: now(),
manifest,
state,
pendingMigrationIds: pending.map((migration) => migration.id),
pendingMigrationIds: pending.map((migration) => migration.id as string),
pendingMigrations: pending,
actions,
blocked,
};
}
function uniqueActionMigrationIds(actions) {
function uniqueActionMigrationIds(actions: PlannedAction[]): string[] {
return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))];
}
function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }) {
const failures = [];
interface RollbackArgs {
configDir: string;
journal: { actions: JournalAction[] };
journalPath: string;
rollbackRoot: string;
backupRoot: string;
previousInstallStateBytes: string | null;
}
function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }: RollbackArgs): void {
const failures: Array<{ relPath: string; error: string }> = [];
for (const action of [...journal.actions].reverse()) {
if (!action.rollbackRelPath) continue;
const rollbackPath = path.join(configDir, action.rollbackRelPath);
const dest = path.join(configDir, action.relPath);
const rollbackPath = path.join(configDir, action.rollbackRelPath as string);
const dest = path.join(configDir, action.relPath as string);
try {
if (fs.existsSync(rollbackPath)) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(rollbackPath, dest);
}
} catch (error) {
failures.push({ relPath: action.relPath, error: error.message });
failures.push({ relPath: action.relPath as string, error: (error as Error).message });
}
if (action.backupRelPath) {
try {
fs.rmSync(path.join(configDir, action.backupRelPath), { force: true });
fs.rmSync(path.join(configDir, action.backupRelPath as string), { force: true });
} catch {
// backup cleanup is best-effort; preserve restore failures above
}
@@ -503,7 +614,7 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
atomicWriteInstallState(configDir, previousInstallStateBytes);
}
} catch (error) {
failures.push({ relPath: INSTALL_STATE_NAME, error: error.message });
failures.push({ relPath: INSTALL_STATE_NAME, error: (error as Error).message });
}
try {
@@ -515,19 +626,33 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
}
if (failures.length > 0) {
const error = new Error('migration rollback incomplete');
const error = new Error('migration rollback incomplete') as Error & { rollbackFailures: typeof failures };
error.rollbackFailures = failures;
throw error;
}
}
function cleanupMigrationRunArtifacts(journalPath, rollbackRoot, backupRoot) {
function cleanupMigrationRunArtifacts(journalPath: string, rollbackRoot: string, backupRoot: string): void {
try { fs.rmSync(journalPath, { force: true }); } catch { /* best-effort */ }
try { fs.rmSync(rollbackRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
try { fs.rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
}
function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().toISOString() }) {
interface ApplyResult {
appliedMigrationIds: string[];
journalRelPath: string;
rollback: () => void;
}
function applyInstallerMigrationPlan({
configDir,
plan,
now = () => new Date().toISOString(),
}: {
configDir: string;
plan: MigrationPlan;
now?: () => string;
}): ApplyResult {
if (!configDir) throw new Error('configDir is required');
if (!plan || !Array.isArray(plan.actions)) throw new Error('plan with actions is required');
if (Array.isArray(plan.blocked) && plan.blocked.length > 0) {
@@ -542,13 +667,13 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
const rollbackRoot = path.join(configDir, rollbackRootRelPath);
const backupRootRelPath = path.posix.join('gsd-migration-journal', `${runId}-backups`);
const backupRoot = path.join(configDir, backupRootRelPath);
const journal = {
const journal: { schemaVersion: number; appliedAt: string; appliedMigrationIds: string[]; actions: JournalAction[] } = {
schemaVersion: 1,
appliedAt,
appliedMigrationIds: uniqueActionMigrationIds(plan.actions),
actions: [],
};
const rollback = [];
const rollback: Array<{ relPath: string; rollbackPath: string }> = [];
const installStatePath = path.join(configDir, INSTALL_STATE_NAME);
const previousInstallStateBytes = fs.existsSync(installStatePath)
? fs.readFileSync(installStatePath, 'utf8')
@@ -622,7 +747,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
const state = readInstallState(configDir);
const applied = appliedMigrationIds(state);
const nextApplied = [...state.appliedMigrations];
const actionsByMigrationId = new Map();
const actionsByMigrationId = new Map<string, PlannedAction>();
for (const action of plan.actions) {
if (action.migrationId && !actionsByMigrationId.has(action.migrationId)) {
actionsByMigrationId.set(action.migrationId, action);
@@ -650,7 +775,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
rollback: () => rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }),
};
} catch (error) {
const rollbackFailures = [];
const rollbackFailures: Array<{ relPath: string; rollbackPath: string; error: string }> = [];
for (const entry of rollback.reverse()) {
const dest = path.join(configDir, entry.relPath);
try {
@@ -660,12 +785,12 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
rollbackFailures.push({
relPath: entry.relPath,
rollbackPath: entry.rollbackPath,
error: rollbackError.message,
error: (rollbackError as Error).message,
});
}
}
if (rollbackFailures.length > 0) {
const rollbackError = new Error(`migration apply failed and rollback incomplete: ${error.message}`);
const rollbackError = new Error(`migration apply failed and rollback incomplete: ${(error as Error).message}`) as Error & { cause: unknown; rollbackFailures: typeof rollbackFailures };
rollbackError.cause = error;
rollbackError.rollbackFailures = rollbackFailures;
throw rollbackError;
@@ -675,19 +800,27 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
}
}
function markPendingMigrationsApplied({ configDir, plan, now = () => new Date().toISOString() }) {
function markPendingMigrationsApplied({
configDir,
plan,
now = () => new Date().toISOString(),
}: {
configDir: string;
plan: MigrationPlan;
now?: () => string;
}): string[] {
if (!plan || !Array.isArray(plan.pendingMigrationIds) || plan.pendingMigrationIds.length === 0) {
return [];
}
const appliedAt = now();
const state = readInstallState(configDir);
const applied = appliedMigrationIds(state);
const checksumsByMigrationId = new Map();
const checksumsByMigrationId = new Map<string, string>();
for (const migration of plan.pendingMigrations || []) {
checksumsByMigrationId.set(migration.id, migrationChecksum(migration));
checksumsByMigrationId.set(migration.id as string, migrationChecksum(migration));
}
const nextApplied = [...state.appliedMigrations];
const newlyApplied = [];
const newlyApplied: string[] = [];
for (const id of plan.pendingMigrationIds) {
if (applied.has(id)) continue;
nextApplied.push({
@@ -707,6 +840,14 @@ function markPendingMigrationsApplied({ configDir, plan, now = () => new Date().
return newlyApplied;
}
interface RunResult {
appliedMigrationIds: string[];
journalRelPath: string | null;
plan: MigrationPlan;
blocked?: PlannedAction[];
rollback?: () => void;
}
function runInstallerMigrations({
configDir,
runtime = null,
@@ -716,17 +857,26 @@ function runInstallerMigrations({
baselineScan = false,
now = () => new Date().toISOString(),
lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
} = {}) {
}: {
configDir: string;
runtime?: string | null;
scope?: string | null;
migrationsDir?: string;
migrations?: MigrationRecord[];
baselineScan?: boolean;
now?: () => string;
lockTimeoutMs?: number;
} = { configDir: '' }): RunResult {
const releaseLock = acquireInstallMigrationLock(configDir, { timeoutMs: lockTimeoutMs });
let primaryError = null;
let primaryError: (Error & { suppressed?: Error[] }) | null = null;
let completed = false;
try {
const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, baselineScan, now });
if (plan.actions.length === 0) {
const appliedMigrationIds = markPendingMigrationsApplied({ configDir, plan, now });
const newlyApplied = markPendingMigrationsApplied({ configDir, plan, now });
completed = true;
return {
appliedMigrationIds,
appliedMigrationIds: newlyApplied,
journalRelPath: null,
plan,
};
@@ -744,14 +894,14 @@ function runInstallerMigrations({
completed = true;
return { ...result, plan };
} catch (error) {
primaryError = error;
primaryError = error as Error & { suppressed?: Error[] };
throw error;
} finally {
try {
releaseLock();
} catch (releaseError) {
if (primaryError) {
primaryError.suppressed = [...(primaryError.suppressed || []), releaseError];
primaryError.suppressed = [...(primaryError.suppressed || []), releaseError as Error];
} else if (completed) {
throw releaseError;
} else {
@@ -761,7 +911,11 @@ function runInstallerMigrations({
}
}
module.exports = {
// Unused but kept to satisfy eslint — sleepSync is referenced in the original
// and may be used by test code that patches this module.
void sleepSync;
export = {
DEFAULT_MIGRATIONS_DIR,
INSTALL_MIGRATION_LOCK_NAME,
INSTALL_STATE_NAME,

View File

@@ -1,18 +1,21 @@
'use strict';
/**
* Installer migration: record first-time installer migration baseline.
*
* ADR-457 build-at-publish: the hand-written
* bin/lib/installer-migrations/000-first-time-baseline.cjs collapsed to a
* TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
const fs = require('fs');
const path = require('path');
import fs from 'node:fs';
import path from 'node:path';
const BASELINE_MIGRATION_ID = '2026-05-11-first-time-baseline-scan';
// Runtime install surfaces must stay aligned with:
// - docs/installer-migrations.md#runtime-configuration-contract-registry
// - docs/ARCHITECTURE.md#runtime-install-contract-matrix
//
// The registry rows are based on each runtime's upstream loader docs where
// available. Source-limited rows are intentionally conservative: scan generated
// files GSD materializes, but do not infer ownership of undocumented host config.
const RUNTIME_SURFACES = {
const RUNTIME_SURFACES: Record<string, string[]> = {
claude: ['get-shit-done', 'commands/gsd', 'skills', 'agents', 'hooks', 'settings.json'],
codex: ['get-shit-done', 'skills', 'agents', 'hooks', 'config.toml', 'hooks.json'],
gemini: ['get-shit-done', 'commands/gsd', 'hooks'],
@@ -42,25 +45,24 @@ const USER_OWNED_PATHS = new Set([
'commands/gsd/dev-preferences.md',
'skills/gsd-dev-preferences/SKILL.md',
]);
let knownGeneratedAgentNames = null;
let knownGeneratedAgentNames: Set<string> | null = null;
function normalizeRelPath(relPath) {
function normalizeRelPath(relPath: string): string {
return relPath.replace(/\\/g, '/').replace(/^\/+/, '');
}
function baselineInstallSurfaces(runtime) {
function baselineInstallSurfaces(runtime: string | undefined): string[] {
if (runtime && RUNTIME_SURFACES[runtime]) return RUNTIME_SURFACES[runtime];
return COMMON_SURFACES;
}
function walkFiles(root, relDir, files) {
function walkFiles(root: string, relDir: string, files: Set<string>): void {
const dir = path.join(root, relDir);
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const relPath = path.posix.join(relDir, entry.name);
if (relDir === '' && INTERNAL_TOP_LEVEL_NAMES.has(entry.name)) continue;
const fullPath = path.join(root, relPath);
if (entry.isDirectory()) {
walkFiles(root, relPath, files);
} else if (entry.isFile()) {
@@ -69,8 +71,8 @@ function walkFiles(root, relDir, files) {
}
}
function scanBaselineFiles(configDir, runtime) {
const relPaths = new Set();
function scanBaselineFiles(configDir: string, runtime: string | undefined): string[] {
const relPaths = new Set<string>();
for (const surface of baselineInstallSurfaces(runtime)) {
const normalized = normalizeRelPath(surface);
const fullPath = path.join(configDir, normalized);
@@ -85,7 +87,7 @@ function scanBaselineFiles(configDir, runtime) {
return [...relPaths];
}
function isUserOwnedBaselinePath(relPath) {
function isUserOwnedBaselinePath(relPath: string): boolean {
if (USER_OWNED_PATHS.has(relPath)) return true;
const parts = relPath.split('/');
if (parts[0] === 'skills' && parts[1] && !parts[1].startsWith('gsd-')) return true;
@@ -93,10 +95,10 @@ function isUserOwnedBaselinePath(relPath) {
return false;
}
function listKnownGeneratedAgentNames() {
function listKnownGeneratedAgentNames(): Set<string> {
if (knownGeneratedAgentNames) return knownGeneratedAgentNames;
knownGeneratedAgentNames = new Set();
knownGeneratedAgentNames = new Set<string>();
const agentsDir = path.resolve(__dirname, '..', '..', '..', '..', 'agents');
try {
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
@@ -112,7 +114,7 @@ function listKnownGeneratedAgentNames() {
return knownGeneratedAgentNames;
}
function isKnownGeneratedAgentPath(relPath, runtime) {
function isKnownGeneratedAgentPath(relPath: string, runtime: string | undefined): boolean {
const parts = relPath.split('/');
if (parts.length !== 2 || parts[0] !== 'agents') return false;
const fileName = parts[1];
@@ -123,7 +125,7 @@ function isKnownGeneratedAgentPath(relPath, runtime) {
return listKnownGeneratedAgentNames().has(agentName);
}
function isStaleGsdLookingPath(relPath) {
function isStaleGsdLookingPath(relPath: string): boolean {
const baseName = path.posix.basename(relPath);
if (/^gsd[-_]/.test(baseName)) return true;
const parts = relPath.split('/');
@@ -133,27 +135,61 @@ function isStaleGsdLookingPath(relPath) {
return false;
}
function baselineActionRank(action) {
interface BaselineAction {
type: string;
relPath: string;
reason: string;
classification?: string;
originalHash?: string | null;
currentHash?: string | null;
prompt?: string;
choices?: string[];
}
function baselineActionRank(action: BaselineAction): number {
if (action.type === 'record-baseline') return 0;
if (action.type === 'baseline-preserve-user') return 1;
return 2;
}
module.exports = {
interface ClassifiedArtifact {
classification: string;
originalHash?: string | null;
currentHash?: string | null;
[key: string]: unknown;
}
interface PlanContext {
configDir: string;
runtime?: string;
baselineScan?: boolean;
classifyArtifact: (relPath: string) => ClassifiedArtifact;
}
interface InstallerMigration {
id: string;
title: string;
description: string;
introducedIn: string;
scopes: string[];
destructive: boolean;
plan: (ctx: PlanContext) => BaselineAction[];
}
const migration: InstallerMigration = {
id: BASELINE_MIGRATION_ID,
title: 'Record first-time installer migration baseline',
description: 'Classify existing install surfaces before destructive installer migrations run.',
introducedIn: '1.50.0',
scopes: ['global', 'local'],
destructive: false,
plan: ({ configDir, runtime, baselineScan, classifyArtifact }) => {
plan: ({ configDir, runtime, baselineScan, classifyArtifact }: PlanContext): BaselineAction[] => {
if (!baselineScan) return [];
const actions = [];
const actions: BaselineAction[] = [];
for (const relPath of scanBaselineFiles(configDir, runtime)) {
// docs/installer-migrations.md#baseline-preserve-user keeps user-owned
// artifacts out of destructive migration flow; classify later only when
// ownership is not already known.
// artifacts out of destructive migration flow.
if (isUserOwnedBaselinePath(relPath)) {
actions.push({
type: 'baseline-preserve-user',
@@ -176,14 +212,14 @@ module.exports = {
continue;
}
const currentHash = artifact.currentHash;
const currentHash = artifact.currentHash ?? null;
if (isKnownGeneratedAgentPath(relPath, runtime)) {
actions.push({
type: 'record-baseline',
relPath,
reason: 'known installer-generated agent included in first-time migration baseline',
classification: artifact.classification,
originalHash: artifact.originalHash,
originalHash: artifact.originalHash ?? null,
currentHash,
});
continue;
@@ -195,7 +231,7 @@ module.exports = {
relPath,
reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice',
classification: 'stale-gsd-looking',
originalHash: artifact.originalHash,
originalHash: artifact.originalHash ?? null,
currentHash,
prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.',
choices: ['keep', 'remove'],
@@ -208,13 +244,16 @@ module.exports = {
relPath,
reason: 'unknown install-surface file preserved by first-time migration baseline',
classification: artifact.classification,
originalHash: artifact.originalHash,
originalHash: artifact.originalHash ?? null,
currentHash,
});
}
return actions.sort((left, right) =>
return actions.sort(
(left, right) =>
baselineActionRank(left) - baselineActionRank(right) || left.relPath.localeCompare(right.relPath)
);
},
};
export = migration;

View File

@@ -1,11 +1,47 @@
'use strict';
/**
* Installer migration: remove manifest-managed legacy orphan hook files
* (ADR-457 build-at-publish: the hand-written
* bin/lib/installer-migrations/001-legacy-orphan-files.cjs collapsed to a
* TypeScript source of truth). Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
const LEGACY_ORPHAN_FILES = [
type ArtifactClassification = string;
interface ClassifiedArtifact {
classification: ArtifactClassification;
[key: string]: unknown;
}
type ActionType = 'remove-managed' | 'backup-and-remove';
interface MigrationAction {
type: ActionType;
relPath: string;
reason: string;
ownershipEvidence: string;
}
interface MigrationPlanContext {
classifyArtifact(relPath: string): ClassifiedArtifact;
}
interface InstallerMigration {
id: string;
title: string;
description: string;
introducedIn: string;
scopes: string[];
destructive: boolean;
plan: (ctx: MigrationPlanContext) => MigrationAction[];
}
const LEGACY_ORPHAN_FILES: ReadonlyArray<string> = [
'hooks/gsd-notify.sh',
'hooks/statusline.js',
];
module.exports = {
const migration: InstallerMigration = {
id: '2026-05-11-legacy-orphan-files',
title: 'Remove manifest-managed legacy orphan hook files',
description: 'Remove legacy orphan hook files that are still manifest-managed.',
@@ -16,10 +52,10 @@ module.exports = {
// evidence. This follows docs/installer-migrations.md#ownership and avoids
// relying on whether a runtime currently registers host hook config in the
// runtime contract registry.
plan: ({ classifyArtifact }) => {
const actions = [];
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
const actions: MigrationAction[] = [];
for (const relPath of LEGACY_ORPHAN_FILES) {
const artifact = classifyArtifact(relPath);
const artifact = ctx.classifyArtifact(relPath);
if (artifact.classification === 'managed-pristine') {
actions.push({
type: 'remove-managed',
@@ -39,3 +75,5 @@ module.exports = {
return actions;
},
};
export = migration;

View File

@@ -1,8 +1,61 @@
'use strict';
/**
* Installer migration: remove legacy Codex hooks.json GSD hook registrations.
*
* ADR-457 build-at-publish: the hand-written
* bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs collapsed to a
* TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
const { isManagedHookCommand } = require('../shell-command-projection.cjs');
import { isManagedHookCommand } from '../shell-command-projection.cjs';
function isStructurallyEmpty(value) {
type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
interface PruneResult {
value: JsonValue;
changed: boolean;
}
interface HooksJsonRead {
exists: boolean;
error?: boolean;
value?: JsonValue;
}
interface MigrationAction {
type: string;
relPath: string;
value: JsonValue;
deleteIfEmpty: boolean;
reason: string;
ownershipEvidence: string;
}
interface MigrationPlanContext {
configDir: string;
readJson(relPath: string): HooksJsonRead;
}
interface InstallerMigration {
id: string;
title: string;
description: string;
introducedIn: string;
runtimes: string[];
scopes: string[];
destructive: boolean;
runtimeContract: string;
plan: (ctx: MigrationPlanContext) => MigrationAction[];
}
function isStructurallyEmpty(value: JsonValue): boolean {
if (value === null || value === undefined) return true;
if (Array.isArray(value)) return value.length === 0;
if (typeof value !== 'object') return false;
@@ -10,7 +63,7 @@ function isStructurallyEmpty(value) {
return true;
}
function isManagedCodexHookCommand(command, configDir) {
function isManagedCodexHookCommand(command: unknown, configDir: string): boolean {
return isManagedHookCommand(command, {
surface: 'codex-hooks-json',
includeLegacyAliases: true,
@@ -18,10 +71,10 @@ function isManagedCodexHookCommand(command, configDir) {
});
}
function pruneLegacyCodexHooksJsonValue(value, configDir) {
function pruneLegacyCodexHooksJsonValue(value: JsonValue, configDir: string): PruneResult {
if (Array.isArray(value)) {
let changed = false;
const next = [];
const next: JsonValue[] = [];
for (const item of value) {
const pruned = pruneLegacyCodexHooksJsonValue(item, configDir);
if (pruned.changed) changed = true;
@@ -31,14 +84,16 @@ function pruneLegacyCodexHooksJsonValue(value, configDir) {
return { value: next, changed };
}
if (value && typeof value === 'object') {
if (isManagedCodexHookCommand(value.command, configDir)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const valueObj = value as Record<string, JsonValue>;
const command = valueObj['command'];
if (isManagedCodexHookCommand(command, configDir)) {
return { value: null, changed: true };
}
let changed = false;
const next = {};
for (const [key, child] of Object.entries(value)) {
const next: { [key: string]: JsonValue } = {};
for (const [key, child] of Object.entries(valueObj)) {
const pruned = pruneLegacyCodexHooksJsonValue(child, configDir);
if (pruned.changed) changed = true;
if (pruned.changed && isStructurallyEmpty(pruned.value)) changed = true;
@@ -50,7 +105,7 @@ function pruneLegacyCodexHooksJsonValue(value, configDir) {
return { value, changed: false };
}
module.exports = {
const migration: InstallerMigration = {
id: '2026-05-11-codex-legacy-hooks-json',
title: 'Remove legacy Codex hooks.json GSD hook registrations',
description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.',
@@ -59,8 +114,9 @@ module.exports = {
scopes: ['global', 'local'],
destructive: true,
runtimeContract: 'docs/installer-migrations.md#runtime-configuration-contract-registry Codex row',
plan: ({ configDir, readJson }) => {
const hooksJson = readJson('hooks.json');
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
const { configDir } = ctx;
const hooksJson = ctx.readJson('hooks.json');
if (!hooksJson.exists || hooksJson.error) return [];
const pruned = pruneLegacyCodexHooksJsonValue(hooksJson.value, configDir);
@@ -78,3 +134,5 @@ module.exports = {
];
},
};
export = migration;

View File

@@ -1,41 +1,40 @@
/**
* lib/intel.cjs -- Intel storage and query operations for GSD.
* lib/intel.cts -- Intel storage and query operations for GSD.
*
* Provides a persistent, queryable intelligence system for project metadata.
* Intel files live in .planning/intel/ and store structured data about
* the project's files, APIs, dependencies, architecture, and tech stack.
*
* All public functions gate on intel.enabled config (no-op when false).
*
* ADR-457 build-at-publish: the hand-written bin/lib/intel.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
// ─── Constants ───────────────────────────────────────────────────────────────
const INTEL_DIR = '.planning/intel';
const INTEL_FILES = {
const INTEL_FILES: Record<string, string> = {
files: 'file-roles.json',
apis: 'api-map.json',
deps: 'dependency-graph.json',
arch: 'arch-decisions.json',
stack: 'stack.json'
stack: 'stack.json',
};
// ─── Internal helpers ────────────────────────────────────────────────────────
/**
* Ensure the intel directory exists under the given planning dir.
*
* @param {string} planningDir - Path to .planning directory
* @returns {string} Full path to .planning/intel/
*/
function ensureIntelDir(planningDir) {
function ensureIntelDir(planningDir: string): string {
const intelPath = path.join(planningDir, 'intel');
platformEnsureDir(intelPath);
return intelPath;
@@ -45,53 +44,66 @@ function ensureIntelDir(planningDir) {
* Check whether intel is enabled in the project config.
* Reads config.json directly via fs. Returns false by default
* (when no config, no intel key, or on error).
*
* @param {string} planningDir - Path to .planning directory
* @returns {boolean}
*/
function isIntelEnabled(planningDir) {
function isIntelEnabled(planningDir: string): boolean {
try {
const configPath = path.join(planningDir, 'config.json');
const raw = platformReadSync(configPath);
if (raw === null) return false;
const config = JSON.parse(raw);
if (config && config.intel && config.intel.enabled === true) return true;
const config: unknown = JSON.parse(raw);
if (
config &&
typeof config === 'object' &&
'intel' in config &&
config.intel &&
typeof config.intel === 'object' &&
'enabled' in config.intel &&
(config.intel as Record<string, unknown>).enabled === true
) return true;
return false;
} catch (_e) {
return false;
}
}
interface DisabledResponse {
disabled: true;
message: string;
}
/**
* Return the standard disabled response object.
* @returns {{ disabled: true, message: string }}
*/
function disabledResponse() {
function disabledResponse(): DisabledResponse {
return { disabled: true, message: 'Intel system disabled. Set intel.enabled=true in config.json to activate.' };
}
/**
* Resolve full path to an intel file.
* @param {string} planningDir
* @param {string} filename
* @returns {string}
*/
function intelFilePath(planningDir, filename) {
function intelFilePath(planningDir: string, filename: string): string {
return path.join(planningDir, 'intel', filename);
}
interface IntelData {
_meta?: {
updated_at?: string;
version?: number;
[key: string]: unknown;
};
entries?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* Safely read and parse a JSON intel file.
* Returns null if file doesn't exist or can't be parsed.
*
* @param {string} filePath
* @returns {object|null}
*/
function safeReadJson(filePath) {
function safeReadJson(filePath: string): IntelData | null {
try {
const raw = platformReadSync(filePath);
if (raw === null) return null;
return JSON.parse(raw);
return JSON.parse(raw) as IntelData;
} catch (_e) {
return null;
}
@@ -100,11 +112,8 @@ function safeReadJson(filePath) {
/**
* Compute SHA-256 hash of a file's contents.
* Returns null if the file doesn't exist.
*
* @param {string} filePath
* @returns {string|null}
*/
function hashFile(filePath) {
function hashFile(filePath: string): string | null {
try {
const content = platformReadSync(filePath);
if (content === null) return null;
@@ -114,22 +123,23 @@ function hashFile(filePath) {
}
}
interface SearchMatch {
key: string;
value: unknown;
}
/**
* Search for a term (case-insensitive) in a JSON object's keys and string values.
* Returns an array of matching entries.
*
* @param {object} data - The JSON data (expects { _meta, entries } or flat object)
* @param {string} term - Search term
* @returns {Array<{ key: string, value: * }>}
*/
function searchJsonEntries(data, term) {
function searchJsonEntries(data: IntelData, term: string): SearchMatch[] {
if (!data || typeof data !== 'object') return [];
const entries = data.entries || data;
if (!entries || typeof entries !== 'object') return [];
const lowerTerm = term.toLowerCase();
const matches = [];
const matches: SearchMatch[] = [];
for (const [key, value] of Object.entries(entries)) {
if (key === '_meta') continue;
@@ -151,12 +161,8 @@ function searchJsonEntries(data, term) {
/**
* Recursively check if a term appears in any string value.
*
* @param {*} value
* @param {string} lowerTerm
* @returns {boolean}
*/
function matchesInValue(value, lowerTerm) {
function matchesInValue(value: unknown, lowerTerm: string): boolean {
if (typeof value === 'string') {
return value.toLowerCase().includes(lowerTerm);
}
@@ -172,12 +178,8 @@ function matchesInValue(value, lowerTerm) {
/**
* Search for a term in arch.md text content.
* Returns matching lines.
*
* @param {string} filePath - Path to arch.md
* @param {string} term - Search term
* @returns {string[]}
*/
function searchArchMd(filePath, term) {
function searchArchMd(filePath: string, term: string): string[] {
try {
const content = platformReadSync(filePath);
if (content === null) return [];
@@ -191,18 +193,20 @@ function searchArchMd(filePath, term) {
// ─── Public API ──────────────────────────────────────────────────────────────
interface IntelQueryResult {
matches: Array<{ source: string; entries: SearchMatch[] }>;
term: string;
total: number;
}
/**
* Query intel files for a search term.
* Searches across all JSON intel files (keys and values) and arch.md (text lines).
*
* @param {string} term - Search term (case-insensitive)
* @param {string} planningDir - Path to .planning directory
* @returns {{ matches: Array<{ source: string, entries: Array }>, term: string, total: number } | { disabled: true, message: string }}
*/
function intelQuery(term, planningDir) {
function intelQuery(term: string, planningDir: string): IntelQueryResult | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
const matches = [];
const matches: Array<{ source: string; entries: SearchMatch[] }> = [];
let total = 0;
// Search all JSON intel files
@@ -221,19 +225,27 @@ function intelQuery(term, planningDir) {
return { matches, term, total };
}
interface IntelStatusFileEntry {
exists: boolean;
updated_at: string | null;
stale: boolean;
}
interface IntelStatusResult {
files: Record<string, IntelStatusFileEntry>;
overall_stale: boolean;
}
/**
* Report status and staleness of each intel file.
* A file is considered stale if its updated_at is older than 24 hours.
*
* @param {string} planningDir - Path to .planning directory
* @returns {{ files: object, overall_stale: boolean } | { disabled: true, message: string }}
*/
function intelStatus(planningDir) {
function intelStatus(planningDir: string): IntelStatusResult | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
const now = Date.now();
const files = {};
const files: Record<string, IntelStatusFileEntry> = {};
let overallStale = false;
for (const [_key, filename] of Object.entries(INTEL_FILES)) {
@@ -246,7 +258,7 @@ function intelStatus(planningDir) {
continue;
}
let updatedAt = null;
let updatedAt: string | null = null;
// All intel files are JSON — read _meta.updated_at
const data = safeReadJson(filePath);
@@ -267,13 +279,16 @@ function intelStatus(planningDir) {
return { files, overall_stale: overallStale };
}
interface IntelDiffResult {
changed: string[];
added: string[];
removed: string[];
}
/**
* Show changes since the last full refresh by comparing file hashes.
*
* @param {string} planningDir - Path to .planning directory
* @returns {{ changed: string[], added: string[], removed: string[] } | { no_baseline: true } | { disabled: true, message: string }}
*/
function intelDiff(planningDir) {
function intelDiff(planningDir: string): IntelDiffResult | { no_baseline: true } | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
const snapshotPath = intelFilePath(planningDir, '.last-refresh.json');
@@ -283,10 +298,10 @@ function intelDiff(planningDir) {
return { no_baseline: true };
}
const prevHashes = snapshot.hashes || {};
const changed = [];
const added = [];
const removed = [];
const prevHashes = (snapshot.hashes as Record<string, string> | undefined) || {};
const changed: string[] = [];
const added: string[] = [];
const removed: string[] = [];
// Check current files against snapshot
for (const [_key, filename] of Object.entries(INTEL_FILES)) {
@@ -308,29 +323,29 @@ function intelDiff(planningDir) {
/**
* Stub for triggering an intel update.
* The actual update is performed by the intel-updater agent (PLAN-02).
*
* @param {string} planningDir - Path to .planning directory
* @returns {{ action: string, message: string } | { disabled: true, message: string }}
*/
function intelUpdate(planningDir) {
function intelUpdate(planningDir: string): { action: string; message: string } | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
return {
action: 'spawn_agent',
message: 'Run gsd-tools intel update or spawn gsd-intel-updater agent for full refresh'
message: 'Run gsd-tools intel update or spawn gsd-intel-updater agent for full refresh',
};
}
interface SaveRefreshResult {
saved: boolean;
timestamp: string;
files: number;
}
/**
* Save a refresh snapshot with hashes of all current intel files.
* Called by the intel-updater agent after completing a refresh.
*
* @param {string} planningDir - Path to .planning directory
* @returns {{ saved: boolean, timestamp: string, files: number }}
*/
function saveRefreshSnapshot(planningDir) {
function saveRefreshSnapshot(planningDir: string): SaveRefreshResult {
const intelPath = ensureIntelDir(planningDir);
const hashes = {};
const hashes: Record<string, string> = {};
let fileCount = 0;
for (const [_key, filename] of Object.entries(INTEL_FILES)) {
@@ -347,7 +362,7 @@ function saveRefreshSnapshot(planningDir) {
platformWriteSync(snapshotPath, JSON.stringify({
hashes,
timestamp,
version: 1
version: 1,
}, null, 2));
return { saved: true, timestamp, files: fileCount };
@@ -358,26 +373,26 @@ function saveRefreshSnapshot(planningDir) {
/**
* Thin wrapper around saveRefreshSnapshot for CLI dispatch.
* Writes .last-refresh.json with accurate timestamps and hashes.
*
* @param {string} planningDir - Path to .planning directory
* @returns {{ saved: boolean, timestamp: string, files: number } | { disabled: true, message: string }}
*/
function intelSnapshot(planningDir) {
function intelSnapshot(planningDir: string): SaveRefreshResult | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
return saveRefreshSnapshot(planningDir);
}
interface IntelValidateResult {
valid: boolean;
errors: string[];
warnings: string[];
}
/**
* Validate all intel files for correctness and freshness.
*
* @param {string} planningDir - Path to .planning directory
* @returns {{ valid: boolean, errors: string[], warnings: string[] } | { disabled: true, message: string }}
*/
function intelValidate(planningDir) {
function intelValidate(planningDir: string): IntelValidateResult | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
const errors = [];
const warnings = [];
const errors: string[] = [];
const warnings: string[] = [];
const STALE_MS = 24 * 60 * 60 * 1000;
const now = Date.now();
@@ -398,11 +413,11 @@ function intelValidate(planningDir) {
errors.push(`${filename}: file missing`);
continue;
}
let data;
let data: IntelData;
try {
data = JSON.parse(raw);
data = JSON.parse(raw) as IntelData;
} catch (e) {
errors.push(`${filename}: invalid JSON — ${e.message}`);
errors.push(`${filename}: invalid JSON — ${(e as Error).message}`);
continue;
}
@@ -421,8 +436,9 @@ function intelValidate(planningDir) {
// files.json: check exports are actual symbol names (no spaces)
if (key === 'files') {
for (const [entryPath, entry] of Object.entries(data.entries)) {
if (entry.exports && Array.isArray(entry.exports)) {
for (const exp of entry.exports) {
const entryObj = entry as Record<string, unknown>;
if (entryObj.exports && Array.isArray(entryObj.exports)) {
for (const exp of entryObj.exports as unknown[]) {
if (typeof exp === 'string' && exp.includes(' ')) {
warnings.push(`${filename}: "${entryPath}" export "${exp}" looks like a description (contains space)`);
}
@@ -441,10 +457,11 @@ function intelValidate(planningDir) {
// deps.json: check entries have version, type, used_by
if (key === 'deps') {
for (const [depName, entry] of Object.entries(data.entries)) {
const missing = [];
if (!entry.version) missing.push('version');
if (!entry.type) missing.push('type');
if (!entry.used_by) missing.push('used_by');
const entryObj = entry as Record<string, unknown>;
const missing: string[] = [];
if (!entryObj.version) missing.push('version');
if (!entryObj.type) missing.push('type');
if (!entryObj.used_by) missing.push('used_by');
if (missing.length > 0) {
warnings.push(`${filename}: "${depName}" missing fields: ${missing.join(', ')}`);
}
@@ -456,16 +473,19 @@ function intelValidate(planningDir) {
return { valid: errors.length === 0, errors, warnings };
}
interface IntelApiSurfaceResult {
written: string;
symbolCount: number;
stale: boolean;
}
/**
* Render .planning/intel/api-map.json into a human-readable API-SURFACE.md.
* Always writes the file — even when api-map.json is absent or empty, the
* surface will contain an explicit "incomplete" banner so consumers never
* mistake silence for "nothing exists".
*
* @param {string} planningDir - Path to .planning directory
* @returns {{ written: string, symbolCount: number, stale: boolean } | { disabled: true, message: string }}
*/
function intelApiSurface(planningDir) {
function intelApiSurface(planningDir: string): IntelApiSurfaceResult | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
const intelPath = ensureIntelDir(planningDir);
@@ -486,7 +506,7 @@ function intelApiSurface(planningDir) {
stale = age > STALE_MS;
}
const lines = [];
const lines: string[] = [];
lines.push('# API Surface');
lines.push('');
lines.push('> Generated from `.planning/intel/api-map.json`. Do not edit by hand.');
@@ -506,7 +526,7 @@ function intelApiSurface(planningDir) {
lines.push(`## \`${symbol}\``);
lines.push('');
if (info && typeof info === 'object') {
for (const [field, val] of Object.entries(info)) {
for (const [field, val] of Object.entries(info as Record<string, unknown>)) {
const display = Array.isArray(val) ? val.join(', ') : String(val);
lines.push(`- **${field}:** ${display}`);
}
@@ -520,27 +540,31 @@ function intelApiSurface(planningDir) {
return { written: outputPath, symbolCount, stale };
}
interface IntelPatchMetaResult {
patched: boolean;
file?: string;
timestamp?: string;
error?: string;
}
/**
* Patch _meta.updated_at in a JSON intel file to the current timestamp.
* Reads the file, updates _meta.updated_at, increments version, writes back.
*
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary file paths
* for use by agents patching individual files outside the intel store.
*
* @param {string} filePath - Absolute or relative path to the JSON intel file
* @returns {{ patched: boolean, file: string, timestamp: string } | { patched: false, error: string }}
*/
function intelPatchMeta(filePath) {
function intelPatchMeta(filePath: string): IntelPatchMetaResult {
try {
const content = platformReadSync(filePath);
if (content === null) {
return { patched: false, error: `File not found: ${filePath}` };
}
let data;
let data: IntelData;
try {
data = JSON.parse(content);
data = JSON.parse(content) as IntelData;
} catch (e) {
return { patched: false, error: `Invalid JSON: ${e.message}` };
return { patched: false, error: `Invalid JSON: ${(e as Error).message}` };
}
if (!data._meta) {
@@ -555,25 +579,28 @@ function intelPatchMeta(filePath) {
return { patched: true, file: filePath, timestamp };
} catch (e) {
return { patched: false, error: e.message };
return { patched: false, error: (e as Error).message };
}
}
interface IntelExtractExportsResult {
file: string;
exports: string[];
method: string;
}
/**
* Extract exports from a JS/CJS file by parsing module.exports or exports.X patterns.
*
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary source files
* for use by agents building intel data from project files.
*
* @param {string} filePath - Path to the JS/CJS file
* @returns {{ file: string, exports: string[], method: string }}
*/
function intelExtractExports(filePath) {
function intelExtractExports(filePath: string): IntelExtractExportsResult {
const content = platformReadSync(filePath);
if (content === null) {
return { file: filePath, exports: [], method: 'none' };
}
const exports = new Set();
const exports = new Set<string>();
let method = 'none';
// Try module.exports = { ... } pattern (handle multi-line)
@@ -608,7 +635,7 @@ function intelExtractExports(filePath) {
// Also try individual exports.X = patterns (only at start of line, not inside strings/regex)
const individualPattern = /^exports\.(\w+)\s*=/gm;
let im;
let im: RegExpExecArray | null;
while ((im = individualPattern.exec(content)) !== null) {
if (!exports.has(im[1])) {
exports.add(im[1]);
@@ -619,11 +646,11 @@ function intelExtractExports(filePath) {
const hadCjs = exports.size > 0;
// ESM patterns
const esmExports = new Set();
const esmExports = new Set<string>();
// export default function X / export default class X
const defaultNamedPattern = /^export\s+default\s+(?:function|class)\s+(\w+)/gm;
let em;
let em: RegExpExecArray | null;
while ((em = defaultNamedPattern.exec(content)) !== null) {
esmExports.add(em[1]);
}
@@ -683,7 +710,7 @@ function intelExtractExports(filePath) {
// ─── Exports ─────────────────────────────────────────────────────────────────
module.exports = {
export = {
// Public API
intelQuery,
intelUpdate,
@@ -704,5 +731,5 @@ module.exports = {
// Constants
INTEL_FILES,
INTEL_DIR
INTEL_DIR,
};

View File

@@ -9,16 +9,61 @@
* Storage format: { id, source_project, date, context, learning, tags, content_hash }
* File naming: {id}.json
* Deduplication: SHA-256 of learning text + source_project
*
* ADR-457 build-at-publish: the hand-written bin/lib/learnings.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
'use strict';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import os from 'node:os';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { output, error: coreError } = core;
import { platformWriteSync } from './shell-command-projection.cjs';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const { output, error: coreError } = require('./core.cjs');
const { platformWriteSync } = require('./shell-command-projection.cjs');
// ─── Types ───────────────────────────────────────────────────────────────────
interface LearningRecord {
id: string;
source_project: string;
date: string;
context: string;
learning: string;
tags: string[];
content_hash: string;
}
interface WriteEntry {
source_project: string;
learning: string;
context?: string;
tags?: string[];
}
interface WriteOpts {
storeDir?: string;
dedupeIndex?: Map<string, string>;
}
interface WriteResult {
id: string;
created: boolean;
content_hash: string;
}
interface CopyResult {
total: number;
created: number;
skipped: number;
}
interface PruneResult {
removed: number;
kept: number;
}
// ─── Constants ───────────────────────────────────────────────────────────────
@@ -26,81 +71,41 @@ const DEFAULT_STORE_DIR = path.join(os.homedir(), '.gsd', 'knowledge');
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Get the store directory, allowing override for testing.
* @param {object} [opts]
* @param {string} [opts.storeDir] - Override store directory
* @returns {string}
*/
function getStoreDir(opts) {
function getStoreDir(opts?: WriteOpts | { storeDir?: string }): string {
return (opts && opts.storeDir) || DEFAULT_STORE_DIR;
}
/**
* Ensure the store directory exists. Created on first write, not on install.
* @param {string} dir
*/
function ensureStoreDir(dir) {
function ensureStoreDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Generate a content hash for deduplication.
* Uses SHA-256 of learning text combined with source_project.
* @param {string} learning
* @param {string} sourceProject
* @returns {string}
*/
function contentHash(learning, sourceProject) {
function contentHash(learning: string, sourceProject: string): string {
return crypto.createHash('sha256')
.update(learning + '\n' + sourceProject)
.digest('hex');
}
/**
* Generate a unique ID based on timestamp + random suffix.
* @returns {string}
*/
function generateId() {
function generateId(): string {
const ts = Date.now().toString(36);
const rand = crypto.randomBytes(4).toString('hex');
return `${ts}-${rand}`;
}
/**
* Read and parse a single learning JSON file.
* Returns null (with stderr warning) for malformed files.
* @param {string} filePath
* @returns {object|null}
*/
function readLearningFile(filePath) {
function readLearningFile(filePath: string): LearningRecord | null {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
return JSON.parse(content) as LearningRecord;
} catch (err) {
process.stderr.write(`Warning: skipping malformed file ${filePath}: ${err.message}\n`);
process.stderr.write(`Warning: skipping malformed file ${filePath}: ${(err as Error).message}\n`);
return null;
}
}
// ─── CRUD Operations ─────────────────────────────────────────────────────────
/**
* Write a learning to the global store.
* Deduplicates by content hash — same content from same project is not stored twice.
*
* @param {object} entry
* @param {string} entry.source_project - Project name or path
* @param {string} entry.learning - The learning text
* @param {string} [entry.context] - Additional context
* @param {string[]} [entry.tags] - Tags for querying
* @param {object} [opts]
* @param {string} [opts.storeDir] - Override store directory
* @returns {{ id: string, created: boolean, content_hash: string }}
*/
function learningsWrite(entry, opts) {
function learningsWrite(entry: WriteEntry, opts?: WriteOpts): WriteResult {
const dir = getStoreDir(opts);
ensureStoreDir(dir);
@@ -108,17 +113,13 @@ function learningsWrite(entry, opts) {
// #306: In bulk-import paths, callers may supply a pre-built dedupeIndex
// (Map<content_hash, id>) to avoid the per-write O(N) store scan.
// When present, use it for the dedupe check instead of scanning the directory.
// After writing a new record, add it to the index so subsequent items in the
// same bulk operation dedupe against it.
// When absent (single-write path), fall back to the existing full-store scan.
if (opts && opts.dedupeIndex) {
const dedupeIndex = opts.dedupeIndex;
if (dedupeIndex.has(hash)) {
return { id: dedupeIndex.get(hash), created: false, content_hash: hash };
return { id: dedupeIndex.get(hash) as string, created: false, content_hash: hash };
}
const id = generateId();
const record = {
const record: LearningRecord = {
id,
source_project: entry.source_project,
date: new Date().toISOString(),
@@ -142,7 +143,7 @@ function learningsWrite(entry, opts) {
}
const id = generateId();
const record = {
const record: LearningRecord = {
id,
source_project: entry.source_project,
date: new Date().toISOString(),
@@ -156,15 +157,7 @@ function learningsWrite(entry, opts) {
return { id, created: true, content_hash: hash };
}
/**
* Read a single learning by ID.
*
* @param {string} id
* @param {object} [opts]
* @param {string} [opts.storeDir] - Override store directory
* @returns {object|null}
*/
function learningsRead(id, opts) {
function learningsRead(id: string, opts?: { storeDir?: string }): LearningRecord | null {
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return null;
const dir = getStoreDir(opts);
const filePath = path.join(dir, `${id}.json`);
@@ -172,19 +165,12 @@ function learningsRead(id, opts) {
return readLearningFile(filePath);
}
/**
* List all learnings, sorted by date (newest first).
*
* @param {object} [opts]
* @param {string} [opts.storeDir] - Override store directory
* @returns {object[]}
*/
function learningsList(opts) {
function learningsList(opts?: { storeDir?: string }): LearningRecord[] {
const dir = getStoreDir(opts);
if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
const results = [];
const results: LearningRecord[] = [];
for (const file of files) {
const record = readLearningFile(path.join(dir, file));
if (record) results.push(record);
@@ -195,32 +181,15 @@ function learningsList(opts) {
return results;
}
/**
* Query learnings by tag.
*
* @param {object} query
* @param {string} [query.tag] - Tag to filter by
* @param {object} [opts]
* @param {string} [opts.storeDir] - Override store directory
* @returns {object[]}
*/
function learningsQuery(query, opts) {
function learningsQuery(query: { tag?: string }, opts?: { storeDir?: string }): LearningRecord[] {
const all = learningsList(opts);
if (query && query.tag) {
return all.filter(r => r.tags && r.tags.includes(query.tag));
return all.filter(r => r.tags && r.tags.includes(query.tag as string));
}
return all;
}
/**
* Delete a learning by ID.
*
* @param {string} id
* @param {object} [opts]
* @param {string} [opts.storeDir] - Override store directory
* @returns {boolean} true if deleted, false if not found
*/
function learningsDelete(id, opts) {
function learningsDelete(id: string, opts?: { storeDir?: string }): boolean {
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return false;
const dir = getStoreDir(opts);
const filePath = path.join(dir, `${id}.json`);
@@ -229,24 +198,7 @@ function learningsDelete(id, opts) {
return true;
}
/**
* Copy learnings from a project's LEARNINGS.md into the global store.
* Parses markdown sections as individual learnings. Deduplicates by content hash.
*
* Expected LEARNINGS.md format:
* ## Section Title
* Learning content paragraph(s)...
*
* ## Another Section
* More content...
*
* @param {string} planningDir - Path to .planning/ directory (or directory containing LEARNINGS.md)
* @param {object} [opts]
* @param {string} [opts.storeDir] - Override store directory
* @param {string} [opts.sourceProject] - Project name (defaults to directory basename)
* @returns {{ total: number, created: number, skipped: number }}
*/
function learningsCopyFromProject(planningDir, opts) {
function learningsCopyFromProject(planningDir: string, opts?: WriteOpts & { sourceProject?: string }): CopyResult {
const learningsPath = path.join(planningDir, 'LEARNINGS.md');
if (!fs.existsSync(learningsPath)) {
return { total: 0, created: 0, skipped: 0 };
@@ -260,7 +212,7 @@ function learningsCopyFromProject(planningDir, opts) {
// O(K*N) -> O(N+K).
const dir = getStoreDir(opts);
ensureStoreDir(dir);
const dedupeIndex = new Map();
const dedupeIndex = new Map<string, string>();
for (const file of fs.readdirSync(dir).filter(f => f.endsWith('.json'))) {
const existing = readLearningFile(path.join(dir, file));
// First-seen-wins, matching the legacy scan path's first-match return so the
@@ -302,15 +254,7 @@ function learningsCopyFromProject(planningDir, opts) {
return { total: created + skipped, created, skipped };
}
/**
* Prune learnings older than a given threshold.
*
* @param {string} olderThan - Duration string like "90d", "30d", "7d"
* @param {object} [opts]
* @param {string} [opts.storeDir] - Override store directory
* @returns {{ removed: number, kept: number }}
*/
function learningsPrune(olderThan, opts) {
function learningsPrune(olderThan: string, opts?: { storeDir?: string }): PruneResult {
const match = /^(\d+)d$/.exec(olderThan);
if (!match) {
throw new Error(`Invalid duration format: "${olderThan}" — expected format like "90d"`);
@@ -345,66 +289,40 @@ function learningsPrune(olderThan, opts) {
// ─── CLI Command Handlers ────────────────────────────────────────────────────
/**
* Handle `gsd-tools learnings list`
* @param {boolean} raw - Raw output flag
*/
function cmdLearningsList(raw) {
function cmdLearningsList(raw: boolean): void {
const results = learningsList();
output({ learnings: results, count: results.length }, raw);
output({ learnings: results, count: results.length }, raw, undefined);
}
/**
* Handle `gsd-tools learnings query --tag <tag>`
* @param {string} tag
* @param {boolean} raw - Raw output flag
*/
function cmdLearningsQuery(tag, raw) {
function cmdLearningsQuery(tag: string, raw: boolean): void {
const results = learningsQuery({ tag });
output({ learnings: results, count: results.length, tag }, raw);
output({ learnings: results, count: results.length, tag }, raw, undefined);
}
/**
* Handle `gsd-tools learnings copy`
* @param {string} cwd - Current working directory
* @param {boolean} raw - Raw output flag
*/
function cmdLearningsCopy(cwd, raw) {
const planningDir = path.join(cwd, '.planning');
const result = learningsCopyFromProject(planningDir);
output(result, raw);
function cmdLearningsCopy(cwd: string, raw: boolean): void {
const planDir = path.join(cwd, '.planning');
const result = learningsCopyFromProject(planDir);
output(result, raw, undefined);
}
/**
* Handle `gsd-tools learnings prune --older-than <duration>`
* @param {string} olderThan - Duration string like "90d"
* @param {boolean} raw - Raw output flag
*/
function cmdLearningsPrune(olderThan, raw) {
function cmdLearningsPrune(olderThan: string, raw: boolean): void {
try {
const result = learningsPrune(olderThan);
output(result, raw);
output(result, raw, undefined);
} catch (err) {
coreError(err.message);
coreError((err as Error).message);
}
}
/**
* Handle `gsd-tools learnings delete <id>`
* @param {string} id
* @param {boolean} raw - Raw output flag
*/
function cmdLearningsDelete(id, raw) {
function cmdLearningsDelete(id: string, raw: boolean): void {
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) {
coreError(`Invalid learning ID: "${id}"`);
}
const deleted = learningsDelete(id);
output({ id, deleted }, raw);
output({ id, deleted }, raw, undefined);
}
// ─── Exports ─────────────────────────────────────────────────────────────────
module.exports = {
export = {
learningsWrite,
learningsRead,
learningsList,

View File

@@ -1,17 +1,44 @@
/**
* Milestone — Milestone and requirements lifecycle operations
* Milestone — Milestone and requirements lifecycle operations.
*
* ADR-457 build-at-publish: the hand-written bin/lib/milestone.cjs collapsed to
* a TypeScript source of truth, compiled by tsc to a gitignored .cjs at the same
* require() path. Behaviour preserved byte-for-behaviour; only types are added.
*/
const fs = require('fs');
const path = require('path');
const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, normalizePhaseName, phaseTokenMatches, output, error } = require('./core.cjs');
const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
const { planningPaths } = require('./planning-workspace.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { writeStateMd, stateReplaceFieldWithFallback } = require('./state.cjs');
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
import core = require('./core.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
import frontmatterMod = require('./frontmatter.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
import stateMod = require('./state.cjs');
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
const {
escapeRegex,
getMilestonePhaseFilter,
extractOneLinerFromBody,
normalizePhaseName,
phaseTokenMatches,
output,
error,
} = core;
const { planningPaths } = planningWorkspace;
const { extractFrontmatter } = frontmatterMod;
const { writeStateMd, stateReplaceFieldWithFallback } = stateMod;
interface MilestoneCompleteOptions {
name?: string;
force?: boolean;
archivePhases?: boolean;
}
function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: boolean): void {
if (!reqIdsRaw || reqIdsRaw.length === 0) {
error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
}
@@ -21,7 +48,7 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
.join(' ')
.replace(/[\[\]]/g, '')
.split(/[,\s]+/)
.map(r => r.trim())
.map((r) => r.trim())
.filter(Boolean);
if (reqIds.length === 0) {
@@ -35,9 +62,9 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
}
let reqContent = fs.readFileSync(reqPath, 'utf-8');
const updated = [];
const alreadyComplete = [];
const notFound = [];
const updated: string[] = [];
const alreadyComplete: string[] = [];
const notFound: string[] = [];
for (const reqId of reqIds) {
let found = false;
@@ -80,16 +107,20 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
platformWriteSync(reqPath, reqContent);
}
output({
output(
{
updated: updated.length > 0,
marked_complete: updated,
already_complete: alreadyComplete,
not_found: notFound,
total: reqIds.length,
}, raw, `${updated.length}/${reqIds.length} requirements marked complete`);
},
raw,
`${updated.length}/${reqIds.length} requirements marked complete`,
);
}
function cmdMilestoneComplete(cwd, version, options, raw) {
function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCompleteOptions, raw: boolean): void {
if (!version) {
error('version required for milestone complete (e.g., v1.0)');
}
@@ -124,27 +155,33 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
if (!options.force) {
try {
// Only guard when STATE.md's milestone field matches the version being completed.
let stateVersion = null;
let stateVersion: string | null = null;
try {
const stateRaw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
if (stateRaw) {
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
if (milestoneMatch) stateVersion = milestoneMatch[1].trim();
}
} catch { /* skip */ }
} catch {
/* skip */
}
if (stateVersion && stateVersion === version) {
const { extractCurrentMilestone } = require('./core.cjs');
const { extractCurrentMilestone } = core;
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
const noDirectoryPhases = [];
let pm;
const phaseDirEntries = (() => {
const noDirectoryPhases: string[] = [];
let pm: RegExpExecArray | null;
const phaseDirEntries = ((): string[] => {
try {
return fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name);
} catch { return []; }
return fs
.readdirSync(phasesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
} catch {
return [];
}
})();
while ((pm = phasePattern.exec(scopedContent)) !== null) {
const phaseNum = pm[1];
@@ -153,7 +190,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
// with a matching token exists on disk. Use the same phaseTokenMatches
// helper that roadmap.analyze uses to avoid false positives on decimal
// (2.1) and letter-suffix (12A) phase IDs.
const hasDirectory = phaseDirEntries.some(d => phaseTokenMatches(d, normalized));
const hasDirectory = phaseDirEntries.some((d) => phaseTokenMatches(d, normalized));
if (!hasDirectory) {
noDirectoryPhases.push(phaseNum);
}
@@ -161,13 +198,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
if (noDirectoryPhases.length > 0) {
error(
`Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` +
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`,
);
}
}
} catch (e) {
// If the error came from our guard, re-throw it; otherwise skip silently.
if (e.message && e.message.startsWith('Cannot mark milestone complete:')) throw e;
const message = e instanceof Error ? e.message : String(e);
if (message && message.startsWith('Cannot mark milestone complete:')) throw e;
// Phase scan failed or STATE version mismatch — allow completion to proceed.
}
}
@@ -176,19 +214,22 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
let phaseCount = 0;
let totalPlans = 0;
let totalTasks = 0;
const accomplishments = [];
const accomplishments: string[] = [];
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
const dirs = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
for (const dir of dirs) {
if (!isDirInMilestone(dir)) continue;
phaseCount++;
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
totalPlans += plans.length;
// Extract one-liners from summaries
@@ -196,7 +237,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
try {
const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
const fm = extractFrontmatter(content);
const oneLiner = fm['one-liner'] || extractOneLinerFromBody(content);
const rawOneLiner = fm['one-liner'];
const oneLiner = (typeof rawOneLiner === 'string' ? rawOneLiner : '') || extractOneLinerFromBody(content);
if (oneLiner) {
accomplishments.push(oneLiner);
}
@@ -210,10 +252,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
totalTasks += xmlTaskMatches.length || mdTaskMatches.length;
}
} catch { /* intentionally empty */ }
} catch {
/* intentionally empty */
}
}
} catch { /* intentionally empty */ }
}
} catch {
/* intentionally empty */
}
// Archive ROADMAP.md
if (fs.existsSync(roadmapPath)) {
@@ -235,7 +281,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
}
// Create/append MILESTONES.md entry
const accomplishmentsList = accomplishments.map(a => `- ${a}`).join('\n');
const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
if (fs.existsSync(milestonesPath)) {
@@ -265,8 +311,12 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null, `${version} milestone complete`);
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null,
`${version} milestone completed and archived`);
stateContent = stateReplaceFieldWithFallback(
stateContent,
'Last Activity Description',
null,
`${version} milestone completed and archived`,
);
// Reset Current Position narrative so resume/progress flows do not keep
// pointing at closed-phase execution instructions.
@@ -277,7 +327,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
`Status: Awaiting next milestone\n` +
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
if (positionPattern.test(stateContent)) {
stateContent = stateContent.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`);
stateContent = stateContent.replace(positionPattern, (_m, header: string) => `${header}${closedPositionBody}`);
} else {
stateContent = `${stateContent.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
}
@@ -287,10 +337,10 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
if (operatorPattern.test(stateContent)) {
stateContent = stateContent.replace(
operatorPattern,
`$1\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd))}\n\n`,
`$1\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string}\n\n`,
);
} else {
stateContent = `${stateContent.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd))}\n`;
stateContent = `${stateContent.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string}\n`;
}
writeStateMd(statePath, stateContent, cwd);
@@ -304,7 +354,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
platformEnsureDir(phaseArchiveDir);
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name);
const phaseDirNames = phaseEntries.filter((e) => e.isDirectory()).map((e) => e.name);
let archivedCount = 0;
for (const dir of phaseDirNames) {
if (!isDirInMilestone(dir)) continue;
@@ -312,7 +362,9 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
archivedCount++;
}
phasesArchived = archivedCount > 0;
} catch { /* intentionally empty */ }
} catch {
/* intentionally empty */
}
}
const result = {
@@ -336,19 +388,19 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
output(result, raw);
}
function cmdPhasesClear(cwd, raw, args) {
function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
const phasesDir = planningPaths(cwd).phases;
const confirm = Array.isArray(args) && args.includes('--confirm');
let cleared = 0;
if (fs.existsSync(phasesDir)) {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory() && !/^999(?:\.|$)/.test(e.name));
const dirs = entries.filter((e) => e.isDirectory() && !/^999(?:\.|$)/.test(e.name));
if (dirs.length > 0 && !confirm) {
error(
`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` +
`Pass --confirm to proceed.`
`Pass --confirm to proceed.`,
);
}
@@ -358,14 +410,15 @@ function cmdPhasesClear(cwd, raw, args) {
cleared++;
}
} catch (e) {
error('Failed to clear phases directory: ' + e.message);
const message = e instanceof Error ? e.message : String(e);
error('Failed to clear phases directory: ' + message);
}
}
output({ cleared }, raw, `${cleared} phase director${cleared === 1 ? 'y' : 'ies'} cleared`);
}
module.exports = {
export = {
cmdRequirementsMarkComplete,
cmdMilestoneComplete,
cmdPhasesClear,

226
src/model-catalog.cts Normal file
View File

@@ -0,0 +1,226 @@
/**
* Model catalog — typed access to model-catalog.json.
*
* ADR-457 build-at-publish: the hand-written bin/lib/model-catalog.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
*/
import path from 'node:path';
// In .cts (CommonJS output) files, `require` is available as a global;
// we use it directly to load JSON candidates.
const _require: NodeRequire = require;
// Resolve model-catalog.json via a prioritised candidate list so the module
// works in every layout:
//
// 1. Co-located install path — get-shit-done/bin/shared/model-catalog.json
// 2. Source-repo dev path — sdk/shared/model-catalog.json
// 3. GSD_MODEL_CATALOG env override
const _catalogCandidates: string[] = [
path.resolve(__dirname, '..', 'shared', 'model-catalog.json'),
path.resolve(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'),
...(process.env['GSD_MODEL_CATALOG'] ? [path.resolve(process.env['GSD_MODEL_CATALOG'])] : []),
];
/** Typed tier entry from model-catalog.json (model + optional reasoning_effort). */
export interface TierEntry {
model: string;
reasoning_effort?: string;
}
/** Per-agent model mapping in the catalog. */
export interface AgentMeta {
golden: string;
balanced: string;
budget: string;
phaseType: string;
routingTier: string;
}
/** The shape of model-catalog.json. */
export interface ModelCatalog {
profiles: string[];
phaseTypes: string[];
adaptiveTierMap: Record<string, string>;
runtimeTierDefaults: Record<string, Record<string, TierEntry | null>>;
providerPresets: Record<string, Record<string, Record<string, TierEntry | null>>>;
agents: Record<string, AgentMeta>;
}
let catalog: ModelCatalog | null = null;
let _catalogLastErr: Error | null = null;
for (const _p of _catalogCandidates) {
try {
catalog = _require(_p) as ModelCatalog;
break;
} catch (e) {
const isMissingCandidate =
(e && (e as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND' && String((e as Error).message || '').includes(_p)) ||
(e && (e as NodeJS.ErrnoException).code === 'ENOENT');
if (!isMissingCandidate) throw e;
_catalogLastErr = e as Error;
}
}
if (!catalog) {
throw new Error(
`model-catalog.json not found. Tried:\n${_catalogCandidates.map((p) => ` ${p}`).join('\n')}\nLast error: ${_catalogLastErr?.message}`
);
}
// After the throw guard above, catalog is guaranteed non-null.
const _catalog = catalog;
export { _catalog as catalog };
export const VALID_PROFILES: string[] = [..._catalog.profiles];
export const VALID_PHASE_TYPES: Set<string> = new Set(_catalog.phaseTypes);
export const VALID_AGENT_TIERS: Set<string> = new Set(Object.keys(_catalog.adaptiveTierMap));
/** Per-profile model slots for each agent. */
export interface AgentModelProfiles {
quality: string;
balanced: string;
budget: string;
adaptive: string;
}
export const MODEL_PROFILES: Record<string, AgentModelProfiles> = Object.fromEntries(
Object.entries(_catalog.agents).map(([agent, meta]) => [agent, {
quality: meta.golden,
balanced: meta.balanced,
budget: meta.budget,
adaptive: _catalog.adaptiveTierMap[meta.routingTier],
}])
);
export const AGENT_TO_PHASE_TYPE: Record<string, string> = Object.fromEntries(
Object.entries(_catalog.agents).map(([agent, meta]) => [agent, meta.phaseType])
);
export const AGENT_DEFAULT_TIERS: Record<string, string> = Object.fromEntries(
Object.entries(_catalog.agents).map(([agent, meta]) => [agent, meta.routingTier])
);
export const MODEL_ALIAS_MAP: Record<string, string | undefined> = Object.fromEntries(
Object.entries(_catalog.runtimeTierDefaults['claude'] ?? {}).map(([tier, entry]) => [tier, entry?.model])
);
export const RUNTIME_PROFILE_MAP: Record<string, Record<string, TierEntry>> = (() => {
const result: Record<string, Record<string, TierEntry>> = {};
for (const [runtime, tiers] of Object.entries(_catalog.runtimeTierDefaults)) {
const filtered: Record<string, TierEntry> = {};
for (const [tier, entry] of Object.entries(tiers)) {
if (entry) filtered[tier] = entry;
}
if (Object.keys(filtered).length > 0) result[runtime] = filtered;
}
return result;
})();
export const KNOWN_RUNTIMES: Set<string> = new Set(Object.keys(_catalog.runtimeTierDefaults));
export const RUNTIMES_WITH_REASONING_EFFORT: Set<string> = new Set(
Object.entries(_catalog.runtimeTierDefaults)
.filter(([, tiers]) => Object.values(tiers).some((entry) => entry && entry.reasoning_effort))
.map(([runtime]) => runtime)
);
export const PROVIDER_PRESETS: Record<string, Record<string, Record<string, TierEntry | null>>> =
_catalog.providerPresets ?? {};
// KNOWN_PROVIDERS excludes 'generic' — it is a sentinel (all null entries) that
// forces users to supply model IDs via model_profile_overrides. It is not a
// real catalog-backed provider (#49).
export const KNOWN_PROVIDERS: Set<string> = new Set(
Object.entries(PROVIDER_PRESETS)
.filter(([, tiers]) =>
Object.values(tiers).some((budgets) =>
budgets && Object.values(budgets).some((entry) => entry && entry.model)
)
)
.map(([name]) => name)
);
export function nextTier(currentTier: string): string | null {
const order = ['light', 'standard', 'heavy'];
const idx = order.indexOf(String(currentTier));
if (idx === -1) return null;
return order[Math.min(idx + 1, order.length - 1)];
}
export function formatAgentToModelMapAsTable(agentToModelMap: Record<string, string>): string {
const agentWidth = Math.max('Agent'.length, ...Object.keys(agentToModelMap).map((a) => a.length));
const modelWidth = Math.max('Model'.length, ...Object.values(agentToModelMap).map((m) => m.length));
const sep = '─'.repeat(agentWidth + 2) + '┼' + '─'.repeat(modelWidth + 2);
const header = ` ${'Agent'.padEnd(agentWidth)} │ ${'Model'.padEnd(modelWidth)}`;
let out = `${header}\n${sep}\n`;
for (const [agent, model] of Object.entries(agentToModelMap)) {
out += ` ${agent.padEnd(agentWidth)} │ ${model.padEnd(modelWidth)}\n`;
}
return out;
}
export function getAgentToModelMapForProfile(normalizedProfile: string): Record<string, string> {
const profile = VALID_PROFILES.includes(normalizedProfile) ? normalizedProfile : 'balanced';
const out: Record<string, string> = {};
for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) {
const profilesRec = profiles as unknown as Record<string, string>;
out[agent] = profile === 'inherit' ? 'inherit' : (profilesRec[profile] ?? profiles.balanced);
}
return out;
}
// ─── Effort rendering ────────────────────────────────────────────────────────
export interface EffortSpec {
param: string;
channel: string;
supported: Set<string>;
clamp(level: string): string;
}
export const EFFORT_RENDERING: Record<string, EffortSpec> = {
claude: {
param: 'output_config.effort',
channel: 'frontmatter',
supported: new Set(['low', 'medium', 'high', 'xhigh', 'max']),
clamp(level: string): string {
if (level === 'minimal') return 'low';
return level;
},
},
codex: {
param: 'model_reasoning_effort',
channel: 'api',
supported: new Set(['minimal', 'low', 'medium', 'high', 'xhigh']),
clamp(level: string): string {
if (level === 'max') return 'xhigh';
return level;
},
},
};
export interface RenderedEffort {
value: string;
param: string | null;
channel: string | null;
}
/**
* Render a universal effort string for a specific runtime.
*/
export function renderEffortForRuntime(runtime: string, universalEffort: string): RenderedEffort {
const spec = EFFORT_RENDERING[runtime];
if (!spec) {
return { value: universalEffort, param: null, channel: null };
}
return {
value: spec.clamp(universalEffort),
param: spec.param,
channel: spec.channel,
};
}
// ─── Fast mode propagation ───────────────────────────────────────────────────
export const RUNTIMES_WITH_FAST_MODE: Set<string> = new Set(['api']);

View File

@@ -1,6 +1,13 @@
'use strict';
/**
* model-profiles — re-exports model catalog symbols consumed by callers that
* historically required bin/lib/model-profiles.cjs.
*
* ADR-457 build-at-publish: the hand-written bin/lib/model-profiles.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
const {
import {
MODEL_PROFILES,
VALID_PROFILES,
AGENT_TO_PHASE_TYPE,
@@ -13,9 +20,9 @@ const {
EFFORT_RENDERING,
renderEffortForRuntime,
RUNTIMES_WITH_FAST_MODE,
} = require('./model-catalog.cjs');
} from './model-catalog.cjs';
module.exports = {
export = {
MODEL_PROFILES,
VALID_PROFILES,
AGENT_TO_PHASE_TYPE,

View File

@@ -0,0 +1,89 @@
/**
* DispatchEvent shape factory — issue #177 (ADR-0174 P1.3), extended in #178 (P1.4).
*
* Creates a structured event record for every Hub dispatch, used by
* DispatchLogger to emit stderr errors and opt-in file audit trails.
*
* ADR-457 build-at-publish: the hand-written
* bin/lib/observability/event.cjs collapsed to a TypeScript source of truth.
* Behaviour is preserved byte-for-behaviour from the prior hand-written .cjs;
* only types are added.
*
* Shape:
* traceId: string — UUID v4, generated per dispatch
* parentTraceId: string|undefined — propagated from the caller when it is a canonical UUID v4
* (RFC 4122); invalid values are silently coerced to undefined.
* command: string — the dispatched verb
* args?: unknown — only present when includeArgs === true
* result: { kind: 'ok' | 'UnknownCommand' | 'InvalidArgs' | 'HandlerRefusal' | 'HandlerFailure', ...payload }
* timestamp: string — ISO 8601
*/
import { randomUUID } from 'node:crypto';
/**
* Canonical UUID v4 regex (RFC 4122).
*/
const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* Returns true only when value is a canonical UUID v4 string.
*/
function isValidParentTraceId(value: unknown): value is string {
return typeof value === 'string' && UUID_V4_REGEX.test(value);
}
/** A HubResult object (open shape — callers supply concrete payloads). */
export type HubResult = Record<string, unknown>;
export interface MakeDispatchEventOpts {
command: string;
args?: unknown;
result: HubResult;
includeArgs?: boolean;
parentTraceId?: unknown;
}
/** An immutable DispatchEvent record. */
export interface DispatchEvent {
readonly traceId: string;
readonly parentTraceId: string | undefined;
readonly command: string;
readonly args?: unknown;
readonly result: HubResult;
readonly timestamp: string;
}
/**
* Create a DispatchEvent.
*/
export function makeDispatchEvent({
command,
args,
result,
includeArgs = false,
parentTraceId,
}: MakeDispatchEventOpts): Readonly<DispatchEvent> {
const resolvedParentTraceId = isValidParentTraceId(parentTraceId) ? parentTraceId : undefined;
const event: {
traceId: string;
parentTraceId: string | undefined;
command: string;
result: HubResult;
timestamp: string;
args?: unknown;
} = {
traceId: randomUUID(),
parentTraceId: resolvedParentTraceId,
command: String(command),
result,
timestamp: new Date().toISOString(),
};
if (includeArgs && args !== undefined) {
event.args = args;
}
return Object.freeze(event);
}

View File

@@ -1,5 +1,3 @@
'use strict';
/**
* DispatchLogger interface + default implementation — issue #177 (ADR-0174 P1.3).
*
@@ -16,12 +14,16 @@
*
* No-op logger (createNoOpLogger):
* Silent on all events. Used as the Hub default when no logger is injected.
*
* ADR-457 build-at-publish: the hand-written bin/lib/observability/logger.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
*/
const fs = require('fs');
const path = require('path');
import fs from 'node:fs';
import path from 'node:path';
const { redactEvent, shouldIncludeArgs } = require('./redaction.cjs');
import { redactEvent } from './redaction.cjs';
const AUDIT_FILE_NAME = '.gsd-trace.jsonl';
const PLANNING_DIR = '.planning';
@@ -30,10 +32,8 @@ const PLANNING_DIR = '.planning';
/**
* Safely serialise a value to JSON, falling back to a placeholder on circular refs.
* @param {unknown} value
* @returns {string}
*/
function _safeStringify(value) {
function _safeStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
@@ -43,12 +43,9 @@ function _safeStringify(value) {
/**
* Determine whether the audit file should be written to.
*
* @param {{ audit?: { enabled?: boolean } } | undefined} config
* @returns {boolean}
*/
function _isAuditEnabled(config) {
if (process.env.GSD_AUDIT === '1') return true;
function _isAuditEnabled(config: { audit?: { enabled?: boolean } } | undefined): boolean {
if (process.env['GSD_AUDIT'] === '1') return true;
if (config && config.audit && config.audit.enabled === true) return true;
return false;
}
@@ -56,11 +53,8 @@ function _isAuditEnabled(config) {
/**
* Build the redacted plain object for the audit file.
* Preserves the full DispatchEvent structure.
*
* @param {object} event - DispatchEvent
* @returns {object}
*/
function _toAuditRecord(event) {
function _toAuditRecord(event: Record<string, unknown>): Record<string, unknown> {
return redactEvent(event);
}
@@ -70,15 +64,13 @@ function _toAuditRecord(event) {
* Per ADR-0174 P1.3 contract: { "kind": "<variant>", "traceId": "<uuid>", ...typedPayload }
* The result's kind is promoted to top-level and the typed payload fields are spread in.
* The `result` wrapper is removed.
*
* @param {object} event - DispatchEvent with an error result
* @returns {object}
*/
function _toStderrRecord(event) {
function _toStderrRecord(event: Record<string, unknown>): Record<string, unknown> {
const redacted = redactEvent(event);
const { result, ...eventWithoutResult } = redacted;
// Flatten: top-level gets kind + typed payload fields from result
const { kind, ...typedPayload } = result;
const resultObj = result as Record<string, unknown>;
const { kind, ...typedPayload } = resultObj;
return Object.assign({}, eventWithoutResult, { kind }, typedPayload);
}
@@ -87,11 +79,8 @@ function _toStderrRecord(event) {
* Creates .planning/ directory if it does not exist.
*
* Uses synchronous fs API (crash-safe for v1 — dispatch is synchronous).
*
* @param {string} cwd - Project root directory.
* @param {object} event - Redacted DispatchEvent.
*/
function _appendAuditLine(cwd, event) {
function _appendAuditLine(cwd: string, event: Record<string, unknown>): void {
const planningDir = path.join(cwd, PLANNING_DIR);
// Ensure the directory exists
if (!fs.existsSync(planningDir)) {
@@ -103,35 +92,38 @@ function _appendAuditLine(cwd, event) {
// ─── Public factories ─────────────────────────────────────────────────────────
interface DispatchLogger {
onEvent(event: Record<string, unknown>): void;
}
/**
* Create a no-op logger. All events are silently dropped.
* This is the Hub's default when no logger is injected by the caller.
*
* @returns {{ onEvent(event: object): void }}
*/
function createNoOpLogger() {
function createNoOpLogger(): DispatchLogger {
return {
onEvent(_event) {
onEvent(_event: Record<string, unknown>): void {
// intentionally empty
},
};
}
interface DefaultLoggerOptions {
cwd?: string;
config?: { audit?: { enabled?: boolean } };
}
/**
* Create the default DispatchLogger.
*
* @param {object} [opts]
* @param {string} [opts.cwd=process.cwd()] - Project root; audit file is written relative to this.
* @param {object} [opts.config] - GSD config object. config.audit.enabled triggers audit.
* @returns {{ onEvent(event: object): void }}
*/
function createDefaultLogger({ cwd = process.cwd(), config } = {}) {
function createDefaultLogger({ cwd = process.cwd(), config }: DefaultLoggerOptions = {}): DispatchLogger {
return {
/**
* @param {object} event - A DispatchEvent from the Hub.
* @param event - A DispatchEvent from the Hub.
*/
onEvent(event) {
const isOk = event && event.result && event.result.kind === 'ok';
onEvent(event: Record<string, unknown>): void {
const resultObj = event && (event['result'] as Record<string, unknown> | undefined);
const isOk = resultObj && resultObj['kind'] === 'ok';
// ── Audit file (both ok and error) ────────────────────────────────────
if (_isAuditEnabled(config)) {
@@ -144,7 +136,7 @@ function createDefaultLogger({ cwd = process.cwd(), config } = {}) {
_safeStringify({
level: 'warn',
source: 'DispatchLogger',
message: 'audit file write failed: ' + String(auditErr && auditErr.message || auditErr),
message: 'audit file write failed: ' + String((auditErr as Error | null)?.message ?? auditErr),
}) + '\n'
);
}
@@ -161,7 +153,7 @@ function createDefaultLogger({ cwd = process.cwd(), config } = {}) {
_safeStringify({
level: 'warn',
source: 'DispatchLogger',
message: 'stderr emit failed: ' + String(stderrErr && stderrErr.message || stderrErr),
message: 'stderr emit failed: ' + String((stderrErr as Error | null)?.message ?? stderrErr),
}) + '\n'
);
}
@@ -171,4 +163,4 @@ function createDefaultLogger({ cwd = process.cwd(), config } = {}) {
};
}
module.exports = { createDefaultLogger, createNoOpLogger };
export = { createDefaultLogger, createNoOpLogger };

View File

@@ -1,7 +1,8 @@
'use strict';
/**
* Arg redaction policy — issue #177 (ADR-0174 P1.3).
* Arg redaction policy — issue #177 (ADR-457 build-at-publish: the
* hand-written bin/lib/observability/redaction.cjs collapsed to a TypeScript
* source of truth). Behaviour is preserved byte-for-behaviour from the prior
* hand-written .cjs; only types are added.
*
* Privacy default: args are OMITTED from every emitted event (both stderr
* and file audit). Opt-in: set GSD_AUDIT_ARGS=1 to include args verbatim.
@@ -11,14 +12,17 @@
* can toggle the env var without module-level caching issues.
*/
/**
* A DispatchEvent (frozen or plain). Must be an object; `args` is optional.
*/
export type DispatchEvent = Record<string, unknown>;
/**
* Returns true when the caller has opted in to including args in events.
* Only GSD_AUDIT_ARGS === '1' enables inclusion; any other value (including
* empty string, 'true', 'yes') keeps the default of omitting args.
*
* @returns {boolean}
*/
function shouldIncludeArgs() {
export function shouldIncludeArgs(): boolean {
return process.env.GSD_AUDIT_ARGS === '1';
}
@@ -32,10 +36,10 @@ function shouldIncludeArgs() {
*
* The original event object is never mutated (it is frozen by makeDispatchEvent).
*
* @param {object} event - A DispatchEvent (frozen or plain).
* @returns {object} A new plain object with the same fields, minus args when redacted.
* @param event - A DispatchEvent (frozen or plain).
* @returns A new plain object with the same fields, minus args when redacted.
*/
function redactEvent(event) {
export function redactEvent(event: DispatchEvent): DispatchEvent {
if (shouldIncludeArgs()) {
// Include path: return a shallow copy with args preserved if present
const copy = Object.assign({}, event);
@@ -46,5 +50,3 @@ function redactEvent(event) {
const { args: _dropped, ...rest } = event;
return rest;
}
module.exports = { shouldIncludeArgs, redactEvent };

View File

@@ -0,0 +1,17 @@
/**
* Type declaration for package-identity.cjs — permanently hand-written,
* not migrated per ADR-457. This .d.cts file allows strict TypeScript
* sources (src/*.cts) to import it under nodenext moduleResolution.
*
* The module exports an Object.freeze()-sealed object; all exports are
* constant strings / a function. Mirror the exact module.exports shape
* from package-identity.cjs as named exports.
*/
export declare const packageName: string;
export declare const PACKAGE_NAME: string;
export declare const binName: string;
export declare const repoSlug: string;
export declare const repoUrl: string;
export declare const changelogRawUrl: string;
export declare function manualInstallCommand(opts?: { scope?: string; runtime?: string }): string;

View File

@@ -1,10 +1,3 @@
'use strict';
const { PHASE_SUBCOMMANDS } = require('./command-aliases.cjs');
// ─── CommandRoutingHub (issue #3788, simplified in #175, typed in #176) ───────
const { createHub, ERROR_KINDS, makeInvalidArgs } = require('./command-routing-hub.cjs');
/**
* Manifest-backed phase subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
@@ -16,11 +9,45 @@ const { createHub, ERROR_KINDS, makeInvalidArgs } = require('./command-routing-h
*
* #3788: dispatch is mediated by CommandRoutingHub. The public entry point
* and observable CLI behaviour are unchanged.
*
* ADR-457 build-at-publish: the hand-written bin/lib/phase-command-router.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
function routePhaseCommand({ phase, args, cwd, raw, error }) {
import { PHASE_SUBCOMMANDS } from './command-aliases.cjs';
// ─── CommandRoutingHub (issue #3788, simplified in #175, typed in #176) ───────
// eslint-disable-next-line @typescript-eslint/no-require-imports
import commandRoutingHub = require('./command-routing-hub.cjs');
const { createHub, ERROR_KINDS, makeInvalidArgs } = commandRoutingHub;
// ─── Types ────────────────────────────────────────────────────────────────────
interface PhaseHandlers {
cmdPhaseMvpMode: (cwd: string, args: string[], raw: boolean) => void;
cmdPhaseNextDecimal: (cwd: string, arg: string | undefined, raw: boolean) => void;
cmdPhaseAdd: (cwd: string, desc: string, raw: boolean, customId: string | null) => void;
cmdPhaseAddBatch: (cwd: string, descriptions: string[], raw: boolean) => void;
cmdPhaseInsert: (cwd: string, pos: string | undefined, desc: string, raw: boolean) => void;
cmdPhaseRemove: (cwd: string, phaseNum: string, opts: { force: boolean }, raw: boolean) => void;
cmdPhaseComplete: (cwd: string, phaseNum: string | undefined, raw: boolean) => void;
}
interface RoutePhaseCommandOptions {
phase: PhaseHandlers;
args: string[];
cwd: string;
raw: boolean;
error: (message: string) => void;
}
// ─── Implementation ───────────────────────────────────────────────────────────
function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOptions): void {
// ── Unsupported subcommands ─────────────────────────────────────────────────
// Resolved before dispatch so the error message stays deterministic.
const UNSUPPORTED = {
const UNSUPPORTED: Record<string, string> = {
scaffold: 'phase scaffold is routed through the top-level scaffold command.',
};
@@ -56,13 +83,13 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
// Each handler receives a ctx object from the hub and must return a HubResult.
const cjsRegistry = {
phase: {
'next-decimal': (_ctx) => {
'next-decimal': (_ctx: Record<string, unknown>): { ok: true; data: null } => {
phase.cmdPhaseNextDecimal(cwd, args[2], raw);
return { ok: true, data: null };
return { ok: true as const, data: null };
},
add: (_ctx) => {
let customId = null;
const descArgs = [];
add: (_ctx: Record<string, unknown>) => {
let customId: string | null = null;
const descArgs: string[] = [];
for (let i = 2; i < args.length; i++) {
const token = args[i];
if (token === '--raw') {
@@ -82,18 +109,18 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
}
}
phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId);
return { ok: true, data: null };
return { ok: true as const, data: null };
},
'add-batch': (_ctx) => {
'add-batch': (_ctx: Record<string, unknown>) => {
const descFlagIdx = args.indexOf('--descriptions');
let descriptions;
let descriptions: string[];
if (descFlagIdx !== -1) {
const rawDescriptions = args[descFlagIdx + 1];
if (!rawDescriptions || rawDescriptions.startsWith('--')) {
return makeInvalidArgs('--descriptions', '--descriptions must be a JSON array');
}
try {
descriptions = JSON.parse(rawDescriptions);
descriptions = JSON.parse(rawDescriptions) as string[];
} catch {
return makeInvalidArgs('--descriptions', '--descriptions must be a JSON array');
}
@@ -104,19 +131,19 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
descriptions = args.slice(2).filter(a => a !== '--raw');
}
phase.cmdPhaseAddBatch(cwd, descriptions, raw);
return { ok: true, data: null };
return { ok: true as const, data: null };
},
insert: (_ctx) => {
insert: (_ctx: Record<string, unknown>) => {
if (args.includes('--dry-run')) {
return makeInvalidArgs('--dry-run', 'phase insert does not support --dry-run');
}
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
return { ok: true, data: null };
return { ok: true as const, data: null };
},
remove: (_ctx) => {
remove: (_ctx: Record<string, unknown>) => {
const removeArgs = args.slice(2).filter(token => token !== '--raw');
let forceFlag = false;
const positional = [];
const positional: string[] = [];
for (const token of removeArgs) {
if (token === '--force') {
forceFlag = true;
@@ -131,11 +158,11 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
return makeInvalidArgs('<phase-number>', 'phase remove accepts exactly one phase number');
}
phase.cmdPhaseRemove(cwd, positional[0], { force: forceFlag }, raw);
return { ok: true, data: null };
return { ok: true as const, data: null };
},
complete: (_ctx) => {
complete: (_ctx: Record<string, unknown>): { ok: true; data: null } => {
phase.cmdPhaseComplete(cwd, args[2], raw);
return { ok: true, data: null };
return { ok: true as const, data: null };
},
},
};
@@ -186,6 +213,6 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
}
}
module.exports = {
export = {
routePhaseCommand,
};

View File

@@ -1,8 +1,9 @@
'use strict';
/**
* Phase Lifecycle Pure Helpers — pure-computation functions extracted from
* the phase-lifecycle SDK handler.
* the phase-lifecycle SDK handler (ADR-457 build-at-publish: the hand-written
* bin/lib/phase-lifecycle.cjs collapsed to a TypeScript source of truth).
* Behaviour is preserved byte-for-behaviour from the prior hand-written .cjs;
* only types are added.
*
* I/O adapter pattern (ADR-3524 Section 4): each side supplies its own I/O
* (sync readFileSync for CJS, async readFile for SDK); the pure computation
@@ -19,14 +20,21 @@
* - Issue #4 (open-gsd/gsd-core)
*/
/** Result of deriveProgressFromRoadmap. */
export interface RoadmapProgress {
completedPhases: number | null;
totalPhases: number | null;
totalPlans: number | null;
}
/**
* Derive completed_phases, total_phases, and total_plans from ROADMAP content.
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
*/
function deriveProgressFromRoadmap(roadmapContent) {
let completedPhases = null;
let totalPhases = null;
let totalPlans = null;
export function deriveProgressFromRoadmap(roadmapContent: string): RoadmapProgress {
let completedPhases: number | null = null;
let totalPhases: number | null = null;
let totalPlans: number | null = null;
try {
// Count Complete rows in the progress table (Status column = "Complete").
@@ -54,7 +62,7 @@ function deriveProgressFromRoadmap(roadmapContent) {
// Sum plan counts from M/N columns in progress table
let totalPlansSum = 0;
const planCellPattern = /\|\s*\d+[^|]*\|\s*(\d+)\/(\d+)\s*\|/gi;
let pm;
let pm: RegExpExecArray | null;
while ((pm = planCellPattern.exec(roadmapContent)) !== null) {
totalPlansSum += parseInt(pm[2], 10);
}
@@ -68,12 +76,7 @@ function deriveProgressFromRoadmap(roadmapContent) {
* Compute progress percent clamped to 100.
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
*/
function clampPercent(completed, total) {
export function clampPercent(completed: number, total: number): number {
if (!total || total <= 0) return 0;
return Math.min(100, Math.round((completed / total) * 100));
}
module.exports = {
deriveProgressFromRoadmap,
clampPercent,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
/**
* Manifest-backed phases subcommand router.
* Keeps gsd-tools.cjs thin while preserving current CJS semantics.
*
* Unsupported in this router (treated as unknown):
* - archive: `phases archive` is excluded from the subcommands list so it
* falls through to the unknown-subcommand error path.
*
* ADR-457 build-at-publish: the hand-written bin/lib/phases-command-router.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
import { PHASES_SUBCOMMANDS } from './command-aliases.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
// ─── Types ────────────────────────────────────────────────────────────────────
interface PhaseListOptions {
type: string | null;
phase: string | null;
includeArchived: boolean;
}
interface PhaseModule {
cmdPhasesList(cwd: string, options: PhaseListOptions, raw: boolean): void;
}
interface MilestoneModule {
cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void;
}
interface RoutePhasesCommandOptions {
phase: PhaseModule;
milestone: MilestoneModule;
args: string[];
cwd: string;
raw: boolean;
error: (message: string) => void;
}
// ─── Implementation ───────────────────────────────────────────────────────────
function routePhasesCommand({ phase, milestone, args, cwd, raw, error }: RoutePhasesCommandOptions): void {
routeCjsCommandFamily({
args,
// Exclude 'archive' so it hits the unknownMessage path.
subcommands: PHASES_SUBCOMMANDS.filter((s) => s !== 'archive'),
error,
unknownMessage: (_subcommand: string, available: string[]) => `Unknown phases subcommand. Available: ${available.join(', ')}`,
handlers: {
list: () => {
const typeIndex = args.indexOf('--type');
const phaseIndex = args.indexOf('--phase');
const options: PhaseListOptions = {
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)),
},
});
}
export = {
routePhasesCommand,
};

106
src/plan-scan.cts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Plan Scan Module — detects plan and summary files in a phase directory.
* Supports both flat (pre-#3139) and nested (post-#3139) layouts.
*
* ADR-457 build-at-publish: the hand-written bin/lib/plan-scan.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
// Excluded derivative files
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
function isRootPlanFile(fileName: string): boolean {
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;
// A summary is never a plan. Reject summaries before the loose /PLAN/i
// fallback so legacy `<N>-PLAN-<NN>-SUMMARY.md` names (which contain the
// substring "PLAN") are not double-counted as plans. (#500 RC2)
if (isRootSummaryFile(fileName)) return false;
return /\.md$/i.test(fileName) && /PLAN/i.test(fileName);
}
function isNestedPlanFile(fileName: string): boolean {
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: string): boolean {
return fileName.endsWith('-SUMMARY.md') || fileName === 'SUMMARY.md';
}
function isNestedSummaryFile(fileName: string): boolean {
return /^SUMMARY-\d+.*\.md$/i.test(fileName) || /-SUMMARY-\d+.*\.md$/i.test(fileName);
}
interface PhaseScanResult {
planCount: number;
summaryCount: number;
completed: boolean;
hasNestedPlans: boolean;
planFiles: string[];
summaryFiles: string[];
}
function scanPhasePlans(phaseDir: string): PhaseScanResult {
let rootFiles: string[];
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: string[] = [];
let nestedSummaryFiles: string[] = [];
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.
// Using export = with extra properties attached.
export = Object.assign(scanPhasePlans, {
scanPhasePlans,
isRootPlanFile,
isNestedPlanFile,
isRootSummaryFile,
isNestedSummaryFile,
});

View File

@@ -7,12 +7,19 @@
*
* Active workstream pointer policy/session identity lives in
* active-workstream-store.cjs and is consumed here via thin adapters.
*
* ADR-457 build-at-publish: the hand-written bin/lib/planning-workspace.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
const fs = require('fs');
const path = require('path');
const { platformEnsureDir } = require('./shell-command-projection.cjs');
const { realClock } = require('./clock.cjs');
import fs from 'node:fs';
import path from 'node:path';
import { platformEnsureDir } from './shell-command-projection.cjs';
import { realClock } from './clock.cjs';
import type { Clock } from './clock.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import activeWorkstreamStore = require('./active-workstream-store.cjs');
const {
createSharedPointerAdapter,
createSessionScopedPointerAdapter,
@@ -20,10 +27,10 @@ const {
getActiveWorkstream: getStoredActiveWorkstream,
setActiveWorkstream: setStoredActiveWorkstream,
clearActiveWorkstream: clearStoredActiveWorkstream,
} = require('./active-workstream-store.cjs');
} = activeWorkstreamStore;
// Track .planning/.lock files held by this process so they can be removed on exit.
const _heldPlanningLocks = new Set();
const _heldPlanningLocks = new Set<string>();
process.on('exit', () => {
for (const lockPath of _heldPlanningLocks) {
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
@@ -47,9 +54,15 @@ const PLANNING_LOCK_RETRY_ERRNOS = new Set([
'ESTALE', // NFS: stale file handle (self-resolves on retry)
]);
function planningDir(cwd, ws, project) {
if (project === undefined) project = process.env.GSD_PROJECT || null;
if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null;
// Loose opts type accepted by createPlanningWorkspace — passed through to
// active-workstream-store get/set/clear which accept { activeWorkstreamAdapter?,
// activeWorkstreamAdapters?, getStored? }. Using Record<string, unknown> is
// compatible with the structural type the store expects.
type WorkstreamAdapterOpts = Record<string, unknown>;
function planningDir(cwd: string, ws?: string | null, project?: string | null): string {
if (project === undefined) project = process.env['GSD_PROJECT'] ?? null;
if (ws === undefined) ws = process.env['GSD_WORKSTREAM'] ?? null;
// Reject path separators and traversal components in project/workstream names
const BAD_SEGMENT = /[/\\]|\.\./;
@@ -66,11 +79,21 @@ function planningDir(cwd, ws, project) {
return base;
}
function planningRoot(cwd) {
function planningRoot(cwd: string): string {
return path.join(cwd, '.planning');
}
function planningPaths(cwd, ws) {
interface PlanningPaths {
planning: string;
state: string;
roadmap: string;
project: string;
config: string;
phases: string;
requirements: string;
}
function planningPaths(cwd: string, ws?: string | null): PlanningPaths {
const base = planningDir(cwd, ws);
return {
planning: base,
@@ -84,14 +107,14 @@ function planningPaths(cwd, ws) {
}
/**
* @param {string} cwd
* @param {function} fn - callback to run while holding the lock
* @param {{ now(): number, sleep(ms: number): void }} [clock]
* @param cwd
* @param fn - callback to run while holding the lock
* @param clock
* Optional clock seam for testing. Defaults to realClock (Date.now + Atomics.wait).
* Pass a fake clock from tests/helpers/clock.cjs to drive timeout/stale logic
* without real wall-clock waits.
*/
function withPlanningLock(cwd, fn, clock) {
function withPlanningLock<T>(cwd: string, fn: () => T, clock?: Clock): T {
if (clock === undefined) clock = realClock;
const lockPath = path.join(planningDir(cwd), '.lock');
const lockTimeout = 10000; // 10 seconds
@@ -100,7 +123,7 @@ function withPlanningLock(cwd, fn, clock) {
// Ensure .planning/ exists
try { platformEnsureDir(planningDir(cwd)); } catch { /* ok */ }
function acquireLock() {
function acquireLock(): void {
// Atomic create — fails if file exists
fs.writeFileSync(lockPath, JSON.stringify({
pid: process.pid,
@@ -111,7 +134,7 @@ function withPlanningLock(cwd, fn, clock) {
_heldPlanningLocks.add(lockPath);
}
function runWithHeldLock() {
function runWithHeldLock(): T {
try {
return fn();
} finally {
@@ -131,11 +154,12 @@ function withPlanningLock(cwd, fn, clock) {
// are recoverable — wait and retry rather than propagating.
// See PLANNING_LOCK_RETRY_ERRNOS for the full list and rationale.
if (lockWasAcquired) throw err;
if (PLANNING_LOCK_RETRY_ERRNOS.has(err.code)) {
const nodeErr = err as NodeJS.ErrnoException;
if (PLANNING_LOCK_RETRY_ERRNOS.has(nodeErr.code ?? '')) {
clock.sleep(100);
continue;
}
if (err.code === 'EEXIST') {
if (nodeErr.code === 'EEXIST') {
// Lock exists — check if stale (>30s old)
try {
const stat = fs.statSync(lockPath);
@@ -159,16 +183,27 @@ function withPlanningLock(cwd, fn, clock) {
return runWithHeldLock();
}
function createPlanningWorkspace(cwd, opts = {}) {
function createPlanningWorkspace(cwd: string, opts: WorkstreamAdapterOpts = {}): {
paths: {
dir(ws?: string | null, project?: string | null): string;
root(): string;
all(ws?: string | null): PlanningPaths;
};
activeWorkstream: {
get(): string | null;
set(name: string): void;
clear(): void;
};
} {
return {
paths: {
dir(ws, project) {
dir(ws?: string | null, project?: string | null) {
return planningDir(cwd, ws, project);
},
root() {
return planningRoot(cwd);
},
all(ws) {
all(ws?: string | null) {
return planningPaths(cwd, ws);
},
},
@@ -176,7 +211,7 @@ function createPlanningWorkspace(cwd, opts = {}) {
get() {
return getStoredActiveWorkstream(cwd, opts);
},
set(name) {
set(name: string) {
setStoredActiveWorkstream(cwd, name, opts);
},
clear() {
@@ -186,11 +221,11 @@ function createPlanningWorkspace(cwd, opts = {}) {
};
}
function getActiveWorkstream(cwd) {
function getActiveWorkstream(cwd: string): string | null {
return getStoredActiveWorkstream(cwd);
}
function setActiveWorkstream(cwd, name) {
function setActiveWorkstream(cwd: string, name: string): void {
setStoredActiveWorkstream(cwd, name);
}
@@ -206,24 +241,23 @@ function setActiveWorkstream(cwd, name) {
* duplication that previously existed across init.cjs, roadmap.cjs,
* core.cjs, gap-checker.cjs (#3739).
*
* @param {string|string[]} absDirOrFiles - Absolute path to the phase directory,
* @param absDirOrFiles - Absolute path to the phase directory,
* OR an already-read files array (avoids a redundant readdirSync at call sites
* that already hold a directory listing).
* @returns {string|null}
*/
function findContextMdIn(absDirOrFiles) {
function findContextMdIn(absDirOrFiles: string | string[]): string | null {
try {
const files = Array.isArray(absDirOrFiles)
? absDirOrFiles
: fs.readdirSync(absDirOrFiles);
if (files.includes('CONTEXT.md')) return 'CONTEXT.md';
return files.find(f => f.endsWith('-CONTEXT.md')) ?? null;
return files.find((f: string) => f.endsWith('-CONTEXT.md')) ?? null;
} catch {
return null;
}
}
module.exports = {
export = {
createPlanningWorkspace,
createSharedPointerAdapter,
createSessionScopedPointerAdapter,

View File

@@ -7,16 +7,99 @@
* - generate-dev-preferences: dev-preferences.md command artifact
* - generate-claude-profile: Developer Profile section in CLAUDE.md
* - generate-claude-md: full CLAUDE.md with managed sections
*
* ADR-457 build-at-publish: the hand-written bin/lib/profile-output.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { output, error, loadConfig } = require('./core.cjs');
const { platformReadSync: safeReadFile, platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
const { getGlobalSkillDir } = require('./runtime-homes.cjs');
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
const { resolveRuntimeNameFromCandidates } = require('./runtime-name-policy.cjs');
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { output, error, loadConfig } = core;
import { platformReadSync as safeReadFile, platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
import { getGlobalSkillDir } from './runtime-homes.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { resolveRuntimeNameFromCandidates } from './runtime-name-policy.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ProfilingOption {
label: string;
value: string;
rating: string;
}
interface ProfilingQuestion {
dimension: string;
header: string;
context: string;
question: string;
options: ProfilingOption[];
}
interface EvidenceEntry {
signal?: string;
quote?: string;
example?: string;
pattern?: string;
project?: string;
}
interface DimensionData {
rating?: string;
confidence?: string;
evidence_count?: number;
cross_project_consistent?: boolean | null;
evidence?: EvidenceEntry[];
evidence_quotes?: EvidenceEntry[];
summary?: string;
claude_instruction?: string;
}
interface AnalysisData {
profile_version?: string;
analyzed_at?: string;
data_source?: string;
projects_list?: string[];
projects_analyzed?: string[];
message_count?: number;
messages_analyzed?: number;
message_threshold?: string;
sensitive_excluded?: unknown[];
dimensions: Record<string, DimensionData>;
}
interface SectionResult {
content: string;
source: string;
linkPath?: string | null;
hasFallback: boolean;
}
interface CmdWriteProfileOptions {
input?: string;
output?: string;
}
interface CmdGenerateDevPreferencesOptions {
analysis?: string;
output?: string;
stack?: string;
}
interface CmdGenerateClaudeProfileOptions {
analysis?: string;
output?: string;
global?: boolean;
}
interface CmdGenerateClaudeMdOptions {
output?: string;
auto?: boolean;
}
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -26,7 +109,7 @@ const DIMENSION_KEYS = [
'frustration_triggers', 'learning_style'
];
const PROFILING_QUESTIONS = [
const PROFILING_QUESTIONS: ProfilingQuestion[] = [
{
dimension: 'communication_style',
header: 'Communication Style',
@@ -125,7 +208,7 @@ const PROFILING_QUESTIONS = [
},
];
const CLAUDE_INSTRUCTIONS = {
const CLAUDE_INSTRUCTIONS: Record<string, Record<string, string>> = {
communication_style: {
'terse-direct': 'Keep responses concise and action-oriented. Skip lengthy preambles. Match this developer\'s direct style.',
'conversational': 'Use a natural conversational tone. Explain reasoning briefly alongside code. Engage with the developer\'s questions.',
@@ -180,9 +263,9 @@ const CLAUDE_INSTRUCTIONS = {
// commands route correctly under the active install (#3584). The values must
// be computed per-call rather than at module load because the slash form
// depends on the runtime resolved from the project's config/env.
function buildClaudeMdFallbacks(runtime) {
function buildClaudeMdFallbacks(runtime: unknown): Record<string, string> {
return {
project: `Project not yet initialized. Run ${formatGsdSlash('new-project', runtime)} to set up.`,
project: `Project not yet initialized. Run ${String(formatGsdSlash('new-project', runtime))} to set up.`,
stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.',
conventions: 'Conventions not yet established. Will populate as patterns emerge during development.',
architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.',
@@ -193,25 +276,25 @@ function buildClaudeMdFallbacks(runtime) {
// Directories where project skills may live (checked in order)
const SKILL_SEARCH_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.github/skills', '.codex/skills'];
function buildClaudeMdWorkflowEnforcement(runtime) {
function buildClaudeMdWorkflowEnforcement(runtime: unknown): string {
return [
'Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.',
'',
'Use these entry points:',
`- \`${formatGsdSlash('quick', runtime)}\` for small fixes, doc updates, and ad-hoc tasks`,
`- \`${formatGsdSlash('debug', runtime)}\` for investigation and bug fixing`,
`- \`${formatGsdSlash('execute-phase', runtime)}\` for planned phase work`,
`- \`${String(formatGsdSlash('quick', runtime))}\` for small fixes, doc updates, and ad-hoc tasks`,
`- \`${String(formatGsdSlash('debug', runtime))}\` for investigation and bug fixing`,
`- \`${String(formatGsdSlash('execute-phase', runtime))}\` for planned phase work`,
'',
'Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.',
].join('\n');
}
function buildClaudeMdProfilePlaceholder(runtime) {
function buildClaudeMdProfilePlaceholder(runtime: unknown): string {
return [
'<!-- GSD:profile-start -->',
'## Developer Profile',
'',
`> Profile not yet configured. Run \`${formatGsdSlash('profile-user', runtime)}\` to generate your developer profile.`,
`> Profile not yet configured. Run \`${String(formatGsdSlash('profile-user', runtime))}\` to generate your developer profile.`,
'> This section is managed by `generate-claude-profile` -- do not edit manually.',
'<!-- GSD:profile-end -->',
].join('\n');
@@ -219,7 +302,7 @@ function buildClaudeMdProfilePlaceholder(runtime) {
// ─── Helper Functions ─────────────────────────────────────────────────────────
function isAmbiguousAnswer(dimension, value) {
function isAmbiguousAnswer(dimension: string, value: string): boolean {
if (dimension === 'communication_style' && value === 'd') return true;
const question = PROFILING_QUESTIONS.find(q => q.dimension === dimension);
if (!question) return false;
@@ -228,7 +311,7 @@ function isAmbiguousAnswer(dimension, value) {
return option.rating === 'mixed';
}
function generateClaudeInstruction(dimension, rating) {
function generateClaudeInstruction(dimension: string, rating: string): string {
const dimInstructions = CLAUDE_INSTRUCTIONS[dimension];
if (dimInstructions && dimInstructions[rating]) {
return dimInstructions[rating];
@@ -236,7 +319,7 @@ function generateClaudeInstruction(dimension, rating) {
return `Adapt to this developer's ${dimension.replace(/_/g, ' ')} preference: ${rating}.`;
}
function extractSectionContent(fileContent, sectionName) {
function extractSectionContent(fileContent: string, sectionName: string): string | null {
const startMarker = `<!-- GSD:${sectionName}-start`;
const endMarker = `<!-- GSD:${sectionName}-end -->`;
const startIdx = fileContent.indexOf(startMarker);
@@ -247,7 +330,7 @@ function extractSectionContent(fileContent, sectionName) {
return fileContent.substring(startTagEnd + 3, endIdx);
}
function buildSection(sectionName, sourceFile, content) {
function buildSection(sectionName: string, sourceFile: string, content: string): string {
return [
`<!-- GSD:${sectionName}-start source:${sourceFile} -->`,
content,
@@ -255,7 +338,7 @@ function buildSection(sectionName, sourceFile, content) {
].join('\n');
}
function updateSection(fileContent, sectionName, newContent) {
function updateSection(fileContent: string, sectionName: string, newContent: string): { content: string; action: string } {
const startMarker = `<!-- GSD:${sectionName}-start`;
const endMarker = `<!-- GSD:${sectionName}-end -->`;
const startIdx = fileContent.indexOf(startMarker);
@@ -268,18 +351,18 @@ function updateSection(fileContent, sectionName, newContent) {
return { content: fileContent.trimEnd() + '\n\n' + newContent + '\n', action: 'appended' };
}
function detectManualEdit(fileContent, sectionName, expectedContent) {
function detectManualEdit(fileContent: string, sectionName: string, expectedContent: string): boolean {
const currentContent = extractSectionContent(fileContent, sectionName);
if (currentContent === null) return false;
const normalize = (s) => s.trim().replace(/\n{3,}/g, '\n\n');
const normalize = (s: string) => s.trim().replace(/\n{3,}/g, '\n\n');
return normalize(currentContent) !== normalize(expectedContent);
}
function extractMarkdownSection(content, sectionName) {
function extractMarkdownSection(content: string | null, sectionName: string): string | null {
if (!content) return null;
const lines = content.split('\n');
let capturing = false;
const result = [];
const result: string[] = [];
const headingPattern = new RegExp(`^## ${sectionName}\\s*$`);
for (const line of lines) {
if (headingPattern.test(line)) {
@@ -295,14 +378,14 @@ function extractMarkdownSection(content, sectionName) {
// ─── CLAUDE.md Section Generators ─────────────────────────────────────────────
function generateProjectSection(cwd) {
function generateProjectSection(cwd: string): SectionResult {
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
const projectPath = path.join(cwd, '.planning', 'PROJECT.md');
const content = safeReadFile(projectPath);
if (!content) {
return { content: fallbacks.project, source: 'PROJECT.md', linkPath: null, hasFallback: true };
return { content: fallbacks['project'], source: 'PROJECT.md', linkPath: null, hasFallback: true };
}
const parts = [];
const parts: string[] = [];
const h1Match = content.match(/^# (.+)$/m);
if (h1Match) parts.push(`**${h1Match[1]}**`);
const whatThisIs = extractMarkdownSection(content, 'What This Is');
@@ -321,12 +404,12 @@ function generateProjectSection(cwd) {
if (body) parts.push(`### Constraints\n\n${body}`);
}
if (parts.length === 0) {
return { content: fallbacks.project, source: 'PROJECT.md', linkPath: null, hasFallback: true };
return { content: fallbacks['project'], source: 'PROJECT.md', linkPath: null, hasFallback: true };
}
return { content: parts.join('\n\n'), source: 'PROJECT.md', linkPath: '.planning/PROJECT.md', hasFallback: false };
}
function generateStackSection(cwd) {
function generateStackSection(cwd: string): SectionResult {
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
const codebasePath = path.join(cwd, '.planning', 'codebase', 'STACK.md');
const researchPath = path.join(cwd, '.planning', 'research', 'STACK.md');
@@ -339,10 +422,10 @@ function generateStackSection(cwd) {
linkPath = '.planning/research/STACK.md';
}
if (!content) {
return { content: fallbacks.stack, source: 'STACK.md', linkPath: null, hasFallback: true };
return { content: fallbacks['stack'], source: 'STACK.md', linkPath: null, hasFallback: true };
}
const lines = content.split('\n');
const summaryLines = [];
const summaryLines: string[] = [];
let inTable = false;
for (const line of lines) {
if (line.startsWith('#')) {
@@ -357,15 +440,15 @@ function generateStackSection(cwd) {
return { content: summary, source, linkPath, hasFallback: false };
}
function generateConventionsSection(cwd) {
function generateConventionsSection(cwd: string): SectionResult {
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
const conventionsPath = path.join(cwd, '.planning', 'codebase', 'CONVENTIONS.md');
const content = safeReadFile(conventionsPath);
if (!content) {
return { content: fallbacks.conventions, source: 'CONVENTIONS.md', linkPath: null, hasFallback: true };
return { content: fallbacks['conventions'], source: 'CONVENTIONS.md', linkPath: null, hasFallback: true };
}
const lines = content.split('\n');
const summaryLines = [];
const summaryLines: string[] = [];
for (const line of lines) {
if (line.startsWith('#')) { if (!line.startsWith('# ')) summaryLines.push(line); continue; }
if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('|')) summaryLines.push(line);
@@ -374,15 +457,15 @@ function generateConventionsSection(cwd) {
return { content: summary, source: 'CONVENTIONS.md', linkPath: '.planning/codebase/CONVENTIONS.md', hasFallback: false };
}
function generateArchitectureSection(cwd) {
function generateArchitectureSection(cwd: string): SectionResult {
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
const architecturePath = path.join(cwd, '.planning', 'codebase', 'ARCHITECTURE.md');
const content = safeReadFile(architecturePath);
if (!content) {
return { content: fallbacks.architecture, source: 'ARCHITECTURE.md', linkPath: null, hasFallback: true };
return { content: fallbacks['architecture'], source: 'ARCHITECTURE.md', linkPath: null, hasFallback: true };
}
const lines = content.split('\n');
const summaryLines = [];
const summaryLines: string[] = [];
for (const line of lines) {
if (line.startsWith('#')) { if (!line.startsWith('# ')) summaryLines.push(line); continue; }
if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('|') || line.startsWith('```')) summaryLines.push(line);
@@ -391,7 +474,7 @@ function generateArchitectureSection(cwd) {
return { content: summary, source: 'ARCHITECTURE.md', linkPath: '.planning/codebase/ARCHITECTURE.md', hasFallback: false };
}
function generateWorkflowSection(cwd) {
function generateWorkflowSection(cwd: string): SectionResult {
return {
content: buildClaudeMdWorkflowEnforcement(resolveRuntime(cwd)),
source: 'GSD defaults',
@@ -405,15 +488,15 @@ function generateWorkflowSection(cwd) {
* (name + description) for each. Returns a table summary for CLAUDE.md so
* agents know which skills are available at session startup (Layer 1 discovery).
*/
function generateSkillsSection(cwd) {
function generateSkillsSection(cwd: string): SectionResult {
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
const discovered = [];
const discovered: Array<{ name: string; description: string; path: string }> = [];
for (const dir of SKILL_SEARCH_DIRS) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
let entries;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(absDir, { withFileTypes: true });
} catch {
@@ -443,7 +526,7 @@ function generateSkillsSection(cwd) {
}
if (discovered.length === 0) {
return { content: fallbacks.skills, source: 'skills/', hasFallback: true };
return { content: fallbacks['skills'], source: 'skills/', hasFallback: true };
}
const lines = ['| Skill | Description | Path |', '|-------|-------------|------|'];
@@ -461,7 +544,7 @@ function generateSkillsSection(cwd) {
* Extract name and description from YAML-like frontmatter in a SKILL.md file.
* Handles multi-line description values (continuation lines indented with spaces).
*/
function extractSkillFrontmatter(content) {
function extractSkillFrontmatter(content: string): { name: string; description: string } {
const result = { name: '', description: '' };
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!fmMatch) return result;
@@ -493,28 +576,28 @@ function extractSkillFrontmatter(content) {
// ─── Commands ─────────────────────────────────────────────────────────────────
function cmdWriteProfile(cwd, options, raw) {
function cmdWriteProfile(cwd: string, options: CmdWriteProfileOptions, raw: boolean): void {
if (!options.input) {
error('--input <analysis-json-path> is required');
}
let analysisPath = options.input;
let analysisPath = options.input!;
if (!path.isAbsolute(analysisPath)) analysisPath = path.join(cwd, analysisPath);
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
let analysis;
let analysis: AnalysisData;
const analysisRaw = safeReadFile(analysisPath);
try {
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
analysis = JSON.parse(analysisRaw);
analysis = JSON.parse(analysisRaw) as AnalysisData;
} catch (err) {
error(`Failed to parse analysis JSON: ${err.message}`);
error(`Failed to parse analysis JSON: ${(err as Error).message}`);
}
if (!analysis.dimensions || typeof analysis.dimensions !== 'object') {
if (!analysis!.dimensions || typeof analysis!.dimensions !== 'object') {
error('Analysis JSON must contain a "dimensions" object');
}
if (!analysis.profile_version) {
if (!analysis!.profile_version) {
error('Analysis JSON must contain "profile_version"');
}
@@ -534,7 +617,7 @@ function cmdWriteProfile(cwd, options, raw) {
let redactedCount = 0;
function redactSensitive(text) {
function redactSensitive(text: unknown): unknown {
if (typeof text !== 'string') return text;
let result = text;
for (const pattern of SENSITIVE_PATTERNS) {
@@ -548,14 +631,14 @@ function cmdWriteProfile(cwd, options, raw) {
return result;
}
for (const dimKey of Object.keys(analysis.dimensions)) {
const dim = analysis.dimensions[dimKey];
for (const dimKey of Object.keys(analysis!.dimensions)) {
const dim = analysis!.dimensions[dimKey];
if (dim.evidence && Array.isArray(dim.evidence)) {
for (let i = 0; i < dim.evidence.length; i++) {
const ev = dim.evidence[i];
if (ev.quote) ev.quote = redactSensitive(ev.quote);
if (ev.example) ev.example = redactSensitive(ev.example);
if (ev.signal) ev.signal = redactSensitive(ev.signal);
if (ev.quote) ev.quote = redactSensitive(ev.quote) as string;
if (ev.example) ev.example = redactSensitive(ev.example) as string;
if (ev.signal) ev.signal = redactSensitive(ev.signal) as string;
}
}
}
@@ -568,7 +651,7 @@ function cmdWriteProfile(cwd, options, raw) {
if (!fs.existsSync(templatePath)) error(`Template not found: ${templatePath}`);
let template = fs.readFileSync(templatePath, 'utf-8');
const dimensionLabels = {
const dimensionLabels: Record<string, string> = {
communication_style: 'Communication',
decision_speed: 'Decisions',
explanation_depth: 'Explanations',
@@ -579,11 +662,11 @@ function cmdWriteProfile(cwd, options, raw) {
learning_style: 'Learning Style',
};
const summaryLines = [];
const summaryLines: string[] = [];
let highCount = 0, mediumCount = 0, lowCount = 0, dimensionsScored = 0;
for (const dimKey of DIMENSION_KEYS) {
const dim = analysis.dimensions[dimKey];
const dim = analysis!.dimensions[dimKey];
if (!dim) continue;
const conf = (dim.confidence || '').toUpperCase();
if (conf === 'HIGH' || conf === 'MEDIUM' || conf === 'LOW') dimensionsScored++;
@@ -603,12 +686,12 @@ function cmdWriteProfile(cwd, options, raw) {
: '- No high or medium confidence dimensions scored yet.';
template = template.replace(/\{\{generated_at\}\}/g, new Date().toISOString());
template = template.replace(/\{\{data_source\}\}/g, analysis.data_source || 'session_analysis');
template = template.replace(/\{\{projects_list\}\}/g, (analysis.projects_list || analysis.projects_analyzed || []).join(', '));
template = template.replace(/\{\{message_count\}\}/g, String(analysis.message_count || analysis.messages_analyzed || 0));
template = template.replace(/\{\{data_source\}\}/g, analysis!.data_source || 'session_analysis');
template = template.replace(/\{\{projects_list\}\}/g, (analysis!.projects_list || analysis!.projects_analyzed || []).join(', '));
template = template.replace(/\{\{message_count\}\}/g, String(analysis!.message_count || analysis!.messages_analyzed || 0));
template = template.replace(/\{\{summary_instructions\}\}/g, summaryInstructions);
template = template.replace(/\{\{profile_version\}\}/g, analysis.profile_version);
template = template.replace(/\{\{projects_count\}\}/g, String((analysis.projects_list || analysis.projects_analyzed || []).length));
template = template.replace(/\{\{profile_version\}\}/g, analysis!.profile_version!);
template = template.replace(/\{\{projects_count\}\}/g, String((analysis!.projects_list || analysis!.projects_analyzed || []).length));
template = template.replace(/\{\{dimensions_scored\}\}/g, String(dimensionsScored));
template = template.replace(/\{\{high_confidence_count\}\}/g, String(highCount));
template = template.replace(/\{\{medium_confidence_count\}\}/g, String(mediumCount));
@@ -617,7 +700,7 @@ function cmdWriteProfile(cwd, options, raw) {
redactedCount > 0 ? `${redactedCount} pattern(s) redacted` : 'None detected');
for (const dimKey of DIMENSION_KEYS) {
const dim = analysis.dimensions[dimKey] || {};
const dim = analysis!.dimensions[dimKey] || {};
const rating = dim.rating || 'UNSCORED';
const confidence = dim.confidence || 'UNSCORED';
const instruction = dim.claude_instruction || 'No strong preference detected. Ask the developer when this dimension is relevant.';
@@ -661,13 +744,13 @@ function cmdWriteProfile(cwd, options, raw) {
medium_confidence: mediumCount,
low_confidence: lowCount,
sensitive_redacted: redactedCount,
source: analysis.data_source || 'session_analysis',
source: analysis!.data_source || 'session_analysis',
};
output(result, raw);
output(result, raw, undefined);
}
function cmdProfileQuestionnaire(options, raw) {
function cmdProfileQuestionnaire(options: { answers?: string }, raw: boolean): void {
if (!options.answers) {
const questionsOutput = {
mode: 'interactive',
@@ -679,7 +762,7 @@ function cmdProfileQuestionnaire(options, raw) {
options: q.options.map(o => ({ label: o.label, value: o.value })),
})),
};
output(questionsOutput, raw);
output(questionsOutput, raw, undefined);
return;
}
@@ -688,7 +771,7 @@ function cmdProfileQuestionnaire(options, raw) {
error(`Expected ${PROFILING_QUESTIONS.length} answers (comma-separated), got ${answerValues.length}`);
}
const analysis = {
const analysis: AnalysisData = {
profile_version: '1.0',
analyzed_at: new Date().toISOString(),
data_source: 'questionnaire',
@@ -711,44 +794,44 @@ function cmdProfileQuestionnaire(options, raw) {
const ambiguous = isAmbiguousAnswer(question.dimension, answerValue);
analysis.dimensions[question.dimension] = {
rating: selectedOption.rating,
rating: selectedOption!.rating,
confidence: ambiguous ? 'LOW' : 'MEDIUM',
evidence_count: 1,
cross_project_consistent: null,
evidence: [{
signal: 'Self-reported via questionnaire',
quote: selectedOption.label,
quote: selectedOption!.label,
project: 'N/A (questionnaire)',
}],
summary: `Developer self-reported as ${selectedOption.rating} for ${question.header.toLowerCase()}.`,
claude_instruction: generateClaudeInstruction(question.dimension, selectedOption.rating),
summary: `Developer self-reported as ${selectedOption!.rating} for ${question.header.toLowerCase()}.`,
claude_instruction: generateClaudeInstruction(question.dimension, selectedOption!.rating),
};
}
output(analysis, raw);
output(analysis, raw, undefined);
}
function cmdGenerateDevPreferences(cwd, options, raw) {
function cmdGenerateDevPreferences(cwd: string, options: CmdGenerateDevPreferencesOptions, raw: boolean): void {
if (!options.analysis) error('--analysis <path> is required');
let analysisPath = options.analysis;
let analysisPath = options.analysis!;
if (!path.isAbsolute(analysisPath)) analysisPath = path.join(cwd, analysisPath);
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
let analysis;
let analysis: AnalysisData;
const analysisRaw = safeReadFile(analysisPath);
try {
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
analysis = JSON.parse(analysisRaw);
analysis = JSON.parse(analysisRaw) as AnalysisData;
} catch (err) {
error(`Failed to parse analysis JSON: ${err.message}`);
error(`Failed to parse analysis JSON: ${(err as Error).message}`);
}
if (!analysis.dimensions || typeof analysis.dimensions !== 'object') {
if (!analysis!.dimensions || typeof analysis!.dimensions !== 'object') {
error('Analysis JSON must contain a "dimensions" object');
}
const devPrefLabels = {
const devPrefLabels: Record<string, string> = {
communication_style: 'Communication',
decision_speed: 'Decision Support',
explanation_depth: 'Explanations',
@@ -763,11 +846,11 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
if (!fs.existsSync(templatePath)) error(`Template not found: ${templatePath}`);
let template = fs.readFileSync(templatePath, 'utf-8');
const directiveLines = [];
const dimensionsIncluded = [];
const directiveLines: string[] = [];
const dimensionsIncluded: string[] = [];
for (const dimKey of DIMENSION_KEYS) {
const dim = analysis.dimensions[dimKey];
const dim = analysis!.dimensions[dimKey];
if (!dim) continue;
const label = devPrefLabels[dimKey] || dimKey;
const confidence = dim.confidence || 'UNSCORED';
@@ -787,11 +870,11 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
const directivesBlock = directiveLines.join('\n').trim();
template = template.replace(/\{\{behavioral_directives\}\}/g, directivesBlock);
template = template.replace(/\{\{generated_at\}\}/g, new Date().toISOString());
template = template.replace(/\{\{data_source\}\}/g, analysis.data_source || 'session_analysis');
template = template.replace(/\{\{data_source\}\}/g, analysis!.data_source || 'session_analysis');
let stackBlock;
if (analysis.data_source === 'questionnaire') {
stackBlock = `Stack preferences not available (questionnaire-only profile). Run \`${formatGsdSlash('profile-user', resolveRuntime(cwd))} --refresh\` with session data to populate.`;
let stackBlock: string;
if (analysis!.data_source === 'questionnaire') {
stackBlock = `Stack preferences not available (questionnaire-only profile). Run \`${String(formatGsdSlash('profile-user', resolveRuntime(cwd)))} --refresh\` with session data to populate.`;
} else if (options.stack) {
stackBlock = options.stack;
} else {
@@ -813,13 +896,13 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
try {
const config = loadConfig(cwd);
effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env.GSD_RUNTIME,
config.runtime,
process.env['GSD_RUNTIME'],
config['runtime'],
'claude'
) || 'claude';
} catch {
effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env.GSD_RUNTIME,
process.env['GSD_RUNTIME'],
'claude'
) || 'claude';
}
@@ -827,7 +910,7 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
if (!skillDir) {
error(`Runtime "${effectiveRuntime}" does not use a skills directory; pass --output to choose a path explicitly.`);
}
outputPath = path.join(skillDir, 'SKILL.md');
outputPath = path.join(skillDir!, 'SKILL.md');
} else if (!path.isAbsolute(outputPath)) {
outputPath = path.join(cwd, outputPath);
}
@@ -839,33 +922,33 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
command_path: outputPath,
command_name: formatGsdSlash('dev-preferences', resolveRuntime(cwd)),
dimensions_included: dimensionsIncluded,
source: analysis.data_source || 'session_analysis',
source: analysis!.data_source || 'session_analysis',
};
output(result, raw);
output(result, raw, undefined);
}
function cmdGenerateClaudeProfile(cwd, options, raw) {
function cmdGenerateClaudeProfile(cwd: string, options: CmdGenerateClaudeProfileOptions, raw: boolean): void {
if (!options.analysis) error('--analysis <path> is required');
let analysisPath = options.analysis;
let analysisPath = options.analysis!;
if (!path.isAbsolute(analysisPath)) analysisPath = path.join(cwd, analysisPath);
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
let analysis;
let analysis: AnalysisData;
const analysisRaw = safeReadFile(analysisPath);
try {
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
analysis = JSON.parse(analysisRaw);
analysis = JSON.parse(analysisRaw) as AnalysisData;
} catch (err) {
error(`Failed to parse analysis JSON: ${err.message}`);
error(`Failed to parse analysis JSON: ${(err as Error).message}`);
}
if (!analysis.dimensions || typeof analysis.dimensions !== 'object') {
if (!analysis!.dimensions || typeof analysis!.dimensions !== 'object') {
error('Analysis JSON must contain a "dimensions" object');
}
const profileLabels = {
const profileLabels: Record<string, string> = {
communication_style: 'Communication',
decision_speed: 'Decisions',
explanation_depth: 'Explanations',
@@ -876,13 +959,13 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
learning_style: 'Learning',
};
const dataSource = analysis.data_source || 'session_analysis';
const tableRows = [];
const directiveLines = [];
const dimensionsIncluded = [];
const dataSource = analysis!.data_source || 'session_analysis';
const tableRows: string[] = [];
const directiveLines: string[] = [];
const dimensionsIncluded: string[] = [];
for (const dimKey of DIMENSION_KEYS) {
const dim = analysis.dimensions[dimKey];
const dim = analysis!.dimensions[dimKey];
if (!dim) continue;
const label = profileLabels[dimKey] || dimKey;
const rating = dim.rating || 'UNSCORED';
@@ -905,7 +988,7 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
'<!-- GSD:profile-start -->',
'## Developer Profile',
'',
`> Generated by GSD from ${dataSource}. Run \`${formatGsdSlash('profile-user', resolveRuntime(cwd))} --refresh\` to update.`,
`> Generated by GSD from ${dataSource}. Run \`${String(formatGsdSlash('profile-user', resolveRuntime(cwd)))}\` to update.`,
'',
'| Dimension | Rating | Confidence |',
'|-----------|--------|------------|',
@@ -918,7 +1001,7 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
const sectionContent = sectionLines.join('\n');
let targetPath;
let targetPath: string;
if (options.global) {
targetPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
} else if (options.output) {
@@ -928,12 +1011,12 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
let configClaudeMdPath = './CLAUDE.md';
try {
const config = loadConfig(cwd);
if (config.claude_md_path) configClaudeMdPath = config.claude_md_path;
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
} catch { /* use default */ }
targetPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath);
}
let action;
let action: string;
let existingContent = safeReadFile(targetPath);
if (existingContent !== null) {
@@ -965,12 +1048,12 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
is_global: !!options.global,
};
output(result, raw);
output(result, raw, undefined);
}
function cmdGenerateClaudeMd(cwd, options, raw) {
function cmdGenerateClaudeMd(cwd: string, options: CmdGenerateClaudeMdOptions, raw: boolean): void {
const MANAGED_SECTIONS = ['project', 'stack', 'conventions', 'architecture', 'skills', 'workflow'];
const generators = {
const generators: Record<string, (cwd: string) => SectionResult> = {
project: generateProjectSection,
stack: generateStackSection,
conventions: generateConventionsSection,
@@ -978,7 +1061,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
skills: generateSkillsSection,
workflow: generateWorkflowSection,
};
const sectionHeadings = {
const sectionHeadings: Record<string, string> = {
project: '## Project',
stack: '## Technology Stack',
conventions: '## Conventions',
@@ -987,10 +1070,10 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
workflow: '## GSD Workflow Enforcement',
};
const generated = {};
const sectionsGenerated = [];
const sectionsFallback = [];
const sectionsSkipped = [];
const generated: Record<string, SectionResult> = {};
const sectionsGenerated: string[] = [];
const sectionsFallback: string[] = [];
const sectionsSkipped: string[] = [];
for (const name of MANAGED_SECTIONS) {
const gen = generators[name](cwd);
@@ -1002,18 +1085,18 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
}
}
let assemblyConfig = {};
let assemblyConfig: Record<string, unknown> = {};
let configClaudeMdPath = './CLAUDE.md';
try {
const config = loadConfig(cwd);
if (config.claude_md_path) configClaudeMdPath = config.claude_md_path;
if (config.claude_md_assembly) assemblyConfig = config.claude_md_assembly;
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
if (config['claude_md_assembly']) assemblyConfig = config['claude_md_assembly'] as Record<string, unknown>;
// #3163: When runtime is codex, override the output target to AGENTS.md
// regardless of claude_md_path, so Codex projects never write to CLAUDE.md.
// GSD_RUNTIME env var takes precedence over config.runtime, mirroring detectRuntime().
const effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env.GSD_RUNTIME,
config.runtime
process.env['GSD_RUNTIME'],
config['runtime']
);
if (!options.output && effectiveRuntime === 'codex') {
configClaudeMdPath = './AGENTS.md';
@@ -1027,13 +1110,13 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
outputPath = path.join(cwd, outputPath);
}
const globalAssemblyMode = assemblyConfig.mode || 'embed';
const blockModes = assemblyConfig.blocks || {};
const globalAssemblyMode = (assemblyConfig['mode'] as string) || 'embed';
const blockModes = (assemblyConfig['blocks'] as Record<string, string>) || {};
// Return the assembled content for a section, respecting link vs embed mode.
// "link" mode writes `@<linkPath>` when the generator has a real source file.
// Falls back to "embed" for sections without a linkable source (workflow, fallbacks).
function buildSectionContent(name, gen, heading) {
function buildSectionContent(name: string, gen: SectionResult, heading: string): string {
const effectiveMode = blockModes[name] || globalAssemblyMode;
if (effectiveMode === 'link' && gen.linkPath && !gen.hasFallback) {
return buildSection(name, gen.source, `${heading}\n\n@${gen.linkPath}`);
@@ -1042,10 +1125,10 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
}
let existingContent = safeReadFile(outputPath);
let action;
let action: string;
if (existingContent === null) {
const sections = [];
const sections: string[] = [];
for (const name of MANAGED_SECTIONS) {
const gen = generated[name];
const heading = sectionHeadings[name];
@@ -1098,7 +1181,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
}
const finalContent = safeReadFile(outputPath);
let profileStatus;
let profileStatus: string;
if (finalContent && finalContent.indexOf('<!-- GSD:profile-start') !== -1) {
if (action === 'created' || existingContent.indexOf('<!-- GSD:profile-start') === -1) {
profileStatus = 'placeholder_added';
@@ -1114,7 +1197,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
let message = `Generated ${genCount}/${totalManaged} sections.`;
if (sectionsFallback.length > 0) message += ` Fallback: ${sectionsFallback.join(', ')}.`;
if (sectionsSkipped.length > 0) message += ` Skipped (manually edited): ${sectionsSkipped.join(', ')}.`;
if (profileStatus === 'placeholder_added') message += ` Run ${formatGsdSlash('profile-user', resolveRuntime(cwd))} to unlock Developer Profile.`;
if (profileStatus === 'placeholder_added') message += ` Run ${String(formatGsdSlash('profile-user', resolveRuntime(cwd)))} to unlock Developer Profile.`;
const result = {
claude_md_path: outputPath,
@@ -1127,10 +1210,10 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
message,
};
output(result, raw);
output(result, raw, undefined);
}
module.exports = {
export = {
cmdWriteProfile,
cmdProfileQuestionnaire,
cmdGenerateDevPreferences,

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