fix(#1140): implement state add-roadmap-evolution CJS handler (#1148)

* fix(#1140): implement state add-roadmap-evolution CJS handler

`query state.add-roadmap-evolution` was unreachable: the CJS state router
listed it in the `unsupported` map with a circular message ("...is SDK-only.
Use: gsd-tools query state.add-roadmap-evolution ...") and no CJS handler
existed after the SDK retirement (ADR-0174). Every `/gsd:phase insert` and
`/gsd:phase --edit` run hit a dead end recording Roadmap Evolution.

Re-implement `cmdStateAddRoadmapEvolution` in CJS (src/state.cts) and wire it
into the state router; remove the now-stale `unsupported` entry. The handler
appends a single-line bullet under `## Accumulated Context` → `### Roadmap
Evolution` (creating the subsection/section if missing, deduping identical
entries), scoping every lookup to the Accumulated Context body so a decoy
heading in an unrelated section is never targeted, and flattening multiline
notes to a single bullet. Section-boundary regexes mirror the sibling
add-decision/add-blocker handlers and preserve following sections on CRLF input.

Regression cases live in tests/state.test.cjs (per the no-new-bug-NNNN-files
policy) and cover the literal issue repro plus the CLI/parser QA matrix
(missing/empty/whitespace note, flag-shaped value, duplicate flags, hostile
shell metacharacters, Unicode, decoy section, CRLF, missing STATE.md).

Closes #1140

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

* chore(#1140): backfill changeset PR number (1148)

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-12 22:27:02 -04:00
committed by GitHub
parent 1b880fefd7
commit b431b1fab4
4 changed files with 481 additions and 3 deletions

View File

@@ -40,6 +40,7 @@ interface StateModule {
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;
@@ -78,9 +79,10 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
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 ...',
},
// 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,
@@ -147,6 +149,17 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
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']);

View File

@@ -69,6 +69,15 @@ interface StateAddBlockerOptions {
text_file?: string;
}
interface StateAddRoadmapEvolutionOptions {
phase?: string;
action?: string;
after?: string;
note?: string;
note_file?: string;
urgent?: boolean;
}
interface StateRecordSessionOptions {
stopped_at?: string;
resume_file?: string | null;
@@ -669,6 +678,102 @@ function cmdStateAddBlocker(cwd: string, text: string | StateAddBlockerOptions,
output(result, raw, 'true');
}
function cmdStateAddRoadmapEvolution(cwd: string, options: StateAddRoadmapEvolutionOptions, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
const { phase, action, after, note, note_file, urgent } = options;
let noteText: string | undefined = undefined;
try {
noteText = readTextArgOrFile(cwd, note, note_file, 'note');
} catch (err) {
output({ added: false, reason: (err as Error).message }, raw, 'false');
return;
}
// Reject missing / empty / whitespace-only notes — an evolution entry with no
// narrative is meaningless and would corrupt the section with a dangling bullet.
if (!noteText || !noteText.trim()) { output({ error: 'note required' }, raw, undefined); return; }
// Flatten line breaks so the entry is always a single Markdown bullet. The
// dedupe + rendering contract is line-oriented; a multiline --note-file would
// otherwise spill continuation lines outside the bullet and defeat dedupe.
// Internal spacing (e.g. dollar columns) is preserved.
const flatNote = noteText.replace(/\s*[\r\n]+\s*/g, ' ').trim();
const actionText = (action && action.trim()) || 'changed';
const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
const urgentText = urgent ? ' (URGENT)' : '';
const entry = `- Phase ${phase || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
let duplicate = false;
let created = false;
let subsectionCreated = false;
// The Roadmap Evolution subsection lives under `## Accumulated Context`. Scope
// every lookup to that section's body so a `### Roadmap Evolution` heading in an
// unrelated h2 section (or a fenced example) can never be matched or mutated.
// The accBody lookahead stops only at the next h2 (`\n##[^#]`), so nested h3
// subsections stay inside the captured Accumulated Context body.
// Section boundaries mirror the sibling handlers (add-decision/add-blocker):
// a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed,
// so following sections are preserved without data loss (see the CRLF test).
readModifyWriteStateMd(statePath, (content) => {
const accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i;
const accMatch = content.match(accPattern);
if (accMatch) {
const accHeader = accMatch[1];
const accBody = accMatch[2];
// Find `### Roadmap Evolution` WITHIN the Accumulated Context body only.
// Bounded by the next h3/h2 or the end of the section body.
const subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i;
const subMatch = accBody.match(subPattern);
if (subMatch) {
let subBody = subMatch[2];
// Dedupe: exact (trimmed) line already present is a no-op replay.
if (subBody.split('\n').some((line) => line.trim() === entry.trim())) {
duplicate = true;
return content;
}
subBody = subBody.replace(/None yet\.?\s*\n?/gi, '');
subBody = subBody.trimEnd() + '\n' + entry + '\n';
const newAccBody = accBody.replace(subPattern, (_m, header: string) => `${header}${subBody}`);
return content.replace(accPattern, () => `${accHeader}${newAccBody}`);
}
// Subsection missing — append it at the end of the Accumulated Context body.
subsectionCreated = true;
const trimmedAcc = accBody.trimEnd();
const block = `${trimmedAcc ? `${trimmedAcc}\n\n` : ''}### Roadmap Evolution\n\n${entry}\n`;
return content.replace(accPattern, () => `${accHeader}${block}`);
}
// No `## Accumulated Context` — DWIM: create both at end of file.
// Mirrors the add-decision / add-blocker auto-create behavior.
created = true;
subsectionCreated = true;
const scaffold = [
'',
'## Accumulated Context',
'',
'### Roadmap Evolution',
'',
entry,
'',
].join('\n');
return content.trimEnd() + '\n' + scaffold;
}, cwd);
if (duplicate) {
output({ added: false, reason: 'duplicate', entry }, raw, 'false');
return;
}
const result: Record<string, unknown> = { added: true, entry };
if (created) result['created'] = true;
if (subsectionCreated) result['subsection_created'] = true;
output(result, raw, 'true');
}
function cmdStateResolveBlocker(cwd: string, text: string, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw, undefined); return; }
@@ -2348,6 +2453,7 @@ export = {
cmdStateUpdateProgress,
cmdStateAddDecision,
cmdStateAddBlocker,
cmdStateAddRoadmapEvolution,
cmdStateResolveBlocker,
cmdStateRecordSession,
cmdStateSnapshot,