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 name: Mutation Testing
# PR-GATING: runs on every pull_request targeting `next` or `main`. # PR-GATING: runs on every pull_request targeting `next` or `main`.
# Computes changed core lib files via git diff and passes them to --mutate, # scripts/mutation-matrix.cjs is the single source of truth for which modules
# so only mutants in CHANGED files are tested — keeps the job bounded. # are "covered" (have meaningful test coverage for mutation). It computes
# If no core lib files changed, the gate passes trivially (skip+exit 0). # changed modules from git diff and emits a GitHub Actions matrix so each
# Full-repo mutation runs are reserved for local exploration (npm run test:mutation). # changed module gets its own Stryker shard running in parallel.
# If no covered modules changed the gate passes trivially (has_work: false).
on: on:
pull_request: pull_request:
@@ -12,10 +13,15 @@ on:
- next - next
- main - main
paths: 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' - 'get-shit-done/bin/lib/**/*.cjs'
- 'tests/**/*.property.test.cjs' - 'tests/**/*.property.test.cjs'
- 'tests/**/*.unit.test.cjs'
- 'tests/adr-parser.test.cjs'
- 'tests/active-workstream-store.test.cjs'
- 'stryker.config.mjs' - 'stryker.config.mjs'
- 'scripts/mutation-matrix.cjs'
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@@ -26,10 +32,60 @@ permissions:
contents: read contents: read
jobs: jobs:
mutation: # ── Job 1: detect ─────────────────────────────────────────────────────────
name: Stryker mutation score (changed files only) # 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 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: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -43,52 +99,69 @@ jobs:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies (builds .cjs via prepare)
run: npm ci run: npm ci
- name: Fetch base ref for diff - name: Run Stryker — ${{ matrix.name }}
# Ensure the base branch tip is available for the git diff below. # MUTATION_TEST_CMD scopes the command runner to only this module's tests.
# fetch-depth: 0 above gets all history, but the remote ref name must exist. # --mutate scopes mutation to only the changed module's built artifact.
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.
# --incremental reuses cached results for unchanged mutants. # --incremental reuses cached results for unchanged mutants.
# The break threshold (50) is read from stryker.config.mjs and causes # The break threshold (50) is read from stryker.config.mjs.
# Stryker to exit non-zero when mutation score < 50%, failing the PR check.
env: env:
NODE_OPTIONS: '--max-old-space-size=4096' NODE_OPTIONS: '--max-old-space-size=4096'
MUTATION_TEST_CMD: node --test ${{ matrix.tests }}
run: | run: |
MUTATE_LIST="${{ steps.changed.outputs.changed }}" echo "Module: ${{ matrix.name }}"
echo "Running Stryker --mutate '${MUTATE_LIST}'" echo "Mutate: ${{ matrix.mutate }}"
npx stryker run --incremental --mutate "${MUTATE_LIST}" 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 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() # upload even on failure so the score is visible if: always() # upload even on failure so the score is visible
with: with:
name: mutation-report-${{ github.run_number }} name: mutation-report-${{ matrix.name }}-${{ github.run_number }}
path: reports/mutation/mutation.html path: reports/mutation/mutation.html
retention-days: 14 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 node-version: 24
- name: Install dev dependencies - name: Install dev dependencies
run: npm ci --ignore-scripts 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) - name: Lint — ESLint (source-grep + timing + no-only-tests + quality)
run: npx eslint . --cache --cache-location node_modules/.cache/eslint/ run: npx eslint . --cache --cache-location node_modules/.cache/eslint/
- name: Lint — skill dependency graph - 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. # 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. # 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/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__/ __pycache__/
*.pyc *.pyc
.venv/ .venv/

View File

@@ -35,6 +35,91 @@ export default tseslint.config(
'**/*.generated.cjs', '**/*.generated.cjs',
// ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .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/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": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@stryker-mutator/core": "^9.6.1", "@stryker-mutator/core": "^9.6.1",
"@types/node": "^22.19.19",
"c8": "^11.0.0", "c8": "^11.0.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-n": "^17.24.0", "eslint-plugin-n": "^17.24.0",
@@ -1769,6 +1770,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.0", "version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
@@ -5060,6 +5071,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/unicorn-magic": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",

View File

@@ -51,6 +51,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@stryker-mutator/core": "^9.6.1", "@stryker-mutator/core": "^9.6.1",
"@types/node": "^22.19.19",
"c8": "^11.0.0", "c8": "^11.0.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-n": "^17.24.0", "eslint-plugin-n": "^17.24.0",
@@ -75,6 +76,7 @@
"build:lib": "tsc -p tsconfig.build.json", "build:lib": "tsc -p tsconfig.build.json",
"generate:identity": "node scripts/generate-package-identity.cjs", "generate:identity": "node scripts/generate-package-identity.cjs",
"prepack": "npm run build:lib", "prepack": "npm run build:lib",
"prepare": "npm run build:lib",
"prepublishOnly": "npm run build:lib && npm run build:hooks", "prepublishOnly": "npm run build:lib && npm run build:hooks",
"pretest": "npm run build:lib && npm run lint:skill-deps", "pretest": "npm run build:lib && npm run lint:skill-deps",
"pretest:coverage": "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)) { if (fs.existsSync(LOCKFILE)) {
try { 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, cwd: PROJECT_ROOT,
encoding: 'utf8', encoding: 'utf8',
shell: process.platform === 'win32', 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: * Owns active workstream source precedence, session identity, and pointer IO:
* CLI --ws > GSD_WORKSTREAM env > stored active workstream pointer. * 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'); import fs from 'node:fs';
const os = require('os'); import os from 'node:os';
const path = require('path'); import path from 'node:path';
const crypto = require('crypto'); import crypto from 'node:crypto';
const { probeTty, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs'); import { probeTty, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
const { isValidActiveWorkstreamName } = require('./workstream-name-policy.cjs'); import { isValidActiveWorkstreamName } from './workstream-name-policy.cjs';
const WORKSTREAM_SESSION_ENV_KEYS = [ const WORKSTREAM_SESSION_ENV_KEYS: ReadonlyArray<string> = [
'GSD_SESSION_KEY', 'GSD_SESSION_KEY',
'CODEX_THREAD_ID', 'CODEX_THREAD_ID',
'CLAUDE_SESSION_ID', 'CLAUDE_SESSION_ID',
@@ -27,24 +31,25 @@ const WORKSTREAM_SESSION_ENV_KEYS = [
'ZELLIJ_SESSION_NAME', 'ZELLIJ_SESSION_NAME',
]; ];
let cachedControllingTtyToken = null; let cachedControllingTtyToken: string | null = null;
let didProbeControllingTtyToken = false; let didProbeControllingTtyToken = false;
function planningRoot(cwd) { function planningRoot(cwd: string): string {
return path.join(cwd, '.planning'); return path.join(cwd, '.planning');
} }
function validateWorkstreamName(name) { function validateWorkstreamName(name: string | null | undefined): boolean {
return isValidActiveWorkstreamName(name); return isValidActiveWorkstreamName(name);
} }
function sanitizeWorkstreamSessionToken(value) { function sanitizeWorkstreamSessionToken(value: unknown): string | null {
if (value === null || value === undefined) return 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; return token ? token.slice(0, 160) : null;
} }
function probeControllingTtyToken() { function probeControllingTtyToken(): string | null {
if (didProbeControllingTtyToken) return cachedControllingTtyToken; if (didProbeControllingTtyToken) return cachedControllingTtyToken;
didProbeControllingTtyToken = true; didProbeControllingTtyToken = true;
@@ -61,7 +66,7 @@ function probeControllingTtyToken() {
return cachedControllingTtyToken; return cachedControllingTtyToken;
} }
function getControllingTtyToken() { function getControllingTtyToken(): string | null {
for (const envKey of ['TTY', 'SSH_TTY']) { for (const envKey of ['TTY', 'SSH_TTY']) {
const token = sanitizeWorkstreamSessionToken(process.env[envKey]); const token = sanitizeWorkstreamSessionToken(process.env[envKey]);
if (token) return `tty-${token.replace(/^dev_/, '')}`; if (token) return `tty-${token.replace(/^dev_/, '')}`;
@@ -70,7 +75,7 @@ function getControllingTtyToken() {
return probeControllingTtyToken(); return probeControllingTtyToken();
} }
function getWorkstreamSessionKey() { function getWorkstreamSessionKey(): string | null {
for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) { for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) {
const raw = process.env[envKey]; const raw = process.env[envKey];
const token = sanitizeWorkstreamSessionToken(raw); const token = sanitizeWorkstreamSessionToken(raw);
@@ -80,11 +85,17 @@ function getWorkstreamSessionKey() {
return getControllingTtyToken(); 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(); const sessionKey = fixedSessionKey || getWorkstreamSessionKey();
if (!sessionKey) return null; if (!sessionKey) return null;
let planningAbs; let planningAbs: string;
try { try {
planningAbs = fs.realpathSync.native(planningRoot(cwd)); planningAbs = fs.realpathSync.native(planningRoot(cwd));
} catch { } 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'); const filePath = path.join(planningRoot(cwd), 'active-workstream');
return { return {
read() { read(): string | null {
const raw = platformReadSync(filePath); const raw = platformReadSync(filePath);
return raw ? raw.trim() || null : null; return raw ? raw.trim() || null : null;
}, },
write(name) { write(name: string): void {
platformWriteSync(filePath, name + '\n'); platformWriteSync(filePath, name + '\n');
}, },
clear() { clear(): void {
try { fs.unlinkSync(filePath); } catch {} try { fs.unlinkSync(filePath); } catch {}
}, },
}; };
} }
function createSessionScopedPointerAdapter(cwd, fixedSessionKey) { function createSessionScopedPointerAdapter(cwd: string, fixedSessionKey?: string | null): WorkstreamPointerAdapter | null {
const scoped = getSessionScopedWorkstreamFile(cwd, fixedSessionKey); const scoped = getSessionScopedWorkstreamFile(cwd, fixedSessionKey);
if (!scoped) return null; if (!scoped) return null;
return { return {
read() { read(): string | null {
const raw = platformReadSync(scoped.filePath); const raw = platformReadSync(scoped.filePath);
return raw ? raw.trim() || null : null; return raw ? raw.trim() || null : null;
}, },
write(name) { write(name: string): void {
platformEnsureDir(scoped.dirPath); platformEnsureDir(scoped.dirPath);
platformWriteSync(scoped.filePath, name + '\n'); platformWriteSync(scoped.filePath, name + '\n');
}, },
clear() { clear(): void {
try { fs.unlinkSync(scoped.filePath); } catch {} try { fs.unlinkSync(scoped.filePath); } catch {}
try { try {
const remaining = fs.readdirSync(scoped.dirPath); const remaining = fs.readdirSync(scoped.dirPath);
@@ -145,22 +162,33 @@ function createSessionScopedPointerAdapter(cwd, fixedSessionKey) {
}; };
} }
function createMemoryPointerAdapter(initialName = null) { function createMemoryPointerAdapter(initialName: string | null = null): WorkstreamPointerAdapter {
let value = initialName; let value: string | null = initialName;
return { return {
read() { read(): string | null {
return value; return value;
}, },
write(name) { write(name: string): void {
value = name; value = name;
}, },
clear() { clear(): void {
value = null; 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) { if (opts.activeWorkstreamAdapter) {
return opts.activeWorkstreamAdapter; return opts.activeWorkstreamAdapter;
} }
@@ -179,7 +207,7 @@ function pickActiveWorkstreamAdapter(cwd, opts = {}) {
return createSharedPointerAdapter(cwd); return createSharedPointerAdapter(cwd);
} }
function getActiveWorkstream(cwd, opts = {}) { function getActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): string | null {
const adapter = pickActiveWorkstreamAdapter(cwd, opts); const adapter = pickActiveWorkstreamAdapter(cwd, opts);
if (!adapter) return null; if (!adapter) return null;
@@ -198,7 +226,7 @@ function getActiveWorkstream(cwd, opts = {}) {
return name; return name;
} }
function setActiveWorkstream(cwd, name, opts = {}) { function setActiveWorkstream(cwd: string, name: string | null | undefined, opts: ActiveWorkstreamOpts = {}): void {
const adapter = pickActiveWorkstreamAdapter(cwd, opts); const adapter = pickActiveWorkstreamAdapter(cwd, opts);
if (!adapter) return; if (!adapter) return;
@@ -215,14 +243,20 @@ function setActiveWorkstream(cwd, name, opts = {}) {
adapter.write(name); adapter.write(name);
} }
function clearActiveWorkstream(cwd, opts = {}) { function clearActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): void {
const adapter = pickActiveWorkstreamAdapter(cwd, opts); const adapter = pickActiveWorkstreamAdapter(cwd, opts);
if (!adapter) return; if (!adapter) return;
adapter.clear(); adapter.clear();
} }
function parseCliWorkstream(args) { interface ParsedCliWorkstream {
const wsEqArg = args.find(arg => arg.startsWith('--ws=')); 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'); const wsIdx = args.indexOf('--ws');
if (wsEqArg) { if (wsEqArg) {
@@ -231,7 +265,7 @@ function parseCliWorkstream(args) {
return { return {
value, value,
source: 'cli', source: 'cli',
args: args.filter(arg => arg !== wsEqArg), args: args.filter((arg) => arg !== wsEqArg),
}; };
} }
@@ -241,7 +275,7 @@ function parseCliWorkstream(args) {
return { return {
value, value,
source: 'cli', 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 = {}) { interface ResolvedWorkstream {
const parsed = parseCliWorkstream(args); ws: string | null;
const getStored = deps.getStored || ((dir) => getActiveWorkstream(dir, deps)); 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'; let source = 'none';
if (parsed.value) { if (parsed.value) {
ws = parsed.value; ws = parsed.value;
source = parsed.source; source = parsed.source ?? 'cli';
} else if (env && typeof env.GSD_WORKSTREAM === 'string' && env.GSD_WORKSTREAM.trim()) { } else if (env && typeof env['GSD_WORKSTREAM'] === 'string' && env['GSD_WORKSTREAM'].trim()) {
ws = env.GSD_WORKSTREAM.trim(); ws = env['GSD_WORKSTREAM'].trim();
source = 'env'; source = 'env';
} else { } else {
ws = getStored(cwd) || null; 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; if (!resolution || !resolution.ws) return;
env.GSD_WORKSTREAM = resolution.ws; env['GSD_WORKSTREAM'] = resolution.ws;
} }
module.exports = { export = {
validateWorkstreamName, validateWorkstreamName,
getWorkstreamSessionKey, getWorkstreamSessionKey,
createSharedPointerAdapter, 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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const { requireSafePath } = require('./security.cjs'); import { requireSafePath } from './security.cjs';
const STATUS_REJECT_SET = new Set(['superseded', 'rejected', 'deprecated']); 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'], status: ['status', 'state', 'lifecycle', 'stage'],
goal: [ goal: [
'context', 'context',
@@ -154,7 +176,7 @@ const CANONICAL_HEADERS = {
], ],
}; };
const CONSEQUENCE_NEGATIVE_HINTS = [ const CONSEQUENCE_NEGATIVE_HINTS: string[] = [
'negative', 'negative',
'drawback', 'drawback',
'risk', 'risk',
@@ -165,7 +187,7 @@ const CONSEQUENCE_NEGATIVE_HINTS = [
'side effect', 'side effect',
]; ];
const CONSEQUENCE_POSITIVE_HINTS = [ const CONSEQUENCE_POSITIVE_HINTS: string[] = [
'positive', 'positive',
'success', 'success',
'metric', 'metric',
@@ -175,8 +197,9 @@ const CONSEQUENCE_POSITIVE_HINTS = [
'benefit', 'benefit',
]; ];
function normalizeAdrHeader(raw) { function normalizeAdrHeader(raw: unknown): string {
return String(raw || '') const s = typeof raw === 'string' ? raw : '';
return s
.trim() .trim()
.toLowerCase() .toLowerCase()
.replace(/[\s:._-]+/g, ' ') .replace(/[\s:._-]+/g, ' ')
@@ -184,8 +207,8 @@ function normalizeAdrHeader(raw) {
.trim(); .trim();
} }
function classifyHeader(normalizedHeader) { function classifyHeader(normalizedHeader: string): CanonicalHeader | null {
for (const [canonical, synonyms] of Object.entries(CANONICAL_HEADERS)) { for (const [canonical, synonyms] of Object.entries(CANONICAL_HEADERS) as Array<[CanonicalHeader, string[]]>) {
for (const synonym of synonyms) { for (const synonym of synonyms) {
if (normalizedHeader === synonym) return canonical; if (normalizedHeader === synonym) return canonical;
if (normalizedHeader.startsWith(`${synonym} `)) return canonical; if (normalizedHeader.startsWith(`${synonym} `)) return canonical;
@@ -194,8 +217,8 @@ function classifyHeader(normalizedHeader) {
return null; return null;
} }
function splitEntries(blockText) { function splitEntries(blockText: unknown): string[] {
return String(blockText || '') return (typeof blockText === 'string' ? blockText : '')
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean)
@@ -203,10 +226,15 @@ function splitEntries(blockText) {
.filter(Boolean); .filter(Boolean);
} }
function parseSections(markdown) { interface MarkdownSection {
const lines = String(markdown || '').split(/\r?\n/); heading: string | null;
const sections = []; body: string[];
let current = { heading: null, body: [] }; }
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) { for (const line of lines) {
const m = line.match(/^#{1,6}\s+(.*)$/); const m = line.match(/^#{1,6}\s+(.*)$/);
@@ -222,7 +250,7 @@ function parseSections(markdown) {
return sections; return sections;
} }
function parseStatusFromSections(sections) { function parseStatusFromSections(sections: MarkdownSection[]): string {
for (const section of sections) { for (const section of sections) {
const canonical = classifyHeader(normalizeAdrHeader(section.heading)); const canonical = classifyHeader(normalizeAdrHeader(section.heading));
if (canonical !== 'status') continue; if (canonical !== 'status') continue;
@@ -239,7 +267,7 @@ function parseStatusFromSections(sections) {
return ''; return '';
} }
function pushUnique(target, values) { function pushUnique(target: string[], values: string[]): void {
const seen = new Set(target); const seen = new Set(target);
for (const value of values) { for (const value of values) {
if (!seen.has(value)) { 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) { for (const entry of lines) {
const lower = entry.toLowerCase(); const lower = entry.toLowerCase();
if (CONSEQUENCE_NEGATIVE_HINTS.some((hint) => lower.includes(hint))) { 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 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 title = titleLine.replace(/^#\s+/, '').trim();
const out = { const out: AdrOut = {
title, title,
status: parseStatusFromSections(sections) || 'accepted', status: parseStatusFromSections(sections) || 'accepted',
context: '', context: '',
@@ -345,12 +397,18 @@ function parseAdrMarkdown(markdown, { sourcePath = '', format = 'auto' } = {}) {
return out; return out;
} }
function shouldRejectAdrStatus(status) { function shouldRejectAdrStatus(status: string): boolean {
return STATUS_REJECT_SET.has(normalizeAdrHeader(status)); return STATUS_REJECT_SET.has(normalizeAdrHeader(status));
} }
function parseCliArgs(argv) { interface CliOpts {
const opts = { input: null, format: 'auto', projectDir: process.cwd() }; 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++) { for (let i = 0; i < argv.length; i++) {
const arg = argv[i]; const arg = argv[i];
if (arg === '--input') { if (arg === '--input') {
@@ -369,11 +427,11 @@ function parseCliArgs(argv) {
return opts; return opts;
} }
function main(argv) { function main(argv: string[]): void {
const opts = parseCliArgs(argv); const opts = parseCliArgs(argv);
const safePath = requireSafePath(opts.input, path.resolve(opts.projectDir), 'ADR input path', { allowAbsolute: true }); const safePath = requireSafePath(opts.input, path.resolve(opts.projectDir), 'ADR input path', { allowAbsolute: true });
const content = fs.readFileSync(safePath, 'utf8'); 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)); process.stdout.write(JSON.stringify(parsed, null, 2));
} }
@@ -381,12 +439,12 @@ if (require.main === module) {
try { try {
main(process.argv.slice(2)); main(process.argv.slice(2));
} catch (error) { } catch (error) {
process.stderr.write(`Error: ${error.message}\n`); process.stderr.write(`Error: ${(error as Error).message}\n`);
process.exit(1); process.exit(1);
} }
} }
module.exports = { export = {
CANONICAL_HEADERS, CANONICAL_HEADERS,
normalizeAdrHeader, normalizeAdrHeader,
parseAdrMarkdown, 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 * Enumerates the file names that gsd workflows officially produce at the
* .planning/ root level. Used by gsd-health (W019) to flag unrecognized files * .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. * Add entries here whenever a new workflow produces a .planning/ root file.
*/ */
'use strict';
// Exact-match canonical file names at .planning/ root // Exact-match canonical file names at .planning/ root
const CANONICAL_EXACT = new Set([ export const CANONICAL_EXACT: ReadonlySet<string> = new Set([
'PROJECT.md', 'PROJECT.md',
'ROADMAP.md', 'ROADMAP.md',
'STATE.md', 'STATE.md',
@@ -27,7 +28,7 @@ const CANONICAL_EXACT = new Set([
// Pattern-match canonical file names (regex tests on the basename) // Pattern-match canonical file names (regex tests on the basename)
// Each pattern includes the name of the workflow that produces it as a comment. // 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+)?-MILESTONE-AUDIT\.md$/i, // gsd-complete-milestone (pre-archive)
/^v\d+\.\d+(?:\.\d+)?-.*\.md$/i, // other version-stamped planning docs /^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 * Return true if `filename` (basename only, no path) matches a canonical
* .planning/ root artifact — either an exact name or a known pattern. * .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; if (CANONICAL_EXACT.has(filename)) return true;
for (const pattern of CANONICAL_PATTERNS) { for (const pattern of CANONICAL_PATTERNS) {
if (pattern.test(filename)) return true; if (pattern.test(filename)) return true;
} }
return false; return false;
} }
module.exports = {
CANONICAL_EXACT,
CANONICAL_PATTERNS,
isCanonicalPlanningFile,
};

View File

@@ -5,33 +5,139 @@
* Returns structured JSON for workflow consumption. * Returns structured JSON for workflow consumption.
* Called by: gsd-tools.cjs audit-open * Called by: gsd-tools.cjs audit-open
* Used by: /gsd:complete-milestone pre-close gate * 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'); // ─── Types ────────────────────────────────────────────────────────────────────
const path = require('path');
const { toPosixPath } = require('./core.cjs'); interface DebugSessionItem {
const { platformReadSync } = require('./shell-command-projection.cjs'); slug: string;
const { planningDir } = require('./planning-workspace.cjs'); status: string;
const { extractFrontmatter } = require('./frontmatter.cjs'); updated: string;
const { requireSafePath, sanitizeForDisplay } = require('./security.cjs'); 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. * Scan .planning/debug/ for open sessions.
* Open = status NOT in ['resolved', 'complete']. * Open = status NOT in ['resolved', 'complete'].
* Ignores the resolved/ subdirectory. * Ignores the resolved/ subdirectory.
*/ */
function scanDebugSessions(planDir) { function scanDebugSessions(planDir: string): DebugSessionItem[] {
const debugDir = path.join(planDir, 'debug'); const debugDir = path.join(planDir, 'debug');
if (!fs.existsSync(debugDir)) return []; if (!fs.existsSync(debugDir)) return [];
const results = []; const results: DebugSessionItem[] = [];
let files; let files: fs.Dirent[];
try { try {
files = fs.readdirSync(debugDir, { withFileTypes: true }); files = fs.readdirSync(debugDir, { withFileTypes: true });
} catch { } catch {
return [{ scan_error: true }]; return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
} }
for (const entry of files) { for (const entry of files) {
@@ -40,7 +146,7 @@ function scanDebugSessions(planDir) {
const filePath = path.join(debugDir, entry.name); const filePath = path.join(debugDir, entry.name);
let safeFilePath; let safeFilePath: string;
try { try {
safeFilePath = requireSafePath(filePath, planDir, 'debug session file', { allowAbsolute: true }); safeFilePath = requireSafePath(filePath, planDir, 'debug session file', { allowAbsolute: true });
} catch { } catch {
@@ -51,7 +157,7 @@ function scanDebugSessions(planDir) {
if (content === null) continue; if (content === null) continue;
const fm = extractFrontmatter(content); const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toLowerCase(); const status = ((fm.status as string) || 'unknown').toLowerCase();
if (status === 'resolved' || status === 'complete') continue; if (status === 'resolved' || status === 'complete') continue;
// Extract hypothesis from "Current Focus" block if parseable // Extract hypothesis from "Current Focus" block if parseable
@@ -66,7 +172,7 @@ function scanDebugSessions(planDir) {
results.push({ results.push({
slug: sanitizeForDisplay(slug), slug: sanitizeForDisplay(slug),
status: sanitizeForDisplay(status), status: sanitizeForDisplay(status),
updated: sanitizeForDisplay(String(fm.updated || fm.date || '')), updated: sanitizeForDisplay(fm.updated || fm.date || ''),
hypothesis, hypothesis,
}); });
} }
@@ -74,29 +180,31 @@ function scanDebugSessions(planDir) {
return results; return results;
} }
// ─── scanQuickTasks ───────────────────────────────────────────────────────────
/** /**
* Scan .planning/quick/ for incomplete tasks. * Scan .planning/quick/ for incomplete tasks.
* Incomplete if SUMMARY.md missing or status !== 'complete'. * Incomplete if SUMMARY.md missing or status !== 'complete'.
*/ */
function scanQuickTasks(planDir) { function scanQuickTasks(planDir: string): QuickTaskItem[] {
const quickDir = path.join(planDir, 'quick'); const quickDir = path.join(planDir, 'quick');
if (!fs.existsSync(quickDir)) return []; if (!fs.existsSync(quickDir)) return [];
let entries; let entries: fs.Dirent[];
try { try {
entries = fs.readdirSync(quickDir, { withFileTypes: true }); entries = fs.readdirSync(quickDir, { withFileTypes: true });
} catch { } catch {
return [{ scan_error: true }]; return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
} }
const results = []; const results: QuickTaskItem[] = [];
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
const dirName = entry.name; const dirName = entry.name;
const taskDir = path.join(quickDir, dirName); const taskDir = path.join(quickDir, dirName);
let safeTaskDir; let safeTaskDir: string;
try { try {
safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', { allowAbsolute: true }); safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', { allowAbsolute: true });
} catch { } catch {
@@ -105,7 +213,7 @@ function scanQuickTasks(planDir) {
// workflows/quick.md mandates `${quick_id}-SUMMARY.md`; older flows used // workflows/quick.md mandates `${quick_id}-SUMMARY.md`; older flows used
// bare `SUMMARY.md`. Accept either to avoid false-positive "missing". // bare `SUMMARY.md`. Accept either to avoid false-positive "missing".
let summaryPath = null; let summaryPath: string | null = null;
try { try {
const summaryFiles = fs.readdirSync(safeTaskDir, { withFileTypes: true }) const summaryFiles = fs.readdirSync(safeTaskDir, { withFileTypes: true })
.filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md'))); .filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
@@ -124,7 +232,7 @@ function scanQuickTasks(planDir) {
const description = ''; const description = '';
if (summaryPath && fs.existsSync(summaryPath)) { if (summaryPath && fs.existsSync(summaryPath)) {
let safeSum; let safeSum: string;
try { try {
safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true }); safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true });
} catch { } catch {
@@ -135,7 +243,7 @@ function scanQuickTasks(planDir) {
status = 'unreadable'; status = 'unreadable';
} else { } else {
const fm = extractFrontmatter(content); 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; return results;
} }
// ─── scanThreads ──────────────────────────────────────────────────────────────
/** /**
* Scan .planning/threads/ for open threads. * Scan .planning/threads/ for open threads.
* Open if status in ['open', 'in_progress', 'in progress'] (case-insensitive). * 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'); const threadsDir = path.join(planDir, 'threads');
if (!fs.existsSync(threadsDir)) return []; if (!fs.existsSync(threadsDir)) return [];
let files; let files: fs.Dirent[];
try { try {
files = fs.readdirSync(threadsDir, { withFileTypes: true }); files = fs.readdirSync(threadsDir, { withFileTypes: true });
} catch { } catch {
return [{ scan_error: true }]; return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
} }
const openStatuses = new Set(['open', 'in_progress', 'in progress']); const openStatuses = new Set(['open', 'in_progress', 'in progress']);
const results = []; const results: ThreadItem[] = [];
for (const entry of files) { for (const entry of files) {
if (!entry.isFile()) continue; if (!entry.isFile()) continue;
@@ -185,7 +295,7 @@ function scanThreads(planDir) {
const filePath = path.join(threadsDir, entry.name); const filePath = path.join(threadsDir, entry.name);
let safeFilePath; let safeFilePath: string;
try { try {
safeFilePath = requireSafePath(filePath, planDir, 'thread file', { allowAbsolute: true }); safeFilePath = requireSafePath(filePath, planDir, 'thread file', { allowAbsolute: true });
} catch { } catch {
@@ -196,7 +306,7 @@ function scanThreads(planDir) {
if (content === null) continue; if (content === null) continue;
const fm = extractFrontmatter(content); 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 // Fall back to scanning body for ## Status: OPEN / IN PROGRESS
if (!status) { if (!status) {
@@ -209,7 +319,7 @@ function scanThreads(planDir) {
if (!openStatuses.has(status)) continue; if (!openStatuses.has(status)) continue;
// Extract title from # Thread: heading or frontmatter title // Extract title from # Thread: heading or frontmatter title
let title = sanitizeForDisplay(String(fm.title || '')); let title = sanitizeForDisplay(fm.title || '');
if (!title) { if (!title) {
const headingMatch = content.match(/^#\s*Thread:\s*(.+)$/m); const headingMatch = content.match(/^#\s*Thread:\s*(.+)$/m);
if (headingMatch) { if (headingMatch) {
@@ -221,7 +331,7 @@ function scanThreads(planDir) {
results.push({ results.push({
slug: sanitizeForDisplay(slug), slug: sanitizeForDisplay(slug),
status: sanitizeForDisplay(status), status: sanitizeForDisplay(status),
updated: sanitizeForDisplay(String(fm.updated || fm.date || '')), updated: sanitizeForDisplay(fm.updated || fm.date || ''),
title, title,
}); });
} }
@@ -229,30 +339,32 @@ function scanThreads(planDir) {
return results; return results;
} }
// ─── scanTodos ────────────────────────────────────────────────────────────────
/** /**
* Scan .planning/todos/pending/ for pending todos. * Scan .planning/todos/pending/ for pending todos.
* Returns array of { filename, priority, area, summary }. * Returns array of { filename, priority, area, summary }.
* Display limited to first 5 + count of remainder. * Display limited to first 5 + count of remainder.
*/ */
function scanTodos(planDir) { function scanTodos(planDir: string): TodoItem[] {
const pendingDir = path.join(planDir, 'todos', 'pending'); const pendingDir = path.join(planDir, 'todos', 'pending');
if (!fs.existsSync(pendingDir)) return []; if (!fs.existsSync(pendingDir)) return [];
let files; let files: fs.Dirent[];
try { try {
files = fs.readdirSync(pendingDir, { withFileTypes: true }); files = fs.readdirSync(pendingDir, { withFileTypes: true });
} catch { } 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 mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
const results = []; const results: TodoItem[] = [];
const displayFiles = mdFiles.slice(0, 5); const displayFiles = mdFiles.slice(0, 5);
for (const entry of displayFiles) { for (const entry of displayFiles) {
const filePath = path.join(pendingDir, entry.name); const filePath = path.join(pendingDir, entry.name);
let safeFilePath; let safeFilePath: string;
try { try {
safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true }); safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true });
} catch { } catch {
@@ -271,36 +383,38 @@ function scanTodos(planDir) {
results.push({ results.push({
filename: sanitizeForDisplay(entry.name), filename: sanitizeForDisplay(entry.name),
priority: sanitizeForDisplay(String(fm.priority || '')), priority: sanitizeForDisplay(fm.priority || ''),
area: sanitizeForDisplay(String(fm.area || '')), area: sanitizeForDisplay(fm.area || ''),
summary, summary,
}); });
} }
if (mdFiles.length > 5) { if (mdFiles.length > 5) {
results.push({ _remainder_count: mdFiles.length - 5 }); results.push({ _remainder_count: mdFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
} }
return results; return results;
} }
// ─── scanSeeds ────────────────────────────────────────────────────────────────
/** /**
* Scan .planning/seeds/SEED-*.md for unimplemented seeds. * Scan .planning/seeds/SEED-*.md for unimplemented seeds.
* Unimplemented if status in ['dormant', 'active', 'triggered']. * Unimplemented if status in ['dormant', 'active', 'triggered'].
*/ */
function scanSeeds(planDir) { function scanSeeds(planDir: string): SeedItem[] {
const seedsDir = path.join(planDir, 'seeds'); const seedsDir = path.join(planDir, 'seeds');
if (!fs.existsSync(seedsDir)) return []; if (!fs.existsSync(seedsDir)) return [];
let files; let files: fs.Dirent[];
try { try {
files = fs.readdirSync(seedsDir, { withFileTypes: true }); files = fs.readdirSync(seedsDir, { withFileTypes: true });
} catch { } catch {
return [{ scan_error: true }]; return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
} }
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']); const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
const results = []; const results: SeedItem[] = [];
for (const entry of files) { for (const entry of files) {
if (!entry.isFile()) continue; if (!entry.isFile()) continue;
@@ -308,7 +422,7 @@ function scanSeeds(planDir) {
const filePath = path.join(seedsDir, entry.name); const filePath = path.join(seedsDir, entry.name);
let safeFilePath; let safeFilePath: string;
try { try {
safeFilePath = requireSafePath(filePath, planDir, 'seed file', { allowAbsolute: true }); safeFilePath = requireSafePath(filePath, planDir, 'seed file', { allowAbsolute: true });
} catch { } catch {
@@ -319,7 +433,7 @@ function scanSeeds(planDir) {
if (content === null) continue; if (content === null) continue;
const fm = extractFrontmatter(content); const fm = extractFrontmatter(content);
const status = (fm.status || 'dormant').toLowerCase(); const status = ((fm.status as string) || 'dormant').toLowerCase();
if (!unimplementedStatuses.has(status)) continue; if (!unimplementedStatuses.has(status)) continue;
@@ -328,7 +442,7 @@ function scanSeeds(planDir) {
const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md'); const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
const slug = sanitizeForDisplay(seed_id.replace(/^SEED-/, '')); const slug = sanitizeForDisplay(seed_id.replace(/^SEED-/, ''));
let title = sanitizeForDisplay(String(fm.title || '')); let title = sanitizeForDisplay(fm.title || '');
if (!title) { if (!title) {
const headingMatch = content.match(/^#\s*(.+)$/m); const headingMatch = content.match(/^#\s*(.+)$/m);
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100)); if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
@@ -345,36 +459,33 @@ function scanSeeds(planDir) {
return results; return results;
} }
// Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure // ─── scanUatGaps ──────────────────────────────────────────────────────────────
// 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']);
/** /**
* Scan .planning/phases for UAT gaps (UAT files with status != 'complete'). * 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'); const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return []; if (!fs.existsSync(phasesDir)) return [];
let dirs; let dirs: string[];
try { try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true }) dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory()) .filter(e => e.isDirectory())
.map(e => e.name) .map(e => e.name)
.sort(); .sort();
} catch { } 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) { for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir); const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir; const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files; let files: string[];
try { try {
files = fs.readdirSync(phaseDir); files = fs.readdirSync(phaseDir);
} catch { } catch {
@@ -384,7 +495,7 @@ function scanUatGaps(planDir) {
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) { for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file); const filePath = path.join(phaseDir, file);
let safeFilePath; let safeFilePath: string;
try { try {
safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true }); safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true });
} catch { } catch {
@@ -395,8 +506,8 @@ function scanUatGaps(planDir) {
if (content === null) continue; if (content === null) continue;
const fm = extractFrontmatter(content); const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toLowerCase(); const status = ((fm.status as string) || 'unknown').toLowerCase();
const result = (fm.result || '').toString().toLowerCase(); const result = ((fm.result as string) || '').toLowerCase();
// Also accept `result: all_pass` as a fallback when status is absent // Also accept `result: all_pass` as a fallback when status is absent
// — covers UATs that omit `status:`. // — covers UATs that omit `status:`.
@@ -418,31 +529,33 @@ function scanUatGaps(planDir) {
return results; return results;
} }
// ─── scanVerificationGaps ─────────────────────────────────────────────────────
/** /**
* Scan .planning/phases for VERIFICATION gaps. * Scan .planning/phases for VERIFICATION gaps.
*/ */
function scanVerificationGaps(planDir) { function scanVerificationGaps(planDir: string): VerificationGapItem[] {
const phasesDir = path.join(planDir, 'phases'); const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return []; if (!fs.existsSync(phasesDir)) return [];
let dirs; let dirs: string[];
try { try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true }) dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory()) .filter(e => e.isDirectory())
.map(e => e.name) .map(e => e.name)
.sort(); .sort();
} catch { } catch {
return [{ scan_error: true }]; return [{ scan_error: true, phase: '', file: '', status: '' }];
} }
const results = []; const results: VerificationGapItem[] = [];
for (const dir of dirs) { for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir); const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir; const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files; let files: string[];
try { try {
files = fs.readdirSync(phaseDir); files = fs.readdirSync(phaseDir);
} catch { } catch {
@@ -452,7 +565,7 @@ function scanVerificationGaps(planDir) {
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) { for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file); const filePath = path.join(phaseDir, file);
let safeFilePath; let safeFilePath: string;
try { try {
safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true }); safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
} catch { } catch {
@@ -463,7 +576,7 @@ function scanVerificationGaps(planDir) {
if (content === null) continue; if (content === null) continue;
const fm = extractFrontmatter(content); 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; if (status !== 'gaps_found' && status !== 'human_needed') continue;
@@ -478,31 +591,33 @@ function scanVerificationGaps(planDir) {
return results; return results;
} }
// ─── scanContextQuestions ─────────────────────────────────────────────────────
/** /**
* Scan .planning/phases for CONTEXT files with open_questions. * Scan .planning/phases for CONTEXT files with open_questions.
*/ */
function scanContextQuestions(planDir) { function scanContextQuestions(planDir: string): ContextQuestionItem[] {
const phasesDir = path.join(planDir, 'phases'); const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return []; if (!fs.existsSync(phasesDir)) return [];
let dirs; let dirs: string[];
try { try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true }) dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory()) .filter(e => e.isDirectory())
.map(e => e.name) .map(e => e.name)
.sort(); .sort();
} catch { } 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) { for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir); const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir; const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files; let files: string[];
try { try {
files = fs.readdirSync(phaseDir); files = fs.readdirSync(phaseDir);
} catch { } catch {
@@ -512,7 +627,7 @@ function scanContextQuestions(planDir) {
for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) { for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file); const filePath = path.join(phaseDir, file);
let safeFilePath; let safeFilePath: string;
try { try {
safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true }); safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
} catch { } catch {
@@ -525,10 +640,10 @@ function scanContextQuestions(planDir) {
const fm = extractFrontmatter(content); const fm = extractFrontmatter(content);
// Check frontmatter open_questions field // Check frontmatter open_questions field
let questions = []; let questions: string[] = [];
if (fm.open_questions) { if (fm.open_questions) {
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) { 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(); const oqBody = oqMatch[1].trim();
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) { if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
const items = oqBody.split('\n') const items = oqBody.split('\n')
.map(l => l.trim()) .map((l: string) => l.trim())
.filter(l => l && l !== '-' && l !== '*') .filter((l: string) => l && l !== '-' && l !== '*')
.filter(l => /^[-*\d]/.test(l) || l.includes('?')); .filter((l: string) => /^[-*\d]/.test(l) || l.includes('?'));
questions = items.slice(0, 3).map(q => sanitizeForDisplay(q.slice(0, 200))); questions = items.slice(0, 3).map((q: string) => sanitizeForDisplay(q.slice(0, 200)));
} }
} }
} }
@@ -561,51 +676,54 @@ function scanContextQuestions(planDir) {
return results; return results;
} }
// ─── auditOpenArtifacts ───────────────────────────────────────────────────────
/** /**
* Main audit function. Scans all .planning/ artifact categories. * Main audit function. Scans all .planning/ artifact categories.
* *
* @param {string} cwd - Project root directory * @param cwd - Project root directory
* @returns {object} Structured audit result * @returns Structured audit result
*/ */
function auditOpenArtifacts(cwd) { function auditOpenArtifacts(cwd: string): AuditResult {
const planDir = planningDir(cwd); const planDir = planningDir(cwd);
const debugSessions = (() => { 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 = (() => { 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 = (() => { 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 = (() => { 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 = (() => { 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 = (() => { 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 = (() => { 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 = (() => { 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) // 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), debug_sessions: countReal(debugSessions),
quick_tasks: countReal(quickTasks), quick_tasks: countReal(quickTasks),
threads: countReal(threads), threads: countReal(threads),
@@ -614,8 +732,9 @@ function auditOpenArtifacts(cwd) {
uat_gaps: countReal(uatGaps), uat_gaps: countReal(uatGaps),
verification_gaps: countReal(verificationGaps), verification_gaps: countReal(verificationGaps),
context_questions: countReal(contextQuestions), 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 { return {
scanned_at: new Date().toISOString(), scanned_at: new Date().toISOString(),
@@ -634,15 +753,17 @@ function auditOpenArtifacts(cwd) {
}; };
} }
// ─── formatAuditReport ────────────────────────────────────────────────────────
/** /**
* Format the audit result as a human-readable report. * Format the audit result as a human-readable report.
* *
* @param {object} auditResult - Result from auditOpenArtifacts() * @param auditResult - Result from auditOpenArtifacts()
* @returns {string} Formatted report * @returns Formatted report
*/ */
function formatAuditReport(auditResult) { function formatAuditReport(auditResult: AuditResult): string {
const { counts, items, has_open_items } = auditResult; const { counts, items, has_open_items } = auditResult;
const lines = []; const lines: string[] = [];
const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
lines.push(hr); lines.push(hr);
@@ -752,4 +873,4 @@ function formatAuditReport(auditResult) {
return lines.join('\n'); 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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const { execFileSync } = require('child_process'); import { execFileSync } from 'node:child_process';
const { output, error, ERROR_REASON } = require('./core.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports
const { parseDecisions } = require('./decisions.cjs'); 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 || '') return String(text || '')
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ') .replace(/[^a-z0-9\s]/g, ' ')
@@ -16,20 +28,20 @@ function normalizePhrase(text) {
const SOFT_PHRASE_MIN_WORDS = 6; const SOFT_PHRASE_MIN_WORDS = 6;
function softPhrase(text) { function softPhrase(text: unknown): string {
const words = normalizePhrase(text).split(' ').filter(Boolean); const words = normalizePhrase(text).split(' ').filter(Boolean);
if (words.length < SOFT_PHRASE_MIN_WORDS) return ''; if (words.length < SOFT_PHRASE_MIN_WORDS) return '';
return words.slice(0, SOFT_PHRASE_MIN_WORDS).join(' '); 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 (!haystack) return false;
if (new RegExp(`\\b${decision.id}\\b`).test(haystack)) return true; if (new RegExp(`\\b${decision.id}\\b`).test(haystack)) return true;
const phrase = softPhrase(decision.text); const phrase = softPhrase(decision.text);
return phrase ? normalizePhrase(haystack).includes(phrase) : false; return phrase ? normalizePhrase(haystack).includes(phrase) : false;
} }
function readIfExists(filePath) { function readIfExists(filePath: string): string {
try { try {
return fs.readFileSync(filePath, 'utf-8'); return fs.readFileSync(filePath, 'utf-8');
} catch { } 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); 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'); const configPath = path.join(projectDir, '.planning', 'config.json');
try { 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 { return {
...(parsed.workflow || {}), ...wf,
auto_advance: parsed.workflow?.auto_advance ?? parsed.auto_advance, auto_advance: (wf['auto_advance'] ?? parsed['auto_advance']) as boolean | undefined,
_auto_chain_active: parsed.workflow?._auto_chain_active ?? parsed._auto_chain_active, _auto_chain_active: (wf['_auto_chain_active'] ?? parsed['_auto_chain_active']) as boolean | undefined,
context_coverage_gate: parsed.workflow?.context_coverage_gate ?? parsed.context_coverage_gate, context_coverage_gate: (wf['context_coverage_gate'] ?? parsed['context_coverage_gate']) as boolean | string | undefined,
}; };
} catch { } catch {
return {}; return {};
} }
} }
function cmdAutoMode(projectDir, raw) { function cmdAutoMode(projectDir: string, raw: boolean): void {
const workflow = readWorkflowConfig(projectDir); const workflow = readWorkflowConfig(projectDir);
const autoAdvance = Boolean(workflow.auto_advance ?? false); const autoAdvance = Boolean(workflow.auto_advance ?? false);
const autoChainActive = Boolean(workflow._auto_chain_active ?? false); const autoChainActive = Boolean(workflow._auto_chain_active ?? false);
@@ -70,10 +89,10 @@ function cmdAutoMode(projectDir, raw) {
source, source,
auto_chain_active: autoChainActive, auto_chain_active: autoChainActive,
auto_advance: autoAdvance, auto_advance: autoAdvance,
}, raw); }, raw, undefined);
} }
function gateEnabled(projectDir) { function gateEnabled(projectDir: string): boolean {
const value = readWorkflowConfig(projectDir).context_coverage_gate; const value = readWorkflowConfig(projectDir).context_coverage_gate;
if (typeof value === 'boolean') return value; if (typeof value === 'boolean') return value;
if (typeof value === 'string') { if (typeof value === 'string') {
@@ -83,7 +102,7 @@ function gateEnabled(projectDir) {
return true; return true;
} }
function loadPlanContents(phaseDir) { function loadPlanContents(phaseDir: string): string[] {
if (!fs.existsSync(phaseDir)) return []; if (!fs.existsSync(phaseDir)) return [];
try { try {
return fs.readdirSync(phaseDir) 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 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; 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 return text
.replace(/<!--[\s\S]*?-->/g, ' ') .replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/```[\s\S]*?```/g, ' ') .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')); const match = frontmatter.match(new RegExp(`^${key}\\s*:(.*)$`, 'm'));
if (!match) return ''; if (!match) return '';
const startIdx = (match.index || 0) + match[0].length; const startIdx = (match.index || 0) + match[0].length;
@@ -117,28 +136,28 @@ function extractYamlBlock(frontmatter, key) {
return block.join('\n'); return block.join('\n');
} }
function extractXmlTagBodies(text) { function extractXmlTagBodies(text: string): string {
const parts = []; const parts: string[] = [];
for (const match of text.matchAll(XML_DECISION_TAGS_RE)) { for (const match of text.matchAll(XML_DECISION_TAGS_RE)) {
if (match[1]) parts.push(match[1]); if (match[1]) parts.push(match[1]);
} }
return parts.join('\n'); return parts.join('\n');
} }
function extractPlanDesignatedSections(planContent) { function extractPlanDesignatedSections(planContent: string | null | undefined): string {
if (!planContent) return ''; if (!planContent) return '';
const cleaned = stripCommentsAndFences(planContent); const cleaned = stripCommentsAndFences(planContent);
const fmMatch = cleaned.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); const fmMatch = cleaned.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
const frontmatter = fmMatch ? fmMatch[1] : ''; const frontmatter = fmMatch ? fmMatch[1] : '';
const body = fmMatch ? fmMatch[2] : cleaned; const body = fmMatch ? fmMatch[2] : cleaned;
const parts = []; const parts: string[] = [];
for (const key of ['must_haves', 'truths', 'objective']) { for (const key of ['must_haves', 'truths', 'objective']) {
const block = extractYamlBlock(frontmatter, key); const block = extractYamlBlock(frontmatter, key);
if (block) parts.push(block); if (block) parts.push(block);
} }
const bodyParts = []; const bodyParts: string[] = [];
let inDesignated = false; let inDesignated = false;
for (const line of body.split(/\r?\n/)) { for (const line of body.split(/\r?\n/)) {
const heading = /^#{1,6}\s+/.test(line); const heading = /^#{1,6}\s+/.test(line);
@@ -154,7 +173,13 @@ function extractPlanDesignatedSections(planContent) {
return parts.join('\n\n'); 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.'; if (uncovered.length === 0) return 'All trackable CONTEXT.md decisions are covered by plans.';
return [ return [
'## Decision Coverage Gap', '## Decision Coverage Gap',
@@ -168,7 +193,7 @@ function buildPlanMessage(uncovered) {
].join('\n'); ].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.'; if (notHonored.length === 0) return 'All trackable CONTEXT.md decisions are honored by shipped artifacts.';
return [ return [
'### Decision Coverage (warning)', '### Decision Coverage (warning)',
@@ -181,31 +206,31 @@ function buildVerifyMessage(notHonored) {
].join('\n'); ].join('\n');
} }
function loadTrackableDecisions(contextPath) { function loadTrackableDecisions(contextPath: string): Decision[] {
return parseDecisions(readIfExists(contextPath)).filter((decision) => decision.trackable); 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 phaseDir = args[2] ? resolvePath(args[2], projectDir) : '';
const contextPath = args[3] ? resolvePath(args[3], projectDir) : ''; const contextPath = args[3] ? resolvePath(args[3], projectDir) : '';
if (!gateEnabled(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; return;
} }
if (!contextPath || !fs.existsSync(contextPath)) { 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; return;
} }
const decisions = loadTrackableDecisions(contextPath); const decisions = loadTrackableDecisions(contextPath);
if (decisions.length === 0) { 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; return;
} }
const sections = loadPlanContents(phaseDir).map(extractPlanDesignatedSections); const sections = loadPlanContents(phaseDir).map(extractPlanDesignatedSections);
const uncovered = []; const uncovered: UncoveredItem[] = [];
let covered = 0; let covered = 0;
for (const decision of decisions) { for (const decision of decisions) {
if (sections.some((section) => decisionMentioned(section, decision))) covered++; if (sections.some((section) => decisionMentioned(section, decision))) covered++;
@@ -219,10 +244,10 @@ function cmdDecisionCoveragePlan(projectDir, args, raw) {
covered, covered,
uncovered, uncovered,
message: buildPlanMessage(uncovered), message: buildPlanMessage(uncovered),
}, raw); }, raw, undefined);
} }
function recentCommitMessages(projectDir) { function recentCommitMessages(projectDir: string): string {
try { try {
return execFileSync('git', ['log', '-n', '200', '--pretty=%s%n%b'], { return execFileSync('git', ['log', '-n', '200', '--pretty=%s%n%b'], {
cwd: projectDir, 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 root = path.resolve(rootDir);
const target = path.resolve(root, candidatePath); const target = path.resolve(root, candidatePath);
return target === root || target.startsWith(`${root}${path.sep}`); return target === root || target.startsWith(`${root}${path.sep}`);
} }
function readModifiedFilesContent(projectDir, summaries) { function readModifiedFilesContent(projectDir: string, summaries: string[]): string {
const out = []; const out: string[] = [];
let total = 0; let total = 0;
for (const summary of summaries) { for (const summary of summaries) {
if (!summary) continue; if (!summary) continue;
@@ -262,22 +287,22 @@ function readModifiedFilesContent(projectDir, summaries) {
return out.join('\n\n'); 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 phaseDir = args[2] ? resolvePath(args[2], projectDir) : '';
const contextPath = args[3] ? resolvePath(args[3], projectDir) : ''; const contextPath = args[3] ? resolvePath(args[3], projectDir) : '';
if (!gateEnabled(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; return;
} }
if (!contextPath || !fs.existsSync(contextPath)) { 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; return;
} }
const decisions = loadTrackableDecisions(contextPath); const decisions = loadTrackableDecisions(contextPath);
if (decisions.length === 0) { 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; return;
} }
@@ -292,7 +317,7 @@ function cmdDecisionCoverageVerify(projectDir, args, raw) {
recentCommitMessages(projectDir), recentCommitMessages(projectDir),
].join('\n\n'); ].join('\n\n');
const notHonored = []; const notHonored: UncoveredItem[] = [];
let honored = 0; let honored = 0;
for (const decision of decisions) { for (const decision of decisions) {
if (decisionMentioned(haystack, decision)) honored++; if (decisionMentioned(haystack, decision)) honored++;
@@ -306,10 +331,16 @@ function cmdDecisionCoverageVerify(projectDir, args, raw) {
honored, honored,
not_honored: notHonored, not_honored: notHonored,
message: buildVerifyMessage(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]; const subcommand = args[1];
if (subcommand === 'auto-mode') { if (subcommand === 'auto-mode') {
cmdAutoMode(cwd, raw); 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); error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify', ERROR_REASON.SDK_UNKNOWN_COMMAND);
} }
module.exports = { export = {
routeCheckCommand, routeCheckCommand,
decisionMentioned, decisionMentioned,
extractPlanDesignatedSections, extractPlanDesignatedSections,

View File

@@ -1,15 +1,50 @@
'use strict';
const { createHub, ERROR_KINDS } = require('./command-routing-hub.cjs');
/** /**
* CJS Command Router Adapter Module * CJS Command Router Adapter Module
* *
* Compatibility routing for gsd-tools.cjs command families. Uses generated * Compatibility routing for gsd-tools.cjs command families. Uses generated
* command metadata for availability and small family-local argument shapers for * command metadata for availability and small family-local argument shapers for
* CJS handler calls. * 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({ function routeCjsCommandFamily({
args, args,
subcommands, subcommands,
@@ -20,7 +55,7 @@ function routeCjsCommandFamily({
error, error,
cwd, cwd,
raw, raw,
}) { }: RouteCjsCommandFamilyOptions): void {
routeHubCommandFamily({ routeHubCommandFamily({
family: '__legacy_cjs_family__', family: '__legacy_cjs_family__',
args, args,
@@ -53,7 +88,7 @@ function routeHubCommandFamily({
error, error,
cwd, cwd,
raw, raw,
}) { }: RouteHubCommandFamilyOptions): void {
const subcommand = args[1] || defaultSubcommand; const subcommand = args[1] || defaultSubcommand;
if (subcommand && unsupported[subcommand]) { if (subcommand && unsupported[subcommand]) {
@@ -65,12 +100,12 @@ function routeHubCommandFamily({
const registryHandlers = Object.fromEntries( const registryHandlers = Object.fromEntries(
Object.entries(handlers).map(([name, handler]) => [ Object.entries(handlers).map(([name, handler]) => [
name, name,
() => { (): { ok: true; data: unknown } => {
const result = handler(); const result = handler();
if (result && typeof result === 'object' && Object.prototype.hasOwnProperty.call(result, 'ok')) { 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.ok) return;
if (result.kind === ERROR_KINDS.UnknownCommand) { if (result.kind === ERROR_KINDS.UnknownCommand) {
error(unknownMessage(subcommand, available)); error(unknownMessage(subcommand ?? '', available));
return; return;
} }
if (result.kind === ERROR_KINDS.InvalidArgs || result.kind === ERROR_KINDS.HandlerRefusal) { if (result.kind === ERROR_KINDS.InvalidArgs || result.kind === ERROR_KINDS.HandlerRefusal) {
error(result.reason); error((result as { reason: string }).reason);
return; 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 * Accepts variable argument shapes so routers can pass legacy projection tuples
* (`registryCommand`, `registryArgs`, `legacyArgs`, optional `rawFormatter`, `cjsFallback`). * (`registryCommand`, `registryArgs`, `legacyArgs`, optional `rawFormatter`, `cjsFallback`).
*/ */
function cjsFallbackHandler(...projectionArgs) { function cjsFallbackHandler(...projectionArgs: unknown[]): unknown {
return projectionArgs[projectionArgs.length - 1]; return projectionArgs[projectionArgs.length - 1];
} }
module.exports = { export = {
routeCjsCommandFamily, routeCjsCommandFamily,
routeHubCommandFamily, routeHubCommandFamily,
cjsFallbackHandler, 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 * Production code uses `realClock` (the default). Test code passes in a
* `makeFakeClock()` instance to drive lock timing without real wall-clock * `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) * - 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. // Module-level Atomics.wait buffer reused across every realClock.sleep() call.
// The buffer value is always 0 (never written), so reuse is semantically // The buffer value is always 0 (never written), so reuse is semantically
// identical to allocating a fresh buffer each time. // 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 (fall back to Date.now()) for any invalid or absent input.
* Returns null when GSD_TEST_MODE is not set. * 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; if (!process.env.GSD_TEST_MODE) return null;
const raw = process.env.GSD_NOW_MS; const raw = process.env.GSD_NOW_MS;
if (typeof raw !== 'string') return null; if (typeof raw !== 'string') return null;
@@ -42,7 +49,7 @@ function _pinnedNowMs() {
return ms; return ms;
} }
const realClock = { export const realClock: Clock = {
/** /**
* Return current epoch milliseconds. * Return current epoch milliseconds.
* *
@@ -54,7 +61,7 @@ const realClock = {
* Any other value (empty string, float, scientific notation, out-of-range) falls * Any other value (empty string, float, scientific notation, out-of-range) falls
* back to Date.now() to prevent RangeError from new Date(ms).toISOString(). * back to Date.now() to prevent RangeError from new Date(ms).toISOString().
*/ */
now() { now(): number {
const pinned = _pinnedNowMs(); const pinned = _pinnedNowMs();
if (pinned !== null) return pinned; if (pinned !== null) return pinned;
return Date.now(); return Date.now();
@@ -64,9 +71,9 @@ const realClock = {
* Return the current instant as an ISO 8601 string (UTC). * Return the current instant as an ISO 8601 string (UTC).
* Uses this.now() so the subprocess time-pin adapter is honoured. * 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(); 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). * Return today's date as a YYYY-MM-DD string (UTC calendar day).
* Uses this.now() so the subprocess time-pin adapter is honoured. * 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]; 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 * inline before the seam. Atomics.wait on a shared buffer that is never
* notified times out after exactly `ms` milliseconds without spinning the CPU. * 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); 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 * 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. * to enable/disable a cohesive group of skills without reinstall.
@@ -13,7 +15,21 @@
* against commands/gsd/ listing in surface-clusters.test.cjs). * 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([ core_loop: Object.freeze([
'new-project', 'new-project',
'discuss-phase', 'discuss-phase',
@@ -122,14 +138,11 @@ const CLUSTERS = Object.freeze({
/** /**
* Build a Set of all skill stems covered by at least one cluster. * Build a Set of all skill stems covered by at least one cluster.
* @returns {Set<string>}
*/ */
function allClusteredSkills() { export function allClusteredSkills(): Set<string> {
const result = new Set(); const result = new Set<string>();
for (const skills of Object.values(CLUSTERS)) { for (const skills of Object.values(CLUSTERS)) {
for (const s of skills) result.add(s); for (const s of skills) result.add(s);
} }
return result; 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. * 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", "canonical": "state.load",
"aliases": [], "aliases": [],
@@ -173,7 +188,7 @@ const STATE_COMMAND_ALIASES = [
} }
]; ];
const VERIFY_COMMAND_ALIASES = [ export const VERIFY_COMMAND_ALIASES: CommandAlias[] = [
{ {
"canonical": "verify.plan-structure", "canonical": "verify.plan-structure",
"aliases": [ "aliases": [
@@ -240,7 +255,7 @@ const VERIFY_COMMAND_ALIASES = [
} }
]; ];
const INIT_COMMAND_ALIASES = [ export const INIT_COMMAND_ALIASES: CommandAlias[] = [
{ {
"canonical": "init.execute-phase", "canonical": "init.execute-phase",
"aliases": [ "aliases": [
@@ -379,7 +394,7 @@ const INIT_COMMAND_ALIASES = [
} }
]; ];
const PHASE_COMMAND_ALIASES = [ export const PHASE_COMMAND_ALIASES: CommandAlias[] = [
{ {
"canonical": "phase.uat-passed", "canonical": "phase.uat-passed",
"aliases": [ "aliases": [
@@ -446,7 +461,7 @@ const PHASE_COMMAND_ALIASES = [
} }
]; ];
const PHASES_COMMAND_ALIASES = [ export const PHASES_COMMAND_ALIASES: CommandAlias[] = [
{ {
"canonical": "phases.list", "canonical": "phases.list",
"aliases": [ "aliases": [
@@ -473,7 +488,7 @@ const PHASES_COMMAND_ALIASES = [
} }
]; ];
const VALIDATE_COMMAND_ALIASES = [ export const VALIDATE_COMMAND_ALIASES: CommandAlias[] = [
{ {
"canonical": "validate.consistency", "canonical": "validate.consistency",
"aliases": [ "aliases": [
@@ -508,7 +523,7 @@ const VALIDATE_COMMAND_ALIASES = [
} }
]; ];
const ROADMAP_COMMAND_ALIASES = [ export const ROADMAP_COMMAND_ALIASES: CommandAlias[] = [
{ {
"canonical": "roadmap.analyze", "canonical": "roadmap.analyze",
"aliases": [ "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", "canonical": "agent.classify-failure",
"aliases": [ "aliases": [
@@ -804,28 +819,10 @@ const NON_FAMILY_COMMAND_ALIASES = [
} }
]; ];
const STATE_SUBCOMMANDS = STATE_COMMAND_ALIASES.map((entry) => entry.subcommand); export const STATE_SUBCOMMANDS: string[] = STATE_COMMAND_ALIASES.map((entry) => entry.subcommand);
const VERIFY_SUBCOMMANDS = VERIFY_COMMAND_ALIASES.map((entry) => entry.subcommand); export const VERIFY_SUBCOMMANDS: string[] = VERIFY_COMMAND_ALIASES.map((entry) => entry.subcommand);
const INIT_SUBCOMMANDS = INIT_COMMAND_ALIASES.map((entry) => entry.subcommand); export const INIT_SUBCOMMANDS: string[] = INIT_COMMAND_ALIASES.map((entry) => entry.subcommand);
const PHASE_SUBCOMMANDS = PHASE_COMMAND_ALIASES.map((entry) => entry.subcommand); export const PHASE_SUBCOMMANDS: string[] = PHASE_COMMAND_ALIASES.map((entry) => entry.subcommand);
const PHASES_SUBCOMMANDS = PHASES_COMMAND_ALIASES.map((entry) => entry.subcommand); export const PHASES_SUBCOMMANDS: string[] = PHASES_COMMAND_ALIASES.map((entry) => entry.subcommand);
const VALIDATE_SUBCOMMANDS = VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand); export const VALIDATE_SUBCOMMANDS: string[] = VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand);
const ROADMAP_SUBCOMMANDS = ROADMAP_COMMAND_ALIASES.map((entry) => entry.subcommand); export const ROADMAP_SUBCOMMANDS: string[] = 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,
};

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 * Shared helpers for command-family adapters to project argv tokens into
* typed named values and multi-word segments. * typed named values and multi-word segments.
@@ -11,24 +12,24 @@
* Extract named --flag <value> pairs from an args array. * Extract named --flag <value> pairs from an args array.
* Returns an object mapping flag names to their values (null if absent). * Returns an object mapping flag names to their values (null if absent).
* Flags listed in `booleanFlags` are treated as booleans. * 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), // 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 // 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) // 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++) { for (let i = 0; i < args.length; i++) {
if (!firstIndex.has(args[i])) firstIndex.set(args[i], 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) { for (const flag of valueFlags) {
const idx = firstIndex.has(`--${flag}`) ? firstIndex.get(`--${flag}`) : -1; const idx = firstIndex.has(`--${flag}`) ? (firstIndex.get(`--${flag}`) as number) : -1;
result[flag] = idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--') result[flag] =
idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--')
? args[idx + 1] ? args[idx + 1]
: null; : null;
} }
@@ -40,23 +41,14 @@ function parseNamedArgs(args, valueFlags = [], booleanFlags = []) {
/** /**
* Collect all tokens after --flag until the next --flag or end of args. * 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}`); const idx = args.indexOf(`--${flag}`);
if (idx === -1) return null; if (idx === -1) return null;
const tokens = []; const tokens: string[] = [];
for (let i = idx + 1; i < args.length; i++) { for (let i = idx + 1; i < args.length; i++) {
if (args[i].startsWith('--')) break; if (args[i].startsWith('--')) break;
tokens.push(args[i]); tokens.push(args[i]);
} }
return tokens.length > 0 ? tokens.join(' ') : null; 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. * - The kind taxonomy is closed. Callers switch on ERROR_KINDS values.
* - Each error variant carries ONLY its own typed payload (#176). * - Each error variant carries ONLY its own typed payload (#176).
* No cross-variant `message`/`details` escape hatches. * 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 * Closed error-kind enum. Export as a frozen object so callers can switch on
* ERROR_KINDS.UnknownCommand etc. without relying on bare string literals. * ERROR_KINDS.UnknownCommand etc. without relying on bare string literals.
@@ -45,20 +56,50 @@ const ERROR_KINDS = Object.freeze({
HandlerRefusal: 'HandlerRefusal', HandlerRefusal: 'HandlerRefusal',
/** A handler threw an unexpected exception. */ /** A handler threw an unexpected exception. */
HandlerFailure: 'HandlerFailure', HandlerFailure: 'HandlerFailure',
}); } as const);
// ─── Observability imports ──────────────────────────────────────────────────── // ─── Result types ─────────────────────────────────────────────────────────────
const { makeDispatchEvent } = require('./observability/event.cjs');
const { createNoOpLogger } = require('./observability/logger.cjs'); 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 ───────────────────────────────────────────────────────── // ─── Internal helpers ─────────────────────────────────────────────────────────
/** /**
* Safe JSON serialisation that never throws. * Safe JSON serialisation that never throws.
* @param {unknown} value
* @returns {string}
*/ */
function _safeJson(value) { function _safeJson(value: unknown): string {
try { try {
return JSON.stringify(value); return JSON.stringify(value);
} catch { } catch {
@@ -72,46 +113,37 @@ function _safeJson(value) {
// Finding 3: all factory returns are Object.freeze'd so callers cannot mutate // Finding 3: all factory returns are Object.freeze'd so callers cannot mutate
// the variant invariant. // the variant invariant.
/** function makeUnknownCommand(command: string): Readonly<UnknownCommandResult> {
* @param {string} command - The unrecognised command string (family or family+subcommand). return Object.freeze({ ok: false as const, kind: ERROR_KINDS.UnknownCommand, command });
* @returns {Readonly<{ ok: false, kind: 'UnknownCommand', command: string }>} }
*/
function makeUnknownCommand(command) { function makeInvalidArgs(arg: string, reason: string): Readonly<InvalidArgsResult> {
return Object.freeze({ ok: false, kind: ERROR_KINDS.UnknownCommand, command }); 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 message - Human-readable description of the failure.
* @param {string} reason - Human-readable explanation of the failure. * @param cause - The original thrown Error, when available.
* @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.
* Non-Error values (strings, plain objects, etc.) are wrapped in an Error * Non-Error values (strings, plain objects, etc.) are wrapped in an Error
* with `.thrown` set to the original value. null/undefined → no cause field. * 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) { function makeHandlerFailure(message: string, cause?: unknown): HandlerFailureResult {
const obj = { ok: false, kind: ERROR_KINDS.HandlerFailure, message }; const obj: {
ok: false;
kind: 'HandlerFailure';
message: string;
cause?: Error;
} = { ok: false as const, kind: ERROR_KINDS.HandlerFailure, message };
if (cause != null) { if (cause != null) {
if (cause instanceof Error) { if (cause instanceof Error) {
obj.cause = cause; obj.cause = cause;
} else { } else {
// Finding 4: wrap non-Error cause so downstream .cause.stack never silently returns undefined // 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; wrapper.thrown = cause;
obj.cause = wrapper; obj.cause = wrapper;
} }
@@ -125,10 +157,8 @@ function makeHandlerFailure(message, cause) {
* Required payload fields per ok:false kind. * Required payload fields per ok:false kind.
* `required` — fields that MUST be present (non-undefined) for the variant to be valid. * `required` — fields that MUST be present (non-undefined) for the variant to be valid.
* `allowed` — the complete set of allowed fields (including ok, kind). * `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: { UnknownCommand: {
required: ['command'], required: ['command'],
allowed: new Set(['ok', 'kind', '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. * Validates a handler-returned { ok: false, ... } result against the typed schema.
* *
* Returns null if valid, or a string describing the contract violation. * 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 { kind } = result;
const schema = _VARIANT_SCHEMA[kind]; const schema = _VARIANT_SCHEMA[kind as string];
// Unknown kind — not in the closed enum // Unknown kind — not in the closed enum
if (!schema) { 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 // Missing required fields
@@ -169,7 +196,7 @@ function _validateErrResult(result) {
if (result[field] === undefined) { if (result[field] === undefined) {
return ( return (
`handler returned malformed Result variant: ` + `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)}` `got: ${_safeJson(result)}`
); );
} }
@@ -180,7 +207,7 @@ function _validateErrResult(result) {
if (!schema.allowed.has(key)) { if (!schema.allowed.has(key)) {
return ( return (
`handler returned malformed Result variant: ` + `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(', ')}. ` + `expected fields: ${[...schema.allowed].join(', ')}. ` +
`got: ${_safeJson(result)}` `got: ${_safeJson(result)}`
); );
@@ -190,34 +217,29 @@ function _validateErrResult(result) {
return null; // valid return null; // valid
} }
/** // ─── Hub options ──────────────────────────────────────────────────────────────
* @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
*/
/** type Handler = (ctx: Record<string, unknown>) => HubResult;
* @typedef {object} HubOptions
* @property {Record<string, Record<string, (ctx: object) => HubResult>>} [cjsRegistry] - interface HubOptions {
* Nested map of family -> subcommand -> handler. cjsRegistry?: Record<string, Record<string, Handler>>;
* @property {Record<string, string[]>} [manifest] - Map of family -> known subcommands. manifest?: Record<string, string[]>;
* Used for UnknownCommand detection. logger?: { onEvent(event: object): void };
* @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 interface DispatchRequest {
* reference implementation (stderr on error, opt-in file audit). family: string;
*/ subcommand?: string;
args?: unknown[];
cwd?: string;
raw?: boolean;
parentTraceId?: unknown;
}
/** /**
* Safe stringify for logger-failure warnings — avoids circular-ref crashes. * Safe stringify for logger-failure warnings — avoids circular-ref crashes.
* @param {unknown} value
* @returns {string}
*/ */
function _safeJsonForWarn(value) { function _safeJsonForWarn(value: unknown): string {
try { try {
return JSON.stringify(value); return JSON.stringify(value);
} catch { } catch {
@@ -227,11 +249,8 @@ function _safeJsonForWarn(value) {
/** /**
* Construct a CommandRoutingHub. * 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 _cjsRegistry = cjsRegistry;
const _manifest = manifest; const _manifest = manifest;
// Default to no-op so callers that don't inject a logger get pure-silent behaviour. // 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 ok path: { ok: true, data } → { kind: 'ok', data }
* HubResult err paths: { ok: false, kind, ...payload } → { kind, ...payload } * 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) { if (hubResult.ok) {
return { kind: 'ok', data: hubResult.data }; return { kind: 'ok', data: hubResult.data };
} }
// err variant: already has kind + typed payload // 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. * Emit a DispatchEvent to the injected logger.
* Logger errors NEVER propagate — they are caught and emitted as a warn line to stderr. * 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 { try {
const eventResult = _normaliseResult(hubResult); const eventResult = _normaliseResult(hubResult);
const event = makeDispatchEvent({ command, args, result: eventResult, parentTraceId }); const event = makeDispatchEvent({ command, args, result: eventResult, parentTraceId });
@@ -278,7 +290,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
_safeJsonForWarn({ _safeJsonForWarn({
level: 'warn', level: 'warn',
source: 'DispatchLogger', source: 'DispatchLogger',
message: 'logger.onEvent failed: ' + String(logErr && logErr.message || logErr), message: 'logger.onEvent failed: ' + String((logErr as Error)?.message || logErr),
}) + '\n' }) + '\n'
); );
} catch { } catch {
@@ -289,15 +301,12 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
/** /**
* Dispatch a command through the hub. * Dispatch a command through the hub.
*
* @param {{ family: string, subcommand: string, args?: unknown[], cwd?: string, raw?: boolean }} req
* @returns {HubResult}
*/ */
function dispatch(req) { function dispatch(req: DispatchRequest): HubResult {
const { family, subcommand, args = [], parentTraceId } = req || {}; const { family, subcommand, args = [], parentTraceId } = req || {} as DispatchRequest;
const command = subcommand ? `${family} ${subcommand}` : String(family); const command = subcommand ? `${family} ${subcommand}` : String(family);
let result; let result: HubResult;
try { try {
result = _dispatch(req); result = _dispatch(req);
} catch (err) { } catch (err) {
@@ -305,7 +314,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
result = makeHandlerFailure(err.message, err); result = makeHandlerFailure(err.message, err);
} else { } else {
// Finding 2: preserve non-Error throwables via a wrapper Error with .thrown // 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; wrapper.thrown = err;
result = makeHandlerFailure(String(err), wrapper); result = makeHandlerFailure(String(err), wrapper);
} }
@@ -315,7 +324,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
return result; return result;
} }
function _dispatch(req) { function _dispatch(req: DispatchRequest): HubResult {
const { family, subcommand, args = [], cwd, raw } = req; const { family, subcommand, args = [], cwd, raw } = req;
// ── manifest check ──────────────────────────────────────────────────────── // ── manifest check ────────────────────────────────────────────────────────
@@ -332,7 +341,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
return _dispatchCjs({ family, subcommand, args, cwd, raw }); 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) { if (!_cjsRegistry) {
return makeUnknownCommand(String(family)); return makeUnknownCommand(String(family));
} }
@@ -355,11 +364,12 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
if (result && typeof result === 'object' && 'ok' in result) { if (result && typeof result === 'object' && 'ok' in result) {
if (!result.ok) { if (!result.ok) {
// Finding 1: runtime-validate ok:false variant shape; coerce malformed to HandlerFailure // 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) { if (violation !== null) {
return makeHandlerFailure( return makeHandlerFailure(
'handler returned malformed Result variant: ' + violation, '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 }; return { dispatch };
} }
module.exports = { export = {
createHub, createHub,
ERROR_KINDS, ERROR_KINDS,
makeUnknownCommand, 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 * Thin adapter — sources schema data from the manifest via the generated
* Configuration Module. All inline literals have been removed; the manifest * Configuration Module. All inline literals have been removed; the manifest
@@ -11,21 +9,25 @@
* - many tests (config-schema.property.test.cjs, bug-*, feat-*, etc.) * - many tests (config-schema.property.test.cjs, bug-*, feat-*, etc.)
* *
* See Phase 2 Cycle 5 (#3536) — schema manifest migration. * 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, VALID_CONFIG_KEYS,
RUNTIME_STATE_KEYS, RUNTIME_STATE_KEYS,
DYNAMIC_KEY_PATTERNS, DYNAMIC_KEY_PATTERNS,
} = require('./configuration.cjs'); } from './configuration.cjs';
/** /**
* Returns true if keyPath is a valid config key (exact, dynamic pattern, or runtime state). * 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 (VALID_CONFIG_KEYS.has(keyPath)) return true;
if (RUNTIME_STATE_KEYS.has(keyPath)) return true; if (RUNTIME_STATE_KEYS.has(keyPath)) return true;
return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath)); 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 * 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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const { output, error, ERROR_REASON, CONFIG_DEFAULTS } = require('./core.cjs'); import os from 'node:os';
const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports
const { planningDir, withPlanningLock } = require('./planning-workspace.cjs'); import core = require('./core.cjs');
const { const { output, error, ERROR_REASON, CONFIG_DEFAULTS } = core;
VALID_PROFILES, import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
getAgentToModelMapForProfile, // eslint-disable-next-line @typescript-eslint/no-require-imports
formatAgentToModelMapAsTable, import planningWorkspace = require('./planning-workspace.cjs');
} = require('./model-profiles.cjs'); const { planningDir, withPlanningLock } = planningWorkspace;
const { VALID_CONFIG_KEYS, isValidConfigKey } = require('./config-schema.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports
const { isSecretKey, maskSecret } = require('./secrets.cjs'); import modelProfiles = require('./model-profiles.cjs');
const { normalizeConfiguredDefaultReviewers } = require('./review-reviewer-selection.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', 'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation',
'agents.nyquist_validation_enabled': 'workflow.nyquist_validation', 'agents.nyquist_validation_enabled': 'workflow.nyquist_validation',
'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]*$/; 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]; const suggested = CONFIG_KEY_SUGGESTIONS[keyPath];
if (suggested) { if (suggested) {
error(`Unknown config key: ${keyPath}. Did you mean ${suggested}?`, ERROR_REASON.CONFIG_INVALID_KEY); 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)) { if (!Array.isArray(value)) {
error('Invalid ship.pr_body_sections value. Expected a JSON array of section objects.'); 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}]`; const prefix = `Invalid ship.pr_body_sections[${index}]`;
if (!section || typeof section !== 'object' || Array.isArray(section)) { if (!section || typeof section !== 'object' || Array.isArray(section)) {
error(`${prefix}. Expected an object.`); 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) { if (unknownKeys.length > 0) {
error(`${prefix}. Unknown field(s): ${unknownKeys.join(', ')}.`); 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.`); 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.`); 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.`); error(`${prefix}. enabled must be true or false.`);
} }
for (const field of ['source', 'fallback', 'template']) { 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.`); error(`${prefix}. ${field} must be a string.`);
} }
} }
const hasContent = ['source', 'fallback', 'template'].some((field) => { 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) { if (!hasContent) {
error(`${prefix}. Provide at least one of source, fallback, or template.`); error(`${prefix}. Provide at least one of source, fallback, or template.`);
} }
if (typeof section.source === 'string' && section.source.trim() !== '') { if (typeof sectionObj['source'] === 'string' && sectionObj['source'].trim() !== '') {
const selectors = section.source.split('||').map((selector) => selector.trim()).filter(Boolean); 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))) { 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 "||".`); error(`${prefix}. source must use selectors like "PLAN.md ## Risks", separated with "||".`);
} }
} }
if (typeof section.template === 'string') { if (typeof sectionObj['template'] === 'string') {
const tokens = section.template.matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g); const tokens = sectionObj['template'].matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g);
for (const match of tokens) { for (const match of tokens) {
if (!SHIP_PR_BODY_TEMPLATE_TOKENS.has(match[1])) { if (!SHIP_PR_BODY_TEMPLATE_TOKENS.has(match[1])) {
error(`${prefix}. Unsupported template token: {${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. * 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. * 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 choices = userChoices || {};
const homedir = require('os').homedir(); const homedir = os.homedir();
// Detect API key availability // Detect API key availability
const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key'); 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 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 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 // Load user-level defaults from ~/.gsd/defaults.json if available
const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json'); const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json');
let userDefaults = {}; let userDefaults: Record<string, unknown> = {};
try { try {
if (fs.existsSync(globalDefaultsPath)) { 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" // Migrate deprecated "depth" key to "granularity"
if ('depth' in userDefaults && !('granularity' in userDefaults)) { if ('depth' in userDefaults && !('granularity' in userDefaults)) {
const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' }; const depthToGranularity: Record<string, string> = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
userDefaults.granularity = depthToGranularity[userDefaults.depth] || userDefaults.depth; userDefaults['granularity'] = depthToGranularity[userDefaults['depth'] as string] || userDefaults['depth'];
delete userDefaults.depth; delete userDefaults['depth'];
try { try {
platformWriteSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2)); platformWriteSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2));
} catch { /* intentionally empty */ } } catch { /* intentionally empty */ }
@@ -153,7 +197,7 @@ function buildNewProjectConfig(userChoices) {
// Ignore malformed global defaults // Ignore malformed global defaults
} }
const hardcoded = { const hardcoded: Record<string, unknown> = {
model_profile: CONFIG_DEFAULTS.model_profile, model_profile: CONFIG_DEFAULTS.model_profile,
commit_docs: CONFIG_DEFAULTS.commit_docs, commit_docs: CONFIG_DEFAULTS.commit_docs,
parallelization: CONFIG_DEFAULTS.parallelization, 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 // Three-level deep merge: hardcoded <- userDefaults <- choices
const config = { const config: Record<string, unknown> = {
...hardcoded, ...hardcoded,
...userDefaults, ...userDefaults,
...choices, ...choices,
git: { git: {
...hardcoded.git, ...hd['git'],
...(userDefaults.git || {}), ...(ud['git'] || {}),
...(choices.git || {}), ...(ch['git'] || {}),
}, },
workflow: { workflow: {
...hardcoded.workflow, ...hd['workflow'],
...(userDefaults.workflow || {}), ...(ud['workflow'] || {}),
...(choices.workflow || {}), ...(ch['workflow'] || {}),
}, },
ship: { ship: {
...hardcoded.ship, ...hd['ship'],
...(userDefaults.ship || {}), ...(ud['ship'] || {}),
...(choices.ship || {}), ...(ch['ship'] || {}),
}, },
hooks: { hooks: {
...hardcoded.hooks, ...hd['hooks'],
...(userDefaults.hooks || {}), ...(ud['hooks'] || {}),
...(choices.hooks || {}), ...(ch['hooks'] || {}),
}, },
agent_skills: { agent_skills: {
...hardcoded.agent_skills, ...hd['agent_skills'],
...(userDefaults.agent_skills || {}), ...(ud['agent_skills'] || {}),
...(choices.agent_skills || {}), ...(ch['agent_skills'] || {}),
}, },
plan_review: { plan_review: {
...hardcoded.plan_review, ...hd['plan_review'],
...(userDefaults.plan_review || {}), ...(ud['plan_review'] || {}),
...(choices.plan_review || {}), ...(ch['plan_review'] || {}),
}, },
}; };
validateShipPrBodySections(config.ship.pr_body_sections); validateShipPrBodySections((config['ship'] as Record<string, unknown>)['pr_body_sections']);
return config; return config;
} }
@@ -264,7 +312,7 @@ function buildNewProjectConfig(userChoices) {
* *
* Idempotent: if config.json already exists, returns { created: false }. * 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 planningBase = planningDir(cwd);
const configPath = path.join(planningBase, 'config.json'); const configPath = path.join(planningBase, 'config.json');
@@ -275,12 +323,12 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
} }
// Parse user choices // Parse user choices
let userChoices = {}; let userChoices: Record<string, unknown> = {};
if (choicesJson && choicesJson.trim() !== '') { if (choicesJson && choicesJson.trim() !== '') {
try { try {
userChoices = JSON.parse(choicesJson); userChoices = JSON.parse(choicesJson) as Record<string, unknown>;
} catch (err) { } 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 { try {
platformEnsureDir(planningBase); platformEnsureDir(planningBase);
} catch (err) { } catch (err) {
error('Failed to create .planning directory: ' + err.message); error('Failed to create .planning directory: ' + (err as Error).message);
} }
const config = buildNewProjectConfig(userChoices); const config = buildNewProjectConfig(userChoices);
@@ -297,7 +345,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
platformWriteSync(configPath, JSON.stringify(config, null, 2)); platformWriteSync(configPath, JSON.stringify(config, null, 2));
output({ created: true, path: '.planning/config.json' }, raw, 'created'); output({ created: true, path: '.planning/config.json' }, raw, 'created');
} catch (err) { } 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 * 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. * 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 planningBase = planningDir(cwd);
const configPath = path.join(planningBase, 'config.json'); const configPath = path.join(planningBase, 'config.json');
@@ -315,7 +363,7 @@ function ensureConfigFile(cwd) {
try { try {
platformEnsureDir(planningBase); platformEnsureDir(planningBase);
} catch (err) { } 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 // Check if config already exists
@@ -329,7 +377,7 @@ function ensureConfigFile(cwd) {
platformWriteSync(configPath, JSON.stringify(config, null, 2)); platformWriteSync(configPath, JSON.stringify(config, null, 2));
return { created: true, path: '.planning/config.json' }; return { created: true, path: '.planning/config.json' };
} catch (err) { } 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 * Note that this exits the process (via `output()`) even in the happy path; use
* `ensureConfigFile()` directly if you need to avoid this. * `ensureConfigFile()` directly if you need to avoid this.
*/ */
function cmdConfigEnsureSection(cwd, raw) { function cmdConfigEnsureSection(cwd: string, raw: boolean): void {
const ensureConfigFileResult = ensureConfigFile(cwd); const ensureConfigFileResult = ensureConfigFile(cwd);
if (ensureConfigFileResult.created) { if (ensureConfigFileResult && ensureConfigFileResult.created) {
output(ensureConfigFileResult, raw, 'created'); output(ensureConfigFileResult, raw, 'created');
} else { } else {
output(ensureConfigFileResult, raw, 'exists'); 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 * 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. * 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'); const configPath = path.join(planningDir(cwd), 'config.json');
return withPlanningLock(cwd, () => { return withPlanningLock(cwd, () => {
// Load existing config or start with empty object // Load existing config or start with empty object
let config = {}; let config: Record<string, unknown> = {};
try { try {
if (fs.existsSync(configPath)) { 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) { } 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") // Set nested value using dot notation (e.g., "workflow.research")
const keys = keyPath.split('.'); const keys = keyPath.split('.');
let current = config; let current: Record<string, unknown> = config;
for (let i = 0; i < keys.length - 1; i++) { for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]; const key = keys[i];
if (current[key] === undefined || typeof current[key] !== 'object') { if (current[key] === undefined || typeof current[key] !== 'object') {
current[key] = {}; current[key] = {};
} }
current = current[key]; current = current[key] as Record<string, unknown>;
} }
const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting
current[keys[keys.length - 1]] = parsedValue; current[keys[keys.length - 1]] = parsedValue;
@@ -387,9 +435,9 @@ function setConfigValue(cwd, keyPath, parsedValue) {
platformWriteSync(configPath, JSON.stringify(config, null, 2)); platformWriteSync(configPath, JSON.stringify(config, null, 2));
return { updated: true, key: keyPath, value: parsedValue, previousValue }; return { updated: true, key: keyPath, value: parsedValue, previousValue };
} catch (err) { } 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()` * Note that this exits the process (via `output()`) even in the happy path; use `setConfigValue()`
* directly if you need to avoid this. * 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) { if (!keyPath) {
error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE); 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); 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)) { validateKnownConfigKeyPath(kp);
error(`Unknown config key: "${keyPath}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
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) // Parse value (handle booleans, numbers, and JSON arrays/objects)
let parsedValue = value; let parsedValue: unknown = val;
if (value === 'true') parsedValue = true; if (val === 'true') parsedValue = true;
else if (value === 'false') parsedValue = false; else if (val === 'false') parsedValue = false;
else if (!isNaN(value) && value !== '') parsedValue = Number(value); else if (!isNaN(Number(val)) && val !== '') parsedValue = Number(val);
else if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) { else if (typeof val === 'string' && (val.startsWith('[') || val.startsWith('{'))) {
try { parsedValue = JSON.parse(value); } catch { /* keep as string */ } try { parsedValue = JSON.parse(val); } catch { /* keep as string */ }
} }
const VALID_CONTEXT_VALUES = ['dev', 'research', 'review']; const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
if (keyPath === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) { if (kp === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) {
error(`Invalid context value '${value}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`); error(`Invalid context value '${val}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`);
} }
// Codebase drift detector (#2003) // Codebase drift detector (#2003)
const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap']; const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
if (keyPath === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) { if (kp === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
error(`Invalid workflow.drift_action '${value}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`); 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) { 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) // Post-planning gap checker (#2493)
if (keyPath === 'workflow.post_planning_gaps') { if (kp === 'workflow.post_planning_gaps') {
if (typeof parsedValue !== 'boolean') { 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 // #3086 — git.create_tag: boolean only
if (keyPath === 'git.create_tag') { if (kp === 'git.create_tag') {
if (typeof parsedValue !== 'boolean') { 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); validateShipPrBodySections(parsedValue);
} }
// Human verification checkpoint mode (#3309) // Human verification checkpoint mode (#3309)
const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase']; const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase'];
if (keyPath === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) { if (kp === '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(', ')}`); error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
} }
// Context position enum validation (#2937) // Context position enum validation (#2937)
const VALID_CONTEXT_POSITIONS = ['front', 'end']; const VALID_CONTEXT_POSITIONS = ['front', 'end'];
if (keyPath === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) { if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
error(`Invalid statusline.context_position '${value}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`); error(`Invalid statusline.context_position '${val}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`);
} }
// Fallow scope + profile enum validation (#3424) // Fallow scope + profile enum validation (#3424)
const VALID_FALLOW_SCOPES = ['phase', 'repo']; const VALID_FALLOW_SCOPES = ['phase', 'repo'];
if (keyPath === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) { if (kp === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) {
error(`Invalid code_quality.fallow.scope '${value}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`); error(`Invalid code_quality.fallow.scope '${val}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`);
} }
const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict']; const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict'];
if (keyPath === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) { if (kp === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) {
error(`Invalid code_quality.fallow.profile '${value}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`); error(`Invalid code_quality.fallow.profile '${val}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`);
} }
// plan_review.source_grounding (#22) — boolean only // plan_review.source_grounding (#22) — boolean only
if (keyPath === 'plan_review.source_grounding') { if (kp === 'plan_review.source_grounding') {
if (typeof parsedValue !== 'boolean') { 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 // plan_review.source_grounding_authority (#22) — enum
const VALID_SOURCE_GROUNDING_AUTHORITIES = ['grep', 'intel', 'treesitter', 'lsp', 'scip']; const VALID_SOURCE_GROUNDING_AUTHORITIES = ['grep', 'intel', 'treesitter', 'lsp', 'scip'];
if (keyPath === 'plan_review.source_grounding_authority' && !VALID_SOURCE_GROUNDING_AUTHORITIES.includes(String(parsedValue))) { if (kp === '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(', ')}`); 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); const normalized = normalizeConfiguredDefaultReviewers(parsedValue);
if (normalized.errors.length > 0) { if (normalized.errors.length > 0) {
error(normalized.errors[0]); error(normalized.errors[0]);
@@ -506,42 +559,31 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
parsedValue = normalized.values; 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 // 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 // to config.json (that's where secrets live on disk); the CLI output
// must never echo it. See lib/secrets.cjs. // must never echo it. See lib/secrets.cjs.
if (isSecretKey(keyPath)) { if (isSecretKey(kp)) {
const masked = maskSecret(parsedValue); // parsedValue is unknown at this point; maskSecret accepts MaskableValue
const masked = maskSecret(parsedValue as Parameters<typeof maskSecret>[0]);
const maskedPrev = setConfigValueResult.previousValue === undefined const maskedPrev = setConfigValueResult.previousValue === undefined
? undefined ? undefined
: maskSecret(setConfigValueResult.previousValue); : maskSecret(setConfigValueResult.previousValue as Parameters<typeof maskSecret>[0]);
const maskedResult = { const maskedResult = {
...setConfigValueResult, ...setConfigValueResult,
value: masked, value: masked,
previousValue: maskedPrev, previousValue: maskedPrev,
masked: true, masked: true,
}; };
output(maskedResult, raw, `${keyPath}=${masked}`); output(maskedResult, raw, `${kp}=${masked}`);
return; return;
} }
output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`); output(setConfigValueResult, raw, `${kp}=${String(parsedValue)}`);
} }
/** function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, defaultValue: unknown): void {
* 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) {
const configPath = path.join(planningDir(cwd), 'config.json'); const configPath = path.join(planningDir(cwd), 'config.json');
const hasDefault = defaultValue !== undefined; const hasDefault = defaultValue !== undefined;
@@ -549,55 +591,61 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
error('Usage: config-get <key.path> [--default <value>]'); 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 { try {
if (fs.existsSync(configPath)) { 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) { } else if (hasDefault) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
output(defaultValue, raw, String(defaultValue)); output(defaultValue, raw, String(defaultValue));
return; return;
} else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) { } else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[keyPath]; const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def)); output(def, raw, String(def));
return; return;
} else { } else {
error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE); error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
} }
} catch (err) { } catch (err) {
if (err.message.startsWith('No config.json')) throw err; if ((err as Error).message.startsWith('No config.json')) throw 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);
} }
// Traverse dot-notation path (e.g., "workflow.auto_advance") // Traverse dot-notation path (e.g., "workflow.auto_advance")
const keys = keyPath.split('.'); const keys = kp.split('.');
let current = config; let current: unknown = config;
for (const key of keys) { for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') { 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 (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) { if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[keyPath]; const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def)); output(def, raw, String(def));
return; 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) { if (current === undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; } if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) { if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[keyPath]; const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def)); output(def, raw, String(def));
return; 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 // Never echo plaintext for sensitive keys via config-get. Plaintext lives
// in config.json on disk; the CLI surface always shows the masked form. // in config.json on disk; the CLI surface always shows the masked form.
if (isSecretKey(keyPath)) { if (isSecretKey(kp)) {
const masked = maskSecret(current); const masked = maskSecret(current as Parameters<typeof maskSecret>[0]);
output(masked, raw, masked); output(masked, raw, masked);
return; return;
} }
@@ -610,22 +658,23 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
* *
* Note that this exits the process (via `output()`) even in the happy path. * 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) { if (!profile) {
error(`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`); 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)) { 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) // Ensure config exists (create if needed)
ensureConfigFile(cwd); ensureConfigFile(cwd);
// Set the model profile in the config // Set the model profile in the config
const { previousValue } = setConfigValue(cwd, 'model_profile', normalizedProfile, raw); const { previousValue } = setConfigValue(cwd, 'model_profile', normalizedProfile);
const previousProfile = previousValue || 'balanced'; const previousProfile = typeof previousValue === 'string' ? previousValue : 'balanced';
// Build result value / message and return // Build result value / message and return
const agentToModelMap = getAgentToModelMapForProfile(normalizedProfile); const agentToModelMap = getAgentToModelMapForProfile(normalizedProfile);
@@ -648,10 +697,10 @@ function cmdConfigSetModelProfile(cwd, profile, raw) {
* displaying raw output. * displaying raw output.
*/ */
function getCmdConfigSetModelProfileResultMessage( function getCmdConfigSetModelProfileResultMessage(
normalizedProfile, normalizedProfile: string,
previousProfile, previousProfile: string,
agentToModelMap agentToModelMap: Record<string, string>
) { ): string {
const agentToModelTable = formatAgentToModelMapAsTable(agentToModelMap); const agentToModelTable = formatAgentToModelMapAsTable(agentToModelMap);
const didChange = previousProfile !== normalizedProfile; const didChange = previousProfile !== normalizedProfile;
const paragraphs = didChange const paragraphs = didChange
@@ -673,7 +722,7 @@ function getCmdConfigSetModelProfileResultMessage(
* Print the resolved config.json path (workstream-aware). Used by settings.md * 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). * 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, // Always emit as plain text — a file path is used via shell substitution,
// never consumed as JSON. Passing raw=true forces plain-text output. // never consumed as JSON. Passing raw=true forces plain-text output.
const configPath = workstreamContext && workstreamContext.configPath 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 * Output: JSON object with { migrated, normalizations, wrote } or a human-readable
* summary when --raw is set. Exits 0 in all cases (including no-op). * 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) { function cmdMigrateConfig(cwd: string, raw: boolean): void {
const { migrateOnDisk } = require('./configuration.cjs'); const ws = process.env['GSD_WORKSTREAM'] || null;
const ws = process.env.GSD_WORKSTREAM || null; const report = migrateOnDisk(cwd, ws || undefined);
const report = await migrateOnDisk(cwd, ws || undefined);
if (raw) { if (raw) {
if (!report.migrated) { if (!report.migrated) {
@@ -705,8 +757,8 @@ async function cmdMigrateConfig(cwd, raw) {
output(msg, true, msg); output(msg, true, msg);
} else { } else {
const lines = [ const lines = [
`Migrated: ${report.wrote}`, `Migrated: ${String(report.wrote)}`,
...report.normalizations.map(n => ` ${n.from} → ${n.to}`), ...(report.normalizations as Array<{ from: string; to: string }>).map(n => ` ${n.from} → ${n.to}`),
].join('\n'); ].join('\n');
output(lines, true, lines); output(lines, true, lines);
} }
@@ -716,7 +768,7 @@ async function cmdMigrateConfig(cwd, raw) {
} }
} }
module.exports = { export = {
VALID_CONFIG_KEYS, VALID_CONFIG_KEYS,
cmdConfigEnsureSection, cmdConfigEnsureSection,
cmdConfigSet, 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 * Pure function. Callers pass tokensUsed + contextWindow; the
* classifier returns the percent and one of three states. Recommendation * 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). * edge cases (e.g. 59.999% displays as 60 but classifies as healthy).
*/ */
const STATES = Object.freeze({ export type ContextState = 'healthy' | 'warning' | 'critical';
HEALTHY: 'healthy',
WARNING: 'warning', export interface ContextUtilizationResult {
CRITICAL: 'critical', 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,
}); });
function classifyContextUtilization(tokensUsed, contextWindow) { export function classifyContextUtilization(
tokensUsed: number,
contextWindow: number,
): ContextUtilizationResult {
if (!Number.isInteger(tokensUsed) || tokensUsed < 0) { 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) { 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 ratio = Math.min(tokensUsed / contextWindow, 1);
const percent = Math.min(Math.round(ratio * 100), 100); const percent = Math.min(Math.round(ratio * 100), 100);
let state; let state: ContextState;
if (ratio < 0.60) state = STATES.HEALTHY; if (ratio < 0.60) state = STATES.HEALTHY;
else if (ratio < 0.70) state = STATES.WARNING; else if (ratio < 0.70) state = STATES.WARNING;
else state = STATES.CRITICAL; else state = STATES.CRITICAL;
return { percent, state }; 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 * Provides `cmdDocsInit` which returns project signals, existing doc inventory
* with GSD marker detection, doc tooling detection, monorepo awareness, and * with GSD marker detection, doc tooling detection, monorepo awareness, and
* model resolution. Used by Phase 2 to route doc generation appropriately. * 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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const { output, loadConfig, resolveModelInternal, pathExistsInternal, toPosixPath, checkAgentsInstalled } = require('./core.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports
const { platformReadSync } = require('./shell-command-projection.cjs'); import core = require('./core.cjs');
const { output, loadConfig, resolveModelInternal, pathExistsInternal, toPosixPath, checkAgentsInstalled } = core;
import { platformReadSync } from './shell-command-projection.cjs';
// ─── Constants ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
@@ -26,11 +32,8 @@ const SKIP_DIRS = new Set([
/** /**
* Check whether a file begins with the GSD doc writer marker. * Check whether a file begins with the GSD doc writer marker.
* Reads the first 500 bytes only — avoids loading large files. * 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 { try {
const buf = Buffer.alloc(500); const buf = Buffer.alloc(500);
const fd = fs.openSync(filePath, 'r'); 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 * Recursively scan the project root (immediate .md files) and docs/ directory
* (up to 4 levels deep) for Markdown files, excluding dirs in SKIP_DIRS. * (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 MAX_DEPTH = 4;
const results = []; const results: DocEntry[] = [];
/** function walkDir(dir: string, depth: number): void {
* 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) {
if (depth > MAX_DEPTH) return; if (depth > MAX_DEPTH) return;
try { try {
const entries = fs.readdirSync(dir, { withFileTypes: true }); const entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -111,38 +111,49 @@ function scanExistingDocs(cwd) {
return results.sort((a, b) => a.path.localeCompare(b.path)); 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. * Detect project type signals from the filesystem and package.json.
* All checks are best-effort and never throw. * All checks are best-effort and never throw.
*
* @param {string} cwd - Project root
* @returns {Object} Boolean signal fields
*/ */
function detectProjectType(cwd) { function detectProjectType(cwd: string): ProjectTypeSignals {
const exists = (rel) => { const exists = (rel: string): boolean => {
try { return pathExistsInternal(cwd, rel); } catch { return false; } try { return pathExistsInternal(cwd, rel); } catch { return false; }
}; };
// Read package.json once — used by has_cli_bin, is_monorepo, has_tests checks. // Read package.json once — used by has_cli_bin, is_monorepo, has_tests checks.
const pkgRaw = platformReadSync(path.join(cwd, 'package.json')); const pkgRaw = platformReadSync(path.join(cwd, 'package.json'));
let pkg = null; let pkg: Record<string, unknown> | null = null;
if (pkgRaw) { 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 // 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 // is_monorepo: pnpm-workspace.yaml, lerna.json, or package.json workspaces
let is_monorepo = exists('pnpm-workspace.yaml') || exists('lerna.json'); let is_monorepo = exists('pnpm-workspace.yaml') || exists('lerna.json');
if (!is_monorepo && pkg) { 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 // has_tests: common test directories or test frameworks in devDependencies
let has_tests = exists('test') || exists('tests') || exists('__tests__') || exists('spec'); let has_tests = exists('test') || exists('tests') || exists('__tests__') || exists('spec');
if (!has_tests && pkg) { 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)); 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. * Detect known documentation tooling in the project.
*
* @param {string} cwd - Project root
* @returns {Object} Boolean detection fields
*/ */
function detectDocTooling(cwd) { function detectDocTooling(cwd: string): DocToolingSignals {
const exists = (rel) => { const exists = (rel: string): boolean => {
try { return pathExistsInternal(cwd, rel); } catch { return false; } 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 * Extract monorepo workspace globs from pnpm-workspace.yaml, package.json
* workspaces, or lerna.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 // pnpm-workspace.yaml
const pnpmRaw = platformReadSync(path.join(cwd, 'pnpm-workspace.yaml')); const pnpmRaw = platformReadSync(path.join(cwd, 'pnpm-workspace.yaml'));
if (pnpmRaw) { if (pnpmRaw) {
const workspaces = []; const workspaces: string[] = [];
for (const line of pnpmRaw.split('\n')) { for (const line of pnpmRaw.split('\n')) {
const m = line.match(/^\s*-\s+['"]?(.+?)['"]?\s*$/); const m = line.match(/^\s*-\s+['"]?(.+?)['"]?\s*$/);
if (m) workspaces.push(m[1].trim()); if (m) workspaces.push(m[1].trim());
@@ -214,9 +226,9 @@ function detectMonorepoWorkspaces(cwd) {
const pkgRaw = platformReadSync(path.join(cwd, 'package.json')); const pkgRaw = platformReadSync(path.join(cwd, 'package.json'));
if (pkgRaw) { if (pkgRaw) {
try { try {
const pkg = JSON.parse(pkgRaw); const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;
if (Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0) { if (Array.isArray(pkg['workspaces']) && (pkg['workspaces'] as unknown[]).length > 0) {
return pkg.workspaces; return pkg['workspaces'] as string[];
} }
} catch { /* invalid JSON */ } } catch { /* invalid JSON */ }
} }
@@ -225,9 +237,9 @@ function detectMonorepoWorkspaces(cwd) {
const lernaRaw = platformReadSync(path.join(cwd, 'lerna.json')); const lernaRaw = platformReadSync(path.join(cwd, 'lerna.json'));
if (lernaRaw) { if (lernaRaw) {
try { try {
const lerna = JSON.parse(lernaRaw); const lerna = JSON.parse(lernaRaw) as Record<string, unknown>;
if (Array.isArray(lerna.packages) && lerna.packages.length > 0) { if (Array.isArray(lerna['packages']) && (lerna['packages'] as unknown[]).length > 0) {
return lerna.packages; return lerna['packages'] as string[];
} }
} catch { /* invalid JSON */ } } catch { /* invalid JSON */ }
} }
@@ -244,13 +256,10 @@ function detectMonorepoWorkspaces(cwd) {
* *
* @example * @example
* node gsd-tools.cjs docs-init --raw * 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 config = loadConfig(cwd);
const result = { const result: Record<string, unknown> = {
doc_writer_model: resolveModelInternal(cwd, 'gsd-doc-writer'), doc_writer_model: resolveModelInternal(cwd, 'gsd-doc-writer'),
commit_docs: config.commit_docs, commit_docs: config.commit_docs,
existing_docs: scanExistingDocs(cwd), existing_docs: scanExistingDocs(cwd),
@@ -260,11 +269,11 @@ function cmdDocsInit(cwd, raw) {
planning_exists: pathExistsInternal(cwd, '.planning'), planning_exists: pathExistsInternal(cwd, '.planning'),
}; };
// Inject project_root and agent installation status (mirrors withProjectRoot in init.cjs) // Inject project_root and agent installation status (mirrors withProjectRoot in init.cjs)
result.project_root = cwd; result['project_root'] = cwd;
const agentStatus = checkAgentsInstalled(); const agentStatus = checkAgentsInstalled();
result.agents_installed = agentStatus.agents_installed; result['agents_installed'] = agentStatus.agents_installed;
result.missing_agents = agentStatus.missing_agents; result['missing_agents'] = agentStatus.missing_agents;
output(result, raw); 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 * - The detector NEVER throws on malformed input — it returns a
* `{ skipped: true }` result. The phase workflow depends on this * `{ skipped: true }` result. The phase workflow depends on this
* non-blocking guarantee. * 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'; 'use strict';
const fs = require('node:fs'); import fs from 'node:fs';
const { platformWriteSync } = require('./shell-command-projection.cjs'); import { platformWriteSync } from './shell-command-projection.cjs';
import { formatGsdSlash } from './runtime-slash.cjs';
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
@@ -39,7 +44,7 @@ const DRIFT_CATEGORIES = Object.freeze(['new_dir', 'barrel', 'migration', 'route
// Category priority when a single file matches multiple rules. // Category priority when a single file matches multiple rules.
// Higher index = more specific = wins. // 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)$/; 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 ────────────────────────────────────────────────────────── // ─── Classification ──────────────────────────────────────────────────────────
type DriftCategory = 'barrel' | 'migration' | 'route' | 'new_dir';
/** /**
* Classify a single file path into a drift category or null. * 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; if (typeof file !== 'string' || !file) return null;
const norm = file.replace(/\\/g, '/'); const norm = file.replace(/\\/g, '/');
if (MIGRATION_RES.some((r) => r.test(norm))) return 'migration'; 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 * markdown, not a structured manifest. If the map mentions `src/lib/` the
* check `structureMd.includes('src/lib')` holds. * check `structureMd.includes('src/lib')` holds.
*/ */
function isPathMapped(file, structureMd) { function isPathMapped(file: string, structureMd: string): boolean {
const norm = file.replace(/\\/g, '/'); const norm = file.replace(/\\/g, '/');
const parts = norm.split('/'); const parts = norm.split('/');
// Check prefixes from longest to shortest; any hit means "mapped". // Check prefixes from longest to shortest; any hit means "mapped".
@@ -104,38 +108,72 @@ function isPathMapped(file, structureMd) {
return false; 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 ────────────────────────────────────────────────────────── // ─── Main detection ──────────────────────────────────────────────────────────
/** /**
* Detect codebase drift. * 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 { try {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return skipped('invalid-input'); return skipped('invalid-input');
} }
const inp = input as DetectDriftInput;
const { const {
addedFiles, addedFiles,
modifiedFiles, modifiedFiles,
deletedFiles, deletedFiles,
structureMd, structureMd,
} = input; } = inp;
const threshold = Number.isInteger(input.threshold) && input.threshold >= 1 const threshold = Number.isInteger(inp.threshold) && (inp.threshold as number) >= 1
? input.threshold ? (inp.threshold as number)
: 3; : 3;
const action = input.action === 'auto-remap' ? 'auto-remap' : 'warn'; const action = inp.action === 'auto-remap' ? 'auto-remap' : 'warn';
if (structureMd === null || structureMd === undefined) { if (structureMd === null || structureMd === undefined) {
return skipped('missing-structure-md'); return skipped('missing-structure-md');
@@ -144,19 +182,18 @@ function detectDrift(input) {
return skipped('invalid-structure-md'); 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 modified = Array.isArray(modifiedFiles) ? modifiedFiles : [];
const deleted = Array.isArray(deletedFiles) ? deletedFiles : []; const deleted = Array.isArray(deletedFiles) ? deletedFiles : [];
// Build elements. One element per file, highest-priority category wins. // Build elements. One element per file, highest-priority category wins.
/** @type {{category: string, path: string}[]} */ const elements: DriftElement[] = [];
const elements = []; const seen = new Map<string, string>();
const seen = new Map();
for (const rawFile of added) { for (const rawFile of added) {
const file = rawFile.replace(/\\/g, '/'); const file = rawFile.replace(/\\/g, '/');
const specific = classifyFile(file); const specific = classifyFile(file);
let category = specific; let category: string | null = specific;
if (!category) { if (!category) {
if (!isPathMapped(file, structureMd)) { if (!isPathMapped(file, structureMd)) {
category = 'new_dir'; category = 'new_dir';
@@ -184,7 +221,7 @@ function detectDrift(input) {
const actionRequired = elements.length >= threshold; const actionRequired = elements.length >= threshold;
let directive = 'none'; let directive = 'none';
let spawnMapper = false; let spawnMapper = false;
let affectedPaths = []; let affectedPaths: string[] = [];
let message = ''; let message = '';
if (actionRequired) { if (actionRequired) {
@@ -193,7 +230,7 @@ function detectDrift(input) {
if (action === 'auto-remap') { if (action === 'auto-remap') {
spawnMapper = true; spawnMapper = true;
} }
message = buildMessage(elements, affectedPaths, action, input.runtime); message = buildMessage(elements, affectedPaths, action, inp.runtime);
} }
return { return {
@@ -214,11 +251,12 @@ function detectDrift(input) {
}; };
} catch (err) { } catch (err) {
// Non-blocking: never throw from this function. // 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 { return {
skipped: true, skipped: true,
reason, reason,
@@ -231,16 +269,17 @@ function skipped(reason) {
}; };
} }
function buildMessage(elements, affectedPaths, action, runtime) { function buildMessage(elements: DriftElement[], affectedPaths: string[], action: string, runtime: string | undefined): string {
const byCat = {}; const byCat: Record<string, string[]> = {};
for (const e of elements) { 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.`, `Codebase drift detected: ${elements.length} structural element(s) since last mapping.`,
'', '',
]; ];
const labels = { const labels: Record<string, string> = {
new_dir: 'New directories', new_dir: 'New directories',
barrel: 'New barrel exports', barrel: 'New barrel exports',
migration: 'New migrations', migration: 'New migrations',
@@ -256,14 +295,13 @@ function buildMessage(elements, affectedPaths, action, runtime) {
if (action === 'auto-remap') { if (action === 'auto-remap') {
lines.push(`Auto-remap scheduled for paths: ${affectedPaths.join(', ')}`); lines.push(`Auto-remap scheduled for paths: ${affectedPaths.join(', ')}`);
} else { } 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 // caller (verify.cmdVerifyCodebaseDrift) resolves the runtime once and
// passes it in via input.runtime so emitted commands match the project // passes it in via input.runtime so emitted commands match the project
// the caller is targeting, not the current process directory. // the caller is targeting, not the current process directory.
const { formatGsdSlash } = require('./runtime-slash.cjs');
const mapCmd = formatGsdSlash('map-codebase', runtime || 'claude'); const mapCmd = formatGsdSlash('map-codebase', runtime || 'claude');
lines.push( 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'); 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 * the top-level directory prefixes (depth 2 when the repo uses an
* `<apps|packages>/<name>/…` layout; depth 1 otherwise). * `<apps|packages>/<name>/…` layout; depth 1 otherwise).
*/ */
function chooseAffectedPaths(paths) { function chooseAffectedPaths(paths: string[]): string[] {
const out = new Set(); const out = new Set<string>();
for (const raw of paths || []) { for (const raw of paths || []) {
if (typeof raw !== 'string' || !raw) continue; if (typeof raw !== 'string' || !raw) continue;
const file = raw.replace(/\\/g, '/'); const file = raw.replace(/\\/g, '/');
@@ -298,9 +336,9 @@ function chooseAffectedPaths(paths) {
* Any path that is absolute, contains traversal, or includes shell * Any path that is absolute, contains traversal, or includes shell
* metacharacters is dropped. * metacharacters is dropped.
*/ */
function sanitizePaths(paths) { function sanitizePaths(paths: unknown): string[] {
if (!Array.isArray(paths)) return []; if (!Array.isArray(paths)) return [];
const out = []; const out: string[] = [];
for (const p of paths) { for (const p of paths) {
if (typeof p !== 'string') continue; if (typeof p !== 'string') continue;
if (p.startsWith('/')) continue; if (p.startsWith('/')) continue;
@@ -314,11 +352,16 @@ function sanitizePaths(paths) {
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; 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: '' }; if (typeof content !== 'string') return { data: {}, body: '' };
const m = content.match(FRONTMATTER_RE); const m = content.match(FRONTMATTER_RE);
if (!m) return { data: {}, body: content }; if (!m) return { data: {}, body: content };
const data = {}; const data: Record<string, string> = {};
for (const line of m[1].split(/\r?\n/)) { for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z0-9_][A-Za-z0-9_-]*):\s*(.*)$/); const kv = line.match(/^([A-Za-z0-9_][A-Za-z0-9_-]*):\s*(.*)$/);
if (!kv) continue; if (!kv) continue;
@@ -327,7 +370,7 @@ function parseFrontmatter(content) {
return { data, body: content.slice(m[0].length) }; 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); const keys = Object.keys(data);
if (keys.length === 0) return body; if (keys.length === 0) return body;
const lines = ['---']; const lines = ['---'];
@@ -340,15 +383,15 @@ function serializeFrontmatter(data, body) {
* Read `last_mapped_commit` from the frontmatter of a `.planning/codebase/*.md` * 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. * file. Returns null if the file does not exist or has no frontmatter.
*/ */
function readMappedCommit(filePath) { function readMappedCommit(filePath: string): string | null {
let content; let content: string;
try { try {
content = fs.readFileSync(filePath, 'utf8'); content = fs.readFileSync(filePath, 'utf8');
} catch { } catch {
return null; return null;
} }
const { data } = parseFrontmatter(content); 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; 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 * Upsert `last_mapped_commit` and `last_mapped_at` into the frontmatter of
* the given file, preserving any other frontmatter keys and the body. * 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): // Symmetric with readMappedCommit (which returns null on missing files):
// tolerate a missing target by creating a minimal frontmatter-only file // tolerate a missing target by creating a minimal frontmatter-only file
// rather than throwing ENOENT. This matters when a mapper produces a new // rather than throwing ENOENT. This matters when a mapper produces a new
@@ -365,17 +408,17 @@ function writeMappedCommit(filePath, commitSha, isoDate) {
try { try {
content = fs.readFileSync(filePath, 'utf8'); content = fs.readFileSync(filePath, 'utf8');
} catch (err) { } catch (err) {
if (err.code !== 'ENOENT') throw err; if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
} }
const { data, body } = parseFrontmatter(content); const { data, body } = parseFrontmatter(content);
data.last_mapped_commit = commitSha; data['last_mapped_commit'] = commitSha;
if (isoDate) data.last_mapped_at = isoDate; if (isoDate) data['last_mapped_at'] = isoDate;
platformWriteSync(filePath, serializeFrontmatter(data, body)); platformWriteSync(filePath, serializeFrontmatter(data, body));
} }
// ─── Exports ───────────────────────────────────────────────────────────────── // ─── Exports ─────────────────────────────────────────────────────────────────
module.exports = { export = {
DRIFT_CATEGORIES, DRIFT_CATEGORIES,
classifyFile, classifyFile,
detectDrift, 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 * 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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const { output, error } = require('./core.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports
const { platformReadSync: safeReadFile, platformWriteSync } = require('./shell-command-projection.cjs'); 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 ─────────────────────────────────────────────────────────── // ─── Parsing engine ───────────────────────────────────────────────────────────
@@ -13,10 +24,10 @@ const { platformReadSync: safeReadFile, platformWriteSync } = require('./shell-c
* Split a YAML inline array body on commas, respecting quoted strings. * Split a YAML inline array body on commas, respecting quoted strings.
* e.g. '"a, b", c' → ['a, b', 'c'] * e.g. '"a, b", c' → ['a, b', 'c']
*/ */
function splitInlineArray(body) { function splitInlineArray(body: string): string[] {
const items = []; const items: string[] = [];
let current = ''; let current = '';
let inQuote = null; // null | '"' | "'" let inQuote: string | null = null;
for (let i = 0; i < body.length; i++) { for (let i = 0; i < body.length; i++) {
const ch = body[i]; const ch = body[i];
@@ -41,8 +52,8 @@ function splitInlineArray(body) {
return items; return items;
} }
function extractFrontmatter(content) { function extractFrontmatter(content: string): Frontmatter {
const frontmatter = {}; const frontmatter: Frontmatter = {};
// Match frontmatter only at byte 0 — a `---` block later in the document // Match frontmatter only at byte 0 — a `---` block later in the document
// body (YAML examples, horizontal rules) must never be treated as frontmatter. // body (YAML examples, horizontal rules) must never be treated as frontmatter.
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
@@ -52,8 +63,8 @@ function extractFrontmatter(content) {
const lines = yaml.split(/\r?\n/); const lines = yaml.split(/\r?\n/);
// Stack to track nested objects: [{obj, key, indent}] // Stack to track nested objects: [{obj, key, indent}]
// obj = object to write to, key = current key collecting array items, indent = indentation level type StackEntry = { obj: Record<string, unknown> | unknown[]; key: string | null; indent: number };
const stack = [{ obj: frontmatter, key: null, indent: -1 }]; const stack: StackEntry[] = [{ obj: frontmatter, key: null, indent: -1 }];
for (const line of lines) { for (const line of lines) {
// Skip empty lines // Skip empty lines
@@ -78,18 +89,18 @@ function extractFrontmatter(content) {
if (value === '' || value === '[') { if (value === '' || value === '[') {
// Key with no value or opening bracket — could be nested object or array // Key with no value or opening bracket — could be nested object or array
// We'll determine based on next lines, for now create placeholder const newObj: Record<string, unknown> | unknown[] = value === '[' ? [] : {};
current.obj[key] = value === '[' ? [] : {}; (current.obj as Record<string, unknown>)[key] = newObj;
current.key = null; current.key = null;
// Push new context for potential nested content // 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(']')) { } else if (value.startsWith('[') && value.endsWith(']')) {
// Inline array: key: [a, b, c] — quote-aware split (REG-04 fix) // 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; current.key = null;
} else { } else {
// Simple key: value // Simple key: value
current.obj[key] = value.replace(/^["']|["']$/g, ''); (current.obj as Record<string, unknown>)[key] = value.replace(/^["']|["']$/g, '');
current.key = null; current.key = null;
} }
} else if (line.trim().startsWith('- ')) { } else if (line.trim().startsWith('- ')) {
@@ -102,9 +113,9 @@ function extractFrontmatter(content) {
const parent = stack.length > 1 ? stack[stack.length - 2] : null; const parent = stack.length > 1 ? stack[stack.length - 2] : null;
if (parent) { if (parent) {
for (const k of Object.keys(parent.obj)) { for (const k of Object.keys(parent.obj)) {
if (parent.obj[k] === current.obj) { if ((parent.obj as Record<string, unknown>)[k] === current.obj) {
parent.obj[k] = [itemValue]; (parent.obj as Record<string, unknown>)[k] = [itemValue];
current.obj = parent.obj[k]; current.obj = (parent.obj as Record<string, unknown>)[k] as unknown[];
break; break;
} }
} }
@@ -118,15 +129,15 @@ function extractFrontmatter(content) {
return frontmatter; return frontmatter;
} }
function reconstructFrontmatter(obj) { function reconstructFrontmatter(obj: Frontmatter): string {
const lines = []; const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) continue; if (value === null || value === undefined) continue;
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (value.length === 0) { if (value.length === 0) {
lines.push(`${key}: []`); lines.push(`${key}: []`);
} else if (value.every(v => typeof v === 'string') && value.length <= 3 && value.join(', ').length < 60) { } else if (value.every(v => typeof v === 'string') && value.length <= 3 && (value).join(', ').length < 60) {
lines.push(`${key}: [${value.join(', ')}]`); lines.push(`${key}: [${(value).join(', ')}]`);
} else { } else {
lines.push(`${key}:`); lines.push(`${key}:`);
for (const item of value) { for (const item of value) {
@@ -140,8 +151,8 @@ function reconstructFrontmatter(obj) {
if (Array.isArray(subval)) { if (Array.isArray(subval)) {
if (subval.length === 0) { if (subval.length === 0) {
lines.push(` ${subkey}: []`); lines.push(` ${subkey}: []`);
} else if (subval.every(v => typeof v === 'string') && subval.length <= 3 && subval.join(', ').length < 60) { } else if (subval.every((v: unknown) => typeof v === 'string') && subval.length <= 3 && (subval).join(', ').length < 60) {
lines.push(` ${subkey}: [${subval.join(', ')}]`); lines.push(` ${subkey}: [${(subval).join(', ')}]`);
} else { } else {
lines.push(` ${subkey}:`); lines.push(` ${subkey}:`);
for (const item of subval) { for (const item of subval) {
@@ -150,7 +161,7 @@ function reconstructFrontmatter(obj) {
} }
} else if (typeof subval === 'object') { } else if (typeof subval === 'object') {
lines.push(` ${subkey}:`); 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 (subsubval === null || subsubval === undefined) continue;
if (Array.isArray(subsubval)) { if (Array.isArray(subsubval)) {
if (subsubval.length === 0) { if (subsubval.length === 0) {
@@ -162,10 +173,12 @@ function reconstructFrontmatter(obj) {
} }
} }
} else { } else {
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
lines.push(` ${subsubkey}: ${subsubval}`); lines.push(` ${subsubkey}: ${subsubval}`);
} }
} }
} else { } else {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const sv = String(subval); const sv = String(subval);
lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`); lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`);
} }
@@ -182,7 +195,7 @@ function reconstructFrontmatter(obj) {
return lines.join('\n'); return lines.join('\n');
} }
function spliceFrontmatter(content, newObj) { function spliceFrontmatter(content: string, newObj: Frontmatter): string {
const yamlStr = reconstructFrontmatter(newObj); const yamlStr = reconstructFrontmatter(newObj);
const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/); const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
if (match) { if (match) {
@@ -191,7 +204,7 @@ function spliceFrontmatter(content, newObj) {
return `---\n${yamlStr}\n---\n\n` + content; 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 // Extract a specific block from must_haves in raw frontmatter YAML
// Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}] // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); 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 // List items are indented one level deeper than blockIndent
// Continuation KVs are indented one level deeper than list items // Continuation KVs are indented one level deeper than list items
const items = []; const items: unknown[] = [];
let current = null; let current: string | Record<string, unknown> | null = null;
let listItemIndent = -1; // detected from first "- " line let listItemIndent = -1; // detected from first "- " line
for (const line of blockLines) { for (const line of blockLines) {
// Skip empty lines // Skip empty lines
if (line.trim() === '') continue; 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 // Stop at same or lower indent level than the block header
if (indent <= blockIndent && line.trim() !== '') break; if (indent <= blockIndent && line.trim() !== '') break;
@@ -259,7 +273,7 @@ function parseMustHavesBlock(content, blockName) {
const kvMatch = afterDash.match(/^(\w+):\s+"?([^"]*)"?\s*$/); const kvMatch = afterDash.match(/^(\w+):\s+"?([^"]*)"?\s*$/);
if (kvMatch) { if (kvMatch) {
current = {}; current = {};
current[kvMatch[1]] = kvMatch[2]; (current)[kvMatch[1]] = kvMatch[2];
} else { } else {
// Looks like KV but doesn't match — treat as plain string (#2757) // Looks like KV but doesn't match — treat as plain string (#2757)
current = afterDash.replace(/^["']|["']$/g, ''); current = afterDash.replace(/^["']|["']$/g, '');
@@ -276,16 +290,17 @@ function parseMustHavesBlock(content, blockName) {
const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, ''); const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, '');
const keys = Object.keys(current); const keys = Object.keys(current);
const lastKey = keys[keys.length - 1]; const lastKey = keys[keys.length - 1];
if (lastKey && !Array.isArray(current[lastKey])) { if (lastKey && !Array.isArray((current)[lastKey])) {
current[lastKey] = current[lastKey] ? [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 { } else {
const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/); const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/);
if (kvMatch) { if (kvMatch) {
const val = kvMatch[2]; const val = kvMatch[2];
// Try to parse as number // 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 ──────────────────────────────────────────────── // ─── 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'] }, plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] },
summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] }, summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] },
verification: { required: ['phase', 'verified', 'status', 'score'] }, 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'); } if (!filePath) { error('file path required'); }
// Path traversal guard: reject null bytes // Path traversal guard: reject null bytes
if (filePath.includes('\0')) { error('file path contains null bytes'); } if (filePath.includes('\0')) { error('file path contains null bytes'); }
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
const content = safeReadFile(fullPath); 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 fm = extractFrontmatter(content);
if (field) { if (field) {
const value = fm[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)); output({ [field]: value }, raw, JSON.stringify(value));
} else { } 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'); } if (!filePath || !field || value === undefined) { error('file, field, and value required'); }
// Path traversal guard: reject null bytes // Path traversal guard: reject null bytes
if (filePath.includes('\0')) { error('file path contains null bytes'); } if (filePath.includes('\0')) { error('file path contains null bytes'); }
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); 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 content = fs.readFileSync(fullPath, 'utf-8');
const fm = extractFrontmatter(content); const fm = extractFrontmatter(content);
let parsedValue; let parsedValue: unknown;
try { parsedValue = JSON.parse(value); } catch { parsedValue = value; } try { parsedValue = JSON.parse(value as string); } catch { parsedValue = value; }
fm[field] = parsedValue; fm[field as string] = parsedValue as FrontmatterValue;
const newContent = spliceFrontmatter(content, fm); const newContent = spliceFrontmatter(content, fm);
platformWriteSync(fullPath, newContent); platformWriteSync(fullPath, newContent);
output({ updated: true, field, value: parsedValue }, raw, 'true'); 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'); } if (!filePath || !data) { error('file and data required'); }
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); 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 content = fs.readFileSync(fullPath, 'utf-8');
const fm = extractFrontmatter(content); const fm = extractFrontmatter(content);
let mergeData; let mergeData: Record<string, FrontmatterValue>;
try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; } try { mergeData = JSON.parse(data as string) as Record<string, FrontmatterValue>; } catch { error('Invalid JSON for --data'); return; }
Object.assign(fm, mergeData); Object.assign(fm, mergeData);
const newContent = spliceFrontmatter(content, fm); const newContent = spliceFrontmatter(content, fm);
platformWriteSync(fullPath, newContent); platformWriteSync(fullPath, newContent);
output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true'); 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'); } 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(', ')}`); } if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); }
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
const content = safeReadFile(fullPath); 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 fm = extractFrontmatter(content);
const missing = schema.required.filter(f => fm[f] === undefined); const missing = schema.required.filter(f => fm[f] === undefined);
const present = 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'); output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
} }
module.exports = { export = {
extractFrontmatter, extractFrontmatter,
reconstructFrontmatter, reconstructFrontmatter,
spliceFrontmatter, spliceFrontmatter,

View File

@@ -1,5 +1,3 @@
'use strict';
/** /**
* Post-planning gap analysis (#2493). * Post-planning gap analysis (#2493).
* *
@@ -12,13 +10,60 @@
* *
* Coverage detection uses word-boundary regex matching to avoid false positives * Coverage detection uses word-boundary regex matching to avoid false positives
* (REQ-1 must not match REQ-10). * (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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const { escapeRegex, output, error } = require('./core.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports
const { planningPaths, planningDir, findContextMdIn } = require('./planning-workspace.cjs'); import core = require('./core.cjs');
const { parseDecisions } = require('./decisions.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. * Parse REQ-IDs from REQUIREMENTS.md content.
@@ -26,10 +71,10 @@ const { parseDecisions } = require('./decisions.cjs');
* Supports both checkbox (`- [ ] **REQ-NN** ...`) and traceability table * Supports both checkbox (`- [ ] **REQ-NN** ...`) and traceability table
* (`| REQ-NN | ... |`) formats. * (`| REQ-NN | ... |`) formats.
*/ */
function parseRequirements(reqMd) { function parseRequirements(reqMd: unknown): ReqItem[] {
if (!reqMd || typeof reqMd !== 'string') return []; if (!reqMd || typeof reqMd !== 'string') return [];
const out = []; const out: ReqItem[] = [];
const seen = new Set(); const seen = new Set<string>();
// Prefix-agnostic ID format: REQ-01, TST-01, BACK-07, INSP-04, etc. // 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_-]+'; const ID_PATTERN = '[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+';
@@ -69,7 +114,7 @@ function parseRequirements(reqMd) {
return out; return out;
} }
function detectCoverage(items, planText) { function detectCoverage(items: Item[], planText: string): CoverageRow[] {
return items.map(it => { return items.map(it => {
const re = new RegExp('\\b' + escapeRegex(it.id) + '\\b'); const re = new RegExp('\\b' + escapeRegex(it.id) + '\\b');
return { return {
@@ -80,12 +125,12 @@ function detectCoverage(items, planText) {
}); });
} }
function naturalKey(s) { function naturalKey(s: unknown): string {
return String(s).replace(/(\d+)/g, (_, n) => n.padStart(8, '0')); return String(s).replace(/(\d+)/g, (_, n: string) => n.padStart(8, '0'));
} }
function sortRows(rows) { function sortRows(rows: CoverageRow[]): CoverageRow[] {
const sourceOrder = { 'REQUIREMENTS.md': 0, 'CONTEXT.md': 1 }; const sourceOrder: Record<string, number> = { 'REQUIREMENTS.md': 0, 'CONTEXT.md': 1 };
return rows.slice().sort((a, b) => { return rows.slice().sort((a, b) => {
const so = (sourceOrder[a.source] ?? 99) - (sourceOrder[b.source] ?? 99); const so = (sourceOrder[a.source] ?? 99) - (sourceOrder[b.source] ?? 99);
if (so !== 0) return so; if (so !== 0) return so;
@@ -93,26 +138,30 @@ function sortRows(rows) {
}); });
} }
function formatGapTable(rows) { function formatGapTable(rows: CoverageRow[]): string {
if (rows.length === 0) { if (rows.length === 0) {
return '## Post-Planning Gap Analysis\n\nNo requirements or decisions to check.\n'; return '## Post-Planning Gap Analysis\n\nNo requirements or decisions to check.\n';
} }
const header = '| Source | Item | Status |\n|--------|------|--------|'; const header = '| Source | Item | Status |\n|--------|------|--------|';
const body = rows.map(r => { const body = rows.map(r => {
const tick = r.status === 'Covered' ? '\u2713 Covered' const tick = r.status === 'Covered' ? '✓ Covered'
: r.status === 'Missing from REQUIREMENTS.md' ? '\u26a0 Missing from REQUIREMENTS.md' : r.status === 'Missing from REQUIREMENTS.md' ? '⚠ Missing from REQUIREMENTS.md'
: '\u2717 Not covered'; : '✗ Not covered';
return `| ${r.source} | ${r.item} | ${tick} |`; return `| ${r.source} | ${r.item} | ${tick} |`;
}).join('\n'); }).join('\n');
return `## Post-Planning Gap Analysis\n\n${header}\n${body}\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'); const cfgPath = path.join(planningDir(cwd), 'config.json');
try { try {
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) as unknown;
if (raw && raw.workflow && typeof raw.workflow.post_planning_gaps === 'boolean') { if (raw && typeof raw === 'object' && 'workflow' in raw) {
return raw.workflow.post_planning_gaps; 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 */ } } catch { /* fall through */ }
return true; return true;
@@ -129,9 +178,10 @@ function readGate(cwd) {
* Tolerates JSON-array-ish input (`["REQ-01","REQ-02"]`) since callers may pass * Tolerates JSON-array-ish input (`["REQ-01","REQ-02"]`) since callers may pass
* the roadmap value through verbatim. * the roadmap value through verbatim.
*/ */
function normalizePhaseReqIds(rawVal) { function normalizePhaseReqIds(rawVal: unknown): string[] | null | undefined {
if (rawVal === undefined) return undefined; if (rawVal === undefined) return undefined;
if (rawVal === null) return null; if (rawVal === null) return null;
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const v = String(rawVal).replace(/["'[\]()]/g, '').trim(); const v = String(rawVal).replace(/["'[\]()]/g, '').trim();
if (v === '' || /^(null|tbd|none)$/i.test(v)) return null; if (v === '' || /^(null|tbd|none)$/i.test(v)) return null;
// Tolerate comma-, space-, or newline-separated lists (callers may pass the // Tolerate comma-, space-, or newline-separated lists (callers may pass the
@@ -140,7 +190,7 @@ function normalizePhaseReqIds(rawVal) {
return ids.length === 0 ? null : ids; 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); const phaseReqIds = normalizePhaseReqIds(options.phaseReqIds);
if (!readGate(cwd)) { if (!readGate(cwd)) {
return { return {
@@ -156,13 +206,13 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
const reqPath = planningPaths(cwd).requirements; const reqPath = planningPaths(cwd).requirements;
const reqMd = fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf-8') : ''; 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). // 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 // 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. // every unrelated project REQ-ID as a gap — mirror §13's skip behavior.
// CONTEXT.md decisions (below) are always in scope regardless. // CONTEXT.md decisions (below) are always in scope regardless.
let ghostReqIds = []; let ghostReqIds: string[] = [];
if (phaseReqIds === null) { if (phaseReqIds === null) {
reqItems = []; reqItems = [];
} else if (Array.isArray(phaseReqIds)) { } 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 // Read the phase directory once; reuse the listing for both context detection
// and plan-file enumeration (avoids redundant readdirSync calls). // and plan-file enumeration (avoids redundant readdirSync calls).
let phaseDirFiles = []; let phaseDirFiles: string[] = [];
try { try {
if (fs.existsSync(absPhaseDir)) phaseDirFiles = fs.readdirSync(absPhaseDir); if (fs.existsSync(absPhaseDir)) phaseDirFiles = fs.readdirSync(absPhaseDir);
} catch { /* unreadable */ } } catch { /* unreadable */ }
@@ -182,9 +232,9 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
const ctxFile = findContextMdIn(phaseDirFiles); const ctxFile = findContextMdIn(phaseDirFiles);
const ctxPath = ctxFile ? path.join(absPhaseDir, ctxFile) : null; const ctxPath = ctxFile ? path.join(absPhaseDir, ctxFile) : null;
const ctxMd = ctxPath ? fs.readFileSync(ctxPath, 'utf-8') : ''; 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 = ''; let planText = '';
try { try {
@@ -215,8 +265,8 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
const uncovered = rows.length - covered; const uncovered = rows.length - covered;
const summary = uncovered === 0 const summary = uncovered === 0
? `\u2713 All ${rows.length} items covered by plans` ? `✓ All ${rows.length} items covered by plans`
: `\u26A0 ${uncovered} of ${rows.length} items not covered by any plan`; : `⚠ ${uncovered} of ${rows.length} items not covered by any plan`;
return { return {
enabled: true, 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'); const idx = args.indexOf('--phase-dir');
if (idx === -1 || !args[idx + 1]) { if (idx === -1 || !args[idx + 1]) {
error('Usage: gap-analysis --phase-dir <path-to-phase-directory>'); 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); output(result, raw, result.table || result.summary);
} }
module.exports = { export = {
parseRequirements, parseRequirements,
detectCoverage, detectCoverage,
formatGapTable, 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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const { execTool, execGit, platformWriteSync } = require('./shell-command-projection.cjs'); import { execTool, execGit, platformWriteSync } from './shell-command-projection.cjs';
// ─── Config Gate ───────────────────────────────────────────────────────────── // ─── Config Gate ─────────────────────────────────────────────────────────────
@@ -10,40 +17,41 @@ const { execTool, execGit, platformWriteSync } = require('./shell-command-projec
* Check whether graphify is enabled in the project config. * Check whether graphify is enabled in the project config.
* Reads config.json directly via fs. Returns false by default * Reads config.json directly via fs. Returns false by default
* (when no config, no graphify key, or on error). * (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 { try {
const configPath = path.join(planningDir, 'config.json'); const configPath = path.join(planningDir, 'config.json');
if (!fs.existsSync(configPath)) return false; if (!fs.existsSync(configPath)) return false;
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); const config: unknown = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (config && config.graphify && config.graphify.enabled === true) return true; 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; return false;
} catch (_e) { } catch (_e) {
return false; return false;
} }
} }
interface DisabledResponse {
disabled: true;
message: string;
}
/** /**
* Return the standard disabled response object. * 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' }; return { disabled: true, message: 'graphify is not enabled. Enable with: gsd-tools config-set graphify.enabled true' };
} }
// ─── Subprocess Helper ─────────────────────────────────────────────────────── // ─── 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). * Frozen enum of typed reason codes for execGraphify failures (#2974).
* Tests assert on result.reason instead of grepping stderr text. * Tests assert on result.reason instead of grepping stderr text.
@@ -53,9 +61,22 @@ const GRAPHIFY_REASON = Object.freeze({
ENOENT: 'graphify_not_found', ENOENT: 'graphify_not_found',
TIMEOUT: 'graphify_timed_out', TIMEOUT: 'graphify_timed_out',
EXIT_NONZERO: 'graphify_exit_nonzero', 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 timeout = options.timeout ?? 30000;
const result = execTool('graphify', args, { const result = execTool('graphify', args, {
cwd, cwd,
@@ -64,7 +85,7 @@ function execGraphify(cwd, args, options = {}) {
}); });
// ENOENT — seam normalizes to exitCode 127. Surface as typed reason. // 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 { return {
exitCode: 127, exitCode: 127,
stdout: '', stdout: '',
@@ -94,13 +115,16 @@ function execGraphify(cwd, args, options = {}) {
// ─── Presence & Version ────────────────────────────────────────────────────── // ─── Presence & Version ──────────────────────────────────────────────────────
interface InstalledResult {
installed: boolean;
message?: string;
}
/** /**
* Check whether the graphify CLI binary is installed and accessible on PATH. * Check whether the graphify CLI binary is installed and accessible on PATH.
* Uses --help (NOT --version, which graphify does not support). * 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 }); const result = execTool('graphify', ['--help'], { timeout: 5000 });
if (result.error) { if (result.error) {
@@ -113,6 +137,12 @@ function checkGraphifyInstalled() {
return { installed: true }; return { installed: true };
} }
interface VersionResult {
version: string | null;
compatible: boolean | null;
warning: string | null;
}
/** /**
* Detect graphify version and check compatibility. * Detect graphify version and check compatibility.
* Tested range: >=0.4.0,<1.0 * 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) * 1. Try `graphify --version` (works for most CLI installations, incl. venv installs)
* 2. Fall back to python3 importlib.metadata (legacy / system Python path) * 2. Fall back to python3 importlib.metadata (legacy / system Python path)
* 3. Return null version gracefully if both fail * 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) // Strategy 1: try `graphify --version` directly (2s timeout -- fast path)
const versionResult = execTool('graphify', ['--version'], { timeout: 2000 }); const versionResult = execTool('graphify', ['--version'], { timeout: 2000 });
let versionStr = null; let versionStr: string | null = null;
if (!versionResult.error && versionResult.exitCode === 0) { if (!versionResult.error && versionResult.exitCode === 0) {
// graphify --version may emit "graphify 0.4.23" or just "0.4.23" // graphify --version may emit "graphify 0.4.23" or just "0.4.23"
@@ -168,32 +196,57 @@ function checkGraphifyVersion() {
// ─── Internal Helpers ──────────────────────────────────────────────────────── // ─── 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. * Safely read and parse a JSON file. Returns null on missing file or parse error.
* Prevents crashes on malformed JSON (T-02-01 mitigation). * 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 { try {
if (!fs.existsSync(filePath)) return null; if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, 'utf8')); return JSON.parse(fs.readFileSync(filePath, 'utf8')) as Graph;
} catch (_e) { } catch (_e) {
return null; return null;
} }
} }
interface AdjEntry {
target: string;
edge: GraphEdge;
}
/** /**
* Build a bidirectional adjacency map from graph nodes and edges. * Build a bidirectional adjacency map from graph nodes and edges.
* Each node ID maps to an array of { target, edge } entries. * Each node ID maps to an array of { target, edge } entries.
* Bidirectional: both source->target and target->source are added (Pitfall 3). * 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) { function buildAdjacencyMap(graph: Graph): Record<string, AdjEntry[]> {
const adj = {}; const adj: Record<string, AdjEntry[]> = {};
for (const node of (graph.nodes || [])) { for (const node of (graph.nodes || [])) {
adj[node.id] = []; adj[node.id] = [];
} }
@@ -206,16 +259,18 @@ function buildAdjacencyMap(graph) {
return adj; 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. * 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). * 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 lowerTerm = term.toLowerCase();
const nodeMap = Object.fromEntries((graph.nodes || []).map(n => [n.id, n])); const nodeMap = Object.fromEntries((graph.nodes || []).map(n => [n.id, n]));
const adj = buildAdjacencyMap(graph); const adj = buildAdjacencyMap(graph);
@@ -228,12 +283,12 @@ function seedAndExpand(graph, term, maxHops = 2) {
// BFS expand from seeds // BFS expand from seeds
const visitedNodes = new Set(seeds.map(n => n.id)); const visitedNodes = new Set(seeds.map(n => n.id));
const collectedEdges = []; const collectedEdges: GraphEdge[] = [];
const seenEdgeKeys = new Set(); const seenEdgeKeys = new Set<string>();
let frontier = seeds.map(n => n.id); let frontier = seeds.map(n => n.id);
for (let hop = 0; hop < maxHops && frontier.length > 0; hop++) { for (let hop = 0; hop < maxHops && frontier.length > 0; hop++) {
const nextFrontier = []; const nextFrontier: string[] = [];
for (const nodeId of frontier) { for (const nodeId of frontier) {
for (const entry of (adj[nodeId] || [])) { for (const entry of (adj[nodeId] || [])) {
// Deduplicate edges by source::target::label key // Deduplicate edges by source::target::label key
@@ -251,27 +306,31 @@ function seedAndExpand(graph, term, maxHops = 2) {
frontier = nextFrontier; 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)) }; 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). * Apply token budget by dropping edges by confidence tier (D-04, D-05, D-06).
* Token estimation: Math.ceil(JSON.stringify(obj).length / 4). * Token estimation: Math.ceil(JSON.stringify(obj).length / 4).
* Drop order: AMBIGUOUS -> INFERRED -> EXTRACTED. * 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; if (!budgetTokens) return result;
const CONFIDENCE_ORDER = ['AMBIGUOUS', 'INFERRED', 'EXTRACTED']; const CONFIDENCE_ORDER = ['AMBIGUOUS', 'INFERRED', 'EXTRACTED'];
let edges = [...result.edges]; let edges = [...result.edges];
let omitted = 0; 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) { for (const tier of CONFIDENCE_ORDER) {
if (estimateTokens({ nodes: result.nodes, edges }) <= budgetTokens) break; if (estimateTokens({ nodes: result.nodes, edges }) <= budgetTokens) break;
@@ -282,7 +341,7 @@ function applyBudget(result, budgetTokens) {
} }
// Find unreachable nodes after edge removal // Find unreachable nodes after edge removal
const reachableNodes = new Set(); const reachableNodes = new Set<string>();
for (const edge of edges) { for (const edge of edges) {
reachableNodes.add(edge.source); reachableNodes.add(edge.source);
reachableNodes.add(edge.target); reachableNodes.add(edge.target);
@@ -302,16 +361,40 @@ function applyBudget(result, budgetTokens) {
// ─── Public API ────────────────────────────────────────────────────────────── // ─── 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. * Query the knowledge graph for nodes matching a term, with optional budget cap.
* Uses seed-then-expand BFS traversal (D-01). * 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'); const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse(); if (!isGraphifyEnabled(planningDir)) return disabledResponse();
@@ -325,7 +408,7 @@ function graphifyQuery(cwd, term, options = {}) {
return { error: 'Failed to parse graph.json' }; return { error: 'Failed to parse graph.json' };
} }
let result = seedAndExpand(graph, term); let result: ExpandResult | BudgetResult = seedAndExpand(graph, term);
if (options.budget) { if (options.budget) {
result = applyBudget(result, options.budget); result = applyBudget(result, options.budget);
@@ -337,39 +420,10 @@ function graphifyQuery(cwd, term, options = {}) {
edges: result.edges, edges: result.edges,
total_nodes: result.nodes.length, total_nodes: result.nodes.length,
total_edges: result.edges.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). * 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 * (#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 * graph, no git, or unreachable commit), distinct from false ("known
* fresh"). * fresh").
*
* @param {string} cwd - Working directory
* @returns {object}
*/ */
function graphifyStatus(cwd) { function graphifyStatus(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning'); const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse(); if (!isGraphifyEnabled(planningDir)) return disabledResponse();
@@ -401,11 +452,12 @@ function graphifyStatus(cwd) {
const age = Date.now() - stat.mtimeMs; const age = Date.now() - stat.mtimeMs;
// Commit-staleness signal (#3170). Validate before passing to git. // 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 builtAt = COMMIT_HASH_RE.test(rawBuilt) ? rawBuilt : null;
const head = readGitHead(cwd); const head = readGitHead(cwd);
let commitsBehind = null; let commitsBehind: number | null = null;
let commitStale = null; let commitStale: boolean | null = null;
if (builtAt && head) { if (builtAt && head) {
commitsBehind = countCommitsBetween(cwd, builtAt, head); commitsBehind = countCommitsBetween(cwd, builtAt, head);
if (commitsBehind !== null) commitStale = commitsBehind > 0; 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). * 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'); const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse(); if (!isGraphifyEnabled(planningDir)) return disabledResponse();
@@ -480,7 +529,7 @@ function graphifyDiff(cwd) {
); );
// Diff edges (keyed by source+target+relation) // 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 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])); 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). * Pre-flight checks for graphify build (BUILD-01, BUILD-02, D-09).
* Does NOT invoke graphify -- returns structured JSON for the builder agent. * 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'); const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse(); if (!isGraphifyEnabled(planningDir)) return disabledResponse();
@@ -521,7 +567,8 @@ function graphifyBuild(cwd) {
// Read build timeout from config -- default 300s per D-02 // Read build timeout from config -- default 300s per D-02
const config = safeReadJson(path.join(planningDir, 'config.json')) || {}; 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 { return {
action: 'spawn_agent', 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). * Write a diff snapshot after successful build (D-06).
* Reads graph.json from .planning/graphs/ and writes .last-build-snapshot.json * Reads graph.json from .planning/graphs/ and writes .last-build-snapshot.json
* using platformWriteSync for crash safety. * 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 graphPath = path.join(cwd, '.planning', 'graphs', 'graph.json');
const graph = safeReadJson(graphPath); const graph = safeReadJson(graphPath);
if (!graph) return { error: 'Cannot write snapshot: graph.json not parseable' }; if (!graph) return { error: 'Cannot write snapshot: graph.json not parseable' };
@@ -566,7 +617,7 @@ function writeSnapshot(cwd) {
// ─── Exports ───────────────────────────────────────────────────────────────── // ─── Exports ─────────────────────────────────────────────────────────────────
module.exports = { export = {
// Config gate // Config gate
isGraphifyEnabled, isGraphifyEnabled,
disabledResponse, disabledResponse,

View File

@@ -1,5 +1,3 @@
'use strict';
/** /**
* gsd2-import — Reverse migration from GSD-2 (.gsd/) to GSD v1 (.planning/) * 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 * - Completed slices ([x] in ROADMAP) → [x] phases in ROADMAP.md
* - Tasks with a SUMMARY file → SUMMARY.md written * - Tasks with a SUMMARY file → SUMMARY.md written
* - Slice RESEARCH.md → phase XX-RESEARCH.md * - 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'); import fs from 'node:fs';
const path = require('node:path'); import path from 'node:path';
const { platformWriteSync } = require('./shell-command-projection.cjs'); 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 ────────────────────────────────────────────────────────────── // ─── Utilities ──────────────────────────────────────────────────────────────
function readOptional(filePath) { function readOptional(filePath: string): string | null {
try { return fs.readFileSync(filePath, 'utf8'); } catch { return 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'); return String(n).padStart(width, '0');
} }
function slugify(title) { function slugify(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); 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. * Find the .gsd/ directory starting from a project root.
* Returns the absolute path or null if not found. * 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)) { if (path.basename(startPath) === '.gsd' && fs.existsSync(startPath)) {
return startPath; return startPath;
} }
@@ -57,8 +112,8 @@ function findGsd2Root(startPath) {
* Each slice entry looks like: * Each slice entry looks like:
* - [x] **S01: Title** `risk:medium` `depends:[S00]` * - [x] **S01: Title** `risk:medium` `depends:[S00]`
*/ */
function parseSlicesFromRoadmap(content) { function parseSlicesFromRoadmap(content: string): SliceInfo[] {
const slices = []; const slices: SliceInfo[] = [];
const sectionMatch = content.match(/## Slices\n([\s\S]*?)(?:\n## |\n# |$)/); const sectionMatch = content.match(/## Slices\n([\s\S]*?)(?:\n## |\n# |$)/);
if (!sectionMatch) return slices; 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. * Parse the milestone title from the first heading in a GSD-2 ROADMAP.md.
* Format: # M001: Title * Format: # M001: Title
*/ */
function parseMilestoneTitle(content) { function parseMilestoneTitle(content: string): string | null {
const m = content.match(/^# \w+:\s*(.+)/m); const m = content.match(/^# \w+:\s*(.+)/m);
return m ? m[1].trim() : null; return m ? m[1].trim() : null;
} }
@@ -83,7 +138,7 @@ function parseMilestoneTitle(content) {
* Parse a task title from a GSD-2 T##-PLAN.md. * Parse a task title from a GSD-2 T##-PLAN.md.
* Format: # T01: Title * Format: # T01: Title
*/ */
function parseTaskTitle(content, fallback) { function parseTaskTitle(content: string, fallback: string): string {
const m = content.match(/^# \w+:\s*(.+)/m); const m = content.match(/^# \w+:\s*(.+)/m);
return m ? m[1].trim() : fallback; return m ? m[1].trim() : fallback;
} }
@@ -91,7 +146,7 @@ function parseTaskTitle(content, fallback) {
/** /**
* Parse the ## Description body from a GSD-2 task plan. * 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# |$)/); const m = content.match(/## Description\n+([\s\S]+?)(?:\n## |\n# |$)/);
return m ? m[1].trim() : ''; return m ? m[1].trim() : '';
} }
@@ -99,19 +154,19 @@ function parseTaskDescription(content) {
/** /**
* Parse ## Must-Haves items from a GSD-2 task plan. * 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# |$)/); const m = content.match(/## Must-Haves\n+([\s\S]+?)(?:\n## |\n# |$)/);
if (!m) return []; if (!m) return [];
return m[1].split('\n') return m[1].split('\n')
.map(l => l.match(/^- \[[ x]\]\s*(.+)/)) .map(l => l.match(/^- \[[ x]\]\s*(.+)/))
.filter(Boolean) .filter((match): match is RegExpMatchArray => match !== null)
.map(match => match[1].trim()); .map(match => match[1].trim());
} }
/** /**
* Read all task plan files from a GSD-2 tasks/ directory. * Read all task plan files from a GSD-2 tasks/ directory.
*/ */
function readTasksDir(tasksDir) { function readTasksDir(tasksDir: string): TaskInfo[] {
if (!fs.existsSync(tasksDir)) return []; if (!fs.existsSync(tasksDir)) return [];
return fs.readdirSync(tasksDir) return fs.readdirSync(tasksDir)
@@ -136,8 +191,8 @@ function readTasksDir(tasksDir) {
/** /**
* Parse a complete GSD-2 .gsd/ directory into a structured representation. * Parse a complete GSD-2 .gsd/ directory into a structured representation.
*/ */
function parseGsd2(gsdDir) { function parseGsd2(gsdDir: string): Gsd2Data {
const data = { const data: Gsd2Data = {
projectContent: readOptional(path.join(gsdDir, 'PROJECT.md')), projectContent: readOptional(path.join(gsdDir, 'PROJECT.md')),
requirements: readOptional(path.join(gsdDir, 'REQUIREMENTS.md')), requirements: readOptional(path.join(gsdDir, 'REQUIREMENTS.md')),
milestones: [], milestones: [],
@@ -157,7 +212,7 @@ function parseGsd2(gsdDir) {
const sliceInfos = roadmapContent ? parseSlicesFromRoadmap(roadmapContent) : []; const sliceInfos = roadmapContent ? parseSlicesFromRoadmap(roadmapContent) : [];
const slices = sliceInfos.map(info => { const slices: Slice[] = sliceInfos.map(info => {
const sDir = path.join(slicesDir, info.id); const sDir = path.join(slicesDir, info.id);
const hasSDir = fs.existsSync(sDir); const hasSDir = fs.existsSync(sDir);
return { return {
@@ -188,7 +243,7 @@ function parseGsd2(gsdDir) {
/** /**
* Build a GSD v1 PLAN.md from a GSD-2 task. * 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 = [ const lines = [
'---', '---',
`phase: "${phasePrefix}"`, `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. * Build a GSD v1 SUMMARY.md from a GSD-2 task summary.
* Strips the GSD-2 frontmatter and preserves the body. * 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 || ''; const raw = task.summary || '';
// Strip GSD-2 frontmatter block (--- ... ---) if present // Strip GSD-2 frontmatter block (--- ... ---) if present
const bodyMatch = raw.match(/^---[\s\S]*?---\n+([\s\S]*)$/); 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. * 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 = [ const lines = [
`# Phase ${phasePrefix} Context`, `# Phase ${phasePrefix} Context`,
'', '',
@@ -263,7 +318,7 @@ function buildContextMd(slice, phasePrefix) {
/** /**
* Build the GSD v1 ROADMAP.md with milestone-sectioned format. * Build the GSD v1 ROADMAP.md with milestone-sectioned format.
*/ */
function buildRoadmapMd(milestones, phaseMap) { function buildRoadmapMd(milestones: Milestone[], phaseMap: PhaseMapEntry[]): string {
const lines = ['# Roadmap', '']; const lines = ['# Roadmap', ''];
for (const milestone of milestones) { 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. * 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 currentEntry = phaseMap.find(p => !p.slice.done);
const totalPhases = phaseMap.length; const totalPhases = phaseMap.length;
const donePhases = phaseMap.filter(p => p.slice.done).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. * Convert parsed GSD-2 data into a map of relative path → file content.
* All paths are relative to the .planning/ root. * All paths are relative to the .planning/ root.
*/ */
function buildPlanningArtifacts(gsd2Data) { function buildPlanningArtifacts(gsd2Data: Gsd2Data): Map<string, string> {
const artifacts = new Map(); const artifacts = new Map<string, string>();
// Passthrough files // Passthrough files
artifacts.set('PROJECT.md', gsd2Data.projectContent || '# Project\n\n(Migrated from GSD-2)\n'); 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'); artifacts.set('config.json', JSON.stringify({ version: 1 }, null, 2) + '\n');
// Build sequential phase map: flatten Milestones → Slices into numbered phases // Build sequential phase map: flatten Milestones → Slices into numbered phases
const phaseMap = []; const phaseMap: PhaseMapEntry[] = [];
let phaseNum = 1; let phaseNum = 1;
for (const milestone of gsd2Data.milestones) { for (const milestone of gsd2Data.milestones) {
for (const slice of milestone.slices) { for (const slice of milestone.slices) {
@@ -365,8 +420,8 @@ function buildPlanningArtifacts(gsd2Data) {
artifacts.set('ROADMAP.md', buildRoadmapMd(gsd2Data.milestones, phaseMap)); artifacts.set('ROADMAP.md', buildRoadmapMd(gsd2Data.milestones, phaseMap));
artifacts.set('STATE.md', buildStateMd(phaseMap)); artifacts.set('STATE.md', buildStateMd(phaseMap));
for (const { slice, phaseNum, milestoneTitle } of phaseMap) { for (const { slice, phaseNum: pNum, milestoneTitle } of phaseMap) {
const prefix = zeroPad(phaseNum); const prefix = zeroPad(pNum);
const slug = slugify(slice.title); const slug = slugify(slice.title);
const dir = `phases/${prefix}-${slug}`; const dir = `phases/${prefix}-${slug}`;
@@ -402,7 +457,7 @@ function buildPlanningArtifacts(gsd2Data) {
/** /**
* Format a dry-run preview string for display before writing. * 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/:']; const lines = ['Preview — files that will be created in .planning/:'];
for (const rel of artifacts.keys()) { for (const rel of artifacts.keys()) {
@@ -421,8 +476,7 @@ function buildPreview(gsd2Data, artifacts, projectDir) {
lines.push(''); lines.push('');
lines.push('Cannot migrate automatically:'); lines.push('Cannot migrate automatically:');
lines.push(' - GSD-2 cost/token ledger (no v1 equivalent)'); 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)) as string})`);
lines.push(` - GSD-2 database state (rebuilt from files on first ${formatGsdSlash('health', resolveRuntime(projectDir))})`);
lines.push(' - VS Code extension state'); lines.push(' - VS Code extension state');
return lines.join('\n'); return lines.join('\n');
@@ -433,7 +487,7 @@ function buildPreview(gsd2Data, artifacts, projectDir) {
/** /**
* Write all artifacts to the .planning/ directory. * 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) { for (const [rel, content] of artifacts) {
const absPath = path.join(planningRoot, rel); const absPath = path.join(planningRoot, rel);
platformWriteSync(absPath, content); platformWriteSync(absPath, content);
@@ -446,9 +500,7 @@ function writePlanningDir(artifacts, planningRoot) {
* Entry point called from gsd-tools.cjs. * Entry point called from gsd-tools.cjs.
* Supports: --force, --dry-run, --path <dir> * Supports: --force, --dry-run, --path <dir>
*/ */
function cmdFromGsd2(args, cwd, raw) { function cmdFromGsd2(args: string[], cwd: string, raw: boolean): void {
const { output, error } = require('./core.cjs');
const force = args.includes('--force'); const force = args.includes('--force');
const dryRun = args.includes('--dry-run'); const dryRun = args.includes('--dry-run');
@@ -459,15 +511,17 @@ function cmdFromGsd2(args, cwd, raw) {
const gsdDir = findGsd2Root(projectDir); const gsdDir = findGsd2Root(projectDir);
if (!gsdDir) { 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'); const planningRoot = path.join(path.dirname(gsdDir), '.planning');
if (fs.existsSync(planningRoot) && !force) { if (fs.existsSync(planningRoot) && !force) {
return output({ output({
success: false, success: false,
error: `.planning/ already exists at ${planningRoot}. Pass --force to overwrite.`, error: `.planning/ already exists at ${planningRoot}. Pass --force to overwrite.`,
}, raw); }, raw, undefined);
return;
} }
const gsd2Data = parseGsd2(gsdDir); const gsd2Data = parseGsd2(gsdDir);
@@ -477,21 +531,22 @@ function cmdFromGsd2(args, cwd, raw) {
const preview = buildPreview(gsd2Data, artifacts, projectDir); const preview = buildPreview(gsd2Data, artifacts, projectDir);
if (dryRun) { if (dryRun) {
return output({ success: true, dryRun: true, preview }, raw); output({ success: true, dryRun: true, preview }, raw, undefined);
return;
} }
writePlanningDir(artifacts, planningRoot); writePlanningDir(artifacts, planningRoot);
return output({ output({
success: true, success: true,
planningDir: planningRoot, planningDir: planningRoot,
filesWritten: artifacts.size, filesWritten: artifacts.size,
milestones: gsd2Data.milestones.length, milestones: gsd2Data.milestones.length,
preview, preview,
}, raw); }, raw, undefined);
} }
module.exports = { export = {
findGsd2Root, findGsd2Root,
parseGsd2, parseGsd2,
buildPlanningArtifacts, 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 * Skill Surface Budget Module — single source of truth for which skills/agents
* are written to the runtime config dirs (ADR-0011). * are written to the runtime config dirs (ADR-0011).
* *
* Background: every installed `gsd-*` skill costs eager system-prompt tokens * ADR-457 build-at-publish: the hand-written bin/lib/install-profiles.cjs collapsed
* because runtimes (Claude Code, opencode, etc.) enumerate skill descriptions * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* in `<available_skills>` on every turn. With 66 skills + 33 agents GSD alone * from the prior hand-written .cjs; only types are added.
* 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
*/ */
'use strict'; import fs from 'node:fs';
import path from 'node:path';
const fs = require('fs'); import os from 'node:os';
const path = require('path'); import { platformWriteSync } from './shell-command-projection.cjs';
const os = require('os');
const { platformWriteSync } = require('./shell-command-projection.cjs');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Profile definitions // Profile definitions
@@ -85,8 +54,10 @@ const PROFILES = Object.freeze({
'pause-work', 'pause-work',
'workspace', 'workspace',
]), ]),
full: '*', full: '*' as const,
}); } as const);
type ProfileName = keyof typeof PROFILES;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Manifest parsing // Manifest parsing
@@ -99,11 +70,8 @@ const PROFILES = Object.freeze({
* *
* No external YAML parser dependency — hand-parse the single line * No external YAML parser dependency — hand-parse the single line
* since GSD enforces flow-style arrays for requires:. * 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); const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/m);
if (!fmMatch) return []; if (!fmMatch) return [];
const fm = fmMatch[1]; const fm = fmMatch[1];
@@ -127,11 +95,8 @@ function parseRequires(content) {
* *
* The caller is responsible for filtering by which agents actually exist — * The caller is responsible for filtering by which agents actually exist —
* this function returns all syntactically valid `gsd-*` matches. * 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. // 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. // 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. // 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 * Also derives calls_agents for each skill by scanning the body text for
* `gsd-*` agent name references. Agent stems are stored under the special * `gsd-*` agent name references. Agent stems are stored under the special
* key `_calls_agents_<stem>` so they don't conflict with skill stems. * 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) { function loadSkillsManifest(commandsDir: string): Map<string, string[]> {
const manifest = new Map(); const manifest = new Map<string, string[]>();
if (!fs.existsSync(commandsDir)) return manifest; if (!fs.existsSync(commandsDir)) return manifest;
const entries = fs.readdirSync(commandsDir, { withFileTypes: true }); const entries = fs.readdirSync(commandsDir, { withFileTypes: true });
for (const entry of entries) { 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. * 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 closed = new Set(base);
const queue = [...closed]; const queue = [...closed];
while (queue.length > 0) { while (queue.length > 0) {
const stem = queue.pop(); const stem = queue.pop()!;
const deps = manifest.get(stem) || []; const deps = manifest.get(stem) || [];
for (const dep of deps) { for (const dep of deps) {
if (!closed.has(dep)) { if (!closed.has(dep)) {
@@ -199,17 +157,23 @@ function computeClosure(base, manifest) {
return closed; 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. * 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 } = {}) { function resolveProfile({ modes, manifest, _profilesOverride }: ResolveProfileOpts = {}): ResolvedProfile {
const profiles = _profilesOverride || PROFILES; const profiles: Record<string, string | readonly string[]> = _profilesOverride || PROFILES;
const activeModes = (modes && modes.length > 0) ? modes : ['full']; const activeModes = (modes && modes.length > 0) ? modes : ['full'];
const normalizedModes = activeModes const normalizedModes = activeModes
.flatMap((mode) => String(mode).split(',')) .flatMap((mode) => String(mode).split(','))
@@ -228,8 +192,8 @@ function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
return { name: 'full', skills: '*', agents: new Set() }; return { name: 'full', skills: '*', agents: new Set() };
} }
const man = manifest || new Map(); const man = manifest || new Map<string, string[]>();
const unionSkills = new Set(); const unionSkills = new Set<string>();
for (const mode of validModes) { for (const mode of validModes) {
const base = profiles[mode]; const base = profiles[mode];
@@ -237,14 +201,14 @@ function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
// This profile is full — sentinel short-circuit // This profile is full — sentinel short-circuit
return { name: 'full', skills: '*', agents: new Set() }; 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); for (const s of closure) unionSkills.add(s);
} }
// Derive agents: union of all agent names referenced in the body text of // 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 // every skill in unionSkills. Agent names are stored in the manifest under
// _calls_agents_<stem> keys (populated by loadSkillsManifest). // _calls_agents_<stem> keys (populated by loadSkillsManifest).
const unionAgents = new Set(); const unionAgents = new Set<string>();
for (const skillStem of unionSkills) { for (const skillStem of unionSkills) {
const agentRefs = man.get(`_calls_agents_${skillStem}`) || []; const agentRefs = man.get(`_calls_agents_${skillStem}`) || [];
for (const agentStem of agentRefs) { 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, // 13 runtime dispatch sites in install.js can each call stageSkillsForMode,
// so accumulating them in a single set avoids leaks without forcing each // so accumulating them in a single set avoids leaks without forcing each
// site to track its own cleanup handle. // site to track its own cleanup handle.
const STAGED_DIRS = new Set(); const STAGED_DIRS = new Set<string>();
let exitHandlerRegistered = false; let exitHandlerRegistered = false;
function cleanupStagedSkills() { function cleanupStagedSkills(): void {
for (const dir of STAGED_DIRS) { for (const dir of STAGED_DIRS) {
try { try {
fs.rmSync(dir, { recursive: true, force: true }); 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 // '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 // is exactly the kind of process users abort mid-run, so without explicit
// signal handling Ctrl+C would leave staged tmp dirs behind. // 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; if (exitHandlerRegistered) return;
exitHandlerRegistered = true; exitHandlerRegistered = true;
process.on('exit', cleanupStagedSkills); process.on('exit', cleanupStagedSkills);
@@ -303,12 +267,8 @@ function ensureExitCleanup() {
/** /**
* Stage a filtered copy of commands/gsd for a resolved profile. * Stage a filtered copy of commands/gsd for a resolved profile.
* In full mode (skills === '*') returns srcDir unchanged (no-op). * 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 (resolvedProfile.skills === '*') return srcDir;
if (!fs.existsSync(srcDir)) return srcDir; if (!fs.existsSync(srcDir)) return srcDir;
@@ -319,14 +279,14 @@ function stageSkillsForProfile(srcDir, resolvedProfile) {
if (!entry.isFile()) continue; if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue; if (!entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3); const stem = entry.name.slice(0, -3);
if (!resolvedProfile.skills.has(stem)) continue; if (!(resolvedProfile.skills).has(stem)) continue;
fs.copyFileSync( fs.copyFileSync(
path.join(srcDir, entry.name), path.join(srcDir, entry.name),
path.join(stageDir, entry.name), path.join(stageDir, entry.name),
); );
} }
} catch (err) { } catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {} try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err; throw err;
} }
STAGED_DIRS.add(stageDir); 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') * For tiered profiles, copies only agents whose full stem (e.g. 'gsd-planner')
* is in resolvedProfile.agents — which is populated by resolveProfile() from * is in resolvedProfile.agents — which is populated by resolveProfile() from
* the _calls_agents_* entries in the manifest. * 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 (resolvedProfile.skills === '*') return srcAgentsDir;
if (!fs.existsSync(srcAgentsDir)) 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) // If agents is empty Set, we produce an empty stageDir (no agents for this profile)
} catch (err) { } catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {} try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err; throw err;
} }
STAGED_DIRS.add(stageDir); STAGED_DIRS.add(stageDir);
@@ -375,7 +331,12 @@ function stageAgentsForProfile(srcAgentsDir, resolvedProfile) {
return stageDir; 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; if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir;
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-skills-')); 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.isFile()) continue;
if (!entry.name.endsWith('.md')) continue; if (!entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3); 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 content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
const skillName = `${prefix}${stem}`; const skillName = `${prefix}${stem}`;
const converted = converter(content, skillName); const converted = converter(content, skillName);
@@ -394,7 +355,7 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted); fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
} }
} catch (err) { } catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {} try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err; throw err;
} }
STAGED_DIRS.add(stageDir); STAGED_DIRS.add(stageDir);
@@ -410,11 +371,8 @@ const PROFILE_MARKER_NAME = '.gsd-profile';
/** /**
* Read the active profile from a runtime config directory. * 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); const markerPath = path.join(runtimeConfigDir, PROFILE_MARKER_NAME);
try { try {
const raw = fs.readFileSync(markerPath, 'utf8').trim(); const raw = fs.readFileSync(markerPath, 'utf8').trim();
@@ -429,11 +387,8 @@ function readActiveProfile(runtimeConfigDir) {
/** /**
* Persist the active profile to a runtime config directory. * 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'); 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). * Rank ordering for profiles (lower index = more restrictive / smaller skill set).
* Unknown profiles default to the permissive end (treated as 'full'). * 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 * 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. * Ordering (most to least restrictive): core < standard < full.
* Composed profiles (e.g. 'core,audit') and unknown profiles are treated as * Composed profiles (e.g. 'core,audit') and unknown profiles are treated as
* 'full' for this comparison. * 'full' for this comparison.
*
* @param {string[]} profileNames
* @returns {string}
*/ */
function mostRestrictiveProfile(profileNames) { function mostRestrictiveProfile(profileNames: string[]): string {
if (!profileNames || profileNames.length === 0) return 'full'; if (!profileNames || profileNames.length === 0) return 'full';
// Initialize with the least-restrictive rank (one past the end of PROFILE_RANK) // 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'; let bestName = 'full';
for (const name of profileNames) { 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. // Unknown/composed profiles are treated as the permissive 'full' rank.
const effectiveRank = rank === -1 ? PROFILE_RANK.indexOf('full') : rank; const effectiveRank = rank === -1 ? PROFILE_RANK.indexOf('full') : rank;
if (effectiveRank < bestRank) { if (effectiveRank < bestRank) {
@@ -475,6 +427,11 @@ function mostRestrictiveProfile(profileNames) {
return bestName; return bestName;
} }
interface ResolveEffectiveProfileOpts {
requestedProfileName: string | null;
targetDir: string;
}
/** /**
* Resolve the effective profile name for an install() run. * 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. * 1. Explicit flag (requestedProfileName != null) → use it as-is.
* 2. Marker exists in targetDir and is not 'full' → use marker. * 2. Marker exists in targetDir and is not 'full' → use marker.
* 3. Else → 'full' (back-compat for fresh non-interactive installs). * 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 // 1. Explicit flag overrides everything
if (requestedProfileName != null) return requestedProfileName; if (requestedProfileName != null) return requestedProfileName;
// 2. Marker-driven (gsd update path) // 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. * @deprecated Use resolveProfile({ modes: ['core'] }) instead.
*/ */
function isMinimalMode(mode) { function isMinimalMode(mode: string): boolean {
return mode === 'minimal' || mode === 'core-only'; return mode === 'minimal' || mode === 'core-only';
} }
@@ -528,7 +476,7 @@ function isMinimalMode(mode) {
* *
* @deprecated String-mode form; use resolvedProfile object form instead. * @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) { if (typeof resolvedProfileOrMode === 'object' && resolvedProfileOrMode !== null) {
const { skills } = resolvedProfileOrMode; const { skills } = resolvedProfileOrMode;
if (skills === '*') return true; if (skills === '*') return true;
@@ -545,11 +493,8 @@ function shouldInstallSkill(skillBaseName, resolvedProfileOrMode) {
* Back-compat wrapper: maps 'minimal' → core profile, 'full' → full. * Back-compat wrapper: maps 'minimal' → core profile, 'full' → full.
* *
* @deprecated Use stageSkillsForProfile with a resolved profile instead. * @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 (!isMinimalMode(mode)) return srcDir;
if (!fs.existsSync(srcDir)) return srcDir; if (!fs.existsSync(srcDir)) return srcDir;
@@ -567,7 +512,7 @@ function stageSkillsForMode(srcDir, mode) {
); );
} }
} catch (err) { } catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {} try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err; throw err;
} }
STAGED_DIRS.add(stageDir); STAGED_DIRS.add(stageDir);
@@ -579,7 +524,7 @@ function stageSkillsForMode(srcDir, mode) {
// Exports // Exports
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
module.exports = { export = {
// New profile API (ADR-0011) // New profile API (ADR-0011)
PROFILES, PROFILES,
PROFILE_RANK, 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 export const RESOLUTION_ENV_VAR = 'GSD_INSTALLER_MIGRATION_RESOLVE';
// runs without a TTY (typical /gsd:update path via Claude Code or any const VALID_CHOICES: ReadonlyArray<string> = ['keep', 'remove'];
// scripted update), prompt-user migration actions cannot be answered
// interactively. We resolve them by classification: // #3628: explicit whitelist of bundled hook files shipped in the npm
// - Stale SDK build artifacts (get-shit-done/sdk/{dist,src}/gsd-*): // distribution under `hooks/`. The classifier-based auto-removal of these
// default `remove`. Fresh install supplies replacements. // files at first-time-baseline scan (added in #3610) is restricted to this
// - User-facing skill anchors (skills/gsd-*/SKILL.md): default `keep`. // set — a shape regex like `^hooks/gsd-[^/]+\.(?:js|sh|cjs|mjs)$` also
// User-owned content is preserved. // matches user-authored custom hooks and retired bundled hooks from prior
// Anything else: fall through to the hard assertion with an improved, // versions, and auto-removing those is silent data loss.
// grouped, actionable error message.
// //
// docs/installer-migrations.md#prompt-user-resolution for the spec. // The bug-3628 regression guard asserts this Set stays aligned with the
const RESOLUTION_ENV_VAR = 'GSD_INSTALLER_MIGRATION_RESOLVE'; // on-disk `hooks/` directory in both directions: whitelist-but-missing
const VALID_CHOICES = ['keep', 'remove']; // 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 || !action.type) return 'skipped';
if (action.type === 'backup-and-remove') return 'backed up and removed'; if (action.type === 'backup-and-remove') return 'backed up and removed';
if (action.type === 'remove-managed') return 'removed'; if (action.type === 'remove-managed') return 'removed';
@@ -27,18 +126,18 @@ function installerMigrationActionLabel(action) {
return 'skipped'; return 'skipped';
} }
function blockedInstallerMigrationActions(result) { function blockedInstallerMigrationActions(result: MigrationResult | null | undefined): MigrationAction[] {
if (result && Array.isArray(result.blocked)) return result.blocked; if (result && Array.isArray(result.blocked)) return result.blocked;
const plan = result && result.plan; const plan = result && result.plan;
if (plan && Array.isArray(plan.blocked)) return plan.blocked; if (plan && Array.isArray(plan.blocked)) return plan.blocked;
return []; return [];
} }
function baselineSummaryLabel(count, noun) { function baselineSummaryLabel(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? '' : 's'}`; return `${count} ${noun}${count === 1 ? '' : 's'}`;
} }
function baselineSummaryRow(type, actions) { function baselineSummaryRow(type: string, actions: MigrationAction[]): SummaryRow {
const count = actions.length; const count = actions.length;
if (type === 'record-baseline') { if (type === 'record-baseline') {
return { 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 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 blocked = blockedInstallerMigrationActions(result);
const blockedSet = new Set(blocked); const blockedSet = new Set(blocked);
const rows = []; const rows: (SummaryRow | null)[] = [];
const baselineIndexes = new Map(); const baselineIndexes = new Map<string, number>();
const baselineActions = new Map(); const baselineActions = new Map<string, MigrationAction[]>();
for (const action of actions) { for (const action of actions) {
const type = action && action.type; const type = action && action.type;
@@ -73,13 +172,13 @@ function summarizeInstallerMigrationResult(result) {
baselineIndexes.set(type, rows.length); baselineIndexes.set(type, rows.length);
rows.push(null); rows.push(null);
} }
baselineActions.get(type).push(action); baselineActions.get(type)!.push(action);
continue; continue;
} }
rows.push({ rows.push({
label: blockedSet.has(action) ? 'blocked' : installerMigrationActionLabel(action), label: blockedSet.has(action) ? 'blocked' : installerMigrationActionLabel(action),
relPath: action.relPath, relPath: action.relPath ?? '',
reason: action.reason || '', reason: action.reason || '',
action, action,
}); });
@@ -88,7 +187,7 @@ function summarizeInstallerMigrationResult(result) {
// Phase 4 requires action reporting without flooding first-time baseline installs: // Phase 4 requires action reporting without flooding first-time baseline installs:
// docs/installer-migrations.md#phase-4-installupdate-integration. // docs/installer-migrations.md#phase-4-installupdate-integration.
for (const [type, baselineRows] of baselineActions) { for (const [type, baselineRows] of baselineActions) {
rows[baselineIndexes.get(type)] = baselineSummaryRow(type, baselineRows); rows[baselineIndexes.get(type)!] = baselineSummaryRow(type, baselineRows);
} }
return { 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 // Classify a blocked prompt-user action into one of the safe-default
// categories. Returns null when no safe default applies — caller must // categories. Returns null when no safe default applies — caller must
// fall back to the hard assertion / interactive prompt for those. // 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. // and are regenerated on every install, so removing them is lossless.
// User-facing skill anchors are the .md files that surface as commands // User-facing skill anchors are the .md files that surface as commands
// to the user — these are user-owned and must be kept. // 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; const relPath = action && action.relPath;
if (typeof relPath !== 'string' || !relPath) return null; if (typeof relPath !== 'string' || !relPath) return null;
if (/^get-shit-done\/sdk\/(dist|src)\//.test(relPath)) { if (/^get-shit-done\/sdk\/(dist|src)\//.test(relPath)) {
@@ -159,8 +231,9 @@ function classifyPromptUserAction(action) {
// `keep` → baseline-preserve-user (idempotent — already on disk). // `keep` → baseline-preserve-user (idempotent — already on disk).
// `remove` → backup-and-remove (safe: keeps a rollback copy in the // `remove` → backup-and-remove (safe: keeps a rollback copy in the
// migration journal under gsd-migration-journal/<runId>-backups/). // migration journal under gsd-migration-journal/<runId>-backups/).
function materializeResolution(action, choice) { function materializeResolution(action: MigrationAction, choice: string): MigrationAction {
const base = { const base: MigrationAction = {
type: '', // overridden in each return branch below
migrationId: action.migrationId, migrationId: action.migrationId,
migrationChecksum: action.migrationChecksum, migrationChecksum: action.migrationChecksum,
relPath: action.relPath, relPath: action.relPath,
@@ -177,13 +250,13 @@ function materializeResolution(action, choice) {
return { ...base, type: 'backup-and-remove', backupRelPath: null }; return { ...base, type: 'backup-and-remove', backupRelPath: null };
} }
function normalizeResolutionChoice(rawValue) { function normalizeResolutionChoice(rawValue: unknown): string | null {
if (typeof rawValue !== 'string') return null; if (typeof rawValue !== 'string') return null;
const normalized = rawValue.trim().toLowerCase(); const normalized = rawValue.trim().toLowerCase();
return VALID_CHOICES.includes(normalized) ? normalized : null; return VALID_CHOICES.includes(normalized) ? normalized : null;
} }
function actionSupportsChoice(action, choice) { function actionSupportsChoice(action: MigrationAction, choice: string): boolean {
if (!action || !choice) return false; if (!action || !choice) return false;
if (!Array.isArray(action.choices) || action.choices.length === 0) { if (!Array.isArray(action.choices) || action.choices.length === 0) {
return VALID_CHOICES.includes(choice); return VALID_CHOICES.includes(choice);
@@ -199,7 +272,10 @@ function actionSupportsChoice(action, choice) {
// could NOT be safely defaulted (caller must still handle those). // could NOT be safely defaulted (caller must still handle those).
// Returns { result, resolutions } where `resolutions` is the structured // Returns { result, resolutions } where `resolutions` is the structured
// log of every defaulted resolution. // log of every defaulted resolution.
function resolveInstallerMigrationPromptsForNonTty(result, options = {}) { export function resolveInstallerMigrationPromptsForNonTty(
result: MigrationResult,
options: ResolveOptions = {},
): ResolvePromptsResult {
if (!result || typeof result !== 'object') { if (!result || typeof result !== 'object') {
return { result, resolutions: [] }; return { result, resolutions: [] };
} }
@@ -215,19 +291,19 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
return { result, resolutions: [] }; return { result, resolutions: [] };
} }
const env = const env: Record<string, string | undefined> =
options && options.env && typeof options.env === 'object' options && options.env && typeof options.env === 'object'
? options.env ? options.env
: process.env; : process.env;
const envChoice = normalizeResolutionChoice(env && env[RESOLUTION_ENV_VAR]); const envChoice = normalizeResolutionChoice(env && env[RESOLUTION_ENV_VAR]);
const resolutions = []; const resolutions: Resolution[] = [];
const unresolved = []; const unresolved: MigrationAction[] = [];
for (const action of blocked) { for (const action of blocked) {
if (action && action.type === 'prompt-user') { if (action && action.type === 'prompt-user') {
let category = null; let category: string | null = null;
let choice = null; let choice: string | null = null;
let source = null; let source: string | null = null;
if (envChoice && actionSupportsChoice(action, envChoice)) { if (envChoice && actionSupportsChoice(action, envChoice)) {
category = 'operator-override'; category = 'operator-override';
choice = envChoice; choice = envChoice;
@@ -257,11 +333,11 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
} }
resolutions.push({ resolutions.push({
relPath: action.relPath, relPath: action.relPath,
category, category: category ?? '',
choice, choice: choice ?? '',
reason: action.reason, reason: action.reason,
resolvedActionType: resolved.type, resolvedActionType: resolved.type,
source, source: source ?? '',
}); });
continue; continue;
} }
@@ -285,18 +361,18 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
// Group blocked prompt-user actions by their `reason` so the operator // Group blocked prompt-user actions by their `reason` so the operator
// sees one summary line per cause instead of N path lines for the // sees one summary line per cause instead of N path lines for the
// same underlying issue. // same underlying issue.
function groupBlockedByReason(blocked) { function groupBlockedByReason(blocked: MigrationAction[]): Map<string, MigrationAction[]> {
const byReason = new Map(); const byReason = new Map<string, MigrationAction[]>();
for (const action of blocked) { for (const action of blocked) {
const reason = (action && action.reason) || 'no reason given'; const reason = (action && action.reason) || 'no reason given';
if (!byReason.has(reason)) byReason.set(reason, []); if (!byReason.has(reason)) byReason.set(reason, []);
byReason.get(reason).push(action); byReason.get(reason)!.push(action);
} }
return byReason; return byReason;
} }
function describeChoicesForActions(blocked) { function describeChoicesForActions(blocked: MigrationAction[]): string[] {
const choiceSet = new Set(); const choiceSet = new Set<string>();
for (const action of blocked) { for (const action of blocked) {
if (action && Array.isArray(action.choices)) { if (action && Array.isArray(action.choices)) {
for (const choice of action.choices) choiceSet.add(choice); for (const choice of action.choices) choiceSet.add(choice);
@@ -308,12 +384,12 @@ function describeChoicesForActions(blocked) {
return [...choiceSet]; return [...choiceSet];
} }
function buildBlockedErrorMessage(blocked) { function buildBlockedErrorMessage(blocked: MigrationAction[]): string {
const byReason = groupBlockedByReason(blocked); const byReason = groupBlockedByReason(blocked);
const totalFiles = blocked.length; const totalFiles = blocked.length;
const choices = describeChoicesForActions(blocked); const choices = describeChoicesForActions(blocked);
const lines = [ const lines: string[] = [
`installer migration blocked pending user choice: ${totalFiles} file${totalFiles === 1 ? '' : 's'} need a decision`, `installer migration blocked pending user choice: ${totalFiles} file${totalFiles === 1 ? '' : 's'} need a decision`,
` choices: [${choices.join(', ')}]`, ` choices: [${choices.join(', ')}]`,
]; ];
@@ -334,22 +410,14 @@ function buildBlockedErrorMessage(blocked) {
return lines.join('\n'); return lines.join('\n');
} }
function assertInstallerMigrationsUnblocked(result) { export function assertInstallerMigrationsUnblocked(result: MigrationResult | null | undefined): void {
const blocked = blockedInstallerMigrationActions(result); const blocked = blockedInstallerMigrationActions(result);
if (blocked.length === 0) return; if (blocked.length === 0) return;
const message = buildBlockedErrorMessage(blocked); const message = buildBlockedErrorMessage(blocked);
const error = new Error(message); const error = Object.assign(new Error(message), {
error.blocked = blocked; blocked,
error.blockedByReason = Object.fromEntries(groupBlockedByReason(blocked)); blockedByReason: Object.fromEntries(groupBlockedByReason(blocked)),
error.resolutionEnvVar = RESOLUTION_ENV_VAR; resolutionEnvVar: RESOLUTION_ENV_VAR,
});
throw error; 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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const crypto = require('crypto'); import crypto from 'node:crypto';
const { import {
validateInstallerMigrationActions, validateInstallerMigrationActions,
validateInstallerMigrationRecord, validateInstallerMigrationRecord,
} = require('./installer-migration-authoring.cjs'); type MigrationRecord,
const { platformWriteSync } = require('./shell-command-projection.cjs'); type MigrationAction,
const { realClock } = require('./clock.cjs'); } 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 MANIFEST_NAME = 'gsd-file-manifest.json';
const INSTALL_STATE_NAME = 'gsd-install-state.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 DEFAULT_LOCK_TIMEOUT_MS = 30_000;
const STRICT_JSON = Symbol('strict-json'); const STRICT_JSON = Symbol('strict-json');
function sha256File(filePath) { function sha256File(filePath: string): string {
const hash = crypto.createHash('sha256'); const hash = crypto.createHash('sha256');
const buffer = Buffer.allocUnsafe(1024 * 1024); const buffer = Buffer.allocUnsafe(1024 * 1024);
const fd = fs.openSync(filePath, 'r'); const fd = fs.openSync(filePath, 'r');
@@ -33,50 +42,64 @@ function sha256File(filePath) {
return hash.digest('hex'); return hash.digest('hex');
} }
function sha256Text(value) { function sha256Text(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex'); 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; if (!fs.existsSync(filePath)) return fallback;
try { try {
return JSON.parse(fs.readFileSync(filePath, 'utf8')); return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) { } catch (error) {
if (fallback === STRICT_JSON) { 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; 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); const manifest = readJsonIfPresent(path.join(configDir, MANIFEST_NAME), null);
if (!manifest || typeof manifest !== 'object') { if (!manifest || typeof manifest !== 'object') {
return { version: null, timestamp: null, mode: null, files: {} }; return { version: null, timestamp: null, mode: null, files: {} };
} }
const m = manifest as Record<string, unknown>;
return { return {
version: manifest.version || null, version: typeof m.version === 'string' ? m.version : null,
timestamp: manifest.timestamp || null, timestamp: typeof m.timestamp === 'string' ? m.timestamp : null,
mode: manifest.mode || null, mode: typeof m.mode === 'string' ? m.mode : null,
files: manifest.files && typeof manifest.files === 'object' ? manifest.files : {}, 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); const state = readJsonIfPresent(path.join(configDir, INSTALL_STATE_NAME), STRICT_JSON);
if (!state || typeof state !== 'object') { if (!state || typeof state !== 'object') {
return { schemaVersion: 1, appliedMigrations: [] }; return { schemaVersion: 1, appliedMigrations: [] };
} }
const s = state as Record<string, unknown>;
return { return {
schemaVersion: state.schemaVersion || 1, schemaVersion: typeof s.schemaVersion === 'number' ? s.schemaVersion : 1,
appliedMigrations: Array.isArray(state.appliedMigrations) ? state.appliedMigrations : [], 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. // 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 // Bypasses the seam because platformWriteSync falls back to a direct write on
// rename failure, which would silently violate this invariant. // rename failure, which would silently violate this invariant.
function atomicWriteInstallState(configDir, content) { function atomicWriteInstallState(configDir: string, content: string): void {
fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(configDir, { recursive: true });
const filePath = path.join(configDir, INSTALL_STATE_NAME); const filePath = path.join(configDir, INSTALL_STATE_NAME);
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; 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'); atomicWriteInstallState(configDir, JSON.stringify(state, null, 2) + '\n');
return state; 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); const { fullPath } = ensureInsideConfig(configDir, relPath);
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
return { exists: false, value: null, error: null }; return { exists: false, value: null, error: null };
@@ -102,11 +131,11 @@ function readJson(configDir, relPath) {
try { try {
return { exists: true, value: JSON.parse(fs.readFileSync(fullPath, 'utf8')), error: null }; return { exists: true, value: JSON.parse(fs.readFileSync(fullPath, 'utf8')), error: null };
} catch (error) { } 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() === '') { if (typeof relPath !== 'string' || relPath.trim() === '') {
throw new Error('migration action relPath must be a non-empty string'); throw new Error('migration action relPath must be a non-empty string');
} }
@@ -121,7 +150,13 @@ function normalizeRelPath(relPath) {
return segments.join('/'); 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 normalized = normalizeRelPath(relPath);
const originalHash = manifest.files[normalized] || null; const originalHash = manifest.files[normalized] || null;
const fullPath = path.join(configDir, normalized); const fullPath = path.join(configDir, normalized);
@@ -138,16 +173,16 @@ function classifyArtifact(configDir, relPath, manifest) {
return { classification: 'managed-modified', originalHash, currentHash }; return { classification: 'managed-modified', originalHash, currentHash };
} }
function appliedMigrationIds(state) { function appliedMigrationIds(state: InstallState): Set<string> {
return new Set( return new Set(
state.appliedMigrations state.appliedMigrations
.filter((entry) => entry && typeof entry.id === 'string') .filter((entry) => entry && typeof entry.id === 'string')
.map((entry) => entry.id) .map((entry) => entry.id as string)
); );
} }
function appliedMigrationEntries(state) { function appliedMigrationEntries(state: InstallState): Map<string, Record<string, unknown>> {
const entries = new Map(); const entries = new Map<string, Record<string, unknown>>();
for (const entry of state.appliedMigrations) { for (const entry of state.appliedMigrations) {
if (entry && typeof entry.id === 'string' && !entries.has(entry.id)) { if (entry && typeof entry.id === 'string' && !entries.has(entry.id)) {
entries.set(entry.id, entry); entries.set(entry.id, entry);
@@ -156,7 +191,7 @@ function appliedMigrationEntries(state) {
return entries; return entries;
} }
function migrationChecksum(migration) { function migrationChecksum(migration: MigrationRecord): string {
const checksum = migration.checksum; const checksum = migration.checksum;
if (typeof checksum === 'string' && checksum) return checksum; if (typeof checksum === 'string' && checksum) return checksum;
const serializable = { const serializable = {
@@ -168,35 +203,35 @@ function migrationChecksum(migration) {
scopes: migration.scopes || null, scopes: migration.scopes || null,
destructive: migration.destructive === true, destructive: migration.destructive === true,
runtimeContract: migration.runtimeContract || null, 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))}`; 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) { for (const migration of migrations) {
const entry = applied.get(migration.id); const entry = applied.get(migration.id as string);
if (!entry || !entry.checksum) continue; if (!entry || !entry.checksum) continue;
const checksum = migrationChecksum(migration); const checksum = migrationChecksum(migration);
if (entry.checksum !== checksum) { if (entry.checksum !== checksum) {
throw new Error( 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 }) { function migrationMatchesContext(migration: MigrationRecord, { runtime, scope }: { runtime: string | null; scope: string | null }): boolean {
if (Array.isArray(migration.runtimes) && migration.runtimes.length > 0) { if (Array.isArray(migration.runtimes) && (migration.runtimes as string[]).length > 0) {
if (!runtime || !migration.runtimes.includes(runtime)) return false; if (!runtime || !(migration.runtimes as string[]).includes(runtime)) return false;
} }
if (Array.isArray(migration.scopes) && migration.scopes.length > 0) { if (Array.isArray(migration.scopes) && (migration.scopes as string[]).length > 0) {
if (!scope || !migration.scopes.includes(scope)) return false; if (!scope || !(migration.scopes as string[]).includes(scope)) return false;
} }
return true; return true;
} }
function discoverInstallerMigrations({ migrationsDir }) { function discoverInstallerMigrations({ migrationsDir }: { migrationsDir: string }): MigrationRecord[] {
if (!migrationsDir || !fs.existsSync(migrationsDir)) return []; if (!migrationsDir || !fs.existsSync(migrationsDir)) return [];
return fs.readdirSync(migrationsDir, { withFileTypes: true }) return fs.readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.cjs')) .filter((entry) => entry.isFile() && entry.name.endsWith('.cjs'))
@@ -204,22 +239,24 @@ function discoverInstallerMigrations({ migrationsDir }) {
.sort() .sort()
.flatMap((fileName) => { .flatMap((fileName) => {
const source = path.join(migrationsDir, fileName); const source = path.join(migrationsDir, fileName);
delete require.cache[require.resolve(source)]; 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]; 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, '-'); return now().replace(/[:.]/g, '-');
} }
function migrationRunId(appliedAt) { function migrationRunId(appliedAt: string): string {
return `${journalTimestamp(() => appliedAt)}-${crypto.randomBytes(8).toString('hex')}`; return `${journalTimestamp(() => appliedAt)}-${crypto.randomBytes(8).toString('hex')}`;
} }
function sleepSync(ms) { function sleepSync(ms: number): void {
const buffer = new SharedArrayBuffer(4); const buffer = new SharedArrayBuffer(4);
Atomics.wait(new Int32Array(buffer), 0, 0, ms); 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), * Returns true if alive or permission-denied (live but not ours),
* false if ESRCH (no such process). * 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; if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) return false;
try { try {
process.kill(pid, 0); process.kill(pid, 0);
return true; // alive (or permission denied — treat as live) return true; // alive (or permission denied — treat as live)
} catch (err) { } 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 * Try to read and parse the lock file JSON. Returns null on any error
* (missing, invalid JSON, I/O failure). * (missing, invalid JSON, I/O failure).
*/ */
function readLockFile(lockPath) { function readLockFile(lockPath: string): LockFileData | null {
try { try {
const raw = fs.readFileSync(lockPath, 'utf8'); const raw = fs.readFileSync(lockPath, 'utf8');
const parsed = JSON.parse(raw); const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && typeof parsed.pid === 'number') { if (parsed && typeof parsed === 'object' && typeof (parsed as Record<string, unknown>).pid === 'number') {
return parsed; return parsed as LockFileData;
} }
return null; return null;
} catch { } 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 }); fs.mkdirSync(configDir, { recursive: true });
const lockPath = path.join(configDir, INSTALL_MIGRATION_LOCK_NAME); const lockPath = path.join(configDir, INSTALL_MIGRATION_LOCK_NAME);
const started = clock.now(); const started = clock.now();
while (true) { while (true) {
let fd = null; let fd: number | null = null;
let lockCreatedByUs = false; let lockCreatedByUs = false;
try { try {
fd = fs.openSync(lockPath, 'wx'); fd = fs.openSync(lockPath, 'wx');
@@ -281,14 +327,14 @@ function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEO
}) + '\n'); }) + '\n');
lockCreatedByUs = false; // release closure owns cleanup from here lockCreatedByUs = false; // release closure owns cleanup from here
return () => { return () => {
const failures = []; const failures: Error[] = [];
// Use unlinkSync (not rmSync with { force: true }) so EPERM errors // Use unlinkSync (not rmSync with { force: true }) so EPERM errors
// are NOT silently swallowed. On Windows, if the unlink fails // are NOT silently swallowed. On Windows, if the unlink fails
// transiently, the error surfaces via releaseError so the caller // transiently, the error surfaces via releaseError so the caller
// can observe and surface it rather than leaving a stale lock. // 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) { 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; releaseError.failures = failures;
throw releaseError; 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. // so it does not orphan as an unreadable (empty/invalid JSON) stale lock.
try { fs.unlinkSync(lockPath); } catch { /* best-effort */ } 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. // Stale-lock reclamation: read the on-disk PID and check liveness.
// If the PID is dead (ESRCH) or is our own process (same-process // If the PID is dead (ESRCH) or is our own process (same-process
// re-entry caused by rmSync silently swallowing an unlink error on // 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 normalized = normalizeRelPath(relPath);
const fullPath = path.resolve(configDir, normalized); const fullPath = path.resolve(configDir, normalized);
const root = path.resolve(configDir); const root = path.resolve(configDir);
@@ -347,18 +399,60 @@ function ensureInsideConfig(configDir, relPath) {
return { normalized, fullPath }; return { normalized, fullPath };
} }
function isStructurallyEmpty(value) { function isStructurallyEmpty(value: unknown): boolean {
if (value === null || value === undefined) return true; if (value === null || value === undefined) return true;
if (Array.isArray(value)) return value.length === 0; if (Array.isArray(value)) return value.length === 0;
return typeof value === 'object' && Object.keys(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 = {}) { function journalAction(action: MigrationAction, status: string, extras: Record<string, unknown> = {}): JournalAction {
const { value, ...safeAction } = action; const { value: _value, ...safeAction } = action;
return { ...safeAction, ...extras, status }; 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({ function planInstallerMigrations({
configDir, configDir,
runtime = null, runtime = null,
@@ -366,7 +460,14 @@ function planInstallerMigrations({
migrations, migrations,
baselineScan = false, baselineScan = false,
now = () => new Date().toISOString(), 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 (!configDir) throw new Error('configDir is required');
if (!Array.isArray(migrations)) throw new Error('migrations must be an array'); if (!Array.isArray(migrations)) throw new Error('migrations must be an array');
@@ -380,20 +481,21 @@ function planInstallerMigrations({
); );
const applied = appliedMigrationEntries(state); const applied = appliedMigrationEntries(state);
assertAppliedMigrationChecksums(applied, scopedMigrations); assertAppliedMigrationChecksums(applied, scopedMigrations);
const pending = scopedMigrations.filter((migration) => !applied.has(migration.id)); const pending = scopedMigrations.filter((migration) => !applied.has(migration.id as string));
const actions = []; const actions: PlannedAction[] = [];
const blocked = []; const blocked: PlannedAction[] = [];
const classifications = new Map(); const classifications = new Map<string, ArtifactClassification>();
const classify = (relPath) => { const classify = (relPath: string): ArtifactClassification => {
const normalized = normalizeRelPath(relPath); const normalized = normalizeRelPath(relPath);
if (!classifications.has(normalized)) { if (!classifications.has(normalized)) {
classifications.set(normalized, classifyArtifact(configDir, normalized, manifest)); classifications.set(normalized, classifyArtifact(configDir, normalized, manifest));
} }
return classifications.get(normalized); return classifications.get(normalized)!;
}; };
for (const migration of pending) { for (const migration of pending) {
const plannedActions = migration.plan({ const planFn = migration.plan as (ctx: PlanContext) => unknown[];
const plannedActions = planFn({
configDir, configDir,
runtime, runtime,
scope, scope,
@@ -406,34 +508,34 @@ function planInstallerMigrations({
}); });
validateInstallerMigrationActions(plannedActions, migration); validateInstallerMigrationActions(plannedActions, migration);
const checksum = migrationChecksum(migration); const checksum = migrationChecksum(migration);
for (const rawAction of plannedActions) { for (const rawAction of plannedActions as MigrationAction[]) {
const relPath = normalizeRelPath(rawAction.relPath); const relPath = normalizeRelPath(rawAction.relPath as string);
const classification = rawAction.classification const classification = rawAction.classification
? { ? {
classification: rawAction.classification, classification: rawAction.classification as string,
originalHash: rawAction.originalHash || null, originalHash: rawAction.originalHash as string | null || null,
currentHash: rawAction.currentHash || null, currentHash: rawAction.currentHash as string | null || null,
} }
: classify(relPath); : classify(relPath);
let protectedType = rawAction.type; let protectedType = rawAction.type as string;
if (rawAction.type === 'remove-managed' && classification.classification === 'managed-modified') { if (rawAction.type === 'remove-managed' && classification.classification === 'managed-modified') {
protectedType = 'backup-and-remove'; protectedType = 'backup-and-remove';
} }
if (rawAction.type === 'remove-managed' && classification.classification === 'unknown') { if (rawAction.type === 'remove-managed' && classification.classification === 'unknown') {
protectedType = 'preserve-user'; protectedType = 'preserve-user';
} }
const action = { const action: PlannedAction = {
migrationId: migration.id, migrationId: migration.id as string,
migrationChecksum: checksum, migrationChecksum: checksum,
type: protectedType, type: protectedType,
relPath, relPath,
reason: rawAction.reason || migration.description || '', reason: rawAction.reason as string || migration.description as string || '',
classification: classification.classification, classification: classification.classification,
originalHash: classification.originalHash, originalHash: classification.originalHash,
currentHash: classification.currentHash, currentHash: classification.currentHash,
}; };
if (action.type !== rawAction.type) { if (action.type !== rawAction.type) {
action.requestedType = rawAction.type; action.requestedType = rawAction.type as string | undefined;
} }
if (action.type === 'backup-and-remove') { if (action.type === 'backup-and-remove') {
action.backupRelPath = null; action.backupRelPath = null;
@@ -443,7 +545,7 @@ function planInstallerMigrations({
action.deleteIfEmpty = rawAction.deleteIfEmpty === true; action.deleteIfEmpty = rawAction.deleteIfEmpty === true;
} }
if (rawAction.prompt) action.prompt = rawAction.prompt; 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') { if (action.type === 'prompt-user') {
blocked.push(action); blocked.push(action);
} else if ( } else if (
@@ -462,34 +564,43 @@ function planInstallerMigrations({
generatedAt: now(), generatedAt: now(),
manifest, manifest,
state, state,
pendingMigrationIds: pending.map((migration) => migration.id), pendingMigrationIds: pending.map((migration) => migration.id as string),
pendingMigrations: pending, pendingMigrations: pending,
actions, actions,
blocked, blocked,
}; };
} }
function uniqueActionMigrationIds(actions) { function uniqueActionMigrationIds(actions: PlannedAction[]): string[] {
return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))]; return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))];
} }
function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }) { interface RollbackArgs {
const failures = []; 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()) { for (const action of [...journal.actions].reverse()) {
if (!action.rollbackRelPath) continue; if (!action.rollbackRelPath) continue;
const rollbackPath = path.join(configDir, action.rollbackRelPath); const rollbackPath = path.join(configDir, action.rollbackRelPath as string);
const dest = path.join(configDir, action.relPath); const dest = path.join(configDir, action.relPath as string);
try { try {
if (fs.existsSync(rollbackPath)) { if (fs.existsSync(rollbackPath)) {
fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(rollbackPath, dest); fs.copyFileSync(rollbackPath, dest);
} }
} catch (error) { } 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) { if (action.backupRelPath) {
try { try {
fs.rmSync(path.join(configDir, action.backupRelPath), { force: true }); fs.rmSync(path.join(configDir, action.backupRelPath as string), { force: true });
} catch { } catch {
// backup cleanup is best-effort; preserve restore failures above // backup cleanup is best-effort; preserve restore failures above
} }
@@ -503,7 +614,7 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
atomicWriteInstallState(configDir, previousInstallStateBytes); atomicWriteInstallState(configDir, previousInstallStateBytes);
} }
} catch (error) { } catch (error) {
failures.push({ relPath: INSTALL_STATE_NAME, error: error.message }); failures.push({ relPath: INSTALL_STATE_NAME, error: (error as Error).message });
} }
try { try {
@@ -515,19 +626,33 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
} }
if (failures.length > 0) { 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; error.rollbackFailures = failures;
throw error; 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(journalPath, { force: true }); } catch { /* best-effort */ }
try { fs.rmSync(rollbackRoot, { recursive: true, 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 */ } 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 (!configDir) throw new Error('configDir is required');
if (!plan || !Array.isArray(plan.actions)) throw new Error('plan with actions 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) { 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 rollbackRoot = path.join(configDir, rollbackRootRelPath);
const backupRootRelPath = path.posix.join('gsd-migration-journal', `${runId}-backups`); const backupRootRelPath = path.posix.join('gsd-migration-journal', `${runId}-backups`);
const backupRoot = path.join(configDir, backupRootRelPath); const backupRoot = path.join(configDir, backupRootRelPath);
const journal = { const journal: { schemaVersion: number; appliedAt: string; appliedMigrationIds: string[]; actions: JournalAction[] } = {
schemaVersion: 1, schemaVersion: 1,
appliedAt, appliedAt,
appliedMigrationIds: uniqueActionMigrationIds(plan.actions), appliedMigrationIds: uniqueActionMigrationIds(plan.actions),
actions: [], actions: [],
}; };
const rollback = []; const rollback: Array<{ relPath: string; rollbackPath: string }> = [];
const installStatePath = path.join(configDir, INSTALL_STATE_NAME); const installStatePath = path.join(configDir, INSTALL_STATE_NAME);
const previousInstallStateBytes = fs.existsSync(installStatePath) const previousInstallStateBytes = fs.existsSync(installStatePath)
? fs.readFileSync(installStatePath, 'utf8') ? fs.readFileSync(installStatePath, 'utf8')
@@ -622,7 +747,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
const state = readInstallState(configDir); const state = readInstallState(configDir);
const applied = appliedMigrationIds(state); const applied = appliedMigrationIds(state);
const nextApplied = [...state.appliedMigrations]; const nextApplied = [...state.appliedMigrations];
const actionsByMigrationId = new Map(); const actionsByMigrationId = new Map<string, PlannedAction>();
for (const action of plan.actions) { for (const action of plan.actions) {
if (action.migrationId && !actionsByMigrationId.has(action.migrationId)) { if (action.migrationId && !actionsByMigrationId.has(action.migrationId)) {
actionsByMigrationId.set(action.migrationId, action); 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 }), rollback: () => rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }),
}; };
} catch (error) { } catch (error) {
const rollbackFailures = []; const rollbackFailures: Array<{ relPath: string; rollbackPath: string; error: string }> = [];
for (const entry of rollback.reverse()) { for (const entry of rollback.reverse()) {
const dest = path.join(configDir, entry.relPath); const dest = path.join(configDir, entry.relPath);
try { try {
@@ -660,12 +785,12 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
rollbackFailures.push({ rollbackFailures.push({
relPath: entry.relPath, relPath: entry.relPath,
rollbackPath: entry.rollbackPath, rollbackPath: entry.rollbackPath,
error: rollbackError.message, error: (rollbackError as Error).message,
}); });
} }
} }
if (rollbackFailures.length > 0) { 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.cause = error;
rollbackError.rollbackFailures = rollbackFailures; rollbackError.rollbackFailures = rollbackFailures;
throw rollbackError; 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) { if (!plan || !Array.isArray(plan.pendingMigrationIds) || plan.pendingMigrationIds.length === 0) {
return []; return [];
} }
const appliedAt = now(); const appliedAt = now();
const state = readInstallState(configDir); const state = readInstallState(configDir);
const applied = appliedMigrationIds(state); const applied = appliedMigrationIds(state);
const checksumsByMigrationId = new Map(); const checksumsByMigrationId = new Map<string, string>();
for (const migration of plan.pendingMigrations || []) { 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 nextApplied = [...state.appliedMigrations];
const newlyApplied = []; const newlyApplied: string[] = [];
for (const id of plan.pendingMigrationIds) { for (const id of plan.pendingMigrationIds) {
if (applied.has(id)) continue; if (applied.has(id)) continue;
nextApplied.push({ nextApplied.push({
@@ -707,6 +840,14 @@ function markPendingMigrationsApplied({ configDir, plan, now = () => new Date().
return newlyApplied; return newlyApplied;
} }
interface RunResult {
appliedMigrationIds: string[];
journalRelPath: string | null;
plan: MigrationPlan;
blocked?: PlannedAction[];
rollback?: () => void;
}
function runInstallerMigrations({ function runInstallerMigrations({
configDir, configDir,
runtime = null, runtime = null,
@@ -716,17 +857,26 @@ function runInstallerMigrations({
baselineScan = false, baselineScan = false,
now = () => new Date().toISOString(), now = () => new Date().toISOString(),
lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS, 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 }); const releaseLock = acquireInstallMigrationLock(configDir, { timeoutMs: lockTimeoutMs });
let primaryError = null; let primaryError: (Error & { suppressed?: Error[] }) | null = null;
let completed = false; let completed = false;
try { try {
const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, baselineScan, now }); const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, baselineScan, now });
if (plan.actions.length === 0) { if (plan.actions.length === 0) {
const appliedMigrationIds = markPendingMigrationsApplied({ configDir, plan, now }); const newlyApplied = markPendingMigrationsApplied({ configDir, plan, now });
completed = true; completed = true;
return { return {
appliedMigrationIds, appliedMigrationIds: newlyApplied,
journalRelPath: null, journalRelPath: null,
plan, plan,
}; };
@@ -744,14 +894,14 @@ function runInstallerMigrations({
completed = true; completed = true;
return { ...result, plan }; return { ...result, plan };
} catch (error) { } catch (error) {
primaryError = error; primaryError = error as Error & { suppressed?: Error[] };
throw error; throw error;
} finally { } finally {
try { try {
releaseLock(); releaseLock();
} catch (releaseError) { } catch (releaseError) {
if (primaryError) { if (primaryError) {
primaryError.suppressed = [...(primaryError.suppressed || []), releaseError]; primaryError.suppressed = [...(primaryError.suppressed || []), releaseError as Error];
} else if (completed) { } else if (completed) {
throw releaseError; throw releaseError;
} else { } 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, DEFAULT_MIGRATIONS_DIR,
INSTALL_MIGRATION_LOCK_NAME, INSTALL_MIGRATION_LOCK_NAME,
INSTALL_STATE_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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const BASELINE_MIGRATION_ID = '2026-05-11-first-time-baseline-scan'; const BASELINE_MIGRATION_ID = '2026-05-11-first-time-baseline-scan';
// Runtime install surfaces must stay aligned with: // Runtime install surfaces must stay aligned with:
// - docs/installer-migrations.md#runtime-configuration-contract-registry // - docs/installer-migrations.md#runtime-configuration-contract-registry
// - docs/ARCHITECTURE.md#runtime-install-contract-matrix // - docs/ARCHITECTURE.md#runtime-install-contract-matrix
// const RUNTIME_SURFACES: Record<string, string[]> = {
// 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 = {
claude: ['get-shit-done', 'commands/gsd', 'skills', 'agents', 'hooks', 'settings.json'], claude: ['get-shit-done', 'commands/gsd', 'skills', 'agents', 'hooks', 'settings.json'],
codex: ['get-shit-done', 'skills', 'agents', 'hooks', 'config.toml', 'hooks.json'], codex: ['get-shit-done', 'skills', 'agents', 'hooks', 'config.toml', 'hooks.json'],
gemini: ['get-shit-done', 'commands/gsd', 'hooks'], gemini: ['get-shit-done', 'commands/gsd', 'hooks'],
@@ -42,25 +45,24 @@ const USER_OWNED_PATHS = new Set([
'commands/gsd/dev-preferences.md', 'commands/gsd/dev-preferences.md',
'skills/gsd-dev-preferences/SKILL.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(/^\/+/, ''); return relPath.replace(/\\/g, '/').replace(/^\/+/, '');
} }
function baselineInstallSurfaces(runtime) { function baselineInstallSurfaces(runtime: string | undefined): string[] {
if (runtime && RUNTIME_SURFACES[runtime]) return RUNTIME_SURFACES[runtime]; if (runtime && RUNTIME_SURFACES[runtime]) return RUNTIME_SURFACES[runtime];
return COMMON_SURFACES; return COMMON_SURFACES;
} }
function walkFiles(root, relDir, files) { function walkFiles(root: string, relDir: string, files: Set<string>): void {
const dir = path.join(root, relDir); const dir = path.join(root, relDir);
if (!fs.existsSync(dir)) return; if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true }); const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const relPath = path.posix.join(relDir, entry.name); const relPath = path.posix.join(relDir, entry.name);
if (relDir === '' && INTERNAL_TOP_LEVEL_NAMES.has(entry.name)) continue; if (relDir === '' && INTERNAL_TOP_LEVEL_NAMES.has(entry.name)) continue;
const fullPath = path.join(root, relPath);
if (entry.isDirectory()) { if (entry.isDirectory()) {
walkFiles(root, relPath, files); walkFiles(root, relPath, files);
} else if (entry.isFile()) { } else if (entry.isFile()) {
@@ -69,8 +71,8 @@ function walkFiles(root, relDir, files) {
} }
} }
function scanBaselineFiles(configDir, runtime) { function scanBaselineFiles(configDir: string, runtime: string | undefined): string[] {
const relPaths = new Set(); const relPaths = new Set<string>();
for (const surface of baselineInstallSurfaces(runtime)) { for (const surface of baselineInstallSurfaces(runtime)) {
const normalized = normalizeRelPath(surface); const normalized = normalizeRelPath(surface);
const fullPath = path.join(configDir, normalized); const fullPath = path.join(configDir, normalized);
@@ -85,7 +87,7 @@ function scanBaselineFiles(configDir, runtime) {
return [...relPaths]; return [...relPaths];
} }
function isUserOwnedBaselinePath(relPath) { function isUserOwnedBaselinePath(relPath: string): boolean {
if (USER_OWNED_PATHS.has(relPath)) return true; if (USER_OWNED_PATHS.has(relPath)) return true;
const parts = relPath.split('/'); const parts = relPath.split('/');
if (parts[0] === 'skills' && parts[1] && !parts[1].startsWith('gsd-')) return true; if (parts[0] === 'skills' && parts[1] && !parts[1].startsWith('gsd-')) return true;
@@ -93,10 +95,10 @@ function isUserOwnedBaselinePath(relPath) {
return false; return false;
} }
function listKnownGeneratedAgentNames() { function listKnownGeneratedAgentNames(): Set<string> {
if (knownGeneratedAgentNames) return knownGeneratedAgentNames; if (knownGeneratedAgentNames) return knownGeneratedAgentNames;
knownGeneratedAgentNames = new Set(); knownGeneratedAgentNames = new Set<string>();
const agentsDir = path.resolve(__dirname, '..', '..', '..', '..', 'agents'); const agentsDir = path.resolve(__dirname, '..', '..', '..', '..', 'agents');
try { try {
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) { for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
@@ -112,7 +114,7 @@ function listKnownGeneratedAgentNames() {
return knownGeneratedAgentNames; return knownGeneratedAgentNames;
} }
function isKnownGeneratedAgentPath(relPath, runtime) { function isKnownGeneratedAgentPath(relPath: string, runtime: string | undefined): boolean {
const parts = relPath.split('/'); const parts = relPath.split('/');
if (parts.length !== 2 || parts[0] !== 'agents') return false; if (parts.length !== 2 || parts[0] !== 'agents') return false;
const fileName = parts[1]; const fileName = parts[1];
@@ -123,7 +125,7 @@ function isKnownGeneratedAgentPath(relPath, runtime) {
return listKnownGeneratedAgentNames().has(agentName); return listKnownGeneratedAgentNames().has(agentName);
} }
function isStaleGsdLookingPath(relPath) { function isStaleGsdLookingPath(relPath: string): boolean {
const baseName = path.posix.basename(relPath); const baseName = path.posix.basename(relPath);
if (/^gsd[-_]/.test(baseName)) return true; if (/^gsd[-_]/.test(baseName)) return true;
const parts = relPath.split('/'); const parts = relPath.split('/');
@@ -133,27 +135,61 @@ function isStaleGsdLookingPath(relPath) {
return false; 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 === 'record-baseline') return 0;
if (action.type === 'baseline-preserve-user') return 1; if (action.type === 'baseline-preserve-user') return 1;
return 2; 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, id: BASELINE_MIGRATION_ID,
title: 'Record first-time installer migration baseline', title: 'Record first-time installer migration baseline',
description: 'Classify existing install surfaces before destructive installer migrations run.', description: 'Classify existing install surfaces before destructive installer migrations run.',
introducedIn: '1.50.0', introducedIn: '1.50.0',
scopes: ['global', 'local'], scopes: ['global', 'local'],
destructive: false, destructive: false,
plan: ({ configDir, runtime, baselineScan, classifyArtifact }) => { plan: ({ configDir, runtime, baselineScan, classifyArtifact }: PlanContext): BaselineAction[] => {
if (!baselineScan) return []; if (!baselineScan) return [];
const actions = []; const actions: BaselineAction[] = [];
for (const relPath of scanBaselineFiles(configDir, runtime)) { for (const relPath of scanBaselineFiles(configDir, runtime)) {
// docs/installer-migrations.md#baseline-preserve-user keeps user-owned // docs/installer-migrations.md#baseline-preserve-user keeps user-owned
// artifacts out of destructive migration flow; classify later only when // artifacts out of destructive migration flow.
// ownership is not already known.
if (isUserOwnedBaselinePath(relPath)) { if (isUserOwnedBaselinePath(relPath)) {
actions.push({ actions.push({
type: 'baseline-preserve-user', type: 'baseline-preserve-user',
@@ -176,14 +212,14 @@ module.exports = {
continue; continue;
} }
const currentHash = artifact.currentHash; const currentHash = artifact.currentHash ?? null;
if (isKnownGeneratedAgentPath(relPath, runtime)) { if (isKnownGeneratedAgentPath(relPath, runtime)) {
actions.push({ actions.push({
type: 'record-baseline', type: 'record-baseline',
relPath, relPath,
reason: 'known installer-generated agent included in first-time migration baseline', reason: 'known installer-generated agent included in first-time migration baseline',
classification: artifact.classification, classification: artifact.classification,
originalHash: artifact.originalHash, originalHash: artifact.originalHash ?? null,
currentHash, currentHash,
}); });
continue; continue;
@@ -195,7 +231,7 @@ module.exports = {
relPath, relPath,
reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice', reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice',
classification: 'stale-gsd-looking', classification: 'stale-gsd-looking',
originalHash: artifact.originalHash, originalHash: artifact.originalHash ?? null,
currentHash, currentHash,
prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.', prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.',
choices: ['keep', 'remove'], choices: ['keep', 'remove'],
@@ -208,13 +244,16 @@ module.exports = {
relPath, relPath,
reason: 'unknown install-surface file preserved by first-time migration baseline', reason: 'unknown install-surface file preserved by first-time migration baseline',
classification: artifact.classification, classification: artifact.classification,
originalHash: artifact.originalHash, originalHash: artifact.originalHash ?? null,
currentHash, currentHash,
}); });
} }
return actions.sort((left, right) => return actions.sort(
(left, right) =>
baselineActionRank(left) - baselineActionRank(right) || left.relPath.localeCompare(right.relPath) 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/gsd-notify.sh',
'hooks/statusline.js', 'hooks/statusline.js',
]; ];
module.exports = { const migration: InstallerMigration = {
id: '2026-05-11-legacy-orphan-files', id: '2026-05-11-legacy-orphan-files',
title: 'Remove manifest-managed legacy orphan hook files', title: 'Remove manifest-managed legacy orphan hook files',
description: 'Remove legacy orphan hook files that are still manifest-managed.', 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 // evidence. This follows docs/installer-migrations.md#ownership and avoids
// relying on whether a runtime currently registers host hook config in the // relying on whether a runtime currently registers host hook config in the
// runtime contract registry. // runtime contract registry.
plan: ({ classifyArtifact }) => { plan: (ctx: MigrationPlanContext): MigrationAction[] => {
const actions = []; const actions: MigrationAction[] = [];
for (const relPath of LEGACY_ORPHAN_FILES) { for (const relPath of LEGACY_ORPHAN_FILES) {
const artifact = classifyArtifact(relPath); const artifact = ctx.classifyArtifact(relPath);
if (artifact.classification === 'managed-pristine') { if (artifact.classification === 'managed-pristine') {
actions.push({ actions.push({
type: 'remove-managed', type: 'remove-managed',
@@ -39,3 +75,5 @@ module.exports = {
return actions; 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 (value === null || value === undefined) return true;
if (Array.isArray(value)) return value.length === 0; if (Array.isArray(value)) return value.length === 0;
if (typeof value !== 'object') return false; if (typeof value !== 'object') return false;
@@ -10,7 +63,7 @@ function isStructurallyEmpty(value) {
return true; return true;
} }
function isManagedCodexHookCommand(command, configDir) { function isManagedCodexHookCommand(command: unknown, configDir: string): boolean {
return isManagedHookCommand(command, { return isManagedHookCommand(command, {
surface: 'codex-hooks-json', surface: 'codex-hooks-json',
includeLegacyAliases: true, 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)) { if (Array.isArray(value)) {
let changed = false; let changed = false;
const next = []; const next: JsonValue[] = [];
for (const item of value) { for (const item of value) {
const pruned = pruneLegacyCodexHooksJsonValue(item, configDir); const pruned = pruneLegacyCodexHooksJsonValue(item, configDir);
if (pruned.changed) changed = true; if (pruned.changed) changed = true;
@@ -31,14 +84,16 @@ function pruneLegacyCodexHooksJsonValue(value, configDir) {
return { value: next, changed }; return { value: next, changed };
} }
if (value && typeof value === 'object') { if (value && typeof value === 'object' && !Array.isArray(value)) {
if (isManagedCodexHookCommand(value.command, configDir)) { const valueObj = value as Record<string, JsonValue>;
const command = valueObj['command'];
if (isManagedCodexHookCommand(command, configDir)) {
return { value: null, changed: true }; return { value: null, changed: true };
} }
let changed = false; let changed = false;
const next = {}; const next: { [key: string]: JsonValue } = {};
for (const [key, child] of Object.entries(value)) { for (const [key, child] of Object.entries(valueObj)) {
const pruned = pruneLegacyCodexHooksJsonValue(child, configDir); const pruned = pruneLegacyCodexHooksJsonValue(child, configDir);
if (pruned.changed) changed = true; if (pruned.changed) changed = true;
if (pruned.changed && isStructurallyEmpty(pruned.value)) changed = true; if (pruned.changed && isStructurallyEmpty(pruned.value)) changed = true;
@@ -50,7 +105,7 @@ function pruneLegacyCodexHooksJsonValue(value, configDir) {
return { value, changed: false }; return { value, changed: false };
} }
module.exports = { const migration: InstallerMigration = {
id: '2026-05-11-codex-legacy-hooks-json', id: '2026-05-11-codex-legacy-hooks-json',
title: 'Remove legacy Codex hooks.json GSD hook registrations', title: 'Remove legacy Codex hooks.json GSD hook registrations',
description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.', description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.',
@@ -59,8 +114,9 @@ module.exports = {
scopes: ['global', 'local'], scopes: ['global', 'local'],
destructive: true, destructive: true,
runtimeContract: 'docs/installer-migrations.md#runtime-configuration-contract-registry Codex row', runtimeContract: 'docs/installer-migrations.md#runtime-configuration-contract-registry Codex row',
plan: ({ configDir, readJson }) => { plan: (ctx: MigrationPlanContext): MigrationAction[] => {
const hooksJson = readJson('hooks.json'); const { configDir } = ctx;
const hooksJson = ctx.readJson('hooks.json');
if (!hooksJson.exists || hooksJson.error) return []; if (!hooksJson.exists || hooksJson.error) return [];
const pruned = pruneLegacyCodexHooksJsonValue(hooksJson.value, configDir); 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. * Provides a persistent, queryable intelligence system for project metadata.
* Intel files live in .planning/intel/ and store structured data about * Intel files live in .planning/intel/ and store structured data about
* the project's files, APIs, dependencies, architecture, and tech stack. * the project's files, APIs, dependencies, architecture, and tech stack.
* *
* All public functions gate on intel.enabled config (no-op when false). * 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'; import fs from 'node:fs';
import path from 'node:path';
const fs = require('fs'); import crypto from 'node:crypto';
const path = require('path'); import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
const crypto = require('crypto');
const { platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
const INTEL_DIR = '.planning/intel'; const INTEL_DIR = '.planning/intel';
const INTEL_FILES = { const INTEL_FILES: Record<string, string> = {
files: 'file-roles.json', files: 'file-roles.json',
apis: 'api-map.json', apis: 'api-map.json',
deps: 'dependency-graph.json', deps: 'dependency-graph.json',
arch: 'arch-decisions.json', arch: 'arch-decisions.json',
stack: 'stack.json' stack: 'stack.json',
}; };
// ─── Internal helpers ──────────────────────────────────────────────────────── // ─── Internal helpers ────────────────────────────────────────────────────────
/** /**
* Ensure the intel directory exists under the given planning dir. * 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'); const intelPath = path.join(planningDir, 'intel');
platformEnsureDir(intelPath); platformEnsureDir(intelPath);
return intelPath; return intelPath;
@@ -45,53 +44,66 @@ function ensureIntelDir(planningDir) {
* Check whether intel is enabled in the project config. * Check whether intel is enabled in the project config.
* Reads config.json directly via fs. Returns false by default * Reads config.json directly via fs. Returns false by default
* (when no config, no intel key, or on error). * (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 { try {
const configPath = path.join(planningDir, 'config.json'); const configPath = path.join(planningDir, 'config.json');
const raw = platformReadSync(configPath); const raw = platformReadSync(configPath);
if (raw === null) return false; if (raw === null) return false;
const config = JSON.parse(raw); const config: unknown = JSON.parse(raw);
if (config && config.intel && config.intel.enabled === true) return true; 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; return false;
} catch (_e) { } catch (_e) {
return false; return false;
} }
} }
interface DisabledResponse {
disabled: true;
message: string;
}
/** /**
* Return the standard disabled response object. * 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.' }; return { disabled: true, message: 'Intel system disabled. Set intel.enabled=true in config.json to activate.' };
} }
/** /**
* Resolve full path to an intel file. * 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); 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. * Safely read and parse a JSON intel file.
* Returns null if file doesn't exist or can't be parsed. * 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 { try {
const raw = platformReadSync(filePath); const raw = platformReadSync(filePath);
if (raw === null) return null; if (raw === null) return null;
return JSON.parse(raw); return JSON.parse(raw) as IntelData;
} catch (_e) { } catch (_e) {
return null; return null;
} }
@@ -100,11 +112,8 @@ function safeReadJson(filePath) {
/** /**
* Compute SHA-256 hash of a file's contents. * Compute SHA-256 hash of a file's contents.
* Returns null if the file doesn't exist. * Returns null if the file doesn't exist.
*
* @param {string} filePath
* @returns {string|null}
*/ */
function hashFile(filePath) { function hashFile(filePath: string): string | null {
try { try {
const content = platformReadSync(filePath); const content = platformReadSync(filePath);
if (content === null) return null; 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. * Search for a term (case-insensitive) in a JSON object's keys and string values.
* Returns an array of matching entries. * 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 []; if (!data || typeof data !== 'object') return [];
const entries = data.entries || data; const entries = data.entries || data;
if (!entries || typeof entries !== 'object') return []; if (!entries || typeof entries !== 'object') return [];
const lowerTerm = term.toLowerCase(); const lowerTerm = term.toLowerCase();
const matches = []; const matches: SearchMatch[] = [];
for (const [key, value] of Object.entries(entries)) { for (const [key, value] of Object.entries(entries)) {
if (key === '_meta') continue; if (key === '_meta') continue;
@@ -151,12 +161,8 @@ function searchJsonEntries(data, term) {
/** /**
* Recursively check if a term appears in any string value. * 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') { if (typeof value === 'string') {
return value.toLowerCase().includes(lowerTerm); return value.toLowerCase().includes(lowerTerm);
} }
@@ -172,12 +178,8 @@ function matchesInValue(value, lowerTerm) {
/** /**
* Search for a term in arch.md text content. * Search for a term in arch.md text content.
* Returns matching lines. * 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 { try {
const content = platformReadSync(filePath); const content = platformReadSync(filePath);
if (content === null) return []; if (content === null) return [];
@@ -191,18 +193,20 @@ function searchArchMd(filePath, term) {
// ─── Public API ────────────────────────────────────────────────────────────── // ─── Public API ──────────────────────────────────────────────────────────────
interface IntelQueryResult {
matches: Array<{ source: string; entries: SearchMatch[] }>;
term: string;
total: number;
}
/** /**
* Query intel files for a search term. * Query intel files for a search term.
* Searches across all JSON intel files (keys and values) and arch.md (text lines). * 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(); if (!isIntelEnabled(planningDir)) return disabledResponse();
const matches = []; const matches: Array<{ source: string; entries: SearchMatch[] }> = [];
let total = 0; let total = 0;
// Search all JSON intel files // Search all JSON intel files
@@ -221,19 +225,27 @@ function intelQuery(term, planningDir) {
return { matches, term, total }; 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. * Report status and staleness of each intel file.
* A file is considered stale if its updated_at is older than 24 hours. * 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(); if (!isIntelEnabled(planningDir)) return disabledResponse();
const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
const now = Date.now(); const now = Date.now();
const files = {}; const files: Record<string, IntelStatusFileEntry> = {};
let overallStale = false; let overallStale = false;
for (const [_key, filename] of Object.entries(INTEL_FILES)) { for (const [_key, filename] of Object.entries(INTEL_FILES)) {
@@ -246,7 +258,7 @@ function intelStatus(planningDir) {
continue; continue;
} }
let updatedAt = null; let updatedAt: string | null = null;
// All intel files are JSON — read _meta.updated_at // All intel files are JSON — read _meta.updated_at
const data = safeReadJson(filePath); const data = safeReadJson(filePath);
@@ -267,13 +279,16 @@ function intelStatus(planningDir) {
return { files, overall_stale: overallStale }; return { files, overall_stale: overallStale };
} }
interface IntelDiffResult {
changed: string[];
added: string[];
removed: string[];
}
/** /**
* Show changes since the last full refresh by comparing file hashes. * 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(); if (!isIntelEnabled(planningDir)) return disabledResponse();
const snapshotPath = intelFilePath(planningDir, '.last-refresh.json'); const snapshotPath = intelFilePath(planningDir, '.last-refresh.json');
@@ -283,10 +298,10 @@ function intelDiff(planningDir) {
return { no_baseline: true }; return { no_baseline: true };
} }
const prevHashes = snapshot.hashes || {}; const prevHashes = (snapshot.hashes as Record<string, string> | undefined) || {};
const changed = []; const changed: string[] = [];
const added = []; const added: string[] = [];
const removed = []; const removed: string[] = [];
// Check current files against snapshot // Check current files against snapshot
for (const [_key, filename] of Object.entries(INTEL_FILES)) { for (const [_key, filename] of Object.entries(INTEL_FILES)) {
@@ -308,29 +323,29 @@ function intelDiff(planningDir) {
/** /**
* Stub for triggering an intel update. * Stub for triggering an intel update.
* The actual update is performed by the intel-updater agent (PLAN-02). * 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(); if (!isIntelEnabled(planningDir)) return disabledResponse();
return { return {
action: 'spawn_agent', 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. * Save a refresh snapshot with hashes of all current intel files.
* Called by the intel-updater agent after completing a refresh. * 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 intelPath = ensureIntelDir(planningDir);
const hashes = {}; const hashes: Record<string, string> = {};
let fileCount = 0; let fileCount = 0;
for (const [_key, filename] of Object.entries(INTEL_FILES)) { for (const [_key, filename] of Object.entries(INTEL_FILES)) {
@@ -347,7 +362,7 @@ function saveRefreshSnapshot(planningDir) {
platformWriteSync(snapshotPath, JSON.stringify({ platformWriteSync(snapshotPath, JSON.stringify({
hashes, hashes,
timestamp, timestamp,
version: 1 version: 1,
}, null, 2)); }, null, 2));
return { saved: true, timestamp, files: fileCount }; return { saved: true, timestamp, files: fileCount };
@@ -358,26 +373,26 @@ function saveRefreshSnapshot(planningDir) {
/** /**
* Thin wrapper around saveRefreshSnapshot for CLI dispatch. * Thin wrapper around saveRefreshSnapshot for CLI dispatch.
* Writes .last-refresh.json with accurate timestamps and hashes. * 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(); if (!isIntelEnabled(planningDir)) return disabledResponse();
return saveRefreshSnapshot(planningDir); return saveRefreshSnapshot(planningDir);
} }
interface IntelValidateResult {
valid: boolean;
errors: string[];
warnings: string[];
}
/** /**
* Validate all intel files for correctness and freshness. * 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(); if (!isIntelEnabled(planningDir)) return disabledResponse();
const errors = []; const errors: string[] = [];
const warnings = []; const warnings: string[] = [];
const STALE_MS = 24 * 60 * 60 * 1000; const STALE_MS = 24 * 60 * 60 * 1000;
const now = Date.now(); const now = Date.now();
@@ -398,11 +413,11 @@ function intelValidate(planningDir) {
errors.push(`${filename}: file missing`); errors.push(`${filename}: file missing`);
continue; continue;
} }
let data; let data: IntelData;
try { try {
data = JSON.parse(raw); data = JSON.parse(raw) as IntelData;
} catch (e) { } catch (e) {
errors.push(`${filename}: invalid JSON — ${e.message}`); errors.push(`${filename}: invalid JSON — ${(e as Error).message}`);
continue; continue;
} }
@@ -421,8 +436,9 @@ function intelValidate(planningDir) {
// files.json: check exports are actual symbol names (no spaces) // files.json: check exports are actual symbol names (no spaces)
if (key === 'files') { if (key === 'files') {
for (const [entryPath, entry] of Object.entries(data.entries)) { for (const [entryPath, entry] of Object.entries(data.entries)) {
if (entry.exports && Array.isArray(entry.exports)) { const entryObj = entry as Record<string, unknown>;
for (const exp of entry.exports) { if (entryObj.exports && Array.isArray(entryObj.exports)) {
for (const exp of entryObj.exports as unknown[]) {
if (typeof exp === 'string' && exp.includes(' ')) { if (typeof exp === 'string' && exp.includes(' ')) {
warnings.push(`${filename}: "${entryPath}" export "${exp}" looks like a description (contains space)`); 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 // deps.json: check entries have version, type, used_by
if (key === 'deps') { if (key === 'deps') {
for (const [depName, entry] of Object.entries(data.entries)) { for (const [depName, entry] of Object.entries(data.entries)) {
const missing = []; const entryObj = entry as Record<string, unknown>;
if (!entry.version) missing.push('version'); const missing: string[] = [];
if (!entry.type) missing.push('type'); if (!entryObj.version) missing.push('version');
if (!entry.used_by) missing.push('used_by'); if (!entryObj.type) missing.push('type');
if (!entryObj.used_by) missing.push('used_by');
if (missing.length > 0) { if (missing.length > 0) {
warnings.push(`${filename}: "${depName}" missing fields: ${missing.join(', ')}`); warnings.push(`${filename}: "${depName}" missing fields: ${missing.join(', ')}`);
} }
@@ -456,16 +473,19 @@ function intelValidate(planningDir) {
return { valid: errors.length === 0, errors, warnings }; 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. * 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 * Always writes the file — even when api-map.json is absent or empty, the
* surface will contain an explicit "incomplete" banner so consumers never * surface will contain an explicit "incomplete" banner so consumers never
* mistake silence for "nothing exists". * 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(); if (!isIntelEnabled(planningDir)) return disabledResponse();
const intelPath = ensureIntelDir(planningDir); const intelPath = ensureIntelDir(planningDir);
@@ -486,7 +506,7 @@ function intelApiSurface(planningDir) {
stale = age > STALE_MS; stale = age > STALE_MS;
} }
const lines = []; const lines: string[] = [];
lines.push('# API Surface'); lines.push('# API Surface');
lines.push(''); lines.push('');
lines.push('> Generated from `.planning/intel/api-map.json`. Do not edit by hand.'); 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(`## \`${symbol}\``);
lines.push(''); lines.push('');
if (info && typeof info === 'object') { 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); const display = Array.isArray(val) ? val.join(', ') : String(val);
lines.push(`- **${field}:** ${display}`); lines.push(`- **${field}:** ${display}`);
} }
@@ -520,27 +540,31 @@ function intelApiSurface(planningDir) {
return { written: outputPath, symbolCount, stale }; 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. * Patch _meta.updated_at in a JSON intel file to the current timestamp.
* Reads the file, updates _meta.updated_at, increments version, writes back. * Reads the file, updates _meta.updated_at, increments version, writes back.
* *
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary file paths * NOTE: Does not gate on isIntelEnabled — operates on arbitrary file paths
* for use by agents patching individual files outside the intel store. * 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 { try {
const content = platformReadSync(filePath); const content = platformReadSync(filePath);
if (content === null) { if (content === null) {
return { patched: false, error: `File not found: ${filePath}` }; return { patched: false, error: `File not found: ${filePath}` };
} }
let data; let data: IntelData;
try { try {
data = JSON.parse(content); data = JSON.parse(content) as IntelData;
} catch (e) { } catch (e) {
return { patched: false, error: `Invalid JSON: ${e.message}` }; return { patched: false, error: `Invalid JSON: ${(e as Error).message}` };
} }
if (!data._meta) { if (!data._meta) {
@@ -555,25 +579,28 @@ function intelPatchMeta(filePath) {
return { patched: true, file: filePath, timestamp }; return { patched: true, file: filePath, timestamp };
} catch (e) { } 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. * 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 * NOTE: Does not gate on isIntelEnabled — operates on arbitrary source files
* for use by agents building intel data from project 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); const content = platformReadSync(filePath);
if (content === null) { if (content === null) {
return { file: filePath, exports: [], method: 'none' }; return { file: filePath, exports: [], method: 'none' };
} }
const exports = new Set(); const exports = new Set<string>();
let method = 'none'; let method = 'none';
// Try module.exports = { ... } pattern (handle multi-line) // 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) // Also try individual exports.X = patterns (only at start of line, not inside strings/regex)
const individualPattern = /^exports\.(\w+)\s*=/gm; const individualPattern = /^exports\.(\w+)\s*=/gm;
let im; let im: RegExpExecArray | null;
while ((im = individualPattern.exec(content)) !== null) { while ((im = individualPattern.exec(content)) !== null) {
if (!exports.has(im[1])) { if (!exports.has(im[1])) {
exports.add(im[1]); exports.add(im[1]);
@@ -619,11 +646,11 @@ function intelExtractExports(filePath) {
const hadCjs = exports.size > 0; const hadCjs = exports.size > 0;
// ESM patterns // ESM patterns
const esmExports = new Set(); const esmExports = new Set<string>();
// export default function X / export default class X // export default function X / export default class X
const defaultNamedPattern = /^export\s+default\s+(?:function|class)\s+(\w+)/gm; const defaultNamedPattern = /^export\s+default\s+(?:function|class)\s+(\w+)/gm;
let em; let em: RegExpExecArray | null;
while ((em = defaultNamedPattern.exec(content)) !== null) { while ((em = defaultNamedPattern.exec(content)) !== null) {
esmExports.add(em[1]); esmExports.add(em[1]);
} }
@@ -683,7 +710,7 @@ function intelExtractExports(filePath) {
// ─── Exports ───────────────────────────────────────────────────────────────── // ─── Exports ─────────────────────────────────────────────────────────────────
module.exports = { export = {
// Public API // Public API
intelQuery, intelQuery,
intelUpdate, intelUpdate,
@@ -704,5 +731,5 @@ module.exports = {
// Constants // Constants
INTEL_FILES, INTEL_FILES,
INTEL_DIR INTEL_DIR,
}; };

View File

@@ -9,16 +9,61 @@
* Storage format: { id, source_project, date, context, learning, tags, content_hash } * Storage format: { id, source_project, date, context, learning, tags, content_hash }
* File naming: {id}.json * File naming: {id}.json
* Deduplication: SHA-256 of learning text + source_project * 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'); // ─── Types ───────────────────────────────────────────────────────────────────
const path = require('path');
const crypto = require('crypto'); interface LearningRecord {
const os = require('os'); id: string;
const { output, error: coreError } = require('./core.cjs'); source_project: string;
const { platformWriteSync } = require('./shell-command-projection.cjs'); 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 ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
@@ -26,81 +71,41 @@ const DEFAULT_STORE_DIR = path.join(os.homedir(), '.gsd', 'knowledge');
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
/** function getStoreDir(opts?: WriteOpts | { storeDir?: string }): string {
* Get the store directory, allowing override for testing.
* @param {object} [opts]
* @param {string} [opts.storeDir] - Override store directory
* @returns {string}
*/
function getStoreDir(opts) {
return (opts && opts.storeDir) || DEFAULT_STORE_DIR; return (opts && opts.storeDir) || DEFAULT_STORE_DIR;
} }
/** function ensureStoreDir(dir: string): void {
* Ensure the store directory exists. Created on first write, not on install.
* @param {string} dir
*/
function ensureStoreDir(dir) {
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
} }
} }
/** function contentHash(learning: string, sourceProject: string): string {
* 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) {
return crypto.createHash('sha256') return crypto.createHash('sha256')
.update(learning + '\n' + sourceProject) .update(learning + '\n' + sourceProject)
.digest('hex'); .digest('hex');
} }
/** function generateId(): string {
* Generate a unique ID based on timestamp + random suffix.
* @returns {string}
*/
function generateId() {
const ts = Date.now().toString(36); const ts = Date.now().toString(36);
const rand = crypto.randomBytes(4).toString('hex'); const rand = crypto.randomBytes(4).toString('hex');
return `${ts}-${rand}`; return `${ts}-${rand}`;
} }
/** function readLearningFile(filePath: string): LearningRecord | null {
* 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) {
try { try {
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content); return JSON.parse(content) as LearningRecord;
} catch (err) { } 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; return null;
} }
} }
// ─── CRUD Operations ───────────────────────────────────────────────────────── // ─── CRUD Operations ─────────────────────────────────────────────────────────
/** function learningsWrite(entry: WriteEntry, opts?: WriteOpts): WriteResult {
* 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) {
const dir = getStoreDir(opts); const dir = getStoreDir(opts);
ensureStoreDir(dir); ensureStoreDir(dir);
@@ -108,17 +113,13 @@ function learningsWrite(entry, opts) {
// #306: In bulk-import paths, callers may supply a pre-built dedupeIndex // #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. // (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) { if (opts && opts.dedupeIndex) {
const dedupeIndex = opts.dedupeIndex; const dedupeIndex = opts.dedupeIndex;
if (dedupeIndex.has(hash)) { 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 id = generateId();
const record = { const record: LearningRecord = {
id, id,
source_project: entry.source_project, source_project: entry.source_project,
date: new Date().toISOString(), date: new Date().toISOString(),
@@ -142,7 +143,7 @@ function learningsWrite(entry, opts) {
} }
const id = generateId(); const id = generateId();
const record = { const record: LearningRecord = {
id, id,
source_project: entry.source_project, source_project: entry.source_project,
date: new Date().toISOString(), date: new Date().toISOString(),
@@ -156,15 +157,7 @@ function learningsWrite(entry, opts) {
return { id, created: true, content_hash: hash }; return { id, created: true, content_hash: hash };
} }
/** function learningsRead(id: string, opts?: { storeDir?: string }): LearningRecord | null {
* 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) {
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return null; if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return null;
const dir = getStoreDir(opts); const dir = getStoreDir(opts);
const filePath = path.join(dir, `${id}.json`); const filePath = path.join(dir, `${id}.json`);
@@ -172,19 +165,12 @@ function learningsRead(id, opts) {
return readLearningFile(filePath); return readLearningFile(filePath);
} }
/** function learningsList(opts?: { storeDir?: string }): LearningRecord[] {
* List all learnings, sorted by date (newest first).
*
* @param {object} [opts]
* @param {string} [opts.storeDir] - Override store directory
* @returns {object[]}
*/
function learningsList(opts) {
const dir = getStoreDir(opts); const dir = getStoreDir(opts);
if (!fs.existsSync(dir)) return []; if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
const results = []; const results: LearningRecord[] = [];
for (const file of files) { for (const file of files) {
const record = readLearningFile(path.join(dir, file)); const record = readLearningFile(path.join(dir, file));
if (record) results.push(record); if (record) results.push(record);
@@ -195,32 +181,15 @@ function learningsList(opts) {
return results; return results;
} }
/** function learningsQuery(query: { tag?: string }, opts?: { storeDir?: string }): LearningRecord[] {
* 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) {
const all = learningsList(opts); const all = learningsList(opts);
if (query && query.tag) { 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; return all;
} }
/** function learningsDelete(id: string, opts?: { storeDir?: string }): boolean {
* 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) {
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return false; if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return false;
const dir = getStoreDir(opts); const dir = getStoreDir(opts);
const filePath = path.join(dir, `${id}.json`); const filePath = path.join(dir, `${id}.json`);
@@ -229,24 +198,7 @@ function learningsDelete(id, opts) {
return true; return true;
} }
/** function learningsCopyFromProject(planningDir: string, opts?: WriteOpts & { sourceProject?: string }): CopyResult {
* 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) {
const learningsPath = path.join(planningDir, 'LEARNINGS.md'); const learningsPath = path.join(planningDir, 'LEARNINGS.md');
if (!fs.existsSync(learningsPath)) { if (!fs.existsSync(learningsPath)) {
return { total: 0, created: 0, skipped: 0 }; return { total: 0, created: 0, skipped: 0 };
@@ -260,7 +212,7 @@ function learningsCopyFromProject(planningDir, opts) {
// O(K*N) -> O(N+K). // O(K*N) -> O(N+K).
const dir = getStoreDir(opts); const dir = getStoreDir(opts);
ensureStoreDir(dir); ensureStoreDir(dir);
const dedupeIndex = new Map(); const dedupeIndex = new Map<string, string>();
for (const file of fs.readdirSync(dir).filter(f => f.endsWith('.json'))) { for (const file of fs.readdirSync(dir).filter(f => f.endsWith('.json'))) {
const existing = readLearningFile(path.join(dir, file)); const existing = readLearningFile(path.join(dir, file));
// First-seen-wins, matching the legacy scan path's first-match return so the // 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 }; return { total: created + skipped, created, skipped };
} }
/** function learningsPrune(olderThan: string, opts?: { storeDir?: string }): PruneResult {
* 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) {
const match = /^(\d+)d$/.exec(olderThan); const match = /^(\d+)d$/.exec(olderThan);
if (!match) { if (!match) {
throw new Error(`Invalid duration format: "${olderThan}" — expected format like "90d"`); throw new Error(`Invalid duration format: "${olderThan}" — expected format like "90d"`);
@@ -345,66 +289,40 @@ function learningsPrune(olderThan, opts) {
// ─── CLI Command Handlers ──────────────────────────────────────────────────── // ─── CLI Command Handlers ────────────────────────────────────────────────────
/** function cmdLearningsList(raw: boolean): void {
* Handle `gsd-tools learnings list`
* @param {boolean} raw - Raw output flag
*/
function cmdLearningsList(raw) {
const results = learningsList(); const results = learningsList();
output({ learnings: results, count: results.length }, raw); output({ learnings: results, count: results.length }, raw, undefined);
} }
/** function cmdLearningsQuery(tag: string, raw: boolean): void {
* Handle `gsd-tools learnings query --tag <tag>`
* @param {string} tag
* @param {boolean} raw - Raw output flag
*/
function cmdLearningsQuery(tag, raw) {
const results = learningsQuery({ tag }); const results = learningsQuery({ tag });
output({ learnings: results, count: results.length, tag }, raw); output({ learnings: results, count: results.length, tag }, raw, undefined);
} }
/** function cmdLearningsCopy(cwd: string, raw: boolean): void {
* Handle `gsd-tools learnings copy` const planDir = path.join(cwd, '.planning');
* @param {string} cwd - Current working directory const result = learningsCopyFromProject(planDir);
* @param {boolean} raw - Raw output flag output(result, raw, undefined);
*/
function cmdLearningsCopy(cwd, raw) {
const planningDir = path.join(cwd, '.planning');
const result = learningsCopyFromProject(planningDir);
output(result, raw);
} }
/** function cmdLearningsPrune(olderThan: string, raw: boolean): void {
* 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) {
try { try {
const result = learningsPrune(olderThan); const result = learningsPrune(olderThan);
output(result, raw); output(result, raw, undefined);
} catch (err) { } catch (err) {
coreError(err.message); coreError((err as Error).message);
} }
} }
/** function cmdLearningsDelete(id: string, raw: boolean): void {
* Handle `gsd-tools learnings delete <id>`
* @param {string} id
* @param {boolean} raw - Raw output flag
*/
function cmdLearningsDelete(id, raw) {
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) { if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) {
coreError(`Invalid learning ID: "${id}"`); coreError(`Invalid learning ID: "${id}"`);
} }
const deleted = learningsDelete(id); const deleted = learningsDelete(id);
output({ id, deleted }, raw); output({ id, deleted }, raw, undefined);
} }
// ─── Exports ───────────────────────────────────────────────────────────────── export = {
module.exports = {
learningsWrite, learningsWrite,
learningsRead, learningsRead,
learningsList, 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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, normalizePhaseName, phaseTokenMatches, output, error } = require('./core.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs'); import core = require('./core.cjs');
const { planningPaths } = require('./planning-workspace.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
const { extractFrontmatter } = require('./frontmatter.cjs'); import planningWorkspace = require('./planning-workspace.cjs');
const { writeStateMd, stateReplaceFieldWithFallback } = require('./state.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs'); 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) { if (!reqIdsRaw || reqIdsRaw.length === 0) {
error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02'); 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(' ') .join(' ')
.replace(/[\[\]]/g, '') .replace(/[\[\]]/g, '')
.split(/[,\s]+/) .split(/[,\s]+/)
.map(r => r.trim()) .map((r) => r.trim())
.filter(Boolean); .filter(Boolean);
if (reqIds.length === 0) { if (reqIds.length === 0) {
@@ -35,9 +62,9 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
} }
let reqContent = fs.readFileSync(reqPath, 'utf-8'); let reqContent = fs.readFileSync(reqPath, 'utf-8');
const updated = []; const updated: string[] = [];
const alreadyComplete = []; const alreadyComplete: string[] = [];
const notFound = []; const notFound: string[] = [];
for (const reqId of reqIds) { for (const reqId of reqIds) {
let found = false; let found = false;
@@ -80,16 +107,20 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
platformWriteSync(reqPath, reqContent); platformWriteSync(reqPath, reqContent);
} }
output({ output(
{
updated: updated.length > 0, updated: updated.length > 0,
marked_complete: updated, marked_complete: updated,
already_complete: alreadyComplete, already_complete: alreadyComplete,
not_found: notFound, not_found: notFound,
total: reqIds.length, 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) { if (!version) {
error('version required for milestone complete (e.g., v1.0)'); error('version required for milestone complete (e.g., v1.0)');
} }
@@ -124,27 +155,33 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
if (!options.force) { if (!options.force) {
try { try {
// Only guard when STATE.md's milestone field matches the version being completed. // Only guard when STATE.md's milestone field matches the version being completed.
let stateVersion = null; let stateVersion: string | null = null;
try { try {
const stateRaw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null; const stateRaw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
if (stateRaw) { if (stateRaw) {
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m); const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
if (milestoneMatch) stateVersion = milestoneMatch[1].trim(); if (milestoneMatch) stateVersion = milestoneMatch[1].trim();
} }
} catch { /* skip */ } } catch {
/* skip */
}
if (stateVersion && stateVersion === version) { if (stateVersion && stateVersion === version) {
const { extractCurrentMilestone } = require('./core.cjs'); const { extractCurrentMilestone } = core;
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const scopedContent = extractCurrentMilestone(roadmapContent, cwd); const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
const noDirectoryPhases = []; const noDirectoryPhases: string[] = [];
let pm; let pm: RegExpExecArray | null;
const phaseDirEntries = (() => { const phaseDirEntries = ((): string[] => {
try { try {
return fs.readdirSync(phasesDir, { withFileTypes: true }) return fs
.filter(e => e.isDirectory()).map(e => e.name); .readdirSync(phasesDir, { withFileTypes: true })
} catch { return []; } .filter((e) => e.isDirectory())
.map((e) => e.name);
} catch {
return [];
}
})(); })();
while ((pm = phasePattern.exec(scopedContent)) !== null) { while ((pm = phasePattern.exec(scopedContent)) !== null) {
const phaseNum = pm[1]; 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 // with a matching token exists on disk. Use the same phaseTokenMatches
// helper that roadmap.analyze uses to avoid false positives on decimal // helper that roadmap.analyze uses to avoid false positives on decimal
// (2.1) and letter-suffix (12A) phase IDs. // (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) { if (!hasDirectory) {
noDirectoryPhases.push(phaseNum); noDirectoryPhases.push(phaseNum);
} }
@@ -161,13 +198,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
if (noDirectoryPhases.length > 0) { if (noDirectoryPhases.length > 0) {
error( error(
`Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` + `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) { } catch (e) {
// If the error came from our guard, re-throw it; otherwise skip silently. // 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. // 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 phaseCount = 0;
let totalPlans = 0; let totalPlans = 0;
let totalTasks = 0; let totalTasks = 0;
const accomplishments = []; const accomplishments: string[] = [];
try { try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); 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) { for (const dir of dirs) {
if (!isDirInMilestone(dir)) continue; if (!isDirInMilestone(dir)) continue;
phaseCount++; phaseCount++;
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.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'); const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
totalPlans += plans.length; totalPlans += plans.length;
// Extract one-liners from summaries // Extract one-liners from summaries
@@ -196,7 +237,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
try { try {
const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8'); const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
const fm = extractFrontmatter(content); 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) { if (oneLiner) {
accomplishments.push(oneLiner); accomplishments.push(oneLiner);
} }
@@ -210,10 +252,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || []; const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
totalTasks += xmlTaskMatches.length || mdTaskMatches.length; totalTasks += xmlTaskMatches.length || mdTaskMatches.length;
} }
} catch { /* intentionally empty */ } } catch {
/* intentionally empty */
} }
} }
} catch { /* intentionally empty */ } }
} catch {
/* intentionally empty */
}
// Archive ROADMAP.md // Archive ROADMAP.md
if (fs.existsSync(roadmapPath)) { if (fs.existsSync(roadmapPath)) {
@@ -235,7 +281,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
} }
// Create/append MILESTONES.md entry // 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`; 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)) { 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, 'Status', null, `${version} milestone complete`);
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today); stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null, stateContent = stateReplaceFieldWithFallback(
`${version} milestone completed and archived`); stateContent,
'Last Activity Description',
null,
`${version} milestone completed and archived`,
);
// Reset Current Position narrative so resume/progress flows do not keep // Reset Current Position narrative so resume/progress flows do not keep
// pointing at closed-phase execution instructions. // pointing at closed-phase execution instructions.
@@ -277,7 +327,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
`Status: Awaiting next milestone\n` + `Status: Awaiting next milestone\n` +
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`; `Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
if (positionPattern.test(stateContent)) { if (positionPattern.test(stateContent)) {
stateContent = stateContent.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`); stateContent = stateContent.replace(positionPattern, (_m, header: string) => `${header}${closedPositionBody}`);
} else { } else {
stateContent = `${stateContent.trimEnd()}\n\n## Current Position\n${closedPositionBody}`; stateContent = `${stateContent.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
} }
@@ -287,10 +337,10 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
if (operatorPattern.test(stateContent)) { if (operatorPattern.test(stateContent)) {
stateContent = stateContent.replace( stateContent = stateContent.replace(
operatorPattern, 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 { } 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); writeStateMd(statePath, stateContent, cwd);
@@ -304,7 +354,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
platformEnsureDir(phaseArchiveDir); platformEnsureDir(phaseArchiveDir);
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true }); 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; let archivedCount = 0;
for (const dir of phaseDirNames) { for (const dir of phaseDirNames) {
if (!isDirInMilestone(dir)) continue; if (!isDirInMilestone(dir)) continue;
@@ -312,7 +362,9 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
archivedCount++; archivedCount++;
} }
phasesArchived = archivedCount > 0; phasesArchived = archivedCount > 0;
} catch { /* intentionally empty */ } } catch {
/* intentionally empty */
}
} }
const result = { const result = {
@@ -336,19 +388,19 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
output(result, raw); output(result, raw);
} }
function cmdPhasesClear(cwd, raw, args) { function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
const phasesDir = planningPaths(cwd).phases; const phasesDir = planningPaths(cwd).phases;
const confirm = Array.isArray(args) && args.includes('--confirm'); const confirm = Array.isArray(args) && args.includes('--confirm');
let cleared = 0; let cleared = 0;
if (fs.existsSync(phasesDir)) { if (fs.existsSync(phasesDir)) {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); 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) { if (dirs.length > 0 && !confirm) {
error( error(
`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` + `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++; cleared++;
} }
} catch (e) { } 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`); output({ cleared }, raw, `${cleared} phase director${cleared === 1 ? 'y' : 'ies'} cleared`);
} }
module.exports = { export = {
cmdRequirementsMarkComplete, cmdRequirementsMarkComplete,
cmdMilestoneComplete, cmdMilestoneComplete,
cmdPhasesClear, 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, MODEL_PROFILES,
VALID_PROFILES, VALID_PROFILES,
AGENT_TO_PHASE_TYPE, AGENT_TO_PHASE_TYPE,
@@ -13,9 +20,9 @@ const {
EFFORT_RENDERING, EFFORT_RENDERING,
renderEffortForRuntime, renderEffortForRuntime,
RUNTIMES_WITH_FAST_MODE, RUNTIMES_WITH_FAST_MODE,
} = require('./model-catalog.cjs'); } from './model-catalog.cjs';
module.exports = { export = {
MODEL_PROFILES, MODEL_PROFILES,
VALID_PROFILES, VALID_PROFILES,
AGENT_TO_PHASE_TYPE, 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). * DispatchLogger interface + default implementation — issue #177 (ADR-0174 P1.3).
* *
@@ -16,12 +14,16 @@
* *
* No-op logger (createNoOpLogger): * No-op logger (createNoOpLogger):
* Silent on all events. Used as the Hub default when no logger is injected. * 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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const { redactEvent, shouldIncludeArgs } = require('./redaction.cjs'); import { redactEvent } from './redaction.cjs';
const AUDIT_FILE_NAME = '.gsd-trace.jsonl'; const AUDIT_FILE_NAME = '.gsd-trace.jsonl';
const PLANNING_DIR = '.planning'; 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. * 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 { try {
return JSON.stringify(value); return JSON.stringify(value);
} catch { } catch {
@@ -43,12 +43,9 @@ function _safeStringify(value) {
/** /**
* Determine whether the audit file should be written to. * Determine whether the audit file should be written to.
*
* @param {{ audit?: { enabled?: boolean } } | undefined} config
* @returns {boolean}
*/ */
function _isAuditEnabled(config) { function _isAuditEnabled(config: { audit?: { enabled?: boolean } } | undefined): boolean {
if (process.env.GSD_AUDIT === '1') return true; if (process.env['GSD_AUDIT'] === '1') return true;
if (config && config.audit && config.audit.enabled === true) return true; if (config && config.audit && config.audit.enabled === true) return true;
return false; return false;
} }
@@ -56,11 +53,8 @@ function _isAuditEnabled(config) {
/** /**
* Build the redacted plain object for the audit file. * Build the redacted plain object for the audit file.
* Preserves the full DispatchEvent structure. * 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); return redactEvent(event);
} }
@@ -70,15 +64,13 @@ function _toAuditRecord(event) {
* Per ADR-0174 P1.3 contract: { "kind": "<variant>", "traceId": "<uuid>", ...typedPayload } * 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's kind is promoted to top-level and the typed payload fields are spread in.
* The `result` wrapper is removed. * 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 redacted = redactEvent(event);
const { result, ...eventWithoutResult } = redacted; const { result, ...eventWithoutResult } = redacted;
// Flatten: top-level gets kind + typed payload fields from result // 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); return Object.assign({}, eventWithoutResult, { kind }, typedPayload);
} }
@@ -87,11 +79,8 @@ function _toStderrRecord(event) {
* Creates .planning/ directory if it does not exist. * Creates .planning/ directory if it does not exist.
* *
* Uses synchronous fs API (crash-safe for v1 — dispatch is synchronous). * 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); const planningDir = path.join(cwd, PLANNING_DIR);
// Ensure the directory exists // Ensure the directory exists
if (!fs.existsSync(planningDir)) { if (!fs.existsSync(planningDir)) {
@@ -103,35 +92,38 @@ function _appendAuditLine(cwd, event) {
// ─── Public factories ───────────────────────────────────────────────────────── // ─── Public factories ─────────────────────────────────────────────────────────
interface DispatchLogger {
onEvent(event: Record<string, unknown>): void;
}
/** /**
* Create a no-op logger. All events are silently dropped. * Create a no-op logger. All events are silently dropped.
* This is the Hub's default when no logger is injected by the caller. * 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 { return {
onEvent(_event) { onEvent(_event: Record<string, unknown>): void {
// intentionally empty // intentionally empty
}, },
}; };
} }
interface DefaultLoggerOptions {
cwd?: string;
config?: { audit?: { enabled?: boolean } };
}
/** /**
* Create the default DispatchLogger. * 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 { return {
/** /**
* @param {object} event - A DispatchEvent from the Hub. * @param event - A DispatchEvent from the Hub.
*/ */
onEvent(event) { onEvent(event: Record<string, unknown>): void {
const isOk = event && event.result && event.result.kind === 'ok'; const resultObj = event && (event['result'] as Record<string, unknown> | undefined);
const isOk = resultObj && resultObj['kind'] === 'ok';
// ── Audit file (both ok and error) ──────────────────────────────────── // ── Audit file (both ok and error) ────────────────────────────────────
if (_isAuditEnabled(config)) { if (_isAuditEnabled(config)) {
@@ -144,7 +136,7 @@ function createDefaultLogger({ cwd = process.cwd(), config } = {}) {
_safeStringify({ _safeStringify({
level: 'warn', level: 'warn',
source: 'DispatchLogger', 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' }) + '\n'
); );
} }
@@ -161,7 +153,7 @@ function createDefaultLogger({ cwd = process.cwd(), config } = {}) {
_safeStringify({ _safeStringify({
level: 'warn', level: 'warn',
source: 'DispatchLogger', source: 'DispatchLogger',
message: 'stderr emit failed: ' + String(stderrErr && stderrErr.message || stderrErr), message: 'stderr emit failed: ' + String((stderrErr as Error | null)?.message ?? stderrErr),
}) + '\n' }) + '\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 * 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. * 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. * 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. * Returns true when the caller has opted in to including args in events.
* Only GSD_AUDIT_ARGS === '1' enables inclusion; any other value (including * Only GSD_AUDIT_ARGS === '1' enables inclusion; any other value (including
* empty string, 'true', 'yes') keeps the default of omitting args. * 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'; 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). * The original event object is never mutated (it is frozen by makeDispatchEvent).
* *
* @param {object} event - A DispatchEvent (frozen or plain). * @param event - A DispatchEvent (frozen or plain).
* @returns {object} A new plain object with the same fields, minus args when redacted. * @returns A new plain object with the same fields, minus args when redacted.
*/ */
function redactEvent(event) { export function redactEvent(event: DispatchEvent): DispatchEvent {
if (shouldIncludeArgs()) { if (shouldIncludeArgs()) {
// Include path: return a shallow copy with args preserved if present // Include path: return a shallow copy with args preserved if present
const copy = Object.assign({}, event); const copy = Object.assign({}, event);
@@ -46,5 +50,3 @@ function redactEvent(event) {
const { args: _dropped, ...rest } = event; const { args: _dropped, ...rest } = event;
return rest; 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. * Manifest-backed phase subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics. * 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 * #3788: dispatch is mediated by CommandRoutingHub. The public entry point
* and observable CLI behaviour are unchanged. * 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 ───────────────────────────────────────────────── // ── Unsupported subcommands ─────────────────────────────────────────────────
// Resolved before dispatch so the error message stays deterministic. // 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.', 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. // Each handler receives a ctx object from the hub and must return a HubResult.
const cjsRegistry = { const cjsRegistry = {
phase: { phase: {
'next-decimal': (_ctx) => { 'next-decimal': (_ctx: Record<string, unknown>): { ok: true; data: null } => {
phase.cmdPhaseNextDecimal(cwd, args[2], raw); phase.cmdPhaseNextDecimal(cwd, args[2], raw);
return { ok: true, data: null }; return { ok: true as const, data: null };
}, },
add: (_ctx) => { add: (_ctx: Record<string, unknown>) => {
let customId = null; let customId: string | null = null;
const descArgs = []; const descArgs: string[] = [];
for (let i = 2; i < args.length; i++) { for (let i = 2; i < args.length; i++) {
const token = args[i]; const token = args[i];
if (token === '--raw') { if (token === '--raw') {
@@ -82,18 +109,18 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
} }
} }
phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId); 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'); const descFlagIdx = args.indexOf('--descriptions');
let descriptions; let descriptions: string[];
if (descFlagIdx !== -1) { if (descFlagIdx !== -1) {
const rawDescriptions = args[descFlagIdx + 1]; const rawDescriptions = args[descFlagIdx + 1];
if (!rawDescriptions || rawDescriptions.startsWith('--')) { if (!rawDescriptions || rawDescriptions.startsWith('--')) {
return makeInvalidArgs('--descriptions', '--descriptions must be a JSON array'); return makeInvalidArgs('--descriptions', '--descriptions must be a JSON array');
} }
try { try {
descriptions = JSON.parse(rawDescriptions); descriptions = JSON.parse(rawDescriptions) as string[];
} catch { } catch {
return makeInvalidArgs('--descriptions', '--descriptions must be a JSON array'); 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'); descriptions = args.slice(2).filter(a => a !== '--raw');
} }
phase.cmdPhaseAddBatch(cwd, descriptions, 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')) { if (args.includes('--dry-run')) {
return makeInvalidArgs('--dry-run', 'phase insert does not support --dry-run'); return makeInvalidArgs('--dry-run', 'phase insert does not support --dry-run');
} }
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw); 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'); const removeArgs = args.slice(2).filter(token => token !== '--raw');
let forceFlag = false; let forceFlag = false;
const positional = []; const positional: string[] = [];
for (const token of removeArgs) { for (const token of removeArgs) {
if (token === '--force') { if (token === '--force') {
forceFlag = true; forceFlag = true;
@@ -131,11 +158,11 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
return makeInvalidArgs('<phase-number>', 'phase remove accepts exactly one phase number'); return makeInvalidArgs('<phase-number>', 'phase remove accepts exactly one phase number');
} }
phase.cmdPhaseRemove(cwd, positional[0], { force: forceFlag }, raw); 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); 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, routePhaseCommand,
}; };

View File

@@ -1,8 +1,9 @@
'use strict';
/** /**
* Phase Lifecycle Pure Helpers — pure-computation functions extracted from * 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 * 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 * (sync readFileSync for CJS, async readFile for SDK); the pure computation
@@ -19,14 +20,21 @@
* - Issue #4 (open-gsd/gsd-core) * - 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. * 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. * Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
*/ */
function deriveProgressFromRoadmap(roadmapContent) { export function deriveProgressFromRoadmap(roadmapContent: string): RoadmapProgress {
let completedPhases = null; let completedPhases: number | null = null;
let totalPhases = null; let totalPhases: number | null = null;
let totalPlans = null; let totalPlans: number | null = null;
try { try {
// Count Complete rows in the progress table (Status column = "Complete"). // 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 // Sum plan counts from M/N columns in progress table
let totalPlansSum = 0; let totalPlansSum = 0;
const planCellPattern = /\|\s*\d+[^|]*\|\s*(\d+)\/(\d+)\s*\|/gi; const planCellPattern = /\|\s*\d+[^|]*\|\s*(\d+)\/(\d+)\s*\|/gi;
let pm; let pm: RegExpExecArray | null;
while ((pm = planCellPattern.exec(roadmapContent)) !== null) { while ((pm = planCellPattern.exec(roadmapContent)) !== null) {
totalPlansSum += parseInt(pm[2], 10); totalPlansSum += parseInt(pm[2], 10);
} }
@@ -68,12 +76,7 @@ function deriveProgressFromRoadmap(roadmapContent) {
* Compute progress percent clamped to 100. * Compute progress percent clamped to 100.
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation. * 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; if (!total || total <= 0) return 0;
return Math.min(100, Math.round((completed / total) * 100)); 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 pointer policy/session identity lives in
* active-workstream-store.cjs and is consumed here via thin adapters. * 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'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const { platformEnsureDir } = require('./shell-command-projection.cjs'); import { platformEnsureDir } from './shell-command-projection.cjs';
const { realClock } = require('./clock.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 { const {
createSharedPointerAdapter, createSharedPointerAdapter,
createSessionScopedPointerAdapter, createSessionScopedPointerAdapter,
@@ -20,10 +27,10 @@ const {
getActiveWorkstream: getStoredActiveWorkstream, getActiveWorkstream: getStoredActiveWorkstream,
setActiveWorkstream: setStoredActiveWorkstream, setActiveWorkstream: setStoredActiveWorkstream,
clearActiveWorkstream: clearStoredActiveWorkstream, clearActiveWorkstream: clearStoredActiveWorkstream,
} = require('./active-workstream-store.cjs'); } = activeWorkstreamStore;
// Track .planning/.lock files held by this process so they can be removed on exit. // 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', () => { process.on('exit', () => {
for (const lockPath of _heldPlanningLocks) { for (const lockPath of _heldPlanningLocks) {
try { fs.unlinkSync(lockPath); } catch { /* already gone */ } 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) 'ESTALE', // NFS: stale file handle (self-resolves on retry)
]); ]);
function planningDir(cwd, ws, project) { // Loose opts type accepted by createPlanningWorkspace — passed through to
if (project === undefined) project = process.env.GSD_PROJECT || null; // active-workstream-store get/set/clear which accept { activeWorkstreamAdapter?,
if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null; // 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 // Reject path separators and traversal components in project/workstream names
const BAD_SEGMENT = /[/\\]|\.\./; const BAD_SEGMENT = /[/\\]|\.\./;
@@ -66,11 +79,21 @@ function planningDir(cwd, ws, project) {
return base; return base;
} }
function planningRoot(cwd) { function planningRoot(cwd: string): string {
return path.join(cwd, '.planning'); 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); const base = planningDir(cwd, ws);
return { return {
planning: base, planning: base,
@@ -84,14 +107,14 @@ function planningPaths(cwd, ws) {
} }
/** /**
* @param {string} cwd * @param cwd
* @param {function} fn - callback to run while holding the lock * @param fn - callback to run while holding the lock
* @param {{ now(): number, sleep(ms: number): void }} [clock] * @param clock
* Optional clock seam for testing. Defaults to realClock (Date.now + Atomics.wait). * 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 * Pass a fake clock from tests/helpers/clock.cjs to drive timeout/stale logic
* without real wall-clock waits. * 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; if (clock === undefined) clock = realClock;
const lockPath = path.join(planningDir(cwd), '.lock'); const lockPath = path.join(planningDir(cwd), '.lock');
const lockTimeout = 10000; // 10 seconds const lockTimeout = 10000; // 10 seconds
@@ -100,7 +123,7 @@ function withPlanningLock(cwd, fn, clock) {
// Ensure .planning/ exists // Ensure .planning/ exists
try { platformEnsureDir(planningDir(cwd)); } catch { /* ok */ } try { platformEnsureDir(planningDir(cwd)); } catch { /* ok */ }
function acquireLock() { function acquireLock(): void {
// Atomic create — fails if file exists // Atomic create — fails if file exists
fs.writeFileSync(lockPath, JSON.stringify({ fs.writeFileSync(lockPath, JSON.stringify({
pid: process.pid, pid: process.pid,
@@ -111,7 +134,7 @@ function withPlanningLock(cwd, fn, clock) {
_heldPlanningLocks.add(lockPath); _heldPlanningLocks.add(lockPath);
} }
function runWithHeldLock() { function runWithHeldLock(): T {
try { try {
return fn(); return fn();
} finally { } finally {
@@ -131,11 +154,12 @@ function withPlanningLock(cwd, fn, clock) {
// are recoverable — wait and retry rather than propagating. // are recoverable — wait and retry rather than propagating.
// See PLANNING_LOCK_RETRY_ERRNOS for the full list and rationale. // See PLANNING_LOCK_RETRY_ERRNOS for the full list and rationale.
if (lockWasAcquired) throw err; 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); clock.sleep(100);
continue; continue;
} }
if (err.code === 'EEXIST') { if (nodeErr.code === 'EEXIST') {
// Lock exists — check if stale (>30s old) // Lock exists — check if stale (>30s old)
try { try {
const stat = fs.statSync(lockPath); const stat = fs.statSync(lockPath);
@@ -159,16 +183,27 @@ function withPlanningLock(cwd, fn, clock) {
return runWithHeldLock(); 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 { return {
paths: { paths: {
dir(ws, project) { dir(ws?: string | null, project?: string | null) {
return planningDir(cwd, ws, project); return planningDir(cwd, ws, project);
}, },
root() { root() {
return planningRoot(cwd); return planningRoot(cwd);
}, },
all(ws) { all(ws?: string | null) {
return planningPaths(cwd, ws); return planningPaths(cwd, ws);
}, },
}, },
@@ -176,7 +211,7 @@ function createPlanningWorkspace(cwd, opts = {}) {
get() { get() {
return getStoredActiveWorkstream(cwd, opts); return getStoredActiveWorkstream(cwd, opts);
}, },
set(name) { set(name: string) {
setStoredActiveWorkstream(cwd, name, opts); setStoredActiveWorkstream(cwd, name, opts);
}, },
clear() { clear() {
@@ -186,11 +221,11 @@ function createPlanningWorkspace(cwd, opts = {}) {
}; };
} }
function getActiveWorkstream(cwd) { function getActiveWorkstream(cwd: string): string | null {
return getStoredActiveWorkstream(cwd); return getStoredActiveWorkstream(cwd);
} }
function setActiveWorkstream(cwd, name) { function setActiveWorkstream(cwd: string, name: string): void {
setStoredActiveWorkstream(cwd, name); setStoredActiveWorkstream(cwd, name);
} }
@@ -206,24 +241,23 @@ function setActiveWorkstream(cwd, name) {
* duplication that previously existed across init.cjs, roadmap.cjs, * duplication that previously existed across init.cjs, roadmap.cjs,
* core.cjs, gap-checker.cjs (#3739). * 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 * OR an already-read files array (avoids a redundant readdirSync at call sites
* that already hold a directory listing). * that already hold a directory listing).
* @returns {string|null}
*/ */
function findContextMdIn(absDirOrFiles) { function findContextMdIn(absDirOrFiles: string | string[]): string | null {
try { try {
const files = Array.isArray(absDirOrFiles) const files = Array.isArray(absDirOrFiles)
? absDirOrFiles ? absDirOrFiles
: fs.readdirSync(absDirOrFiles); : fs.readdirSync(absDirOrFiles);
if (files.includes('CONTEXT.md')) return 'CONTEXT.md'; 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 { } catch {
return null; return null;
} }
} }
module.exports = { export = {
createPlanningWorkspace, createPlanningWorkspace,
createSharedPointerAdapter, createSharedPointerAdapter,
createSessionScopedPointerAdapter, createSessionScopedPointerAdapter,

View File

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

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