fix: create milestone directory when triage defers to a not-yet-existing milestone (#1813)

This commit is contained in:
TÂCHES 2026-03-21 12:04:24 -06:00 committed by GitHub
parent 562e8eb164
commit 81acd05579
4 changed files with 281 additions and 30 deletions

View file

@ -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`);

View file

@ -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 });
}
});

View file

@ -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<string, CaptureEntry[]>();
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) {

View file

@ -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,