fix: create milestone directory when triage defers to a not-yet-existing milestone (#1813)
This commit is contained in:
parent
562e8eb164
commit
81acd05579
4 changed files with 281 additions and 30 deletions
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue