From 51da6c4f74ff55a3ff762316e276a68cc8eec14c Mon Sep 17 00:00:00 2001 From: deseltrus <101901449+deseltrus@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:03:00 +0100 Subject: [PATCH] feat: park/discard actions for in-progress milestones (#1107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto-start.ts | 2 +- src/resources/extensions/gsd/auto.ts | 8 +- src/resources/extensions/gsd/commands.ts | 71 ++++ src/resources/extensions/gsd/doctor-checks.ts | 2 +- src/resources/extensions/gsd/doctor.ts | 2 +- src/resources/extensions/gsd/export-html.ts | 4 +- .../extensions/gsd/guided-flow-queue.ts | 14 +- src/resources/extensions/gsd/guided-flow.ts | 141 +++++- .../extensions/gsd/milestone-actions.ts | 126 ++++++ .../extensions/gsd/parallel-eligibility.ts | 6 +- src/resources/extensions/gsd/state.ts | 56 ++- .../gsd/tests/park-edge-cases.test.ts | 276 ++++++++++++ .../gsd/tests/park-milestone.test.ts | 401 ++++++++++++++++++ src/resources/extensions/gsd/types.ts | 2 +- .../extensions/gsd/visualizer-data.ts | 2 +- .../extensions/gsd/visualizer-views.ts | 2 +- 16 files changed, 1090 insertions(+), 25 deletions(-) create mode 100644 src/resources/extensions/gsd/milestone-actions.ts create mode 100644 src/resources/extensions/gsd/tests/park-edge-cases.test.ts create mode 100644 src/resources/extensions/gsd/tests/park-milestone.test.ts diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 09416fec3..8eb91177b 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -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."; diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 96efd60b4..4342b3e7e 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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"); diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 25dfd6667..e8424bad1 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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 `, "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 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 Add rule, pattern, or lesson to KNOWLEDGE.md", diff --git a/src/resources/extensions/gsd/doctor-checks.ts b/src/resources/extensions/gsd/doctor-checks.ts index c6bf5aad9..fd58fa5ee 100644 --- a/src/resources/extensions/gsd/doctor-checks.ts +++ b/src/resources/extensions/gsd/doctor-checks.ts @@ -535,7 +535,7 @@ function buildStateMarkdownForCheck(state: Awaited>): 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}`); } diff --git a/src/resources/extensions/gsd/export-html.ts b/src/resources/extensions/gsd/export-html.ts index 5528df66e..18c367aaf 100644 --- a/src/resources/extensions/gsd/export-html.ts +++ b/src/resources/extensions/gsd/export-html.ts @@ -320,7 +320,7 @@ function buildProgressSection(data: VisualizerData): string { : '

No slices in roadmap yet.

'; return ` -
+
${esc(ms.id)} @@ -494,6 +494,7 @@ function buildMilestoneDepSVG(ms: VisualizerMilestone, data: VisualizerData): st done active pending + parked `; 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} diff --git a/src/resources/extensions/gsd/guided-flow-queue.ts b/src/resources/extensions/gsd/guided-flow-queue.ts index a18f36eb4..fa58ba445 100644 --- a/src/resources/extensions/gsd/guided-flow-queue.ts +++ b/src/resources/extensions/gsd/guided-flow-queue.ts @@ -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); diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 4e5466d4a..e2f1b6c26 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -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 { + 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; } diff --git a/src/resources/extensions/gsd/milestone-actions.ts b/src/resources/extensions/gsd/milestone-actions.ts new file mode 100644 index 000000000..79851f178 --- /dev/null +++ b/src/resources/extensions/gsd/milestone-actions.ts @@ -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; + } +} diff --git a/src/resources/extensions/gsd/parallel-eligibility.ts b/src/resources/extensions/gsd/parallel-eligibility.ts index b714611ad..b02a8f0db 100644 --- a/src/resources/extensions/gsd/parallel-eligibility.ts +++ b/src/resources/extensions/gsd/parallel-eligibility.ts @@ -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; } diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 5127621db..3f0b31c98 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -103,9 +103,17 @@ export async function getActiveMilestoneId(basePath: string): Promise { const roadmapCache = new Map(); const completeMilestoneIds = new Set(); + // Track parked milestone IDs so Phase 2 can check without re-reading disk + const parkedMilestoneIds = new Set(); + 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 { 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 { }; 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 { }, }; } + 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 or create a new milestone.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + }, + }; + } // All milestones complete const lastEntry = registry[registry.length - 1]; return { diff --git a/src/resources/extensions/gsd/tests/park-edge-cases.test.ts b/src/resources/extensions/gsd/tests/park-edge-cases.test.ts new file mode 100644 index 000000000..f69bfeaad --- /dev/null +++ b/src/resources/extensions/gsd/tests/park-edge-cases.test.ts @@ -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(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 { + + // ─── 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); }); diff --git a/src/resources/extensions/gsd/tests/park-milestone.test.ts b/src/resources/extensions/gsd/tests/park-milestone.test.ts new file mode 100644 index 000000000..a9b3d73a6 --- /dev/null +++ b/src/resources/extensions/gsd/tests/park-milestone.test.ts @@ -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(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 { + + // ─── 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); +}); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index 2cc4e8622..1e6110087 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -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[]; } diff --git a/src/resources/extensions/gsd/visualizer-data.ts b/src/resources/extensions/gsd/visualizer-data.ts index cf5c8b7ec..f3a0d465f 100644 --- a/src/resources/extensions/gsd/visualizer-data.ts +++ b/src/resources/extensions/gsd/visualizer-data.ts @@ -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[]; } diff --git a/src/resources/extensions/gsd/visualizer-views.ts b/src/resources/extensions/gsd/visualizer-views.ts index 5c58f4a92..67721af30 100644 --- a/src/resources/extensions/gsd/visualizer-views.ts +++ b/src/resources/extensions/gsd/visualizer-views.ts @@ -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);