* 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:
@@ -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']);
|
||||
|
||||
106
src/state.cts
106
src/state.cts
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user