fix(#2390): warn on goal-shaped phase.add titles, correct auto-detect docs (#2425)

* fix(#2390): phase.add title warning + auto-detect doc fix

phase.add now returns a `warning` field when a description reads as
goal-shaped (>80 chars and/or multi-sentence) rather than title-shaped,
instead of silently writing the whole paragraph verbatim as the
`### Phase N:` header. The CLI still creates the phase as-is (the
strict two-layer slash-vs-CLI interface is unchanged); the warning
just surfaces the gap.

Also clarifies six doc sites (command argument hints, workflow
detection steps, and how-to/reference docs) that described the
phase-number argument as "auto-detecting" the next unplanned phase --
that detection is an orchestrating-workflow/LLM step reading
ROADMAP.md (concretely: `query roadmap.analyze`'s `next_phase`
field), not a `gsd-tools.cjs` CLI feature.

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

* chore(#2390): regenerate fixtures + lint gate-prep

* fix(#2390): repair failing tests after gate verification

* chore(#2390): add changeset (#2425)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-19 07:53:04 -04:00
committed by GitHub
parent 1720aacf0c
commit a7d83dc234
31 changed files with 245 additions and 111 deletions

View File

@@ -759,6 +759,32 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
output(result, raw);
}
// #2390 — phase.add title-shape heuristic. A description at or under this many
// characters, and with no sentence-ending punctuation followed by more text,
// reads as a short Title. Anything longer or multi-sentence reads as a Goal,
// not a Title. phase.add still writes the phase verbatim (it never mangles
// ROADMAP.md), but when the description looks goal-shaped the JSON result
// gains a `warning` key naming the gap, so the caller — or the orchestrating
// add-phase workflow — can split title vs. goal instead of the whole paragraph
// landing silently in the `### Phase N:` header.
const PHASE_ADD_TITLE_MAX_LEN = 80;
const PHASE_ADD_MULTI_SENTENCE_RE = /[.!?]['")\]]?\s+\S/;
function describeGoalShapedTitle(description: string): string | null {
const trimmed = description.trim();
const tooLong = trimmed.length > PHASE_ADD_TITLE_MAX_LEN;
const multiSentence = PHASE_ADD_MULTI_SENTENCE_RE.test(trimmed);
if (!tooLong && !multiSentence) return null;
const reasons = [
tooLong ? `${trimmed.length} chars (over the ${PHASE_ADD_TITLE_MAX_LEN}-char title threshold)` : null,
multiSentence ? 'multiple sentences' : null,
].filter(Boolean).join(', ');
return (
`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
`as the phase title; consider a short title with the detail moved to **Goal:**.`
);
}
function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?: string): void {
if (!description) {
error('description required for phase add');
@@ -858,7 +884,9 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?:
return { newPhaseId: _newPhaseId, dirName: _dirName };
});
const result = {
const titleWarning = describeGoalShapedTitle(description);
const result: Record<string, unknown> = {
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
padded:
typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
@@ -869,8 +897,9 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?:
),
naming_mode: config.phase_naming,
};
if (titleWarning) result['warning'] = titleWarning;
output(result, raw, result.padded);
output(result, raw, result['padded']);
}
function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): void {