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:
deseltrus 2026-03-18 08:03:00 +01:00 committed by GitHub
parent 6f410a0041
commit 51da6c4f74
16 changed files with 1090 additions and 25 deletions

View file

@ -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.";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;
}
}

View file

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

View file

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

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

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

View file

@ -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[];
}

View file

@ -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[];
}

View file

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