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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #609.

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

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

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

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

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

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

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

---------

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

View File

@@ -0,0 +1,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
View 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,
};

View File

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

48
src/artifacts.cts Normal file
View 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
View 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 };

View 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,
};

View 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
View 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
View 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
View File

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

828
src/command-aliases.cts Normal file
View 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);

View 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
View 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

File diff suppressed because it is too large Load Diff

33
src/config-schema.cts Normal file
View 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
View 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
View File

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

View File

@@ -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

File diff suppressed because it is too large Load Diff

127
src/decisions.cts Normal file
View File

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

279
src/docs.cts Normal file
View 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
View 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
View File

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

404
src/frontmatter.cts Normal file
View 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
View 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
View 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
View 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,
};

View File

@@ -0,0 +1,95 @@
/**
* Manifest-backed init subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: all init.* subcommands have SDK equivalents and are dispatched
* via executeForCjs (the sync bridge). CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* CJS-only subcommands: none.
* SDK-only (unsupported in CJS router): none.
*
* ADR-457 build-at-publish: the hand-written bin/lib/init-command-router.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
import { INIT_SUBCOMMANDS } from './command-aliases.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
import { parseNamedArgs } from './command-arg-projection.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface InitModule {
cmdInitExecutePhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record<string, string | boolean | null>): void;
cmdInitPlanPhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record<string, string | boolean | null>): void;
cmdInitNewProject(cwd: string, raw: boolean): void;
cmdInitNewMilestone(cwd: string, raw: boolean): void;
cmdInitQuick(cwd: string, name: string, raw: boolean): void;
cmdInitIngestDocs(cwd: string, raw: boolean): void;
cmdInitResume(cwd: string, raw: boolean): void;
cmdInitVerifyWork(cwd: string, phase: string | undefined, raw: boolean): void;
cmdInitPhaseOp(cwd: string, phase: string | undefined, raw: boolean): void;
cmdInitTodos(cwd: string, phase: string | undefined, raw: boolean): void;
cmdInitMilestoneOp(cwd: string, raw: boolean): void;
cmdInitMapCodebase(cwd: string, raw: boolean): void;
cmdInitProgress(cwd: string, raw: boolean): void;
cmdInitManager(cwd: string, raw: boolean): void;
cmdInitNewWorkspace(cwd: string, raw: boolean): void;
cmdInitListWorkspaces(cwd: string, raw: boolean): void;
cmdInitRemoveWorkspace(cwd: string, name: string | undefined, raw: boolean): void;
}
interface RouteInitCommandOptions {
init: InitModule;
args: string[];
cwd: string;
raw: boolean;
error: (message: string) => void;
}
// ─── Implementation ───────────────────────────────────────────────────────────
function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptions): void {
routeCjsCommandFamily({
args,
subcommands: INIT_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand: string, available: string[]) => `Unknown init workflow: ${_subcommand}\nAvailable: ${available.join(', ')}`,
handlers: {
'execute-phase': () => {
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
},
'plan-phase': () => {
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
},
'new-project': () => init.cmdInitNewProject(cwd, raw),
'new-milestone': () => init.cmdInitNewMilestone(cwd, raw),
quick: () => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw),
'ingest-docs': () => init.cmdInitIngestDocs(cwd, raw),
resume: () => init.cmdInitResume(cwd, raw),
'verify-work': () => init.cmdInitVerifyWork(cwd, args[2], raw),
'phase-op': () => init.cmdInitPhaseOp(cwd, args[2], raw),
todos: () => init.cmdInitTodos(cwd, args[2], raw),
'milestone-op': () => init.cmdInitMilestoneOp(cwd, raw),
'map-codebase': () => init.cmdInitMapCodebase(cwd, raw),
progress: () => init.cmdInitProgress(cwd, raw),
// Keep manager on CJS for now so runtime-specific command rendering
// (e.g. $gsd-* for codex) stays consistent with runtime-slash helpers.
manager: () => init.cmdInitManager(cwd, raw),
'new-workspace': () => init.cmdInitNewWorkspace(cwd, raw),
'list-workspaces': () => init.cmdInitListWorkspaces(cwd, raw),
'remove-workspace': () => init.cmdInitRemoveWorkspace(cwd, args[2], raw),
},
});
}
export = {
routeInitCommand,
};

2231
src/init.cts Normal file

File diff suppressed because it is too large Load Diff

548
src/install-profiles.cts Normal file
View 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,
};

View File

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

View File

@@ -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;
}

View 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,
};

View 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;

View 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;

View 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
View 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
View 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
View 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
View File

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

38
src/model-profiles.cts Normal file
View 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,
};

View File

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

View File

@@ -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 };

View 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;
}

View File

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

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

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

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

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

272
src/planning-workspace.cts Normal file
View 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

File diff suppressed because it is too large Load Diff

627
src/profile-pipeline.cts Normal file
View 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
View 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
View 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,
},
};
}

View 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,
};
}

View 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
View 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
View 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,
};

View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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 };
}

View 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 });
}

View 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
View 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

File diff suppressed because it is too large Load Diff

468
src/surface.cts Normal file
View 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
View 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
View 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
View 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
View 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
View 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 ?? '',
});
}

View 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
View 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;
}

View 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

File diff suppressed because it is too large Load Diff

View 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,
};
}

View 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,
};

View 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
View 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

File diff suppressed because it is too large Load Diff