Files
msd-core/src/state-command-router.cts
Tom Boucher bad2c76c5e feat(#1826): cmdStateRebuild CLI + dry-run + verbose + integration tests + docs (#1830)
Phase 2 of approved feature #1817. Wires the pure `rebuildCore` transition
(Phase 1, #1827) to the `gsd state rebuild` CLI subcommand per ADR-1817
§5 (heavy/manual counterpart to lightweight, auto-triggered `state sync`).

Source changes:
- src/state.cts: implement cmdStateRebuild. Locks via
  readModifyWriteStateMd (real path) or read-only (dry-run). Wires
  phaseInventoryProvider to a real .planning/phases/ disk scan (same
  canonical source buildStateFrontmatter uses). --dry-run emits a
  structured preview without writing. --verbose tees the audit-log
  entries to stderr (treated as data-only per ADR-1577).
- src/state-command-router.cts: register cmdStateRebuild in the
  StateModule interface + add the rebuild handler with --dry-run and
  --verbose flag parsing.
- src/command-aliases.cts: register the state.rebuild canonical +
  'state rebuild' alias + mutation=true (so the manifest covers the
  new subcommand for SDK parity / dispatch hub tests).

Tests (tests/state-rebuild-cli.test.cjs, 5 cases):
- state rebuild with no flags reconciles drifted body + drops orphan
  table rows + appends audit log (end-to-end #1, #2, audit log).
- state rebuild --dry-run computes the diff, writes nothing (criterion #5).
- state rebuild --verbose tees the log; audit-log section still written.
- Running rebuild twice on the just-rebuilt file is byte-identical
  (criterion #6 end-to-end).
- Missing STATE.md produces a clean 'STATE.md not found' message, no
  stack trace (CONTRIBUTING QA matrix).

Verified locally:
- node --test tests/state-rebuild-cli.test.cjs → 5/5 pass
- node --test tests/state-rebuild.test.cjs → 18/18 pass (Phase 1 regression)
- node --test tests/state-transition.test.cjs → 85/85 pass (ADR-1769 regression)

Docs (docs/COMMANDS.md): document `state rebuild [--dry-run] [--verbose]`
with the canonical-command block format used by `state sync` /
`state prune`.

Changeset (.changeset/1817-state-rebuild.md): type=Added, user-facing
description of the new subcommand (closes #1817 epic on merge).
2026-06-29 16:15:55 -04:00

214 lines
10 KiB
TypeScript

/**
* 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 } = cjsCommandRouterAdapter;
import { parseNamedArgs } from './command-arg-projection.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
// 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;
cmdStateAddRoadmapEvolution(cwd: string, opts: Record<string, string | boolean | 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;
cmdStateRebuild(cwd: string, opts: { dryRun: boolean; verbose: 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',
// No SDK-only state subcommands remain: add-roadmap-evolution was the last
// holdout after the SDK retirement (ADR-0174) and is now implemented in CJS
// (handler below). See #1140.
unsupported: {},
error,
cwd,
raw,
unknownMessage: (subcommand: string, available: string[]) => `Unknown state subcommand: "${subcommand}". Available: ${available.join(', ')}`,
handlers: {
load: () => state.cmdStateLoad(cwd, raw),
json: () => state.cmdStateJson(cwd, raw),
get: () => state.cmdStateGet(cwd, args[2], raw),
update: () => state.cmdStateUpdate(cwd, args[2], args[3]),
patch: () => {
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': () => state.cmdStateAdvancePlan(cwd, raw),
'record-metric': () => {
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': () => state.cmdStateUpdateProgress(cwd, raw),
'add-decision': () => {
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': () => {
const a = parseNamedArgs(args, ['text', 'text-file']);
state.cmdStateAddBlocker(cwd, { text: strArg(a, 'text'), text_file: strArg(a, 'text-file') }, raw);
},
'add-roadmap-evolution': () => {
const a = parseNamedArgs(args, ['phase', 'action', 'after', 'note', 'note-file'], ['urgent']);
state.cmdStateAddRoadmapEvolution(cwd, {
phase: strArg(a, 'phase'),
action: strArg(a, 'action'),
after: strArg(a, 'after'),
note: strArg(a, 'note'),
note_file: strArg(a, 'note-file'),
urgent: a['urgent'] === true,
}, raw);
},
'resolve-blocker': () => state.cmdStateResolveBlocker(cwd, strArg(parseNamedArgs(args, ['text']), 'text'), raw),
'record-session': () => {
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': () => {
const a = parseNamedArgs(args, ['phase', 'name', 'plans']);
state.cmdStateBeginPhase(cwd, strArg(a, 'phase'), strArg(a, 'name'), parsePlans(strArg(a, 'plans')), raw);
},
'signal-waiting': () => {
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': () => state.cmdSignalResume(cwd, raw),
'planned-phase': () => {
const a = parseNamedArgs(args, ['phase', 'name', 'plans']);
state.cmdStatePlannedPhase(cwd, strArg(a, 'phase'), parsePlans(strArg(a, 'plans')), raw);
},
validate: () => state.cmdStateValidate(cwd, raw),
sync: () => {
const a = parseNamedArgs(args, [], ['verify']);
state.cmdStateSync(cwd, { verify: a['verify'] }, raw);
},
prune: () => {
const a = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
state.cmdStatePrune(cwd, { keepRecent: strArg(a, 'keep-recent') || '3', dryRun: a['dry-run'] === true }, raw);
},
rebuild: () => {
const a = parseNamedArgs(args, [], ['dry-run', 'verbose']);
state.cmdStateRebuild(cwd, { dryRun: a['dry-run'] === true, verbose: a['verbose'] === 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': () => {
const a = parseNamedArgs(args, ['milestone', 'name']);
state.cmdStateMilestoneSwitch(cwd, strArg(a, 'milestone'), strArg(a, 'name'), raw);
},
},
});
}
export = {
routeStateCommand,
};