diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index a1c6ad397..4b83aff08 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -22,7 +22,8 @@ export type DoctorIssueCode = | "task_done_must_haves_not_verified" | "active_requirement_missing_owner" | "blocked_requirement_missing_reason" - | "blocker_discovered_no_replan"; + | "blocker_discovered_no_replan" + | "delimiter_in_title"; export interface DoctorIssue { severity: DoctorSeverity; @@ -91,15 +92,43 @@ function validatePreferenceShape(preferences: GSDPreferences): string[] { return issues; } +/** + * Characters that are used as delimiters in GSD state management documents + * and should not appear in milestone or slice titles. + * + * - "—" (em dash, U+2014): used as a display separator in STATE.md and other docs. + * A title containing "—" makes the separator ambiguous, corrupting state display + * and confusing the LLM agent that reads and writes these files. + * - "–" (en dash, U+2013): visually similar to em dash; same ambiguity risk. + * - "/" (forward slash, U+002F): used as the path separator in unit IDs (M001/S01) + * and git branch names (gsd/M001/S01). A slash in a title can break path resolution. + */ +const TITLE_DELIMITER_RE = /[\u2014\u2013\/]/; // em dash, en dash, forward slash + +/** + * Check whether a milestone or slice title contains characters that conflict + * with GSD's state document delimiter conventions. + * Returns a human-readable description of the problem, or null if the title is safe. + */ +export function validateTitle(title: string): string | null { + if (TITLE_DELIMITER_RE.test(title)) { + const found: string[] = []; + if (/[\u2014\u2013]/.test(title)) found.push("em/en dash (\u2014 or \u2013)"); + if (/\//.test(title)) found.push("forward slash (/)"); + return `title contains ${found.join(" and ")}, which conflict with GSD state document delimiters`; + } + return null; +} + function buildStateMarkdown(state: Awaited>): string { const lines: string[] = []; lines.push("# GSD State", ""); const activeMilestone = state.activeMilestone - ? `${state.activeMilestone.id} — ${state.activeMilestone.title}` + ? `${state.activeMilestone.id}: ${state.activeMilestone.title}` : "None"; const activeSlice = state.activeSlice - ? `${state.activeSlice.id} — ${state.activeSlice.title}` + ? `${state.activeSlice.id}: ${state.activeSlice.title}` : "None"; lines.push(`**Active Milestone:** ${activeMilestone}`); @@ -477,6 +506,20 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; const milestonePath = resolveMilestonePath(basePath, milestoneId); if (!milestonePath) continue; + // Validate milestone title for delimiter characters that break state documents. + const milestoneTitleIssue = validateTitle(milestone.title); + if (milestoneTitleIssue) { + issues.push({ + severity: "warning", + code: "delimiter_in_title", + scope: "milestone", + unitId: milestoneId, + message: `Milestone ${milestoneId} ${milestoneTitleIssue}. Rename the milestone to remove these characters to prevent state corruption.`, + file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), + fixable: false, + }); + } + const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; if (!roadmapContent) continue; @@ -486,6 +529,20 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; const unitId = `${milestoneId}/${slice.id}`; if (options?.scope && !matchesScope(unitId, options.scope) && options.scope !== milestoneId) continue; + // Validate slice title for delimiter characters. + const sliceTitleIssue = validateTitle(slice.title); + if (sliceTitleIssue) { + issues.push({ + severity: "warning", + code: "delimiter_in_title", + scope: "slice", + unitId, + message: `Slice ${unitId} ${sliceTitleIssue}. Rename the slice to remove these characters to prevent state corruption.`, + file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), + fixable: false, + }); + } + const slicePath = resolveSlicePath(basePath, milestoneId, slice.id); if (!slicePath) continue; diff --git a/src/resources/extensions/gsd/templates/context.md b/src/resources/extensions/gsd/templates/context.md index 7b72e443b..3e19bb788 100644 --- a/src/resources/extensions/gsd/templates/context.md +++ b/src/resources/extensions/gsd/templates/context.md @@ -1,4 +1,4 @@ -# {{milestoneId}}: {{milestoneTitle}} — Context +# {{milestoneId}}: {{milestoneTitle}} **Gathered:** {{date}} **Status:** Ready for planning diff --git a/src/resources/extensions/gsd/templates/state.md b/src/resources/extensions/gsd/templates/state.md index 2279f79fe..35c000b1c 100644 --- a/src/resources/extensions/gsd/templates/state.md +++ b/src/resources/extensions/gsd/templates/state.md @@ -1,8 +1,8 @@ # GSD State -**Active Milestone:** {{milestoneId}} — {{milestoneTitle}} -**Active Slice:** {{sliceId}} — {{sliceTitle}} -**Active Task:** {{taskId}} — {{taskTitle}} +**Active Milestone:** {{milestoneId}}: {{milestoneTitle}} +**Active Slice:** {{sliceId}}: {{sliceTitle}} +**Active Task:** {{taskId}}: {{taskTitle}} **Phase:** {{phase}} **Slice Branch:** {{activeBranch}} **Active Workspace:** {{activeWorkspace}} diff --git a/src/resources/extensions/gsd/tests/doctor.test.ts b/src/resources/extensions/gsd/tests/doctor.test.ts index d2ae96480..7a2f47a8c 100644 --- a/src/resources/extensions/gsd/tests/doctor.test.ts +++ b/src/resources/extensions/gsd/tests/doctor.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync import { join } from "node:path"; import { tmpdir } from "node:os"; -import { formatDoctorReport, runGSDDoctor, summarizeDoctorIssues, filterDoctorIssues, selectDoctorScope } from "../doctor.js"; +import { formatDoctorReport, runGSDDoctor, summarizeDoctorIssues, filterDoctorIssues, selectDoctorScope, validateTitle } from "../doctor.js"; import { createTestContext } from './test-helpers.ts'; const { assertEq, assertTrue, report } = createTestContext(); @@ -471,6 +471,120 @@ Discovered an issue. rmSync(mhBase, { recursive: true, force: true }); } + // ─── validateTitle: em dash and slash detection ──────────────────────── + console.log("\n=== validateTitle: returns null for clean titles ==="); + { + assertEq(validateTitle("Foundation"), null, "clean title passes"); + assertEq(validateTitle("Build Core Systems"), null, "clean title with spaces passes"); + assertEq(validateTitle("API v2 Integration"), null, "clean title with version passes"); + assertEq(validateTitle(""), null, "empty title passes"); + } + + console.log("\n=== validateTitle: detects em dash ==="); + { + const result = validateTitle("Foundation — Build Core"); + assertTrue(result !== null, "detects em dash in title"); + assertTrue(result!.includes("em/en dash"), "message mentions em/en dash"); + } + + console.log("\n=== validateTitle: detects en dash ==="); + { + const result = validateTitle("Phase 1 – Phase 2"); + assertTrue(result !== null, "detects en dash in title"); + assertTrue(result!.includes("em/en dash"), "message mentions em/en dash for en dash"); + } + + console.log("\n=== validateTitle: detects forward slash ==="); + { + const result = validateTitle("Client/Server"); + assertTrue(result !== null, "detects forward slash in title"); + assertTrue(result!.includes("forward slash"), "message mentions forward slash"); + } + + console.log("\n=== validateTitle: detects both em dash and slash ==="); + { + const result = validateTitle("Client — Server/API"); + assertTrue(result !== null, "detects both delimiters"); + assertTrue(result!.includes("em/en dash"), "message mentions em/en dash"); + assertTrue(result!.includes("forward slash"), "message mentions forward slash"); + } + + // ─── doctor detects delimiter_in_title for milestone ─────────────────── + console.log("\n=== doctor detects em dash in milestone title ==="); + { + const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-test-")); + const dtGsd = join(dtBase, ".gsd"); + const dtMDir = join(dtGsd, "milestones", "M001"); + const dtSDir = join(dtMDir, "slices", "S01"); + const dtTDir = join(dtSDir, "tasks"); + mkdirSync(dtTDir, { recursive: true }); + + // Roadmap with em dash in milestone title + writeFileSync(join(dtMDir, "M001-ROADMAP.md"), `# M001: Foundation — Build Core\n\n## Slices\n- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`); + writeFileSync(join(dtSDir, "S01-PLAN.md"), `# S01: Demo Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n Task.\n`); + writeFileSync(join(dtTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Steps\n\n1. Do the thing.\n`); + + const report = await runGSDDoctor(dtBase, { fix: false }); + const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title"); + assertTrue(dtIssues.length >= 1, "detects delimiter_in_title for milestone with em dash"); + const milestoneIssue = dtIssues.find(i => i.scope === "milestone"); + assertTrue(milestoneIssue !== undefined, "delimiter issue has milestone scope"); + assertEq(milestoneIssue?.severity, "warning", "delimiter issue has warning severity"); + assertEq(milestoneIssue?.unitId, "M001", "delimiter issue unitId is M001"); + assertTrue(milestoneIssue?.message?.includes("em/en dash") ?? false, "issue message mentions em/en dash"); + assertEq(milestoneIssue?.fixable, false, "delimiter issue is not auto-fixable"); + + rmSync(dtBase, { recursive: true, force: true }); + } + + // ─── doctor detects delimiter_in_title for slice ──────────────────────── + console.log("\n=== doctor detects em dash in slice title ==="); + { + const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-slice-")); + const dtGsd = join(dtBase, ".gsd"); + const dtMDir = join(dtGsd, "milestones", "M001"); + const dtSDir = join(dtMDir, "slices", "S01"); + const dtTDir = join(dtSDir, "tasks"); + mkdirSync(dtTDir, { recursive: true }); + + // Roadmap with em dash in slice title (milestone title is clean) + writeFileSync(join(dtMDir, "M001-ROADMAP.md"), `# M001: Clean Milestone\n\n## Slices\n- [ ] **S01: Core — Foundation** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`); + writeFileSync(join(dtSDir, "S01-PLAN.md"), `# S01: Core — Foundation\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n Task.\n`); + writeFileSync(join(dtTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Steps\n\n1. Do the thing.\n`); + + const report = await runGSDDoctor(dtBase, { fix: false }); + const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title"); + assertTrue(dtIssues.length >= 1, "detects delimiter_in_title for slice with em dash"); + const sliceIssue = dtIssues.find(i => i.scope === "slice"); + assertTrue(sliceIssue !== undefined, "delimiter issue has slice scope"); + assertEq(sliceIssue?.severity, "warning", "slice delimiter issue has warning severity"); + assertEq(sliceIssue?.unitId, "M001/S01", "slice delimiter issue unitId is M001/S01"); + + rmSync(dtBase, { recursive: true, force: true }); + } + + // ─── doctor does NOT flag clean titles ────────────────────────────────── + console.log("\n=== doctor does NOT flag milestone with clean title ==="); + { + const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-clean-")); + const dtGsd = join(dtBase, ".gsd"); + const dtMDir = join(dtGsd, "milestones", "M001"); + const dtSDir = join(dtMDir, "slices", "S01"); + const dtTDir = join(dtSDir, "tasks"); + mkdirSync(dtTDir, { recursive: true }); + + // Roadmap with clean titles (no delimiters) + writeFileSync(join(dtMDir, "M001-ROADMAP.md"), `# M001: Foundation Build Core\n\n## Slices\n- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`); + writeFileSync(join(dtSDir, "S01-PLAN.md"), `# S01: Demo Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n Task.\n`); + writeFileSync(join(dtTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Steps\n\n1. Do the thing.\n`); + + const report = await runGSDDoctor(dtBase, { fix: false }); + const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title"); + assertEq(dtIssues.length, 0, "no delimiter_in_title issues for clean titles"); + + rmSync(dtBase, { recursive: true, force: true }); + } + report(); } diff --git a/src/resources/extensions/gsd/tests/regex-hardening.test.ts b/src/resources/extensions/gsd/tests/regex-hardening.test.ts index 77039e73f..f0a0b4a3c 100644 --- a/src/resources/extensions/gsd/tests/regex-hardening.test.ts +++ b/src/resources/extensions/gsd/tests/regex-hardening.test.ts @@ -67,6 +67,18 @@ async function main(): Promise { assertEq('M001-abc123: Title'.replace(TITLE_STRIP_RE, ''), 'Title', 'strips M001-abc123: Title → Title'); assertEq('M042-z9a8b7: Dashboard'.replace(TITLE_STRIP_RE, ''), 'Dashboard', 'strips M042-z9a8b7: Dashboard'); + // Em dash in title — current format (M001: Title) correctly preserves em dash in title body + assertEq( + 'M001: Foundation — Build Core'.replace(TITLE_STRIP_RE, ''), + 'Foundation — Build Core', + 'strips M001: prefix and preserves em dash in title body', + ); + assertEq( + 'M001-abc123: Foundation — Build Core'.replace(TITLE_STRIP_RE, ''), + 'Foundation — Build Core', + 'strips M001-abc123: prefix and preserves em dash in title body (unique format)', + ); + // Edge case: dash-style separator (M001 — Title: Subtitle preserves colon in body) assertEq( 'M001 — Unique Milestone IDs: Foo'.replace(TITLE_STRIP_RE, ''),