feat: park/discard actions for in-progress milestones (#1107)
* feat: add park/discard actions for in-progress milestones Users could not discard, park, or skip milestones once work had begun. The wizard only offered "Go auto" and "View status" for milestones with a roadmap, trapping users with stale or deprioritized milestones. This adds: - Park mechanism: PARKED.md marker file in milestone directory. deriveState() transparently skips parked milestones when finding the active one. Parked milestones do NOT satisfy depends_on for downstream milestones, preventing accidental unblocking. - "Milestone actions" submenu in all four active-milestone wizard branches (roadmap-exists, planning, summarizing, executing). Offers Park / Discard / Skip / Back with clean navigation. - /gsd park [id] and /gsd unpark [id] CLI subcommands for direct access. - New module milestone-actions.ts with parkMilestone(), unparkMilestone(), discardMilestone(), isParked(), getParkedReason() — keeps guided-flow and commands thin. - 14 tests (36 assertions) covering state derivation, dependency semantics, park/unpark round-trip, discard with queue-order pruning, and edge cases (all-parked, no-roadmap park, progress counts). Files changed: types.ts — Add 'parked' to MilestoneRegistryEntry.status milestone-actions.ts — NEW: park/unpark/discard core logic state.ts — Skip parked in getActiveMilestoneId + deriveState guided-flow.ts — Milestone actions submenu in 4 wizard branches commands.ts — /gsd park and /gsd unpark subcommands + help guided-flow-queue.ts — Parked count in queue summary visualizer-data.ts — Add 'parked' to VisualizerMilestone.status park-milestone.test.ts — NEW: comprehensive test suite Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add edge case tests for park/discard milestone interactions Covers 9 critical scenarios (31 assertions): - Discard breaks depends_on chain → system correctly blocks - Park blocks depends_on chain - Queue order survives discards (QUEUE-ORDER.json pruned) - Park all + discard all → clean pre-planning state - Mixed states coexist (complete + parked + active + pending) - Park then discard same milestone - Discard milestone that has deps on others Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address critical review findings for park/discard feature Fixes 7 issues found by adversarial code review: 1. CRITICAL: auto-mode crashed with "Unexpected: N incomplete" error when all milestones were parked. Filter now excludes 'parked' status, and pre-planning phase is recognized as a valid stop condition. 2. Merge-to-main was skipped when parked milestones existed — same incomplete filter now excludes parked. 3. Completed milestones could be parked, corrupting depends_on satisfaction. parkMilestone() now guards against SUMMARY.md existence. 4. Escape during park reason picker silently parked with literal "not_yet" as reason. Now properly cancels the operation. 5. Parked milestones lost their human-readable title in registry (showed ID instead). Phase 1 now caches roadmap for parked milestones too, for title extraction. 6. GSD_MILESTONE_LOCK bypassed parked check — parallel workers locked to a parked milestone now correctly return null. 7. Parked milestones were eligible for parallel execution, wasting worker slots. parallel-eligibility now skips parked milestones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: complete parked status display across all surfaces - Visualizer: parked milestones show pause glyph (yellow) instead of pending dot - Doctor: parked milestones show pause emoji in registry report - HTML export: add .dot-parked CSS (yellow), parked legend entry, collapse parked milestone details by default - Queue reorder: exclude parked milestones from movable list Closes all remaining cosmetic findings from adversarial review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f410a0041
commit
51da6c4f74
16 changed files with 1090 additions and 25 deletions
|
|
@ -400,7 +400,7 @@ export async function bootstrapAutoSession(
|
|||
ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
|
||||
ctx.ui.setFooter(hideFooter);
|
||||
const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
|
||||
const pendingCount = state.registry.filter(m => m.status !== 'complete').length;
|
||||
const pendingCount = state.registry.filter(m => m.status !== 'complete' && m.status !== 'parked').length;
|
||||
const scopeMsg = pendingCount > 1
|
||||
? `Will loop through ${pendingCount} milestones.`
|
||||
: "Will loop until milestone complete.";
|
||||
|
|
|
|||
|
|
@ -822,8 +822,8 @@ async function showStepWizard(
|
|||
: "previous unit";
|
||||
|
||||
if (!mid || state.phase === "complete") {
|
||||
const incomplete = state.registry.filter(m => m.status !== "complete");
|
||||
if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked") {
|
||||
const incomplete = state.registry.filter(m => m.status !== "complete" && m.status !== "parked");
|
||||
if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked" && state.phase !== "pre-planning") {
|
||||
const ids = incomplete.map(m => m.id).join(", ");
|
||||
const diag = `basePath=${s.basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
|
||||
ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
|
||||
|
|
@ -1113,9 +1113,9 @@ async function dispatchNextUnit(
|
|||
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
||||
}
|
||||
|
||||
const incomplete = state.registry.filter(m => m.status !== "complete");
|
||||
const incomplete = state.registry.filter(m => m.status !== "complete" && m.status !== "parked");
|
||||
if (incomplete.length === 0) {
|
||||
// Genuinely all complete — merge milestone branch to main before stopping (#962)
|
||||
// Genuinely all complete (parked milestones excluded) — merge milestone branch to main before stopping (#962)
|
||||
if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
|
||||
try {
|
||||
const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
|
||||
|
|
|
|||
|
|
@ -116,6 +116,8 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|||
{ cmd: "knowledge", desc: "Add persistent project knowledge (rule, pattern, or lesson)" },
|
||||
{ cmd: "new-milestone", desc: "Create a milestone from a specification document (headless)" },
|
||||
{ cmd: "parallel", desc: "Parallel milestone orchestration (start, status, stop, merge)" },
|
||||
{ cmd: "park", desc: "Park a milestone — skip without deleting" },
|
||||
{ cmd: "unpark", desc: "Reactivate a parked milestone" },
|
||||
{ cmd: "update", desc: "Update GSD to the latest version" },
|
||||
];
|
||||
const parts = prefix.trim().split(/\s+/);
|
||||
|
|
@ -593,6 +595,73 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "park" || trimmed.startsWith("park ")) {
|
||||
const basePath = projectRoot();
|
||||
const arg = trimmed.replace(/^park\s*/, "").trim();
|
||||
const { parkMilestone, isParked } = await import("./milestone-actions.js");
|
||||
const { deriveState } = await import("./state.js");
|
||||
|
||||
let targetId = arg;
|
||||
if (!targetId) {
|
||||
// Park the current active milestone
|
||||
const state = await deriveState(basePath);
|
||||
if (!state.activeMilestone) {
|
||||
ctx.ui.notify("No active milestone to park.", "warning");
|
||||
return;
|
||||
}
|
||||
targetId = state.activeMilestone.id;
|
||||
}
|
||||
|
||||
if (isParked(basePath, targetId)) {
|
||||
ctx.ui.notify(`${targetId} is already parked. Use /gsd unpark ${targetId} to reactivate.`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract reason from remaining args (e.g., /gsd park M002 "reason here")
|
||||
const reasonParts = arg.replace(targetId, "").trim().replace(/^["']|["']$/g, "");
|
||||
const reason = reasonParts || "Parked via /gsd park";
|
||||
|
||||
const success = parkMilestone(basePath, targetId, reason);
|
||||
if (success) {
|
||||
ctx.ui.notify(`Parked ${targetId}. Run /gsd unpark ${targetId} to reactivate.`, "info");
|
||||
} else {
|
||||
ctx.ui.notify(`Could not park ${targetId} — milestone not found.`, "warning");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "unpark" || trimmed.startsWith("unpark ")) {
|
||||
const basePath = projectRoot();
|
||||
const arg = trimmed.replace(/^unpark\s*/, "").trim();
|
||||
const { unparkMilestone } = await import("./milestone-actions.js");
|
||||
const { deriveState } = await import("./state.js");
|
||||
|
||||
let targetId = arg;
|
||||
if (!targetId) {
|
||||
// List parked milestones and let user pick
|
||||
const state = await deriveState(basePath);
|
||||
const parkedEntries = state.registry.filter(e => e.status === "parked");
|
||||
if (parkedEntries.length === 0) {
|
||||
ctx.ui.notify("No parked milestones.", "info");
|
||||
return;
|
||||
}
|
||||
if (parkedEntries.length === 1) {
|
||||
targetId = parkedEntries[0].id;
|
||||
} else {
|
||||
ctx.ui.notify(`Parked milestones: ${parkedEntries.map(e => e.id).join(", ")}. Specify which to unpark: /gsd unpark <id>`, "info");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const success = unparkMilestone(basePath, targetId);
|
||||
if (success) {
|
||||
ctx.ui.notify(`Unparked ${targetId}. It will resume its normal position in the queue.`, "info");
|
||||
} else {
|
||||
ctx.ui.notify(`Could not unpark ${targetId} — milestone not found or not parked.`, "warning");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "new-milestone") {
|
||||
const basePath = projectRoot();
|
||||
const headlessContextPath = join(basePath, ".gsd", "runtime", "headless-context.md");
|
||||
|
|
@ -747,6 +816,8 @@ function showHelp(ctx: ExtensionCommandContext): void {
|
|||
" /gsd triage Classify and route pending captures",
|
||||
" /gsd skip <unit> Prevent a unit from auto-mode dispatch",
|
||||
" /gsd undo Revert last completed unit [--force]",
|
||||
" /gsd park [id] Park a milestone — skip without deleting [reason]",
|
||||
" /gsd unpark [id] Reactivate a parked milestone",
|
||||
"",
|
||||
"PROJECT KNOWLEDGE",
|
||||
" /gsd knowledge <type> <text> Add rule, pattern, or lesson to KNOWLEDGE.md",
|
||||
|
|
|
|||
|
|
@ -535,7 +535,7 @@ function buildStateMarkdownForCheck(state: Awaited<ReturnType<typeof deriveState
|
|||
lines.push("## Milestone Registry");
|
||||
|
||||
for (const entry of state.registry) {
|
||||
const glyph = entry.status === "complete" ? "\u2705" : entry.status === "active" ? "\uD83D\uDD04" : "\u2B1C";
|
||||
const glyph = entry.status === "complete" ? "\u2705" : entry.status === "active" ? "\uD83D\uDD04" : entry.status === "parked" ? "\u23F8\uFE0F" : "\u2B1C";
|
||||
lines.push(`- ${glyph} **${entry.id}:** ${entry.title}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ function buildStateMarkdown(state: Awaited<ReturnType<typeof deriveState>>): str
|
|||
lines.push("## Milestone Registry");
|
||||
|
||||
for (const entry of state.registry) {
|
||||
const glyph = entry.status === "complete" ? "\u2705" : entry.status === "active" ? "\uD83D\uDD04" : "\u2B1C";
|
||||
const glyph = entry.status === "complete" ? "\u2705" : entry.status === "active" ? "\uD83D\uDD04" : entry.status === "parked" ? "\u23F8\uFE0F" : "\u2B1C";
|
||||
lines.push(`- ${glyph} **${entry.id}:** ${entry.title}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -320,7 +320,7 @@ function buildProgressSection(data: VisualizerData): string {
|
|||
: '<p class="empty indent">No slices in roadmap yet.</p>';
|
||||
|
||||
return `
|
||||
<details class="ms-block" ${ms.status !== 'pending' ? 'open' : ''}>
|
||||
<details class="ms-block" ${ms.status !== 'pending' && ms.status !== 'parked' ? 'open' : ''}>
|
||||
<summary class="ms-summary ms-${ms.status}">
|
||||
<span class="dot dot-${ms.status}"></span>
|
||||
<span class="mono ms-id">${esc(ms.id)}</span>
|
||||
|
|
@ -494,6 +494,7 @@ function buildMilestoneDepSVG(ms: VisualizerMilestone, data: VisualizerData): st
|
|||
<span><span class="dot dot-complete dot-sm"></span> done</span>
|
||||
<span><span class="dot dot-active dot-sm"></span> active</span>
|
||||
<span><span class="dot dot-pending dot-sm"></span> pending</span>
|
||||
<span><span class="dot dot-parked dot-sm"></span> parked</span>
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
|
|
@ -1037,6 +1038,7 @@ code{font-family:var(--mono);font-size:12px;background:var(--bg-3);padding:1px 5
|
|||
.dot-complete{background:var(--ok);opacity:.6}
|
||||
.dot-active{background:var(--accent)}
|
||||
.dot-pending{background:transparent;border:1.5px solid var(--border-2)}
|
||||
.dot-parked{background:var(--warn);opacity:.5}
|
||||
|
||||
/* Header */
|
||||
header{background:var(--bg-1);border-bottom:1px solid var(--border-1);padding:12px 32px;position:sticky;top:0;z-index:200}
|
||||
|
|
|
|||
|
|
@ -75,14 +75,16 @@ export async function showQueue(
|
|||
m => m.status === "pending" || m.status === "active",
|
||||
);
|
||||
const completeCount = state.registry.filter(m => m.status === "complete").length;
|
||||
const parkedCount = state.registry.filter(m => m.status === "parked").length;
|
||||
|
||||
// ── If multiple pending milestones, show queue management hub ──────
|
||||
if (pendingMilestones.length > 1) {
|
||||
const summaryParts = [`${completeCount} complete, ${pendingMilestones.length} pending.`];
|
||||
if (parkedCount > 0) summaryParts.push(`${parkedCount} parked.`);
|
||||
|
||||
const choice = await showNextAction(ctx, {
|
||||
title: "GSD — Queue Management",
|
||||
summary: [
|
||||
`${completeCount} complete, ${pendingMilestones.length} pending.`,
|
||||
],
|
||||
summary: summaryParts,
|
||||
actions: [
|
||||
{
|
||||
id: "reorder",
|
||||
|
|
@ -125,7 +127,7 @@ export async function handleQueueReorder(
|
|||
.map(m => ({ id: m.id, title: m.title, dependsOn: m.dependsOn }));
|
||||
|
||||
const pending = state.registry
|
||||
.filter(m => m.status !== "complete")
|
||||
.filter(m => m.status !== "complete" && m.status !== "parked")
|
||||
.map(m => ({ id: m.id, title: m.title, dependsOn: m.dependsOn }));
|
||||
|
||||
const result = await showReorderUI(ctx, completed, pending);
|
||||
|
|
@ -287,9 +289,9 @@ export async function buildExistingMilestonesContext(
|
|||
}
|
||||
}
|
||||
|
||||
// For active/pending milestones, include the roadmap if it exists
|
||||
// For active/pending/parked milestones, include the roadmap if it exists
|
||||
// (shows what's planned but not yet built)
|
||||
if (status === "active" || status === "pending") {
|
||||
if (status === "active" || status === "pending" || status === "parked") {
|
||||
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
||||
if (roadmapFile) {
|
||||
const content = await loadFile(roadmapFile);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { validateDirectory } from "./validate-directory.js";
|
|||
import { showConfirm } from "../shared/mod.js";
|
||||
import { debugLog } from "./debug-logger.js";
|
||||
import { findMilestoneIds, nextMilestoneId } from "./milestone-ids.js";
|
||||
import { parkMilestone, discardMilestone } from "./milestone-actions.js";
|
||||
|
||||
// ─── Re-exports (preserve public API for existing importers) ────────────────
|
||||
export {
|
||||
|
|
@ -597,6 +598,110 @@ function selfHealRuntimeRecords(basePath: string, ctx: ExtensionContext): { clea
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Milestone Actions Submenu ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shows a submenu with Park / Discard / Skip / Back options for the active milestone.
|
||||
* Returns true if an action was taken (caller should re-enter showSmartEntry or
|
||||
* dispatch a new workflow). Returns false if the user chose "Back".
|
||||
*/
|
||||
async function handleMilestoneActions(
|
||||
ctx: ExtensionCommandContext,
|
||||
pi: ExtensionAPI,
|
||||
basePath: string,
|
||||
milestoneId: string,
|
||||
milestoneTitle: string,
|
||||
options?: { step?: boolean },
|
||||
): Promise<boolean> {
|
||||
const stepMode = options?.step;
|
||||
const choice = await showNextAction(ctx, {
|
||||
title: `Milestone Actions — ${milestoneId}`,
|
||||
summary: [`${milestoneId}: ${milestoneTitle}`],
|
||||
actions: [
|
||||
{
|
||||
id: "park",
|
||||
label: "Park milestone",
|
||||
description: "Pause this milestone — it stays on disk but is skipped.",
|
||||
},
|
||||
{
|
||||
id: "discard",
|
||||
label: "Discard milestone",
|
||||
description: "Permanently delete this milestone and all its contents.",
|
||||
},
|
||||
{
|
||||
id: "skip",
|
||||
label: "Skip — create new milestone",
|
||||
description: "Leave this milestone and start a fresh one.",
|
||||
},
|
||||
{
|
||||
id: "back",
|
||||
label: "Back",
|
||||
description: "Return to the previous menu.",
|
||||
},
|
||||
],
|
||||
notYetMessage: "Run /gsd when ready.",
|
||||
});
|
||||
|
||||
if (choice === "park") {
|
||||
const reason = await showNextAction(ctx, {
|
||||
title: `Park ${milestoneId}`,
|
||||
summary: ["Why is this milestone being parked?"],
|
||||
actions: [
|
||||
{ id: "priority_shift", label: "Priority shift", description: "Other work is more important right now." },
|
||||
{ id: "blocked_external", label: "Blocked externally", description: "Waiting on an external dependency or decision." },
|
||||
{ id: "needs_rethink", label: "Needs rethinking", description: "The approach needs to be reconsidered." },
|
||||
],
|
||||
notYetMessage: "Run /gsd when ready.",
|
||||
});
|
||||
|
||||
// User pressed "Not yet" / Escape — cancel the park operation
|
||||
if (!reason || reason === "not_yet") return false;
|
||||
|
||||
const reasonText = reason === "priority_shift" ? "Priority shift — other work is more important"
|
||||
: reason === "blocked_external" ? "Blocked externally — waiting on external dependency"
|
||||
: reason === "needs_rethink" ? "Needs rethinking — approach needs reconsideration"
|
||||
: "Parked by user";
|
||||
|
||||
const success = parkMilestone(basePath, milestoneId, reasonText);
|
||||
if (success) {
|
||||
ctx.ui.notify(`Parked ${milestoneId}. Run /gsd unpark ${milestoneId} to reactivate.`, "info");
|
||||
} else {
|
||||
ctx.ui.notify(`Could not park ${milestoneId} — milestone not found or already parked.`, "warning");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (choice === "discard") {
|
||||
const confirmed = await showConfirm(ctx, {
|
||||
title: "Discard milestone?",
|
||||
message: `This will permanently delete ${milestoneId} and all its contents (roadmap, plans, task summaries).`,
|
||||
confirmLabel: "Discard",
|
||||
declineLabel: "Cancel",
|
||||
});
|
||||
if (confirmed) {
|
||||
discardMilestone(basePath, milestoneId);
|
||||
ctx.ui.notify(`Discarded ${milestoneId}.`, "info");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (choice === "skip") {
|
||||
const milestoneIds = findMilestoneIds(basePath);
|
||||
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
|
||||
const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
|
||||
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
|
||||
dispatchWorkflow(pi, buildDiscussPrompt(nextId,
|
||||
`New milestone ${nextId}.`,
|
||||
basePath
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
// "back" or null
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function showSmartEntry(
|
||||
ctx: ExtensionCommandContext,
|
||||
pi: ExtensionAPI,
|
||||
|
|
@ -923,8 +1028,6 @@ export async function showSmartEntry(
|
|||
basePath
|
||||
));
|
||||
} else if (choice === "discard_milestone") {
|
||||
const mDir = resolveMilestonePath(basePath, milestoneId);
|
||||
if (!mDir) return;
|
||||
const confirmed = await showConfirm(ctx, {
|
||||
title: "Discard milestone?",
|
||||
message: `This will permanently delete ${milestoneId} and all its contents.`,
|
||||
|
|
@ -932,7 +1035,7 @@ export async function showSmartEntry(
|
|||
declineLabel: "Cancel",
|
||||
});
|
||||
if (confirmed) {
|
||||
rmSync(mDir, { recursive: true, force: true });
|
||||
discardMilestone(basePath, milestoneId);
|
||||
return showSmartEntry(ctx, pi, basePath, options);
|
||||
}
|
||||
}
|
||||
|
|
@ -950,6 +1053,11 @@ export async function showSmartEntry(
|
|||
label: "View status",
|
||||
description: "See milestone progress and blockers.",
|
||||
},
|
||||
{
|
||||
id: "milestone_actions",
|
||||
label: "Milestone actions",
|
||||
description: "Park, discard, or skip this milestone.",
|
||||
},
|
||||
];
|
||||
|
||||
const choice = await showNextAction(ctx, {
|
||||
|
|
@ -964,6 +1072,9 @@ export async function showSmartEntry(
|
|||
} else if (choice === "status") {
|
||||
const { fireStatusViaCommand } = await import("./commands.js");
|
||||
await fireStatusViaCommand(ctx);
|
||||
} else if (choice === "milestone_actions") {
|
||||
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
||||
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
|
@ -1001,6 +1112,11 @@ export async function showSmartEntry(
|
|||
label: "View status",
|
||||
description: "See milestone progress.",
|
||||
},
|
||||
{
|
||||
id: "milestone_actions",
|
||||
label: "Milestone actions",
|
||||
description: "Park, discard, or skip this milestone.",
|
||||
},
|
||||
];
|
||||
|
||||
const summaryParts = [];
|
||||
|
|
@ -1035,6 +1151,9 @@ export async function showSmartEntry(
|
|||
} else if (choice === "status") {
|
||||
const { fireStatusViaCommand } = await import("./commands.js");
|
||||
await fireStatusViaCommand(ctx);
|
||||
} else if (choice === "milestone_actions") {
|
||||
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
||||
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -1056,6 +1175,11 @@ export async function showSmartEntry(
|
|||
label: "View status",
|
||||
description: "Review tasks before completing.",
|
||||
},
|
||||
{
|
||||
id: "milestone_actions",
|
||||
label: "Milestone actions",
|
||||
description: "Park, discard, or skip this milestone.",
|
||||
},
|
||||
],
|
||||
notYetMessage: "Run /gsd when ready.",
|
||||
});
|
||||
|
|
@ -1071,6 +1195,9 @@ export async function showSmartEntry(
|
|||
} else if (choice === "status") {
|
||||
const { fireStatusViaCommand } = await import("./commands.js");
|
||||
await fireStatusViaCommand(ctx);
|
||||
} else if (choice === "milestone_actions") {
|
||||
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
||||
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -1111,6 +1238,11 @@ export async function showSmartEntry(
|
|||
label: "View status",
|
||||
description: "See slice progress before starting.",
|
||||
},
|
||||
{
|
||||
id: "milestone_actions",
|
||||
label: "Milestone actions",
|
||||
description: "Park, discard, or skip this milestone.",
|
||||
},
|
||||
],
|
||||
notYetMessage: "Run /gsd when ready.",
|
||||
});
|
||||
|
|
@ -1134,6 +1266,9 @@ export async function showSmartEntry(
|
|||
} else if (choice === "status") {
|
||||
const { fireStatusViaCommand } = await import("./commands.js");
|
||||
await fireStatusViaCommand(ctx);
|
||||
} else if (choice === "milestone_actions") {
|
||||
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
||||
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
126
src/resources/extensions/gsd/milestone-actions.ts
Normal file
126
src/resources/extensions/gsd/milestone-actions.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* GSD Milestone Actions — Park, Unpark, and Discard operations.
|
||||
*
|
||||
* Park: Creates a PARKED.md marker file. deriveState() skips parked milestones
|
||||
* when finding the active milestone, but keeps them in the registry.
|
||||
*
|
||||
* Unpark: Removes the PARKED.md marker. The milestone resumes normal state
|
||||
* derivation (active/pending depending on position and dependencies).
|
||||
*
|
||||
* Discard: Permanently removes the milestone directory. Also prunes
|
||||
* QUEUE-ORDER.json if the discarded milestone was in it.
|
||||
*/
|
||||
|
||||
import { existsSync, rmSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
resolveMilestonePath,
|
||||
resolveMilestoneFile,
|
||||
buildMilestoneFileName,
|
||||
} from "./paths.js";
|
||||
import { invalidateAllCaches } from "./cache.js";
|
||||
import { loadQueueOrder, saveQueueOrder } from "./queue-order.js";
|
||||
|
||||
// ─── Park ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Park a milestone — creates a PARKED.md marker file with reason and timestamp.
|
||||
* Parked milestones are skipped during active-milestone discovery but stay on disk.
|
||||
* Returns true if successfully parked, false if milestone not found or already parked.
|
||||
*/
|
||||
export function parkMilestone(basePath: string, milestoneId: string, reason: string): boolean {
|
||||
const mDir = resolveMilestonePath(basePath, milestoneId);
|
||||
if (!mDir || !existsSync(mDir)) return false;
|
||||
|
||||
// Guard: do not park a completed milestone — it would corrupt depends_on satisfaction
|
||||
const summaryFile = resolveMilestoneFile(basePath, milestoneId, "SUMMARY");
|
||||
if (summaryFile) return false;
|
||||
|
||||
const parkedPath = join(mDir, buildMilestoneFileName(milestoneId, "PARKED"));
|
||||
if (existsSync(parkedPath)) return false; // already parked
|
||||
|
||||
const content = [
|
||||
"---",
|
||||
`parked_at: ${new Date().toISOString()}`,
|
||||
`reason: "${reason.replace(/"/g, '\\"')}"`,
|
||||
"---",
|
||||
"",
|
||||
`# ${milestoneId} — Parked`,
|
||||
"",
|
||||
`> ${reason}`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
writeFileSync(parkedPath, content, "utf-8");
|
||||
invalidateAllCaches();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Unpark ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unpark a milestone — removes the PARKED.md marker file.
|
||||
* Returns true if successfully unparked, false if milestone not found or not parked.
|
||||
*/
|
||||
export function unparkMilestone(basePath: string, milestoneId: string): boolean {
|
||||
const mDir = resolveMilestonePath(basePath, milestoneId);
|
||||
if (!mDir || !existsSync(mDir)) return false;
|
||||
|
||||
const parkedPath = join(mDir, buildMilestoneFileName(milestoneId, "PARKED"));
|
||||
if (!existsSync(parkedPath)) return false; // not parked
|
||||
|
||||
unlinkSync(parkedPath);
|
||||
invalidateAllCaches();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Discard ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Discard a milestone — permanently removes the milestone directory and
|
||||
* prunes it from QUEUE-ORDER.json if present.
|
||||
* Returns true if successfully discarded, false if milestone not found.
|
||||
*/
|
||||
export function discardMilestone(basePath: string, milestoneId: string): boolean {
|
||||
const mDir = resolveMilestonePath(basePath, milestoneId);
|
||||
if (!mDir || !existsSync(mDir)) return false;
|
||||
|
||||
rmSync(mDir, { recursive: true, force: true });
|
||||
|
||||
// Prune from queue order if present
|
||||
const order = loadQueueOrder(basePath);
|
||||
if (order && order.includes(milestoneId)) {
|
||||
saveQueueOrder(basePath, order.filter(id => id !== milestoneId));
|
||||
}
|
||||
|
||||
invalidateAllCaches();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Query ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check whether a milestone is parked (PARKED.md exists).
|
||||
*/
|
||||
export function isParked(basePath: string, milestoneId: string): boolean {
|
||||
return !!resolveMilestoneFile(basePath, milestoneId, "PARKED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the park reason from PARKED.md frontmatter.
|
||||
* Returns null if the milestone is not parked or the reason can't be extracted.
|
||||
*/
|
||||
export function getParkedReason(basePath: string, milestoneId: string): string | null {
|
||||
const parkedFile = resolveMilestoneFile(basePath, milestoneId, "PARKED");
|
||||
if (!parkedFile) return null;
|
||||
|
||||
try {
|
||||
const content = readFileSync(parkedFile, "utf-8");
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return null;
|
||||
const reasonMatch = match[1].match(/reason:\s*"([^"]*?)"/);
|
||||
return reasonMatch ? reasonMatch[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -118,13 +118,13 @@ export async function analyzeParallelEligibility(
|
|||
const title = entry?.title ?? mid;
|
||||
const status = entry?.status ?? "pending";
|
||||
|
||||
// Rule 1: skip complete milestones
|
||||
if (status === "complete") {
|
||||
// Rule 1: skip complete and parked milestones
|
||||
if (status === "complete" || status === "parked") {
|
||||
ineligible.push({
|
||||
milestoneId: mid,
|
||||
title,
|
||||
eligible: false,
|
||||
reason: "Already complete.",
|
||||
reason: status === "parked" ? "Milestone is parked." : "Already complete.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,9 +103,17 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
|
|||
// Parallel worker isolation
|
||||
const milestoneLock = process.env.GSD_MILESTONE_LOCK;
|
||||
if (milestoneLock) {
|
||||
return milestoneIds.includes(milestoneLock) ? milestoneLock : null;
|
||||
if (!milestoneIds.includes(milestoneLock)) return null;
|
||||
// Locked milestone that is parked should not be active
|
||||
const lockedParked = resolveMilestoneFile(basePath, milestoneLock, "PARKED");
|
||||
if (lockedParked) return null;
|
||||
return milestoneLock;
|
||||
}
|
||||
for (const mid of milestoneIds) {
|
||||
// Skip parked milestones — they are not eligible for active status
|
||||
const parkedFile = resolveMilestoneFile(basePath, mid, "PARKED");
|
||||
if (parkedFile) continue;
|
||||
|
||||
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
||||
const content = roadmapFile ? await loadFile(roadmapFile) : null;
|
||||
if (!content) {
|
||||
|
|
@ -224,7 +232,22 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
const roadmapCache = new Map<string, Roadmap>();
|
||||
const completeMilestoneIds = new Set<string>();
|
||||
|
||||
// Track parked milestone IDs so Phase 2 can check without re-reading disk
|
||||
const parkedMilestoneIds = new Set<string>();
|
||||
|
||||
for (const mid of milestoneIds) {
|
||||
// Skip parked milestones — they do NOT count as complete (don't satisfy depends_on)
|
||||
// But still parse their roadmap for title extraction in Phase 2.
|
||||
const parkedFile = resolveMilestoneFile(basePath, mid, "PARKED");
|
||||
if (parkedFile) {
|
||||
parkedMilestoneIds.add(mid);
|
||||
// Cache roadmap for title extraction (but don't add to completeMilestoneIds)
|
||||
const prf = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
||||
const prc = prf ? await cachedLoadFile(prf) : null;
|
||||
if (prc) roadmapCache.set(mid, parseRoadmap(prc));
|
||||
continue;
|
||||
}
|
||||
|
||||
const rf = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
||||
const rc = rf ? await cachedLoadFile(rf) : null;
|
||||
if (!rc) {
|
||||
|
|
@ -247,6 +270,16 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
let activeMilestoneHasDraft = false;
|
||||
|
||||
for (const mid of milestoneIds) {
|
||||
// Skip parked milestones — register them as 'parked' and move on
|
||||
if (parkedMilestoneIds.has(mid)) {
|
||||
const roadmap = roadmapCache.get(mid) ?? null;
|
||||
const title = roadmap
|
||||
? roadmap.title.replace(/^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/, '')
|
||||
: mid;
|
||||
registry.push({ id: mid, title, status: 'parked' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const roadmap = roadmapCache.get(mid) ?? null;
|
||||
|
||||
if (!roadmap) {
|
||||
|
|
@ -352,8 +385,9 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
};
|
||||
|
||||
if (!activeMilestone) {
|
||||
// Check whether any milestones are pending (dep-blocked) vs all complete
|
||||
// Check whether any milestones are pending (dep-blocked) or parked
|
||||
const pendingEntries = registry.filter(entry => entry.status === 'pending');
|
||||
const parkedEntries = registry.filter(entry => entry.status === 'parked');
|
||||
if (pendingEntries.length > 0) {
|
||||
// All incomplete milestones are dep-blocked — no progress possible
|
||||
const blockerDetails = pendingEntries
|
||||
|
|
@ -376,6 +410,24 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
},
|
||||
};
|
||||
}
|
||||
if (parkedEntries.length > 0) {
|
||||
// All non-complete milestones are parked — nothing active, but not "all complete"
|
||||
const parkedIds = parkedEntries.map(e => e.id).join(', ');
|
||||
return {
|
||||
activeMilestone: null,
|
||||
activeSlice: null,
|
||||
activeTask: null,
|
||||
phase: 'pre-planning',
|
||||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: `All remaining milestones are parked (${parkedIds}). Run /gsd unpark <id> or create a new milestone.`,
|
||||
registry,
|
||||
requirements,
|
||||
progress: {
|
||||
milestones: milestoneProgress,
|
||||
},
|
||||
};
|
||||
}
|
||||
// All milestones complete
|
||||
const lastEntry = registry[registry.length - 1];
|
||||
return {
|
||||
|
|
|
|||
276
src/resources/extensions/gsd/tests/park-edge-cases.test.ts
Normal file
276
src/resources/extensions/gsd/tests/park-edge-cases.test.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* Edge Case Tests for Park/Discard Milestone Feature
|
||||
*
|
||||
* Tests critical edge cases:
|
||||
* 1. Discard breaks depends_on chain → permanent block
|
||||
* 2. Park blocks depends_on chain
|
||||
* 3. Discard active, next (no deps) activates
|
||||
* 4. Park all + discard all → clean state
|
||||
* 5. Discard non-existent → graceful failure
|
||||
* 6. Queue order survives discards
|
||||
* 7. Circular deps + park interaction
|
||||
* 8. Discard milestone that has depends_on on others
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { deriveState, invalidateStateCache } from '../state.ts';
|
||||
import { clearPathCache } from '../paths.ts';
|
||||
import { parkMilestone, unparkMilestone, discardMilestone } from '../milestone-actions.ts';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) { passed++; } else { failed++; console.error(` FAIL: ${message}`); }
|
||||
}
|
||||
function assertEq<T>(actual: T, expected: T, message: string): void {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) { passed++; }
|
||||
else { failed++; console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); }
|
||||
}
|
||||
|
||||
function createFixture(): string {
|
||||
const b = mkdtempSync(join(tmpdir(), 'gsd-edge-'));
|
||||
mkdirSync(join(b, '.gsd', 'milestones'), { recursive: true });
|
||||
return b;
|
||||
}
|
||||
|
||||
function createM(b: string, mid: string, opts?: { roadmap?: boolean; summary?: boolean; dependsOn?: string[] }): void {
|
||||
const d = join(b, '.gsd', 'milestones', mid);
|
||||
mkdirSync(d, { recursive: true });
|
||||
if (opts?.dependsOn) {
|
||||
writeFileSync(join(d, `${mid}-CONTEXT.md`), `---\ndepends_on: [${opts.dependsOn.join(', ')}]\n---\n# ${mid}`, 'utf-8');
|
||||
}
|
||||
if (opts?.roadmap) {
|
||||
writeFileSync(join(d, `${mid}-ROADMAP.md`), [
|
||||
`# ${mid}: Test`,
|
||||
'', '## Vision', 'Test',
|
||||
'', '## Success Criteria', '- [ ] ok',
|
||||
'', '## Slices',
|
||||
`- [${opts?.summary ? 'x' : ' '}] **S01: Setup** \`risk:low\` \`depends:[]\``,
|
||||
' - After this: done',
|
||||
].join('\n'), 'utf-8');
|
||||
}
|
||||
if (opts?.summary) {
|
||||
writeFileSync(join(d, `${mid}-SUMMARY.md`), `---\nid: ${mid}\n---\n# Done`, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void { clearPathCache(); invalidateStateCache(); }
|
||||
function cleanup(b: string): void { rmSync(b, { recursive: true, force: true }); }
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── EDGE 1: Discard breaks depends_on → downstream is BLOCKED ────────
|
||||
console.log('\n=== EDGE 1: Discard breaks depends_on chain ===');
|
||||
{
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true, summary: true }); // complete
|
||||
createM(b, 'M002', { roadmap: true }); // active
|
||||
createM(b, 'M003', { roadmap: true, dependsOn: ['M002'] }); // depends on M002
|
||||
clear();
|
||||
|
||||
discardMilestone(b, 'M002');
|
||||
const s = await deriveState(b);
|
||||
|
||||
// M003 depends on M002 which no longer exists.
|
||||
// M002 is not in completeMilestoneIds → dep is unmet → M003 stays pending
|
||||
assertEq(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 stays pending after dep discarded');
|
||||
assertEq(s.phase, 'blocked', 'system is blocked (unmet dep on deleted milestone)');
|
||||
assert(s.blockers.length > 0, 'blockers list is not empty');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EDGE 2: Park blocks depends_on chain ────────────────────────────
|
||||
console.log('\n=== EDGE 2: Park blocks depends_on chain ===');
|
||||
{
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true, summary: true });
|
||||
createM(b, 'M002', { roadmap: true });
|
||||
createM(b, 'M003', { roadmap: true, dependsOn: ['M002'] });
|
||||
clear();
|
||||
|
||||
parkMilestone(b, 'M002', 'testing');
|
||||
const s = await deriveState(b);
|
||||
assertEq(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 pending when M002 parked');
|
||||
// System should be blocked since M003 deps unmet and M002 is parked
|
||||
assert(s.activeMilestone === null, 'no active milestone (M002 parked, M003 dep-blocked)');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EDGE 3: Discard active, next (no deps) activates ────────────────
|
||||
console.log('\n=== EDGE 3: Discard active → next activates ===');
|
||||
{
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true });
|
||||
createM(b, 'M002', { roadmap: true }); // no depends_on
|
||||
clear();
|
||||
|
||||
discardMilestone(b, 'M001');
|
||||
const s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M002', 'M002 becomes active');
|
||||
assert(s.phase !== 'blocked', 'not blocked');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EDGE 4: Park all + discard all → clean pre-planning ─────────────
|
||||
console.log('\n=== EDGE 4: Park all → discard all → clean state ===');
|
||||
{
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true });
|
||||
createM(b, 'M002', { roadmap: true });
|
||||
clear();
|
||||
|
||||
parkMilestone(b, 'M001', 'test');
|
||||
parkMilestone(b, 'M002', 'test');
|
||||
discardMilestone(b, 'M001');
|
||||
discardMilestone(b, 'M002');
|
||||
const s = await deriveState(b);
|
||||
assertEq(s.activeMilestone, null, 'no active milestone');
|
||||
assertEq(s.phase, 'pre-planning', 'phase is pre-planning');
|
||||
assertEq(s.registry.length, 0, 'empty registry');
|
||||
assert(s.nextAction.includes('No milestones'), 'nextAction mentions no milestones');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EDGE 5: Discard non-existent → graceful false ───────────────────
|
||||
console.log('\n=== EDGE 5: Discard non-existent ===');
|
||||
{
|
||||
const b = createFixture();
|
||||
try {
|
||||
const result = discardMilestone(b, 'M999');
|
||||
assert(!result, 'returns false for non-existent');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EDGE 6: Queue order survives discards ───────────────────────────
|
||||
console.log('\n=== EDGE 6: Queue order after discard ===');
|
||||
{
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true });
|
||||
createM(b, 'M002', { roadmap: true });
|
||||
createM(b, 'M003', { roadmap: true });
|
||||
writeFileSync(
|
||||
join(b, '.gsd', 'QUEUE-ORDER.json'),
|
||||
JSON.stringify({ order: ['M003', 'M001', 'M002'], updatedAt: new Date().toISOString() }),
|
||||
'utf-8',
|
||||
);
|
||||
clear();
|
||||
|
||||
// With custom queue order, M003 should be active first
|
||||
let s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M003', 'M003 active (custom queue order)');
|
||||
|
||||
// Discard M003 → M001 should be next per queue order
|
||||
discardMilestone(b, 'M003');
|
||||
s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M001', 'M001 active after M003 discarded');
|
||||
|
||||
// Verify queue order file was updated
|
||||
const order = JSON.parse(readFileSync(join(b, '.gsd', 'QUEUE-ORDER.json'), 'utf-8'));
|
||||
assert(!order.order.includes('M003'), 'M003 removed from QUEUE-ORDER.json');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EDGE 7: Discard milestone that has deps on others ───────────────
|
||||
console.log('\n=== EDGE 7: Discard a milestone that depends on others ===');
|
||||
{
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true });
|
||||
createM(b, 'M002', { roadmap: true, dependsOn: ['M001'] });
|
||||
createM(b, 'M003', { roadmap: true }); // no deps
|
||||
clear();
|
||||
|
||||
// M002 depends on M001, so M001 is active, M002 is pending
|
||||
let s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M001', 'M001 is active');
|
||||
assertEq(s.registry.find(e => e.id === 'M002')?.status, 'pending', 'M002 pending (dep on M001)');
|
||||
|
||||
// Discard M002 (the one WITH deps) — should be fine, M003 becomes pending
|
||||
discardMilestone(b, 'M002');
|
||||
s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M001', 'M001 still active');
|
||||
assert(!s.registry.some(e => e.id === 'M002'), 'M002 gone from registry');
|
||||
assertEq(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 is pending (after M001)');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EDGE 8: Park → Discard → state transitions ─────────────────────
|
||||
console.log('\n=== EDGE 8: Park then discard same milestone ===');
|
||||
{
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true });
|
||||
createM(b, 'M002', { roadmap: true });
|
||||
clear();
|
||||
|
||||
parkMilestone(b, 'M001', 'temp');
|
||||
let s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M002', 'M002 active while M001 parked');
|
||||
|
||||
// Now discard the parked milestone
|
||||
discardMilestone(b, 'M001');
|
||||
s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M002', 'M002 still active');
|
||||
assert(!s.registry.some(e => e.id === 'M001'), 'M001 gone completely');
|
||||
assertEq(s.registry.length, 1, 'only M002 in registry');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EDGE 9: Complete + parked + pending coexist ─────────────────────
|
||||
console.log('\n=== EDGE 9: Mixed states — complete + parked + active ===');
|
||||
{
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true, summary: true }); // complete
|
||||
createM(b, 'M002', { roadmap: true }); // will park
|
||||
createM(b, 'M003', { roadmap: true }); // will be active
|
||||
createM(b, 'M004', { roadmap: true }); // will be pending
|
||||
clear();
|
||||
|
||||
parkMilestone(b, 'M002', 'parked');
|
||||
const s = await deriveState(b);
|
||||
assertEq(s.registry.find(e => e.id === 'M001')?.status, 'complete', 'M001 complete');
|
||||
assertEq(s.registry.find(e => e.id === 'M002')?.status, 'parked', 'M002 parked');
|
||||
assertEq(s.registry.find(e => e.id === 'M003')?.status, 'active', 'M003 active');
|
||||
assertEq(s.registry.find(e => e.id === 'M004')?.status, 'pending', 'M004 pending');
|
||||
assertEq(s.activeMilestone?.id, 'M003', 'M003 is the active milestone');
|
||||
assertEq(s.progress?.milestones.done, 1, '1 done');
|
||||
assertEq(s.progress?.milestones.total, 4, '4 total');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
console.log(`\n${'='.repeat(50)}`);
|
||||
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
else console.log('All edge cases passed!');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
401
src/resources/extensions/gsd/tests/park-milestone.test.ts
Normal file
401
src/resources/extensions/gsd/tests/park-milestone.test.ts
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { deriveState, invalidateStateCache, getActiveMilestoneId } from '../state.ts';
|
||||
import { clearPathCache } from '../paths.ts';
|
||||
import { parkMilestone, unparkMilestone, discardMilestone, isParked, getParkedReason } from '../milestone-actions.ts';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEq<T>(actual: T, expected: T, message: string): void {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-park-test-'));
|
||||
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function createMilestone(base: string, mid: string, opts?: { withRoadmap?: boolean; withSummary?: boolean; dependsOn?: string[] }): void {
|
||||
const mDir = join(base, '.gsd', 'milestones', mid);
|
||||
mkdirSync(mDir, { recursive: true });
|
||||
|
||||
if (opts?.dependsOn) {
|
||||
writeFileSync(join(mDir, `${mid}-CONTEXT.md`), [
|
||||
'---',
|
||||
`depends_on: [${opts.dependsOn.join(', ')}]`,
|
||||
'---',
|
||||
'',
|
||||
`# ${mid} Context`,
|
||||
].join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
if (opts?.withRoadmap) {
|
||||
writeFileSync(join(mDir, `${mid}-ROADMAP.md`), [
|
||||
`# ${mid}: Test Milestone`,
|
||||
'',
|
||||
'## Vision',
|
||||
'Test milestone for park/unpark testing.',
|
||||
'',
|
||||
'## Success Criteria',
|
||||
'- [ ] Tests pass',
|
||||
'',
|
||||
'## Slices',
|
||||
`- [${opts?.withSummary ? 'x' : ' '}] **S01: Setup** \`risk:low\` \`depends:[]\``,
|
||||
' - After this: Basic setup complete.',
|
||||
].join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
if (opts?.withSummary) {
|
||||
writeFileSync(join(mDir, `${mid}-SUMMARY.md`), [
|
||||
'---',
|
||||
`id: ${mid}`,
|
||||
'---',
|
||||
'',
|
||||
`# ${mid} — Complete`,
|
||||
].join('\n'), 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function clearCaches(): void {
|
||||
clearPathCache();
|
||||
invalidateStateCache();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── Test 1: parkMilestone creates PARKED.md ──────────────────────────
|
||||
console.log('\n=== parkMilestone creates PARKED.md ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
const success = parkMilestone(base, 'M001', 'Priority shift');
|
||||
assert(success, 'parkMilestone returns true');
|
||||
assert(isParked(base, 'M001'), 'isParked returns true after parking');
|
||||
|
||||
const reason = getParkedReason(base, 'M001');
|
||||
assertEq(reason, 'Priority shift', 'reason matches');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 2: parkMilestone is idempotent — fails if already parked ────
|
||||
console.log('\n=== parkMilestone fails if already parked ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
parkMilestone(base, 'M001', 'First park');
|
||||
const secondPark = parkMilestone(base, 'M001', 'Second park');
|
||||
assert(!secondPark, 'second parkMilestone returns false');
|
||||
assertEq(getParkedReason(base, 'M001'), 'First park', 'reason unchanged from first park');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 3: unparkMilestone removes PARKED.md ────────────────────────
|
||||
console.log('\n=== unparkMilestone removes PARKED.md ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
parkMilestone(base, 'M001', 'Test reason');
|
||||
assert(isParked(base, 'M001'), 'milestone is parked');
|
||||
|
||||
const success = unparkMilestone(base, 'M001');
|
||||
assert(success, 'unparkMilestone returns true');
|
||||
assert(!isParked(base, 'M001'), 'isParked returns false after unpark');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 4: unparkMilestone fails if not parked ──────────────────────
|
||||
console.log('\n=== unparkMilestone fails if not parked ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
const result = unparkMilestone(base, 'M001');
|
||||
assert(!result, 'unparkMilestone returns false when not parked');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 5: deriveState returns 'parked' status ──────────────────────
|
||||
console.log('\n=== deriveState returns parked status ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
parkMilestone(base, 'M001', 'Test reason');
|
||||
|
||||
const state = await deriveState(base);
|
||||
const entry = state.registry.find(e => e.id === 'M001');
|
||||
assert(!!entry, 'M001 in registry');
|
||||
assertEq(entry?.status, 'parked', 'status is parked');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 6: deriveState skips parked milestone for active ─────────────
|
||||
console.log('\n=== deriveState skips parked milestone ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
createMilestone(base, 'M002', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
// Before park: M001 is active
|
||||
const stateBefore = await deriveState(base);
|
||||
assertEq(stateBefore.activeMilestone?.id, 'M001', 'before park: M001 is active');
|
||||
|
||||
parkMilestone(base, 'M001', 'Testing');
|
||||
|
||||
// After park: M002 becomes active
|
||||
const stateAfter = await deriveState(base);
|
||||
assertEq(stateAfter.activeMilestone?.id, 'M002', 'after park: M002 is active');
|
||||
|
||||
// M001 still in registry as parked
|
||||
const m001 = stateAfter.registry.find(e => e.id === 'M001');
|
||||
assertEq(m001?.status, 'parked', 'M001 has parked status');
|
||||
|
||||
// M002 is active
|
||||
const m002 = stateAfter.registry.find(e => e.id === 'M002');
|
||||
assertEq(m002?.status, 'active', 'M002 has active status');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 7: getActiveMilestoneId skips parked ────────────────────────
|
||||
console.log('\n=== getActiveMilestoneId skips parked ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
createMilestone(base, 'M002', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
parkMilestone(base, 'M001', 'Testing');
|
||||
|
||||
const activeId = await getActiveMilestoneId(base);
|
||||
assertEq(activeId, 'M002', 'getActiveMilestoneId returns M002');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 8: Parked milestone does NOT satisfy depends_on ─────────────
|
||||
console.log('\n=== Parked milestone does not satisfy depends_on ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
createMilestone(base, 'M002', { withRoadmap: true, dependsOn: ['M001'] });
|
||||
clearCaches();
|
||||
|
||||
parkMilestone(base, 'M001', 'Testing');
|
||||
|
||||
const state = await deriveState(base);
|
||||
// M001 is parked, M002 depends on M001 → M002 should be pending, not active
|
||||
const m002 = state.registry.find(e => e.id === 'M002');
|
||||
assertEq(m002?.status, 'pending', 'M002 stays pending when M001 is parked');
|
||||
|
||||
// No active milestone (both are blocked/parked)
|
||||
assertEq(state.activeMilestone, null, 'no active milestone');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 9: Park then unpark restores correct status ─────────────────
|
||||
console.log('\n=== Park then unpark restores status ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
createMilestone(base, 'M002', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
// Park M001
|
||||
parkMilestone(base, 'M001', 'Testing');
|
||||
const stateParked = await deriveState(base);
|
||||
assertEq(stateParked.activeMilestone?.id, 'M002', 'while parked: M002 is active');
|
||||
|
||||
// Unpark M001 — M001 should become active again (it's first in queue)
|
||||
unparkMilestone(base, 'M001');
|
||||
const stateUnparked = await deriveState(base);
|
||||
assertEq(stateUnparked.activeMilestone?.id, 'M001', 'after unpark: M001 is active again');
|
||||
assertEq(stateUnparked.registry.find(e => e.id === 'M001')?.status, 'active', 'M001 is active status');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 10: discardMilestone removes directory ──────────────────────
|
||||
console.log('\n=== discardMilestone removes directory ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
const mDir = join(base, '.gsd', 'milestones', 'M001');
|
||||
assert(existsSync(mDir), 'milestone dir exists before discard');
|
||||
|
||||
const success = discardMilestone(base, 'M001');
|
||||
assert(success, 'discardMilestone returns true');
|
||||
assert(!existsSync(mDir), 'milestone dir removed after discard');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assert(!state.registry.some(e => e.id === 'M001'), 'M001 not in registry after discard');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 11: discardMilestone updates queue order ────────────────────
|
||||
console.log('\n=== discardMilestone updates queue order ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
createMilestone(base, 'M002', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
// Write a queue order that includes M001
|
||||
const queuePath = join(base, '.gsd', 'QUEUE-ORDER.json');
|
||||
writeFileSync(queuePath, JSON.stringify({ order: ['M001', 'M002'], updatedAt: new Date().toISOString() }), 'utf-8');
|
||||
|
||||
discardMilestone(base, 'M001');
|
||||
|
||||
// Queue order should no longer include M001
|
||||
const queueContent = JSON.parse(readFileSync(queuePath, 'utf-8'));
|
||||
assert(!queueContent.order.includes('M001'), 'M001 removed from queue order');
|
||||
assert(queueContent.order.includes('M002'), 'M002 still in queue order');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 12: All milestones parked → no active milestone ─────────────
|
||||
console.log('\n=== All milestones parked → no active ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
parkMilestone(base, 'M001', 'Testing');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone, null, 'no active milestone when all parked');
|
||||
assertEq(state.phase, 'pre-planning', 'phase is pre-planning');
|
||||
assert(state.registry.length === 1, 'registry still has 1 entry');
|
||||
assertEq(state.registry[0]?.status, 'parked', 'entry is parked');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 13: Parked milestone without roadmap ────────────────────────
|
||||
console.log('\n=== Park milestone without roadmap ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001'); // No roadmap
|
||||
createMilestone(base, 'M002', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
parkMilestone(base, 'M001', 'Not ready yet');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone?.id, 'M002', 'M002 is active when M001 (no roadmap) is parked');
|
||||
assertEq(state.registry.find(e => e.id === 'M001')?.status, 'parked', 'M001 is parked');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 14: Progress counts with parked milestone ───────────────────
|
||||
console.log('\n=== Progress counts with parked ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true, withSummary: true }); // complete
|
||||
createMilestone(base, 'M002', { withRoadmap: true }); // will park
|
||||
createMilestone(base, 'M003', { withRoadmap: true }); // will be active
|
||||
clearCaches();
|
||||
|
||||
parkMilestone(base, 'M002', 'Parked');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.progress?.milestones.done, 1, '1 complete milestone');
|
||||
assertEq(state.progress?.milestones.total, 3, '3 total milestones (including parked)');
|
||||
assertEq(state.activeMilestone?.id, 'M003', 'M003 is active');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Results
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log(`\n${'='.repeat(40)}`);
|
||||
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('All tests passed ✓');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -191,7 +191,7 @@ export interface ActiveRef {
|
|||
export interface MilestoneRegistryEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'complete' | 'active' | 'pending';
|
||||
status: 'complete' | 'active' | 'pending' | 'parked';
|
||||
/** Milestone IDs that must be complete before this milestone becomes active. Populated from CONTEXT.md YAML frontmatter. */
|
||||
dependsOn?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import type {
|
|||
export interface VisualizerMilestone {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'complete' | 'active' | 'pending';
|
||||
status: 'complete' | 'active' | 'pending' | 'parked';
|
||||
dependsOn: string[];
|
||||
slices: VisualizerSlice[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export function renderProgressView(
|
|||
}
|
||||
|
||||
// Milestone header line
|
||||
const msStatus = ms.status === "complete" ? "done" : ms.status === "active" ? "active" : "pending";
|
||||
const msStatus = ms.status === "complete" ? "done" : ms.status === "active" ? "active" : ms.status === "parked" ? "paused" : "pending";
|
||||
const statusGlyph = th.fg(STATUS_COLOR[msStatus], STATUS_GLYPH[msStatus]);
|
||||
const statusLabel = th.fg(STATUS_COLOR[msStatus], ms.status);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue