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:
7
.changeset/code-review-flags-ts-migration.md
Normal file
7
.changeset/code-review-flags-ts-migration.md
Normal 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. -->
|
||||
7
.changeset/code-review-leaf-batch-1-ts.md
Normal file
7
.changeset/code-review-leaf-batch-1-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-10-ts.md
Normal file
7
.changeset/migration-batch-10-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-11-ts.md
Normal file
7
.changeset/migration-batch-11-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-12-ts.md
Normal file
7
.changeset/migration-batch-12-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-13-ts.md
Normal file
7
.changeset/migration-batch-13-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-14-ts.md
Normal file
7
.changeset/migration-batch-14-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-15-ts.md
Normal file
7
.changeset/migration-batch-15-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-2-ts.md
Normal file
7
.changeset/migration-batch-2-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-3-ts.md
Normal file
7
.changeset/migration-batch-3-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-4-ts.md
Normal file
7
.changeset/migration-batch-4-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-5-ts.md
Normal file
7
.changeset/migration-batch-5-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-6-ts.md
Normal file
7
.changeset/migration-batch-6-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-7-ts.md
Normal file
7
.changeset/migration-batch-7-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-batch-8-ts.md
Normal file
7
.changeset/migration-batch-8-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-core-ts.md
Normal file
7
.changeset/migration-core-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-finalize-ts.md
Normal file
7
.changeset/migration-finalize-ts.md
Normal 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. -->
|
||||
7
.changeset/migration-milestone-ts.md
Normal file
7
.changeset/migration-milestone-ts.md
Normal 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. -->
|
||||
163
.github/workflows/mutation.yml
vendored
163
.github/workflows/mutation.yml
vendored
@@ -1,10 +1,11 @@
|
||||
name: Mutation Testing
|
||||
|
||||
# PR-GATING: runs on every pull_request targeting `next` or `main`.
|
||||
# Computes changed core lib files via git diff and passes them to --mutate,
|
||||
# so only mutants in CHANGED files are tested — keeps the job bounded.
|
||||
# If no core lib files changed, the gate passes trivially (skip+exit 0).
|
||||
# Full-repo mutation runs are reserved for local exploration (npm run test:mutation).
|
||||
# scripts/mutation-matrix.cjs is the single source of truth for which modules
|
||||
# are "covered" (have meaningful test coverage for mutation). It computes
|
||||
# changed modules from git diff and emits a GitHub Actions matrix so each
|
||||
# changed module gets its own Stryker shard running in parallel.
|
||||
# If no covered modules changed the gate passes trivially (has_work: false).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -12,10 +13,15 @@ on:
|
||||
- next
|
||||
- main
|
||||
paths:
|
||||
# Only run when lib source or property tests change
|
||||
# Only run when lib source, property/unit tests, or mutation config change
|
||||
- 'src/**/*.cts'
|
||||
- 'get-shit-done/bin/lib/**/*.cjs'
|
||||
- 'tests/**/*.property.test.cjs'
|
||||
- 'tests/**/*.unit.test.cjs'
|
||||
- 'tests/adr-parser.test.cjs'
|
||||
- 'tests/active-workstream-store.test.cjs'
|
||||
- 'stryker.config.mjs'
|
||||
- 'scripts/mutation-matrix.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -26,10 +32,60 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mutation:
|
||||
name: Stryker mutation score (changed files only)
|
||||
# ── Job 1: detect ─────────────────────────────────────────────────────────
|
||||
# Computes which covered modules changed and emits a matrix for the mutate job.
|
||||
# Intentionally does NOT run npm ci — it only needs git + Node builtins.
|
||||
detect:
|
||||
name: Detect changed covered modules
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
has_work: ${{ steps.matrix.outputs.has_work }}
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
- name: Fetch base ref for diff
|
||||
# Ensure the base branch tip is available for the git diff below.
|
||||
# fetch-depth: 0 above gets all history, but the remote ref name must exist.
|
||||
# For workflow_dispatch (no base_ref) we fall back to `next`.
|
||||
run: git fetch origin ${{ github.base_ref || 'next' }} --depth=1
|
||||
|
||||
- name: Compute mutation matrix
|
||||
id: matrix
|
||||
run: |
|
||||
BASE_REF="origin/${{ github.base_ref || 'next' }}"
|
||||
# Run the matrix script; capture the JSON output.
|
||||
JSON=$(node scripts/mutation-matrix.cjs --base "${BASE_REF}")
|
||||
|
||||
# Extract has_work and the compact matrix string for GITHUB_OUTPUT.
|
||||
HAS_WORK=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).has_work)" <<< "${JSON}")
|
||||
MATRIX=$(node -e "process.stdout.write(JSON.stringify(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).matrix))" <<< "${JSON}")
|
||||
|
||||
echo "has_work=${HAS_WORK}" >> "${GITHUB_OUTPUT}"
|
||||
echo "matrix=${MATRIX}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Human-readable summary for the step log.
|
||||
echo "has_work=${HAS_WORK}"
|
||||
echo "${JSON}"
|
||||
|
||||
# ── Job 2: mutate ──────────────────────────────────────────────────────────
|
||||
# One shard per changed covered module, each running only that module's tests.
|
||||
# Skipped entirely when detect reports no covered files changed.
|
||||
mutate:
|
||||
name: Stryker (${{ matrix.name }})
|
||||
needs: detect
|
||||
if: needs.detect.outputs.has_work == 'true'
|
||||
# GitHub-hosted runners only. Speed comes from running shards in PARALLEL
|
||||
# (one job per changed module), not from larger/3rd-party runners.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15 # per-shard; lower than the old 30-min serial budget
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -43,52 +99,69 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install dependencies (builds .cjs via prepare)
|
||||
run: npm ci
|
||||
|
||||
- name: Fetch base ref for diff
|
||||
# Ensure the base branch tip is available for the git diff below.
|
||||
# fetch-depth: 0 above gets all history, but the remote ref name must exist.
|
||||
run: git fetch origin ${{ github.base_ref }} --depth=1
|
||||
|
||||
- name: Compute changed core lib files
|
||||
id: changed
|
||||
run: |
|
||||
BASE_REF="origin/${{ github.base_ref }}"
|
||||
# Find changed non-test, non-generated .cjs files in bin/lib
|
||||
CHANGED=$(git diff --name-only "${BASE_REF}...HEAD" -- 'get-shit-done/bin/lib/**/*.cjs' \
|
||||
| grep -v '\.test\.cjs$' \
|
||||
| grep -v -E '/(configuration|command-aliases|commands|core|install-profiles|installer-migrations|phase|profile-output|state|verify|init|audit|gsd2-import)\.cjs$' \
|
||||
|| true)
|
||||
if [ -z "$CHANGED" ]; then
|
||||
echo "changed=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Join with commas for --mutate
|
||||
MUTATE_LIST=$(echo "$CHANGED" | paste -sd, -)
|
||||
echo "changed=${MUTATE_LIST}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Skip mutation gate (no core lib files changed)
|
||||
if: steps.changed.outputs.changed == ''
|
||||
run: echo "No core lib files changed; skipping mutation gate"
|
||||
|
||||
- name: Run Stryker (incremental, changed files only)
|
||||
if: steps.changed.outputs.changed != ''
|
||||
# --mutate scopes mutation to only the changed production files.
|
||||
- name: Run Stryker — ${{ matrix.name }}
|
||||
# MUTATION_TEST_CMD scopes the command runner to only this module's tests.
|
||||
# --mutate scopes mutation to only the changed module's built artifact.
|
||||
# --incremental reuses cached results for unchanged mutants.
|
||||
# The break threshold (50) is read from stryker.config.mjs and causes
|
||||
# Stryker to exit non-zero when mutation score < 50%, failing the PR check.
|
||||
# The break threshold (50) is read from stryker.config.mjs.
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
MUTATION_TEST_CMD: node --test ${{ matrix.tests }}
|
||||
run: |
|
||||
MUTATE_LIST="${{ steps.changed.outputs.changed }}"
|
||||
echo "Running Stryker --mutate '${MUTATE_LIST}'"
|
||||
npx stryker run --incremental --mutate "${MUTATE_LIST}"
|
||||
echo "Module: ${{ matrix.name }}"
|
||||
echo "Mutate: ${{ matrix.mutate }}"
|
||||
echo "Tests: ${{ matrix.tests }}"
|
||||
npx stryker run --incremental --mutate "${{ matrix.mutate }}"
|
||||
|
||||
- name: Upload mutation HTML report
|
||||
- name: Upload mutation report — ${{ matrix.name }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always() # upload even on failure so the score is visible
|
||||
with:
|
||||
name: mutation-report-${{ github.run_number }}
|
||||
name: mutation-report-${{ matrix.name }}-${{ github.run_number }}
|
||||
path: reports/mutation/mutation.html
|
||||
retention-days: 14
|
||||
|
||||
# ── Job 3: mutation-gate ───────────────────────────────────────────────────
|
||||
# Stable required-check name for branch protection. Passes when:
|
||||
# • has_work is false (nothing to mutate — trivial pass), OR
|
||||
# • all mutate shards succeeded.
|
||||
# Fails when any shard failed.
|
||||
# Summary job keeps the LEGACY check name so existing branch protection
|
||||
# (which requires "Stryker mutation score (changed files only)") needs no
|
||||
# change. The per-module shards report as "Stryker (<module>)".
|
||||
mutation-gate:
|
||||
name: Stryker mutation score (changed files only)
|
||||
needs: [detect, mutate]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate gate
|
||||
run: |
|
||||
DETECT="${{ needs.detect.result }}"
|
||||
MUTATE="${{ needs.mutate.result }}"
|
||||
HAS_WORK="${{ needs.detect.outputs.has_work }}"
|
||||
|
||||
echo "detect result : ${DETECT}"
|
||||
echo "mutate result : ${MUTATE}"
|
||||
echo "has_work : ${HAS_WORK}"
|
||||
|
||||
if [ "${DETECT}" != "success" ]; then
|
||||
echo "FAIL: detect job did not succeed (${DETECT})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${HAS_WORK}" = "false" ]; then
|
||||
echo "PASS: no covered modules changed — gate trivially green"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${MUTATE}" = "success" ]; then
|
||||
echo "PASS: all mutation shards passed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "FAIL: one or more mutation shards failed or were cancelled (${MUTATE})"
|
||||
exit 1
|
||||
|
||||
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@@ -96,6 +96,11 @@ jobs:
|
||||
node-version: 24
|
||||
- name: Install dev dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
# ADR-457 build-at-publish: bin/lib/*.cjs are gitignored, built by tsc.
|
||||
# --ignore-scripts skips the prepare build, but lint:skill-deps (and other
|
||||
# lint scripts) require() the built modules — so build them explicitly.
|
||||
- name: Build runtime lib (required by lint scripts)
|
||||
run: npm run build:lib
|
||||
- name: Lint — ESLint (source-grep + timing + no-only-tests + quality)
|
||||
run: npx eslint . --cache --cache-location node_modules/.cache/eslint/
|
||||
- name: Lint — skill dependency graph
|
||||
|
||||
85
.gitignore
vendored
85
.gitignore
vendored
@@ -67,6 +67,91 @@ build/
|
||||
# by `npm run build:lib`). Source of truth is src/; these are emitted, never edited.
|
||||
# Published via prepublishOnly; built before test via pretest. Grows as modules migrate.
|
||||
/get-shit-done/bin/lib/semver-compare.cjs
|
||||
/get-shit-done/bin/lib/config-types.cjs
|
||||
/get-shit-done/bin/lib/code-review-flags.cjs
|
||||
/get-shit-done/bin/lib/context-utilization.cjs
|
||||
/get-shit-done/bin/lib/artifacts.cjs
|
||||
/get-shit-done/bin/lib/command-arg-projection.cjs
|
||||
/get-shit-done/bin/lib/clock.cjs
|
||||
/get-shit-done/bin/lib/ui-safety-gate.cjs
|
||||
/get-shit-done/bin/lib/review-reviewer-selection.cjs
|
||||
/get-shit-done/bin/lib/clusters.cjs
|
||||
/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs
|
||||
/get-shit-done/bin/lib/observability/redaction.cjs
|
||||
/get-shit-done/bin/lib/installer-migration-report.cjs
|
||||
/get-shit-done/bin/lib/prompt-budget.cjs
|
||||
/get-shit-done/bin/lib/secrets.cjs
|
||||
/get-shit-done/bin/lib/phase-lifecycle.cjs
|
||||
/get-shit-done/bin/lib/workstream-name-policy.cjs
|
||||
/get-shit-done/bin/lib/decisions.cjs
|
||||
/get-shit-done/bin/lib/validate.cjs
|
||||
/get-shit-done/bin/lib/schema-detect.cjs
|
||||
/get-shit-done/bin/lib/runtime-name-policy.cjs
|
||||
/get-shit-done/bin/lib/runtime-slash.cjs
|
||||
/get-shit-done/bin/lib/observability/event.cjs
|
||||
/get-shit-done/bin/lib/workstream-inventory-builder.cjs
|
||||
/get-shit-done/bin/lib/plan-scan.cjs
|
||||
/get-shit-done/bin/lib/fallow-runner.cjs
|
||||
/get-shit-done/bin/lib/project-root.cjs
|
||||
/get-shit-done/bin/lib/installer-migration-authoring.cjs
|
||||
/get-shit-done/bin/lib/update-context.cjs
|
||||
/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs
|
||||
/get-shit-done/bin/lib/runtime-homes.cjs
|
||||
/get-shit-done/bin/lib/model-catalog.cjs
|
||||
/get-shit-done/bin/lib/configuration.cjs
|
||||
/get-shit-done/bin/lib/state-document.cjs
|
||||
/get-shit-done/bin/lib/shell-command-projection.cjs
|
||||
/get-shit-done/bin/lib/security.cjs
|
||||
/get-shit-done/bin/lib/command-aliases.cjs
|
||||
/get-shit-done/bin/lib/config-schema.cjs
|
||||
/get-shit-done/bin/lib/model-profiles.cjs
|
||||
/get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs
|
||||
/get-shit-done/bin/lib/observability/logger.cjs
|
||||
/get-shit-done/bin/lib/active-workstream-store.cjs
|
||||
/get-shit-done/bin/lib/adr-parser.cjs
|
||||
/get-shit-done/bin/lib/graphify.cjs
|
||||
/get-shit-done/bin/lib/install-profiles.cjs
|
||||
/get-shit-done/bin/lib/intel.cjs
|
||||
/get-shit-done/bin/lib/installer-migrations.cjs
|
||||
/get-shit-done/bin/lib/worktree-safety.cjs
|
||||
/get-shit-done/bin/lib/planning-workspace.cjs
|
||||
/get-shit-done/bin/lib/runtime-artifact-layout.cjs
|
||||
/get-shit-done/bin/lib/command-routing-hub.cjs
|
||||
/get-shit-done/bin/lib/core.cjs
|
||||
/get-shit-done/bin/lib/drift.cjs
|
||||
/get-shit-done/bin/lib/cjs-command-router-adapter.cjs
|
||||
/get-shit-done/bin/lib/phase-command-router.cjs
|
||||
/get-shit-done/bin/lib/surface.cjs
|
||||
/get-shit-done/bin/lib/gap-checker.cjs
|
||||
/get-shit-done/bin/lib/docs.cjs
|
||||
/get-shit-done/bin/lib/check-command-router.cjs
|
||||
/get-shit-done/bin/lib/frontmatter.cjs
|
||||
/get-shit-done/bin/lib/learnings.cjs
|
||||
/get-shit-done/bin/lib/gsd2-import.cjs
|
||||
/get-shit-done/bin/lib/profile-pipeline.cjs
|
||||
/get-shit-done/bin/lib/roadmap-upgrade.cjs
|
||||
/get-shit-done/bin/lib/phases-command-router.cjs
|
||||
/get-shit-done/bin/lib/verify-command-router.cjs
|
||||
/get-shit-done/bin/lib/init-command-router.cjs
|
||||
/get-shit-done/bin/lib/agent-command-router.cjs
|
||||
/get-shit-done/bin/lib/task-command-router.cjs
|
||||
/get-shit-done/bin/lib/validate-command-router.cjs
|
||||
/get-shit-done/bin/lib/workstream-inventory.cjs
|
||||
/get-shit-done/bin/lib/roadmap-command-router.cjs
|
||||
/get-shit-done/bin/lib/state-command-router.cjs
|
||||
/get-shit-done/bin/lib/config.cjs
|
||||
/get-shit-done/bin/lib/profile-output.cjs
|
||||
/get-shit-done/bin/lib/template.cjs
|
||||
/get-shit-done/bin/lib/commands.cjs
|
||||
/get-shit-done/bin/lib/state.cjs
|
||||
/get-shit-done/bin/lib/milestone.cjs
|
||||
/get-shit-done/bin/lib/phase.cjs
|
||||
/get-shit-done/bin/lib/verify.cjs
|
||||
/get-shit-done/bin/lib/init.cjs
|
||||
/get-shit-done/bin/lib/uat.cjs
|
||||
/get-shit-done/bin/lib/workstream.cjs
|
||||
/get-shit-done/bin/lib/roadmap.cjs
|
||||
/get-shit-done/bin/lib/audit.cjs
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
@@ -35,6 +35,91 @@ export default tseslint.config(
|
||||
'**/*.generated.cjs',
|
||||
// ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs.
|
||||
'get-shit-done/bin/lib/semver-compare.cjs',
|
||||
'get-shit-done/bin/lib/code-review-flags.cjs',
|
||||
'get-shit-done/bin/lib/context-utilization.cjs',
|
||||
'get-shit-done/bin/lib/artifacts.cjs',
|
||||
'get-shit-done/bin/lib/command-arg-projection.cjs',
|
||||
'get-shit-done/bin/lib/clock.cjs',
|
||||
'get-shit-done/bin/lib/ui-safety-gate.cjs',
|
||||
'get-shit-done/bin/lib/review-reviewer-selection.cjs',
|
||||
'get-shit-done/bin/lib/clusters.cjs',
|
||||
'get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs',
|
||||
'get-shit-done/bin/lib/observability/redaction.cjs',
|
||||
'get-shit-done/bin/lib/installer-migration-report.cjs',
|
||||
'get-shit-done/bin/lib/prompt-budget.cjs',
|
||||
'get-shit-done/bin/lib/secrets.cjs',
|
||||
'get-shit-done/bin/lib/phase-lifecycle.cjs',
|
||||
'get-shit-done/bin/lib/workstream-name-policy.cjs',
|
||||
'get-shit-done/bin/lib/decisions.cjs',
|
||||
'get-shit-done/bin/lib/validate.cjs',
|
||||
'get-shit-done/bin/lib/schema-detect.cjs',
|
||||
'get-shit-done/bin/lib/runtime-name-policy.cjs',
|
||||
'get-shit-done/bin/lib/runtime-slash.cjs',
|
||||
'get-shit-done/bin/lib/observability/event.cjs',
|
||||
'get-shit-done/bin/lib/workstream-inventory-builder.cjs',
|
||||
'get-shit-done/bin/lib/plan-scan.cjs',
|
||||
'get-shit-done/bin/lib/fallow-runner.cjs',
|
||||
'get-shit-done/bin/lib/project-root.cjs',
|
||||
'get-shit-done/bin/lib/installer-migration-authoring.cjs',
|
||||
'get-shit-done/bin/lib/update-context.cjs',
|
||||
'get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs',
|
||||
'get-shit-done/bin/lib/runtime-homes.cjs',
|
||||
'get-shit-done/bin/lib/model-catalog.cjs',
|
||||
'get-shit-done/bin/lib/configuration.cjs',
|
||||
'get-shit-done/bin/lib/state-document.cjs',
|
||||
'get-shit-done/bin/lib/shell-command-projection.cjs',
|
||||
'get-shit-done/bin/lib/security.cjs',
|
||||
'get-shit-done/bin/lib/command-aliases.cjs',
|
||||
'get-shit-done/bin/lib/config-schema.cjs',
|
||||
'get-shit-done/bin/lib/model-profiles.cjs',
|
||||
'get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs',
|
||||
'get-shit-done/bin/lib/observability/logger.cjs',
|
||||
'get-shit-done/bin/lib/active-workstream-store.cjs',
|
||||
'get-shit-done/bin/lib/adr-parser.cjs',
|
||||
'get-shit-done/bin/lib/graphify.cjs',
|
||||
'get-shit-done/bin/lib/install-profiles.cjs',
|
||||
'get-shit-done/bin/lib/intel.cjs',
|
||||
'get-shit-done/bin/lib/installer-migrations.cjs',
|
||||
'get-shit-done/bin/lib/worktree-safety.cjs',
|
||||
'get-shit-done/bin/lib/planning-workspace.cjs',
|
||||
'get-shit-done/bin/lib/runtime-artifact-layout.cjs',
|
||||
'get-shit-done/bin/lib/command-routing-hub.cjs',
|
||||
'get-shit-done/bin/lib/core.cjs',
|
||||
'get-shit-done/bin/lib/drift.cjs',
|
||||
'get-shit-done/bin/lib/cjs-command-router-adapter.cjs',
|
||||
'get-shit-done/bin/lib/phase-command-router.cjs',
|
||||
'get-shit-done/bin/lib/surface.cjs',
|
||||
'get-shit-done/bin/lib/roadmap-upgrade.cjs',
|
||||
'get-shit-done/bin/lib/config-types.cjs',
|
||||
'get-shit-done/bin/lib/phases-command-router.cjs',
|
||||
'get-shit-done/bin/lib/verify-command-router.cjs',
|
||||
'get-shit-done/bin/lib/init-command-router.cjs',
|
||||
'get-shit-done/bin/lib/agent-command-router.cjs',
|
||||
'get-shit-done/bin/lib/task-command-router.cjs',
|
||||
'get-shit-done/bin/lib/validate-command-router.cjs',
|
||||
'get-shit-done/bin/lib/workstream-inventory.cjs',
|
||||
'get-shit-done/bin/lib/roadmap-command-router.cjs',
|
||||
'get-shit-done/bin/lib/state-command-router.cjs',
|
||||
'get-shit-done/bin/lib/gap-checker.cjs',
|
||||
'get-shit-done/bin/lib/config.cjs',
|
||||
'get-shit-done/bin/lib/profile-output.cjs',
|
||||
'get-shit-done/bin/lib/commands.cjs',
|
||||
'get-shit-done/bin/lib/state.cjs',
|
||||
'get-shit-done/bin/lib/milestone.cjs',
|
||||
'get-shit-done/bin/lib/phase.cjs',
|
||||
'get-shit-done/bin/lib/verify.cjs',
|
||||
'get-shit-done/bin/lib/init.cjs',
|
||||
'get-shit-done/bin/lib/docs.cjs',
|
||||
'get-shit-done/bin/lib/check-command-router.cjs',
|
||||
'get-shit-done/bin/lib/frontmatter.cjs',
|
||||
'get-shit-done/bin/lib/learnings.cjs',
|
||||
'get-shit-done/bin/lib/gsd2-import.cjs',
|
||||
'get-shit-done/bin/lib/profile-pipeline.cjs',
|
||||
'get-shit-done/bin/lib/template.cjs',
|
||||
'get-shit-done/bin/lib/uat.cjs',
|
||||
'get-shit-done/bin/lib/workstream.cjs',
|
||||
'get-shit-done/bin/lib/roadmap.cjs',
|
||||
'get-shit-done/bin/lib/audit.cjs',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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
18
package-lock.json
generated
@@ -19,6 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@stryker-mutator/core": "^9.6.1",
|
||||
"@types/node": "^22.19.19",
|
||||
"c8": "^11.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-n": "^17.24.0",
|
||||
@@ -1769,6 +1770,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
||||
@@ -5060,6 +5071,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@stryker-mutator/core": "^9.6.1",
|
||||
"@types/node": "^22.19.19",
|
||||
"c8": "^11.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-n": "^17.24.0",
|
||||
@@ -75,6 +76,7 @@
|
||||
"build:lib": "tsc -p tsconfig.build.json",
|
||||
"generate:identity": "node scripts/generate-package-identity.cjs",
|
||||
"prepack": "npm run build:lib",
|
||||
"prepare": "npm run build:lib",
|
||||
"prepublishOnly": "npm run build:lib && npm run build:hooks",
|
||||
"pretest": "npm run build:lib && npm run lint:skill-deps",
|
||||
"pretest:coverage": "npm run build:lib && npm run lint:skill-deps",
|
||||
|
||||
@@ -212,7 +212,11 @@ if (fs.existsSync(LOCKFILE)) {
|
||||
// ---------------------------------------------------------------------------
|
||||
if (fs.existsSync(LOCKFILE)) {
|
||||
try {
|
||||
const res = spawnSync(npmCmd, ['ci', '--dry-run'], {
|
||||
// --ignore-scripts: this is a lockfile-vs-package.json sync check, not a
|
||||
// build. Without it, npm would run the `prepare` lifecycle (build:lib via
|
||||
// tsc) — which fails when check:env runs before deps are installed (tsc
|
||||
// absent), misreporting an out-of-sync lockfile. ADR-457 build-at-publish.
|
||||
const res = spawnSync(npmCmd, ['ci', '--dry-run', '--ignore-scripts'], {
|
||||
cwd: PROJECT_ROOT,
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
|
||||
219
scripts/mutation-matrix.cjs
Normal file
219
scripts/mutation-matrix.cjs
Normal 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();
|
||||
@@ -3,16 +3,20 @@
|
||||
*
|
||||
* Owns active workstream source precedence, session identity, and pointer IO:
|
||||
* CLI --ws > GSD_WORKSTREAM env > stored active workstream pointer.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/active-workstream-store.cjs
|
||||
* collapsed to a TypeScript source of truth. Behaviour is preserved
|
||||
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { probeTty, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { isValidActiveWorkstreamName } = require('./workstream-name-policy.cjs');
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { probeTty, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { isValidActiveWorkstreamName } from './workstream-name-policy.cjs';
|
||||
|
||||
const WORKSTREAM_SESSION_ENV_KEYS = [
|
||||
const WORKSTREAM_SESSION_ENV_KEYS: ReadonlyArray<string> = [
|
||||
'GSD_SESSION_KEY',
|
||||
'CODEX_THREAD_ID',
|
||||
'CLAUDE_SESSION_ID',
|
||||
@@ -27,24 +31,25 @@ const WORKSTREAM_SESSION_ENV_KEYS = [
|
||||
'ZELLIJ_SESSION_NAME',
|
||||
];
|
||||
|
||||
let cachedControllingTtyToken = null;
|
||||
let cachedControllingTtyToken: string | null = null;
|
||||
let didProbeControllingTtyToken = false;
|
||||
|
||||
function planningRoot(cwd) {
|
||||
function planningRoot(cwd: string): string {
|
||||
return path.join(cwd, '.planning');
|
||||
}
|
||||
|
||||
function validateWorkstreamName(name) {
|
||||
function validateWorkstreamName(name: string | null | undefined): boolean {
|
||||
return isValidActiveWorkstreamName(name);
|
||||
}
|
||||
|
||||
function sanitizeWorkstreamSessionToken(value) {
|
||||
function sanitizeWorkstreamSessionToken(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const token = String(value).trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
const raw = typeof value === 'string' ? value : `${value as number | boolean}`;
|
||||
const token = raw.trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
return token ? token.slice(0, 160) : null;
|
||||
}
|
||||
|
||||
function probeControllingTtyToken() {
|
||||
function probeControllingTtyToken(): string | null {
|
||||
if (didProbeControllingTtyToken) return cachedControllingTtyToken;
|
||||
didProbeControllingTtyToken = true;
|
||||
|
||||
@@ -61,7 +66,7 @@ function probeControllingTtyToken() {
|
||||
return cachedControllingTtyToken;
|
||||
}
|
||||
|
||||
function getControllingTtyToken() {
|
||||
function getControllingTtyToken(): string | null {
|
||||
for (const envKey of ['TTY', 'SSH_TTY']) {
|
||||
const token = sanitizeWorkstreamSessionToken(process.env[envKey]);
|
||||
if (token) return `tty-${token.replace(/^dev_/, '')}`;
|
||||
@@ -70,7 +75,7 @@ function getControllingTtyToken() {
|
||||
return probeControllingTtyToken();
|
||||
}
|
||||
|
||||
function getWorkstreamSessionKey() {
|
||||
function getWorkstreamSessionKey(): string | null {
|
||||
for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) {
|
||||
const raw = process.env[envKey];
|
||||
const token = sanitizeWorkstreamSessionToken(raw);
|
||||
@@ -80,11 +85,17 @@ function getWorkstreamSessionKey() {
|
||||
return getControllingTtyToken();
|
||||
}
|
||||
|
||||
function getSessionScopedWorkstreamFile(cwd, fixedSessionKey) {
|
||||
interface SessionScopedWorkstreamFile {
|
||||
sessionKey: string;
|
||||
dirPath: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
function getSessionScopedWorkstreamFile(cwd: string, fixedSessionKey?: string | null): SessionScopedWorkstreamFile | null {
|
||||
const sessionKey = fixedSessionKey || getWorkstreamSessionKey();
|
||||
if (!sessionKey) return null;
|
||||
|
||||
let planningAbs;
|
||||
let planningAbs: string;
|
||||
try {
|
||||
planningAbs = fs.realpathSync.native(planningRoot(cwd));
|
||||
} catch {
|
||||
@@ -104,36 +115,42 @@ function getSessionScopedWorkstreamFile(cwd, fixedSessionKey) {
|
||||
};
|
||||
}
|
||||
|
||||
function createSharedPointerAdapter(cwd) {
|
||||
interface WorkstreamPointerAdapter {
|
||||
read(): string | null;
|
||||
write(name: string): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
function createSharedPointerAdapter(cwd: string): WorkstreamPointerAdapter {
|
||||
const filePath = path.join(planningRoot(cwd), 'active-workstream');
|
||||
return {
|
||||
read() {
|
||||
read(): string | null {
|
||||
const raw = platformReadSync(filePath);
|
||||
return raw ? raw.trim() || null : null;
|
||||
},
|
||||
write(name) {
|
||||
write(name: string): void {
|
||||
platformWriteSync(filePath, name + '\n');
|
||||
},
|
||||
clear() {
|
||||
clear(): void {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionScopedPointerAdapter(cwd, fixedSessionKey) {
|
||||
function createSessionScopedPointerAdapter(cwd: string, fixedSessionKey?: string | null): WorkstreamPointerAdapter | null {
|
||||
const scoped = getSessionScopedWorkstreamFile(cwd, fixedSessionKey);
|
||||
if (!scoped) return null;
|
||||
|
||||
return {
|
||||
read() {
|
||||
read(): string | null {
|
||||
const raw = platformReadSync(scoped.filePath);
|
||||
return raw ? raw.trim() || null : null;
|
||||
},
|
||||
write(name) {
|
||||
write(name: string): void {
|
||||
platformEnsureDir(scoped.dirPath);
|
||||
platformWriteSync(scoped.filePath, name + '\n');
|
||||
},
|
||||
clear() {
|
||||
clear(): void {
|
||||
try { fs.unlinkSync(scoped.filePath); } catch {}
|
||||
try {
|
||||
const remaining = fs.readdirSync(scoped.dirPath);
|
||||
@@ -145,22 +162,33 @@ function createSessionScopedPointerAdapter(cwd, fixedSessionKey) {
|
||||
};
|
||||
}
|
||||
|
||||
function createMemoryPointerAdapter(initialName = null) {
|
||||
let value = initialName;
|
||||
function createMemoryPointerAdapter(initialName: string | null = null): WorkstreamPointerAdapter {
|
||||
let value: string | null = initialName;
|
||||
return {
|
||||
read() {
|
||||
read(): string | null {
|
||||
return value;
|
||||
},
|
||||
write(name) {
|
||||
write(name: string): void {
|
||||
value = name;
|
||||
},
|
||||
clear() {
|
||||
clear(): void {
|
||||
value = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pickActiveWorkstreamAdapter(cwd, opts = {}) {
|
||||
interface ActiveWorkstreamAdapters {
|
||||
session?: WorkstreamPointerAdapter;
|
||||
shared?: WorkstreamPointerAdapter;
|
||||
}
|
||||
|
||||
interface ActiveWorkstreamOpts {
|
||||
activeWorkstreamAdapter?: WorkstreamPointerAdapter;
|
||||
activeWorkstreamAdapters?: ActiveWorkstreamAdapters;
|
||||
getStored?: (dir: string) => string | null;
|
||||
}
|
||||
|
||||
function pickActiveWorkstreamAdapter(cwd: string, opts: ActiveWorkstreamOpts = {}): WorkstreamPointerAdapter | null {
|
||||
if (opts.activeWorkstreamAdapter) {
|
||||
return opts.activeWorkstreamAdapter;
|
||||
}
|
||||
@@ -179,7 +207,7 @@ function pickActiveWorkstreamAdapter(cwd, opts = {}) {
|
||||
return createSharedPointerAdapter(cwd);
|
||||
}
|
||||
|
||||
function getActiveWorkstream(cwd, opts = {}) {
|
||||
function getActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): string | null {
|
||||
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
|
||||
if (!adapter) return null;
|
||||
|
||||
@@ -198,7 +226,7 @@ function getActiveWorkstream(cwd, opts = {}) {
|
||||
return name;
|
||||
}
|
||||
|
||||
function setActiveWorkstream(cwd, name, opts = {}) {
|
||||
function setActiveWorkstream(cwd: string, name: string | null | undefined, opts: ActiveWorkstreamOpts = {}): void {
|
||||
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
|
||||
if (!adapter) return;
|
||||
|
||||
@@ -215,14 +243,20 @@ function setActiveWorkstream(cwd, name, opts = {}) {
|
||||
adapter.write(name);
|
||||
}
|
||||
|
||||
function clearActiveWorkstream(cwd, opts = {}) {
|
||||
function clearActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): void {
|
||||
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
|
||||
if (!adapter) return;
|
||||
adapter.clear();
|
||||
}
|
||||
|
||||
function parseCliWorkstream(args) {
|
||||
const wsEqArg = args.find(arg => arg.startsWith('--ws='));
|
||||
interface ParsedCliWorkstream {
|
||||
value: string | null;
|
||||
source: string | null;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
function parseCliWorkstream(args: string[]): ParsedCliWorkstream {
|
||||
const wsEqArg = args.find((arg) => arg.startsWith('--ws='));
|
||||
const wsIdx = args.indexOf('--ws');
|
||||
|
||||
if (wsEqArg) {
|
||||
@@ -231,7 +265,7 @@ function parseCliWorkstream(args) {
|
||||
return {
|
||||
value,
|
||||
source: 'cli',
|
||||
args: args.filter(arg => arg !== wsEqArg),
|
||||
args: args.filter((arg) => arg !== wsEqArg),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,7 +275,7 @@ function parseCliWorkstream(args) {
|
||||
return {
|
||||
value,
|
||||
source: 'cli',
|
||||
args: args.filter((_, idx) => idx !== wsIdx && idx !== wsIdx + 1),
|
||||
args: args.filter((_: string, idx: number) => idx !== wsIdx && idx !== wsIdx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,18 +286,29 @@ function parseCliWorkstream(args) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveActiveWorkstream(cwd, args, env = process.env, deps = {}) {
|
||||
const parsed = parseCliWorkstream(args);
|
||||
const getStored = deps.getStored || ((dir) => getActiveWorkstream(dir, deps));
|
||||
interface ResolvedWorkstream {
|
||||
ws: string | null;
|
||||
source: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
let ws = null;
|
||||
function resolveActiveWorkstream(
|
||||
cwd: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
deps: ActiveWorkstreamOpts = {}
|
||||
): ResolvedWorkstream {
|
||||
const parsed = parseCliWorkstream(args);
|
||||
const getStored = deps.getStored || ((dir: string) => getActiveWorkstream(dir, deps));
|
||||
|
||||
let ws: string | null = null;
|
||||
let source = 'none';
|
||||
|
||||
if (parsed.value) {
|
||||
ws = parsed.value;
|
||||
source = parsed.source;
|
||||
} else if (env && typeof env.GSD_WORKSTREAM === 'string' && env.GSD_WORKSTREAM.trim()) {
|
||||
ws = env.GSD_WORKSTREAM.trim();
|
||||
source = parsed.source ?? 'cli';
|
||||
} else if (env && typeof env['GSD_WORKSTREAM'] === 'string' && env['GSD_WORKSTREAM'].trim()) {
|
||||
ws = env['GSD_WORKSTREAM'].trim();
|
||||
source = 'env';
|
||||
} else {
|
||||
ws = getStored(cwd) || null;
|
||||
@@ -281,12 +326,15 @@ function resolveActiveWorkstream(cwd, args, env = process.env, deps = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function applyResolvedWorkstreamEnv(resolution, env = process.env) {
|
||||
function applyResolvedWorkstreamEnv(
|
||||
resolution: ResolvedWorkstream | null | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): void {
|
||||
if (!resolution || !resolution.ws) return;
|
||||
env.GSD_WORKSTREAM = resolution.ws;
|
||||
env['GSD_WORKSTREAM'] = resolution.ws;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
validateWorkstreamName,
|
||||
getWorkstreamSessionKey,
|
||||
createSharedPointerAdapter,
|
||||
@@ -1,12 +1,34 @@
|
||||
'use strict';
|
||||
/**
|
||||
* ADR Markdown parser — parses Architecture Decision Record documents into
|
||||
* structured objects for downstream processing (adr command, gap checker, etc.).
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/adr-parser.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { requireSafePath } = require('./security.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { requireSafePath } from './security.cjs';
|
||||
|
||||
const STATUS_REJECT_SET = new Set(['superseded', 'rejected', 'deprecated']);
|
||||
|
||||
const CANONICAL_HEADERS = {
|
||||
type CanonicalHeader =
|
||||
| 'status'
|
||||
| 'goal'
|
||||
| 'decisions'
|
||||
| 'considered_options'
|
||||
| 'risks'
|
||||
| 'success_criteria'
|
||||
| 'plan_sequence'
|
||||
| 'key_files'
|
||||
| 'out_of_scope'
|
||||
| 'deferred'
|
||||
| 'dependencies'
|
||||
| 'update'
|
||||
| 'consequences';
|
||||
|
||||
const CANONICAL_HEADERS: Record<CanonicalHeader, string[]> = {
|
||||
status: ['status', 'state', 'lifecycle', 'stage'],
|
||||
goal: [
|
||||
'context',
|
||||
@@ -154,7 +176,7 @@ const CANONICAL_HEADERS = {
|
||||
],
|
||||
};
|
||||
|
||||
const CONSEQUENCE_NEGATIVE_HINTS = [
|
||||
const CONSEQUENCE_NEGATIVE_HINTS: string[] = [
|
||||
'negative',
|
||||
'drawback',
|
||||
'risk',
|
||||
@@ -165,7 +187,7 @@ const CONSEQUENCE_NEGATIVE_HINTS = [
|
||||
'side effect',
|
||||
];
|
||||
|
||||
const CONSEQUENCE_POSITIVE_HINTS = [
|
||||
const CONSEQUENCE_POSITIVE_HINTS: string[] = [
|
||||
'positive',
|
||||
'success',
|
||||
'metric',
|
||||
@@ -175,8 +197,9 @@ const CONSEQUENCE_POSITIVE_HINTS = [
|
||||
'benefit',
|
||||
];
|
||||
|
||||
function normalizeAdrHeader(raw) {
|
||||
return String(raw || '')
|
||||
function normalizeAdrHeader(raw: unknown): string {
|
||||
const s = typeof raw === 'string' ? raw : '';
|
||||
return s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s:._-]+/g, ' ')
|
||||
@@ -184,8 +207,8 @@ function normalizeAdrHeader(raw) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function classifyHeader(normalizedHeader) {
|
||||
for (const [canonical, synonyms] of Object.entries(CANONICAL_HEADERS)) {
|
||||
function classifyHeader(normalizedHeader: string): CanonicalHeader | null {
|
||||
for (const [canonical, synonyms] of Object.entries(CANONICAL_HEADERS) as Array<[CanonicalHeader, string[]]>) {
|
||||
for (const synonym of synonyms) {
|
||||
if (normalizedHeader === synonym) return canonical;
|
||||
if (normalizedHeader.startsWith(`${synonym} `)) return canonical;
|
||||
@@ -194,8 +217,8 @@ function classifyHeader(normalizedHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitEntries(blockText) {
|
||||
return String(blockText || '')
|
||||
function splitEntries(blockText: unknown): string[] {
|
||||
return (typeof blockText === 'string' ? blockText : '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
@@ -203,10 +226,15 @@ function splitEntries(blockText) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseSections(markdown) {
|
||||
const lines = String(markdown || '').split(/\r?\n/);
|
||||
const sections = [];
|
||||
let current = { heading: null, body: [] };
|
||||
interface MarkdownSection {
|
||||
heading: string | null;
|
||||
body: string[];
|
||||
}
|
||||
|
||||
function parseSections(markdown: unknown): MarkdownSection[] {
|
||||
const lines = (typeof markdown === 'string' ? markdown : '').split(/\r?\n/);
|
||||
const sections: MarkdownSection[] = [];
|
||||
let current: MarkdownSection = { heading: null, body: [] };
|
||||
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^#{1,6}\s+(.*)$/);
|
||||
@@ -222,7 +250,7 @@ function parseSections(markdown) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
function parseStatusFromSections(sections) {
|
||||
function parseStatusFromSections(sections: MarkdownSection[]): string {
|
||||
for (const section of sections) {
|
||||
const canonical = classifyHeader(normalizeAdrHeader(section.heading));
|
||||
if (canonical !== 'status') continue;
|
||||
@@ -239,7 +267,7 @@ function parseStatusFromSections(sections) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function pushUnique(target, values) {
|
||||
function pushUnique(target: string[], values: string[]): void {
|
||||
const seen = new Set(target);
|
||||
for (const value of values) {
|
||||
if (!seen.has(value)) {
|
||||
@@ -249,7 +277,26 @@ function pushUnique(target, values) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseConsequences(lines, out) {
|
||||
interface AdrOut {
|
||||
title: string;
|
||||
status: string;
|
||||
context: string;
|
||||
decisions: string[];
|
||||
options_considered: string[];
|
||||
consequences_positive: string[];
|
||||
consequences_negative: string[];
|
||||
out_of_scope: string[];
|
||||
deferred: string[];
|
||||
dependencies: string[];
|
||||
updates: Array<{ heading: string; entries: string[] }>;
|
||||
source_path: string;
|
||||
key_files: string[];
|
||||
plan_sequence: string[];
|
||||
format: string;
|
||||
unmapped_headers: string[];
|
||||
}
|
||||
|
||||
function parseConsequences(lines: string[], out: AdrOut): void {
|
||||
for (const entry of lines) {
|
||||
const lower = entry.toLowerCase();
|
||||
if (CONSEQUENCE_NEGATIVE_HINTS.some((hint) => lower.includes(hint))) {
|
||||
@@ -264,12 +311,17 @@ function parseConsequences(lines, out) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseAdrMarkdown(markdown, { sourcePath = '', format = 'auto' } = {}) {
|
||||
interface ParseAdrMarkdownOptions {
|
||||
sourcePath?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
function parseAdrMarkdown(markdown: unknown, { sourcePath = '', format = 'auto' }: ParseAdrMarkdownOptions = {}): AdrOut {
|
||||
const sections = parseSections(markdown);
|
||||
const titleLine = String(markdown || '').split(/\r?\n/).find((line) => /^#\s+/.test(line)) || '';
|
||||
const titleLine = (typeof markdown === 'string' ? markdown : '').split(/\r?\n/).find((line) => /^#\s+/.test(line)) || '';
|
||||
const title = titleLine.replace(/^#\s+/, '').trim();
|
||||
|
||||
const out = {
|
||||
const out: AdrOut = {
|
||||
title,
|
||||
status: parseStatusFromSections(sections) || 'accepted',
|
||||
context: '',
|
||||
@@ -345,12 +397,18 @@ function parseAdrMarkdown(markdown, { sourcePath = '', format = 'auto' } = {}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function shouldRejectAdrStatus(status) {
|
||||
function shouldRejectAdrStatus(status: string): boolean {
|
||||
return STATUS_REJECT_SET.has(normalizeAdrHeader(status));
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const opts = { input: null, format: 'auto', projectDir: process.cwd() };
|
||||
interface CliOpts {
|
||||
input: string | null;
|
||||
format: string;
|
||||
projectDir: string;
|
||||
}
|
||||
|
||||
function parseCliArgs(argv: string[]): CliOpts {
|
||||
const opts: CliOpts = { input: null, format: 'auto', projectDir: process.cwd() };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--input') {
|
||||
@@ -369,11 +427,11 @@ function parseCliArgs(argv) {
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
function main(argv: string[]): void {
|
||||
const opts = parseCliArgs(argv);
|
||||
const safePath = requireSafePath(opts.input, path.resolve(opts.projectDir), 'ADR input path', { allowAbsolute: true });
|
||||
const content = fs.readFileSync(safePath, 'utf8');
|
||||
const parsed = parseAdrMarkdown(content, { sourcePath: opts.input, format: opts.format });
|
||||
const parsed = parseAdrMarkdown(content, { sourcePath: opts.input ?? undefined, format: opts.format });
|
||||
process.stdout.write(JSON.stringify(parsed, null, 2));
|
||||
}
|
||||
|
||||
@@ -381,12 +439,12 @@ if (require.main === module) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error: ${error.message}\n`);
|
||||
process.stderr.write(`Error: ${(error as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
CANONICAL_HEADERS,
|
||||
normalizeAdrHeader,
|
||||
parseAdrMarkdown,
|
||||
103
src/agent-command-router.cts
Normal file
103
src/agent-command-router.cts
Normal 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,
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Canonical GSD artifact registry.
|
||||
* Canonical GSD artifact registry (ADR-457 build-at-publish: the hand-written
|
||||
* bin/lib/artifacts.cjs collapsed to a TypeScript source of truth). Behaviour
|
||||
* is preserved byte-for-behaviour from the prior hand-written .cjs; only types
|
||||
* are added.
|
||||
*
|
||||
* Enumerates the file names that gsd workflows officially produce at the
|
||||
* .planning/ root level. Used by gsd-health (W019) to flag unrecognized files
|
||||
@@ -8,10 +11,8 @@
|
||||
* Add entries here whenever a new workflow produces a .planning/ root file.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// Exact-match canonical file names at .planning/ root
|
||||
const CANONICAL_EXACT = new Set([
|
||||
export const CANONICAL_EXACT: ReadonlySet<string> = new Set([
|
||||
'PROJECT.md',
|
||||
'ROADMAP.md',
|
||||
'STATE.md',
|
||||
@@ -27,8 +28,8 @@ const CANONICAL_EXACT = new Set([
|
||||
|
||||
// Pattern-match canonical file names (regex tests on the basename)
|
||||
// Each pattern includes the name of the workflow that produces it as a comment.
|
||||
const CANONICAL_PATTERNS = [
|
||||
/^v\d+\.\d+(?:\.\d+)?-MILESTONE-AUDIT\.md$/i, // gsd-complete-milestone (pre-archive)
|
||||
export const CANONICAL_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^v\d+\.\d+(?:\.\d+)?-MILESTONE-AUDIT\.md$/i, // gsd-complete-milestone (pre-archive)
|
||||
/^v\d+\.\d+(?:\.\d+)?-.*\.md$/i, // other version-stamped planning docs
|
||||
];
|
||||
|
||||
@@ -36,18 +37,12 @@ const CANONICAL_PATTERNS = [
|
||||
* Return true if `filename` (basename only, no path) matches a canonical
|
||||
* .planning/ root artifact — either an exact name or a known pattern.
|
||||
*
|
||||
* @param {string} filename - Basename of the file (e.g. "STATE.md")
|
||||
* @param filename - Basename of the file (e.g. "STATE.md")
|
||||
*/
|
||||
function isCanonicalPlanningFile(filename) {
|
||||
export function isCanonicalPlanningFile(filename: string): boolean {
|
||||
if (CANONICAL_EXACT.has(filename)) return true;
|
||||
for (const pattern of CANONICAL_PATTERNS) {
|
||||
if (pattern.test(filename)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CANONICAL_EXACT,
|
||||
CANONICAL_PATTERNS,
|
||||
isCanonicalPlanningFile,
|
||||
};
|
||||
@@ -5,33 +5,139 @@
|
||||
* Returns structured JSON for workflow consumption.
|
||||
* Called by: gsd-tools.cjs audit-open
|
||||
* Used by: /gsd:complete-milestone pre-close gate
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/audit.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only strict types are added.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { platformReadSync } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter } = frontmatter;
|
||||
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { toPosixPath } = require('./core.cjs');
|
||||
const { platformReadSync } = require('./shell-command-projection.cjs');
|
||||
const { planningDir } = require('./planning-workspace.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { requireSafePath, sanitizeForDisplay } = require('./security.cjs');
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DebugSessionItem {
|
||||
slug: string;
|
||||
status: string;
|
||||
updated: string;
|
||||
hypothesis: string;
|
||||
scan_error?: boolean;
|
||||
}
|
||||
|
||||
interface QuickTaskItem {
|
||||
slug: string;
|
||||
date: string;
|
||||
status: string;
|
||||
description: string;
|
||||
scan_error?: boolean;
|
||||
}
|
||||
|
||||
interface ThreadItem {
|
||||
slug: string;
|
||||
status: string;
|
||||
updated: string;
|
||||
title: string;
|
||||
scan_error?: boolean;
|
||||
}
|
||||
|
||||
interface TodoItem {
|
||||
filename: string;
|
||||
priority: string;
|
||||
area: string;
|
||||
summary: string;
|
||||
scan_error?: boolean;
|
||||
_remainder_count?: number;
|
||||
}
|
||||
|
||||
interface SeedItem {
|
||||
seed_id: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
title: string;
|
||||
scan_error?: boolean;
|
||||
}
|
||||
|
||||
interface UatGapItem {
|
||||
phase: string;
|
||||
file: string;
|
||||
status: string;
|
||||
open_scenario_count: number;
|
||||
scan_error?: boolean;
|
||||
}
|
||||
|
||||
interface VerificationGapItem {
|
||||
phase: string;
|
||||
file: string;
|
||||
status: string;
|
||||
scan_error?: boolean;
|
||||
}
|
||||
|
||||
interface ContextQuestionItem {
|
||||
phase: string;
|
||||
file: string;
|
||||
question_count: number;
|
||||
questions: string[];
|
||||
scan_error?: boolean;
|
||||
}
|
||||
|
||||
interface AuditCounts {
|
||||
debug_sessions: number;
|
||||
quick_tasks: number;
|
||||
threads: number;
|
||||
todos: number;
|
||||
seeds: number;
|
||||
uat_gaps: number;
|
||||
verification_gaps: number;
|
||||
context_questions: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface AuditResult {
|
||||
scanned_at: string;
|
||||
has_open_items: boolean;
|
||||
counts: AuditCounts;
|
||||
items: {
|
||||
debug_sessions: DebugSessionItem[];
|
||||
quick_tasks: QuickTaskItem[];
|
||||
threads: ThreadItem[];
|
||||
todos: TodoItem[];
|
||||
seeds: SeedItem[];
|
||||
uat_gaps: UatGapItem[];
|
||||
verification_gaps: VerificationGapItem[];
|
||||
context_questions: ContextQuestionItem[];
|
||||
};
|
||||
}
|
||||
|
||||
// Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure
|
||||
// per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
|
||||
// not recreated on each loop iteration.
|
||||
const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
|
||||
|
||||
// ─── scanDebugSessions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/debug/ for open sessions.
|
||||
* Open = status NOT in ['resolved', 'complete'].
|
||||
* Ignores the resolved/ subdirectory.
|
||||
*/
|
||||
function scanDebugSessions(planDir) {
|
||||
function scanDebugSessions(planDir: string): DebugSessionItem[] {
|
||||
const debugDir = path.join(planDir, 'debug');
|
||||
if (!fs.existsSync(debugDir)) return [];
|
||||
|
||||
const results = [];
|
||||
let files;
|
||||
const results: DebugSessionItem[] = [];
|
||||
let files: fs.Dirent[];
|
||||
try {
|
||||
files = fs.readdirSync(debugDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [{ scan_error: true }];
|
||||
return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
|
||||
}
|
||||
|
||||
for (const entry of files) {
|
||||
@@ -40,7 +146,7 @@ function scanDebugSessions(planDir) {
|
||||
|
||||
const filePath = path.join(debugDir, entry.name);
|
||||
|
||||
let safeFilePath;
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'debug session file', { allowAbsolute: true });
|
||||
} catch {
|
||||
@@ -51,7 +157,7 @@ function scanDebugSessions(planDir) {
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = (fm.status || 'unknown').toLowerCase();
|
||||
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
if (status === 'resolved' || status === 'complete') continue;
|
||||
|
||||
// Extract hypothesis from "Current Focus" block if parseable
|
||||
@@ -66,7 +172,7 @@ function scanDebugSessions(planDir) {
|
||||
results.push({
|
||||
slug: sanitizeForDisplay(slug),
|
||||
status: sanitizeForDisplay(status),
|
||||
updated: sanitizeForDisplay(String(fm.updated || fm.date || '')),
|
||||
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
|
||||
hypothesis,
|
||||
});
|
||||
}
|
||||
@@ -74,29 +180,31 @@ function scanDebugSessions(planDir) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanQuickTasks ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/quick/ for incomplete tasks.
|
||||
* Incomplete if SUMMARY.md missing or status !== 'complete'.
|
||||
*/
|
||||
function scanQuickTasks(planDir) {
|
||||
function scanQuickTasks(planDir: string): QuickTaskItem[] {
|
||||
const quickDir = path.join(planDir, 'quick');
|
||||
if (!fs.existsSync(quickDir)) return [];
|
||||
|
||||
let entries;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(quickDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [{ scan_error: true }];
|
||||
return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const results: QuickTaskItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const dirName = entry.name;
|
||||
const taskDir = path.join(quickDir, dirName);
|
||||
|
||||
let safeTaskDir;
|
||||
let safeTaskDir: string;
|
||||
try {
|
||||
safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', { allowAbsolute: true });
|
||||
} catch {
|
||||
@@ -105,7 +213,7 @@ function scanQuickTasks(planDir) {
|
||||
|
||||
// workflows/quick.md mandates `${quick_id}-SUMMARY.md`; older flows used
|
||||
// bare `SUMMARY.md`. Accept either to avoid false-positive "missing".
|
||||
let summaryPath = null;
|
||||
let summaryPath: string | null = null;
|
||||
try {
|
||||
const summaryFiles = fs.readdirSync(safeTaskDir, { withFileTypes: true })
|
||||
.filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
|
||||
@@ -124,7 +232,7 @@ function scanQuickTasks(planDir) {
|
||||
const description = '';
|
||||
|
||||
if (summaryPath && fs.existsSync(summaryPath)) {
|
||||
let safeSum;
|
||||
let safeSum: string;
|
||||
try {
|
||||
safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true });
|
||||
} catch {
|
||||
@@ -135,7 +243,7 @@ function scanQuickTasks(planDir) {
|
||||
status = 'unreadable';
|
||||
} else {
|
||||
const fm = extractFrontmatter(content);
|
||||
status = (fm.status || 'unknown').toLowerCase();
|
||||
status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,23 +269,25 @@ function scanQuickTasks(planDir) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanThreads ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/threads/ for open threads.
|
||||
* Open if status in ['open', 'in_progress', 'in progress'] (case-insensitive).
|
||||
*/
|
||||
function scanThreads(planDir) {
|
||||
function scanThreads(planDir: string): ThreadItem[] {
|
||||
const threadsDir = path.join(planDir, 'threads');
|
||||
if (!fs.existsSync(threadsDir)) return [];
|
||||
|
||||
let files;
|
||||
let files: fs.Dirent[];
|
||||
try {
|
||||
files = fs.readdirSync(threadsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [{ scan_error: true }];
|
||||
return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
|
||||
}
|
||||
|
||||
const openStatuses = new Set(['open', 'in_progress', 'in progress']);
|
||||
const results = [];
|
||||
const results: ThreadItem[] = [];
|
||||
|
||||
for (const entry of files) {
|
||||
if (!entry.isFile()) continue;
|
||||
@@ -185,7 +295,7 @@ function scanThreads(planDir) {
|
||||
|
||||
const filePath = path.join(threadsDir, entry.name);
|
||||
|
||||
let safeFilePath;
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'thread file', { allowAbsolute: true });
|
||||
} catch {
|
||||
@@ -196,7 +306,7 @@ function scanThreads(planDir) {
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
let status = (fm.status || '').toLowerCase().trim();
|
||||
let status = ((fm.status as string) || '').toLowerCase().trim();
|
||||
|
||||
// Fall back to scanning body for ## Status: OPEN / IN PROGRESS
|
||||
if (!status) {
|
||||
@@ -209,7 +319,7 @@ function scanThreads(planDir) {
|
||||
if (!openStatuses.has(status)) continue;
|
||||
|
||||
// Extract title from # Thread: heading or frontmatter title
|
||||
let title = sanitizeForDisplay(String(fm.title || ''));
|
||||
let title = sanitizeForDisplay(fm.title || '');
|
||||
if (!title) {
|
||||
const headingMatch = content.match(/^#\s*Thread:\s*(.+)$/m);
|
||||
if (headingMatch) {
|
||||
@@ -221,7 +331,7 @@ function scanThreads(planDir) {
|
||||
results.push({
|
||||
slug: sanitizeForDisplay(slug),
|
||||
status: sanitizeForDisplay(status),
|
||||
updated: sanitizeForDisplay(String(fm.updated || fm.date || '')),
|
||||
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
|
||||
title,
|
||||
});
|
||||
}
|
||||
@@ -229,30 +339,32 @@ function scanThreads(planDir) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanTodos ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/todos/pending/ for pending todos.
|
||||
* Returns array of { filename, priority, area, summary }.
|
||||
* Display limited to first 5 + count of remainder.
|
||||
*/
|
||||
function scanTodos(planDir) {
|
||||
function scanTodos(planDir: string): TodoItem[] {
|
||||
const pendingDir = path.join(planDir, 'todos', 'pending');
|
||||
if (!fs.existsSync(pendingDir)) return [];
|
||||
|
||||
let files;
|
||||
let files: fs.Dirent[];
|
||||
try {
|
||||
files = fs.readdirSync(pendingDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [{ scan_error: true }];
|
||||
return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
|
||||
}
|
||||
|
||||
const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
|
||||
const results = [];
|
||||
const results: TodoItem[] = [];
|
||||
|
||||
const displayFiles = mdFiles.slice(0, 5);
|
||||
for (const entry of displayFiles) {
|
||||
const filePath = path.join(pendingDir, entry.name);
|
||||
|
||||
let safeFilePath;
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true });
|
||||
} catch {
|
||||
@@ -271,36 +383,38 @@ function scanTodos(planDir) {
|
||||
|
||||
results.push({
|
||||
filename: sanitizeForDisplay(entry.name),
|
||||
priority: sanitizeForDisplay(String(fm.priority || '')),
|
||||
area: sanitizeForDisplay(String(fm.area || '')),
|
||||
priority: sanitizeForDisplay(fm.priority || ''),
|
||||
area: sanitizeForDisplay(fm.area || ''),
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
if (mdFiles.length > 5) {
|
||||
results.push({ _remainder_count: mdFiles.length - 5 });
|
||||
results.push({ _remainder_count: mdFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanSeeds ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/seeds/SEED-*.md for unimplemented seeds.
|
||||
* Unimplemented if status in ['dormant', 'active', 'triggered'].
|
||||
*/
|
||||
function scanSeeds(planDir) {
|
||||
function scanSeeds(planDir: string): SeedItem[] {
|
||||
const seedsDir = path.join(planDir, 'seeds');
|
||||
if (!fs.existsSync(seedsDir)) return [];
|
||||
|
||||
let files;
|
||||
let files: fs.Dirent[];
|
||||
try {
|
||||
files = fs.readdirSync(seedsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [{ scan_error: true }];
|
||||
return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
|
||||
}
|
||||
|
||||
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
|
||||
const results = [];
|
||||
const results: SeedItem[] = [];
|
||||
|
||||
for (const entry of files) {
|
||||
if (!entry.isFile()) continue;
|
||||
@@ -308,7 +422,7 @@ function scanSeeds(planDir) {
|
||||
|
||||
const filePath = path.join(seedsDir, entry.name);
|
||||
|
||||
let safeFilePath;
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'seed file', { allowAbsolute: true });
|
||||
} catch {
|
||||
@@ -319,7 +433,7 @@ function scanSeeds(planDir) {
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = (fm.status || 'dormant').toLowerCase();
|
||||
const status = ((fm.status as string) || 'dormant').toLowerCase();
|
||||
|
||||
if (!unimplementedStatuses.has(status)) continue;
|
||||
|
||||
@@ -328,7 +442,7 @@ function scanSeeds(planDir) {
|
||||
const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
|
||||
const slug = sanitizeForDisplay(seed_id.replace(/^SEED-/, ''));
|
||||
|
||||
let title = sanitizeForDisplay(String(fm.title || ''));
|
||||
let title = sanitizeForDisplay(fm.title || '');
|
||||
if (!title) {
|
||||
const headingMatch = content.match(/^#\s*(.+)$/m);
|
||||
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
|
||||
@@ -345,36 +459,33 @@ function scanSeeds(planDir) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure
|
||||
// per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
|
||||
// not recreated on each loop iteration.
|
||||
const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
|
||||
// ─── scanUatGaps ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/phases for UAT gaps (UAT files with status != 'complete').
|
||||
*/
|
||||
function scanUatGaps(planDir) {
|
||||
function scanUatGaps(planDir: string): UatGapItem[] {
|
||||
const phasesDir = path.join(planDir, 'phases');
|
||||
if (!fs.existsSync(phasesDir)) return [];
|
||||
|
||||
let dirs;
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return [{ scan_error: true }];
|
||||
return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const results: UatGapItem[] = [];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const phaseDir = path.join(phasesDir, dir);
|
||||
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
||||
|
||||
let files;
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(phaseDir);
|
||||
} catch {
|
||||
@@ -384,7 +495,7 @@ function scanUatGaps(planDir) {
|
||||
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
|
||||
const filePath = path.join(phaseDir, file);
|
||||
|
||||
let safeFilePath;
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true });
|
||||
} catch {
|
||||
@@ -395,8 +506,8 @@ function scanUatGaps(planDir) {
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = (fm.status || 'unknown').toLowerCase();
|
||||
const result = (fm.result || '').toString().toLowerCase();
|
||||
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
const result = ((fm.result as string) || '').toLowerCase();
|
||||
|
||||
// Also accept `result: all_pass` as a fallback when status is absent
|
||||
// — covers UATs that omit `status:`.
|
||||
@@ -418,31 +529,33 @@ function scanUatGaps(planDir) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanVerificationGaps ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/phases for VERIFICATION gaps.
|
||||
*/
|
||||
function scanVerificationGaps(planDir) {
|
||||
function scanVerificationGaps(planDir: string): VerificationGapItem[] {
|
||||
const phasesDir = path.join(planDir, 'phases');
|
||||
if (!fs.existsSync(phasesDir)) return [];
|
||||
|
||||
let dirs;
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return [{ scan_error: true }];
|
||||
return [{ scan_error: true, phase: '', file: '', status: '' }];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const results: VerificationGapItem[] = [];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const phaseDir = path.join(phasesDir, dir);
|
||||
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
||||
|
||||
let files;
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(phaseDir);
|
||||
} catch {
|
||||
@@ -452,7 +565,7 @@ function scanVerificationGaps(planDir) {
|
||||
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
||||
const filePath = path.join(phaseDir, file);
|
||||
|
||||
let safeFilePath;
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
|
||||
} catch {
|
||||
@@ -463,7 +576,7 @@ function scanVerificationGaps(planDir) {
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = (fm.status || 'unknown').toLowerCase();
|
||||
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
|
||||
if (status !== 'gaps_found' && status !== 'human_needed') continue;
|
||||
|
||||
@@ -478,31 +591,33 @@ function scanVerificationGaps(planDir) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanContextQuestions ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/phases for CONTEXT files with open_questions.
|
||||
*/
|
||||
function scanContextQuestions(planDir) {
|
||||
function scanContextQuestions(planDir: string): ContextQuestionItem[] {
|
||||
const phasesDir = path.join(planDir, 'phases');
|
||||
if (!fs.existsSync(phasesDir)) return [];
|
||||
|
||||
let dirs;
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return [{ scan_error: true }];
|
||||
return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const results: ContextQuestionItem[] = [];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const phaseDir = path.join(phasesDir, dir);
|
||||
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
||||
|
||||
let files;
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(phaseDir);
|
||||
} catch {
|
||||
@@ -512,7 +627,7 @@ function scanContextQuestions(planDir) {
|
||||
for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
|
||||
const filePath = path.join(phaseDir, file);
|
||||
|
||||
let safeFilePath;
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
|
||||
} catch {
|
||||
@@ -525,10 +640,10 @@ function scanContextQuestions(planDir) {
|
||||
const fm = extractFrontmatter(content);
|
||||
|
||||
// Check frontmatter open_questions field
|
||||
let questions = [];
|
||||
let questions: string[] = [];
|
||||
if (fm.open_questions) {
|
||||
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
|
||||
questions = fm.open_questions.map(q => sanitizeForDisplay(String(q).slice(0, 200)));
|
||||
questions = (fm.open_questions as unknown[]).map(q => sanitizeForDisplay(String(q).slice(0, 200)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,10 +654,10 @@ function scanContextQuestions(planDir) {
|
||||
const oqBody = oqMatch[1].trim();
|
||||
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
|
||||
const items = oqBody.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(l => l && l !== '-' && l !== '*')
|
||||
.filter(l => /^[-*\d]/.test(l) || l.includes('?'));
|
||||
questions = items.slice(0, 3).map(q => sanitizeForDisplay(q.slice(0, 200)));
|
||||
.map((l: string) => l.trim())
|
||||
.filter((l: string) => l && l !== '-' && l !== '*')
|
||||
.filter((l: string) => /^[-*\d]/.test(l) || l.includes('?'));
|
||||
questions = items.slice(0, 3).map((q: string) => sanitizeForDisplay(q.slice(0, 200)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,51 +676,54 @@ function scanContextQuestions(planDir) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── auditOpenArtifacts ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Main audit function. Scans all .planning/ artifact categories.
|
||||
*
|
||||
* @param {string} cwd - Project root directory
|
||||
* @returns {object} Structured audit result
|
||||
* @param cwd - Project root directory
|
||||
* @returns Structured audit result
|
||||
*/
|
||||
function auditOpenArtifacts(cwd) {
|
||||
function auditOpenArtifacts(cwd: string): AuditResult {
|
||||
const planDir = planningDir(cwd);
|
||||
|
||||
const debugSessions = (() => {
|
||||
try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true }]; }
|
||||
try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }]; }
|
||||
})();
|
||||
|
||||
const quickTasks = (() => {
|
||||
try { return scanQuickTasks(planDir); } catch { return [{ scan_error: true }]; }
|
||||
try { return scanQuickTasks(planDir); } catch { return [{ scan_error: true, slug: '', date: '', status: '', description: '' }]; }
|
||||
})();
|
||||
|
||||
const threads = (() => {
|
||||
try { return scanThreads(planDir); } catch { return [{ scan_error: true }]; }
|
||||
try { return scanThreads(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }]; }
|
||||
})();
|
||||
|
||||
const todos = (() => {
|
||||
try { return scanTodos(planDir); } catch { return [{ scan_error: true }]; }
|
||||
try { return scanTodos(planDir); } catch { return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }]; }
|
||||
})();
|
||||
|
||||
const seeds = (() => {
|
||||
try { return scanSeeds(planDir); } catch { return [{ scan_error: true }]; }
|
||||
try { return scanSeeds(planDir); } catch { return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }]; }
|
||||
})();
|
||||
|
||||
const uatGaps = (() => {
|
||||
try { return scanUatGaps(planDir); } catch { return [{ scan_error: true }]; }
|
||||
try { return scanUatGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }]; }
|
||||
})();
|
||||
|
||||
const verificationGaps = (() => {
|
||||
try { return scanVerificationGaps(planDir); } catch { return [{ scan_error: true }]; }
|
||||
try { return scanVerificationGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '' }]; }
|
||||
})();
|
||||
|
||||
const contextQuestions = (() => {
|
||||
try { return scanContextQuestions(planDir); } catch { return [{ scan_error: true }]; }
|
||||
try { return scanContextQuestions(planDir); } catch { return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }]; }
|
||||
})();
|
||||
|
||||
// Count real items (not scan_error sentinels)
|
||||
const countReal = arr => arr.filter(i => !i.scan_error && !i._remainder_count).length;
|
||||
const countReal = (arr: Array<{ scan_error?: boolean; _remainder_count?: number }>) =>
|
||||
arr.filter(i => !i.scan_error && !i._remainder_count).length;
|
||||
|
||||
const counts = {
|
||||
const counts: AuditCounts = {
|
||||
debug_sessions: countReal(debugSessions),
|
||||
quick_tasks: countReal(quickTasks),
|
||||
threads: countReal(threads),
|
||||
@@ -614,8 +732,9 @@ function auditOpenArtifacts(cwd) {
|
||||
uat_gaps: countReal(uatGaps),
|
||||
verification_gaps: countReal(verificationGaps),
|
||||
context_questions: countReal(contextQuestions),
|
||||
total: 0,
|
||||
};
|
||||
counts.total = Object.values(counts).reduce((s, n) => s + n, 0);
|
||||
counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions;
|
||||
|
||||
return {
|
||||
scanned_at: new Date().toISOString(),
|
||||
@@ -634,15 +753,17 @@ function auditOpenArtifacts(cwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── formatAuditReport ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format the audit result as a human-readable report.
|
||||
*
|
||||
* @param {object} auditResult - Result from auditOpenArtifacts()
|
||||
* @returns {string} Formatted report
|
||||
* @param auditResult - Result from auditOpenArtifacts()
|
||||
* @returns Formatted report
|
||||
*/
|
||||
function formatAuditReport(auditResult) {
|
||||
function formatAuditReport(auditResult: AuditResult): string {
|
||||
const { counts, items, has_open_items } = auditResult;
|
||||
const lines = [];
|
||||
const lines: string[] = [];
|
||||
const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
|
||||
|
||||
lines.push(hr);
|
||||
@@ -752,4 +873,4 @@ function formatAuditReport(auditResult) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = { auditOpenArtifacts, formatAuditReport };
|
||||
export = { auditOpenArtifacts, formatAuditReport };
|
||||
@@ -1,12 +1,24 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Check subcommand router — auto-mode, decision-coverage-plan, decision-coverage-verify.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/check-command-router.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only strict types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { output, error, ERROR_REASON } = require('./core.cjs');
|
||||
const { parseDecisions } = require('./decisions.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output, error, ERROR_REASON } = core;
|
||||
import { parseDecisions } from './decisions.cjs';
|
||||
import type { Decision } from './decisions.cjs';
|
||||
|
||||
function normalizePhrase(text) {
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizePhrase(text: unknown): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
return String(text || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
@@ -16,20 +28,20 @@ function normalizePhrase(text) {
|
||||
|
||||
const SOFT_PHRASE_MIN_WORDS = 6;
|
||||
|
||||
function softPhrase(text) {
|
||||
function softPhrase(text: unknown): string {
|
||||
const words = normalizePhrase(text).split(' ').filter(Boolean);
|
||||
if (words.length < SOFT_PHRASE_MIN_WORDS) return '';
|
||||
return words.slice(0, SOFT_PHRASE_MIN_WORDS).join(' ');
|
||||
}
|
||||
|
||||
function decisionMentioned(haystack, decision) {
|
||||
function decisionMentioned(haystack: string | null | undefined, decision: Decision): boolean {
|
||||
if (!haystack) return false;
|
||||
if (new RegExp(`\\b${decision.id}\\b`).test(haystack)) return true;
|
||||
const phrase = softPhrase(decision.text);
|
||||
return phrase ? normalizePhrase(haystack).includes(phrase) : false;
|
||||
}
|
||||
|
||||
function readIfExists(filePath) {
|
||||
function readIfExists(filePath: string): string {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
@@ -37,26 +49,33 @@ function readIfExists(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePath(inputPath, projectDir) {
|
||||
function resolvePath(inputPath: string, projectDir: string): string {
|
||||
return path.isAbsolute(inputPath) ? inputPath : path.join(projectDir, inputPath);
|
||||
}
|
||||
|
||||
function readWorkflowConfig(projectDir) {
|
||||
interface WorkflowConfig {
|
||||
auto_advance?: boolean;
|
||||
_auto_chain_active?: boolean;
|
||||
context_coverage_gate?: boolean | string;
|
||||
}
|
||||
|
||||
function readWorkflowConfig(projectDir: string): WorkflowConfig {
|
||||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
const wf = (parsed['workflow'] as Record<string, unknown> | undefined) || {};
|
||||
return {
|
||||
...(parsed.workflow || {}),
|
||||
auto_advance: parsed.workflow?.auto_advance ?? parsed.auto_advance,
|
||||
_auto_chain_active: parsed.workflow?._auto_chain_active ?? parsed._auto_chain_active,
|
||||
context_coverage_gate: parsed.workflow?.context_coverage_gate ?? parsed.context_coverage_gate,
|
||||
...wf,
|
||||
auto_advance: (wf['auto_advance'] ?? parsed['auto_advance']) as boolean | undefined,
|
||||
_auto_chain_active: (wf['_auto_chain_active'] ?? parsed['_auto_chain_active']) as boolean | undefined,
|
||||
context_coverage_gate: (wf['context_coverage_gate'] ?? parsed['context_coverage_gate']) as boolean | string | undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function cmdAutoMode(projectDir, raw) {
|
||||
function cmdAutoMode(projectDir: string, raw: boolean): void {
|
||||
const workflow = readWorkflowConfig(projectDir);
|
||||
const autoAdvance = Boolean(workflow.auto_advance ?? false);
|
||||
const autoChainActive = Boolean(workflow._auto_chain_active ?? false);
|
||||
@@ -70,10 +89,10 @@ function cmdAutoMode(projectDir, raw) {
|
||||
source,
|
||||
auto_chain_active: autoChainActive,
|
||||
auto_advance: autoAdvance,
|
||||
}, raw);
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
function gateEnabled(projectDir) {
|
||||
function gateEnabled(projectDir: string): boolean {
|
||||
const value = readWorkflowConfig(projectDir).context_coverage_gate;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
@@ -83,7 +102,7 @@ function gateEnabled(projectDir) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadPlanContents(phaseDir) {
|
||||
function loadPlanContents(phaseDir: string): string[] {
|
||||
if (!fs.existsSync(phaseDir)) return [];
|
||||
try {
|
||||
return fs.readdirSync(phaseDir)
|
||||
@@ -97,14 +116,14 @@ function loadPlanContents(phaseDir) {
|
||||
const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
|
||||
const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi;
|
||||
|
||||
function stripCommentsAndFences(text) {
|
||||
function stripCommentsAndFences(text: string): string {
|
||||
return text
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/~~~[\s\S]*?~~~/g, ' ');
|
||||
}
|
||||
|
||||
function extractYamlBlock(frontmatter, key) {
|
||||
function extractYamlBlock(frontmatter: string, key: string): string {
|
||||
const match = frontmatter.match(new RegExp(`^${key}\\s*:(.*)$`, 'm'));
|
||||
if (!match) return '';
|
||||
const startIdx = (match.index || 0) + match[0].length;
|
||||
@@ -117,28 +136,28 @@ function extractYamlBlock(frontmatter, key) {
|
||||
return block.join('\n');
|
||||
}
|
||||
|
||||
function extractXmlTagBodies(text) {
|
||||
const parts = [];
|
||||
function extractXmlTagBodies(text: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const match of text.matchAll(XML_DECISION_TAGS_RE)) {
|
||||
if (match[1]) parts.push(match[1]);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function extractPlanDesignatedSections(planContent) {
|
||||
function extractPlanDesignatedSections(planContent: string | null | undefined): string {
|
||||
if (!planContent) return '';
|
||||
const cleaned = stripCommentsAndFences(planContent);
|
||||
const fmMatch = cleaned.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
const frontmatter = fmMatch ? fmMatch[1] : '';
|
||||
const body = fmMatch ? fmMatch[2] : cleaned;
|
||||
|
||||
const parts = [];
|
||||
const parts: string[] = [];
|
||||
for (const key of ['must_haves', 'truths', 'objective']) {
|
||||
const block = extractYamlBlock(frontmatter, key);
|
||||
if (block) parts.push(block);
|
||||
}
|
||||
|
||||
const bodyParts = [];
|
||||
const bodyParts: string[] = [];
|
||||
let inDesignated = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const heading = /^#{1,6}\s+/.test(line);
|
||||
@@ -154,7 +173,13 @@ function extractPlanDesignatedSections(planContent) {
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
function buildPlanMessage(uncovered) {
|
||||
interface UncoveredItem {
|
||||
id: string;
|
||||
text: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
function buildPlanMessage(uncovered: UncoveredItem[]): string {
|
||||
if (uncovered.length === 0) return 'All trackable CONTEXT.md decisions are covered by plans.';
|
||||
return [
|
||||
'## Decision Coverage Gap',
|
||||
@@ -168,7 +193,7 @@ function buildPlanMessage(uncovered) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildVerifyMessage(notHonored) {
|
||||
function buildVerifyMessage(notHonored: UncoveredItem[]): string {
|
||||
if (notHonored.length === 0) return 'All trackable CONTEXT.md decisions are honored by shipped artifacts.';
|
||||
return [
|
||||
'### Decision Coverage (warning)',
|
||||
@@ -181,31 +206,31 @@ function buildVerifyMessage(notHonored) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function loadTrackableDecisions(contextPath) {
|
||||
function loadTrackableDecisions(contextPath: string): Decision[] {
|
||||
return parseDecisions(readIfExists(contextPath)).filter((decision) => decision.trackable);
|
||||
}
|
||||
|
||||
function cmdDecisionCoveragePlan(projectDir, args, raw) {
|
||||
function cmdDecisionCoveragePlan(projectDir: string, args: string[], raw: boolean): void {
|
||||
const phaseDir = args[2] ? resolvePath(args[2], projectDir) : '';
|
||||
const contextPath = args[3] ? resolvePath(args[3], projectDir) : '';
|
||||
|
||||
if (!gateEnabled(projectDir)) {
|
||||
output({ passed: true, skipped: true, reason: 'workflow.context_coverage_gate is false', total: 0, covered: 0, uncovered: [], message: 'Decision coverage gate disabled by config.' }, raw);
|
||||
output({ passed: true, skipped: true, reason: 'workflow.context_coverage_gate is false', total: 0, covered: 0, uncovered: [], message: 'Decision coverage gate disabled by config.' }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
if (!contextPath || !fs.existsSync(contextPath)) {
|
||||
output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw);
|
||||
output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const decisions = loadTrackableDecisions(contextPath);
|
||||
if (decisions.length === 0) {
|
||||
output({ passed: true, skipped: true, reason: 'no trackable decisions', total: 0, covered: 0, uncovered: [], message: 'No trackable decisions in CONTEXT.md.' }, raw);
|
||||
output({ passed: true, skipped: true, reason: 'no trackable decisions', total: 0, covered: 0, uncovered: [], message: 'No trackable decisions in CONTEXT.md.' }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = loadPlanContents(phaseDir).map(extractPlanDesignatedSections);
|
||||
const uncovered = [];
|
||||
const uncovered: UncoveredItem[] = [];
|
||||
let covered = 0;
|
||||
for (const decision of decisions) {
|
||||
if (sections.some((section) => decisionMentioned(section, decision))) covered++;
|
||||
@@ -219,10 +244,10 @@ function cmdDecisionCoveragePlan(projectDir, args, raw) {
|
||||
covered,
|
||||
uncovered,
|
||||
message: buildPlanMessage(uncovered),
|
||||
}, raw);
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
function recentCommitMessages(projectDir) {
|
||||
function recentCommitMessages(projectDir: string): string {
|
||||
try {
|
||||
return execFileSync('git', ['log', '-n', '200', '--pretty=%s%n%b'], {
|
||||
cwd: projectDir,
|
||||
@@ -234,14 +259,14 @@ function recentCommitMessages(projectDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function isInsideRoot(candidatePath, rootDir) {
|
||||
function isInsideRoot(candidatePath: string, rootDir: string): boolean {
|
||||
const root = path.resolve(rootDir);
|
||||
const target = path.resolve(root, candidatePath);
|
||||
return target === root || target.startsWith(`${root}${path.sep}`);
|
||||
}
|
||||
|
||||
function readModifiedFilesContent(projectDir, summaries) {
|
||||
const out = [];
|
||||
function readModifiedFilesContent(projectDir: string, summaries: string[]): string {
|
||||
const out: string[] = [];
|
||||
let total = 0;
|
||||
for (const summary of summaries) {
|
||||
if (!summary) continue;
|
||||
@@ -262,22 +287,22 @@ function readModifiedFilesContent(projectDir, summaries) {
|
||||
return out.join('\n\n');
|
||||
}
|
||||
|
||||
function cmdDecisionCoverageVerify(projectDir, args, raw) {
|
||||
function cmdDecisionCoverageVerify(projectDir: string, args: string[], raw: boolean): void {
|
||||
const phaseDir = args[2] ? resolvePath(args[2], projectDir) : '';
|
||||
const contextPath = args[3] ? resolvePath(args[3], projectDir) : '';
|
||||
|
||||
if (!gateEnabled(projectDir)) {
|
||||
output({ skipped: true, blocking: false, reason: 'workflow.context_coverage_gate is false', total: 0, honored: 0, not_honored: [], message: 'Decision coverage gate disabled by config.' }, raw);
|
||||
output({ skipped: true, blocking: false, reason: 'workflow.context_coverage_gate is false', total: 0, honored: 0, not_honored: [], message: 'Decision coverage gate disabled by config.' }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
if (!contextPath || !fs.existsSync(contextPath)) {
|
||||
output({ skipped: true, blocking: false, reason: 'CONTEXT.md missing', total: 0, honored: 0, not_honored: [], message: 'No CONTEXT.md - nothing to check.' }, raw);
|
||||
output({ skipped: true, blocking: false, reason: 'CONTEXT.md missing', total: 0, honored: 0, not_honored: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const decisions = loadTrackableDecisions(contextPath);
|
||||
if (decisions.length === 0) {
|
||||
output({ skipped: true, blocking: false, reason: 'no trackable decisions', total: 0, honored: 0, not_honored: [], message: 'No trackable decisions in CONTEXT.md.' }, raw);
|
||||
output({ skipped: true, blocking: false, reason: 'no trackable decisions', total: 0, honored: 0, not_honored: [], message: 'No trackable decisions in CONTEXT.md.' }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,7 +317,7 @@ function cmdDecisionCoverageVerify(projectDir, args, raw) {
|
||||
recentCommitMessages(projectDir),
|
||||
].join('\n\n');
|
||||
|
||||
const notHonored = [];
|
||||
const notHonored: UncoveredItem[] = [];
|
||||
let honored = 0;
|
||||
for (const decision of decisions) {
|
||||
if (decisionMentioned(haystack, decision)) honored++;
|
||||
@@ -306,10 +331,16 @@ function cmdDecisionCoverageVerify(projectDir, args, raw) {
|
||||
honored,
|
||||
not_honored: notHonored,
|
||||
message: buildVerifyMessage(notHonored),
|
||||
}, raw);
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
function routeCheckCommand({ args, cwd, raw }) {
|
||||
interface RouteCheckCommandOptions {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
|
||||
const subcommand = args[1];
|
||||
if (subcommand === 'auto-mode') {
|
||||
cmdAutoMode(cwd, raw);
|
||||
@@ -326,7 +357,7 @@ function routeCheckCommand({ args, cwd, raw }) {
|
||||
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
routeCheckCommand,
|
||||
decisionMentioned,
|
||||
extractPlanDesignatedSections,
|
||||
@@ -1,15 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
const { createHub, ERROR_KINDS } = require('./command-routing-hub.cjs');
|
||||
|
||||
/**
|
||||
* CJS Command Router Adapter Module
|
||||
*
|
||||
* Compatibility routing for gsd-tools.cjs command families. Uses generated
|
||||
* command metadata for availability and small family-local argument shapers for
|
||||
* CJS handler calls.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/cjs-command-router-adapter.cjs
|
||||
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import commandRoutingHub = require('./command-routing-hub.cjs');
|
||||
const { createHub, ERROR_KINDS } = commandRoutingHub;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Handler = () => unknown;
|
||||
|
||||
interface RouteCjsCommandFamilyOptions {
|
||||
args: string[];
|
||||
subcommands: string[];
|
||||
handlers: Record<string, Handler>;
|
||||
defaultSubcommand?: string;
|
||||
unsupported?: Record<string, string>;
|
||||
unknownMessage: (subcommand: string, available: string[]) => string;
|
||||
error: (message: string) => void;
|
||||
cwd?: string;
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
interface RouteHubCommandFamilyOptions {
|
||||
family: string;
|
||||
args: string[];
|
||||
subcommands: string[];
|
||||
handlers: Record<string, Handler>;
|
||||
defaultSubcommand?: string;
|
||||
unsupported?: Record<string, string>;
|
||||
unknownMessage: (subcommand: string, available: string[]) => string;
|
||||
error: (message: string) => void;
|
||||
cwd?: string;
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
function routeCjsCommandFamily({
|
||||
args,
|
||||
subcommands,
|
||||
@@ -20,7 +55,7 @@ function routeCjsCommandFamily({
|
||||
error,
|
||||
cwd,
|
||||
raw,
|
||||
}) {
|
||||
}: RouteCjsCommandFamilyOptions): void {
|
||||
routeHubCommandFamily({
|
||||
family: '__legacy_cjs_family__',
|
||||
args,
|
||||
@@ -53,7 +88,7 @@ function routeHubCommandFamily({
|
||||
error,
|
||||
cwd,
|
||||
raw,
|
||||
}) {
|
||||
}: RouteHubCommandFamilyOptions): void {
|
||||
const subcommand = args[1] || defaultSubcommand;
|
||||
|
||||
if (subcommand && unsupported[subcommand]) {
|
||||
@@ -65,12 +100,12 @@ function routeHubCommandFamily({
|
||||
const registryHandlers = Object.fromEntries(
|
||||
Object.entries(handlers).map(([name, handler]) => [
|
||||
name,
|
||||
() => {
|
||||
(): { ok: true; data: unknown } => {
|
||||
const result = handler();
|
||||
if (result && typeof result === 'object' && Object.prototype.hasOwnProperty.call(result, 'ok')) {
|
||||
return result;
|
||||
return result as { ok: true; data: unknown };
|
||||
}
|
||||
return { ok: true, data: null };
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -90,14 +125,14 @@ function routeHubCommandFamily({
|
||||
|
||||
if (result.ok) return;
|
||||
if (result.kind === ERROR_KINDS.UnknownCommand) {
|
||||
error(unknownMessage(subcommand, available));
|
||||
error(unknownMessage(subcommand ?? '', available));
|
||||
return;
|
||||
}
|
||||
if (result.kind === ERROR_KINDS.InvalidArgs || result.kind === ERROR_KINDS.HandlerRefusal) {
|
||||
error(result.reason);
|
||||
error((result as { reason: string }).reason);
|
||||
return;
|
||||
}
|
||||
error(result.message);
|
||||
error((result as { message: string }).message);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,11 +142,11 @@ function routeHubCommandFamily({
|
||||
* Accepts variable argument shapes so routers can pass legacy projection tuples
|
||||
* (`registryCommand`, `registryArgs`, `legacyArgs`, optional `rawFormatter`, `cjsFallback`).
|
||||
*/
|
||||
function cjsFallbackHandler(...projectionArgs) {
|
||||
function cjsFallbackHandler(...projectionArgs: unknown[]): unknown {
|
||||
return projectionArgs[projectionArgs.length - 1];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
routeCjsCommandFamily,
|
||||
routeHubCommandFamily,
|
||||
cjsFallbackHandler,
|
||||
@@ -1,7 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Deterministic clock seam for lock modules (issue #453).
|
||||
* Deterministic clock seam for lock modules (ADR-457 build-at-publish: the
|
||||
* hand-written bin/lib/clock.cjs collapsed to a TypeScript source of truth).
|
||||
* Behaviour is preserved byte-for-behaviour from the prior hand-written .cjs;
|
||||
* only types are added.
|
||||
*
|
||||
* Production code uses `realClock` (the default). Test code passes in a
|
||||
* `makeFakeClock()` instance to drive lock timing without real wall-clock
|
||||
@@ -14,6 +15,14 @@
|
||||
* - sleep() → Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
||||
*/
|
||||
|
||||
/** Clock interface implemented by both realClock and test fakes. */
|
||||
export interface Clock {
|
||||
now(): number;
|
||||
nowIso(): string;
|
||||
today(): string;
|
||||
sleep(ms: number): void;
|
||||
}
|
||||
|
||||
// Module-level Atomics.wait buffer reused across every realClock.sleep() call.
|
||||
// The buffer value is always 0 (never written), so reuse is semantically
|
||||
// identical to allocating a fresh buffer each time.
|
||||
@@ -28,21 +37,19 @@ const _realSleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
||||
*
|
||||
* Returns null (fall back to Date.now()) for any invalid or absent input.
|
||||
* Returns null when GSD_TEST_MODE is not set.
|
||||
*
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function _pinnedNowMs() {
|
||||
function _pinnedNowMs(): number | null {
|
||||
if (!process.env.GSD_TEST_MODE) return null;
|
||||
const raw = process.env.GSD_NOW_MS;
|
||||
if (typeof raw !== 'string') return null;
|
||||
const t = raw.trim();
|
||||
if (!/^-?\d+$/.test(t)) return null; // reject '', 'abc', '1e30', '12.5'
|
||||
if (!/^-?\d+$/.test(t)) return null; // reject '', 'abc', '1e30', '12.5'
|
||||
const ms = Number(t);
|
||||
if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) return null; // Date-valid bounds
|
||||
if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) return null; // Date-valid bounds
|
||||
return ms;
|
||||
}
|
||||
|
||||
const realClock = {
|
||||
export const realClock: Clock = {
|
||||
/**
|
||||
* Return current epoch milliseconds.
|
||||
*
|
||||
@@ -54,7 +61,7 @@ const realClock = {
|
||||
* Any other value (empty string, float, scientific notation, out-of-range) falls
|
||||
* back to Date.now() to prevent RangeError from new Date(ms).toISOString().
|
||||
*/
|
||||
now() {
|
||||
now(): number {
|
||||
const pinned = _pinnedNowMs();
|
||||
if (pinned !== null) return pinned;
|
||||
return Date.now();
|
||||
@@ -64,9 +71,9 @@ const realClock = {
|
||||
* Return the current instant as an ISO 8601 string (UTC).
|
||||
* Uses this.now() so the subprocess time-pin adapter is honoured.
|
||||
*
|
||||
* @returns {string} e.g. "2020-06-15T12:00:00.000Z"
|
||||
* @returns e.g. "2020-06-15T12:00:00.000Z"
|
||||
*/
|
||||
nowIso() {
|
||||
nowIso(): string {
|
||||
return new Date(this.now()).toISOString();
|
||||
},
|
||||
|
||||
@@ -74,9 +81,9 @@ const realClock = {
|
||||
* Return today's date as a YYYY-MM-DD string (UTC calendar day).
|
||||
* Uses this.now() so the subprocess time-pin adapter is honoured.
|
||||
*
|
||||
* @returns {string} e.g. "2020-06-15"
|
||||
* @returns e.g. "2020-06-15"
|
||||
*/
|
||||
today() {
|
||||
today(): string {
|
||||
return this.nowIso().split('T')[0];
|
||||
},
|
||||
|
||||
@@ -86,11 +93,9 @@ const realClock = {
|
||||
* inline before the seam. Atomics.wait on a shared buffer that is never
|
||||
* notified times out after exactly `ms` milliseconds without spinning the CPU.
|
||||
*
|
||||
* @param {number} ms - milliseconds to sleep
|
||||
* @param ms - milliseconds to sleep
|
||||
*/
|
||||
sleep(ms) {
|
||||
sleep(ms: number): void {
|
||||
Atomics.wait(_realSleepBuf, 0, 0, ms);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { realClock };
|
||||
@@ -1,6 +1,8 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2).
|
||||
* Skill cluster definitions for the runtime surface module (ADR-457
|
||||
* build-at-publish: the hand-written bin/lib/clusters.cjs collapsed to a
|
||||
* TypeScript source of truth). Behaviour is preserved byte-for-behaviour from
|
||||
* the prior hand-written .cjs; only types are added.
|
||||
*
|
||||
* Each cluster is a named group of skill stems. Clusters are used by /gsd:surface
|
||||
* to enable/disable a cohesive group of skills without reinstall.
|
||||
@@ -13,7 +15,21 @@
|
||||
* against commands/gsd/ listing in surface-clusters.test.cjs).
|
||||
*/
|
||||
|
||||
const CLUSTERS = Object.freeze({
|
||||
export type ClusterName =
|
||||
| 'core_loop'
|
||||
| 'audit_review'
|
||||
| 'milestone'
|
||||
| 'research_ideate'
|
||||
| 'workspace_state'
|
||||
| 'docs'
|
||||
| 'ui'
|
||||
| 'ai_eval'
|
||||
| 'ns_meta'
|
||||
| 'utility';
|
||||
|
||||
export type ClusterMap = Readonly<Record<ClusterName, ReadonlyArray<string>>>;
|
||||
|
||||
export const CLUSTERS: ClusterMap = Object.freeze({
|
||||
core_loop: Object.freeze([
|
||||
'new-project',
|
||||
'discuss-phase',
|
||||
@@ -122,14 +138,11 @@ const CLUSTERS = Object.freeze({
|
||||
|
||||
/**
|
||||
* Build a Set of all skill stems covered by at least one cluster.
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function allClusteredSkills() {
|
||||
const result = new Set();
|
||||
export function allClusteredSkills(): Set<string> {
|
||||
const result = new Set<string>();
|
||||
for (const skills of Object.values(CLUSTERS)) {
|
||||
for (const s of skills) result.add(s);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { CLUSTERS, allClusteredSkills };
|
||||
73
src/code-review-flags.cts
Normal file
73
src/code-review-flags.cts
Normal 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';
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* state.*, verify.*, init.*, phase.*, phases.*, validate.*, roadmap.*, and non-family alias/subcommand metadata for CJS routing.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/command-aliases.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const STATE_COMMAND_ALIASES = [
|
||||
interface CommandAlias {
|
||||
canonical: string;
|
||||
aliases: string[];
|
||||
subcommand: string;
|
||||
mutation: boolean;
|
||||
}
|
||||
|
||||
interface NonFamilyCommandAlias {
|
||||
canonical: string;
|
||||
aliases: string[];
|
||||
mutation: boolean;
|
||||
}
|
||||
|
||||
export const STATE_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "state.load",
|
||||
"aliases": [],
|
||||
@@ -173,7 +188,7 @@ const STATE_COMMAND_ALIASES = [
|
||||
}
|
||||
];
|
||||
|
||||
const VERIFY_COMMAND_ALIASES = [
|
||||
export const VERIFY_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "verify.plan-structure",
|
||||
"aliases": [
|
||||
@@ -240,7 +255,7 @@ const VERIFY_COMMAND_ALIASES = [
|
||||
}
|
||||
];
|
||||
|
||||
const INIT_COMMAND_ALIASES = [
|
||||
export const INIT_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "init.execute-phase",
|
||||
"aliases": [
|
||||
@@ -379,7 +394,7 @@ const INIT_COMMAND_ALIASES = [
|
||||
}
|
||||
];
|
||||
|
||||
const PHASE_COMMAND_ALIASES = [
|
||||
export const PHASE_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "phase.uat-passed",
|
||||
"aliases": [
|
||||
@@ -446,7 +461,7 @@ const PHASE_COMMAND_ALIASES = [
|
||||
}
|
||||
];
|
||||
|
||||
const PHASES_COMMAND_ALIASES = [
|
||||
export const PHASES_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "phases.list",
|
||||
"aliases": [
|
||||
@@ -473,7 +488,7 @@ const PHASES_COMMAND_ALIASES = [
|
||||
}
|
||||
];
|
||||
|
||||
const VALIDATE_COMMAND_ALIASES = [
|
||||
export const VALIDATE_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "validate.consistency",
|
||||
"aliases": [
|
||||
@@ -508,7 +523,7 @@ const VALIDATE_COMMAND_ALIASES = [
|
||||
}
|
||||
];
|
||||
|
||||
const ROADMAP_COMMAND_ALIASES = [
|
||||
export const ROADMAP_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "roadmap.analyze",
|
||||
"aliases": [
|
||||
@@ -559,7 +574,7 @@ const ROADMAP_COMMAND_ALIASES = [
|
||||
}
|
||||
];
|
||||
|
||||
const NON_FAMILY_COMMAND_ALIASES = [
|
||||
export const NON_FAMILY_COMMAND_ALIASES: NonFamilyCommandAlias[] = [
|
||||
{
|
||||
"canonical": "agent.classify-failure",
|
||||
"aliases": [
|
||||
@@ -804,28 +819,10 @@ const NON_FAMILY_COMMAND_ALIASES = [
|
||||
}
|
||||
];
|
||||
|
||||
const STATE_SUBCOMMANDS = STATE_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
const VERIFY_SUBCOMMANDS = VERIFY_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
const INIT_SUBCOMMANDS = INIT_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
const PHASE_SUBCOMMANDS = PHASE_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
const PHASES_SUBCOMMANDS = PHASES_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
const VALIDATE_SUBCOMMANDS = VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
const ROADMAP_SUBCOMMANDS = ROADMAP_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
|
||||
module.exports = {
|
||||
STATE_COMMAND_ALIASES,
|
||||
VERIFY_COMMAND_ALIASES,
|
||||
INIT_COMMAND_ALIASES,
|
||||
PHASE_COMMAND_ALIASES,
|
||||
PHASES_COMMAND_ALIASES,
|
||||
VALIDATE_COMMAND_ALIASES,
|
||||
ROADMAP_COMMAND_ALIASES,
|
||||
NON_FAMILY_COMMAND_ALIASES,
|
||||
STATE_SUBCOMMANDS,
|
||||
VERIFY_SUBCOMMANDS,
|
||||
INIT_SUBCOMMANDS,
|
||||
PHASE_SUBCOMMANDS,
|
||||
PHASES_SUBCOMMANDS,
|
||||
VALIDATE_SUBCOMMANDS,
|
||||
ROADMAP_SUBCOMMANDS,
|
||||
};
|
||||
export const STATE_SUBCOMMANDS: string[] = STATE_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
export const VERIFY_SUBCOMMANDS: string[] = VERIFY_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
export const INIT_SUBCOMMANDS: string[] = INIT_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
export const PHASE_SUBCOMMANDS: string[] = PHASE_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
export const PHASES_SUBCOMMANDS: string[] = PHASES_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
export const VALIDATE_SUBCOMMANDS: string[] = VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
export const ROADMAP_SUBCOMMANDS: string[] = ROADMAP_COMMAND_ALIASES.map((entry) => entry.subcommand);
|
||||
@@ -1,7 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Command Argument Projection Module
|
||||
* Command Argument Projection Module (ADR-457 build-at-publish: the
|
||||
* hand-written bin/lib/command-arg-projection.cjs collapsed to a TypeScript
|
||||
* source of truth). Behaviour is preserved byte-for-behaviour from the prior
|
||||
* hand-written .cjs; only types are added.
|
||||
*
|
||||
* Shared helpers for command-family adapters to project argv tokens into
|
||||
* typed named values and multi-word segments.
|
||||
@@ -11,26 +12,26 @@
|
||||
* Extract named --flag <value> pairs from an args array.
|
||||
* Returns an object mapping flag names to their values (null if absent).
|
||||
* Flags listed in `booleanFlags` are treated as booleans.
|
||||
*
|
||||
* @param {string[]} args
|
||||
* @param {string[]} [valueFlags]
|
||||
* @param {string[]} [booleanFlags]
|
||||
* @returns {Record<string, string|boolean|null>}
|
||||
*/
|
||||
function parseNamedArgs(args, valueFlags = [], booleanFlags = []) {
|
||||
export function parseNamedArgs(
|
||||
args: string[],
|
||||
valueFlags: string[] = [],
|
||||
booleanFlags: string[] = [],
|
||||
): Record<string, string | boolean | null> {
|
||||
// Index each token's first position once (firstIndex.get(t) ?? -1 === args.indexOf(t),
|
||||
// firstIndex.has(t) === args.includes(t)) so the flag loops below don't each re-scan
|
||||
// argv — O(argv + flags) instead of O(flags * argv). Semantics are unchanged. (#312)
|
||||
const firstIndex = new Map();
|
||||
const firstIndex = new Map<string, number>();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (!firstIndex.has(args[i])) firstIndex.set(args[i], i);
|
||||
}
|
||||
const result = {};
|
||||
const result: Record<string, string | boolean | null> = {};
|
||||
for (const flag of valueFlags) {
|
||||
const idx = firstIndex.has(`--${flag}`) ? firstIndex.get(`--${flag}`) : -1;
|
||||
result[flag] = idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--')
|
||||
? args[idx + 1]
|
||||
: null;
|
||||
const idx = firstIndex.has(`--${flag}`) ? (firstIndex.get(`--${flag}`) as number) : -1;
|
||||
result[flag] =
|
||||
idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--')
|
||||
? args[idx + 1]
|
||||
: null;
|
||||
}
|
||||
for (const flag of booleanFlags) {
|
||||
result[flag] = firstIndex.has(`--${flag}`);
|
||||
@@ -40,23 +41,14 @@ function parseNamedArgs(args, valueFlags = [], booleanFlags = []) {
|
||||
|
||||
/**
|
||||
* Collect all tokens after --flag until the next --flag or end of args.
|
||||
*
|
||||
* @param {string[]} args
|
||||
* @param {string} flag
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function parseMultiwordArg(args, flag) {
|
||||
export function parseMultiwordArg(args: string[], flag: string): string | null {
|
||||
const idx = args.indexOf(`--${flag}`);
|
||||
if (idx === -1) return null;
|
||||
const tokens = [];
|
||||
const tokens: string[] = [];
|
||||
for (let i = idx + 1; i < args.length; i++) {
|
||||
if (args[i].startsWith('--')) break;
|
||||
tokens.push(args[i]);
|
||||
}
|
||||
return tokens.length > 0 ? tokens.join(' ') : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseNamedArgs,
|
||||
parseMultiwordArg,
|
||||
};
|
||||
@@ -25,8 +25,19 @@
|
||||
* - The kind taxonomy is closed. Callers switch on ERROR_KINDS values.
|
||||
* - Each error variant carries ONLY its own typed payload (#176).
|
||||
* No cross-variant `message`/`details` escape hatches.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/command-routing-hub.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
||||
* the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
import { makeDispatchEvent } from './observability/event.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import observabilityLogger = require('./observability/logger.cjs');
|
||||
const { createNoOpLogger } = observabilityLogger;
|
||||
|
||||
// ─── Error kind constants ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Closed error-kind enum. Export as a frozen object so callers can switch on
|
||||
* ERROR_KINDS.UnknownCommand etc. without relying on bare string literals.
|
||||
@@ -45,20 +56,50 @@ const ERROR_KINDS = Object.freeze({
|
||||
HandlerRefusal: 'HandlerRefusal',
|
||||
/** A handler threw an unexpected exception. */
|
||||
HandlerFailure: 'HandlerFailure',
|
||||
});
|
||||
} as const);
|
||||
|
||||
// ─── Observability imports ────────────────────────────────────────────────────
|
||||
const { makeDispatchEvent } = require('./observability/event.cjs');
|
||||
const { createNoOpLogger } = require('./observability/logger.cjs');
|
||||
// ─── Result types ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface OkResult {
|
||||
ok: true;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface UnknownCommandResult {
|
||||
ok: false;
|
||||
kind: 'UnknownCommand';
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface InvalidArgsResult {
|
||||
ok: false;
|
||||
kind: 'InvalidArgs';
|
||||
arg: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface HandlerRefusalResult {
|
||||
ok: false;
|
||||
kind: 'HandlerRefusal';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface HandlerFailureResult {
|
||||
ok: false;
|
||||
kind: 'HandlerFailure';
|
||||
message: string;
|
||||
cause?: Error;
|
||||
}
|
||||
|
||||
type ErrResult = UnknownCommandResult | InvalidArgsResult | HandlerRefusalResult | HandlerFailureResult;
|
||||
type HubResult = OkResult | ErrResult;
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Safe JSON serialisation that never throws.
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function _safeJson(value) {
|
||||
function _safeJson(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
@@ -72,46 +113,37 @@ function _safeJson(value) {
|
||||
// Finding 3: all factory returns are Object.freeze'd so callers cannot mutate
|
||||
// the variant invariant.
|
||||
|
||||
/**
|
||||
* @param {string} command - The unrecognised command string (family or family+subcommand).
|
||||
* @returns {Readonly<{ ok: false, kind: 'UnknownCommand', command: string }>}
|
||||
*/
|
||||
function makeUnknownCommand(command) {
|
||||
return Object.freeze({ ok: false, kind: ERROR_KINDS.UnknownCommand, command });
|
||||
function makeUnknownCommand(command: string): Readonly<UnknownCommandResult> {
|
||||
return Object.freeze({ ok: false as const, kind: ERROR_KINDS.UnknownCommand, command });
|
||||
}
|
||||
|
||||
function makeInvalidArgs(arg: string, reason: string): Readonly<InvalidArgsResult> {
|
||||
return Object.freeze({ ok: false as const, kind: ERROR_KINDS.InvalidArgs, arg, reason });
|
||||
}
|
||||
|
||||
function makeHandlerRefusal(reason: string): Readonly<HandlerRefusalResult> {
|
||||
return Object.freeze({ ok: false as const, kind: ERROR_KINDS.HandlerRefusal, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} arg - The argument token that failed validation.
|
||||
* @param {string} reason - Human-readable explanation of the failure.
|
||||
* @returns {Readonly<{ ok: false, kind: 'InvalidArgs', arg: string, reason: string }>}
|
||||
*/
|
||||
function makeInvalidArgs(arg, reason) {
|
||||
return Object.freeze({ ok: false, kind: ERROR_KINDS.InvalidArgs, arg, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} reason - Human-readable explanation for the refusal.
|
||||
* @returns {Readonly<{ ok: false, kind: 'HandlerRefusal', reason: string }>}
|
||||
*/
|
||||
function makeHandlerRefusal(reason) {
|
||||
return Object.freeze({ ok: false, kind: ERROR_KINDS.HandlerRefusal, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message - Human-readable description of the failure.
|
||||
* @param {Error} [cause] - The original thrown Error, when available.
|
||||
* @param message - Human-readable description of the failure.
|
||||
* @param cause - The original thrown Error, when available.
|
||||
* Non-Error values (strings, plain objects, etc.) are wrapped in an Error
|
||||
* with `.thrown` set to the original value. null/undefined → no cause field.
|
||||
* @returns {{ ok: false, kind: 'HandlerFailure', message: string, cause?: Error }}
|
||||
*/
|
||||
function makeHandlerFailure(message, cause) {
|
||||
const obj = { ok: false, kind: ERROR_KINDS.HandlerFailure, message };
|
||||
function makeHandlerFailure(message: string, cause?: unknown): HandlerFailureResult {
|
||||
const obj: {
|
||||
ok: false;
|
||||
kind: 'HandlerFailure';
|
||||
message: string;
|
||||
cause?: Error;
|
||||
} = { ok: false as const, kind: ERROR_KINDS.HandlerFailure, message };
|
||||
if (cause != null) {
|
||||
if (cause instanceof Error) {
|
||||
obj.cause = cause;
|
||||
} else {
|
||||
// Finding 4: wrap non-Error cause so downstream .cause.stack never silently returns undefined
|
||||
const wrapper = new Error('non-Error cause: ' + _safeJson(cause));
|
||||
const wrapper = new Error('non-Error cause: ' + _safeJson(cause)) as Error & { thrown?: unknown };
|
||||
wrapper.thrown = cause;
|
||||
obj.cause = wrapper;
|
||||
}
|
||||
@@ -125,10 +157,8 @@ function makeHandlerFailure(message, cause) {
|
||||
* Required payload fields per ok:false kind.
|
||||
* `required` — fields that MUST be present (non-undefined) for the variant to be valid.
|
||||
* `allowed` — the complete set of allowed fields (including ok, kind).
|
||||
*
|
||||
* @type {Record<string, { required: string[], allowed: Set<string> }>}
|
||||
*/
|
||||
const _VARIANT_SCHEMA = {
|
||||
const _VARIANT_SCHEMA: Record<string, { required: string[]; allowed: Set<string> }> = {
|
||||
UnknownCommand: {
|
||||
required: ['command'],
|
||||
allowed: new Set(['ok', 'kind', 'command']),
|
||||
@@ -151,17 +181,14 @@ const _VARIANT_SCHEMA = {
|
||||
* Validates a handler-returned { ok: false, ... } result against the typed schema.
|
||||
*
|
||||
* Returns null if valid, or a string describing the contract violation.
|
||||
*
|
||||
* @param {object} result
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function _validateErrResult(result) {
|
||||
function _validateErrResult(result: Record<string, unknown>): string | null {
|
||||
const { kind } = result;
|
||||
const schema = _VARIANT_SCHEMA[kind];
|
||||
const schema = _VARIANT_SCHEMA[kind as string];
|
||||
|
||||
// Unknown kind — not in the closed enum
|
||||
if (!schema) {
|
||||
return `handler returned unknown kind '${kind}': expected one of ${Object.keys(_VARIANT_SCHEMA).join(', ')}`;
|
||||
return `handler returned unknown kind '${String(kind)}': expected one of ${Object.keys(_VARIANT_SCHEMA).join(', ')}`;
|
||||
}
|
||||
|
||||
// Missing required fields
|
||||
@@ -169,7 +196,7 @@ function _validateErrResult(result) {
|
||||
if (result[field] === undefined) {
|
||||
return (
|
||||
`handler returned malformed Result variant: ` +
|
||||
`kind '${kind}' requires field '${field}' but it is missing. ` +
|
||||
`kind '${String(kind)}' requires field '${field}' but it is missing. ` +
|
||||
`got: ${_safeJson(result)}`
|
||||
);
|
||||
}
|
||||
@@ -180,7 +207,7 @@ function _validateErrResult(result) {
|
||||
if (!schema.allowed.has(key)) {
|
||||
return (
|
||||
`handler returned malformed Result variant: ` +
|
||||
`kind '${kind}' does not allow field '${key}'. ` +
|
||||
`kind '${String(kind)}' does not allow field '${key}'. ` +
|
||||
`expected fields: ${[...schema.allowed].join(', ')}. ` +
|
||||
`got: ${_safeJson(result)}`
|
||||
);
|
||||
@@ -190,34 +217,29 @@ function _validateErrResult(result) {
|
||||
return null; // valid
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ ok: true, data: unknown }} OkResult
|
||||
* @typedef {{ ok: false, kind: 'UnknownCommand', command: string }} UnknownCommandResult
|
||||
* @typedef {{ ok: false, kind: 'InvalidArgs', arg: string, reason: string }} InvalidArgsResult
|
||||
* @typedef {{ ok: false, kind: 'HandlerRefusal', reason: string }} HandlerRefusalResult
|
||||
* @typedef {{ ok: false, kind: 'HandlerFailure', message: string, cause?: Error }} HandlerFailureResult
|
||||
* @typedef {UnknownCommandResult | InvalidArgsResult | HandlerRefusalResult | HandlerFailureResult} ErrResult
|
||||
* @typedef {OkResult | ErrResult} HubResult
|
||||
*/
|
||||
// ─── Hub options ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {object} HubOptions
|
||||
* @property {Record<string, Record<string, (ctx: object) => HubResult>>} [cjsRegistry] -
|
||||
* Nested map of family -> subcommand -> handler.
|
||||
* @property {Record<string, string[]>} [manifest] - Map of family -> known subcommands.
|
||||
* Used for UnknownCommand detection.
|
||||
* @property {{ onEvent(event: object): void }} [logger] -
|
||||
* DispatchLogger to receive a DispatchEvent after every dispatch.
|
||||
* Defaults to a no-op logger (silent). Use createDefaultLogger() for the
|
||||
* reference implementation (stderr on error, opt-in file audit).
|
||||
*/
|
||||
type Handler = (ctx: Record<string, unknown>) => HubResult;
|
||||
|
||||
interface HubOptions {
|
||||
cjsRegistry?: Record<string, Record<string, Handler>>;
|
||||
manifest?: Record<string, string[]>;
|
||||
logger?: { onEvent(event: object): void };
|
||||
}
|
||||
|
||||
interface DispatchRequest {
|
||||
family: string;
|
||||
subcommand?: string;
|
||||
args?: unknown[];
|
||||
cwd?: string;
|
||||
raw?: boolean;
|
||||
parentTraceId?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe stringify for logger-failure warnings — avoids circular-ref crashes.
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function _safeJsonForWarn(value) {
|
||||
function _safeJsonForWarn(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
@@ -227,11 +249,8 @@ function _safeJsonForWarn(value) {
|
||||
|
||||
/**
|
||||
* Construct a CommandRoutingHub.
|
||||
*
|
||||
* @param {HubOptions} options
|
||||
* @returns {{ dispatch: (req: object) => HubResult }}
|
||||
*/
|
||||
function createHub({ cjsRegistry, manifest, logger } = {}) {
|
||||
function createHub({ cjsRegistry, manifest, logger }: HubOptions = {}): { dispatch: (req: DispatchRequest) => HubResult } {
|
||||
const _cjsRegistry = cjsRegistry;
|
||||
const _manifest = manifest;
|
||||
// Default to no-op so callers that don't inject a logger get pure-silent behaviour.
|
||||
@@ -245,28 +264,21 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
|
||||
*
|
||||
* HubResult ok path: { ok: true, data } → { kind: 'ok', data }
|
||||
* HubResult err paths: { ok: false, kind, ...payload } → { kind, ...payload }
|
||||
*
|
||||
* @param {object} hubResult
|
||||
* @returns {object}
|
||||
*/
|
||||
function _normaliseResult(hubResult) {
|
||||
function _normaliseResult(hubResult: HubResult): Record<string, unknown> {
|
||||
if (hubResult.ok) {
|
||||
return { kind: 'ok', data: hubResult.data };
|
||||
}
|
||||
// err variant: already has kind + typed payload
|
||||
return hubResult;
|
||||
// Double-cast through unknown to satisfy strict index-signature check.
|
||||
return hubResult as unknown as Record<string, unknown>; // eslint-disable-line @typescript-eslint/no-unsafe-return
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a DispatchEvent to the injected logger.
|
||||
* Logger errors NEVER propagate — they are caught and emitted as a warn line to stderr.
|
||||
*
|
||||
* @param {string} command - The dispatched command string.
|
||||
* @param {unknown} args - The raw args from the request.
|
||||
* @param {object} hubResult - The HubResult.
|
||||
* @param {string} [parentTraceId] - Optional parent trace ID from the request (P1.4).
|
||||
*/
|
||||
function _notifyLogger(command, args, hubResult, parentTraceId) {
|
||||
function _notifyLogger(command: string, args: unknown, hubResult: HubResult, parentTraceId?: unknown): void {
|
||||
try {
|
||||
const eventResult = _normaliseResult(hubResult);
|
||||
const event = makeDispatchEvent({ command, args, result: eventResult, parentTraceId });
|
||||
@@ -278,7 +290,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
|
||||
_safeJsonForWarn({
|
||||
level: 'warn',
|
||||
source: 'DispatchLogger',
|
||||
message: 'logger.onEvent failed: ' + String(logErr && logErr.message || logErr),
|
||||
message: 'logger.onEvent failed: ' + String((logErr as Error)?.message || logErr),
|
||||
}) + '\n'
|
||||
);
|
||||
} catch {
|
||||
@@ -289,15 +301,12 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
|
||||
|
||||
/**
|
||||
* Dispatch a command through the hub.
|
||||
*
|
||||
* @param {{ family: string, subcommand: string, args?: unknown[], cwd?: string, raw?: boolean }} req
|
||||
* @returns {HubResult}
|
||||
*/
|
||||
function dispatch(req) {
|
||||
const { family, subcommand, args = [], parentTraceId } = req || {};
|
||||
function dispatch(req: DispatchRequest): HubResult {
|
||||
const { family, subcommand, args = [], parentTraceId } = req || {} as DispatchRequest;
|
||||
const command = subcommand ? `${family} ${subcommand}` : String(family);
|
||||
|
||||
let result;
|
||||
let result: HubResult;
|
||||
try {
|
||||
result = _dispatch(req);
|
||||
} catch (err) {
|
||||
@@ -305,7 +314,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
|
||||
result = makeHandlerFailure(err.message, err);
|
||||
} else {
|
||||
// Finding 2: preserve non-Error throwables via a wrapper Error with .thrown
|
||||
const wrapper = new Error('non-Error thrown: ' + _safeJson(err));
|
||||
const wrapper = new Error('non-Error thrown: ' + _safeJson(err)) as Error & { thrown?: unknown };
|
||||
wrapper.thrown = err;
|
||||
result = makeHandlerFailure(String(err), wrapper);
|
||||
}
|
||||
@@ -315,7 +324,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function _dispatch(req) {
|
||||
function _dispatch(req: DispatchRequest): HubResult {
|
||||
const { family, subcommand, args = [], cwd, raw } = req;
|
||||
|
||||
// ── manifest check ────────────────────────────────────────────────────────
|
||||
@@ -332,7 +341,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
|
||||
return _dispatchCjs({ family, subcommand, args, cwd, raw });
|
||||
}
|
||||
|
||||
function _dispatchCjs({ family, subcommand, args, cwd, raw }) {
|
||||
function _dispatchCjs({ family, subcommand, args, cwd, raw }: DispatchRequest): HubResult {
|
||||
if (!_cjsRegistry) {
|
||||
return makeUnknownCommand(String(family));
|
||||
}
|
||||
@@ -355,11 +364,12 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
|
||||
if (result && typeof result === 'object' && 'ok' in result) {
|
||||
if (!result.ok) {
|
||||
// Finding 1: runtime-validate ok:false variant shape; coerce malformed to HandlerFailure
|
||||
const violation = _validateErrResult(result);
|
||||
const violation = _validateErrResult(result as unknown as Record<string, unknown>);
|
||||
if (violation !== null) {
|
||||
return makeHandlerFailure(
|
||||
'handler returned malformed Result variant: ' + violation,
|
||||
new Error('expected ' + (result.kind || '<no kind>') + ', got ' + _safeJson(result))
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-plus-operands
|
||||
new Error('expected ' + ((result as unknown as Record<string, unknown>)['kind'] ?? '<no kind>') + ', got ' + _safeJson(result))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -378,7 +388,7 @@ function createHub({ cjsRegistry, manifest, logger } = {}) {
|
||||
return { dispatch };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
createHub,
|
||||
ERROR_KINDS,
|
||||
makeUnknownCommand,
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Thin adapter — sources schema data from the manifest via the generated
|
||||
* Configuration Module. All inline literals have been removed; the manifest
|
||||
@@ -11,21 +9,25 @@
|
||||
* - many tests (config-schema.property.test.cjs, bug-*, feat-*, etc.)
|
||||
*
|
||||
* See Phase 2 Cycle 5 (#3536) — schema manifest migration.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/config-schema.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
||||
* the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const {
|
||||
import {
|
||||
VALID_CONFIG_KEYS,
|
||||
RUNTIME_STATE_KEYS,
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
} = require('./configuration.cjs');
|
||||
} from './configuration.cjs';
|
||||
|
||||
/**
|
||||
* Returns true if keyPath is a valid config key (exact, dynamic pattern, or runtime state).
|
||||
*/
|
||||
function isValidConfigKey(keyPath) {
|
||||
function isValidConfigKey(keyPath: string): boolean {
|
||||
if (VALID_CONFIG_KEYS.has(keyPath)) return true;
|
||||
if (RUNTIME_STATE_KEYS.has(keyPath)) return true;
|
||||
return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));
|
||||
}
|
||||
|
||||
module.exports = { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey };
|
||||
export = { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey };
|
||||
@@ -1,22 +1,48 @@
|
||||
/**
|
||||
* Config — Planning config CRUD operations
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/config.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only strict types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { output, error, ERROR_REASON, CONFIG_DEFAULTS } = require('./core.cjs');
|
||||
const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { planningDir, withPlanningLock } = require('./planning-workspace.cjs');
|
||||
const {
|
||||
VALID_PROFILES,
|
||||
getAgentToModelMapForProfile,
|
||||
formatAgentToModelMapAsTable,
|
||||
} = require('./model-profiles.cjs');
|
||||
const { VALID_CONFIG_KEYS, isValidConfigKey } = require('./config-schema.cjs');
|
||||
const { isSecretKey, maskSecret } = require('./secrets.cjs');
|
||||
const { normalizeConfiguredDefaultReviewers } = require('./review-reviewer-selection.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output, error, ERROR_REASON, CONFIG_DEFAULTS } = core;
|
||||
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir, withPlanningLock } = planningWorkspace;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import modelProfiles = require('./model-profiles.cjs');
|
||||
const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import configSchema = require('./config-schema.cjs');
|
||||
const { VALID_CONFIG_KEYS, isValidConfigKey } = configSchema;
|
||||
import { isSecretKey, maskSecret } from './secrets.cjs';
|
||||
import { normalizeConfiguredDefaultReviewers } from './review-reviewer-selection.cjs';
|
||||
import { migrateOnDisk } from './configuration.cjs';
|
||||
|
||||
const CONFIG_KEY_SUGGESTIONS = {
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SetConfigValueResult {
|
||||
updated: boolean;
|
||||
key: string;
|
||||
value: unknown;
|
||||
previousValue: unknown;
|
||||
}
|
||||
|
||||
interface WorkstreamContext {
|
||||
configPath?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CONFIG_KEY_SUGGESTIONS: Record<string, string> = {
|
||||
'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation',
|
||||
'agents.nyquist_validation_enabled': 'workflow.nyquist_validation',
|
||||
'nyquist.validation_enabled': 'workflow.nyquist_validation',
|
||||
@@ -42,62 +68,78 @@ const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([
|
||||
]);
|
||||
const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/;
|
||||
|
||||
function validateKnownConfigKeyPath(keyPath) {
|
||||
/**
|
||||
* Schema-level defaults for well-known config keys.
|
||||
* When a key is absent from config.json and no --default flag was supplied,
|
||||
* cmdConfigGet checks here before emitting "Key not found".
|
||||
*/
|
||||
const SCHEMA_DEFAULTS: Record<string, unknown> = {
|
||||
'context_window': 200000,
|
||||
'executor.stall_detect_interval_minutes': 5,
|
||||
'executor.stall_threshold_minutes': 10,
|
||||
'git.create_tag': true,
|
||||
};
|
||||
|
||||
// ─── Validation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function validateKnownConfigKeyPath(keyPath: string): void {
|
||||
const suggested = CONFIG_KEY_SUGGESTIONS[keyPath];
|
||||
if (suggested) {
|
||||
error(`Unknown config key: ${keyPath}. Did you mean ${suggested}?`, ERROR_REASON.CONFIG_INVALID_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function validateShipPrBodySections(value) {
|
||||
function validateShipPrBodySections(value: unknown): void {
|
||||
if (!Array.isArray(value)) {
|
||||
error('Invalid ship.pr_body_sections value. Expected a JSON array of section objects.');
|
||||
}
|
||||
|
||||
value.forEach((section, index) => {
|
||||
(value as unknown[]).forEach((section: unknown, index: number) => {
|
||||
const prefix = `Invalid ship.pr_body_sections[${index}]`;
|
||||
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
||||
error(`${prefix}. Expected an object.`);
|
||||
}
|
||||
|
||||
const unknownKeys = Object.keys(section).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key));
|
||||
const sectionObj = section as Record<string, unknown>;
|
||||
const unknownKeys = Object.keys(sectionObj).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
error(`${prefix}. Unknown field(s): ${unknownKeys.join(', ')}.`);
|
||||
}
|
||||
|
||||
if (typeof section.heading !== 'string' || section.heading.trim() === '') {
|
||||
if (typeof sectionObj['heading'] !== 'string' || sectionObj['heading'].trim() === '') {
|
||||
error(`${prefix}. heading must be a non-empty string.`);
|
||||
}
|
||||
if (/[\r\n]/.test(section.heading)) {
|
||||
if (/[\r\n]/.test(sectionObj['heading'] as string)) {
|
||||
error(`${prefix}. heading must be a single line.`);
|
||||
}
|
||||
|
||||
if ('enabled' in section && typeof section.enabled !== 'boolean') {
|
||||
if ('enabled' in sectionObj && typeof sectionObj['enabled'] !== 'boolean') {
|
||||
error(`${prefix}. enabled must be true or false.`);
|
||||
}
|
||||
|
||||
for (const field of ['source', 'fallback', 'template']) {
|
||||
if (field in section && typeof section[field] !== 'string') {
|
||||
if (field in sectionObj && typeof sectionObj[field] !== 'string') {
|
||||
error(`${prefix}. ${field} must be a string.`);
|
||||
}
|
||||
}
|
||||
|
||||
const hasContent = ['source', 'fallback', 'template'].some((field) => {
|
||||
return typeof section[field] === 'string' && section[field].trim() !== '';
|
||||
const v = sectionObj[field];
|
||||
return typeof v === 'string' && v.trim() !== '';
|
||||
});
|
||||
if (!hasContent) {
|
||||
error(`${prefix}. Provide at least one of source, fallback, or template.`);
|
||||
}
|
||||
|
||||
if (typeof section.source === 'string' && section.source.trim() !== '') {
|
||||
const selectors = section.source.split('||').map((selector) => selector.trim()).filter(Boolean);
|
||||
if (typeof sectionObj['source'] === 'string' && sectionObj['source'].trim() !== '') {
|
||||
const selectors = sectionObj['source'].split('||').map((selector) => selector.trim()).filter(Boolean);
|
||||
if (selectors.length === 0 || selectors.some((selector) => !SHIP_PR_BODY_SOURCE_RE.test(selector))) {
|
||||
error(`${prefix}. source must use selectors like "PLAN.md ## Risks", separated with "||".`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof section.template === 'string') {
|
||||
const tokens = section.template.matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g);
|
||||
if (typeof sectionObj['template'] === 'string') {
|
||||
const tokens = sectionObj['template'].matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g);
|
||||
for (const match of tokens) {
|
||||
if (!SHIP_PR_BODY_TEMPLATE_TOKENS.has(match[1])) {
|
||||
error(`${prefix}. Unsupported template token: {${match[1]}}.`);
|
||||
@@ -107,6 +149,8 @@ function validateShipPrBodySections(value) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Core config operations ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a fully-materialized config object for a new project.
|
||||
*
|
||||
@@ -121,29 +165,29 @@ function validateShipPrBodySections(value) {
|
||||
*
|
||||
* Returns a plain object — does NOT write any files.
|
||||
*/
|
||||
function buildNewProjectConfig(userChoices) {
|
||||
function buildNewProjectConfig(userChoices: Record<string, unknown>): Record<string, unknown> {
|
||||
const choices = userChoices || {};
|
||||
const homedir = require('os').homedir();
|
||||
const homedir = os.homedir();
|
||||
|
||||
// Detect API key availability
|
||||
const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key');
|
||||
const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile));
|
||||
const hasBraveSearch = !!(process.env['BRAVE_API_KEY'] || fs.existsSync(braveKeyFile));
|
||||
const firecrawlKeyFile = path.join(homedir, '.gsd', 'firecrawl_api_key');
|
||||
const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || fs.existsSync(firecrawlKeyFile));
|
||||
const hasFirecrawl = !!(process.env['FIRECRAWL_API_KEY'] || fs.existsSync(firecrawlKeyFile));
|
||||
const exaKeyFile = path.join(homedir, '.gsd', 'exa_api_key');
|
||||
const hasExaSearch = !!(process.env.EXA_API_KEY || fs.existsSync(exaKeyFile));
|
||||
const hasExaSearch = !!(process.env['EXA_API_KEY'] || fs.existsSync(exaKeyFile));
|
||||
|
||||
// Load user-level defaults from ~/.gsd/defaults.json if available
|
||||
const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json');
|
||||
let userDefaults = {};
|
||||
let userDefaults: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(globalDefaultsPath)) {
|
||||
userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8'));
|
||||
userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8')) as Record<string, unknown>;
|
||||
// Migrate deprecated "depth" key to "granularity"
|
||||
if ('depth' in userDefaults && !('granularity' in userDefaults)) {
|
||||
const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
|
||||
userDefaults.granularity = depthToGranularity[userDefaults.depth] || userDefaults.depth;
|
||||
delete userDefaults.depth;
|
||||
const depthToGranularity: Record<string, string> = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
|
||||
userDefaults['granularity'] = depthToGranularity[userDefaults['depth'] as string] || userDefaults['depth'];
|
||||
delete userDefaults['depth'];
|
||||
try {
|
||||
platformWriteSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2));
|
||||
} catch { /* intentionally empty */ }
|
||||
@@ -153,7 +197,7 @@ function buildNewProjectConfig(userChoices) {
|
||||
// Ignore malformed global defaults
|
||||
}
|
||||
|
||||
const hardcoded = {
|
||||
const hardcoded: Record<string, unknown> = {
|
||||
model_profile: CONFIG_DEFAULTS.model_profile,
|
||||
commit_docs: CONFIG_DEFAULTS.commit_docs,
|
||||
parallelization: CONFIG_DEFAULTS.parallelization,
|
||||
@@ -214,44 +258,48 @@ function buildNewProjectConfig(userChoices) {
|
||||
},
|
||||
};
|
||||
|
||||
const ud = userDefaults as Record<string, Record<string, unknown>>;
|
||||
const ch = choices as Record<string, Record<string, unknown>>;
|
||||
const hd = hardcoded as Record<string, Record<string, unknown>>;
|
||||
|
||||
// Three-level deep merge: hardcoded <- userDefaults <- choices
|
||||
const config = {
|
||||
const config: Record<string, unknown> = {
|
||||
...hardcoded,
|
||||
...userDefaults,
|
||||
...choices,
|
||||
git: {
|
||||
...hardcoded.git,
|
||||
...(userDefaults.git || {}),
|
||||
...(choices.git || {}),
|
||||
...hd['git'],
|
||||
...(ud['git'] || {}),
|
||||
...(ch['git'] || {}),
|
||||
},
|
||||
workflow: {
|
||||
...hardcoded.workflow,
|
||||
...(userDefaults.workflow || {}),
|
||||
...(choices.workflow || {}),
|
||||
...hd['workflow'],
|
||||
...(ud['workflow'] || {}),
|
||||
...(ch['workflow'] || {}),
|
||||
},
|
||||
ship: {
|
||||
...hardcoded.ship,
|
||||
...(userDefaults.ship || {}),
|
||||
...(choices.ship || {}),
|
||||
...hd['ship'],
|
||||
...(ud['ship'] || {}),
|
||||
...(ch['ship'] || {}),
|
||||
},
|
||||
hooks: {
|
||||
...hardcoded.hooks,
|
||||
...(userDefaults.hooks || {}),
|
||||
...(choices.hooks || {}),
|
||||
...hd['hooks'],
|
||||
...(ud['hooks'] || {}),
|
||||
...(ch['hooks'] || {}),
|
||||
},
|
||||
agent_skills: {
|
||||
...hardcoded.agent_skills,
|
||||
...(userDefaults.agent_skills || {}),
|
||||
...(choices.agent_skills || {}),
|
||||
...hd['agent_skills'],
|
||||
...(ud['agent_skills'] || {}),
|
||||
...(ch['agent_skills'] || {}),
|
||||
},
|
||||
plan_review: {
|
||||
...hardcoded.plan_review,
|
||||
...(userDefaults.plan_review || {}),
|
||||
...(choices.plan_review || {}),
|
||||
...hd['plan_review'],
|
||||
...(ud['plan_review'] || {}),
|
||||
...(ch['plan_review'] || {}),
|
||||
},
|
||||
};
|
||||
|
||||
validateShipPrBodySections(config.ship.pr_body_sections);
|
||||
validateShipPrBodySections((config['ship'] as Record<string, unknown>)['pr_body_sections']);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -264,7 +312,7 @@ function buildNewProjectConfig(userChoices) {
|
||||
*
|
||||
* Idempotent: if config.json already exists, returns { created: false }.
|
||||
*/
|
||||
function cmdConfigNewProject(cwd, choicesJson, raw) {
|
||||
function cmdConfigNewProject(cwd: string, choicesJson: string | undefined, raw: boolean): void {
|
||||
const planningBase = planningDir(cwd);
|
||||
const configPath = path.join(planningBase, 'config.json');
|
||||
|
||||
@@ -275,12 +323,12 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
|
||||
}
|
||||
|
||||
// Parse user choices
|
||||
let userChoices = {};
|
||||
let userChoices: Record<string, unknown> = {};
|
||||
if (choicesJson && choicesJson.trim() !== '') {
|
||||
try {
|
||||
userChoices = JSON.parse(choicesJson);
|
||||
userChoices = JSON.parse(choicesJson) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
error('Invalid JSON for config-new-project: ' + err.message);
|
||||
error('Invalid JSON for config-new-project: ' + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +336,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
|
||||
try {
|
||||
platformEnsureDir(planningBase);
|
||||
} catch (err) {
|
||||
error('Failed to create .planning directory: ' + err.message);
|
||||
error('Failed to create .planning directory: ' + (err as Error).message);
|
||||
}
|
||||
|
||||
const config = buildNewProjectConfig(userChoices);
|
||||
@@ -297,7 +345,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
|
||||
platformWriteSync(configPath, JSON.stringify(config, null, 2));
|
||||
output({ created: true, path: '.planning/config.json' }, raw, 'created');
|
||||
} catch (err) {
|
||||
error('Failed to write config.json: ' + err.message);
|
||||
error('Failed to write config.json: ' + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +355,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
|
||||
* Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
|
||||
* the happy path. But note that `error()` will still `exit(1)` out of the process.
|
||||
*/
|
||||
function ensureConfigFile(cwd) {
|
||||
function ensureConfigFile(cwd: string): { created: boolean; reason?: string; path?: string } | undefined {
|
||||
const planningBase = planningDir(cwd);
|
||||
const configPath = path.join(planningBase, 'config.json');
|
||||
|
||||
@@ -315,7 +363,7 @@ function ensureConfigFile(cwd) {
|
||||
try {
|
||||
platformEnsureDir(planningBase);
|
||||
} catch (err) {
|
||||
error('Failed to create .planning directory: ' + err.message);
|
||||
error('Failed to create .planning directory: ' + (err as Error).message);
|
||||
}
|
||||
|
||||
// Check if config already exists
|
||||
@@ -329,7 +377,7 @@ function ensureConfigFile(cwd) {
|
||||
platformWriteSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { created: true, path: '.planning/config.json' };
|
||||
} catch (err) {
|
||||
error('Failed to create config.json: ' + err.message);
|
||||
error('Failed to create config.json: ' + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,9 +387,9 @@ function ensureConfigFile(cwd) {
|
||||
* Note that this exits the process (via `output()`) even in the happy path; use
|
||||
* `ensureConfigFile()` directly if you need to avoid this.
|
||||
*/
|
||||
function cmdConfigEnsureSection(cwd, raw) {
|
||||
function cmdConfigEnsureSection(cwd: string, raw: boolean): void {
|
||||
const ensureConfigFileResult = ensureConfigFile(cwd);
|
||||
if (ensureConfigFileResult.created) {
|
||||
if (ensureConfigFileResult && ensureConfigFileResult.created) {
|
||||
output(ensureConfigFileResult, raw, 'created');
|
||||
} else {
|
||||
output(ensureConfigFileResult, raw, 'exists');
|
||||
@@ -355,29 +403,29 @@ function cmdConfigEnsureSection(cwd, raw) {
|
||||
* Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
|
||||
* the happy path. But note that `error()` will still `exit(1)` out of the process.
|
||||
*/
|
||||
function setConfigValue(cwd, keyPath, parsedValue) {
|
||||
function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): SetConfigValueResult {
|
||||
const configPath = path.join(planningDir(cwd), 'config.json');
|
||||
|
||||
return withPlanningLock(cwd, () => {
|
||||
// Load existing config or start with empty object
|
||||
let config = {};
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
} catch (err) {
|
||||
error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
|
||||
// Set nested value using dot notation (e.g., "workflow.research")
|
||||
const keys = keyPath.split('.');
|
||||
let current = config;
|
||||
let current: Record<string, unknown> = config;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (current[key] === undefined || typeof current[key] !== 'object') {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting
|
||||
current[keys[keys.length - 1]] = parsedValue;
|
||||
@@ -387,9 +435,9 @@ function setConfigValue(cwd, keyPath, parsedValue) {
|
||||
platformWriteSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { updated: true, key: keyPath, value: parsedValue, previousValue };
|
||||
} catch (err) {
|
||||
error('Failed to write config.json: ' + err.message);
|
||||
error('Failed to write config.json: ' + (err as Error).message);
|
||||
}
|
||||
});
|
||||
}) as SetConfigValueResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,7 +447,7 @@ function setConfigValue(cwd, keyPath, parsedValue) {
|
||||
* Note that this exits the process (via `output()`) even in the happy path; use `setConfigValue()`
|
||||
* directly if you need to avoid this.
|
||||
*/
|
||||
function cmdConfigSet(cwd, keyPath, value, raw) {
|
||||
function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | undefined, raw: boolean): void {
|
||||
if (!keyPath) {
|
||||
error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
|
||||
}
|
||||
@@ -414,91 +462,96 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
||||
error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
|
||||
}
|
||||
|
||||
validateKnownConfigKeyPath(keyPath);
|
||||
// After the two error() guards above, keyPath and value are narrowed to string.
|
||||
// TypeScript doesn't always infer never-return narrowing through error(), so we assert.
|
||||
const kp = keyPath!;
|
||||
const val = value!;
|
||||
|
||||
if (!isValidConfigKey(keyPath)) {
|
||||
error(`Unknown config key: "${keyPath}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
|
||||
validateKnownConfigKeyPath(kp);
|
||||
|
||||
if (!isValidConfigKey(kp)) {
|
||||
error(`Unknown config key: "${kp}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
|
||||
}
|
||||
|
||||
// Parse value (handle booleans, numbers, and JSON arrays/objects)
|
||||
let parsedValue = value;
|
||||
if (value === 'true') parsedValue = true;
|
||||
else if (value === 'false') parsedValue = false;
|
||||
else if (!isNaN(value) && value !== '') parsedValue = Number(value);
|
||||
else if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) {
|
||||
try { parsedValue = JSON.parse(value); } catch { /* keep as string */ }
|
||||
let parsedValue: unknown = val;
|
||||
if (val === 'true') parsedValue = true;
|
||||
else if (val === 'false') parsedValue = false;
|
||||
else if (!isNaN(Number(val)) && val !== '') parsedValue = Number(val);
|
||||
else if (typeof val === 'string' && (val.startsWith('[') || val.startsWith('{'))) {
|
||||
try { parsedValue = JSON.parse(val); } catch { /* keep as string */ }
|
||||
}
|
||||
|
||||
const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
|
||||
if (keyPath === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) {
|
||||
error(`Invalid context value '${value}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`);
|
||||
if (kp === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) {
|
||||
error(`Invalid context value '${val}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`);
|
||||
}
|
||||
|
||||
// Codebase drift detector (#2003)
|
||||
const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
|
||||
if (keyPath === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
|
||||
error(`Invalid workflow.drift_action '${value}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`);
|
||||
if (kp === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
|
||||
error(`Invalid workflow.drift_action '${val}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`);
|
||||
}
|
||||
if (keyPath === 'workflow.drift_threshold') {
|
||||
if (kp === 'workflow.drift_threshold') {
|
||||
if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) {
|
||||
error(`Invalid workflow.drift_threshold '${value}'. Must be a positive integer.`);
|
||||
error(`Invalid workflow.drift_threshold '${val}'. Must be a positive integer.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-planning gap checker (#2493)
|
||||
if (keyPath === 'workflow.post_planning_gaps') {
|
||||
if (kp === 'workflow.post_planning_gaps') {
|
||||
if (typeof parsedValue !== 'boolean') {
|
||||
error(`Invalid workflow.post_planning_gaps '${value}'. Must be a boolean (true or false).`);
|
||||
error(`Invalid workflow.post_planning_gaps '${val}'. Must be a boolean (true or false).`);
|
||||
}
|
||||
}
|
||||
|
||||
// #3086 — git.create_tag: boolean only
|
||||
if (keyPath === 'git.create_tag') {
|
||||
if (kp === 'git.create_tag') {
|
||||
if (typeof parsedValue !== 'boolean') {
|
||||
error(`Invalid git.create_tag '${value}'. Must be a boolean (true or false).`);
|
||||
error(`Invalid git.create_tag '${val}'. Must be a boolean (true or false).`);
|
||||
}
|
||||
}
|
||||
|
||||
if (keyPath === 'ship.pr_body_sections') {
|
||||
if (kp === 'ship.pr_body_sections') {
|
||||
validateShipPrBodySections(parsedValue);
|
||||
}
|
||||
|
||||
// Human verification checkpoint mode (#3309)
|
||||
const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase'];
|
||||
if (keyPath === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
|
||||
error(`Invalid workflow.human_verify_mode '${value}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
|
||||
if (kp === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
|
||||
error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
|
||||
}
|
||||
|
||||
// Context position enum validation (#2937)
|
||||
const VALID_CONTEXT_POSITIONS = ['front', 'end'];
|
||||
if (keyPath === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
|
||||
error(`Invalid statusline.context_position '${value}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`);
|
||||
if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
|
||||
error(`Invalid statusline.context_position '${val}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`);
|
||||
}
|
||||
|
||||
// Fallow scope + profile enum validation (#3424)
|
||||
const VALID_FALLOW_SCOPES = ['phase', 'repo'];
|
||||
if (keyPath === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) {
|
||||
error(`Invalid code_quality.fallow.scope '${value}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`);
|
||||
if (kp === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) {
|
||||
error(`Invalid code_quality.fallow.scope '${val}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`);
|
||||
}
|
||||
const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict'];
|
||||
if (keyPath === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) {
|
||||
error(`Invalid code_quality.fallow.profile '${value}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`);
|
||||
if (kp === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) {
|
||||
error(`Invalid code_quality.fallow.profile '${val}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`);
|
||||
}
|
||||
|
||||
// plan_review.source_grounding (#22) — boolean only
|
||||
if (keyPath === 'plan_review.source_grounding') {
|
||||
if (kp === 'plan_review.source_grounding') {
|
||||
if (typeof parsedValue !== 'boolean') {
|
||||
error(`Invalid plan_review.source_grounding '${value}'. Must be a boolean (true or false).`);
|
||||
error(`Invalid plan_review.source_grounding '${val}'. Must be a boolean (true or false).`);
|
||||
}
|
||||
}
|
||||
|
||||
// plan_review.source_grounding_authority (#22) — enum
|
||||
const VALID_SOURCE_GROUNDING_AUTHORITIES = ['grep', 'intel', 'treesitter', 'lsp', 'scip'];
|
||||
if (keyPath === 'plan_review.source_grounding_authority' && !VALID_SOURCE_GROUNDING_AUTHORITIES.includes(String(parsedValue))) {
|
||||
error(`Invalid plan_review.source_grounding_authority '${value}'. Valid values: ${VALID_SOURCE_GROUNDING_AUTHORITIES.join(', ')}`);
|
||||
if (kp === 'plan_review.source_grounding_authority' && !VALID_SOURCE_GROUNDING_AUTHORITIES.includes(String(parsedValue))) {
|
||||
error(`Invalid plan_review.source_grounding_authority '${val}'. Valid values: ${VALID_SOURCE_GROUNDING_AUTHORITIES.join(', ')}`);
|
||||
}
|
||||
|
||||
if (keyPath === 'review.default_reviewers') {
|
||||
if (kp === 'review.default_reviewers') {
|
||||
const normalized = normalizeConfiguredDefaultReviewers(parsedValue);
|
||||
if (normalized.errors.length > 0) {
|
||||
error(normalized.errors[0]);
|
||||
@@ -506,42 +559,31 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
||||
parsedValue = normalized.values;
|
||||
}
|
||||
|
||||
const setConfigValueResult = setConfigValue(cwd, keyPath, parsedValue);
|
||||
const setConfigValueResult = setConfigValue(cwd, kp, parsedValue);
|
||||
|
||||
// Mask secrets in both JSON and text output. The plaintext is written
|
||||
// to config.json (that's where secrets live on disk); the CLI output
|
||||
// must never echo it. See lib/secrets.cjs.
|
||||
if (isSecretKey(keyPath)) {
|
||||
const masked = maskSecret(parsedValue);
|
||||
if (isSecretKey(kp)) {
|
||||
// parsedValue is unknown at this point; maskSecret accepts MaskableValue
|
||||
const masked = maskSecret(parsedValue as Parameters<typeof maskSecret>[0]);
|
||||
const maskedPrev = setConfigValueResult.previousValue === undefined
|
||||
? undefined
|
||||
: maskSecret(setConfigValueResult.previousValue);
|
||||
: maskSecret(setConfigValueResult.previousValue as Parameters<typeof maskSecret>[0]);
|
||||
const maskedResult = {
|
||||
...setConfigValueResult,
|
||||
value: masked,
|
||||
previousValue: maskedPrev,
|
||||
masked: true,
|
||||
};
|
||||
output(maskedResult, raw, `${keyPath}=${masked}`);
|
||||
output(maskedResult, raw, `${kp}=${masked}`);
|
||||
return;
|
||||
}
|
||||
|
||||
output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`);
|
||||
output(setConfigValueResult, raw, `${kp}=${String(parsedValue)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema-level defaults for well-known config keys.
|
||||
* When a key is absent from config.json and no --default flag was supplied,
|
||||
* cmdConfigGet checks here before emitting "Key not found".
|
||||
*/
|
||||
const SCHEMA_DEFAULTS = {
|
||||
'context_window': 200000,
|
||||
'executor.stall_detect_interval_minutes': 5,
|
||||
'executor.stall_threshold_minutes': 10,
|
||||
'git.create_tag': true,
|
||||
};
|
||||
|
||||
function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
|
||||
function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, defaultValue: unknown): void {
|
||||
const configPath = path.join(planningDir(cwd), 'config.json');
|
||||
const hasDefault = defaultValue !== undefined;
|
||||
|
||||
@@ -549,55 +591,61 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
|
||||
error('Usage: config-get <key.path> [--default <value>]');
|
||||
}
|
||||
|
||||
let config = {};
|
||||
// After the error() guard, keyPath is narrowed to string.
|
||||
const kp = keyPath!;
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
} else if (hasDefault) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
output(defaultValue, raw, String(defaultValue));
|
||||
return;
|
||||
} else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
const def = SCHEMA_DEFAULTS[keyPath];
|
||||
} else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
|
||||
const def = SCHEMA_DEFAULTS[kp];
|
||||
output(def, raw, String(def));
|
||||
return;
|
||||
} else {
|
||||
error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message.startsWith('No config.json')) throw err;
|
||||
error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
if ((err as Error).message.startsWith('No config.json')) throw err;
|
||||
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
|
||||
// Traverse dot-notation path (e.g., "workflow.auto_advance")
|
||||
const keys = keyPath.split('.');
|
||||
let current = config;
|
||||
const keys = kp.split('.');
|
||||
let current: unknown = config;
|
||||
for (const key of keys) {
|
||||
if (current === undefined || current === null || typeof current !== 'object') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
const def = SCHEMA_DEFAULTS[keyPath];
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
|
||||
const def = SCHEMA_DEFAULTS[kp];
|
||||
output(def, raw, String(def));
|
||||
return;
|
||||
}
|
||||
error(`Key not found: ${keyPath}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
|
||||
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
|
||||
}
|
||||
current = current[key];
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
if (current === undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
|
||||
const def = SCHEMA_DEFAULTS[keyPath];
|
||||
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
|
||||
const def = SCHEMA_DEFAULTS[kp];
|
||||
output(def, raw, String(def));
|
||||
return;
|
||||
}
|
||||
error(`Key not found: ${keyPath}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
|
||||
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Never echo plaintext for sensitive keys via config-get. Plaintext lives
|
||||
// in config.json on disk; the CLI surface always shows the masked form.
|
||||
if (isSecretKey(keyPath)) {
|
||||
const masked = maskSecret(current);
|
||||
if (isSecretKey(kp)) {
|
||||
const masked = maskSecret(current as Parameters<typeof maskSecret>[0]);
|
||||
output(masked, raw, masked);
|
||||
return;
|
||||
}
|
||||
@@ -610,22 +658,23 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
|
||||
*
|
||||
* Note that this exits the process (via `output()`) even in the happy path.
|
||||
*/
|
||||
function cmdConfigSetModelProfile(cwd, profile, raw) {
|
||||
function cmdConfigSetModelProfile(cwd: string, profile: string | undefined, raw: boolean): void {
|
||||
if (!profile) {
|
||||
error(`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`);
|
||||
}
|
||||
|
||||
const normalizedProfile = profile.toLowerCase().trim();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const normalizedProfile = profile!.toLowerCase().trim();
|
||||
if (!VALID_PROFILES.includes(normalizedProfile)) {
|
||||
error(`Invalid profile '${profile}'. Valid profiles: ${VALID_PROFILES.join(', ')}`);
|
||||
error(`Invalid profile '${String(profile)}'. Valid profiles: ${VALID_PROFILES.join(', ')}`);
|
||||
}
|
||||
|
||||
// Ensure config exists (create if needed)
|
||||
ensureConfigFile(cwd);
|
||||
|
||||
// Set the model profile in the config
|
||||
const { previousValue } = setConfigValue(cwd, 'model_profile', normalizedProfile, raw);
|
||||
const previousProfile = previousValue || 'balanced';
|
||||
const { previousValue } = setConfigValue(cwd, 'model_profile', normalizedProfile);
|
||||
const previousProfile = typeof previousValue === 'string' ? previousValue : 'balanced';
|
||||
|
||||
// Build result value / message and return
|
||||
const agentToModelMap = getAgentToModelMapForProfile(normalizedProfile);
|
||||
@@ -648,10 +697,10 @@ function cmdConfigSetModelProfile(cwd, profile, raw) {
|
||||
* displaying raw output.
|
||||
*/
|
||||
function getCmdConfigSetModelProfileResultMessage(
|
||||
normalizedProfile,
|
||||
previousProfile,
|
||||
agentToModelMap
|
||||
) {
|
||||
normalizedProfile: string,
|
||||
previousProfile: string,
|
||||
agentToModelMap: Record<string, string>
|
||||
): string {
|
||||
const agentToModelTable = formatAgentToModelMapAsTable(agentToModelMap);
|
||||
const didChange = previousProfile !== normalizedProfile;
|
||||
const paragraphs = didChange
|
||||
@@ -673,7 +722,7 @@ function getCmdConfigSetModelProfileResultMessage(
|
||||
* Print the resolved config.json path (workstream-aware). Used by settings.md
|
||||
* so the workflow writes/reads the correct file when a workstream is active (#2282).
|
||||
*/
|
||||
function cmdConfigPath(cwd, _raw, workstreamContext = null) {
|
||||
function cmdConfigPath(cwd: string, _raw: boolean, workstreamContext: WorkstreamContext | null = null): void {
|
||||
// Always emit as plain text — a file path is used via shell substitution,
|
||||
// never consumed as JSON. Passing raw=true forces plain-text output.
|
||||
const configPath = workstreamContext && workstreamContext.configPath
|
||||
@@ -693,11 +742,14 @@ function cmdConfigPath(cwd, _raw, workstreamContext = null) {
|
||||
*
|
||||
* Output: JSON object with { migrated, normalizations, wrote } or a human-readable
|
||||
* summary when --raw is set. Exits 0 in all cases (including no-op).
|
||||
*
|
||||
* Note: migrateOnDisk() is synchronous; the original CJS used async for
|
||||
* forward-compatibility but no await is needed. Dropped async per ADR-457 policy
|
||||
* (caller uses `await` which is safe on a sync return value).
|
||||
*/
|
||||
async function cmdMigrateConfig(cwd, raw) {
|
||||
const { migrateOnDisk } = require('./configuration.cjs');
|
||||
const ws = process.env.GSD_WORKSTREAM || null;
|
||||
const report = await migrateOnDisk(cwd, ws || undefined);
|
||||
function cmdMigrateConfig(cwd: string, raw: boolean): void {
|
||||
const ws = process.env['GSD_WORKSTREAM'] || null;
|
||||
const report = migrateOnDisk(cwd, ws || undefined);
|
||||
|
||||
if (raw) {
|
||||
if (!report.migrated) {
|
||||
@@ -705,8 +757,8 @@ async function cmdMigrateConfig(cwd, raw) {
|
||||
output(msg, true, msg);
|
||||
} else {
|
||||
const lines = [
|
||||
`Migrated: ${report.wrote}`,
|
||||
...report.normalizations.map(n => ` ${n.from} → ${n.to}`),
|
||||
`Migrated: ${String(report.wrote)}`,
|
||||
...(report.normalizations as Array<{ from: string; to: string }>).map(n => ` ${n.from} → ${n.to}`),
|
||||
].join('\n');
|
||||
output(lines, true, lines);
|
||||
}
|
||||
@@ -716,7 +768,7 @@ async function cmdMigrateConfig(cwd, raw) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
VALID_CONFIG_KEYS,
|
||||
cmdConfigEnsureSection,
|
||||
cmdConfigSet,
|
||||
288
src/configuration.cts
Normal file
288
src/configuration.cts
Normal 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,
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Context-utilization classifier for `gsd-health --context`.
|
||||
* Context-utilization classifier for `gsd-health --context` (ADR-457
|
||||
* build-at-publish: the hand-written bin/lib/context-utilization.cjs collapsed
|
||||
* to a TypeScript source of truth). Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only types are added.
|
||||
*
|
||||
* Pure function. Callers pass tokensUsed + contextWindow; the
|
||||
* classifier returns the percent and one of three states. Recommendation
|
||||
@@ -19,29 +20,42 @@
|
||||
* edge cases (e.g. 59.999% displays as 60 but classifies as healthy).
|
||||
*/
|
||||
|
||||
const STATES = Object.freeze({
|
||||
HEALTHY: 'healthy',
|
||||
WARNING: 'warning',
|
||||
CRITICAL: 'critical',
|
||||
});
|
||||
export type ContextState = 'healthy' | 'warning' | 'critical';
|
||||
|
||||
function classifyContextUtilization(tokensUsed, contextWindow) {
|
||||
export interface ContextUtilizationResult {
|
||||
percent: number;
|
||||
state: ContextState;
|
||||
}
|
||||
|
||||
export const STATES: Readonly<{ HEALTHY: 'healthy'; WARNING: 'warning'; CRITICAL: 'critical' }> =
|
||||
Object.freeze({
|
||||
HEALTHY: 'healthy' as const,
|
||||
WARNING: 'warning' as const,
|
||||
CRITICAL: 'critical' as const,
|
||||
});
|
||||
|
||||
export function classifyContextUtilization(
|
||||
tokensUsed: number,
|
||||
contextWindow: number,
|
||||
): ContextUtilizationResult {
|
||||
if (!Number.isInteger(tokensUsed) || tokensUsed < 0) {
|
||||
throw new TypeError(`tokensUsed must be a non-negative integer, got: ${tokensUsed} (${typeof tokensUsed})`);
|
||||
throw new TypeError(
|
||||
`tokensUsed must be a non-negative integer, got: ${tokensUsed} (${typeof tokensUsed})`,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
|
||||
throw new TypeError(`contextWindow must be a positive integer, got: ${contextWindow} (${typeof contextWindow})`);
|
||||
throw new TypeError(
|
||||
`contextWindow must be a positive integer, got: ${contextWindow} (${typeof contextWindow})`,
|
||||
);
|
||||
}
|
||||
|
||||
const ratio = Math.min(tokensUsed / contextWindow, 1);
|
||||
const percent = Math.min(Math.round(ratio * 100), 100);
|
||||
|
||||
let state;
|
||||
let state: ContextState;
|
||||
if (ratio < 0.60) state = STATES.HEALTHY;
|
||||
else if (ratio < 0.70) state = STATES.WARNING;
|
||||
else state = STATES.CRITICAL;
|
||||
|
||||
return { percent, state };
|
||||
}
|
||||
|
||||
module.exports = { classifyContextUtilization, STATES };
|
||||
File diff suppressed because it is too large
Load Diff
127
src/decisions.cts
Normal file
127
src/decisions.cts
Normal 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;
|
||||
}
|
||||
@@ -4,12 +4,18 @@
|
||||
* Provides `cmdDocsInit` which returns project signals, existing doc inventory
|
||||
* with GSD marker detection, doc tooling detection, monorepo awareness, and
|
||||
* model resolution. Used by Phase 2 to route doc generation appropriately.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/docs.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only strict types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { output, loadConfig, resolveModelInternal, pathExistsInternal, toPosixPath, checkAgentsInstalled } = require('./core.cjs');
|
||||
const { platformReadSync } = require('./shell-command-projection.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output, loadConfig, resolveModelInternal, pathExistsInternal, toPosixPath, checkAgentsInstalled } = core;
|
||||
import { platformReadSync } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,11 +32,8 @@ const SKIP_DIRS = new Set([
|
||||
/**
|
||||
* Check whether a file begins with the GSD doc writer marker.
|
||||
* Reads the first 500 bytes only — avoids loading large files.
|
||||
*
|
||||
* @param {string} filePath - Absolute path to the file
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasGsdMarker(filePath) {
|
||||
function hasGsdMarker(filePath: string): boolean {
|
||||
try {
|
||||
const buf = Buffer.alloc(500);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
@@ -42,23 +45,20 @@ function hasGsdMarker(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
interface DocEntry {
|
||||
path: string;
|
||||
has_gsd_marker: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan the project root (immediate .md files) and docs/ directory
|
||||
* (up to 4 levels deep) for Markdown files, excluding dirs in SKIP_DIRS.
|
||||
*
|
||||
* @param {string} cwd - Project root
|
||||
* @returns {Array<{path: string, has_gsd_marker: boolean}>}
|
||||
*/
|
||||
function scanExistingDocs(cwd) {
|
||||
function scanExistingDocs(cwd: string): DocEntry[] {
|
||||
const MAX_DEPTH = 4;
|
||||
const results = [];
|
||||
const results: DocEntry[] = [];
|
||||
|
||||
/**
|
||||
* Recursively walk a directory for .md files up to MAX_DEPTH levels.
|
||||
* @param {string} dir - Directory to scan
|
||||
* @param {number} depth - Current depth (1-based)
|
||||
*/
|
||||
function walkDir(dir, depth) {
|
||||
function walkDir(dir: string, depth: number): void {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
@@ -111,38 +111,49 @@ function scanExistingDocs(cwd) {
|
||||
return results.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
interface ProjectTypeSignals {
|
||||
has_package_json: boolean;
|
||||
has_api_routes: boolean;
|
||||
has_cli_bin: boolean;
|
||||
is_open_source: boolean;
|
||||
has_deploy_config: boolean;
|
||||
is_monorepo: boolean;
|
||||
has_tests: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect project type signals from the filesystem and package.json.
|
||||
* All checks are best-effort and never throw.
|
||||
*
|
||||
* @param {string} cwd - Project root
|
||||
* @returns {Object} Boolean signal fields
|
||||
*/
|
||||
function detectProjectType(cwd) {
|
||||
const exists = (rel) => {
|
||||
function detectProjectType(cwd: string): ProjectTypeSignals {
|
||||
const exists = (rel: string): boolean => {
|
||||
try { return pathExistsInternal(cwd, rel); } catch { return false; }
|
||||
};
|
||||
|
||||
// Read package.json once — used by has_cli_bin, is_monorepo, has_tests checks.
|
||||
const pkgRaw = platformReadSync(path.join(cwd, 'package.json'));
|
||||
let pkg = null;
|
||||
let pkg: Record<string, unknown> | null = null;
|
||||
if (pkgRaw) {
|
||||
try { pkg = JSON.parse(pkgRaw); } catch { /* invalid JSON */ }
|
||||
try { pkg = JSON.parse(pkgRaw) as Record<string, unknown>; } catch { /* invalid JSON */ }
|
||||
}
|
||||
|
||||
// has_cli_bin: package.json has a `bin` field
|
||||
const has_cli_bin = !!(pkg && pkg.bin && (typeof pkg.bin === 'string' || Object.keys(pkg.bin).length > 0));
|
||||
const binField = pkg?.['bin'];
|
||||
const has_cli_bin = !!(binField && (
|
||||
typeof binField === 'string' ||
|
||||
(typeof binField === 'object' && Object.keys(binField).length > 0)
|
||||
));
|
||||
|
||||
// is_monorepo: pnpm-workspace.yaml, lerna.json, or package.json workspaces
|
||||
let is_monorepo = exists('pnpm-workspace.yaml') || exists('lerna.json');
|
||||
if (!is_monorepo && pkg) {
|
||||
is_monorepo = Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0;
|
||||
is_monorepo = Array.isArray(pkg['workspaces']) && (pkg['workspaces'] as unknown[]).length > 0;
|
||||
}
|
||||
|
||||
// has_tests: common test directories or test frameworks in devDependencies
|
||||
let has_tests = exists('test') || exists('tests') || exists('__tests__') || exists('spec');
|
||||
if (!has_tests && pkg) {
|
||||
const devDeps = Object.keys(pkg.devDependencies || {});
|
||||
const devDeps = Object.keys((pkg['devDependencies'] as Record<string, unknown> | undefined) || {});
|
||||
has_tests = devDeps.some(d => ['vitest', 'jest', 'mocha', 'jasmine', 'ava'].includes(d));
|
||||
}
|
||||
|
||||
@@ -168,14 +179,18 @@ function detectProjectType(cwd) {
|
||||
};
|
||||
}
|
||||
|
||||
interface DocToolingSignals {
|
||||
docusaurus: boolean;
|
||||
vitepress: boolean;
|
||||
mkdocs: boolean;
|
||||
storybook: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect known documentation tooling in the project.
|
||||
*
|
||||
* @param {string} cwd - Project root
|
||||
* @returns {Object} Boolean detection fields
|
||||
*/
|
||||
function detectDocTooling(cwd) {
|
||||
const exists = (rel) => {
|
||||
function detectDocTooling(cwd: string): DocToolingSignals {
|
||||
const exists = (rel: string): boolean => {
|
||||
try { return pathExistsInternal(cwd, rel); } catch { return false; }
|
||||
};
|
||||
|
||||
@@ -194,15 +209,12 @@ function detectDocTooling(cwd) {
|
||||
/**
|
||||
* Extract monorepo workspace globs from pnpm-workspace.yaml, package.json
|
||||
* workspaces, or lerna.json.
|
||||
*
|
||||
* @param {string} cwd - Project root
|
||||
* @returns {string[]} Array of workspace glob patterns, or [] if not a monorepo
|
||||
*/
|
||||
function detectMonorepoWorkspaces(cwd) {
|
||||
function detectMonorepoWorkspaces(cwd: string): string[] {
|
||||
// pnpm-workspace.yaml
|
||||
const pnpmRaw = platformReadSync(path.join(cwd, 'pnpm-workspace.yaml'));
|
||||
if (pnpmRaw) {
|
||||
const workspaces = [];
|
||||
const workspaces: string[] = [];
|
||||
for (const line of pnpmRaw.split('\n')) {
|
||||
const m = line.match(/^\s*-\s+['"]?(.+?)['"]?\s*$/);
|
||||
if (m) workspaces.push(m[1].trim());
|
||||
@@ -214,9 +226,9 @@ function detectMonorepoWorkspaces(cwd) {
|
||||
const pkgRaw = platformReadSync(path.join(cwd, 'package.json'));
|
||||
if (pkgRaw) {
|
||||
try {
|
||||
const pkg = JSON.parse(pkgRaw);
|
||||
if (Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0) {
|
||||
return pkg.workspaces;
|
||||
const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;
|
||||
if (Array.isArray(pkg['workspaces']) && (pkg['workspaces'] as unknown[]).length > 0) {
|
||||
return pkg['workspaces'] as string[];
|
||||
}
|
||||
} catch { /* invalid JSON */ }
|
||||
}
|
||||
@@ -225,9 +237,9 @@ function detectMonorepoWorkspaces(cwd) {
|
||||
const lernaRaw = platformReadSync(path.join(cwd, 'lerna.json'));
|
||||
if (lernaRaw) {
|
||||
try {
|
||||
const lerna = JSON.parse(lernaRaw);
|
||||
if (Array.isArray(lerna.packages) && lerna.packages.length > 0) {
|
||||
return lerna.packages;
|
||||
const lerna = JSON.parse(lernaRaw) as Record<string, unknown>;
|
||||
if (Array.isArray(lerna['packages']) && (lerna['packages'] as unknown[]).length > 0) {
|
||||
return lerna['packages'] as string[];
|
||||
}
|
||||
} catch { /* invalid JSON */ }
|
||||
}
|
||||
@@ -244,13 +256,10 @@ function detectMonorepoWorkspaces(cwd) {
|
||||
*
|
||||
* @example
|
||||
* node gsd-tools.cjs docs-init --raw
|
||||
*
|
||||
* @param {string} cwd - Project root directory
|
||||
* @param {boolean} raw - Pass raw JSON flag through to output()
|
||||
*/
|
||||
function cmdDocsInit(cwd, raw) {
|
||||
function cmdDocsInit(cwd: string, raw: boolean): void {
|
||||
const config = loadConfig(cwd);
|
||||
const result = {
|
||||
const result: Record<string, unknown> = {
|
||||
doc_writer_model: resolveModelInternal(cwd, 'gsd-doc-writer'),
|
||||
commit_docs: config.commit_docs,
|
||||
existing_docs: scanExistingDocs(cwd),
|
||||
@@ -260,11 +269,11 @@ function cmdDocsInit(cwd, raw) {
|
||||
planning_exists: pathExistsInternal(cwd, '.planning'),
|
||||
};
|
||||
// Inject project_root and agent installation status (mirrors withProjectRoot in init.cjs)
|
||||
result.project_root = cwd;
|
||||
result['project_root'] = cwd;
|
||||
const agentStatus = checkAgentsInstalled();
|
||||
result.agents_installed = agentStatus.agents_installed;
|
||||
result.missing_agents = agentStatus.missing_agents;
|
||||
output(result, raw);
|
||||
result['agents_installed'] = agentStatus.agents_installed;
|
||||
result['missing_agents'] = agentStatus.missing_agents;
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
module.exports = { cmdDocsInit };
|
||||
export = { cmdDocsInit };
|
||||
@@ -26,12 +26,17 @@
|
||||
* - The detector NEVER throws on malformed input — it returns a
|
||||
* `{ skipped: true }` result. The phase workflow depends on this
|
||||
* non-blocking guarantee.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/drift.cjs collapsed to
|
||||
* a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
||||
* the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const { platformWriteSync } = require('./shell-command-projection.cjs');
|
||||
import fs from 'node:fs';
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash } from './runtime-slash.cjs';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -39,7 +44,7 @@ const DRIFT_CATEGORIES = Object.freeze(['new_dir', 'barrel', 'migration', 'route
|
||||
|
||||
// Category priority when a single file matches multiple rules.
|
||||
// Higher index = more specific = wins.
|
||||
const CATEGORY_PRIORITY = { new_dir: 0, barrel: 1, route: 2, migration: 3 };
|
||||
const CATEGORY_PRIORITY: Record<string, number> = { new_dir: 0, barrel: 1, route: 2, migration: 3 };
|
||||
|
||||
const BARREL_RE = /^(packages|apps)\/[^/]+\/src\/index\.(ts|tsx|js|mjs|cjs)$/;
|
||||
|
||||
@@ -67,13 +72,12 @@ const SAFE_PATH_RE = /^(?!.*\.\.)(?:[A-Za-z0-9_.][A-Za-z0-9_.\-]*)(?:\/[A-Za-z0-
|
||||
|
||||
// ─── Classification ──────────────────────────────────────────────────────────
|
||||
|
||||
type DriftCategory = 'barrel' | 'migration' | 'route' | 'new_dir';
|
||||
|
||||
/**
|
||||
* Classify a single file path into a drift category or null.
|
||||
*
|
||||
* @param {string} file - repo-relative path, forward slashes.
|
||||
* @returns {'barrel'|'migration'|'route'|null}
|
||||
*/
|
||||
function classifyFile(file) {
|
||||
function classifyFile(file: unknown): DriftCategory | null {
|
||||
if (typeof file !== 'string' || !file) return null;
|
||||
const norm = file.replace(/\\/g, '/');
|
||||
if (MIGRATION_RES.some((r) => r.test(norm))) return 'migration';
|
||||
@@ -90,7 +94,7 @@ function classifyFile(file) {
|
||||
* markdown, not a structured manifest. If the map mentions `src/lib/` the
|
||||
* check `structureMd.includes('src/lib')` holds.
|
||||
*/
|
||||
function isPathMapped(file, structureMd) {
|
||||
function isPathMapped(file: string, structureMd: string): boolean {
|
||||
const norm = file.replace(/\\/g, '/');
|
||||
const parts = norm.split('/');
|
||||
// Check prefixes from longest to shortest; any hit means "mapped".
|
||||
@@ -104,38 +108,72 @@ function isPathMapped(file, structureMd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DriftElement {
|
||||
category: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface DetectDriftInput {
|
||||
addedFiles?: unknown[];
|
||||
modifiedFiles?: unknown[];
|
||||
deletedFiles?: unknown[];
|
||||
structureMd?: string | null;
|
||||
threshold?: number;
|
||||
action?: string;
|
||||
runtime?: string;
|
||||
}
|
||||
|
||||
interface DetectDriftResult {
|
||||
skipped: false;
|
||||
elements: DriftElement[];
|
||||
actionRequired: boolean;
|
||||
directive: string;
|
||||
spawnMapper: boolean;
|
||||
affectedPaths: string[];
|
||||
threshold: number;
|
||||
action: string;
|
||||
message: string;
|
||||
counts: {
|
||||
added: number;
|
||||
modified: number;
|
||||
deleted: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SkippedResult {
|
||||
skipped: true;
|
||||
reason: string;
|
||||
elements: DriftElement[];
|
||||
actionRequired: false;
|
||||
directive: string;
|
||||
spawnMapper: false;
|
||||
affectedPaths: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ─── Main detection ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect codebase drift.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string[]} input.addedFiles - files with git status A (new)
|
||||
* @param {string[]} input.modifiedFiles - files with git status M
|
||||
* @param {string[]} input.deletedFiles - files with git status D
|
||||
* @param {string|null|undefined} input.structureMd - contents of STRUCTURE.md
|
||||
* @param {number} [input.threshold=3] - min number of drift elements that triggers action
|
||||
* @param {'warn'|'auto-remap'} [input.action='warn']
|
||||
* @param {string} [input.runtime='claude'] - runtime name (claude, codex, ...) used
|
||||
* to format the slash-command in the remediation message. Caller resolves and
|
||||
* passes this in to keep drift.cjs a pure library with no env/config reads.
|
||||
* @returns {object} result
|
||||
*/
|
||||
function detectDrift(input) {
|
||||
function detectDrift(input: unknown): DetectDriftResult | SkippedResult {
|
||||
try {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return skipped('invalid-input');
|
||||
}
|
||||
const inp = input as DetectDriftInput;
|
||||
const {
|
||||
addedFiles,
|
||||
modifiedFiles,
|
||||
deletedFiles,
|
||||
structureMd,
|
||||
} = input;
|
||||
const threshold = Number.isInteger(input.threshold) && input.threshold >= 1
|
||||
? input.threshold
|
||||
} = inp;
|
||||
const threshold = Number.isInteger(inp.threshold) && (inp.threshold as number) >= 1
|
||||
? (inp.threshold as number)
|
||||
: 3;
|
||||
const action = input.action === 'auto-remap' ? 'auto-remap' : 'warn';
|
||||
const action = inp.action === 'auto-remap' ? 'auto-remap' : 'warn';
|
||||
|
||||
if (structureMd === null || structureMd === undefined) {
|
||||
return skipped('missing-structure-md');
|
||||
@@ -144,19 +182,18 @@ function detectDrift(input) {
|
||||
return skipped('invalid-structure-md');
|
||||
}
|
||||
|
||||
const added = Array.isArray(addedFiles) ? addedFiles.filter((x) => typeof x === 'string') : [];
|
||||
const added = Array.isArray(addedFiles) ? addedFiles.filter((x): x is string => typeof x === 'string') : [];
|
||||
const modified = Array.isArray(modifiedFiles) ? modifiedFiles : [];
|
||||
const deleted = Array.isArray(deletedFiles) ? deletedFiles : [];
|
||||
|
||||
// Build elements. One element per file, highest-priority category wins.
|
||||
/** @type {{category: string, path: string}[]} */
|
||||
const elements = [];
|
||||
const seen = new Map();
|
||||
const elements: DriftElement[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
|
||||
for (const rawFile of added) {
|
||||
const file = rawFile.replace(/\\/g, '/');
|
||||
const specific = classifyFile(file);
|
||||
let category = specific;
|
||||
let category: string | null = specific;
|
||||
if (!category) {
|
||||
if (!isPathMapped(file, structureMd)) {
|
||||
category = 'new_dir';
|
||||
@@ -184,7 +221,7 @@ function detectDrift(input) {
|
||||
const actionRequired = elements.length >= threshold;
|
||||
let directive = 'none';
|
||||
let spawnMapper = false;
|
||||
let affectedPaths = [];
|
||||
let affectedPaths: string[] = [];
|
||||
let message = '';
|
||||
|
||||
if (actionRequired) {
|
||||
@@ -193,7 +230,7 @@ function detectDrift(input) {
|
||||
if (action === 'auto-remap') {
|
||||
spawnMapper = true;
|
||||
}
|
||||
message = buildMessage(elements, affectedPaths, action, input.runtime);
|
||||
message = buildMessage(elements, affectedPaths, action, inp.runtime);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -214,11 +251,12 @@ function detectDrift(input) {
|
||||
};
|
||||
} catch (err) {
|
||||
// Non-blocking: never throw from this function.
|
||||
return skipped('exception:' + (err && err.message ? err.message : String(err)));
|
||||
const errMsg = (err as Error)?.message ? (err as Error).message : String(err);
|
||||
return skipped('exception:' + errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
function skipped(reason) {
|
||||
function skipped(reason: string): SkippedResult {
|
||||
return {
|
||||
skipped: true,
|
||||
reason,
|
||||
@@ -231,16 +269,17 @@ function skipped(reason) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessage(elements, affectedPaths, action, runtime) {
|
||||
const byCat = {};
|
||||
function buildMessage(elements: DriftElement[], affectedPaths: string[], action: string, runtime: string | undefined): string {
|
||||
const byCat: Record<string, string[]> = {};
|
||||
for (const e of elements) {
|
||||
(byCat[e.category] ||= []).push(e.path);
|
||||
if (!byCat[e.category]) byCat[e.category] = [];
|
||||
byCat[e.category].push(e.path);
|
||||
}
|
||||
const lines = [
|
||||
const lines: string[] = [
|
||||
`Codebase drift detected: ${elements.length} structural element(s) since last mapping.`,
|
||||
'',
|
||||
];
|
||||
const labels = {
|
||||
const labels: Record<string, string> = {
|
||||
new_dir: 'New directories',
|
||||
barrel: 'New barrel exports',
|
||||
migration: 'New migrations',
|
||||
@@ -256,14 +295,13 @@ function buildMessage(elements, affectedPaths, action, runtime) {
|
||||
if (action === 'auto-remap') {
|
||||
lines.push(`Auto-remap scheduled for paths: ${affectedPaths.join(', ')}`);
|
||||
} else {
|
||||
// drift.cjs is a pure library — it must never read env/config. The
|
||||
// drift.cts is a pure library — it must never read env/config. The
|
||||
// caller (verify.cmdVerifyCodebaseDrift) resolves the runtime once and
|
||||
// passes it in via input.runtime so emitted commands match the project
|
||||
// the caller is targeting, not the current process directory.
|
||||
const { formatGsdSlash } = require('./runtime-slash.cjs');
|
||||
const mapCmd = formatGsdSlash('map-codebase', runtime || 'claude');
|
||||
lines.push(
|
||||
`Run ${mapCmd} --paths ${affectedPaths.join(',')} to refresh planning context.`,
|
||||
`Run ${String(mapCmd)} --paths ${affectedPaths.join(',')} to refresh planning context.`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
@@ -276,8 +314,8 @@ function buildMessage(elements, affectedPaths, action, runtime) {
|
||||
* the top-level directory prefixes (depth 2 when the repo uses an
|
||||
* `<apps|packages>/<name>/…` layout; depth 1 otherwise).
|
||||
*/
|
||||
function chooseAffectedPaths(paths) {
|
||||
const out = new Set();
|
||||
function chooseAffectedPaths(paths: string[]): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const raw of paths || []) {
|
||||
if (typeof raw !== 'string' || !raw) continue;
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
@@ -298,9 +336,9 @@ function chooseAffectedPaths(paths) {
|
||||
* Any path that is absolute, contains traversal, or includes shell
|
||||
* metacharacters is dropped.
|
||||
*/
|
||||
function sanitizePaths(paths) {
|
||||
function sanitizePaths(paths: unknown): string[] {
|
||||
if (!Array.isArray(paths)) return [];
|
||||
const out = [];
|
||||
const out: string[] = [];
|
||||
for (const p of paths) {
|
||||
if (typeof p !== 'string') continue;
|
||||
if (p.startsWith('/')) continue;
|
||||
@@ -314,11 +352,16 @@ function sanitizePaths(paths) {
|
||||
|
||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
||||
|
||||
function parseFrontmatter(content) {
|
||||
interface FrontmatterResult {
|
||||
data: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: unknown): FrontmatterResult {
|
||||
if (typeof content !== 'string') return { data: {}, body: '' };
|
||||
const m = content.match(FRONTMATTER_RE);
|
||||
if (!m) return { data: {}, body: content };
|
||||
const data = {};
|
||||
const data: Record<string, string> = {};
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const kv = line.match(/^([A-Za-z0-9_][A-Za-z0-9_-]*):\s*(.*)$/);
|
||||
if (!kv) continue;
|
||||
@@ -327,7 +370,7 @@ function parseFrontmatter(content) {
|
||||
return { data, body: content.slice(m[0].length) };
|
||||
}
|
||||
|
||||
function serializeFrontmatter(data, body) {
|
||||
function serializeFrontmatter(data: Record<string, string>, body: string): string {
|
||||
const keys = Object.keys(data);
|
||||
if (keys.length === 0) return body;
|
||||
const lines = ['---'];
|
||||
@@ -340,15 +383,15 @@ function serializeFrontmatter(data, body) {
|
||||
* Read `last_mapped_commit` from the frontmatter of a `.planning/codebase/*.md`
|
||||
* file. Returns null if the file does not exist or has no frontmatter.
|
||||
*/
|
||||
function readMappedCommit(filePath) {
|
||||
let content;
|
||||
function readMappedCommit(filePath: string): string | null {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const { data } = parseFrontmatter(content);
|
||||
const sha = data.last_mapped_commit;
|
||||
const sha = data['last_mapped_commit'];
|
||||
return typeof sha === 'string' && sha.length > 0 ? sha : null;
|
||||
}
|
||||
|
||||
@@ -356,7 +399,7 @@ function readMappedCommit(filePath) {
|
||||
* Upsert `last_mapped_commit` and `last_mapped_at` into the frontmatter of
|
||||
* the given file, preserving any other frontmatter keys and the body.
|
||||
*/
|
||||
function writeMappedCommit(filePath, commitSha, isoDate) {
|
||||
function writeMappedCommit(filePath: string, commitSha: string, isoDate?: string): void {
|
||||
// Symmetric with readMappedCommit (which returns null on missing files):
|
||||
// tolerate a missing target by creating a minimal frontmatter-only file
|
||||
// rather than throwing ENOENT. This matters when a mapper produces a new
|
||||
@@ -365,17 +408,17 @@ function writeMappedCommit(filePath, commitSha, isoDate) {
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
const { data, body } = parseFrontmatter(content);
|
||||
data.last_mapped_commit = commitSha;
|
||||
if (isoDate) data.last_mapped_at = isoDate;
|
||||
data['last_mapped_commit'] = commitSha;
|
||||
if (isoDate) data['last_mapped_at'] = isoDate;
|
||||
platformWriteSync(filePath, serializeFrontmatter(data, body));
|
||||
}
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
DRIFT_CATEGORIES,
|
||||
classifyFile,
|
||||
detectDrift,
|
||||
165
src/fallow-runner.cts
Normal file
165
src/fallow-runner.cts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
/**
|
||||
* Frontmatter — YAML frontmatter parsing, serialization, and CRUD commands
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/frontmatter.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only strict types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { output, error } = require('./core.cjs');
|
||||
const { platformReadSync: safeReadFile, platformWriteSync } = require('./shell-command-projection.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output, error } = core;
|
||||
import { platformReadSync as safeReadFile, platformWriteSync } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type FrontmatterValue = string | string[] | Record<string, unknown>;
|
||||
type Frontmatter = Record<string, FrontmatterValue>;
|
||||
|
||||
// ─── Parsing engine ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,10 +24,10 @@ const { platformReadSync: safeReadFile, platformWriteSync } = require('./shell-c
|
||||
* Split a YAML inline array body on commas, respecting quoted strings.
|
||||
* e.g. '"a, b", c' → ['a, b', 'c']
|
||||
*/
|
||||
function splitInlineArray(body) {
|
||||
const items = [];
|
||||
function splitInlineArray(body: string): string[] {
|
||||
const items: string[] = [];
|
||||
let current = '';
|
||||
let inQuote = null; // null | '"' | "'"
|
||||
let inQuote: string | null = null;
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
@@ -41,8 +52,8 @@ function splitInlineArray(body) {
|
||||
return items;
|
||||
}
|
||||
|
||||
function extractFrontmatter(content) {
|
||||
const frontmatter = {};
|
||||
function extractFrontmatter(content: string): Frontmatter {
|
||||
const frontmatter: Frontmatter = {};
|
||||
// Match frontmatter only at byte 0 — a `---` block later in the document
|
||||
// body (YAML examples, horizontal rules) must never be treated as frontmatter.
|
||||
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
||||
@@ -52,8 +63,8 @@ function extractFrontmatter(content) {
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
|
||||
// Stack to track nested objects: [{obj, key, indent}]
|
||||
// obj = object to write to, key = current key collecting array items, indent = indentation level
|
||||
const stack = [{ obj: frontmatter, key: null, indent: -1 }];
|
||||
type StackEntry = { obj: Record<string, unknown> | unknown[]; key: string | null; indent: number };
|
||||
const stack: StackEntry[] = [{ obj: frontmatter, key: null, indent: -1 }];
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip empty lines
|
||||
@@ -78,18 +89,18 @@ function extractFrontmatter(content) {
|
||||
|
||||
if (value === '' || value === '[') {
|
||||
// Key with no value or opening bracket — could be nested object or array
|
||||
// We'll determine based on next lines, for now create placeholder
|
||||
current.obj[key] = value === '[' ? [] : {};
|
||||
const newObj: Record<string, unknown> | unknown[] = value === '[' ? [] : {};
|
||||
(current.obj as Record<string, unknown>)[key] = newObj;
|
||||
current.key = null;
|
||||
// Push new context for potential nested content
|
||||
stack.push({ obj: current.obj[key], key: null, indent });
|
||||
stack.push({ obj: newObj, key: null, indent });
|
||||
} else if (value.startsWith('[') && value.endsWith(']')) {
|
||||
// Inline array: key: [a, b, c] — quote-aware split (REG-04 fix)
|
||||
current.obj[key] = splitInlineArray(value.slice(1, -1));
|
||||
(current.obj as Record<string, unknown>)[key] = splitInlineArray(value.slice(1, -1));
|
||||
current.key = null;
|
||||
} else {
|
||||
// Simple key: value
|
||||
current.obj[key] = value.replace(/^["']|["']$/g, '');
|
||||
(current.obj as Record<string, unknown>)[key] = value.replace(/^["']|["']$/g, '');
|
||||
current.key = null;
|
||||
}
|
||||
} else if (line.trim().startsWith('- ')) {
|
||||
@@ -102,9 +113,9 @@ function extractFrontmatter(content) {
|
||||
const parent = stack.length > 1 ? stack[stack.length - 2] : null;
|
||||
if (parent) {
|
||||
for (const k of Object.keys(parent.obj)) {
|
||||
if (parent.obj[k] === current.obj) {
|
||||
parent.obj[k] = [itemValue];
|
||||
current.obj = parent.obj[k];
|
||||
if ((parent.obj as Record<string, unknown>)[k] === current.obj) {
|
||||
(parent.obj as Record<string, unknown>)[k] = [itemValue];
|
||||
current.obj = (parent.obj as Record<string, unknown>)[k] as unknown[];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -118,15 +129,15 @@ function extractFrontmatter(content) {
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
function reconstructFrontmatter(obj) {
|
||||
const lines = [];
|
||||
function reconstructFrontmatter(obj: Frontmatter): string {
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
lines.push(`${key}: []`);
|
||||
} else if (value.every(v => typeof v === 'string') && value.length <= 3 && value.join(', ').length < 60) {
|
||||
lines.push(`${key}: [${value.join(', ')}]`);
|
||||
} else if (value.every(v => typeof v === 'string') && value.length <= 3 && (value).join(', ').length < 60) {
|
||||
lines.push(`${key}: [${(value).join(', ')}]`);
|
||||
} else {
|
||||
lines.push(`${key}:`);
|
||||
for (const item of value) {
|
||||
@@ -140,8 +151,8 @@ function reconstructFrontmatter(obj) {
|
||||
if (Array.isArray(subval)) {
|
||||
if (subval.length === 0) {
|
||||
lines.push(` ${subkey}: []`);
|
||||
} else if (subval.every(v => typeof v === 'string') && subval.length <= 3 && subval.join(', ').length < 60) {
|
||||
lines.push(` ${subkey}: [${subval.join(', ')}]`);
|
||||
} else if (subval.every((v: unknown) => typeof v === 'string') && subval.length <= 3 && (subval).join(', ').length < 60) {
|
||||
lines.push(` ${subkey}: [${(subval).join(', ')}]`);
|
||||
} else {
|
||||
lines.push(` ${subkey}:`);
|
||||
for (const item of subval) {
|
||||
@@ -150,7 +161,7 @@ function reconstructFrontmatter(obj) {
|
||||
}
|
||||
} else if (typeof subval === 'object') {
|
||||
lines.push(` ${subkey}:`);
|
||||
for (const [subsubkey, subsubval] of Object.entries(subval)) {
|
||||
for (const [subsubkey, subsubval] of Object.entries(subval as Record<string, unknown>)) {
|
||||
if (subsubval === null || subsubval === undefined) continue;
|
||||
if (Array.isArray(subsubval)) {
|
||||
if (subsubval.length === 0) {
|
||||
@@ -162,10 +173,12 @@ function reconstructFrontmatter(obj) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
|
||||
lines.push(` ${subsubkey}: ${subsubval}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
const sv = String(subval);
|
||||
lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`);
|
||||
}
|
||||
@@ -182,7 +195,7 @@ function reconstructFrontmatter(obj) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function spliceFrontmatter(content, newObj) {
|
||||
function spliceFrontmatter(content: string, newObj: Frontmatter): string {
|
||||
const yamlStr = reconstructFrontmatter(newObj);
|
||||
const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
|
||||
if (match) {
|
||||
@@ -191,7 +204,7 @@ function spliceFrontmatter(content, newObj) {
|
||||
return `---\n${yamlStr}\n---\n\n` + content;
|
||||
}
|
||||
|
||||
function parseMustHavesBlock(content, blockName) {
|
||||
function parseMustHavesBlock(content: string, blockName: string): unknown[] {
|
||||
// Extract a specific block from must_haves in raw frontmatter YAML
|
||||
// Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
|
||||
const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
||||
@@ -223,14 +236,15 @@ function parseMustHavesBlock(content, blockName) {
|
||||
|
||||
// List items are indented one level deeper than blockIndent
|
||||
// Continuation KVs are indented one level deeper than list items
|
||||
const items = [];
|
||||
let current = null;
|
||||
const items: unknown[] = [];
|
||||
let current: string | Record<string, unknown> | null = null;
|
||||
let listItemIndent = -1; // detected from first "- " line
|
||||
|
||||
for (const line of blockLines) {
|
||||
// Skip empty lines
|
||||
if (line.trim() === '') continue;
|
||||
const indent = line.match(/^(\s*)/)[1].length;
|
||||
const indentMatch = line.match(/^(\s*)/);
|
||||
const indent = indentMatch ? indentMatch[1].length : 0;
|
||||
// Stop at same or lower indent level than the block header
|
||||
if (indent <= blockIndent && line.trim() !== '') break;
|
||||
|
||||
@@ -259,7 +273,7 @@ function parseMustHavesBlock(content, blockName) {
|
||||
const kvMatch = afterDash.match(/^(\w+):\s+"?([^"]*)"?\s*$/);
|
||||
if (kvMatch) {
|
||||
current = {};
|
||||
current[kvMatch[1]] = kvMatch[2];
|
||||
(current)[kvMatch[1]] = kvMatch[2];
|
||||
} else {
|
||||
// Looks like KV but doesn't match — treat as plain string (#2757)
|
||||
current = afterDash.replace(/^["']|["']$/g, '');
|
||||
@@ -276,16 +290,17 @@ function parseMustHavesBlock(content, blockName) {
|
||||
const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, '');
|
||||
const keys = Object.keys(current);
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (lastKey && !Array.isArray(current[lastKey])) {
|
||||
current[lastKey] = current[lastKey] ? [current[lastKey]] : [];
|
||||
if (lastKey && !Array.isArray((current)[lastKey])) {
|
||||
const existing = (current)[lastKey];
|
||||
(current)[lastKey] = existing ? [existing] : [];
|
||||
}
|
||||
if (lastKey) current[lastKey].push(arrVal);
|
||||
if (lastKey) ((current)[lastKey] as unknown[]).push(arrVal);
|
||||
} else {
|
||||
const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/);
|
||||
if (kvMatch) {
|
||||
const val = kvMatch[2];
|
||||
// Try to parse as number
|
||||
current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
|
||||
(current)[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,73 +325,73 @@ function parseMustHavesBlock(content, blockName) {
|
||||
|
||||
// ─── Frontmatter CRUD commands ────────────────────────────────────────────────
|
||||
|
||||
const FRONTMATTER_SCHEMAS = {
|
||||
const FRONTMATTER_SCHEMAS: Record<string, { required: string[] }> = {
|
||||
plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] },
|
||||
summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] },
|
||||
verification: { required: ['phase', 'verified', 'status', 'score'] },
|
||||
};
|
||||
|
||||
function cmdFrontmatterGet(cwd, filePath, field, raw) {
|
||||
function cmdFrontmatterGet(cwd: string, filePath: string, field: string | undefined, raw: boolean): void {
|
||||
if (!filePath) { error('file path required'); }
|
||||
// Path traversal guard: reject null bytes
|
||||
if (filePath.includes('\0')) { error('file path contains null bytes'); }
|
||||
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
||||
const content = safeReadFile(fullPath);
|
||||
if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
|
||||
if (!content) { output({ error: 'File not found', path: filePath }, raw, undefined); return; }
|
||||
const fm = extractFrontmatter(content);
|
||||
if (field) {
|
||||
const value = fm[field];
|
||||
if (value === undefined) { output({ error: 'Field not found', field }, raw); return; }
|
||||
if (value === undefined) { output({ error: 'Field not found', field }, raw, undefined); return; }
|
||||
output({ [field]: value }, raw, JSON.stringify(value));
|
||||
} else {
|
||||
output(fm, raw);
|
||||
output(fm, raw, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
|
||||
function cmdFrontmatterSet(cwd: string, filePath: string, field: string | undefined, value: string | undefined, raw: boolean): void {
|
||||
if (!filePath || !field || value === undefined) { error('file, field, and value required'); }
|
||||
// Path traversal guard: reject null bytes
|
||||
if (filePath.includes('\0')) { error('file path contains null bytes'); }
|
||||
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
||||
if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
|
||||
if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw, undefined); return; }
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
let parsedValue;
|
||||
try { parsedValue = JSON.parse(value); } catch { parsedValue = value; }
|
||||
fm[field] = parsedValue;
|
||||
let parsedValue: unknown;
|
||||
try { parsedValue = JSON.parse(value as string); } catch { parsedValue = value; }
|
||||
fm[field as string] = parsedValue as FrontmatterValue;
|
||||
const newContent = spliceFrontmatter(content, fm);
|
||||
platformWriteSync(fullPath, newContent);
|
||||
output({ updated: true, field, value: parsedValue }, raw, 'true');
|
||||
}
|
||||
|
||||
function cmdFrontmatterMerge(cwd, filePath, data, raw) {
|
||||
function cmdFrontmatterMerge(cwd: string, filePath: string, data: string | undefined, raw: boolean): void {
|
||||
if (!filePath || !data) { error('file and data required'); }
|
||||
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
||||
if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
|
||||
if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw, undefined); return; }
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
let mergeData;
|
||||
try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; }
|
||||
let mergeData: Record<string, FrontmatterValue>;
|
||||
try { mergeData = JSON.parse(data as string) as Record<string, FrontmatterValue>; } catch { error('Invalid JSON for --data'); return; }
|
||||
Object.assign(fm, mergeData);
|
||||
const newContent = spliceFrontmatter(content, fm);
|
||||
platformWriteSync(fullPath, newContent);
|
||||
output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true');
|
||||
}
|
||||
|
||||
function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
|
||||
function cmdFrontmatterValidate(cwd: string, filePath: string, schemaName: string | undefined, raw: boolean): void {
|
||||
if (!filePath || !schemaName) { error('file and schema required'); }
|
||||
const schema = FRONTMATTER_SCHEMAS[schemaName];
|
||||
const schema = FRONTMATTER_SCHEMAS[schemaName as string];
|
||||
if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); }
|
||||
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
||||
const content = safeReadFile(fullPath);
|
||||
if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
|
||||
if (!content) { output({ error: 'File not found', path: filePath }, raw, undefined); return; }
|
||||
const fm = extractFrontmatter(content);
|
||||
const missing = schema.required.filter(f => fm[f] === undefined);
|
||||
const present = schema.required.filter(f => fm[f] !== undefined);
|
||||
output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
extractFrontmatter,
|
||||
reconstructFrontmatter,
|
||||
spliceFrontmatter,
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Post-planning gap analysis (#2493).
|
||||
*
|
||||
@@ -12,13 +10,60 @@
|
||||
*
|
||||
* Coverage detection uses word-boundary regex matching to avoid false positives
|
||||
* (REQ-1 must not match REQ-10).
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/gap-checker.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only strict types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, output, error } = require('./core.cjs');
|
||||
const { planningPaths, planningDir, findContextMdIn } = require('./planning-workspace.cjs');
|
||||
const { parseDecisions } = require('./decisions.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { escapeRegex, output, error } = core;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningPaths, planningDir, findContextMdIn } = planningWorkspace;
|
||||
import { parseDecisions } from './decisions.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ReqItem {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface RequirementItem extends ReqItem {
|
||||
source: string;
|
||||
}
|
||||
|
||||
type DecisionItem = ReturnType<typeof parseDecisions>[number] & { source: string };
|
||||
|
||||
type Item = RequirementItem | DecisionItem;
|
||||
|
||||
interface CoverageRow {
|
||||
source: string;
|
||||
item: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface GapCounts {
|
||||
total: number;
|
||||
covered: number;
|
||||
uncovered: number;
|
||||
}
|
||||
|
||||
interface GapResult {
|
||||
enabled: boolean;
|
||||
rows: CoverageRow[];
|
||||
table: string;
|
||||
summary: string;
|
||||
counts: GapCounts;
|
||||
}
|
||||
|
||||
interface RunGapAnalysisOptions {
|
||||
phaseReqIds?: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse REQ-IDs from REQUIREMENTS.md content.
|
||||
@@ -26,10 +71,10 @@ const { parseDecisions } = require('./decisions.cjs');
|
||||
* Supports both checkbox (`- [ ] **REQ-NN** ...`) and traceability table
|
||||
* (`| REQ-NN | ... |`) formats.
|
||||
*/
|
||||
function parseRequirements(reqMd) {
|
||||
function parseRequirements(reqMd: unknown): ReqItem[] {
|
||||
if (!reqMd || typeof reqMd !== 'string') return [];
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const out: ReqItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Prefix-agnostic ID format: REQ-01, TST-01, BACK-07, INSP-04, etc.
|
||||
const ID_PATTERN = '[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+';
|
||||
@@ -69,7 +114,7 @@ function parseRequirements(reqMd) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectCoverage(items, planText) {
|
||||
function detectCoverage(items: Item[], planText: string): CoverageRow[] {
|
||||
return items.map(it => {
|
||||
const re = new RegExp('\\b' + escapeRegex(it.id) + '\\b');
|
||||
return {
|
||||
@@ -80,12 +125,12 @@ function detectCoverage(items, planText) {
|
||||
});
|
||||
}
|
||||
|
||||
function naturalKey(s) {
|
||||
return String(s).replace(/(\d+)/g, (_, n) => n.padStart(8, '0'));
|
||||
function naturalKey(s: unknown): string {
|
||||
return String(s).replace(/(\d+)/g, (_, n: string) => n.padStart(8, '0'));
|
||||
}
|
||||
|
||||
function sortRows(rows) {
|
||||
const sourceOrder = { 'REQUIREMENTS.md': 0, 'CONTEXT.md': 1 };
|
||||
function sortRows(rows: CoverageRow[]): CoverageRow[] {
|
||||
const sourceOrder: Record<string, number> = { 'REQUIREMENTS.md': 0, 'CONTEXT.md': 1 };
|
||||
return rows.slice().sort((a, b) => {
|
||||
const so = (sourceOrder[a.source] ?? 99) - (sourceOrder[b.source] ?? 99);
|
||||
if (so !== 0) return so;
|
||||
@@ -93,26 +138,30 @@ function sortRows(rows) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatGapTable(rows) {
|
||||
function formatGapTable(rows: CoverageRow[]): string {
|
||||
if (rows.length === 0) {
|
||||
return '## Post-Planning Gap Analysis\n\nNo requirements or decisions to check.\n';
|
||||
}
|
||||
const header = '| Source | Item | Status |\n|--------|------|--------|';
|
||||
const body = rows.map(r => {
|
||||
const tick = r.status === 'Covered' ? '\u2713 Covered'
|
||||
: r.status === 'Missing from REQUIREMENTS.md' ? '\u26a0 Missing from REQUIREMENTS.md'
|
||||
: '\u2717 Not covered';
|
||||
const tick = r.status === 'Covered' ? '✓ Covered'
|
||||
: r.status === 'Missing from REQUIREMENTS.md' ? '⚠ Missing from REQUIREMENTS.md'
|
||||
: '✗ Not covered';
|
||||
return `| ${r.source} | ${r.item} | ${tick} |`;
|
||||
}).join('\n');
|
||||
return `## Post-Planning Gap Analysis\n\n${header}\n${body}\n`;
|
||||
}
|
||||
|
||||
function readGate(cwd) {
|
||||
function readGate(cwd: string): boolean {
|
||||
const cfgPath = path.join(planningDir(cwd), 'config.json');
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
if (raw && raw.workflow && typeof raw.workflow.post_planning_gaps === 'boolean') {
|
||||
return raw.workflow.post_planning_gaps;
|
||||
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) as unknown;
|
||||
if (raw && typeof raw === 'object' && 'workflow' in raw) {
|
||||
const wf = (raw as Record<string, unknown>)['workflow'];
|
||||
if (wf && typeof wf === 'object' && 'post_planning_gaps' in wf) {
|
||||
const val = (wf as Record<string, unknown>)['post_planning_gaps'];
|
||||
if (typeof val === 'boolean') return val;
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return true;
|
||||
@@ -129,9 +178,10 @@ function readGate(cwd) {
|
||||
* Tolerates JSON-array-ish input (`["REQ-01","REQ-02"]`) since callers may pass
|
||||
* the roadmap value through verbatim.
|
||||
*/
|
||||
function normalizePhaseReqIds(rawVal) {
|
||||
function normalizePhaseReqIds(rawVal: unknown): string[] | null | undefined {
|
||||
if (rawVal === undefined) return undefined;
|
||||
if (rawVal === null) return null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
const v = String(rawVal).replace(/["'[\]()]/g, '').trim();
|
||||
if (v === '' || /^(null|tbd|none)$/i.test(v)) return null;
|
||||
// Tolerate comma-, space-, or newline-separated lists (callers may pass the
|
||||
@@ -140,7 +190,7 @@ function normalizePhaseReqIds(rawVal) {
|
||||
return ids.length === 0 ? null : ids;
|
||||
}
|
||||
|
||||
function runGapAnalysis(cwd, phaseDir, options = {}) {
|
||||
function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOptions = {}): GapResult {
|
||||
const phaseReqIds = normalizePhaseReqIds(options.phaseReqIds);
|
||||
if (!readGate(cwd)) {
|
||||
return {
|
||||
@@ -156,13 +206,13 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
||||
|
||||
const reqPath = planningPaths(cwd).requirements;
|
||||
const reqMd = fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf-8') : '';
|
||||
let reqItems = parseRequirements(reqMd).map(r => ({ ...r, source: 'REQUIREMENTS.md' }));
|
||||
let reqItems: RequirementItem[] = parseRequirements(reqMd).map(r => ({ ...r, source: 'REQUIREMENTS.md' }));
|
||||
|
||||
// Scope the requirements comparison to the phase's mapped REQ-IDs (#447).
|
||||
// A phase that maps no requirements (phase_req_ids null/TBD) must not report
|
||||
// every unrelated project REQ-ID as a gap — mirror §13's skip behavior.
|
||||
// CONTEXT.md decisions (below) are always in scope regardless.
|
||||
let ghostReqIds = [];
|
||||
let ghostReqIds: string[] = [];
|
||||
if (phaseReqIds === null) {
|
||||
reqItems = [];
|
||||
} else if (Array.isArray(phaseReqIds)) {
|
||||
@@ -174,7 +224,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
||||
|
||||
// Read the phase directory once; reuse the listing for both context detection
|
||||
// and plan-file enumeration (avoids redundant readdirSync calls).
|
||||
let phaseDirFiles = [];
|
||||
let phaseDirFiles: string[] = [];
|
||||
try {
|
||||
if (fs.existsSync(absPhaseDir)) phaseDirFiles = fs.readdirSync(absPhaseDir);
|
||||
} catch { /* unreadable */ }
|
||||
@@ -182,9 +232,9 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
||||
const ctxFile = findContextMdIn(phaseDirFiles);
|
||||
const ctxPath = ctxFile ? path.join(absPhaseDir, ctxFile) : null;
|
||||
const ctxMd = ctxPath ? fs.readFileSync(ctxPath, 'utf-8') : '';
|
||||
const dItems = parseDecisions(ctxMd).map(d => ({ ...d, source: 'CONTEXT.md' }));
|
||||
const dItems: DecisionItem[] = parseDecisions(ctxMd).map(d => ({ ...d, source: 'CONTEXT.md' }));
|
||||
|
||||
const items = [...reqItems, ...dItems];
|
||||
const items: Item[] = [...reqItems, ...dItems];
|
||||
|
||||
let planText = '';
|
||||
try {
|
||||
@@ -215,8 +265,8 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
||||
const uncovered = rows.length - covered;
|
||||
|
||||
const summary = uncovered === 0
|
||||
? `\u2713 All ${rows.length} items covered by plans`
|
||||
: `\u26A0 ${uncovered} of ${rows.length} items not covered by any plan`;
|
||||
? `✓ All ${rows.length} items covered by plans`
|
||||
: `⚠ ${uncovered} of ${rows.length} items not covered by any plan`;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -227,7 +277,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function cmdGapAnalysis(cwd, args, raw) {
|
||||
function cmdGapAnalysis(cwd: string, args: string[], raw: boolean): void {
|
||||
const idx = args.indexOf('--phase-dir');
|
||||
if (idx === -1 || !args[idx + 1]) {
|
||||
error('Usage: gap-analysis --phase-dir <path-to-phase-directory>');
|
||||
@@ -243,7 +293,7 @@ function cmdGapAnalysis(cwd, args, raw) {
|
||||
output(result, raw, result.table || result.summary);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
parseRequirements,
|
||||
detectCoverage,
|
||||
formatGapTable,
|
||||
@@ -1,8 +1,15 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Graphify integration module — config gate, subprocess execution, knowledge-graph
|
||||
* query, status, diff, build pipeline, and snapshot helpers.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/graphify.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execTool, execGit, platformWriteSync } = require('./shell-command-projection.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execTool, execGit, platformWriteSync } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Config Gate ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,40 +17,41 @@ const { execTool, execGit, platformWriteSync } = require('./shell-command-projec
|
||||
* Check whether graphify is enabled in the project config.
|
||||
* Reads config.json directly via fs. Returns false by default
|
||||
* (when no config, no graphify key, or on error).
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isGraphifyEnabled(planningDir) {
|
||||
function isGraphifyEnabled(planningDir: string): boolean {
|
||||
try {
|
||||
const configPath = path.join(planningDir, 'config.json');
|
||||
if (!fs.existsSync(configPath)) return false;
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (config && config.graphify && config.graphify.enabled === true) return true;
|
||||
const config: unknown = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (
|
||||
config &&
|
||||
typeof config === 'object' &&
|
||||
'graphify' in config &&
|
||||
config.graphify &&
|
||||
typeof config.graphify === 'object' &&
|
||||
'enabled' in config.graphify &&
|
||||
(config.graphify as Record<string, unknown>).enabled === true
|
||||
) return true;
|
||||
return false;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface DisabledResponse {
|
||||
disabled: true;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the standard disabled response object.
|
||||
* @returns {{ disabled: true, message: string }}
|
||||
*/
|
||||
function disabledResponse() {
|
||||
function disabledResponse(): DisabledResponse {
|
||||
return { disabled: true, message: 'graphify is not enabled. Enable with: gsd-tools config-set graphify.enabled true' };
|
||||
}
|
||||
|
||||
// ─── Subprocess Helper ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute graphify CLI as a subprocess with proper env and timeout handling.
|
||||
*
|
||||
* @param {string} cwd - Working directory for the subprocess
|
||||
* @param {string[]} args - Arguments to pass to graphify
|
||||
* @param {{ timeout?: number }} [options={}] - Options (timeout in ms, default 30000)
|
||||
* @returns {{ exitCode: number, stdout: string, stderr: string }}
|
||||
*/
|
||||
/**
|
||||
* Frozen enum of typed reason codes for execGraphify failures (#2974).
|
||||
* Tests assert on result.reason instead of grepping stderr text.
|
||||
@@ -53,9 +61,22 @@ const GRAPHIFY_REASON = Object.freeze({
|
||||
ENOENT: 'graphify_not_found',
|
||||
TIMEOUT: 'graphify_timed_out',
|
||||
EXIT_NONZERO: 'graphify_exit_nonzero',
|
||||
});
|
||||
} as const);
|
||||
|
||||
function execGraphify(cwd, args, options = {}) {
|
||||
type GraphifyReason = typeof GRAPHIFY_REASON[keyof typeof GRAPHIFY_REASON];
|
||||
|
||||
interface GraphifyExecResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
reason: GraphifyReason;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute graphify CLI as a subprocess with proper env and timeout handling.
|
||||
*/
|
||||
function execGraphify(cwd: string, args: string[], options: { timeout?: number } = {}): GraphifyExecResult {
|
||||
const timeout = options.timeout ?? 30000;
|
||||
const result = execTool('graphify', args, {
|
||||
cwd,
|
||||
@@ -64,7 +85,7 @@ function execGraphify(cwd, args, options = {}) {
|
||||
});
|
||||
|
||||
// ENOENT — seam normalizes to exitCode 127. Surface as typed reason.
|
||||
if (result.error && result.error.code === 'ENOENT') {
|
||||
if (result.error && (result.error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {
|
||||
exitCode: 127,
|
||||
stdout: '',
|
||||
@@ -94,13 +115,16 @@ function execGraphify(cwd, args, options = {}) {
|
||||
|
||||
// ─── Presence & Version ──────────────────────────────────────────────────────
|
||||
|
||||
interface InstalledResult {
|
||||
installed: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the graphify CLI binary is installed and accessible on PATH.
|
||||
* Uses --help (NOT --version, which graphify does not support).
|
||||
*
|
||||
* @returns {{ installed: boolean, message?: string }}
|
||||
*/
|
||||
function checkGraphifyInstalled() {
|
||||
function checkGraphifyInstalled(): InstalledResult {
|
||||
const result = execTool('graphify', ['--help'], { timeout: 5000 });
|
||||
|
||||
if (result.error) {
|
||||
@@ -113,6 +137,12 @@ function checkGraphifyInstalled() {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
interface VersionResult {
|
||||
version: string | null;
|
||||
compatible: boolean | null;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect graphify version and check compatibility.
|
||||
* Tested range: >=0.4.0,<1.0
|
||||
@@ -121,14 +151,12 @@ function checkGraphifyInstalled() {
|
||||
* 1. Try `graphify --version` (works for most CLI installations, incl. venv installs)
|
||||
* 2. Fall back to python3 importlib.metadata (legacy / system Python path)
|
||||
* 3. Return null version gracefully if both fail
|
||||
*
|
||||
* @returns {{ version: string|null, compatible: boolean|null, warning: string|null }}
|
||||
*/
|
||||
function checkGraphifyVersion() {
|
||||
function checkGraphifyVersion(): VersionResult {
|
||||
// Strategy 1: try `graphify --version` directly (2s timeout -- fast path)
|
||||
const versionResult = execTool('graphify', ['--version'], { timeout: 2000 });
|
||||
|
||||
let versionStr = null;
|
||||
let versionStr: string | null = null;
|
||||
|
||||
if (!versionResult.error && versionResult.exitCode === 0) {
|
||||
// graphify --version may emit "graphify 0.4.23" or just "0.4.23"
|
||||
@@ -168,32 +196,57 @@ function checkGraphifyVersion() {
|
||||
|
||||
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
relation?: string;
|
||||
confidence?: string;
|
||||
confidence_score?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Graph {
|
||||
nodes?: GraphNode[];
|
||||
edges?: GraphEdge[];
|
||||
links?: GraphEdge[];
|
||||
hyperedges?: unknown[];
|
||||
built_at_commit?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read and parse a JSON file. Returns null on missing file or parse error.
|
||||
* Prevents crashes on malformed JSON (T-02-01 mitigation).
|
||||
*
|
||||
* @param {string} filePath - Absolute path to JSON file
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function safeReadJson(filePath) {
|
||||
function safeReadJson(filePath: string): Graph | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as Graph;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface AdjEntry {
|
||||
target: string;
|
||||
edge: GraphEdge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bidirectional adjacency map from graph nodes and edges.
|
||||
* Each node ID maps to an array of { target, edge } entries.
|
||||
* Bidirectional: both source->target and target->source are added (Pitfall 3).
|
||||
*
|
||||
* @param {{ nodes: object[], edges: object[] }} graph
|
||||
* @returns {Object.<string, Array<{ target: string, edge: object }>>}
|
||||
*/
|
||||
function buildAdjacencyMap(graph) {
|
||||
const adj = {};
|
||||
function buildAdjacencyMap(graph: Graph): Record<string, AdjEntry[]> {
|
||||
const adj: Record<string, AdjEntry[]> = {};
|
||||
for (const node of (graph.nodes || [])) {
|
||||
adj[node.id] = [];
|
||||
}
|
||||
@@ -206,16 +259,18 @@ function buildAdjacencyMap(graph) {
|
||||
return adj;
|
||||
}
|
||||
|
||||
interface ExpandResult {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
seeds: Set<string>;
|
||||
trimmed?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed-then-expand query: find nodes matching term, then BFS-expand up to maxHops.
|
||||
* Matches on node label and description (case-insensitive substring, D-01).
|
||||
*
|
||||
* @param {{ nodes: object[], edges: object[] }} graph
|
||||
* @param {string} term - Search term
|
||||
* @param {number} [maxHops=2] - Maximum BFS hops from seed nodes
|
||||
* @returns {{ nodes: object[], edges: object[], seeds: Set<string> }}
|
||||
*/
|
||||
function seedAndExpand(graph, term, maxHops = 2) {
|
||||
function seedAndExpand(graph: Graph, term: string, maxHops = 2): ExpandResult {
|
||||
const lowerTerm = term.toLowerCase();
|
||||
const nodeMap = Object.fromEntries((graph.nodes || []).map(n => [n.id, n]));
|
||||
const adj = buildAdjacencyMap(graph);
|
||||
@@ -228,12 +283,12 @@ function seedAndExpand(graph, term, maxHops = 2) {
|
||||
|
||||
// BFS expand from seeds
|
||||
const visitedNodes = new Set(seeds.map(n => n.id));
|
||||
const collectedEdges = [];
|
||||
const seenEdgeKeys = new Set();
|
||||
const collectedEdges: GraphEdge[] = [];
|
||||
const seenEdgeKeys = new Set<string>();
|
||||
let frontier = seeds.map(n => n.id);
|
||||
|
||||
for (let hop = 0; hop < maxHops && frontier.length > 0; hop++) {
|
||||
const nextFrontier = [];
|
||||
const nextFrontier: string[] = [];
|
||||
for (const nodeId of frontier) {
|
||||
for (const entry of (adj[nodeId] || [])) {
|
||||
// Deduplicate edges by source::target::label key
|
||||
@@ -251,27 +306,31 @@ function seedAndExpand(graph, term, maxHops = 2) {
|
||||
frontier = nextFrontier;
|
||||
}
|
||||
|
||||
const resultNodes = [...visitedNodes].map(id => nodeMap[id]).filter(Boolean);
|
||||
const resultNodes = [...visitedNodes].map(id => nodeMap[id]).filter((n): n is GraphNode => Boolean(n));
|
||||
return { nodes: resultNodes, edges: collectedEdges, seeds: new Set(seeds.map(n => n.id)) };
|
||||
}
|
||||
|
||||
interface BudgetResult {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
trimmed: string | null;
|
||||
total_nodes: number;
|
||||
total_edges: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply token budget by dropping edges by confidence tier (D-04, D-05, D-06).
|
||||
* Token estimation: Math.ceil(JSON.stringify(obj).length / 4).
|
||||
* Drop order: AMBIGUOUS -> INFERRED -> EXTRACTED.
|
||||
*
|
||||
* @param {{ nodes: object[], edges: object[], seeds: Set<string> }} result
|
||||
* @param {number|null} budgetTokens - Max tokens, or null/falsy for unlimited
|
||||
* @returns {{ nodes: object[], edges: object[], trimmed: string|null, total_nodes: number, total_edges: number, term?: string }}
|
||||
*/
|
||||
function applyBudget(result, budgetTokens) {
|
||||
function applyBudget(result: ExpandResult, budgetTokens: number | null): ExpandResult | BudgetResult {
|
||||
if (!budgetTokens) return result;
|
||||
|
||||
const CONFIDENCE_ORDER = ['AMBIGUOUS', 'INFERRED', 'EXTRACTED'];
|
||||
let edges = [...result.edges];
|
||||
let omitted = 0;
|
||||
|
||||
const estimateTokens = (obj) => Math.ceil(JSON.stringify(obj).length / 4);
|
||||
const estimateTokens = (obj: unknown) => Math.ceil(JSON.stringify(obj).length / 4);
|
||||
|
||||
for (const tier of CONFIDENCE_ORDER) {
|
||||
if (estimateTokens({ nodes: result.nodes, edges }) <= budgetTokens) break;
|
||||
@@ -282,7 +341,7 @@ function applyBudget(result, budgetTokens) {
|
||||
}
|
||||
|
||||
// Find unreachable nodes after edge removal
|
||||
const reachableNodes = new Set();
|
||||
const reachableNodes = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
reachableNodes.add(edge.source);
|
||||
reachableNodes.add(edge.target);
|
||||
@@ -302,16 +361,40 @@ function applyBudget(result, budgetTokens) {
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strict 4-40 hex fence for graph.built_at_commit values (#3170). Anything
|
||||
* else (dashed, prose, empty) is treated as absent so a hostile graph.json
|
||||
* cannot smuggle a `--upload-pack=…` option into a `git` argv.
|
||||
*/
|
||||
const COMMIT_HASH_RE = /^[0-9a-f]{4,40}$/i;
|
||||
|
||||
/**
|
||||
* Read git HEAD for the project at `cwd`. Returns the full commit hash on
|
||||
* success, or null when cwd is not a git repo / `git` is not on PATH.
|
||||
*/
|
||||
function readGitHead(cwd: string): string | null {
|
||||
const r = execGit(['rev-parse', 'HEAD'], { cwd });
|
||||
if (r.exitCode !== 0) return null;
|
||||
return r.stdout.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commits between `from` and `to` (exclusive..inclusive, like
|
||||
* `git rev-list --count A..B`). Returns null when either ref is unreachable
|
||||
* or the cwd is not a git repo.
|
||||
*/
|
||||
function countCommitsBetween(cwd: string, from: string, to: string): number | null {
|
||||
const r = execGit(['rev-list', '--count', `${from}..${to}`], { cwd });
|
||||
if (r.exitCode !== 0) return null;
|
||||
const n = parseInt(r.stdout.trim(), 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the knowledge graph for nodes matching a term, with optional budget cap.
|
||||
* Uses seed-then-expand BFS traversal (D-01).
|
||||
*
|
||||
* @param {string} cwd - Working directory
|
||||
* @param {string} term - Search term
|
||||
* @param {{ budget?: number|null }} [options={}]
|
||||
* @returns {object}
|
||||
*/
|
||||
function graphifyQuery(cwd, term, options = {}) {
|
||||
function graphifyQuery(cwd: string, term: string, options: { budget?: number | null } = {}): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
@@ -325,7 +408,7 @@ function graphifyQuery(cwd, term, options = {}) {
|
||||
return { error: 'Failed to parse graph.json' };
|
||||
}
|
||||
|
||||
let result = seedAndExpand(graph, term);
|
||||
let result: ExpandResult | BudgetResult = seedAndExpand(graph, term);
|
||||
|
||||
if (options.budget) {
|
||||
result = applyBudget(result, options.budget);
|
||||
@@ -337,39 +420,10 @@ function graphifyQuery(cwd, term, options = {}) {
|
||||
edges: result.edges,
|
||||
total_nodes: result.nodes.length,
|
||||
total_edges: result.edges.length,
|
||||
trimmed: result.trimmed || null,
|
||||
trimmed: 'trimmed' in result ? (result.trimmed || null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict 4-40 hex fence for graph.built_at_commit values (#3170). Anything
|
||||
* else (dashed, prose, empty) is treated as absent so a hostile graph.json
|
||||
* cannot smuggle a `--upload-pack=…` option into a `git` argv.
|
||||
*/
|
||||
const COMMIT_HASH_RE = /^[0-9a-f]{4,40}$/i;
|
||||
|
||||
/**
|
||||
* Read git HEAD for the project at `cwd`. Returns the full commit hash on
|
||||
* success, or null when cwd is not a git repo / `git` is not on PATH.
|
||||
*/
|
||||
function readGitHead(cwd) {
|
||||
const r = execGit(['rev-parse', 'HEAD'], { cwd });
|
||||
if (r.exitCode !== 0) return null;
|
||||
return r.stdout.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commits between `from` and `to` (exclusive..inclusive, like
|
||||
* `git rev-list --count A..B`). Returns null when either ref is unreachable
|
||||
* or the cwd is not a git repo.
|
||||
*/
|
||||
function countCommitsBetween(cwd, from, to) {
|
||||
const r = execGit(['rev-list', '--count', `${from}..${to}`], { cwd });
|
||||
if (r.exitCode !== 0) return null;
|
||||
const n = parseInt(r.stdout.trim(), 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return status information about the knowledge graph (STAT-01, STAT-02).
|
||||
*
|
||||
@@ -378,11 +432,8 @@ function countCommitsBetween(cwd, from, to) {
|
||||
* (#3170). Tri-state on commit_stale: null means "we don't know" (pre-v0.7
|
||||
* graph, no git, or unreachable commit), distinct from false ("known
|
||||
* fresh").
|
||||
*
|
||||
* @param {string} cwd - Working directory
|
||||
* @returns {object}
|
||||
*/
|
||||
function graphifyStatus(cwd) {
|
||||
function graphifyStatus(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
@@ -401,11 +452,12 @@ function graphifyStatus(cwd) {
|
||||
const age = Date.now() - stat.mtimeMs;
|
||||
|
||||
// Commit-staleness signal (#3170). Validate before passing to git.
|
||||
const rawBuilt = (graph.built_at_commit || '').toString().trim();
|
||||
const builtAtCommit = graph.built_at_commit;
|
||||
const rawBuilt = (typeof builtAtCommit === 'string' ? builtAtCommit : '').trim();
|
||||
const builtAt = COMMIT_HASH_RE.test(rawBuilt) ? rawBuilt : null;
|
||||
const head = readGitHead(cwd);
|
||||
let commitsBehind = null;
|
||||
let commitStale = null;
|
||||
let commitsBehind: number | null = null;
|
||||
let commitStale: boolean | null = null;
|
||||
if (builtAt && head) {
|
||||
commitsBehind = countCommitsBetween(cwd, builtAt, head);
|
||||
if (commitsBehind !== null) commitStale = commitsBehind > 0;
|
||||
@@ -443,11 +495,8 @@ function graphifyStatus(cwd) {
|
||||
|
||||
/**
|
||||
* Compute topology-level diff between current graph and last build snapshot (D-07, D-08, D-09).
|
||||
*
|
||||
* @param {string} cwd - Working directory
|
||||
* @returns {object}
|
||||
*/
|
||||
function graphifyDiff(cwd) {
|
||||
function graphifyDiff(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
@@ -480,7 +529,7 @@ function graphifyDiff(cwd) {
|
||||
);
|
||||
|
||||
// Diff edges (keyed by source+target+relation)
|
||||
const edgeKey = (e) => `${e.source}::${e.target}::${e.relation || e.label || ''}`;
|
||||
const edgeKey = (e: GraphEdge) => `${e.source}::${e.target}::${e.relation || e.label || ''}`;
|
||||
const currentEdgeMap = Object.fromEntries((current.edges || current.links || []).map(e => [edgeKey(e), e]));
|
||||
const snapshotEdgeMap = Object.fromEntries((snapshot.edges || snapshot.links || []).map(e => [edgeKey(e), e]));
|
||||
|
||||
@@ -502,11 +551,8 @@ function graphifyDiff(cwd) {
|
||||
/**
|
||||
* Pre-flight checks for graphify build (BUILD-01, BUILD-02, D-09).
|
||||
* Does NOT invoke graphify -- returns structured JSON for the builder agent.
|
||||
*
|
||||
* @param {string} cwd - Working directory
|
||||
* @returns {object}
|
||||
*/
|
||||
function graphifyBuild(cwd) {
|
||||
function graphifyBuild(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
@@ -521,7 +567,8 @@ function graphifyBuild(cwd) {
|
||||
|
||||
// Read build timeout from config -- default 300s per D-02
|
||||
const config = safeReadJson(path.join(planningDir, 'config.json')) || {};
|
||||
const timeoutSec = (config.graphify && config.graphify.build_timeout) || 300;
|
||||
const graphifyConfig = config.graphify as Record<string, unknown> | undefined;
|
||||
const timeoutSec = (graphifyConfig && graphifyConfig.build_timeout) || 300;
|
||||
|
||||
return {
|
||||
action: 'spawn_agent',
|
||||
@@ -534,15 +581,19 @@ function graphifyBuild(cwd) {
|
||||
};
|
||||
}
|
||||
|
||||
interface SnapshotResult {
|
||||
saved: boolean;
|
||||
timestamp: string;
|
||||
node_count: number;
|
||||
edge_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a diff snapshot after successful build (D-06).
|
||||
* Reads graph.json from .planning/graphs/ and writes .last-build-snapshot.json
|
||||
* using platformWriteSync for crash safety.
|
||||
*
|
||||
* @param {string} cwd - Working directory
|
||||
* @returns {object}
|
||||
*/
|
||||
function writeSnapshot(cwd) {
|
||||
function writeSnapshot(cwd: string): SnapshotResult | { error: string } {
|
||||
const graphPath = path.join(cwd, '.planning', 'graphs', 'graph.json');
|
||||
const graph = safeReadJson(graphPath);
|
||||
if (!graph) return { error: 'Cannot write snapshot: graph.json not parseable' };
|
||||
@@ -566,7 +617,7 @@ function writeSnapshot(cwd) {
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
// Config gate
|
||||
isGraphifyEnabled,
|
||||
disabledResponse,
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* gsd2-import — Reverse migration from GSD-2 (.gsd/) to GSD v1 (.planning/)
|
||||
*
|
||||
@@ -15,23 +13,80 @@
|
||||
* - Completed slices ([x] in ROADMAP) → [x] phases in ROADMAP.md
|
||||
* - Tasks with a SUMMARY file → SUMMARY.md written
|
||||
* - Slice RESEARCH.md → phase XX-RESEARCH.md
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/gsd2-import.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only strict types are added.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { platformWriteSync } = require('./shell-command-projection.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output } = core;
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SliceInfo {
|
||||
done: boolean;
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface TaskInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
mustHaves: string[];
|
||||
plan: string | null;
|
||||
summary: string | null;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
interface Slice {
|
||||
id: string;
|
||||
title: string;
|
||||
done: boolean;
|
||||
plan: string | null;
|
||||
summary: string | null;
|
||||
research: string | null;
|
||||
context: string | null;
|
||||
tasks: TaskInfo[];
|
||||
}
|
||||
|
||||
interface Milestone {
|
||||
id: string;
|
||||
title: string;
|
||||
research: string | null;
|
||||
slices: Slice[];
|
||||
}
|
||||
|
||||
interface Gsd2Data {
|
||||
projectContent: string | null;
|
||||
requirements: string | null;
|
||||
milestones: Milestone[];
|
||||
}
|
||||
|
||||
interface PhaseMapEntry {
|
||||
milestoneId: string;
|
||||
milestoneTitle: string;
|
||||
slice: Slice;
|
||||
phaseNum: number;
|
||||
}
|
||||
|
||||
// ─── Utilities ──────────────────────────────────────────────────────────────
|
||||
|
||||
function readOptional(filePath) {
|
||||
function readOptional(filePath: string): string | null {
|
||||
try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
|
||||
}
|
||||
|
||||
function zeroPad(n, width = 2) {
|
||||
function zeroPad(n: number, width = 2): string {
|
||||
return String(n).padStart(width, '0');
|
||||
}
|
||||
|
||||
function slugify(title) {
|
||||
function slugify(title: string): string {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
@@ -41,7 +96,7 @@ function slugify(title) {
|
||||
* Find the .gsd/ directory starting from a project root.
|
||||
* Returns the absolute path or null if not found.
|
||||
*/
|
||||
function findGsd2Root(startPath) {
|
||||
function findGsd2Root(startPath: string): string | null {
|
||||
if (path.basename(startPath) === '.gsd' && fs.existsSync(startPath)) {
|
||||
return startPath;
|
||||
}
|
||||
@@ -57,8 +112,8 @@ function findGsd2Root(startPath) {
|
||||
* Each slice entry looks like:
|
||||
* - [x] **S01: Title** `risk:medium` `depends:[S00]`
|
||||
*/
|
||||
function parseSlicesFromRoadmap(content) {
|
||||
const slices = [];
|
||||
function parseSlicesFromRoadmap(content: string): SliceInfo[] {
|
||||
const slices: SliceInfo[] = [];
|
||||
const sectionMatch = content.match(/## Slices\n([\s\S]*?)(?:\n## |\n# |$)/);
|
||||
if (!sectionMatch) return slices;
|
||||
|
||||
@@ -74,7 +129,7 @@ function parseSlicesFromRoadmap(content) {
|
||||
* Parse the milestone title from the first heading in a GSD-2 ROADMAP.md.
|
||||
* Format: # M001: Title
|
||||
*/
|
||||
function parseMilestoneTitle(content) {
|
||||
function parseMilestoneTitle(content: string): string | null {
|
||||
const m = content.match(/^# \w+:\s*(.+)/m);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
@@ -83,7 +138,7 @@ function parseMilestoneTitle(content) {
|
||||
* Parse a task title from a GSD-2 T##-PLAN.md.
|
||||
* Format: # T01: Title
|
||||
*/
|
||||
function parseTaskTitle(content, fallback) {
|
||||
function parseTaskTitle(content: string, fallback: string): string {
|
||||
const m = content.match(/^# \w+:\s*(.+)/m);
|
||||
return m ? m[1].trim() : fallback;
|
||||
}
|
||||
@@ -91,7 +146,7 @@ function parseTaskTitle(content, fallback) {
|
||||
/**
|
||||
* Parse the ## Description body from a GSD-2 task plan.
|
||||
*/
|
||||
function parseTaskDescription(content) {
|
||||
function parseTaskDescription(content: string): string {
|
||||
const m = content.match(/## Description\n+([\s\S]+?)(?:\n## |\n# |$)/);
|
||||
return m ? m[1].trim() : '';
|
||||
}
|
||||
@@ -99,19 +154,19 @@ function parseTaskDescription(content) {
|
||||
/**
|
||||
* Parse ## Must-Haves items from a GSD-2 task plan.
|
||||
*/
|
||||
function parseTaskMustHaves(content) {
|
||||
function parseTaskMustHaves(content: string): string[] {
|
||||
const m = content.match(/## Must-Haves\n+([\s\S]+?)(?:\n## |\n# |$)/);
|
||||
if (!m) return [];
|
||||
return m[1].split('\n')
|
||||
.map(l => l.match(/^- \[[ x]\]\s*(.+)/))
|
||||
.filter(Boolean)
|
||||
.filter((match): match is RegExpMatchArray => match !== null)
|
||||
.map(match => match[1].trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all task plan files from a GSD-2 tasks/ directory.
|
||||
*/
|
||||
function readTasksDir(tasksDir) {
|
||||
function readTasksDir(tasksDir: string): TaskInfo[] {
|
||||
if (!fs.existsSync(tasksDir)) return [];
|
||||
|
||||
return fs.readdirSync(tasksDir)
|
||||
@@ -136,8 +191,8 @@ function readTasksDir(tasksDir) {
|
||||
/**
|
||||
* Parse a complete GSD-2 .gsd/ directory into a structured representation.
|
||||
*/
|
||||
function parseGsd2(gsdDir) {
|
||||
const data = {
|
||||
function parseGsd2(gsdDir: string): Gsd2Data {
|
||||
const data: Gsd2Data = {
|
||||
projectContent: readOptional(path.join(gsdDir, 'PROJECT.md')),
|
||||
requirements: readOptional(path.join(gsdDir, 'REQUIREMENTS.md')),
|
||||
milestones: [],
|
||||
@@ -157,7 +212,7 @@ function parseGsd2(gsdDir) {
|
||||
|
||||
const sliceInfos = roadmapContent ? parseSlicesFromRoadmap(roadmapContent) : [];
|
||||
|
||||
const slices = sliceInfos.map(info => {
|
||||
const slices: Slice[] = sliceInfos.map(info => {
|
||||
const sDir = path.join(slicesDir, info.id);
|
||||
const hasSDir = fs.existsSync(sDir);
|
||||
return {
|
||||
@@ -188,7 +243,7 @@ function parseGsd2(gsdDir) {
|
||||
/**
|
||||
* Build a GSD v1 PLAN.md from a GSD-2 task.
|
||||
*/
|
||||
function buildPlanMd(task, phasePrefix, planPrefix, phaseSlug, milestoneTitle) {
|
||||
function buildPlanMd(task: TaskInfo, phasePrefix: string, planPrefix: string, phaseSlug: string, milestoneTitle: string): string {
|
||||
const lines = [
|
||||
'---',
|
||||
`phase: "${phasePrefix}"`,
|
||||
@@ -225,7 +280,7 @@ function buildPlanMd(task, phasePrefix, planPrefix, phaseSlug, milestoneTitle) {
|
||||
* Build a GSD v1 SUMMARY.md from a GSD-2 task summary.
|
||||
* Strips the GSD-2 frontmatter and preserves the body.
|
||||
*/
|
||||
function buildSummaryMd(task, phasePrefix, planPrefix) {
|
||||
function buildSummaryMd(task: TaskInfo, phasePrefix: string, planPrefix: string): string {
|
||||
const raw = task.summary || '';
|
||||
// Strip GSD-2 frontmatter block (--- ... ---) if present
|
||||
const bodyMatch = raw.match(/^---[\s\S]*?---\n+([\s\S]*)$/);
|
||||
@@ -245,7 +300,7 @@ function buildSummaryMd(task, phasePrefix, planPrefix) {
|
||||
/**
|
||||
* Build a GSD v1 XX-CONTEXT.md from a GSD-2 slice.
|
||||
*/
|
||||
function buildContextMd(slice, phasePrefix) {
|
||||
function buildContextMd(slice: Slice, phasePrefix: string): string {
|
||||
const lines = [
|
||||
`# Phase ${phasePrefix} Context`,
|
||||
'',
|
||||
@@ -263,7 +318,7 @@ function buildContextMd(slice, phasePrefix) {
|
||||
/**
|
||||
* Build the GSD v1 ROADMAP.md with milestone-sectioned format.
|
||||
*/
|
||||
function buildRoadmapMd(milestones, phaseMap) {
|
||||
function buildRoadmapMd(milestones: Milestone[], phaseMap: PhaseMapEntry[]): string {
|
||||
const lines = ['# Roadmap', ''];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
@@ -284,7 +339,7 @@ function buildRoadmapMd(milestones, phaseMap) {
|
||||
/**
|
||||
* Build the GSD v1 STATE.md reflecting the current position in the project.
|
||||
*/
|
||||
function buildStateMd(phaseMap) {
|
||||
function buildStateMd(phaseMap: PhaseMapEntry[]): string {
|
||||
const currentEntry = phaseMap.find(p => !p.slice.done);
|
||||
const totalPhases = phaseMap.length;
|
||||
const donePhases = phaseMap.filter(p => p.slice.done).length;
|
||||
@@ -340,8 +395,8 @@ function buildStateMd(phaseMap) {
|
||||
* Convert parsed GSD-2 data into a map of relative path → file content.
|
||||
* All paths are relative to the .planning/ root.
|
||||
*/
|
||||
function buildPlanningArtifacts(gsd2Data) {
|
||||
const artifacts = new Map();
|
||||
function buildPlanningArtifacts(gsd2Data: Gsd2Data): Map<string, string> {
|
||||
const artifacts = new Map<string, string>();
|
||||
|
||||
// Passthrough files
|
||||
artifacts.set('PROJECT.md', gsd2Data.projectContent || '# Project\n\n(Migrated from GSD-2)\n');
|
||||
@@ -353,7 +408,7 @@ function buildPlanningArtifacts(gsd2Data) {
|
||||
artifacts.set('config.json', JSON.stringify({ version: 1 }, null, 2) + '\n');
|
||||
|
||||
// Build sequential phase map: flatten Milestones → Slices into numbered phases
|
||||
const phaseMap = [];
|
||||
const phaseMap: PhaseMapEntry[] = [];
|
||||
let phaseNum = 1;
|
||||
for (const milestone of gsd2Data.milestones) {
|
||||
for (const slice of milestone.slices) {
|
||||
@@ -365,8 +420,8 @@ function buildPlanningArtifacts(gsd2Data) {
|
||||
artifacts.set('ROADMAP.md', buildRoadmapMd(gsd2Data.milestones, phaseMap));
|
||||
artifacts.set('STATE.md', buildStateMd(phaseMap));
|
||||
|
||||
for (const { slice, phaseNum, milestoneTitle } of phaseMap) {
|
||||
const prefix = zeroPad(phaseNum);
|
||||
for (const { slice, phaseNum: pNum, milestoneTitle } of phaseMap) {
|
||||
const prefix = zeroPad(pNum);
|
||||
const slug = slugify(slice.title);
|
||||
const dir = `phases/${prefix}-${slug}`;
|
||||
|
||||
@@ -402,7 +457,7 @@ function buildPlanningArtifacts(gsd2Data) {
|
||||
/**
|
||||
* Format a dry-run preview string for display before writing.
|
||||
*/
|
||||
function buildPreview(gsd2Data, artifacts, projectDir) {
|
||||
function buildPreview(gsd2Data: Gsd2Data, artifacts: Map<string, string>, projectDir: string): string {
|
||||
const lines = ['Preview — files that will be created in .planning/:'];
|
||||
|
||||
for (const rel of artifacts.keys()) {
|
||||
@@ -421,8 +476,7 @@ function buildPreview(gsd2Data, artifacts, projectDir) {
|
||||
lines.push('');
|
||||
lines.push('Cannot migrate automatically:');
|
||||
lines.push(' - GSD-2 cost/token ledger (no v1 equivalent)');
|
||||
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
|
||||
lines.push(` - GSD-2 database state (rebuilt from files on first ${formatGsdSlash('health', resolveRuntime(projectDir))})`);
|
||||
lines.push(` - GSD-2 database state (rebuilt from files on first ${formatGsdSlash('health', resolveRuntime(projectDir)) as string})`);
|
||||
lines.push(' - VS Code extension state');
|
||||
|
||||
return lines.join('\n');
|
||||
@@ -433,7 +487,7 @@ function buildPreview(gsd2Data, artifacts, projectDir) {
|
||||
/**
|
||||
* Write all artifacts to the .planning/ directory.
|
||||
*/
|
||||
function writePlanningDir(artifacts, planningRoot) {
|
||||
function writePlanningDir(artifacts: Map<string, string>, planningRoot: string): void {
|
||||
for (const [rel, content] of artifacts) {
|
||||
const absPath = path.join(planningRoot, rel);
|
||||
platformWriteSync(absPath, content);
|
||||
@@ -446,9 +500,7 @@ function writePlanningDir(artifacts, planningRoot) {
|
||||
* Entry point called from gsd-tools.cjs.
|
||||
* Supports: --force, --dry-run, --path <dir>
|
||||
*/
|
||||
function cmdFromGsd2(args, cwd, raw) {
|
||||
const { output, error } = require('./core.cjs');
|
||||
|
||||
function cmdFromGsd2(args: string[], cwd: string, raw: boolean): void {
|
||||
const force = args.includes('--force');
|
||||
const dryRun = args.includes('--dry-run');
|
||||
|
||||
@@ -459,15 +511,17 @@ function cmdFromGsd2(args, cwd, raw) {
|
||||
|
||||
const gsdDir = findGsd2Root(projectDir);
|
||||
if (!gsdDir) {
|
||||
return output({ success: false, error: `No .gsd/ directory found in ${projectDir}` }, raw);
|
||||
output({ success: false, error: `No .gsd/ directory found in ${projectDir}` }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const planningRoot = path.join(path.dirname(gsdDir), '.planning');
|
||||
if (fs.existsSync(planningRoot) && !force) {
|
||||
return output({
|
||||
output({
|
||||
success: false,
|
||||
error: `.planning/ already exists at ${planningRoot}. Pass --force to overwrite.`,
|
||||
}, raw);
|
||||
}, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const gsd2Data = parseGsd2(gsdDir);
|
||||
@@ -477,21 +531,22 @@ function cmdFromGsd2(args, cwd, raw) {
|
||||
const preview = buildPreview(gsd2Data, artifacts, projectDir);
|
||||
|
||||
if (dryRun) {
|
||||
return output({ success: true, dryRun: true, preview }, raw);
|
||||
output({ success: true, dryRun: true, preview }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
writePlanningDir(artifacts, planningRoot);
|
||||
|
||||
return output({
|
||||
output({
|
||||
success: true,
|
||||
planningDir: planningRoot,
|
||||
filesWritten: artifacts.size,
|
||||
milestones: gsd2Data.milestones.length,
|
||||
preview,
|
||||
}, raw);
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
findGsd2Root,
|
||||
parseGsd2,
|
||||
buildPlanningArtifacts,
|
||||
95
src/init-command-router.cts
Normal file
95
src/init-command-router.cts
Normal 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
2231
src/init.cts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,46 +2,15 @@
|
||||
* Skill Surface Budget Module — single source of truth for which skills/agents
|
||||
* are written to the runtime config dirs (ADR-0011).
|
||||
*
|
||||
* Background: every installed `gsd-*` skill costs eager system-prompt tokens
|
||||
* because runtimes (Claude Code, opencode, etc.) enumerate skill descriptions
|
||||
* in `<available_skills>` on every turn. With 66 skills + 33 agents GSD alone
|
||||
* consumes ~60% of the default 1%-of-context skill-listing budget, causing
|
||||
* dropped skills when users stack multiple plugins (#3408).
|
||||
*
|
||||
* Profile model: three named profiles replace the old minimal/full binary:
|
||||
* - core — eight skills covering the main project loop (includes surface for ADR-0011 expand contract)
|
||||
* - standard — core + phase management and workspace skills
|
||||
* - full — all skills (previous default, '*' sentinel)
|
||||
* Profiles compose: --profile=core,audit resolves to union(closure(core), closure(audit)).
|
||||
* Back-compat aliases: --minimal / --core-only both map to --profile=core.
|
||||
*
|
||||
* This module owns:
|
||||
* - PROFILES map: named profile → base skill set (or '*' sentinel for full)
|
||||
* - loadSkillsManifest: parse requires: frontmatter from commands/gsd/*.md
|
||||
* - resolveProfile: compute transitive closure of profile base over manifest
|
||||
* - stageSkillsForProfile / stageAgentsForProfile: filesystem staging
|
||||
* - readActiveProfile / writeActiveProfile: .gsd-profile marker persistence
|
||||
* - resolveEffectiveProfile: explicit flag > .gsd-profile marker > full
|
||||
* - mostRestrictiveProfile: resolve multi-runtime disagreement to smallest set
|
||||
*
|
||||
* Companion module (Phase 2): get-shit-done/bin/lib/surface.cjs owns the
|
||||
* runtime /gsd:surface command. It reuses stageSkillsForProfile and
|
||||
* stageAgentsForProfile from this module for cluster-level enable/disable
|
||||
* without reinstall, persisting state in <runtimeConfigDir>/.gsd-surface.json.
|
||||
*
|
||||
* Legacy back-compat exports (deprecated, kept for existing callers):
|
||||
* - MINIMAL_SKILL_ALLOWLIST — derived from PROFILES.core
|
||||
* - isMinimalMode(mode) — returns true for 'minimal'
|
||||
* - shouldInstallSkill(name, mode|resolvedProfile) — overloaded
|
||||
* - stageSkillsForMode(srcDir, mode) — wraps stageSkillsForProfile
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/install-profiles.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { platformWriteSync } = require('./shell-command-projection.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile definitions
|
||||
@@ -85,8 +54,10 @@ const PROFILES = Object.freeze({
|
||||
'pause-work',
|
||||
'workspace',
|
||||
]),
|
||||
full: '*',
|
||||
});
|
||||
full: '*' as const,
|
||||
} as const);
|
||||
|
||||
type ProfileName = keyof typeof PROFILES;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest parsing
|
||||
@@ -99,11 +70,8 @@ const PROFILES = Object.freeze({
|
||||
*
|
||||
* No external YAML parser dependency — hand-parse the single line
|
||||
* since GSD enforces flow-style arrays for requires:.
|
||||
*
|
||||
* @param {string} content full file content
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function parseRequires(content) {
|
||||
function parseRequires(content: string): string[] {
|
||||
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/m);
|
||||
if (!fmMatch) return [];
|
||||
const fm = fmMatch[1];
|
||||
@@ -127,11 +95,8 @@ function parseRequires(content) {
|
||||
*
|
||||
* The caller is responsible for filtering by which agents actually exist —
|
||||
* this function returns all syntactically valid `gsd-*` matches.
|
||||
*
|
||||
* @param {string} content full file content
|
||||
* @returns {string[]} deduplicated agent stems like ['gsd-planner', 'gsd-executor']
|
||||
*/
|
||||
function parseCallsAgents(content) {
|
||||
function parseCallsAgents(content: string): string[] {
|
||||
// Match word-boundary gsd-<stem> patterns; stems are lowercase letters and hyphens.
|
||||
// We use a regex that matches `gsd-` followed by one or more lowercase-alpha-or-hyphen chars.
|
||||
// This catches `gsd-planner`, `gsd-plan-checker`, etc. in prose and code.
|
||||
@@ -146,12 +111,9 @@ function parseCallsAgents(content) {
|
||||
* Also derives calls_agents for each skill by scanning the body text for
|
||||
* `gsd-*` agent name references. Agent stems are stored under the special
|
||||
* key `_calls_agents_<stem>` so they don't conflict with skill stems.
|
||||
*
|
||||
* @param {string} commandsDir absolute path to commands/gsd/
|
||||
* @returns {Map<string, string[]>} stem → [required stem, ...] plus _calls_agents_<stem> entries
|
||||
*/
|
||||
function loadSkillsManifest(commandsDir) {
|
||||
const manifest = new Map();
|
||||
function loadSkillsManifest(commandsDir: string): Map<string, string[]> {
|
||||
const manifest = new Map<string, string[]>();
|
||||
if (!fs.existsSync(commandsDir)) return manifest;
|
||||
const entries = fs.readdirSync(commandsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
@@ -178,16 +140,12 @@ function loadSkillsManifest(commandsDir) {
|
||||
|
||||
/**
|
||||
* Compute the transitive closure of a set of skill stems over the manifest.
|
||||
*
|
||||
* @param {Iterable<string>} base initial set of stems
|
||||
* @param {Map<string, string[]>} manifest skill → [required stems]
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function computeClosure(base, manifest) {
|
||||
function computeClosure(base: Iterable<string>, manifest: Map<string, string[]>): Set<string> {
|
||||
const closed = new Set(base);
|
||||
const queue = [...closed];
|
||||
while (queue.length > 0) {
|
||||
const stem = queue.pop();
|
||||
const stem = queue.pop()!;
|
||||
const deps = manifest.get(stem) || [];
|
||||
for (const dep of deps) {
|
||||
if (!closed.has(dep)) {
|
||||
@@ -199,17 +157,23 @@ function computeClosure(base, manifest) {
|
||||
return closed;
|
||||
}
|
||||
|
||||
interface ResolvedProfile {
|
||||
name: string;
|
||||
skills: Set<string> | '*';
|
||||
agents: Set<string>;
|
||||
}
|
||||
|
||||
interface ResolveProfileOpts {
|
||||
modes?: string[];
|
||||
manifest?: Map<string, string[]>;
|
||||
_profilesOverride?: Record<string, string | readonly string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a profile (or composed profiles) to a typed result object.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string[]} [opts.modes=['full']] profile names to resolve and union
|
||||
* @param {Map<string, string[]>} [opts.manifest] parsed requires: graph
|
||||
* @param {object} [opts._profilesOverride] for testing — override PROFILES
|
||||
* @returns {{ name: string, skills: Set<string>|'*', agents: Set<string> }}
|
||||
*/
|
||||
function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
|
||||
const profiles = _profilesOverride || PROFILES;
|
||||
function resolveProfile({ modes, manifest, _profilesOverride }: ResolveProfileOpts = {}): ResolvedProfile {
|
||||
const profiles: Record<string, string | readonly string[]> = _profilesOverride || PROFILES;
|
||||
const activeModes = (modes && modes.length > 0) ? modes : ['full'];
|
||||
const normalizedModes = activeModes
|
||||
.flatMap((mode) => String(mode).split(','))
|
||||
@@ -228,8 +192,8 @@ function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
|
||||
return { name: 'full', skills: '*', agents: new Set() };
|
||||
}
|
||||
|
||||
const man = manifest || new Map();
|
||||
const unionSkills = new Set();
|
||||
const man = manifest || new Map<string, string[]>();
|
||||
const unionSkills = new Set<string>();
|
||||
|
||||
for (const mode of validModes) {
|
||||
const base = profiles[mode];
|
||||
@@ -237,14 +201,14 @@ function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
|
||||
// This profile is full — sentinel short-circuit
|
||||
return { name: 'full', skills: '*', agents: new Set() };
|
||||
}
|
||||
const closure = computeClosure(base, man);
|
||||
const closure = computeClosure(base as Iterable<string>, man);
|
||||
for (const s of closure) unionSkills.add(s);
|
||||
}
|
||||
|
||||
// Derive agents: union of all agent names referenced in the body text of
|
||||
// every skill in unionSkills. Agent names are stored in the manifest under
|
||||
// _calls_agents_<stem> keys (populated by loadSkillsManifest).
|
||||
const unionAgents = new Set();
|
||||
const unionAgents = new Set<string>();
|
||||
for (const skillStem of unionSkills) {
|
||||
const agentRefs = man.get(`_calls_agents_${skillStem}`) || [];
|
||||
for (const agentStem of agentRefs) {
|
||||
@@ -264,10 +228,10 @@ function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
|
||||
// 13 runtime dispatch sites in install.js can each call stageSkillsForMode,
|
||||
// so accumulating them in a single set avoids leaks without forcing each
|
||||
// site to track its own cleanup handle.
|
||||
const STAGED_DIRS = new Set();
|
||||
const STAGED_DIRS = new Set<string>();
|
||||
let exitHandlerRegistered = false;
|
||||
|
||||
function cleanupStagedSkills() {
|
||||
function cleanupStagedSkills(): void {
|
||||
for (const dir of STAGED_DIRS) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
@@ -283,9 +247,9 @@ function cleanupStagedSkills() {
|
||||
// 'exit' event. `process.on('exit')` does NOT fire on these — an installer
|
||||
// is exactly the kind of process users abort mid-run, so without explicit
|
||||
// signal handling Ctrl+C would leave staged tmp dirs behind.
|
||||
const CLEANUP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
||||
const CLEANUP_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
||||
|
||||
function ensureExitCleanup() {
|
||||
function ensureExitCleanup(): void {
|
||||
if (exitHandlerRegistered) return;
|
||||
exitHandlerRegistered = true;
|
||||
process.on('exit', cleanupStagedSkills);
|
||||
@@ -303,12 +267,8 @@ function ensureExitCleanup() {
|
||||
/**
|
||||
* Stage a filtered copy of commands/gsd for a resolved profile.
|
||||
* In full mode (skills === '*') returns srcDir unchanged (no-op).
|
||||
*
|
||||
* @param {string} srcDir absolute path to commands/gsd
|
||||
* @param {{ skills: Set<string>|'*' }} resolvedProfile
|
||||
* @returns {string} path to staged dir (or srcDir for full)
|
||||
*/
|
||||
function stageSkillsForProfile(srcDir, resolvedProfile) {
|
||||
function stageSkillsForProfile(srcDir: string, resolvedProfile: ResolvedProfile): string {
|
||||
if (resolvedProfile.skills === '*') return srcDir;
|
||||
if (!fs.existsSync(srcDir)) return srcDir;
|
||||
|
||||
@@ -319,14 +279,14 @@ function stageSkillsForProfile(srcDir, resolvedProfile) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
if (!resolvedProfile.skills.has(stem)) continue;
|
||||
if (!(resolvedProfile.skills).has(stem)) continue;
|
||||
fs.copyFileSync(
|
||||
path.join(srcDir, entry.name),
|
||||
path.join(stageDir, entry.name),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
@@ -340,12 +300,8 @@ function stageSkillsForProfile(srcDir, resolvedProfile) {
|
||||
* For tiered profiles, copies only agents whose full stem (e.g. 'gsd-planner')
|
||||
* is in resolvedProfile.agents — which is populated by resolveProfile() from
|
||||
* the _calls_agents_* entries in the manifest.
|
||||
*
|
||||
* @param {string} srcAgentsDir absolute path to agents/
|
||||
* @param {{ agents: Set<string>, skills: Set<string>|'*' }} resolvedProfile
|
||||
* @returns {string} path to staged dir (or srcAgentsDir for full)
|
||||
*/
|
||||
function stageAgentsForProfile(srcAgentsDir, resolvedProfile) {
|
||||
function stageAgentsForProfile(srcAgentsDir: string, resolvedProfile: ResolvedProfile): string {
|
||||
if (resolvedProfile.skills === '*') return srcAgentsDir;
|
||||
if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir;
|
||||
|
||||
@@ -367,7 +323,7 @@ function stageAgentsForProfile(srcAgentsDir, resolvedProfile) {
|
||||
}
|
||||
// If agents is empty Set, we produce an empty stageDir (no agents for this profile)
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
@@ -375,7 +331,12 @@ function stageAgentsForProfile(srcAgentsDir, resolvedProfile) {
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix) {
|
||||
function stageSkillsForRuntimeAsSkills(
|
||||
srcCommandsDir: string,
|
||||
resolvedProfile: ResolvedProfile,
|
||||
converter: (content: string, skillName: string) => string,
|
||||
prefix: string,
|
||||
): string {
|
||||
if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-skills-'));
|
||||
@@ -385,7 +346,7 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
if (resolvedProfile.skills !== '*' && !resolvedProfile.skills.has(stem)) continue;
|
||||
if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem)) continue;
|
||||
const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
|
||||
const skillName = `${prefix}${stem}`;
|
||||
const converted = converter(content, skillName);
|
||||
@@ -394,7 +355,7 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
@@ -410,11 +371,8 @@ const PROFILE_MARKER_NAME = '.gsd-profile';
|
||||
|
||||
/**
|
||||
* Read the active profile from a runtime config directory.
|
||||
*
|
||||
* @param {string} runtimeConfigDir absolute path (e.g. ~/.claude/skills)
|
||||
* @returns {string|null} profile name (e.g. 'core', 'standard', 'core,audit') or null
|
||||
*/
|
||||
function readActiveProfile(runtimeConfigDir) {
|
||||
function readActiveProfile(runtimeConfigDir: string): string | null {
|
||||
const markerPath = path.join(runtimeConfigDir, PROFILE_MARKER_NAME);
|
||||
try {
|
||||
const raw = fs.readFileSync(markerPath, 'utf8').trim();
|
||||
@@ -429,11 +387,8 @@ function readActiveProfile(runtimeConfigDir) {
|
||||
|
||||
/**
|
||||
* Persist the active profile to a runtime config directory.
|
||||
*
|
||||
* @param {string} runtimeConfigDir absolute path (e.g. ~/.claude/skills)
|
||||
* @param {string} profileName e.g. 'core', 'standard', 'full'
|
||||
*/
|
||||
function writeActiveProfile(runtimeConfigDir, profileName) {
|
||||
function writeActiveProfile(runtimeConfigDir: string, profileName: string): void {
|
||||
platformWriteSync(path.join(runtimeConfigDir, PROFILE_MARKER_NAME), profileName + '\n');
|
||||
}
|
||||
|
||||
@@ -445,7 +400,7 @@ function writeActiveProfile(runtimeConfigDir, profileName) {
|
||||
* Rank ordering for profiles (lower index = more restrictive / smaller skill set).
|
||||
* Unknown profiles default to the permissive end (treated as 'full').
|
||||
*/
|
||||
const PROFILE_RANK = Object.freeze(['core', 'standard', 'full']);
|
||||
const PROFILE_RANK = Object.freeze(['core', 'standard', 'full'] as const);
|
||||
|
||||
/**
|
||||
* Given an array of profile names (one per runtime), return the most-restrictive
|
||||
@@ -454,17 +409,14 @@ const PROFILE_RANK = Object.freeze(['core', 'standard', 'full']);
|
||||
* Ordering (most to least restrictive): core < standard < full.
|
||||
* Composed profiles (e.g. 'core,audit') and unknown profiles are treated as
|
||||
* 'full' for this comparison.
|
||||
*
|
||||
* @param {string[]} profileNames
|
||||
* @returns {string}
|
||||
*/
|
||||
function mostRestrictiveProfile(profileNames) {
|
||||
function mostRestrictiveProfile(profileNames: string[]): string {
|
||||
if (!profileNames || profileNames.length === 0) return 'full';
|
||||
// Initialize with the least-restrictive rank (one past the end of PROFILE_RANK)
|
||||
let bestRank = PROFILE_RANK.length;
|
||||
let bestRank: number = PROFILE_RANK.length;
|
||||
let bestName = 'full';
|
||||
for (const name of profileNames) {
|
||||
const rank = PROFILE_RANK.indexOf(name);
|
||||
const rank = PROFILE_RANK.indexOf(name as ProfileName);
|
||||
// Unknown/composed profiles are treated as the permissive 'full' rank.
|
||||
const effectiveRank = rank === -1 ? PROFILE_RANK.indexOf('full') : rank;
|
||||
if (effectiveRank < bestRank) {
|
||||
@@ -475,6 +427,11 @@ function mostRestrictiveProfile(profileNames) {
|
||||
return bestName;
|
||||
}
|
||||
|
||||
interface ResolveEffectiveProfileOpts {
|
||||
requestedProfileName: string | null;
|
||||
targetDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective profile name for an install() run.
|
||||
*
|
||||
@@ -482,17 +439,8 @@ function mostRestrictiveProfile(profileNames) {
|
||||
* 1. Explicit flag (requestedProfileName != null) → use it as-is.
|
||||
* 2. Marker exists in targetDir and is not 'full' → use marker.
|
||||
* 3. Else → 'full' (back-compat for fresh non-interactive installs).
|
||||
*
|
||||
* This is the single source-of-truth for the "which profile should this
|
||||
* install() invocation use?" question. Extracted so it can be unit-tested
|
||||
* independently of the bin/install.js megafile.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string|null} opts.requestedProfileName explicit flag value (or null)
|
||||
* @param {string} opts.targetDir runtime config dir (e.g. ~/.claude)
|
||||
* @returns {string} profile name, e.g. 'core', 'standard', 'full'
|
||||
*/
|
||||
function resolveEffectiveProfile({ requestedProfileName, targetDir }) {
|
||||
function resolveEffectiveProfile({ requestedProfileName, targetDir }: ResolveEffectiveProfileOpts): string {
|
||||
// 1. Explicit flag overrides everything
|
||||
if (requestedProfileName != null) return requestedProfileName;
|
||||
// 2. Marker-driven (gsd update path)
|
||||
@@ -517,7 +465,7 @@ const MINIMAL_ALLOWLIST_SET = new Set(MINIMAL_SKILL_ALLOWLIST);
|
||||
/**
|
||||
* @deprecated Use resolveProfile({ modes: ['core'] }) instead.
|
||||
*/
|
||||
function isMinimalMode(mode) {
|
||||
function isMinimalMode(mode: string): boolean {
|
||||
return mode === 'minimal' || mode === 'core-only';
|
||||
}
|
||||
|
||||
@@ -528,7 +476,7 @@ function isMinimalMode(mode) {
|
||||
*
|
||||
* @deprecated String-mode form; use resolvedProfile object form instead.
|
||||
*/
|
||||
function shouldInstallSkill(skillBaseName, resolvedProfileOrMode) {
|
||||
function shouldInstallSkill(skillBaseName: string, resolvedProfileOrMode: ResolvedProfile | string): boolean {
|
||||
if (typeof resolvedProfileOrMode === 'object' && resolvedProfileOrMode !== null) {
|
||||
const { skills } = resolvedProfileOrMode;
|
||||
if (skills === '*') return true;
|
||||
@@ -545,11 +493,8 @@ function shouldInstallSkill(skillBaseName, resolvedProfileOrMode) {
|
||||
* Back-compat wrapper: maps 'minimal' → core profile, 'full' → full.
|
||||
*
|
||||
* @deprecated Use stageSkillsForProfile with a resolved profile instead.
|
||||
* @param {string} srcDir absolute path to commands/gsd
|
||||
* @param {string} mode 'full' | 'minimal'
|
||||
* @returns {string} path to use (original or staged tmp)
|
||||
*/
|
||||
function stageSkillsForMode(srcDir, mode) {
|
||||
function stageSkillsForMode(srcDir: string, mode: string): string {
|
||||
if (!isMinimalMode(mode)) return srcDir;
|
||||
if (!fs.existsSync(srcDir)) return srcDir;
|
||||
|
||||
@@ -567,7 +512,7 @@ function stageSkillsForMode(srcDir, mode) {
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
@@ -579,7 +524,7 @@ function stageSkillsForMode(srcDir, mode) {
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
// New profile API (ADR-0011)
|
||||
PROFILES,
|
||||
PROFILE_RANK,
|
||||
136
src/installer-migration-authoring.cts
Normal file
136
src/installer-migration-authoring.cts
Normal 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[];
|
||||
}
|
||||
@@ -1,21 +1,120 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Installer migration report utilities (ADR-457 build-at-publish: the
|
||||
* hand-written bin/lib/installer-migration-report.cjs collapsed to a TypeScript
|
||||
* source of truth). Behaviour is preserved byte-for-behaviour from the prior
|
||||
* hand-written .cjs; only types are added.
|
||||
*
|
||||
* Resolution environment variable surface for #3541 — when the installer
|
||||
* runs without a TTY (typical /gsd:update path via Claude Code or any
|
||||
* scripted update), prompt-user migration actions cannot be answered
|
||||
* interactively. Classification-based defaults apply; anything else falls
|
||||
* through to the hard assertion with a grouped, actionable error message.
|
||||
*
|
||||
* docs/installer-migrations.md#prompt-user-resolution for the spec.
|
||||
*/
|
||||
|
||||
// Resolution environment variable surface for #3541 — when the installer
|
||||
// runs without a TTY (typical /gsd:update path via Claude Code or any
|
||||
// scripted update), prompt-user migration actions cannot be answered
|
||||
// interactively. We resolve them by classification:
|
||||
// - Stale SDK build artifacts (get-shit-done/sdk/{dist,src}/gsd-*):
|
||||
// default `remove`. Fresh install supplies replacements.
|
||||
// - User-facing skill anchors (skills/gsd-*/SKILL.md): default `keep`.
|
||||
// User-owned content is preserved.
|
||||
// Anything else: fall through to the hard assertion with an improved,
|
||||
// grouped, actionable error message.
|
||||
export const RESOLUTION_ENV_VAR = 'GSD_INSTALLER_MIGRATION_RESOLVE';
|
||||
const VALID_CHOICES: ReadonlyArray<string> = ['keep', 'remove'];
|
||||
|
||||
// #3628: explicit whitelist of bundled hook files shipped in the npm
|
||||
// distribution under `hooks/`. The classifier-based auto-removal of these
|
||||
// files at first-time-baseline scan (added in #3610) is restricted to this
|
||||
// set — a shape regex like `^hooks/gsd-[^/]+\.(?:js|sh|cjs|mjs)$` also
|
||||
// matches user-authored custom hooks and retired bundled hooks from prior
|
||||
// versions, and auto-removing those is silent data loss.
|
||||
//
|
||||
// docs/installer-migrations.md#prompt-user-resolution for the spec.
|
||||
const RESOLUTION_ENV_VAR = 'GSD_INSTALLER_MIGRATION_RESOLVE';
|
||||
const VALID_CHOICES = ['keep', 'remove'];
|
||||
// The bug-3628 regression guard asserts this Set stays aligned with the
|
||||
// on-disk `hooks/` directory in both directions: whitelist-but-missing
|
||||
// AND shipped-but-not-whitelisted both fail CI.
|
||||
export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set([
|
||||
'hooks/gsd-check-update-worker.js',
|
||||
'hooks/gsd-check-update.js',
|
||||
'hooks/gsd-context-monitor.js',
|
||||
'hooks/gsd-graphify-update.sh',
|
||||
'hooks/gsd-phase-boundary.sh',
|
||||
'hooks/gsd-prompt-guard.js',
|
||||
'hooks/gsd-read-guard.js',
|
||||
'hooks/gsd-read-injection-scanner.js',
|
||||
'hooks/gsd-session-state.sh',
|
||||
'hooks/gsd-statusline.js',
|
||||
'hooks/gsd-update-banner.js',
|
||||
'hooks/gsd-validate-commit.sh',
|
||||
'hooks/gsd-workflow-guard.js',
|
||||
'hooks/gsd-worktree-path-guard.js',
|
||||
]));
|
||||
|
||||
function installerMigrationActionLabel(action) {
|
||||
// ── Internal action types ─────────────────────────────────────────────────────
|
||||
|
||||
interface MigrationAction {
|
||||
type: string;
|
||||
relPath?: string;
|
||||
reason?: string;
|
||||
migrationId?: string;
|
||||
migrationChecksum?: string;
|
||||
classification?: string;
|
||||
originalHash?: string | null;
|
||||
currentHash?: string | null;
|
||||
requestedType?: string;
|
||||
backupRelPath?: string | null;
|
||||
choices?: string[];
|
||||
deleteIfEmpty?: boolean;
|
||||
count?: number;
|
||||
actions?: MigrationAction[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface MigrationPlan {
|
||||
actions?: MigrationAction[];
|
||||
blocked?: MigrationAction[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface MigrationResult {
|
||||
blocked?: MigrationAction[];
|
||||
plan?: MigrationPlan;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SummaryRow {
|
||||
label: string;
|
||||
relPath: string;
|
||||
reason: string;
|
||||
action: MigrationAction;
|
||||
}
|
||||
|
||||
interface SummarizeResult {
|
||||
hasReportableActions: boolean;
|
||||
blocked: MigrationAction[];
|
||||
rows: (SummaryRow | null)[];
|
||||
}
|
||||
|
||||
interface Resolution {
|
||||
relPath: string | undefined;
|
||||
category: string;
|
||||
choice: string;
|
||||
reason: string | undefined;
|
||||
resolvedActionType: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface ResolvePromptsResult {
|
||||
result: MigrationResult;
|
||||
resolutions: Resolution[];
|
||||
}
|
||||
|
||||
interface ClassifyResult {
|
||||
category: string;
|
||||
choice: string;
|
||||
}
|
||||
|
||||
interface ResolveOptions {
|
||||
isTty?: boolean;
|
||||
env?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function installerMigrationActionLabel(action: MigrationAction | null | undefined): string {
|
||||
if (!action || !action.type) return 'skipped';
|
||||
if (action.type === 'backup-and-remove') return 'backed up and removed';
|
||||
if (action.type === 'remove-managed') return 'removed';
|
||||
@@ -27,18 +126,18 @@ function installerMigrationActionLabel(action) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
function blockedInstallerMigrationActions(result) {
|
||||
function blockedInstallerMigrationActions(result: MigrationResult | null | undefined): MigrationAction[] {
|
||||
if (result && Array.isArray(result.blocked)) return result.blocked;
|
||||
const plan = result && result.plan;
|
||||
if (plan && Array.isArray(plan.blocked)) return plan.blocked;
|
||||
return [];
|
||||
}
|
||||
|
||||
function baselineSummaryLabel(count, noun) {
|
||||
function baselineSummaryLabel(count: number, noun: string): string {
|
||||
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function baselineSummaryRow(type, actions) {
|
||||
function baselineSummaryRow(type: string, actions: MigrationAction[]): SummaryRow {
|
||||
const count = actions.length;
|
||||
if (type === 'record-baseline') {
|
||||
return {
|
||||
@@ -56,14 +155,14 @@ function baselineSummaryRow(type, actions) {
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeInstallerMigrationResult(result) {
|
||||
export function summarizeInstallerMigrationResult(result: MigrationResult | null | undefined): SummarizeResult {
|
||||
const plan = result && result.plan;
|
||||
const actions = plan && Array.isArray(plan.actions) ? plan.actions : [];
|
||||
const actions: MigrationAction[] = plan && Array.isArray(plan.actions) ? plan.actions : [];
|
||||
const blocked = blockedInstallerMigrationActions(result);
|
||||
const blockedSet = new Set(blocked);
|
||||
const rows = [];
|
||||
const baselineIndexes = new Map();
|
||||
const baselineActions = new Map();
|
||||
const rows: (SummaryRow | null)[] = [];
|
||||
const baselineIndexes = new Map<string, number>();
|
||||
const baselineActions = new Map<string, MigrationAction[]>();
|
||||
|
||||
for (const action of actions) {
|
||||
const type = action && action.type;
|
||||
@@ -73,13 +172,13 @@ function summarizeInstallerMigrationResult(result) {
|
||||
baselineIndexes.set(type, rows.length);
|
||||
rows.push(null);
|
||||
}
|
||||
baselineActions.get(type).push(action);
|
||||
baselineActions.get(type)!.push(action);
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
label: blockedSet.has(action) ? 'blocked' : installerMigrationActionLabel(action),
|
||||
relPath: action.relPath,
|
||||
relPath: action.relPath ?? '',
|
||||
reason: action.reason || '',
|
||||
action,
|
||||
});
|
||||
@@ -88,7 +187,7 @@ function summarizeInstallerMigrationResult(result) {
|
||||
// Phase 4 requires action reporting without flooding first-time baseline installs:
|
||||
// docs/installer-migrations.md#phase-4-installupdate-integration.
|
||||
for (const [type, baselineRows] of baselineActions) {
|
||||
rows[baselineIndexes.get(type)] = baselineSummaryRow(type, baselineRows);
|
||||
rows[baselineIndexes.get(type)!] = baselineSummaryRow(type, baselineRows);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -98,33 +197,6 @@ function summarizeInstallerMigrationResult(result) {
|
||||
};
|
||||
}
|
||||
|
||||
// #3628: explicit whitelist of bundled hook files shipped in the npm
|
||||
// distribution under `hooks/`. The classifier-based auto-removal of these
|
||||
// files at first-time-baseline scan (added in #3610) is restricted to this
|
||||
// set — a shape regex like `^hooks/gsd-[^/]+\.(?:js|sh|cjs|mjs)$` also
|
||||
// matches user-authored custom hooks and retired bundled hooks from prior
|
||||
// versions, and auto-removing those is silent data loss.
|
||||
//
|
||||
// The bug-3628 regression guard asserts this Set stays aligned with the
|
||||
// on-disk `hooks/` directory in both directions: whitelist-but-missing
|
||||
// AND shipped-but-not-whitelisted both fail CI.
|
||||
const BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
|
||||
'hooks/gsd-check-update-worker.js',
|
||||
'hooks/gsd-check-update.js',
|
||||
'hooks/gsd-context-monitor.js',
|
||||
'hooks/gsd-graphify-update.sh',
|
||||
'hooks/gsd-phase-boundary.sh',
|
||||
'hooks/gsd-prompt-guard.js',
|
||||
'hooks/gsd-read-guard.js',
|
||||
'hooks/gsd-read-injection-scanner.js',
|
||||
'hooks/gsd-session-state.sh',
|
||||
'hooks/gsd-statusline.js',
|
||||
'hooks/gsd-update-banner.js',
|
||||
'hooks/gsd-validate-commit.sh',
|
||||
'hooks/gsd-workflow-guard.js',
|
||||
'hooks/gsd-worktree-path-guard.js',
|
||||
]));
|
||||
|
||||
// Classify a blocked prompt-user action into one of the safe-default
|
||||
// categories. Returns null when no safe default applies — caller must
|
||||
// fall back to the hard assertion / interactive prompt for those.
|
||||
@@ -133,7 +205,7 @@ const BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
|
||||
// and are regenerated on every install, so removing them is lossless.
|
||||
// User-facing skill anchors are the .md files that surface as commands
|
||||
// to the user — these are user-owned and must be kept.
|
||||
function classifyPromptUserAction(action) {
|
||||
export function classifyPromptUserAction(action: MigrationAction): ClassifyResult | null {
|
||||
const relPath = action && action.relPath;
|
||||
if (typeof relPath !== 'string' || !relPath) return null;
|
||||
if (/^get-shit-done\/sdk\/(dist|src)\//.test(relPath)) {
|
||||
@@ -159,8 +231,9 @@ function classifyPromptUserAction(action) {
|
||||
// `keep` → baseline-preserve-user (idempotent — already on disk).
|
||||
// `remove` → backup-and-remove (safe: keeps a rollback copy in the
|
||||
// migration journal under gsd-migration-journal/<runId>-backups/).
|
||||
function materializeResolution(action, choice) {
|
||||
const base = {
|
||||
function materializeResolution(action: MigrationAction, choice: string): MigrationAction {
|
||||
const base: MigrationAction = {
|
||||
type: '', // overridden in each return branch below
|
||||
migrationId: action.migrationId,
|
||||
migrationChecksum: action.migrationChecksum,
|
||||
relPath: action.relPath,
|
||||
@@ -177,13 +250,13 @@ function materializeResolution(action, choice) {
|
||||
return { ...base, type: 'backup-and-remove', backupRelPath: null };
|
||||
}
|
||||
|
||||
function normalizeResolutionChoice(rawValue) {
|
||||
function normalizeResolutionChoice(rawValue: unknown): string | null {
|
||||
if (typeof rawValue !== 'string') return null;
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
return VALID_CHOICES.includes(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function actionSupportsChoice(action, choice) {
|
||||
function actionSupportsChoice(action: MigrationAction, choice: string): boolean {
|
||||
if (!action || !choice) return false;
|
||||
if (!Array.isArray(action.choices) || action.choices.length === 0) {
|
||||
return VALID_CHOICES.includes(choice);
|
||||
@@ -199,7 +272,10 @@ function actionSupportsChoice(action, choice) {
|
||||
// could NOT be safely defaulted (caller must still handle those).
|
||||
// Returns { result, resolutions } where `resolutions` is the structured
|
||||
// log of every defaulted resolution.
|
||||
function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
|
||||
export function resolveInstallerMigrationPromptsForNonTty(
|
||||
result: MigrationResult,
|
||||
options: ResolveOptions = {},
|
||||
): ResolvePromptsResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return { result, resolutions: [] };
|
||||
}
|
||||
@@ -215,19 +291,19 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
|
||||
return { result, resolutions: [] };
|
||||
}
|
||||
|
||||
const env =
|
||||
const env: Record<string, string | undefined> =
|
||||
options && options.env && typeof options.env === 'object'
|
||||
? options.env
|
||||
: process.env;
|
||||
const envChoice = normalizeResolutionChoice(env && env[RESOLUTION_ENV_VAR]);
|
||||
const resolutions = [];
|
||||
const unresolved = [];
|
||||
const resolutions: Resolution[] = [];
|
||||
const unresolved: MigrationAction[] = [];
|
||||
|
||||
for (const action of blocked) {
|
||||
if (action && action.type === 'prompt-user') {
|
||||
let category = null;
|
||||
let choice = null;
|
||||
let source = null;
|
||||
let category: string | null = null;
|
||||
let choice: string | null = null;
|
||||
let source: string | null = null;
|
||||
if (envChoice && actionSupportsChoice(action, envChoice)) {
|
||||
category = 'operator-override';
|
||||
choice = envChoice;
|
||||
@@ -257,11 +333,11 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
|
||||
}
|
||||
resolutions.push({
|
||||
relPath: action.relPath,
|
||||
category,
|
||||
choice,
|
||||
category: category ?? '',
|
||||
choice: choice ?? '',
|
||||
reason: action.reason,
|
||||
resolvedActionType: resolved.type,
|
||||
source,
|
||||
source: source ?? '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -285,18 +361,18 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
|
||||
// Group blocked prompt-user actions by their `reason` so the operator
|
||||
// sees one summary line per cause instead of N path lines for the
|
||||
// same underlying issue.
|
||||
function groupBlockedByReason(blocked) {
|
||||
const byReason = new Map();
|
||||
function groupBlockedByReason(blocked: MigrationAction[]): Map<string, MigrationAction[]> {
|
||||
const byReason = new Map<string, MigrationAction[]>();
|
||||
for (const action of blocked) {
|
||||
const reason = (action && action.reason) || 'no reason given';
|
||||
if (!byReason.has(reason)) byReason.set(reason, []);
|
||||
byReason.get(reason).push(action);
|
||||
byReason.get(reason)!.push(action);
|
||||
}
|
||||
return byReason;
|
||||
}
|
||||
|
||||
function describeChoicesForActions(blocked) {
|
||||
const choiceSet = new Set();
|
||||
function describeChoicesForActions(blocked: MigrationAction[]): string[] {
|
||||
const choiceSet = new Set<string>();
|
||||
for (const action of blocked) {
|
||||
if (action && Array.isArray(action.choices)) {
|
||||
for (const choice of action.choices) choiceSet.add(choice);
|
||||
@@ -308,12 +384,12 @@ function describeChoicesForActions(blocked) {
|
||||
return [...choiceSet];
|
||||
}
|
||||
|
||||
function buildBlockedErrorMessage(blocked) {
|
||||
function buildBlockedErrorMessage(blocked: MigrationAction[]): string {
|
||||
const byReason = groupBlockedByReason(blocked);
|
||||
const totalFiles = blocked.length;
|
||||
const choices = describeChoicesForActions(blocked);
|
||||
|
||||
const lines = [
|
||||
const lines: string[] = [
|
||||
`installer migration blocked pending user choice: ${totalFiles} file${totalFiles === 1 ? '' : 's'} need a decision`,
|
||||
` choices: [${choices.join(', ')}]`,
|
||||
];
|
||||
@@ -334,22 +410,14 @@ function buildBlockedErrorMessage(blocked) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function assertInstallerMigrationsUnblocked(result) {
|
||||
export function assertInstallerMigrationsUnblocked(result: MigrationResult | null | undefined): void {
|
||||
const blocked = blockedInstallerMigrationActions(result);
|
||||
if (blocked.length === 0) return;
|
||||
const message = buildBlockedErrorMessage(blocked);
|
||||
const error = new Error(message);
|
||||
error.blocked = blocked;
|
||||
error.blockedByReason = Object.fromEntries(groupBlockedByReason(blocked));
|
||||
error.resolutionEnvVar = RESOLUTION_ENV_VAR;
|
||||
const error = Object.assign(new Error(message), {
|
||||
blocked,
|
||||
blockedByReason: Object.fromEntries(groupBlockedByReason(blocked)),
|
||||
resolutionEnvVar: RESOLUTION_ENV_VAR,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESOLUTION_ENV_VAR,
|
||||
BUNDLED_GSD_HOOK_FILES,
|
||||
assertInstallerMigrationsUnblocked,
|
||||
classifyPromptUserAction,
|
||||
resolveInstallerMigrationPromptsForNonTty,
|
||||
summarizeInstallerMigrationResult,
|
||||
};
|
||||
@@ -1,14 +1,23 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Installer migrations engine — plan, apply, and track filesystem-mutation
|
||||
* migrations for GSD runtime config directories.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/installer-migrations.cjs
|
||||
* collapsed to a TypeScript source of truth. Behaviour is preserved
|
||||
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const {
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
validateInstallerMigrationActions,
|
||||
validateInstallerMigrationRecord,
|
||||
} = require('./installer-migration-authoring.cjs');
|
||||
const { platformWriteSync } = require('./shell-command-projection.cjs');
|
||||
const { realClock } = require('./clock.cjs');
|
||||
type MigrationRecord,
|
||||
type MigrationAction,
|
||||
} from './installer-migration-authoring.cjs';
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
import { realClock, type Clock } from './clock.cjs';
|
||||
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
const INSTALL_STATE_NAME = 'gsd-install-state.json';
|
||||
@@ -17,7 +26,7 @@ const DEFAULT_MIGRATIONS_DIR = path.join(__dirname, 'installer-migrations');
|
||||
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
|
||||
const STRICT_JSON = Symbol('strict-json');
|
||||
|
||||
function sha256File(filePath) {
|
||||
function sha256File(filePath: string): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
@@ -33,50 +42,64 @@ function sha256File(filePath) {
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function sha256Text(value) {
|
||||
function sha256Text(value: string): string {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function readJsonIfPresent(filePath, fallback) {
|
||||
function readJsonIfPresent(filePath: string, fallback: unknown): unknown {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
if (fallback === STRICT_JSON) {
|
||||
throw new Error(`invalid installer migration state JSON: ${filePath}: ${error.message}`);
|
||||
throw new Error(`invalid installer migration state JSON: ${filePath}: ${(error as Error).message}`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function readInstallManifest(configDir) {
|
||||
interface InstallManifest {
|
||||
version: string | null;
|
||||
timestamp: string | null;
|
||||
mode: string | null;
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
function readInstallManifest(configDir: string): InstallManifest {
|
||||
const manifest = readJsonIfPresent(path.join(configDir, MANIFEST_NAME), null);
|
||||
if (!manifest || typeof manifest !== 'object') {
|
||||
return { version: null, timestamp: null, mode: null, files: {} };
|
||||
}
|
||||
const m = manifest as Record<string, unknown>;
|
||||
return {
|
||||
version: manifest.version || null,
|
||||
timestamp: manifest.timestamp || null,
|
||||
mode: manifest.mode || null,
|
||||
files: manifest.files && typeof manifest.files === 'object' ? manifest.files : {},
|
||||
version: typeof m.version === 'string' ? m.version : null,
|
||||
timestamp: typeof m.timestamp === 'string' ? m.timestamp : null,
|
||||
mode: typeof m.mode === 'string' ? m.mode : null,
|
||||
files: m.files && typeof m.files === 'object' ? m.files as Record<string, string> : {},
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallState(configDir) {
|
||||
interface InstallState {
|
||||
schemaVersion: number;
|
||||
appliedMigrations: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function readInstallState(configDir: string): InstallState {
|
||||
const state = readJsonIfPresent(path.join(configDir, INSTALL_STATE_NAME), STRICT_JSON);
|
||||
if (!state || typeof state !== 'object') {
|
||||
return { schemaVersion: 1, appliedMigrations: [] };
|
||||
}
|
||||
const s = state as Record<string, unknown>;
|
||||
return {
|
||||
schemaVersion: state.schemaVersion || 1,
|
||||
appliedMigrations: Array.isArray(state.appliedMigrations) ? state.appliedMigrations : [],
|
||||
schemaVersion: typeof s.schemaVersion === 'number' ? s.schemaVersion : 1,
|
||||
appliedMigrations: Array.isArray(s.appliedMigrations) ? s.appliedMigrations as Array<Record<string, unknown>> : [],
|
||||
};
|
||||
}
|
||||
|
||||
// Strict atomic write for the install state: must never be left half-written.
|
||||
// Bypasses the seam because platformWriteSync falls back to a direct write on
|
||||
// rename failure, which would silently violate this invariant.
|
||||
function atomicWriteInstallState(configDir, content) {
|
||||
function atomicWriteInstallState(configDir: string, content: string): void {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const filePath = path.join(configDir, INSTALL_STATE_NAME);
|
||||
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||
@@ -89,12 +112,18 @@ function atomicWriteInstallState(configDir, content) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeInstallState(configDir, state) {
|
||||
function writeInstallState(configDir: string, state: InstallState): InstallState {
|
||||
atomicWriteInstallState(configDir, JSON.stringify(state, null, 2) + '\n');
|
||||
return state;
|
||||
}
|
||||
|
||||
function readJson(configDir, relPath) {
|
||||
interface ReadJsonResult {
|
||||
exists: boolean;
|
||||
value: unknown;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
function readJson(configDir: string, relPath: string): ReadJsonResult {
|
||||
const { fullPath } = ensureInsideConfig(configDir, relPath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return { exists: false, value: null, error: null };
|
||||
@@ -102,11 +131,11 @@ function readJson(configDir, relPath) {
|
||||
try {
|
||||
return { exists: true, value: JSON.parse(fs.readFileSync(fullPath, 'utf8')), error: null };
|
||||
} catch (error) {
|
||||
return { exists: true, value: null, error };
|
||||
return { exists: true, value: null, error: error as Error };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelPath(relPath) {
|
||||
function normalizeRelPath(relPath: string): string {
|
||||
if (typeof relPath !== 'string' || relPath.trim() === '') {
|
||||
throw new Error('migration action relPath must be a non-empty string');
|
||||
}
|
||||
@@ -121,7 +150,13 @@ function normalizeRelPath(relPath) {
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function classifyArtifact(configDir, relPath, manifest) {
|
||||
interface ArtifactClassification {
|
||||
classification: string;
|
||||
originalHash: string | null;
|
||||
currentHash: string | null;
|
||||
}
|
||||
|
||||
function classifyArtifact(configDir: string, relPath: string, manifest: InstallManifest): ArtifactClassification {
|
||||
const normalized = normalizeRelPath(relPath);
|
||||
const originalHash = manifest.files[normalized] || null;
|
||||
const fullPath = path.join(configDir, normalized);
|
||||
@@ -138,16 +173,16 @@ function classifyArtifact(configDir, relPath, manifest) {
|
||||
return { classification: 'managed-modified', originalHash, currentHash };
|
||||
}
|
||||
|
||||
function appliedMigrationIds(state) {
|
||||
function appliedMigrationIds(state: InstallState): Set<string> {
|
||||
return new Set(
|
||||
state.appliedMigrations
|
||||
.filter((entry) => entry && typeof entry.id === 'string')
|
||||
.map((entry) => entry.id)
|
||||
.map((entry) => entry.id as string)
|
||||
);
|
||||
}
|
||||
|
||||
function appliedMigrationEntries(state) {
|
||||
const entries = new Map();
|
||||
function appliedMigrationEntries(state: InstallState): Map<string, Record<string, unknown>> {
|
||||
const entries = new Map<string, Record<string, unknown>>();
|
||||
for (const entry of state.appliedMigrations) {
|
||||
if (entry && typeof entry.id === 'string' && !entries.has(entry.id)) {
|
||||
entries.set(entry.id, entry);
|
||||
@@ -156,7 +191,7 @@ function appliedMigrationEntries(state) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function migrationChecksum(migration) {
|
||||
function migrationChecksum(migration: MigrationRecord): string {
|
||||
const checksum = migration.checksum;
|
||||
if (typeof checksum === 'string' && checksum) return checksum;
|
||||
const serializable = {
|
||||
@@ -168,35 +203,35 @@ function migrationChecksum(migration) {
|
||||
scopes: migration.scopes || null,
|
||||
destructive: migration.destructive === true,
|
||||
runtimeContract: migration.runtimeContract || null,
|
||||
plan: typeof migration.plan === 'function' ? migration.plan.toString() : null,
|
||||
plan: typeof migration.plan === 'function' ? (migration.plan as (...args: unknown[]) => unknown).toString() : null,
|
||||
};
|
||||
return `sha256:${sha256Text(JSON.stringify(serializable))}`;
|
||||
}
|
||||
|
||||
function assertAppliedMigrationChecksums(applied, migrations) {
|
||||
function assertAppliedMigrationChecksums(applied: Map<string, Record<string, unknown>>, migrations: MigrationRecord[]): void {
|
||||
for (const migration of migrations) {
|
||||
const entry = applied.get(migration.id);
|
||||
const entry = applied.get(migration.id as string);
|
||||
if (!entry || !entry.checksum) continue;
|
||||
const checksum = migrationChecksum(migration);
|
||||
if (entry.checksum !== checksum) {
|
||||
throw new Error(
|
||||
`applied migration checksum changed for ${migration.id}; create a new fix-forward migration id`
|
||||
`applied migration checksum changed for ${migration.id as string}; create a new fix-forward migration id`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function migrationMatchesContext(migration, { runtime, scope }) {
|
||||
if (Array.isArray(migration.runtimes) && migration.runtimes.length > 0) {
|
||||
if (!runtime || !migration.runtimes.includes(runtime)) return false;
|
||||
function migrationMatchesContext(migration: MigrationRecord, { runtime, scope }: { runtime: string | null; scope: string | null }): boolean {
|
||||
if (Array.isArray(migration.runtimes) && (migration.runtimes as string[]).length > 0) {
|
||||
if (!runtime || !(migration.runtimes as string[]).includes(runtime)) return false;
|
||||
}
|
||||
if (Array.isArray(migration.scopes) && migration.scopes.length > 0) {
|
||||
if (!scope || !migration.scopes.includes(scope)) return false;
|
||||
if (Array.isArray(migration.scopes) && (migration.scopes as string[]).length > 0) {
|
||||
if (!scope || !(migration.scopes as string[]).includes(scope)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function discoverInstallerMigrations({ migrationsDir }) {
|
||||
function discoverInstallerMigrations({ migrationsDir }: { migrationsDir: string }): MigrationRecord[] {
|
||||
if (!migrationsDir || !fs.existsSync(migrationsDir)) return [];
|
||||
return fs.readdirSync(migrationsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.cjs'))
|
||||
@@ -204,22 +239,24 @@ function discoverInstallerMigrations({ migrationsDir }) {
|
||||
.sort()
|
||||
.flatMap((fileName) => {
|
||||
const source = path.join(migrationsDir, fileName);
|
||||
|
||||
delete require.cache[require.resolve(source)];
|
||||
const exported = require(source);
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const exported: unknown = require(source);
|
||||
const records = Array.isArray(exported) ? exported : [exported];
|
||||
return records.map((record) => validateInstallerMigrationRecord(record, source));
|
||||
return records.map((record) => validateInstallerMigrationRecord(record as MigrationRecord, source));
|
||||
});
|
||||
}
|
||||
|
||||
function journalTimestamp(now) {
|
||||
function journalTimestamp(now: () => string): string {
|
||||
return now().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
function migrationRunId(appliedAt) {
|
||||
function migrationRunId(appliedAt: string): string {
|
||||
return `${journalTimestamp(() => appliedAt)}-${crypto.randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
function sleepSync(ms) {
|
||||
function sleepSync(ms: number): void {
|
||||
const buffer = new SharedArrayBuffer(4);
|
||||
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
||||
}
|
||||
@@ -231,26 +268,31 @@ function sleepSync(ms) {
|
||||
* Returns true if alive or permission-denied (live but not ours),
|
||||
* false if ESRCH (no such process).
|
||||
*/
|
||||
function isPidAlive(pid) {
|
||||
function isPidAlive(pid: number): boolean {
|
||||
if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true; // alive (or permission denied — treat as live)
|
||||
} catch (err) {
|
||||
return err.code !== 'ESRCH';
|
||||
return (err as NodeJS.ErrnoException).code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
interface LockFileData {
|
||||
pid: number;
|
||||
acquiredAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to read and parse the lock file JSON. Returns null on any error
|
||||
* (missing, invalid JSON, I/O failure).
|
||||
*/
|
||||
function readLockFile(lockPath) {
|
||||
function readLockFile(lockPath: string): LockFileData | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(lockPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && typeof parsed.pid === 'number') {
|
||||
return parsed;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && typeof (parsed as Record<string, unknown>).pid === 'number') {
|
||||
return parsed as LockFileData;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
@@ -258,13 +300,17 @@ function readLockFile(lockPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEOUT_MS } = {}, clock = realClock) {
|
||||
function acquireInstallMigrationLock(
|
||||
configDir: string,
|
||||
{ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
||||
clock: Clock = realClock,
|
||||
): () => void {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const lockPath = path.join(configDir, INSTALL_MIGRATION_LOCK_NAME);
|
||||
const started = clock.now();
|
||||
|
||||
while (true) {
|
||||
let fd = null;
|
||||
let fd: number | null = null;
|
||||
let lockCreatedByUs = false;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, 'wx');
|
||||
@@ -281,14 +327,14 @@ function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEO
|
||||
}) + '\n');
|
||||
lockCreatedByUs = false; // release closure owns cleanup from here
|
||||
return () => {
|
||||
const failures = [];
|
||||
const failures: Error[] = [];
|
||||
// Use unlinkSync (not rmSync with { force: true }) so EPERM errors
|
||||
// are NOT silently swallowed. On Windows, if the unlink fails
|
||||
// transiently, the error surfaces via releaseError so the caller
|
||||
// can observe and surface it rather than leaving a stale lock.
|
||||
try { fs.unlinkSync(lockPath); } catch (error) { failures.push(error); }
|
||||
try { fs.unlinkSync(lockPath); } catch (error) { failures.push(error as Error); }
|
||||
if (failures.length > 0) {
|
||||
const releaseError = new Error(`failed to release installer migration lock: ${lockPath}`);
|
||||
const releaseError = new Error(`failed to release installer migration lock: ${lockPath}`) as Error & { failures: Error[] };
|
||||
releaseError.failures = failures;
|
||||
throw releaseError;
|
||||
}
|
||||
@@ -304,7 +350,8 @@ function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEO
|
||||
// so it does not orphan as an unreadable (empty/invalid JSON) stale lock.
|
||||
try { fs.unlinkSync(lockPath); } catch { /* best-effort */ }
|
||||
}
|
||||
if (error && error.code === 'EEXIST') {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err && err.code === 'EEXIST') {
|
||||
// Stale-lock reclamation: read the on-disk PID and check liveness.
|
||||
// If the PID is dead (ESRCH) or is our own process (same-process
|
||||
// re-entry caused by rmSync silently swallowing an unlink error on
|
||||
@@ -337,7 +384,12 @@ function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEO
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInsideConfig(configDir, relPath) {
|
||||
interface EnsureInsideConfigResult {
|
||||
normalized: string;
|
||||
fullPath: string;
|
||||
}
|
||||
|
||||
function ensureInsideConfig(configDir: string, relPath: string): EnsureInsideConfigResult {
|
||||
const normalized = normalizeRelPath(relPath);
|
||||
const fullPath = path.resolve(configDir, normalized);
|
||||
const root = path.resolve(configDir);
|
||||
@@ -347,18 +399,60 @@ function ensureInsideConfig(configDir, relPath) {
|
||||
return { normalized, fullPath };
|
||||
}
|
||||
|
||||
function isStructurallyEmpty(value) {
|
||||
function isStructurallyEmpty(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
return typeof value === 'object' && Object.keys(value).length === 0;
|
||||
}
|
||||
|
||||
interface JournalAction extends Record<string, unknown> {
|
||||
status: string;
|
||||
}
|
||||
|
||||
function journalAction(action, status, extras = {}) {
|
||||
const { value, ...safeAction } = action;
|
||||
function journalAction(action: MigrationAction, status: string, extras: Record<string, unknown> = {}): JournalAction {
|
||||
const { value: _value, ...safeAction } = action;
|
||||
return { ...safeAction, ...extras, status };
|
||||
}
|
||||
|
||||
interface PlanContext {
|
||||
configDir: string;
|
||||
runtime: string | null;
|
||||
scope: string | null;
|
||||
manifest: InstallManifest;
|
||||
state: InstallState;
|
||||
baselineScan: boolean;
|
||||
now: () => string;
|
||||
classifyArtifact: (relPath: string) => ArtifactClassification;
|
||||
readJson: (relPath: string) => ReadJsonResult;
|
||||
}
|
||||
|
||||
interface PlannedAction extends MigrationAction {
|
||||
migrationId: string;
|
||||
migrationChecksum: string;
|
||||
type: string;
|
||||
relPath: string;
|
||||
reason: string;
|
||||
classification: string;
|
||||
originalHash: string | null;
|
||||
currentHash: string | null;
|
||||
requestedType?: string;
|
||||
backupRelPath?: string | null;
|
||||
value?: unknown;
|
||||
deleteIfEmpty?: boolean;
|
||||
prompt?: unknown;
|
||||
choices?: unknown[];
|
||||
}
|
||||
|
||||
interface MigrationPlan {
|
||||
generatedAt: string;
|
||||
manifest: InstallManifest;
|
||||
state: InstallState;
|
||||
pendingMigrationIds: string[];
|
||||
pendingMigrations: MigrationRecord[];
|
||||
actions: PlannedAction[];
|
||||
blocked: PlannedAction[];
|
||||
}
|
||||
|
||||
function planInstallerMigrations({
|
||||
configDir,
|
||||
runtime = null,
|
||||
@@ -366,7 +460,14 @@ function planInstallerMigrations({
|
||||
migrations,
|
||||
baselineScan = false,
|
||||
now = () => new Date().toISOString(),
|
||||
}) {
|
||||
}: {
|
||||
configDir: string;
|
||||
runtime?: string | null;
|
||||
scope?: string | null;
|
||||
migrations: MigrationRecord[];
|
||||
baselineScan?: boolean;
|
||||
now?: () => string;
|
||||
}): MigrationPlan {
|
||||
if (!configDir) throw new Error('configDir is required');
|
||||
if (!Array.isArray(migrations)) throw new Error('migrations must be an array');
|
||||
|
||||
@@ -380,20 +481,21 @@ function planInstallerMigrations({
|
||||
);
|
||||
const applied = appliedMigrationEntries(state);
|
||||
assertAppliedMigrationChecksums(applied, scopedMigrations);
|
||||
const pending = scopedMigrations.filter((migration) => !applied.has(migration.id));
|
||||
const actions = [];
|
||||
const blocked = [];
|
||||
const classifications = new Map();
|
||||
const classify = (relPath) => {
|
||||
const pending = scopedMigrations.filter((migration) => !applied.has(migration.id as string));
|
||||
const actions: PlannedAction[] = [];
|
||||
const blocked: PlannedAction[] = [];
|
||||
const classifications = new Map<string, ArtifactClassification>();
|
||||
const classify = (relPath: string): ArtifactClassification => {
|
||||
const normalized = normalizeRelPath(relPath);
|
||||
if (!classifications.has(normalized)) {
|
||||
classifications.set(normalized, classifyArtifact(configDir, normalized, manifest));
|
||||
}
|
||||
return classifications.get(normalized);
|
||||
return classifications.get(normalized)!;
|
||||
};
|
||||
|
||||
for (const migration of pending) {
|
||||
const plannedActions = migration.plan({
|
||||
const planFn = migration.plan as (ctx: PlanContext) => unknown[];
|
||||
const plannedActions = planFn({
|
||||
configDir,
|
||||
runtime,
|
||||
scope,
|
||||
@@ -406,34 +508,34 @@ function planInstallerMigrations({
|
||||
});
|
||||
validateInstallerMigrationActions(plannedActions, migration);
|
||||
const checksum = migrationChecksum(migration);
|
||||
for (const rawAction of plannedActions) {
|
||||
const relPath = normalizeRelPath(rawAction.relPath);
|
||||
for (const rawAction of plannedActions as MigrationAction[]) {
|
||||
const relPath = normalizeRelPath(rawAction.relPath as string);
|
||||
const classification = rawAction.classification
|
||||
? {
|
||||
classification: rawAction.classification,
|
||||
originalHash: rawAction.originalHash || null,
|
||||
currentHash: rawAction.currentHash || null,
|
||||
classification: rawAction.classification as string,
|
||||
originalHash: rawAction.originalHash as string | null || null,
|
||||
currentHash: rawAction.currentHash as string | null || null,
|
||||
}
|
||||
: classify(relPath);
|
||||
let protectedType = rawAction.type;
|
||||
let protectedType = rawAction.type as string;
|
||||
if (rawAction.type === 'remove-managed' && classification.classification === 'managed-modified') {
|
||||
protectedType = 'backup-and-remove';
|
||||
}
|
||||
if (rawAction.type === 'remove-managed' && classification.classification === 'unknown') {
|
||||
protectedType = 'preserve-user';
|
||||
}
|
||||
const action = {
|
||||
migrationId: migration.id,
|
||||
const action: PlannedAction = {
|
||||
migrationId: migration.id as string,
|
||||
migrationChecksum: checksum,
|
||||
type: protectedType,
|
||||
relPath,
|
||||
reason: rawAction.reason || migration.description || '',
|
||||
reason: rawAction.reason as string || migration.description as string || '',
|
||||
classification: classification.classification,
|
||||
originalHash: classification.originalHash,
|
||||
currentHash: classification.currentHash,
|
||||
};
|
||||
if (action.type !== rawAction.type) {
|
||||
action.requestedType = rawAction.type;
|
||||
action.requestedType = rawAction.type as string | undefined;
|
||||
}
|
||||
if (action.type === 'backup-and-remove') {
|
||||
action.backupRelPath = null;
|
||||
@@ -443,7 +545,7 @@ function planInstallerMigrations({
|
||||
action.deleteIfEmpty = rawAction.deleteIfEmpty === true;
|
||||
}
|
||||
if (rawAction.prompt) action.prompt = rawAction.prompt;
|
||||
if (Array.isArray(rawAction.choices)) action.choices = rawAction.choices;
|
||||
if (Array.isArray(rawAction.choices)) action.choices = rawAction.choices as unknown[];
|
||||
if (action.type === 'prompt-user') {
|
||||
blocked.push(action);
|
||||
} else if (
|
||||
@@ -462,34 +564,43 @@ function planInstallerMigrations({
|
||||
generatedAt: now(),
|
||||
manifest,
|
||||
state,
|
||||
pendingMigrationIds: pending.map((migration) => migration.id),
|
||||
pendingMigrationIds: pending.map((migration) => migration.id as string),
|
||||
pendingMigrations: pending,
|
||||
actions,
|
||||
blocked,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueActionMigrationIds(actions) {
|
||||
function uniqueActionMigrationIds(actions: PlannedAction[]): string[] {
|
||||
return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))];
|
||||
}
|
||||
|
||||
function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }) {
|
||||
const failures = [];
|
||||
interface RollbackArgs {
|
||||
configDir: string;
|
||||
journal: { actions: JournalAction[] };
|
||||
journalPath: string;
|
||||
rollbackRoot: string;
|
||||
backupRoot: string;
|
||||
previousInstallStateBytes: string | null;
|
||||
}
|
||||
|
||||
function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }: RollbackArgs): void {
|
||||
const failures: Array<{ relPath: string; error: string }> = [];
|
||||
for (const action of [...journal.actions].reverse()) {
|
||||
if (!action.rollbackRelPath) continue;
|
||||
const rollbackPath = path.join(configDir, action.rollbackRelPath);
|
||||
const dest = path.join(configDir, action.relPath);
|
||||
const rollbackPath = path.join(configDir, action.rollbackRelPath as string);
|
||||
const dest = path.join(configDir, action.relPath as string);
|
||||
try {
|
||||
if (fs.existsSync(rollbackPath)) {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(rollbackPath, dest);
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({ relPath: action.relPath, error: error.message });
|
||||
failures.push({ relPath: action.relPath as string, error: (error as Error).message });
|
||||
}
|
||||
if (action.backupRelPath) {
|
||||
try {
|
||||
fs.rmSync(path.join(configDir, action.backupRelPath), { force: true });
|
||||
fs.rmSync(path.join(configDir, action.backupRelPath as string), { force: true });
|
||||
} catch {
|
||||
// backup cleanup is best-effort; preserve restore failures above
|
||||
}
|
||||
@@ -503,7 +614,7 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
|
||||
atomicWriteInstallState(configDir, previousInstallStateBytes);
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({ relPath: INSTALL_STATE_NAME, error: error.message });
|
||||
failures.push({ relPath: INSTALL_STATE_NAME, error: (error as Error).message });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -515,19 +626,33 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const error = new Error('migration rollback incomplete');
|
||||
const error = new Error('migration rollback incomplete') as Error & { rollbackFailures: typeof failures };
|
||||
error.rollbackFailures = failures;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupMigrationRunArtifacts(journalPath, rollbackRoot, backupRoot) {
|
||||
function cleanupMigrationRunArtifacts(journalPath: string, rollbackRoot: string, backupRoot: string): void {
|
||||
try { fs.rmSync(journalPath, { force: true }); } catch { /* best-effort */ }
|
||||
try { fs.rmSync(rollbackRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
try { fs.rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().toISOString() }) {
|
||||
interface ApplyResult {
|
||||
appliedMigrationIds: string[];
|
||||
journalRelPath: string;
|
||||
rollback: () => void;
|
||||
}
|
||||
|
||||
function applyInstallerMigrationPlan({
|
||||
configDir,
|
||||
plan,
|
||||
now = () => new Date().toISOString(),
|
||||
}: {
|
||||
configDir: string;
|
||||
plan: MigrationPlan;
|
||||
now?: () => string;
|
||||
}): ApplyResult {
|
||||
if (!configDir) throw new Error('configDir is required');
|
||||
if (!plan || !Array.isArray(plan.actions)) throw new Error('plan with actions is required');
|
||||
if (Array.isArray(plan.blocked) && plan.blocked.length > 0) {
|
||||
@@ -542,13 +667,13 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
const rollbackRoot = path.join(configDir, rollbackRootRelPath);
|
||||
const backupRootRelPath = path.posix.join('gsd-migration-journal', `${runId}-backups`);
|
||||
const backupRoot = path.join(configDir, backupRootRelPath);
|
||||
const journal = {
|
||||
const journal: { schemaVersion: number; appliedAt: string; appliedMigrationIds: string[]; actions: JournalAction[] } = {
|
||||
schemaVersion: 1,
|
||||
appliedAt,
|
||||
appliedMigrationIds: uniqueActionMigrationIds(plan.actions),
|
||||
actions: [],
|
||||
};
|
||||
const rollback = [];
|
||||
const rollback: Array<{ relPath: string; rollbackPath: string }> = [];
|
||||
const installStatePath = path.join(configDir, INSTALL_STATE_NAME);
|
||||
const previousInstallStateBytes = fs.existsSync(installStatePath)
|
||||
? fs.readFileSync(installStatePath, 'utf8')
|
||||
@@ -622,7 +747,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
const state = readInstallState(configDir);
|
||||
const applied = appliedMigrationIds(state);
|
||||
const nextApplied = [...state.appliedMigrations];
|
||||
const actionsByMigrationId = new Map();
|
||||
const actionsByMigrationId = new Map<string, PlannedAction>();
|
||||
for (const action of plan.actions) {
|
||||
if (action.migrationId && !actionsByMigrationId.has(action.migrationId)) {
|
||||
actionsByMigrationId.set(action.migrationId, action);
|
||||
@@ -650,7 +775,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
rollback: () => rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }),
|
||||
};
|
||||
} catch (error) {
|
||||
const rollbackFailures = [];
|
||||
const rollbackFailures: Array<{ relPath: string; rollbackPath: string; error: string }> = [];
|
||||
for (const entry of rollback.reverse()) {
|
||||
const dest = path.join(configDir, entry.relPath);
|
||||
try {
|
||||
@@ -660,12 +785,12 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
rollbackFailures.push({
|
||||
relPath: entry.relPath,
|
||||
rollbackPath: entry.rollbackPath,
|
||||
error: rollbackError.message,
|
||||
error: (rollbackError as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rollbackFailures.length > 0) {
|
||||
const rollbackError = new Error(`migration apply failed and rollback incomplete: ${error.message}`);
|
||||
const rollbackError = new Error(`migration apply failed and rollback incomplete: ${(error as Error).message}`) as Error & { cause: unknown; rollbackFailures: typeof rollbackFailures };
|
||||
rollbackError.cause = error;
|
||||
rollbackError.rollbackFailures = rollbackFailures;
|
||||
throw rollbackError;
|
||||
@@ -675,19 +800,27 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
}
|
||||
}
|
||||
|
||||
function markPendingMigrationsApplied({ configDir, plan, now = () => new Date().toISOString() }) {
|
||||
function markPendingMigrationsApplied({
|
||||
configDir,
|
||||
plan,
|
||||
now = () => new Date().toISOString(),
|
||||
}: {
|
||||
configDir: string;
|
||||
plan: MigrationPlan;
|
||||
now?: () => string;
|
||||
}): string[] {
|
||||
if (!plan || !Array.isArray(plan.pendingMigrationIds) || plan.pendingMigrationIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const appliedAt = now();
|
||||
const state = readInstallState(configDir);
|
||||
const applied = appliedMigrationIds(state);
|
||||
const checksumsByMigrationId = new Map();
|
||||
const checksumsByMigrationId = new Map<string, string>();
|
||||
for (const migration of plan.pendingMigrations || []) {
|
||||
checksumsByMigrationId.set(migration.id, migrationChecksum(migration));
|
||||
checksumsByMigrationId.set(migration.id as string, migrationChecksum(migration));
|
||||
}
|
||||
const nextApplied = [...state.appliedMigrations];
|
||||
const newlyApplied = [];
|
||||
const newlyApplied: string[] = [];
|
||||
for (const id of plan.pendingMigrationIds) {
|
||||
if (applied.has(id)) continue;
|
||||
nextApplied.push({
|
||||
@@ -707,6 +840,14 @@ function markPendingMigrationsApplied({ configDir, plan, now = () => new Date().
|
||||
return newlyApplied;
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
appliedMigrationIds: string[];
|
||||
journalRelPath: string | null;
|
||||
plan: MigrationPlan;
|
||||
blocked?: PlannedAction[];
|
||||
rollback?: () => void;
|
||||
}
|
||||
|
||||
function runInstallerMigrations({
|
||||
configDir,
|
||||
runtime = null,
|
||||
@@ -716,17 +857,26 @@ function runInstallerMigrations({
|
||||
baselineScan = false,
|
||||
now = () => new Date().toISOString(),
|
||||
lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
}: {
|
||||
configDir: string;
|
||||
runtime?: string | null;
|
||||
scope?: string | null;
|
||||
migrationsDir?: string;
|
||||
migrations?: MigrationRecord[];
|
||||
baselineScan?: boolean;
|
||||
now?: () => string;
|
||||
lockTimeoutMs?: number;
|
||||
} = { configDir: '' }): RunResult {
|
||||
const releaseLock = acquireInstallMigrationLock(configDir, { timeoutMs: lockTimeoutMs });
|
||||
let primaryError = null;
|
||||
let primaryError: (Error & { suppressed?: Error[] }) | null = null;
|
||||
let completed = false;
|
||||
try {
|
||||
const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, baselineScan, now });
|
||||
if (plan.actions.length === 0) {
|
||||
const appliedMigrationIds = markPendingMigrationsApplied({ configDir, plan, now });
|
||||
const newlyApplied = markPendingMigrationsApplied({ configDir, plan, now });
|
||||
completed = true;
|
||||
return {
|
||||
appliedMigrationIds,
|
||||
appliedMigrationIds: newlyApplied,
|
||||
journalRelPath: null,
|
||||
plan,
|
||||
};
|
||||
@@ -744,14 +894,14 @@ function runInstallerMigrations({
|
||||
completed = true;
|
||||
return { ...result, plan };
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
primaryError = error as Error & { suppressed?: Error[] };
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
releaseLock();
|
||||
} catch (releaseError) {
|
||||
if (primaryError) {
|
||||
primaryError.suppressed = [...(primaryError.suppressed || []), releaseError];
|
||||
primaryError.suppressed = [...(primaryError.suppressed || []), releaseError as Error];
|
||||
} else if (completed) {
|
||||
throw releaseError;
|
||||
} else {
|
||||
@@ -761,7 +911,11 @@ function runInstallerMigrations({
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Unused but kept to satisfy eslint — sleepSync is referenced in the original
|
||||
// and may be used by test code that patches this module.
|
||||
void sleepSync;
|
||||
|
||||
export = {
|
||||
DEFAULT_MIGRATIONS_DIR,
|
||||
INSTALL_MIGRATION_LOCK_NAME,
|
||||
INSTALL_STATE_NAME,
|
||||
@@ -1,18 +1,21 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Installer migration: record first-time installer migration baseline.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written
|
||||
* bin/lib/installer-migrations/000-first-time-baseline.cjs collapsed to a
|
||||
* TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
||||
* the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BASELINE_MIGRATION_ID = '2026-05-11-first-time-baseline-scan';
|
||||
|
||||
// Runtime install surfaces must stay aligned with:
|
||||
// - docs/installer-migrations.md#runtime-configuration-contract-registry
|
||||
// - docs/ARCHITECTURE.md#runtime-install-contract-matrix
|
||||
//
|
||||
// The registry rows are based on each runtime's upstream loader docs where
|
||||
// available. Source-limited rows are intentionally conservative: scan generated
|
||||
// files GSD materializes, but do not infer ownership of undocumented host config.
|
||||
const RUNTIME_SURFACES = {
|
||||
const RUNTIME_SURFACES: Record<string, string[]> = {
|
||||
claude: ['get-shit-done', 'commands/gsd', 'skills', 'agents', 'hooks', 'settings.json'],
|
||||
codex: ['get-shit-done', 'skills', 'agents', 'hooks', 'config.toml', 'hooks.json'],
|
||||
gemini: ['get-shit-done', 'commands/gsd', 'hooks'],
|
||||
@@ -42,25 +45,24 @@ const USER_OWNED_PATHS = new Set([
|
||||
'commands/gsd/dev-preferences.md',
|
||||
'skills/gsd-dev-preferences/SKILL.md',
|
||||
]);
|
||||
let knownGeneratedAgentNames = null;
|
||||
let knownGeneratedAgentNames: Set<string> | null = null;
|
||||
|
||||
function normalizeRelPath(relPath) {
|
||||
function normalizeRelPath(relPath: string): string {
|
||||
return relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function baselineInstallSurfaces(runtime) {
|
||||
function baselineInstallSurfaces(runtime: string | undefined): string[] {
|
||||
if (runtime && RUNTIME_SURFACES[runtime]) return RUNTIME_SURFACES[runtime];
|
||||
return COMMON_SURFACES;
|
||||
}
|
||||
|
||||
function walkFiles(root, relDir, files) {
|
||||
function walkFiles(root: string, relDir: string, files: Set<string>): void {
|
||||
const dir = path.join(root, relDir);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const relPath = path.posix.join(relDir, entry.name);
|
||||
if (relDir === '' && INTERNAL_TOP_LEVEL_NAMES.has(entry.name)) continue;
|
||||
const fullPath = path.join(root, relPath);
|
||||
if (entry.isDirectory()) {
|
||||
walkFiles(root, relPath, files);
|
||||
} else if (entry.isFile()) {
|
||||
@@ -69,8 +71,8 @@ function walkFiles(root, relDir, files) {
|
||||
}
|
||||
}
|
||||
|
||||
function scanBaselineFiles(configDir, runtime) {
|
||||
const relPaths = new Set();
|
||||
function scanBaselineFiles(configDir: string, runtime: string | undefined): string[] {
|
||||
const relPaths = new Set<string>();
|
||||
for (const surface of baselineInstallSurfaces(runtime)) {
|
||||
const normalized = normalizeRelPath(surface);
|
||||
const fullPath = path.join(configDir, normalized);
|
||||
@@ -85,7 +87,7 @@ function scanBaselineFiles(configDir, runtime) {
|
||||
return [...relPaths];
|
||||
}
|
||||
|
||||
function isUserOwnedBaselinePath(relPath) {
|
||||
function isUserOwnedBaselinePath(relPath: string): boolean {
|
||||
if (USER_OWNED_PATHS.has(relPath)) return true;
|
||||
const parts = relPath.split('/');
|
||||
if (parts[0] === 'skills' && parts[1] && !parts[1].startsWith('gsd-')) return true;
|
||||
@@ -93,10 +95,10 @@ function isUserOwnedBaselinePath(relPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function listKnownGeneratedAgentNames() {
|
||||
function listKnownGeneratedAgentNames(): Set<string> {
|
||||
if (knownGeneratedAgentNames) return knownGeneratedAgentNames;
|
||||
|
||||
knownGeneratedAgentNames = new Set();
|
||||
knownGeneratedAgentNames = new Set<string>();
|
||||
const agentsDir = path.resolve(__dirname, '..', '..', '..', '..', 'agents');
|
||||
try {
|
||||
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
||||
@@ -112,7 +114,7 @@ function listKnownGeneratedAgentNames() {
|
||||
return knownGeneratedAgentNames;
|
||||
}
|
||||
|
||||
function isKnownGeneratedAgentPath(relPath, runtime) {
|
||||
function isKnownGeneratedAgentPath(relPath: string, runtime: string | undefined): boolean {
|
||||
const parts = relPath.split('/');
|
||||
if (parts.length !== 2 || parts[0] !== 'agents') return false;
|
||||
const fileName = parts[1];
|
||||
@@ -123,7 +125,7 @@ function isKnownGeneratedAgentPath(relPath, runtime) {
|
||||
return listKnownGeneratedAgentNames().has(agentName);
|
||||
}
|
||||
|
||||
function isStaleGsdLookingPath(relPath) {
|
||||
function isStaleGsdLookingPath(relPath: string): boolean {
|
||||
const baseName = path.posix.basename(relPath);
|
||||
if (/^gsd[-_]/.test(baseName)) return true;
|
||||
const parts = relPath.split('/');
|
||||
@@ -133,27 +135,61 @@ function isStaleGsdLookingPath(relPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function baselineActionRank(action) {
|
||||
interface BaselineAction {
|
||||
type: string;
|
||||
relPath: string;
|
||||
reason: string;
|
||||
classification?: string;
|
||||
originalHash?: string | null;
|
||||
currentHash?: string | null;
|
||||
prompt?: string;
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
function baselineActionRank(action: BaselineAction): number {
|
||||
if (action.type === 'record-baseline') return 0;
|
||||
if (action.type === 'baseline-preserve-user') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
interface ClassifiedArtifact {
|
||||
classification: string;
|
||||
originalHash?: string | null;
|
||||
currentHash?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PlanContext {
|
||||
configDir: string;
|
||||
runtime?: string;
|
||||
baselineScan?: boolean;
|
||||
classifyArtifact: (relPath: string) => ClassifiedArtifact;
|
||||
}
|
||||
|
||||
interface InstallerMigration {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
introducedIn: string;
|
||||
scopes: string[];
|
||||
destructive: boolean;
|
||||
plan: (ctx: PlanContext) => BaselineAction[];
|
||||
}
|
||||
|
||||
const migration: InstallerMigration = {
|
||||
id: BASELINE_MIGRATION_ID,
|
||||
title: 'Record first-time installer migration baseline',
|
||||
description: 'Classify existing install surfaces before destructive installer migrations run.',
|
||||
introducedIn: '1.50.0',
|
||||
scopes: ['global', 'local'],
|
||||
destructive: false,
|
||||
plan: ({ configDir, runtime, baselineScan, classifyArtifact }) => {
|
||||
plan: ({ configDir, runtime, baselineScan, classifyArtifact }: PlanContext): BaselineAction[] => {
|
||||
if (!baselineScan) return [];
|
||||
|
||||
const actions = [];
|
||||
const actions: BaselineAction[] = [];
|
||||
for (const relPath of scanBaselineFiles(configDir, runtime)) {
|
||||
// docs/installer-migrations.md#baseline-preserve-user keeps user-owned
|
||||
// artifacts out of destructive migration flow; classify later only when
|
||||
// ownership is not already known.
|
||||
// artifacts out of destructive migration flow.
|
||||
if (isUserOwnedBaselinePath(relPath)) {
|
||||
actions.push({
|
||||
type: 'baseline-preserve-user',
|
||||
@@ -176,14 +212,14 @@ module.exports = {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentHash = artifact.currentHash;
|
||||
const currentHash = artifact.currentHash ?? null;
|
||||
if (isKnownGeneratedAgentPath(relPath, runtime)) {
|
||||
actions.push({
|
||||
type: 'record-baseline',
|
||||
relPath,
|
||||
reason: 'known installer-generated agent included in first-time migration baseline',
|
||||
classification: artifact.classification,
|
||||
originalHash: artifact.originalHash,
|
||||
originalHash: artifact.originalHash ?? null,
|
||||
currentHash,
|
||||
});
|
||||
continue;
|
||||
@@ -195,7 +231,7 @@ module.exports = {
|
||||
relPath,
|
||||
reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice',
|
||||
classification: 'stale-gsd-looking',
|
||||
originalHash: artifact.originalHash,
|
||||
originalHash: artifact.originalHash ?? null,
|
||||
currentHash,
|
||||
prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.',
|
||||
choices: ['keep', 'remove'],
|
||||
@@ -208,13 +244,16 @@ module.exports = {
|
||||
relPath,
|
||||
reason: 'unknown install-surface file preserved by first-time migration baseline',
|
||||
classification: artifact.classification,
|
||||
originalHash: artifact.originalHash,
|
||||
originalHash: artifact.originalHash ?? null,
|
||||
currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
return actions.sort((left, right) =>
|
||||
baselineActionRank(left) - baselineActionRank(right) || left.relPath.localeCompare(right.relPath)
|
||||
return actions.sort(
|
||||
(left, right) =>
|
||||
baselineActionRank(left) - baselineActionRank(right) || left.relPath.localeCompare(right.relPath)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export = migration;
|
||||
@@ -1,11 +1,47 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Installer migration: remove manifest-managed legacy orphan hook files
|
||||
* (ADR-457 build-at-publish: the hand-written
|
||||
* bin/lib/installer-migrations/001-legacy-orphan-files.cjs collapsed to a
|
||||
* TypeScript source of truth). Behaviour is preserved byte-for-behaviour from
|
||||
* the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const LEGACY_ORPHAN_FILES = [
|
||||
type ArtifactClassification = string;
|
||||
|
||||
interface ClassifiedArtifact {
|
||||
classification: ArtifactClassification;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ActionType = 'remove-managed' | 'backup-and-remove';
|
||||
|
||||
interface MigrationAction {
|
||||
type: ActionType;
|
||||
relPath: string;
|
||||
reason: string;
|
||||
ownershipEvidence: string;
|
||||
}
|
||||
|
||||
interface MigrationPlanContext {
|
||||
classifyArtifact(relPath: string): ClassifiedArtifact;
|
||||
}
|
||||
|
||||
interface InstallerMigration {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
introducedIn: string;
|
||||
scopes: string[];
|
||||
destructive: boolean;
|
||||
plan: (ctx: MigrationPlanContext) => MigrationAction[];
|
||||
}
|
||||
|
||||
const LEGACY_ORPHAN_FILES: ReadonlyArray<string> = [
|
||||
'hooks/gsd-notify.sh',
|
||||
'hooks/statusline.js',
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
const migration: InstallerMigration = {
|
||||
id: '2026-05-11-legacy-orphan-files',
|
||||
title: 'Remove manifest-managed legacy orphan hook files',
|
||||
description: 'Remove legacy orphan hook files that are still manifest-managed.',
|
||||
@@ -16,10 +52,10 @@ module.exports = {
|
||||
// evidence. This follows docs/installer-migrations.md#ownership and avoids
|
||||
// relying on whether a runtime currently registers host hook config in the
|
||||
// runtime contract registry.
|
||||
plan: ({ classifyArtifact }) => {
|
||||
const actions = [];
|
||||
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
|
||||
const actions: MigrationAction[] = [];
|
||||
for (const relPath of LEGACY_ORPHAN_FILES) {
|
||||
const artifact = classifyArtifact(relPath);
|
||||
const artifact = ctx.classifyArtifact(relPath);
|
||||
if (artifact.classification === 'managed-pristine') {
|
||||
actions.push({
|
||||
type: 'remove-managed',
|
||||
@@ -39,3 +75,5 @@ module.exports = {
|
||||
return actions;
|
||||
},
|
||||
};
|
||||
|
||||
export = migration;
|
||||
@@ -1,8 +1,61 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Installer migration: remove legacy Codex hooks.json GSD hook registrations.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written
|
||||
* bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs collapsed to a
|
||||
* TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
||||
* the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const { isManagedHookCommand } = require('../shell-command-projection.cjs');
|
||||
import { isManagedHookCommand } from '../shell-command-projection.cjs';
|
||||
|
||||
function isStructurallyEmpty(value) {
|
||||
type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
interface PruneResult {
|
||||
value: JsonValue;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
interface HooksJsonRead {
|
||||
exists: boolean;
|
||||
error?: boolean;
|
||||
value?: JsonValue;
|
||||
}
|
||||
|
||||
interface MigrationAction {
|
||||
type: string;
|
||||
relPath: string;
|
||||
value: JsonValue;
|
||||
deleteIfEmpty: boolean;
|
||||
reason: string;
|
||||
ownershipEvidence: string;
|
||||
}
|
||||
|
||||
interface MigrationPlanContext {
|
||||
configDir: string;
|
||||
readJson(relPath: string): HooksJsonRead;
|
||||
}
|
||||
|
||||
interface InstallerMigration {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
introducedIn: string;
|
||||
runtimes: string[];
|
||||
scopes: string[];
|
||||
destructive: boolean;
|
||||
runtimeContract: string;
|
||||
plan: (ctx: MigrationPlanContext) => MigrationAction[];
|
||||
}
|
||||
|
||||
function isStructurallyEmpty(value: JsonValue): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
if (typeof value !== 'object') return false;
|
||||
@@ -10,7 +63,7 @@ function isStructurallyEmpty(value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isManagedCodexHookCommand(command, configDir) {
|
||||
function isManagedCodexHookCommand(command: unknown, configDir: string): boolean {
|
||||
return isManagedHookCommand(command, {
|
||||
surface: 'codex-hooks-json',
|
||||
includeLegacyAliases: true,
|
||||
@@ -18,10 +71,10 @@ function isManagedCodexHookCommand(command, configDir) {
|
||||
});
|
||||
}
|
||||
|
||||
function pruneLegacyCodexHooksJsonValue(value, configDir) {
|
||||
function pruneLegacyCodexHooksJsonValue(value: JsonValue, configDir: string): PruneResult {
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const next = [];
|
||||
const next: JsonValue[] = [];
|
||||
for (const item of value) {
|
||||
const pruned = pruneLegacyCodexHooksJsonValue(item, configDir);
|
||||
if (pruned.changed) changed = true;
|
||||
@@ -31,14 +84,16 @@ function pruneLegacyCodexHooksJsonValue(value, configDir) {
|
||||
return { value: next, changed };
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
if (isManagedCodexHookCommand(value.command, configDir)) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const valueObj = value as Record<string, JsonValue>;
|
||||
const command = valueObj['command'];
|
||||
if (isManagedCodexHookCommand(command, configDir)) {
|
||||
return { value: null, changed: true };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const next = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const next: { [key: string]: JsonValue } = {};
|
||||
for (const [key, child] of Object.entries(valueObj)) {
|
||||
const pruned = pruneLegacyCodexHooksJsonValue(child, configDir);
|
||||
if (pruned.changed) changed = true;
|
||||
if (pruned.changed && isStructurallyEmpty(pruned.value)) changed = true;
|
||||
@@ -50,7 +105,7 @@ function pruneLegacyCodexHooksJsonValue(value, configDir) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
const migration: InstallerMigration = {
|
||||
id: '2026-05-11-codex-legacy-hooks-json',
|
||||
title: 'Remove legacy Codex hooks.json GSD hook registrations',
|
||||
description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.',
|
||||
@@ -59,8 +114,9 @@ module.exports = {
|
||||
scopes: ['global', 'local'],
|
||||
destructive: true,
|
||||
runtimeContract: 'docs/installer-migrations.md#runtime-configuration-contract-registry Codex row',
|
||||
plan: ({ configDir, readJson }) => {
|
||||
const hooksJson = readJson('hooks.json');
|
||||
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
|
||||
const { configDir } = ctx;
|
||||
const hooksJson = ctx.readJson('hooks.json');
|
||||
if (!hooksJson.exists || hooksJson.error) return [];
|
||||
|
||||
const pruned = pruneLegacyCodexHooksJsonValue(hooksJson.value, configDir);
|
||||
@@ -78,3 +134,5 @@ module.exports = {
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export = migration;
|
||||
@@ -1,41 +1,40 @@
|
||||
/**
|
||||
* lib/intel.cjs -- Intel storage and query operations for GSD.
|
||||
* lib/intel.cts -- Intel storage and query operations for GSD.
|
||||
*
|
||||
* Provides a persistent, queryable intelligence system for project metadata.
|
||||
* Intel files live in .planning/intel/ and store structured data about
|
||||
* the project's files, APIs, dependencies, architecture, and tech stack.
|
||||
*
|
||||
* All public functions gate on intel.enabled config (no-op when false).
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/intel.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const INTEL_DIR = '.planning/intel';
|
||||
|
||||
const INTEL_FILES = {
|
||||
const INTEL_FILES: Record<string, string> = {
|
||||
files: 'file-roles.json',
|
||||
apis: 'api-map.json',
|
||||
deps: 'dependency-graph.json',
|
||||
arch: 'arch-decisions.json',
|
||||
stack: 'stack.json'
|
||||
stack: 'stack.json',
|
||||
};
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure the intel directory exists under the given planning dir.
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {string} Full path to .planning/intel/
|
||||
*/
|
||||
function ensureIntelDir(planningDir) {
|
||||
function ensureIntelDir(planningDir: string): string {
|
||||
const intelPath = path.join(planningDir, 'intel');
|
||||
platformEnsureDir(intelPath);
|
||||
return intelPath;
|
||||
@@ -45,53 +44,66 @@ function ensureIntelDir(planningDir) {
|
||||
* Check whether intel is enabled in the project config.
|
||||
* Reads config.json directly via fs. Returns false by default
|
||||
* (when no config, no intel key, or on error).
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isIntelEnabled(planningDir) {
|
||||
function isIntelEnabled(planningDir: string): boolean {
|
||||
try {
|
||||
const configPath = path.join(planningDir, 'config.json');
|
||||
const raw = platformReadSync(configPath);
|
||||
if (raw === null) return false;
|
||||
const config = JSON.parse(raw);
|
||||
if (config && config.intel && config.intel.enabled === true) return true;
|
||||
const config: unknown = JSON.parse(raw);
|
||||
if (
|
||||
config &&
|
||||
typeof config === 'object' &&
|
||||
'intel' in config &&
|
||||
config.intel &&
|
||||
typeof config.intel === 'object' &&
|
||||
'enabled' in config.intel &&
|
||||
(config.intel as Record<string, unknown>).enabled === true
|
||||
) return true;
|
||||
return false;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface DisabledResponse {
|
||||
disabled: true;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the standard disabled response object.
|
||||
* @returns {{ disabled: true, message: string }}
|
||||
*/
|
||||
function disabledResponse() {
|
||||
function disabledResponse(): DisabledResponse {
|
||||
return { disabled: true, message: 'Intel system disabled. Set intel.enabled=true in config.json to activate.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve full path to an intel file.
|
||||
* @param {string} planningDir
|
||||
* @param {string} filename
|
||||
* @returns {string}
|
||||
*/
|
||||
function intelFilePath(planningDir, filename) {
|
||||
function intelFilePath(planningDir: string, filename: string): string {
|
||||
return path.join(planningDir, 'intel', filename);
|
||||
}
|
||||
|
||||
interface IntelData {
|
||||
_meta?: {
|
||||
updated_at?: string;
|
||||
version?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
entries?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read and parse a JSON intel file.
|
||||
* Returns null if file doesn't exist or can't be parsed.
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function safeReadJson(filePath) {
|
||||
function safeReadJson(filePath: string): IntelData | null {
|
||||
try {
|
||||
const raw = platformReadSync(filePath);
|
||||
if (raw === null) return null;
|
||||
return JSON.parse(raw);
|
||||
return JSON.parse(raw) as IntelData;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
@@ -100,11 +112,8 @@ function safeReadJson(filePath) {
|
||||
/**
|
||||
* Compute SHA-256 hash of a file's contents.
|
||||
* Returns null if the file doesn't exist.
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function hashFile(filePath) {
|
||||
function hashFile(filePath: string): string | null {
|
||||
try {
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) return null;
|
||||
@@ -114,22 +123,23 @@ function hashFile(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
interface SearchMatch {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a term (case-insensitive) in a JSON object's keys and string values.
|
||||
* Returns an array of matching entries.
|
||||
*
|
||||
* @param {object} data - The JSON data (expects { _meta, entries } or flat object)
|
||||
* @param {string} term - Search term
|
||||
* @returns {Array<{ key: string, value: * }>}
|
||||
*/
|
||||
function searchJsonEntries(data, term) {
|
||||
function searchJsonEntries(data: IntelData, term: string): SearchMatch[] {
|
||||
if (!data || typeof data !== 'object') return [];
|
||||
|
||||
const entries = data.entries || data;
|
||||
if (!entries || typeof entries !== 'object') return [];
|
||||
|
||||
const lowerTerm = term.toLowerCase();
|
||||
const matches = [];
|
||||
const matches: SearchMatch[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (key === '_meta') continue;
|
||||
@@ -151,12 +161,8 @@ function searchJsonEntries(data, term) {
|
||||
|
||||
/**
|
||||
* Recursively check if a term appears in any string value.
|
||||
*
|
||||
* @param {*} value
|
||||
* @param {string} lowerTerm
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function matchesInValue(value, lowerTerm) {
|
||||
function matchesInValue(value: unknown, lowerTerm: string): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase().includes(lowerTerm);
|
||||
}
|
||||
@@ -172,12 +178,8 @@ function matchesInValue(value, lowerTerm) {
|
||||
/**
|
||||
* Search for a term in arch.md text content.
|
||||
* Returns matching lines.
|
||||
*
|
||||
* @param {string} filePath - Path to arch.md
|
||||
* @param {string} term - Search term
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function searchArchMd(filePath, term) {
|
||||
function searchArchMd(filePath: string, term: string): string[] {
|
||||
try {
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) return [];
|
||||
@@ -191,18 +193,20 @@ function searchArchMd(filePath, term) {
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface IntelQueryResult {
|
||||
matches: Array<{ source: string; entries: SearchMatch[] }>;
|
||||
term: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query intel files for a search term.
|
||||
* Searches across all JSON intel files (keys and values) and arch.md (text lines).
|
||||
*
|
||||
* @param {string} term - Search term (case-insensitive)
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {{ matches: Array<{ source: string, entries: Array }>, term: string, total: number } | { disabled: true, message: string }}
|
||||
*/
|
||||
function intelQuery(term, planningDir) {
|
||||
function intelQuery(term: string, planningDir: string): IntelQueryResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const matches = [];
|
||||
const matches: Array<{ source: string; entries: SearchMatch[] }> = [];
|
||||
let total = 0;
|
||||
|
||||
// Search all JSON intel files
|
||||
@@ -221,19 +225,27 @@ function intelQuery(term, planningDir) {
|
||||
return { matches, term, total };
|
||||
}
|
||||
|
||||
interface IntelStatusFileEntry {
|
||||
exists: boolean;
|
||||
updated_at: string | null;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
interface IntelStatusResult {
|
||||
files: Record<string, IntelStatusFileEntry>;
|
||||
overall_stale: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report status and staleness of each intel file.
|
||||
* A file is considered stale if its updated_at is older than 24 hours.
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {{ files: object, overall_stale: boolean } | { disabled: true, message: string }}
|
||||
*/
|
||||
function intelStatus(planningDir) {
|
||||
function intelStatus(planningDir: string): IntelStatusResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const now = Date.now();
|
||||
const files = {};
|
||||
const files: Record<string, IntelStatusFileEntry> = {};
|
||||
let overallStale = false;
|
||||
|
||||
for (const [_key, filename] of Object.entries(INTEL_FILES)) {
|
||||
@@ -246,7 +258,7 @@ function intelStatus(planningDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let updatedAt = null;
|
||||
let updatedAt: string | null = null;
|
||||
|
||||
// All intel files are JSON — read _meta.updated_at
|
||||
const data = safeReadJson(filePath);
|
||||
@@ -267,13 +279,16 @@ function intelStatus(planningDir) {
|
||||
return { files, overall_stale: overallStale };
|
||||
}
|
||||
|
||||
interface IntelDiffResult {
|
||||
changed: string[];
|
||||
added: string[];
|
||||
removed: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show changes since the last full refresh by comparing file hashes.
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {{ changed: string[], added: string[], removed: string[] } | { no_baseline: true } | { disabled: true, message: string }}
|
||||
*/
|
||||
function intelDiff(planningDir) {
|
||||
function intelDiff(planningDir: string): IntelDiffResult | { no_baseline: true } | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const snapshotPath = intelFilePath(planningDir, '.last-refresh.json');
|
||||
@@ -283,10 +298,10 @@ function intelDiff(planningDir) {
|
||||
return { no_baseline: true };
|
||||
}
|
||||
|
||||
const prevHashes = snapshot.hashes || {};
|
||||
const changed = [];
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const prevHashes = (snapshot.hashes as Record<string, string> | undefined) || {};
|
||||
const changed: string[] = [];
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
|
||||
// Check current files against snapshot
|
||||
for (const [_key, filename] of Object.entries(INTEL_FILES)) {
|
||||
@@ -308,29 +323,29 @@ function intelDiff(planningDir) {
|
||||
/**
|
||||
* Stub for triggering an intel update.
|
||||
* The actual update is performed by the intel-updater agent (PLAN-02).
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {{ action: string, message: string } | { disabled: true, message: string }}
|
||||
*/
|
||||
function intelUpdate(planningDir) {
|
||||
function intelUpdate(planningDir: string): { action: string; message: string } | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
return {
|
||||
action: 'spawn_agent',
|
||||
message: 'Run gsd-tools intel update or spawn gsd-intel-updater agent for full refresh'
|
||||
message: 'Run gsd-tools intel update or spawn gsd-intel-updater agent for full refresh',
|
||||
};
|
||||
}
|
||||
|
||||
interface SaveRefreshResult {
|
||||
saved: boolean;
|
||||
timestamp: string;
|
||||
files: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a refresh snapshot with hashes of all current intel files.
|
||||
* Called by the intel-updater agent after completing a refresh.
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {{ saved: boolean, timestamp: string, files: number }}
|
||||
*/
|
||||
function saveRefreshSnapshot(planningDir) {
|
||||
function saveRefreshSnapshot(planningDir: string): SaveRefreshResult {
|
||||
const intelPath = ensureIntelDir(planningDir);
|
||||
const hashes = {};
|
||||
const hashes: Record<string, string> = {};
|
||||
let fileCount = 0;
|
||||
|
||||
for (const [_key, filename] of Object.entries(INTEL_FILES)) {
|
||||
@@ -347,7 +362,7 @@ function saveRefreshSnapshot(planningDir) {
|
||||
platformWriteSync(snapshotPath, JSON.stringify({
|
||||
hashes,
|
||||
timestamp,
|
||||
version: 1
|
||||
version: 1,
|
||||
}, null, 2));
|
||||
|
||||
return { saved: true, timestamp, files: fileCount };
|
||||
@@ -358,26 +373,26 @@ function saveRefreshSnapshot(planningDir) {
|
||||
/**
|
||||
* Thin wrapper around saveRefreshSnapshot for CLI dispatch.
|
||||
* Writes .last-refresh.json with accurate timestamps and hashes.
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {{ saved: boolean, timestamp: string, files: number } | { disabled: true, message: string }}
|
||||
*/
|
||||
function intelSnapshot(planningDir) {
|
||||
function intelSnapshot(planningDir: string): SaveRefreshResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
return saveRefreshSnapshot(planningDir);
|
||||
}
|
||||
|
||||
interface IntelValidateResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all intel files for correctness and freshness.
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {{ valid: boolean, errors: string[], warnings: string[] } | { disabled: true, message: string }}
|
||||
*/
|
||||
function intelValidate(planningDir) {
|
||||
function intelValidate(planningDir: string): IntelValidateResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const STALE_MS = 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
@@ -398,11 +413,11 @@ function intelValidate(planningDir) {
|
||||
errors.push(`${filename}: file missing`);
|
||||
continue;
|
||||
}
|
||||
let data;
|
||||
let data: IntelData;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
data = JSON.parse(raw) as IntelData;
|
||||
} catch (e) {
|
||||
errors.push(`${filename}: invalid JSON — ${e.message}`);
|
||||
errors.push(`${filename}: invalid JSON — ${(e as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -421,8 +436,9 @@ function intelValidate(planningDir) {
|
||||
// files.json: check exports are actual symbol names (no spaces)
|
||||
if (key === 'files') {
|
||||
for (const [entryPath, entry] of Object.entries(data.entries)) {
|
||||
if (entry.exports && Array.isArray(entry.exports)) {
|
||||
for (const exp of entry.exports) {
|
||||
const entryObj = entry as Record<string, unknown>;
|
||||
if (entryObj.exports && Array.isArray(entryObj.exports)) {
|
||||
for (const exp of entryObj.exports as unknown[]) {
|
||||
if (typeof exp === 'string' && exp.includes(' ')) {
|
||||
warnings.push(`${filename}: "${entryPath}" export "${exp}" looks like a description (contains space)`);
|
||||
}
|
||||
@@ -441,10 +457,11 @@ function intelValidate(planningDir) {
|
||||
// deps.json: check entries have version, type, used_by
|
||||
if (key === 'deps') {
|
||||
for (const [depName, entry] of Object.entries(data.entries)) {
|
||||
const missing = [];
|
||||
if (!entry.version) missing.push('version');
|
||||
if (!entry.type) missing.push('type');
|
||||
if (!entry.used_by) missing.push('used_by');
|
||||
const entryObj = entry as Record<string, unknown>;
|
||||
const missing: string[] = [];
|
||||
if (!entryObj.version) missing.push('version');
|
||||
if (!entryObj.type) missing.push('type');
|
||||
if (!entryObj.used_by) missing.push('used_by');
|
||||
if (missing.length > 0) {
|
||||
warnings.push(`${filename}: "${depName}" missing fields: ${missing.join(', ')}`);
|
||||
}
|
||||
@@ -456,16 +473,19 @@ function intelValidate(planningDir) {
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
interface IntelApiSurfaceResult {
|
||||
written: string;
|
||||
symbolCount: number;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render .planning/intel/api-map.json into a human-readable API-SURFACE.md.
|
||||
* Always writes the file — even when api-map.json is absent or empty, the
|
||||
* surface will contain an explicit "incomplete" banner so consumers never
|
||||
* mistake silence for "nothing exists".
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning directory
|
||||
* @returns {{ written: string, symbolCount: number, stale: boolean } | { disabled: true, message: string }}
|
||||
*/
|
||||
function intelApiSurface(planningDir) {
|
||||
function intelApiSurface(planningDir: string): IntelApiSurfaceResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const intelPath = ensureIntelDir(planningDir);
|
||||
@@ -486,7 +506,7 @@ function intelApiSurface(planningDir) {
|
||||
stale = age > STALE_MS;
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
const lines: string[] = [];
|
||||
lines.push('# API Surface');
|
||||
lines.push('');
|
||||
lines.push('> Generated from `.planning/intel/api-map.json`. Do not edit by hand.');
|
||||
@@ -506,7 +526,7 @@ function intelApiSurface(planningDir) {
|
||||
lines.push(`## \`${symbol}\``);
|
||||
lines.push('');
|
||||
if (info && typeof info === 'object') {
|
||||
for (const [field, val] of Object.entries(info)) {
|
||||
for (const [field, val] of Object.entries(info as Record<string, unknown>)) {
|
||||
const display = Array.isArray(val) ? val.join(', ') : String(val);
|
||||
lines.push(`- **${field}:** ${display}`);
|
||||
}
|
||||
@@ -520,27 +540,31 @@ function intelApiSurface(planningDir) {
|
||||
return { written: outputPath, symbolCount, stale };
|
||||
}
|
||||
|
||||
interface IntelPatchMetaResult {
|
||||
patched: boolean;
|
||||
file?: string;
|
||||
timestamp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch _meta.updated_at in a JSON intel file to the current timestamp.
|
||||
* Reads the file, updates _meta.updated_at, increments version, writes back.
|
||||
*
|
||||
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary file paths
|
||||
* for use by agents patching individual files outside the intel store.
|
||||
*
|
||||
* @param {string} filePath - Absolute or relative path to the JSON intel file
|
||||
* @returns {{ patched: boolean, file: string, timestamp: string } | { patched: false, error: string }}
|
||||
*/
|
||||
function intelPatchMeta(filePath) {
|
||||
function intelPatchMeta(filePath: string): IntelPatchMetaResult {
|
||||
try {
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) {
|
||||
return { patched: false, error: `File not found: ${filePath}` };
|
||||
}
|
||||
let data;
|
||||
let data: IntelData;
|
||||
try {
|
||||
data = JSON.parse(content);
|
||||
data = JSON.parse(content) as IntelData;
|
||||
} catch (e) {
|
||||
return { patched: false, error: `Invalid JSON: ${e.message}` };
|
||||
return { patched: false, error: `Invalid JSON: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
if (!data._meta) {
|
||||
@@ -555,25 +579,28 @@ function intelPatchMeta(filePath) {
|
||||
|
||||
return { patched: true, file: filePath, timestamp };
|
||||
} catch (e) {
|
||||
return { patched: false, error: e.message };
|
||||
return { patched: false, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
interface IntelExtractExportsResult {
|
||||
file: string;
|
||||
exports: string[];
|
||||
method: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract exports from a JS/CJS file by parsing module.exports or exports.X patterns.
|
||||
*
|
||||
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary source files
|
||||
* for use by agents building intel data from project files.
|
||||
*
|
||||
* @param {string} filePath - Path to the JS/CJS file
|
||||
* @returns {{ file: string, exports: string[], method: string }}
|
||||
*/
|
||||
function intelExtractExports(filePath) {
|
||||
function intelExtractExports(filePath: string): IntelExtractExportsResult {
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) {
|
||||
return { file: filePath, exports: [], method: 'none' };
|
||||
}
|
||||
const exports = new Set();
|
||||
const exports = new Set<string>();
|
||||
let method = 'none';
|
||||
|
||||
// Try module.exports = { ... } pattern (handle multi-line)
|
||||
@@ -608,7 +635,7 @@ function intelExtractExports(filePath) {
|
||||
|
||||
// Also try individual exports.X = patterns (only at start of line, not inside strings/regex)
|
||||
const individualPattern = /^exports\.(\w+)\s*=/gm;
|
||||
let im;
|
||||
let im: RegExpExecArray | null;
|
||||
while ((im = individualPattern.exec(content)) !== null) {
|
||||
if (!exports.has(im[1])) {
|
||||
exports.add(im[1]);
|
||||
@@ -619,11 +646,11 @@ function intelExtractExports(filePath) {
|
||||
const hadCjs = exports.size > 0;
|
||||
|
||||
// ESM patterns
|
||||
const esmExports = new Set();
|
||||
const esmExports = new Set<string>();
|
||||
|
||||
// export default function X / export default class X
|
||||
const defaultNamedPattern = /^export\s+default\s+(?:function|class)\s+(\w+)/gm;
|
||||
let em;
|
||||
let em: RegExpExecArray | null;
|
||||
while ((em = defaultNamedPattern.exec(content)) !== null) {
|
||||
esmExports.add(em[1]);
|
||||
}
|
||||
@@ -683,7 +710,7 @@ function intelExtractExports(filePath) {
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
// Public API
|
||||
intelQuery,
|
||||
intelUpdate,
|
||||
@@ -704,5 +731,5 @@ module.exports = {
|
||||
|
||||
// Constants
|
||||
INTEL_FILES,
|
||||
INTEL_DIR
|
||||
INTEL_DIR,
|
||||
};
|
||||
@@ -9,16 +9,61 @@
|
||||
* Storage format: { id, source_project, date, context, learning, tags, content_hash }
|
||||
* File naming: {id}.json
|
||||
* Deduplication: SHA-256 of learning text + source_project
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/learnings.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only strict types are added.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output, error: coreError } = core;
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const os = require('os');
|
||||
const { output, error: coreError } = require('./core.cjs');
|
||||
const { platformWriteSync } = require('./shell-command-projection.cjs');
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LearningRecord {
|
||||
id: string;
|
||||
source_project: string;
|
||||
date: string;
|
||||
context: string;
|
||||
learning: string;
|
||||
tags: string[];
|
||||
content_hash: string;
|
||||
}
|
||||
|
||||
interface WriteEntry {
|
||||
source_project: string;
|
||||
learning: string;
|
||||
context?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface WriteOpts {
|
||||
storeDir?: string;
|
||||
dedupeIndex?: Map<string, string>;
|
||||
}
|
||||
|
||||
interface WriteResult {
|
||||
id: string;
|
||||
created: boolean;
|
||||
content_hash: string;
|
||||
}
|
||||
|
||||
interface CopyResult {
|
||||
total: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface PruneResult {
|
||||
removed: number;
|
||||
kept: number;
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,81 +71,41 @@ const DEFAULT_STORE_DIR = path.join(os.homedir(), '.gsd', 'knowledge');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the store directory, allowing override for testing.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {string}
|
||||
*/
|
||||
function getStoreDir(opts) {
|
||||
function getStoreDir(opts?: WriteOpts | { storeDir?: string }): string {
|
||||
return (opts && opts.storeDir) || DEFAULT_STORE_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the store directory exists. Created on first write, not on install.
|
||||
* @param {string} dir
|
||||
*/
|
||||
function ensureStoreDir(dir) {
|
||||
function ensureStoreDir(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a content hash for deduplication.
|
||||
* Uses SHA-256 of learning text combined with source_project.
|
||||
* @param {string} learning
|
||||
* @param {string} sourceProject
|
||||
* @returns {string}
|
||||
*/
|
||||
function contentHash(learning, sourceProject) {
|
||||
function contentHash(learning: string, sourceProject: string): string {
|
||||
return crypto.createHash('sha256')
|
||||
.update(learning + '\n' + sourceProject)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID based on timestamp + random suffix.
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateId() {
|
||||
function generateId(): string {
|
||||
const ts = Date.now().toString(36);
|
||||
const rand = crypto.randomBytes(4).toString('hex');
|
||||
return `${ts}-${rand}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse a single learning JSON file.
|
||||
* Returns null (with stderr warning) for malformed files.
|
||||
* @param {string} filePath
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function readLearningFile(filePath) {
|
||||
function readLearningFile(filePath: string): LearningRecord | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
return JSON.parse(content) as LearningRecord;
|
||||
} catch (err) {
|
||||
process.stderr.write(`Warning: skipping malformed file ${filePath}: ${err.message}\n`);
|
||||
process.stderr.write(`Warning: skipping malformed file ${filePath}: ${(err as Error).message}\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CRUD Operations ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write a learning to the global store.
|
||||
* Deduplicates by content hash — same content from same project is not stored twice.
|
||||
*
|
||||
* @param {object} entry
|
||||
* @param {string} entry.source_project - Project name or path
|
||||
* @param {string} entry.learning - The learning text
|
||||
* @param {string} [entry.context] - Additional context
|
||||
* @param {string[]} [entry.tags] - Tags for querying
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {{ id: string, created: boolean, content_hash: string }}
|
||||
*/
|
||||
function learningsWrite(entry, opts) {
|
||||
function learningsWrite(entry: WriteEntry, opts?: WriteOpts): WriteResult {
|
||||
const dir = getStoreDir(opts);
|
||||
ensureStoreDir(dir);
|
||||
|
||||
@@ -108,17 +113,13 @@ function learningsWrite(entry, opts) {
|
||||
|
||||
// #306: In bulk-import paths, callers may supply a pre-built dedupeIndex
|
||||
// (Map<content_hash, id>) to avoid the per-write O(N) store scan.
|
||||
// When present, use it for the dedupe check instead of scanning the directory.
|
||||
// After writing a new record, add it to the index so subsequent items in the
|
||||
// same bulk operation dedupe against it.
|
||||
// When absent (single-write path), fall back to the existing full-store scan.
|
||||
if (opts && opts.dedupeIndex) {
|
||||
const dedupeIndex = opts.dedupeIndex;
|
||||
if (dedupeIndex.has(hash)) {
|
||||
return { id: dedupeIndex.get(hash), created: false, content_hash: hash };
|
||||
return { id: dedupeIndex.get(hash) as string, created: false, content_hash: hash };
|
||||
}
|
||||
const id = generateId();
|
||||
const record = {
|
||||
const record: LearningRecord = {
|
||||
id,
|
||||
source_project: entry.source_project,
|
||||
date: new Date().toISOString(),
|
||||
@@ -142,7 +143,7 @@ function learningsWrite(entry, opts) {
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const record = {
|
||||
const record: LearningRecord = {
|
||||
id,
|
||||
source_project: entry.source_project,
|
||||
date: new Date().toISOString(),
|
||||
@@ -156,15 +157,7 @@ function learningsWrite(entry, opts) {
|
||||
return { id, created: true, content_hash: hash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single learning by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function learningsRead(id, opts) {
|
||||
function learningsRead(id: string, opts?: { storeDir?: string }): LearningRecord | null {
|
||||
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return null;
|
||||
const dir = getStoreDir(opts);
|
||||
const filePath = path.join(dir, `${id}.json`);
|
||||
@@ -172,19 +165,12 @@ function learningsRead(id, opts) {
|
||||
return readLearningFile(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all learnings, sorted by date (newest first).
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function learningsList(opts) {
|
||||
function learningsList(opts?: { storeDir?: string }): LearningRecord[] {
|
||||
const dir = getStoreDir(opts);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
||||
const results = [];
|
||||
const results: LearningRecord[] = [];
|
||||
for (const file of files) {
|
||||
const record = readLearningFile(path.join(dir, file));
|
||||
if (record) results.push(record);
|
||||
@@ -195,32 +181,15 @@ function learningsList(opts) {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query learnings by tag.
|
||||
*
|
||||
* @param {object} query
|
||||
* @param {string} [query.tag] - Tag to filter by
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function learningsQuery(query, opts) {
|
||||
function learningsQuery(query: { tag?: string }, opts?: { storeDir?: string }): LearningRecord[] {
|
||||
const all = learningsList(opts);
|
||||
if (query && query.tag) {
|
||||
return all.filter(r => r.tags && r.tags.includes(query.tag));
|
||||
return all.filter(r => r.tags && r.tags.includes(query.tag as string));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a learning by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {boolean} true if deleted, false if not found
|
||||
*/
|
||||
function learningsDelete(id, opts) {
|
||||
function learningsDelete(id: string, opts?: { storeDir?: string }): boolean {
|
||||
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return false;
|
||||
const dir = getStoreDir(opts);
|
||||
const filePath = path.join(dir, `${id}.json`);
|
||||
@@ -229,24 +198,7 @@ function learningsDelete(id, opts) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy learnings from a project's LEARNINGS.md into the global store.
|
||||
* Parses markdown sections as individual learnings. Deduplicates by content hash.
|
||||
*
|
||||
* Expected LEARNINGS.md format:
|
||||
* ## Section Title
|
||||
* Learning content paragraph(s)...
|
||||
*
|
||||
* ## Another Section
|
||||
* More content...
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning/ directory (or directory containing LEARNINGS.md)
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @param {string} [opts.sourceProject] - Project name (defaults to directory basename)
|
||||
* @returns {{ total: number, created: number, skipped: number }}
|
||||
*/
|
||||
function learningsCopyFromProject(planningDir, opts) {
|
||||
function learningsCopyFromProject(planningDir: string, opts?: WriteOpts & { sourceProject?: string }): CopyResult {
|
||||
const learningsPath = path.join(planningDir, 'LEARNINGS.md');
|
||||
if (!fs.existsSync(learningsPath)) {
|
||||
return { total: 0, created: 0, skipped: 0 };
|
||||
@@ -260,7 +212,7 @@ function learningsCopyFromProject(planningDir, opts) {
|
||||
// O(K*N) -> O(N+K).
|
||||
const dir = getStoreDir(opts);
|
||||
ensureStoreDir(dir);
|
||||
const dedupeIndex = new Map();
|
||||
const dedupeIndex = new Map<string, string>();
|
||||
for (const file of fs.readdirSync(dir).filter(f => f.endsWith('.json'))) {
|
||||
const existing = readLearningFile(path.join(dir, file));
|
||||
// First-seen-wins, matching the legacy scan path's first-match return so the
|
||||
@@ -302,15 +254,7 @@ function learningsCopyFromProject(planningDir, opts) {
|
||||
return { total: created + skipped, created, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune learnings older than a given threshold.
|
||||
*
|
||||
* @param {string} olderThan - Duration string like "90d", "30d", "7d"
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {{ removed: number, kept: number }}
|
||||
*/
|
||||
function learningsPrune(olderThan, opts) {
|
||||
function learningsPrune(olderThan: string, opts?: { storeDir?: string }): PruneResult {
|
||||
const match = /^(\d+)d$/.exec(olderThan);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid duration format: "${olderThan}" — expected format like "90d"`);
|
||||
@@ -345,66 +289,40 @@ function learningsPrune(olderThan, opts) {
|
||||
|
||||
// ─── CLI Command Handlers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle `gsd-tools learnings list`
|
||||
* @param {boolean} raw - Raw output flag
|
||||
*/
|
||||
function cmdLearningsList(raw) {
|
||||
function cmdLearningsList(raw: boolean): void {
|
||||
const results = learningsList();
|
||||
output({ learnings: results, count: results.length }, raw);
|
||||
output({ learnings: results, count: results.length }, raw, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `gsd-tools learnings query --tag <tag>`
|
||||
* @param {string} tag
|
||||
* @param {boolean} raw - Raw output flag
|
||||
*/
|
||||
function cmdLearningsQuery(tag, raw) {
|
||||
function cmdLearningsQuery(tag: string, raw: boolean): void {
|
||||
const results = learningsQuery({ tag });
|
||||
output({ learnings: results, count: results.length, tag }, raw);
|
||||
output({ learnings: results, count: results.length, tag }, raw, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `gsd-tools learnings copy`
|
||||
* @param {string} cwd - Current working directory
|
||||
* @param {boolean} raw - Raw output flag
|
||||
*/
|
||||
function cmdLearningsCopy(cwd, raw) {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
const result = learningsCopyFromProject(planningDir);
|
||||
output(result, raw);
|
||||
function cmdLearningsCopy(cwd: string, raw: boolean): void {
|
||||
const planDir = path.join(cwd, '.planning');
|
||||
const result = learningsCopyFromProject(planDir);
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `gsd-tools learnings prune --older-than <duration>`
|
||||
* @param {string} olderThan - Duration string like "90d"
|
||||
* @param {boolean} raw - Raw output flag
|
||||
*/
|
||||
function cmdLearningsPrune(olderThan, raw) {
|
||||
function cmdLearningsPrune(olderThan: string, raw: boolean): void {
|
||||
try {
|
||||
const result = learningsPrune(olderThan);
|
||||
output(result, raw);
|
||||
output(result, raw, undefined);
|
||||
} catch (err) {
|
||||
coreError(err.message);
|
||||
coreError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `gsd-tools learnings delete <id>`
|
||||
* @param {string} id
|
||||
* @param {boolean} raw - Raw output flag
|
||||
*/
|
||||
function cmdLearningsDelete(id, raw) {
|
||||
function cmdLearningsDelete(id: string, raw: boolean): void {
|
||||
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) {
|
||||
coreError(`Invalid learning ID: "${id}"`);
|
||||
}
|
||||
const deleted = learningsDelete(id);
|
||||
output({ id, deleted }, raw);
|
||||
output({ id, deleted }, raw, undefined);
|
||||
}
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
learningsWrite,
|
||||
learningsRead,
|
||||
learningsList,
|
||||
@@ -1,17 +1,44 @@
|
||||
/**
|
||||
* Milestone — Milestone and requirements lifecycle operations
|
||||
* Milestone — Milestone and requirements lifecycle operations.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/milestone.cjs collapsed to
|
||||
* a TypeScript source of truth, compiled by tsc to a gitignored .cjs at the same
|
||||
* require() path. Behaviour preserved byte-for-behaviour; only types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, normalizePhaseName, phaseTokenMatches, output, error } = require('./core.cjs');
|
||||
const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { planningPaths } = require('./planning-workspace.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { writeStateMd, stateReplaceFieldWithFallback } = require('./state.cjs');
|
||||
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
|
||||
import core = require('./core.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
|
||||
import stateMod = require('./state.cjs');
|
||||
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
|
||||
function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
|
||||
const {
|
||||
escapeRegex,
|
||||
getMilestonePhaseFilter,
|
||||
extractOneLinerFromBody,
|
||||
normalizePhaseName,
|
||||
phaseTokenMatches,
|
||||
output,
|
||||
error,
|
||||
} = core;
|
||||
const { planningPaths } = planningWorkspace;
|
||||
const { extractFrontmatter } = frontmatterMod;
|
||||
const { writeStateMd, stateReplaceFieldWithFallback } = stateMod;
|
||||
|
||||
interface MilestoneCompleteOptions {
|
||||
name?: string;
|
||||
force?: boolean;
|
||||
archivePhases?: boolean;
|
||||
}
|
||||
|
||||
function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: boolean): void {
|
||||
if (!reqIdsRaw || reqIdsRaw.length === 0) {
|
||||
error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
|
||||
}
|
||||
@@ -21,7 +48,7 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
|
||||
.join(' ')
|
||||
.replace(/[\[\]]/g, '')
|
||||
.split(/[,\s]+/)
|
||||
.map(r => r.trim())
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (reqIds.length === 0) {
|
||||
@@ -35,9 +62,9 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
|
||||
}
|
||||
|
||||
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
||||
const updated = [];
|
||||
const alreadyComplete = [];
|
||||
const notFound = [];
|
||||
const updated: string[] = [];
|
||||
const alreadyComplete: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
let found = false;
|
||||
@@ -80,16 +107,20 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
|
||||
platformWriteSync(reqPath, reqContent);
|
||||
}
|
||||
|
||||
output({
|
||||
updated: updated.length > 0,
|
||||
marked_complete: updated,
|
||||
already_complete: alreadyComplete,
|
||||
not_found: notFound,
|
||||
total: reqIds.length,
|
||||
}, raw, `${updated.length}/${reqIds.length} requirements marked complete`);
|
||||
output(
|
||||
{
|
||||
updated: updated.length > 0,
|
||||
marked_complete: updated,
|
||||
already_complete: alreadyComplete,
|
||||
not_found: notFound,
|
||||
total: reqIds.length,
|
||||
},
|
||||
raw,
|
||||
`${updated.length}/${reqIds.length} requirements marked complete`,
|
||||
);
|
||||
}
|
||||
|
||||
function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCompleteOptions, raw: boolean): void {
|
||||
if (!version) {
|
||||
error('version required for milestone complete (e.g., v1.0)');
|
||||
}
|
||||
@@ -124,27 +155,33 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
if (!options.force) {
|
||||
try {
|
||||
// Only guard when STATE.md's milestone field matches the version being completed.
|
||||
let stateVersion = null;
|
||||
let stateVersion: string | null = null;
|
||||
try {
|
||||
const stateRaw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
|
||||
if (stateRaw) {
|
||||
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
||||
if (milestoneMatch) stateVersion = milestoneMatch[1].trim();
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
if (stateVersion && stateVersion === version) {
|
||||
const { extractCurrentMilestone } = require('./core.cjs');
|
||||
const { extractCurrentMilestone } = core;
|
||||
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
||||
const noDirectoryPhases = [];
|
||||
let pm;
|
||||
const phaseDirEntries = (() => {
|
||||
const noDirectoryPhases: string[] = [];
|
||||
let pm: RegExpExecArray | null;
|
||||
const phaseDirEntries = ((): string[] => {
|
||||
try {
|
||||
return fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory()).map(e => e.name);
|
||||
} catch { return []; }
|
||||
return fs
|
||||
.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
while ((pm = phasePattern.exec(scopedContent)) !== null) {
|
||||
const phaseNum = pm[1];
|
||||
@@ -153,7 +190,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
// with a matching token exists on disk. Use the same phaseTokenMatches
|
||||
// helper that roadmap.analyze uses to avoid false positives on decimal
|
||||
// (2.1) and letter-suffix (12A) phase IDs.
|
||||
const hasDirectory = phaseDirEntries.some(d => phaseTokenMatches(d, normalized));
|
||||
const hasDirectory = phaseDirEntries.some((d) => phaseTokenMatches(d, normalized));
|
||||
if (!hasDirectory) {
|
||||
noDirectoryPhases.push(phaseNum);
|
||||
}
|
||||
@@ -161,13 +198,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
if (noDirectoryPhases.length > 0) {
|
||||
error(
|
||||
`Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` +
|
||||
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`
|
||||
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If the error came from our guard, re-throw it; otherwise skip silently.
|
||||
if (e.message && e.message.startsWith('Cannot mark milestone complete:')) throw e;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (message && message.startsWith('Cannot mark milestone complete:')) throw e;
|
||||
// Phase scan failed or STATE version mismatch — allow completion to proceed.
|
||||
}
|
||||
}
|
||||
@@ -176,19 +214,22 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
let phaseCount = 0;
|
||||
let totalPlans = 0;
|
||||
let totalTasks = 0;
|
||||
const accomplishments = [];
|
||||
const accomplishments: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
||||
const dirs = entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!isDirInMilestone(dir)) continue;
|
||||
|
||||
phaseCount++;
|
||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
||||
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
||||
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
||||
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
totalPlans += plans.length;
|
||||
|
||||
// Extract one-liners from summaries
|
||||
@@ -196,7 +237,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
const oneLiner = fm['one-liner'] || extractOneLinerFromBody(content);
|
||||
const rawOneLiner = fm['one-liner'];
|
||||
const oneLiner = (typeof rawOneLiner === 'string' ? rawOneLiner : '') || extractOneLinerFromBody(content);
|
||||
if (oneLiner) {
|
||||
accomplishments.push(oneLiner);
|
||||
}
|
||||
@@ -210,10 +252,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
|
||||
totalTasks += xmlTaskMatches.length || mdTaskMatches.length;
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
} catch {
|
||||
/* intentionally empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
} catch {
|
||||
/* intentionally empty */
|
||||
}
|
||||
|
||||
// Archive ROADMAP.md
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
@@ -235,7 +281,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
}
|
||||
|
||||
// Create/append MILESTONES.md entry
|
||||
const accomplishmentsList = accomplishments.map(a => `- ${a}`).join('\n');
|
||||
const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
|
||||
const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
|
||||
|
||||
if (fs.existsSync(milestonesPath)) {
|
||||
@@ -265,8 +311,12 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
|
||||
stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null, `${version} milestone complete`);
|
||||
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
|
||||
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null,
|
||||
`${version} milestone completed and archived`);
|
||||
stateContent = stateReplaceFieldWithFallback(
|
||||
stateContent,
|
||||
'Last Activity Description',
|
||||
null,
|
||||
`${version} milestone completed and archived`,
|
||||
);
|
||||
|
||||
// Reset Current Position narrative so resume/progress flows do not keep
|
||||
// pointing at closed-phase execution instructions.
|
||||
@@ -277,7 +327,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
`Status: Awaiting next milestone\n` +
|
||||
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
|
||||
if (positionPattern.test(stateContent)) {
|
||||
stateContent = stateContent.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`);
|
||||
stateContent = stateContent.replace(positionPattern, (_m, header: string) => `${header}${closedPositionBody}`);
|
||||
} else {
|
||||
stateContent = `${stateContent.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
|
||||
}
|
||||
@@ -287,10 +337,10 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
if (operatorPattern.test(stateContent)) {
|
||||
stateContent = stateContent.replace(
|
||||
operatorPattern,
|
||||
`$1\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd))}\n\n`,
|
||||
`$1\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string}\n\n`,
|
||||
);
|
||||
} else {
|
||||
stateContent = `${stateContent.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd))}\n`;
|
||||
stateContent = `${stateContent.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string}\n`;
|
||||
}
|
||||
|
||||
writeStateMd(statePath, stateContent, cwd);
|
||||
@@ -304,7 +354,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
platformEnsureDir(phaseArchiveDir);
|
||||
|
||||
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
const phaseDirNames = phaseEntries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
let archivedCount = 0;
|
||||
for (const dir of phaseDirNames) {
|
||||
if (!isDirInMilestone(dir)) continue;
|
||||
@@ -312,7 +362,9 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
archivedCount++;
|
||||
}
|
||||
phasesArchived = archivedCount > 0;
|
||||
} catch { /* intentionally empty */ }
|
||||
} catch {
|
||||
/* intentionally empty */
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
@@ -336,19 +388,19 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
output(result, raw);
|
||||
}
|
||||
|
||||
function cmdPhasesClear(cwd, raw, args) {
|
||||
function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
|
||||
const phasesDir = planningPaths(cwd).phases;
|
||||
const confirm = Array.isArray(args) && args.includes('--confirm');
|
||||
let cleared = 0;
|
||||
|
||||
if (fs.existsSync(phasesDir)) {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory() && !/^999(?:\.|$)/.test(e.name));
|
||||
const dirs = entries.filter((e) => e.isDirectory() && !/^999(?:\.|$)/.test(e.name));
|
||||
|
||||
if (dirs.length > 0 && !confirm) {
|
||||
error(
|
||||
`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` +
|
||||
`Pass --confirm to proceed.`
|
||||
`Pass --confirm to proceed.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,14 +410,15 @@ function cmdPhasesClear(cwd, raw, args) {
|
||||
cleared++;
|
||||
}
|
||||
} catch (e) {
|
||||
error('Failed to clear phases directory: ' + e.message);
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
error('Failed to clear phases directory: ' + message);
|
||||
}
|
||||
}
|
||||
|
||||
output({ cleared }, raw, `${cleared} phase director${cleared === 1 ? 'y' : 'ies'} cleared`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
cmdRequirementsMarkComplete,
|
||||
cmdMilestoneComplete,
|
||||
cmdPhasesClear,
|
||||
226
src/model-catalog.cts
Normal file
226
src/model-catalog.cts
Normal 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']);
|
||||
@@ -1,6 +1,13 @@
|
||||
'use strict';
|
||||
/**
|
||||
* model-profiles — re-exports model catalog symbols consumed by callers that
|
||||
* historically required bin/lib/model-profiles.cjs.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/model-profiles.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
||||
* the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const {
|
||||
import {
|
||||
MODEL_PROFILES,
|
||||
VALID_PROFILES,
|
||||
AGENT_TO_PHASE_TYPE,
|
||||
@@ -13,9 +20,9 @@ const {
|
||||
EFFORT_RENDERING,
|
||||
renderEffortForRuntime,
|
||||
RUNTIMES_WITH_FAST_MODE,
|
||||
} = require('./model-catalog.cjs');
|
||||
} from './model-catalog.cjs';
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
MODEL_PROFILES,
|
||||
VALID_PROFILES,
|
||||
AGENT_TO_PHASE_TYPE,
|
||||
89
src/observability/event.cts
Normal file
89
src/observability/event.cts
Normal 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);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DispatchLogger interface + default implementation — issue #177 (ADR-0174 P1.3).
|
||||
*
|
||||
@@ -16,12 +14,16 @@
|
||||
*
|
||||
* No-op logger (createNoOpLogger):
|
||||
* Silent on all events. Used as the Hub default when no logger is injected.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/observability/logger.cjs
|
||||
* collapsed to a TypeScript source of truth. Behaviour is preserved
|
||||
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const { redactEvent, shouldIncludeArgs } = require('./redaction.cjs');
|
||||
import { redactEvent } from './redaction.cjs';
|
||||
|
||||
const AUDIT_FILE_NAME = '.gsd-trace.jsonl';
|
||||
const PLANNING_DIR = '.planning';
|
||||
@@ -30,10 +32,8 @@ const PLANNING_DIR = '.planning';
|
||||
|
||||
/**
|
||||
* Safely serialise a value to JSON, falling back to a placeholder on circular refs.
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function _safeStringify(value) {
|
||||
function _safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
@@ -43,12 +43,9 @@ function _safeStringify(value) {
|
||||
|
||||
/**
|
||||
* Determine whether the audit file should be written to.
|
||||
*
|
||||
* @param {{ audit?: { enabled?: boolean } } | undefined} config
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isAuditEnabled(config) {
|
||||
if (process.env.GSD_AUDIT === '1') return true;
|
||||
function _isAuditEnabled(config: { audit?: { enabled?: boolean } } | undefined): boolean {
|
||||
if (process.env['GSD_AUDIT'] === '1') return true;
|
||||
if (config && config.audit && config.audit.enabled === true) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -56,11 +53,8 @@ function _isAuditEnabled(config) {
|
||||
/**
|
||||
* Build the redacted plain object for the audit file.
|
||||
* Preserves the full DispatchEvent structure.
|
||||
*
|
||||
* @param {object} event - DispatchEvent
|
||||
* @returns {object}
|
||||
*/
|
||||
function _toAuditRecord(event) {
|
||||
function _toAuditRecord(event: Record<string, unknown>): Record<string, unknown> {
|
||||
return redactEvent(event);
|
||||
}
|
||||
|
||||
@@ -70,15 +64,13 @@ function _toAuditRecord(event) {
|
||||
* Per ADR-0174 P1.3 contract: { "kind": "<variant>", "traceId": "<uuid>", ...typedPayload }
|
||||
* The result's kind is promoted to top-level and the typed payload fields are spread in.
|
||||
* The `result` wrapper is removed.
|
||||
*
|
||||
* @param {object} event - DispatchEvent with an error result
|
||||
* @returns {object}
|
||||
*/
|
||||
function _toStderrRecord(event) {
|
||||
function _toStderrRecord(event: Record<string, unknown>): Record<string, unknown> {
|
||||
const redacted = redactEvent(event);
|
||||
const { result, ...eventWithoutResult } = redacted;
|
||||
// Flatten: top-level gets kind + typed payload fields from result
|
||||
const { kind, ...typedPayload } = result;
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const { kind, ...typedPayload } = resultObj;
|
||||
return Object.assign({}, eventWithoutResult, { kind }, typedPayload);
|
||||
}
|
||||
|
||||
@@ -87,11 +79,8 @@ function _toStderrRecord(event) {
|
||||
* Creates .planning/ directory if it does not exist.
|
||||
*
|
||||
* Uses synchronous fs API (crash-safe for v1 — dispatch is synchronous).
|
||||
*
|
||||
* @param {string} cwd - Project root directory.
|
||||
* @param {object} event - Redacted DispatchEvent.
|
||||
*/
|
||||
function _appendAuditLine(cwd, event) {
|
||||
function _appendAuditLine(cwd: string, event: Record<string, unknown>): void {
|
||||
const planningDir = path.join(cwd, PLANNING_DIR);
|
||||
// Ensure the directory exists
|
||||
if (!fs.existsSync(planningDir)) {
|
||||
@@ -103,35 +92,38 @@ function _appendAuditLine(cwd, event) {
|
||||
|
||||
// ─── Public factories ─────────────────────────────────────────────────────────
|
||||
|
||||
interface DispatchLogger {
|
||||
onEvent(event: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a no-op logger. All events are silently dropped.
|
||||
* This is the Hub's default when no logger is injected by the caller.
|
||||
*
|
||||
* @returns {{ onEvent(event: object): void }}
|
||||
*/
|
||||
function createNoOpLogger() {
|
||||
function createNoOpLogger(): DispatchLogger {
|
||||
return {
|
||||
onEvent(_event) {
|
||||
onEvent(_event: Record<string, unknown>): void {
|
||||
// intentionally empty
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface DefaultLoggerOptions {
|
||||
cwd?: string;
|
||||
config?: { audit?: { enabled?: boolean } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the default DispatchLogger.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.cwd=process.cwd()] - Project root; audit file is written relative to this.
|
||||
* @param {object} [opts.config] - GSD config object. config.audit.enabled triggers audit.
|
||||
* @returns {{ onEvent(event: object): void }}
|
||||
*/
|
||||
function createDefaultLogger({ cwd = process.cwd(), config } = {}) {
|
||||
function createDefaultLogger({ cwd = process.cwd(), config }: DefaultLoggerOptions = {}): DispatchLogger {
|
||||
return {
|
||||
/**
|
||||
* @param {object} event - A DispatchEvent from the Hub.
|
||||
* @param event - A DispatchEvent from the Hub.
|
||||
*/
|
||||
onEvent(event) {
|
||||
const isOk = event && event.result && event.result.kind === 'ok';
|
||||
onEvent(event: Record<string, unknown>): void {
|
||||
const resultObj = event && (event['result'] as Record<string, unknown> | undefined);
|
||||
const isOk = resultObj && resultObj['kind'] === 'ok';
|
||||
|
||||
// ── Audit file (both ok and error) ────────────────────────────────────
|
||||
if (_isAuditEnabled(config)) {
|
||||
@@ -144,7 +136,7 @@ function createDefaultLogger({ cwd = process.cwd(), config } = {}) {
|
||||
_safeStringify({
|
||||
level: 'warn',
|
||||
source: 'DispatchLogger',
|
||||
message: 'audit file write failed: ' + String(auditErr && auditErr.message || auditErr),
|
||||
message: 'audit file write failed: ' + String((auditErr as Error | null)?.message ?? auditErr),
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
@@ -161,7 +153,7 @@ function createDefaultLogger({ cwd = process.cwd(), config } = {}) {
|
||||
_safeStringify({
|
||||
level: 'warn',
|
||||
source: 'DispatchLogger',
|
||||
message: 'stderr emit failed: ' + String(stderrErr && stderrErr.message || stderrErr),
|
||||
message: 'stderr emit failed: ' + String((stderrErr as Error | null)?.message ?? stderrErr),
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
@@ -171,4 +163,4 @@ function createDefaultLogger({ cwd = process.cwd(), config } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createDefaultLogger, createNoOpLogger };
|
||||
export = { createDefaultLogger, createNoOpLogger };
|
||||
@@ -1,7 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Arg redaction policy — issue #177 (ADR-0174 P1.3).
|
||||
* Arg redaction policy — issue #177 (ADR-457 build-at-publish: the
|
||||
* hand-written bin/lib/observability/redaction.cjs collapsed to a TypeScript
|
||||
* source of truth). Behaviour is preserved byte-for-behaviour from the prior
|
||||
* hand-written .cjs; only types are added.
|
||||
*
|
||||
* Privacy default: args are OMITTED from every emitted event (both stderr
|
||||
* and file audit). Opt-in: set GSD_AUDIT_ARGS=1 to include args verbatim.
|
||||
@@ -11,14 +12,17 @@
|
||||
* can toggle the env var without module-level caching issues.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A DispatchEvent (frozen or plain). Must be an object; `args` is optional.
|
||||
*/
|
||||
export type DispatchEvent = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Returns true when the caller has opted in to including args in events.
|
||||
* Only GSD_AUDIT_ARGS === '1' enables inclusion; any other value (including
|
||||
* empty string, 'true', 'yes') keeps the default of omitting args.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldIncludeArgs() {
|
||||
export function shouldIncludeArgs(): boolean {
|
||||
return process.env.GSD_AUDIT_ARGS === '1';
|
||||
}
|
||||
|
||||
@@ -32,10 +36,10 @@ function shouldIncludeArgs() {
|
||||
*
|
||||
* The original event object is never mutated (it is frozen by makeDispatchEvent).
|
||||
*
|
||||
* @param {object} event - A DispatchEvent (frozen or plain).
|
||||
* @returns {object} A new plain object with the same fields, minus args when redacted.
|
||||
* @param event - A DispatchEvent (frozen or plain).
|
||||
* @returns A new plain object with the same fields, minus args when redacted.
|
||||
*/
|
||||
function redactEvent(event) {
|
||||
export function redactEvent(event: DispatchEvent): DispatchEvent {
|
||||
if (shouldIncludeArgs()) {
|
||||
// Include path: return a shallow copy with args preserved if present
|
||||
const copy = Object.assign({}, event);
|
||||
@@ -46,5 +50,3 @@ function redactEvent(event) {
|
||||
const { args: _dropped, ...rest } = event;
|
||||
return rest;
|
||||
}
|
||||
|
||||
module.exports = { shouldIncludeArgs, redactEvent };
|
||||
17
src/package-identity.d.cts
Normal file
17
src/package-identity.d.cts
Normal 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;
|
||||
@@ -1,10 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
const { PHASE_SUBCOMMANDS } = require('./command-aliases.cjs');
|
||||
|
||||
// ─── CommandRoutingHub (issue #3788, simplified in #175, typed in #176) ───────
|
||||
const { createHub, ERROR_KINDS, makeInvalidArgs } = require('./command-routing-hub.cjs');
|
||||
|
||||
/**
|
||||
* Manifest-backed phase subcommand router.
|
||||
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
|
||||
@@ -16,11 +9,45 @@ const { createHub, ERROR_KINDS, makeInvalidArgs } = require('./command-routing-h
|
||||
*
|
||||
* #3788: dispatch is mediated by CommandRoutingHub. The public entry point
|
||||
* and observable CLI behaviour are unchanged.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/phase-command-router.cjs
|
||||
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
function routePhaseCommand({ phase, args, cwd, raw, error }) {
|
||||
|
||||
import { PHASE_SUBCOMMANDS } from './command-aliases.cjs';
|
||||
|
||||
// ─── CommandRoutingHub (issue #3788, simplified in #175, typed in #176) ───────
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import commandRoutingHub = require('./command-routing-hub.cjs');
|
||||
const { createHub, ERROR_KINDS, makeInvalidArgs } = commandRoutingHub;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PhaseHandlers {
|
||||
cmdPhaseMvpMode: (cwd: string, args: string[], raw: boolean) => void;
|
||||
cmdPhaseNextDecimal: (cwd: string, arg: string | undefined, raw: boolean) => void;
|
||||
cmdPhaseAdd: (cwd: string, desc: string, raw: boolean, customId: string | null) => void;
|
||||
cmdPhaseAddBatch: (cwd: string, descriptions: string[], raw: boolean) => void;
|
||||
cmdPhaseInsert: (cwd: string, pos: string | undefined, desc: string, raw: boolean) => void;
|
||||
cmdPhaseRemove: (cwd: string, phaseNum: string, opts: { force: boolean }, raw: boolean) => void;
|
||||
cmdPhaseComplete: (cwd: string, phaseNum: string | undefined, raw: boolean) => void;
|
||||
}
|
||||
|
||||
interface RoutePhaseCommandOptions {
|
||||
phase: PhaseHandlers;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
raw: boolean;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOptions): void {
|
||||
// ── Unsupported subcommands ─────────────────────────────────────────────────
|
||||
// Resolved before dispatch so the error message stays deterministic.
|
||||
const UNSUPPORTED = {
|
||||
const UNSUPPORTED: Record<string, string> = {
|
||||
scaffold: 'phase scaffold is routed through the top-level scaffold command.',
|
||||
};
|
||||
|
||||
@@ -56,13 +83,13 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
|
||||
// Each handler receives a ctx object from the hub and must return a HubResult.
|
||||
const cjsRegistry = {
|
||||
phase: {
|
||||
'next-decimal': (_ctx) => {
|
||||
'next-decimal': (_ctx: Record<string, unknown>): { ok: true; data: null } => {
|
||||
phase.cmdPhaseNextDecimal(cwd, args[2], raw);
|
||||
return { ok: true, data: null };
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
add: (_ctx) => {
|
||||
let customId = null;
|
||||
const descArgs = [];
|
||||
add: (_ctx: Record<string, unknown>) => {
|
||||
let customId: string | null = null;
|
||||
const descArgs: string[] = [];
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
const token = args[i];
|
||||
if (token === '--raw') {
|
||||
@@ -82,18 +109,18 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
|
||||
}
|
||||
}
|
||||
phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId);
|
||||
return { ok: true, data: null };
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
'add-batch': (_ctx) => {
|
||||
'add-batch': (_ctx: Record<string, unknown>) => {
|
||||
const descFlagIdx = args.indexOf('--descriptions');
|
||||
let descriptions;
|
||||
let descriptions: string[];
|
||||
if (descFlagIdx !== -1) {
|
||||
const rawDescriptions = args[descFlagIdx + 1];
|
||||
if (!rawDescriptions || rawDescriptions.startsWith('--')) {
|
||||
return makeInvalidArgs('--descriptions', '--descriptions must be a JSON array');
|
||||
}
|
||||
try {
|
||||
descriptions = JSON.parse(rawDescriptions);
|
||||
descriptions = JSON.parse(rawDescriptions) as string[];
|
||||
} catch {
|
||||
return makeInvalidArgs('--descriptions', '--descriptions must be a JSON array');
|
||||
}
|
||||
@@ -104,19 +131,19 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
|
||||
descriptions = args.slice(2).filter(a => a !== '--raw');
|
||||
}
|
||||
phase.cmdPhaseAddBatch(cwd, descriptions, raw);
|
||||
return { ok: true, data: null };
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
insert: (_ctx) => {
|
||||
insert: (_ctx: Record<string, unknown>) => {
|
||||
if (args.includes('--dry-run')) {
|
||||
return makeInvalidArgs('--dry-run', 'phase insert does not support --dry-run');
|
||||
}
|
||||
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
|
||||
return { ok: true, data: null };
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
remove: (_ctx) => {
|
||||
remove: (_ctx: Record<string, unknown>) => {
|
||||
const removeArgs = args.slice(2).filter(token => token !== '--raw');
|
||||
let forceFlag = false;
|
||||
const positional = [];
|
||||
const positional: string[] = [];
|
||||
for (const token of removeArgs) {
|
||||
if (token === '--force') {
|
||||
forceFlag = true;
|
||||
@@ -131,11 +158,11 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
|
||||
return makeInvalidArgs('<phase-number>', 'phase remove accepts exactly one phase number');
|
||||
}
|
||||
phase.cmdPhaseRemove(cwd, positional[0], { force: forceFlag }, raw);
|
||||
return { ok: true, data: null };
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
complete: (_ctx) => {
|
||||
complete: (_ctx: Record<string, unknown>): { ok: true; data: null } => {
|
||||
phase.cmdPhaseComplete(cwd, args[2], raw);
|
||||
return { ok: true, data: null };
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -186,6 +213,6 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
routePhaseCommand,
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Phase Lifecycle Pure Helpers — pure-computation functions extracted from
|
||||
* the phase-lifecycle SDK handler.
|
||||
* the phase-lifecycle SDK handler (ADR-457 build-at-publish: the hand-written
|
||||
* bin/lib/phase-lifecycle.cjs collapsed to a TypeScript source of truth).
|
||||
* Behaviour is preserved byte-for-behaviour from the prior hand-written .cjs;
|
||||
* only types are added.
|
||||
*
|
||||
* I/O adapter pattern (ADR-3524 Section 4): each side supplies its own I/O
|
||||
* (sync readFileSync for CJS, async readFile for SDK); the pure computation
|
||||
@@ -19,14 +20,21 @@
|
||||
* - Issue #4 (open-gsd/gsd-core)
|
||||
*/
|
||||
|
||||
/** Result of deriveProgressFromRoadmap. */
|
||||
export interface RoadmapProgress {
|
||||
completedPhases: number | null;
|
||||
totalPhases: number | null;
|
||||
totalPlans: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive completed_phases, total_phases, and total_plans from ROADMAP content.
|
||||
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
|
||||
*/
|
||||
function deriveProgressFromRoadmap(roadmapContent) {
|
||||
let completedPhases = null;
|
||||
let totalPhases = null;
|
||||
let totalPlans = null;
|
||||
export function deriveProgressFromRoadmap(roadmapContent: string): RoadmapProgress {
|
||||
let completedPhases: number | null = null;
|
||||
let totalPhases: number | null = null;
|
||||
let totalPlans: number | null = null;
|
||||
|
||||
try {
|
||||
// Count Complete rows in the progress table (Status column = "Complete").
|
||||
@@ -54,7 +62,7 @@ function deriveProgressFromRoadmap(roadmapContent) {
|
||||
// Sum plan counts from M/N columns in progress table
|
||||
let totalPlansSum = 0;
|
||||
const planCellPattern = /\|\s*\d+[^|]*\|\s*(\d+)\/(\d+)\s*\|/gi;
|
||||
let pm;
|
||||
let pm: RegExpExecArray | null;
|
||||
while ((pm = planCellPattern.exec(roadmapContent)) !== null) {
|
||||
totalPlansSum += parseInt(pm[2], 10);
|
||||
}
|
||||
@@ -68,12 +76,7 @@ function deriveProgressFromRoadmap(roadmapContent) {
|
||||
* Compute progress percent clamped to 100.
|
||||
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
|
||||
*/
|
||||
function clampPercent(completed, total) {
|
||||
export function clampPercent(completed: number, total: number): number {
|
||||
if (!total || total <= 0) return 0;
|
||||
return Math.min(100, Math.round((completed / total) * 100));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deriveProgressFromRoadmap,
|
||||
clampPercent,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
71
src/phases-command-router.cts
Normal file
71
src/phases-command-router.cts
Normal 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
106
src/plan-scan.cts
Normal 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,
|
||||
});
|
||||
@@ -7,12 +7,19 @@
|
||||
*
|
||||
* Active workstream pointer policy/session identity lives in
|
||||
* active-workstream-store.cjs and is consumed here via thin adapters.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/planning-workspace.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
||||
* the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { realClock } = require('./clock.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
import type { Clock } from './clock.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import activeWorkstreamStore = require('./active-workstream-store.cjs');
|
||||
const {
|
||||
createSharedPointerAdapter,
|
||||
createSessionScopedPointerAdapter,
|
||||
@@ -20,10 +27,10 @@ const {
|
||||
getActiveWorkstream: getStoredActiveWorkstream,
|
||||
setActiveWorkstream: setStoredActiveWorkstream,
|
||||
clearActiveWorkstream: clearStoredActiveWorkstream,
|
||||
} = require('./active-workstream-store.cjs');
|
||||
} = activeWorkstreamStore;
|
||||
|
||||
// Track .planning/.lock files held by this process so they can be removed on exit.
|
||||
const _heldPlanningLocks = new Set();
|
||||
const _heldPlanningLocks = new Set<string>();
|
||||
process.on('exit', () => {
|
||||
for (const lockPath of _heldPlanningLocks) {
|
||||
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
||||
@@ -47,9 +54,15 @@ const PLANNING_LOCK_RETRY_ERRNOS = new Set([
|
||||
'ESTALE', // NFS: stale file handle (self-resolves on retry)
|
||||
]);
|
||||
|
||||
function planningDir(cwd, ws, project) {
|
||||
if (project === undefined) project = process.env.GSD_PROJECT || null;
|
||||
if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null;
|
||||
// Loose opts type accepted by createPlanningWorkspace — passed through to
|
||||
// active-workstream-store get/set/clear which accept { activeWorkstreamAdapter?,
|
||||
// activeWorkstreamAdapters?, getStored? }. Using Record<string, unknown> is
|
||||
// compatible with the structural type the store expects.
|
||||
type WorkstreamAdapterOpts = Record<string, unknown>;
|
||||
|
||||
function planningDir(cwd: string, ws?: string | null, project?: string | null): string {
|
||||
if (project === undefined) project = process.env['GSD_PROJECT'] ?? null;
|
||||
if (ws === undefined) ws = process.env['GSD_WORKSTREAM'] ?? null;
|
||||
|
||||
// Reject path separators and traversal components in project/workstream names
|
||||
const BAD_SEGMENT = /[/\\]|\.\./;
|
||||
@@ -66,11 +79,21 @@ function planningDir(cwd, ws, project) {
|
||||
return base;
|
||||
}
|
||||
|
||||
function planningRoot(cwd) {
|
||||
function planningRoot(cwd: string): string {
|
||||
return path.join(cwd, '.planning');
|
||||
}
|
||||
|
||||
function planningPaths(cwd, ws) {
|
||||
interface PlanningPaths {
|
||||
planning: string;
|
||||
state: string;
|
||||
roadmap: string;
|
||||
project: string;
|
||||
config: string;
|
||||
phases: string;
|
||||
requirements: string;
|
||||
}
|
||||
|
||||
function planningPaths(cwd: string, ws?: string | null): PlanningPaths {
|
||||
const base = planningDir(cwd, ws);
|
||||
return {
|
||||
planning: base,
|
||||
@@ -84,14 +107,14 @@ function planningPaths(cwd, ws) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} cwd
|
||||
* @param {function} fn - callback to run while holding the lock
|
||||
* @param {{ now(): number, sleep(ms: number): void }} [clock]
|
||||
* @param cwd
|
||||
* @param fn - callback to run while holding the lock
|
||||
* @param clock
|
||||
* Optional clock seam for testing. Defaults to realClock (Date.now + Atomics.wait).
|
||||
* Pass a fake clock from tests/helpers/clock.cjs to drive timeout/stale logic
|
||||
* without real wall-clock waits.
|
||||
*/
|
||||
function withPlanningLock(cwd, fn, clock) {
|
||||
function withPlanningLock<T>(cwd: string, fn: () => T, clock?: Clock): T {
|
||||
if (clock === undefined) clock = realClock;
|
||||
const lockPath = path.join(planningDir(cwd), '.lock');
|
||||
const lockTimeout = 10000; // 10 seconds
|
||||
@@ -100,7 +123,7 @@ function withPlanningLock(cwd, fn, clock) {
|
||||
// Ensure .planning/ exists
|
||||
try { platformEnsureDir(planningDir(cwd)); } catch { /* ok */ }
|
||||
|
||||
function acquireLock() {
|
||||
function acquireLock(): void {
|
||||
// Atomic create — fails if file exists
|
||||
fs.writeFileSync(lockPath, JSON.stringify({
|
||||
pid: process.pid,
|
||||
@@ -111,7 +134,7 @@ function withPlanningLock(cwd, fn, clock) {
|
||||
_heldPlanningLocks.add(lockPath);
|
||||
}
|
||||
|
||||
function runWithHeldLock() {
|
||||
function runWithHeldLock(): T {
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
@@ -131,11 +154,12 @@ function withPlanningLock(cwd, fn, clock) {
|
||||
// are recoverable — wait and retry rather than propagating.
|
||||
// See PLANNING_LOCK_RETRY_ERRNOS for the full list and rationale.
|
||||
if (lockWasAcquired) throw err;
|
||||
if (PLANNING_LOCK_RETRY_ERRNOS.has(err.code)) {
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (PLANNING_LOCK_RETRY_ERRNOS.has(nodeErr.code ?? '')) {
|
||||
clock.sleep(100);
|
||||
continue;
|
||||
}
|
||||
if (err.code === 'EEXIST') {
|
||||
if (nodeErr.code === 'EEXIST') {
|
||||
// Lock exists — check if stale (>30s old)
|
||||
try {
|
||||
const stat = fs.statSync(lockPath);
|
||||
@@ -159,16 +183,27 @@ function withPlanningLock(cwd, fn, clock) {
|
||||
return runWithHeldLock();
|
||||
}
|
||||
|
||||
function createPlanningWorkspace(cwd, opts = {}) {
|
||||
function createPlanningWorkspace(cwd: string, opts: WorkstreamAdapterOpts = {}): {
|
||||
paths: {
|
||||
dir(ws?: string | null, project?: string | null): string;
|
||||
root(): string;
|
||||
all(ws?: string | null): PlanningPaths;
|
||||
};
|
||||
activeWorkstream: {
|
||||
get(): string | null;
|
||||
set(name: string): void;
|
||||
clear(): void;
|
||||
};
|
||||
} {
|
||||
return {
|
||||
paths: {
|
||||
dir(ws, project) {
|
||||
dir(ws?: string | null, project?: string | null) {
|
||||
return planningDir(cwd, ws, project);
|
||||
},
|
||||
root() {
|
||||
return planningRoot(cwd);
|
||||
},
|
||||
all(ws) {
|
||||
all(ws?: string | null) {
|
||||
return planningPaths(cwd, ws);
|
||||
},
|
||||
},
|
||||
@@ -176,7 +211,7 @@ function createPlanningWorkspace(cwd, opts = {}) {
|
||||
get() {
|
||||
return getStoredActiveWorkstream(cwd, opts);
|
||||
},
|
||||
set(name) {
|
||||
set(name: string) {
|
||||
setStoredActiveWorkstream(cwd, name, opts);
|
||||
},
|
||||
clear() {
|
||||
@@ -186,11 +221,11 @@ function createPlanningWorkspace(cwd, opts = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveWorkstream(cwd) {
|
||||
function getActiveWorkstream(cwd: string): string | null {
|
||||
return getStoredActiveWorkstream(cwd);
|
||||
}
|
||||
|
||||
function setActiveWorkstream(cwd, name) {
|
||||
function setActiveWorkstream(cwd: string, name: string): void {
|
||||
setStoredActiveWorkstream(cwd, name);
|
||||
}
|
||||
|
||||
@@ -206,24 +241,23 @@ function setActiveWorkstream(cwd, name) {
|
||||
* duplication that previously existed across init.cjs, roadmap.cjs,
|
||||
* core.cjs, gap-checker.cjs (#3739).
|
||||
*
|
||||
* @param {string|string[]} absDirOrFiles - Absolute path to the phase directory,
|
||||
* @param absDirOrFiles - Absolute path to the phase directory,
|
||||
* OR an already-read files array (avoids a redundant readdirSync at call sites
|
||||
* that already hold a directory listing).
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function findContextMdIn(absDirOrFiles) {
|
||||
function findContextMdIn(absDirOrFiles: string | string[]): string | null {
|
||||
try {
|
||||
const files = Array.isArray(absDirOrFiles)
|
||||
? absDirOrFiles
|
||||
: fs.readdirSync(absDirOrFiles);
|
||||
if (files.includes('CONTEXT.md')) return 'CONTEXT.md';
|
||||
return files.find(f => f.endsWith('-CONTEXT.md')) ?? null;
|
||||
return files.find((f: string) => f.endsWith('-CONTEXT.md')) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
createPlanningWorkspace,
|
||||
createSharedPointerAdapter,
|
||||
createSessionScopedPointerAdapter,
|
||||
@@ -7,16 +7,99 @@
|
||||
* - generate-dev-preferences: dev-preferences.md command artifact
|
||||
* - generate-claude-profile: Developer Profile section in CLAUDE.md
|
||||
* - generate-claude-md: full CLAUDE.md with managed sections
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/profile-output.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only strict types are added.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { output, error, loadConfig } = require('./core.cjs');
|
||||
const { platformReadSync: safeReadFile, platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { getGlobalSkillDir } = require('./runtime-homes.cjs');
|
||||
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
|
||||
const { resolveRuntimeNameFromCandidates } = require('./runtime-name-policy.cjs');
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output, error, loadConfig } = core;
|
||||
import { platformReadSync as safeReadFile, platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { getGlobalSkillDir } from './runtime-homes.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
import { resolveRuntimeNameFromCandidates } from './runtime-name-policy.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProfilingOption {
|
||||
label: string;
|
||||
value: string;
|
||||
rating: string;
|
||||
}
|
||||
|
||||
interface ProfilingQuestion {
|
||||
dimension: string;
|
||||
header: string;
|
||||
context: string;
|
||||
question: string;
|
||||
options: ProfilingOption[];
|
||||
}
|
||||
|
||||
interface EvidenceEntry {
|
||||
signal?: string;
|
||||
quote?: string;
|
||||
example?: string;
|
||||
pattern?: string;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
interface DimensionData {
|
||||
rating?: string;
|
||||
confidence?: string;
|
||||
evidence_count?: number;
|
||||
cross_project_consistent?: boolean | null;
|
||||
evidence?: EvidenceEntry[];
|
||||
evidence_quotes?: EvidenceEntry[];
|
||||
summary?: string;
|
||||
claude_instruction?: string;
|
||||
}
|
||||
|
||||
interface AnalysisData {
|
||||
profile_version?: string;
|
||||
analyzed_at?: string;
|
||||
data_source?: string;
|
||||
projects_list?: string[];
|
||||
projects_analyzed?: string[];
|
||||
message_count?: number;
|
||||
messages_analyzed?: number;
|
||||
message_threshold?: string;
|
||||
sensitive_excluded?: unknown[];
|
||||
dimensions: Record<string, DimensionData>;
|
||||
}
|
||||
|
||||
interface SectionResult {
|
||||
content: string;
|
||||
source: string;
|
||||
linkPath?: string | null;
|
||||
hasFallback: boolean;
|
||||
}
|
||||
|
||||
interface CmdWriteProfileOptions {
|
||||
input?: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface CmdGenerateDevPreferencesOptions {
|
||||
analysis?: string;
|
||||
output?: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
interface CmdGenerateClaudeProfileOptions {
|
||||
analysis?: string;
|
||||
output?: string;
|
||||
global?: boolean;
|
||||
}
|
||||
|
||||
interface CmdGenerateClaudeMdOptions {
|
||||
output?: string;
|
||||
auto?: boolean;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,7 +109,7 @@ const DIMENSION_KEYS = [
|
||||
'frustration_triggers', 'learning_style'
|
||||
];
|
||||
|
||||
const PROFILING_QUESTIONS = [
|
||||
const PROFILING_QUESTIONS: ProfilingQuestion[] = [
|
||||
{
|
||||
dimension: 'communication_style',
|
||||
header: 'Communication Style',
|
||||
@@ -125,7 +208,7 @@ const PROFILING_QUESTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
const CLAUDE_INSTRUCTIONS = {
|
||||
const CLAUDE_INSTRUCTIONS: Record<string, Record<string, string>> = {
|
||||
communication_style: {
|
||||
'terse-direct': 'Keep responses concise and action-oriented. Skip lengthy preambles. Match this developer\'s direct style.',
|
||||
'conversational': 'Use a natural conversational tone. Explain reasoning briefly alongside code. Engage with the developer\'s questions.',
|
||||
@@ -180,9 +263,9 @@ const CLAUDE_INSTRUCTIONS = {
|
||||
// commands route correctly under the active install (#3584). The values must
|
||||
// be computed per-call rather than at module load because the slash form
|
||||
// depends on the runtime resolved from the project's config/env.
|
||||
function buildClaudeMdFallbacks(runtime) {
|
||||
function buildClaudeMdFallbacks(runtime: unknown): Record<string, string> {
|
||||
return {
|
||||
project: `Project not yet initialized. Run ${formatGsdSlash('new-project', runtime)} to set up.`,
|
||||
project: `Project not yet initialized. Run ${String(formatGsdSlash('new-project', runtime))} to set up.`,
|
||||
stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.',
|
||||
conventions: 'Conventions not yet established. Will populate as patterns emerge during development.',
|
||||
architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.',
|
||||
@@ -193,25 +276,25 @@ function buildClaudeMdFallbacks(runtime) {
|
||||
// Directories where project skills may live (checked in order)
|
||||
const SKILL_SEARCH_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.github/skills', '.codex/skills'];
|
||||
|
||||
function buildClaudeMdWorkflowEnforcement(runtime) {
|
||||
function buildClaudeMdWorkflowEnforcement(runtime: unknown): string {
|
||||
return [
|
||||
'Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.',
|
||||
'',
|
||||
'Use these entry points:',
|
||||
`- \`${formatGsdSlash('quick', runtime)}\` for small fixes, doc updates, and ad-hoc tasks`,
|
||||
`- \`${formatGsdSlash('debug', runtime)}\` for investigation and bug fixing`,
|
||||
`- \`${formatGsdSlash('execute-phase', runtime)}\` for planned phase work`,
|
||||
`- \`${String(formatGsdSlash('quick', runtime))}\` for small fixes, doc updates, and ad-hoc tasks`,
|
||||
`- \`${String(formatGsdSlash('debug', runtime))}\` for investigation and bug fixing`,
|
||||
`- \`${String(formatGsdSlash('execute-phase', runtime))}\` for planned phase work`,
|
||||
'',
|
||||
'Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildClaudeMdProfilePlaceholder(runtime) {
|
||||
function buildClaudeMdProfilePlaceholder(runtime: unknown): string {
|
||||
return [
|
||||
'<!-- GSD:profile-start -->',
|
||||
'## Developer Profile',
|
||||
'',
|
||||
`> Profile not yet configured. Run \`${formatGsdSlash('profile-user', runtime)}\` to generate your developer profile.`,
|
||||
`> Profile not yet configured. Run \`${String(formatGsdSlash('profile-user', runtime))}\` to generate your developer profile.`,
|
||||
'> This section is managed by `generate-claude-profile` -- do not edit manually.',
|
||||
'<!-- GSD:profile-end -->',
|
||||
].join('\n');
|
||||
@@ -219,7 +302,7 @@ function buildClaudeMdProfilePlaceholder(runtime) {
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
function isAmbiguousAnswer(dimension, value) {
|
||||
function isAmbiguousAnswer(dimension: string, value: string): boolean {
|
||||
if (dimension === 'communication_style' && value === 'd') return true;
|
||||
const question = PROFILING_QUESTIONS.find(q => q.dimension === dimension);
|
||||
if (!question) return false;
|
||||
@@ -228,7 +311,7 @@ function isAmbiguousAnswer(dimension, value) {
|
||||
return option.rating === 'mixed';
|
||||
}
|
||||
|
||||
function generateClaudeInstruction(dimension, rating) {
|
||||
function generateClaudeInstruction(dimension: string, rating: string): string {
|
||||
const dimInstructions = CLAUDE_INSTRUCTIONS[dimension];
|
||||
if (dimInstructions && dimInstructions[rating]) {
|
||||
return dimInstructions[rating];
|
||||
@@ -236,7 +319,7 @@ function generateClaudeInstruction(dimension, rating) {
|
||||
return `Adapt to this developer's ${dimension.replace(/_/g, ' ')} preference: ${rating}.`;
|
||||
}
|
||||
|
||||
function extractSectionContent(fileContent, sectionName) {
|
||||
function extractSectionContent(fileContent: string, sectionName: string): string | null {
|
||||
const startMarker = `<!-- GSD:${sectionName}-start`;
|
||||
const endMarker = `<!-- GSD:${sectionName}-end -->`;
|
||||
const startIdx = fileContent.indexOf(startMarker);
|
||||
@@ -247,7 +330,7 @@ function extractSectionContent(fileContent, sectionName) {
|
||||
return fileContent.substring(startTagEnd + 3, endIdx);
|
||||
}
|
||||
|
||||
function buildSection(sectionName, sourceFile, content) {
|
||||
function buildSection(sectionName: string, sourceFile: string, content: string): string {
|
||||
return [
|
||||
`<!-- GSD:${sectionName}-start source:${sourceFile} -->`,
|
||||
content,
|
||||
@@ -255,7 +338,7 @@ function buildSection(sectionName, sourceFile, content) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function updateSection(fileContent, sectionName, newContent) {
|
||||
function updateSection(fileContent: string, sectionName: string, newContent: string): { content: string; action: string } {
|
||||
const startMarker = `<!-- GSD:${sectionName}-start`;
|
||||
const endMarker = `<!-- GSD:${sectionName}-end -->`;
|
||||
const startIdx = fileContent.indexOf(startMarker);
|
||||
@@ -268,18 +351,18 @@ function updateSection(fileContent, sectionName, newContent) {
|
||||
return { content: fileContent.trimEnd() + '\n\n' + newContent + '\n', action: 'appended' };
|
||||
}
|
||||
|
||||
function detectManualEdit(fileContent, sectionName, expectedContent) {
|
||||
function detectManualEdit(fileContent: string, sectionName: string, expectedContent: string): boolean {
|
||||
const currentContent = extractSectionContent(fileContent, sectionName);
|
||||
if (currentContent === null) return false;
|
||||
const normalize = (s) => s.trim().replace(/\n{3,}/g, '\n\n');
|
||||
const normalize = (s: string) => s.trim().replace(/\n{3,}/g, '\n\n');
|
||||
return normalize(currentContent) !== normalize(expectedContent);
|
||||
}
|
||||
|
||||
function extractMarkdownSection(content, sectionName) {
|
||||
function extractMarkdownSection(content: string | null, sectionName: string): string | null {
|
||||
if (!content) return null;
|
||||
const lines = content.split('\n');
|
||||
let capturing = false;
|
||||
const result = [];
|
||||
const result: string[] = [];
|
||||
const headingPattern = new RegExp(`^## ${sectionName}\\s*$`);
|
||||
for (const line of lines) {
|
||||
if (headingPattern.test(line)) {
|
||||
@@ -295,14 +378,14 @@ function extractMarkdownSection(content, sectionName) {
|
||||
|
||||
// ─── CLAUDE.md Section Generators ─────────────────────────────────────────────
|
||||
|
||||
function generateProjectSection(cwd) {
|
||||
function generateProjectSection(cwd: string): SectionResult {
|
||||
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
|
||||
const projectPath = path.join(cwd, '.planning', 'PROJECT.md');
|
||||
const content = safeReadFile(projectPath);
|
||||
if (!content) {
|
||||
return { content: fallbacks.project, source: 'PROJECT.md', linkPath: null, hasFallback: true };
|
||||
return { content: fallbacks['project'], source: 'PROJECT.md', linkPath: null, hasFallback: true };
|
||||
}
|
||||
const parts = [];
|
||||
const parts: string[] = [];
|
||||
const h1Match = content.match(/^# (.+)$/m);
|
||||
if (h1Match) parts.push(`**${h1Match[1]}**`);
|
||||
const whatThisIs = extractMarkdownSection(content, 'What This Is');
|
||||
@@ -321,12 +404,12 @@ function generateProjectSection(cwd) {
|
||||
if (body) parts.push(`### Constraints\n\n${body}`);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return { content: fallbacks.project, source: 'PROJECT.md', linkPath: null, hasFallback: true };
|
||||
return { content: fallbacks['project'], source: 'PROJECT.md', linkPath: null, hasFallback: true };
|
||||
}
|
||||
return { content: parts.join('\n\n'), source: 'PROJECT.md', linkPath: '.planning/PROJECT.md', hasFallback: false };
|
||||
}
|
||||
|
||||
function generateStackSection(cwd) {
|
||||
function generateStackSection(cwd: string): SectionResult {
|
||||
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
|
||||
const codebasePath = path.join(cwd, '.planning', 'codebase', 'STACK.md');
|
||||
const researchPath = path.join(cwd, '.planning', 'research', 'STACK.md');
|
||||
@@ -339,10 +422,10 @@ function generateStackSection(cwd) {
|
||||
linkPath = '.planning/research/STACK.md';
|
||||
}
|
||||
if (!content) {
|
||||
return { content: fallbacks.stack, source: 'STACK.md', linkPath: null, hasFallback: true };
|
||||
return { content: fallbacks['stack'], source: 'STACK.md', linkPath: null, hasFallback: true };
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const summaryLines = [];
|
||||
const summaryLines: string[] = [];
|
||||
let inTable = false;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#')) {
|
||||
@@ -357,15 +440,15 @@ function generateStackSection(cwd) {
|
||||
return { content: summary, source, linkPath, hasFallback: false };
|
||||
}
|
||||
|
||||
function generateConventionsSection(cwd) {
|
||||
function generateConventionsSection(cwd: string): SectionResult {
|
||||
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
|
||||
const conventionsPath = path.join(cwd, '.planning', 'codebase', 'CONVENTIONS.md');
|
||||
const content = safeReadFile(conventionsPath);
|
||||
if (!content) {
|
||||
return { content: fallbacks.conventions, source: 'CONVENTIONS.md', linkPath: null, hasFallback: true };
|
||||
return { content: fallbacks['conventions'], source: 'CONVENTIONS.md', linkPath: null, hasFallback: true };
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const summaryLines = [];
|
||||
const summaryLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#')) { if (!line.startsWith('# ')) summaryLines.push(line); continue; }
|
||||
if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('|')) summaryLines.push(line);
|
||||
@@ -374,15 +457,15 @@ function generateConventionsSection(cwd) {
|
||||
return { content: summary, source: 'CONVENTIONS.md', linkPath: '.planning/codebase/CONVENTIONS.md', hasFallback: false };
|
||||
}
|
||||
|
||||
function generateArchitectureSection(cwd) {
|
||||
function generateArchitectureSection(cwd: string): SectionResult {
|
||||
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
|
||||
const architecturePath = path.join(cwd, '.planning', 'codebase', 'ARCHITECTURE.md');
|
||||
const content = safeReadFile(architecturePath);
|
||||
if (!content) {
|
||||
return { content: fallbacks.architecture, source: 'ARCHITECTURE.md', linkPath: null, hasFallback: true };
|
||||
return { content: fallbacks['architecture'], source: 'ARCHITECTURE.md', linkPath: null, hasFallback: true };
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const summaryLines = [];
|
||||
const summaryLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#')) { if (!line.startsWith('# ')) summaryLines.push(line); continue; }
|
||||
if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('|') || line.startsWith('```')) summaryLines.push(line);
|
||||
@@ -391,7 +474,7 @@ function generateArchitectureSection(cwd) {
|
||||
return { content: summary, source: 'ARCHITECTURE.md', linkPath: '.planning/codebase/ARCHITECTURE.md', hasFallback: false };
|
||||
}
|
||||
|
||||
function generateWorkflowSection(cwd) {
|
||||
function generateWorkflowSection(cwd: string): SectionResult {
|
||||
return {
|
||||
content: buildClaudeMdWorkflowEnforcement(resolveRuntime(cwd)),
|
||||
source: 'GSD defaults',
|
||||
@@ -405,15 +488,15 @@ function generateWorkflowSection(cwd) {
|
||||
* (name + description) for each. Returns a table summary for CLAUDE.md so
|
||||
* agents know which skills are available at session startup (Layer 1 discovery).
|
||||
*/
|
||||
function generateSkillsSection(cwd) {
|
||||
function generateSkillsSection(cwd: string): SectionResult {
|
||||
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
|
||||
const discovered = [];
|
||||
const discovered: Array<{ name: string; description: string; path: string }> = [];
|
||||
|
||||
for (const dir of SKILL_SEARCH_DIRS) {
|
||||
const absDir = path.join(cwd, dir);
|
||||
if (!fs.existsSync(absDir)) continue;
|
||||
|
||||
let entries;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
@@ -443,7 +526,7 @@ function generateSkillsSection(cwd) {
|
||||
}
|
||||
|
||||
if (discovered.length === 0) {
|
||||
return { content: fallbacks.skills, source: 'skills/', hasFallback: true };
|
||||
return { content: fallbacks['skills'], source: 'skills/', hasFallback: true };
|
||||
}
|
||||
|
||||
const lines = ['| Skill | Description | Path |', '|-------|-------------|------|'];
|
||||
@@ -461,7 +544,7 @@ function generateSkillsSection(cwd) {
|
||||
* Extract name and description from YAML-like frontmatter in a SKILL.md file.
|
||||
* Handles multi-line description values (continuation lines indented with spaces).
|
||||
*/
|
||||
function extractSkillFrontmatter(content) {
|
||||
function extractSkillFrontmatter(content: string): { name: string; description: string } {
|
||||
const result = { name: '', description: '' };
|
||||
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return result;
|
||||
@@ -493,28 +576,28 @@ function extractSkillFrontmatter(content) {
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function cmdWriteProfile(cwd, options, raw) {
|
||||
function cmdWriteProfile(cwd: string, options: CmdWriteProfileOptions, raw: boolean): void {
|
||||
if (!options.input) {
|
||||
error('--input <analysis-json-path> is required');
|
||||
}
|
||||
|
||||
let analysisPath = options.input;
|
||||
let analysisPath = options.input!;
|
||||
if (!path.isAbsolute(analysisPath)) analysisPath = path.join(cwd, analysisPath);
|
||||
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
|
||||
|
||||
let analysis;
|
||||
let analysis: AnalysisData;
|
||||
const analysisRaw = safeReadFile(analysisPath);
|
||||
try {
|
||||
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
|
||||
analysis = JSON.parse(analysisRaw);
|
||||
analysis = JSON.parse(analysisRaw) as AnalysisData;
|
||||
} catch (err) {
|
||||
error(`Failed to parse analysis JSON: ${err.message}`);
|
||||
error(`Failed to parse analysis JSON: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
if (!analysis.dimensions || typeof analysis.dimensions !== 'object') {
|
||||
if (!analysis!.dimensions || typeof analysis!.dimensions !== 'object') {
|
||||
error('Analysis JSON must contain a "dimensions" object');
|
||||
}
|
||||
if (!analysis.profile_version) {
|
||||
if (!analysis!.profile_version) {
|
||||
error('Analysis JSON must contain "profile_version"');
|
||||
}
|
||||
|
||||
@@ -534,7 +617,7 @@ function cmdWriteProfile(cwd, options, raw) {
|
||||
|
||||
let redactedCount = 0;
|
||||
|
||||
function redactSensitive(text) {
|
||||
function redactSensitive(text: unknown): unknown {
|
||||
if (typeof text !== 'string') return text;
|
||||
let result = text;
|
||||
for (const pattern of SENSITIVE_PATTERNS) {
|
||||
@@ -548,14 +631,14 @@ function cmdWriteProfile(cwd, options, raw) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const dimKey of Object.keys(analysis.dimensions)) {
|
||||
const dim = analysis.dimensions[dimKey];
|
||||
for (const dimKey of Object.keys(analysis!.dimensions)) {
|
||||
const dim = analysis!.dimensions[dimKey];
|
||||
if (dim.evidence && Array.isArray(dim.evidence)) {
|
||||
for (let i = 0; i < dim.evidence.length; i++) {
|
||||
const ev = dim.evidence[i];
|
||||
if (ev.quote) ev.quote = redactSensitive(ev.quote);
|
||||
if (ev.example) ev.example = redactSensitive(ev.example);
|
||||
if (ev.signal) ev.signal = redactSensitive(ev.signal);
|
||||
if (ev.quote) ev.quote = redactSensitive(ev.quote) as string;
|
||||
if (ev.example) ev.example = redactSensitive(ev.example) as string;
|
||||
if (ev.signal) ev.signal = redactSensitive(ev.signal) as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,7 +651,7 @@ function cmdWriteProfile(cwd, options, raw) {
|
||||
if (!fs.existsSync(templatePath)) error(`Template not found: ${templatePath}`);
|
||||
let template = fs.readFileSync(templatePath, 'utf-8');
|
||||
|
||||
const dimensionLabels = {
|
||||
const dimensionLabels: Record<string, string> = {
|
||||
communication_style: 'Communication',
|
||||
decision_speed: 'Decisions',
|
||||
explanation_depth: 'Explanations',
|
||||
@@ -579,11 +662,11 @@ function cmdWriteProfile(cwd, options, raw) {
|
||||
learning_style: 'Learning Style',
|
||||
};
|
||||
|
||||
const summaryLines = [];
|
||||
const summaryLines: string[] = [];
|
||||
let highCount = 0, mediumCount = 0, lowCount = 0, dimensionsScored = 0;
|
||||
|
||||
for (const dimKey of DIMENSION_KEYS) {
|
||||
const dim = analysis.dimensions[dimKey];
|
||||
const dim = analysis!.dimensions[dimKey];
|
||||
if (!dim) continue;
|
||||
const conf = (dim.confidence || '').toUpperCase();
|
||||
if (conf === 'HIGH' || conf === 'MEDIUM' || conf === 'LOW') dimensionsScored++;
|
||||
@@ -603,12 +686,12 @@ function cmdWriteProfile(cwd, options, raw) {
|
||||
: '- No high or medium confidence dimensions scored yet.';
|
||||
|
||||
template = template.replace(/\{\{generated_at\}\}/g, new Date().toISOString());
|
||||
template = template.replace(/\{\{data_source\}\}/g, analysis.data_source || 'session_analysis');
|
||||
template = template.replace(/\{\{projects_list\}\}/g, (analysis.projects_list || analysis.projects_analyzed || []).join(', '));
|
||||
template = template.replace(/\{\{message_count\}\}/g, String(analysis.message_count || analysis.messages_analyzed || 0));
|
||||
template = template.replace(/\{\{data_source\}\}/g, analysis!.data_source || 'session_analysis');
|
||||
template = template.replace(/\{\{projects_list\}\}/g, (analysis!.projects_list || analysis!.projects_analyzed || []).join(', '));
|
||||
template = template.replace(/\{\{message_count\}\}/g, String(analysis!.message_count || analysis!.messages_analyzed || 0));
|
||||
template = template.replace(/\{\{summary_instructions\}\}/g, summaryInstructions);
|
||||
template = template.replace(/\{\{profile_version\}\}/g, analysis.profile_version);
|
||||
template = template.replace(/\{\{projects_count\}\}/g, String((analysis.projects_list || analysis.projects_analyzed || []).length));
|
||||
template = template.replace(/\{\{profile_version\}\}/g, analysis!.profile_version!);
|
||||
template = template.replace(/\{\{projects_count\}\}/g, String((analysis!.projects_list || analysis!.projects_analyzed || []).length));
|
||||
template = template.replace(/\{\{dimensions_scored\}\}/g, String(dimensionsScored));
|
||||
template = template.replace(/\{\{high_confidence_count\}\}/g, String(highCount));
|
||||
template = template.replace(/\{\{medium_confidence_count\}\}/g, String(mediumCount));
|
||||
@@ -617,7 +700,7 @@ function cmdWriteProfile(cwd, options, raw) {
|
||||
redactedCount > 0 ? `${redactedCount} pattern(s) redacted` : 'None detected');
|
||||
|
||||
for (const dimKey of DIMENSION_KEYS) {
|
||||
const dim = analysis.dimensions[dimKey] || {};
|
||||
const dim = analysis!.dimensions[dimKey] || {};
|
||||
const rating = dim.rating || 'UNSCORED';
|
||||
const confidence = dim.confidence || 'UNSCORED';
|
||||
const instruction = dim.claude_instruction || 'No strong preference detected. Ask the developer when this dimension is relevant.';
|
||||
@@ -661,13 +744,13 @@ function cmdWriteProfile(cwd, options, raw) {
|
||||
medium_confidence: mediumCount,
|
||||
low_confidence: lowCount,
|
||||
sensitive_redacted: redactedCount,
|
||||
source: analysis.data_source || 'session_analysis',
|
||||
source: analysis!.data_source || 'session_analysis',
|
||||
};
|
||||
|
||||
output(result, raw);
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
function cmdProfileQuestionnaire(options, raw) {
|
||||
function cmdProfileQuestionnaire(options: { answers?: string }, raw: boolean): void {
|
||||
if (!options.answers) {
|
||||
const questionsOutput = {
|
||||
mode: 'interactive',
|
||||
@@ -679,7 +762,7 @@ function cmdProfileQuestionnaire(options, raw) {
|
||||
options: q.options.map(o => ({ label: o.label, value: o.value })),
|
||||
})),
|
||||
};
|
||||
output(questionsOutput, raw);
|
||||
output(questionsOutput, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -688,7 +771,7 @@ function cmdProfileQuestionnaire(options, raw) {
|
||||
error(`Expected ${PROFILING_QUESTIONS.length} answers (comma-separated), got ${answerValues.length}`);
|
||||
}
|
||||
|
||||
const analysis = {
|
||||
const analysis: AnalysisData = {
|
||||
profile_version: '1.0',
|
||||
analyzed_at: new Date().toISOString(),
|
||||
data_source: 'questionnaire',
|
||||
@@ -711,44 +794,44 @@ function cmdProfileQuestionnaire(options, raw) {
|
||||
const ambiguous = isAmbiguousAnswer(question.dimension, answerValue);
|
||||
|
||||
analysis.dimensions[question.dimension] = {
|
||||
rating: selectedOption.rating,
|
||||
rating: selectedOption!.rating,
|
||||
confidence: ambiguous ? 'LOW' : 'MEDIUM',
|
||||
evidence_count: 1,
|
||||
cross_project_consistent: null,
|
||||
evidence: [{
|
||||
signal: 'Self-reported via questionnaire',
|
||||
quote: selectedOption.label,
|
||||
quote: selectedOption!.label,
|
||||
project: 'N/A (questionnaire)',
|
||||
}],
|
||||
summary: `Developer self-reported as ${selectedOption.rating} for ${question.header.toLowerCase()}.`,
|
||||
claude_instruction: generateClaudeInstruction(question.dimension, selectedOption.rating),
|
||||
summary: `Developer self-reported as ${selectedOption!.rating} for ${question.header.toLowerCase()}.`,
|
||||
claude_instruction: generateClaudeInstruction(question.dimension, selectedOption!.rating),
|
||||
};
|
||||
}
|
||||
|
||||
output(analysis, raw);
|
||||
output(analysis, raw, undefined);
|
||||
}
|
||||
|
||||
function cmdGenerateDevPreferences(cwd, options, raw) {
|
||||
function cmdGenerateDevPreferences(cwd: string, options: CmdGenerateDevPreferencesOptions, raw: boolean): void {
|
||||
if (!options.analysis) error('--analysis <path> is required');
|
||||
|
||||
let analysisPath = options.analysis;
|
||||
let analysisPath = options.analysis!;
|
||||
if (!path.isAbsolute(analysisPath)) analysisPath = path.join(cwd, analysisPath);
|
||||
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
|
||||
|
||||
let analysis;
|
||||
let analysis: AnalysisData;
|
||||
const analysisRaw = safeReadFile(analysisPath);
|
||||
try {
|
||||
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
|
||||
analysis = JSON.parse(analysisRaw);
|
||||
analysis = JSON.parse(analysisRaw) as AnalysisData;
|
||||
} catch (err) {
|
||||
error(`Failed to parse analysis JSON: ${err.message}`);
|
||||
error(`Failed to parse analysis JSON: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
if (!analysis.dimensions || typeof analysis.dimensions !== 'object') {
|
||||
if (!analysis!.dimensions || typeof analysis!.dimensions !== 'object') {
|
||||
error('Analysis JSON must contain a "dimensions" object');
|
||||
}
|
||||
|
||||
const devPrefLabels = {
|
||||
const devPrefLabels: Record<string, string> = {
|
||||
communication_style: 'Communication',
|
||||
decision_speed: 'Decision Support',
|
||||
explanation_depth: 'Explanations',
|
||||
@@ -763,11 +846,11 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
|
||||
if (!fs.existsSync(templatePath)) error(`Template not found: ${templatePath}`);
|
||||
let template = fs.readFileSync(templatePath, 'utf-8');
|
||||
|
||||
const directiveLines = [];
|
||||
const dimensionsIncluded = [];
|
||||
const directiveLines: string[] = [];
|
||||
const dimensionsIncluded: string[] = [];
|
||||
|
||||
for (const dimKey of DIMENSION_KEYS) {
|
||||
const dim = analysis.dimensions[dimKey];
|
||||
const dim = analysis!.dimensions[dimKey];
|
||||
if (!dim) continue;
|
||||
const label = devPrefLabels[dimKey] || dimKey;
|
||||
const confidence = dim.confidence || 'UNSCORED';
|
||||
@@ -787,11 +870,11 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
|
||||
const directivesBlock = directiveLines.join('\n').trim();
|
||||
template = template.replace(/\{\{behavioral_directives\}\}/g, directivesBlock);
|
||||
template = template.replace(/\{\{generated_at\}\}/g, new Date().toISOString());
|
||||
template = template.replace(/\{\{data_source\}\}/g, analysis.data_source || 'session_analysis');
|
||||
template = template.replace(/\{\{data_source\}\}/g, analysis!.data_source || 'session_analysis');
|
||||
|
||||
let stackBlock;
|
||||
if (analysis.data_source === 'questionnaire') {
|
||||
stackBlock = `Stack preferences not available (questionnaire-only profile). Run \`${formatGsdSlash('profile-user', resolveRuntime(cwd))} --refresh\` with session data to populate.`;
|
||||
let stackBlock: string;
|
||||
if (analysis!.data_source === 'questionnaire') {
|
||||
stackBlock = `Stack preferences not available (questionnaire-only profile). Run \`${String(formatGsdSlash('profile-user', resolveRuntime(cwd)))} --refresh\` with session data to populate.`;
|
||||
} else if (options.stack) {
|
||||
stackBlock = options.stack;
|
||||
} else {
|
||||
@@ -813,13 +896,13 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
|
||||
try {
|
||||
const config = loadConfig(cwd);
|
||||
effectiveRuntime = resolveRuntimeNameFromCandidates(
|
||||
process.env.GSD_RUNTIME,
|
||||
config.runtime,
|
||||
process.env['GSD_RUNTIME'],
|
||||
config['runtime'],
|
||||
'claude'
|
||||
) || 'claude';
|
||||
} catch {
|
||||
effectiveRuntime = resolveRuntimeNameFromCandidates(
|
||||
process.env.GSD_RUNTIME,
|
||||
process.env['GSD_RUNTIME'],
|
||||
'claude'
|
||||
) || 'claude';
|
||||
}
|
||||
@@ -827,7 +910,7 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
|
||||
if (!skillDir) {
|
||||
error(`Runtime "${effectiveRuntime}" does not use a skills directory; pass --output to choose a path explicitly.`);
|
||||
}
|
||||
outputPath = path.join(skillDir, 'SKILL.md');
|
||||
outputPath = path.join(skillDir!, 'SKILL.md');
|
||||
} else if (!path.isAbsolute(outputPath)) {
|
||||
outputPath = path.join(cwd, outputPath);
|
||||
}
|
||||
@@ -839,33 +922,33 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
|
||||
command_path: outputPath,
|
||||
command_name: formatGsdSlash('dev-preferences', resolveRuntime(cwd)),
|
||||
dimensions_included: dimensionsIncluded,
|
||||
source: analysis.data_source || 'session_analysis',
|
||||
source: analysis!.data_source || 'session_analysis',
|
||||
};
|
||||
|
||||
output(result, raw);
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
function cmdGenerateClaudeProfile(cwd, options, raw) {
|
||||
function cmdGenerateClaudeProfile(cwd: string, options: CmdGenerateClaudeProfileOptions, raw: boolean): void {
|
||||
if (!options.analysis) error('--analysis <path> is required');
|
||||
|
||||
let analysisPath = options.analysis;
|
||||
let analysisPath = options.analysis!;
|
||||
if (!path.isAbsolute(analysisPath)) analysisPath = path.join(cwd, analysisPath);
|
||||
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
|
||||
|
||||
let analysis;
|
||||
let analysis: AnalysisData;
|
||||
const analysisRaw = safeReadFile(analysisPath);
|
||||
try {
|
||||
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
|
||||
analysis = JSON.parse(analysisRaw);
|
||||
analysis = JSON.parse(analysisRaw) as AnalysisData;
|
||||
} catch (err) {
|
||||
error(`Failed to parse analysis JSON: ${err.message}`);
|
||||
error(`Failed to parse analysis JSON: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
if (!analysis.dimensions || typeof analysis.dimensions !== 'object') {
|
||||
if (!analysis!.dimensions || typeof analysis!.dimensions !== 'object') {
|
||||
error('Analysis JSON must contain a "dimensions" object');
|
||||
}
|
||||
|
||||
const profileLabels = {
|
||||
const profileLabels: Record<string, string> = {
|
||||
communication_style: 'Communication',
|
||||
decision_speed: 'Decisions',
|
||||
explanation_depth: 'Explanations',
|
||||
@@ -876,13 +959,13 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
||||
learning_style: 'Learning',
|
||||
};
|
||||
|
||||
const dataSource = analysis.data_source || 'session_analysis';
|
||||
const tableRows = [];
|
||||
const directiveLines = [];
|
||||
const dimensionsIncluded = [];
|
||||
const dataSource = analysis!.data_source || 'session_analysis';
|
||||
const tableRows: string[] = [];
|
||||
const directiveLines: string[] = [];
|
||||
const dimensionsIncluded: string[] = [];
|
||||
|
||||
for (const dimKey of DIMENSION_KEYS) {
|
||||
const dim = analysis.dimensions[dimKey];
|
||||
const dim = analysis!.dimensions[dimKey];
|
||||
if (!dim) continue;
|
||||
const label = profileLabels[dimKey] || dimKey;
|
||||
const rating = dim.rating || 'UNSCORED';
|
||||
@@ -905,7 +988,7 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
||||
'<!-- GSD:profile-start -->',
|
||||
'## Developer Profile',
|
||||
'',
|
||||
`> Generated by GSD from ${dataSource}. Run \`${formatGsdSlash('profile-user', resolveRuntime(cwd))} --refresh\` to update.`,
|
||||
`> Generated by GSD from ${dataSource}. Run \`${String(formatGsdSlash('profile-user', resolveRuntime(cwd)))}\` to update.`,
|
||||
'',
|
||||
'| Dimension | Rating | Confidence |',
|
||||
'|-----------|--------|------------|',
|
||||
@@ -918,7 +1001,7 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
||||
|
||||
const sectionContent = sectionLines.join('\n');
|
||||
|
||||
let targetPath;
|
||||
let targetPath: string;
|
||||
if (options.global) {
|
||||
targetPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
||||
} else if (options.output) {
|
||||
@@ -928,12 +1011,12 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
||||
let configClaudeMdPath = './CLAUDE.md';
|
||||
try {
|
||||
const config = loadConfig(cwd);
|
||||
if (config.claude_md_path) configClaudeMdPath = config.claude_md_path;
|
||||
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
|
||||
} catch { /* use default */ }
|
||||
targetPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath);
|
||||
}
|
||||
|
||||
let action;
|
||||
let action: string;
|
||||
|
||||
let existingContent = safeReadFile(targetPath);
|
||||
if (existingContent !== null) {
|
||||
@@ -965,12 +1048,12 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
||||
is_global: !!options.global,
|
||||
};
|
||||
|
||||
output(result, raw);
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
function cmdGenerateClaudeMd(cwd: string, options: CmdGenerateClaudeMdOptions, raw: boolean): void {
|
||||
const MANAGED_SECTIONS = ['project', 'stack', 'conventions', 'architecture', 'skills', 'workflow'];
|
||||
const generators = {
|
||||
const generators: Record<string, (cwd: string) => SectionResult> = {
|
||||
project: generateProjectSection,
|
||||
stack: generateStackSection,
|
||||
conventions: generateConventionsSection,
|
||||
@@ -978,7 +1061,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
skills: generateSkillsSection,
|
||||
workflow: generateWorkflowSection,
|
||||
};
|
||||
const sectionHeadings = {
|
||||
const sectionHeadings: Record<string, string> = {
|
||||
project: '## Project',
|
||||
stack: '## Technology Stack',
|
||||
conventions: '## Conventions',
|
||||
@@ -987,10 +1070,10 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
workflow: '## GSD Workflow Enforcement',
|
||||
};
|
||||
|
||||
const generated = {};
|
||||
const sectionsGenerated = [];
|
||||
const sectionsFallback = [];
|
||||
const sectionsSkipped = [];
|
||||
const generated: Record<string, SectionResult> = {};
|
||||
const sectionsGenerated: string[] = [];
|
||||
const sectionsFallback: string[] = [];
|
||||
const sectionsSkipped: string[] = [];
|
||||
|
||||
for (const name of MANAGED_SECTIONS) {
|
||||
const gen = generators[name](cwd);
|
||||
@@ -1002,18 +1085,18 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
}
|
||||
}
|
||||
|
||||
let assemblyConfig = {};
|
||||
let assemblyConfig: Record<string, unknown> = {};
|
||||
let configClaudeMdPath = './CLAUDE.md';
|
||||
try {
|
||||
const config = loadConfig(cwd);
|
||||
if (config.claude_md_path) configClaudeMdPath = config.claude_md_path;
|
||||
if (config.claude_md_assembly) assemblyConfig = config.claude_md_assembly;
|
||||
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
|
||||
if (config['claude_md_assembly']) assemblyConfig = config['claude_md_assembly'] as Record<string, unknown>;
|
||||
// #3163: When runtime is codex, override the output target to AGENTS.md
|
||||
// regardless of claude_md_path, so Codex projects never write to CLAUDE.md.
|
||||
// GSD_RUNTIME env var takes precedence over config.runtime, mirroring detectRuntime().
|
||||
const effectiveRuntime = resolveRuntimeNameFromCandidates(
|
||||
process.env.GSD_RUNTIME,
|
||||
config.runtime
|
||||
process.env['GSD_RUNTIME'],
|
||||
config['runtime']
|
||||
);
|
||||
if (!options.output && effectiveRuntime === 'codex') {
|
||||
configClaudeMdPath = './AGENTS.md';
|
||||
@@ -1027,13 +1110,13 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
outputPath = path.join(cwd, outputPath);
|
||||
}
|
||||
|
||||
const globalAssemblyMode = assemblyConfig.mode || 'embed';
|
||||
const blockModes = assemblyConfig.blocks || {};
|
||||
const globalAssemblyMode = (assemblyConfig['mode'] as string) || 'embed';
|
||||
const blockModes = (assemblyConfig['blocks'] as Record<string, string>) || {};
|
||||
|
||||
// Return the assembled content for a section, respecting link vs embed mode.
|
||||
// "link" mode writes `@<linkPath>` when the generator has a real source file.
|
||||
// Falls back to "embed" for sections without a linkable source (workflow, fallbacks).
|
||||
function buildSectionContent(name, gen, heading) {
|
||||
function buildSectionContent(name: string, gen: SectionResult, heading: string): string {
|
||||
const effectiveMode = blockModes[name] || globalAssemblyMode;
|
||||
if (effectiveMode === 'link' && gen.linkPath && !gen.hasFallback) {
|
||||
return buildSection(name, gen.source, `${heading}\n\n@${gen.linkPath}`);
|
||||
@@ -1042,10 +1125,10 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
}
|
||||
|
||||
let existingContent = safeReadFile(outputPath);
|
||||
let action;
|
||||
let action: string;
|
||||
|
||||
if (existingContent === null) {
|
||||
const sections = [];
|
||||
const sections: string[] = [];
|
||||
for (const name of MANAGED_SECTIONS) {
|
||||
const gen = generated[name];
|
||||
const heading = sectionHeadings[name];
|
||||
@@ -1098,7 +1181,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
}
|
||||
|
||||
const finalContent = safeReadFile(outputPath);
|
||||
let profileStatus;
|
||||
let profileStatus: string;
|
||||
if (finalContent && finalContent.indexOf('<!-- GSD:profile-start') !== -1) {
|
||||
if (action === 'created' || existingContent.indexOf('<!-- GSD:profile-start') === -1) {
|
||||
profileStatus = 'placeholder_added';
|
||||
@@ -1114,7 +1197,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
let message = `Generated ${genCount}/${totalManaged} sections.`;
|
||||
if (sectionsFallback.length > 0) message += ` Fallback: ${sectionsFallback.join(', ')}.`;
|
||||
if (sectionsSkipped.length > 0) message += ` Skipped (manually edited): ${sectionsSkipped.join(', ')}.`;
|
||||
if (profileStatus === 'placeholder_added') message += ` Run ${formatGsdSlash('profile-user', resolveRuntime(cwd))} to unlock Developer Profile.`;
|
||||
if (profileStatus === 'placeholder_added') message += ` Run ${String(formatGsdSlash('profile-user', resolveRuntime(cwd)))} to unlock Developer Profile.`;
|
||||
|
||||
const result = {
|
||||
claude_md_path: outputPath,
|
||||
@@ -1127,10 +1210,10 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
message,
|
||||
};
|
||||
|
||||
output(result, raw);
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
cmdWriteProfile,
|
||||
cmdProfileQuestionnaire,
|
||||
cmdGenerateDevPreferences,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user