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:
350
src/active-workstream-store.cts
Normal file
350
src/active-workstream-store.cts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Active Workstream Pointer Store Module
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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: ReadonlyArray<string> = [
|
||||
'GSD_SESSION_KEY',
|
||||
'CODEX_THREAD_ID',
|
||||
'CLAUDE_SESSION_ID',
|
||||
'CLAUDE_CODE_SSE_PORT',
|
||||
'OPENCODE_SESSION_ID',
|
||||
'GEMINI_SESSION_ID',
|
||||
'CURSOR_SESSION_ID',
|
||||
'WINDSURF_SESSION_ID',
|
||||
'TERM_SESSION_ID',
|
||||
'WT_SESSION',
|
||||
'TMUX_PANE',
|
||||
'ZELLIJ_SESSION_NAME',
|
||||
];
|
||||
|
||||
let cachedControllingTtyToken: string | null = null;
|
||||
let didProbeControllingTtyToken = false;
|
||||
|
||||
function planningRoot(cwd: string): string {
|
||||
return path.join(cwd, '.planning');
|
||||
}
|
||||
|
||||
function validateWorkstreamName(name: string | null | undefined): boolean {
|
||||
return isValidActiveWorkstreamName(name);
|
||||
}
|
||||
|
||||
function sanitizeWorkstreamSessionToken(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
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(): string | null {
|
||||
if (didProbeControllingTtyToken) return cachedControllingTtyToken;
|
||||
didProbeControllingTtyToken = true;
|
||||
|
||||
if (!(process.stdin && process.stdin.isTTY)) {
|
||||
return cachedControllingTtyToken;
|
||||
}
|
||||
|
||||
const ttyPath = probeTty();
|
||||
if (ttyPath) {
|
||||
const token = sanitizeWorkstreamSessionToken(ttyPath.replace(/^\/dev\//, ''));
|
||||
if (token) cachedControllingTtyToken = `tty-${token}`;
|
||||
}
|
||||
|
||||
return cachedControllingTtyToken;
|
||||
}
|
||||
|
||||
function getControllingTtyToken(): string | null {
|
||||
for (const envKey of ['TTY', 'SSH_TTY']) {
|
||||
const token = sanitizeWorkstreamSessionToken(process.env[envKey]);
|
||||
if (token) return `tty-${token.replace(/^dev_/, '')}`;
|
||||
}
|
||||
|
||||
return probeControllingTtyToken();
|
||||
}
|
||||
|
||||
function getWorkstreamSessionKey(): string | null {
|
||||
for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) {
|
||||
const raw = process.env[envKey];
|
||||
const token = sanitizeWorkstreamSessionToken(raw);
|
||||
if (token) return `${envKey.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${token}`;
|
||||
}
|
||||
|
||||
return getControllingTtyToken();
|
||||
}
|
||||
|
||||
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: string;
|
||||
try {
|
||||
planningAbs = fs.realpathSync.native(planningRoot(cwd));
|
||||
} catch {
|
||||
planningAbs = path.resolve(planningRoot(cwd));
|
||||
}
|
||||
const projectId = crypto
|
||||
.createHash('sha1')
|
||||
.update(planningAbs)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
const dirPath = path.join(os.tmpdir(), 'gsd-workstream-sessions', projectId);
|
||||
return {
|
||||
sessionKey,
|
||||
dirPath,
|
||||
filePath: path.join(dirPath, sessionKey),
|
||||
};
|
||||
}
|
||||
|
||||
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(): string | null {
|
||||
const raw = platformReadSync(filePath);
|
||||
return raw ? raw.trim() || null : null;
|
||||
},
|
||||
write(name: string): void {
|
||||
platformWriteSync(filePath, name + '\n');
|
||||
},
|
||||
clear(): void {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionScopedPointerAdapter(cwd: string, fixedSessionKey?: string | null): WorkstreamPointerAdapter | null {
|
||||
const scoped = getSessionScopedWorkstreamFile(cwd, fixedSessionKey);
|
||||
if (!scoped) return null;
|
||||
|
||||
return {
|
||||
read(): string | null {
|
||||
const raw = platformReadSync(scoped.filePath);
|
||||
return raw ? raw.trim() || null : null;
|
||||
},
|
||||
write(name: string): void {
|
||||
platformEnsureDir(scoped.dirPath);
|
||||
platformWriteSync(scoped.filePath, name + '\n');
|
||||
},
|
||||
clear(): void {
|
||||
try { fs.unlinkSync(scoped.filePath); } catch {}
|
||||
try {
|
||||
const remaining = fs.readdirSync(scoped.dirPath);
|
||||
if (remaining.length === 0) {
|
||||
fs.rmdirSync(scoped.dirPath);
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMemoryPointerAdapter(initialName: string | null = null): WorkstreamPointerAdapter {
|
||||
let value: string | null = initialName;
|
||||
return {
|
||||
read(): string | null {
|
||||
return value;
|
||||
},
|
||||
write(name: string): void {
|
||||
value = name;
|
||||
},
|
||||
clear(): void {
|
||||
value = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const sessionKey = getWorkstreamSessionKey();
|
||||
if (sessionKey) {
|
||||
if (opts.activeWorkstreamAdapters && opts.activeWorkstreamAdapters.session) {
|
||||
return opts.activeWorkstreamAdapters.session;
|
||||
}
|
||||
return createSessionScopedPointerAdapter(cwd, sessionKey);
|
||||
}
|
||||
|
||||
if (opts.activeWorkstreamAdapters && opts.activeWorkstreamAdapters.shared) {
|
||||
return opts.activeWorkstreamAdapters.shared;
|
||||
}
|
||||
return createSharedPointerAdapter(cwd);
|
||||
}
|
||||
|
||||
function getActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): string | null {
|
||||
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
|
||||
if (!adapter) return null;
|
||||
|
||||
const name = adapter.read();
|
||||
if (!name || !validateWorkstreamName(name)) {
|
||||
adapter.clear();
|
||||
return null;
|
||||
}
|
||||
|
||||
const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
|
||||
if (!fs.existsSync(wsDir)) {
|
||||
adapter.clear();
|
||||
return null;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
function setActiveWorkstream(cwd: string, name: string | null | undefined, opts: ActiveWorkstreamOpts = {}): void {
|
||||
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
|
||||
if (!adapter) return;
|
||||
|
||||
if (!name) {
|
||||
adapter.clear();
|
||||
return;
|
||||
}
|
||||
if (!validateWorkstreamName(name)) {
|
||||
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, underscores, or dots');
|
||||
}
|
||||
|
||||
const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
|
||||
platformEnsureDir(wsDir);
|
||||
adapter.write(name);
|
||||
}
|
||||
|
||||
function clearActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): void {
|
||||
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
|
||||
if (!adapter) return;
|
||||
adapter.clear();
|
||||
}
|
||||
|
||||
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) {
|
||||
const value = wsEqArg.slice('--ws='.length).trim();
|
||||
if (!value) throw new Error('Missing value for --ws');
|
||||
return {
|
||||
value,
|
||||
source: 'cli',
|
||||
args: args.filter((arg) => arg !== wsEqArg),
|
||||
};
|
||||
}
|
||||
|
||||
if (wsIdx !== -1) {
|
||||
const value = args[wsIdx + 1];
|
||||
if (!value || value.startsWith('--')) throw new Error('Missing value for --ws');
|
||||
return {
|
||||
value,
|
||||
source: 'cli',
|
||||
args: args.filter((_: string, idx: number) => idx !== wsIdx && idx !== wsIdx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: null,
|
||||
source: null,
|
||||
args: args.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolvedWorkstream {
|
||||
ws: string | null;
|
||||
source: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
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 ?? '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;
|
||||
source = ws ? 'store' : 'none';
|
||||
}
|
||||
|
||||
if (ws && !validateWorkstreamName(ws)) {
|
||||
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, underscores, or dots');
|
||||
}
|
||||
|
||||
return {
|
||||
ws,
|
||||
source,
|
||||
args: parsed.args,
|
||||
};
|
||||
}
|
||||
|
||||
function applyResolvedWorkstreamEnv(
|
||||
resolution: ResolvedWorkstream | null | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): void {
|
||||
if (!resolution || !resolution.ws) return;
|
||||
env['GSD_WORKSTREAM'] = resolution.ws;
|
||||
}
|
||||
|
||||
export = {
|
||||
validateWorkstreamName,
|
||||
getWorkstreamSessionKey,
|
||||
createSharedPointerAdapter,
|
||||
createSessionScopedPointerAdapter,
|
||||
createMemoryPointerAdapter,
|
||||
pickActiveWorkstreamAdapter,
|
||||
getActiveWorkstream,
|
||||
setActiveWorkstream,
|
||||
clearActiveWorkstream,
|
||||
parseCliWorkstream,
|
||||
resolveActiveWorkstream,
|
||||
applyResolvedWorkstreamEnv,
|
||||
};
|
||||
452
src/adr-parser.cts
Normal file
452
src/adr-parser.cts
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { requireSafePath } from './security.cjs';
|
||||
|
||||
const STATUS_REJECT_SET = new Set(['superseded', 'rejected', 'deprecated']);
|
||||
|
||||
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',
|
||||
'background',
|
||||
'problem statement',
|
||||
'problem',
|
||||
'situation',
|
||||
'forces',
|
||||
'motivation',
|
||||
'issue',
|
||||
'drivers',
|
||||
'pain points',
|
||||
'story',
|
||||
'setting',
|
||||
'premise',
|
||||
'status quo',
|
||||
'context and problem statement',
|
||||
],
|
||||
decisions: [
|
||||
'decision',
|
||||
'decisions',
|
||||
'resolution',
|
||||
'conclusion',
|
||||
'choice',
|
||||
'we decided',
|
||||
'direction',
|
||||
'approach',
|
||||
'solution',
|
||||
'outcome',
|
||||
'selected option',
|
||||
'recommendation',
|
||||
'strategy',
|
||||
'decision outcome',
|
||||
],
|
||||
considered_options: [
|
||||
'considered options',
|
||||
'alternatives',
|
||||
'options',
|
||||
'choices',
|
||||
'candidates',
|
||||
'approaches considered',
|
||||
'variants',
|
||||
'trade-offs',
|
||||
'pros and cons of the options',
|
||||
'discussion',
|
||||
],
|
||||
risks: [
|
||||
'risks',
|
||||
'trade-offs',
|
||||
'drawbacks',
|
||||
'cost',
|
||||
'tensions',
|
||||
'liabilities',
|
||||
'negative consequences',
|
||||
'side effects',
|
||||
],
|
||||
success_criteria: [
|
||||
'success criteria',
|
||||
'acceptance criteria',
|
||||
'validation',
|
||||
"how we'll know",
|
||||
'metrics',
|
||||
'kpis',
|
||||
'verification',
|
||||
'test strategy',
|
||||
'compliance',
|
||||
'definition of done',
|
||||
'exit criteria',
|
||||
'positive consequences',
|
||||
],
|
||||
plan_sequence: [
|
||||
'implementation plan',
|
||||
'implementation notes',
|
||||
'steps',
|
||||
'tasks',
|
||||
'roadmap',
|
||||
'sequence',
|
||||
'migration plan',
|
||||
'plan',
|
||||
'action items',
|
||||
'work breakdown',
|
||||
'phases',
|
||||
'milestones',
|
||||
'stages',
|
||||
],
|
||||
key_files: [
|
||||
'affected files',
|
||||
'files touched',
|
||||
'surface area',
|
||||
'modules affected',
|
||||
'code locations',
|
||||
'file changes',
|
||||
'diff summary',
|
||||
'touched code',
|
||||
],
|
||||
out_of_scope: [
|
||||
'out of scope',
|
||||
'non-goals',
|
||||
'excluded',
|
||||
'not in this adr',
|
||||
'out of bounds',
|
||||
"won't do",
|
||||
"won't have",
|
||||
'beyond scope',
|
||||
'anti-goals',
|
||||
],
|
||||
deferred: [
|
||||
'future work',
|
||||
'deferred',
|
||||
'future',
|
||||
'later',
|
||||
'follow-up',
|
||||
'next steps',
|
||||
],
|
||||
dependencies: [
|
||||
'dependencies',
|
||||
'depends on',
|
||||
'prerequisites',
|
||||
'sequencing',
|
||||
'order',
|
||||
'blocked by',
|
||||
'cross-cuts',
|
||||
'related adrs',
|
||||
'links',
|
||||
'references',
|
||||
'see also',
|
||||
'upstream',
|
||||
'inbound',
|
||||
],
|
||||
update: [
|
||||
'update',
|
||||
'revision',
|
||||
'amendment',
|
||||
'locked design',
|
||||
'final decision',
|
||||
'post-grilling',
|
||||
'addendum',
|
||||
],
|
||||
consequences: [
|
||||
'consequences',
|
||||
'implications',
|
||||
'impact',
|
||||
'what this means',
|
||||
'result',
|
||||
],
|
||||
};
|
||||
|
||||
const CONSEQUENCE_NEGATIVE_HINTS: string[] = [
|
||||
'negative',
|
||||
'drawback',
|
||||
'risk',
|
||||
'cost',
|
||||
'liability',
|
||||
'trade-off',
|
||||
'tension',
|
||||
'side effect',
|
||||
];
|
||||
|
||||
const CONSEQUENCE_POSITIVE_HINTS: string[] = [
|
||||
'positive',
|
||||
'success',
|
||||
'metric',
|
||||
'kpi',
|
||||
'verification',
|
||||
'acceptance',
|
||||
'benefit',
|
||||
];
|
||||
|
||||
function normalizeAdrHeader(raw: unknown): string {
|
||||
const s = typeof raw === 'string' ? raw : '';
|
||||
return s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s:._-]+/g, ' ')
|
||||
.replace(/[^\w\s]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitEntries(blockText: unknown): string[] {
|
||||
return (typeof blockText === 'string' ? blockText : '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => line.replace(/^[-*+]\s+/, '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
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+(.*)$/);
|
||||
if (m) {
|
||||
if (current.heading || current.body.length) sections.push(current);
|
||||
current = { heading: m[1].trim(), body: [] };
|
||||
} else {
|
||||
current.body.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.heading || current.body.length) sections.push(current);
|
||||
return sections;
|
||||
}
|
||||
|
||||
function parseStatusFromSections(sections: MarkdownSection[]): string {
|
||||
for (const section of sections) {
|
||||
const canonical = classifyHeader(normalizeAdrHeader(section.heading));
|
||||
if (canonical !== 'status') continue;
|
||||
const line = splitEntries(section.body.join('\n'))[0] || '';
|
||||
const norm = normalizeAdrHeader(line);
|
||||
if (!norm) return '';
|
||||
if (norm.includes('accepted')) return 'accepted';
|
||||
if (norm.includes('proposed')) return 'proposed';
|
||||
if (norm.includes('superseded')) return 'superseded';
|
||||
if (norm.includes('rejected')) return 'rejected';
|
||||
if (norm.includes('deprecated')) return 'deprecated';
|
||||
return norm;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pushUnique(target: string[], values: string[]): void {
|
||||
const seen = new Set(target);
|
||||
for (const value of values) {
|
||||
if (!seen.has(value)) {
|
||||
target.push(value);
|
||||
seen.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))) {
|
||||
out.consequences_negative.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (CONSEQUENCE_POSITIVE_HINTS.some((hint) => lower.includes(hint))) {
|
||||
out.consequences_positive.push(entry);
|
||||
continue;
|
||||
}
|
||||
out.consequences_positive.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
interface ParseAdrMarkdownOptions {
|
||||
sourcePath?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
function parseAdrMarkdown(markdown: unknown, { sourcePath = '', format = 'auto' }: ParseAdrMarkdownOptions = {}): AdrOut {
|
||||
const sections = parseSections(markdown);
|
||||
const titleLine = (typeof markdown === 'string' ? markdown : '').split(/\r?\n/).find((line) => /^#\s+/.test(line)) || '';
|
||||
const title = titleLine.replace(/^#\s+/, '').trim();
|
||||
|
||||
const out: AdrOut = {
|
||||
title,
|
||||
status: parseStatusFromSections(sections) || 'accepted',
|
||||
context: '',
|
||||
decisions: [],
|
||||
options_considered: [],
|
||||
consequences_positive: [],
|
||||
consequences_negative: [],
|
||||
out_of_scope: [],
|
||||
deferred: [],
|
||||
dependencies: [],
|
||||
updates: [],
|
||||
source_path: sourcePath,
|
||||
key_files: [],
|
||||
plan_sequence: [],
|
||||
format,
|
||||
unmapped_headers: [],
|
||||
};
|
||||
|
||||
for (const section of sections) {
|
||||
const heading = section.heading || '';
|
||||
if (!heading) continue;
|
||||
const canonical = classifyHeader(normalizeAdrHeader(heading));
|
||||
const entries = splitEntries(section.body.join('\n'));
|
||||
const prose = section.body.join('\n').trim();
|
||||
|
||||
if (!canonical) {
|
||||
out.unmapped_headers.push(heading);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (canonical) {
|
||||
case 'goal':
|
||||
if (!out.context && prose) out.context = prose;
|
||||
break;
|
||||
case 'decisions':
|
||||
pushUnique(out.decisions, entries);
|
||||
break;
|
||||
case 'considered_options':
|
||||
pushUnique(out.options_considered, entries);
|
||||
break;
|
||||
case 'risks':
|
||||
pushUnique(out.consequences_negative, entries);
|
||||
break;
|
||||
case 'success_criteria':
|
||||
pushUnique(out.consequences_positive, entries);
|
||||
break;
|
||||
case 'plan_sequence':
|
||||
pushUnique(out.plan_sequence, entries);
|
||||
break;
|
||||
case 'key_files':
|
||||
pushUnique(out.key_files, entries);
|
||||
break;
|
||||
case 'out_of_scope':
|
||||
pushUnique(out.out_of_scope, entries);
|
||||
break;
|
||||
case 'deferred':
|
||||
pushUnique(out.deferred, entries);
|
||||
break;
|
||||
case 'dependencies':
|
||||
pushUnique(out.dependencies, entries);
|
||||
break;
|
||||
case 'update':
|
||||
out.updates.push({ heading, entries });
|
||||
break;
|
||||
case 'consequences':
|
||||
parseConsequences(entries, out);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function shouldRejectAdrStatus(status: string): boolean {
|
||||
return STATUS_REJECT_SET.has(normalizeAdrHeader(status));
|
||||
}
|
||||
|
||||
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') {
|
||||
opts.input = argv[++i] || null;
|
||||
} else if (arg === '--format') {
|
||||
opts.format = argv[++i] || 'auto';
|
||||
} else if (arg === '--project-dir') {
|
||||
opts.projectDir = argv[++i] || process.cwd();
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (!opts.input) {
|
||||
throw new Error('Missing required --input <path>');
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
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 ?? undefined, format: opts.format });
|
||||
process.stdout.write(JSON.stringify(parsed, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error: ${(error as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export = {
|
||||
CANONICAL_HEADERS,
|
||||
normalizeAdrHeader,
|
||||
parseAdrMarkdown,
|
||||
shouldRejectAdrStatus,
|
||||
};
|
||||
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,
|
||||
};
|
||||
48
src/artifacts.cts
Normal file
48
src/artifacts.cts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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
|
||||
* so stale or misnamed artifacts don't silently mislead agents or reviewers.
|
||||
*
|
||||
* Add entries here whenever a new workflow produces a .planning/ root file.
|
||||
*/
|
||||
|
||||
// Exact-match canonical file names at .planning/ root
|
||||
export const CANONICAL_EXACT: ReadonlySet<string> = new Set([
|
||||
'PROJECT.md',
|
||||
'ROADMAP.md',
|
||||
'STATE.md',
|
||||
'REQUIREMENTS.md',
|
||||
'MILESTONES.md',
|
||||
'BACKLOG.md',
|
||||
'LEARNINGS.md',
|
||||
'THREADS.md',
|
||||
'config.json',
|
||||
'CLAUDE.md',
|
||||
'RETROSPECTIVE.md',
|
||||
]);
|
||||
|
||||
// Pattern-match canonical file names (regex tests on the basename)
|
||||
// Each pattern includes the name of the workflow that produces it as a comment.
|
||||
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
|
||||
];
|
||||
|
||||
/**
|
||||
* Return true if `filename` (basename only, no path) matches a canonical
|
||||
* .planning/ root artifact — either an exact name or a known pattern.
|
||||
*
|
||||
* @param filename - Basename of the file (e.g. "STATE.md")
|
||||
*/
|
||||
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;
|
||||
}
|
||||
876
src/audit.cts
Normal file
876
src/audit.cts
Normal file
@@ -0,0 +1,876 @@
|
||||
/**
|
||||
* Open Artifact Audit — Cross-type unresolved state scanner
|
||||
*
|
||||
* Scans all .planning/ artifact categories for items with open/unresolved state.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
// ─── 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: string): DebugSessionItem[] {
|
||||
const debugDir = path.join(planDir, 'debug');
|
||||
if (!fs.existsSync(debugDir)) return [];
|
||||
|
||||
const results: DebugSessionItem[] = [];
|
||||
let files: fs.Dirent[];
|
||||
try {
|
||||
files = fs.readdirSync(debugDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
|
||||
}
|
||||
|
||||
for (const entry of files) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
|
||||
const filePath = path.join(debugDir, entry.name);
|
||||
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'debug session file', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
if (status === 'resolved' || status === 'complete') continue;
|
||||
|
||||
// Extract hypothesis from "Current Focus" block if parseable
|
||||
let hypothesis = '';
|
||||
const focusMatch = content.match(/##\s*Current Focus[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i);
|
||||
if (focusMatch) {
|
||||
const focusText = focusMatch[1].trim().split('\n')[0].trim();
|
||||
hypothesis = sanitizeForDisplay(focusText.slice(0, 100));
|
||||
}
|
||||
|
||||
const slug = path.basename(entry.name, '.md');
|
||||
results.push({
|
||||
slug: sanitizeForDisplay(slug),
|
||||
status: sanitizeForDisplay(status),
|
||||
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
|
||||
hypothesis,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanQuickTasks ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/quick/ for incomplete tasks.
|
||||
* Incomplete if SUMMARY.md missing or status !== 'complete'.
|
||||
*/
|
||||
function scanQuickTasks(planDir: string): QuickTaskItem[] {
|
||||
const quickDir = path.join(planDir, 'quick');
|
||||
if (!fs.existsSync(quickDir)) return [];
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(quickDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
|
||||
}
|
||||
|
||||
const results: QuickTaskItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const dirName = entry.name;
|
||||
const taskDir = path.join(quickDir, dirName);
|
||||
|
||||
let safeTaskDir: string;
|
||||
try {
|
||||
safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// workflows/quick.md mandates `${quick_id}-SUMMARY.md`; older flows used
|
||||
// bare `SUMMARY.md`. Accept either to avoid false-positive "missing".
|
||||
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')));
|
||||
if (summaryFiles.length > 0) {
|
||||
// Prefer the per-task `${quick_id}-SUMMARY.md` form when present.
|
||||
const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`)
|
||||
|| summaryFiles.find(e => e.name.endsWith('-SUMMARY.md'))
|
||||
|| summaryFiles[0];
|
||||
summaryPath = path.join(safeTaskDir, preferred.name);
|
||||
}
|
||||
} catch {
|
||||
// fall through with summaryPath = null → status: missing
|
||||
}
|
||||
|
||||
let status = 'missing';
|
||||
const description = '';
|
||||
|
||||
if (summaryPath && fs.existsSync(summaryPath)) {
|
||||
let safeSum: string;
|
||||
try {
|
||||
safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeSum);
|
||||
if (content === null) {
|
||||
status = 'unreadable';
|
||||
} else {
|
||||
const fm = extractFrontmatter(content);
|
||||
status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'complete') continue;
|
||||
|
||||
// Parse date and slug from directory name: YYYYMMDD-slug or YYYY-MM-DD-slug
|
||||
let date = '';
|
||||
let slug = sanitizeForDisplay(dirName);
|
||||
const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/);
|
||||
if (dateMatch) {
|
||||
date = dateMatch[1];
|
||||
slug = sanitizeForDisplay(dateMatch[2]);
|
||||
}
|
||||
|
||||
results.push({
|
||||
slug,
|
||||
date,
|
||||
status: sanitizeForDisplay(status),
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanThreads ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/threads/ for open threads.
|
||||
* Open if status in ['open', 'in_progress', 'in progress'] (case-insensitive).
|
||||
*/
|
||||
function scanThreads(planDir: string): ThreadItem[] {
|
||||
const threadsDir = path.join(planDir, 'threads');
|
||||
if (!fs.existsSync(threadsDir)) return [];
|
||||
|
||||
let files: fs.Dirent[];
|
||||
try {
|
||||
files = fs.readdirSync(threadsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
|
||||
}
|
||||
|
||||
const openStatuses = new Set(['open', 'in_progress', 'in progress']);
|
||||
const results: ThreadItem[] = [];
|
||||
|
||||
for (const entry of files) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
|
||||
const filePath = path.join(threadsDir, entry.name);
|
||||
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'thread file', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
let status = ((fm.status as string) || '').toLowerCase().trim();
|
||||
|
||||
// Fall back to scanning body for ## Status: OPEN / IN PROGRESS
|
||||
if (!status) {
|
||||
const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
|
||||
if (bodyStatusMatch) {
|
||||
status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
|
||||
}
|
||||
}
|
||||
|
||||
if (!openStatuses.has(status)) continue;
|
||||
|
||||
// Extract title from # Thread: heading or frontmatter title
|
||||
let title = sanitizeForDisplay(fm.title || '');
|
||||
if (!title) {
|
||||
const headingMatch = content.match(/^#\s*Thread:\s*(.+)$/m);
|
||||
if (headingMatch) {
|
||||
title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
|
||||
}
|
||||
}
|
||||
|
||||
const slug = path.basename(entry.name, '.md');
|
||||
results.push({
|
||||
slug: sanitizeForDisplay(slug),
|
||||
status: sanitizeForDisplay(status),
|
||||
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
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: string): TodoItem[] {
|
||||
const pendingDir = path.join(planDir, 'todos', 'pending');
|
||||
if (!fs.existsSync(pendingDir)) return [];
|
||||
|
||||
let files: fs.Dirent[];
|
||||
try {
|
||||
files = fs.readdirSync(pendingDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
|
||||
}
|
||||
|
||||
const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
|
||||
const results: TodoItem[] = [];
|
||||
|
||||
const displayFiles = mdFiles.slice(0, 5);
|
||||
for (const entry of displayFiles) {
|
||||
const filePath = path.join(pendingDir, entry.name);
|
||||
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
|
||||
// Extract first line of body after frontmatter
|
||||
const bodyMatch = content.replace(/^---[\s\S]*?---\n?/, '');
|
||||
const firstLine = bodyMatch.trim().split('\n')[0] || '';
|
||||
const summary = sanitizeForDisplay(firstLine.slice(0, 100));
|
||||
|
||||
results.push({
|
||||
filename: sanitizeForDisplay(entry.name),
|
||||
priority: sanitizeForDisplay(fm.priority || ''),
|
||||
area: sanitizeForDisplay(fm.area || ''),
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
if (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: string): SeedItem[] {
|
||||
const seedsDir = path.join(planDir, 'seeds');
|
||||
if (!fs.existsSync(seedsDir)) return [];
|
||||
|
||||
let files: fs.Dirent[];
|
||||
try {
|
||||
files = fs.readdirSync(seedsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
|
||||
}
|
||||
|
||||
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
|
||||
const results: SeedItem[] = [];
|
||||
|
||||
for (const entry of files) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md')) continue;
|
||||
|
||||
const filePath = path.join(seedsDir, entry.name);
|
||||
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'seed file', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = ((fm.status as string) || 'dormant').toLowerCase();
|
||||
|
||||
if (!unimplementedStatuses.has(status)) continue;
|
||||
|
||||
// Extract seed_id from filename or frontmatter
|
||||
const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
|
||||
const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
|
||||
const slug = sanitizeForDisplay(seed_id.replace(/^SEED-/, ''));
|
||||
|
||||
let title = sanitizeForDisplay(fm.title || '');
|
||||
if (!title) {
|
||||
const headingMatch = content.match(/^#\s*(.+)$/m);
|
||||
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
|
||||
}
|
||||
|
||||
results.push({
|
||||
seed_id: sanitizeForDisplay(seed_id),
|
||||
slug,
|
||||
status: sanitizeForDisplay(status),
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanUatGaps ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/phases for UAT gaps (UAT files with status != 'complete').
|
||||
*/
|
||||
function scanUatGaps(planDir: string): UatGapItem[] {
|
||||
const phasesDir = path.join(planDir, 'phases');
|
||||
if (!fs.existsSync(phasesDir)) return [];
|
||||
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }];
|
||||
}
|
||||
|
||||
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: string[];
|
||||
try {
|
||||
files = fs.readdirSync(phaseDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
|
||||
const filePath = path.join(phaseDir, file);
|
||||
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
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:`.
|
||||
if (TERMINAL_UAT_STATUSES.has(status)) continue;
|
||||
if (status === 'unknown' && result === 'all_pass') continue;
|
||||
|
||||
// Count open scenarios
|
||||
const pendingMatches = (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length;
|
||||
|
||||
results.push({
|
||||
phase: sanitizeForDisplay(phaseNum),
|
||||
file: sanitizeForDisplay(file),
|
||||
status: sanitizeForDisplay(status),
|
||||
open_scenario_count: pendingMatches,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanVerificationGaps ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/phases for VERIFICATION gaps.
|
||||
*/
|
||||
function scanVerificationGaps(planDir: string): VerificationGapItem[] {
|
||||
const phasesDir = path.join(planDir, 'phases');
|
||||
if (!fs.existsSync(phasesDir)) return [];
|
||||
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return [{ scan_error: true, phase: '', file: '', status: '' }];
|
||||
}
|
||||
|
||||
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: string[];
|
||||
try {
|
||||
files = fs.readdirSync(phaseDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
||||
const filePath = path.join(phaseDir, file);
|
||||
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
|
||||
if (status !== 'gaps_found' && status !== 'human_needed') continue;
|
||||
|
||||
results.push({
|
||||
phase: sanitizeForDisplay(phaseNum),
|
||||
file: sanitizeForDisplay(file),
|
||||
status: sanitizeForDisplay(status),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── scanContextQuestions ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan .planning/phases for CONTEXT files with open_questions.
|
||||
*/
|
||||
function scanContextQuestions(planDir: string): ContextQuestionItem[] {
|
||||
const phasesDir = path.join(planDir, 'phases');
|
||||
if (!fs.existsSync(phasesDir)) return [];
|
||||
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
|
||||
}
|
||||
|
||||
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: string[];
|
||||
try {
|
||||
files = fs.readdirSync(phaseDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
|
||||
const filePath = path.join(phaseDir, file);
|
||||
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
|
||||
// Check frontmatter open_questions field
|
||||
let questions: string[] = [];
|
||||
if (fm.open_questions) {
|
||||
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
|
||||
questions = (fm.open_questions as unknown[]).map(q => sanitizeForDisplay(String(q).slice(0, 200)));
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for ## Open Questions section in body
|
||||
if (questions.length === 0) {
|
||||
const oqMatch = content.match(/##\s*Open Questions[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i);
|
||||
if (oqMatch) {
|
||||
const oqBody = oqMatch[1].trim();
|
||||
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
|
||||
const items = oqBody.split('\n')
|
||||
.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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (questions.length === 0) continue;
|
||||
|
||||
results.push({
|
||||
phase: sanitizeForDisplay(phaseNum),
|
||||
file: sanitizeForDisplay(file),
|
||||
question_count: questions.length,
|
||||
questions: questions.slice(0, 3),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── auditOpenArtifacts ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Main audit function. Scans all .planning/ artifact categories.
|
||||
*
|
||||
* @param cwd - Project root directory
|
||||
* @returns Structured audit result
|
||||
*/
|
||||
function auditOpenArtifacts(cwd: string): AuditResult {
|
||||
const planDir = planningDir(cwd);
|
||||
|
||||
const debugSessions = (() => {
|
||||
try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }]; }
|
||||
})();
|
||||
|
||||
const quickTasks = (() => {
|
||||
try { return scanQuickTasks(planDir); } catch { return [{ scan_error: true, slug: '', date: '', status: '', description: '' }]; }
|
||||
})();
|
||||
|
||||
const threads = (() => {
|
||||
try { return scanThreads(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }]; }
|
||||
})();
|
||||
|
||||
const todos = (() => {
|
||||
try { return scanTodos(planDir); } catch { return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }]; }
|
||||
})();
|
||||
|
||||
const seeds = (() => {
|
||||
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, phase: '', file: '', status: '', open_scenario_count: 0 }]; }
|
||||
})();
|
||||
|
||||
const verificationGaps = (() => {
|
||||
try { return scanVerificationGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '' }]; }
|
||||
})();
|
||||
|
||||
const contextQuestions = (() => {
|
||||
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: Array<{ scan_error?: boolean; _remainder_count?: number }>) =>
|
||||
arr.filter(i => !i.scan_error && !i._remainder_count).length;
|
||||
|
||||
const counts: AuditCounts = {
|
||||
debug_sessions: countReal(debugSessions),
|
||||
quick_tasks: countReal(quickTasks),
|
||||
threads: countReal(threads),
|
||||
todos: countReal(todos),
|
||||
seeds: countReal(seeds),
|
||||
uat_gaps: countReal(uatGaps),
|
||||
verification_gaps: countReal(verificationGaps),
|
||||
context_questions: countReal(contextQuestions),
|
||||
total: 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(),
|
||||
has_open_items: counts.total > 0,
|
||||
counts,
|
||||
items: {
|
||||
debug_sessions: debugSessions,
|
||||
quick_tasks: quickTasks,
|
||||
threads,
|
||||
todos,
|
||||
seeds,
|
||||
uat_gaps: uatGaps,
|
||||
verification_gaps: verificationGaps,
|
||||
context_questions: contextQuestions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── formatAuditReport ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format the audit result as a human-readable report.
|
||||
*
|
||||
* @param auditResult - Result from auditOpenArtifacts()
|
||||
* @returns Formatted report
|
||||
*/
|
||||
function formatAuditReport(auditResult: AuditResult): string {
|
||||
const { counts, items, has_open_items } = auditResult;
|
||||
const lines: string[] = [];
|
||||
const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
|
||||
|
||||
lines.push(hr);
|
||||
lines.push(' Milestone Close: Open Artifact Audit');
|
||||
lines.push(hr);
|
||||
|
||||
if (!has_open_items) {
|
||||
lines.push('');
|
||||
lines.push(' All artifact types clear. Safe to proceed.');
|
||||
lines.push('');
|
||||
lines.push(hr);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Debug sessions (blocking quality — red)
|
||||
if (counts.debug_sessions > 0) {
|
||||
lines.push('');
|
||||
lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open)`);
|
||||
for (const item of items.debug_sessions.filter(i => !i.scan_error)) {
|
||||
const hyp = item.hypothesis ? ` — ${item.hypothesis}` : '';
|
||||
lines.push(` • ${item.slug} [${item.status}]${hyp}`);
|
||||
}
|
||||
}
|
||||
|
||||
// UAT gaps (blocking quality — red)
|
||||
if (counts.uat_gaps > 0) {
|
||||
lines.push('');
|
||||
lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT)`);
|
||||
for (const item of items.uat_gaps.filter(i => !i.scan_error)) {
|
||||
lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}] — ${item.open_scenario_count} pending scenarios`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verification gaps (blocking quality — red)
|
||||
if (counts.verification_gaps > 0) {
|
||||
lines.push('');
|
||||
lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved)`);
|
||||
for (const item of items.verification_gaps.filter(i => !i.scan_error)) {
|
||||
lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Quick tasks (incomplete work — yellow)
|
||||
if (counts.quick_tasks > 0) {
|
||||
lines.push('');
|
||||
lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete)`);
|
||||
for (const item of items.quick_tasks.filter(i => !i.scan_error)) {
|
||||
const d = item.date ? ` (${item.date})` : '';
|
||||
lines.push(` • ${item.slug}${d} [${item.status}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Todos (incomplete work — yellow)
|
||||
if (counts.todos > 0) {
|
||||
const realTodos = items.todos.filter(i => !i.scan_error && !i._remainder_count);
|
||||
const remainder = items.todos.find(i => i._remainder_count);
|
||||
lines.push('');
|
||||
lines.push(`🟡 Pending Todos (${counts.todos} pending)`);
|
||||
for (const item of realTodos) {
|
||||
const area = item.area ? ` [${item.area}]` : '';
|
||||
const pri = item.priority ? ` (${item.priority})` : '';
|
||||
lines.push(` • ${item.filename}${area}${pri}`);
|
||||
if (item.summary) lines.push(` ${item.summary}`);
|
||||
}
|
||||
if (remainder) {
|
||||
lines.push(` ... and ${remainder._remainder_count} more`);
|
||||
}
|
||||
}
|
||||
|
||||
// Threads (deferred decisions — blue)
|
||||
if (counts.threads > 0) {
|
||||
lines.push('');
|
||||
lines.push(`🔵 Open Threads (${counts.threads} active)`);
|
||||
for (const item of items.threads.filter(i => !i.scan_error)) {
|
||||
const title = item.title ? ` — ${item.title}` : '';
|
||||
lines.push(` • ${item.slug} [${item.status}]${title}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Seeds (deferred decisions — blue)
|
||||
if (counts.seeds > 0) {
|
||||
lines.push('');
|
||||
lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending)`);
|
||||
for (const item of items.seeds.filter(i => !i.scan_error)) {
|
||||
const title = item.title ? ` — ${item.title}` : '';
|
||||
lines.push(` • ${item.seed_id} [${item.status}]${title}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Context questions (deferred decisions — blue)
|
||||
if (counts.context_questions > 0) {
|
||||
lines.push('');
|
||||
lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions)`);
|
||||
for (const item of items.context_questions.filter(i => !i.scan_error)) {
|
||||
lines.push(` • Phase ${item.phase}: ${item.file} (${item.question_count} question${item.question_count !== 1 ? 's' : ''})`);
|
||||
for (const q of item.questions) {
|
||||
lines.push(` - ${q}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(hr);
|
||||
lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`);
|
||||
lines.push(hr);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export = { auditOpenArtifacts, formatAuditReport };
|
||||
364
src/check-command-router.cts
Normal file
364
src/check-command-router.cts
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
// ─── 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, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const SOFT_PHRASE_MIN_WORDS = 6;
|
||||
|
||||
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: 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: string): string {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePath(inputPath: string, projectDir: string): string {
|
||||
return path.isAbsolute(inputPath) ? inputPath : path.join(projectDir, inputPath);
|
||||
}
|
||||
|
||||
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')) as Record<string, unknown>;
|
||||
const wf = (parsed['workflow'] as Record<string, unknown> | undefined) || {};
|
||||
return {
|
||||
...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: string, raw: boolean): void {
|
||||
const workflow = readWorkflowConfig(projectDir);
|
||||
const autoAdvance = Boolean(workflow.auto_advance ?? false);
|
||||
const autoChainActive = Boolean(workflow._auto_chain_active ?? false);
|
||||
let source = 'none';
|
||||
if (autoChainActive && autoAdvance) source = 'both';
|
||||
else if (autoChainActive) source = 'auto_chain';
|
||||
else if (autoAdvance) source = 'auto_advance';
|
||||
|
||||
output({
|
||||
active: autoChainActive || autoAdvance,
|
||||
source,
|
||||
auto_chain_active: autoChainActive,
|
||||
auto_advance: autoAdvance,
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
function gateEnabled(projectDir: string): boolean {
|
||||
const value = readWorkflowConfig(projectDir).context_coverage_gate;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === 'false' || lower === 'true') return lower !== 'false';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadPlanContents(phaseDir: string): string[] {
|
||||
if (!fs.existsSync(phaseDir)) return [];
|
||||
try {
|
||||
return fs.readdirSync(phaseDir)
|
||||
.filter((entry) => /-PLAN\.md$/.test(entry))
|
||||
.map((entry) => readIfExists(path.join(phaseDir, entry)));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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: string): string {
|
||||
return text
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/~~~[\s\S]*?~~~/g, ' ');
|
||||
}
|
||||
|
||||
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;
|
||||
const rest = frontmatter.slice(startIdx + 1).split(/\r?\n/);
|
||||
const block = [match[1] || ''];
|
||||
for (const line of rest) {
|
||||
if (line === '' || /^\s/.test(line)) block.push(line);
|
||||
else break;
|
||||
}
|
||||
return block.join('\n');
|
||||
}
|
||||
|
||||
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: 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: string[] = [];
|
||||
for (const key of ['must_haves', 'truths', 'objective']) {
|
||||
const block = extractYamlBlock(frontmatter, key);
|
||||
if (block) parts.push(block);
|
||||
}
|
||||
|
||||
const bodyParts: string[] = [];
|
||||
let inDesignated = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const heading = /^#{1,6}\s+/.test(line);
|
||||
if (heading) {
|
||||
inDesignated = DESIGNATED_HEADINGS_RE.test(line);
|
||||
if (inDesignated) bodyParts.push(line);
|
||||
continue;
|
||||
}
|
||||
if (inDesignated) bodyParts.push(line);
|
||||
}
|
||||
parts.push(bodyParts.join('\n'));
|
||||
parts.push(extractXmlTagBodies(cleaned));
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
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',
|
||||
'',
|
||||
`${uncovered.length} CONTEXT.md decision(s) are not covered by any plan:`,
|
||||
'',
|
||||
...uncovered.map((item) => `- **${item.id}** (${item.category || 'uncategorized'}): ${item.text}`),
|
||||
'',
|
||||
'Resolve by citing `D-NN:` in a relevant plan\'s `must_haves`/`truths` (or body),',
|
||||
'OR move the decision to `### Claude\'s Discretion` / tag it `[informational]` if it should not be tracked.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildVerifyMessage(notHonored: UncoveredItem[]): string {
|
||||
if (notHonored.length === 0) return 'All trackable CONTEXT.md decisions are honored by shipped artifacts.';
|
||||
return [
|
||||
'### Decision Coverage (warning)',
|
||||
'',
|
||||
`${notHonored.length} decision(s) not found in shipped artifacts:`,
|
||||
'',
|
||||
...notHonored.map((item) => `- **${item.id}** (${item.category || 'uncategorized'}): ${item.text}`),
|
||||
'',
|
||||
'This is a soft warning - verification status is unchanged.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function loadTrackableDecisions(contextPath: string): Decision[] {
|
||||
return parseDecisions(readIfExists(contextPath)).filter((decision) => decision.trackable);
|
||||
}
|
||||
|
||||
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, 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, 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, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = loadPlanContents(phaseDir).map(extractPlanDesignatedSections);
|
||||
const uncovered: UncoveredItem[] = [];
|
||||
let covered = 0;
|
||||
for (const decision of decisions) {
|
||||
if (sections.some((section) => decisionMentioned(section, decision))) covered++;
|
||||
else uncovered.push({ id: decision.id, text: decision.text, category: decision.category });
|
||||
}
|
||||
|
||||
output({
|
||||
passed: uncovered.length === 0,
|
||||
skipped: false,
|
||||
total: decisions.length,
|
||||
covered,
|
||||
uncovered,
|
||||
message: buildPlanMessage(uncovered),
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
function recentCommitMessages(projectDir: string): string {
|
||||
try {
|
||||
return execFileSync('git', ['log', '-n', '200', '--pretty=%s%n%b'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
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: string, summaries: string[]): string {
|
||||
const out: string[] = [];
|
||||
let total = 0;
|
||||
for (const summary of summaries) {
|
||||
if (!summary) continue;
|
||||
for (const blockMatch of summary.matchAll(/files_modified:\s*\n((?:[ \t]*-\s+.+\n?)+)/g)) {
|
||||
const files = [...(blockMatch[1] || '').matchAll(/-\s+(.+)/g)]
|
||||
.map((match) => match[1].trim().replace(/^["']|["']$/g, ''));
|
||||
for (const file of files) {
|
||||
if (total >= 50) break;
|
||||
if (!file || !isInsideRoot(file, projectDir)) continue;
|
||||
const raw = readIfExists(resolvePath(file, projectDir));
|
||||
out.push(raw.length > 256 * 1024 ? raw.slice(0, 256 * 1024) : raw);
|
||||
total++;
|
||||
}
|
||||
if (total >= 50) break;
|
||||
}
|
||||
if (total >= 50) break;
|
||||
}
|
||||
return out.join('\n\n');
|
||||
}
|
||||
|
||||
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, 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, 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, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const planContents = loadPlanContents(phaseDir);
|
||||
const summaryParts = fs.existsSync(phaseDir)
|
||||
? fs.readdirSync(phaseDir).filter((entry) => /-SUMMARY\.md$/.test(entry)).map((entry) => readIfExists(path.join(phaseDir, entry)))
|
||||
: [];
|
||||
const haystack = [
|
||||
planContents.join('\n\n'),
|
||||
summaryParts.join('\n\n'),
|
||||
readModifiedFilesContent(projectDir, summaryParts),
|
||||
recentCommitMessages(projectDir),
|
||||
].join('\n\n');
|
||||
|
||||
const notHonored: UncoveredItem[] = [];
|
||||
let honored = 0;
|
||||
for (const decision of decisions) {
|
||||
if (decisionMentioned(haystack, decision)) honored++;
|
||||
else notHonored.push({ id: decision.id, text: decision.text, category: decision.category });
|
||||
}
|
||||
|
||||
output({
|
||||
skipped: false,
|
||||
blocking: false,
|
||||
total: decisions.length,
|
||||
honored,
|
||||
not_honored: notHonored,
|
||||
message: buildVerifyMessage(notHonored),
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'decision-coverage-plan') {
|
||||
cmdDecisionCoveragePlan(cwd, args, raw);
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'decision-coverage-verify') {
|
||||
cmdDecisionCoverageVerify(cwd, args, raw);
|
||||
return;
|
||||
}
|
||||
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
}
|
||||
|
||||
export = {
|
||||
routeCheckCommand,
|
||||
decisionMentioned,
|
||||
extractPlanDesignatedSections,
|
||||
};
|
||||
153
src/cjs-command-router-adapter.cts
Normal file
153
src/cjs-command-router-adapter.cts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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,
|
||||
handlers,
|
||||
defaultSubcommand,
|
||||
unsupported = {},
|
||||
unknownMessage,
|
||||
error,
|
||||
cwd,
|
||||
raw,
|
||||
}: RouteCjsCommandFamilyOptions): void {
|
||||
routeHubCommandFamily({
|
||||
family: '__legacy_cjs_family__',
|
||||
args,
|
||||
subcommands,
|
||||
handlers,
|
||||
defaultSubcommand,
|
||||
unsupported,
|
||||
unknownMessage,
|
||||
error,
|
||||
cwd,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub-backed family router adapter.
|
||||
*
|
||||
* Deepens the command-topology seam by routing family handlers through
|
||||
* CommandRoutingHub's typed Result contract instead of ad-hoc per-router
|
||||
* lookup + error handling branches.
|
||||
*/
|
||||
function routeHubCommandFamily({
|
||||
family,
|
||||
args,
|
||||
subcommands,
|
||||
handlers,
|
||||
defaultSubcommand,
|
||||
unsupported = {},
|
||||
unknownMessage,
|
||||
error,
|
||||
cwd,
|
||||
raw,
|
||||
}: RouteHubCommandFamilyOptions): void {
|
||||
const subcommand = args[1] || defaultSubcommand;
|
||||
|
||||
if (subcommand && unsupported[subcommand]) {
|
||||
error(unsupported[subcommand]);
|
||||
return;
|
||||
}
|
||||
|
||||
const available = subcommands.filter((s) => !unsupported[s]);
|
||||
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 as { ok: true; data: unknown };
|
||||
}
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const hub = createHub({
|
||||
cjsRegistry: { [family]: registryHandlers },
|
||||
manifest: { [family]: available },
|
||||
});
|
||||
|
||||
const result = hub.dispatch({
|
||||
family,
|
||||
subcommand,
|
||||
args: args.slice(2),
|
||||
cwd,
|
||||
raw,
|
||||
});
|
||||
|
||||
if (result.ok) return;
|
||||
if (result.kind === ERROR_KINDS.UnknownCommand) {
|
||||
error(unknownMessage(subcommand ?? '', available));
|
||||
return;
|
||||
}
|
||||
if (result.kind === ERROR_KINDS.InvalidArgs || result.kind === ERROR_KINDS.HandlerRefusal) {
|
||||
error((result as { reason: string }).reason);
|
||||
return;
|
||||
}
|
||||
error((result as { message: string }).message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection helper for family routers that still declare SDK registry metadata
|
||||
* but execute the CJS fallback path at this seam.
|
||||
*
|
||||
* Accepts variable argument shapes so routers can pass legacy projection tuples
|
||||
* (`registryCommand`, `registryArgs`, `legacyArgs`, optional `rawFormatter`, `cjsFallback`).
|
||||
*/
|
||||
function cjsFallbackHandler(...projectionArgs: unknown[]): unknown {
|
||||
return projectionArgs[projectionArgs.length - 1];
|
||||
}
|
||||
|
||||
export = {
|
||||
routeCjsCommandFamily,
|
||||
routeHubCommandFamily,
|
||||
cjsFallbackHandler,
|
||||
};
|
||||
101
src/clock.cts
Normal file
101
src/clock.cts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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
|
||||
* waits or Atomics.wait calls.
|
||||
*
|
||||
* Both methods in realClock use exactly the same system primitives that
|
||||
* acquireStateLock and withPlanningLock used inline before the seam was
|
||||
* introduced:
|
||||
* - now() → Date.now()
|
||||
* - 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.
|
||||
const _realSleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
/**
|
||||
* Parse GSD_NOW_MS to a valid pinned epoch millisecond value, or return null.
|
||||
*
|
||||
* Accepts ONLY strict decimal integer strings within JS Date bounds
|
||||
* (abs(ms) <= 8.64e15). Rejects empty strings, whitespace-only, floats
|
||||
* ('12.5'), scientific notation ('1e30'), and out-of-range values.
|
||||
*
|
||||
* Returns null (fall back to Date.now()) for any invalid or absent input.
|
||||
* Returns null when GSD_TEST_MODE is not set.
|
||||
*/
|
||||
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'
|
||||
const ms = Number(t);
|
||||
if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) return null; // Date-valid bounds
|
||||
return ms;
|
||||
}
|
||||
|
||||
export const realClock: Clock = {
|
||||
/**
|
||||
* Return current epoch milliseconds.
|
||||
*
|
||||
* When both GSD_TEST_MODE and GSD_NOW_MS are set (subprocess time-pin adapter,
|
||||
* issue #474), returns the pinned millisecond value so all date-stamping in the
|
||||
* subprocess SUT is deterministic. In production, falls back to Date.now().
|
||||
*
|
||||
* Only strict decimal integer strings within JS Date bounds are accepted as pins.
|
||||
* 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(): number {
|
||||
const pinned = _pinnedNowMs();
|
||||
if (pinned !== null) return pinned;
|
||||
return Date.now();
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the current instant as an ISO 8601 string (UTC).
|
||||
* Uses this.now() so the subprocess time-pin adapter is honoured.
|
||||
*
|
||||
* @returns e.g. "2020-06-15T12:00:00.000Z"
|
||||
*/
|
||||
nowIso(): string {
|
||||
return new Date(this.now()).toISOString();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 e.g. "2020-06-15"
|
||||
*/
|
||||
today(): string {
|
||||
return this.nowIso().split('T')[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronous sleep via Atomics.wait.
|
||||
* This is the identical primitive acquireStateLock and withPlanningLock used
|
||||
* 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 ms - milliseconds to sleep
|
||||
*/
|
||||
sleep(ms: number): void {
|
||||
Atomics.wait(_realSleepBuf, 0, 0, ms);
|
||||
},
|
||||
};
|
||||
148
src/clusters.cts
Normal file
148
src/clusters.cts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Cluster membership may overlap (a skill can live in two clusters). The union
|
||||
* of all clusters should cover every installed skill stem; uncategorized stems
|
||||
* are flagged by surface-clusters.test.cjs.
|
||||
*
|
||||
* Source: docs/research/2026-05-12-skill-surface-budget.md §3.2 (verified
|
||||
* against commands/gsd/ listing in surface-clusters.test.cjs).
|
||||
*/
|
||||
|
||||
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',
|
||||
'plan-phase',
|
||||
'execute-phase',
|
||||
'help',
|
||||
'update',
|
||||
]),
|
||||
|
||||
audit_review: Object.freeze([
|
||||
'code-review',
|
||||
'review',
|
||||
'audit-fix',
|
||||
'audit-milestone',
|
||||
'audit-uat',
|
||||
'verify-work',
|
||||
'validate-phase',
|
||||
'plan-review-convergence',
|
||||
'eval-review',
|
||||
'add-tests',
|
||||
'secure-phase',
|
||||
]),
|
||||
|
||||
milestone: Object.freeze([
|
||||
'new-milestone',
|
||||
'complete-milestone',
|
||||
'milestone-summary',
|
||||
'health',
|
||||
]),
|
||||
|
||||
research_ideate: Object.freeze([
|
||||
'sketch',
|
||||
'spike',
|
||||
'forensics',
|
||||
'explore',
|
||||
'graphify',
|
||||
'ns-ideate',
|
||||
]),
|
||||
|
||||
workspace_state: Object.freeze([
|
||||
'pause-work',
|
||||
'resume-work',
|
||||
'workspace',
|
||||
'workstreams',
|
||||
'thread',
|
||||
'capture',
|
||||
'inbox',
|
||||
]),
|
||||
|
||||
docs: Object.freeze([
|
||||
'docs-update',
|
||||
'ingest-docs',
|
||||
]),
|
||||
|
||||
ui: Object.freeze([
|
||||
'ui-phase',
|
||||
'ui-review',
|
||||
]),
|
||||
|
||||
ai_eval: Object.freeze([
|
||||
'ai-integration-phase',
|
||||
'eval-review',
|
||||
]),
|
||||
|
||||
ns_meta: Object.freeze([
|
||||
'ns-context',
|
||||
'ns-ideate',
|
||||
'ns-manage',
|
||||
'ns-project',
|
||||
'ns-review',
|
||||
'ns-workflow',
|
||||
]),
|
||||
|
||||
utility: Object.freeze([
|
||||
'health',
|
||||
'stats',
|
||||
'settings',
|
||||
'cleanup',
|
||||
'pr-branch',
|
||||
'ship',
|
||||
'undo',
|
||||
'fast',
|
||||
'quick',
|
||||
'autonomous',
|
||||
'config',
|
||||
'progress',
|
||||
'phase',
|
||||
'review',
|
||||
'update',
|
||||
'help',
|
||||
'code-review',
|
||||
'import',
|
||||
'manager',
|
||||
'map-codebase',
|
||||
'profile-user',
|
||||
'spec-phase',
|
||||
'ultraplan-phase',
|
||||
'mvp-phase',
|
||||
'execute-phase',
|
||||
'review-backlog',
|
||||
'debug',
|
||||
'extract-learnings',
|
||||
'surface',
|
||||
]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a Set of all skill stems covered by at least one cluster.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
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';
|
||||
}
|
||||
828
src/command-aliases.cts
Normal file
828
src/command-aliases.cts
Normal file
@@ -0,0 +1,828 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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": [],
|
||||
"subcommand": "load",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "state.json",
|
||||
"aliases": [
|
||||
"state json"
|
||||
],
|
||||
"subcommand": "json",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "state.get",
|
||||
"aliases": [
|
||||
"state get"
|
||||
],
|
||||
"subcommand": "get",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "state.update",
|
||||
"aliases": [
|
||||
"state update"
|
||||
],
|
||||
"subcommand": "update",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.patch",
|
||||
"aliases": [
|
||||
"state patch"
|
||||
],
|
||||
"subcommand": "patch",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.begin-phase",
|
||||
"aliases": [
|
||||
"state begin-phase"
|
||||
],
|
||||
"subcommand": "begin-phase",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.advance-plan",
|
||||
"aliases": [
|
||||
"state advance-plan"
|
||||
],
|
||||
"subcommand": "advance-plan",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.record-metric",
|
||||
"aliases": [
|
||||
"state record-metric"
|
||||
],
|
||||
"subcommand": "record-metric",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.update-progress",
|
||||
"aliases": [
|
||||
"state update-progress"
|
||||
],
|
||||
"subcommand": "update-progress",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.add-decision",
|
||||
"aliases": [
|
||||
"state add-decision"
|
||||
],
|
||||
"subcommand": "add-decision",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.add-blocker",
|
||||
"aliases": [
|
||||
"state add-blocker"
|
||||
],
|
||||
"subcommand": "add-blocker",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.resolve-blocker",
|
||||
"aliases": [
|
||||
"state resolve-blocker"
|
||||
],
|
||||
"subcommand": "resolve-blocker",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.record-session",
|
||||
"aliases": [
|
||||
"state record-session"
|
||||
],
|
||||
"subcommand": "record-session",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.signal-waiting",
|
||||
"aliases": [
|
||||
"state signal-waiting"
|
||||
],
|
||||
"subcommand": "signal-waiting",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.signal-resume",
|
||||
"aliases": [
|
||||
"state signal-resume"
|
||||
],
|
||||
"subcommand": "signal-resume",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.planned-phase",
|
||||
"aliases": [
|
||||
"state planned-phase"
|
||||
],
|
||||
"subcommand": "planned-phase",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.validate",
|
||||
"aliases": [
|
||||
"state validate"
|
||||
],
|
||||
"subcommand": "validate",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "state.sync",
|
||||
"aliases": [
|
||||
"state sync"
|
||||
],
|
||||
"subcommand": "sync",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.prune",
|
||||
"aliases": [
|
||||
"state prune"
|
||||
],
|
||||
"subcommand": "prune",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.milestone-switch",
|
||||
"aliases": [
|
||||
"state milestone-switch"
|
||||
],
|
||||
"subcommand": "milestone-switch",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "state.add-roadmap-evolution",
|
||||
"aliases": [
|
||||
"state add-roadmap-evolution"
|
||||
],
|
||||
"subcommand": "add-roadmap-evolution",
|
||||
"mutation": true
|
||||
}
|
||||
];
|
||||
|
||||
export const VERIFY_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "verify.plan-structure",
|
||||
"aliases": [
|
||||
"verify plan-structure"
|
||||
],
|
||||
"subcommand": "plan-structure",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "verify.phase-completeness",
|
||||
"aliases": [
|
||||
"verify phase-completeness"
|
||||
],
|
||||
"subcommand": "phase-completeness",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "verify.references",
|
||||
"aliases": [
|
||||
"verify references"
|
||||
],
|
||||
"subcommand": "references",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "verify.commits",
|
||||
"aliases": [
|
||||
"verify commits"
|
||||
],
|
||||
"subcommand": "commits",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "verify.artifacts",
|
||||
"aliases": [
|
||||
"verify artifacts"
|
||||
],
|
||||
"subcommand": "artifacts",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "verify.key-links",
|
||||
"aliases": [
|
||||
"verify key-links"
|
||||
],
|
||||
"subcommand": "key-links",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "verify.schema-drift",
|
||||
"aliases": [
|
||||
"verify schema-drift"
|
||||
],
|
||||
"subcommand": "schema-drift",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "verify.codebase-drift",
|
||||
"aliases": [
|
||||
"verify codebase-drift"
|
||||
],
|
||||
"subcommand": "codebase-drift",
|
||||
"mutation": false
|
||||
}
|
||||
];
|
||||
|
||||
export const INIT_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "init.execute-phase",
|
||||
"aliases": [
|
||||
"init execute-phase"
|
||||
],
|
||||
"subcommand": "execute-phase",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.plan-phase",
|
||||
"aliases": [
|
||||
"init plan-phase"
|
||||
],
|
||||
"subcommand": "plan-phase",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.new-project",
|
||||
"aliases": [
|
||||
"init new-project"
|
||||
],
|
||||
"subcommand": "new-project",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.new-milestone",
|
||||
"aliases": [
|
||||
"init new-milestone"
|
||||
],
|
||||
"subcommand": "new-milestone",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.quick",
|
||||
"aliases": [
|
||||
"init quick"
|
||||
],
|
||||
"subcommand": "quick",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.ingest-docs",
|
||||
"aliases": [
|
||||
"init ingest-docs"
|
||||
],
|
||||
"subcommand": "ingest-docs",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.resume",
|
||||
"aliases": [
|
||||
"init resume"
|
||||
],
|
||||
"subcommand": "resume",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.verify-work",
|
||||
"aliases": [
|
||||
"init verify-work"
|
||||
],
|
||||
"subcommand": "verify-work",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.phase-op",
|
||||
"aliases": [
|
||||
"init phase-op"
|
||||
],
|
||||
"subcommand": "phase-op",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.todos",
|
||||
"aliases": [
|
||||
"init todos"
|
||||
],
|
||||
"subcommand": "todos",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.milestone-op",
|
||||
"aliases": [
|
||||
"init milestone-op"
|
||||
],
|
||||
"subcommand": "milestone-op",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.map-codebase",
|
||||
"aliases": [
|
||||
"init map-codebase"
|
||||
],
|
||||
"subcommand": "map-codebase",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.progress",
|
||||
"aliases": [
|
||||
"init progress"
|
||||
],
|
||||
"subcommand": "progress",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.manager",
|
||||
"aliases": [
|
||||
"init manager"
|
||||
],
|
||||
"subcommand": "manager",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.new-workspace",
|
||||
"aliases": [
|
||||
"init new-workspace"
|
||||
],
|
||||
"subcommand": "new-workspace",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.list-workspaces",
|
||||
"aliases": [
|
||||
"init list-workspaces"
|
||||
],
|
||||
"subcommand": "list-workspaces",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.remove-workspace",
|
||||
"aliases": [
|
||||
"init remove-workspace"
|
||||
],
|
||||
"subcommand": "remove-workspace",
|
||||
"mutation": false
|
||||
}
|
||||
];
|
||||
|
||||
export const PHASE_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "phase.uat-passed",
|
||||
"aliases": [
|
||||
"phase uat-passed"
|
||||
],
|
||||
"subcommand": "uat-passed",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "phase.next-decimal",
|
||||
"aliases": [
|
||||
"phase next-decimal"
|
||||
],
|
||||
"subcommand": "next-decimal",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "phase.add",
|
||||
"aliases": [
|
||||
"phase add"
|
||||
],
|
||||
"subcommand": "add",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "phase.add-batch",
|
||||
"aliases": [
|
||||
"phase add-batch"
|
||||
],
|
||||
"subcommand": "add-batch",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "phase.insert",
|
||||
"aliases": [
|
||||
"phase insert"
|
||||
],
|
||||
"subcommand": "insert",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "phase.remove",
|
||||
"aliases": [
|
||||
"phase remove"
|
||||
],
|
||||
"subcommand": "remove",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "phase.complete",
|
||||
"aliases": [
|
||||
"phase complete"
|
||||
],
|
||||
"subcommand": "complete",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "phase.scaffold",
|
||||
"aliases": [
|
||||
"phase scaffold"
|
||||
],
|
||||
"subcommand": "scaffold",
|
||||
"mutation": true
|
||||
}
|
||||
];
|
||||
|
||||
export const PHASES_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "phases.list",
|
||||
"aliases": [
|
||||
"phases list"
|
||||
],
|
||||
"subcommand": "list",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "phases.clear",
|
||||
"aliases": [
|
||||
"phases clear"
|
||||
],
|
||||
"subcommand": "clear",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "phases.archive",
|
||||
"aliases": [
|
||||
"phases archive"
|
||||
],
|
||||
"subcommand": "archive",
|
||||
"mutation": true
|
||||
}
|
||||
];
|
||||
|
||||
export const VALIDATE_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "validate.consistency",
|
||||
"aliases": [
|
||||
"validate consistency"
|
||||
],
|
||||
"subcommand": "consistency",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "validate.health",
|
||||
"aliases": [
|
||||
"validate health"
|
||||
],
|
||||
"subcommand": "health",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "validate.agents",
|
||||
"aliases": [
|
||||
"validate agents"
|
||||
],
|
||||
"subcommand": "agents",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "validate.context",
|
||||
"aliases": [
|
||||
"validate context"
|
||||
],
|
||||
"subcommand": "context",
|
||||
"mutation": false
|
||||
}
|
||||
];
|
||||
|
||||
export const ROADMAP_COMMAND_ALIASES: CommandAlias[] = [
|
||||
{
|
||||
"canonical": "roadmap.analyze",
|
||||
"aliases": [
|
||||
"roadmap analyze"
|
||||
],
|
||||
"subcommand": "analyze",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "roadmap.get-phase",
|
||||
"aliases": [
|
||||
"roadmap get-phase"
|
||||
],
|
||||
"subcommand": "get-phase",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "roadmap.update-plan-progress",
|
||||
"aliases": [
|
||||
"roadmap update-plan-progress"
|
||||
],
|
||||
"subcommand": "update-plan-progress",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "roadmap.annotate-dependencies",
|
||||
"aliases": [
|
||||
"roadmap annotate-dependencies"
|
||||
],
|
||||
"subcommand": "annotate-dependencies",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "roadmap.validate",
|
||||
"aliases": [
|
||||
"roadmap validate"
|
||||
],
|
||||
"subcommand": "validate",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "roadmap.upgrade",
|
||||
"aliases": [
|
||||
"roadmap upgrade"
|
||||
],
|
||||
"subcommand": "upgrade",
|
||||
"mutation": true
|
||||
}
|
||||
];
|
||||
|
||||
export const NON_FAMILY_COMMAND_ALIASES: NonFamilyCommandAlias[] = [
|
||||
{
|
||||
"canonical": "agent.classify-failure",
|
||||
"aliases": [
|
||||
"agent classify-failure"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "check-commit",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "check.decision-coverage-plan",
|
||||
"aliases": [
|
||||
"check decision-coverage-plan"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "check.decision-coverage-verify",
|
||||
"aliases": [
|
||||
"check decision-coverage-verify"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "commit",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "commit-to-subrepo",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "config-ensure-section",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "config-new-project",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "config-set",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "config-set-model-profile",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "docs-init",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "frontmatter.get",
|
||||
"aliases": [],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "frontmatter.merge",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "frontmatter.set",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "frontmatter.validate",
|
||||
"aliases": [
|
||||
"frontmatter validate"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "generate-claude-md",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "generate-claude-profile",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "generate-dev-preferences",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "learnings.copy",
|
||||
"aliases": [
|
||||
"learnings copy"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "learnings.delete",
|
||||
"aliases": [
|
||||
"learnings delete"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "learnings.prune",
|
||||
"aliases": [
|
||||
"learnings prune"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "milestone.complete",
|
||||
"aliases": [
|
||||
"milestone complete"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "phase.mvp-mode",
|
||||
"aliases": [
|
||||
"phase mvp-mode"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "progress.bar",
|
||||
"aliases": [
|
||||
"progress bar"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "requirements.mark-complete",
|
||||
"aliases": [
|
||||
"requirements mark-complete"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "stats.json",
|
||||
"aliases": [
|
||||
"stats json"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "task.is-behavior-adding",
|
||||
"aliases": [
|
||||
"task is-behavior-adding"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "template.fill",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "template.select",
|
||||
"aliases": [
|
||||
"template select"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "todo.complete",
|
||||
"aliases": [
|
||||
"todo complete"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "todo.match-phase",
|
||||
"aliases": [
|
||||
"todo match-phase"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "uat.render-checkpoint",
|
||||
"aliases": [
|
||||
"uat render-checkpoint"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "verify-summary",
|
||||
"aliases": [
|
||||
"verify.summary",
|
||||
"verify summary"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "workstream.complete",
|
||||
"aliases": [
|
||||
"workstream complete"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "workstream.create",
|
||||
"aliases": [
|
||||
"workstream create"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "workstream.list",
|
||||
"aliases": [
|
||||
"workstream list"
|
||||
],
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "workstream.progress",
|
||||
"aliases": [
|
||||
"workstream progress"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "workstream.set",
|
||||
"aliases": [
|
||||
"workstream set"
|
||||
],
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "write-profile",
|
||||
"aliases": [],
|
||||
"mutation": true
|
||||
}
|
||||
];
|
||||
|
||||
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);
|
||||
54
src/command-arg-projection.cts
Normal file
54
src/command-arg-projection.cts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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<string, number>();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (!firstIndex.has(args[i])) firstIndex.set(args[i], i);
|
||||
}
|
||||
const result: Record<string, string | boolean | null> = {};
|
||||
for (const flag of valueFlags) {
|
||||
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}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all tokens after --flag until the next --flag or end of args.
|
||||
*/
|
||||
export function parseMultiwordArg(args: string[], flag: string): string | null {
|
||||
const idx = args.indexOf(`--${flag}`);
|
||||
if (idx === -1) return null;
|
||||
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;
|
||||
}
|
||||
398
src/command-routing-hub.cts
Normal file
398
src/command-routing-hub.cts
Normal file
@@ -0,0 +1,398 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Command Routing Hub — issue #3788, simplified in #175, typed in #176, observability in #177.
|
||||
*
|
||||
* A pure-result dispatch hub that centralizes CJS routing,
|
||||
* the error taxonomy, and the no-throw contract that all command-family routers
|
||||
* currently duplicate independently.
|
||||
*
|
||||
* Design:
|
||||
* createHub({ cjsRegistry, manifest }) -> hub
|
||||
* hub.dispatch({ family, subcommand, args, cwd, raw }) -> Result
|
||||
*
|
||||
* Result = { ok: true, data }
|
||||
* | { ok: false, kind: 'UnknownCommand', command: string }
|
||||
* | { ok: false, kind: 'InvalidArgs', arg: string, reason: string }
|
||||
* | { ok: false, kind: 'HandlerRefusal', reason: string }
|
||||
* | { ok: false, kind: 'HandlerFailure', message: string, cause?: Error }
|
||||
*
|
||||
* Invariants:
|
||||
* - Hub always routes through CJS handlers. There is no SDK path (#175).
|
||||
* - Hub never prints to stdout/stderr, never calls process.exit.
|
||||
* - Hub never throws — all internal throws are caught and converted to
|
||||
* { ok: false, kind: 'HandlerFailure', message, cause }.
|
||||
* - 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.
|
||||
*
|
||||
* #175: SdkLoadFailed and SdkDispatchFailed removed — Hub is CJS-only.
|
||||
* #176: Field renamed errorKind → kind; payloads are typed per variant.
|
||||
*
|
||||
* @readonly
|
||||
*/
|
||||
const ERROR_KINDS = Object.freeze({
|
||||
/** The requested family/subcommand combination is not present in the manifest. */
|
||||
UnknownCommand: 'UnknownCommand',
|
||||
/** The handler rejected the supplied arguments before executing. */
|
||||
InvalidArgs: 'InvalidArgs',
|
||||
/** A CJS handler returned an explicit refusal (e.g. unsupported subcommand). */
|
||||
HandlerRefusal: 'HandlerRefusal',
|
||||
/** A handler threw an unexpected exception. */
|
||||
HandlerFailure: 'HandlerFailure',
|
||||
} as const);
|
||||
|
||||
// ─── 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.
|
||||
*/
|
||||
function _safeJson(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Typed-payload factories (#176) ──────────────────────────────────────────
|
||||
// Each factory returns a frozen discriminated-union variant for its kind.
|
||||
// No cross-variant fields bleed between variants.
|
||||
// Finding 3: all factory returns are Object.freeze'd so callers cannot mutate
|
||||
// the variant invariant.
|
||||
|
||||
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 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.
|
||||
*/
|
||||
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)) as Error & { thrown?: unknown };
|
||||
wrapper.thrown = cause;
|
||||
obj.cause = wrapper;
|
||||
}
|
||||
}
|
||||
return Object.freeze(obj);
|
||||
}
|
||||
|
||||
// ─── Handler-return shape validator (Finding 1) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
const _VARIANT_SCHEMA: Record<string, { required: string[]; allowed: Set<string> }> = {
|
||||
UnknownCommand: {
|
||||
required: ['command'],
|
||||
allowed: new Set(['ok', 'kind', 'command']),
|
||||
},
|
||||
InvalidArgs: {
|
||||
required: ['arg', 'reason'],
|
||||
allowed: new Set(['ok', 'kind', 'arg', 'reason']),
|
||||
},
|
||||
HandlerRefusal: {
|
||||
required: ['reason'],
|
||||
allowed: new Set(['ok', 'kind', 'reason']),
|
||||
},
|
||||
HandlerFailure: {
|
||||
required: ['message'],
|
||||
allowed: new Set(['ok', 'kind', 'message', 'cause']),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a handler-returned { ok: false, ... } result against the typed schema.
|
||||
*
|
||||
* Returns null if valid, or a string describing the contract violation.
|
||||
*/
|
||||
function _validateErrResult(result: Record<string, unknown>): string | null {
|
||||
const { kind } = result;
|
||||
const schema = _VARIANT_SCHEMA[kind as string];
|
||||
|
||||
// Unknown kind — not in the closed enum
|
||||
if (!schema) {
|
||||
return `handler returned unknown kind '${String(kind)}': expected one of ${Object.keys(_VARIANT_SCHEMA).join(', ')}`;
|
||||
}
|
||||
|
||||
// Missing required fields
|
||||
for (const field of schema.required) {
|
||||
if (result[field] === undefined) {
|
||||
return (
|
||||
`handler returned malformed Result variant: ` +
|
||||
`kind '${String(kind)}' requires field '${field}' but it is missing. ` +
|
||||
`got: ${_safeJson(result)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extraneous fields outside the typed payload
|
||||
for (const key of Object.keys(result)) {
|
||||
if (!schema.allowed.has(key)) {
|
||||
return (
|
||||
`handler returned malformed Result variant: ` +
|
||||
`kind '${String(kind)}' does not allow field '${key}'. ` +
|
||||
`expected fields: ${[...schema.allowed].join(', ')}. ` +
|
||||
`got: ${_safeJson(result)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null; // valid
|
||||
}
|
||||
|
||||
// ─── Hub options ──────────────────────────────────────────────────────────────
|
||||
|
||||
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.
|
||||
*/
|
||||
function _safeJsonForWarn(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a CommandRoutingHub.
|
||||
*/
|
||||
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.
|
||||
// Consumers can opt into the reference impl by importing createDefaultLogger.
|
||||
const _logger = (logger && typeof logger.onEvent === 'function')
|
||||
? logger
|
||||
: createNoOpLogger();
|
||||
|
||||
/**
|
||||
* Normalise a HubResult into the DispatchEvent result shape.
|
||||
*
|
||||
* HubResult ok path: { ok: true, data } → { kind: 'ok', data }
|
||||
* HubResult err paths: { ok: false, kind, ...payload } → { kind, ...payload }
|
||||
*/
|
||||
function _normaliseResult(hubResult: HubResult): Record<string, unknown> {
|
||||
if (hubResult.ok) {
|
||||
return { kind: 'ok', data: hubResult.data };
|
||||
}
|
||||
// err variant: already has kind + typed payload
|
||||
// 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.
|
||||
*/
|
||||
function _notifyLogger(command: string, args: unknown, hubResult: HubResult, parentTraceId?: unknown): void {
|
||||
try {
|
||||
const eventResult = _normaliseResult(hubResult);
|
||||
const event = makeDispatchEvent({ command, args, result: eventResult, parentTraceId });
|
||||
_logger.onEvent(event);
|
||||
} catch (logErr) {
|
||||
// Logger must never break dispatch. Emit a degraded warn line.
|
||||
try {
|
||||
process.stderr.write(
|
||||
_safeJsonForWarn({
|
||||
level: 'warn',
|
||||
source: 'DispatchLogger',
|
||||
message: 'logger.onEvent failed: ' + String((logErr as Error)?.message || logErr),
|
||||
}) + '\n'
|
||||
);
|
||||
} catch {
|
||||
// If even stderr.write fails, swallow silently — dispatch result is returned below.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a command through the hub.
|
||||
*/
|
||||
function dispatch(req: DispatchRequest): HubResult {
|
||||
const { family, subcommand, args = [], parentTraceId } = req || {} as DispatchRequest;
|
||||
const command = subcommand ? `${family} ${subcommand}` : String(family);
|
||||
|
||||
let result: HubResult;
|
||||
try {
|
||||
result = _dispatch(req);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
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)) as Error & { thrown?: unknown };
|
||||
wrapper.thrown = err;
|
||||
result = makeHandlerFailure(String(err), wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
_notifyLogger(command, args, result, parentTraceId);
|
||||
return result;
|
||||
}
|
||||
|
||||
function _dispatch(req: DispatchRequest): HubResult {
|
||||
const { family, subcommand, args = [], cwd, raw } = req;
|
||||
|
||||
// ── manifest check ────────────────────────────────────────────────────────
|
||||
if (_manifest) {
|
||||
const knownSubcommands = _manifest[family];
|
||||
if (!knownSubcommands) {
|
||||
return makeUnknownCommand(String(family));
|
||||
}
|
||||
if (subcommand && !knownSubcommands.includes(subcommand)) {
|
||||
return makeUnknownCommand(`${family} ${subcommand}`);
|
||||
}
|
||||
}
|
||||
|
||||
return _dispatchCjs({ family, subcommand, args, cwd, raw });
|
||||
}
|
||||
|
||||
function _dispatchCjs({ family, subcommand, args, cwd, raw }: DispatchRequest): HubResult {
|
||||
if (!_cjsRegistry) {
|
||||
return makeUnknownCommand(String(family));
|
||||
}
|
||||
|
||||
const familyHandlers = _cjsRegistry[family];
|
||||
if (!familyHandlers) {
|
||||
return makeUnknownCommand(String(family));
|
||||
}
|
||||
|
||||
const handler = subcommand ? familyHandlers[subcommand] : familyHandlers[''];
|
||||
if (typeof handler !== 'function') {
|
||||
return makeUnknownCommand(subcommand ? `${family} ${subcommand}` : String(family));
|
||||
}
|
||||
|
||||
// Invoke the handler. It must return a HubResult or throw.
|
||||
// If it throws, the outer try/catch in dispatch() catches it.
|
||||
const result = handler({ family, subcommand, args, cwd, raw });
|
||||
|
||||
// If the handler returned a HubResult, validate ok:false variants against the typed schema.
|
||||
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 as unknown as Record<string, unknown>);
|
||||
if (violation !== null) {
|
||||
return makeHandlerFailure(
|
||||
'handler returned malformed Result variant: ' + violation,
|
||||
// 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// If the handler returned nothing (undefined), treat as success with no data.
|
||||
if (result === undefined || result === null) {
|
||||
return { ok: true, data: null };
|
||||
}
|
||||
|
||||
// Any other return value is treated as the data payload.
|
||||
return { ok: true, data: result };
|
||||
}
|
||||
|
||||
return { dispatch };
|
||||
}
|
||||
|
||||
export = {
|
||||
createHub,
|
||||
ERROR_KINDS,
|
||||
makeUnknownCommand,
|
||||
makeInvalidArgs,
|
||||
makeHandlerRefusal,
|
||||
makeHandlerFailure,
|
||||
};
|
||||
1410
src/commands.cts
Normal file
1410
src/commands.cts
Normal file
File diff suppressed because it is too large
Load Diff
33
src/config-schema.cts
Normal file
33
src/config-schema.cts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Thin adapter — sources schema data from the manifest via the generated
|
||||
* Configuration Module. All inline literals have been removed; the manifest
|
||||
* at get-shit-done/bin/shared/config-schema.manifest.json is the single source of truth.
|
||||
*
|
||||
* Imported by:
|
||||
* - config.cjs (isValidConfigKey validator)
|
||||
* - core.cjs
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
VALID_CONFIG_KEYS,
|
||||
RUNTIME_STATE_KEYS,
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
} from './configuration.cjs';
|
||||
|
||||
/**
|
||||
* Returns true if keyPath is a valid config key (exact, dynamic pattern, or runtime state).
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
export = { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey };
|
||||
780
src/config.cts
Normal file
780
src/config.cts
Normal file
@@ -0,0 +1,780 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
// ─── 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',
|
||||
'hooks.research_questions': 'workflow.research_before_questions',
|
||||
'workflow.research_questions': 'workflow.research_before_questions',
|
||||
'workflow.codereview': 'workflow.code_review',
|
||||
'workflow.review_command': 'workflow.code_review_command',
|
||||
'workflow.review': 'workflow.code_review',
|
||||
'workflow.code_review_level': 'workflow.code_review_depth',
|
||||
'workflow.review_depth': 'workflow.code_review_depth',
|
||||
'review.model': 'review.models.<cli-name>',
|
||||
'sub_repos': 'planning.sub_repos',
|
||||
'plan_checker': 'workflow.plan_check',
|
||||
};
|
||||
|
||||
const SHIP_PR_BODY_SECTION_KEYS = new Set(['heading', 'enabled', 'source', 'fallback', 'template']);
|
||||
const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([
|
||||
'phase_number',
|
||||
'phase_name',
|
||||
'phase_dir',
|
||||
'base_branch',
|
||||
'padded_phase',
|
||||
]);
|
||||
const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/;
|
||||
|
||||
/**
|
||||
* 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: unknown): void {
|
||||
if (!Array.isArray(value)) {
|
||||
error('Invalid ship.pr_body_sections value. Expected a JSON array of section objects.');
|
||||
}
|
||||
|
||||
(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 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 sectionObj['heading'] !== 'string' || sectionObj['heading'].trim() === '') {
|
||||
error(`${prefix}. heading must be a non-empty string.`);
|
||||
}
|
||||
if (/[\r\n]/.test(sectionObj['heading'] as string)) {
|
||||
error(`${prefix}. heading must be a single line.`);
|
||||
}
|
||||
|
||||
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 sectionObj && typeof sectionObj[field] !== 'string') {
|
||||
error(`${prefix}. ${field} must be a string.`);
|
||||
}
|
||||
}
|
||||
|
||||
const hasContent = ['source', 'fallback', 'template'].some((field) => {
|
||||
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 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 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]}}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Core config operations ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a fully-materialized config object for a new project.
|
||||
*
|
||||
* Merges (increasing priority):
|
||||
* 1. Hardcoded defaults — every key that loadConfig() resolves, plus mode/granularity
|
||||
* 2. User-level defaults from ~/.gsd/defaults.json (if present)
|
||||
* 3. userChoices — the settings the user explicitly selected during /gsd:new-project
|
||||
*
|
||||
* Uses the canonical `git` namespace for branching keys (consistent with VALID_CONFIG_KEYS
|
||||
* and the settings workflow). loadConfig() handles both flat and nested formats, so this
|
||||
* is backward-compatible with existing projects that have flat keys.
|
||||
*
|
||||
* Returns a plain object — does NOT write any files.
|
||||
*/
|
||||
function buildNewProjectConfig(userChoices: Record<string, unknown>): Record<string, unknown> {
|
||||
const choices = userChoices || {};
|
||||
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 firecrawlKeyFile = path.join(homedir, '.gsd', 'firecrawl_api_key');
|
||||
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));
|
||||
|
||||
// Load user-level defaults from ~/.gsd/defaults.json if available
|
||||
const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json');
|
||||
let userDefaults: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(globalDefaultsPath)) {
|
||||
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: 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 */ }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed global defaults
|
||||
}
|
||||
|
||||
const hardcoded: Record<string, unknown> = {
|
||||
model_profile: CONFIG_DEFAULTS.model_profile,
|
||||
commit_docs: CONFIG_DEFAULTS.commit_docs,
|
||||
parallelization: CONFIG_DEFAULTS.parallelization,
|
||||
search_gitignored: CONFIG_DEFAULTS.search_gitignored,
|
||||
brave_search: hasBraveSearch,
|
||||
firecrawl: hasFirecrawl,
|
||||
exa_search: hasExaSearch,
|
||||
git: {
|
||||
branching_strategy: CONFIG_DEFAULTS.branching_strategy,
|
||||
create_tag: true,
|
||||
phase_branch_template: CONFIG_DEFAULTS.phase_branch_template,
|
||||
milestone_branch_template: CONFIG_DEFAULTS.milestone_branch_template,
|
||||
quick_branch_template: CONFIG_DEFAULTS.quick_branch_template,
|
||||
},
|
||||
workflow: {
|
||||
research: true,
|
||||
plan_check: true,
|
||||
verifier: true,
|
||||
nyquist_validation: true,
|
||||
auto_advance: false,
|
||||
node_repair: true,
|
||||
node_repair_budget: 2,
|
||||
ui_phase: true,
|
||||
ui_safety_gate: true,
|
||||
ai_integration_phase: true,
|
||||
tdd_mode: false,
|
||||
human_verify_mode: 'end-of-phase',
|
||||
text_mode: false,
|
||||
research_before_questions: false,
|
||||
discuss_mode: 'discuss',
|
||||
skip_discuss: false,
|
||||
code_review: true,
|
||||
code_review_depth: 'standard',
|
||||
code_review_command: null,
|
||||
pattern_mapper: true,
|
||||
plan_bounce: false,
|
||||
plan_bounce_script: null,
|
||||
plan_bounce_passes: 2,
|
||||
auto_prune_state: false,
|
||||
post_planning_gaps: CONFIG_DEFAULTS.post_planning_gaps,
|
||||
security_enforcement: CONFIG_DEFAULTS.security_enforcement,
|
||||
security_asvs_level: CONFIG_DEFAULTS.security_asvs_level,
|
||||
security_block_on: CONFIG_DEFAULTS.security_block_on,
|
||||
},
|
||||
ship: {
|
||||
pr_body_sections: [],
|
||||
},
|
||||
hooks: {
|
||||
context_warnings: true,
|
||||
},
|
||||
project_code: null,
|
||||
phase_naming: 'sequential',
|
||||
agent_skills: {},
|
||||
claude_md_path: './CLAUDE.md',
|
||||
plan_review: {
|
||||
source_grounding: true,
|
||||
source_grounding_authority: 'grep',
|
||||
},
|
||||
};
|
||||
|
||||
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: Record<string, unknown> = {
|
||||
...hardcoded,
|
||||
...userDefaults,
|
||||
...choices,
|
||||
git: {
|
||||
...hd['git'],
|
||||
...(ud['git'] || {}),
|
||||
...(ch['git'] || {}),
|
||||
},
|
||||
workflow: {
|
||||
...hd['workflow'],
|
||||
...(ud['workflow'] || {}),
|
||||
...(ch['workflow'] || {}),
|
||||
},
|
||||
ship: {
|
||||
...hd['ship'],
|
||||
...(ud['ship'] || {}),
|
||||
...(ch['ship'] || {}),
|
||||
},
|
||||
hooks: {
|
||||
...hd['hooks'],
|
||||
...(ud['hooks'] || {}),
|
||||
...(ch['hooks'] || {}),
|
||||
},
|
||||
agent_skills: {
|
||||
...hd['agent_skills'],
|
||||
...(ud['agent_skills'] || {}),
|
||||
...(ch['agent_skills'] || {}),
|
||||
},
|
||||
plan_review: {
|
||||
...hd['plan_review'],
|
||||
...(ud['plan_review'] || {}),
|
||||
...(ch['plan_review'] || {}),
|
||||
},
|
||||
};
|
||||
|
||||
validateShipPrBodySections((config['ship'] as Record<string, unknown>)['pr_body_sections']);
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command: create a fully-materialized .planning/config.json for a new project.
|
||||
*
|
||||
* Accepts user-chosen settings as a JSON string (the keys the user explicitly
|
||||
* configured during /gsd:new-project). All remaining keys are filled from
|
||||
* hardcoded defaults and optional ~/.gsd/defaults.json.
|
||||
*
|
||||
* Idempotent: if config.json already exists, returns { created: false }.
|
||||
*/
|
||||
function cmdConfigNewProject(cwd: string, choicesJson: string | undefined, raw: boolean): void {
|
||||
const planningBase = planningDir(cwd);
|
||||
const configPath = path.join(planningBase, 'config.json');
|
||||
|
||||
// Idempotent: don't overwrite existing config
|
||||
if (fs.existsSync(configPath)) {
|
||||
output({ created: false, reason: 'already_exists' }, raw, 'exists');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse user choices
|
||||
let userChoices: Record<string, unknown> = {};
|
||||
if (choicesJson && choicesJson.trim() !== '') {
|
||||
try {
|
||||
userChoices = JSON.parse(choicesJson) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
error('Invalid JSON for config-new-project: ' + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure .planning directory exists
|
||||
try {
|
||||
platformEnsureDir(planningBase);
|
||||
} catch (err) {
|
||||
error('Failed to create .planning directory: ' + (err as Error).message);
|
||||
}
|
||||
|
||||
const config = buildNewProjectConfig(userChoices);
|
||||
|
||||
try {
|
||||
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 as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the config file exists (creates it if needed).
|
||||
*
|
||||
* 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: string): { created: boolean; reason?: string; path?: string } | undefined {
|
||||
const planningBase = planningDir(cwd);
|
||||
const configPath = path.join(planningBase, 'config.json');
|
||||
|
||||
// Ensure .planning directory exists
|
||||
try {
|
||||
platformEnsureDir(planningBase);
|
||||
} catch (err) {
|
||||
error('Failed to create .planning directory: ' + (err as Error).message);
|
||||
}
|
||||
|
||||
// Check if config already exists
|
||||
if (fs.existsSync(configPath)) {
|
||||
return { created: false, reason: 'already_exists' };
|
||||
}
|
||||
|
||||
const config = buildNewProjectConfig({});
|
||||
|
||||
try {
|
||||
platformWriteSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { created: true, path: '.planning/config.json' };
|
||||
} catch (err) {
|
||||
error('Failed to create config.json: ' + (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to ensure the config file exists (creates it if needed).
|
||||
*
|
||||
* 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: string, raw: boolean): void {
|
||||
const ensureConfigFileResult = ensureConfigFile(cwd);
|
||||
if (ensureConfigFileResult && ensureConfigFileResult.created) {
|
||||
output(ensureConfigFileResult, raw, 'created');
|
||||
} else {
|
||||
output(ensureConfigFileResult, raw, 'exists');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value in the config file, allowing nested values via dot notation (e.g.,
|
||||
* "workflow.research").
|
||||
*
|
||||
* 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: 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: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
} catch (err) {
|
||||
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: 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] as Record<string, unknown>;
|
||||
}
|
||||
const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting
|
||||
current[keys[keys.length - 1]] = parsedValue;
|
||||
|
||||
// Write back
|
||||
try {
|
||||
platformWriteSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { updated: true, key: keyPath, value: parsedValue, previousValue };
|
||||
} catch (err) {
|
||||
error('Failed to write config.json: ' + (err as Error).message);
|
||||
}
|
||||
}) as SetConfigValueResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to set a value in the config file, allowing nested values via dot notation (e.g.,
|
||||
* "workflow.research").
|
||||
*
|
||||
* 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: string, keyPath: string | undefined, value: string | undefined, raw: boolean): void {
|
||||
if (!keyPath) {
|
||||
error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
|
||||
}
|
||||
// #3593: reject the "key without value" form (e.g. `config-set
|
||||
// model_profile` with args[2] === undefined). Without this guard the
|
||||
// value passes through as undefined, the number/boolean/json branches
|
||||
// all fall through, and the write either silently strips the key
|
||||
// (JSON.stringify drops undefined values) or writes a corrupt entry.
|
||||
// Typed reason so the negative-matrix test can assert on it instead
|
||||
// of greppinng prose.
|
||||
if (value === undefined) {
|
||||
error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
|
||||
}
|
||||
|
||||
// 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!;
|
||||
|
||||
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: 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 (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 (kp === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
|
||||
error(`Invalid workflow.drift_action '${val}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`);
|
||||
}
|
||||
if (kp === 'workflow.drift_threshold') {
|
||||
if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) {
|
||||
error(`Invalid workflow.drift_threshold '${val}'. Must be a positive integer.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-planning gap checker (#2493)
|
||||
if (kp === 'workflow.post_planning_gaps') {
|
||||
if (typeof parsedValue !== 'boolean') {
|
||||
error(`Invalid workflow.post_planning_gaps '${val}'. Must be a boolean (true or false).`);
|
||||
}
|
||||
}
|
||||
|
||||
// #3086 — git.create_tag: boolean only
|
||||
if (kp === 'git.create_tag') {
|
||||
if (typeof parsedValue !== 'boolean') {
|
||||
error(`Invalid git.create_tag '${val}'. Must be a boolean (true or false).`);
|
||||
}
|
||||
}
|
||||
|
||||
if (kp === 'ship.pr_body_sections') {
|
||||
validateShipPrBodySections(parsedValue);
|
||||
}
|
||||
|
||||
// Human verification checkpoint mode (#3309)
|
||||
const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase'];
|
||||
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 (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 (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 (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 (kp === 'plan_review.source_grounding') {
|
||||
if (typeof parsedValue !== 'boolean') {
|
||||
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 (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 (kp === 'review.default_reviewers') {
|
||||
const normalized = normalizeConfiguredDefaultReviewers(parsedValue);
|
||||
if (normalized.errors.length > 0) {
|
||||
error(normalized.errors[0]);
|
||||
}
|
||||
parsedValue = normalized.values;
|
||||
}
|
||||
|
||||
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(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 as Parameters<typeof maskSecret>[0]);
|
||||
const maskedResult = {
|
||||
...setConfigValueResult,
|
||||
value: masked,
|
||||
previousValue: maskedPrev,
|
||||
masked: true,
|
||||
};
|
||||
output(maskedResult, raw, `${kp}=${masked}`);
|
||||
return;
|
||||
}
|
||||
|
||||
output(setConfigValueResult, raw, `${kp}=${String(parsedValue)}`);
|
||||
}
|
||||
|
||||
function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, defaultValue: unknown): void {
|
||||
const configPath = path.join(planningDir(cwd), 'config.json');
|
||||
const hasDefault = defaultValue !== undefined;
|
||||
|
||||
if (!keyPath) {
|
||||
error('Usage: config-get <key.path> [--default <value>]');
|
||||
}
|
||||
|
||||
// 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')) 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, 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 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 = 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, kp)) {
|
||||
const def = SCHEMA_DEFAULTS[kp];
|
||||
output(def, raw, String(def));
|
||||
return;
|
||||
}
|
||||
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
|
||||
}
|
||||
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, kp)) {
|
||||
const def = SCHEMA_DEFAULTS[kp];
|
||||
output(def, raw, String(def));
|
||||
return;
|
||||
}
|
||||
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(kp)) {
|
||||
const masked = maskSecret(current as Parameters<typeof maskSecret>[0]);
|
||||
output(masked, raw, masked);
|
||||
return;
|
||||
}
|
||||
|
||||
output(current, raw, String(current));
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to set the model profile in the config file.
|
||||
*
|
||||
* Note that this exits the process (via `output()`) even in the happy path.
|
||||
*/
|
||||
function cmdConfigSetModelProfile(cwd: string, profile: string | undefined, raw: boolean): void {
|
||||
if (!profile) {
|
||||
error(`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const normalizedProfile = profile!.toLowerCase().trim();
|
||||
if (!VALID_PROFILES.includes(normalizedProfile)) {
|
||||
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);
|
||||
const previousProfile = typeof previousValue === 'string' ? previousValue : 'balanced';
|
||||
|
||||
// Build result value / message and return
|
||||
const agentToModelMap = getAgentToModelMapForProfile(normalizedProfile);
|
||||
const result = {
|
||||
updated: true,
|
||||
profile: normalizedProfile,
|
||||
previousProfile,
|
||||
agentToModelMap,
|
||||
};
|
||||
const rawValue = getCmdConfigSetModelProfileResultMessage(
|
||||
normalizedProfile,
|
||||
previousProfile,
|
||||
agentToModelMap
|
||||
);
|
||||
output(result, raw, rawValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message to display for the result of the `config-set-model-profile` command when
|
||||
* displaying raw output.
|
||||
*/
|
||||
function getCmdConfigSetModelProfileResultMessage(
|
||||
normalizedProfile: string,
|
||||
previousProfile: string,
|
||||
agentToModelMap: Record<string, string>
|
||||
): string {
|
||||
const agentToModelTable = formatAgentToModelMapAsTable(agentToModelMap);
|
||||
const didChange = previousProfile !== normalizedProfile;
|
||||
const paragraphs = didChange
|
||||
? [
|
||||
`✓ Model profile set to: ${normalizedProfile} (was: ${previousProfile})`,
|
||||
'Agents will now use:',
|
||||
agentToModelTable,
|
||||
'Next spawned agents will use the new profile.',
|
||||
]
|
||||
: [
|
||||
`✓ Model profile is already set to: ${normalizedProfile}`,
|
||||
'Agents are using:',
|
||||
agentToModelTable,
|
||||
];
|
||||
return paragraphs.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 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
|
||||
? workstreamContext.configPath
|
||||
: path.join(planningDir(cwd), 'config.json');
|
||||
output(configPath, true, configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit on-disk migration of legacy config keys to canonical nested shape.
|
||||
*
|
||||
* Wraps the Configuration Module's migrateOnDisk() for the CLI surface. This
|
||||
* is the Phase 2 acceptance-criteria deliverable for opt-in migration (#3536):
|
||||
* users can run `gsd-tools migrate-config` to apply all four legacy-key
|
||||
* migrations to their .planning/config.json without having to load any config
|
||||
* implicitly via another command.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
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) {
|
||||
const msg = 'No legacy keys found — config is already canonical.';
|
||||
output(msg, true, msg);
|
||||
} else {
|
||||
const lines = [
|
||||
`Migrated: ${String(report.wrote)}`,
|
||||
...(report.normalizations as Array<{ from: string; to: string }>).map(n => ` ${n.from} → ${n.to}`),
|
||||
].join('\n');
|
||||
output(lines, true, lines);
|
||||
}
|
||||
} else {
|
||||
// output() JSON.stringify's its first arg when raw=false; pass the report object.
|
||||
output(report, false, report);
|
||||
}
|
||||
}
|
||||
|
||||
export = {
|
||||
VALID_CONFIG_KEYS,
|
||||
cmdConfigEnsureSection,
|
||||
cmdConfigSet,
|
||||
cmdConfigGet,
|
||||
cmdConfigSetModelProfile,
|
||||
cmdConfigNewProject,
|
||||
cmdConfigPath,
|
||||
cmdMigrateConfig,
|
||||
};
|
||||
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,
|
||||
};
|
||||
61
src/context-utilization.cts
Normal file
61
src/context-utilization.cts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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
|
||||
* strings are NOT in this module — formatting is the renderer's job
|
||||
* (see `validate context` in gsd-tools.cjs). That separation lets the
|
||||
* copy change without touching this module's tests.
|
||||
*
|
||||
* Thresholds:
|
||||
* < 60% healthy no action
|
||||
* 60–70% warning approaching the fracture zone
|
||||
* ≥ 70% critical reasoning quality may degrade
|
||||
*
|
||||
* State boundaries use the exact ratio. The displayed `percent` is
|
||||
* rounded for human reading and may differ from the boundary by ±1 in
|
||||
* edge cases (e.g. 59.999% displays as 60 but classifies as healthy).
|
||||
*/
|
||||
|
||||
export type ContextState = 'healthy' | 'warning' | 'critical';
|
||||
|
||||
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})`,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
|
||||
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: ContextState;
|
||||
if (ratio < 0.60) state = STATES.HEALTHY;
|
||||
else if (ratio < 0.70) state = STATES.WARNING;
|
||||
else state = STATES.CRITICAL;
|
||||
|
||||
return { percent, state };
|
||||
}
|
||||
2194
src/core.cts
Normal file
2194
src/core.cts
Normal file
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;
|
||||
}
|
||||
279
src/docs.cts
Normal file
279
src/docs.cts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Docs — Commands for the docs-update workflow
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const GSD_MARKER = '<!-- generated-by: gsd-doc-writer -->';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', '.planning', '.claude', '__pycache__',
|
||||
'target', 'dist', 'build', '.next', '.nuxt', 'coverage',
|
||||
'.vscode', '.idea',
|
||||
]);
|
||||
|
||||
// ─── Private helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check whether a file begins with the GSD doc writer marker.
|
||||
* Reads the first 500 bytes only — avoids loading large files.
|
||||
*/
|
||||
function hasGsdMarker(filePath: string): boolean {
|
||||
try {
|
||||
const buf = Buffer.alloc(500);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
const bytesRead = fs.readSync(fd, buf, 0, 500, 0);
|
||||
fs.closeSync(fd);
|
||||
return buf.slice(0, bytesRead).toString('utf-8').includes(GSD_MARKER);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
function scanExistingDocs(cwd: string): DocEntry[] {
|
||||
const MAX_DEPTH = 4;
|
||||
const results: DocEntry[] = [];
|
||||
|
||||
function walkDir(dir: string, depth: number): void {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(abs, depth + 1);
|
||||
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||
const rel = toPosixPath(path.relative(cwd, abs));
|
||||
results.push({ path: rel, has_gsd_marker: hasGsdMarker(abs) });
|
||||
}
|
||||
}
|
||||
} catch { /* directory may not exist — best-effort */ }
|
||||
}
|
||||
|
||||
// Scan root-level .md files (non-recursive)
|
||||
try {
|
||||
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||
const abs = path.join(cwd, entry.name);
|
||||
const rel = toPosixPath(path.relative(cwd, abs));
|
||||
results.push({ path: rel, has_gsd_marker: hasGsdMarker(abs) });
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
// Recursively scan docs/ directory
|
||||
const docsDir = path.join(cwd, 'docs');
|
||||
walkDir(docsDir, 1);
|
||||
|
||||
// Fallback: if docs/ does not exist, try documentation/ or doc/
|
||||
try {
|
||||
fs.statSync(docsDir);
|
||||
} catch {
|
||||
const alternatives = ['documentation', 'doc'];
|
||||
for (const alt of alternatives) {
|
||||
const altDir = path.join(cwd, alt);
|
||||
try {
|
||||
const stat = fs.statSync(altDir);
|
||||
if (stat.isDirectory()) {
|
||||
walkDir(altDir, 1);
|
||||
break;
|
||||
}
|
||||
} catch { /* not present */ }
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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: Record<string, unknown> | null = null;
|
||||
if (pkgRaw) {
|
||||
try { pkg = JSON.parse(pkgRaw) as Record<string, unknown>; } catch { /* invalid JSON */ }
|
||||
}
|
||||
|
||||
// has_cli_bin: package.json has a `bin` field
|
||||
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'] 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'] as Record<string, unknown> | undefined) || {});
|
||||
has_tests = devDeps.some(d => ['vitest', 'jest', 'mocha', 'jasmine', 'ava'].includes(d));
|
||||
}
|
||||
|
||||
// has_deploy_config: various deployment config files
|
||||
const deployFiles = [
|
||||
'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
|
||||
'fly.toml', 'render.yaml', 'vercel.json', 'netlify.toml', 'railway.json',
|
||||
'.github/workflows/deploy.yml', '.github/workflows/deploy.yaml',
|
||||
];
|
||||
const has_deploy_config = deployFiles.some(f => exists(f));
|
||||
|
||||
return {
|
||||
has_package_json: exists('package.json'),
|
||||
has_api_routes: (
|
||||
exists('src/app/api') || exists('routes') || exists('src/routes') ||
|
||||
exists('api') || exists('server')
|
||||
),
|
||||
has_cli_bin,
|
||||
is_open_source: exists('LICENSE') || exists('LICENSE.md'),
|
||||
has_deploy_config,
|
||||
is_monorepo,
|
||||
has_tests,
|
||||
};
|
||||
}
|
||||
|
||||
interface DocToolingSignals {
|
||||
docusaurus: boolean;
|
||||
vitepress: boolean;
|
||||
mkdocs: boolean;
|
||||
storybook: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect known documentation tooling in the project.
|
||||
*/
|
||||
function detectDocTooling(cwd: string): DocToolingSignals {
|
||||
const exists = (rel: string): boolean => {
|
||||
try { return pathExistsInternal(cwd, rel); } catch { return false; }
|
||||
};
|
||||
|
||||
return {
|
||||
docusaurus: exists('docusaurus.config.js') || exists('docusaurus.config.ts'),
|
||||
vitepress: (
|
||||
exists('.vitepress/config.js') ||
|
||||
exists('.vitepress/config.ts') ||
|
||||
exists('.vitepress/config.mts')
|
||||
),
|
||||
mkdocs: exists('mkdocs.yml'),
|
||||
storybook: exists('.storybook'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract monorepo workspace globs from pnpm-workspace.yaml, package.json
|
||||
* workspaces, or lerna.json.
|
||||
*/
|
||||
function detectMonorepoWorkspaces(cwd: string): string[] {
|
||||
// pnpm-workspace.yaml
|
||||
const pnpmRaw = platformReadSync(path.join(cwd, 'pnpm-workspace.yaml'));
|
||||
if (pnpmRaw) {
|
||||
const workspaces: string[] = [];
|
||||
for (const line of pnpmRaw.split('\n')) {
|
||||
const m = line.match(/^\s*-\s+['"]?(.+?)['"]?\s*$/);
|
||||
if (m) workspaces.push(m[1].trim());
|
||||
}
|
||||
if (workspaces.length > 0) return workspaces;
|
||||
}
|
||||
|
||||
// package.json workspaces
|
||||
const pkgRaw = platformReadSync(path.join(cwd, 'package.json'));
|
||||
if (pkgRaw) {
|
||||
try {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
// lerna.json
|
||||
const lernaRaw = platformReadSync(path.join(cwd, 'lerna.json'));
|
||||
if (lernaRaw) {
|
||||
try {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// ─── Public commands ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return JSON context for the docs-update workflow: project signals, existing
|
||||
* doc inventory, doc tooling detection, monorepo workspaces, and model
|
||||
* resolution. Follows the cmdInitMapCodebase pattern.
|
||||
*
|
||||
* @example
|
||||
* node gsd-tools.cjs docs-init --raw
|
||||
*/
|
||||
function cmdDocsInit(cwd: string, raw: boolean): void {
|
||||
const config = loadConfig(cwd);
|
||||
const result: Record<string, unknown> = {
|
||||
doc_writer_model: resolveModelInternal(cwd, 'gsd-doc-writer'),
|
||||
commit_docs: config.commit_docs,
|
||||
existing_docs: scanExistingDocs(cwd),
|
||||
project_type: detectProjectType(cwd),
|
||||
doc_tooling: detectDocTooling(cwd),
|
||||
monorepo_workspaces: detectMonorepoWorkspaces(cwd),
|
||||
planning_exists: pathExistsInternal(cwd, '.planning'),
|
||||
};
|
||||
// Inject project_root and agent installation status (mirrors withProjectRoot in init.cjs)
|
||||
result['project_root'] = cwd;
|
||||
const agentStatus = checkAgentsInstalled();
|
||||
result['agents_installed'] = agentStatus.agents_installed;
|
||||
result['missing_agents'] = agentStatus.missing_agents;
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
export = { cmdDocsInit };
|
||||
431
src/drift.cts
Normal file
431
src/drift.cts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Codebase Drift Detection (#2003)
|
||||
*
|
||||
* Detects structural drift between a committed codebase and the
|
||||
* `.planning/codebase/STRUCTURE.md` map produced by `gsd-codebase-mapper`.
|
||||
*
|
||||
* Four categories of drift element:
|
||||
* - new_dir → a newly-added file whose directory prefix does not appear
|
||||
* in STRUCTURE.md
|
||||
* - barrel → a newly-added barrel export at
|
||||
* (packages|apps)/<name>/src/index.(ts|tsx|js|mjs|cjs)
|
||||
* - migration → a newly-added migration file under one of the recognized
|
||||
* migration directories (supabase, prisma, drizzle, src/migrations, …)
|
||||
* - route → a newly-added route module under a `routes/` or `api/` dir
|
||||
*
|
||||
* Each file is counted at most once; when a file matches multiple categories
|
||||
* the most specific category wins (migration > route > barrel > new_dir).
|
||||
*
|
||||
* Design decisions (see PR for full rubber-duck):
|
||||
* - The library is pure. It takes parsed git diff output and returns a
|
||||
* structured result. The CLI/workflow layer is responsible for running
|
||||
* git and for spawning mappers.
|
||||
* - `last_mapped_commit` is stored as YAML-style frontmatter at the top of
|
||||
* each `.planning/codebase/*.md` file. This keeps the baseline attached
|
||||
* to the file, survives git moves, and avoids a sidecar JSON.
|
||||
* - 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';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash } from './runtime-slash.cjs';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
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: Record<string, number> = { new_dir: 0, barrel: 1, route: 2, migration: 3 };
|
||||
|
||||
const BARREL_RE = /^(packages|apps)\/[^/]+\/src\/index\.(ts|tsx|js|mjs|cjs)$/;
|
||||
|
||||
const MIGRATION_RES = [
|
||||
/^supabase\/migrations\/.+\.sql$/,
|
||||
/^prisma\/migrations\/.+/,
|
||||
/^drizzle\/meta\/.+/,
|
||||
/^drizzle\/migrations\/.+/,
|
||||
/^src\/migrations\/.+\.(ts|js|sql)$/,
|
||||
/^db\/migrations\/.+\.(sql|ts|js)$/,
|
||||
/^migrations\/.+\.(sql|ts|js)$/,
|
||||
];
|
||||
|
||||
const ROUTE_RES = [
|
||||
/^(apps|packages)\/[^/]+\/src\/routes\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
|
||||
/^src\/routes\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
|
||||
/^src\/api\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
|
||||
/^(apps|packages)\/[^/]+\/src\/api\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
|
||||
];
|
||||
|
||||
// A conservative allowlist for `--paths` arguments passed to the mapper:
|
||||
// repo-relative path components separated by /, containing only
|
||||
// alphanumerics, dash, underscore, and dot (no `..`, no `/..`).
|
||||
const SAFE_PATH_RE = /^(?!.*\.\.)(?:[A-Za-z0-9_.][A-Za-z0-9_.\-]*)(?:\/[A-Za-z0-9_.][A-Za-z0-9_.\-]*)*$/;
|
||||
|
||||
// ─── Classification ──────────────────────────────────────────────────────────
|
||||
|
||||
type DriftCategory = 'barrel' | 'migration' | 'route' | 'new_dir';
|
||||
|
||||
/**
|
||||
* Classify a single file path into a drift category or null.
|
||||
*/
|
||||
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';
|
||||
if (ROUTE_RES.some((r) => r.test(norm))) return 'route';
|
||||
if (BARREL_RE.test(norm)) return 'barrel';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff any prefix of `file` (dir1, dir1/dir2, …) appears as a substring
|
||||
* of `structureMd`. Used to decide whether a file is in "mapped territory".
|
||||
*
|
||||
* Matching is deliberately substring-based — STRUCTURE.md is free-form
|
||||
* markdown, not a structured manifest. If the map mentions `src/lib/` the
|
||||
* check `structureMd.includes('src/lib')` holds.
|
||||
*/
|
||||
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".
|
||||
for (let i = parts.length - 1; i >= 1; i--) {
|
||||
const prefix = parts.slice(0, i).join('/');
|
||||
if (structureMd.includes(prefix)) return true;
|
||||
}
|
||||
// Finally, if even the top-level dir is mentioned, count as mapped.
|
||||
if (parts.length > 0 && structureMd.includes(parts[0] + '/')) return true;
|
||||
if (parts.length > 0 && structureMd.includes('`' + parts[0] + '`')) return true;
|
||||
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.
|
||||
*/
|
||||
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,
|
||||
} = inp;
|
||||
const threshold = Number.isInteger(inp.threshold) && (inp.threshold as number) >= 1
|
||||
? (inp.threshold as number)
|
||||
: 3;
|
||||
const action = inp.action === 'auto-remap' ? 'auto-remap' : 'warn';
|
||||
|
||||
if (structureMd === null || structureMd === undefined) {
|
||||
return skipped('missing-structure-md');
|
||||
}
|
||||
if (typeof structureMd !== 'string') {
|
||||
return skipped('invalid-structure-md');
|
||||
}
|
||||
|
||||
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.
|
||||
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: string | null = specific;
|
||||
if (!category) {
|
||||
if (!isPathMapped(file, structureMd)) {
|
||||
category = 'new_dir';
|
||||
} else {
|
||||
continue; // mapped, known, ordinary file — not drift
|
||||
}
|
||||
}
|
||||
// Dedup: if we've already counted this path at higher-or-equal priority, skip
|
||||
const prior = seen.get(file);
|
||||
if (prior && CATEGORY_PRIORITY[prior] >= CATEGORY_PRIORITY[category]) continue;
|
||||
seen.set(file, category);
|
||||
}
|
||||
|
||||
for (const [file, category] of seen.entries()) {
|
||||
elements.push({ category, path: file });
|
||||
}
|
||||
|
||||
// Sort for stable output.
|
||||
elements.sort((a, b) =>
|
||||
a.category === b.category
|
||||
? a.path.localeCompare(b.path)
|
||||
: a.category.localeCompare(b.category),
|
||||
);
|
||||
|
||||
const actionRequired = elements.length >= threshold;
|
||||
let directive = 'none';
|
||||
let spawnMapper = false;
|
||||
let affectedPaths: string[] = [];
|
||||
let message = '';
|
||||
|
||||
if (actionRequired) {
|
||||
directive = action;
|
||||
affectedPaths = chooseAffectedPaths(elements.map((e) => e.path));
|
||||
if (action === 'auto-remap') {
|
||||
spawnMapper = true;
|
||||
}
|
||||
message = buildMessage(elements, affectedPaths, action, inp.runtime);
|
||||
}
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
elements,
|
||||
actionRequired,
|
||||
directive,
|
||||
spawnMapper,
|
||||
affectedPaths,
|
||||
threshold,
|
||||
action,
|
||||
message,
|
||||
counts: {
|
||||
added: added.length,
|
||||
modified: modified.length,
|
||||
deleted: deleted.length,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
// Non-blocking: never throw from this function.
|
||||
const errMsg = (err as Error)?.message ? (err as Error).message : String(err);
|
||||
return skipped('exception:' + errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
function skipped(reason: string): SkippedResult {
|
||||
return {
|
||||
skipped: true,
|
||||
reason,
|
||||
elements: [],
|
||||
actionRequired: false,
|
||||
directive: 'none',
|
||||
spawnMapper: false,
|
||||
affectedPaths: [],
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessage(elements: DriftElement[], affectedPaths: string[], action: string, runtime: string | undefined): string {
|
||||
const byCat: Record<string, string[]> = {};
|
||||
for (const e of elements) {
|
||||
if (!byCat[e.category]) byCat[e.category] = [];
|
||||
byCat[e.category].push(e.path);
|
||||
}
|
||||
const lines: string[] = [
|
||||
`Codebase drift detected: ${elements.length} structural element(s) since last mapping.`,
|
||||
'',
|
||||
];
|
||||
const labels: Record<string, string> = {
|
||||
new_dir: 'New directories',
|
||||
barrel: 'New barrel exports',
|
||||
migration: 'New migrations',
|
||||
route: 'New route modules',
|
||||
};
|
||||
for (const cat of ['new_dir', 'barrel', 'migration', 'route']) {
|
||||
if (byCat[cat]) {
|
||||
lines.push(`${labels[cat]}:`);
|
||||
for (const p of byCat[cat]) lines.push(` - ${p}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
if (action === 'auto-remap') {
|
||||
lines.push(`Auto-remap scheduled for paths: ${affectedPaths.join(', ')}`);
|
||||
} else {
|
||||
// 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 mapCmd = formatGsdSlash('map-codebase', runtime || 'claude');
|
||||
lines.push(
|
||||
`Run ${String(mapCmd)} --paths ${affectedPaths.join(',')} to refresh planning context.`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ─── Affected paths ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Collapse a list of drifted file paths into a sorted, deduplicated list of
|
||||
* the top-level directory prefixes (depth 2 when the repo uses an
|
||||
* `<apps|packages>/<name>/…` layout; depth 1 otherwise).
|
||||
*/
|
||||
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, '/');
|
||||
const parts = file.split('/');
|
||||
if (parts.length === 0) continue;
|
||||
const top = parts[0];
|
||||
if ((top === 'apps' || top === 'packages') && parts.length >= 2) {
|
||||
out.add(`${top}/${parts[1]}`);
|
||||
} else {
|
||||
out.add(top);
|
||||
}
|
||||
}
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter `paths` to only those that are safe to splice into a mapper prompt.
|
||||
* Any path that is absolute, contains traversal, or includes shell
|
||||
* metacharacters is dropped.
|
||||
*/
|
||||
function sanitizePaths(paths: unknown): string[] {
|
||||
if (!Array.isArray(paths)) return [];
|
||||
const out: string[] = [];
|
||||
for (const p of paths) {
|
||||
if (typeof p !== 'string') continue;
|
||||
if (p.startsWith('/')) continue;
|
||||
if (!SAFE_PATH_RE.test(p)) continue;
|
||||
out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Frontmatter helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
||||
|
||||
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: 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;
|
||||
data[kv[1]] = kv[2];
|
||||
}
|
||||
return { data, body: content.slice(m[0].length) };
|
||||
}
|
||||
|
||||
function serializeFrontmatter(data: Record<string, string>, body: string): string {
|
||||
const keys = Object.keys(data);
|
||||
if (keys.length === 0) return body;
|
||||
const lines = ['---'];
|
||||
for (const k of keys) lines.push(`${k}: ${data[k]}`);
|
||||
lines.push('---');
|
||||
return lines.join('\n') + '\n' + 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: 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'];
|
||||
return typeof sha === 'string' && sha.length > 0 ? sha : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 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
|
||||
// doc and the caller stamps it before any prior content existed.
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
} catch (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;
|
||||
platformWriteSync(filePath, serializeFrontmatter(data, body));
|
||||
}
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export = {
|
||||
DRIFT_CATEGORIES,
|
||||
classifyFile,
|
||||
detectDrift,
|
||||
chooseAffectedPaths,
|
||||
sanitizePaths,
|
||||
readMappedCommit,
|
||||
writeMappedCommit,
|
||||
// Exposed for the CLI layer to reuse the same parser.
|
||||
parseFrontmatter,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
404
src/frontmatter.cts
Normal file
404
src/frontmatter.cts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Split a YAML inline array body on commas, respecting quoted strings.
|
||||
* e.g. '"a, b", c' → ['a, b', 'c']
|
||||
*/
|
||||
function splitInlineArray(body: string): string[] {
|
||||
const items: string[] = [];
|
||||
let current = '';
|
||||
let inQuote: string | null = null;
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote) {
|
||||
inQuote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === ',') {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) items.push(trimmed);
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) items.push(trimmed);
|
||||
return items;
|
||||
}
|
||||
|
||||
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---/);
|
||||
if (!match) return frontmatter;
|
||||
|
||||
const yaml = match[1];
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
|
||||
// Stack to track nested objects: [{obj, key, indent}]
|
||||
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
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
// Calculate indentation (number of leading spaces)
|
||||
const indentMatch = line.match(/^(\s*)/);
|
||||
const indent = indentMatch ? indentMatch[1].length : 0;
|
||||
|
||||
// Pop stack back to appropriate level
|
||||
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
const current = stack[stack.length - 1];
|
||||
|
||||
// Check for key: value pattern
|
||||
const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
|
||||
if (keyMatch) {
|
||||
const key = keyMatch[2];
|
||||
const value = keyMatch[3].trim();
|
||||
|
||||
if (value === '' || value === '[') {
|
||||
// Key with no value or opening bracket — could be nested object or array
|
||||
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: newObj, key: null, indent });
|
||||
} else if (value.startsWith('[') && value.endsWith(']')) {
|
||||
// Inline array: key: [a, b, c] — quote-aware split (REG-04 fix)
|
||||
(current.obj as Record<string, unknown>)[key] = splitInlineArray(value.slice(1, -1));
|
||||
current.key = null;
|
||||
} else {
|
||||
// Simple key: value
|
||||
(current.obj as Record<string, unknown>)[key] = value.replace(/^["']|["']$/g, '');
|
||||
current.key = null;
|
||||
}
|
||||
} else if (line.trim().startsWith('- ')) {
|
||||
// Array item
|
||||
const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, '');
|
||||
|
||||
// If current context is an empty object, convert to array
|
||||
if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) {
|
||||
// Find the key in parent that points to this object and convert it
|
||||
const parent = stack.length > 1 ? stack[stack.length - 2] : null;
|
||||
if (parent) {
|
||||
for (const k of Object.keys(parent.obj)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(current.obj)) {
|
||||
current.obj.push(itemValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
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 {
|
||||
lines.push(`${key}:`);
|
||||
for (const item of value) {
|
||||
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
|
||||
}
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
lines.push(`${key}:`);
|
||||
for (const [subkey, subval] of Object.entries(value)) {
|
||||
if (subval === null || subval === undefined) continue;
|
||||
if (Array.isArray(subval)) {
|
||||
if (subval.length === 0) {
|
||||
lines.push(` ${subkey}: []`);
|
||||
} 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) {
|
||||
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
|
||||
}
|
||||
}
|
||||
} else if (typeof subval === 'object') {
|
||||
lines.push(` ${subkey}:`);
|
||||
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) {
|
||||
lines.push(` ${subsubkey}: []`);
|
||||
} else {
|
||||
lines.push(` ${subsubkey}:`);
|
||||
for (const item of subsubval) {
|
||||
lines.push(` - ${item}`);
|
||||
}
|
||||
}
|
||||
} 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}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const sv = String(value);
|
||||
if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{')) {
|
||||
lines.push(`${key}: "${sv}"`);
|
||||
} else {
|
||||
lines.push(`${key}: ${sv}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function spliceFrontmatter(content: string, newObj: Frontmatter): string {
|
||||
const yamlStr = reconstructFrontmatter(newObj);
|
||||
const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
|
||||
if (match) {
|
||||
return `---\n${yamlStr}\n---` + content.slice(match[0].length);
|
||||
}
|
||||
return `---\n${yamlStr}\n---\n\n` + content;
|
||||
}
|
||||
|
||||
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---/);
|
||||
if (!fmMatch) return [];
|
||||
|
||||
const yaml = fmMatch[1];
|
||||
|
||||
// Find must_haves: first to detect its indentation level
|
||||
const mustHavesMatch = yaml.match(/^(\s*)must_haves:\s*$/m);
|
||||
if (!mustHavesMatch) return [];
|
||||
const mustHavesIndent = mustHavesMatch[1].length;
|
||||
|
||||
// Find the block (e.g., "truths:", "artifacts:", "key_links:") under must_haves
|
||||
// It must be indented more than must_haves but we detect the actual indent dynamically
|
||||
const blockPattern = new RegExp(`^(\\s+)${blockName}:\\s*$`, 'm');
|
||||
const blockMatch = yaml.match(blockPattern);
|
||||
if (!blockMatch) return [];
|
||||
|
||||
const blockIndent = blockMatch[1].length;
|
||||
// The block must be nested under must_haves (more indented)
|
||||
if (blockIndent <= mustHavesIndent) return [];
|
||||
|
||||
// Find where the block starts in the yaml string
|
||||
const blockStart = yaml.indexOf(blockMatch[0]);
|
||||
if (blockStart === -1) return [];
|
||||
|
||||
const afterBlock = yaml.slice(blockStart);
|
||||
const blockLines = afterBlock.split(/\r?\n/).slice(1); // skip the header line
|
||||
|
||||
// List items are indented one level deeper than blockIndent
|
||||
// Continuation KVs are indented one level deeper than list items
|
||||
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 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;
|
||||
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('- ')) {
|
||||
// Detect list item indent from the first occurrence
|
||||
if (listItemIndent === -1) listItemIndent = indent;
|
||||
|
||||
// Only treat as a top-level list item if at the expected indent
|
||||
if (indent === listItemIndent) {
|
||||
if (current) items.push(current);
|
||||
const afterDash = trimmed.slice(2);
|
||||
const trimmedAfterDash = afterDash.trim();
|
||||
// Check if it's a fully-quoted string (may contain ':' inside the quotes)
|
||||
if ((trimmedAfterDash.startsWith('"') && trimmedAfterDash.endsWith('"')) ||
|
||||
(trimmedAfterDash.startsWith("'") && trimmedAfterDash.endsWith("'"))) {
|
||||
current = trimmedAfterDash.slice(1, -1);
|
||||
// Check if it's a simple string item (no colon means not a key-value)
|
||||
} else if (!afterDash.includes(':')) {
|
||||
current = afterDash.replace(/^["']|["']$/g, '');
|
||||
} else {
|
||||
// Key-value on same line as dash: "- path: value"
|
||||
// YAML KV always has at least one space after the colon: "key: value"
|
||||
// Requiring \s+ rejects "Class::Method" and "db:seed" (no space after colon)
|
||||
const kvMatch = afterDash.match(/^(\w+):\s+"?([^"]*)"?\s*$/);
|
||||
if (kvMatch) {
|
||||
current = {};
|
||||
(current)[kvMatch[1]] = kvMatch[2];
|
||||
} else {
|
||||
// Looks like KV but doesn't match — treat as plain string (#2757)
|
||||
current = afterDash.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (current && typeof current === 'object' && indent > listItemIndent) {
|
||||
// Continuation key-value or nested array item
|
||||
if (trimmed.startsWith('- ')) {
|
||||
// Array item under a key
|
||||
const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, '');
|
||||
const keys = Object.keys(current);
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (lastKey && !Array.isArray((current)[lastKey])) {
|
||||
const existing = (current)[lastKey];
|
||||
(current)[lastKey] = existing ? [existing] : [];
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current) items.push(current);
|
||||
|
||||
// Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue.
|
||||
// This is a critical diagnostic: empty must_haves causes verification to silently degrade
|
||||
// to Option C (LLM-derived truths) instead of checking documented contracts.
|
||||
if (items.length === 0 && blockLines.length > 0) {
|
||||
const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length;
|
||||
if (nonEmptyLines > 0) {
|
||||
process.stderr.write(
|
||||
`[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` +
|
||||
`Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// ─── Frontmatter CRUD commands ────────────────────────────────────────────────
|
||||
|
||||
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: 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, undefined); return; }
|
||||
const fm = extractFrontmatter(content);
|
||||
if (field) {
|
||||
const value = fm[field];
|
||||
if (value === undefined) { output({ error: 'Field not found', field }, raw, undefined); return; }
|
||||
output({ [field]: value }, raw, JSON.stringify(value));
|
||||
} else {
|
||||
output(fm, raw, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
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, undefined); return; }
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
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: 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, undefined); return; }
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
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: string, filePath: string, schemaName: string | undefined, raw: boolean): void {
|
||||
if (!filePath || !schemaName) { error('file and schema required'); }
|
||||
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, 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');
|
||||
}
|
||||
|
||||
export = {
|
||||
extractFrontmatter,
|
||||
reconstructFrontmatter,
|
||||
spliceFrontmatter,
|
||||
parseMustHavesBlock,
|
||||
FRONTMATTER_SCHEMAS,
|
||||
cmdFrontmatterGet,
|
||||
cmdFrontmatterSet,
|
||||
cmdFrontmatterMerge,
|
||||
cmdFrontmatterValidate,
|
||||
};
|
||||
304
src/gap-checker.cts
Normal file
304
src/gap-checker.cts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Post-planning gap analysis (#2493).
|
||||
*
|
||||
* Reads REQUIREMENTS.md (planning-root) and CONTEXT.md (per-phase) and compares
|
||||
* each REQ-ID and D-ID against the concatenated text of all PLAN.md files in
|
||||
* the phase directory. Emits a unified `Source | Item | Status` report.
|
||||
*
|
||||
* Gated on workflow.post_planning_gaps (default true). When false, returns
|
||||
* { enabled: false } and does not scan.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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.
|
||||
*
|
||||
* Supports both checkbox (`- [ ] **REQ-NN** ...`) and traceability table
|
||||
* (`| REQ-NN | ... |`) formats.
|
||||
*/
|
||||
function parseRequirements(reqMd: unknown): ReqItem[] {
|
||||
if (!reqMd || typeof reqMd !== 'string') return [];
|
||||
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_-]+';
|
||||
|
||||
const checkboxRe = new RegExp(`^\\s*-\\s*\\[[x ]\\]\\s*\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`, 'gm');
|
||||
let cm = checkboxRe.exec(reqMd);
|
||||
while (cm !== null) {
|
||||
const id = cm[1];
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push({ id, text: (cm[2] || '').trim() });
|
||||
}
|
||||
cm = checkboxRe.exec(reqMd);
|
||||
}
|
||||
|
||||
const tableFirstCellRe = new RegExp(`^\\s*\\|\\s*(${ID_PATTERN})\\s*\\|`);
|
||||
const separatorRowRe = /^\s*\|[\s:|-]+\|\s*$/;
|
||||
const lines = reqMd.split(/\r?\n/);
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (!line.includes('|')) continue;
|
||||
|
||||
// Skip markdown table separator rows and header rows immediately preceding them.
|
||||
if (separatorRowRe.test(line)) continue;
|
||||
if (i + 1 < lines.length && separatorRowRe.test(lines[i + 1])) continue;
|
||||
|
||||
const tm = tableFirstCellRe.exec(line);
|
||||
if (!tm) continue;
|
||||
const id = tm[1];
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push({ id, text: '' });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectCoverage(items: Item[], planText: string): CoverageRow[] {
|
||||
return items.map(it => {
|
||||
const re = new RegExp('\\b' + escapeRegex(it.id) + '\\b');
|
||||
return {
|
||||
source: it.source,
|
||||
item: it.id,
|
||||
status: re.test(planText) ? 'Covered' : 'Not covered',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function naturalKey(s: unknown): string {
|
||||
return String(s).replace(/(\d+)/g, (_, n: string) => n.padStart(8, '0'));
|
||||
}
|
||||
|
||||
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;
|
||||
return naturalKey(a.item).localeCompare(naturalKey(b.item));
|
||||
});
|
||||
}
|
||||
|
||||
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' ? '✓ 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: string): boolean {
|
||||
const cfgPath = path.join(planningDir(cwd), 'config.json');
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw `--phase-req-ids` argument into the scoping signal used by
|
||||
* runGapAnalysis (#447). Mirrors §13's null/TBD skip semantics.
|
||||
*
|
||||
* undefined → flag absent: compare the whole REQUIREMENTS.md (back-compat)
|
||||
* null | '' | TBD → no requirements mapped to this phase: skip the comparison
|
||||
* "REQ-01,REQ-02" → restrict the comparison to these IDs
|
||||
*
|
||||
* Tolerates JSON-array-ish input (`["REQ-01","REQ-02"]`) since callers may pass
|
||||
* the roadmap value through verbatim.
|
||||
*/
|
||||
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
|
||||
// roadmap value verbatim, whose serialization is not guaranteed).
|
||||
const ids = v.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
|
||||
return ids.length === 0 ? null : ids;
|
||||
}
|
||||
|
||||
function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOptions = {}): GapResult {
|
||||
const phaseReqIds = normalizePhaseReqIds(options.phaseReqIds);
|
||||
if (!readGate(cwd)) {
|
||||
return {
|
||||
enabled: false,
|
||||
rows: [],
|
||||
table: '',
|
||||
summary: 'workflow.post_planning_gaps disabled — skipping post-planning gap analysis',
|
||||
counts: { total: 0, covered: 0, uncovered: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const absPhaseDir = path.isAbsolute(phaseDir) ? phaseDir : path.join(cwd, phaseDir);
|
||||
|
||||
const reqPath = planningPaths(cwd).requirements;
|
||||
const reqMd = fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf-8') : '';
|
||||
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: string[] = [];
|
||||
if (phaseReqIds === null) {
|
||||
reqItems = [];
|
||||
} else if (Array.isArray(phaseReqIds)) {
|
||||
const wanted = new Set(phaseReqIds);
|
||||
const foundIds = new Set(reqItems.map(r => r.id));
|
||||
reqItems = reqItems.filter(r => wanted.has(r.id));
|
||||
ghostReqIds = phaseReqIds.filter(id => !foundIds.has(id));
|
||||
}
|
||||
|
||||
// Read the phase directory once; reuse the listing for both context detection
|
||||
// and plan-file enumeration (avoids redundant readdirSync calls).
|
||||
let phaseDirFiles: string[] = [];
|
||||
try {
|
||||
if (fs.existsSync(absPhaseDir)) phaseDirFiles = fs.readdirSync(absPhaseDir);
|
||||
} catch { /* unreadable */ }
|
||||
|
||||
const ctxFile = findContextMdIn(phaseDirFiles);
|
||||
const ctxPath = ctxFile ? path.join(absPhaseDir, ctxFile) : null;
|
||||
const ctxMd = ctxPath ? fs.readFileSync(ctxPath, 'utf-8') : '';
|
||||
const dItems: DecisionItem[] = parseDecisions(ctxMd).map(d => ({ ...d, source: 'CONTEXT.md' }));
|
||||
|
||||
const items: Item[] = [...reqItems, ...dItems];
|
||||
|
||||
let planText = '';
|
||||
try {
|
||||
if (phaseDirFiles.length > 0) {
|
||||
const files = phaseDirFiles.filter(f => /-PLAN\.md$/.test(f));
|
||||
planText = files.map(f => {
|
||||
try { return fs.readFileSync(path.join(absPhaseDir, f), 'utf-8'); }
|
||||
catch { return ''; }
|
||||
}).join('\n');
|
||||
}
|
||||
} catch { /* unreadable */ }
|
||||
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
enabled: true,
|
||||
rows: [],
|
||||
table: '## Post-Planning Gap Analysis\n\nNo requirements or decisions to check.\n',
|
||||
summary: 'no requirements or decisions to check',
|
||||
counts: { total: 0, covered: 0, uncovered: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const rows = sortRows([
|
||||
...detectCoverage(items, planText),
|
||||
...ghostReqIds.map(id => ({ source: 'REQUIREMENTS.md', item: id, status: 'Missing from REQUIREMENTS.md' })),
|
||||
]);
|
||||
const covered = rows.filter(r => r.status === 'Covered').length;
|
||||
const uncovered = rows.length - covered;
|
||||
|
||||
const summary = uncovered === 0
|
||||
? `✓ All ${rows.length} items covered by plans`
|
||||
: `⚠ ${uncovered} of ${rows.length} items not covered by any plan`;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
rows,
|
||||
table: formatGapTable(rows) + '\n' + summary + '\n',
|
||||
summary,
|
||||
counts: { total: rows.length, covered, uncovered },
|
||||
};
|
||||
}
|
||||
|
||||
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>');
|
||||
}
|
||||
const phaseDir = args[idx + 1];
|
||||
|
||||
// Optional --phase-req-ids scopes the requirements comparison (#447).
|
||||
// Absent → compare the whole REQUIREMENTS.md (back-compat).
|
||||
const reqIdx = args.indexOf('--phase-req-ids');
|
||||
const phaseReqIds = reqIdx === -1 ? undefined : (args[reqIdx + 1] ?? '');
|
||||
|
||||
const result = runGapAnalysis(cwd, phaseDir, { phaseReqIds });
|
||||
output(result, raw, result.table || result.summary);
|
||||
}
|
||||
|
||||
export = {
|
||||
parseRequirements,
|
||||
detectCoverage,
|
||||
formatGapTable,
|
||||
sortRows,
|
||||
normalizePhaseReqIds,
|
||||
runGapAnalysis,
|
||||
cmdGapAnalysis,
|
||||
};
|
||||
643
src/graphify.cts
Normal file
643
src/graphify.cts
Normal file
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execTool, execGit, platformWriteSync } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Config Gate ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
function isGraphifyEnabled(planningDir: string): boolean {
|
||||
try {
|
||||
const configPath = path.join(planningDir, 'config.json');
|
||||
if (!fs.existsSync(configPath)) return false;
|
||||
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.
|
||||
*/
|
||||
function disabledResponse(): DisabledResponse {
|
||||
return { disabled: true, message: 'graphify is not enabled. Enable with: gsd-tools config-set graphify.enabled true' };
|
||||
}
|
||||
|
||||
// ─── Subprocess Helper ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Frozen enum of typed reason codes for execGraphify failures (#2974).
|
||||
* Tests assert on result.reason instead of grepping stderr text.
|
||||
*/
|
||||
const GRAPHIFY_REASON = Object.freeze({
|
||||
OK: 'ok',
|
||||
ENOENT: 'graphify_not_found',
|
||||
TIMEOUT: 'graphify_timed_out',
|
||||
EXIT_NONZERO: 'graphify_exit_nonzero',
|
||||
} as const);
|
||||
|
||||
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,
|
||||
timeout,
|
||||
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
||||
});
|
||||
|
||||
// ENOENT — seam normalizes to exitCode 127. Surface as typed reason.
|
||||
if (result.error && (result.error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {
|
||||
exitCode: 127,
|
||||
stdout: '',
|
||||
stderr: 'graphify not found on PATH',
|
||||
reason: GRAPHIFY_REASON.ENOENT,
|
||||
};
|
||||
}
|
||||
|
||||
// Timeout — seam exposes signal; spawnSync sets SIGTERM when killed by timeout.
|
||||
if (result.signal === 'SIGTERM') {
|
||||
return {
|
||||
exitCode: 124,
|
||||
stdout: result.stdout,
|
||||
stderr: 'graphify timed out after ' + timeout + 'ms',
|
||||
reason: GRAPHIFY_REASON.TIMEOUT,
|
||||
timeout_ms: timeout,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
reason: result.exitCode === 0 ? GRAPHIFY_REASON.OK : GRAPHIFY_REASON.EXIT_NONZERO,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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).
|
||||
*/
|
||||
function checkGraphifyInstalled(): InstalledResult {
|
||||
const result = execTool('graphify', ['--help'], { timeout: 5000 });
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
installed: false,
|
||||
message: 'graphify is not installed.\n\nInstall with:\n uv pip install graphifyy && graphify install',
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
* Detection strategy:
|
||||
* 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
|
||||
*/
|
||||
function checkGraphifyVersion(): VersionResult {
|
||||
// Strategy 1: try `graphify --version` directly (2s timeout -- fast path)
|
||||
const versionResult = execTool('graphify', ['--version'], { timeout: 2000 });
|
||||
|
||||
let versionStr: string | null = null;
|
||||
|
||||
if (!versionResult.error && versionResult.exitCode === 0) {
|
||||
// graphify --version may emit "graphify 0.4.23" or just "0.4.23"
|
||||
const match = versionResult.stdout.match(/(\d+\.\d+(?:\.\d+)*)/);
|
||||
if (match) {
|
||||
versionStr = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: fall back to python3 importlib.metadata
|
||||
if (!versionStr) {
|
||||
const pyResult = execTool('python3', [
|
||||
'-c',
|
||||
'from importlib.metadata import version; print(version("graphifyy"))',
|
||||
], { timeout: 5000 });
|
||||
|
||||
if (!pyResult.error && pyResult.exitCode === 0 && pyResult.stdout) {
|
||||
versionStr = pyResult.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
if (!versionStr) {
|
||||
return { version: null, compatible: null, warning: 'Could not determine graphify version' };
|
||||
}
|
||||
|
||||
const parts = versionStr.split('.').map(Number);
|
||||
|
||||
if (parts.length < 2 || parts.some(isNaN)) {
|
||||
return { version: versionStr, compatible: null, warning: 'Could not parse version: ' + versionStr };
|
||||
}
|
||||
|
||||
const compatible = parts[0] === 0 && parts[1] >= 4;
|
||||
const warning = compatible ? null : 'graphify version ' + versionStr + ' is outside tested range >=0.4.0,<1.0';
|
||||
|
||||
return { version: versionStr, compatible, warning };
|
||||
}
|
||||
|
||||
// ─── 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).
|
||||
*/
|
||||
function safeReadJson(filePath: string): Graph | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
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).
|
||||
*/
|
||||
function buildAdjacencyMap(graph: Graph): Record<string, AdjEntry[]> {
|
||||
const adj: Record<string, AdjEntry[]> = {};
|
||||
for (const node of (graph.nodes || [])) {
|
||||
adj[node.id] = [];
|
||||
}
|
||||
for (const edge of (graph.edges || graph.links || [])) {
|
||||
if (!adj[edge.source]) adj[edge.source] = [];
|
||||
if (!adj[edge.target]) adj[edge.target] = [];
|
||||
adj[edge.source].push({ target: edge.target, edge });
|
||||
adj[edge.target].push({ target: edge.source, edge });
|
||||
}
|
||||
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).
|
||||
*/
|
||||
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);
|
||||
|
||||
// Seed: match on label and description (case-insensitive substring)
|
||||
const seeds = (graph.nodes || []).filter(n =>
|
||||
(n.label || '').toLowerCase().includes(lowerTerm) ||
|
||||
(n.description || '').toLowerCase().includes(lowerTerm)
|
||||
);
|
||||
|
||||
// BFS expand from seeds
|
||||
const visitedNodes = new Set(seeds.map(n => n.id));
|
||||
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: string[] = [];
|
||||
for (const nodeId of frontier) {
|
||||
for (const entry of (adj[nodeId] || [])) {
|
||||
// Deduplicate edges by source::target::label key
|
||||
const edgeKey = `${entry.edge.source}::${entry.edge.target}::${entry.edge.label || ''}`;
|
||||
if (!seenEdgeKeys.has(edgeKey)) {
|
||||
seenEdgeKeys.add(edgeKey);
|
||||
collectedEdges.push(entry.edge);
|
||||
}
|
||||
if (!visitedNodes.has(entry.target)) {
|
||||
visitedNodes.add(entry.target);
|
||||
nextFrontier.push(entry.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = nextFrontier;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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: unknown) => Math.ceil(JSON.stringify(obj).length / 4);
|
||||
|
||||
for (const tier of CONFIDENCE_ORDER) {
|
||||
if (estimateTokens({ nodes: result.nodes, edges }) <= budgetTokens) break;
|
||||
const before = edges.length;
|
||||
// Check both confidence and confidence_score field names (Open Question 1)
|
||||
edges = edges.filter(e => (e.confidence || e.confidence_score) !== tier);
|
||||
omitted += before - edges.length;
|
||||
}
|
||||
|
||||
// Find unreachable nodes after edge removal
|
||||
const reachableNodes = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
reachableNodes.add(edge.source);
|
||||
reachableNodes.add(edge.target);
|
||||
}
|
||||
// Always keep seed nodes
|
||||
const nodes = result.nodes.filter(n => reachableNodes.has(n.id) || (result.seeds && result.seeds.has(n.id)));
|
||||
const unreachable = result.nodes.length - nodes.length;
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
trimmed: omitted > 0 ? `[${omitted} edges omitted, ${unreachable} nodes unreachable]` : null,
|
||||
total_nodes: nodes.length,
|
||||
total_edges: edges.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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).
|
||||
*/
|
||||
function graphifyQuery(cwd: string, term: string, options: { budget?: number | null } = {}): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
|
||||
if (!fs.existsSync(graphPath)) {
|
||||
return { error: 'No graph built yet. Run graphify build first.' };
|
||||
}
|
||||
|
||||
const graph = safeReadJson(graphPath);
|
||||
if (!graph) {
|
||||
return { error: 'Failed to parse graph.json' };
|
||||
}
|
||||
|
||||
let result: ExpandResult | BudgetResult = seedAndExpand(graph, term);
|
||||
|
||||
if (options.budget) {
|
||||
result = applyBudget(result, options.budget);
|
||||
}
|
||||
|
||||
return {
|
||||
term,
|
||||
nodes: result.nodes,
|
||||
edges: result.edges,
|
||||
total_nodes: result.nodes.length,
|
||||
total_edges: result.edges.length,
|
||||
trimmed: 'trimmed' in result ? (result.trimmed || null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return status information about the knowledge graph (STAT-01, STAT-02).
|
||||
*
|
||||
* Surfaces the graphify v0.7+ commit-staleness signal as four optional
|
||||
* fields when graph.built_at_commit is present and validly formatted
|
||||
* (#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").
|
||||
*/
|
||||
function graphifyStatus(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
|
||||
if (!fs.existsSync(graphPath)) {
|
||||
return { exists: false, message: 'No graph built yet. Run graphify build to create one.' };
|
||||
}
|
||||
|
||||
const stat = fs.statSync(graphPath);
|
||||
const graph = safeReadJson(graphPath);
|
||||
if (!graph) {
|
||||
return { error: 'Failed to parse graph.json' };
|
||||
}
|
||||
|
||||
const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const age = Date.now() - stat.mtimeMs;
|
||||
|
||||
// Commit-staleness signal (#3170). Validate before passing to git.
|
||||
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: number | null = null;
|
||||
let commitStale: boolean | null = null;
|
||||
if (builtAt && head) {
|
||||
commitsBehind = countCommitsBetween(cwd, builtAt, head);
|
||||
if (commitsBehind !== null) commitStale = commitsBehind > 0;
|
||||
}
|
||||
|
||||
// Auto-update status (#3347). Read .last-build-status.json written by the
|
||||
// hooks/gsd-graphify-update.sh PostToolUse hook (opt-in via graphify.auto_update,
|
||||
// default false). When the most recent auto-build is "failed" or still "running",
|
||||
// fold that into the existing `stale: true` signal so consumers (gsd-planner,
|
||||
// gsd-phase-researcher) surface the standard "treat semantic relationships as
|
||||
// approximate" annotation without per-consumer prompt changes. The full state
|
||||
// (running/failed/exit_code/duration_ms/head_at_build) is exposed under
|
||||
// `last_build` for callers that want richer context.
|
||||
const statusPath = path.join(planningDir, 'graphs', '.last-build-status.json');
|
||||
const lastBuildAutoUpdate = fs.existsSync(statusPath) ? safeReadJson(statusPath) : null;
|
||||
const autoUpdateStale =
|
||||
lastBuildAutoUpdate &&
|
||||
(lastBuildAutoUpdate.status === 'failed' || lastBuildAutoUpdate.status === 'running');
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
last_build: stat.mtime.toISOString(),
|
||||
node_count: (graph.nodes || []).length,
|
||||
edge_count: (graph.edges || graph.links || []).length,
|
||||
hyperedge_count: (graph.hyperedges || []).length,
|
||||
stale: age > STALE_MS || Boolean(autoUpdateStale),
|
||||
age_hours: Math.round(age / (60 * 60 * 1000)),
|
||||
built_at_commit: builtAt ? builtAt.slice(0, 7) : null,
|
||||
current_commit: head ? head.slice(0, 7) : null,
|
||||
commits_behind: commitsBehind,
|
||||
commit_stale: commitStale,
|
||||
last_build_auto_update: lastBuildAutoUpdate || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute topology-level diff between current graph and last build snapshot (D-07, D-08, D-09).
|
||||
*/
|
||||
function graphifyDiff(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const snapshotPath = path.join(planningDir, 'graphs', '.last-build-snapshot.json');
|
||||
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
|
||||
|
||||
if (!fs.existsSync(snapshotPath)) {
|
||||
return { no_baseline: true, message: 'No previous snapshot. Run graphify build first, then build again to generate a diff baseline.' };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(graphPath)) {
|
||||
return { error: 'No current graph. Run graphify build first.' };
|
||||
}
|
||||
|
||||
const current = safeReadJson(graphPath);
|
||||
const snapshot = safeReadJson(snapshotPath);
|
||||
|
||||
if (!current || !snapshot) {
|
||||
return { error: 'Failed to parse graph or snapshot file' };
|
||||
}
|
||||
|
||||
// Diff nodes
|
||||
const currentNodeMap = Object.fromEntries((current.nodes || []).map(n => [n.id, n]));
|
||||
const snapshotNodeMap = Object.fromEntries((snapshot.nodes || []).map(n => [n.id, n]));
|
||||
|
||||
const nodesAdded = Object.keys(currentNodeMap).filter(id => !snapshotNodeMap[id]);
|
||||
const nodesRemoved = Object.keys(snapshotNodeMap).filter(id => !currentNodeMap[id]);
|
||||
const nodesChanged = Object.keys(currentNodeMap).filter(id =>
|
||||
snapshotNodeMap[id] && JSON.stringify(currentNodeMap[id]) !== JSON.stringify(snapshotNodeMap[id])
|
||||
);
|
||||
|
||||
// Diff edges (keyed by source+target+relation)
|
||||
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]));
|
||||
|
||||
const edgesAdded = Object.keys(currentEdgeMap).filter(k => !snapshotEdgeMap[k]);
|
||||
const edgesRemoved = Object.keys(snapshotEdgeMap).filter(k => !currentEdgeMap[k]);
|
||||
const edgesChanged = Object.keys(currentEdgeMap).filter(k =>
|
||||
snapshotEdgeMap[k] && JSON.stringify(currentEdgeMap[k]) !== JSON.stringify(snapshotEdgeMap[k])
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: { added: nodesAdded.length, removed: nodesRemoved.length, changed: nodesChanged.length },
|
||||
edges: { added: edgesAdded.length, removed: edgesRemoved.length, changed: edgesChanged.length },
|
||||
timestamp: snapshot.timestamp || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Build Pipeline (Phase 3) ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-flight checks for graphify build (BUILD-01, BUILD-02, D-09).
|
||||
* Does NOT invoke graphify -- returns structured JSON for the builder agent.
|
||||
*/
|
||||
function graphifyBuild(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const installed = checkGraphifyInstalled();
|
||||
if (!installed.installed) return { error: installed.message };
|
||||
|
||||
const version = checkGraphifyVersion();
|
||||
|
||||
// Ensure output directory exists (D-05)
|
||||
const graphsDir = path.join(planningDir, 'graphs');
|
||||
fs.mkdirSync(graphsDir, { recursive: true });
|
||||
|
||||
// Read build timeout from config -- default 300s per D-02
|
||||
const config = safeReadJson(path.join(planningDir, 'config.json')) || {};
|
||||
const graphifyConfig = config.graphify as Record<string, unknown> | undefined;
|
||||
const timeoutSec = (graphifyConfig && graphifyConfig.build_timeout) || 300;
|
||||
|
||||
return {
|
||||
action: 'spawn_agent',
|
||||
graphs_dir: graphsDir,
|
||||
graphify_out: path.join(cwd, 'graphify-out'),
|
||||
timeout_seconds: timeoutSec,
|
||||
version: version.version,
|
||||
version_warning: version.warning,
|
||||
artifacts: ['graph.json', 'graph.html', 'GRAPH_REPORT.md'],
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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' };
|
||||
|
||||
const snapshot = {
|
||||
version: 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
nodes: graph.nodes || [],
|
||||
edges: graph.edges || graph.links || [],
|
||||
};
|
||||
|
||||
const snapshotPath = path.join(cwd, '.planning', 'graphs', '.last-build-snapshot.json');
|
||||
platformWriteSync(snapshotPath, JSON.stringify(snapshot, null, 2));
|
||||
return {
|
||||
saved: true,
|
||||
timestamp: snapshot.timestamp,
|
||||
node_count: snapshot.nodes.length,
|
||||
edge_count: snapshot.edges.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export = {
|
||||
// Config gate
|
||||
isGraphifyEnabled,
|
||||
disabledResponse,
|
||||
// Subprocess
|
||||
execGraphify,
|
||||
GRAPHIFY_REASON,
|
||||
// Presence and version
|
||||
checkGraphifyInstalled,
|
||||
checkGraphifyVersion,
|
||||
// Query (Phase 2)
|
||||
graphifyQuery,
|
||||
safeReadJson,
|
||||
buildAdjacencyMap,
|
||||
seedAndExpand,
|
||||
applyBudget,
|
||||
// Status (Phase 2)
|
||||
graphifyStatus,
|
||||
// Diff (Phase 2)
|
||||
graphifyDiff,
|
||||
// Build (Phase 3)
|
||||
graphifyBuild,
|
||||
writeSnapshot,
|
||||
};
|
||||
569
src/gsd2-import.cts
Normal file
569
src/gsd2-import.cts
Normal file
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* gsd2-import — Reverse migration from GSD-2 (.gsd/) to GSD v1 (.planning/)
|
||||
*
|
||||
* Reads a GSD-2 project directory structure and produces a complete
|
||||
* .planning/ artifact tree in GSD v1 format.
|
||||
*
|
||||
* GSD-2 hierarchy: Milestone → Slice → Task
|
||||
* GSD v1 hierarchy: Milestone (in ROADMAP.md) → Phase → Plan
|
||||
*
|
||||
* Mapping rules:
|
||||
* - Slices are numbered sequentially across all milestones (01, 02, …)
|
||||
* - Tasks within a slice become plans (01-01, 01-02, …)
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
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: string): string | null {
|
||||
try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
|
||||
}
|
||||
|
||||
function zeroPad(n: number, width = 2): string {
|
||||
return String(n).padStart(width, '0');
|
||||
}
|
||||
|
||||
function slugify(title: string): string {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
// ─── GSD-2 Parser ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the .gsd/ directory starting from a project root.
|
||||
* Returns the absolute path or null if not found.
|
||||
*/
|
||||
function findGsd2Root(startPath: string): string | null {
|
||||
if (path.basename(startPath) === '.gsd' && fs.existsSync(startPath)) {
|
||||
return startPath;
|
||||
}
|
||||
const candidate = path.join(startPath, '.gsd');
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the ## Slices section from a GSD-2 milestone ROADMAP.md.
|
||||
* Each slice entry looks like:
|
||||
* - [x] **S01: Title** `risk:medium` `depends:[S00]`
|
||||
*/
|
||||
function parseSlicesFromRoadmap(content: string): SliceInfo[] {
|
||||
const slices: SliceInfo[] = [];
|
||||
const sectionMatch = content.match(/## Slices\n([\s\S]*?)(?:\n## |\n# |$)/);
|
||||
if (!sectionMatch) return slices;
|
||||
|
||||
for (const line of sectionMatch[1].split('\n')) {
|
||||
const m = line.match(/^- \[([x ])\]\s+\*\*(\w+):\s*([^*]+)\*\*/);
|
||||
if (!m) continue;
|
||||
slices.push({ done: m[1] === 'x', id: m[2].trim(), title: m[3].trim() });
|
||||
}
|
||||
return slices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the milestone title from the first heading in a GSD-2 ROADMAP.md.
|
||||
* Format: # M001: Title
|
||||
*/
|
||||
function parseMilestoneTitle(content: string): string | null {
|
||||
const m = content.match(/^# \w+:\s*(.+)/m);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a task title from a GSD-2 T##-PLAN.md.
|
||||
* Format: # T01: Title
|
||||
*/
|
||||
function parseTaskTitle(content: string, fallback: string): string {
|
||||
const m = content.match(/^# \w+:\s*(.+)/m);
|
||||
return m ? m[1].trim() : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the ## Description body from a GSD-2 task plan.
|
||||
*/
|
||||
function parseTaskDescription(content: string): string {
|
||||
const m = content.match(/## Description\n+([\s\S]+?)(?:\n## |\n# |$)/);
|
||||
return m ? m[1].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ## Must-Haves items from a GSD-2 task plan.
|
||||
*/
|
||||
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((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: string): TaskInfo[] {
|
||||
if (!fs.existsSync(tasksDir)) return [];
|
||||
|
||||
return fs.readdirSync(tasksDir)
|
||||
.filter(f => f.endsWith('-PLAN.md'))
|
||||
.sort()
|
||||
.map(tf => {
|
||||
const tid = tf.replace('-PLAN.md', '');
|
||||
const plan = readOptional(path.join(tasksDir, tf));
|
||||
const summary = readOptional(path.join(tasksDir, `${tid}-SUMMARY.md`));
|
||||
return {
|
||||
id: tid,
|
||||
title: plan ? parseTaskTitle(plan, tid) : tid,
|
||||
description: plan ? parseTaskDescription(plan) : '',
|
||||
mustHaves: plan ? parseTaskMustHaves(plan) : [],
|
||||
plan,
|
||||
summary,
|
||||
done: !!summary,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a complete GSD-2 .gsd/ directory into a structured representation.
|
||||
*/
|
||||
function parseGsd2(gsdDir: string): Gsd2Data {
|
||||
const data: Gsd2Data = {
|
||||
projectContent: readOptional(path.join(gsdDir, 'PROJECT.md')),
|
||||
requirements: readOptional(path.join(gsdDir, 'REQUIREMENTS.md')),
|
||||
milestones: [],
|
||||
};
|
||||
|
||||
const milestonesBase = path.join(gsdDir, 'milestones');
|
||||
if (!fs.existsSync(milestonesBase)) return data;
|
||||
|
||||
const milestoneIds = fs.readdirSync(milestonesBase)
|
||||
.filter(d => fs.statSync(path.join(milestonesBase, d)).isDirectory())
|
||||
.sort();
|
||||
|
||||
for (const mid of milestoneIds) {
|
||||
const mDir = path.join(milestonesBase, mid);
|
||||
const roadmapContent = readOptional(path.join(mDir, `${mid}-ROADMAP.md`));
|
||||
const slicesDir = path.join(mDir, 'slices');
|
||||
|
||||
const sliceInfos = roadmapContent ? parseSlicesFromRoadmap(roadmapContent) : [];
|
||||
|
||||
const slices: Slice[] = sliceInfos.map(info => {
|
||||
const sDir = path.join(slicesDir, info.id);
|
||||
const hasSDir = fs.existsSync(sDir);
|
||||
return {
|
||||
id: info.id,
|
||||
title: info.title,
|
||||
done: info.done,
|
||||
plan: hasSDir ? readOptional(path.join(sDir, `${info.id}-PLAN.md`)) : null,
|
||||
summary: hasSDir ? readOptional(path.join(sDir, `${info.id}-SUMMARY.md`)) : null,
|
||||
research: hasSDir ? readOptional(path.join(sDir, `${info.id}-RESEARCH.md`)) : null,
|
||||
context: hasSDir ? readOptional(path.join(sDir, `${info.id}-CONTEXT.md`)) : null,
|
||||
tasks: hasSDir ? readTasksDir(path.join(sDir, 'tasks')) : [],
|
||||
};
|
||||
});
|
||||
|
||||
data.milestones.push({
|
||||
id: mid,
|
||||
title: roadmapContent ? (parseMilestoneTitle(roadmapContent) ?? mid) : mid,
|
||||
research: readOptional(path.join(mDir, `${mid}-RESEARCH.md`)),
|
||||
slices,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Artifact Builders ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a GSD v1 PLAN.md from a GSD-2 task.
|
||||
*/
|
||||
function buildPlanMd(task: TaskInfo, phasePrefix: string, planPrefix: string, phaseSlug: string, milestoneTitle: string): string {
|
||||
const lines = [
|
||||
'---',
|
||||
`phase: "${phasePrefix}"`,
|
||||
`plan: "${planPrefix}"`,
|
||||
'type: "implementation"',
|
||||
'---',
|
||||
'',
|
||||
'<objective>',
|
||||
task.title,
|
||||
'</objective>',
|
||||
'',
|
||||
'<context>',
|
||||
`Phase: ${phasePrefix} (${phaseSlug}) — Milestone: ${milestoneTitle}`,
|
||||
];
|
||||
|
||||
if (task.description) {
|
||||
lines.push('', task.description);
|
||||
}
|
||||
|
||||
lines.push('</context>');
|
||||
|
||||
if (task.mustHaves.length > 0) {
|
||||
lines.push('', '<must_haves>');
|
||||
for (const mh of task.mustHaves) {
|
||||
lines.push(`- ${mh}`);
|
||||
}
|
||||
lines.push('</must_haves>');
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GSD v1 SUMMARY.md from a GSD-2 task summary.
|
||||
* Strips the GSD-2 frontmatter and preserves the body.
|
||||
*/
|
||||
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]*)$/);
|
||||
const body = bodyMatch ? bodyMatch[1].trim() : raw.trim();
|
||||
|
||||
return [
|
||||
'---',
|
||||
`phase: "${phasePrefix}"`,
|
||||
`plan: "${planPrefix}"`,
|
||||
'---',
|
||||
'',
|
||||
body || 'Task completed (migrated from GSD-2).',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GSD v1 XX-CONTEXT.md from a GSD-2 slice.
|
||||
*/
|
||||
function buildContextMd(slice: Slice, phasePrefix: string): string {
|
||||
const lines = [
|
||||
`# Phase ${phasePrefix} Context`,
|
||||
'',
|
||||
`Migrated from GSD-2 slice ${slice.id}: ${slice.title}`,
|
||||
];
|
||||
|
||||
const extra = slice.context || '';
|
||||
if (extra.trim()) {
|
||||
lines.push('', extra.trim());
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the GSD v1 ROADMAP.md with milestone-sectioned format.
|
||||
*/
|
||||
function buildRoadmapMd(milestones: Milestone[], phaseMap: PhaseMapEntry[]): string {
|
||||
const lines = ['# Roadmap', ''];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
lines.push(`## ${milestone.id}: ${milestone.title}`, '');
|
||||
const mPhases = phaseMap.filter(p => p.milestoneId === milestone.id);
|
||||
for (const { slice, phaseNum } of mPhases) {
|
||||
const prefix = zeroPad(phaseNum);
|
||||
const slug = slugify(slice.title);
|
||||
const check = slice.done ? 'x' : ' ';
|
||||
lines.push(`- [${check}] **Phase ${prefix}: ${slug}** — ${slice.title}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the GSD v1 STATE.md reflecting the current position in the project.
|
||||
*/
|
||||
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;
|
||||
const pct = totalPhases > 0 ? Math.round((donePhases / totalPhases) * 100) : 0;
|
||||
|
||||
const currentPhaseNum = currentEntry ? zeroPad(currentEntry.phaseNum) : zeroPad(totalPhases);
|
||||
const currentSlug = currentEntry ? slugify(currentEntry.slice.title) : 'complete';
|
||||
const status = currentEntry ? 'Ready to plan' : 'All phases complete';
|
||||
|
||||
const filled = Math.round(pct / 10);
|
||||
const bar = `[${'█'.repeat(filled)}${'░'.repeat(10 - filled)}]`;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
return [
|
||||
'# Project State',
|
||||
'',
|
||||
'## Project Reference',
|
||||
'',
|
||||
'See: .planning/PROJECT.md',
|
||||
'',
|
||||
`**Current focus:** Phase ${currentPhaseNum} (${currentSlug})`,
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
`Phase: ${currentPhaseNum} of ${zeroPad(totalPhases)} (${currentSlug})`,
|
||||
`Status: ${status}`,
|
||||
`Last activity: ${today} — Migrated from GSD-2`,
|
||||
'',
|
||||
`Progress: ${bar} ${pct}%`,
|
||||
'',
|
||||
'## Accumulated Context',
|
||||
'',
|
||||
'### Decisions',
|
||||
'',
|
||||
'Migrated from GSD-2. Review PROJECT.md for key decisions.',
|
||||
'',
|
||||
'### Blockers/Concerns',
|
||||
'',
|
||||
'None.',
|
||||
'',
|
||||
'## Session Continuity',
|
||||
'',
|
||||
`Last session: ${today}`,
|
||||
'Stopped at: Migration from GSD-2 completed',
|
||||
'Resume file: None',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Transformer ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert parsed GSD-2 data into a map of relative path → file content.
|
||||
* All paths are relative to the .planning/ root.
|
||||
*/
|
||||
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');
|
||||
if (gsd2Data.requirements) {
|
||||
artifacts.set('REQUIREMENTS.md', gsd2Data.requirements);
|
||||
}
|
||||
|
||||
// Minimal valid v1 config
|
||||
artifacts.set('config.json', JSON.stringify({ version: 1 }, null, 2) + '\n');
|
||||
|
||||
// Build sequential phase map: flatten Milestones → Slices into numbered phases
|
||||
const phaseMap: PhaseMapEntry[] = [];
|
||||
let phaseNum = 1;
|
||||
for (const milestone of gsd2Data.milestones) {
|
||||
for (const slice of milestone.slices) {
|
||||
phaseMap.push({ milestoneId: milestone.id, milestoneTitle: milestone.title, slice, phaseNum });
|
||||
phaseNum++;
|
||||
}
|
||||
}
|
||||
|
||||
artifacts.set('ROADMAP.md', buildRoadmapMd(gsd2Data.milestones, phaseMap));
|
||||
artifacts.set('STATE.md', buildStateMd(phaseMap));
|
||||
|
||||
for (const { slice, phaseNum: pNum, milestoneTitle } of phaseMap) {
|
||||
const prefix = zeroPad(pNum);
|
||||
const slug = slugify(slice.title);
|
||||
const dir = `phases/${prefix}-${slug}`;
|
||||
|
||||
artifacts.set(`${dir}/${prefix}-CONTEXT.md`, buildContextMd(slice, prefix));
|
||||
|
||||
if (slice.research) {
|
||||
artifacts.set(`${dir}/${prefix}-RESEARCH.md`, slice.research);
|
||||
}
|
||||
|
||||
for (let i = 0; i < slice.tasks.length; i++) {
|
||||
const task = slice.tasks[i];
|
||||
const planPrefix = zeroPad(i + 1);
|
||||
|
||||
artifacts.set(
|
||||
`${dir}/${prefix}-${planPrefix}-PLAN.md`,
|
||||
buildPlanMd(task, prefix, planPrefix, slug, milestoneTitle)
|
||||
);
|
||||
|
||||
if (task.done && task.summary) {
|
||||
artifacts.set(
|
||||
`${dir}/${prefix}-${planPrefix}-SUMMARY.md`,
|
||||
buildSummaryMd(task, prefix, planPrefix)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
// ─── Preview ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format a dry-run preview string for display before writing.
|
||||
*/
|
||||
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()) {
|
||||
lines.push(` ${rel}`);
|
||||
}
|
||||
|
||||
const totalSlices = gsd2Data.milestones.reduce((s, m) => s + m.slices.length, 0);
|
||||
const doneSlices = gsd2Data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
|
||||
const allTasks = gsd2Data.milestones.flatMap(m => m.slices.flatMap(sl => sl.tasks));
|
||||
const doneTasks = allTasks.filter(t => t.done).length;
|
||||
|
||||
lines.push('');
|
||||
lines.push(`Milestones: ${gsd2Data.milestones.length}`);
|
||||
lines.push(`Phases (slices): ${totalSlices} (${doneSlices} completed)`);
|
||||
lines.push(`Plans (tasks): ${allTasks.length} (${doneTasks} completed)`);
|
||||
lines.push('');
|
||||
lines.push('Cannot migrate automatically:');
|
||||
lines.push(' - GSD-2 cost/token ledger (no v1 equivalent)');
|
||||
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');
|
||||
}
|
||||
|
||||
// ─── Writer ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write all artifacts to the .planning/ directory.
|
||||
*/
|
||||
function writePlanningDir(artifacts: Map<string, string>, planningRoot: string): void {
|
||||
for (const [rel, content] of artifacts) {
|
||||
const absPath = path.join(planningRoot, rel);
|
||||
platformWriteSync(absPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Command Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Entry point called from gsd-tools.cjs.
|
||||
* Supports: --force, --dry-run, --path <dir>
|
||||
*/
|
||||
function cmdFromGsd2(args: string[], cwd: string, raw: boolean): void {
|
||||
const force = args.includes('--force');
|
||||
const dryRun = args.includes('--dry-run');
|
||||
|
||||
const pathIdx = args.indexOf('--path');
|
||||
const projectDir = pathIdx >= 0 && args[pathIdx + 1]
|
||||
? path.resolve(cwd, args[pathIdx + 1])
|
||||
: cwd;
|
||||
|
||||
const gsdDir = findGsd2Root(projectDir);
|
||||
if (!gsdDir) {
|
||||
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) {
|
||||
output({
|
||||
success: false,
|
||||
error: `.planning/ already exists at ${planningRoot}. Pass --force to overwrite.`,
|
||||
}, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const gsd2Data = parseGsd2(gsdDir);
|
||||
const artifacts = buildPlanningArtifacts(gsd2Data);
|
||||
// Use projectDir (resolved from --path) — not the process cwd — so the
|
||||
// preview command targets the project actually being imported (#3584).
|
||||
const preview = buildPreview(gsd2Data, artifacts, projectDir);
|
||||
|
||||
if (dryRun) {
|
||||
output({ success: true, dryRun: true, preview }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
writePlanningDir(artifacts, planningRoot);
|
||||
|
||||
output({
|
||||
success: true,
|
||||
planningDir: planningRoot,
|
||||
filesWritten: artifacts.size,
|
||||
milestones: gsd2Data.milestones.length,
|
||||
preview,
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
export = {
|
||||
findGsd2Root,
|
||||
parseGsd2,
|
||||
buildPlanningArtifacts,
|
||||
buildPreview,
|
||||
writePlanningDir,
|
||||
cmdFromGsd2,
|
||||
// Exported for unit tests
|
||||
parseSlicesFromRoadmap,
|
||||
parseMilestoneTitle,
|
||||
parseTaskTitle,
|
||||
parseTaskDescription,
|
||||
parseTaskMustHaves,
|
||||
buildPlanMd,
|
||||
buildSummaryMd,
|
||||
buildContextMd,
|
||||
buildRoadmapMd,
|
||||
buildStateMd,
|
||||
slugify,
|
||||
zeroPad,
|
||||
};
|
||||
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
548
src/install-profiles.cts
Normal file
548
src/install-profiles.cts
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* Skill Surface Budget Module — single source of truth for which skills/agents
|
||||
* are written to the runtime config dirs (ADR-0011).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PROFILES maps profile name → base skill set (array) or '*' sentinel (full).
|
||||
*
|
||||
* The effective set for any profile is CLOSURE(base, requires: manifest).
|
||||
* standard is a superset of core; full is the identity (all skills).
|
||||
*
|
||||
* Composition: --profile=core,audit resolves to union(closure(core), closure(audit)).
|
||||
*/
|
||||
const PROFILES = Object.freeze({
|
||||
core: Object.freeze([
|
||||
'new-project',
|
||||
'discuss-phase',
|
||||
'plan-phase',
|
||||
'execute-phase',
|
||||
'phase',
|
||||
'help',
|
||||
'update',
|
||||
'surface',
|
||||
]),
|
||||
standard: Object.freeze([
|
||||
// Core loop
|
||||
'new-project',
|
||||
'discuss-phase',
|
||||
'plan-phase',
|
||||
'execute-phase',
|
||||
'help',
|
||||
'update',
|
||||
'surface',
|
||||
// Phase management (hot nodes from audit — required by 38+ skills)
|
||||
'phase',
|
||||
'review',
|
||||
'config',
|
||||
'progress',
|
||||
// Workspace / state
|
||||
'resume-work',
|
||||
'pause-work',
|
||||
'workspace',
|
||||
]),
|
||||
full: '*' as const,
|
||||
} as const);
|
||||
|
||||
type ProfileName = keyof typeof PROFILES;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse the requires: field from YAML frontmatter.
|
||||
* Handles: "requires: [a, b, c]" (flow style) and absent field.
|
||||
* Returns string[] — empty array if no requires: field.
|
||||
*
|
||||
* No external YAML parser dependency — hand-parse the single line
|
||||
* since GSD enforces flow-style arrays for requires:.
|
||||
*/
|
||||
function parseRequires(content: string): string[] {
|
||||
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/m);
|
||||
if (!fmMatch) return [];
|
||||
const fm = fmMatch[1];
|
||||
const line = fm.match(/^requires:\s*(.+)$/m);
|
||||
if (!line) return [];
|
||||
const val = line[1].trim();
|
||||
// Flow-style: [a, b, c]
|
||||
if (val.startsWith('[') && val.endsWith(']')) {
|
||||
const inner = val.slice(1, -1).trim();
|
||||
if (!inner) return [];
|
||||
return inner.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
// Single bare value (not currently used, but defensive)
|
||||
return val ? [val] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse agent references from a skill file's body text.
|
||||
* Scans the full content for `gsd-<stem>` patterns that correspond to
|
||||
* real agent files. Returns all unique `gsd-*` stems found in the body.
|
||||
*
|
||||
* The caller is responsible for filtering by which agents actually exist —
|
||||
* this function returns all syntactically valid `gsd-*` matches.
|
||||
*/
|
||||
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.
|
||||
const matches = content.match(/\bgsd-[a-z][a-z-]*/g);
|
||||
if (!matches) return [];
|
||||
// Deduplicate
|
||||
return [...new Set(matches)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the requires: dependency graph from a commands/gsd directory.
|
||||
* 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.
|
||||
*/
|
||||
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) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(commandsDir, entry.name), 'utf8');
|
||||
manifest.set(stem, parseRequires(content));
|
||||
// Derive agent references from body text
|
||||
const agentRefs = parseCallsAgents(content);
|
||||
manifest.set(`_calls_agents_${stem}`, agentRefs);
|
||||
} catch {
|
||||
manifest.set(stem, []);
|
||||
manifest.set(`_calls_agents_${stem}`, []);
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile resolution (transitive closure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the transitive closure of a set of skill stems over the 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 deps = manifest.get(stem) || [];
|
||||
for (const dep of deps) {
|
||||
if (!closed.has(dep)) {
|
||||
closed.add(dep);
|
||||
queue.push(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
*/
|
||||
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(','))
|
||||
.map((mode) => mode.trim())
|
||||
.filter(Boolean);
|
||||
const modesToResolve = normalizedModes.length > 0 ? normalizedModes : ['full'];
|
||||
|
||||
// If any mode is 'full', the result is the full sentinel
|
||||
if (modesToResolve.includes('full')) {
|
||||
return { name: 'full', skills: '*', agents: new Set() };
|
||||
}
|
||||
|
||||
const validModes = modesToResolve.filter((mode) => Object.prototype.hasOwnProperty.call(profiles, mode));
|
||||
if (validModes.length === 0) {
|
||||
// Invalid/corrupt marker fallback: avoid empty installs by defaulting to full.
|
||||
return { name: 'full', skills: '*', agents: new Set() };
|
||||
}
|
||||
|
||||
const man = manifest || new Map<string, string[]>();
|
||||
const unionSkills = new Set<string>();
|
||||
|
||||
for (const mode of validModes) {
|
||||
const base = profiles[mode];
|
||||
if (base === '*') {
|
||||
// This profile is full — sentinel short-circuit
|
||||
return { name: 'full', skills: '*', agents: new Set() };
|
||||
}
|
||||
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<string>();
|
||||
for (const skillStem of unionSkills) {
|
||||
const agentRefs = man.get(`_calls_agents_${skillStem}`) || [];
|
||||
for (const agentStem of agentRefs) {
|
||||
unionAgents.add(agentStem);
|
||||
}
|
||||
}
|
||||
|
||||
const name = validModes.length === 1 ? validModes[0] : validModes.join(',');
|
||||
return { name, skills: unionSkills, agents: unionAgents };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Staging — skills
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Stage dirs created during this process — cleaned up on exit.
|
||||
// 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<string>();
|
||||
let exitHandlerRegistered = false;
|
||||
|
||||
function cleanupStagedSkills(): void {
|
||||
for (const dir of STAGED_DIRS) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort: missing dir or permission error shouldn't crash a
|
||||
// successful install. The OS reaps tmpdir eventually.
|
||||
}
|
||||
}
|
||||
STAGED_DIRS.clear();
|
||||
}
|
||||
|
||||
// Signals we register a cleanup handler for in addition to the natural
|
||||
// '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: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
||||
|
||||
function ensureExitCleanup(): void {
|
||||
if (exitHandlerRegistered) return;
|
||||
exitHandlerRegistered = true;
|
||||
process.on('exit', cleanupStagedSkills);
|
||||
for (const sig of CLEANUP_SIGNALS) {
|
||||
// `once` so re-raising the signal below isn't intercepted by us a second
|
||||
// time — the OS-default handler should take over and exit with the right
|
||||
// status code (so CI sees the abort, scripts see 130 for SIGINT, etc.).
|
||||
process.once(sig, () => {
|
||||
cleanupStagedSkills();
|
||||
process.kill(process.pid, sig);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a filtered copy of commands/gsd for a resolved profile.
|
||||
* In full mode (skills === '*') returns srcDir unchanged (no-op).
|
||||
*/
|
||||
function stageSkillsForProfile(srcDir: string, resolvedProfile: ResolvedProfile): string {
|
||||
if (resolvedProfile.skills === '*') return srcDir;
|
||||
if (!fs.existsSync(srcDir)) return srcDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-skills-'));
|
||||
try {
|
||||
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
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 { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a filtered copy of the agents directory for a resolved profile.
|
||||
* For 'full', returns srcAgentsDir unchanged.
|
||||
* 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.
|
||||
*/
|
||||
function stageAgentsForProfile(srcAgentsDir: string, resolvedProfile: ResolvedProfile): string {
|
||||
if (resolvedProfile.skills === '*') return srcAgentsDir;
|
||||
if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-agents-'));
|
||||
try {
|
||||
if (resolvedProfile.agents instanceof Set && resolvedProfile.agents.size > 0) {
|
||||
const entries = fs.readdirSync(srcAgentsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
// Agent stem is the full filename without extension, e.g. "gsd-planner"
|
||||
const stem = entry.name.slice(0, -3);
|
||||
if (!resolvedProfile.agents.has(stem)) continue;
|
||||
fs.copyFileSync(
|
||||
path.join(srcAgentsDir, entry.name),
|
||||
path.join(stageDir, entry.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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 { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
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-'));
|
||||
try {
|
||||
const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
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;
|
||||
const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
|
||||
const skillName = `${prefix}${stem}`;
|
||||
const converted = converter(content, skillName);
|
||||
const destDir = path.join(stageDir, skillName);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile marker persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROFILE_MARKER_NAME = '.gsd-profile';
|
||||
|
||||
/**
|
||||
* Read the active profile from a runtime config directory.
|
||||
*/
|
||||
function readActiveProfile(runtimeConfigDir: string): string | null {
|
||||
const markerPath = path.join(runtimeConfigDir, PROFILE_MARKER_NAME);
|
||||
try {
|
||||
const raw = fs.readFileSync(markerPath, 'utf8').trim();
|
||||
if (!raw) return null;
|
||||
// Validate that it looks like a profile name (alphanumeric + hyphens + commas)
|
||||
if (!/^[a-z0-9,_-]+$/i.test(raw)) return null;
|
||||
return raw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the active profile to a runtime config directory.
|
||||
*/
|
||||
function writeActiveProfile(runtimeConfigDir: string, profileName: string): void {
|
||||
platformWriteSync(path.join(runtimeConfigDir, PROFILE_MARKER_NAME), profileName + '\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile resolution helpers for install / update flows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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'] as const);
|
||||
|
||||
/**
|
||||
* Given an array of profile names (one per runtime), return the most-restrictive
|
||||
* profile — i.e. the one with the smallest effective skill set.
|
||||
*
|
||||
* Ordering (most to least restrictive): core < standard < full.
|
||||
* Composed profiles (e.g. 'core,audit') and unknown profiles are treated as
|
||||
* 'full' for this comparison.
|
||||
*/
|
||||
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: number = PROFILE_RANK.length;
|
||||
let bestName = 'full';
|
||||
for (const name of profileNames) {
|
||||
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) {
|
||||
bestRank = effectiveRank;
|
||||
bestName = rank === -1 ? 'full' : name;
|
||||
}
|
||||
}
|
||||
return bestName;
|
||||
}
|
||||
|
||||
interface ResolveEffectiveProfileOpts {
|
||||
requestedProfileName: string | null;
|
||||
targetDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective profile name for an install() run.
|
||||
*
|
||||
* Priority:
|
||||
* 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).
|
||||
*/
|
||||
function resolveEffectiveProfile({ requestedProfileName, targetDir }: ResolveEffectiveProfileOpts): string {
|
||||
// 1. Explicit flag overrides everything
|
||||
if (requestedProfileName != null) return requestedProfileName;
|
||||
// 2. Marker-driven (gsd update path)
|
||||
const marker = readActiveProfile(targetDir);
|
||||
if (marker && marker !== 'full') return marker;
|
||||
// 3. Default
|
||||
return 'full';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Back-compat shims (deprecated — use profile-based API instead)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @deprecated Use PROFILES.core instead.
|
||||
* Preserved for callers in install.js and existing tests.
|
||||
*/
|
||||
const MINIMAL_SKILL_ALLOWLIST = Object.freeze([...PROFILES.core]);
|
||||
|
||||
const MINIMAL_ALLOWLIST_SET = new Set(MINIMAL_SKILL_ALLOWLIST);
|
||||
|
||||
/**
|
||||
* @deprecated Use resolveProfile({ modes: ['core'] }) instead.
|
||||
*/
|
||||
function isMinimalMode(mode: string): boolean {
|
||||
return mode === 'minimal' || mode === 'core-only';
|
||||
}
|
||||
|
||||
/**
|
||||
* Overloaded for back-compat.
|
||||
* - If resolvedProfileOrMode is a string: legacy mode check (full/minimal)
|
||||
* - If resolvedProfileOrMode is an object with .skills: new profile API
|
||||
*
|
||||
* @deprecated String-mode form; use resolvedProfile object form instead.
|
||||
*/
|
||||
function shouldInstallSkill(skillBaseName: string, resolvedProfileOrMode: ResolvedProfile | string): boolean {
|
||||
if (typeof resolvedProfileOrMode === 'object' && resolvedProfileOrMode !== null) {
|
||||
const { skills } = resolvedProfileOrMode;
|
||||
if (skills === '*') return true;
|
||||
return skills instanceof Set && skills.has(skillBaseName);
|
||||
}
|
||||
// Legacy string mode
|
||||
const mode = resolvedProfileOrMode;
|
||||
if (!isMinimalMode(mode)) return true;
|
||||
return MINIMAL_ALLOWLIST_SET.has(skillBaseName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a filtered copy of the source commands/gsd directory.
|
||||
* Back-compat wrapper: maps 'minimal' → core profile, 'full' → full.
|
||||
*
|
||||
* @deprecated Use stageSkillsForProfile with a resolved profile instead.
|
||||
*/
|
||||
function stageSkillsForMode(srcDir: string, mode: string): string {
|
||||
if (!isMinimalMode(mode)) return srcDir;
|
||||
if (!fs.existsSync(srcDir)) return srcDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-minimal-skills-'));
|
||||
try {
|
||||
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const baseName = entry.name.replace(/\.md$/, '');
|
||||
if (!shouldInstallSkill(baseName, mode)) continue;
|
||||
fs.copyFileSync(
|
||||
path.join(srcDir, entry.name),
|
||||
path.join(stageDir, entry.name),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export = {
|
||||
// New profile API (ADR-0011)
|
||||
PROFILES,
|
||||
PROFILE_RANK,
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
resolveEffectiveProfile,
|
||||
mostRestrictiveProfile,
|
||||
stageSkillsForProfile,
|
||||
stageAgentsForProfile,
|
||||
stageSkillsForRuntimeAsSkills,
|
||||
STAGED_DIRS,
|
||||
readActiveProfile,
|
||||
writeActiveProfile,
|
||||
// Shared internals
|
||||
cleanupStagedSkills,
|
||||
// Back-compat / deprecated
|
||||
MINIMAL_SKILL_ALLOWLIST,
|
||||
isMinimalMode,
|
||||
shouldInstallSkill,
|
||||
stageSkillsForMode,
|
||||
};
|
||||
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[];
|
||||
}
|
||||
423
src/installer-migration-report.cts
Normal file
423
src/installer-migration-report.cts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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.
|
||||
//
|
||||
// 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',
|
||||
]));
|
||||
|
||||
// ── 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';
|
||||
if (action.type === 'rewrite-json') return action.deleteIfEmpty ? 'rewrote or removed' : 'rewrote';
|
||||
if (action.type === 'record-baseline') return 'recorded';
|
||||
if (action.type === 'baseline-preserve-user') return 'preserved';
|
||||
if (action.type === 'preserve-user') return 'preserved';
|
||||
if (action.type === 'prompt-user') return 'blocked';
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
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: number, noun: string): string {
|
||||
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function baselineSummaryRow(type: string, actions: MigrationAction[]): SummaryRow {
|
||||
const count = actions.length;
|
||||
if (type === 'record-baseline') {
|
||||
return {
|
||||
label: 'recorded',
|
||||
relPath: baselineSummaryLabel(count, 'managed baseline file'),
|
||||
reason: 'first-time baseline scan',
|
||||
action: { type: 'record-baseline-summary', count, actions },
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'preserved',
|
||||
relPath: baselineSummaryLabel(count, 'user baseline file'),
|
||||
reason: 'first-time baseline scan',
|
||||
action: { type: 'baseline-preserve-user-summary', count, actions },
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeInstallerMigrationResult(result: MigrationResult | null | undefined): SummarizeResult {
|
||||
const plan = result && result.plan;
|
||||
const actions: MigrationAction[] = plan && Array.isArray(plan.actions) ? plan.actions : [];
|
||||
const blocked = blockedInstallerMigrationActions(result);
|
||||
const blockedSet = new Set(blocked);
|
||||
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;
|
||||
if (type === 'record-baseline' || type === 'baseline-preserve-user') {
|
||||
if (!baselineActions.has(type)) {
|
||||
baselineActions.set(type, []);
|
||||
baselineIndexes.set(type, rows.length);
|
||||
rows.push(null);
|
||||
}
|
||||
baselineActions.get(type)!.push(action);
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
label: blockedSet.has(action) ? 'blocked' : installerMigrationActionLabel(action),
|
||||
relPath: action.relPath ?? '',
|
||||
reason: action.reason || '',
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
return {
|
||||
hasReportableActions: actions.length > 0 || blocked.length > 0,
|
||||
blocked,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Stale SDK build artifacts live under get-shit-done/sdk/{dist,src}/
|
||||
// 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.
|
||||
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)) {
|
||||
return { category: 'stale-sdk-build-artifact', choice: 'remove' };
|
||||
}
|
||||
if (/^skills\/gsd-[^/]+\/SKILL\.md$/.test(relPath)) {
|
||||
return { category: 'user-facing-skill', choice: 'keep' };
|
||||
}
|
||||
// #3610 / #3628: bundled GSD hooks shipped under `hooks/`. The whitelist
|
||||
// is the explicit set of filenames in the npm distribution — files that
|
||||
// match the shape but are NOT in the whitelist (user-authored hooks,
|
||||
// retired hooks from prior versions) fall through to the block-or-prompt
|
||||
// flow so the user retains control. On a first-time-baseline scan the
|
||||
// installer can safely remove whitelisted hooks because it is about to
|
||||
// write the fresh bundled versions in their place.
|
||||
if (BUNDLED_GSD_HOOK_FILES.has(relPath)) {
|
||||
return { category: 'bundled-gsd-hook', choice: 'remove' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert a blocked prompt-user action into a concrete plan 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: MigrationAction, choice: string): MigrationAction {
|
||||
const base: MigrationAction = {
|
||||
type: '', // overridden in each return branch below
|
||||
migrationId: action.migrationId,
|
||||
migrationChecksum: action.migrationChecksum,
|
||||
relPath: action.relPath,
|
||||
reason: action.reason,
|
||||
classification: action.classification,
|
||||
originalHash: action.originalHash || null,
|
||||
currentHash: action.currentHash || null,
|
||||
requestedType: 'prompt-user',
|
||||
};
|
||||
if (choice === 'keep') {
|
||||
return { ...base, type: 'baseline-preserve-user' };
|
||||
}
|
||||
// 'remove'
|
||||
return { ...base, type: 'backup-and-remove', backupRelPath: null };
|
||||
}
|
||||
|
||||
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: MigrationAction, choice: string): boolean {
|
||||
if (!action || !choice) return false;
|
||||
if (!Array.isArray(action.choices) || action.choices.length === 0) {
|
||||
return VALID_CHOICES.includes(choice);
|
||||
}
|
||||
return action.choices.includes(choice);
|
||||
}
|
||||
|
||||
// Resolve prompt-user actions when stdin is not a TTY. Mutates the
|
||||
// passed result so:
|
||||
// - resolved actions are appended to plan.actions in their concrete
|
||||
// form (baseline-preserve-user / backup-and-remove);
|
||||
// - result.blocked and plan.blocked are filtered to actions that
|
||||
// could NOT be safely defaulted (caller must still handle those).
|
||||
// Returns { result, resolutions } where `resolutions` is the structured
|
||||
// log of every defaulted resolution.
|
||||
export function resolveInstallerMigrationPromptsForNonTty(
|
||||
result: MigrationResult,
|
||||
options: ResolveOptions = {},
|
||||
): ResolvePromptsResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return { result, resolutions: [] };
|
||||
}
|
||||
const blocked = blockedInstallerMigrationActions(result);
|
||||
if (blocked.length === 0) {
|
||||
return { result, resolutions: [] };
|
||||
}
|
||||
const isTty = options.isTty === true;
|
||||
if (isTty) {
|
||||
// Honour interactive prompting paths (not implemented yet — the
|
||||
// hard throw is still the right behaviour for TTY runs); resolver
|
||||
// only fires when the installer cannot interactively ask.
|
||||
return { result, resolutions: [] };
|
||||
}
|
||||
|
||||
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: Resolution[] = [];
|
||||
const unresolved: MigrationAction[] = [];
|
||||
|
||||
for (const action of blocked) {
|
||||
if (action && action.type === 'prompt-user') {
|
||||
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;
|
||||
source = RESOLUTION_ENV_VAR;
|
||||
} else {
|
||||
const classification = classifyPromptUserAction(action);
|
||||
if (classification) {
|
||||
category = classification.category;
|
||||
choice = classification.choice;
|
||||
source = 'non-tty-default';
|
||||
}
|
||||
}
|
||||
|
||||
if (choice) {
|
||||
const resolved = materializeResolution(action, choice);
|
||||
// Replace the original prompt-user action in-place when present so
|
||||
// applyInstallerMigrationPlan never sees an unsupported action type.
|
||||
// Fallback to append only when the blocked action did not originate
|
||||
// from plan.actions (defensive).
|
||||
if (result.plan && Array.isArray(result.plan.actions)) {
|
||||
const idx = result.plan.actions.indexOf(action);
|
||||
if (idx >= 0) {
|
||||
result.plan.actions[idx] = resolved;
|
||||
} else {
|
||||
result.plan.actions.push(resolved);
|
||||
}
|
||||
}
|
||||
resolutions.push({
|
||||
relPath: action.relPath,
|
||||
category: category ?? '',
|
||||
choice: choice ?? '',
|
||||
reason: action.reason,
|
||||
resolvedActionType: resolved.type,
|
||||
source: source ?? '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
unresolved.push(action);
|
||||
}
|
||||
|
||||
// Mutate both the top-level and plan.blocked surfaces so downstream
|
||||
// callers (assertInstallerMigrationsUnblocked, summarizers) see the
|
||||
// post-resolution state.
|
||||
if (Array.isArray(result.blocked)) {
|
||||
result.blocked = unresolved;
|
||||
}
|
||||
if (result.plan && Array.isArray(result.plan.blocked)) {
|
||||
result.plan.blocked = unresolved;
|
||||
}
|
||||
|
||||
return { result, resolutions };
|
||||
}
|
||||
|
||||
// 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: 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);
|
||||
}
|
||||
return byReason;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (choiceSet.size === 0) {
|
||||
for (const fallback of VALID_CHOICES) choiceSet.add(fallback);
|
||||
}
|
||||
return [...choiceSet];
|
||||
}
|
||||
|
||||
function buildBlockedErrorMessage(blocked: MigrationAction[]): string {
|
||||
const byReason = groupBlockedByReason(blocked);
|
||||
const totalFiles = blocked.length;
|
||||
const choices = describeChoicesForActions(blocked);
|
||||
|
||||
const lines: string[] = [
|
||||
`installer migration blocked pending user choice: ${totalFiles} file${totalFiles === 1 ? '' : 's'} need a decision`,
|
||||
` choices: [${choices.join(', ')}]`,
|
||||
];
|
||||
for (const [reason, actions] of byReason) {
|
||||
lines.push(` - ${actions.length} file${actions.length === 1 ? '' : 's'}: ${reason}`);
|
||||
// Show up to 3 sample paths so operators can spot which files are
|
||||
// affected without dumping a thousand-line wall when SDK build
|
||||
// artifacts leak.
|
||||
const sample = actions.slice(0, 3).map((a) => a.relPath);
|
||||
if (sample.length > 0) {
|
||||
lines.push(` e.g. ${sample.join(', ')}${actions.length > sample.length ? `, ... (+${actions.length - sample.length} more)` : ''}`);
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
` resolve non-interactively by setting ${RESOLUTION_ENV_VAR}=<choice> ` +
|
||||
`(or run the installer in a TTY to be prompted per file).`
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function assertInstallerMigrationsUnblocked(result: MigrationResult | null | undefined): void {
|
||||
const blocked = blockedInstallerMigrationActions(result);
|
||||
if (blocked.length === 0) return;
|
||||
const message = buildBlockedErrorMessage(blocked);
|
||||
const error = Object.assign(new Error(message), {
|
||||
blocked,
|
||||
blockedByReason: Object.fromEntries(groupBlockedByReason(blocked)),
|
||||
resolutionEnvVar: RESOLUTION_ENV_VAR,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
932
src/installer-migrations.cts
Normal file
932
src/installer-migrations.cts
Normal file
@@ -0,0 +1,932 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
validateInstallerMigrationActions,
|
||||
validateInstallerMigrationRecord,
|
||||
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';
|
||||
const INSTALL_MIGRATION_LOCK_NAME = 'gsd-install-migration.lock';
|
||||
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: string): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
while (true) {
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
||||
if (bytesRead === 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function sha256Text(value: string): string {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
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 as Error).message}`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
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: 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> : {},
|
||||
};
|
||||
}
|
||||
|
||||
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: 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: 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()}`;
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, content, 'utf8');
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
} catch (error) {
|
||||
try { fs.rmSync(tmpPath, { force: true }); } catch { /* best-effort */ }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function writeInstallState(configDir: string, state: InstallState): InstallState {
|
||||
atomicWriteInstallState(configDir, JSON.stringify(state, null, 2) + '\n');
|
||||
return state;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
try {
|
||||
return { exists: true, value: JSON.parse(fs.readFileSync(fullPath, 'utf8')), error: null };
|
||||
} catch (error) {
|
||||
return { exists: true, value: null, error: error as Error };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelPath(relPath: string): string {
|
||||
if (typeof relPath !== 'string' || relPath.trim() === '') {
|
||||
throw new Error('migration action relPath must be a non-empty string');
|
||||
}
|
||||
const normalized = relPath.replace(/\\/g, '/');
|
||||
if (path.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
|
||||
throw new Error(`migration action relPath must stay inside configDir: ${relPath}`);
|
||||
}
|
||||
const segments = normalized.split('/');
|
||||
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
||||
throw new Error(`migration action relPath must stay inside configDir: ${relPath}`);
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
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);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return { classification: originalHash ? 'managed-missing' : 'missing', originalHash, currentHash: null };
|
||||
}
|
||||
const currentHash = sha256File(fullPath);
|
||||
if (!originalHash) {
|
||||
return { classification: 'unknown', originalHash: null, currentHash };
|
||||
}
|
||||
if (currentHash === originalHash) {
|
||||
return { classification: 'managed-pristine', originalHash, currentHash };
|
||||
}
|
||||
return { classification: 'managed-modified', originalHash, currentHash };
|
||||
}
|
||||
|
||||
function appliedMigrationIds(state: InstallState): Set<string> {
|
||||
return new Set(
|
||||
state.appliedMigrations
|
||||
.filter((entry) => entry && typeof entry.id === 'string')
|
||||
.map((entry) => entry.id as string)
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function migrationChecksum(migration: MigrationRecord): string {
|
||||
const checksum = migration.checksum;
|
||||
if (typeof checksum === 'string' && checksum) return checksum;
|
||||
const serializable = {
|
||||
id: migration.id,
|
||||
title: migration.title || null,
|
||||
description: migration.description || null,
|
||||
introducedIn: migration.introducedIn || null,
|
||||
runtimes: migration.runtimes || null,
|
||||
scopes: migration.scopes || null,
|
||||
destructive: migration.destructive === true,
|
||||
runtimeContract: migration.runtimeContract || null,
|
||||
plan: typeof migration.plan === 'function' ? (migration.plan as (...args: unknown[]) => unknown).toString() : null,
|
||||
};
|
||||
return `sha256:${sha256Text(JSON.stringify(serializable))}`;
|
||||
}
|
||||
|
||||
function assertAppliedMigrationChecksums(applied: Map<string, Record<string, unknown>>, migrations: MigrationRecord[]): void {
|
||||
for (const migration of migrations) {
|
||||
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 as string}; create a new fix-forward migration id`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 as string[]).length > 0) {
|
||||
if (!scope || !(migration.scopes as string[]).includes(scope)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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'))
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.flatMap((fileName) => {
|
||||
const source = path.join(migrationsDir, fileName);
|
||||
|
||||
delete require.cache[require.resolve(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 as MigrationRecord, source));
|
||||
});
|
||||
}
|
||||
|
||||
function journalTimestamp(now: () => string): string {
|
||||
return now().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
function migrationRunId(appliedAt: string): string {
|
||||
return `${journalTimestamp(() => appliedAt)}-${crypto.randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
function sleepSync(ms: number): void {
|
||||
const buffer = new SharedArrayBuffer(4);
|
||||
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a given PID is alive on the current host.
|
||||
* Uses process.kill(pid, 0) which works on POSIX and Windows (Node's
|
||||
* implementation maps it to OpenProcess + GetExitCodeProcess on win32).
|
||||
* Returns true if alive or permission-denied (live but not ours),
|
||||
* false if ESRCH (no such process).
|
||||
*/
|
||||
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 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: string): LockFileData | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(lockPath, 'utf8');
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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: number | null = null;
|
||||
let lockCreatedByUs = false;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, 'wx');
|
||||
// Close the open descriptor before writing so the file handle is
|
||||
// released on Windows before the release closure unlinks it.
|
||||
// Write payload via writeFileSync with the path (not the fd) so we
|
||||
// don't hold an open fd across the lifetime of the lock.
|
||||
fs.closeSync(fd);
|
||||
fd = null;
|
||||
lockCreatedByUs = true; // we own the file; clean it up on any subsequent error
|
||||
fs.writeFileSync(lockPath, JSON.stringify({
|
||||
pid: process.pid,
|
||||
acquiredAt: new Date().toISOString(),
|
||||
}) + '\n');
|
||||
lockCreatedByUs = false; // release closure owns cleanup from here
|
||||
return () => {
|
||||
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 as Error); }
|
||||
if (failures.length > 0) {
|
||||
const releaseError = new Error(`failed to release installer migration lock: ${lockPath}`) as Error & { failures: Error[] };
|
||||
releaseError.failures = failures;
|
||||
throw releaseError;
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (fd !== null) {
|
||||
try { fs.closeSync(fd); } catch { /* best-effort */ }
|
||||
try { fs.unlinkSync(lockPath); } catch { /* best-effort */ }
|
||||
fd = null;
|
||||
} else if (lockCreatedByUs) {
|
||||
// fd was closed but writeFileSync threw before we returned the release
|
||||
// closure — the empty lock file is still on disk and must be removed
|
||||
// so it does not orphan as an unreadable (empty/invalid JSON) stale lock.
|
||||
try { fs.unlinkSync(lockPath); } catch { /* best-effort */ }
|
||||
}
|
||||
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
|
||||
// a previous call in the same invocation — the root cause of #3670),
|
||||
// reclaim the lock by removing the stale file and retrying.
|
||||
const lockData = readLockFile(lockPath);
|
||||
if (lockData !== null) {
|
||||
const holderPid = lockData.pid;
|
||||
const isSameProcess = holderPid === process.pid;
|
||||
const isDeadProcess = !isPidAlive(holderPid);
|
||||
if (isSameProcess || isDeadProcess) {
|
||||
// Reclaim: remove the stale lock and loop back to openSync.
|
||||
// Only continue (retry) when unlink actually succeeds — a silent
|
||||
// continue on reclaim failure recreates the original deadlock:
|
||||
// the lock stays on disk and we spin indefinitely.
|
||||
let reclaimed = false;
|
||||
try { fs.unlinkSync(lockPath); reclaimed = true; } catch { /* unlink failed — fall through to timeout path */ }
|
||||
if (reclaimed) continue;
|
||||
}
|
||||
}
|
||||
if (clock.now() - started >= timeoutMs) {
|
||||
const holderInfo = lockData ? ` (held by pid ${lockData.pid} since ${lockData.acquiredAt})` : '';
|
||||
throw new Error(`installer migration lock is held: ${lockPath}${holderInfo}`);
|
||||
}
|
||||
clock.sleep(Math.min(50, Math.max(1, timeoutMs - (clock.now() - started))));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
|
||||
throw new Error(`migration path escapes configDir: ${relPath}`);
|
||||
}
|
||||
return { normalized, fullPath };
|
||||
}
|
||||
|
||||
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: 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,
|
||||
scope = null,
|
||||
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');
|
||||
|
||||
const manifest = readInstallManifest(configDir);
|
||||
const state = readInstallState(configDir);
|
||||
const validatedMigrations = migrations.map((migration) =>
|
||||
validateInstallerMigrationRecord(migration)
|
||||
);
|
||||
const scopedMigrations = validatedMigrations.filter((migration) =>
|
||||
migrationMatchesContext(migration, { runtime, scope })
|
||||
);
|
||||
const applied = appliedMigrationEntries(state);
|
||||
assertAppliedMigrationChecksums(applied, scopedMigrations);
|
||||
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)!;
|
||||
};
|
||||
|
||||
for (const migration of pending) {
|
||||
const planFn = migration.plan as (ctx: PlanContext) => unknown[];
|
||||
const plannedActions = planFn({
|
||||
configDir,
|
||||
runtime,
|
||||
scope,
|
||||
manifest,
|
||||
state,
|
||||
baselineScan,
|
||||
now,
|
||||
classifyArtifact: classify,
|
||||
readJson: (relPath) => readJson(configDir, relPath),
|
||||
});
|
||||
validateInstallerMigrationActions(plannedActions, migration);
|
||||
const checksum = migrationChecksum(migration);
|
||||
for (const rawAction of plannedActions as MigrationAction[]) {
|
||||
const relPath = normalizeRelPath(rawAction.relPath as string);
|
||||
const classification = rawAction.classification
|
||||
? {
|
||||
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 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: PlannedAction = {
|
||||
migrationId: migration.id as string,
|
||||
migrationChecksum: checksum,
|
||||
type: protectedType,
|
||||
relPath,
|
||||
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 as string | undefined;
|
||||
}
|
||||
if (action.type === 'backup-and-remove') {
|
||||
action.backupRelPath = null;
|
||||
}
|
||||
if (action.type === 'rewrite-json') {
|
||||
action.value = rawAction.value;
|
||||
action.deleteIfEmpty = rawAction.deleteIfEmpty === true;
|
||||
}
|
||||
if (rawAction.prompt) action.prompt = rawAction.prompt;
|
||||
if (Array.isArray(rawAction.choices)) action.choices = rawAction.choices as unknown[];
|
||||
if (action.type === 'prompt-user') {
|
||||
blocked.push(action);
|
||||
} else if (
|
||||
action.classification === 'unknown' &&
|
||||
action.type !== 'rewrite-json' &&
|
||||
action.type !== 'record-baseline' &&
|
||||
action.type !== 'baseline-preserve-user'
|
||||
) {
|
||||
blocked.push(action);
|
||||
}
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: now(),
|
||||
manifest,
|
||||
state,
|
||||
pendingMigrationIds: pending.map((migration) => migration.id as string),
|
||||
pendingMigrations: pending,
|
||||
actions,
|
||||
blocked,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueActionMigrationIds(actions: PlannedAction[]): string[] {
|
||||
return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))];
|
||||
}
|
||||
|
||||
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 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 as string, error: (error as Error).message });
|
||||
}
|
||||
if (action.backupRelPath) {
|
||||
try {
|
||||
fs.rmSync(path.join(configDir, action.backupRelPath as string), { force: true });
|
||||
} catch {
|
||||
// backup cleanup is best-effort; preserve restore failures above
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (previousInstallStateBytes === null) {
|
||||
fs.rmSync(path.join(configDir, INSTALL_STATE_NAME), { force: true });
|
||||
} else {
|
||||
atomicWriteInstallState(configDir, previousInstallStateBytes);
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({ relPath: INSTALL_STATE_NAME, error: (error as Error).message });
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(journalPath, { force: true });
|
||||
fs.rmSync(rollbackRoot, { recursive: true, force: true });
|
||||
fs.rmSync(backupRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// journal cleanup is best-effort; the rollback above is the safety-critical part
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const error = new Error('migration rollback incomplete') as Error & { rollbackFailures: typeof failures };
|
||||
error.rollbackFailures = failures;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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 */ }
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new Error(`migration plan has ${plan.blocked.length} blocked action(s)`);
|
||||
}
|
||||
|
||||
const appliedAt = now();
|
||||
const runId = migrationRunId(appliedAt);
|
||||
const journalRelPath = path.posix.join('gsd-migration-journal', `${runId}.json`);
|
||||
const journalPath = path.join(configDir, journalRelPath);
|
||||
const rollbackRootRelPath = path.posix.join('gsd-migration-journal', `${runId}-rollback`);
|
||||
const rollbackRoot = path.join(configDir, rollbackRootRelPath);
|
||||
const backupRootRelPath = path.posix.join('gsd-migration-journal', `${runId}-backups`);
|
||||
const backupRoot = path.join(configDir, backupRootRelPath);
|
||||
const journal: { schemaVersion: number; appliedAt: string; appliedMigrationIds: string[]; actions: JournalAction[] } = {
|
||||
schemaVersion: 1,
|
||||
appliedAt,
|
||||
appliedMigrationIds: uniqueActionMigrationIds(plan.actions),
|
||||
actions: [],
|
||||
};
|
||||
const rollback: Array<{ relPath: string; rollbackPath: string }> = [];
|
||||
const installStatePath = path.join(configDir, INSTALL_STATE_NAME);
|
||||
const previousInstallStateBytes = fs.existsSync(installStatePath)
|
||||
? fs.readFileSync(installStatePath, 'utf8')
|
||||
: null;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
|
||||
platformWriteSync(journalPath, JSON.stringify(journal, null, 2) + '\n');
|
||||
|
||||
for (const action of plan.actions) {
|
||||
if (
|
||||
action.type !== 'remove-managed' &&
|
||||
action.type !== 'backup-and-remove' &&
|
||||
action.type !== 'rewrite-json' &&
|
||||
action.type !== 'record-baseline' &&
|
||||
action.type !== 'baseline-preserve-user'
|
||||
) {
|
||||
throw new Error(`unsupported migration action type: ${action.type}`);
|
||||
}
|
||||
|
||||
const { normalized, fullPath } = ensureInsideConfig(configDir, action.relPath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
journal.actions.push(journalAction(action, 'missing'));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action.type === 'record-baseline' || action.type === 'baseline-preserve-user') {
|
||||
journal.actions.push(journalAction(action, action.type === 'record-baseline' ? 'recorded' : 'preserved'));
|
||||
continue;
|
||||
}
|
||||
|
||||
const rollbackPath = path.join(rollbackRoot, normalized);
|
||||
fs.mkdirSync(path.dirname(rollbackPath), { recursive: true });
|
||||
fs.copyFileSync(fullPath, rollbackPath);
|
||||
rollback.push({ relPath: normalized, rollbackPath });
|
||||
|
||||
if (action.type === 'rewrite-json') {
|
||||
if (action.deleteIfEmpty && isStructurallyEmpty(action.value)) {
|
||||
fs.rmSync(fullPath, { force: true });
|
||||
journal.actions.push(journalAction(action, 'removed', {
|
||||
rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized),
|
||||
}));
|
||||
} else {
|
||||
platformWriteSync(fullPath, JSON.stringify(action.value, null, 2) + '\n');
|
||||
journal.actions.push(journalAction(action, 'rewritten', {
|
||||
rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized),
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action.type === 'backup-and-remove') {
|
||||
const backupRelPath = action.backupRelPath || path.posix.join(backupRootRelPath, normalized);
|
||||
const backupPath = path.join(configDir, backupRelPath);
|
||||
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
||||
fs.copyFileSync(fullPath, backupPath);
|
||||
journal.actions.push(journalAction(action, 'removed', {
|
||||
backupRelPath,
|
||||
rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized),
|
||||
}));
|
||||
} else {
|
||||
journal.actions.push(journalAction(action, 'removed', {
|
||||
rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized),
|
||||
}));
|
||||
}
|
||||
fs.rmSync(fullPath, { force: true });
|
||||
}
|
||||
|
||||
platformWriteSync(journalPath, JSON.stringify(journal, null, 2) + '\n');
|
||||
|
||||
const state = readInstallState(configDir);
|
||||
const applied = appliedMigrationIds(state);
|
||||
const nextApplied = [...state.appliedMigrations];
|
||||
const actionsByMigrationId = new Map<string, PlannedAction>();
|
||||
for (const action of plan.actions) {
|
||||
if (action.migrationId && !actionsByMigrationId.has(action.migrationId)) {
|
||||
actionsByMigrationId.set(action.migrationId, action);
|
||||
}
|
||||
}
|
||||
for (const id of journal.appliedMigrationIds) {
|
||||
if (!applied.has(id)) {
|
||||
const action = actionsByMigrationId.get(id);
|
||||
nextApplied.push({
|
||||
id,
|
||||
appliedAt,
|
||||
journal: journalRelPath,
|
||||
checksum: action && action.migrationChecksum ? action.migrationChecksum : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
writeInstallState(configDir, {
|
||||
schemaVersion: 1,
|
||||
appliedMigrations: nextApplied,
|
||||
});
|
||||
|
||||
return {
|
||||
appliedMigrationIds: journal.appliedMigrationIds,
|
||||
journalRelPath,
|
||||
rollback: () => rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }),
|
||||
};
|
||||
} catch (error) {
|
||||
const rollbackFailures: Array<{ relPath: string; rollbackPath: string; error: string }> = [];
|
||||
for (const entry of rollback.reverse()) {
|
||||
const dest = path.join(configDir, entry.relPath);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(entry.rollbackPath, dest);
|
||||
} catch (rollbackError) {
|
||||
rollbackFailures.push({
|
||||
relPath: entry.relPath,
|
||||
rollbackPath: entry.rollbackPath,
|
||||
error: (rollbackError as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rollbackFailures.length > 0) {
|
||||
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;
|
||||
}
|
||||
cleanupMigrationRunArtifacts(journalPath, rollbackRoot, backupRoot);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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<string, string>();
|
||||
for (const migration of plan.pendingMigrations || []) {
|
||||
checksumsByMigrationId.set(migration.id as string, migrationChecksum(migration));
|
||||
}
|
||||
const nextApplied = [...state.appliedMigrations];
|
||||
const newlyApplied: string[] = [];
|
||||
for (const id of plan.pendingMigrationIds) {
|
||||
if (applied.has(id)) continue;
|
||||
nextApplied.push({
|
||||
id,
|
||||
appliedAt,
|
||||
journal: null,
|
||||
checksum: checksumsByMigrationId.get(id) || null,
|
||||
});
|
||||
newlyApplied.push(id);
|
||||
}
|
||||
if (newlyApplied.length > 0) {
|
||||
writeInstallState(configDir, {
|
||||
schemaVersion: 1,
|
||||
appliedMigrations: nextApplied,
|
||||
});
|
||||
}
|
||||
return newlyApplied;
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
appliedMigrationIds: string[];
|
||||
journalRelPath: string | null;
|
||||
plan: MigrationPlan;
|
||||
blocked?: PlannedAction[];
|
||||
rollback?: () => void;
|
||||
}
|
||||
|
||||
function runInstallerMigrations({
|
||||
configDir,
|
||||
runtime = null,
|
||||
scope = null,
|
||||
migrationsDir = DEFAULT_MIGRATIONS_DIR,
|
||||
migrations = discoverInstallerMigrations({ migrationsDir }),
|
||||
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: (Error & { suppressed?: Error[] }) | null = null;
|
||||
let completed = false;
|
||||
try {
|
||||
const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, baselineScan, now });
|
||||
if (plan.actions.length === 0) {
|
||||
const newlyApplied = markPendingMigrationsApplied({ configDir, plan, now });
|
||||
completed = true;
|
||||
return {
|
||||
appliedMigrationIds: newlyApplied,
|
||||
journalRelPath: null,
|
||||
plan,
|
||||
};
|
||||
}
|
||||
if (plan.blocked.length > 0) {
|
||||
completed = true;
|
||||
return {
|
||||
appliedMigrationIds: [],
|
||||
journalRelPath: null,
|
||||
plan,
|
||||
blocked: plan.blocked,
|
||||
};
|
||||
}
|
||||
const result = applyInstallerMigrationPlan({ configDir, plan, now });
|
||||
completed = true;
|
||||
return { ...result, plan };
|
||||
} catch (error) {
|
||||
primaryError = error as Error & { suppressed?: Error[] };
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
releaseLock();
|
||||
} catch (releaseError) {
|
||||
if (primaryError) {
|
||||
primaryError.suppressed = [...(primaryError.suppressed || []), releaseError as Error];
|
||||
} else if (completed) {
|
||||
throw releaseError;
|
||||
} else {
|
||||
throw releaseError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
MANIFEST_NAME,
|
||||
acquireInstallMigrationLock,
|
||||
applyInstallerMigrationPlan,
|
||||
classifyArtifact,
|
||||
discoverInstallerMigrations,
|
||||
planInstallerMigrations,
|
||||
readInstallManifest,
|
||||
readInstallState,
|
||||
runInstallerMigrations,
|
||||
writeInstallState,
|
||||
};
|
||||
259
src/installer-migrations/000-first-time-baseline.cts
Normal file
259
src/installer-migrations/000-first-time-baseline.cts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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
|
||||
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'],
|
||||
opencode: ['get-shit-done', 'command', 'skills', 'agents'],
|
||||
kilo: ['get-shit-done', 'command', 'skills', 'agents'],
|
||||
copilot: ['get-shit-done', 'skills', 'agents'],
|
||||
antigravity: ['get-shit-done', 'skills', 'agents'],
|
||||
cursor: ['get-shit-done', 'skills', 'agents'],
|
||||
windsurf: ['get-shit-done', 'skills', 'agents', 'rules'],
|
||||
augment: ['get-shit-done', 'skills', 'agents'],
|
||||
trae: ['get-shit-done', 'skills', 'agents', 'rules'],
|
||||
qwen: ['get-shit-done', 'skills', 'agents'],
|
||||
hermes: ['get-shit-done', 'skills/gsd', 'agents'],
|
||||
cline: ['get-shit-done', 'skills', 'agents'],
|
||||
codebuddy: ['get-shit-done', 'skills', 'agents'],
|
||||
};
|
||||
|
||||
const COMMON_SURFACES = ['get-shit-done', 'skills', 'agents', 'hooks'];
|
||||
const INTERNAL_TOP_LEVEL_NAMES = new Set([
|
||||
'gsd-file-manifest.json',
|
||||
'gsd-install-state.json',
|
||||
'gsd-migration-backups',
|
||||
'gsd-migration-journal',
|
||||
]);
|
||||
const USER_OWNED_PATHS = new Set([
|
||||
'get-shit-done/USER-PROFILE.md',
|
||||
'commands/gsd/dev-preferences.md',
|
||||
'skills/gsd-dev-preferences/SKILL.md',
|
||||
]);
|
||||
let knownGeneratedAgentNames: Set<string> | null = null;
|
||||
|
||||
function normalizeRelPath(relPath: string): string {
|
||||
return relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function baselineInstallSurfaces(runtime: string | undefined): string[] {
|
||||
if (runtime && RUNTIME_SURFACES[runtime]) return RUNTIME_SURFACES[runtime];
|
||||
return COMMON_SURFACES;
|
||||
}
|
||||
|
||||
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;
|
||||
if (entry.isDirectory()) {
|
||||
walkFiles(root, relPath, files);
|
||||
} else if (entry.isFile()) {
|
||||
files.add(normalizeRelPath(relPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
walkFiles(configDir, normalized, relPaths);
|
||||
} else if (stat.isFile() && !INTERNAL_TOP_LEVEL_NAMES.has(normalized)) {
|
||||
relPaths.add(normalized);
|
||||
}
|
||||
}
|
||||
return [...relPaths];
|
||||
}
|
||||
|
||||
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;
|
||||
if (parts[0] === 'agents' && parts[1] && !parts[1].startsWith('gsd-')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function listKnownGeneratedAgentNames(): Set<string> {
|
||||
if (knownGeneratedAgentNames) return knownGeneratedAgentNames;
|
||||
|
||||
knownGeneratedAgentNames = new Set<string>();
|
||||
const agentsDir = path.resolve(__dirname, '..', '..', '..', '..', 'agents');
|
||||
try {
|
||||
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
||||
if (entry.isFile() && entry.name.startsWith('gsd-') && entry.name.endsWith('.md')) {
|
||||
knownGeneratedAgentNames.add(entry.name.replace(/\.md$/, ''));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If the source agent directory is unavailable, fail closed and treat
|
||||
// GSD-looking agent files as user-choice artifacts.
|
||||
}
|
||||
|
||||
return knownGeneratedAgentNames;
|
||||
}
|
||||
|
||||
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];
|
||||
const extension = path.posix.extname(fileName);
|
||||
if (extension !== '.md' && !(runtime === 'codex' && extension === '.toml')) return false;
|
||||
|
||||
const agentName = fileName.slice(0, -extension.length);
|
||||
return listKnownGeneratedAgentNames().has(agentName);
|
||||
}
|
||||
|
||||
function isStaleGsdLookingPath(relPath: string): boolean {
|
||||
const baseName = path.posix.basename(relPath);
|
||||
if (/^gsd[-_]/.test(baseName)) return true;
|
||||
const parts = relPath.split('/');
|
||||
if ((parts[0] === 'skills' || parts[0] === 'agents') && parts[1] && parts[1].startsWith('gsd-')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 }: PlanContext): BaselineAction[] => {
|
||||
if (!baselineScan) return [];
|
||||
|
||||
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.
|
||||
if (isUserOwnedBaselinePath(relPath)) {
|
||||
actions.push({
|
||||
type: 'baseline-preserve-user',
|
||||
relPath,
|
||||
reason: 'known user-owned artifact preserved by first-time migration baseline',
|
||||
classification: 'user-owned',
|
||||
originalHash: null,
|
||||
currentHash: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const artifact = classifyArtifact(relPath);
|
||||
if (artifact.classification === 'managed-pristine' || artifact.classification === 'managed-modified') {
|
||||
actions.push({
|
||||
type: 'record-baseline',
|
||||
relPath,
|
||||
reason: 'existing manifest-managed file included in first-time migration baseline',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
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 ?? null,
|
||||
currentHash,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isStaleGsdLookingPath(relPath)) {
|
||||
actions.push({
|
||||
type: 'prompt-user',
|
||||
relPath,
|
||||
reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice',
|
||||
classification: 'stale-gsd-looking',
|
||||
originalHash: artifact.originalHash ?? null,
|
||||
currentHash,
|
||||
prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.',
|
||||
choices: ['keep', 'remove'],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
actions.push({
|
||||
type: 'baseline-preserve-user',
|
||||
relPath,
|
||||
reason: 'unknown install-surface file preserved by first-time migration baseline',
|
||||
classification: artifact.classification,
|
||||
originalHash: artifact.originalHash ?? null,
|
||||
currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
return actions.sort(
|
||||
(left, right) =>
|
||||
baselineActionRank(left) - baselineActionRank(right) || left.relPath.localeCompare(right.relPath)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export = migration;
|
||||
79
src/installer-migrations/001-legacy-orphan-files.cts
Normal file
79
src/installer-migrations/001-legacy-orphan-files.cts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
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.',
|
||||
introducedIn: '1.50.0',
|
||||
scopes: ['global', 'local'],
|
||||
destructive: true,
|
||||
// Retired generated hook files are removed only with manifest-managed
|
||||
// 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: (ctx: MigrationPlanContext): MigrationAction[] => {
|
||||
const actions: MigrationAction[] = [];
|
||||
for (const relPath of LEGACY_ORPHAN_FILES) {
|
||||
const artifact = ctx.classifyArtifact(relPath);
|
||||
if (artifact.classification === 'managed-pristine') {
|
||||
actions.push({
|
||||
type: 'remove-managed',
|
||||
relPath,
|
||||
reason: 'legacy orphan hook file retired by installer migration',
|
||||
ownershipEvidence: 'legacy hook path is manifest-managed in gsd-file-manifest.json',
|
||||
});
|
||||
} else if (artifact.classification === 'managed-modified') {
|
||||
actions.push({
|
||||
type: 'backup-and-remove',
|
||||
relPath,
|
||||
reason: 'legacy orphan hook file retired by installer migration',
|
||||
ownershipEvidence: 'legacy hook path is manifest-managed in gsd-file-manifest.json',
|
||||
});
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
},
|
||||
};
|
||||
|
||||
export = migration;
|
||||
138
src/installer-migrations/002-codex-legacy-hooks-json.cts
Normal file
138
src/installer-migrations/002-codex-legacy-hooks-json.cts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { isManagedHookCommand } from '../shell-command-projection.cjs';
|
||||
|
||||
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;
|
||||
for (const _key in value) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isManagedCodexHookCommand(command: unknown, configDir: string): boolean {
|
||||
return isManagedHookCommand(command, {
|
||||
surface: 'codex-hooks-json',
|
||||
includeLegacyAliases: true,
|
||||
configDir,
|
||||
});
|
||||
}
|
||||
|
||||
function pruneLegacyCodexHooksJsonValue(value: JsonValue, configDir: string): PruneResult {
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const next: JsonValue[] = [];
|
||||
for (const item of value) {
|
||||
const pruned = pruneLegacyCodexHooksJsonValue(item, configDir);
|
||||
if (pruned.changed) changed = true;
|
||||
if (pruned.changed && isStructurallyEmpty(pruned.value)) changed = true;
|
||||
else next.push(pruned.value);
|
||||
}
|
||||
return { value: next, changed };
|
||||
}
|
||||
|
||||
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: { [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;
|
||||
else next[key] = pruned.value;
|
||||
}
|
||||
return { value: next, changed };
|
||||
}
|
||||
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
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.',
|
||||
introducedIn: '1.50.0',
|
||||
runtimes: ['codex'],
|
||||
scopes: ['global', 'local'],
|
||||
destructive: true,
|
||||
runtimeContract: 'docs/installer-migrations.md#runtime-configuration-contract-registry Codex row',
|
||||
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);
|
||||
if (!pruned.changed) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'rewrite-json',
|
||||
relPath: 'hooks.json',
|
||||
value: pruned.value,
|
||||
deleteIfEmpty: true,
|
||||
reason: 'legacy Codex hooks.json GSD registration retired by installer migration',
|
||||
ownershipEvidence: 'pruned command matches generated GSD hook command under the install hooks directory',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export = migration;
|
||||
735
src/intel.cts
Normal file
735
src/intel.cts
Normal file
@@ -0,0 +1,735 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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: Record<string, string> = {
|
||||
files: 'file-roles.json',
|
||||
apis: 'api-map.json',
|
||||
deps: 'dependency-graph.json',
|
||||
arch: 'arch-decisions.json',
|
||||
stack: 'stack.json',
|
||||
};
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure the intel directory exists under the given planning dir.
|
||||
*/
|
||||
function ensureIntelDir(planningDir: string): string {
|
||||
const intelPath = path.join(planningDir, 'intel');
|
||||
platformEnsureDir(intelPath);
|
||||
return intelPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
function isIntelEnabled(planningDir: string): boolean {
|
||||
try {
|
||||
const configPath = path.join(planningDir, 'config.json');
|
||||
const raw = platformReadSync(configPath);
|
||||
if (raw === null) return false;
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
function safeReadJson(filePath: string): IntelData | null {
|
||||
try {
|
||||
const raw = platformReadSync(filePath);
|
||||
if (raw === null) return null;
|
||||
return JSON.parse(raw) as IntelData;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of a file's contents.
|
||||
* Returns null if the file doesn't exist.
|
||||
*/
|
||||
function hashFile(filePath: string): string | null {
|
||||
try {
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) return null;
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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: SearchMatch[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (key === '_meta') continue;
|
||||
|
||||
// Check key match
|
||||
if (key.toLowerCase().includes(lowerTerm)) {
|
||||
matches.push({ key, value });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check string value match (recursive for objects)
|
||||
if (matchesInValue(value, lowerTerm)) {
|
||||
matches.push({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively check if a term appears in any string value.
|
||||
*/
|
||||
function matchesInValue(value: unknown, lowerTerm: string): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase().includes(lowerTerm);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(v => matchesInValue(v, lowerTerm));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(v => matchesInValue(v, lowerTerm));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a term in arch.md text content.
|
||||
* Returns matching lines.
|
||||
*/
|
||||
function searchArchMd(filePath: string, term: string): string[] {
|
||||
try {
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) return [];
|
||||
const lowerTerm = term.toLowerCase();
|
||||
const lines = content.split(/\r?\n/);
|
||||
return lines.filter(line => line.toLowerCase().includes(lowerTerm));
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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).
|
||||
*/
|
||||
function intelQuery(term: string, planningDir: string): IntelQueryResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const matches: Array<{ source: string; entries: SearchMatch[] }> = [];
|
||||
let total = 0;
|
||||
|
||||
// Search all JSON intel files
|
||||
for (const [_key, filename] of Object.entries(INTEL_FILES)) {
|
||||
const filePath = intelFilePath(planningDir, filename);
|
||||
const data = safeReadJson(filePath);
|
||||
if (!data) continue;
|
||||
|
||||
const found = searchJsonEntries(data, term);
|
||||
if (found.length > 0) {
|
||||
matches.push({ source: filename, entries: found });
|
||||
total += found.length;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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: Record<string, IntelStatusFileEntry> = {};
|
||||
let overallStale = false;
|
||||
|
||||
for (const [_key, filename] of Object.entries(INTEL_FILES)) {
|
||||
const filePath = intelFilePath(planningDir, filename);
|
||||
const exists = fs.existsSync(filePath);
|
||||
|
||||
if (!exists) {
|
||||
files[filename] = { exists: false, updated_at: null, stale: true };
|
||||
overallStale = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let updatedAt: string | null = null;
|
||||
|
||||
// All intel files are JSON — read _meta.updated_at
|
||||
const data = safeReadJson(filePath);
|
||||
if (data && data._meta && data._meta.updated_at) {
|
||||
updatedAt = data._meta.updated_at;
|
||||
}
|
||||
|
||||
let stale = true;
|
||||
if (updatedAt) {
|
||||
const age = now - new Date(updatedAt).getTime();
|
||||
stale = age > STALE_MS;
|
||||
}
|
||||
|
||||
if (stale) overallStale = true;
|
||||
files[filename] = { exists: true, updated_at: updatedAt, stale };
|
||||
}
|
||||
|
||||
return { files, overall_stale: overallStale };
|
||||
}
|
||||
|
||||
interface IntelDiffResult {
|
||||
changed: string[];
|
||||
added: string[];
|
||||
removed: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show changes since the last full refresh by comparing file hashes.
|
||||
*/
|
||||
function intelDiff(planningDir: string): IntelDiffResult | { no_baseline: true } | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const snapshotPath = intelFilePath(planningDir, '.last-refresh.json');
|
||||
const snapshot = safeReadJson(snapshotPath);
|
||||
|
||||
if (!snapshot) {
|
||||
return { no_baseline: true };
|
||||
}
|
||||
|
||||
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)) {
|
||||
const filePath = intelFilePath(planningDir, filename);
|
||||
const currentHash = hashFile(filePath);
|
||||
|
||||
if (currentHash && !prevHashes[filename]) {
|
||||
added.push(filename);
|
||||
} else if (currentHash && prevHashes[filename] && currentHash !== prevHashes[filename]) {
|
||||
changed.push(filename);
|
||||
} else if (!currentHash && prevHashes[filename]) {
|
||||
removed.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
return { changed, added, removed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub for triggering an intel update.
|
||||
* The actual update is performed by the intel-updater agent (PLAN-02).
|
||||
*/
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
function saveRefreshSnapshot(planningDir: string): SaveRefreshResult {
|
||||
const intelPath = ensureIntelDir(planningDir);
|
||||
const hashes: Record<string, string> = {};
|
||||
let fileCount = 0;
|
||||
|
||||
for (const [_key, filename] of Object.entries(INTEL_FILES)) {
|
||||
const filePath = path.join(intelPath, filename);
|
||||
const hash = hashFile(filePath);
|
||||
if (hash) {
|
||||
hashes[filename] = hash;
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const snapshotPath = path.join(intelPath, '.last-refresh.json');
|
||||
platformWriteSync(snapshotPath, JSON.stringify({
|
||||
hashes,
|
||||
timestamp,
|
||||
version: 1,
|
||||
}, null, 2));
|
||||
|
||||
return { saved: true, timestamp, files: fileCount };
|
||||
}
|
||||
|
||||
// ─── CLI Subcommands ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Thin wrapper around saveRefreshSnapshot for CLI dispatch.
|
||||
* Writes .last-refresh.json with accurate timestamps and hashes.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
function intelValidate(planningDir: string): IntelValidateResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const STALE_MS = 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, filename] of Object.entries(INTEL_FILES)) {
|
||||
const filePath = intelFilePath(planningDir, filename);
|
||||
|
||||
// Check existence
|
||||
if (!fs.existsSync(filePath)) {
|
||||
errors.push(`${filename}: file does not exist`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// All intel files are JSON — validate _meta and entries structure
|
||||
|
||||
// Parse JSON
|
||||
const raw = platformReadSync(filePath);
|
||||
if (raw === null) {
|
||||
errors.push(`${filename}: file missing`);
|
||||
continue;
|
||||
}
|
||||
let data: IntelData;
|
||||
try {
|
||||
data = JSON.parse(raw) as IntelData;
|
||||
} catch (e) {
|
||||
errors.push(`${filename}: invalid JSON — ${(e as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check _meta.updated_at recency
|
||||
if (data._meta && data._meta.updated_at) {
|
||||
const age = now - new Date(data._meta.updated_at).getTime();
|
||||
if (age > STALE_MS) {
|
||||
warnings.push(`${filename}: _meta.updated_at is ${Math.round(age / 3600000)} hours old (>24 hr)`);
|
||||
}
|
||||
} else {
|
||||
warnings.push(`${filename}: missing _meta.updated_at`);
|
||||
}
|
||||
|
||||
// Validate entries are objects with expected fields
|
||||
if (data.entries && typeof data.entries === 'object') {
|
||||
// files.json: check exports are actual symbol names (no spaces)
|
||||
if (key === 'files') {
|
||||
for (const [entryPath, entry] of Object.entries(data.entries)) {
|
||||
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)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spot-check first 5 file paths exist on disk
|
||||
const entryPaths = Object.keys(data.entries).slice(0, 5);
|
||||
for (const ep of entryPaths) {
|
||||
if (!fs.existsSync(ep)) {
|
||||
warnings.push(`${filename}: entry path "${ep}" does not exist on disk`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deps.json: check entries have version, type, used_by
|
||||
if (key === 'deps') {
|
||||
for (const [depName, entry] of Object.entries(data.entries)) {
|
||||
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(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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".
|
||||
*/
|
||||
function intelApiSurface(planningDir: string): IntelApiSurfaceResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
|
||||
const intelPath = ensureIntelDir(planningDir);
|
||||
const apiMapPath = path.join(intelPath, INTEL_FILES.apis);
|
||||
const outputPath = path.join(intelPath, 'API-SURFACE.md');
|
||||
|
||||
const data = safeReadJson(apiMapPath);
|
||||
const entries = (data && data.entries && typeof data.entries === 'object')
|
||||
? Object.entries(data.entries)
|
||||
: [];
|
||||
const symbolCount = entries.length;
|
||||
|
||||
// Staleness: reuse the _meta.updated_at field if present
|
||||
const STALE_MS = 24 * 60 * 60 * 1000;
|
||||
let stale = true;
|
||||
if (data && data._meta && data._meta.updated_at) {
|
||||
const age = Date.now() - new Date(data._meta.updated_at).getTime();
|
||||
stale = age > STALE_MS;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('# API Surface');
|
||||
lines.push('');
|
||||
lines.push('> Generated from `.planning/intel/api-map.json`. Do not edit by hand.');
|
||||
lines.push('');
|
||||
|
||||
if (symbolCount === 0) {
|
||||
lines.push('> **Incomplete:** api-map.json has no entries (intel extraction is regex/JS-only or not yet populated).');
|
||||
lines.push('> Treat absence here as "unknown", not "does not exist".');
|
||||
lines.push('');
|
||||
} else {
|
||||
if (stale) {
|
||||
lines.push('> **Warning:** api-map.json is stale (>24 hours old). Data below may be out of date.');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
for (const [symbol, info] of entries) {
|
||||
lines.push(`## \`${symbol}\``);
|
||||
lines.push('');
|
||||
if (info && typeof info === 'object') {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
platformWriteSync(outputPath, lines.join('\n'));
|
||||
|
||||
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.
|
||||
*/
|
||||
function intelPatchMeta(filePath: string): IntelPatchMetaResult {
|
||||
try {
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) {
|
||||
return { patched: false, error: `File not found: ${filePath}` };
|
||||
}
|
||||
let data: IntelData;
|
||||
try {
|
||||
data = JSON.parse(content) as IntelData;
|
||||
} catch (e) {
|
||||
return { patched: false, error: `Invalid JSON: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
if (!data._meta) {
|
||||
data._meta = {};
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
data._meta.updated_at = timestamp;
|
||||
data._meta.version = (data._meta.version || 0) + 1;
|
||||
|
||||
platformWriteSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
||||
|
||||
return { patched: true, file: filePath, timestamp };
|
||||
} catch (e) {
|
||||
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.
|
||||
*/
|
||||
function intelExtractExports(filePath: string): IntelExtractExportsResult {
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) {
|
||||
return { file: filePath, exports: [], method: 'none' };
|
||||
}
|
||||
const exports = new Set<string>();
|
||||
let method = 'none';
|
||||
|
||||
// Try module.exports = { ... } pattern (handle multi-line)
|
||||
// Find the LAST module.exports assignment (the actual one, not references in code)
|
||||
const allMatches = [...content.matchAll(/module\.exports\s*=\s*\{/g)];
|
||||
if (allMatches.length > 0) {
|
||||
const lastMatch = allMatches[allMatches.length - 1];
|
||||
const startIdx = lastMatch.index + lastMatch[0].length;
|
||||
// Find matching closing brace by counting braces
|
||||
let depth = 1;
|
||||
let endIdx = startIdx;
|
||||
while (endIdx < content.length && depth > 0) {
|
||||
if (content[endIdx] === '{') depth++;
|
||||
else if (content[endIdx] === '}') depth--;
|
||||
if (depth > 0) endIdx++;
|
||||
}
|
||||
const block = content.substring(startIdx, endIdx);
|
||||
method = 'module.exports';
|
||||
// Extract key names from lines like " keyName," or " keyName: value,"
|
||||
const lines = block.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Skip comments and empty lines
|
||||
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
|
||||
// Match identifier at start of line (before comma, colon, end of line)
|
||||
const keyMatch = trimmed.match(/^(\w+)\s*[,}:]/) || trimmed.match(/^(\w+)$/);
|
||||
if (keyMatch) {
|
||||
exports.add(keyMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also try individual exports.X = patterns (only at start of line, not inside strings/regex)
|
||||
const individualPattern = /^exports\.(\w+)\s*=/gm;
|
||||
let im: RegExpExecArray | null;
|
||||
while ((im = individualPattern.exec(content)) !== null) {
|
||||
if (!exports.has(im[1])) {
|
||||
exports.add(im[1]);
|
||||
if (method === 'none') method = 'exports.X';
|
||||
}
|
||||
}
|
||||
|
||||
const hadCjs = exports.size > 0;
|
||||
|
||||
// ESM patterns
|
||||
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: RegExpExecArray | null;
|
||||
while ((em = defaultNamedPattern.exec(content)) !== null) {
|
||||
esmExports.add(em[1]);
|
||||
}
|
||||
|
||||
// export default (without named function/class)
|
||||
const defaultAnonPattern = /^export\s+default\s+(?!function\s|class\s)/gm;
|
||||
if (defaultAnonPattern.test(content) && esmExports.size === 0) {
|
||||
esmExports.add('default');
|
||||
}
|
||||
|
||||
// export function X( / export async function X(
|
||||
const exportFnPattern = /^export\s+(?:async\s+)?function\s+(\w+)\s*\(/gm;
|
||||
while ((em = exportFnPattern.exec(content)) !== null) {
|
||||
esmExports.add(em[1]);
|
||||
}
|
||||
|
||||
// export const X = / export let X = / export var X =
|
||||
const exportVarPattern = /^export\s+(?:const|let|var)\s+(\w+)\s*=/gm;
|
||||
while ((em = exportVarPattern.exec(content)) !== null) {
|
||||
esmExports.add(em[1]);
|
||||
}
|
||||
|
||||
// export class X
|
||||
const exportClassPattern = /^export\s+class\s+(\w+)/gm;
|
||||
while ((em = exportClassPattern.exec(content)) !== null) {
|
||||
esmExports.add(em[1]);
|
||||
}
|
||||
|
||||
// export { X, Y, Z } — strip "as alias" parts
|
||||
const exportBlockPattern = /^export\s*\{([^}]+)\}/gm;
|
||||
while ((em = exportBlockPattern.exec(content)) !== null) {
|
||||
const items = em[1].split(',');
|
||||
for (const item of items) {
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed) continue;
|
||||
// "foo as bar" -> extract "foo"
|
||||
const name = trimmed.split(/\s+as\s+/)[0].trim();
|
||||
if (name) esmExports.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge ESM exports into the result
|
||||
for (const e of esmExports) {
|
||||
exports.add(e);
|
||||
}
|
||||
|
||||
// Determine method
|
||||
const hadEsm = esmExports.size > 0;
|
||||
if (hadCjs && hadEsm) {
|
||||
method = 'mixed';
|
||||
} else if (hadEsm && !hadCjs) {
|
||||
method = 'esm';
|
||||
}
|
||||
|
||||
return { file: filePath, exports: [...exports], method };
|
||||
}
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export = {
|
||||
// Public API
|
||||
intelQuery,
|
||||
intelUpdate,
|
||||
intelStatus,
|
||||
intelDiff,
|
||||
saveRefreshSnapshot,
|
||||
|
||||
// CLI subcommands
|
||||
intelSnapshot,
|
||||
intelValidate,
|
||||
intelExtractExports,
|
||||
intelPatchMeta,
|
||||
intelApiSurface,
|
||||
|
||||
// Utilities
|
||||
ensureIntelDir,
|
||||
isIntelEnabled,
|
||||
|
||||
// Constants
|
||||
INTEL_FILES,
|
||||
INTEL_DIR,
|
||||
};
|
||||
339
src/learnings.cts
Normal file
339
src/learnings.cts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Learnings — Global knowledge store with CRUD operations
|
||||
*
|
||||
* Provides a cross-project learnings store at ~/.gsd/knowledge/.
|
||||
* Each learning is stored as an individual JSON file with content-hash
|
||||
* deduplication. Supports write, read, list, query, delete, copy-from-project,
|
||||
* and prune operations.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
// ─── 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_STORE_DIR = path.join(os.homedir(), '.gsd', 'knowledge');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getStoreDir(opts?: WriteOpts | { storeDir?: string }): string {
|
||||
return (opts && opts.storeDir) || DEFAULT_STORE_DIR;
|
||||
}
|
||||
|
||||
function ensureStoreDir(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function contentHash(learning: string, sourceProject: string): string {
|
||||
return crypto.createHash('sha256')
|
||||
.update(learning + '\n' + sourceProject)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
const ts = Date.now().toString(36);
|
||||
const rand = crypto.randomBytes(4).toString('hex');
|
||||
return `${ts}-${rand}`;
|
||||
}
|
||||
|
||||
function readLearningFile(filePath: string): LearningRecord | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content) as LearningRecord;
|
||||
} catch (err) {
|
||||
process.stderr.write(`Warning: skipping malformed file ${filePath}: ${(err as Error).message}\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CRUD Operations ─────────────────────────────────────────────────────────
|
||||
|
||||
function learningsWrite(entry: WriteEntry, opts?: WriteOpts): WriteResult {
|
||||
const dir = getStoreDir(opts);
|
||||
ensureStoreDir(dir);
|
||||
|
||||
const hash = contentHash(entry.learning, entry.source_project);
|
||||
|
||||
// #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.
|
||||
if (opts && opts.dedupeIndex) {
|
||||
const dedupeIndex = opts.dedupeIndex;
|
||||
if (dedupeIndex.has(hash)) {
|
||||
return { id: dedupeIndex.get(hash) as string, created: false, content_hash: hash };
|
||||
}
|
||||
const id = generateId();
|
||||
const record: LearningRecord = {
|
||||
id,
|
||||
source_project: entry.source_project,
|
||||
date: new Date().toISOString(),
|
||||
context: entry.context || '',
|
||||
learning: entry.learning,
|
||||
tags: entry.tags || [],
|
||||
content_hash: hash,
|
||||
};
|
||||
platformWriteSync(path.join(dir, `${id}.json`), JSON.stringify(record, null, 2));
|
||||
dedupeIndex.set(hash, id);
|
||||
return { id, created: true, content_hash: hash };
|
||||
}
|
||||
|
||||
// Check for duplicate by scanning existing files (single-write path, unchanged)
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
const existing = readLearningFile(path.join(dir, file));
|
||||
if (existing && existing.content_hash === hash) {
|
||||
return { id: existing.id, created: false, content_hash: hash };
|
||||
}
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const record: LearningRecord = {
|
||||
id,
|
||||
source_project: entry.source_project,
|
||||
date: new Date().toISOString(),
|
||||
context: entry.context || '',
|
||||
learning: entry.learning,
|
||||
tags: entry.tags || [],
|
||||
content_hash: hash,
|
||||
};
|
||||
|
||||
platformWriteSync(path.join(dir, `${id}.json`), JSON.stringify(record, null, 2));
|
||||
return { id, created: true, content_hash: hash };
|
||||
}
|
||||
|
||||
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`);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return readLearningFile(filePath);
|
||||
}
|
||||
|
||||
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: LearningRecord[] = [];
|
||||
for (const file of files) {
|
||||
const record = readLearningFile(path.join(dir, file));
|
||||
if (record) results.push(record);
|
||||
}
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
results.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
return results;
|
||||
}
|
||||
|
||||
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 as string));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
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`);
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(learningsPath, 'utf-8');
|
||||
const sourceProject = (opts && opts.sourceProject) || path.basename(path.resolve(planningDir, '..'));
|
||||
|
||||
// #306: Build the content_hash -> id dedupe index once before the loop so
|
||||
// that learningsWrite does not re-scan the entire store on every call —
|
||||
// O(K*N) -> O(N+K).
|
||||
const dir = getStoreDir(opts);
|
||||
ensureStoreDir(dir);
|
||||
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
|
||||
// dedupe-hit `id` is identical on both paths even if the store already holds
|
||||
// duplicate content_hashes. (#306)
|
||||
if (existing && existing.content_hash && !dedupeIndex.has(existing.content_hash)) {
|
||||
dedupeIndex.set(existing.content_hash, existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse markdown: split on ## headings
|
||||
const sections = content.split(/^## /m).slice(1); // skip preamble before first ##
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const section of sections) {
|
||||
const lines = section.trim().split('\n');
|
||||
const title = lines[0].trim();
|
||||
const body = lines.slice(1).join('\n').trim();
|
||||
if (!body) continue;
|
||||
|
||||
// Extract tags from title (simple: use words as tags)
|
||||
const tags = title.toLowerCase().split(/\s+/).filter(w => w.length > 2);
|
||||
|
||||
const result = learningsWrite({
|
||||
source_project: sourceProject,
|
||||
learning: body,
|
||||
context: title,
|
||||
tags,
|
||||
}, { ...opts, dedupeIndex });
|
||||
|
||||
if (result.created) {
|
||||
created++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { total: created + skipped, created, skipped };
|
||||
}
|
||||
|
||||
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"`);
|
||||
}
|
||||
|
||||
const days = parseInt(match[1], 10);
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
const dir = getStoreDir(opts);
|
||||
|
||||
if (!fs.existsSync(dir)) return { removed: 0, kept: 0 };
|
||||
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
||||
let removed = 0;
|
||||
let kept = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const record = readLearningFile(filePath);
|
||||
if (!record) continue;
|
||||
|
||||
const recordDate = new Date(record.date);
|
||||
if (recordDate < cutoff) {
|
||||
fs.unlinkSync(filePath);
|
||||
removed++;
|
||||
} else {
|
||||
kept++;
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, kept };
|
||||
}
|
||||
|
||||
// ─── CLI Command Handlers ────────────────────────────────────────────────────
|
||||
|
||||
function cmdLearningsList(raw: boolean): void {
|
||||
const results = learningsList();
|
||||
output({ learnings: results, count: results.length }, raw, undefined);
|
||||
}
|
||||
|
||||
function cmdLearningsQuery(tag: string, raw: boolean): void {
|
||||
const results = learningsQuery({ tag });
|
||||
output({ learnings: results, count: results.length, tag }, raw, undefined);
|
||||
}
|
||||
|
||||
function cmdLearningsCopy(cwd: string, raw: boolean): void {
|
||||
const planDir = path.join(cwd, '.planning');
|
||||
const result = learningsCopyFromProject(planDir);
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
function cmdLearningsPrune(olderThan: string, raw: boolean): void {
|
||||
try {
|
||||
const result = learningsPrune(olderThan);
|
||||
output(result, raw, undefined);
|
||||
} catch (err) {
|
||||
coreError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
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, undefined);
|
||||
}
|
||||
|
||||
export = {
|
||||
learningsWrite,
|
||||
learningsRead,
|
||||
learningsList,
|
||||
learningsQuery,
|
||||
learningsDelete,
|
||||
learningsCopyFromProject,
|
||||
learningsPrune,
|
||||
cmdLearningsList,
|
||||
cmdLearningsQuery,
|
||||
cmdLearningsCopy,
|
||||
cmdLearningsPrune,
|
||||
cmdLearningsDelete,
|
||||
DEFAULT_STORE_DIR,
|
||||
};
|
||||
425
src/milestone.cts
Normal file
425
src/milestone.cts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// Accept comma-separated, space-separated, or bracket-wrapped: [REQ-01, REQ-02]
|
||||
const reqIds = reqIdsRaw
|
||||
.join(' ')
|
||||
.replace(/[\[\]]/g, '')
|
||||
.split(/[,\s]+/)
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (reqIds.length === 0) {
|
||||
error('no valid requirement IDs found');
|
||||
}
|
||||
|
||||
const reqPath = planningPaths(cwd).requirements;
|
||||
if (!fs.existsSync(reqPath)) {
|
||||
output({ updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds }, raw, 'no requirements file');
|
||||
return;
|
||||
}
|
||||
|
||||
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
||||
const updated: string[] = [];
|
||||
const alreadyComplete: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
let found = false;
|
||||
const reqEscaped = escapeRegex(reqId);
|
||||
|
||||
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
||||
// Use replace() directly and compare — avoids test()+replace() global regex
|
||||
// lastIndex bug where test() advances state and replace() misses matches.
|
||||
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
||||
const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
|
||||
if (afterCheckbox !== reqContent) {
|
||||
reqContent = afterCheckbox;
|
||||
found = true;
|
||||
}
|
||||
|
||||
// Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
|
||||
const tablePattern = new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
|
||||
const afterTable = reqContent.replace(tablePattern, '$1 Complete $2');
|
||||
if (afterTable !== reqContent) {
|
||||
reqContent = afterTable;
|
||||
found = true;
|
||||
}
|
||||
|
||||
if (found) {
|
||||
updated.push(reqId);
|
||||
} else {
|
||||
// Check if already complete before declaring not_found.
|
||||
// Non-global flag is fine here — we only need to know if a match exists.
|
||||
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i');
|
||||
const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i');
|
||||
if (doneCheckbox.test(reqContent) || doneTable.test(reqContent)) {
|
||||
alreadyComplete.push(reqId);
|
||||
} else {
|
||||
notFound.push(reqId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updated.length > 0) {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCompleteOptions, raw: boolean): void {
|
||||
if (!version) {
|
||||
error('version required for milestone complete (e.g., v1.0)');
|
||||
}
|
||||
|
||||
const roadmapPath = planningPaths(cwd).roadmap;
|
||||
const reqPath = planningPaths(cwd).requirements;
|
||||
const statePath = planningPaths(cwd).state;
|
||||
const milestonesPath = path.join(cwd, '.planning', 'MILESTONES.md');
|
||||
const archiveDir = path.join(cwd, '.planning', 'milestones');
|
||||
const phasesDir = planningPaths(cwd).phases;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const milestoneName = options.name || version;
|
||||
|
||||
// Ensure archive directory exists
|
||||
platformEnsureDir(archiveDir);
|
||||
|
||||
// Scope stats and accomplishments to only the phases belonging to the
|
||||
// current milestone's ROADMAP. Uses the shared filter from core.cjs
|
||||
// (same logic used by cmdPhasesList and other callers).
|
||||
const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
|
||||
if (isDirInMilestone.missingExplicitVersion) {
|
||||
error(`no phases found for milestone ${version} in ROADMAP.md`);
|
||||
}
|
||||
|
||||
// Guard: prevent marking complete when ROADMAP still lists phases that have
|
||||
// no directory on disk (disk_status: no_directory). This catches the case
|
||||
// where the active milestone was erroneously marked complete before phases
|
||||
// were even started. Only fires when STATE.md confirms the current milestone
|
||||
// version matches what is being completed — no false positives on fresh
|
||||
// projects where phases haven't been scaffolded yet.
|
||||
// Pass --force to override this guard.
|
||||
if (!options.force) {
|
||||
try {
|
||||
// Only guard when STATE.md's milestone field matches the version being completed.
|
||||
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 */
|
||||
}
|
||||
|
||||
if (stateVersion && stateVersion === version) {
|
||||
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: 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 [];
|
||||
}
|
||||
})();
|
||||
while ((pm = phasePattern.exec(scopedContent)) !== null) {
|
||||
const phaseNum = pm[1];
|
||||
const normalized = normalizePhaseName(phaseNum);
|
||||
// A phase has disk_status: 'no_directory' when no phase directory
|
||||
// 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));
|
||||
if (!hasDirectory) {
|
||||
noDirectoryPhases.push(phaseNum);
|
||||
}
|
||||
}
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If the error came from our guard, re-throw it; otherwise skip silently.
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
// Gather stats from phases (scoped to current milestone only)
|
||||
let phaseCount = 0;
|
||||
let totalPlans = 0;
|
||||
let totalTasks = 0;
|
||||
const accomplishments: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
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');
|
||||
totalPlans += plans.length;
|
||||
|
||||
// Extract one-liners from summaries
|
||||
for (const s of summaries) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
const rawOneLiner = fm['one-liner'];
|
||||
const oneLiner = (typeof rawOneLiner === 'string' ? rawOneLiner : '') || extractOneLinerFromBody(content);
|
||||
if (oneLiner) {
|
||||
accomplishments.push(oneLiner);
|
||||
}
|
||||
// Count tasks: prefer **Tasks:** N from Performance section,
|
||||
// then <task XML tags, then ## Task N markdown headers
|
||||
const tasksFieldMatch = content.match(/\*\*Tasks:\*\*\s*(\d+)/);
|
||||
if (tasksFieldMatch) {
|
||||
totalTasks += parseInt(tasksFieldMatch[1], 10);
|
||||
} else {
|
||||
const xmlTaskMatches = content.match(/<task[\s>]/gi) || [];
|
||||
const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
|
||||
totalTasks += xmlTaskMatches.length || mdTaskMatches.length;
|
||||
}
|
||||
} catch {
|
||||
/* intentionally empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* intentionally empty */
|
||||
}
|
||||
|
||||
// Archive ROADMAP.md
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
platformWriteSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent);
|
||||
}
|
||||
|
||||
// Archive REQUIREMENTS.md
|
||||
if (fs.existsSync(reqPath)) {
|
||||
const reqContent = fs.readFileSync(reqPath, 'utf-8');
|
||||
const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`;
|
||||
platformWriteSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent);
|
||||
}
|
||||
|
||||
// Archive audit file if exists
|
||||
const auditFile = path.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`);
|
||||
if (fs.existsSync(auditFile)) {
|
||||
fs.renameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
|
||||
}
|
||||
|
||||
// Create/append MILESTONES.md entry
|
||||
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)) {
|
||||
const existing = fs.readFileSync(milestonesPath, 'utf-8');
|
||||
if (!existing.trim()) {
|
||||
// Empty file — treat like new
|
||||
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
||||
} else {
|
||||
// Insert after the header line(s) for reverse chronological order (newest first)
|
||||
const headerMatch = existing.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
|
||||
if (headerMatch) {
|
||||
const header = headerMatch[1];
|
||||
const rest = existing.slice(header.length);
|
||||
platformWriteSync(milestonesPath, header + milestoneEntry + rest);
|
||||
} else {
|
||||
// No recognizable header — prepend the entry
|
||||
platformWriteSync(milestonesPath, milestoneEntry + existing);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
||||
}
|
||||
|
||||
// Update STATE.md — keep frontmatter/body semantically aligned after closure
|
||||
if (fs.existsSync(statePath)) {
|
||||
let stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||
|
||||
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`,
|
||||
);
|
||||
|
||||
// Reset Current Position narrative so resume/progress flows do not keep
|
||||
// pointing at closed-phase execution instructions.
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
const closedPositionBody =
|
||||
`\nPhase: Milestone ${version} complete\n` +
|
||||
`Plan: —\n` +
|
||||
`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: string) => `${header}${closedPositionBody}`);
|
||||
} else {
|
||||
stateContent = `${stateContent.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
|
||||
}
|
||||
|
||||
// Normalize operator-next-step tails that can become stale after close.
|
||||
const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
if (operatorPattern.test(stateContent)) {
|
||||
stateContent = stateContent.replace(
|
||||
operatorPattern,
|
||||
`$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)) as string}\n`;
|
||||
}
|
||||
|
||||
writeStateMd(statePath, stateContent, cwd);
|
||||
}
|
||||
|
||||
// Archive phase directories if requested
|
||||
let phasesArchived = false;
|
||||
if (options.archivePhases) {
|
||||
try {
|
||||
const phaseArchiveDir = path.join(archiveDir, `${version}-phases`);
|
||||
platformEnsureDir(phaseArchiveDir);
|
||||
|
||||
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const phaseDirNames = phaseEntries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
let archivedCount = 0;
|
||||
for (const dir of phaseDirNames) {
|
||||
if (!isDirInMilestone(dir)) continue;
|
||||
fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
|
||||
archivedCount++;
|
||||
}
|
||||
phasesArchived = archivedCount > 0;
|
||||
} catch {
|
||||
/* intentionally empty */
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
version,
|
||||
name: milestoneName,
|
||||
date: today,
|
||||
phases: phaseCount,
|
||||
plans: totalPlans,
|
||||
tasks: totalTasks,
|
||||
accomplishments,
|
||||
archived: {
|
||||
roadmap: fs.existsSync(path.join(archiveDir, `${version}-ROADMAP.md`)),
|
||||
requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)),
|
||||
audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
|
||||
phases: phasesArchived,
|
||||
},
|
||||
milestones_updated: true,
|
||||
state_updated: fs.existsSync(statePath),
|
||||
};
|
||||
|
||||
output(result, raw);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
if (dirs.length > 0 && !confirm) {
|
||||
error(
|
||||
`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` +
|
||||
`Pass --confirm to proceed.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const entry of dirs) {
|
||||
fs.rmSync(path.join(phasesDir, entry.name), { recursive: true, force: true });
|
||||
cleared++;
|
||||
}
|
||||
} catch (e) {
|
||||
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`);
|
||||
}
|
||||
|
||||
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']);
|
||||
38
src/model-profiles.cts
Normal file
38
src/model-profiles.cts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
MODEL_PROFILES,
|
||||
VALID_PROFILES,
|
||||
AGENT_TO_PHASE_TYPE,
|
||||
VALID_PHASE_TYPES,
|
||||
AGENT_DEFAULT_TIERS,
|
||||
VALID_AGENT_TIERS,
|
||||
nextTier,
|
||||
formatAgentToModelMapAsTable,
|
||||
getAgentToModelMapForProfile,
|
||||
EFFORT_RENDERING,
|
||||
renderEffortForRuntime,
|
||||
RUNTIMES_WITH_FAST_MODE,
|
||||
} from './model-catalog.cjs';
|
||||
|
||||
export = {
|
||||
MODEL_PROFILES,
|
||||
VALID_PROFILES,
|
||||
AGENT_TO_PHASE_TYPE,
|
||||
VALID_PHASE_TYPES,
|
||||
AGENT_DEFAULT_TIERS,
|
||||
VALID_AGENT_TIERS,
|
||||
nextTier,
|
||||
formatAgentToModelMapAsTable,
|
||||
getAgentToModelMapForProfile,
|
||||
EFFORT_RENDERING,
|
||||
renderEffortForRuntime,
|
||||
RUNTIMES_WITH_FAST_MODE,
|
||||
};
|
||||
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);
|
||||
}
|
||||
166
src/observability/logger.cts
Normal file
166
src/observability/logger.cts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* DispatchLogger interface + default implementation — issue #177 (ADR-0174 P1.3).
|
||||
*
|
||||
* Interface:
|
||||
* { onEvent(event: DispatchEvent): void }
|
||||
*
|
||||
* Default behaviour (createDefaultLogger):
|
||||
* 1. Silent on success — no stdout/stderr when result.kind === 'ok'.
|
||||
* 2. Structured JSON to stderr on error — one line per dispatch error.
|
||||
* 3. Opt-in audit file — when GSD_AUDIT=1 OR config.audit.enabled===true,
|
||||
* appends every event (success + error) as one JSON line to
|
||||
* .planning/.gsd-trace.jsonl relative to `cwd`. Creates .planning/ if absent.
|
||||
* 4. Args redaction — args omitted by default; included when GSD_AUDIT_ARGS=1.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { redactEvent } from './redaction.cjs';
|
||||
|
||||
const AUDIT_FILE_NAME = '.gsd-trace.jsonl';
|
||||
const PLANNING_DIR = '.planning';
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Safely serialise a value to JSON, falling back to a placeholder on circular refs.
|
||||
*/
|
||||
function _safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return JSON.stringify({ _serializationError: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the audit file should be written to.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the redacted plain object for the audit file.
|
||||
* Preserves the full DispatchEvent structure.
|
||||
*/
|
||||
function _toAuditRecord(event: Record<string, unknown>): Record<string, unknown> {
|
||||
return redactEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flattened stderr error line.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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 resultObj = result as Record<string, unknown>;
|
||||
const { kind, ...typedPayload } = resultObj;
|
||||
return Object.assign({}, eventWithoutResult, { kind }, typedPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one JSON line to the audit file.
|
||||
* Creates .planning/ directory if it does not exist.
|
||||
*
|
||||
* Uses synchronous fs API (crash-safe for v1 — dispatch is synchronous).
|
||||
*/
|
||||
function _appendAuditLine(cwd: string, event: Record<string, unknown>): void {
|
||||
const planningDir = path.join(cwd, PLANNING_DIR);
|
||||
// Ensure the directory exists
|
||||
if (!fs.existsSync(planningDir)) {
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
}
|
||||
const auditPath = path.join(planningDir, AUDIT_FILE_NAME);
|
||||
fs.appendFileSync(auditPath, _safeStringify(event) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// ─── 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.
|
||||
*/
|
||||
function createNoOpLogger(): DispatchLogger {
|
||||
return {
|
||||
onEvent(_event: Record<string, unknown>): void {
|
||||
// intentionally empty
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface DefaultLoggerOptions {
|
||||
cwd?: string;
|
||||
config?: { audit?: { enabled?: boolean } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the default DispatchLogger.
|
||||
*/
|
||||
function createDefaultLogger({ cwd = process.cwd(), config }: DefaultLoggerOptions = {}): DispatchLogger {
|
||||
return {
|
||||
/**
|
||||
* @param event - A DispatchEvent from the Hub.
|
||||
*/
|
||||
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)) {
|
||||
try {
|
||||
const auditRecord = _toAuditRecord(event);
|
||||
_appendAuditLine(cwd, auditRecord);
|
||||
} catch (auditErr) {
|
||||
// Audit errors must not surface to callers
|
||||
process.stderr.write(
|
||||
_safeStringify({
|
||||
level: 'warn',
|
||||
source: 'DispatchLogger',
|
||||
message: 'audit file write failed: ' + String((auditErr as Error | null)?.message ?? auditErr),
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stderr on error ───────────────────────────────────────────────────
|
||||
if (!isOk) {
|
||||
try {
|
||||
const stderrRecord = _toStderrRecord(event);
|
||||
process.stderr.write(_safeStringify(stderrRecord) + '\n');
|
||||
} catch (stderrErr) {
|
||||
// Last-resort: we cannot throw from the logger
|
||||
process.stderr.write(
|
||||
_safeStringify({
|
||||
level: 'warn',
|
||||
source: 'DispatchLogger',
|
||||
message: 'stderr emit failed: ' + String((stderrErr as Error | null)?.message ?? stderrErr),
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
// ── Silent on success (no else branch needed) ─────────────────────────
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export = { createDefaultLogger, createNoOpLogger };
|
||||
52
src/observability/redaction.cts
Normal file
52
src/observability/redaction.cts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* This module is deliberately simple and has no side effects — redaction
|
||||
* decisions are stateless reads of process.env at call time so that tests
|
||||
* 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.
|
||||
*/
|
||||
export function shouldIncludeArgs(): boolean {
|
||||
return process.env.GSD_AUDIT_ARGS === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a redacted copy of a DispatchEvent.
|
||||
*
|
||||
* If args should be omitted (default), strips the `args` field entirely.
|
||||
* If args should be included (GSD_AUDIT_ARGS=1), passes the event through
|
||||
* unchanged (args were already set by makeDispatchEvent with includeArgs:true,
|
||||
* or absent — in which case they stay absent).
|
||||
*
|
||||
* The original event object is never mutated (it is frozen by makeDispatchEvent).
|
||||
*
|
||||
* @param event - A DispatchEvent (frozen or plain).
|
||||
* @returns A new plain object with the same fields, minus args when redacted.
|
||||
*/
|
||||
export function redactEvent(event: DispatchEvent): DispatchEvent {
|
||||
if (shouldIncludeArgs()) {
|
||||
// Include path: return a shallow copy with args preserved if present
|
||||
const copy = Object.assign({}, event);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// Exclude path: build a copy omitting `args`
|
||||
const { args: _dropped, ...rest } = event;
|
||||
return rest;
|
||||
}
|
||||
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;
|
||||
218
src/phase-command-router.cts
Normal file
218
src/phase-command-router.cts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Manifest-backed phase subcommand router.
|
||||
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
|
||||
*
|
||||
* Unsupported in this router:
|
||||
* - scaffold: routed through top-level scaffold command.
|
||||
*
|
||||
* CJS-only subcommands: mvp-mode (dispatched directly, before hub).
|
||||
*
|
||||
* #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.
|
||||
*/
|
||||
|
||||
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: Record<string, string> = {
|
||||
scaffold: 'phase scaffold is routed through the top-level scaffold command.',
|
||||
};
|
||||
|
||||
const subcommand = args[1];
|
||||
|
||||
if (subcommand && UNSUPPORTED[subcommand]) {
|
||||
error(UNSUPPORTED[subcommand]);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── No subcommand → reject early with helpful error ────────────────────────
|
||||
// Pre-#3788 code resolved unknown subcommands via routeCjsCommandFamily which
|
||||
// fell through to error() when no handler matched (including undefined).
|
||||
// Post-#3788 the hub's manifest check is skipped for falsy subcommand, so we
|
||||
// must guard here to preserve the deterministic "Available: ..." error message.
|
||||
if (!subcommand) {
|
||||
const available = PHASE_SUBCOMMANDS.filter(s => !UNSUPPORTED[s]).join(', ');
|
||||
error(`Unknown phase subcommand. Available: ${available}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── CJS-only subcommands (dispatched directly, before hub) ─────────────────
|
||||
// `mvp-mode` has a CJS-native implementation in phase.cmdPhaseMvpMode that
|
||||
// differs from the SDK query layer (different ROADMAP scan + error codes).
|
||||
// Dispatch it early to preserve pre-migration observable behaviour (correct
|
||||
// exit code, correct JSON error reason code, correct ROADMAP scan).
|
||||
if (subcommand === 'mvp-mode') {
|
||||
phase.cmdPhaseMvpMode(cwd, args.slice(2), raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Build the CJS registry ──────────────────────────────────────────────────
|
||||
// Each handler receives a ctx object from the hub and must return a HubResult.
|
||||
const cjsRegistry = {
|
||||
phase: {
|
||||
'next-decimal': (_ctx: Record<string, unknown>): { ok: true; data: null } => {
|
||||
phase.cmdPhaseNextDecimal(cwd, args[2], raw);
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
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') {
|
||||
continue;
|
||||
}
|
||||
if (token === '--id') {
|
||||
const id = args[i + 1];
|
||||
if (!id || id.startsWith('--')) {
|
||||
return makeInvalidArgs('--id', '--id requires a value');
|
||||
}
|
||||
customId = id;
|
||||
i++;
|
||||
} else if (token.startsWith('--')) {
|
||||
return makeInvalidArgs(token, `phase add does not support ${token}`);
|
||||
} else {
|
||||
descArgs.push(token);
|
||||
}
|
||||
}
|
||||
phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId);
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
'add-batch': (_ctx: Record<string, unknown>) => {
|
||||
const descFlagIdx = args.indexOf('--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) as string[];
|
||||
} catch {
|
||||
return makeInvalidArgs('--descriptions', '--descriptions must be a JSON array');
|
||||
}
|
||||
if (!Array.isArray(descriptions)) {
|
||||
return makeInvalidArgs('--descriptions', '--descriptions must be a JSON array');
|
||||
}
|
||||
} else {
|
||||
descriptions = args.slice(2).filter(a => a !== '--raw');
|
||||
}
|
||||
phase.cmdPhaseAddBatch(cwd, descriptions, raw);
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
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 as const, data: null };
|
||||
},
|
||||
remove: (_ctx: Record<string, unknown>) => {
|
||||
const removeArgs = args.slice(2).filter(token => token !== '--raw');
|
||||
let forceFlag = false;
|
||||
const positional: string[] = [];
|
||||
for (const token of removeArgs) {
|
||||
if (token === '--force') {
|
||||
forceFlag = true;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('--')) {
|
||||
return makeInvalidArgs(token, `phase remove does not support ${token}`);
|
||||
}
|
||||
positional.push(token);
|
||||
}
|
||||
if (positional.length !== 1) {
|
||||
return makeInvalidArgs('<phase-number>', 'phase remove accepts exactly one phase number');
|
||||
}
|
||||
phase.cmdPhaseRemove(cwd, positional[0], { force: forceFlag }, raw);
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
complete: (_ctx: Record<string, unknown>): { ok: true; data: null } => {
|
||||
phase.cmdPhaseComplete(cwd, args[2], raw);
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Build manifest (available subcommands for UnknownCommand detection) ─────
|
||||
// `availableSubcommands` is what the error message shows. It excludes
|
||||
// unsupported commands (already handled above) but does NOT include 'mvp-mode'
|
||||
// because it was absent from PHASE_SUBCOMMANDS in the original and was not
|
||||
// shown in the "Available:" list there either.
|
||||
//
|
||||
// `manifestSubcommands` is the full routing set for the hub — it includes
|
||||
// 'mvp-mode' (which the original code routed via a handler even without a
|
||||
// manifest entry) so the hub's UnknownCommand check passes for it.
|
||||
const availableSubcommands = PHASE_SUBCOMMANDS.filter(s => !UNSUPPORTED[s]);
|
||||
const manifestSubcommands = ['mvp-mode', ...availableSubcommands];
|
||||
const manifest = { phase: manifestSubcommands };
|
||||
|
||||
// ── Construct hub ──────────────────────────────────────────────────────────
|
||||
// #175: Hub is CJS-only — no mode param, no sdkLoader.
|
||||
const hub = createHub({ cjsRegistry, manifest });
|
||||
|
||||
// ── Dispatch ────────────────────────────────────────────────────────────────
|
||||
const result = hub.dispatch({
|
||||
family: 'phase',
|
||||
subcommand,
|
||||
args: args.slice(2),
|
||||
cwd,
|
||||
raw,
|
||||
});
|
||||
|
||||
// ── Translate result → CLI output / error (adapter responsibility) ──────────
|
||||
// CJS handlers call output() themselves (inside phase.cmdPhase*()).
|
||||
// No further output call is needed here.
|
||||
if (!result.ok) {
|
||||
if (result.kind === ERROR_KINDS.UnknownCommand) {
|
||||
const available = availableSubcommands.join(', ');
|
||||
error(`Unknown phase subcommand. Available: ${available}`);
|
||||
return;
|
||||
}
|
||||
if (result.kind === ERROR_KINDS.InvalidArgs || result.kind === ERROR_KINDS.HandlerRefusal) {
|
||||
// #176: typed payload — reason holds the human-readable message
|
||||
error(result.reason);
|
||||
return;
|
||||
}
|
||||
// HandlerFailure: message field
|
||||
error(result.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export = {
|
||||
routePhaseCommand,
|
||||
};
|
||||
82
src/phase-lifecycle.cts
Normal file
82
src/phase-lifecycle.cts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Phase Lifecycle Pure Helpers — pure-computation functions extracted from
|
||||
* 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
|
||||
* logic is shared via this generated artifact.
|
||||
*
|
||||
* Scope:
|
||||
* - deriveProgressFromRoadmap(roadmapContent): count Complete rows => idempotent
|
||||
* - clampPercent(completed, total): percent with 100 ceiling
|
||||
*
|
||||
* These two functions are the root-cause fix for issue #4.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - 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.
|
||||
*/
|
||||
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").
|
||||
// Pattern: row where the phase cell starts with a digit (data row, not header),
|
||||
// followed by any cell content, then a "Complete" status cell.
|
||||
// Handles both short form ("| 4. |") and long form ("| 01. Foundation |").
|
||||
// See phase-lifecycle.ts ~line 1655 for the original SDK pattern.
|
||||
const tableCompletePattern = /\|\s*\d+[^|]*\|\s*[^|]*\|\s*Complete\s*\|/gi;
|
||||
const completeMatches = roadmapContent.match(tableCompletePattern);
|
||||
completedPhases = completeMatches ? completeMatches.length : null;
|
||||
|
||||
// Count total phase rows in the progress table.
|
||||
// Identify the table by looking for Phase|...|Status|...|Completed header.
|
||||
const progressTableMatch = roadmapContent.match(
|
||||
/\|\s*Phase\s*\|[^|]*\|[^|]*Status[^|]*\|[^|]*Completed[^|]*\|[\s\S]*?(?=\n\n|\n##|$)/i,
|
||||
);
|
||||
if (progressTableMatch) {
|
||||
const tableText = progressTableMatch[0];
|
||||
// Count data rows (rows starting with pipe then a phase number)
|
||||
const dataRowPattern = /^\|\s*\d+/gm;
|
||||
const dataRows = tableText.match(dataRowPattern);
|
||||
totalPhases = dataRows ? dataRows.length : null;
|
||||
}
|
||||
|
||||
// Sum plan counts from M/N columns in progress table
|
||||
let totalPlansSum = 0;
|
||||
const planCellPattern = /\|\s*\d+[^|]*\|\s*(\d+)\/(\d+)\s*\|/gi;
|
||||
let pm: RegExpExecArray | null;
|
||||
while ((pm = planCellPattern.exec(roadmapContent)) !== null) {
|
||||
totalPlansSum += parseInt(pm[2], 10);
|
||||
}
|
||||
if (totalPlansSum > 0) totalPlans = totalPlansSum;
|
||||
} catch { /* intentionally empty — fall through to existing values */ }
|
||||
|
||||
return { completedPhases, totalPhases, totalPlans };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute progress percent clamped to 100.
|
||||
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
|
||||
*/
|
||||
export function clampPercent(completed: number, total: number): number {
|
||||
if (!total || total <= 0) return 0;
|
||||
return Math.min(100, Math.round((completed / total) * 100));
|
||||
}
|
||||
1727
src/phase.cts
Normal file
1727
src/phase.cts
Normal file
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,
|
||||
});
|
||||
272
src/planning-workspace.cts
Normal file
272
src/planning-workspace.cts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Planning Workspace — .planning path resolution + active workstream routing.
|
||||
*
|
||||
* This module owns the planning workspace seam:
|
||||
* - planningDir/planningRoot/planningPaths
|
||||
* - planning lock semantics
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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,
|
||||
createMemoryPointerAdapter,
|
||||
getActiveWorkstream: getStoredActiveWorkstream,
|
||||
setActiveWorkstream: setStoredActiveWorkstream,
|
||||
clearActiveWorkstream: clearStoredActiveWorkstream,
|
||||
} = activeWorkstreamStore;
|
||||
|
||||
// Track .planning/.lock files held by this process so they can be removed on exit.
|
||||
const _heldPlanningLocks = new Set<string>();
|
||||
process.on('exit', () => {
|
||||
for (const lockPath of _heldPlanningLocks) {
|
||||
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Transient errno codes that indicate a temporary filesystem condition under
|
||||
// concurrent O_EXCL races — Docker overlay-fs (ENOENT/EINVAL/EIO), NFS
|
||||
// (ESTALE), and OS-level interrupt/retry signals (EAGAIN/EINTR). These are
|
||||
// recoverable; withPlanningLock retries instead of propagating them.
|
||||
// Truly fatal codes (EMFILE, ENOSPC, EROFS, EACCES) are NOT in this set and
|
||||
// will still throw immediately.
|
||||
const PLANNING_LOCK_RETRY_ERRNOS = new Set([
|
||||
'EPERM', // Windows / macOS AV scanner holds the file open during delete
|
||||
'EBUSY', // Windows: file in use by another process
|
||||
'EAGAIN', // POSIX: resource temporarily unavailable
|
||||
'EINTR', // POSIX: syscall interrupted by signal
|
||||
'EINVAL', // Docker overlay-fs: transient during concurrent O_EXCL creation
|
||||
'EIO', // Docker overlay-fs / NFS: transient I/O error
|
||||
'ENOENT', // Docker overlay-fs: parent dir transiently missing during race
|
||||
'ESTALE', // NFS: stale file handle (self-resolves on retry)
|
||||
]);
|
||||
|
||||
// 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 = /[/\\]|\.\./;
|
||||
if (project && BAD_SEGMENT.test(project)) {
|
||||
throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`);
|
||||
}
|
||||
if (ws && BAD_SEGMENT.test(ws)) {
|
||||
throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`);
|
||||
}
|
||||
|
||||
let base = path.join(cwd, '.planning');
|
||||
if (project) base = path.join(base, project);
|
||||
if (ws) base = path.join(base, 'workstreams', ws);
|
||||
return base;
|
||||
}
|
||||
|
||||
function planningRoot(cwd: string): string {
|
||||
return path.join(cwd, '.planning');
|
||||
}
|
||||
|
||||
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,
|
||||
state: path.join(base, 'STATE.md'),
|
||||
roadmap: path.join(base, 'ROADMAP.md'),
|
||||
project: path.join(base, 'PROJECT.md'),
|
||||
config: path.join(base, 'config.json'),
|
||||
phases: path.join(base, 'phases'),
|
||||
requirements: path.join(base, 'REQUIREMENTS.md'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @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<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
|
||||
const start = clock.now();
|
||||
|
||||
// Ensure .planning/ exists
|
||||
try { platformEnsureDir(planningDir(cwd)); } catch { /* ok */ }
|
||||
|
||||
function acquireLock(): void {
|
||||
// Atomic create — fails if file exists
|
||||
fs.writeFileSync(lockPath, JSON.stringify({
|
||||
pid: process.pid,
|
||||
cwd,
|
||||
acquired: new Date().toISOString(),
|
||||
}), { flag: 'wx' });
|
||||
|
||||
_heldPlanningLocks.add(lockPath);
|
||||
}
|
||||
|
||||
function runWithHeldLock(): T {
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
_heldPlanningLocks.delete(lockPath);
|
||||
try { fs.unlinkSync(lockPath); } catch { /* already released */ }
|
||||
}
|
||||
}
|
||||
|
||||
while (clock.now() - start < lockTimeout) {
|
||||
let lockWasAcquired = false;
|
||||
try {
|
||||
acquireLock();
|
||||
lockWasAcquired = true;
|
||||
return runWithHeldLock();
|
||||
} catch (err) {
|
||||
// Transient filesystem errors (Docker overlay-fs, NFS, OS signals, AV scanners)
|
||||
// are recoverable — wait and retry rather than propagating.
|
||||
// See PLANNING_LOCK_RETRY_ERRNOS for the full list and rationale.
|
||||
if (lockWasAcquired) throw err;
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (PLANNING_LOCK_RETRY_ERRNOS.has(nodeErr.code ?? '')) {
|
||||
clock.sleep(100);
|
||||
continue;
|
||||
}
|
||||
if (nodeErr.code === 'EEXIST') {
|
||||
// Lock exists — check if stale (>30s old)
|
||||
try {
|
||||
const stat = fs.statSync(lockPath);
|
||||
if (clock.now() - stat.mtimeMs > 30000) {
|
||||
fs.unlinkSync(lockPath);
|
||||
continue; // retry
|
||||
}
|
||||
} catch { continue; }
|
||||
|
||||
// Wait and retry (cross-platform, no shell dependency)
|
||||
clock.sleep(100);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout — stale-lock recovery, then re-acquire atomically before entering critical section.
|
||||
try { fs.unlinkSync(lockPath); } catch { /* ok */ }
|
||||
acquireLock();
|
||||
return runWithHeldLock();
|
||||
}
|
||||
|
||||
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?: string | null, project?: string | null) {
|
||||
return planningDir(cwd, ws, project);
|
||||
},
|
||||
root() {
|
||||
return planningRoot(cwd);
|
||||
},
|
||||
all(ws?: string | null) {
|
||||
return planningPaths(cwd, ws);
|
||||
},
|
||||
},
|
||||
activeWorkstream: {
|
||||
get() {
|
||||
return getStoredActiveWorkstream(cwd, opts);
|
||||
},
|
||||
set(name: string) {
|
||||
setStoredActiveWorkstream(cwd, name, opts);
|
||||
},
|
||||
clear() {
|
||||
clearStoredActiveWorkstream(cwd, opts);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveWorkstream(cwd: string): string | null {
|
||||
return getStoredActiveWorkstream(cwd);
|
||||
}
|
||||
|
||||
function setActiveWorkstream(cwd: string, name: string): void {
|
||||
setStoredActiveWorkstream(cwd, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the CONTEXT.md file in a phase directory, handling both the bare
|
||||
* form (`CONTEXT.md`) and the padded-prefix convention (`NN-CONTEXT.md`,
|
||||
* `NN.N-CONTEXT.md`, etc.) used by gsd-discuss-phase output.
|
||||
*
|
||||
* Returns the filename (not the full path) of the first match, or null if
|
||||
* no CONTEXT.md exists in the directory.
|
||||
*
|
||||
* Canonical dual-form predicate extracted here to eliminate the 5-site
|
||||
* duplication that previously existed across init.cjs, roadmap.cjs,
|
||||
* core.cjs, gap-checker.cjs (#3739).
|
||||
*
|
||||
* @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).
|
||||
*/
|
||||
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: string) => f.endsWith('-CONTEXT.md')) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export = {
|
||||
createPlanningWorkspace,
|
||||
createSharedPointerAdapter,
|
||||
createSessionScopedPointerAdapter,
|
||||
createMemoryPointerAdapter,
|
||||
planningDir,
|
||||
planningRoot,
|
||||
planningPaths,
|
||||
withPlanningLock,
|
||||
getActiveWorkstream,
|
||||
setActiveWorkstream,
|
||||
findContextMdIn,
|
||||
};
|
||||
1224
src/profile-output.cts
Normal file
1224
src/profile-output.cts
Normal file
File diff suppressed because it is too large
Load Diff
627
src/profile-pipeline.cts
Normal file
627
src/profile-pipeline.cts
Normal file
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* Profile Pipeline — session scanning, message extraction, and sampling
|
||||
*
|
||||
* Reads Claude Code session history (read-only) to extract user messages
|
||||
* for behavioral profiling. Three commands:
|
||||
* - scan-sessions: list all projects and sessions
|
||||
* - extract-messages: extract user messages from a specific project
|
||||
* - profile-sample: multi-project sampling with recency weighting
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/profile-pipeline.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.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import readline from 'node:readline';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output, error, reapStaleTempFiles } = core;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SessionEntry {
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
size: number;
|
||||
modified: Date;
|
||||
}
|
||||
|
||||
interface IndexEntry {
|
||||
sessionId?: string;
|
||||
summary?: string;
|
||||
messageCount?: number;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
interface IndexData {
|
||||
originalPath: string | null;
|
||||
entries: Map<string, IndexEntry>;
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
name: string;
|
||||
directory: string;
|
||||
sessionCount: number;
|
||||
totalSize: number;
|
||||
totalSizeHuman: string;
|
||||
lastActive: string;
|
||||
dateRange: { first: string; last: string };
|
||||
sessions?: SessionDetail[];
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
sessionId: string;
|
||||
size: number;
|
||||
sizeHuman: string;
|
||||
modified: string;
|
||||
summary?: string;
|
||||
messageCount?: number;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
interface ExtractedMessage {
|
||||
sessionId: string;
|
||||
projectPath: string | null;
|
||||
timestamp: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface SampleMessage {
|
||||
sessionId: string;
|
||||
projectName: string;
|
||||
projectPath: string | null;
|
||||
timestamp: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface JsonlRecord {
|
||||
type?: string;
|
||||
userType?: string;
|
||||
isMeta?: boolean;
|
||||
isSidechain?: boolean;
|
||||
message?: {
|
||||
content?: unknown;
|
||||
};
|
||||
cwd?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// ─── Session I/O Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function getSessionsDir(overridePath?: string | null): string | null {
|
||||
const dir = overridePath || path.join(os.homedir(), '.claude', 'projects');
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
return dir;
|
||||
}
|
||||
|
||||
function scanProjectDir(projectDirPath: string): SessionEntry[] {
|
||||
const entries = fs.readdirSync(projectDirPath);
|
||||
const sessions: SessionEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.replace('.jsonl', '');
|
||||
const filePath = path.join(projectDirPath, entry);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
sessions.push({
|
||||
sessionId,
|
||||
filePath,
|
||||
size: stat.size,
|
||||
modified: stat.mtime,
|
||||
});
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function readSessionIndex(projectDirPath: string): IndexData {
|
||||
try {
|
||||
const indexPath = path.join(projectDirPath, 'sessions-index.json');
|
||||
const raw = fs.readFileSync(indexPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { originalPath?: string; entries?: IndexEntry[] };
|
||||
const entries = new Map<string, IndexEntry>();
|
||||
for (const entry of (parsed.entries || [])) {
|
||||
if (entry.sessionId) {
|
||||
entries.set(entry.sessionId, entry);
|
||||
}
|
||||
}
|
||||
return { originalPath: parsed.originalPath || null, entries };
|
||||
} catch {
|
||||
return { originalPath: null, entries: new Map() };
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectName(projectDirName: string, indexData: IndexData, firstRecordCwd?: string | null): string {
|
||||
if (indexData && indexData.originalPath) {
|
||||
return path.basename(indexData.originalPath);
|
||||
}
|
||||
if (firstRecordCwd) {
|
||||
return path.basename(firstRecordCwd);
|
||||
}
|
||||
return projectDirName;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
return `${(bytes / 1073741824).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatProjectTable(projects: ProjectInfo[]): string {
|
||||
let out = '';
|
||||
out += 'Project'.padEnd(35) + 'Sessions'.padEnd(10) + 'Size'.padEnd(10) + 'Last Active\n';
|
||||
out += '-'.repeat(75) + '\n';
|
||||
for (const p of projects) {
|
||||
const name = p.name.length > 33 ? p.name.substring(0, 30) + '...' : p.name;
|
||||
out += name.padEnd(35) + String(p.sessionCount).padEnd(10) +
|
||||
p.totalSizeHuman.padEnd(10) + p.lastActive + '\n';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatSessionTable(sessions: SessionDetail[]): string {
|
||||
let out = '';
|
||||
out += ' Session ID'.padEnd(42) + 'Size'.padEnd(10) + 'Modified\n';
|
||||
out += ' ' + '-'.repeat(70) + '\n';
|
||||
for (const s of sessions) {
|
||||
const id = s.sessionId.length > 38 ? s.sessionId.substring(0, 35) + '...' : s.sessionId;
|
||||
out += ' ' + id.padEnd(40) + formatBytes(s.size).padEnd(10) +
|
||||
new Date(s.modified).toISOString().replace('T', ' ').substring(0, 19) + '\n';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Message Extraction Helpers ───────────────────────────────────────────────
|
||||
|
||||
function isGenuineUserMessage(record: JsonlRecord): boolean {
|
||||
if (record.type !== 'user') return false;
|
||||
if (record.userType !== 'external') return false;
|
||||
if (record.isMeta === true) return false;
|
||||
if (record.isSidechain === true) return false;
|
||||
const content = record.message?.content;
|
||||
if (typeof content !== 'string') return false;
|
||||
if (content.length === 0) return false;
|
||||
if (content.startsWith('<local-command')) return false;
|
||||
if (content.startsWith('<command-')) return false;
|
||||
if (content.startsWith('<task-notification')) return false;
|
||||
if (content.startsWith('<local-command-stdout')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function truncateContent(content: string, maxLen = 2000): string {
|
||||
if (content.length <= maxLen) return content;
|
||||
return content.substring(0, maxLen) + '... [truncated]';
|
||||
}
|
||||
|
||||
async function streamExtractMessages(filePath: string, filterFn: (r: JsonlRecord) => boolean, maxMessages = 300): Promise<ExtractedMessage[]> {
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
const messages: ExtractedMessage[] = [];
|
||||
const sessionId = path.basename(filePath, '.jsonl');
|
||||
|
||||
for await (const line of rl) {
|
||||
if (messages.length >= maxMessages) break;
|
||||
let record: JsonlRecord;
|
||||
try {
|
||||
record = JSON.parse(line) as JsonlRecord;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!filterFn(record)) continue;
|
||||
messages.push({
|
||||
sessionId,
|
||||
projectPath: record.cwd || null,
|
||||
timestamp: record.timestamp || null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
content: truncateContent(String(record.message?.content ?? '')),
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function cmdScanSessions(overridePath: string | null | undefined, options: { json?: boolean; verbose?: boolean }, raw: boolean): void {
|
||||
const sessionsDir = getSessionsDir(overridePath);
|
||||
if (!sessionsDir) {
|
||||
const searchedPath = overridePath || '~/.claude/projects';
|
||||
error(`No Claude Code sessions found at ${searchedPath}.${overridePath ? '' : ' Is Claude Code installed?'}`);
|
||||
}
|
||||
|
||||
process.stderr.write('Reading your session history (read-only, nothing is modified or sent anywhere)...\n');
|
||||
|
||||
let projectDirs: string[];
|
||||
try {
|
||||
projectDirs = fs.readdirSync(sessionsDir as string).filter(entry => {
|
||||
const fullPath = path.join(sessionsDir as string, entry);
|
||||
try {
|
||||
return fs.statSync(fullPath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
error(`Cannot read sessions directory: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const projects: ProjectInfo[] = [];
|
||||
|
||||
for (const dirName of projectDirs) {
|
||||
const projectPath = path.join(sessionsDir as string, dirName);
|
||||
const sessions = scanProjectDir(projectPath);
|
||||
if (sessions.length === 0) continue;
|
||||
|
||||
const indexData = readSessionIndex(projectPath);
|
||||
const projectName = getProjectName(dirName, indexData);
|
||||
|
||||
if (indexData.entries.size === 0 && !options.json) {
|
||||
process.stderr.write(`Index not found for ${projectName}, scanning directory...\n`);
|
||||
}
|
||||
|
||||
const totalSize = sessions.reduce((sum, s) => sum + s.size, 0);
|
||||
const lastActive = sessions[0].modified.toISOString();
|
||||
const oldest = sessions[sessions.length - 1].modified.toISOString();
|
||||
const newest = sessions[0].modified.toISOString();
|
||||
|
||||
const project: ProjectInfo = {
|
||||
name: projectName,
|
||||
directory: dirName,
|
||||
sessionCount: sessions.length,
|
||||
totalSize,
|
||||
totalSizeHuman: formatBytes(totalSize),
|
||||
lastActive: lastActive.replace('T', ' ').substring(0, 19),
|
||||
dateRange: { first: oldest, last: newest },
|
||||
};
|
||||
|
||||
if (options.verbose) {
|
||||
project.sessions = sessions.map(s => {
|
||||
const indexed = indexData.entries.get(s.sessionId);
|
||||
const session: SessionDetail = {
|
||||
sessionId: s.sessionId,
|
||||
size: s.size,
|
||||
sizeHuman: formatBytes(s.size),
|
||||
modified: s.modified.toISOString(),
|
||||
};
|
||||
if (indexed) {
|
||||
if (indexed.summary) session.summary = indexed.summary;
|
||||
if (indexed.messageCount !== undefined) session.messageCount = indexed.messageCount;
|
||||
if (indexed.created) session.created = indexed.created;
|
||||
}
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
|
||||
projects.sort((a, b) => b.dateRange.last.localeCompare(a.dateRange.last));
|
||||
|
||||
if (options.json || raw) {
|
||||
output(projects, raw, undefined);
|
||||
} else {
|
||||
process.stdout.write('\n' + formatProjectTable(projects));
|
||||
if (options.verbose) {
|
||||
for (const p of projects) {
|
||||
process.stdout.write(`\n ${p.name} (${p.sessionCount} sessions):\n`);
|
||||
if (p.sessions) {
|
||||
process.stdout.write(formatSessionTable(p.sessions));
|
||||
}
|
||||
}
|
||||
}
|
||||
process.stdout.write(`\nTotal: ${projects.length} projects\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdExtractMessages(projectArg: string, options: { sessionId?: string; limit?: number }, raw: boolean, overridePath?: string | null): Promise<void> {
|
||||
const sessionsDir = getSessionsDir(overridePath);
|
||||
if (!sessionsDir) {
|
||||
const searchedPath = overridePath || '~/.claude/projects';
|
||||
error(`No Claude Code sessions found at ${searchedPath}.${overridePath ? '' : ' Is Claude Code installed?'}`);
|
||||
}
|
||||
|
||||
let projectDirs: string[];
|
||||
try {
|
||||
projectDirs = fs.readdirSync(sessionsDir as string).filter(entry => {
|
||||
const fullPath = path.join(sessionsDir as string, entry);
|
||||
try {
|
||||
return fs.statSync(fullPath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
error(`Cannot read sessions directory: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let matchedDir: string | null = null;
|
||||
let matchedName: string | null = null;
|
||||
|
||||
for (const dirName of projectDirs) {
|
||||
if (dirName === projectArg) {
|
||||
matchedDir = dirName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedDir) {
|
||||
const lowerArg = projectArg.toLowerCase();
|
||||
const matches = projectDirs.filter(d => d.toLowerCase().includes(lowerArg));
|
||||
if (matches.length === 1) {
|
||||
matchedDir = matches[0];
|
||||
} else if (matches.length > 1) {
|
||||
const exactNameMatches: { dirName: string; name: string }[] = [];
|
||||
for (const dirName of matches) {
|
||||
const indexData = readSessionIndex(path.join(sessionsDir as string, dirName));
|
||||
const pName = getProjectName(dirName, indexData);
|
||||
if (pName.toLowerCase() === lowerArg) {
|
||||
exactNameMatches.push({ dirName, name: pName });
|
||||
}
|
||||
}
|
||||
if (exactNameMatches.length === 1) {
|
||||
matchedDir = exactNameMatches[0].dirName;
|
||||
matchedName = exactNameMatches[0].name;
|
||||
} else {
|
||||
const names = matches.map(d => {
|
||||
const idx = readSessionIndex(path.join(sessionsDir as string, d));
|
||||
return ` - ${getProjectName(d, idx)} (${d})`;
|
||||
});
|
||||
error(`Multiple projects match "${projectArg}":\n${names.join('\n')}\nBe more specific.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedDir) {
|
||||
const available = projectDirs.map(d => {
|
||||
const idx = readSessionIndex(path.join(sessionsDir as string, d));
|
||||
return ` - ${getProjectName(d, idx)}`;
|
||||
});
|
||||
error(`No project matching "${projectArg}". Available projects:\n${available.join('\n')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectPath = path.join(sessionsDir as string, matchedDir);
|
||||
const indexData = readSessionIndex(projectPath);
|
||||
const projectName = matchedName || getProjectName(matchedDir, indexData);
|
||||
|
||||
process.stderr.write('Reading your session history (read-only, nothing is modified or sent anywhere)...\n');
|
||||
|
||||
let sessions = scanProjectDir(projectPath);
|
||||
|
||||
if (options.sessionId) {
|
||||
sessions = sessions.filter(s => s.sessionId === options.sessionId);
|
||||
if (sessions.length === 0) {
|
||||
error(`Session "${options.sessionId}" not found in project "${projectName}".`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.limit && options.limit > 0) {
|
||||
sessions = sessions.slice(0, options.limit);
|
||||
}
|
||||
|
||||
reapStaleTempFiles('gsd-pipeline-', { dirsOnly: true });
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pipeline-'));
|
||||
const outputPath = path.join(tmpDir, 'extracted-messages.jsonl');
|
||||
|
||||
let sessionsProcessed = 0;
|
||||
let sessionsSkipped = 0;
|
||||
let messagesExtracted = 0;
|
||||
let messagesTruncated = 0;
|
||||
const total = sessions.length;
|
||||
const batchLimit = 300;
|
||||
|
||||
for (let i = 0; i < sessions.length; i++) {
|
||||
if (messagesExtracted >= batchLimit) break;
|
||||
|
||||
const session = sessions[i];
|
||||
process.stderr.write(`\rProcessing session ${i + 1}/${total}...`);
|
||||
|
||||
try {
|
||||
const remaining = batchLimit - messagesExtracted;
|
||||
const msgs = await streamExtractMessages(session.filePath, isGenuineUserMessage, remaining);
|
||||
for (const msg of msgs) {
|
||||
fs.appendFileSync(outputPath, JSON.stringify(msg) + '\n');
|
||||
messagesExtracted++;
|
||||
if (msg.content.endsWith('... [truncated]')) {
|
||||
messagesTruncated++;
|
||||
}
|
||||
}
|
||||
sessionsProcessed++;
|
||||
} catch (err) {
|
||||
sessionsSkipped++;
|
||||
process.stderr.write(`\nWarning: Skipped session ${session.sessionId}: ${(err as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write('\r' + ' '.repeat(60) + '\r');
|
||||
|
||||
const result = {
|
||||
output_file: outputPath,
|
||||
project: projectName,
|
||||
sessions_processed: sessionsProcessed,
|
||||
sessions_skipped: sessionsSkipped,
|
||||
messages_extracted: messagesExtracted,
|
||||
messages_truncated: messagesTruncated,
|
||||
};
|
||||
|
||||
if (sessionsSkipped > 0 && sessionsProcessed > 0) {
|
||||
process.stdout.write(JSON.stringify(result, null, 2));
|
||||
process.exit(2);
|
||||
} else if (sessionsProcessed === 0 && sessionsSkipped > 0) {
|
||||
process.stdout.write(JSON.stringify(result, null, 2));
|
||||
process.exit(1);
|
||||
} else {
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdProfileSample(overridePath: string | null | undefined, options: { limit?: number; maxChars?: number; maxPerProject?: number }, raw: boolean): Promise<void> {
|
||||
const sessionsDir = getSessionsDir(overridePath);
|
||||
if (!sessionsDir) {
|
||||
const searchedPath = overridePath || '~/.claude/projects';
|
||||
error(`No Claude Code sessions found at ${searchedPath}.${overridePath ? '' : ' Is Claude Code installed?'}`);
|
||||
}
|
||||
|
||||
process.stderr.write('Reading your session history (read-only, nothing is modified or sent anywhere)...\n');
|
||||
|
||||
const limit = options.limit || 150;
|
||||
const maxChars = options.maxChars || 500;
|
||||
|
||||
let projectDirs: string[];
|
||||
try {
|
||||
projectDirs = fs.readdirSync(sessionsDir as string).filter(entry => {
|
||||
const fullPath = path.join(sessionsDir as string, entry);
|
||||
try {
|
||||
return fs.statSync(fullPath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
error(`Cannot read sessions directory: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectDirs.length === 0) {
|
||||
error('No project directories found in sessions directory.');
|
||||
}
|
||||
|
||||
interface ProjectMeta {
|
||||
dirName: string;
|
||||
projectPath: string;
|
||||
sessions: SessionEntry[];
|
||||
projectName: string;
|
||||
lastActive: Date;
|
||||
}
|
||||
|
||||
const projectMeta: ProjectMeta[] = [];
|
||||
for (const dirName of projectDirs) {
|
||||
const projectPath = path.join(sessionsDir as string, dirName);
|
||||
const sessions = scanProjectDir(projectPath);
|
||||
if (sessions.length === 0) continue;
|
||||
const indexData = readSessionIndex(projectPath);
|
||||
const projectName = getProjectName(dirName, indexData);
|
||||
const lastActive = sessions[0].modified;
|
||||
projectMeta.push({ dirName, projectPath, sessions, projectName, lastActive });
|
||||
}
|
||||
|
||||
projectMeta.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime());
|
||||
|
||||
const projectCount = projectMeta.length;
|
||||
if (projectCount === 0) {
|
||||
error('No projects with sessions found.');
|
||||
}
|
||||
|
||||
const perProjectCap = options.maxPerProject || Math.max(5, Math.floor(limit / projectCount));
|
||||
|
||||
const recencyThreshold = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
const allMessages: SampleMessage[] = [];
|
||||
let skippedContextDumps = 0;
|
||||
const projectBreakdown: { project: string; messages: number; sessions: number }[] = [];
|
||||
|
||||
for (const proj of projectMeta) {
|
||||
if (allMessages.length >= limit) break;
|
||||
|
||||
const cappedSessions = proj.sessions.slice(0, perProjectCap);
|
||||
|
||||
let projectMessages = 0;
|
||||
let projectSessionsUsed = 0;
|
||||
|
||||
for (const session of cappedSessions) {
|
||||
if (allMessages.length >= limit) break;
|
||||
|
||||
const isRecent = session.modified.getTime() >= recencyThreshold;
|
||||
const perSessionMax = isRecent ? 10 : 3;
|
||||
|
||||
const remaining = Math.min(perSessionMax, limit - allMessages.length);
|
||||
|
||||
try {
|
||||
const msgs = await streamExtractMessages(session.filePath, isGenuineUserMessage, remaining);
|
||||
let sessionUsed = false;
|
||||
|
||||
for (const msg of msgs) {
|
||||
if (allMessages.length >= limit) break;
|
||||
|
||||
const content = msg.content || '';
|
||||
if (content.startsWith('This session is being continued')) {
|
||||
skippedContextDumps++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
||||
if (lines.length > 3) {
|
||||
const logPattern = /^\[?(DEBUG|INFO|WARN|ERROR|LOG)\]?/i;
|
||||
const timestampPattern = /^\d{4}-\d{2}-\d{2}/;
|
||||
const logLines = lines.filter(l => logPattern.test(l.trim()) || timestampPattern.test(l.trim()));
|
||||
if (logLines.length / lines.length > 0.8) {
|
||||
skippedContextDumps++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = truncateContent(content, maxChars);
|
||||
|
||||
allMessages.push({
|
||||
sessionId: msg.sessionId,
|
||||
projectName: proj.projectName,
|
||||
projectPath: msg.projectPath,
|
||||
timestamp: msg.timestamp,
|
||||
content: truncated,
|
||||
});
|
||||
|
||||
projectMessages++;
|
||||
sessionUsed = true;
|
||||
}
|
||||
if (sessionUsed) projectSessionsUsed++;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (projectMessages > 0) {
|
||||
projectBreakdown.push({
|
||||
project: proj.projectName,
|
||||
messages: projectMessages,
|
||||
sessions: projectSessionsUsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reapStaleTempFiles('gsd-profile-', { dirsOnly: true });
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-'));
|
||||
const outputPath = path.join(tmpDir, 'profile-sample.jsonl');
|
||||
for (const msg of allMessages) {
|
||||
fs.appendFileSync(outputPath, JSON.stringify(msg) + '\n');
|
||||
}
|
||||
|
||||
const result = {
|
||||
output_file: outputPath,
|
||||
projects_sampled: projectBreakdown.length,
|
||||
messages_sampled: allMessages.length,
|
||||
per_project_cap: perProjectCap,
|
||||
message_char_limit: maxChars,
|
||||
skipped_context_dumps: skippedContextDumps,
|
||||
project_breakdown: projectBreakdown,
|
||||
};
|
||||
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
export = {
|
||||
cmdScanSessions,
|
||||
cmdExtractMessages,
|
||||
cmdProfileSample,
|
||||
};
|
||||
115
src/project-root.cts
Normal file
115
src/project-root.cts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/project-root.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';
|
||||
import os from 'node:os';
|
||||
|
||||
const FIND_PROJECT_ROOT_MAX_DEPTH = 10;
|
||||
|
||||
export function findProjectRoot(startDir: string): string {
|
||||
let resolvedStart: string;
|
||||
try {
|
||||
resolvedStart = path.resolve(startDir);
|
||||
} catch {
|
||||
return startDir;
|
||||
}
|
||||
|
||||
const fsRoot = path.parse(resolvedStart).root;
|
||||
const home = os.homedir();
|
||||
|
||||
// If startDir already contains .planning/, it IS the project root.
|
||||
try {
|
||||
const ownPlanningDir = resolvedStart + path.sep + '.planning';
|
||||
if (fs.existsSync(ownPlanningDir) && fs.statSync(ownPlanningDir).isDirectory()) {
|
||||
return startDir;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
// Walk upward, mirroring isInsideGitRepo from the CJS reference.
|
||||
function isInsideGitRepo(candidateParent: string): boolean {
|
||||
let d = resolvedStart;
|
||||
while (d !== fsRoot) {
|
||||
try {
|
||||
if (fs.existsSync(d + path.sep + '.git')) return true;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (d === candidateParent) break;
|
||||
const next = path.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 = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
if (parent === home) break;
|
||||
|
||||
const parentPlanning = parent + path.sep + '.planning';
|
||||
let parentPlanningIsDir = false;
|
||||
try {
|
||||
parentPlanningIsDir = fs.existsSync(parentPlanning) && fs.statSync(parentPlanning).isDirectory();
|
||||
} catch {
|
||||
parentPlanningIsDir = false;
|
||||
}
|
||||
|
||||
if (parentPlanningIsDir) {
|
||||
const configPath = parentPlanning + path.sep + 'config.json';
|
||||
let matched = false;
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const config: unknown = JSON.parse(raw);
|
||||
if (config && typeof config === 'object') {
|
||||
const cfg = config as Record<string, unknown>;
|
||||
const subReposValue =
|
||||
cfg['sub_repos'] ??
|
||||
(cfg['planning'] && typeof cfg['planning'] === 'object'
|
||||
? (cfg['planning'] as Record<string, unknown>)['sub_repos']
|
||||
: undefined);
|
||||
const subRepos = Array.isArray(subReposValue) ? (subReposValue as unknown[]) : [];
|
||||
if (subRepos.length > 0) {
|
||||
const relPath = path.relative(parent, resolvedStart);
|
||||
const topSegment = relPath.split(path.sep)[0];
|
||||
if (subRepos.includes(topSegment)) {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
if (cfg['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;
|
||||
}
|
||||
436
src/prompt-budget.cts
Normal file
436
src/prompt-budget.cts
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* prompt-budget.cts
|
||||
*
|
||||
* Pure functions for assembling and trimming review prompts to fit within
|
||||
* a token budget (ADR-457 build-at-publish: the hand-written
|
||||
* bin/lib/prompt-budget.cjs collapsed to a TypeScript source of truth).
|
||||
* Behaviour is preserved byte-for-behaviour from the prior hand-written .cjs;
|
||||
* only types are added.
|
||||
*
|
||||
* Used by the review pipeline to support small-context models.
|
||||
*
|
||||
* Trim priority (in order — never violate):
|
||||
* 1. Instructions: ALWAYS kept verbatim
|
||||
* 2. Reserve note tokens FIRST when any trim is anticipated
|
||||
* 3. Roadmap: ALWAYS kept verbatim
|
||||
* 4. PROJECT.md: head-shrink to projectMdHeadLines (default 40) if over budget
|
||||
* 5. Plans: tail-truncate proportionally; never drop a whole plan
|
||||
* 6. Context: DROP first if still over
|
||||
* 7. Research: DROP second if still over
|
||||
* 8. Requirements: DROP last (last-resort)
|
||||
* 9. Hard-fail: if minimum-set exceeds effectiveBudget
|
||||
*/
|
||||
|
||||
const NOTE_RESERVE_TOKENS = 80;
|
||||
|
||||
const DEFAULT_NOTE_TEMPLATE = [
|
||||
'<note>',
|
||||
'Prompt automatically trimmed to fit a {budget}-token budget.',
|
||||
'Omitted sections: {omittedList}.',
|
||||
'Plan content truncated by approximately {planTruncationPct}%.',
|
||||
'Treat any missing context as out-of-scope rather than a review concern.',
|
||||
'</note>',
|
||||
].join('\n');
|
||||
|
||||
/** A single plan section with its filename and content. */
|
||||
export interface PlanSection {
|
||||
file: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Input sections for applyBudget. */
|
||||
export interface PromptSections {
|
||||
instructions: string;
|
||||
roadmap: string;
|
||||
plans: PlanSection[];
|
||||
projectMd?: string | null;
|
||||
context?: string | null;
|
||||
research?: string | null;
|
||||
requirements?: string | null;
|
||||
}
|
||||
|
||||
/** Options for applyBudget. */
|
||||
export interface BudgetOptions {
|
||||
safetyMarginPct?: number;
|
||||
noteTemplate?: string;
|
||||
projectMdHeadLines?: number;
|
||||
}
|
||||
|
||||
/** Metadata about the budget application result. */
|
||||
export interface BudgetMetadata {
|
||||
budget: number;
|
||||
effectiveBudget: number;
|
||||
estimatedTokens: number;
|
||||
omitted: string[];
|
||||
projectMdShrunk: boolean;
|
||||
planTruncationPct: number;
|
||||
hardFailed: boolean;
|
||||
noteInjected: boolean;
|
||||
}
|
||||
|
||||
/** Result of applyBudget. */
|
||||
export interface BudgetResult {
|
||||
prompt: string;
|
||||
metadata: BudgetMetadata;
|
||||
}
|
||||
|
||||
/** Input to applyBudget. */
|
||||
export interface ApplyBudgetInput {
|
||||
sections: PromptSections;
|
||||
budget: number;
|
||||
options?: BudgetOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate tokens for a string. Chars / 4, rounded up.
|
||||
*/
|
||||
export function estimateTokens(text: string | null | undefined): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the trim-disclosure note.
|
||||
*/
|
||||
function renderNote(template: string, budget: number, omitted: string[], planTruncationPct: number): string {
|
||||
const omittedList = omitted.length > 0 ? omitted.join(', ') : 'none';
|
||||
return template
|
||||
.replace('{budget}', String(budget))
|
||||
.replace('{omittedList}', omittedList)
|
||||
.replace('{planTruncationPct}', String(Math.round(planTruncationPct)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Head-shrink a string to at most `maxLines` lines.
|
||||
*/
|
||||
function headShrink(text: string, maxLines: number): string {
|
||||
if (maxLines <= 0) return '';
|
||||
let idx = -1;
|
||||
let seen = 0;
|
||||
while (seen < maxLines) {
|
||||
idx = text.indexOf('\n', idx + 1);
|
||||
if (idx === -1) return text;
|
||||
seen += 1;
|
||||
}
|
||||
return text.slice(0, idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail-truncate a string to at most `maxChars` characters.
|
||||
*/
|
||||
function tailTruncate(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
return text.slice(0, maxChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the final prompt string from its sections.
|
||||
*/
|
||||
function assemblePrompt(parts: {
|
||||
instructions: string;
|
||||
note: string | null;
|
||||
roadmap: string;
|
||||
projectMd: string | null;
|
||||
plans: PlanSection[];
|
||||
context: string | null;
|
||||
research: string | null;
|
||||
requirements: string | null;
|
||||
}): string {
|
||||
const {
|
||||
instructions,
|
||||
note,
|
||||
roadmap,
|
||||
projectMd,
|
||||
plans,
|
||||
context,
|
||||
research,
|
||||
requirements,
|
||||
} = parts;
|
||||
|
||||
const blocks: string[] = [];
|
||||
|
||||
blocks.push(instructions);
|
||||
|
||||
if (note) blocks.push(note);
|
||||
|
||||
blocks.push('## Roadmap\n\n' + roadmap);
|
||||
|
||||
if (projectMd) blocks.push('## Project\n\n' + projectMd);
|
||||
|
||||
const planBlocks = plans
|
||||
.map((p) => '### ' + p.file + '\n\n' + p.content)
|
||||
.join('\n\n');
|
||||
blocks.push('## Plans\n\n' + planBlocks);
|
||||
|
||||
if (context) blocks.push('## Context\n\n' + context);
|
||||
if (research) blocks.push('## Research\n\n' + research);
|
||||
if (requirements) blocks.push('## Requirements\n\n' + requirements);
|
||||
|
||||
return blocks.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a token budget to a set of review prompt sections.
|
||||
* Returns the trimmed prompt and structured metadata.
|
||||
*/
|
||||
export function applyBudget({ sections, budget, options = {} }: ApplyBudgetInput): BudgetResult {
|
||||
const {
|
||||
safetyMarginPct = 10,
|
||||
noteTemplate = DEFAULT_NOTE_TEMPLATE,
|
||||
projectMdHeadLines = 40,
|
||||
} = options;
|
||||
|
||||
const effectiveBudget = Math.floor(budget * (1 - safetyMarginPct / 100));
|
||||
|
||||
const {
|
||||
instructions,
|
||||
roadmap,
|
||||
plans,
|
||||
projectMd: projectMdRaw = null,
|
||||
context: contextRaw = null,
|
||||
research: researchRaw = null,
|
||||
requirements: requirementsRaw = null,
|
||||
} = sections;
|
||||
|
||||
// Working mutable state
|
||||
let projectMd: string | null = projectMdRaw;
|
||||
let context: string | null = contextRaw;
|
||||
let research: string | null = researchRaw;
|
||||
let requirements: string | null = requirementsRaw;
|
||||
let workingPlans: PlanSection[] = plans.map((p) => ({ file: p.file, content: p.content }));
|
||||
|
||||
const omitted: string[] = [];
|
||||
let projectMdShrunk = false;
|
||||
let planTruncationPct = 0;
|
||||
let noteInjected = false;
|
||||
let hardFailed = false;
|
||||
|
||||
// Minimum-set check: instructions + roadmap + 1KB per plan.
|
||||
// NOTE_RESERVE_TOKENS is intentionally excluded here: a note is only injected
|
||||
// when trimming actually occurs, and a prompt that fits without any trim needs
|
||||
// no note at all. Including NOTE_RESERVE_TOKENS here would cause false hard-fails
|
||||
// for prompts that genuinely fit the effective budget untrimmed.
|
||||
const MIN_PLAN_BYTES = 1024;
|
||||
const minPlanTokens = plans.reduce((sum, p) => {
|
||||
return sum + estimateTokens(p.content.slice(0, MIN_PLAN_BYTES));
|
||||
}, 0);
|
||||
const minSet =
|
||||
estimateTokens(instructions) +
|
||||
estimateTokens(roadmap) +
|
||||
minPlanTokens;
|
||||
|
||||
if (minSet > effectiveBudget) {
|
||||
return {
|
||||
prompt: '',
|
||||
metadata: {
|
||||
budget,
|
||||
effectiveBudget,
|
||||
estimatedTokens: 0,
|
||||
omitted: [],
|
||||
projectMdShrunk: false,
|
||||
planTruncationPct: 0,
|
||||
hardFailed: true,
|
||||
noteInjected: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Budget accounting ──────────────────────────────────────────────────────
|
||||
const TOKENS_ROADMAP_HEADER = estimateTokens('## Roadmap\n\n');
|
||||
const TOKENS_PROJECT_HEADER = estimateTokens('## Project\n\n');
|
||||
const TOKENS_PLANS_HEADER = estimateTokens('## Plans\n\n');
|
||||
const TOKENS_CONTEXT_HEADER = estimateTokens('## Context\n\n');
|
||||
const TOKENS_RESEARCH_HEADER = estimateTokens('## Research\n\n');
|
||||
const TOKENS_REQUIREMENTS_HEADER = estimateTokens('## Requirements\n\n');
|
||||
const TOKENS_PLAN_ITEM_HEADERS = workingPlans.reduce(
|
||||
(sum, p) => sum + estimateTokens('### ' + p.file + '\n\n'),
|
||||
0
|
||||
);
|
||||
|
||||
const staticBaseTokens =
|
||||
estimateTokens(instructions) +
|
||||
TOKENS_ROADMAP_HEADER +
|
||||
estimateTokens(roadmap) +
|
||||
TOKENS_PLANS_HEADER +
|
||||
TOKENS_PLAN_ITEM_HEADERS;
|
||||
|
||||
let projectTokens = projectMd
|
||||
? TOKENS_PROJECT_HEADER + estimateTokens(projectMd)
|
||||
: 0;
|
||||
let contextTokens = context
|
||||
? TOKENS_CONTEXT_HEADER + estimateTokens(context)
|
||||
: 0;
|
||||
let researchTokens = research
|
||||
? TOKENS_RESEARCH_HEADER + estimateTokens(research)
|
||||
: 0;
|
||||
let requirementsTokens = requirements
|
||||
? TOKENS_REQUIREMENTS_HEADER + estimateTokens(requirements)
|
||||
: 0;
|
||||
let planContentTokens = workingPlans.reduce(
|
||||
(sum, p) => sum + estimateTokens(p.content),
|
||||
0
|
||||
);
|
||||
|
||||
const getCurrentBaseTokens = (): number =>
|
||||
staticBaseTokens +
|
||||
projectTokens +
|
||||
planContentTokens +
|
||||
contextTokens +
|
||||
researchTokens +
|
||||
requirementsTokens;
|
||||
|
||||
let currentBaseTokens = getCurrentBaseTokens();
|
||||
|
||||
// Detect budget pressure: is ANY trim needed?
|
||||
// Pressure exists when the current base tokens already exceed the effective
|
||||
// budget. Only when pressure is real do we reserve NOTE_RESERVE_TOKENS so
|
||||
// the note itself fits after trimming. Checking against
|
||||
// effectiveBudget - NOTE_RESERVE_TOKENS (the old threshold) would cause
|
||||
// spurious pressure 80 tokens early, dropping sections that fit fine.
|
||||
const baseTokens = currentBaseTokens;
|
||||
const budgetUnderPressure = baseTokens > effectiveBudget;
|
||||
|
||||
// Available for content (reserve note slot when under pressure)
|
||||
const contentBudget = budgetUnderPressure
|
||||
? effectiveBudget - NOTE_RESERVE_TOKENS
|
||||
: effectiveBudget;
|
||||
|
||||
// ── Trim step 1: head-shrink PROJECT.md ───────────────────────────────────
|
||||
if (currentBaseTokens > contentBudget && projectMd) {
|
||||
const shrunk = headShrink(projectMd, projectMdHeadLines);
|
||||
if (shrunk !== projectMd) {
|
||||
projectMd = shrunk;
|
||||
projectMdShrunk = true;
|
||||
projectTokens = TOKENS_PROJECT_HEADER + estimateTokens(projectMd);
|
||||
currentBaseTokens = getCurrentBaseTokens();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trim step 2: proportional plan truncation ─────────────────────────────
|
||||
if (currentBaseTokens > contentBudget) {
|
||||
// Compute tokens available for plan content only
|
||||
const overhead =
|
||||
staticBaseTokens +
|
||||
projectTokens +
|
||||
contextTokens +
|
||||
researchTokens +
|
||||
requirementsTokens;
|
||||
const planBudgetTokens = contentBudget - overhead;
|
||||
const totalPlanTokens = planContentTokens;
|
||||
|
||||
if (planBudgetTokens > 0 && planBudgetTokens < totalPlanTokens) {
|
||||
// Proportional share per plan (at least 1KB per plan)
|
||||
const totalOriginalChars = plans.reduce(
|
||||
(sum, p) => sum + p.content.length,
|
||||
0
|
||||
);
|
||||
|
||||
const totalPlanCharsBudget = planBudgetTokens * 4;
|
||||
workingPlans = workingPlans.map((p) => {
|
||||
const proportionalShare =
|
||||
totalOriginalChars > 0
|
||||
? Math.floor((p.content.length / totalOriginalChars) * totalPlanCharsBudget)
|
||||
: 0;
|
||||
const maxChars = Math.max(proportionalShare, MIN_PLAN_BYTES);
|
||||
return { file: p.file, content: tailTruncate(p.content, maxChars) };
|
||||
});
|
||||
|
||||
const newTotalChars = workingPlans.reduce(
|
||||
(sum, p) => sum + p.content.length,
|
||||
0
|
||||
);
|
||||
if (totalOriginalChars > 0) {
|
||||
planTruncationPct =
|
||||
((totalOriginalChars - newTotalChars) / totalOriginalChars) * 100;
|
||||
}
|
||||
planContentTokens = workingPlans.reduce(
|
||||
(sum, p) => sum + estimateTokens(p.content),
|
||||
0
|
||||
);
|
||||
currentBaseTokens = getCurrentBaseTokens();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trim step 3: drop context ─────────────────────────────────────────────
|
||||
if (currentBaseTokens > contentBudget && context) {
|
||||
context = null;
|
||||
omitted.push('context');
|
||||
contextTokens = 0;
|
||||
currentBaseTokens = getCurrentBaseTokens();
|
||||
}
|
||||
|
||||
// ── Trim step 4: drop research ────────────────────────────────────────────
|
||||
if (currentBaseTokens > contentBudget && research) {
|
||||
research = null;
|
||||
omitted.push('research');
|
||||
researchTokens = 0;
|
||||
currentBaseTokens = getCurrentBaseTokens();
|
||||
}
|
||||
|
||||
// ── Trim step 5: drop requirements (last resort) ──────────────────────────
|
||||
if (currentBaseTokens > contentBudget && requirements) {
|
||||
requirements = null;
|
||||
omitted.push('requirements');
|
||||
requirementsTokens = 0;
|
||||
currentBaseTokens = getCurrentBaseTokens();
|
||||
}
|
||||
|
||||
// ── Decide whether note is actually needed ────────────────────────────────
|
||||
const anyTrimOccurred =
|
||||
omitted.length > 0 || projectMdShrunk || planTruncationPct > 0;
|
||||
|
||||
let note: string | null = null;
|
||||
if (anyTrimOccurred) {
|
||||
note = renderNote(noteTemplate, budget, omitted, planTruncationPct);
|
||||
noteInjected = true;
|
||||
}
|
||||
|
||||
// Suppress unused variable warning — currentBaseTokens is used via the
|
||||
// closure in getCurrentBaseTokens(); the final value is not used directly.
|
||||
void currentBaseTokens;
|
||||
|
||||
// ── Assemble ──────────────────────────────────────────────────────────────
|
||||
const prompt = assemblePrompt({
|
||||
instructions,
|
||||
note,
|
||||
roadmap,
|
||||
projectMd,
|
||||
plans: workingPlans,
|
||||
context,
|
||||
research,
|
||||
requirements,
|
||||
});
|
||||
|
||||
const estimatedTokens = estimateTokens(prompt);
|
||||
|
||||
if (estimatedTokens > effectiveBudget) {
|
||||
hardFailed = true;
|
||||
return {
|
||||
prompt: '',
|
||||
metadata: {
|
||||
budget,
|
||||
effectiveBudget,
|
||||
estimatedTokens,
|
||||
omitted,
|
||||
projectMdShrunk,
|
||||
planTruncationPct,
|
||||
hardFailed,
|
||||
noteInjected,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prompt,
|
||||
metadata: {
|
||||
budget,
|
||||
effectiveBudget,
|
||||
estimatedTokens,
|
||||
omitted,
|
||||
projectMdShrunk,
|
||||
planTruncationPct,
|
||||
hardFailed,
|
||||
noteInjected,
|
||||
},
|
||||
};
|
||||
}
|
||||
152
src/review-reviewer-selection.cts
Normal file
152
src/review-reviewer-selection.cts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Review Reviewer Selection Module (ADR-457 build-at-publish: the hand-written
|
||||
* bin/lib/review-reviewer-selection.cjs collapsed to a TypeScript source of
|
||||
* truth). Behaviour is preserved byte-for-behaviour from the prior hand-written
|
||||
* .cjs; only types are added.
|
||||
*
|
||||
* Owns reviewer-selection policy projection for /gsd:review:
|
||||
* explicit flags > --all > review.default_reviewers > all detected.
|
||||
*/
|
||||
|
||||
export const KNOWN_REVIEWER_SLUGS: ReadonlyArray<string> = [
|
||||
'gemini',
|
||||
'claude',
|
||||
'codex',
|
||||
'coderabbit',
|
||||
'opencode',
|
||||
'qwen',
|
||||
'cursor',
|
||||
'antigravity',
|
||||
'ollama',
|
||||
'lm_studio',
|
||||
'llama_cpp',
|
||||
];
|
||||
|
||||
export interface NormalizedDefaultReviewers {
|
||||
absent: boolean;
|
||||
values: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ReviewerSelectionInput {
|
||||
detected?: unknown[];
|
||||
explicitFlags?: unknown[];
|
||||
allFlag?: unknown;
|
||||
configuredDefaultReviewers?: unknown;
|
||||
}
|
||||
|
||||
export interface ReviewerSelectionResult {
|
||||
source: string;
|
||||
selected: string[];
|
||||
warnings: string[];
|
||||
infos: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export function normalizeConfiguredDefaultReviewers(
|
||||
rawValue: unknown,
|
||||
): NormalizedDefaultReviewers {
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
return { absent: true, values: [], errors: [] };
|
||||
}
|
||||
if (!Array.isArray(rawValue)) {
|
||||
return {
|
||||
absent: false,
|
||||
values: [],
|
||||
errors: ['review.default_reviewers must be a JSON array of reviewer slugs'],
|
||||
};
|
||||
}
|
||||
if (rawValue.length === 0) {
|
||||
return {
|
||||
absent: false,
|
||||
values: [],
|
||||
errors: ['review.default_reviewers cannot be empty'],
|
||||
};
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const item of rawValue) {
|
||||
if (typeof item !== 'string') {
|
||||
errors.push('review.default_reviewers must contain only string slugs');
|
||||
continue;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(item)) {
|
||||
errors.push(`invalid reviewer slug in review.default_reviewers: ${item}`);
|
||||
continue;
|
||||
}
|
||||
const slug = item.toLowerCase();
|
||||
if (!seen.has(slug)) {
|
||||
seen.add(slug);
|
||||
normalized.push(slug);
|
||||
}
|
||||
}
|
||||
|
||||
return { absent: false, values: normalized, errors };
|
||||
}
|
||||
|
||||
export function resolveReviewerSelection(
|
||||
input: ReviewerSelectionInput,
|
||||
): ReviewerSelectionResult {
|
||||
const detected = new Set(
|
||||
(input.detected ?? []).map((v) => String(v).toLowerCase()),
|
||||
);
|
||||
const explicitFlags = new Set(
|
||||
(input.explicitFlags ?? []).map((v) => String(v).toLowerCase()),
|
||||
);
|
||||
const allFlag = !!input.allFlag;
|
||||
const normalizedDefaults = normalizeConfiguredDefaultReviewers(
|
||||
input.configuredDefaultReviewers,
|
||||
);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const infos: string[] = [];
|
||||
const errors: string[] = [...normalizedDefaults.errors];
|
||||
|
||||
let source = 'no_config_all_detected';
|
||||
let selected: string[] = [];
|
||||
|
||||
if (explicitFlags.size > 0) {
|
||||
source = 'explicit_flags';
|
||||
selected = [...explicitFlags].filter((slug) => detected.has(slug));
|
||||
const missing = [...explicitFlags].filter((slug) => !detected.has(slug));
|
||||
if (missing.length > 0) {
|
||||
infos.push(`explicit reviewers missing on host: ${missing.join(', ')}`);
|
||||
}
|
||||
if (selected.length === 0 && errors.length === 0) {
|
||||
errors.push('no selected reviewers are available for explicit flags');
|
||||
}
|
||||
} else if (allFlag) {
|
||||
source = 'all_flag';
|
||||
selected = [...detected];
|
||||
} else if (!normalizedDefaults.absent) {
|
||||
source = 'config_default';
|
||||
const knownDefaults: string[] = [];
|
||||
for (const slug of normalizedDefaults.values) {
|
||||
if (!KNOWN_REVIEWER_SLUGS.includes(slug)) {
|
||||
warnings.push(`unknown reviewer slug in review.default_reviewers: ${slug}`);
|
||||
} else {
|
||||
knownDefaults.push(slug);
|
||||
}
|
||||
}
|
||||
const undetected = knownDefaults.filter((slug) => !detected.has(slug));
|
||||
if (undetected.length > 0) {
|
||||
infos.push(`configured reviewers not detected on this host: ${undetected.join(', ')}`);
|
||||
}
|
||||
selected = knownDefaults.filter((slug) => detected.has(slug));
|
||||
if (selected.length === 0 && errors.length === 0) {
|
||||
errors.push('all configured default reviewers are unavailable on this host');
|
||||
}
|
||||
} else {
|
||||
selected = [...detected];
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
selected: selected.sort(),
|
||||
warnings,
|
||||
infos,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
198
src/roadmap-command-router.cts
Normal file
198
src/roadmap-command-router.cts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Manifest-backed roadmap subcommand router.
|
||||
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/roadmap-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 fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ROADMAP_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;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapUpgrade = require('./roadmap-upgrade.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 core = require('./core.cjs');
|
||||
const { loadConfig } = core;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RoadmapModule {
|
||||
cmdRoadmapGetPhase(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||
cmdRoadmapAnalyze(cwd: string, raw: boolean): void;
|
||||
cmdRoadmapUpdatePlanProgress(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||
cmdRoadmapAnnotateDependencies(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||
}
|
||||
|
||||
interface RouteRoadmapCommandOptions {
|
||||
roadmap: RoadmapModule;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
raw: boolean;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
interface W021Warning {
|
||||
code: 'W021';
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ─── W021 Implementation ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check each phase entry in a milestone-prefixed ROADMAP.md for W021 violations.
|
||||
*
|
||||
* W021: a phase whose ID integer prefix does not match its enclosing milestone's
|
||||
* major version number.
|
||||
*
|
||||
* Sentinel milestones (0 = pre-milestone, 999 = backlog) are exempt.
|
||||
*
|
||||
* @param content - ROADMAP.md content
|
||||
* @returns Array of W021 warnings
|
||||
*/
|
||||
function checkW021(content: string): W021Warning[] {
|
||||
const warnings: W021Warning[] = [];
|
||||
|
||||
// Sentinel milestone integers exempt from W021
|
||||
const SENTINELS = new Set([0, 999]);
|
||||
const MIGRATION_CMD = 'gsd-tools roadmap upgrade --convention milestone-prefixed';
|
||||
|
||||
// Milestone section heading: ## [GSD] v2.0 — Label OR ## v2.0: Label OR ## Roadmap v2.0
|
||||
// OR ## ✅ v2.0 OR ## 🚧 v2.0 (emoji-prefixed variants used by roadmap templates)
|
||||
// Capture the major integer.
|
||||
const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
|
||||
|
||||
// Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form)
|
||||
const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*\s*:/i;
|
||||
// Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index)
|
||||
const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)\s*:/i;
|
||||
|
||||
let currentMilestoneMajor: number | null = null;
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const milestoneMatch = line.match(MILESTONE_RE);
|
||||
if (milestoneMatch) {
|
||||
currentMilestoneMajor = parseInt(milestoneMatch[1], 10);
|
||||
continue;
|
||||
}
|
||||
|
||||
const phaseMatch = line.match(PHASE_RE);
|
||||
if (phaseMatch) {
|
||||
const phaseMajor = parseInt(phaseMatch[1], 10);
|
||||
if (SENTINELS.has(phaseMajor)) continue; // exempt
|
||||
|
||||
if (currentMilestoneMajor !== null && phaseMajor !== currentMilestoneMajor) {
|
||||
const phaseId = `${phaseMatch[1]}-${phaseMatch[2]}`;
|
||||
warnings.push({
|
||||
code: 'W021',
|
||||
message:
|
||||
`Phase ID prefix mismatch: phase "${phaseId}" is listed under v${currentMilestoneMajor}.x ` +
|
||||
`but its prefix (${phaseMajor}) does not match. ` +
|
||||
`Run \`${MIGRATION_CMD}\` to fix.`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// When the convention is active, an unprefixed heading (### Phase 1:) is itself a W021
|
||||
// violation — it is missing the required M-NN prefix entirely.
|
||||
const unprefixedMatch = line.match(UNPREFIXED_PHASE_RE);
|
||||
if (unprefixedMatch && currentMilestoneMajor !== null) {
|
||||
const rawId = unprefixedMatch[1];
|
||||
// Skip if it matched PHASE_RE already (it didn't reach here in that case)
|
||||
// Also skip if it looks like a bare integer whose prefix matches the section
|
||||
// — those pass; only non-matching or non-prefixed forms fire W021.
|
||||
const numericMajor = parseInt(rawId, 10);
|
||||
if (!SENTINELS.has(numericMajor)) {
|
||||
warnings.push({
|
||||
code: 'W021',
|
||||
message:
|
||||
`Phase ID "${rawId}" is not in M-NN form (milestone-prefixed convention is active). ` +
|
||||
`Run \`${MIGRATION_CMD}\` to migrate.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function routeRoadmapCommand({ roadmap, args, cwd, raw, error }: RouteRoadmapCommandOptions): void {
|
||||
routeCjsCommandFamily({
|
||||
args,
|
||||
subcommands: ROADMAP_SUBCOMMANDS,
|
||||
unsupported: {},
|
||||
error,
|
||||
unknownMessage: (_subcommand: string, available: string[]) => `Unknown roadmap subcommand. Available: ${available.join(', ')}`,
|
||||
handlers: {
|
||||
'get-phase': () => roadmap.cmdRoadmapGetPhase(cwd, args[2], raw),
|
||||
analyze: () => roadmap.cmdRoadmapAnalyze(cwd, raw),
|
||||
'update-plan-progress': () => roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw),
|
||||
'annotate-dependencies': () => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw),
|
||||
'validate': () => {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
let roadmapContent = '';
|
||||
try {
|
||||
roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
|
||||
} catch {
|
||||
// ROADMAP.md missing — return empty warnings
|
||||
}
|
||||
|
||||
// W021 only fires when phase_id_convention is explicitly 'milestone-prefixed'.
|
||||
// Authoritative source: .planning/config.json (set by the upgrade command).
|
||||
// Fallback: ROADMAP.md frontmatter (for projects that set the field there directly).
|
||||
let convention: string | undefined | null;
|
||||
try {
|
||||
const cfg = loadConfig(cwd);
|
||||
convention = cfg['phase_id_convention'] as string | undefined | null;
|
||||
} catch {
|
||||
convention = undefined;
|
||||
}
|
||||
if (convention === undefined || convention === null) {
|
||||
// Fallback: read from ROADMAP.md frontmatter
|
||||
const fmMatch = roadmapContent.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
||||
if (fmMatch) {
|
||||
const kvMatch = fmMatch[1].match(/^phase_id_convention:\s*(.*)$/m);
|
||||
if (kvMatch) {
|
||||
const val = kvMatch[1].trim();
|
||||
if (val !== 'null' && val !== '') {
|
||||
convention = val.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const warnings = (convention === 'milestone-prefixed')
|
||||
? checkW021(roadmapContent)
|
||||
: [];
|
||||
|
||||
const result = { warnings };
|
||||
if (raw) process.stdout.write(JSON.stringify(result));
|
||||
else process.stdout.write(JSON.stringify(result, null, 2));
|
||||
},
|
||||
'upgrade': () => {
|
||||
const dryRun = !args.includes('--apply');
|
||||
const convention = args.find((_a, i) => args[i - 1] === '--convention') || 'milestone-prefixed';
|
||||
if (convention !== 'milestone-prefixed') {
|
||||
process.stderr.write('Only --convention milestone-prefixed is supported\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const plan = roadmapUpgrade.computeMigrationPlan(cwd);
|
||||
roadmapUpgrade.applyMigration(cwd, plan, { dryRun });
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export = {
|
||||
routeRoadmapCommand,
|
||||
};
|
||||
611
src/roadmap-upgrade.cts
Normal file
611
src/roadmap-upgrade.cts
Normal file
@@ -0,0 +1,611 @@
|
||||
/**
|
||||
* Roadmap Upgrade — Migration tool for converting legacy 'Phase N' phase IDs
|
||||
* to milestone-prefixed 'Phase M-NN' form.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/roadmap-upgrade.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';
|
||||
import { execSync } from 'node:child_process';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
|
||||
// ─── Regex helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:)
|
||||
// Captures: (hashes)(spaces)(phase-number)(rest-of-line)
|
||||
const LEGACY_PHASE_HEADING_RE = /^(#{2,4})\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:(.*)/i;
|
||||
|
||||
// Matches already-migrated phase headings: ### Phase M-NN: Name
|
||||
const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
|
||||
|
||||
// Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
|
||||
// The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
|
||||
const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
|
||||
|
||||
// Matches checklist phase references: - [ ] **Phase N:** or - [x] **Phase N:** (also decimal)
|
||||
const CHECKLIST_PHASE_RE = /^(\s*-\s*\[[ x]\]\s*\*{0,2})Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedPhaseEntry {
|
||||
lineIndex: number;
|
||||
headingLine: string;
|
||||
alreadyMigrated: boolean;
|
||||
milestoneInt?: number | null;
|
||||
legacyPhaseNum?: string;
|
||||
phaseName?: string;
|
||||
hashes?: string;
|
||||
}
|
||||
|
||||
interface AssignedMapping {
|
||||
newId: string;
|
||||
milestoneInt: number;
|
||||
subIndex: number;
|
||||
legacyPhaseNum: string;
|
||||
}
|
||||
|
||||
interface PhaseRename {
|
||||
oldId: string;
|
||||
newId: string;
|
||||
oldDir: string;
|
||||
newDir: string;
|
||||
}
|
||||
|
||||
interface RoadmapEdit {
|
||||
lineIndex: number;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface CrossRefEdit {
|
||||
file: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface MigrationPlan {
|
||||
alreadyMigrated: boolean;
|
||||
phases: PhaseRename[];
|
||||
roadmapEdits: RoadmapEdit[];
|
||||
crossRefEdits: CrossRefEdit[];
|
||||
}
|
||||
|
||||
interface ApplyMigrationResult {
|
||||
applied?: boolean;
|
||||
alreadyMigrated?: boolean;
|
||||
dryRun?: boolean;
|
||||
renamedDirs?: string[];
|
||||
editedFiles?: string[];
|
||||
}
|
||||
|
||||
// ─── Pure computation helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the ROADMAP.md content and build a list of phase entries with their
|
||||
* enclosing milestone major version.
|
||||
*
|
||||
* Returns an array of:
|
||||
* { lineIndex, headingLine, milestoneInt, legacyPhaseNum, phaseName }
|
||||
*/
|
||||
function parseRoadmapPhases(lines: string[]): ParsedPhaseEntry[] {
|
||||
const results: ParsedPhaseEntry[] = [];
|
||||
let currentMilestoneInt: number | null = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
const milestoneMatch = line.match(MILESTONE_HEADING_RE);
|
||||
if (milestoneMatch) {
|
||||
currentMilestoneInt = parseInt(milestoneMatch[1], 10);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (MIGRATED_PHASE_HEADING_RE.test(line)) {
|
||||
// Already-migrated heading found — caller will detect this
|
||||
results.push({ lineIndex: i, headingLine: line, alreadyMigrated: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
const phaseMatch = line.match(LEGACY_PHASE_HEADING_RE);
|
||||
if (phaseMatch) {
|
||||
results.push({
|
||||
lineIndex: i,
|
||||
headingLine: line,
|
||||
milestoneInt: currentMilestoneInt,
|
||||
legacyPhaseNum: phaseMatch[2],
|
||||
phaseName: phaseMatch[3].trim(),
|
||||
hashes: phaseMatch[1],
|
||||
alreadyMigrated: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign sub-indices within each milestone, building a per-entry mapping.
|
||||
*
|
||||
* Input: array from parseRoadmapPhases (non-migrated entries only).
|
||||
* Returns: Map<lineIndex, { newId, milestoneInt, subIndex }>
|
||||
*
|
||||
* Keyed by `lineIndex` (the unique position of the heading line in ROADMAP.md)
|
||||
* so that identical legacy phase numbers in different milestones (e.g., two
|
||||
* `Phase 1` headings in v1.0 and v2.0) each get their own correct M-NN ID
|
||||
* instead of the later milestone's mapping overwriting the earlier one.
|
||||
*
|
||||
* Sub-indices are 1-based and sequential within each milestone.
|
||||
*/
|
||||
function assignSubIndices(phaseEntries: ParsedPhaseEntry[]): Map<number, AssignedMapping> {
|
||||
const milestoneCounters = new Map<number, number>(); // milestoneInt → counter
|
||||
const mapping = new Map<number, AssignedMapping>(); // lineIndex → { newId, milestoneInt, subIndex }
|
||||
|
||||
for (const entry of phaseEntries) {
|
||||
if (entry.alreadyMigrated) continue;
|
||||
const m = entry.milestoneInt;
|
||||
if (m === null || m === undefined) continue;
|
||||
|
||||
const counter = (milestoneCounters.get(m) || 0) + 1;
|
||||
milestoneCounters.set(m, counter);
|
||||
|
||||
const subIndex = String(counter).padStart(2, '0');
|
||||
const newId = `${m}-${subIndex}`;
|
||||
|
||||
mapping.set(entry.lineIndex, { newId, milestoneInt: m, subIndex: counter, legacyPhaseNum: entry.legacyPhaseNum! });
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a phase directory name and return its numeric token (stripping project_code prefix).
|
||||
* e.g. "GSD-01-setup" → "01", "01-setup" → "01", "02-implement" → "02", "02.1-hotfix" → "02.1"
|
||||
*/
|
||||
function extractPhaseNumFromDir(dirName: string): string | null {
|
||||
// Strip optional project_code prefix: "GSD-01-setup" → "01-setup"
|
||||
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
// Matches: digits + optional letter + optional decimal suffix, followed by '-' or end.
|
||||
// e.g. "02.1-hotfix" → "02.1", "01-setup" → "01"
|
||||
const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build the new directory name from old name and new phase ID.
|
||||
* old: "01-setup" newId: "1-02" projectCode: "GSD" → "GSD-01-02-setup"
|
||||
* old: "01-setup" newId: "1-02" projectCode: null → "01-02-setup"
|
||||
* old: "GSD-01-setup" newId: "1-02" projectCode: "GSD" → "GSD-01-02-setup"
|
||||
*/
|
||||
function buildNewDirName(oldDirName: string, newId: string, projectCode: string | null): string {
|
||||
// Strip existing project_code prefix
|
||||
const stripped = oldDirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
|
||||
// Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1)
|
||||
const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i);
|
||||
const slug = slugMatch ? slugMatch[1] : stripped;
|
||||
|
||||
// Build M-NN prefix (zero-pad both parts)
|
||||
const [milestoneStr, subStr] = newId.split('-');
|
||||
const milestoneInt = parseInt(milestoneStr, 10);
|
||||
const paddedMilestone = String(milestoneInt).padStart(2, '0');
|
||||
const newBase = slug ? `${paddedMilestone}-${subStr}-${slug}` : `${paddedMilestone}-${subStr}`;
|
||||
|
||||
return projectCode ? `${projectCode}-${newBase}` : newBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read project_code from config.json if present.
|
||||
*/
|
||||
function readProjectCode(configPath: string): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return typeof parsed['project_code'] === 'string' ? parsed['project_code'] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── computeMigrationPlan ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute a migration plan without touching the filesystem.
|
||||
*/
|
||||
function computeMigrationPlan(cwd: string, options: Record<string, unknown> = {}): MigrationPlan {
|
||||
void options;
|
||||
const pDir = planningDir(cwd);
|
||||
const roadmapPath = path.join(pDir, 'ROADMAP.md');
|
||||
const configPath = path.join(pDir, 'config.json');
|
||||
const phasesDir = path.join(pDir, 'phases');
|
||||
|
||||
// ── Check config for existing convention ─────────────────────────────────
|
||||
let configData: Record<string, unknown> = {};
|
||||
try {
|
||||
configData = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record<string, unknown>;
|
||||
} catch { /* config may not exist */ }
|
||||
|
||||
if (configData['phase_id_convention'] === 'milestone-prefixed') {
|
||||
return { alreadyMigrated: true, phases: [], roadmapEdits: [], crossRefEdits: [] };
|
||||
}
|
||||
|
||||
const projectCode = typeof configData['project_code'] === 'string' ? configData['project_code'] : null;
|
||||
|
||||
// ── Read ROADMAP.md ───────────────────────────────────────────────────────
|
||||
let roadmapContent = '';
|
||||
try {
|
||||
roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(`ROADMAP.md not found at ${roadmapPath}`);
|
||||
}
|
||||
|
||||
const lines = roadmapContent.split('\n');
|
||||
const parsedPhases = parseRoadmapPhases(lines);
|
||||
|
||||
// Check for any already-migrated headings
|
||||
const hasAnyMigrated = parsedPhases.some(e => e.alreadyMigrated);
|
||||
if (hasAnyMigrated) {
|
||||
return { alreadyMigrated: true, phases: [], roadmapEdits: [], crossRefEdits: [] };
|
||||
}
|
||||
|
||||
const legacyPhases = parsedPhases.filter(e => !e.alreadyMigrated);
|
||||
const idMapping = assignSubIndices(legacyPhases);
|
||||
|
||||
// Secondary lookup: (milestoneInt, normalizedLegacyNum) → newId
|
||||
// Used for directory renames and checklist rewrites where line position is unknown.
|
||||
// For simplicity, each milestone gets its own Map from legacy num → newId.
|
||||
const milestoneIdMap = new Map<number, Map<string, string>>(); // milestoneInt → Map<normalizedLegacyNum, newId>
|
||||
for (const [, entry] of idMapping) {
|
||||
if (!milestoneIdMap.has(entry.milestoneInt)) {
|
||||
milestoneIdMap.set(entry.milestoneInt, new Map<string, string>());
|
||||
}
|
||||
const mMap = milestoneIdMap.get(entry.milestoneInt)!;
|
||||
const legacyNum = entry.legacyPhaseNum;
|
||||
// Register integer forms (covers plain numeric and letter-suffix IDs)
|
||||
const intPart = parseInt(legacyNum, 10);
|
||||
const paddedLegacy = String(intPart).padStart(2, '0');
|
||||
const unpaddedLegacy = String(intPart);
|
||||
mMap.set(paddedLegacy, entry.newId);
|
||||
mMap.set(unpaddedLegacy, entry.newId);
|
||||
// Also register the original form and padded-integer+decimal form
|
||||
// so decimal IDs like "2.1" / "02.1" round-trip correctly.
|
||||
mMap.set(legacyNum, entry.newId);
|
||||
const dotIdx = legacyNum.indexOf('.');
|
||||
if (dotIdx !== -1) {
|
||||
const decimalSuffix = legacyNum.slice(dotIdx); // e.g. ".1"
|
||||
mMap.set(paddedLegacy + decimalSuffix, entry.newId);
|
||||
mMap.set(unpaddedLegacy + decimalSuffix, entry.newId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read existing phase directories ───────────────────────────────────────
|
||||
let existingDirs: string[] = [];
|
||||
try {
|
||||
existingDirs = fs.readdirSync(phasesDir).filter(d => {
|
||||
try {
|
||||
return fs.statSync(path.join(phasesDir, d)).isDirectory();
|
||||
} catch { return false; }
|
||||
});
|
||||
} catch { /* phases dir may not exist */ }
|
||||
|
||||
// ── Build phase rename pairs ───────────────────────────────────────────────
|
||||
// Flat ordered list of (legacyPhaseNum, newId) in ROADMAP order, for dir matching.
|
||||
const orderedMappings = [...idMapping.values()].map(e => ({
|
||||
legacyPhaseNum: e.legacyPhaseNum,
|
||||
newId: e.newId,
|
||||
milestoneInt: e.milestoneInt,
|
||||
_used: false,
|
||||
}));
|
||||
|
||||
// Note: if the same legacy phase number appears in multiple milestones (the exact legacy
|
||||
// ambiguity this tool is designed to resolve), directories are matched in ROADMAP document
|
||||
// order — the first ROADMAP occurrence of a given number claims the first matching disk dir.
|
||||
// This is the only unambiguous assignment strategy for flat dirs that carry no milestone
|
||||
// context. The dry-run output shows the complete rename plan so users can review before
|
||||
// applying with --apply.
|
||||
|
||||
const phases: PhaseRename[] = [];
|
||||
for (const dirName of existingDirs) {
|
||||
const phaseNum = extractPhaseNumFromDir(dirName);
|
||||
if (!phaseNum) continue;
|
||||
|
||||
const intPart = parseInt(phaseNum, 10);
|
||||
const paddedPhaseNum = String(intPart).padStart(2, '0');
|
||||
const unpaddedPhaseNum = String(intPart);
|
||||
// For decimal IDs like "02.1", also try "2.1"
|
||||
const dotIdx = phaseNum.indexOf('.');
|
||||
const decimalUnpadded = dotIdx !== -1 ? unpaddedPhaseNum + phaseNum.slice(dotIdx) : null;
|
||||
|
||||
// Find the first unused mapping whose legacy number matches (exact, padded, unpadded, or decimal)
|
||||
const found = orderedMappings.find(m => !m._used && (
|
||||
m.legacyPhaseNum === phaseNum ||
|
||||
m.legacyPhaseNum === paddedPhaseNum ||
|
||||
m.legacyPhaseNum === unpaddedPhaseNum ||
|
||||
(decimalUnpadded && m.legacyPhaseNum === decimalUnpadded)
|
||||
));
|
||||
if (!found) continue;
|
||||
found._used = true;
|
||||
|
||||
const newDirName = buildNewDirName(dirName, found.newId, projectCode);
|
||||
if (newDirName !== dirName) {
|
||||
phases.push({
|
||||
oldId: phaseNum,
|
||||
newId: found.newId,
|
||||
oldDir: dirName,
|
||||
newDir: newDirName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build ROADMAP.md line edits ────────────────────────────────────────────
|
||||
const roadmapEdits: RoadmapEdit[] = [];
|
||||
|
||||
for (const entry of legacyPhases) {
|
||||
// Use lineIndex as the canonical key (not legacyPhaseNum, which may collide across milestones)
|
||||
const mapping = idMapping.get(entry.lineIndex);
|
||||
if (!mapping) continue;
|
||||
|
||||
// Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name"
|
||||
const oldLine = lines[entry.lineIndex];
|
||||
const newLine = oldLine.replace(
|
||||
/^(#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*:)/i,
|
||||
`$1${mapping.newId}$2`
|
||||
);
|
||||
if (newLine !== oldLine) {
|
||||
roadmapEdits.push({ lineIndex: entry.lineIndex, from: oldLine, to: newLine });
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite checklist lines in ROADMAP.md — use milestone context to resolve collisions.
|
||||
let currentChecklistMilestone: number | null = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Track enclosing milestone section for context-aware lookup
|
||||
const milestoneHeadingMatch = line.match(MILESTONE_HEADING_RE);
|
||||
if (milestoneHeadingMatch) {
|
||||
currentChecklistMilestone = parseInt(milestoneHeadingMatch[1], 10);
|
||||
}
|
||||
|
||||
// Already in roadmapEdits? skip
|
||||
if (roadmapEdits.some(e => e.lineIndex === i)) continue;
|
||||
|
||||
// Match checklist items: "- [ ] **Phase N:**" or "- [x] Phase N:" (also decimal)
|
||||
const checklistMatch = line.match(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)(\d+[A-Z]?(?:\.\d+)*)(\s*[:\s*])/i);
|
||||
if (checklistMatch) {
|
||||
const legacyNum = checklistMatch[2];
|
||||
const cIntPart = parseInt(legacyNum, 10);
|
||||
const paddedLegacy = String(cIntPart).padStart(2, '0');
|
||||
const unpaddedLegacy = String(cIntPart);
|
||||
const cDotIdx = legacyNum.indexOf('.');
|
||||
const paddedLegacyDecimal = cDotIdx !== -1 ? paddedLegacy + legacyNum.slice(cDotIdx) : null;
|
||||
|
||||
// Prefer milestone-context lookup (avoids collision across milestones)
|
||||
let newId: string | undefined;
|
||||
if (currentChecklistMilestone !== null && milestoneIdMap.has(currentChecklistMilestone)) {
|
||||
const mMap = milestoneIdMap.get(currentChecklistMilestone)!;
|
||||
newId = mMap.get(legacyNum) || mMap.get(paddedLegacy) || mMap.get(unpaddedLegacy);
|
||||
if (!newId && paddedLegacyDecimal) newId = mMap.get(paddedLegacyDecimal);
|
||||
}
|
||||
if (!newId) {
|
||||
// Fallback: use ordered flat list (no milestone collision in this roadmap)
|
||||
const found = orderedMappings.find(m =>
|
||||
m.legacyPhaseNum === legacyNum ||
|
||||
m.legacyPhaseNum === paddedLegacy ||
|
||||
m.legacyPhaseNum === unpaddedLegacy ||
|
||||
(paddedLegacyDecimal && m.legacyPhaseNum === paddedLegacyDecimal)
|
||||
);
|
||||
if (found) newId = found.newId;
|
||||
}
|
||||
|
||||
if (newId) {
|
||||
const newLine = line.replace(
|
||||
/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*[:\s*])/i,
|
||||
`$1${newId}$2`
|
||||
);
|
||||
if (newLine !== line) {
|
||||
roadmapEdits.push({ lineIndex: i, from: line, to: newLine });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build cross-ref edits for STATE.md and PROJECT.md ────────────────────
|
||||
const crossRefEdits: CrossRefEdit[] = [];
|
||||
const crossRefFiles = ['STATE.md', 'PROJECT.md'];
|
||||
|
||||
for (const fileName of crossRefFiles) {
|
||||
const filePath = path.join(pDir, fileName);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Iterate using orderedMappings (ROADMAP order) — idMapping is now keyed by lineIndex.
|
||||
for (const m of orderedMappings) {
|
||||
const legacyNum = m.legacyPhaseNum;
|
||||
const xIntPart = parseInt(legacyNum, 10);
|
||||
const paddedNum = String(xIntPart).padStart(2, '0');
|
||||
const unpaddedNum = String(xIntPart);
|
||||
// Decimal suffix (e.g. ".1" from "2.1") — preserve in cross-ref patterns
|
||||
const xDotIdx = legacyNum.indexOf('.');
|
||||
const decimalSuffix = xDotIdx !== -1 ? legacyNum.slice(xDotIdx) : '';
|
||||
|
||||
// Rewrite project_code-prefixed references: "GSD-01-" → "GSD-01-02-"
|
||||
if (projectCode) {
|
||||
const [milestoneStr, subStr] = m.newId.split('-');
|
||||
const paddedMilestone = String(parseInt(milestoneStr, 10)).padStart(2, '0');
|
||||
const prefixedNew = `${projectCode}-${paddedMilestone}-${subStr}-`;
|
||||
// Try both padded and original forms as old prefix
|
||||
for (const oldNum of new Set([paddedNum + decimalSuffix, unpaddedNum + decimalSuffix, paddedNum, unpaddedNum])) {
|
||||
const prefixedOld = `${projectCode}-${oldNum}-`;
|
||||
if (fileContent.includes(prefixedOld)) {
|
||||
crossRefEdits.push({ file: fileName, from: prefixedOld, to: prefixedNew });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite prose references: "Phase 1:" → "Phase 1-01:", "Phase 2.1:" → "Phase 1-02:"
|
||||
const proseOldPatterns = new Set([
|
||||
`Phase ${unpaddedNum}${decimalSuffix}:`,
|
||||
`Phase ${paddedNum}${decimalSuffix}:`,
|
||||
`Phase ${legacyNum}:`,
|
||||
]);
|
||||
for (const proseOld of proseOldPatterns) {
|
||||
if (fileContent.includes(proseOld)) {
|
||||
const proseNew = `Phase ${m.newId}:`;
|
||||
crossRefEdits.push({ file: fileName, from: proseOld, to: proseNew });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
alreadyMigrated: false,
|
||||
phases,
|
||||
roadmapEdits,
|
||||
crossRefEdits,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── applyMigration ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply the migration plan computed by computeMigrationPlan().
|
||||
*
|
||||
* @param cwd
|
||||
* @param plan
|
||||
* @param options
|
||||
* @param options.dryRun - Print plan and exit without mutating. (default true)
|
||||
*/
|
||||
function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: boolean } = {}): ApplyMigrationResult {
|
||||
const dryRun = options.dryRun !== false; // default true
|
||||
|
||||
if (plan.alreadyMigrated) {
|
||||
return { alreadyMigrated: true };
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
|
||||
return { dryRun: true };
|
||||
}
|
||||
|
||||
// ── Real run: verify clean working tree ───────────────────────────────────
|
||||
let gitStatus: string;
|
||||
try {
|
||||
gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8' });
|
||||
} catch (err) {
|
||||
throw new Error(`git status failed: ${(err as Error).message}`);
|
||||
}
|
||||
if (gitStatus.trim().length > 0) {
|
||||
throw new Error('Working tree is dirty. Commit or stash changes before migrating.');
|
||||
}
|
||||
|
||||
// Capture HEAD sha for rollback
|
||||
let headSha: string;
|
||||
try {
|
||||
headSha = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8' }).trim();
|
||||
} catch (err) {
|
||||
throw new Error(`git rev-parse HEAD failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const pDir = planningDir(cwd);
|
||||
const phasesDir = path.join(pDir, 'phases');
|
||||
const roadmapPath = path.join(pDir, 'ROADMAP.md');
|
||||
const configPath = path.join(pDir, 'config.json');
|
||||
|
||||
const renamedDirs: string[] = [];
|
||||
const editedFiles: string[] = [];
|
||||
|
||||
try {
|
||||
// 1. Rename phase directories
|
||||
for (const phaseEntry of plan.phases) {
|
||||
const oldPath = path.join(phasesDir, phaseEntry.oldDir);
|
||||
const newPath = path.join(phasesDir, phaseEntry.newDir);
|
||||
if (fs.existsSync(oldPath)) {
|
||||
fs.renameSync(oldPath, newPath);
|
||||
renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Rewrite ROADMAP.md phase headings
|
||||
if (plan.roadmapEdits.length > 0) {
|
||||
const roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
|
||||
const lines = roadmapContent.split('\n');
|
||||
|
||||
// Sort edits by lineIndex to apply in order
|
||||
const sortedEdits = [...plan.roadmapEdits].sort((a, b) => a.lineIndex - b.lineIndex);
|
||||
for (const edit of sortedEdits) {
|
||||
if (lines[edit.lineIndex] === edit.from) {
|
||||
lines[edit.lineIndex] = edit.to;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, lines.join('\n'), 'utf8');
|
||||
editedFiles.push('ROADMAP.md');
|
||||
}
|
||||
|
||||
// 3. Rewrite cross-refs in STATE.md and PROJECT.md
|
||||
const crossRefsByFile = new Map<string, CrossRefEdit[]>();
|
||||
for (const edit of plan.crossRefEdits) {
|
||||
if (!crossRefsByFile.has(edit.file)) {
|
||||
crossRefsByFile.set(edit.file, []);
|
||||
}
|
||||
crossRefsByFile.get(edit.file)!.push(edit);
|
||||
}
|
||||
|
||||
for (const [fileName, edits] of crossRefsByFile) {
|
||||
const filePath = path.join(pDir, fileName);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
let changed = false;
|
||||
|
||||
for (const edit of edits) {
|
||||
if (content.includes(edit.from)) {
|
||||
// Replace all occurrences
|
||||
content = content.split(edit.from).join(edit.to);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
editedFiles.push(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update config.json: set phase_id_convention to 'milestone-prefixed'
|
||||
let configData: Record<string, unknown> = {};
|
||||
try {
|
||||
configData = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record<string, unknown>;
|
||||
} catch { /* config may not exist yet */ }
|
||||
|
||||
configData['phase_id_convention'] = 'milestone-prefixed';
|
||||
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8');
|
||||
editedFiles.push('config.json');
|
||||
|
||||
} catch (err) {
|
||||
// Rollback via git reset --hard + git clean
|
||||
try {
|
||||
execSync(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe' });
|
||||
execSync('git clean -fd .planning/phases/', { cwd, stdio: 'pipe' });
|
||||
} catch {
|
||||
// Swallow rollback errors — surface original error
|
||||
}
|
||||
throw new Error(`Migration failed (rolled back to ${headSha}): ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return { applied: true, renamedDirs, editedFiles };
|
||||
}
|
||||
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export = {
|
||||
computeMigrationPlan,
|
||||
applyMigration,
|
||||
};
|
||||
713
src/roadmap.cts
Normal file
713
src/roadmap.cts
Normal file
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* Roadmap — Roadmap parsing and update operations
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/roadmap.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.
|
||||
*/
|
||||
|
||||
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, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, phaseTokenMatches } = core;
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningPaths, withPlanningLock, findContextMdIn } = planningWorkspace;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import scanPhasePlans = require('./plan-scan.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter, parseMustHavesBlock } = frontmatter;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PhasePlansAndSummaries {
|
||||
planCount: number;
|
||||
summaryCount: number;
|
||||
hasContext: boolean;
|
||||
hasResearch: boolean;
|
||||
}
|
||||
|
||||
interface PhaseSearchResult {
|
||||
found: boolean;
|
||||
phase_number: string;
|
||||
phase_name: string;
|
||||
goal?: string | null;
|
||||
mode?: string | null;
|
||||
success_criteria?: string[];
|
||||
section?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface TruthValue {
|
||||
count: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// ─── coerceTruthToString ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary YAML scalar/object into a string for cross-cutting
|
||||
* truth aggregation. Handles:
|
||||
* - strings (passthrough)
|
||||
* - numbers / booleans (String() coercion — issue #2770: bare YAML ints
|
||||
* like `- 3` must be surfaced, not silently skipped)
|
||||
* - kv-shaped objects from parseMustHavesBlock continuation kv (issue
|
||||
* #2757) — extract the first meaningful string field
|
||||
*
|
||||
* Returns the empty string when no usable text can be derived; callers should
|
||||
* skip empty results.
|
||||
*/
|
||||
function coerceTruthToString(t: unknown): string {
|
||||
if (t === null || t === undefined) return '';
|
||||
if (typeof t === 'string') return t;
|
||||
if (typeof t === 'number' || typeof t === 'boolean' || typeof t === 'bigint') {
|
||||
return String(t);
|
||||
}
|
||||
if (typeof t === 'object') {
|
||||
// Prefer common title-bearing keys produced by parseMustHavesBlock
|
||||
for (const k of ['title', 'text', 'name', 'rule', 'path', 'provides']) {
|
||||
const v = (t as Record<string, unknown>)[k];
|
||||
if (typeof v === 'string' && v.trim()) return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ─── countPhasePlansAndSummaries ──────────────────────────────────────────────
|
||||
|
||||
function countPhasePlansAndSummaries(phaseDir: string): PhasePlansAndSummaries {
|
||||
const { planCount, summaryCount } = scanPhasePlans(phaseDir);
|
||||
// hasContext and hasResearch are not plan-scan concerns — read the directory
|
||||
// once and share the listing for all non-plan metadata that cmdRoadmapAnalyze needs.
|
||||
let phaseFiles: string[] = [];
|
||||
try { phaseFiles = fs.readdirSync(phaseDir); } catch { /* empty */ }
|
||||
return {
|
||||
planCount,
|
||||
summaryCount,
|
||||
hasContext: findContextMdIn(phaseFiles) !== null,
|
||||
hasResearch: phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
|
||||
};
|
||||
}
|
||||
|
||||
// `phaseMarkdownRegexSource` moved to core.cjs (#3537) so phase.cjs and
|
||||
// core.cjs itself can consume it without circular deps. Imported above.
|
||||
|
||||
// ─── searchPhaseInContent ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Search for a phase header (and its section) within the given content string.
|
||||
* Returns a result object if found (either a full match or a malformed_roadmap
|
||||
* checklist-only match), or null if the phase is not present at all.
|
||||
*/
|
||||
function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSearchResult | null {
|
||||
// Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
|
||||
const phasePattern = new RegExp(
|
||||
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
|
||||
'i'
|
||||
);
|
||||
const headerMatch = content.match(phasePattern);
|
||||
|
||||
if (!headerMatch) {
|
||||
// Fallback: check if phase exists in summary list but missing detail section
|
||||
const checklistPattern = new RegExp(
|
||||
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`,
|
||||
'i'
|
||||
);
|
||||
const checklistMatch = content.match(checklistPattern);
|
||||
|
||||
if (checklistMatch) {
|
||||
return {
|
||||
found: false,
|
||||
phase_number: phaseNum,
|
||||
phase_name: checklistMatch[1].trim(),
|
||||
error: 'malformed_roadmap',
|
||||
message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const phaseName = headerMatch[1].trim();
|
||||
const headerIndex = headerMatch.index!;
|
||||
|
||||
// Find the end of this section (next ## or ### phase header, or end of file).
|
||||
// Also matches bracket-prefixed headings like ### [GSD] Phase 2-01:.
|
||||
const restOfContent = content.slice(headerIndex);
|
||||
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w][\w.-]*/i);
|
||||
const sectionEnd = nextHeaderMatch
|
||||
? headerIndex + nextHeaderMatch.index!
|
||||
: content.length;
|
||||
|
||||
const section = content.slice(headerIndex, sectionEnd).trim();
|
||||
|
||||
// Extract goal if present (supports both **Goal:** and **Goal**: formats)
|
||||
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
const goal = goalMatch ? goalMatch[1].trim() : null;
|
||||
|
||||
// Mode: vertical-MVP slice mode flag. Lowercased + trimmed for canonical
|
||||
// comparison; unrecognized values are preserved verbatim for forward-compat.
|
||||
const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
const mode = modeMatch ? modeMatch[1].trim().toLowerCase() : null;
|
||||
|
||||
// Extract success criteria as structured array
|
||||
const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
|
||||
const success_criteria = criteriaMatch
|
||||
? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
found: true,
|
||||
phase_number: phaseNum,
|
||||
phase_name: phaseName,
|
||||
goal,
|
||||
mode,
|
||||
success_criteria,
|
||||
section,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
|
||||
|
||||
function cmdRoadmapGetPhase(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
const roadmapPath = planningPaths(cwd).roadmap;
|
||||
|
||||
if (!fs.existsSync(roadmapPath)) {
|
||||
output({ found: false, error: 'ROADMAP.md not found' }, raw, '');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
|
||||
|
||||
// #3599 two-pass: when the caller passes a project-code-prefixed ID like
|
||||
// `PROJ-42`, try the exact-prefixed heading first (`### Phase PROJ-42:`).
|
||||
// If no match, fall back to the #3537 padding-tolerant numeric form so
|
||||
// a `CK-01` query still resolves to `### Phase 1:`. Doing this at the
|
||||
// call site (instead of inside phaseMarkdownRegexSource) avoids the
|
||||
// alternation-order ambiguity where a bare `### Phase 42:` heading in
|
||||
// the same document would intercept the match for a `PROJ-42` query.
|
||||
const fullContent = stripShippedMilestones(rawContent);
|
||||
|
||||
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
|
||||
if (exactSource) {
|
||||
const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum);
|
||||
if (exactMilestone && !exactMilestone.error) {
|
||||
output(exactMilestone, raw, exactMilestone.section);
|
||||
return;
|
||||
}
|
||||
const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum);
|
||||
if (exactFull && !exactFull.error) {
|
||||
output(exactFull, raw, exactFull.section);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// #3537: padding-tolerant fragment so callers passing `02.7` still match
|
||||
// un-padded ROADMAP prose (`### Phase 2.7:`).
|
||||
const escapedPhase = phaseMarkdownRegexSource(phaseNum);
|
||||
|
||||
// Search the current milestone slice first, then fall back to full roadmap.
|
||||
// A malformed_roadmap result (checklist-only) from the milestone should not
|
||||
// block finding a full header match in the wider roadmap content.
|
||||
const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
|
||||
const result = (milestoneResult && !milestoneResult.error)
|
||||
? milestoneResult
|
||||
: searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
|
||||
|
||||
if (!result) {
|
||||
output({ found: false, phase_number: phaseNum }, raw, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
output(result, raw, '');
|
||||
return;
|
||||
}
|
||||
|
||||
output(result, raw, result.section);
|
||||
} catch (e) {
|
||||
error('Failed to read ROADMAP.md: ' + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── cmdRoadmapAnalyze ────────────────────────────────────────────────────────
|
||||
|
||||
function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
const roadmapPath = planningPaths(cwd).roadmap;
|
||||
|
||||
if (!fs.existsSync(roadmapPath)) {
|
||||
output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
const phasesDir = planningPaths(cwd).phases;
|
||||
|
||||
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)\s*:\s*([^\n]+)/gi;
|
||||
const phases: Array<{
|
||||
number: string;
|
||||
name: string;
|
||||
goal: string | null;
|
||||
mode: string | null;
|
||||
depends_on: string | null;
|
||||
plan_count: number;
|
||||
summary_count: number;
|
||||
has_context: boolean;
|
||||
has_research: boolean;
|
||||
disk_status: string;
|
||||
roadmap_complete: boolean;
|
||||
}> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
|
||||
const _phaseDirNames = (() => {
|
||||
try {
|
||||
return fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name);
|
||||
} catch { return []; }
|
||||
})();
|
||||
|
||||
while ((match = phasePattern.exec(content)) !== null) {
|
||||
const phaseNum = match[1];
|
||||
const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
|
||||
|
||||
// Extract goal from the section
|
||||
const sectionStart = match.index;
|
||||
const restOfContent = content.slice(sectionStart);
|
||||
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
|
||||
// recognised as section boundaries.
|
||||
const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+\d[\d.-]*/i);
|
||||
const sectionEnd = nextHeader ? sectionStart + nextHeader.index! : content.length;
|
||||
const section = content.slice(sectionStart, sectionEnd);
|
||||
|
||||
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
const goal = goalMatch ? goalMatch[1].trim() : null;
|
||||
|
||||
const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
const mode = modeMatch ? modeMatch[1].trim().toLowerCase() : null;
|
||||
|
||||
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
|
||||
|
||||
// Check completion on disk
|
||||
const normalized = normalizePhaseName(phaseNum);
|
||||
let diskStatus = 'no_directory';
|
||||
let planCount = 0;
|
||||
let summaryCount = 0;
|
||||
let hasContext = false;
|
||||
let hasResearch = false;
|
||||
|
||||
try {
|
||||
const dirMatch = _phaseDirNames.find(d => phaseTokenMatches(d, normalized));
|
||||
|
||||
if (dirMatch) {
|
||||
const counts = countPhasePlansAndSummaries(path.join(phasesDir, dirMatch));
|
||||
planCount = counts.planCount;
|
||||
summaryCount = counts.summaryCount;
|
||||
hasContext = counts.hasContext;
|
||||
hasResearch = counts.hasResearch;
|
||||
|
||||
if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete';
|
||||
else if (summaryCount > 0) diskStatus = 'partial';
|
||||
else if (planCount > 0) diskStatus = 'planned';
|
||||
else if (hasResearch) diskStatus = 'researched';
|
||||
else if (hasContext) diskStatus = 'discussed';
|
||||
else diskStatus = 'empty';
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Check ROADMAP checkbox status.
|
||||
// #3537: padding-tolerant fragment — the heading discovered above may use
|
||||
// a different padding than the summary-bullet checkbox below it (mixed
|
||||
// padding inside one ROADMAP is legal and seen in real projects).
|
||||
const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}[:\\s]`, 'i');
|
||||
const checkboxMatch = content.match(checkboxPattern);
|
||||
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
|
||||
|
||||
// If roadmap marks phase complete, trust that over disk file structure.
|
||||
// Phases completed before GSD tracking (or via external tools) may lack
|
||||
// the standard PLAN/SUMMARY pairs but are still done.
|
||||
if (roadmapComplete && diskStatus !== 'complete') {
|
||||
diskStatus = 'complete';
|
||||
}
|
||||
|
||||
phases.push({
|
||||
number: phaseNum,
|
||||
name: phaseName,
|
||||
goal,
|
||||
mode,
|
||||
depends_on,
|
||||
plan_count: planCount,
|
||||
summary_count: summaryCount,
|
||||
has_context: hasContext,
|
||||
has_research: hasResearch,
|
||||
disk_status: diskStatus,
|
||||
roadmap_complete: roadmapComplete,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract milestone info
|
||||
const milestones: Array<{ heading: string; version: string }> = [];
|
||||
const milestonePattern = /##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi;
|
||||
let mMatch: RegExpExecArray | null;
|
||||
while ((mMatch = milestonePattern.exec(content)) !== null) {
|
||||
milestones.push({
|
||||
heading: mMatch[1].trim(),
|
||||
version: 'v' + mMatch[2],
|
||||
});
|
||||
}
|
||||
|
||||
// Find current and next phase
|
||||
const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
|
||||
const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory' || p.disk_status === 'discussed' || p.disk_status === 'researched') || null;
|
||||
|
||||
// Aggregated stats
|
||||
const totalPlans = phases.reduce((sum, p) => sum + p.plan_count, 0);
|
||||
const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0);
|
||||
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
|
||||
|
||||
// Detect phases in summary list without detail sections (malformed ROADMAP)
|
||||
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi;
|
||||
const checklistPhases = new Set<string>();
|
||||
let checklistMatch: RegExpExecArray | null;
|
||||
while ((checklistMatch = checklistPattern.exec(content)) !== null) {
|
||||
checklistPhases.add(checklistMatch[1]);
|
||||
}
|
||||
const detailPhases = new Set(phases.map(p => p.number));
|
||||
const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p));
|
||||
|
||||
const result = {
|
||||
milestones,
|
||||
phases,
|
||||
phase_count: phases.length,
|
||||
completed_phases: completedPhases,
|
||||
total_plans: totalPlans,
|
||||
total_summaries: totalSummaries,
|
||||
progress_percent: totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0,
|
||||
current_phase: currentPhase ? currentPhase.number : null,
|
||||
next_phase: nextPhase ? nextPhase.number : null,
|
||||
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
|
||||
};
|
||||
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
// ─── cmdRoadmapUpdatePlanProgress ─────────────────────────────────────────────
|
||||
|
||||
function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | undefined, raw: boolean): void {
|
||||
if (!phaseNum) {
|
||||
error('phase number required for roadmap update-plan-progress');
|
||||
}
|
||||
|
||||
const roadmapPath = planningPaths(cwd).roadmap;
|
||||
|
||||
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
||||
if (!phaseInfo) {
|
||||
error(`Phase ${phaseNum} not found`);
|
||||
}
|
||||
|
||||
const planCount = phaseInfo!.plans.length;
|
||||
const summaryCount = phaseInfo!.summaries.length;
|
||||
|
||||
if (planCount === 0) {
|
||||
output({ updated: false, reason: 'No plans found', plan_count: 0, summary_count: 0 }, raw, 'no plans');
|
||||
return;
|
||||
}
|
||||
|
||||
const isComplete = summaryCount >= planCount;
|
||||
const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
if (!fs.existsSync(roadmapPath)) {
|
||||
output({ updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
||||
withPlanningLock(cwd, () => {
|
||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const phasePattern = phaseMarkdownRegexSource(phaseNum);
|
||||
|
||||
// Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
|
||||
const tableRowPattern = new RegExp(
|
||||
`^(\\|\\s*${phasePattern}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
|
||||
'im'
|
||||
);
|
||||
const dateField = isComplete ? ` ${today} ` : ' ';
|
||||
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
|
||||
const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
|
||||
if (cells.length === 5) {
|
||||
// 5-col: Phase | Milestone | Plans | Status | Completed
|
||||
cells[2] = ` ${summaryCount}/${planCount} `;
|
||||
cells[3] = ` ${status.padEnd(11)}`;
|
||||
cells[4] = dateField;
|
||||
} else if (cells.length === 4) {
|
||||
// 4-col: Phase | Plans | Status | Completed
|
||||
cells[1] = ` ${summaryCount}/${planCount} `;
|
||||
cells[2] = ` ${status.padEnd(11)}`;
|
||||
cells[3] = dateField;
|
||||
}
|
||||
return '|' + cells.join('|') + '|';
|
||||
});
|
||||
|
||||
// Update plan count in phase detail section
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i'
|
||||
);
|
||||
const planCountText = isComplete
|
||||
? `${summaryCount}/${planCount} plans complete`
|
||||
: `${summaryCount}/${planCount} plans executed`;
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
|
||||
|
||||
// If complete: check checkbox
|
||||
if (isComplete) {
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}[:\\s][^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
}
|
||||
|
||||
// Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md", "- [ ] 50-01:", or "- [ ] **50-01**")
|
||||
for (const summaryFile of phaseInfo!.summaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
|
||||
platformWriteSync(roadmapPath, roadmapContent);
|
||||
});
|
||||
output({
|
||||
updated: true,
|
||||
phase: phaseNum,
|
||||
plan_count: planCount,
|
||||
summary_count: summaryCount,
|
||||
status,
|
||||
complete: isComplete,
|
||||
}, raw, `${summaryCount}/${planCount} ${status}`);
|
||||
}
|
||||
|
||||
// ─── cmdRoadmapAnnotateDependencies ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Annotate the ROADMAP.md plan list for a phase with wave dependency notes
|
||||
* and a cross-cutting constraints subsection derived from PLAN frontmatter.
|
||||
*
|
||||
* Wave dependency notes: "Wave 2 — blocked on Wave 1 completion" inserted as
|
||||
* bold headers before each wave group in the plan checklist.
|
||||
*
|
||||
* Cross-cutting constraints: must_haves.truths strings that appear in 2+ plans
|
||||
* are surfaced in a "Cross-cutting constraints" subsection below the plan list.
|
||||
*
|
||||
* The operation is idempotent: if wave headers already exist in the section
|
||||
* the function returns without modifying the file.
|
||||
*/
|
||||
function cmdRoadmapAnnotateDependencies(cwd: string, phaseNum: string | null | undefined, raw: boolean): void {
|
||||
if (!phaseNum) {
|
||||
error('phase number required for roadmap annotate-dependencies');
|
||||
}
|
||||
|
||||
const roadmapPath = planningPaths(cwd).roadmap;
|
||||
if (!fs.existsSync(roadmapPath)) {
|
||||
output({ updated: false, reason: 'ROADMAP.md not found' }, raw, 'no roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
||||
if (!phaseInfo || phaseInfo.plans.length === 0) {
|
||||
output({ updated: false, reason: 'no plans found for phase', phase: phaseNum }, raw, 'no plans');
|
||||
return;
|
||||
}
|
||||
|
||||
// Read each PLAN.md and extract wave + must_haves.truths
|
||||
const planData: Array<{ planFile: string; planId: string; wave: number; truths: unknown[] }> = [];
|
||||
for (const planFile of phaseInfo.plans) {
|
||||
const planPath = path.join(path.resolve(cwd, phaseInfo.directory), planFile);
|
||||
try {
|
||||
const content = fs.readFileSync(planPath, 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
const wave = parseInt(fm.wave as string, 10) || 1;
|
||||
const planId = planFile.replace(/-PLAN\.md$/i, '').replace(/PLAN\.md$/i, '');
|
||||
const truths = parseMustHavesBlock(content, 'truths') || [];
|
||||
planData.push({ planFile, planId, wave, truths });
|
||||
} catch { /* skip unreadable plans */ }
|
||||
}
|
||||
|
||||
if (planData.length === 0) {
|
||||
output({ updated: false, reason: 'could not read plan frontmatter' }, raw, 'no frontmatter');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group plans by wave (sorted)
|
||||
const waveGroups = new Map<number, typeof planData>();
|
||||
for (const p of planData) {
|
||||
if (!waveGroups.has(p.wave)) waveGroups.set(p.wave, []);
|
||||
waveGroups.get(p.wave)!.push(p);
|
||||
}
|
||||
const waves = [...waveGroups.keys()].sort((a, b) => a - b);
|
||||
|
||||
// Find cross-cutting truths: appear in 2+ plans (de-duplicated, case-insensitive).
|
||||
//
|
||||
// Issue #2770: must **coerce, not skip**. A previous guard
|
||||
// `if (typeof t !== 'string') continue` silently dropped numeric scalars
|
||||
// (YAML ints like `- 3`) and kv-shaped truths (`- title: X`), so the
|
||||
// cross-cutting analysis lost real constraints rather than crashing on
|
||||
// `t.trim()`. We coerce primitives via `String(t)` and extract a sensible
|
||||
// string field from object-shaped items produced by parseMustHavesBlock's
|
||||
// continuation-kv path (issue #2757 produces those shapes for nested keys).
|
||||
const truthCounts = new Map<string, TruthValue>();
|
||||
for (const { truths } of planData) {
|
||||
const seen = new Set<string>();
|
||||
for (const t of truths) {
|
||||
const text = coerceTruthToString(t);
|
||||
if (!text) continue;
|
||||
const trimmed = text.trim();
|
||||
const key = trimmed.toLowerCase();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (!truthCounts.has(key)) truthCounts.set(key, { count: 0, text: trimmed });
|
||||
truthCounts.get(key)!.count++;
|
||||
}
|
||||
}
|
||||
const crossCuttingTruths = [...truthCounts.values()]
|
||||
.filter(v => v.count >= 2)
|
||||
.map(v => v.text);
|
||||
|
||||
// Patch ROADMAP.md
|
||||
let updated = false;
|
||||
withPlanningLock(cwd, () => {
|
||||
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
|
||||
// Find the phase section.
|
||||
// #3537: padding-tolerant fragment so the caller's resolved padded id
|
||||
// matches un-padded ROADMAP headings.
|
||||
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
|
||||
const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}:[^\\n]*)`, 'i');
|
||||
const phaseMatch = content.match(phaseHeaderPattern);
|
||||
if (!phaseMatch) return;
|
||||
|
||||
const phaseStart = phaseMatch.index!;
|
||||
const restAfterHeader = content.slice(phaseStart);
|
||||
const nextPhaseOffset = restAfterHeader.slice(1).search(/\n#{2,4}\s+Phase\s+\d/i);
|
||||
const phaseEnd = nextPhaseOffset >= 0 ? phaseStart + 1 + nextPhaseOffset : content.length;
|
||||
const phaseSection = content.slice(phaseStart, phaseEnd);
|
||||
|
||||
// Idempotency: skip if annotation markers already present
|
||||
if (
|
||||
/\*\*Wave\s+\d+/i.test(phaseSection) ||
|
||||
/\*\*Cross-cutting constraints:\*\*/i.test(phaseSection)
|
||||
) return;
|
||||
|
||||
// Find the Plans: section within the phase section.
|
||||
// #3691 Bug 1: `Plans:\s*\n` required no text after the colon, missing variants like
|
||||
// `Plans: 3 plans across 2 waves\n` or `**Plans:** 3 plans\n` (bold-wrapped).
|
||||
// `\*{0,2}Plans\*{0,2}:[^\n]*\n` accepts any text (or none) after the colon
|
||||
// and tolerates optional `**` markdown bold wrappers on either side.
|
||||
// The checklist group uses `+` (not `*`) so that a bold `**Plans:**` description
|
||||
// line with no immediately-following checklist items (e.g. a summary line above a
|
||||
// separate bare `Plans:` block) does not consume the match and prevent the actual
|
||||
// list from being found.
|
||||
// Review fix (F2): `(?:^|\n)` anchors the match to start-of-line so mid-line
|
||||
// occurrences like `***Plans:***` embedded in a sentence or `OpenPlans: foo`
|
||||
// do not trigger a false match. Groups 1 and 2 retain the same semantics.
|
||||
const plansBlockMatch = phaseSection.match(/(?:^|\n)(\*{0,2}Plans\*{0,2}:[^\n]*\n)((?:\s*-\s*\[[ x]\][^\n]*\n?)+)/i);
|
||||
if (!plansBlockMatch) return;
|
||||
|
||||
const plansHeader = plansBlockMatch[1];
|
||||
const existingList = plansBlockMatch[2];
|
||||
const listLines = existingList.split('\n').filter(l => /^\s*-\s*\[/.test(l));
|
||||
|
||||
if (listLines.length === 0) return;
|
||||
|
||||
// #314 perf: build a first-wins Map so per-line lookup is O(1) instead of O(plans).
|
||||
// First-wins mirrors .find() semantics: if the same planId appears more than once
|
||||
// in planData, the earlier entry wins — identical to what .find() returned before.
|
||||
const planById = new Map<string, typeof planData[number]>();
|
||||
for (const p of planData) {
|
||||
if (!planById.has(p.planId)) planById.set(p.planId, p);
|
||||
}
|
||||
|
||||
// Build wave-annotated plan list
|
||||
const linesByWave = new Map<number, string[]>();
|
||||
for (const line of listLines) {
|
||||
// Match plan ID from line: "- [ ] 01-01-PLAN.md — ..." or "- [ ] 01-01: ..."
|
||||
// #3691 Bug 3: `[\w-]+?` excluded `.`, so decimal IDs like `02.3-01` were captured
|
||||
// as `02` only and never matched planData entries. `[\w.-]+?` preserves the
|
||||
// terminating alternation (`-PLAN.md|.md|:|\s—`) as the boundary anchor.
|
||||
const idMatch = line.match(/\[\s*[x ]\s*\]\s*([\w.-]+?)(?:-PLAN\.md|\.md|:|\s—)/i);
|
||||
const planId = idMatch ? idMatch[1] : null;
|
||||
// Review fix (F3): reject malformed IDs that start with `.`, contain consecutive
|
||||
// dots, or otherwise violate the `^\w[\w.-]*$` contract. A leading-dot ID
|
||||
// (e.g. `.invalid-PLAN.md`) would silently default to wave 1 — defensively
|
||||
// skip the line instead so corrupted ROADMAP entries don't corrupt wave layout.
|
||||
if (planId && !/^\w[\w.-]*$/.test(planId)) continue;
|
||||
const planEntry = planId ? (planById.get(planId) || null) : null;
|
||||
const wave = planEntry ? planEntry.wave : 1;
|
||||
if (!linesByWave.has(wave)) linesByWave.set(wave, []);
|
||||
linesByWave.get(wave)!.push(line);
|
||||
}
|
||||
|
||||
const annotatedLines: string[] = [];
|
||||
const sortedWaves = [...linesByWave.keys()].sort((a, b) => a - b);
|
||||
for (let i = 0; i < sortedWaves.length; i++) {
|
||||
const w = sortedWaves[i];
|
||||
const waveLines = linesByWave.get(w)!;
|
||||
if (sortedWaves.length > 1) {
|
||||
const dep = i > 0 ? ` *(blocked on Wave ${sortedWaves[i - 1]} completion)*` : '';
|
||||
annotatedLines.push(`**Wave ${w}**${dep}`);
|
||||
}
|
||||
annotatedLines.push(...waveLines);
|
||||
if (i < sortedWaves.length - 1) annotatedLines.push('');
|
||||
}
|
||||
|
||||
// Append cross-cutting constraints subsection if any found
|
||||
if (crossCuttingTruths.length > 0) {
|
||||
annotatedLines.push('');
|
||||
annotatedLines.push('**Cross-cutting constraints:**');
|
||||
for (const t of crossCuttingTruths) {
|
||||
annotatedLines.push(`- ${t}`);
|
||||
}
|
||||
}
|
||||
|
||||
const newListBlock = annotatedLines.join('\n') + '\n';
|
||||
const newPhaseSection = phaseSection.replace(
|
||||
plansBlockMatch[0],
|
||||
plansHeader + newListBlock
|
||||
);
|
||||
|
||||
const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);
|
||||
if (nextContent === content) return;
|
||||
platformWriteSync(roadmapPath, nextContent);
|
||||
updated = true;
|
||||
});
|
||||
|
||||
output({
|
||||
updated,
|
||||
phase: phaseNum,
|
||||
waves: waves.length,
|
||||
cross_cutting_constraints: crossCuttingTruths.length,
|
||||
}, raw, updated ? `annotated ${waves.length} wave(s), ${crossCuttingTruths.length} constraint(s)` : 'skipped (already annotated or no plan list)');
|
||||
}
|
||||
|
||||
export = {
|
||||
cmdRoadmapGetPhase,
|
||||
cmdRoadmapAnalyze,
|
||||
cmdRoadmapUpdatePlanProgress,
|
||||
cmdRoadmapAnnotateDependencies,
|
||||
};
|
||||
322
src/runtime-artifact-layout.cts
Normal file
322
src/runtime-artifact-layout.cts
Normal file
@@ -0,0 +1,322 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Runtime artifact layout module — resolves the artifact directory shapes
|
||||
* (commands, agents, skills) for each supported runtime.
|
||||
*
|
||||
* grok is intentionally absent: it is in runtime-homes.cjs but not wired
|
||||
* here. The TypeError on unknown runtime is the loud-fail signal that a
|
||||
* runtime was added to the homes list without a layout entry.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/runtime-artifact-layout.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';
|
||||
import fs from 'node:fs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installProfiles = require('./install-profiles.cjs');
|
||||
const {
|
||||
stageSkillsForProfile,
|
||||
stageAgentsForProfile,
|
||||
stageSkillsForRuntimeAsSkills,
|
||||
} = installProfiles;
|
||||
|
||||
// In .cts (CommonJS output) files, `require` is available as a global.
|
||||
const _require: NodeRequire = require;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy installer exports (avoids GSD_TEST_MODE env mutation at module load)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InstallExports {
|
||||
readGsdCommandNames: () => string[];
|
||||
[converterName: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bin/install.js exports in a test-safe way.
|
||||
* Sets GSD_TEST_MODE only for the duration of the require() call and only if
|
||||
* it was not already set, restoring the original value in a finally block so
|
||||
* the module-level environment is never permanently mutated.
|
||||
*/
|
||||
function loadInstallExports(): InstallExports {
|
||||
const savedTestMode = process.env['GSD_TEST_MODE'];
|
||||
if (savedTestMode === undefined) process.env['GSD_TEST_MODE'] = '1';
|
||||
try {
|
||||
return _require('../../../bin/install.js') as InstallExports;
|
||||
} finally {
|
||||
if (savedTestMode === undefined) delete process.env['GSD_TEST_MODE'];
|
||||
else process.env['GSD_TEST_MODE'] = savedTestMode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache after first successful load. */
|
||||
let _installExports: InstallExports | null = null;
|
||||
function getInstallExports(): InstallExports {
|
||||
if (!_installExports) _installExports = loadInstallExports();
|
||||
return _installExports;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ArtifactKindName = 'commands' | 'agents' | 'skills';
|
||||
|
||||
// Mirrors the (unexported) ResolvedProfile in install-profiles.cts.
|
||||
// Must stay in sync if that shape changes.
|
||||
interface ResolvedProfile {
|
||||
name: string;
|
||||
skills: Set<string> | '*';
|
||||
agents: Set<string>;
|
||||
}
|
||||
|
||||
interface ArtifactKind {
|
||||
kind: ArtifactKindName;
|
||||
destSubpath: string;
|
||||
prefix: string;
|
||||
stage: (resolvedProfile: ResolvedProfile) => string;
|
||||
}
|
||||
|
||||
interface Layout {
|
||||
runtime: string;
|
||||
configDir: string;
|
||||
kinds: ArtifactKind[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source root finders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Locate the GSD commands/gsd source directory.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If runtimeConfigDir provided, check <runtimeConfigDir>/.gsd-source marker.
|
||||
* 2. Walk up from __dirname using path.dirname (no literal .. segments).
|
||||
* 3. Throw a descriptive error if neither succeeds.
|
||||
*/
|
||||
function findInstallSourceRoot(runtimeConfigDir?: string): string {
|
||||
// Step 1: marker check
|
||||
if (runtimeConfigDir) {
|
||||
const markerPath = path.join(runtimeConfigDir, '.gsd-source');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
try {
|
||||
const src = fs.readFileSync(markerPath, 'utf8').trim();
|
||||
if (src && fs.existsSync(src)) return src;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: walk up from __dirname
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const candidate = path.join(dir, 'commands', 'gsd');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
throw new Error(`findInstallSourceRoot: could not locate commands/gsd from ${__dirname}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the GSD agents source directory.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If runtimeConfigDir provided, check <runtimeConfigDir>/.gsd-source marker.
|
||||
* 2. Walk up from __dirname using path.dirname (no literal .. segments).
|
||||
* 3. Throw a descriptive error if neither succeeds.
|
||||
*/
|
||||
function findAgentsSourceRoot(runtimeConfigDir?: string): string {
|
||||
// Step 1: marker check
|
||||
if (runtimeConfigDir) {
|
||||
const markerPath = path.join(runtimeConfigDir, '.gsd-source');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
try {
|
||||
const src = fs.readFileSync(markerPath, 'utf8').trim();
|
||||
if (src && fs.existsSync(src)) {
|
||||
// Marker points to commands/gsd; agents/ is a sibling of commands/
|
||||
const agentsCandidate = path.resolve(path.dirname(src), '..', 'agents');
|
||||
if (fs.existsSync(agentsCandidate)) return agentsCandidate;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: walk up from __dirname
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const candidate = path.join(dir, 'agents');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
throw new Error(`findAgentsSourceRoot: could not locate agents/ from ${__dirname}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allowlisted runtimes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALLOWED_RUNTIMES = new Set([
|
||||
'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity',
|
||||
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy',
|
||||
'cline', 'opencode', 'kilo',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout table builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function commandsKind(destSubpath: string, prefix: string, configDir: string): ArtifactKind {
|
||||
return {
|
||||
kind: 'commands',
|
||||
destSubpath,
|
||||
prefix,
|
||||
stage: (resolved) => stageSkillsForProfile(findInstallSourceRoot(configDir), resolved),
|
||||
};
|
||||
}
|
||||
|
||||
function agentsKind(destSubpath: string, prefix: string, configDir: string): ArtifactKind {
|
||||
return {
|
||||
kind: 'agents',
|
||||
destSubpath,
|
||||
prefix,
|
||||
stage: (resolved) => stageAgentsForProfile(findAgentsSourceRoot(configDir), resolved),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a skills kind descriptor.
|
||||
*
|
||||
* @param destSubpath
|
||||
* @param prefix
|
||||
* @param converterName name of converter function in bin/install.js exports
|
||||
* @param runtime canonical runtime ID (gates Hermes/Qwen branding in converter)
|
||||
* @param configDir runtime config dir (for .gsd-source marker resolution)
|
||||
*/
|
||||
function skillsKind(
|
||||
destSubpath: string,
|
||||
prefix: string,
|
||||
converterName: string,
|
||||
runtime: string,
|
||||
configDir: string,
|
||||
): ArtifactKind {
|
||||
return {
|
||||
kind: 'skills',
|
||||
destSubpath,
|
||||
prefix,
|
||||
stage: (resolved) => {
|
||||
const installExports = getInstallExports();
|
||||
const realConverter = installExports[converterName] as (content: string, skillName: string, runtime: string, cmdNames: string[]) => string;
|
||||
// Compute cmdNames once per stage call for performance (#3583).
|
||||
// Extra args are ignored by converters that don't need runtime/cmdNames.
|
||||
const cmdNames = installExports.readGsdCommandNames();
|
||||
const wrappedConverter = (content: string, skillName: string): string =>
|
||||
realConverter(content, skillName, runtime, cmdNames);
|
||||
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the artifact layout for a given runtime and config directory.
|
||||
*/
|
||||
function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: 'local' | 'global' = 'global'): Layout {
|
||||
if (typeof configDir !== 'string' || configDir === '') {
|
||||
throw new TypeError('configDir must be a non-empty string');
|
||||
}
|
||||
if (scope !== 'local' && scope !== 'global') {
|
||||
throw new TypeError('scope must be "local" or "global"');
|
||||
}
|
||||
if (!ALLOWED_RUNTIMES.has(runtime)) {
|
||||
throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
|
||||
}
|
||||
|
||||
let kinds: ArtifactKind[];
|
||||
switch (runtime) {
|
||||
case 'claude':
|
||||
if (scope === 'local') {
|
||||
kinds = [
|
||||
commandsKind('commands/gsd', 'gsd-', configDir),
|
||||
agentsKind('agents', 'gsd-', configDir),
|
||||
];
|
||||
} else {
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir)];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cursor':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir)];
|
||||
break;
|
||||
|
||||
case 'gemini':
|
||||
kinds = [commandsKind('commands/gsd', 'gsd-', configDir)];
|
||||
break;
|
||||
|
||||
case 'codex':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodexSkill', 'codex', configDir)];
|
||||
break;
|
||||
|
||||
case 'copilot':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCopilotSkill', 'copilot', configDir)];
|
||||
break;
|
||||
|
||||
case 'antigravity':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir)];
|
||||
break;
|
||||
|
||||
case 'windsurf':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToWindsurfSkill', 'windsurf', configDir)];
|
||||
break;
|
||||
|
||||
case 'augment':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir)];
|
||||
break;
|
||||
|
||||
case 'trae':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir)];
|
||||
break;
|
||||
|
||||
case 'qwen':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir)];
|
||||
break;
|
||||
|
||||
case 'hermes':
|
||||
kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir)];
|
||||
break;
|
||||
|
||||
case 'codebuddy':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', 'codebuddy', configDir)];
|
||||
break;
|
||||
|
||||
case 'cline':
|
||||
kinds = [];
|
||||
break;
|
||||
|
||||
case 'opencode':
|
||||
kinds = [commandsKind('command', 'gsd-', configDir)];
|
||||
break;
|
||||
|
||||
case 'kilo':
|
||||
kinds = [commandsKind('command', 'gsd-', configDir)];
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
|
||||
}
|
||||
|
||||
return { runtime, configDir, kinds };
|
||||
}
|
||||
|
||||
export = { resolveRuntimeArtifactLayout, findInstallSourceRoot };
|
||||
180
src/runtime-homes.cts
Normal file
180
src/runtime-homes.cts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* runtime-homes.cts — canonical runtime → global config/skills directory mapping.
|
||||
*
|
||||
* Single source of truth for resolving the global config base directory and
|
||||
* the correct global skills directory for every GSD-supported runtime.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/runtime-homes.cjs
|
||||
* collapsed to a TypeScript source of truth. Behaviour is preserved
|
||||
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
||||
*
|
||||
* Runtime-specific notes:
|
||||
* hermes — GSD skills nest under skills/gsd/<skillName>/ (not the flat
|
||||
* skills/<skillName>/ layout used by all other runtimes).
|
||||
* cline — Rules-based; commands are embedded in .clinerules. Cline does
|
||||
* not use a skills/ directory. getGlobalSkillDir() returns null
|
||||
* for cline so the caller can emit an appropriate warning.
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Expand a leading ~ to os.homedir().
|
||||
*/
|
||||
function expandTilde(p: string): string {
|
||||
if (!p) return p;
|
||||
if (p.startsWith('~/') || p === '~') return path.join(os.homedir(), p.slice(1));
|
||||
return p;
|
||||
}
|
||||
|
||||
export interface ResolveAntigravityOpts {
|
||||
env?: Record<string, string | undefined>;
|
||||
home?: string;
|
||||
existsSync?: (p: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Antigravity global config dir across 1.x and 2.x layouts.
|
||||
*/
|
||||
export function resolveAntigravityGlobalDir(opts: ResolveAntigravityOpts = {}): string {
|
||||
const env: Record<string, string | undefined> = opts.env ?? process.env;
|
||||
const home = opts.home ?? os.homedir();
|
||||
const existsSyncFn = opts.existsSync ?? fs.existsSync;
|
||||
|
||||
if (env['ANTIGRAVITY_CONFIG_DIR']) return expandTilde(env['ANTIGRAVITY_CONFIG_DIR']);
|
||||
|
||||
const base = path.join(home, '.gemini');
|
||||
const candidates = [
|
||||
path.join(base, 'antigravity'),
|
||||
path.join(base, 'antigravity-ide'),
|
||||
path.join(base, 'antigravity-cli'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSyncFn(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return path.join(base, 'antigravity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the global config base directory for the given runtime.
|
||||
* Respects the same env-var overrides as bin/install.js getGlobalDir().
|
||||
*/
|
||||
export function getGlobalConfigDir(runtime: string): string {
|
||||
const home = os.homedir();
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
|
||||
switch (runtime) {
|
||||
// ── Claude Code ──────────────────────────────────────────────────────────
|
||||
case 'claude':
|
||||
return env['CLAUDE_CONFIG_DIR'] ? expandTilde(env['CLAUDE_CONFIG_DIR']) : path.join(home, '.claude');
|
||||
|
||||
// ── Cursor ───────────────────────────────────────────────────────────────
|
||||
case 'cursor':
|
||||
return env['CURSOR_CONFIG_DIR'] ? expandTilde(env['CURSOR_CONFIG_DIR']) : path.join(home, '.cursor');
|
||||
|
||||
// ── Gemini CLI ───────────────────────────────────────────────────────────
|
||||
case 'gemini':
|
||||
return env['GEMINI_CONFIG_DIR'] ? expandTilde(env['GEMINI_CONFIG_DIR']) : path.join(home, '.gemini');
|
||||
|
||||
// ── Codex ────────────────────────────────────────────────────────────────
|
||||
case 'codex':
|
||||
return env['CODEX_HOME'] ? expandTilde(env['CODEX_HOME']) : path.join(home, '.codex');
|
||||
|
||||
// ── Grok Build ───────────────────────────────────────────────────────────
|
||||
case 'grok':
|
||||
return env['GROK_AGENTS_HOME'] ? expandTilde(env['GROK_AGENTS_HOME']) : path.join(home, '.agents');
|
||||
|
||||
// ── Copilot (VS Code) ────────────────────────────────────────────────────
|
||||
case 'copilot':
|
||||
return env['COPILOT_CONFIG_DIR'] ? expandTilde(env['COPILOT_CONFIG_DIR']) : path.join(home, '.copilot');
|
||||
|
||||
// ── Antigravity ──────────────────────────────────────────────────────────
|
||||
case 'antigravity':
|
||||
return resolveAntigravityGlobalDir({ env, home });
|
||||
|
||||
// ── Windsurf ─────────────────────────────────────────────────────────────
|
||||
case 'windsurf':
|
||||
return env['WINDSURF_CONFIG_DIR']
|
||||
? expandTilde(env['WINDSURF_CONFIG_DIR'])
|
||||
: path.join(home, '.codeium', 'windsurf');
|
||||
|
||||
// ── Augment ──────────────────────────────────────────────────────────────
|
||||
case 'augment':
|
||||
return env['AUGMENT_CONFIG_DIR'] ? expandTilde(env['AUGMENT_CONFIG_DIR']) : path.join(home, '.augment');
|
||||
|
||||
// ── Trae ─────────────────────────────────────────────────────────────────
|
||||
case 'trae':
|
||||
return env['TRAE_CONFIG_DIR'] ? expandTilde(env['TRAE_CONFIG_DIR']) : path.join(home, '.trae');
|
||||
|
||||
// ── Qwen Code ────────────────────────────────────────────────────────────
|
||||
case 'qwen':
|
||||
return env['QWEN_CONFIG_DIR'] ? expandTilde(env['QWEN_CONFIG_DIR']) : path.join(home, '.qwen');
|
||||
|
||||
// ── Hermes Agent ─────────────────────────────────────────────────────────
|
||||
case 'hermes':
|
||||
return env['HERMES_HOME'] ? expandTilde(env['HERMES_HOME']) : path.join(home, '.hermes');
|
||||
|
||||
// ── CodeBuddy ────────────────────────────────────────────────────────────
|
||||
case 'codebuddy':
|
||||
return env['CODEBUDDY_CONFIG_DIR'] ? expandTilde(env['CODEBUDDY_CONFIG_DIR']) : path.join(home, '.codebuddy');
|
||||
|
||||
// ── Cline ────────────────────────────────────────────────────────────────
|
||||
case 'cline':
|
||||
return env['CLINE_CONFIG_DIR'] ? expandTilde(env['CLINE_CONFIG_DIR']) : path.join(home, '.cline');
|
||||
|
||||
// ── OpenCode (XDG) ───────────────────────────────────────────────────────
|
||||
case 'opencode': {
|
||||
if (env['OPENCODE_CONFIG_DIR']) return expandTilde(env['OPENCODE_CONFIG_DIR']);
|
||||
if (env['XDG_CONFIG_HOME']) return path.join(expandTilde(env['XDG_CONFIG_HOME']), 'opencode');
|
||||
return path.join(home, '.config', 'opencode');
|
||||
}
|
||||
|
||||
// ── Kilo (XDG) ───────────────────────────────────────────────────────────
|
||||
case 'kilo': {
|
||||
if (env['KILO_CONFIG_DIR']) return expandTilde(env['KILO_CONFIG_DIR']);
|
||||
if (env['XDG_CONFIG_HOME']) return path.join(expandTilde(env['XDG_CONFIG_HOME']), 'kilo');
|
||||
return path.join(home, '.config', 'kilo');
|
||||
}
|
||||
|
||||
// ── Default (Claude fallback) ─────────────────────────────────────────────
|
||||
default:
|
||||
return env['CLAUDE_CONFIG_DIR'] ? expandTilde(env['CLAUDE_CONFIG_DIR']) : path.join(home, '.claude');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the global skills base directory for the given runtime.
|
||||
* Most runtimes: <configDir>/skills
|
||||
* Hermes: <configDir>/skills/gsd (nested category layout — #2841)
|
||||
* Cline: null (rules-based, no skills directory)
|
||||
*/
|
||||
export function getGlobalSkillsBase(runtime: string): string | null {
|
||||
if (runtime === 'cline') return null;
|
||||
const configDir = getGlobalConfigDir(runtime);
|
||||
if (runtime === 'hermes') return path.join(configDir, 'skills', 'gsd');
|
||||
return path.join(configDir, 'skills');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full path to a specific skill's directory for the given runtime.
|
||||
* Returns null for runtimes that don't use a skills directory (cline).
|
||||
*/
|
||||
export function getGlobalSkillDir(runtime: string, skillName: string): string | null {
|
||||
const base = getGlobalSkillsBase(runtime);
|
||||
if (base === null) return null;
|
||||
return path.join(base, skillName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a human-readable display path for a global skill (for log messages).
|
||||
*/
|
||||
export function getGlobalSkillDisplayPath(runtime: string, skillName: string): string {
|
||||
const dir = getGlobalSkillDir(runtime, skillName);
|
||||
if (!dir) return `(${runtime} does not use a skills directory)`;
|
||||
// Replace homedir prefix with ~ for readability
|
||||
const home = os.homedir();
|
||||
return dir.startsWith(home) ? '~' + dir.slice(home.length) : dir;
|
||||
}
|
||||
89
src/runtime-name-policy.cts
Normal file
89
src/runtime-name-policy.cts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Runtime name policy — alias resolution and canonicalization for GSD runtime
|
||||
* identifiers (ADR-457 build-at-publish: the hand-written
|
||||
* bin/lib/runtime-name-policy.cjs collapsed to a TypeScript source of truth).
|
||||
* Behaviour is preserved byte-for-behaviour from the prior hand-written .cjs;
|
||||
* only types are added.
|
||||
*
|
||||
* Group C cross-import candidate: no bin/lib sibling dependencies; only
|
||||
* node:fs and node:path. Once this module is migrated, runtime-slash.cjs
|
||||
* (which imports runtime-name-policy.cjs) becomes the first true cross-import
|
||||
* proof candidate.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const FALLBACK_ALIASES: Readonly<Record<string, string[]>> = {
|
||||
claude: ['claude', 'claude-code', 'claude-cli'],
|
||||
opencode: ['opencode', 'open-code', 'opencode-cli'],
|
||||
kilo: ['kilo', 'kilo-cli'],
|
||||
gemini: ['gemini', 'gemini-cli', 'gemini-code'],
|
||||
codex: ['codex', 'codex-app', 'codex-cli', 'codex_desktop', 'codex-desktop'],
|
||||
copilot: ['copilot', 'copilot-cli', 'github-copilot'],
|
||||
antigravity: ['antigravity', 'antigravity-cli', 'antigravity-agent'],
|
||||
cursor: ['cursor', 'cursor-cli', 'cursor-nightly'],
|
||||
windsurf: ['windsurf', 'windsurf-cli', 'windsurf-next'],
|
||||
augment: ['augment', 'augment-code', 'augment-cli'],
|
||||
trae: ['trae', 'trae-cli'],
|
||||
qwen: ['qwen', 'qwen-code', 'qwen-cli'],
|
||||
hermes: ['hermes', 'hermes-agent', 'hermes-cli'],
|
||||
codebuddy: ['codebuddy', 'codebuddy-cli'],
|
||||
cline: ['cline', 'cline-cli'],
|
||||
};
|
||||
|
||||
function normalizeRuntimeToken(value: string): string {
|
||||
return String(value).trim().toLowerCase().replace(/[_\s]+/g, '-');
|
||||
}
|
||||
|
||||
function loadAliasManifest(): Record<string, string[]> {
|
||||
const manifestCandidates = [
|
||||
path.resolve(__dirname, '..', 'shared', 'runtime-aliases.manifest.json'),
|
||||
path.resolve(__dirname, '../../../sdk/shared/runtime-aliases.manifest.json'),
|
||||
];
|
||||
for (const manifestPath of manifestCandidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown;
|
||||
if (parsed && typeof parsed === 'object') return parsed as Record<string, string[]>;
|
||||
} catch {
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
return { ...FALLBACK_ALIASES };
|
||||
}
|
||||
|
||||
const aliasManifest = loadAliasManifest();
|
||||
const aliasToCanonical = new Map<string, string>();
|
||||
for (const [canonical, aliases] of Object.entries(aliasManifest)) {
|
||||
if (typeof canonical !== 'string' || !Array.isArray(aliases)) continue;
|
||||
aliasToCanonical.set(normalizeRuntimeToken(canonical), normalizeRuntimeToken(canonical));
|
||||
for (const alias of aliases) {
|
||||
if (typeof alias !== 'string') continue;
|
||||
aliasToCanonical.set(normalizeRuntimeToken(alias), normalizeRuntimeToken(canonical));
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalizeRuntimeName(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
return aliasToCanonical.get(normalizeRuntimeToken(value)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve runtime from a precedence list of candidate values.
|
||||
*
|
||||
* - First non-empty string candidate wins.
|
||||
* - Known aliases are canonicalized (codex-cli -> codex).
|
||||
* - Unknown values are normalized and returned (future-runtime tolerance).
|
||||
*
|
||||
* @param candidates - string candidates in precedence order
|
||||
* @returns the resolved runtime name, or null if no valid candidate
|
||||
*/
|
||||
export function resolveRuntimeNameFromCandidates(...candidates: unknown[]): string | null {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const normalized = normalizeRuntimeToken(candidate);
|
||||
if (!normalized) continue;
|
||||
return canonicalizeRuntimeName(normalized) || normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
112
src/runtime-slash.cts
Normal file
112
src/runtime-slash.cts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* runtime-slash.cts — single source of truth for emitting GSD slash-command
|
||||
* references in user-facing runtime output (recommended-actions JSON, persisted
|
||||
* ROADMAP.md entries, verify/validate fix hints, error messages, etc.).
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/runtime-slash.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
||||
* the prior hand-written .cjs; only types are added.
|
||||
*
|
||||
* Background: #2808 unified all GSD skill installs to register under the hyphen
|
||||
* form (`name: gsd-<cmd>`). The legacy colon form `/gsd:<cmd>` is no longer
|
||||
* routable by Claude Code skill installs, but ~50 runtime emissions in
|
||||
* bin/lib/*.cjs still hardcoded it (#3584). Codex installs need the shell-var
|
||||
* `$gsd-<cmd>` form. This module is the only place the runtime should decide
|
||||
* which shape to emit.
|
||||
*
|
||||
* - codex: $gsd-<cmd> (shell-var syntax)
|
||||
* - claude, cursor, opencode, kilo, etc.: /gsd-<cmd>
|
||||
*
|
||||
* The colon form is never emitted.
|
||||
*
|
||||
* Cross-import proof candidate (ADR-457): this is the first TS source that
|
||||
* imports a sibling TS-migrated module. The import specifier uses the .cjs
|
||||
* extension per nodenext convention; tsc resolves it to src/runtime-name-policy.cts.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { canonicalizeRuntimeName, resolveRuntimeNameFromCandidates } from './runtime-name-policy.cjs';
|
||||
|
||||
export function formatGsdSlash(commandName: unknown, runtime: unknown): unknown {
|
||||
if (typeof commandName !== 'string') return commandName;
|
||||
if (commandName === '') return commandName;
|
||||
|
||||
// Strip any existing leading prefix so the helper is idempotent and accepts
|
||||
// both legacy `/gsd:<name>` and canonical hyphen-form input (plus the bare
|
||||
// `gsd:<name>` shorthand and codex `$gsd-<name>` shell-var input).
|
||||
const stripped = commandName.replace(/^[/$]?gsd[-:]/i, '');
|
||||
// If the regex matched nothing (no prefix), the input is already a bare name.
|
||||
const bare = stripped === commandName ? commandName : stripped;
|
||||
// Defensive: a degenerate input like `/gsd:`, `gsd-`, or whitespace-only
|
||||
// normalizes to empty. Returning the original colon-form would re-emit the
|
||||
// deprecated shape that this module exists to suppress (#3584). Return an
|
||||
// empty string so callers see "no command" rather than the broken input.
|
||||
if (bare === '' || bare.trim() === '') return '';
|
||||
|
||||
// Split on the first whitespace so only the command token is rewritten —
|
||||
// anything after the first space is caller-supplied arguments (phase
|
||||
// numbers, --flags, --paths C:\\Users\\Me, etc.) that must round-trip
|
||||
// untouched. Codex lowercases only the command token; preserving the
|
||||
// argument tail prevents path/flag corruption on case-sensitive systems.
|
||||
const wsMatch = bare.match(/^(\S+)(\s[\s\S]*)?$/);
|
||||
const token = wsMatch ? wsMatch[1] : bare;
|
||||
const tail = wsMatch && wsMatch[2] ? wsMatch[2] : '';
|
||||
|
||||
const runtimeText = (typeof runtime === 'string' && runtime ? runtime : 'claude').toLowerCase();
|
||||
const rt = canonicalizeRuntimeName(runtimeText) || runtimeText;
|
||||
if (rt === 'codex') {
|
||||
// Codex skills are invoked as $gsd-<cmd> (shell-var syntax). The command
|
||||
// token is lowercased because shell-var identifiers are conventionally
|
||||
// lowercase; matches the convertCodexSlash() projection in bin/install.js.
|
||||
return `$gsd-${token.toLowerCase()}${tail}`;
|
||||
}
|
||||
return `/gsd-${token}${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective runtime for a project directory.
|
||||
*
|
||||
* process.env.GSD_RUNTIME > config.runtime > 'claude'
|
||||
*
|
||||
* Mirrors the precedence already used by profile-output.cjs and the rest of
|
||||
* the runtime resolution chain. Returns a lowercased string so downstream
|
||||
* comparisons can be case-blind.
|
||||
*
|
||||
* @param projectDir - path to the project directory, or null/undefined
|
||||
* @returns the resolved runtime name
|
||||
*/
|
||||
export function resolveRuntime(projectDir: string | null | undefined): string {
|
||||
const envRuntime = resolveRuntimeNameFromCandidates(process.env['GSD_RUNTIME']);
|
||||
if (envRuntime) return envRuntime;
|
||||
if (projectDir) {
|
||||
try {
|
||||
// Read config.json directly (not via loadConfig). loadConfig has a side
|
||||
// effect of normalizing and re-writing legacy keys back to disk, which
|
||||
// would mutate the project file just to read the runtime name. We only
|
||||
// need the literal `runtime:` value, so a plain JSON read is sufficient
|
||||
// and side-effect-free.
|
||||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && 'runtime' in parsed) {
|
||||
const configRuntime = resolveRuntimeNameFromCandidates((parsed as Record<string, unknown>)['runtime']);
|
||||
if (configRuntime) return configRuntime;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default — a missing/broken config must not crash
|
||||
// runtime output formatting.
|
||||
}
|
||||
}
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: format using the runtime resolved from a project directory.
|
||||
* Equivalent to `formatGsdSlash(name, resolveRuntime(projectDir))`.
|
||||
*/
|
||||
export function formatGsdSlashFor(projectDir: string | null | undefined, commandName: unknown): unknown {
|
||||
return formatGsdSlash(commandName, resolveRuntime(projectDir));
|
||||
}
|
||||
200
src/schema-detect.cts
Normal file
200
src/schema-detect.cts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Schema Drift Detection — detects schema-relevant file changes and verifies
|
||||
* that the appropriate database push command was executed during a phase
|
||||
* (ADR-457 build-at-publish: the hand-written bin/lib/schema-detect.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 module does not read the filesystem directly.
|
||||
*/
|
||||
|
||||
// ─── ORM Patterns ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface SchemaPattern {
|
||||
pattern: RegExp;
|
||||
orm: string;
|
||||
}
|
||||
|
||||
export const SCHEMA_PATTERNS: ReadonlyArray<SchemaPattern> = [
|
||||
{ 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 ──────────────────────────────────────
|
||||
|
||||
export interface OrmInfo {
|
||||
pushCommand: string;
|
||||
envHint: string;
|
||||
interactiveWarning: string | null;
|
||||
evidencePatterns: RegExp[];
|
||||
}
|
||||
|
||||
export const ORM_INFO: Readonly<Record<string, OrmInfo>> = {
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DetectSchemaFilesResult {
|
||||
detected: boolean;
|
||||
matches: string[];
|
||||
orms: string[];
|
||||
}
|
||||
|
||||
export function detectSchemaFiles(files: string[]): DetectSchemaFilesResult {
|
||||
const matches: string[] = [];
|
||||
const orms = new Set<string>();
|
||||
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],
|
||||
};
|
||||
}
|
||||
|
||||
export function detectSchemaOrm(ormName: string): OrmInfo | null {
|
||||
return ORM_INFO[ormName] || null;
|
||||
}
|
||||
|
||||
export interface CheckSchemaDriftOptions {
|
||||
skipCheck?: boolean;
|
||||
}
|
||||
|
||||
export interface CheckSchemaDriftResult {
|
||||
driftDetected: boolean;
|
||||
blocking: boolean;
|
||||
skipped?: boolean;
|
||||
schemaFiles: string[];
|
||||
orms: string[];
|
||||
unpushedOrms: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function checkSchemaDrift(
|
||||
changedFiles: string[],
|
||||
executionLog: string,
|
||||
options: CheckSchemaDriftOptions = {},
|
||||
): CheckSchemaDriftResult {
|
||||
const { skipCheck = false } = options;
|
||||
const detection = detectSchemaFiles(changedFiles);
|
||||
if (!detection.detected) {
|
||||
return {
|
||||
driftDetected: false,
|
||||
blocking: false,
|
||||
schemaFiles: [],
|
||||
orms: [],
|
||||
unpushedOrms: [],
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
const pushedOrms = new Set<string>();
|
||||
const unpushedOrms: string[] = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Suppress unused variable warning — pushedOrms tracks for conceptual clarity
|
||||
void pushedOrms;
|
||||
|
||||
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((x): x is string => x !== null)
|
||||
.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,
|
||||
};
|
||||
}
|
||||
35
src/secrets.cts
Normal file
35
src/secrets.cts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Secrets handling — masking convention for API keys and other
|
||||
* credentials managed via /gsd-settings-integrations (ADR-457 build-at-publish:
|
||||
* the hand-written bin/lib/secrets.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 module does not read the filesystem.
|
||||
*/
|
||||
|
||||
export const SECRET_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'brave_search',
|
||||
'firecrawl',
|
||||
'exa_search',
|
||||
]);
|
||||
|
||||
export function isSecretKey(keyPath: string): boolean {
|
||||
return SECRET_CONFIG_KEYS.has(keyPath);
|
||||
}
|
||||
|
||||
/** Scalar types that stringify meaningfully. */
|
||||
type MaskableValue = string | number | boolean | null | undefined;
|
||||
|
||||
export function maskSecret(value: MaskableValue): string {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return '(unset)';
|
||||
const s = String(value);
|
||||
if (s.length < 8)
|
||||
return '****';
|
||||
return '****' + s.slice(-4);
|
||||
}
|
||||
|
||||
export function maskIfSecret(keyPath: string, value: MaskableValue): MaskableValue {
|
||||
return isSecretKey(keyPath) ? maskSecret(value) : value;
|
||||
}
|
||||
447
src/security.cts
Normal file
447
src/security.cts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Security — Input validation, path traversal prevention, and prompt injection guards
|
||||
*
|
||||
* This module centralizes security checks for GSD tooling. Because GSD generates
|
||||
* markdown files that become LLM system prompts (agent instructions, workflow state,
|
||||
* phase plans), any user-controlled text that flows into these files is a potential
|
||||
* indirect prompt injection vector.
|
||||
*
|
||||
* Threat model:
|
||||
* 1. Path traversal: user-supplied file paths escape the project directory
|
||||
* 2. Prompt injection: malicious text in arguments/PRDs embeds LLM instructions
|
||||
* 3. Shell metacharacter injection: user text interpreted by shell
|
||||
* 4. JSON injection: malformed JSON crashes or corrupts state
|
||||
* 5. Regex DoS: crafted input causes catastrophic backtracking
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/security.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';
|
||||
|
||||
// ─── Path Traversal Prevention ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate that a file path resolves within an allowed base directory.
|
||||
* Prevents path traversal attacks via ../ sequences, symlinks, or absolute paths.
|
||||
*/
|
||||
export function validatePath(filePath: unknown, baseDir: unknown, opts: { allowAbsolute?: boolean } = {}): { safe: boolean; resolved: string; error?: string } {
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return { safe: false, resolved: '', error: 'Empty or invalid file path' };
|
||||
}
|
||||
if (!baseDir || typeof baseDir !== 'string') {
|
||||
return { safe: false, resolved: '', error: 'Empty or invalid base directory' };
|
||||
}
|
||||
if (filePath.includes('\0')) {
|
||||
return { safe: false, resolved: '', error: 'Path contains null bytes' };
|
||||
}
|
||||
let resolvedBase: string;
|
||||
try {
|
||||
resolvedBase = fs.realpathSync(path.resolve(baseDir));
|
||||
} catch {
|
||||
resolvedBase = path.resolve(baseDir);
|
||||
}
|
||||
let resolvedPath: string;
|
||||
if (path.isAbsolute(filePath)) {
|
||||
if (!opts.allowAbsolute) {
|
||||
return { safe: false, resolved: '', error: 'Absolute paths not allowed' };
|
||||
}
|
||||
resolvedPath = path.resolve(filePath);
|
||||
} else {
|
||||
resolvedPath = path.resolve(baseDir, filePath);
|
||||
}
|
||||
try {
|
||||
resolvedPath = fs.realpathSync(resolvedPath);
|
||||
} catch {
|
||||
const parentDir = path.dirname(resolvedPath);
|
||||
try {
|
||||
const realParent = fs.realpathSync(parentDir);
|
||||
resolvedPath = path.join(realParent, path.basename(resolvedPath));
|
||||
} catch {
|
||||
// Parent doesn't exist either — keep the resolved path as-is
|
||||
}
|
||||
}
|
||||
const normalizedBase = resolvedBase + path.sep;
|
||||
const normalizedPath = resolvedPath + path.sep;
|
||||
if (resolvedPath !== resolvedBase && !normalizedPath.startsWith(normalizedBase)) {
|
||||
return {
|
||||
safe: false,
|
||||
resolved: resolvedPath,
|
||||
error: `Path escapes allowed directory: ${resolvedPath} is outside ${resolvedBase}`,
|
||||
};
|
||||
}
|
||||
return { safe: true, resolved: resolvedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a file path and throw on traversal attempt.
|
||||
* Convenience wrapper around validatePath for use in CLI commands.
|
||||
*/
|
||||
export function requireSafePath(filePath: unknown, baseDir: unknown, label: string | null | undefined, opts: { allowAbsolute?: boolean } = {}): string {
|
||||
const result = validatePath(filePath, baseDir, opts);
|
||||
if (!result.safe) {
|
||||
throw new Error(`${label || 'Path'} validation failed: ${result.error}`);
|
||||
}
|
||||
return result.resolved;
|
||||
}
|
||||
|
||||
// ─── Prompt Injection Detection ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Patterns that indicate prompt injection attempts in user-supplied text.
|
||||
* These patterns catch common indirect prompt injection techniques where
|
||||
* an attacker embeds LLM instructions in text that will be read by an agent.
|
||||
*
|
||||
* Note: This is defense-in-depth — not a complete solution. The primary defense
|
||||
* is proper input/output boundaries in agent prompts.
|
||||
*/
|
||||
export const INJECTION_PATTERNS: RegExp[] = [
|
||||
// Direct instruction override attempts
|
||||
/ignore\s+(all\s+)?previous\s+instructions/i,
|
||||
/ignore\s+(all\s+)?above\s+instructions/i,
|
||||
/disregard\s+(all\s+)?previous/i,
|
||||
/forget\s+(all\s+)?(your\s+)?instructions/i,
|
||||
/override\s+(system|previous)\s+(prompt|instructions)/i,
|
||||
|
||||
// Role/identity manipulation
|
||||
/you\s+are\s+now\s+(?:a|an|the)\s+/i,
|
||||
/act\s+as\s+(?:a|an|the)\s+(?!plan|phase|wave)/i,
|
||||
/pretend\s+(?:you(?:'re| are)\s+|to\s+be\s+)/i,
|
||||
/from\s+now\s+on,?\s+you\s+(?:are|will|should|must)/i,
|
||||
|
||||
// System prompt extraction
|
||||
/(?:print|output|reveal|show|display|repeat)\s+(?:your\s+)?(?:system\s+)?(?:prompt|instructions)/i,
|
||||
/what\s+(?:are|is)\s+your\s+(?:system\s+)?(?:prompt|instructions)/i,
|
||||
|
||||
// Hidden instruction markers (XML/HTML tags that mimic system messages)
|
||||
// Note: <instructions> is excluded — GSD uses it as legitimate prompt structure
|
||||
// Requires > to close the tag (not just whitespace) to avoid matching generic types like Promise<User | null>
|
||||
/<\/?(?:system|assistant|human)>/i,
|
||||
/\[SYSTEM\]/i,
|
||||
/\[\/?(INST)\]/i,
|
||||
/<<\s*SYS\s*>>/i,
|
||||
|
||||
// Exfiltration attempts
|
||||
/(?:send|post|fetch|curl|wget)\s+(?:to|from)\s+https?:\/\//i,
|
||||
/(?:base64|btoa|encode)\s+(?:and\s+)?(?:send|exfiltrate|output)/i,
|
||||
|
||||
// Tool manipulation
|
||||
/(?:run|execute|call|invoke)\s+(?:the\s+)?(?:bash|shell|exec|spawn)\s+(?:tool|command)/i,
|
||||
];
|
||||
|
||||
// Explicit safe-list for data: MIME types that are benign in link targets.
|
||||
// Note: image/svg+xml is intentionally NOT in this list (SVG can host <script>).
|
||||
const DATA_URI_SAFE_MIME_RE = /^data:(image\/(png|jpe?g|gif|webp|bmp|ico|avif|heic)|font\/(woff2?|otf|ttf))(;[^,]*)?,/i;
|
||||
|
||||
interface MarkdownLinkPattern {
|
||||
pattern: RegExp;
|
||||
ruleId: string;
|
||||
safePredicate?: (line: string) => boolean;
|
||||
}
|
||||
|
||||
export const MARKDOWN_LINK_PATTERNS: MarkdownLinkPattern[] = [
|
||||
{
|
||||
pattern: /\]\(\s*javascript:/i,
|
||||
ruleId: 'MD-LINK-JS-SCHEME',
|
||||
},
|
||||
{
|
||||
pattern: /\]\(\s*data:/i,
|
||||
ruleId: 'MD-LINK-DATA-SCHEME',
|
||||
safePredicate: (line: string) => {
|
||||
const m = line.match(/\]\(\s*(data:[^)]*)/i);
|
||||
if (!m) return false;
|
||||
return DATA_URI_SAFE_MIME_RE.test(m[1]);
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /\]\(\s*https?:\/\/[^/\s]+:[^/@\s]+@/i,
|
||||
ruleId: 'MD-LINK-USERINFO',
|
||||
},
|
||||
{
|
||||
pattern: /[?&](token|access_token|id_token|refresh_token|api_key|apikey|secret|password|client_secret|code)=/i,
|
||||
ruleId: 'MD-LINK-TOKEN-IN-QUERY',
|
||||
},
|
||||
];
|
||||
|
||||
interface ObfuscationPatternEntry {
|
||||
pattern: RegExp;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const OBFUSCATION_PATTERN_ENTRIES: ObfuscationPatternEntry[] = [
|
||||
{
|
||||
pattern: /\b(\w\s){4,}\w\b/,
|
||||
message: 'Character-spacing obfuscation pattern detected (e.g. "i g n o r e")',
|
||||
},
|
||||
{
|
||||
pattern: /<\/?(system|human|assistant|user)\s*>/i,
|
||||
message: 'Delimiter injection pattern: <system>/<human>/<assistant>/<user> tag detected',
|
||||
},
|
||||
{
|
||||
pattern: /0x[0-9a-fA-F]{16,}/,
|
||||
message: 'Long hex sequence detected — possible encoded payload',
|
||||
},
|
||||
];
|
||||
|
||||
interface StructuredFinding {
|
||||
ruleId: string;
|
||||
file: string | undefined;
|
||||
line: number;
|
||||
match: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan text for potential prompt injection patterns.
|
||||
* Returns an array of findings (empty = clean).
|
||||
*/
|
||||
export function scanForInjection(text: unknown, opts: { strict?: boolean; file?: string } = {}): { clean: boolean; findings: string[]; structuredFindings: StructuredFinding[] } {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return { clean: true, findings: [], structuredFindings: [] };
|
||||
}
|
||||
|
||||
const findings: string[] = [];
|
||||
const structuredFindings: StructuredFinding[] = [];
|
||||
|
||||
for (const pattern of INJECTION_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
findings.push(`Matched injection pattern: ${pattern.source}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of OBFUSCATION_PATTERN_ENTRIES) {
|
||||
if (entry.pattern.test(text)) {
|
||||
findings.push(entry.message);
|
||||
}
|
||||
}
|
||||
|
||||
const lines = text.split('\n');
|
||||
for (const entry of MARKDOWN_LINK_PATTERNS) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const m = line.match(entry.pattern);
|
||||
if (!m) continue;
|
||||
if (entry.safePredicate && entry.safePredicate(line)) continue;
|
||||
const matchText = m[0];
|
||||
findings.push(`Matched markdown link pattern [${entry.ruleId}]: ${matchText}`);
|
||||
structuredFindings.push({
|
||||
ruleId: entry.ruleId,
|
||||
file: opts.file,
|
||||
line: i + 1,
|
||||
match: matchText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.strict) {
|
||||
// Check for suspicious Unicode that could hide instructions
|
||||
// (zero-width chars, RTL override, homoglyph attacks)
|
||||
if (/[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD]/.test(text)) {
|
||||
findings.push('Contains suspicious zero-width or invisible Unicode characters');
|
||||
}
|
||||
|
||||
// Layer 1: Unicode tag block U+E0000–E007F (2025 supply-chain attack vector)
|
||||
// These characters are invisible and can embed hidden instructions
|
||||
if (/[\uDB40\uDC00-\uDB40\uDC7F]/u.test(text) || /[\u{E0000}-\u{E007F}]/u.test(text)) {
|
||||
findings.push('Contains Unicode tag block characters (U+E0000–E007F) — invisible instruction injection vector');
|
||||
}
|
||||
|
||||
// Check for extremely long strings that could be prompt stuffing.
|
||||
// Normalize CRLF → LF before measuring so Windows checkouts don't inflate the count.
|
||||
const normalizedLength = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').length;
|
||||
if (normalizedLength > 50000) {
|
||||
findings.push(`Suspicious text length: ${normalizedLength} chars (potential prompt stuffing)`);
|
||||
}
|
||||
}
|
||||
|
||||
return { clean: findings.length === 0, findings, structuredFindings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize text that will be embedded in agent prompts or planning documents.
|
||||
* Strips known injection markers while preserving legitimate content.
|
||||
*/
|
||||
export function sanitizeForPrompt(text: unknown): string {
|
||||
if (!text || typeof text !== 'string') return text as string;
|
||||
|
||||
let sanitized = text;
|
||||
|
||||
// Strip zero-width characters that could hide instructions
|
||||
sanitized = sanitized.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD]/g, '');
|
||||
|
||||
// Neutralize XML/HTML tags that mimic system boundaries
|
||||
// Note: <instructions> is excluded — GSD uses it as legitimate prompt structure
|
||||
sanitized = sanitized.replace(/<(\/?)\s*(?:system|assistant|human|user)\s*>/gi,
|
||||
(_, slash: string) => `<${slash || ''}system-text>`);
|
||||
|
||||
// Neutralize [SYSTEM] / [INST] / [/INST] markers
|
||||
sanitized = sanitized.replace(/\[(\/?)(SYSTEM|INST)\]/gi, (_, slash: string, tag: string) => `[${slash}${tag.toUpperCase()}-TEXT]`);
|
||||
|
||||
// Neutralize <<SYS>> and <</SYS>> markers (Llama-style delimiters)
|
||||
sanitized = sanitized.replace(/<<\/?\s*SYS\s*>>/gi, '«SYS-TEXT»');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize text that will be displayed back to the user.
|
||||
* Removes protocol-like leak markers that should never surface in checkpoints.
|
||||
*/
|
||||
export function sanitizeForDisplay(text: unknown): string {
|
||||
if (!text || typeof text !== 'string') return text as string;
|
||||
|
||||
let sanitized = sanitizeForPrompt(text);
|
||||
|
||||
const protocolLeakPatterns = [
|
||||
/^\s*(?:assistant|user|system)\s+to=[^:\s]+:[^\n]+$/i,
|
||||
/^\s*<\|(?:assistant|user|system)[^|]*\|>\s*$/i,
|
||||
];
|
||||
|
||||
sanitized = sanitized
|
||||
.split('\n')
|
||||
.filter(line => !protocolLeakPatterns.some(pattern => pattern.test(line)))
|
||||
.join('\n');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// ─── Shell Safety ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate that a string is safe to use as a shell argument when quoted.
|
||||
*/
|
||||
export function validateShellArg(value: unknown, label: string | null | undefined): string {
|
||||
if (!value || typeof value !== 'string') {
|
||||
throw new Error(`${label || 'Argument'}: empty or invalid value`);
|
||||
}
|
||||
if (value.includes('\0')) {
|
||||
throw new Error(`${label || 'Argument'}: contains null bytes`);
|
||||
}
|
||||
if (/[$`]/.test(value) && /\$\(|`/.test(value)) {
|
||||
throw new Error(`${label || 'Argument'}: contains potential command substitution`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ─── JSON Safety ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Safely parse JSON with error handling and optional size limits.
|
||||
*/
|
||||
export function safeJsonParse(text: unknown, opts: { maxLength?: number; label?: string } = {}): { ok: boolean; value?: unknown; error?: string } {
|
||||
const maxLength = opts.maxLength || 1048576;
|
||||
const label = opts.label || 'JSON';
|
||||
if (!text || typeof text !== 'string') {
|
||||
return { ok: false, error: `${label}: empty or invalid input` };
|
||||
}
|
||||
if (text.length > maxLength) {
|
||||
return { ok: false, error: `${label}: input exceeds ${maxLength} byte limit (got ${text.length})` };
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(text) as unknown;
|
||||
return { ok: true, value };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: `${label}: parse error — ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase/Argument Validation ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a phase number argument.
|
||||
*/
|
||||
export function validatePhaseNumber(phase: unknown): { valid: boolean; normalized?: string; error?: string } {
|
||||
if (!phase || typeof phase !== 'string') {
|
||||
return { valid: false, error: 'Phase number is required' };
|
||||
}
|
||||
const trimmed = phase.trim();
|
||||
if (/^\d{1,4}[A-Z]?(?:\.\d{1,3})*$/i.test(trimmed)) {
|
||||
return { valid: true, normalized: trimmed };
|
||||
}
|
||||
if (/^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+){1,4}$/i.test(trimmed) && trimmed.length <= 30) {
|
||||
return { valid: true, normalized: trimmed };
|
||||
}
|
||||
return { valid: false, error: `Invalid phase number format: "${trimmed}"` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a STATE.md field name to prevent injection into regex patterns.
|
||||
*/
|
||||
export function validateFieldName(field: unknown): { valid: boolean; error?: string } {
|
||||
if (!field || typeof field !== 'string') {
|
||||
return { valid: false, error: 'Field name is required' };
|
||||
}
|
||||
if (/^[A-Za-z][A-Za-z0-9 _.\-/]{0,60}$/.test(field)) {
|
||||
return { valid: true };
|
||||
}
|
||||
return { valid: false, error: `Invalid field name: "${field}"` };
|
||||
}
|
||||
|
||||
// ─── Layer 3: Structural Schema Validation ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const KNOWN_VALID_TAGS = new Set([
|
||||
'objective', 'process', 'step', 'success_criteria', 'critical_rules',
|
||||
'available_agent_types', 'purpose', 'required_reading',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate the XML structure of a prompt file.
|
||||
*/
|
||||
export function validatePromptStructure(text: unknown, fileType: string): { valid: boolean; violations: string[] } {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return { valid: true, violations: [] };
|
||||
}
|
||||
if (fileType !== 'agent' && fileType !== 'workflow') {
|
||||
return { valid: true, violations: [] };
|
||||
}
|
||||
const violations: string[] = [];
|
||||
const tagRegex = /<([A-Za-z][A-Za-z0-9_-]*)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tagRegex.exec(text)) !== null) {
|
||||
const tag = match[1].toLowerCase();
|
||||
if (!KNOWN_VALID_TAGS.has(tag)) {
|
||||
violations.push(`Unknown XML tag in ${fileType} file: <${tag}>`);
|
||||
}
|
||||
}
|
||||
return { valid: violations.length === 0, violations };
|
||||
}
|
||||
|
||||
// ─── Layer 4: Paragraph-Level Entropy Anomaly Detection ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function shannonEntropy(text: string): number {
|
||||
if (!text || text.length === 0) return 0;
|
||||
const freq: Record<string, number> = {};
|
||||
for (const ch of text) {
|
||||
freq[ch] = (freq[ch] || 0) + 1;
|
||||
}
|
||||
const len = text.length;
|
||||
let entropy = 0;
|
||||
for (const count of Object.values(freq)) {
|
||||
const p = count / len;
|
||||
entropy -= p * Math.log2(p);
|
||||
}
|
||||
return entropy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan text for paragraphs with anomalously high Shannon entropy.
|
||||
*/
|
||||
export function scanEntropyAnomalies(text: unknown): { clean: boolean; findings: string[] } {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return { clean: true, findings: [] };
|
||||
}
|
||||
const findings: string[] = [];
|
||||
const paragraphs = text.split(/\n\n+/);
|
||||
for (const para of paragraphs) {
|
||||
if (para.length <= 50) continue;
|
||||
const entropy = shannonEntropy(para);
|
||||
if (entropy > 5.5) {
|
||||
findings.push(
|
||||
`High-entropy paragraph detected (${entropy.toFixed(2)} bits/char) — possible encoded payload`
|
||||
);
|
||||
}
|
||||
}
|
||||
return { clean: findings.length === 0, findings };
|
||||
}
|
||||
560
src/shell-command-projection.cts
Normal file
560
src/shell-command-projection.cts
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Shell Command Projection Module
|
||||
*
|
||||
* Tracer-bullet seam for runtime-aware projection of serialized command text
|
||||
* that GSD writes into runtime config or prints for copy/paste. This module
|
||||
* does NOT execute commands; it only renders command text for external shells
|
||||
* and runtimes.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/shell-command-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.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
// Use non-destructured namespace import so test-time mock.method(childProcess, 'spawnSync')
|
||||
// can intercept calls from this seam — destructured imports capture references
|
||||
// at load time and become un-mockable.
|
||||
import childProcess from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Return true when a managed hook command must be prefixed with PowerShell's
|
||||
* call operator so a quoted executable token is invokable by the target
|
||||
* runtime/shell combination.
|
||||
*
|
||||
* Current evidence-backed policy:
|
||||
* - Gemini on Windows requires `& ` for quoted node/bash runners.
|
||||
* - Claude Code on Windows does NOT: its hook commands execute under bash/Git
|
||||
* Bash and `& ` breaks there (#3413).
|
||||
*
|
||||
* Keep the policy conservative until another runtime has a verified need.
|
||||
*/
|
||||
export function hookCommandNeedsPowerShellCallOperator(opts: { platform?: string; runtime?: string } = {}): boolean {
|
||||
const platform = opts.platform || process.platform;
|
||||
const runtime = opts.runtime || 'generic';
|
||||
return platform === 'win32' && runtime === 'gemini';
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a fully-assembled hook command string for the target runtime.
|
||||
*/
|
||||
export function formatHookCommandForRuntime(command: string, opts: { platform?: string; runtime?: string } = {}): string {
|
||||
return hookCommandNeedsPowerShellCallOperator(opts) ? `& ${command}` : command;
|
||||
}
|
||||
|
||||
// #166/#580: Claude Code on Windows executes hook command strings inside Git
|
||||
// Bash. A `.sh` hook wrapped with an explicit bash.exe path makes bash try to
|
||||
// exec bash itself ("C:/.../bash.exe: cannot execute binary file"). Both install
|
||||
// paths — global (buildHookCommand) and local (buildLocalShellHookCommand) — must
|
||||
// drop the bash runner in this case and emit only the anchored script path.
|
||||
// Centralized here so the two paths cannot silently drift apart again: the local
|
||||
// path missed this guard and reintroduced the #166/#377 failure (#580).
|
||||
export function shellHookOmitsBashRunner({ platform, runtime = 'generic', isShellHook = false }: { platform?: string; runtime?: string; isShellHook?: boolean } = {}): boolean {
|
||||
const p = platform ?? process.platform;
|
||||
return p === 'win32' && runtime === 'claude' && isShellHook;
|
||||
}
|
||||
|
||||
// Builds the command string for a local-install managed `.sh` hook. Mirrors the
|
||||
// global buildHookCommand path but uses the $CLAUDE_PROJECT_DIR-anchored prefix
|
||||
// instead of an absolute configDir. On Claude/Windows the bash runner is dropped
|
||||
// (see shellHookOmitsBashRunner) and the anchored script path is emitted alone —
|
||||
// matching the global path. Elsewhere the resolved bash runner is required; a
|
||||
// null runner yields null so callers skip registration instead of emitting a
|
||||
// broken hook (#3393).
|
||||
export function buildLocalShellHookCommand({ localPrefix, hookFile, bashRunner, runtime = 'generic', platform = process.platform }: {
|
||||
localPrefix?: string | null;
|
||||
hookFile?: string | null;
|
||||
bashRunner?: string | null;
|
||||
runtime?: string;
|
||||
platform?: string;
|
||||
}): string | null {
|
||||
if (!localPrefix || !hookFile) return null;
|
||||
const scriptPath = `${localPrefix}/hooks/${hookFile}`;
|
||||
if (shellHookOmitsBashRunner({ platform, runtime, isShellHook: true })) {
|
||||
return formatHookCommandForRuntime(scriptPath, { platform, runtime });
|
||||
}
|
||||
if (!bashRunner) return null;
|
||||
return projectShellCommandText({
|
||||
runnerToken: bashRunner,
|
||||
argTokens: [scriptPath],
|
||||
runtime,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a managed hook script path token for serialized shell commands.
|
||||
* Windows managed hook commands normalize to forward slashes so the same path
|
||||
* survives JSON/TOML/config surfaces consistently.
|
||||
*/
|
||||
export function formatManagedHookScriptToken(scriptPath: string, opts: { platform?: string } = {}): string | null {
|
||||
const platform = opts.platform || process.platform;
|
||||
if (platform !== 'win32') return null;
|
||||
return JSON.stringify(scriptPath.replace(/\\/g, '/'));
|
||||
}
|
||||
|
||||
export function projectLocalHookPrefix({ runtime = 'claude', dirName }: { runtime?: string; dirName?: string | null }): string | undefined | null {
|
||||
if (!dirName) return dirName;
|
||||
return (runtime === 'gemini' || runtime === 'antigravity')
|
||||
? dirName
|
||||
: `"$CLAUDE_PROJECT_DIR"/${dirName}`;
|
||||
}
|
||||
|
||||
export function projectPortableHookBaseDir({ configDir, homeDir }: { configDir?: string | null; homeDir?: string | null }): string {
|
||||
const normalizedConfigDir = String(configDir || '').replace(/\\/g, '/');
|
||||
const normalizedHome = String(homeDir || '').replace(/\\/g, '/');
|
||||
if (!normalizedConfigDir || !normalizedHome) return normalizedConfigDir;
|
||||
return normalizedConfigDir.startsWith(normalizedHome)
|
||||
? '$HOME' + normalizedConfigDir.slice(normalizedHome.length)
|
||||
: normalizedConfigDir;
|
||||
}
|
||||
|
||||
export function projectShellCommandText({
|
||||
runnerToken,
|
||||
argTokens = [],
|
||||
runtime = 'generic',
|
||||
platform = process.platform,
|
||||
}: {
|
||||
runnerToken?: string | null;
|
||||
argTokens?: (string | null | undefined)[];
|
||||
runtime?: string;
|
||||
platform?: string;
|
||||
}): string | null {
|
||||
if (!runnerToken) return null;
|
||||
const parts = [runnerToken, ...argTokens.filter(Boolean)] as string[];
|
||||
return formatHookCommandForRuntime(parts.join(' '), { platform, runtime });
|
||||
}
|
||||
|
||||
export function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime = 'generic', platform = process.platform }: {
|
||||
absoluteRunner?: string | null;
|
||||
scriptPath?: string | null;
|
||||
runtime?: string;
|
||||
platform?: string;
|
||||
}): string | null {
|
||||
if (!absoluteRunner || !scriptPath) return null;
|
||||
const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
|
||||
return projectShellCommandText({
|
||||
runnerToken: absoluteRunner,
|
||||
argTokens: [JSON.stringify(normalizedScriptPath)],
|
||||
runtime,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
|
||||
'settings-json': new Set([
|
||||
'gsd-check-update.js',
|
||||
'gsd-statusline.js',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
'gsd-read-injection-scanner.js',
|
||||
'gsd-update-banner.js',
|
||||
'gsd-workflow-guard.js',
|
||||
]),
|
||||
'codex-toml': new Set([
|
||||
'gsd-check-update.js',
|
||||
]),
|
||||
};
|
||||
|
||||
const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
|
||||
'settings-json': new Set([
|
||||
'gsd-check-update.js',
|
||||
'gsd-statusline.js',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
'gsd-read-injection-scanner.js',
|
||||
'gsd-update-banner.js',
|
||||
'gsd-workflow-guard.js',
|
||||
'gsd-session-state.sh',
|
||||
'gsd-validate-commit.sh',
|
||||
'gsd-phase-boundary.sh',
|
||||
]),
|
||||
'codex-toml': new Set([
|
||||
'gsd-check-update.js',
|
||||
]),
|
||||
'codex-hooks-json': new Set([
|
||||
'gsd-check-update.js',
|
||||
// #3426: Windows .cmd shim for Codex hook — must be treated as managed so
|
||||
// reconcileCodexHooksJsonSessionStart can replace stale node-runner commands
|
||||
// with the .cmd shim on reinstall (and vice-versa on cross-platform moves).
|
||||
'gsd-check-update.cmd',
|
||||
]),
|
||||
};
|
||||
|
||||
const LEGACY_MANAGED_HOOK_ALIASES_BY_SURFACE: Record<string, Set<string>> = {
|
||||
'codex-toml': new Set([
|
||||
'gsd-update-check.js',
|
||||
]),
|
||||
'codex-hooks-json': new Set([
|
||||
'gsd-update-check.js',
|
||||
]),
|
||||
};
|
||||
|
||||
function managedHookSurfaceSet(surface: string = 'settings-json'): Set<string> {
|
||||
return MANAGED_HOOK_BASENAMES_BY_SURFACE[surface] || MANAGED_HOOK_BASENAMES_BY_SURFACE['settings-json'];
|
||||
}
|
||||
|
||||
export function isManagedHookBasename(scriptPathOrBasename: string | null | undefined, opts: { surface?: string } = {}): boolean {
|
||||
if (!scriptPathOrBasename) return false;
|
||||
const surface = opts.surface || 'settings-json';
|
||||
const basename = String(scriptPathOrBasename).split(/[\\/]/).pop() || '';
|
||||
return managedHookSurfaceSet(surface).has(basename);
|
||||
}
|
||||
|
||||
function managedHookCommandSurfaceSet(surface: string = 'settings-json', includeLegacyAliases: boolean = false): Set<string> {
|
||||
const base = MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE[surface]
|
||||
|| MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE['settings-json'];
|
||||
if (!includeLegacyAliases) return base;
|
||||
const aliases = LEGACY_MANAGED_HOOK_ALIASES_BY_SURFACE[surface];
|
||||
if (!aliases || aliases.size === 0) return base;
|
||||
return new Set([...base, ...aliases]);
|
||||
}
|
||||
|
||||
export function isManagedHookCommand(commandText: unknown, opts: { surface?: string; includeLegacyAliases?: boolean; configDir?: string } = {}): boolean {
|
||||
if (typeof commandText !== 'string') return false;
|
||||
const surface = opts.surface || 'settings-json';
|
||||
const includeLegacyAliases = opts.includeLegacyAliases === true;
|
||||
const managedBasenames = managedHookCommandSurfaceSet(surface, includeLegacyAliases);
|
||||
if (!managedBasenames || managedBasenames.size === 0) return false;
|
||||
const normalizedCommand = commandText.replace(/\\/g, '/');
|
||||
|
||||
if (typeof opts.configDir === 'string' && opts.configDir.length > 0) {
|
||||
const normalizedHooksDir = `${path.join(opts.configDir, 'hooks').replace(/\\/g, '/')}/`;
|
||||
if (!normalizedCommand.includes(normalizedHooksDir)) return false;
|
||||
}
|
||||
|
||||
for (const basename of managedBasenames) {
|
||||
const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`(^|[\\\\/\\s"'` + '`' + `])${escapedBasename}(?=$|[\\s"'` + '`' + `])`);
|
||||
if (pattern.test(normalizedCommand)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection helper for legacy settings.json hook rewrites.
|
||||
*
|
||||
* Non-Windows keeps the original script token shape when provided (single
|
||||
* quote / bareword / quoted), while Windows normalizes to double-quoted
|
||||
* forward-slash path tokens for stable cross-shell behavior.
|
||||
*/
|
||||
export function projectLegacySettingsHookCommand({
|
||||
absoluteRunner,
|
||||
scriptPath,
|
||||
scriptToken,
|
||||
runtime = 'generic',
|
||||
platform = process.platform,
|
||||
}: {
|
||||
absoluteRunner?: string | null;
|
||||
scriptPath?: string | null;
|
||||
scriptToken?: string | null;
|
||||
runtime?: string;
|
||||
platform?: string;
|
||||
}): string | null {
|
||||
if (!absoluteRunner || !scriptPath) return null;
|
||||
const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
|
||||
const commandScriptToken = platform === 'win32'
|
||||
? JSON.stringify(normalizedScriptPath)
|
||||
: (scriptToken || JSON.stringify(normalizedScriptPath));
|
||||
return projectShellCommandText({
|
||||
runnerToken: absoluteRunner,
|
||||
argTokens: [commandScriptToken],
|
||||
runtime,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
export function escapeTomlDoubleQuotedString(value: unknown): string {
|
||||
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
export function projectCodexHookTomlCommand({ absoluteRunner, scriptPath, platform = process.platform }: {
|
||||
absoluteRunner?: string | null;
|
||||
scriptPath?: string | null;
|
||||
platform?: string;
|
||||
}): string | null {
|
||||
const command = projectManagedHookCommand({
|
||||
absoluteRunner,
|
||||
scriptPath,
|
||||
runtime: 'codex',
|
||||
platform,
|
||||
});
|
||||
return command === null ? null : escapeTomlDoubleQuotedString(command);
|
||||
}
|
||||
|
||||
export function escapePowerShellSingleQuoted(value: unknown): string {
|
||||
return String(value).replace(/'/g, "''");
|
||||
}
|
||||
|
||||
export function escapePosixDoubleQuoted(value: unknown): string {
|
||||
return String(value).replace(/[\\$"`]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function escapeSingleQuotedShellLiteral(value: unknown): string {
|
||||
return String(value).replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
interface ShellAction {
|
||||
label: string | null;
|
||||
shell: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export function renderShellActionLines(shellActions: ShellAction[] = []): string[] {
|
||||
return shellActions.map((action) => {
|
||||
if (!action || !action.command) return '';
|
||||
return action.label ? `${action.label}: ${action.command}` : action.command;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
export function projectPathActionProjection({
|
||||
mode = 'repair',
|
||||
targetDir,
|
||||
platform = process.platform,
|
||||
}: {
|
||||
mode?: string;
|
||||
targetDir?: string | null;
|
||||
platform?: string;
|
||||
}): { shellActions: ShellAction[]; actionLines: string[] } {
|
||||
if (!targetDir) return { shellActions: [], actionLines: [] };
|
||||
|
||||
const isWin32 = platform === 'win32';
|
||||
|
||||
let shellActions: ShellAction[];
|
||||
if (isWin32) {
|
||||
const psTargetDir = escapePowerShellSingleQuoted(targetDir);
|
||||
const bashTargetDir = escapeSingleQuotedShellLiteral(String(targetDir).replace(/\\/g, '/'));
|
||||
shellActions = [
|
||||
{
|
||||
label: 'PowerShell',
|
||||
shell: 'powershell',
|
||||
command: `[Environment]::SetEnvironmentVariable('PATH', '${psTargetDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')`,
|
||||
},
|
||||
{
|
||||
label: 'cmd.exe',
|
||||
shell: 'cmd',
|
||||
command: `powershell -Command "[Environment]::SetEnvironmentVariable('PATH', '${psTargetDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"`,
|
||||
},
|
||||
{
|
||||
label: 'Git Bash',
|
||||
shell: 'bash',
|
||||
command: `echo 'export PATH="${bashTargetDir}:$PATH"' >> ~/.bashrc`,
|
||||
},
|
||||
];
|
||||
} else if (mode === 'persist') {
|
||||
const bashTargetDir = escapeSingleQuotedShellLiteral(String(targetDir));
|
||||
shellActions = [
|
||||
{
|
||||
label: 'zsh',
|
||||
shell: 'zsh',
|
||||
command: `echo 'export PATH="${bashTargetDir}:$PATH"' >> ~/.zshrc`,
|
||||
},
|
||||
{
|
||||
label: 'bash',
|
||||
shell: 'bash',
|
||||
command: `echo 'export PATH="${bashTargetDir}:$PATH"' >> ~/.bashrc`,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
const posixTargetDir = escapePosixDoubleQuoted(targetDir);
|
||||
shellActions = [
|
||||
{
|
||||
label: null,
|
||||
shell: 'posix',
|
||||
command: `export PATH="${posixTargetDir}:$PATH"`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
shellActions,
|
||||
actionLines: renderShellActionLines(shellActions),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectPersistentPathExportActions({ targetDir, platform = process.platform }: {
|
||||
targetDir?: string | null;
|
||||
platform?: string;
|
||||
}): { shellActions: ShellAction[] } {
|
||||
const projected = projectPathActionProjection({
|
||||
mode: 'persist',
|
||||
targetDir,
|
||||
platform,
|
||||
});
|
||||
return { shellActions: projected.shellActions };
|
||||
}
|
||||
|
||||
|
||||
// ─── Subprocess dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
interface SpawnResultOutput {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
function _spawnResult(result: { error?: NodeJS.ErrnoException | null; status?: number | null; stdout?: Buffer | string | null; stderr?: Buffer | string | null; signal?: NodeJS.Signals | null }, program: string): SpawnResultOutput {
|
||||
if (result.error && result.error.code === 'ENOENT') {
|
||||
return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error };
|
||||
}
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
stdout: (result.stdout ?? '').toString().trim(),
|
||||
stderr: (result.stderr ?? '').toString().trim(),
|
||||
signal: result.signal ?? null,
|
||||
error: result.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function execGit(args: string[], opts: { cwd?: string; env?: Record<string, string>; timeout?: number } = {}): SpawnResultOutput {
|
||||
// Non-interactive defaults: a hung credential prompt or terminal-input
|
||||
// probe must surface as a timeout, not block the tool forever. Callers
|
||||
// can override via opts.env.
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GCM_INTERACTIVE: 'never',
|
||||
...(opts.env || {}),
|
||||
};
|
||||
const result = childProcess.spawnSync('git', args, {
|
||||
cwd: opts.cwd,
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
timeout: opts.timeout ?? 10_000,
|
||||
});
|
||||
return _spawnResult(result, 'git');
|
||||
}
|
||||
|
||||
export function execNpm(args: string[], opts: { cwd?: string; timeout?: number } = {}): SpawnResultOutput {
|
||||
const result = childProcess.spawnSync('npm', args, {
|
||||
cwd: opts.cwd,
|
||||
shell: process.platform === 'win32',
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: opts.timeout ?? 15_000,
|
||||
});
|
||||
return _spawnResult(result, 'npm');
|
||||
}
|
||||
|
||||
export function execTool(program: string, args: string[], opts: { cwd?: string; env?: Record<string, string>; timeout?: number } = {}): SpawnResultOutput {
|
||||
const result = childProcess.spawnSync(program, args, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env ? { ...process.env, ...opts.env } : undefined,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
timeout: opts.timeout ?? 30_000,
|
||||
});
|
||||
return _spawnResult(result, program);
|
||||
}
|
||||
|
||||
export function probeTty(opts: { platform?: string } = {}): string | null {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
if (platform === 'win32') return null;
|
||||
try {
|
||||
const ttyPath = childProcess.execFileSync('tty', [], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['inherit', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
if (!ttyPath || ttyPath === 'not a tty') return null;
|
||||
return ttyPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Platform file I/O ────────────────────────────────────────────────────────
|
||||
|
||||
function _normalizeMd(content: string): string {
|
||||
if (!content || typeof content !== 'string') return content;
|
||||
let text = content.replace(/\r\n/g, '\n');
|
||||
const lines = text.split('\n');
|
||||
const result: string[] = [];
|
||||
const fenceRegex = /^```/;
|
||||
const insideFence = new Array<boolean>(lines.length);
|
||||
let fenceOpen = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (fenceRegex.test(lines[i].trimEnd())) {
|
||||
if (fenceOpen) {
|
||||
insideFence[i] = false;
|
||||
fenceOpen = false;
|
||||
} else {
|
||||
insideFence[i] = false;
|
||||
fenceOpen = true;
|
||||
}
|
||||
} else {
|
||||
insideFence[i] = fenceOpen;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const prev = i > 0 ? lines[i - 1] : '';
|
||||
const prevTrimmed = prev.trimEnd();
|
||||
const trimmed = line.trimEnd();
|
||||
const isFenceLine = fenceRegex.test(trimmed);
|
||||
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') result.push('');
|
||||
if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
|
||||
if (i === 0 || !insideFence[i - 1]) result.push('');
|
||||
}
|
||||
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 && prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) && prevTrimmed !== '---') result.push('');
|
||||
result.push(line);
|
||||
if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '') result.push('');
|
||||
if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '') result.push('');
|
||||
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
|
||||
const next = lines[i + 1];
|
||||
if (next !== undefined && next.trimEnd() !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) && !/^\s/.test(next)) result.push('');
|
||||
}
|
||||
}
|
||||
text = result.join('\n');
|
||||
text = text.replace(/\n{3,}/g, '\n\n');
|
||||
text = text.replace(/\n*$/, '\n');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function normalizeContent(filePath: string, content: string, opts: { encoding?: BufferEncoding } = {}): { content: string; encoding: BufferEncoding } {
|
||||
const encoding = opts.encoding ?? 'utf-8';
|
||||
const isMd = path.extname(filePath).toLowerCase() === '.md';
|
||||
let normalized: string;
|
||||
if (isMd) {
|
||||
normalized = _normalizeMd(content);
|
||||
} else {
|
||||
normalized = (content ?? '').replace(/\r\n/g, '\n').replace(/\n*$/, '\n');
|
||||
}
|
||||
return { content: normalized, encoding };
|
||||
}
|
||||
|
||||
export function platformWriteSync(filePath: string, content: string, opts: { encoding?: BufferEncoding } = {}): void {
|
||||
const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tmpPath = filePath + '.tmp.' + process.pid;
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, normalized, encoding);
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
} catch {
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* already gone */ }
|
||||
fs.writeFileSync(filePath, normalized, encoding);
|
||||
}
|
||||
}
|
||||
|
||||
export function platformReadSync(filePath: string, opts: { encoding?: BufferEncoding; required?: boolean } = {}): string | null {
|
||||
const encoding = opts.encoding ?? 'utf-8';
|
||||
try {
|
||||
return fs.readFileSync(filePath, encoding);
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === 'ENOENT') {
|
||||
if (opts.required) throw err;
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function platformEnsureDir(dirPath: string): void {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
323
src/state-command-router.cts
Normal file
323
src/state-command-router.cts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/state-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 { STATE_SUBCOMMANDS } from './command-aliases.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
|
||||
const { routeHubCommandFamily, cjsFallbackHandler } = cjsCommandRouterAdapter;
|
||||
import { parseNamedArgs } from './command-arg-projection.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Handler type matching cjs-command-router-adapter's internal Handler type.
|
||||
type Handler = () => unknown;
|
||||
|
||||
// Helper: cast cjsFallbackHandler result (always the last arg, a Handler) to Handler.
|
||||
function fallback(...projectionArgs: unknown[]): Handler {
|
||||
return cjsFallbackHandler(...projectionArgs) as Handler;
|
||||
}
|
||||
|
||||
// Helper: extract string-only named arg value (value flags never return boolean).
|
||||
function strArg(opts: Record<string, string | boolean | null>, key: string): string | null | undefined {
|
||||
const v = opts[key];
|
||||
if (typeof v === 'boolean') return undefined;
|
||||
return v;
|
||||
}
|
||||
|
||||
interface StateModule {
|
||||
cmdStateLoad(cwd: string, raw: boolean): void;
|
||||
cmdStateJson(cwd: string, raw: boolean): void;
|
||||
cmdStateGet(cwd: string, field: string | undefined, raw: boolean): void;
|
||||
cmdStateUpdate(cwd: string, field: string | undefined, value: string | undefined): void;
|
||||
cmdStatePatch(cwd: string, patches: Record<string, string>, raw: boolean): void;
|
||||
cmdStateAdvancePlan(cwd: string, raw: boolean): void;
|
||||
cmdStateRecordMetric(cwd: string, opts: Record<string, string | null | undefined>, raw: boolean): void;
|
||||
cmdStateUpdateProgress(cwd: string, raw: boolean): void;
|
||||
cmdStateAddDecision(cwd: string, opts: Record<string, string | null | undefined>, raw: boolean): void;
|
||||
cmdStateAddBlocker(cwd: string, opts: Record<string, string | null | undefined>, raw: boolean): void;
|
||||
cmdStateResolveBlocker(cwd: string, text: string | null | undefined, raw: boolean): void;
|
||||
cmdStateRecordSession(cwd: string, opts: Record<string, string | null | undefined>, raw: boolean): void;
|
||||
cmdStateBeginPhase(cwd: string, phase: string | null | undefined, name: string | null | undefined, plans: number | null, raw: boolean): void;
|
||||
cmdSignalWaiting(cwd: string, type: string | null | undefined, question: string | null | undefined, options: string | null | undefined, phase: string | null | undefined, raw: boolean): void;
|
||||
cmdSignalResume(cwd: string, raw: boolean): void;
|
||||
cmdStatePlannedPhase(cwd: string, phase: string | null | undefined, plans: number | null, raw: boolean): void;
|
||||
cmdStateValidate(cwd: string, raw: boolean): void;
|
||||
cmdStateSync(cwd: string, opts: { verify: string | boolean | null | undefined }, raw: boolean): void;
|
||||
cmdStatePrune(cwd: string, opts: { keepRecent: string; dryRun: boolean }, raw: boolean): void;
|
||||
cmdStateCompletePhase(cwd: string, raw: boolean, phase: string | null | undefined): void;
|
||||
cmdStateMilestoneSwitch(cwd: string, milestone: string | null | undefined, name: string | null | undefined, raw: boolean): void;
|
||||
}
|
||||
|
||||
interface RouteStateCommandOptions {
|
||||
state: StateModule;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
raw: boolean;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOptions): void {
|
||||
const parsePlans = (plans: string | null | undefined): number | null => {
|
||||
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: string, available: string[]) => `Unknown state subcommand: "${subcommand}". Available: ${available.join(', ')}`,
|
||||
handlers: {
|
||||
load: fallback(
|
||||
'state.load',
|
||||
[],
|
||||
args.slice(1),
|
||||
null,
|
||||
() => state.cmdStateLoad(cwd, raw),
|
||||
),
|
||||
json: fallback(
|
||||
'state.json',
|
||||
[],
|
||||
args.slice(1),
|
||||
null,
|
||||
() => state.cmdStateJson(cwd, raw),
|
||||
),
|
||||
get: fallback(
|
||||
'state.get',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => state.cmdStateGet(cwd, args[2], raw),
|
||||
),
|
||||
update: fallback(
|
||||
'state.update',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => state.cmdStateUpdate(cwd, args[2], args[3]),
|
||||
),
|
||||
patch: fallback(
|
||||
'state.patch',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const patches: Record<string, string> = {};
|
||||
if (args.length === 3 && typeof args[2] === 'string' && args[2].trim().startsWith('{')) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(args[2]);
|
||||
} catch (err) {
|
||||
error(`state patch: invalid JSON object: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
error('state patch: JSON input must be an object of field/value pairs.');
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (key && value !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
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': fallback(
|
||||
'state.advance-plan',
|
||||
[],
|
||||
args.slice(1),
|
||||
null,
|
||||
() => state.cmdStateAdvancePlan(cwd, raw),
|
||||
),
|
||||
'record-metric': fallback(
|
||||
'state.record-metric',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const a = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']);
|
||||
state.cmdStateRecordMetric(cwd, {
|
||||
phase: strArg(a, 'phase'),
|
||||
plan: strArg(a, 'plan'),
|
||||
duration: strArg(a, 'duration'),
|
||||
tasks: strArg(a, 'tasks'),
|
||||
files: strArg(a, 'files'),
|
||||
}, raw);
|
||||
},
|
||||
),
|
||||
'update-progress': fallback(
|
||||
'state.update-progress',
|
||||
[],
|
||||
args.slice(1),
|
||||
null,
|
||||
() => state.cmdStateUpdateProgress(cwd, raw),
|
||||
),
|
||||
'add-decision': fallback(
|
||||
'state.add-decision',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const a = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']);
|
||||
state.cmdStateAddDecision(cwd, {
|
||||
phase: strArg(a, 'phase'),
|
||||
summary: strArg(a, 'summary'),
|
||||
summary_file: strArg(a, 'summary-file'),
|
||||
rationale: strArg(a, 'rationale') || '',
|
||||
rationale_file: strArg(a, 'rationale-file'),
|
||||
}, raw);
|
||||
},
|
||||
),
|
||||
'add-blocker': fallback(
|
||||
'state.add-blocker',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const a = parseNamedArgs(args, ['text', 'text-file']);
|
||||
state.cmdStateAddBlocker(cwd, { text: strArg(a, 'text'), text_file: strArg(a, 'text-file') }, raw);
|
||||
},
|
||||
),
|
||||
'resolve-blocker': fallback(
|
||||
'state.resolve-blocker',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => state.cmdStateResolveBlocker(cwd, strArg(parseNamedArgs(args, ['text']), 'text'), raw),
|
||||
),
|
||||
'record-session': fallback(
|
||||
'state.record-session',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const a = 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: strArg(a, 'stopped-at'), resume_file: strArg(a, 'resume-file') }, raw);
|
||||
},
|
||||
),
|
||||
'begin-phase': fallback(
|
||||
'state.begin-phase',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const a = parseNamedArgs(args, ['phase', 'name', 'plans']);
|
||||
state.cmdStateBeginPhase(cwd, strArg(a, 'phase'), strArg(a, 'name'), parsePlans(strArg(a, 'plans')), raw);
|
||||
},
|
||||
),
|
||||
'signal-waiting': fallback(
|
||||
'state.signal-waiting',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const a = parseNamedArgs(args, ['type', 'question', 'options', 'phase']);
|
||||
state.cmdSignalWaiting(cwd, strArg(a, 'type'), strArg(a, 'question'), strArg(a, 'options'), strArg(a, 'phase'), raw);
|
||||
},
|
||||
),
|
||||
'signal-resume': fallback(
|
||||
'state.signal-resume',
|
||||
[],
|
||||
args.slice(1),
|
||||
null,
|
||||
() => state.cmdSignalResume(cwd, raw),
|
||||
),
|
||||
'planned-phase': fallback(
|
||||
'state.planned-phase',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const a = parseNamedArgs(args, ['phase', 'name', 'plans']);
|
||||
state.cmdStatePlannedPhase(cwd, strArg(a, 'phase'), parsePlans(strArg(a, 'plans')), raw);
|
||||
},
|
||||
),
|
||||
validate: fallback(
|
||||
'state.validate',
|
||||
[],
|
||||
args.slice(1),
|
||||
null,
|
||||
() => state.cmdStateValidate(cwd, raw),
|
||||
),
|
||||
sync: fallback(
|
||||
'state.sync',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const a = parseNamedArgs(args, [], ['verify']);
|
||||
state.cmdStateSync(cwd, { verify: a['verify'] }, raw);
|
||||
},
|
||||
),
|
||||
prune: fallback(
|
||||
'state.prune',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const a = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
|
||||
state.cmdStatePrune(cwd, { keepRecent: strArg(a, 'keep-recent') || '3', dryRun: a['dry-run'] === true }, raw);
|
||||
},
|
||||
),
|
||||
// complete-phase: CJS-only — no SDK counterpart.
|
||||
'complete-phase': () => {
|
||||
const a = parseNamedArgs(args, ['phase']);
|
||||
state.cmdStateCompletePhase(cwd, raw, strArg(a, 'phase') || args[2]);
|
||||
},
|
||||
'milestone-switch': fallback(
|
||||
'state.milestone-switch',
|
||||
args.slice(2),
|
||||
args.slice(1),
|
||||
null,
|
||||
() => {
|
||||
const a = parseNamedArgs(args, ['milestone', 'name']);
|
||||
state.cmdStateMilestoneSwitch(cwd, strArg(a, 'milestone'), strArg(a, 'name'), raw);
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export = {
|
||||
routeStateCommand,
|
||||
};
|
||||
276
src/state-document.cts
Normal file
276
src/state-document.cts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* STATE.md Document Module — pure transforms for STATE.md text.
|
||||
* This module does not read the filesystem and does not own persistence or locking.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/state-document.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only types are added.
|
||||
*/
|
||||
|
||||
// Internal helpers
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
interface ProgressRecord {
|
||||
total_phases?: unknown;
|
||||
completed_phases?: unknown;
|
||||
total_plans?: unknown;
|
||||
completed_plans?: unknown;
|
||||
percent?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function existingProgressExceedsDerived(existingProgress: ProgressRecord, derivedProgress: ProgressRecord, key: string): boolean {
|
||||
const existing = toFiniteNumber(existingProgress[key]);
|
||||
const derived = toFiniteNumber(derivedProgress[key]);
|
||||
return existing !== null && derived !== null && existing > derived;
|
||||
}
|
||||
|
||||
export function stateExtractField(content: string, fieldName: string): string | null {
|
||||
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;
|
||||
}
|
||||
|
||||
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
|
||||
const escaped = escapeRegex(fieldName);
|
||||
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
|
||||
if (boldPattern.test(content)) {
|
||||
return content.replace(boldPattern, (_match, prefix: string) => `${prefix}${newValue}`);
|
||||
}
|
||||
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
|
||||
if (plainPattern.test(content)) {
|
||||
return content.replace(plainPattern, (_match, prefix: string) => `${prefix}${newValue}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function stateReplaceFieldWithFallback(content: string, primary: string, fallback: string | null | undefined, value: string): string {
|
||||
let result = stateReplaceField(content, primary, value);
|
||||
if (result)
|
||||
return result;
|
||||
if (fallback) {
|
||||
result = stateReplaceField(content, fallback, value);
|
||||
if (result)
|
||||
return result;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function normalizeStateStatus(status: string | null | undefined, pausedAt: unknown): string {
|
||||
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;
|
||||
}
|
||||
|
||||
export function computeProgressPercent(
|
||||
completedPlans: number | null,
|
||||
totalPlans: number | null,
|
||||
completedPhases: number | null,
|
||||
totalPhases: number | null
|
||||
): number | null {
|
||||
const hasPlanData = totalPlans !== null && totalPlans > 0 && completedPlans !== null;
|
||||
const hasPhaseData = totalPhases !== null && totalPhases > 0 && completedPhases !== null;
|
||||
if (!hasPlanData && !hasPhaseData)
|
||||
return null;
|
||||
// Use nullish coalescing to avoid non-null assertion operators (flow narrowing
|
||||
// cannot track through intermediate boolean variables).
|
||||
const planFraction = hasPlanData ? (completedPlans ?? 0) / (totalPlans ?? 1) : 1;
|
||||
const phaseFraction = hasPhaseData ? (completedPhases ?? 0) / (totalPhases ?? 1) : 1;
|
||||
return Math.min(100, Math.round(Math.min(planFraction, phaseFraction) * 100));
|
||||
}
|
||||
|
||||
export function shouldPreserveExistingProgress(existingProgress: unknown, derivedProgress: unknown): boolean {
|
||||
if (!existingProgress || typeof existingProgress !== 'object')
|
||||
return false;
|
||||
if (!derivedProgress || typeof derivedProgress !== 'object')
|
||||
return false;
|
||||
const existing = existingProgress as ProgressRecord;
|
||||
const derived = derivedProgress as ProgressRecord;
|
||||
return (existingProgressExceedsDerived(existing, derived, 'total_phases') ||
|
||||
existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
|
||||
existingProgressExceedsDerived(existing, derived, 'total_plans') ||
|
||||
existingProgressExceedsDerived(existing, derived, 'completed_plans'));
|
||||
}
|
||||
|
||||
export function normalizeProgressNumbers(progress: unknown): unknown {
|
||||
if (!progress || typeof progress !== 'object')
|
||||
return progress;
|
||||
const normalized: ProgressRecord = { ...(progress as ProgressRecord) };
|
||||
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").
|
||||
*/
|
||||
export const KNOWN_TEMPLATE_DEFAULTS: Record<string, string[]> = {
|
||||
'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.
|
||||
*/
|
||||
export const KNOWN_STATUS_PATTERNS: RegExp[] = [
|
||||
/^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 field - Canonical field name (case-sensitive key lookup attempted
|
||||
* first, then case-insensitive fallback).
|
||||
* @param value - The current value extracted from STATE.md.
|
||||
* @returns boolean
|
||||
*/
|
||||
export function isStateTemplateDefault(field: string, value: unknown): boolean {
|
||||
if (value === null || value === undefined) return true; // absent → initial write
|
||||
// Narrow to string: callers pass string values extracted from STATE.md.
|
||||
const v = (typeof value === 'string' ? value : `${value as boolean | number}`).trim();
|
||||
if (v === '') return true; // blank → treat as absent
|
||||
|
||||
// Look up the defaults list, trying exact key first then case-insensitive.
|
||||
let defaults: string[] | null | undefined = 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 content - Full STATE.md text.
|
||||
* @param field - Field name as it appears in STATE.md.
|
||||
* @param knownDefaults - The defaults list to check against (typically
|
||||
* KNOWN_TEMPLATE_DEFAULTS[field]).
|
||||
* @param newValue - Value to write when replacement is permitted.
|
||||
* @returns Updated content (or original if skipped).
|
||||
*/
|
||||
export function stateReplaceFieldIfTemplate(content: string, field: string, knownDefaults: string[] | null | undefined, newValue: string | null | undefined): string {
|
||||
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;
|
||||
}
|
||||
2154
src/state.cts
Normal file
2154
src/state.cts
Normal file
File diff suppressed because it is too large
Load Diff
468
src/surface.cts
Normal file
468
src/surface.cts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Runtime surface module — ADR-0011 Phase 2 (Option B).
|
||||
*
|
||||
* Manages the runtime enable/disable surface state (the `.gsd-surface.json` marker in
|
||||
* each runtime's config dir root (e.g., ~/.claude)) independently of the install-time profile marker
|
||||
* (`.gsd-profile`). Runtime config locations are resolved by callers.
|
||||
*
|
||||
* Effective skill set = base profile ∪ explicitAdds − disabledClusters − explicitRemoves,
|
||||
* then transitively closed via the manifest.
|
||||
*
|
||||
* Exports:
|
||||
* readSurface(runtimeConfigDir)
|
||||
* writeSurface(runtimeConfigDir, surfaceState)
|
||||
* resolveSurface(runtimeConfigDir, manifest, clusterMap)
|
||||
* applySurface(runtimeConfigDir, layout, manifest, clusterMap)
|
||||
* listSurface(runtimeConfigDir, manifest, clusterMap)
|
||||
* pruneSkillDirs(skillsDir, retainedNames, prefix, manifest)
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/surface.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';
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installProfiles = require('./install-profiles.cjs');
|
||||
const {
|
||||
readActiveProfile,
|
||||
resolveProfile,
|
||||
loadSkillsManifest,
|
||||
} = installProfiles;
|
||||
import { CLUSTERS } from './clusters.cjs';
|
||||
import type { ClusterMap } from './clusters.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
|
||||
const { findInstallSourceRoot } = runtimeArtifactLayout;
|
||||
|
||||
const SURFACE_FILE_NAME = '.gsd-surface.json';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ArtifactKind {
|
||||
kind: string;
|
||||
destSubpath: string;
|
||||
prefix: string;
|
||||
stage: (resolvedProfile: { name: string; skills: Set<string> | '*'; agents: Set<string> }) => string;
|
||||
}
|
||||
|
||||
interface Layout {
|
||||
runtime: string;
|
||||
configDir: string;
|
||||
kinds: ArtifactKind[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State IO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @typedef {Object} SurfaceState
|
||||
* @property {string} baseProfile
|
||||
* @property {string[]} disabledClusters
|
||||
* @property {string[]} explicitAdds
|
||||
* @property {string[]} explicitRemoves
|
||||
*/
|
||||
|
||||
interface SurfaceState {
|
||||
baseProfile: string;
|
||||
disabledClusters: string[];
|
||||
explicitAdds: string[];
|
||||
explicitRemoves: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the surface state from a runtime config directory.
|
||||
*
|
||||
* @param runtimeConfigDir
|
||||
* @returns null if file missing or corrupt
|
||||
*/
|
||||
function readSurface(runtimeConfigDir: string): SurfaceState | null {
|
||||
const filePath = path.join(runtimeConfigDir, SURFACE_FILE_NAME);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
// Structural validation — must have these fields with expected types
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const p = parsed as Record<string, unknown>;
|
||||
if (typeof p['baseProfile'] !== 'string') return null;
|
||||
if (!Array.isArray(p['disabledClusters'])) return null;
|
||||
if (!Array.isArray(p['explicitAdds'])) return null;
|
||||
if (!Array.isArray(p['explicitRemoves'])) return null;
|
||||
return {
|
||||
baseProfile: p['baseProfile'],
|
||||
disabledClusters: p['disabledClusters'] as string[],
|
||||
explicitAdds: p['explicitAdds'] as string[],
|
||||
explicitRemoves: p['explicitRemoves'] as string[],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the surface state atomically via the platform seam (mkdir + tmp+rename).
|
||||
*/
|
||||
function writeSurface(runtimeConfigDir: string, surfaceState: SurfaceState): void {
|
||||
platformWriteSync(path.join(runtimeConfigDir, SURFACE_FILE_NAME), JSON.stringify(surfaceState, null, 2) + '\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Expand cluster names to skill stems using the provided clusterMap.
|
||||
*/
|
||||
function clustersToSkills(clusterNames: string[], clusterMap: ClusterMap | Record<string, string[]>): Set<string> {
|
||||
const result = new Set<string>();
|
||||
for (const name of clusterNames) {
|
||||
const members = (clusterMap as Record<string, ReadonlyArray<string> | undefined>)[name];
|
||||
if (members) {
|
||||
for (const s of members) result.add(s);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize manifest inputs to the skill-dependency Map shape expected by
|
||||
* resolveProfile/computeClosure.
|
||||
*
|
||||
* Supports:
|
||||
* - canonical Map<string, string[]>
|
||||
* - legacy plain object map: { [stem]: string[] }
|
||||
* - parsed gsd-file-manifest.json object ({ files: { ... } }) by rebuilding
|
||||
* the dependency manifest from the install source tree
|
||||
*/
|
||||
function normalizeSkillManifest(runtimeConfigDir: string, manifest: Map<string, string[]> | object | null | undefined): Map<string, string[]> {
|
||||
if (manifest instanceof Map) {
|
||||
return manifest;
|
||||
}
|
||||
if (!manifest || typeof manifest !== 'object') {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Legacy/ad-hoc object map: stem -> requires[]
|
||||
const arrayEntries = Object.entries(manifest as Record<string, unknown>).filter(([, value]) => Array.isArray(value)) as [string, string[]][];
|
||||
if (arrayEntries.length > 0) {
|
||||
return new Map(arrayEntries.map(([stem, deps]) => [stem, deps]));
|
||||
}
|
||||
|
||||
// Parsed gsd-file-manifest.json shape: rebuild the dependency map from source.
|
||||
const manifestObj = manifest as Record<string, unknown>;
|
||||
if (manifestObj['files'] && typeof manifestObj['files'] === 'object') {
|
||||
const srcCommandsDir = findInstallSourceRoot(runtimeConfigDir);
|
||||
return loadSkillsManifest(srcCommandsDir);
|
||||
}
|
||||
|
||||
return new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective surface to a typed profile-like object.
|
||||
* Shape: { name, skills: Set<string>|'*', agents: Set<string> }
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Start with base profile resolved via resolveProfile()
|
||||
* 2. Remove skills in disabled clusters
|
||||
* 3. Add explicitAdds (and their transitive closure)
|
||||
* 4. Remove explicitRemoves (only the stem itself, no cascade)
|
||||
*/
|
||||
function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>): { name: string; skills: Set<string>; agents: Set<string> } {
|
||||
const cm = clusterMap || CLUSTERS;
|
||||
const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest);
|
||||
const surface = readSurface(runtimeConfigDir);
|
||||
|
||||
// Determine base profile name: from surface state or from .gsd-profile marker
|
||||
const baseProfileName = (surface && surface.baseProfile)
|
||||
? surface.baseProfile
|
||||
: (readActiveProfile(runtimeConfigDir) || 'full');
|
||||
|
||||
// Resolve base profile
|
||||
const baseResolved = resolveProfile({
|
||||
modes: baseProfileName.split(',').map((s: string) => s.trim()),
|
||||
manifest: skillManifest,
|
||||
});
|
||||
|
||||
// If full, we need to enumerate all skills from the manifest
|
||||
let skills: Set<string>;
|
||||
if (baseResolved.skills === '*') {
|
||||
// Materialize all skill stems from manifest
|
||||
skills = new Set<string>();
|
||||
for (const [key] of skillManifest) {
|
||||
if (!key.startsWith('_calls_agents_')) skills.add(key);
|
||||
}
|
||||
} else {
|
||||
skills = new Set(baseResolved.skills);
|
||||
}
|
||||
|
||||
if (surface) {
|
||||
// Step 2: remove disabled cluster members
|
||||
const disabledSkills = clustersToSkills(surface.disabledClusters, cm);
|
||||
for (const s of disabledSkills) skills.delete(s);
|
||||
|
||||
// Step 3: add explicitAdds with transitive closure
|
||||
if (surface.explicitAdds.length > 0) {
|
||||
const addSet = new Set(surface.explicitAdds);
|
||||
// Compute closure of adds
|
||||
const queue = [...addSet];
|
||||
const visited = new Set(addSet);
|
||||
while (queue.length > 0) {
|
||||
const stem = queue.pop()!;
|
||||
const deps = skillManifest.get(stem) || [];
|
||||
for (const dep of deps) {
|
||||
if (!visited.has(dep)) {
|
||||
visited.add(dep);
|
||||
queue.push(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const s of visited) skills.add(s);
|
||||
}
|
||||
|
||||
// Step 4: remove explicitRemoves (stem only, no cascade)
|
||||
for (const s of surface.explicitRemoves) {
|
||||
skills.delete(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive agents from skills
|
||||
const agents = new Set<string>();
|
||||
for (const skillStem of skills) {
|
||||
const agentRefs = skillManifest.get(`_calls_agents_${skillStem}`) || [];
|
||||
for (const agentStem of agentRefs) agents.add(agentStem);
|
||||
}
|
||||
|
||||
const name = surface ? `surface:${surface.baseProfile}` : `profile:${baseProfileName}`;
|
||||
return { name, skills, agents };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Re-stage the active surface using the resolved layout.
|
||||
* Iterates layout.kinds and syncs each artifact kind to its destination.
|
||||
*/
|
||||
function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>): { name: string; skills: Set<string>; agents: Set<string> } {
|
||||
if (path.resolve(runtimeConfigDir) !== path.resolve(layout.configDir)) {
|
||||
throw new TypeError('applySurface runtimeConfigDir must match layout.configDir');
|
||||
}
|
||||
const skillManifest = normalizeSkillManifest(layout.configDir, manifest);
|
||||
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap);
|
||||
for (const kind of layout.kinds) {
|
||||
const staged = kind.stage(resolved);
|
||||
const dest = path.join(layout.configDir, kind.destSubpath);
|
||||
_syncGsdDir(staged, dest, kind, skillManifest);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune GSD-managed skill directories from a skills directory.
|
||||
*
|
||||
* Removes every directory in `skillsDir` that is GSD-owned but NOT listed
|
||||
* in `retainedNames`. User-owned dirs (not matching the GSD ownership criteria)
|
||||
* are always preserved.
|
||||
*
|
||||
* Ownership criteria:
|
||||
* - Non-empty prefix (e.g. 'gsd-'): dir name starts with that prefix AND
|
||||
* appears in the manifest (manifest membership is required). Dirs that match
|
||||
* the prefix but are NOT in the manifest are treated as user-owned and
|
||||
* preserved — this prevents data loss for user-created gsd-* directories.
|
||||
* A warning is written to stderr when such a dir is encountered.
|
||||
* - Empty prefix (Hermes): dir name appears as a canonical skill stem in the
|
||||
* manifest. User dirs not in the manifest are preserved.
|
||||
* - Empty prefix without manifest, or manifest not a Map: conservative; no
|
||||
* dirs are removed.
|
||||
*
|
||||
* This is the single point of truth for skill-dir pruning. Both _syncGsdDir
|
||||
* (surface apply) and callers that need stand-alone pruning use this function.
|
||||
*
|
||||
* @param skillsDir directory that contains the gsd-STEM sub-dirs
|
||||
* @param retainedNames set of directory names to keep (e.g. 'gsd-help')
|
||||
* @param prefix GSD dir prefix, e.g. 'gsd-' (or '' for Hermes)
|
||||
* @param manifest optional; required for Hermes empty-prefix case
|
||||
* and for manifest-membership gate in prefixed case.
|
||||
* Must be a Map; any other type is treated as missing.
|
||||
*/
|
||||
function pruneSkillDirs(skillsDir: string, retainedNames: Set<string>, prefix: string, manifest?: Map<string, string[]>): void {
|
||||
if (!fs.existsSync(skillsDir)) return;
|
||||
|
||||
// Finding 2: guard against callers passing a truthy non-Map as manifest.
|
||||
// A non-Map manifest would throw on .keys(); treat it as absent and be conservative.
|
||||
const safeManifest = (manifest instanceof Map) ? manifest : null;
|
||||
|
||||
// Build the canonical stem set from the manifest (used for both prefixed and Hermes paths).
|
||||
// Deletion requires manifest membership — without a valid manifest, be conservative.
|
||||
const canonicalStems = safeManifest
|
||||
? new Set([...safeManifest.keys()].filter(k => !k.startsWith('_calls_agents_')))
|
||||
: null;
|
||||
|
||||
for (const entry of fs.readdirSync(skillsDir)) {
|
||||
const entryPath = path.join(skillsDir, entry);
|
||||
if (!fs.statSync(entryPath).isDirectory()) continue;
|
||||
|
||||
let isGsdOwned: boolean;
|
||||
if (prefix !== '') {
|
||||
if (!entry.startsWith(prefix)) {
|
||||
// Does not match prefix at all — user-owned, preserve.
|
||||
continue;
|
||||
}
|
||||
if (!canonicalStems) {
|
||||
// No manifest available: cannot confirm ownership — preserve conservatively.
|
||||
continue;
|
||||
}
|
||||
// Finding 1 fix: prefix match is necessary but NOT sufficient.
|
||||
// The dir must also be in the manifest to be considered GSD-owned.
|
||||
// A user-created gsd-* dir that isn't in the manifest is preserved with a warning.
|
||||
if (!canonicalStems.has(entry.slice(prefix.length))) {
|
||||
process.stderr.write(
|
||||
`[gsd] Warning: ${entry} matches GSD prefix '${prefix}' but is not in the manifest — preserving (user-owned or unknown)\n`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
isGsdOwned = true;
|
||||
} else if (canonicalStems) {
|
||||
// Hermes: GSD-owned iff the directory name appears in the canonical manifest.
|
||||
isGsdOwned = canonicalStems.has(entry);
|
||||
} else {
|
||||
// No manifest available: be conservative, don't remove anything.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isGsdOwned) continue; // Hermes path only: preserve user-owned dirs not in manifest
|
||||
if (retainedNames.has(entry)) continue; // GSD-owned and in retain set
|
||||
try {
|
||||
fs.rmSync(entryPath, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
process.stderr.write(`surface: failed to prune ${entryPath}: ${(err as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync destination directory from staged source.
|
||||
*
|
||||
* For 'commands' kind: iterate *.md files in destDir, remove if not in staged set.
|
||||
* For 'agents' kind: same, but only remove files starting with 'gsd-' prefix.
|
||||
* For 'skills' kind: iterate directories in destDir matching kind.prefix; add missing
|
||||
* by copying recursively; remove dirs not in staged set. Preserves dirs not matching
|
||||
* the prefix (user-owned skills). Pruning is delegated to pruneSkillDirs().
|
||||
*
|
||||
* For Hermes (empty prefix): uses manifest membership to discriminate GSD-owned vs
|
||||
* user-owned dirs. GSD-owned = stem in manifest; removal targets = in manifest AND
|
||||
* not in staged set. User-owned (not in manifest) are always preserved.
|
||||
*/
|
||||
function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | string, manifest?: Map<string, string[]>): void {
|
||||
if (!fs.existsSync(stagedDir)) return;
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
// Normalize: allow legacy string context for backward-compat with internal callers
|
||||
const kindName = (typeof kind === 'string') ? kind : kind.kind;
|
||||
const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-';
|
||||
|
||||
if (kindName === 'skills') {
|
||||
// Skills kind: work with directories, not files.
|
||||
// Each staged entry is a directory named ${prefix}${stem}.
|
||||
const stagedDirs = new Set<string>(
|
||||
fs.readdirSync(stagedDir).filter(entry => {
|
||||
return fs.statSync(path.join(stagedDir, entry)).isDirectory();
|
||||
})
|
||||
);
|
||||
|
||||
// Copy missing dirs from staged to dest (always overwrite to ensure content is current)
|
||||
for (const dirName of stagedDirs) {
|
||||
const destSubDir = path.join(destDir, dirName);
|
||||
fs.cpSync(path.join(stagedDir, dirName), destSubDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Prune GSD-owned dirs that are no longer in the staged set.
|
||||
// pruneSkillDirs() is the single point of truth for this logic.
|
||||
pruneSkillDirs(destDir, stagedDirs, kindPrefix, manifest);
|
||||
} else {
|
||||
// commands / agents kind: work with .md files
|
||||
const stagedFiles = new Set<string>(
|
||||
fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'))
|
||||
);
|
||||
|
||||
// Copy files from staged to dest (overwrite to keep content current)
|
||||
for (const file of stagedFiles) {
|
||||
fs.copyFileSync(path.join(stagedDir, file), path.join(destDir, file));
|
||||
}
|
||||
|
||||
// Remove gsd-only files from dest that aren't in staged set
|
||||
// For commands dir: all .md files are gsd skills
|
||||
// For agents dir: only gsd-* files
|
||||
const destEntries = fs.readdirSync(destDir).filter(f => f.endsWith('.md'));
|
||||
for (const file of destEntries) {
|
||||
if (kindName === 'agents' && !file.startsWith('gsd-')) continue;
|
||||
if (!stagedFiles.has(file)) {
|
||||
try { fs.unlinkSync(path.join(destDir, file)); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List the currently enabled and disabled skills with token cost.
|
||||
*
|
||||
* Token cost = sum of description lengths ÷ 4 (mirrors audit script).
|
||||
* Descriptions are read from the install source (findInstallSourceRoot).
|
||||
*/
|
||||
function listSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>): { enabled: string[]; disabled: string[]; tokenCost: number } {
|
||||
const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest);
|
||||
const resolved = resolveSurface(runtimeConfigDir, skillManifest, clusterMap);
|
||||
|
||||
// All known stems from manifest (exclude _calls_agents_ meta keys)
|
||||
const allStems: string[] = [];
|
||||
for (const [key] of skillManifest) {
|
||||
if (!key.startsWith('_calls_agents_')) allStems.push(key);
|
||||
}
|
||||
|
||||
const enabledSet = resolved.skills instanceof Set ? resolved.skills : new Set(allStems);
|
||||
|
||||
const enabled = allStems.filter(s => enabledSet.has(s)).sort();
|
||||
const disabled = allStems.filter(s => !enabledSet.has(s)).sort();
|
||||
|
||||
// Compute token cost by reading descriptions from the install source
|
||||
const srcCommandsDir = findInstallSourceRoot(runtimeConfigDir);
|
||||
let tokenCost = 0;
|
||||
for (const stem of enabled) {
|
||||
const filePath = path.join(srcCommandsDir, `${stem}.md`);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const descMatch = content.match(/^description:\s*(.+)$/m);
|
||||
if (descMatch) {
|
||||
tokenCost += Math.ceil(descMatch[1].trim().length / 4);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return { enabled, disabled, tokenCost };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export = {
|
||||
readSurface,
|
||||
writeSurface,
|
||||
resolveSurface,
|
||||
applySurface,
|
||||
listSurface,
|
||||
// Exported for testing and for callers that need stand-alone pruning
|
||||
pruneSkillDirs,
|
||||
_syncGsdDir,
|
||||
};
|
||||
111
src/task-command-router.cts
Normal file
111
src/task-command-router.cts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Task command router — is-behavior-adding subcommand handler.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/task-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 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, ERROR_REASON } = core;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BehaviorAddingChecks {
|
||||
tdd_true: boolean;
|
||||
has_behavior_block: boolean;
|
||||
has_source_files: boolean;
|
||||
}
|
||||
|
||||
interface BehaviorAddingResult {
|
||||
is_behavior_adding: boolean;
|
||||
checks: BehaviorAddingChecks;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
interface RouteTaskCommandOptions {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
function isBehaviorAddingTaskContent(content: string): BehaviorAddingResult {
|
||||
const tddTrue = /\btdd\s*=\s*["']true["']/i.test(content);
|
||||
|
||||
const behaviorMatch = content.match(/<behavior>([\s\S]*?)<\/behavior>/i);
|
||||
const hasBehaviorBlock = Boolean(behaviorMatch && behaviorMatch[1].trim().length > 0);
|
||||
|
||||
const filesMatch = content.match(/<files>([\s\S]*?)<\/files>/i);
|
||||
let hasSourceFiles = false;
|
||||
if (filesMatch) {
|
||||
const fileLines = filesMatch[1]
|
||||
.split(/[\n,]/)
|
||||
.map((line) => line.trim().replace(/^[-*]\s*/, ''))
|
||||
.filter(Boolean);
|
||||
hasSourceFiles = fileLines.some((file) =>
|
||||
!/\.md$/i.test(file) &&
|
||||
!/\.json$/i.test(file) &&
|
||||
!/\.test\.[^.]+$/i.test(file) &&
|
||||
!/\.spec\.[^.]+$/i.test(file) &&
|
||||
!/(^|[\\/])tests?[\\/]/i.test(file) &&
|
||||
!/\.(yml|yaml|toml|ini|cfg|conf|properties)$/i.test(file) &&
|
||||
!/(^|[\\/])\.env(\..+)?$/i.test(file)
|
||||
);
|
||||
}
|
||||
|
||||
const isBehaviorAdding = tddTrue && hasBehaviorBlock && hasSourceFiles;
|
||||
const missing: string[] = [];
|
||||
if (!tddTrue) missing.push('tdd="true" frontmatter absent');
|
||||
if (!hasBehaviorBlock) missing.push('<behavior> block missing or empty');
|
||||
if (!hasSourceFiles) missing.push('<files> has no non-test source file');
|
||||
|
||||
return {
|
||||
is_behavior_adding: isBehaviorAdding,
|
||||
checks: {
|
||||
tdd_true: tddTrue,
|
||||
has_behavior_block: hasBehaviorBlock,
|
||||
has_source_files: hasSourceFiles,
|
||||
},
|
||||
reason: isBehaviorAdding ? null : `Not behavior-adding: ${missing.join('; ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
function routeTaskCommand({ args, cwd, raw }: RouteTaskCommandOptions): void {
|
||||
const subcommand = args[1];
|
||||
if (subcommand !== 'is-behavior-adding') {
|
||||
error('Unknown task subcommand. Available: is-behavior-adding', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
}
|
||||
|
||||
let content: string | null = null;
|
||||
if (args[2] === '--task-content') {
|
||||
content = args[3] || null;
|
||||
} else if (args[2]) {
|
||||
const projectRoot = path.resolve(cwd || process.cwd());
|
||||
const requestedPath = args[2];
|
||||
const resolvedTaskPath = path.resolve(projectRoot, requestedPath);
|
||||
const rel = path.relative(projectRoot, resolvedTaskPath);
|
||||
if (rel === '..' || rel.startsWith(`..${path.sep}`)) {
|
||||
error(`Task file is outside project scope: ${requestedPath}`, ERROR_REASON.USAGE);
|
||||
}
|
||||
if (!fs.existsSync(resolvedTaskPath)) {
|
||||
error(`Task file not found: ${requestedPath}`, ERROR_REASON.USAGE);
|
||||
}
|
||||
content = fs.readFileSync(resolvedTaskPath, 'utf-8');
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
error('Usage: task.is-behavior-adding <plan-file-path> | --task-content "<xml>"', ERROR_REASON.USAGE);
|
||||
}
|
||||
|
||||
output(isBehaviorAddingTaskContent(content as string), raw, undefined);
|
||||
}
|
||||
|
||||
export = {
|
||||
isBehaviorAddingTaskContent,
|
||||
routeTaskCommand,
|
||||
};
|
||||
268
src/template.cts
Normal file
268
src/template.cts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Template — Template selection and fill operations
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/template.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.
|
||||
*/
|
||||
|
||||
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 { normalizePhaseName, findPhaseInternal, generateSlugInternal, toPosixPath, output, error } = core;
|
||||
import { platformWriteSync } 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 { reconstructFrontmatter } = frontmatter;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Mirrors frontmatter.cts Frontmatter type for type-safe reconstruction calls.
|
||||
type FrontmatterValue = string | string[] | Record<string, unknown>;
|
||||
type FrontmatterFields = Record<string, FrontmatterValue>;
|
||||
|
||||
interface TemplateFillOptions {
|
||||
phase?: string;
|
||||
name?: string;
|
||||
plan?: string;
|
||||
type?: string;
|
||||
wave?: string | number;
|
||||
fields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TemplateSelectResult {
|
||||
template: string;
|
||||
type: string;
|
||||
taskCount?: number;
|
||||
fileCount?: number;
|
||||
hasDecisions?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── cmdTemplateSelect ────────────────────────────────────────────────────────
|
||||
|
||||
function cmdTemplateSelect(cwd: string, planPath: string | null | undefined, raw: boolean): void {
|
||||
if (!planPath) {
|
||||
error('plan-path required');
|
||||
}
|
||||
|
||||
try {
|
||||
const fullPath = path.join(cwd, planPath!);
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
|
||||
// Simple heuristics
|
||||
const taskMatch = content.match(/###\s*Task\s*\d+/g) || [];
|
||||
const taskCount = taskMatch.length;
|
||||
|
||||
const decisionMatch = content.match(/decision/gi) || [];
|
||||
const hasDecisions = decisionMatch.length > 0;
|
||||
|
||||
// Count file mentions
|
||||
const fileMentions = new Set<string>();
|
||||
const filePattern = /`([^`]+\.[a-zA-Z]+)`/g;
|
||||
let m;
|
||||
while ((m = filePattern.exec(content)) !== null) {
|
||||
if (m[1].includes('/') && !m[1].startsWith('http')) {
|
||||
fileMentions.add(m[1]);
|
||||
}
|
||||
}
|
||||
const fileCount = fileMentions.size;
|
||||
|
||||
let template = 'templates/summary-standard.md';
|
||||
let type = 'standard';
|
||||
|
||||
if (taskCount <= 2 && fileCount <= 3 && !hasDecisions) {
|
||||
template = 'templates/summary-minimal.md';
|
||||
type = 'minimal';
|
||||
} else if (hasDecisions || fileCount > 6 || taskCount > 5) {
|
||||
template = 'templates/summary-complex.md';
|
||||
type = 'complex';
|
||||
}
|
||||
|
||||
const result: TemplateSelectResult = { template, type, taskCount, fileCount, hasDecisions };
|
||||
output(result, raw, template);
|
||||
} catch (e) {
|
||||
// Fallback to standard
|
||||
output({ template: 'templates/summary-standard.md', type: 'standard', error: (e as Error).message }, raw, 'templates/summary-standard.md');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── cmdTemplateFill ──────────────────────────────────────────────────────────
|
||||
|
||||
function cmdTemplateFill(cwd: string, templateType: string | null | undefined, options: TemplateFillOptions, raw: boolean): void {
|
||||
if (!templateType) { error('template type required: summary, plan, or verification'); }
|
||||
if (!options.phase) { error('--phase required'); }
|
||||
|
||||
const phaseInfo = findPhaseInternal(cwd, options.phase);
|
||||
if (!phaseInfo || !phaseInfo.found) { output({ error: 'Phase not found', phase: options.phase }, raw, undefined); return; }
|
||||
|
||||
const padded = normalizePhaseName(options.phase);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const phaseName = options.name || phaseInfo.phase_name || 'Unnamed';
|
||||
const phaseSlug = phaseInfo.phase_slug || generateSlugInternal(phaseName);
|
||||
const phaseId = `${padded}-${phaseSlug}`;
|
||||
const planNum = (options.plan || '01').padStart(2, '0');
|
||||
const fields = options.fields || {};
|
||||
|
||||
let frontmatterObj: Record<string, unknown>;
|
||||
let body: string;
|
||||
let fileName: string;
|
||||
|
||||
switch (templateType) {
|
||||
case 'summary': {
|
||||
frontmatterObj = {
|
||||
phase: phaseId,
|
||||
plan: planNum,
|
||||
subsystem: '[primary category]',
|
||||
tags: [],
|
||||
provides: [],
|
||||
affects: [],
|
||||
'tech-stack': { added: [], patterns: [] },
|
||||
'key-files': { created: [], modified: [] },
|
||||
'key-decisions': [],
|
||||
'patterns-established': [],
|
||||
duration: '[X]min',
|
||||
completed: today,
|
||||
...fields,
|
||||
};
|
||||
body = [
|
||||
`# Phase ${options.phase}: ${phaseName} Summary`,
|
||||
'',
|
||||
'**[Substantive one-liner describing outcome]**',
|
||||
'',
|
||||
'## Performance',
|
||||
'- **Duration:** [time]',
|
||||
'- **Tasks:** [count completed]',
|
||||
'- **Files modified:** [count]',
|
||||
'',
|
||||
'## Accomplishments',
|
||||
'- [Key outcome 1]',
|
||||
'- [Key outcome 2]',
|
||||
'',
|
||||
'## Task Commits',
|
||||
'1. **Task 1: [task name]** - `hash`',
|
||||
'',
|
||||
'## Files Created/Modified',
|
||||
'- `path/to/file.ts` - What it does',
|
||||
'',
|
||||
'## Decisions & Deviations',
|
||||
'[Key decisions or "None - followed plan as specified"]',
|
||||
'',
|
||||
'## Next Phase Readiness',
|
||||
'[What\'s ready for next phase]',
|
||||
].join('\n');
|
||||
fileName = `${padded}-${planNum}-SUMMARY.md`;
|
||||
break;
|
||||
}
|
||||
case 'plan': {
|
||||
const planType = options.type || 'execute';
|
||||
const wave = parseInt(String(options.wave), 10) || 1;
|
||||
frontmatterObj = {
|
||||
phase: phaseId,
|
||||
plan: planNum,
|
||||
type: planType,
|
||||
wave,
|
||||
depends_on: [],
|
||||
files_modified: [],
|
||||
autonomous: true,
|
||||
user_setup: [],
|
||||
must_haves: { truths: [], artifacts: [], key_links: [] },
|
||||
...fields,
|
||||
};
|
||||
const planBase = planningDir(cwd);
|
||||
const projectRef = toPosixPath(path.relative(cwd, path.join(planBase, 'PROJECT.md')));
|
||||
const roadmapRef = toPosixPath(path.relative(cwd, path.join(planBase, 'ROADMAP.md')));
|
||||
const stateRef = toPosixPath(path.relative(cwd, path.join(planBase, 'STATE.md')));
|
||||
body = [
|
||||
`# Phase ${options.phase} Plan ${planNum}: [Title]`,
|
||||
'',
|
||||
'## Objective',
|
||||
'- **What:** [What this plan builds]',
|
||||
'- **Why:** [Why it matters for the phase goal]',
|
||||
'- **Output:** [Concrete deliverable]',
|
||||
'',
|
||||
'## Context',
|
||||
`@${projectRef}`,
|
||||
`@${roadmapRef}`,
|
||||
`@${stateRef}`,
|
||||
'',
|
||||
'## Tasks',
|
||||
'',
|
||||
'<task type="code">',
|
||||
' <name>[Task name]</name>',
|
||||
' <files>[file paths]</files>',
|
||||
' <action>[What to do]</action>',
|
||||
' <verify>[How to verify]</verify>',
|
||||
' <done>[Definition of done]</done>',
|
||||
'</task>',
|
||||
'',
|
||||
'## Verification',
|
||||
'[How to verify this plan achieved its objective]',
|
||||
'',
|
||||
'## Success Criteria',
|
||||
'- [ ] [Criterion 1]',
|
||||
'- [ ] [Criterion 2]',
|
||||
].join('\n');
|
||||
fileName = `${padded}-${planNum}-PLAN.md`;
|
||||
break;
|
||||
}
|
||||
case 'verification': {
|
||||
frontmatterObj = {
|
||||
phase: phaseId,
|
||||
verified: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
score: '0/0 must-haves verified',
|
||||
...fields,
|
||||
};
|
||||
body = [
|
||||
`# Phase ${options.phase}: ${phaseName} — Verification`,
|
||||
'',
|
||||
'## Observable Truths',
|
||||
'| # | Truth | Status | Evidence |',
|
||||
'|---|-------|--------|----------|',
|
||||
'| 1 | [Truth] | pending | |',
|
||||
'',
|
||||
'## Required Artifacts',
|
||||
'| Artifact | Expected | Status | Details |',
|
||||
'|----------|----------|--------|---------|',
|
||||
'| [path] | [what] | pending | |',
|
||||
'',
|
||||
'## Key Link Verification',
|
||||
'| From | To | Via | Status | Details |',
|
||||
'|------|----|----|--------|---------|',
|
||||
'| [source] | [target] | [connection] | pending | |',
|
||||
'',
|
||||
'## Requirements Coverage',
|
||||
'| Requirement | Status | Blocking Issue |',
|
||||
'|-------------|--------|----------------|',
|
||||
'| [req] | pending | |',
|
||||
'',
|
||||
'## Result',
|
||||
'[Pending verification]',
|
||||
].join('\n');
|
||||
fileName = `${padded}-VERIFICATION.md`;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
error(`Unknown template type: ${templateType}. Available: summary, plan, verification`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullContent = `---\n${reconstructFrontmatter(frontmatterObj as FrontmatterFields)}\n---\n\n${body}\n`;
|
||||
const outPath = path.join(cwd, phaseInfo.directory, fileName);
|
||||
|
||||
if (fs.existsSync(outPath)) {
|
||||
output({ error: 'File already exists', path: toPosixPath(path.relative(cwd, outPath)) }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
platformWriteSync(outPath, fullContent);
|
||||
const relPath = toPosixPath(path.relative(cwd, outPath));
|
||||
output({ created: true, path: relPath, template: templateType }, raw, relPath);
|
||||
}
|
||||
|
||||
export = { cmdTemplateSelect, cmdTemplateFill };
|
||||
350
src/uat.cts
Normal file
350
src/uat.cts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* UAT Audit — Cross-phase UAT/VERIFICATION scanner
|
||||
*
|
||||
* Reads all *-UAT.md and *-VERIFICATION.md files across all phases.
|
||||
* Extracts non-passing items. Returns structured JSON for workflow consumption.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/uat.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.
|
||||
*/
|
||||
|
||||
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, getMilestonePhaseFilter, toPosixPath } = core;
|
||||
// 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';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type UatResult = string;
|
||||
type UatCategory = 'server_blocked' | 'device_needed' | 'build_needed' | 'third_party' | 'blocked' | 'skipped_unresolved' | 'pending' | 'human_uat' | 'unknown';
|
||||
|
||||
interface UatItem {
|
||||
test?: number;
|
||||
name: string;
|
||||
expected?: string;
|
||||
result: UatResult;
|
||||
category: UatCategory;
|
||||
reason?: string;
|
||||
blocked_by?: string;
|
||||
}
|
||||
|
||||
interface UatFileResult {
|
||||
phase: string;
|
||||
phase_dir: string;
|
||||
file: string;
|
||||
file_path: string;
|
||||
type: 'uat' | 'verification';
|
||||
status: string;
|
||||
items: UatItem[];
|
||||
}
|
||||
|
||||
interface CurrentTest {
|
||||
complete: boolean;
|
||||
number?: number;
|
||||
name?: string;
|
||||
expected?: string;
|
||||
}
|
||||
|
||||
// ─── cmdAuditUat ─────────────────────────────────────────────────────────────
|
||||
|
||||
function cmdAuditUat(cwd: string, raw: boolean): void {
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
if (!fs.existsSync(phasesDir)) {
|
||||
error('No phases directory found in planning directory');
|
||||
}
|
||||
|
||||
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
||||
const results: UatFileResult[] = [];
|
||||
|
||||
// Scan all phase directories
|
||||
const dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.filter(isDirInMilestone)
|
||||
.sort();
|
||||
|
||||
for (const dir of dirs) {
|
||||
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
||||
const phaseDir = path.join(phasesDir, dir);
|
||||
const files = fs.readdirSync(phaseDir);
|
||||
|
||||
// Process UAT files
|
||||
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
|
||||
const content = fs.readFileSync(path.join(phaseDir, file), 'utf-8');
|
||||
const items = parseUatItems(content);
|
||||
if (items.length > 0) {
|
||||
results.push({
|
||||
phase: phaseNum,
|
||||
phase_dir: dir,
|
||||
file,
|
||||
file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))),
|
||||
type: 'uat',
|
||||
status: (extractFrontmatter(content).status as string || 'unknown'),
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process VERIFICATION files
|
||||
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
||||
const content = fs.readFileSync(path.join(phaseDir, file), 'utf-8');
|
||||
const status = extractFrontmatter(content).status as string || 'unknown';
|
||||
if (status === 'human_needed' || status === 'gaps_found') {
|
||||
const items = parseVerificationItems(content, status);
|
||||
if (items.length > 0) {
|
||||
results.push({
|
||||
phase: phaseNum,
|
||||
phase_dir: dir,
|
||||
file,
|
||||
file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))),
|
||||
type: 'verification',
|
||||
status,
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute summary
|
||||
const summary: {
|
||||
total_files: number;
|
||||
total_items: number;
|
||||
by_category: Record<string, number>;
|
||||
by_phase: Record<string, number>;
|
||||
} = {
|
||||
total_files: results.length,
|
||||
total_items: results.reduce((sum, r) => sum + r.items.length, 0),
|
||||
by_category: {},
|
||||
by_phase: {},
|
||||
};
|
||||
|
||||
for (const r of results) {
|
||||
if (!summary.by_phase[r.phase]) summary.by_phase[r.phase] = 0;
|
||||
for (const item of r.items) {
|
||||
summary.by_phase[r.phase]++;
|
||||
const cat = item.category || 'unknown';
|
||||
summary.by_category[cat] = (summary.by_category[cat] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
output({ results, summary }, raw, undefined);
|
||||
}
|
||||
|
||||
// ─── cmdRenderCheckpoint ──────────────────────────────────────────────────────
|
||||
|
||||
function cmdRenderCheckpoint(cwd: string, options: { file?: string } = {}, raw: boolean): void {
|
||||
const filePath = options.file;
|
||||
if (!filePath) {
|
||||
error('UAT file required: use uat render-checkpoint --file <path>');
|
||||
}
|
||||
|
||||
const resolvedPath = requireSafePath(filePath, cwd, 'UAT file', { allowAbsolute: true });
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
error(`UAT file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(resolvedPath, 'utf-8');
|
||||
const currentTest = parseCurrentTest(content);
|
||||
|
||||
if (currentTest.complete) {
|
||||
error('UAT session is already complete; no pending checkpoint to render');
|
||||
}
|
||||
|
||||
const checkpoint = buildCheckpoint(currentTest as Required<Omit<CurrentTest, 'complete'>> & { complete: false });
|
||||
output({
|
||||
file_path: toPosixPath(path.relative(cwd, resolvedPath)),
|
||||
test_number: currentTest.number,
|
||||
test_name: currentTest.name,
|
||||
checkpoint,
|
||||
}, raw, checkpoint);
|
||||
}
|
||||
|
||||
// ─── parseCurrentTest ─────────────────────────────────────────────────────────
|
||||
|
||||
function parseCurrentTest(content: string): CurrentTest {
|
||||
const currentTestMatch = content.match(/##\s*Current Test\s*(?:\n<!--[\s\S]*?-->)?\n([\s\S]*?)(?=\n##\s|$)/i);
|
||||
if (!currentTestMatch) {
|
||||
error('UAT file is missing a Current Test section');
|
||||
}
|
||||
|
||||
const section = currentTestMatch![1].trimEnd();
|
||||
if (!section.trim()) {
|
||||
error('Current Test section is empty');
|
||||
}
|
||||
|
||||
if (/\[testing complete\]/i.test(section)) {
|
||||
return { complete: true };
|
||||
}
|
||||
|
||||
const numberMatch = section.match(/^number:\s*(\d+)\s*$/m);
|
||||
const nameMatch = section.match(/^name:\s*(.+)\s*$/m);
|
||||
const expectedBlockMatch = section.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s)/m)
|
||||
|| section.match(/^expected:\s*\|\n([\s\S]+)/m);
|
||||
const expectedInlineMatch = section.match(/^expected:\s*(.+)\s*$/m);
|
||||
|
||||
if (!numberMatch || !nameMatch || (!expectedBlockMatch && !expectedInlineMatch)) {
|
||||
error('Current Test section is malformed');
|
||||
}
|
||||
|
||||
let expected: string;
|
||||
if (expectedBlockMatch) {
|
||||
expected = expectedBlockMatch[1]
|
||||
.split('\n')
|
||||
.map((line: string) => line.replace(/^ {2}/, ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
} else {
|
||||
expected = expectedInlineMatch![1].trim();
|
||||
}
|
||||
|
||||
return {
|
||||
complete: false,
|
||||
number: parseInt(numberMatch![1], 10),
|
||||
name: sanitizeForDisplay(nameMatch![1].trim()),
|
||||
expected: sanitizeForDisplay(expected),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── buildCheckpoint ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildCheckpoint(currentTest: { number: number; name: string; expected: string }): string {
|
||||
return [
|
||||
'╔══════════════════════════════════════════════════════════════╗',
|
||||
'║ CHECKPOINT: Verification Required ║',
|
||||
'╚══════════════════════════════════════════════════════════════╝',
|
||||
'',
|
||||
`**Test ${currentTest.number}: ${currentTest.name}**`,
|
||||
'',
|
||||
currentTest.expected,
|
||||
'',
|
||||
'──────────────────────────────────────────────────────────────',
|
||||
'Type `pass` or describe what\'s wrong.',
|
||||
'──────────────────────────────────────────────────────────────',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── parseUatItems ────────────────────────────────────────────────────────────
|
||||
|
||||
function parseUatItems(content: string): UatItem[] {
|
||||
const items: UatItem[] = [];
|
||||
// Match test blocks: ### N. Name\nexpected: ...\nresult: ...\n
|
||||
// Accept both bare (result: pending) and bracketed (result: [pending]) formats (#2273)
|
||||
const testPattern = /###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*\[?(\w+)\]?(?:\n(?:reported|reason|blocked_by):\s*[^\n]*)?/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = testPattern.exec(content)) !== null) {
|
||||
const [, num, name, expected, result] = match;
|
||||
if (result === 'pending' || result === 'skipped' || result === 'blocked') {
|
||||
// Extract optional fields — limit to current test block (up to next ### or EOF)
|
||||
const afterMatch = content.slice(match.index);
|
||||
const nextHeading = afterMatch.indexOf('\n###', 1);
|
||||
const blockText = nextHeading > 0 ? afterMatch.slice(0, nextHeading) : afterMatch;
|
||||
const reasonMatch = blockText.match(/reason:\s*(.+)/);
|
||||
const blockedByMatch = blockText.match(/blocked_by:\s*(.+)/);
|
||||
|
||||
const item: UatItem = {
|
||||
test: parseInt(num, 10),
|
||||
name: name.trim(),
|
||||
expected: expected.trim(),
|
||||
result,
|
||||
category: categorizeItem(result, reasonMatch?.[1], blockedByMatch?.[1]),
|
||||
};
|
||||
if (reasonMatch) item.reason = reasonMatch[1].trim();
|
||||
if (blockedByMatch) item.blocked_by = blockedByMatch[1].trim();
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// ─── parseVerificationItems ───────────────────────────────────────────────────
|
||||
|
||||
function parseVerificationItems(content: string, status: string): UatItem[] {
|
||||
const items: UatItem[] = [];
|
||||
if (status === 'human_needed') {
|
||||
// Extract from human_verification section — look for numbered items or table rows
|
||||
const hvSection = content.match(/##\s*Human Verification.*?\n([\s\S]*?)(?=\n##\s|\n---\s|$)/i);
|
||||
if (hvSection) {
|
||||
const lines = hvSection[1].split('\n');
|
||||
for (const line of lines) {
|
||||
// Match table rows: | N | description | ... |
|
||||
const tableMatch = line.match(/\|\s*(\d+)\s*\|\s*([^|]+)/);
|
||||
// Match bullet items: - description
|
||||
const bulletMatch = line.match(/^[-*]\s+(.+)/);
|
||||
// Match numbered items: 1. description
|
||||
const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
|
||||
|
||||
if (tableMatch) {
|
||||
// Skip rows that already have a passing result (PASS, pass, resolved, etc.)
|
||||
const rowRemainder = line.slice(tableMatch.index! + tableMatch[0].length);
|
||||
const cellValues = rowRemainder.split('|').map(c => c.trim());
|
||||
const hasPassResult = cellValues.some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
|
||||
if (hasPassResult) continue;
|
||||
items.push({
|
||||
test: parseInt(tableMatch[1], 10),
|
||||
name: tableMatch[2].trim(),
|
||||
result: 'human_needed',
|
||||
category: 'human_uat',
|
||||
});
|
||||
} else if (numberedMatch) {
|
||||
items.push({
|
||||
test: parseInt(numberedMatch[1], 10),
|
||||
name: numberedMatch[2].trim(),
|
||||
result: 'human_needed',
|
||||
category: 'human_uat',
|
||||
});
|
||||
} else if (bulletMatch && bulletMatch[1].length > 10) {
|
||||
items.push({
|
||||
name: bulletMatch[1].trim(),
|
||||
result: 'human_needed',
|
||||
category: 'human_uat',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// gaps_found items are already handled by plan-phase --gaps pipeline
|
||||
return items;
|
||||
}
|
||||
|
||||
// ─── categorizeItem ───────────────────────────────────────────────────────────
|
||||
|
||||
function categorizeItem(result: string, reason?: string, blockedBy?: string): UatCategory {
|
||||
if (result === 'blocked' || blockedBy) {
|
||||
if (blockedBy) {
|
||||
if (/server/i.test(blockedBy)) return 'server_blocked';
|
||||
if (/device|physical/i.test(blockedBy)) return 'device_needed';
|
||||
if (/build|release|preview/i.test(blockedBy)) return 'build_needed';
|
||||
if (/third.party|twilio|stripe/i.test(blockedBy)) return 'third_party';
|
||||
}
|
||||
return 'blocked';
|
||||
}
|
||||
if (result === 'skipped') {
|
||||
if (reason) {
|
||||
if (/server|not running|not available/i.test(reason)) return 'server_blocked';
|
||||
if (/simulator|physical|device/i.test(reason)) return 'device_needed';
|
||||
if (/build|release|preview/i.test(reason)) return 'build_needed';
|
||||
}
|
||||
return 'skipped_unresolved';
|
||||
}
|
||||
if (result === 'pending') return 'pending';
|
||||
if (result === 'human_needed') return 'human_uat';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export = {
|
||||
cmdAuditUat,
|
||||
cmdRenderCheckpoint,
|
||||
parseCurrentTest,
|
||||
buildCheckpoint,
|
||||
};
|
||||
114
src/ui-safety-gate.cts
Normal file
114
src/ui-safety-gate.cts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* UI Safety Gate — shell-free implementation (ADR-457 build-at-publish: the
|
||||
* hand-written bin/lib/ui-safety-gate.cjs collapsed to a TypeScript source of
|
||||
* truth). Behaviour is preserved byte-for-behaviour from the prior hand-written
|
||||
* .cjs; only types are added.
|
||||
*
|
||||
* Replaces the bash shell-based one-liner that silently degraded on Windows
|
||||
* PowerShell / cmd.exe because the locale env-var prefix was not recognised.
|
||||
* This module runs inside Node.js — no shell dependency, works identically
|
||||
* on bash, Git-Bash, PowerShell, and cmd.exe.
|
||||
*
|
||||
* Word-boundary anchoring:
|
||||
* (^|[^a-zA-Z0-9])(TOKEN)([^a-zA-Z0-9]|$)
|
||||
* Equivalent to POSIX ERE [^[:alnum:]] — matches tokens only when they are not
|
||||
* interior substrings of alphanumeric compound words (e.g. "microfrontend" is NOT
|
||||
* matched; "micro-frontend" and "micro frontend" ARE matched).
|
||||
*
|
||||
* Public API:
|
||||
* checkUiPresence(text: string): { hasUI: boolean, tokens: string[] }
|
||||
*
|
||||
* CLI usage — reads phase-section text from STDIN to avoid ARG_MAX limits:
|
||||
* echo "$PHASE_SECTION" | node get-shit-done/bin/lib/ui-safety-gate.cjs
|
||||
* echo $? → 0 if UI tokens found, 1 if not, 2 on usage error
|
||||
*
|
||||
* Exit codes mirror grep: 0 = match found, 1 = no match, 2 = usage error.
|
||||
*
|
||||
* Canonical location: get-shit-done/bin/lib/ui-safety-gate.cjs (#448)
|
||||
* This path is deployed by the GSD installer to $RUNTIME_DIR/get-shit-done/bin/lib/.
|
||||
* bin/lib/ui-safety-gate.cjs (root) is retained for source-repo and npm usage.
|
||||
*/
|
||||
|
||||
export interface UiPresenceResult {
|
||||
hasUI: boolean;
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
export const UI_TOKENS: ReadonlyArray<string> = [
|
||||
'UI',
|
||||
'interface',
|
||||
'frontend',
|
||||
'component',
|
||||
'layout',
|
||||
'page',
|
||||
'screen',
|
||||
'view',
|
||||
'form',
|
||||
'dashboard',
|
||||
'widget',
|
||||
];
|
||||
|
||||
/**
|
||||
* Built once at module load — no per-call compilation overhead.
|
||||
* ASCII word boundaries — matches the original ASCII-grep intent of #3706.
|
||||
* Note: JS [a-zA-Z0-9] is ASCII-only and NOT equivalent to POSIX [[:alnum:]],
|
||||
* which is locale-sensitive and includes accented characters.
|
||||
*/
|
||||
const UI_GATE_PATTERN = new RegExp(
|
||||
'(^|[^a-zA-Z0-9])(' + UI_TOKENS.join('|') + ')([^a-zA-Z0-9]|$)',
|
||||
'i',
|
||||
);
|
||||
|
||||
// Global-flagged variant for extracting ALL matches per line (matchAll).
|
||||
const UI_GATE_PATTERN_GLOBAL = new RegExp(UI_GATE_PATTERN.source, 'gi');
|
||||
|
||||
/**
|
||||
* Check a roadmap phase section string for frontend UI indicators.
|
||||
*
|
||||
* @param text - The roadmap phase section content (may be multi-line, CRLF or LF).
|
||||
* @returns hasUI — true if any UI token was matched as a standalone word;
|
||||
* tokens — matched token strings (lowercased), deduplicated.
|
||||
*/
|
||||
export function checkUiPresence(text: string): UiPresenceResult {
|
||||
if (typeof text !== 'string') {
|
||||
return { hasUI: false, tokens: [] };
|
||||
}
|
||||
|
||||
// Normalise CRLF so the pattern sees consistent line boundaries.
|
||||
const normalised = text.replace(/\r\n/g, '\n');
|
||||
|
||||
const found = new Set<string>();
|
||||
for (const line of normalised.split('\n')) {
|
||||
// Reset lastIndex before each line so the global pattern restarts from 0.
|
||||
UI_GATE_PATTERN_GLOBAL.lastIndex = 0;
|
||||
for (const m of line.matchAll(UI_GATE_PATTERN_GLOBAL)) {
|
||||
found.add(m[2].toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return { hasUI: found.size > 0, tokens: [...found] };
|
||||
}
|
||||
|
||||
// ── CLI entry point ─────────────────────────────────────────────────────────
|
||||
// Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits.
|
||||
// Invoked by workflow .md bash blocks as: echo "$PHASE_SECTION" | node .../ui-safety-gate.cjs
|
||||
// Exit 0 = UI found, 1 = no UI, 2 = startup error.
|
||||
|
||||
if (require.main === module) {
|
||||
// Collect stdin chunks asynchronously.
|
||||
const chunks: string[] = [];
|
||||
process.stdin.setEncoding('utf-8');
|
||||
|
||||
process.stdin.on('data', (chunk: string) => chunks.push(chunk));
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const input = chunks.join('');
|
||||
const result = checkUiPresence(input);
|
||||
process.exit(result.hasUI ? 0 : 1);
|
||||
});
|
||||
|
||||
process.stdin.on('error', (err: Error) => {
|
||||
process.stderr.write(`ERROR: ui-safety-gate.cjs stdin read failed: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
236
src/update-context.cts
Normal file
236
src/update-context.cts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Update-context resolver (issue #498, candidate 3).
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/update-context.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';
|
||||
import nodeFs from 'node:fs';
|
||||
import nodeOs from 'node:os';
|
||||
|
||||
/** Runtime → candidate relative dir pairs. */
|
||||
export type RuntimeDirEntry = [string, string];
|
||||
|
||||
// Runtime -> candidate relative dir. Order matters: it is the probe order, and
|
||||
// mirrors the RUNTIME_DIRS array the bash used (a runtime may have several
|
||||
// candidate dirs). Kept here, not derived from the installer's getDirName,
|
||||
// because update detection probes ALL historical dirs per runtime.
|
||||
export const RUNTIME_DIRS: RuntimeDirEntry[] = [
|
||||
['claude', '.claude'],
|
||||
['opencode', '.config/opencode'],
|
||||
['opencode', '.opencode'],
|
||||
['antigravity', '.gemini/antigravity-ide'],
|
||||
['antigravity', '.gemini/antigravity-cli'],
|
||||
['antigravity', '.gemini/antigravity'],
|
||||
['antigravity', '.agent'], // local Antigravity install dir (#503; bin/install.js getDirName('antigravity'))
|
||||
['gemini', '.gemini'],
|
||||
['kilo', '.config/kilo'],
|
||||
['kilo', '.kilo'],
|
||||
['codex', '.codex'],
|
||||
];
|
||||
|
||||
const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
|
||||
|
||||
function expandHome(p: string | undefined | null, home: string): string {
|
||||
if (!p) return '';
|
||||
return p.startsWith('~/') ? path.join(home, p.slice(2)) : p;
|
||||
}
|
||||
|
||||
function versionFile(dir: string): string { return path.join(dir, 'get-shit-done', 'VERSION'); }
|
||||
function markerFile(dir: string): string { return path.join(dir, 'get-shit-done', 'workflows', 'update.md'); }
|
||||
|
||||
export interface FsAdapter {
|
||||
exists(p: string): boolean;
|
||||
readFile(p: string): string | null;
|
||||
}
|
||||
|
||||
// Detection: a dir "has GSD" if it carries a VERSION file or the update.md
|
||||
// workflow marker.
|
||||
function hasInstall(fs: FsAdapter, dir: string): boolean {
|
||||
return fs.exists(versionFile(dir)) || fs.exists(markerFile(dir));
|
||||
}
|
||||
|
||||
// Read VERSION at dir; return a trimmed semver string, or null if missing/invalid.
|
||||
function validVersionAt(fs: FsAdapter, dir: string): string | null {
|
||||
const raw = fs.readFile(versionFile(dir));
|
||||
if (raw == null) return null;
|
||||
const trimmed = String(raw).trim();
|
||||
return SEMVER_PREFIX.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
// A version is TRUSTED only when BOTH the VERSION file and the update.md marker
|
||||
// exist (and VERSION is valid semver).
|
||||
function trustedVersionAt(fs: FsAdapter, dir: string | undefined): string | null {
|
||||
return dir && fs.exists(markerFile(dir)) ? validVersionAt(fs, dir) : null;
|
||||
}
|
||||
|
||||
export interface InferPreferredRuntimeOpts {
|
||||
fs: FsAdapter;
|
||||
env: Record<string, string | undefined>;
|
||||
preferredConfigDir: string;
|
||||
}
|
||||
|
||||
// Infer the preferred runtime from preferredConfigDir config files, then env.
|
||||
export function inferPreferredRuntime({ fs, env, preferredConfigDir }: InferPreferredRuntimeOpts): string {
|
||||
if (preferredConfigDir) {
|
||||
if (fs.exists(path.join(preferredConfigDir, 'kilo.json')) ||
|
||||
fs.exists(path.join(preferredConfigDir, 'kilo.jsonc'))) return 'kilo';
|
||||
if (fs.exists(path.join(preferredConfigDir, 'opencode.json')) ||
|
||||
fs.exists(path.join(preferredConfigDir, 'opencode.jsonc'))) return 'opencode';
|
||||
if (fs.exists(path.join(preferredConfigDir, 'config.toml'))) return 'codex';
|
||||
}
|
||||
if (env['CODEX_HOME']) return 'codex';
|
||||
if (env['ANTIGRAVITY_CONFIG_DIR']) return 'antigravity';
|
||||
if (env['GEMINI_CONFIG_DIR']) return 'gemini';
|
||||
if (env['KILO_CONFIG_DIR'] || env['KILO_CONFIG']) return 'kilo';
|
||||
if (env['OPENCODE_CONFIG_DIR'] || env['OPENCODE_CONFIG']) return 'opencode';
|
||||
if (env['CLAUDE_CONFIG_DIR']) return 'claude';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
export interface EnvRuntimeDirsOpts {
|
||||
env: Record<string, string | undefined>;
|
||||
home: string;
|
||||
}
|
||||
|
||||
// Absolute env-override candidates, mirroring the bash ENV_RUNTIME_DIRS block.
|
||||
export function envRuntimeDirs({ env, home }: EnvRuntimeDirsOpts): RuntimeDirEntry[] {
|
||||
const out: RuntimeDirEntry[] = [];
|
||||
const ex = (v: string | undefined) => expandHome(v, home);
|
||||
if (env['CLAUDE_CONFIG_DIR']) out.push(['claude', ex(env['CLAUDE_CONFIG_DIR'])]);
|
||||
if (env['ANTIGRAVITY_CONFIG_DIR']) out.push(['antigravity', ex(env['ANTIGRAVITY_CONFIG_DIR'])]);
|
||||
if (env['GEMINI_CONFIG_DIR']) out.push(['gemini', ex(env['GEMINI_CONFIG_DIR'])]);
|
||||
if (env['KILO_CONFIG_DIR']) out.push(['kilo', ex(env['KILO_CONFIG_DIR'])]);
|
||||
else if (env['KILO_CONFIG']) out.push(['kilo', path.dirname(ex(env['KILO_CONFIG']))]);
|
||||
else if (env['XDG_CONFIG_HOME']) out.push(['kilo', path.join(ex(env['XDG_CONFIG_HOME']), 'kilo')]);
|
||||
if (env['OPENCODE_CONFIG_DIR']) out.push(['opencode', ex(env['OPENCODE_CONFIG_DIR'])]);
|
||||
else if (env['OPENCODE_CONFIG']) out.push(['opencode', path.dirname(ex(env['OPENCODE_CONFIG']))]);
|
||||
else if (env['XDG_CONFIG_HOME']) out.push(['opencode', path.join(ex(env['XDG_CONFIG_HOME']), 'opencode')]);
|
||||
if (env['CODEX_HOME']) out.push(['codex', ex(env['CODEX_HOME'])]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Stable reorder: entries whose runtime === preferred first, original order kept.
|
||||
function preferFirst(entries: RuntimeDirEntry[], preferred: string): RuntimeDirEntry[] {
|
||||
const pref = entries.filter(([rt]) => rt === preferred);
|
||||
const rest = entries.filter(([rt]) => rt !== preferred);
|
||||
return [...pref, ...rest];
|
||||
}
|
||||
|
||||
export interface ResolveUpdateContextOpts {
|
||||
home: string;
|
||||
cwd: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
fs: FsAdapter;
|
||||
preferredConfigDir?: string;
|
||||
preferredRuntime?: string;
|
||||
}
|
||||
|
||||
export interface UpdateContext {
|
||||
installedVersion: string;
|
||||
scope: 'LOCAL' | 'GLOBAL' | 'UNKNOWN';
|
||||
runtime: string;
|
||||
gsdDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure resolver. Returns { installedVersion, scope, runtime, gsdDir }.
|
||||
*/
|
||||
export function resolveUpdateContext({
|
||||
home,
|
||||
cwd,
|
||||
env = {},
|
||||
fs,
|
||||
preferredConfigDir = '',
|
||||
preferredRuntime = '',
|
||||
}: ResolveUpdateContextOpts): UpdateContext {
|
||||
// Expand a leading `~/` before any probe.
|
||||
preferredConfigDir = expandHome(preferredConfigDir, home);
|
||||
const preferred = preferredRuntime || inferPreferredRuntime({ fs, env, preferredConfigDir });
|
||||
|
||||
// Fast path: a validated preferredConfigDir (custom --config-dir install).
|
||||
if (preferredConfigDir && hasInstall(fs, preferredConfigDir)) {
|
||||
const resolvedPref = path.resolve(preferredConfigDir);
|
||||
let scope: 'LOCAL' | 'GLOBAL' = 'GLOBAL';
|
||||
for (const [, reldir] of RUNTIME_DIRS) {
|
||||
if (path.resolve(cwd, reldir) === resolvedPref) { scope = 'LOCAL'; break; }
|
||||
}
|
||||
return {
|
||||
installedVersion: trustedVersionAt(fs, preferredConfigDir) ?? '0.0.0',
|
||||
scope,
|
||||
runtime: preferred,
|
||||
gsdDir: preferredConfigDir,
|
||||
};
|
||||
}
|
||||
|
||||
const orderedEnv = preferFirst(envRuntimeDirs({ env, home }), preferred);
|
||||
const orderedRuntime = preferFirst(RUNTIME_DIRS, preferred);
|
||||
|
||||
// LOCAL probe (relative to cwd).
|
||||
let localRuntime = '', localDir = '';
|
||||
for (const [rt, reldir] of orderedRuntime) {
|
||||
const cand = path.resolve(cwd, reldir);
|
||||
if (hasInstall(fs, cand)) { localRuntime = rt; localDir = cand; break; }
|
||||
}
|
||||
|
||||
// GLOBAL probe: absolute env candidates first, then $HOME-relative.
|
||||
let globalRuntime = '', globalDir = '';
|
||||
for (const [rt, absdir] of orderedEnv) {
|
||||
if (hasInstall(fs, absdir)) { globalRuntime = rt; globalDir = path.resolve(absdir); break; }
|
||||
}
|
||||
if (!globalRuntime) {
|
||||
for (const [rt, reldir] of orderedRuntime) {
|
||||
const cand = path.resolve(home, reldir);
|
||||
if (hasInstall(fs, cand)) { globalRuntime = rt; globalDir = cand; break; }
|
||||
}
|
||||
}
|
||||
|
||||
const localValid = trustedVersionAt(fs, localDir);
|
||||
const isLocal = !!localValid && (!globalDir || localDir !== globalDir);
|
||||
|
||||
if (isLocal) {
|
||||
return { installedVersion: localValid, scope: 'LOCAL', runtime: localRuntime, gsdDir: localDir };
|
||||
}
|
||||
const globalValid = trustedVersionAt(fs, globalDir);
|
||||
if (globalValid) {
|
||||
return { installedVersion: globalValid, scope: 'GLOBAL', runtime: globalRuntime, gsdDir: globalDir };
|
||||
}
|
||||
// A runtime dir was detected (VERSION or marker present) but is not a
|
||||
// complete, valid install: keep scope/runtime/dir and report 0.0.0 so the
|
||||
// caller re-installs.
|
||||
if (localRuntime && (!globalDir || localDir !== globalDir)) {
|
||||
return { installedVersion: '0.0.0', scope: 'LOCAL', runtime: localRuntime, gsdDir: localDir };
|
||||
}
|
||||
if (globalRuntime) {
|
||||
return { installedVersion: '0.0.0', scope: 'GLOBAL', runtime: globalRuntime, gsdDir: globalDir };
|
||||
}
|
||||
return { installedVersion: '0.0.0', scope: 'UNKNOWN', runtime: 'claude', gsdDir: '' };
|
||||
}
|
||||
|
||||
export interface LoadUpdateContextOpts {
|
||||
home?: string;
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
preferredConfigDir?: string;
|
||||
preferredRuntime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI wiring: resolve against the real filesystem.
|
||||
*/
|
||||
export function loadUpdateContext(opts: LoadUpdateContextOpts = {}): UpdateContext {
|
||||
const fs: FsAdapter = {
|
||||
exists: (p: string) => nodeFs.existsSync(p),
|
||||
readFile: (p: string) => { try { return nodeFs.readFileSync(p, 'utf8'); } catch { return null; } },
|
||||
};
|
||||
return resolveUpdateContext({
|
||||
home: opts.home ?? nodeOs.homedir(),
|
||||
cwd: opts.cwd ?? process.cwd(),
|
||||
env: opts.env ?? process.env,
|
||||
fs,
|
||||
preferredConfigDir: opts.preferredConfigDir ?? '',
|
||||
preferredRuntime: opts.preferredRuntime ?? '',
|
||||
});
|
||||
}
|
||||
108
src/validate-command-router.cts
Normal file
108
src/validate-command-router.cts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Manifest-backed validate subcommand router.
|
||||
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
|
||||
*
|
||||
* Phase 6: validate.consistency, validate.health, validate.agents are
|
||||
* dispatched via executeForCjs when the SDK is available. CJS fallback
|
||||
* retained when:
|
||||
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
|
||||
* - SDK is unavailable (build not present).
|
||||
*
|
||||
* CJS-only subcommands:
|
||||
* - context: complex inline logic using classifyContextUtilization and
|
||||
* output formatting that has no direct SDK counterpart. Remains CJS-native.
|
||||
*
|
||||
* SDK-only (unsupported in CJS router): none.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/validate-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 { VALIDATE_SUBCOMMANDS } from './command-aliases.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.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';
|
||||
import { classifyContextUtilization, STATES } from './context-utilization.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VerifyModule {
|
||||
cmdValidateConsistency(cwd: string, raw: boolean): void;
|
||||
cmdValidateHealth(cwd: string, opts: { repair: boolean; backfill: boolean }, raw: boolean): void;
|
||||
cmdValidateAgents(cwd: string, raw: boolean): void;
|
||||
}
|
||||
|
||||
interface RouteValidateCommandOptions {
|
||||
verify: VerifyModule;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
raw: boolean;
|
||||
output: (result: unknown, raw: boolean, rawValue?: unknown) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
function routeValidateCommand({ verify, args, cwd, raw, output: outputFn, error }: RouteValidateCommandOptions): void {
|
||||
routeCjsCommandFamily({
|
||||
args,
|
||||
subcommands: VALIDATE_SUBCOMMANDS,
|
||||
unsupported: {},
|
||||
error,
|
||||
unknownMessage: (_subcommand: string, available: string[]) => `Unknown validate subcommand. Available: ${available.join(', ')}`,
|
||||
handlers: {
|
||||
consistency: () => verify.cmdValidateConsistency(cwd, raw),
|
||||
// Keep health on CJS for now so fix hints are rendered via runtime-slash
|
||||
// helpers (codex expects $gsd-* command shape).
|
||||
health: () => {
|
||||
const repairFlag = args.includes('--repair');
|
||||
const backfillFlag = args.includes('--backfill');
|
||||
verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw);
|
||||
},
|
||||
agents: () => verify.cmdValidateAgents(cwd, raw),
|
||||
// context: CJS-only — complex inline logic using classifyContextUtilization
|
||||
// with custom output formatting that has no direct SDK counterpart.
|
||||
context: () => {
|
||||
const opts = parseNamedArgs(args, ['tokens-used', 'context-window']);
|
||||
if (opts['tokens-used'] === null) {
|
||||
error('--tokens-used <integer> is required for `validate context`');
|
||||
return;
|
||||
}
|
||||
if (opts['context-window'] === null) {
|
||||
error('--context-window <integer> is required for `validate context`');
|
||||
return;
|
||||
}
|
||||
const threadCmd = String(formatGsdSlash('thread', resolveRuntime(cwd)));
|
||||
const RECOMMENDATIONS: Record<string, string | null> = {
|
||||
[STATES.HEALTHY]: null,
|
||||
[STATES.WARNING]: `Context is approaching the fracture zone — consider ${threadCmd} to continue in a fresh window.`,
|
||||
[STATES.CRITICAL]: `Reasoning quality may degrade past 70% utilization (fracture point). Run ${threadCmd} now to preserve output quality.`,
|
||||
};
|
||||
let classified: ReturnType<typeof classifyContextUtilization>;
|
||||
try {
|
||||
classified = classifyContextUtilization(Number(opts['tokens-used']), Number(opts['context-window']));
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
const flag = /tokensUsed/.test(msg) ? '--tokens-used' : '--context-window';
|
||||
error(`${flag} must be a non-negative integer (window > 0), got the values supplied`);
|
||||
return;
|
||||
}
|
||||
const result = { ...classified, recommendation: RECOMMENDATIONS[classified.state] };
|
||||
if (args.includes('--json')) {
|
||||
outputFn(result, raw);
|
||||
} else {
|
||||
const lines = [`Context utilization: ${result.percent}% (${result.state})`];
|
||||
if (result.recommendation) lines.push(result.recommendation);
|
||||
outputFn(result, true, lines.join('\n'));
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export = {
|
||||
routeValidateCommand,
|
||||
};
|
||||
115
src/validate.cts
Normal file
115
src/validate.cts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Validate Helpers — pure computation helpers and regex constants extracted from
|
||||
* sdk/src/query/validate.ts (ADR-457 build-at-publish: the hand-written
|
||||
* bin/lib/validate.cjs collapsed to a TypeScript source of truth). Behaviour is
|
||||
* preserved byte-for-behaviour from the prior hand-written .cjs; only types are
|
||||
* added.
|
||||
*
|
||||
* No I/O. No async. No filesystem operations.
|
||||
*
|
||||
* Issue #6 drift items (three helpers):
|
||||
* 1. phaseVariants() — replaces parseInt-based padded/unpadded check in verify.cjs
|
||||
* Check 8 (W006 disk-existence and W007 roadmap-membership checks).
|
||||
* 2. buildRoadmapPhaseVariants() — replaces raw roadmapPhases set in W007 loop.
|
||||
* 3. buildNotStartedPhaseVariants() — replaces raw+zero-padded notStartedPhases
|
||||
* in W006 skip logic.
|
||||
*
|
||||
* Issue #26 drift items (four constants/helpers):
|
||||
* 4. phaseDirNameRe — W005 phase directory naming regex (was inline in verify.cjs Check 6).
|
||||
* 5. PHASE_TOKEN_FROM_DIR_RE — extracts phase token from dir name (was inline in
|
||||
* verify.cjs forEachArchivedPhaseToken / collectDiskPhases).
|
||||
* 6. MILESTONE_ARCHIVE_DIR_RE — identifies milestone archive directories (was inline).
|
||||
* 7. canonicalPlanStem() — I001 PLAN/SUMMARY stem canonicalization (was inline in Check 7).
|
||||
*
|
||||
* I/O adapter pattern (ADR-3524 §4): pure transforms extracted from the SDK.
|
||||
*
|
||||
* References:
|
||||
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
|
||||
* - Issue #6 (open-gsd/gsd-core)
|
||||
* - Issue #26 (open-gsd/gsd-core)
|
||||
* - PR #154 (issue #4) — generator pattern precedent
|
||||
* - PR #156 (issue #6) — validate.ts generator that #26 extends
|
||||
*/
|
||||
|
||||
// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
|
||||
// Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
|
||||
// deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
|
||||
export const phaseDirNameRe = /^(?:[A-Z]{1,6}-)?\d{2,}(?:-\d+)*(?:\.\d+)*-[\w-]+$/i;
|
||||
// Extracts the full phase token from a directory name, including milestone-prefixed
|
||||
// multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
|
||||
// Greedily captures all leading all-digit segments before the first letter-start segment.
|
||||
export const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+(?:-\d+)*[A-Z]?(?:\.\d+)*)(?:-[a-z]|$)/i;
|
||||
export const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
|
||||
|
||||
// ── Issue #26: I001 canonicalization ────────────────────────────────────────
|
||||
export function canonicalPlanStem(stem: string): string {
|
||||
const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d+)/i);
|
||||
return m ? m[1] : stem;
|
||||
}
|
||||
|
||||
/** Result of buildRoadmapPhaseVariants. */
|
||||
export interface RoadmapPhaseVariantsResult {
|
||||
roadmapPhases: Set<string>;
|
||||
roadmapPhaseVariants: Set<string>;
|
||||
}
|
||||
|
||||
// ── Issue #6: phase variant helpers (W006/W007) ──────────────────────────────
|
||||
export function phaseVariants(phase: string): Set<string> {
|
||||
const variants = new Set([phase]);
|
||||
const dotIdx = phase.indexOf('.');
|
||||
const head = dotIdx === -1 ? phase : phase.slice(0, dotIdx);
|
||||
const tail = dotIdx === -1 ? '' : phase.slice(dotIdx);
|
||||
|
||||
// Milestone-prefixed IDs: M-NN or M-N-N. Add padding-normalized variant.
|
||||
// e.g. "2-01" → also "02-01"; "02-01" → also "2-01"
|
||||
const milestoneHeadMatch = head.match(/^(\d+)((?:-\d+)+)([A-Z]?)$/i);
|
||||
if (milestoneHeadMatch) {
|
||||
const major = milestoneHeadMatch[1];
|
||||
const subSegs = milestoneHeadMatch[2]; // e.g. "-01" or "-04-01"
|
||||
const letter = milestoneHeadMatch[3] || '';
|
||||
const paddedMajor = major.padStart(2, '0');
|
||||
const unpaddedMajor = String(parseInt(major, 10));
|
||||
// Pad/unpad sub-segments individually
|
||||
const paddedSubs = subSegs.slice(1).split('-').map(s => s.padStart(2, '0')).join('-');
|
||||
const unpaddedSubs = subSegs.slice(1).split('-').map(s => String(parseInt(s, 10))).join('-');
|
||||
variants.add(`${paddedMajor}-${paddedSubs}${letter}${tail}`);
|
||||
variants.add(`${unpaddedMajor}-${unpaddedSubs}${letter}${tail}`);
|
||||
variants.add(`${unpaddedMajor}-${paddedSubs}${letter}${tail}`);
|
||||
variants.add(`${paddedMajor}-${unpaddedSubs}${letter}${tail}`);
|
||||
return variants;
|
||||
}
|
||||
|
||||
// Plain numeric/decimal IDs: "1", "01", "12A", "12.1"
|
||||
const headMatch = head.match(/^(\d+)([A-Z]?)$/i);
|
||||
if (!headMatch) return variants;
|
||||
const numericHead = headMatch[1];
|
||||
const letterSuffix = headMatch[2] || '';
|
||||
variants.add(`${String(parseInt(numericHead, 10))}${letterSuffix}${tail}`);
|
||||
variants.add(`${numericHead.padStart(2, '0')}${letterSuffix}${tail}`);
|
||||
return variants;
|
||||
}
|
||||
|
||||
export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseVariantsResult {
|
||||
const roadmapPhases = new Set<string>();
|
||||
const roadmapPhaseVariants = new Set<string>();
|
||||
// Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:),
|
||||
// and bracket-prefixed (### [GSD] Phase 2-01:) headings.
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
||||
roadmapPhases.add(m[1]);
|
||||
for (const variant of phaseVariants(m[1])) roadmapPhaseVariants.add(variant);
|
||||
}
|
||||
return { roadmapPhases, roadmapPhaseVariants };
|
||||
}
|
||||
|
||||
export function buildNotStartedPhaseVariants(roadmapContent: string): Set<string> {
|
||||
const notStartedPhases = new Set<string>();
|
||||
// Also matches milestone-prefixed and bracket-prefixed checklist items.
|
||||
const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)[:\s*]/gi;
|
||||
let um: RegExpExecArray | null;
|
||||
while ((um = uncheckedPattern.exec(roadmapContent)) !== null) {
|
||||
for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant);
|
||||
}
|
||||
return notStartedPhases;
|
||||
}
|
||||
68
src/verify-command-router.cts
Normal file
68
src/verify-command-router.cts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Manifest-backed verify subcommand router.
|
||||
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/verify-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 { VERIFY_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 VerifyModule {
|
||||
cmdVerifyPlanStructure(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||
cmdVerifyPhaseCompleteness(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||
cmdVerifyReferences(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||
cmdVerifyCommits(cwd: string, args: string[], raw: boolean): void;
|
||||
cmdVerifyArtifacts(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||
cmdVerifyKeyLinks(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||
cmdVerifySchemaDrift(cwd: string, phase: string | undefined, skip: boolean, raw: boolean): void;
|
||||
cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void;
|
||||
}
|
||||
|
||||
interface RouteVerifyCommandOptions {
|
||||
verify: VerifyModule;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
raw: boolean;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
function routeVerifyCommand({ verify, args, cwd, raw, error }: RouteVerifyCommandOptions): void {
|
||||
routeCjsCommandFamily({
|
||||
args,
|
||||
subcommands: VERIFY_SUBCOMMANDS,
|
||||
unsupported: {},
|
||||
error,
|
||||
unknownMessage: (_subcommand: string, available: string[]) => `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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export = {
|
||||
routeVerifyCommand,
|
||||
};
|
||||
1766
src/verify.cts
Normal file
1766
src/verify.cts
Normal file
File diff suppressed because it is too large
Load Diff
148
src/workstream-inventory-builder.cts
Normal file
148
src/workstream-inventory-builder.cts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Workstream Inventory Builder — pure projection from pre-collected
|
||||
* filesystem data to typed WorkstreamInventory. No I/O. No async.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written
|
||||
* bin/lib/workstream-inventory-builder.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';
|
||||
|
||||
// Internal helpers
|
||||
function toPosixPath(p: string): string {
|
||||
return p.split('\\').join('/');
|
||||
}
|
||||
|
||||
export function isCompletedInventory(status: unknown): boolean {
|
||||
const s = (typeof status === 'string'
|
||||
? status
|
||||
: typeof status === 'number' || typeof status === 'boolean'
|
||||
? String(status)
|
||||
: ''
|
||||
).trim().toLowerCase();
|
||||
return /\bmilestone\s+complete\b/.test(s) || /\barchived\b/.test(s);
|
||||
}
|
||||
|
||||
export interface PhaseFilesCount {
|
||||
directory: string;
|
||||
planCount: number;
|
||||
summaryCount: number;
|
||||
}
|
||||
|
||||
export interface PhaseStatus {
|
||||
directory: string;
|
||||
status: 'complete' | 'in_progress' | 'pending';
|
||||
plan_count: number;
|
||||
summary_count: number;
|
||||
}
|
||||
|
||||
export interface WorkstreamFilesExist {
|
||||
roadmap: boolean;
|
||||
state: boolean;
|
||||
requirements: boolean;
|
||||
}
|
||||
|
||||
export interface StateProjection {
|
||||
status: string;
|
||||
current_phase: string | null | undefined;
|
||||
last_activity: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface BuildWorkstreamInventoryInputs {
|
||||
name: string;
|
||||
projectDir: string;
|
||||
workstreamDir: string;
|
||||
phaseDirNames: string[];
|
||||
activeWorkstreamName: string;
|
||||
phaseFilesCounts: PhaseFilesCount[];
|
||||
roadmapPhaseCount: number;
|
||||
stateProjection: StateProjection;
|
||||
filesExist: WorkstreamFilesExist;
|
||||
}
|
||||
|
||||
export interface WorkstreamInventory {
|
||||
name: string;
|
||||
path: string;
|
||||
active: boolean;
|
||||
files: WorkstreamFilesExist;
|
||||
status: string;
|
||||
current_phase: string | null | undefined;
|
||||
last_activity: string | null | undefined;
|
||||
phases: PhaseStatus[];
|
||||
phase_count: number;
|
||||
completed_phases: number;
|
||||
roadmap_phase_count: number;
|
||||
total_plans: number;
|
||||
completed_plans: number;
|
||||
progress_percent: number;
|
||||
}
|
||||
|
||||
export function buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs): WorkstreamInventory {
|
||||
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<string, { planCount: number; summaryCount: number }>();
|
||||
for (const entry of phaseFilesCounts) {
|
||||
countsMap.set(entry.directory, { planCount: entry.planCount, summaryCount: entry.summaryCount });
|
||||
}
|
||||
|
||||
const phases: PhaseStatus[] = [];
|
||||
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: 'complete' | 'in_progress' | 'pending' =
|
||||
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(path.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,
|
||||
};
|
||||
}
|
||||
176
src/workstream-inventory.cts
Normal file
176
src/workstream-inventory.cts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Workstream Inventory Module
|
||||
*
|
||||
* Owns discovery and read-only projection of .planning/workstreams/* state.
|
||||
* Command handlers should render outputs from this inventory instead of
|
||||
* rescanning workstream directories directly.
|
||||
*
|
||||
* Pure projection logic lives in workstream-inventory-builder.cts.
|
||||
* This module handles I/O orchestration only.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/workstream-inventory.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';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { toPosixPath, readSubdirectories } = core;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planScan = require('./plan-scan.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningPaths, planningRoot, getActiveWorkstream } = planningWorkspace;
|
||||
import { stateExtractField } from './state-document.cjs';
|
||||
import { buildWorkstreamInventory, isCompletedInventory } from './workstream-inventory-builder.cjs';
|
||||
import type { WorkstreamInventory, StateProjection } from './workstream-inventory-builder.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PhaseFileCounts {
|
||||
planCount: number;
|
||||
summaryCount: number;
|
||||
}
|
||||
|
||||
interface InspectWorkstreamOptions {
|
||||
active?: string | null;
|
||||
}
|
||||
|
||||
interface WorkstreamInventoryList {
|
||||
mode: 'flat' | 'workstream';
|
||||
active: string | null;
|
||||
workstreams: WorkstreamInventory[];
|
||||
count: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
function workstreamsRoot(cwd: string): string {
|
||||
return path.join(planningRoot(cwd), 'workstreams');
|
||||
}
|
||||
|
||||
function countRoadmapPhases(roadmapPath: string, fallbackCount: number): number {
|
||||
try {
|
||||
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const matches = roadmapContent.match(/^#{2,4}\s+Phase\s+[\w][\w.-]*/gm);
|
||||
return matches ? matches.length : fallbackCount;
|
||||
} catch {
|
||||
return fallbackCount;
|
||||
}
|
||||
}
|
||||
|
||||
function countPhaseFiles(phaseDir: string): PhaseFileCounts {
|
||||
const scan = planScan(phaseDir);
|
||||
return { planCount: scan.planCount, summaryCount: scan.summaryCount };
|
||||
}
|
||||
|
||||
function readStateProjection(statePath: string): StateProjection {
|
||||
try {
|
||||
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||
return {
|
||||
status: stateExtractField(stateContent, 'Status') || 'unknown',
|
||||
current_phase: stateExtractField(stateContent, 'Current Phase'),
|
||||
last_activity: stateExtractField(stateContent, 'Last Activity'),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
status: 'unknown',
|
||||
current_phase: null,
|
||||
last_activity: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function sortWorkstreamInventories(inventories: WorkstreamInventory[], activeWorkstreamName: string | null): WorkstreamInventory[] {
|
||||
return [...inventories].sort((a, b) => {
|
||||
const aActive = a.name === activeWorkstreamName ? 1 : 0;
|
||||
const bActive = b.name === activeWorkstreamName ? 1 : 0;
|
||||
if (aActive !== bActive) {
|
||||
return bActive - aActive;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function inspectWorkstream(cwd: string, name: string, options: InspectWorkstreamOptions = {}): WorkstreamInventory | null {
|
||||
const wsDir = path.join(workstreamsRoot(cwd), name);
|
||||
if (!fs.existsSync(wsDir)) return null;
|
||||
|
||||
const activeWorkstreamName = options.active === undefined ? getActiveWorkstream(cwd) : options.active;
|
||||
const p = planningPaths(cwd, name);
|
||||
const phaseDirNames = readSubdirectories(p.phases);
|
||||
|
||||
// Collect per-phase file counts
|
||||
const phaseFilesCounts = phaseDirNames.map(dir => {
|
||||
const counts = countPhaseFiles(path.join(p.phases, dir));
|
||||
return { directory: dir, planCount: counts.planCount, summaryCount: counts.summaryCount };
|
||||
});
|
||||
|
||||
return buildWorkstreamInventory({
|
||||
name,
|
||||
projectDir: cwd,
|
||||
workstreamDir: wsDir,
|
||||
phaseDirNames,
|
||||
activeWorkstreamName: activeWorkstreamName ?? '',
|
||||
phaseFilesCounts,
|
||||
roadmapPhaseCount: countRoadmapPhases(p.roadmap, phaseDirNames.length),
|
||||
stateProjection: readStateProjection(p.state),
|
||||
filesExist: {
|
||||
roadmap: fs.existsSync(p.roadmap),
|
||||
state: fs.existsSync(p.state),
|
||||
requirements: fs.existsSync(p.requirements),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function listWorkstreamInventories(cwd: string): WorkstreamInventoryList {
|
||||
const wsRoot = workstreamsRoot(cwd);
|
||||
if (!fs.existsSync(wsRoot)) {
|
||||
return {
|
||||
mode: 'flat',
|
||||
active: null,
|
||||
workstreams: [],
|
||||
count: 0,
|
||||
message: 'No workstreams — operating in flat mode',
|
||||
};
|
||||
}
|
||||
|
||||
const active = getActiveWorkstream(cwd);
|
||||
const entries = fs.readdirSync(wsRoot, { withFileTypes: true });
|
||||
const workstreams: WorkstreamInventory[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const inventory = inspectWorkstream(cwd, entry.name, { active });
|
||||
if (inventory) workstreams.push(inventory);
|
||||
}
|
||||
|
||||
const ordered = sortWorkstreamInventories(workstreams, active);
|
||||
|
||||
return {
|
||||
mode: 'workstream',
|
||||
active,
|
||||
workstreams: ordered,
|
||||
count: ordered.length,
|
||||
};
|
||||
}
|
||||
|
||||
function getOtherActiveWorkstreamInventories(cwd: string, excludeWs: string): WorkstreamInventory[] {
|
||||
return listWorkstreamInventories(cwd).workstreams
|
||||
.filter(inventory => inventory.name !== excludeWs)
|
||||
.filter(inventory => !isCompletedInventory(inventory.status));
|
||||
}
|
||||
|
||||
// Re-export toPosixPath for compatibility (used by callers indirectly through core)
|
||||
export = {
|
||||
countPhaseFiles,
|
||||
countRoadmapPhases,
|
||||
getOtherActiveWorkstreamInventories,
|
||||
inspectWorkstream,
|
||||
isCompletedInventory,
|
||||
listWorkstreamInventories,
|
||||
sortWorkstreamInventories,
|
||||
workstreamsRoot,
|
||||
};
|
||||
101
src/workstream-name-policy.cts
Normal file
101
src/workstream-name-policy.cts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Canonical workstream name validation and slug normalization
|
||||
* (ADR-457 build-at-publish: the hand-written bin/lib/workstream-name-policy.cjs
|
||||
* collapsed to a TypeScript source of truth). Behaviour is preserved
|
||||
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
||||
*
|
||||
* Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs.
|
||||
*/
|
||||
|
||||
export const INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE =
|
||||
'Invalid workstream name: must be alphanumeric, hyphens, underscores, or dots';
|
||||
|
||||
const ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
||||
|
||||
/** Result of validateActiveWorkstreamName. */
|
||||
export interface WorkstreamValidationResult {
|
||||
ok: boolean;
|
||||
reason: 'empty' | 'invalid' | null;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export function normalizeWorkstreamNameInput(name: string | null | undefined): string | null {
|
||||
const value = String(name ?? '').trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function hasInvalidPathSegment(name: string | null | undefined): boolean {
|
||||
const value = String(name ?? '');
|
||||
return /[/\\]/.test(value) || value === '.' || value === '..' || value.includes('..');
|
||||
}
|
||||
|
||||
export function validateActiveWorkstreamName(name: string | null | undefined): WorkstreamValidationResult {
|
||||
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.
|
||||
*/
|
||||
export function validateWorkstreamName(name: string | null | undefined): boolean {
|
||||
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.
|
||||
*/
|
||||
export function toWorkstreamSlug(name: string | null | undefined): string {
|
||||
return String(name ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (..)
|
||||
*/
|
||||
export function isValidActiveWorkstreamName(name: string | null | undefined): boolean {
|
||||
return validateActiveWorkstreamName(name).ok;
|
||||
}
|
||||
|
||||
export function assertValidActiveWorkstreamName(
|
||||
name: string | null | undefined,
|
||||
errorMessage: string = INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE,
|
||||
): string {
|
||||
const validation = validateActiveWorkstreamName(name);
|
||||
if (!validation.ok) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return validation.value!;
|
||||
}
|
||||
422
src/workstream.cts
Normal file
422
src/workstream.cts
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Workstream — CRUD operations for workstream namespacing
|
||||
*
|
||||
* Workstreams enable parallel milestones by scoping ROADMAP.md, STATE.md,
|
||||
* REQUIREMENTS.md, and phases/ into .planning/workstreams/{name}/ directories.
|
||||
*
|
||||
* When no workstreams/ directory exists, GSD operates in "flat mode" with
|
||||
* everything at .planning/ — backward compatible with pre-workstream installs.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/workstream.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.
|
||||
*/
|
||||
|
||||
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, toPosixPath, getMilestoneInfo, generateSlugInternal } = 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 { planningRoot, setActiveWorkstream, getActiveWorkstream } = planningWorkspace;
|
||||
import {
|
||||
toWorkstreamSlug,
|
||||
assertValidActiveWorkstreamName,
|
||||
isValidActiveWorkstreamName,
|
||||
INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE,
|
||||
} from './workstream-name-policy.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import workstreamInventory = require('./workstream-inventory.cjs');
|
||||
const {
|
||||
getOtherActiveWorkstreamInventories,
|
||||
inspectWorkstream,
|
||||
listWorkstreamInventories,
|
||||
} = workstreamInventory;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface WorkstreamCreateOptions {
|
||||
migrate?: boolean;
|
||||
migrateName?: string | null;
|
||||
}
|
||||
|
||||
interface MigrateResult {
|
||||
migrated: boolean;
|
||||
workstream: string;
|
||||
files_moved: string[];
|
||||
}
|
||||
|
||||
// ─── Migration ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Migrate flat .planning/ layout to workstream mode.
|
||||
* Moves per-workstream files (ROADMAP.md, STATE.md, REQUIREMENTS.md, phases/)
|
||||
* into .planning/workstreams/{name}/. Shared files (PROJECT.md, config.json,
|
||||
* milestones/, research/, codebase/, todos/) stay in place.
|
||||
*/
|
||||
function migrateToWorkstreams(cwd: string, workstreamName: string): MigrateResult {
|
||||
try {
|
||||
assertValidActiveWorkstreamName(workstreamName, 'Invalid workstream name for migration');
|
||||
} catch {
|
||||
throw new Error('Invalid workstream name for migration');
|
||||
}
|
||||
|
||||
const baseDir = planningRoot(cwd);
|
||||
const wsDir = path.join(baseDir, 'workstreams', workstreamName);
|
||||
|
||||
if (fs.existsSync(path.join(baseDir, 'workstreams'))) {
|
||||
throw new Error('Already in workstream mode — .planning/workstreams/ exists');
|
||||
}
|
||||
|
||||
const toMove: Array<{ name: string; type: string }> = [
|
||||
{ name: 'ROADMAP.md', type: 'file' },
|
||||
{ name: 'STATE.md', type: 'file' },
|
||||
{ name: 'REQUIREMENTS.md', type: 'file' },
|
||||
{ name: 'phases', type: 'dir' },
|
||||
];
|
||||
|
||||
platformEnsureDir(wsDir);
|
||||
|
||||
const filesMoved: string[] = [];
|
||||
try {
|
||||
for (const item of toMove) {
|
||||
const src = path.join(baseDir, item.name);
|
||||
if (fs.existsSync(src)) {
|
||||
const dest = path.join(wsDir, item.name);
|
||||
fs.renameSync(src, dest);
|
||||
filesMoved.push(item.name);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
for (const name of filesMoved) {
|
||||
try { fs.renameSync(path.join(wsDir, name), path.join(baseDir, name)); } catch { /* ignore */ }
|
||||
}
|
||||
try { fs.rmSync(wsDir, { recursive: true }); } catch { /* ignore */ }
|
||||
try { fs.rmdirSync(path.join(baseDir, 'workstreams')); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
|
||||
return { migrated: true, workstream: workstreamName, files_moved: filesMoved };
|
||||
}
|
||||
|
||||
// ─── CRUD Commands ────────────────────────────────────────────────────────────
|
||||
|
||||
function cmdWorkstreamCreate(cwd: string, name: string | null | undefined, options: WorkstreamCreateOptions, raw: boolean): void {
|
||||
if (!name) {
|
||||
error('workstream name required. Usage: workstream create <name>');
|
||||
}
|
||||
|
||||
const slug = toWorkstreamSlug(name);
|
||||
if (!slug) {
|
||||
error('Invalid workstream name — must contain at least one alphanumeric character');
|
||||
}
|
||||
|
||||
const baseDir = planningRoot(cwd);
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
error(`.planning/ directory not found — run ${formatGsdSlash('new-project', resolveRuntime(cwd)) as string} first`);
|
||||
}
|
||||
|
||||
const wsRoot = path.join(baseDir, 'workstreams');
|
||||
const wsDir = path.join(wsRoot, slug);
|
||||
|
||||
if (fs.existsSync(wsDir) && fs.existsSync(path.join(wsDir, 'STATE.md'))) {
|
||||
output({ created: false, error: 'already_exists', workstream: slug, path: toPosixPath(path.relative(cwd, wsDir)) }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const isFlatMode = !fs.existsSync(wsRoot);
|
||||
let migration: MigrateResult | null = null;
|
||||
if (isFlatMode && options.migrate !== false) {
|
||||
const hasExistingWork = fs.existsSync(path.join(baseDir, 'ROADMAP.md')) ||
|
||||
fs.existsSync(path.join(baseDir, 'STATE.md')) ||
|
||||
fs.existsSync(path.join(baseDir, 'phases'));
|
||||
|
||||
if (hasExistingWork) {
|
||||
const migrateName = options.migrateName || null;
|
||||
let existingWsName: string;
|
||||
if (migrateName) {
|
||||
const slugged = toWorkstreamSlug(migrateName);
|
||||
if (!slugged) {
|
||||
output({
|
||||
created: false,
|
||||
error: 'migration_failed',
|
||||
message: 'Invalid migrate-name — must contain at least one alphanumeric character',
|
||||
}, raw, undefined);
|
||||
return;
|
||||
}
|
||||
existingWsName = slugged;
|
||||
} else {
|
||||
try {
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
existingWsName = generateSlugInternal(milestone.name) || 'default';
|
||||
} catch {
|
||||
existingWsName = 'default';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
migration = migrateToWorkstreams(cwd, existingWsName);
|
||||
} catch (e) {
|
||||
output({ created: false, error: 'migration_failed', message: (e as Error).message }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
platformEnsureDir(wsRoot);
|
||||
}
|
||||
}
|
||||
|
||||
platformEnsureDir(wsDir);
|
||||
platformEnsureDir(path.join(wsDir, 'phases'));
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const stateContent = [
|
||||
'---',
|
||||
`workstream: ${slug}`,
|
||||
`created: ${today}`,
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'**Status:** Not started',
|
||||
'**Current Phase:** None',
|
||||
`**Last Activity:** ${today}`,
|
||||
'**Last Activity Description:** Workstream created',
|
||||
'',
|
||||
'## Progress',
|
||||
'**Phases Complete:** 0',
|
||||
'**Current Plan:** N/A',
|
||||
'',
|
||||
'## Session Continuity',
|
||||
'**Stopped At:** N/A',
|
||||
'**Resume File:** None',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const statePath = path.join(wsDir, 'STATE.md');
|
||||
if (!fs.existsSync(statePath)) {
|
||||
platformWriteSync(statePath, stateContent);
|
||||
}
|
||||
|
||||
setActiveWorkstream(cwd, slug);
|
||||
|
||||
const relPath = toPosixPath(path.relative(cwd, wsDir));
|
||||
output({
|
||||
created: true,
|
||||
workstream: slug,
|
||||
path: relPath,
|
||||
state_path: relPath + '/STATE.md',
|
||||
phases_path: relPath + '/phases',
|
||||
migration: migration || null,
|
||||
active: true,
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
function cmdWorkstreamList(cwd: string, raw: boolean): void {
|
||||
const inventory = listWorkstreamInventories(cwd);
|
||||
if (inventory.mode === 'flat') {
|
||||
output({ mode: 'flat', workstreams: [], message: inventory.message }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const workstreams = inventory.workstreams.map(ws => ({
|
||||
name: ws.name,
|
||||
path: ws.path,
|
||||
has_roadmap: ws.files.roadmap,
|
||||
has_state: ws.files.state,
|
||||
status: ws.status,
|
||||
current_phase: ws.current_phase,
|
||||
phase_count: ws.phase_count,
|
||||
completed_phases: ws.completed_phases,
|
||||
}));
|
||||
|
||||
output({ mode: 'workstream', workstreams, count: workstreams.length }, raw, undefined);
|
||||
}
|
||||
|
||||
function cmdWorkstreamStatus(cwd: string, name: string | null | undefined, raw: boolean): void {
|
||||
if (!name) error('workstream name required. Usage: workstream status <name>');
|
||||
try {
|
||||
assertValidActiveWorkstreamName(name, INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE);
|
||||
} catch {
|
||||
error(INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE);
|
||||
}
|
||||
|
||||
const wsDir = path.join(planningRoot(cwd), 'workstreams', name!);
|
||||
if (!fs.existsSync(wsDir)) {
|
||||
output({ found: false, workstream: name }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const inv = inspectWorkstream(cwd, name!);
|
||||
if (!inv) {
|
||||
output({ found: false, workstream: name }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
output({
|
||||
found: true,
|
||||
workstream: name,
|
||||
path: inv.path,
|
||||
files: inv.files,
|
||||
phases: inv.phases,
|
||||
phase_count: inv.phase_count,
|
||||
completed_phases: inv.completed_phases,
|
||||
status: inv.status,
|
||||
current_phase: inv.current_phase,
|
||||
last_activity: inv.last_activity,
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
function cmdWorkstreamComplete(cwd: string, name: string | null | undefined, options: Record<string, unknown>, raw: boolean): void {
|
||||
if (!name) error('workstream name required. Usage: workstream complete <name>');
|
||||
try {
|
||||
assertValidActiveWorkstreamName(name, INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE);
|
||||
} catch {
|
||||
error(INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE);
|
||||
}
|
||||
|
||||
const root = planningRoot(cwd);
|
||||
const wsRoot = path.join(root, 'workstreams');
|
||||
const wsDir = path.join(wsRoot, name!);
|
||||
|
||||
if (!fs.existsSync(wsDir)) {
|
||||
output({ completed: false, error: 'not_found', workstream: name }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const active = getActiveWorkstream(cwd);
|
||||
if (active === name) setActiveWorkstream(cwd, null as unknown as string);
|
||||
|
||||
const archiveDir = path.join(root, 'milestones');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
let archivePath = path.join(archiveDir, `ws-${name}-${today}`);
|
||||
let suffix = 1;
|
||||
while (fs.existsSync(archivePath)) {
|
||||
archivePath = path.join(archiveDir, `ws-${name}-${today}-${suffix++}`);
|
||||
}
|
||||
|
||||
platformEnsureDir(archivePath);
|
||||
|
||||
const filesMoved: string[] = [];
|
||||
try {
|
||||
const entries = fs.readdirSync(wsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
fs.renameSync(path.join(wsDir, entry.name), path.join(archivePath, entry.name));
|
||||
filesMoved.push(entry.name);
|
||||
}
|
||||
} catch (err) {
|
||||
for (const fname of filesMoved) {
|
||||
try { fs.renameSync(path.join(archivePath, fname), path.join(wsDir, fname)); } catch { /* ignore */ }
|
||||
}
|
||||
try { fs.rmSync(archivePath, { recursive: true }); } catch { /* ignore */ }
|
||||
if (active === name) setActiveWorkstream(cwd, name!);
|
||||
output({ completed: false, error: 'archive_failed', message: (err as Error).message, workstream: name }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
try { fs.rmdirSync(wsDir); } catch { /* ignore */ }
|
||||
|
||||
let remainingWs = 0;
|
||||
try {
|
||||
remainingWs = fs.readdirSync(wsRoot, { withFileTypes: true }).filter(e => e.isDirectory()).length;
|
||||
if (remainingWs === 0) fs.rmdirSync(wsRoot);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
output({
|
||||
completed: true,
|
||||
workstream: name,
|
||||
archived_to: toPosixPath(path.relative(cwd, archivePath)),
|
||||
remaining_workstreams: remainingWs,
|
||||
reverted_to_flat: remainingWs === 0,
|
||||
}, raw, undefined);
|
||||
}
|
||||
|
||||
// ─── Active Workstream Commands ───────────────────────────────────────────────
|
||||
|
||||
function cmdWorkstreamSet(cwd: string, name: string | null | undefined, raw: boolean): void {
|
||||
if (!name || name === '--clear') {
|
||||
if (name !== '--clear') {
|
||||
error('Workstream name required. Usage: workstream set <name> (or workstream set --clear to unset)');
|
||||
}
|
||||
const previous = getActiveWorkstream(cwd);
|
||||
setActiveWorkstream(cwd, null as unknown as string);
|
||||
output({ active: null, cleared: true, previous: previous || null }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidActiveWorkstreamName(name)) {
|
||||
output({ active: null, error: 'invalid_name', message: 'Workstream name must be alphanumeric, hyphens, underscores, or dots' }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
|
||||
if (!fs.existsSync(wsDir)) {
|
||||
output({ active: null, error: 'not_found', workstream: name }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveWorkstream(cwd, name);
|
||||
output({ active: name, set: true }, raw, name);
|
||||
}
|
||||
|
||||
function cmdWorkstreamGet(cwd: string, raw: boolean): void {
|
||||
const active = getActiveWorkstream(cwd);
|
||||
const wsRoot = path.join(planningRoot(cwd), 'workstreams');
|
||||
output({ active, mode: fs.existsSync(wsRoot) ? 'workstream' : 'flat' }, raw, active || 'none');
|
||||
}
|
||||
|
||||
function cmdWorkstreamProgress(cwd: string, raw: boolean): void {
|
||||
const inventory = listWorkstreamInventories(cwd);
|
||||
if (inventory.mode === 'flat') {
|
||||
output({ mode: 'flat', workstreams: [], message: inventory.message }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const workstreams = inventory.workstreams.map(ws => ({
|
||||
name: ws.name,
|
||||
active: ws.active,
|
||||
status: ws.status,
|
||||
current_phase: ws.current_phase ?? null,
|
||||
phases: `${ws.completed_phases}/${ws.roadmap_phase_count}`,
|
||||
plans: `${ws.completed_plans}/${ws.total_plans}`,
|
||||
progress_percent: ws.progress_percent,
|
||||
}));
|
||||
|
||||
output({ mode: 'workstream', active: inventory.active, workstreams, count: workstreams.length }, raw, undefined);
|
||||
}
|
||||
|
||||
// ─── Collision Detection ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return other workstreams that are NOT complete.
|
||||
* Used to detect whether the milestone has active parallel work
|
||||
* when a workstream finishes its last phase.
|
||||
*/
|
||||
function getOtherActiveWorkstreams(cwd: string, excludeWs: string): Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
current_phase: string | null;
|
||||
phases: string;
|
||||
}> {
|
||||
return getOtherActiveWorkstreamInventories(cwd, excludeWs).map(ws => ({
|
||||
name: ws.name,
|
||||
status: ws.status,
|
||||
current_phase: ws.current_phase ?? null,
|
||||
phases: `${ws.completed_phases}/${ws.phase_count}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export = {
|
||||
migrateToWorkstreams,
|
||||
cmdWorkstreamCreate,
|
||||
cmdWorkstreamList,
|
||||
cmdWorkstreamStatus,
|
||||
cmdWorkstreamComplete,
|
||||
cmdWorkstreamSet,
|
||||
cmdWorkstreamGet,
|
||||
cmdWorkstreamProgress,
|
||||
getOtherActiveWorkstreams,
|
||||
};
|
||||
1062
src/worktree-safety.cts
Normal file
1062
src/worktree-safety.cts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user