diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 6d7f054de..4f60e801e 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -304,36 +304,43 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV try { const { executeTriageResolutions } = await import("./triage-resolution.js"); const state = await deriveState(s.basePath); - const mid = state.activeMilestone?.id; - const sid = state.activeSlice?.id; + const mid = state.activeMilestone?.id ?? ""; + const sid = state.activeSlice?.id ?? ""; - if (mid && sid) { - const triageResult = executeTriageResolutions(s.basePath, mid, sid); + // executeTriageResolutions handles defer milestone creation even + // without an active milestone/slice (the "all milestones complete" + // scenario from #1562). inject/replan/quick-task still require mid+sid. + const triageResult = executeTriageResolutions(s.basePath, mid, sid); - if (triageResult.injected > 0) { - ctx.ui.notify( - `Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`, - "info", - ); - } - if (triageResult.replanned > 0) { - ctx.ui.notify( - `Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`, - "info", - ); - } - if (triageResult.quickTasks.length > 0) { - for (const qt of triageResult.quickTasks) { - s.pendingQuickTasks.push(qt); - } - ctx.ui.notify( - `Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`, - "info", - ); - } - for (const action of triageResult.actions) { - process.stderr.write(`gsd-triage: ${action}\n`); + if (triageResult.injected > 0) { + ctx.ui.notify( + `Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`, + "info", + ); + } + if (triageResult.replanned > 0) { + ctx.ui.notify( + `Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`, + "info", + ); + } + if (triageResult.deferredMilestones > 0) { + ctx.ui.notify( + `Triage: created ${triageResult.deferredMilestones} deferred milestone director${triageResult.deferredMilestones === 1 ? "y" : "ies"}.`, + "info", + ); + } + if (triageResult.quickTasks.length > 0) { + for (const qt of triageResult.quickTasks) { + s.pendingQuickTasks.push(qt); } + ctx.ui.notify( + `Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`, + "info", + ); + } + for (const action of triageResult.actions) { + process.stderr.write(`gsd-triage: ${action}\n`); } } catch (err) { process.stderr.write(`gsd-triage: resolution execution failed: ${(err as Error).message}\n`); diff --git a/src/resources/extensions/gsd/tests/triage-resolution.test.ts b/src/resources/extensions/gsd/tests/triage-resolution.test.ts index 29cf26b8e..496685732 100644 --- a/src/resources/extensions/gsd/tests/triage-resolution.test.ts +++ b/src/resources/extensions/gsd/tests/triage-resolution.test.ts @@ -10,7 +10,7 @@ import { tmpdir } from "node:os"; import { appendCapture, markCaptureResolved, markCaptureExecuted, loadAllCaptures, loadActionableCaptures } from "../captures.ts"; // Import only the functions that don't depend on @gsd/pi-coding-agent // (triage-ui.ts imports next-action-ui.ts which imports the unavailable package) -import { executeInject, executeReplan, detectFileOverlap, loadDeferredCaptures, loadReplanCaptures, buildQuickTaskPrompt, executeTriageResolutions } from "../triage-resolution.ts"; +import { executeInject, executeReplan, detectFileOverlap, loadDeferredCaptures, loadReplanCaptures, buildQuickTaskPrompt, executeTriageResolutions, ensureDeferMilestoneDir } from "../triage-resolution.ts"; function makeTempDir(prefix: string): string { const dir = join( @@ -414,3 +414,142 @@ test("resolution: executeTriageResolutions returns empty result when no actionab rmSync(tmp, { recursive: true, force: true }); } }); + +// ─── ensureDeferMilestoneDir ───────────────────────────────────────────────── + +test("resolution: ensureDeferMilestoneDir creates milestone directory with CONTEXT-DRAFT.md", () => { + const tmp = makeTempDir("res-defer-create"); + try { + mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true }); + + const captures = [ + { id: "CAP-aaa111", text: "add performance monitoring", timestamp: "2026-03-15T20:00:00Z", status: "resolved" as const, classification: "defer" as const }, + { id: "CAP-bbb222", text: "optimize database queries", timestamp: "2026-03-15T20:01:00Z", status: "resolved" as const, classification: "defer" as const }, + ]; + + const created = ensureDeferMilestoneDir(tmp, "M005", captures); + assert.strictEqual(created, true, "should return true"); + + const msDir = join(tmp, ".gsd", "milestones", "M005"); + assert.ok(existsSync(msDir), "milestone directory should exist"); + + const draftPath = join(msDir, "M005-CONTEXT-DRAFT.md"); + assert.ok(existsSync(draftPath), "CONTEXT-DRAFT.md should exist"); + + const content = readFileSync(draftPath, "utf-8"); + assert.ok(content.includes("# M005:"), "should have milestone heading"); + assert.ok(content.includes("CAP-aaa111"), "should list first capture"); + assert.ok(content.includes("CAP-bbb222"), "should list second capture"); + assert.ok(content.includes("add performance monitoring"), "should include capture text"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: ensureDeferMilestoneDir returns true without overwriting existing directory", () => { + const tmp = makeTempDir("res-defer-exists"); + try { + const msDir = join(tmp, ".gsd", "milestones", "M003"); + mkdirSync(msDir, { recursive: true }); + writeFileSync(join(msDir, "M003-CONTEXT.md"), "# M003: Existing\n", "utf-8"); + + const created = ensureDeferMilestoneDir(tmp, "M003", []); + assert.strictEqual(created, true, "should return true for existing dir"); + // Original file should still be there + assert.ok(existsSync(join(msDir, "M003-CONTEXT.md")), "existing files should be preserved"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: ensureDeferMilestoneDir rejects invalid milestone IDs", () => { + const tmp = makeTempDir("res-defer-invalid"); + try { + mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true }); + assert.strictEqual(ensureDeferMilestoneDir(tmp, "S03", []), false, "should reject slice IDs"); + assert.strictEqual(ensureDeferMilestoneDir(tmp, "not-a-milestone", []), false, "should reject arbitrary strings"); + assert.strictEqual(ensureDeferMilestoneDir(tmp, "", []), false, "should reject empty string"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: ensureDeferMilestoneDir handles unique milestone IDs (M005-abc123)", () => { + const tmp = makeTempDir("res-defer-unique"); + try { + mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true }); + + const created = ensureDeferMilestoneDir(tmp, "M005-abc123", [ + { id: "CAP-ccc333", text: "future work", timestamp: "2026-03-15T20:00:00Z", status: "resolved" as const, classification: "defer" as const }, + ]); + assert.strictEqual(created, true); + + const msDir = join(tmp, ".gsd", "milestones", "M005-abc123"); + assert.ok(existsSync(msDir), "milestone directory should exist"); + assert.ok( + existsSync(join(msDir, "M005-abc123-CONTEXT-DRAFT.md")), + "CONTEXT-DRAFT.md should use full milestone ID", + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── executeTriageResolutions + defer ──────────────────────────────────────── + +test("resolution: executeTriageResolutions creates milestone dir for deferred captures", () => { + const tmp = makeTempDir("res-exec-defer"); + try { + mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true }); + + const id1 = appendCapture(tmp, "add caching layer"); + const id2 = appendCapture(tmp, "optimize queries"); + markCaptureResolved(tmp, id1, "defer", "deferred to M005", "future perf work"); + markCaptureResolved(tmp, id2, "defer", "deferred to M005", "future perf work"); + + const result = executeTriageResolutions(tmp, "M001", "S01"); + + assert.strictEqual(result.deferredMilestones, 1, "should create 1 milestone"); + assert.ok( + existsSync(join(tmp, ".gsd", "milestones", "M005")), + "M005 directory should exist", + ); + assert.ok( + existsSync(join(tmp, ".gsd", "milestones", "M005", "M005-CONTEXT-DRAFT.md")), + "CONTEXT-DRAFT.md should exist", + ); + + // Deferred captures should be marked as executed + const all = loadAllCaptures(tmp); + assert.strictEqual(all[0].executed, true, "first defer should be marked executed"); + assert.strictEqual(all[1].executed, true, "second defer should be marked executed"); + + // Verify the draft content includes both captures + const draft = readFileSync(join(tmp, ".gsd", "milestones", "M005", "M005-CONTEXT-DRAFT.md"), "utf-8"); + assert.ok(draft.includes("add caching layer"), "should include first capture text"); + assert.ok(draft.includes("optimize queries"), "should include second capture text"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: executeTriageResolutions skips defer when milestone already exists", () => { + const tmp = makeTempDir("res-exec-defer-exists"); + try { + // Pre-create M005 + const msDir = join(tmp, ".gsd", "milestones", "M005"); + mkdirSync(msDir, { recursive: true }); + writeFileSync(join(msDir, "M005-CONTEXT.md"), "# M005: Already Planned\n", "utf-8"); + + const id = appendCapture(tmp, "defer this"); + markCaptureResolved(tmp, id, "defer", "deferred to M005", "later"); + + const result = executeTriageResolutions(tmp, "M001", "S01"); + + assert.strictEqual(result.deferredMilestones, 0, "should not count existing milestone"); + // Original file should be preserved + assert.ok(existsSync(join(msDir, "M005-CONTEXT.md")), "existing files should be preserved"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/triage-resolution.ts b/src/resources/extensions/gsd/triage-resolution.ts index 3765c63dd..61e959077 100644 --- a/src/resources/extensions/gsd/triage-resolution.ts +++ b/src/resources/extensions/gsd/triage-resolution.ts @@ -10,9 +10,10 @@ * Also provides detectFileOverlap() for surfacing downstream impact on quick tasks. */ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { gsdRoot } from "./paths.js"; +import { gsdRoot, milestonesDir } from "./paths.js"; +import { MILESTONE_ID_RE } from "./milestone-ids.js"; import type { Classification, CaptureEntry } from "./captures.js"; import { loadPendingCaptures, @@ -165,6 +166,63 @@ export function detectFileOverlap( return overlappingTasks; } +// ─── Defer Milestone Creation ───────────────────────────────────────────────── + +/** + * Ensure the milestone directory exists when triage defers a capture to a + * not-yet-created milestone (e.g., "M005"). + * + * Creates the directory with a seed CONTEXT-DRAFT.md so that `deriveState()` + * discovers the milestone and enters the discussion phase instead of + * treating the project as fully complete. + * + * @param basePath - Project root + * @param targetMilestone - The milestone ID to defer to (e.g., "M005") + * @param captures - Captures being deferred to this milestone + * @returns true if the directory was created (or already existed), false on error + */ +export function ensureDeferMilestoneDir( + basePath: string, + targetMilestone: string, + captures: CaptureEntry[], +): boolean { + if (!MILESTONE_ID_RE.test(targetMilestone)) return false; + + const msDir = join(milestonesDir(basePath), targetMilestone); + if (existsSync(msDir)) return true; + + try { + mkdirSync(msDir, { recursive: true }); + + // Seed CONTEXT-DRAFT.md with deferred capture context + const captureList = captures + .map(c => `- **${c.id}:** ${c.text}`) + .join("\n"); + + const draftContent = [ + `# ${targetMilestone}: Deferred Work`, + ``, + `This milestone was created by triage when captures were deferred here.`, + `Discuss scope and goals before planning slices.`, + ``, + `## Deferred Captures`, + ``, + captureList || `(no captures yet)`, + ``, + ].join("\n"); + + writeFileSync( + join(msDir, `${targetMilestone}-CONTEXT-DRAFT.md`), + draftContent, + "utf-8", + ); + + return true; + } catch { + return false; + } +} + /** * Load deferred captures (classification === "defer") for injection into * reassess-roadmap prompts. @@ -212,6 +270,8 @@ export interface TriageExecutionResult { injected: number; /** Number of replan triggers written */ replanned: number; + /** Number of defer milestone directories created */ + deferredMilestones: number; /** Captures classified as quick-task that need dispatch */ quickTasks: CaptureEntry[]; /** Details of each action taken, for logging */ @@ -240,11 +300,44 @@ export function executeTriageResolutions( const result: TriageExecutionResult = { injected: 0, replanned: 0, + deferredMilestones: 0, quickTasks: [], actions: [], }; const actionable = loadActionableCaptures(basePath); + + // Also process deferred captures that target milestone IDs — create + // milestone directories so deriveState() discovers them. + const deferred = loadAllCaptures(basePath).filter( + c => c.status === "resolved" && !c.executed && c.classification === "defer", + ); + if (deferred.length > 0) { + // Group deferred captures by target milestone + const byMilestone = new Map(); + for (const cap of deferred) { + const target = cap.resolution?.match(/\b(M\d{3}(?:-[a-z0-9]{6})?)\b/)?.[1]; + if (target) { + const list = byMilestone.get(target) ?? []; + list.push(cap); + byMilestone.set(target, list); + } + } + for (const [milestoneId, captures] of byMilestone) { + const msDir = join(milestonesDir(basePath), milestoneId); + if (!existsSync(msDir)) { + const created = ensureDeferMilestoneDir(basePath, milestoneId, captures); + if (created) { + result.deferredMilestones++; + result.actions.push(`Created milestone ${milestoneId} for ${captures.length} deferred capture(s)`); + for (const cap of captures) { + markCaptureExecuted(basePath, cap.id); + } + } + } + } + } + if (actionable.length === 0) return result; for (const capture of actionable) { diff --git a/src/resources/extensions/gsd/triage-ui.ts b/src/resources/extensions/gsd/triage-ui.ts index 2f5db0c64..a9b81f46f 100644 --- a/src/resources/extensions/gsd/triage-ui.ts +++ b/src/resources/extensions/gsd/triage-ui.ts @@ -13,6 +13,7 @@ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; import { showNextAction } from "../shared/tui.js"; import type { CaptureEntry, Classification, TriageResult } from "./captures.js"; import { markCaptureResolved } from "./captures.js"; +import { ensureDeferMilestoneDir } from "./triage-resolution.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -96,6 +97,12 @@ export async function showTriageConfirmation( result.rationale, ); + // Create the milestone directory when deferring to a milestone that + // doesn't exist yet, so deriveState() discovers it. + if (result.classification === "defer" && result.targetSlice) { + ensureDeferMilestoneDir(basePath, result.targetSlice, [capture]); + } + confirmed.push({ captureId: result.captureId, classification: result.classification, @@ -161,6 +168,11 @@ export async function showTriageConfirmation( userOverride ? `User override: ${result.rationale}` : result.rationale, ); + // Create the milestone directory when user confirms/overrides to defer + if (finalClassification === "defer" && result.targetSlice) { + ensureDeferMilestoneDir(basePath, result.targetSlice, [capture]); + } + confirmed.push({ captureId: result.captureId, classification: finalClassification,