diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index a2248847f..a14183753 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -93,6 +93,7 @@ import { getAutoWorktreeOriginalBase, mergeMilestoneToMain, } from "./auto-worktree.js"; +import { pruneQueueOrder } from "./queue-order.js"; import { showNextAction } from "../shared/next-action-ui.js"; import { resolveExpectedArtifactPath, @@ -1251,6 +1252,11 @@ async function dispatchNextUnit( unitLifetimeDispatches.clear(); // Capture integration branch for the new milestone and update git service captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); + // Prune completed milestone from queue order file + const pendingIds = state.registry + .filter(m => m.status !== "complete") + .map(m => m.id); + pruneQueueOrder(basePath, pendingIds); } if (mid) { currentMilestoneId = mid; diff --git a/src/resources/extensions/gsd/dispatch-guard.ts b/src/resources/extensions/gsd/dispatch-guard.ts index 01b729987..46ff9c663 100644 --- a/src/resources/extensions/gsd/dispatch-guard.ts +++ b/src/resources/extensions/gsd/dispatch-guard.ts @@ -5,7 +5,7 @@ import { readFileSync } from "node:fs"; import { readdirSync } from "node:fs"; import { resolveMilestoneFile, milestonesDir } from "./paths.js"; import { parseRoadmapSlices } from "./roadmap-slices.js"; -import { extractMilestoneSeq, milestoneIdSort } from "./guided-flow.js"; +import { findMilestoneIds } from "./guided-flow.js"; const SLICE_DISPATCH_TYPES = new Set([ "research-slice", @@ -43,24 +43,12 @@ export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string const [targetMid, targetSid] = unitId.split("/"); if (!targetMid || !targetSid) return null; - const targetSeq = extractMilestoneSeq(targetMid); - if (targetSeq === 0) return null; - - // Scan actual milestone directories instead of iterating by number - let milestoneIds: string[]; - try { - milestoneIds = readdirSync(milestonesDir(base), { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => { - const match = d.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/); - return match ? match[1] : null; - }) - .filter((id): id is string => id !== null) - .sort(milestoneIdSort) - .filter(id => extractMilestoneSeq(id) <= targetSeq); - } catch { - return null; - } + // Use findMilestoneIds to respect custom queue order. + // Only check milestones that come BEFORE the target in queue order. + const allIds = findMilestoneIds(base); + const targetIdx = allIds.indexOf(targetMid); + if (targetIdx < 0) return null; + const milestoneIds = allIds.slice(0, targetIdx + 1); for (const mid of milestoneIds) { // Read from disk (working tree) — always has the latest state diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 58e91d351..0f93c2550 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -22,11 +22,12 @@ import { } from "./paths.js"; import { randomInt } from "node:crypto"; import { join } from "node:path"; -import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs"; import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js"; import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; import { showConfirm } from "../shared/confirm-ui.js"; +import { loadQueueOrder, sortByQueueOrder, saveQueueOrder } from "./queue-order.js"; // ─── Auto-start after discuss ───────────────────────────────────────────────── @@ -203,13 +204,16 @@ function buildDiscussPrompt(nextId: string, preamble: string, _basePath: string) export function findMilestoneIds(basePath: string): string[] { const dir = milestonesDir(basePath); try { - return readdirSync(dir, { withFileTypes: true }) + const ids = readdirSync(dir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => { const match = d.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/); return match ? match[1] : d.name; - }) - .sort(milestoneIdSort); + }); + + // Apply custom queue order if available, else fall back to numeric sort + const customOrder = loadQueueOrder(basePath); + return sortByQueueOrder(ids, customOrder); } catch { return []; } @@ -305,6 +309,235 @@ export async function showQueue( return; } + // ── Count pending milestones ──────────────────────────────────────── + const pendingMilestones = state.registry.filter( + m => m.status === "pending" || m.status === "active", + ); + const completeCount = state.registry.filter(m => m.status === "complete").length; + + // ── If multiple pending milestones, show queue management hub ────── + if (pendingMilestones.length > 1) { + const choice = await showNextAction(ctx, { + title: "GSD — Queue Management", + summary: [ + `${completeCount} complete, ${pendingMilestones.length} pending.`, + ], + actions: [ + { + id: "reorder", + label: "Reorder queue", + description: `Change execution order of ${pendingMilestones.length} pending milestones.`, + recommended: true, + }, + { + id: "add", + label: "Add new work", + description: "Queue new milestones via discussion.", + }, + ], + notYetMessage: "Run /gsd queue when ready.", + }); + + if (choice === "reorder") { + await handleQueueReorder(ctx, basePath, state); + return; + } + if (choice === "not_yet") return; + // "add" falls through to existing queue-add logic below + } + + // ── Existing queue-add flow ───────────────────────────────────────── + await showQueueAdd(ctx, pi, basePath, state); +} + +async function handleQueueReorder( + ctx: ExtensionCommandContext, + basePath: string, + state: Awaited>, +): Promise { + const { showQueueReorder: showReorderUI } = await import("./queue-reorder-ui.js"); + const { invalidateStateCache } = await import("./state.js"); + + const completed = state.registry + .filter(m => m.status === "complete") + .map(m => ({ id: m.id, title: m.title, dependsOn: m.dependsOn })); + + const pending = state.registry + .filter(m => m.status !== "complete") + .map(m => ({ id: m.id, title: m.title, dependsOn: m.dependsOn })); + + const result = await showReorderUI(ctx, completed, pending); + if (!result) { + ctx.ui.notify("Queue reorder cancelled.", "info"); + return; + } + + // Save the new order + saveQueueOrder(basePath, result.order); + invalidateStateCache(); + + // Remove conflicting depends_on entries from CONTEXT.md files + if (result.depsToRemove.length > 0) { + removeDependsOnFromContextFiles(basePath, result.depsToRemove); + } + + // Sync PROJECT.md milestone sequence table + syncProjectMdSequence(basePath, state.registry, result.order); + + // Commit the change + const filesToAdd = [".gsd/QUEUE-ORDER.json", ".gsd/PROJECT.md"]; + for (const r of result.depsToRemove) { + filesToAdd.push(`.gsd/milestones/${r.milestone}/${r.milestone}-CONTEXT.md`); + } + try { + nativeAddPaths(basePath, filesToAdd); + nativeCommit(basePath, "docs: reorder queue"); + } catch { + // Commit may fail if nothing changed or git hooks block — non-fatal + } + + const depInfo = result.depsToRemove.length > 0 + ? ` (removed ${result.depsToRemove.length} depends_on)` + : ""; + ctx.ui.notify(`Queue reordered: ${result.order.join(" → ")}${depInfo}`, "info"); +} + +/** + * Remove specific depends_on entries from milestone CONTEXT.md frontmatter. + */ +function removeDependsOnFromContextFiles( + basePath: string, + depsToRemove: Array<{ milestone: string; dep: string }>, +): void { + // Group removals by milestone + const byMilestone = new Map(); + for (const { milestone, dep } of depsToRemove) { + const existing = byMilestone.get(milestone) ?? []; + existing.push(dep); + byMilestone.set(milestone, existing); + } + + for (const [mid, depsToRemoveForMid] of byMilestone) { + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + if (!contextFile || !existsSync(contextFile)) continue; + + const content = readFileSync(contextFile, "utf-8"); + + // Parse frontmatter + const trimmed = content.trimStart(); + if (!trimmed.startsWith("---")) continue; + const afterFirst = trimmed.indexOf("\n"); + if (afterFirst === -1) continue; + const rest = trimmed.slice(afterFirst + 1); + const endIdx = rest.indexOf("\n---"); + if (endIdx === -1) continue; + + const fmText = rest.slice(0, endIdx); + const body = rest.slice(endIdx + 4); + + // Parse depends_on line(s) + const fmLines = fmText.split("\n"); + const removeSet = new Set(depsToRemoveForMid.map(d => d.toUpperCase())); + + // Handle inline format: depends_on: [M009, M010] + const inlineMatch = fmLines.findIndex(l => /^depends_on:\s*\[/.test(l)); + if (inlineMatch >= 0) { + const line = fmLines[inlineMatch]; + const inner = line.match(/\[([^\]]*)\]/); + if (inner) { + const remaining = inner[1] + .split(",") + .map(s => s.trim()) + .filter(s => s && !removeSet.has(s.toUpperCase())); + if (remaining.length === 0) { + fmLines.splice(inlineMatch, 1); + } else { + fmLines[inlineMatch] = `depends_on: [${remaining.join(", ")}]`; + } + } + } else { + // Handle multi-line format + const keyIdx = fmLines.findIndex(l => /^depends_on:\s*$/.test(l)); + if (keyIdx >= 0) { + let end = keyIdx + 1; + while (end < fmLines.length && /^\s+-\s/.test(fmLines[end])) { + const val = fmLines[end].replace(/^\s+-\s*/, "").trim().toUpperCase(); + if (removeSet.has(val)) { + fmLines.splice(end, 1); + } else { + end++; + } + } + if (end === keyIdx + 1 || (end <= fmLines.length && !/^\s+-\s/.test(fmLines[keyIdx + 1] ?? ""))) { + fmLines.splice(keyIdx, 1); + } + } + } + + // Rebuild file + const newFm = fmLines.filter(l => l !== undefined).join("\n"); + const newContent = newFm.trim() + ? `---\n${newFm}\n---${body}` + : body.replace(/^\n+/, ""); + writeFileSync(contextFile, newContent, "utf-8"); + } +} + +function syncProjectMdSequence( + basePath: string, + registry: Array<{ id: string; title: string; status: string }>, + newOrder: string[], +): void { + const projectPath = resolveGsdRootFile(basePath, "PROJECT"); + if (!projectPath || !existsSync(projectPath)) return; + + const content = readFileSync(projectPath, "utf-8"); + const lines = content.split("\n"); + + const headerIdx = lines.findIndex(l => /^##\s+Milestone Sequence/.test(l)); + if (headerIdx < 0) return; + + let tableStart = headerIdx + 1; + while (tableStart < lines.length && !lines[tableStart].startsWith("|")) tableStart++; + if (tableStart >= lines.length) return; + + let tableEnd = tableStart + 1; + while (tableEnd < lines.length && lines[tableEnd].startsWith("|")) tableEnd++; + + const registryMap = new Map(registry.map(m => [m.id, m])); + const completedSet = new Set(registry.filter(m => m.status === "complete").map(m => m.id)); + + const newRows: string[] = []; + for (const m of registry) { + if (m.status === "complete") { + newRows.push(`| ${m.id} | ${m.title} | ✅ Complete |`); + } + } + let isFirst = true; + for (const id of newOrder) { + if (completedSet.has(id)) continue; + const m = registryMap.get(id); + if (!m) continue; + const status = isFirst ? "📋 Next" : "📋 Queued"; + newRows.push(`| ${m.id} | ${m.title} | ${status} |`); + isFirst = false; + } + + const headerLine = lines[tableStart]; + const separatorLine = lines[tableStart + 1]; + const newTable = [headerLine, separatorLine, ...newRows]; + lines.splice(tableStart, tableEnd - tableStart, ...newTable); + writeFileSync(projectPath, lines.join("\n"), "utf-8"); +} + +async function showQueueAdd( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + basePath: string, + state: Awaited>, +): Promise { + const milestoneIds = findMilestoneIds(basePath); + // ── Build existing milestones context for the prompt ──────────────── const existingContext = await buildExistingMilestonesContext(basePath, milestoneIds, state); diff --git a/src/resources/extensions/gsd/queue-order.ts b/src/resources/extensions/gsd/queue-order.ts new file mode 100644 index 000000000..c408993c3 --- /dev/null +++ b/src/resources/extensions/gsd/queue-order.ts @@ -0,0 +1,231 @@ +/** + * GSD Queue Order — Custom milestone execution ordering. + * + * Stores an explicit execution order in `.gsd/QUEUE-ORDER.json`. + * When present, `findMilestoneIds()` uses this order instead of + * the default numeric sort (milestoneIdSort). + * + * The file is committed to git (not gitignored) so ordering + * survives branch switches and is shared across sessions. + */ + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { gsdRoot } from "./paths.js"; +import { milestoneIdSort } from "./guided-flow.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface QueueOrderFile { + order: string[]; + updatedAt: string; +} + +export interface DependencyViolation { + milestone: string; + dependsOn: string; + type: 'would_block' | 'circular' | 'missing_dep'; + message: string; +} + +export interface DependencyRedundancy { + milestone: string; + dependsOn: string; +} + +export interface DependencyValidation { + valid: boolean; + violations: DependencyViolation[]; + redundant: DependencyRedundancy[]; +} + +// ─── Path ──────────────────────────────────────────────────────────────────── + +function queueOrderPath(basePath: string): string { + return join(gsdRoot(basePath), "QUEUE-ORDER.json"); +} + +// ─── Read / Write ──────────────────────────────────────────────────────────── + +/** + * Load the custom queue order. Returns null if no file exists or if + * the file is corrupt/unreadable. + */ +export function loadQueueOrder(basePath: string): string[] | null { + const p = queueOrderPath(basePath); + if (!existsSync(p)) return null; + try { + const data: QueueOrderFile = JSON.parse(readFileSync(p, "utf-8")); + if (!Array.isArray(data.order)) return null; + return data.order; + } catch { + return null; + } +} + +/** + * Save a custom queue order to disk. + */ +export function saveQueueOrder(basePath: string, order: string[]): void { + const data: QueueOrderFile = { + order, + updatedAt: new Date().toISOString(), + }; + writeFileSync(queueOrderPath(basePath), JSON.stringify(data, null, 2) + "\n", "utf-8"); +} + +// ─── Sorting ───────────────────────────────────────────────────────────────── + +/** + * Sort milestone IDs respecting a custom order. + * + * - IDs present in `customOrder` appear in that exact sequence. + * - IDs on disk but NOT in `customOrder` are appended at the end, + * sorted by the default `milestoneIdSort` (numeric). + * - IDs in `customOrder` but NOT on disk are silently skipped. + * - When `customOrder` is null, falls back to `milestoneIdSort`. + */ +export function sortByQueueOrder(ids: string[], customOrder: string[] | null): string[] { + if (!customOrder) return [...ids].sort(milestoneIdSort); + + const idSet = new Set(ids); + const ordered: string[] = []; + + // First: IDs from customOrder that exist on disk + for (const id of customOrder) { + if (idSet.has(id)) { + ordered.push(id); + idSet.delete(id); + } + } + + // Then: remaining IDs not in customOrder, in default sort order + const remaining = [...idSet].sort(milestoneIdSort); + return [...ordered, ...remaining]; +} + +// ─── Pruning ───────────────────────────────────────────────────────────────── + +/** + * Remove IDs from the queue order file that are no longer valid + * (completed or deleted milestones). No-op if file doesn't exist. + */ +export function pruneQueueOrder(basePath: string, validIds: string[]): void { + const order = loadQueueOrder(basePath); + if (!order) return; + + const validSet = new Set(validIds); + const pruned = order.filter(id => validSet.has(id)); + + if (pruned.length !== order.length) { + saveQueueOrder(basePath, pruned); + } +} + +// ─── Validation ────────────────────────────────────────────────────────────── + +/** + * Validate a proposed queue order against dependency constraints. + * + * Checks: + * - would_block: A milestone is placed before one of its dependencies + * - circular: Two or more milestones form a dependency cycle + * - missing_dep: A milestone depends on an ID that doesn't exist + * - redundant: A dependency is satisfied by queue position (dep comes earlier) + */ +export function validateQueueOrder( + order: string[], + depsMap: Map, + completedIds: Set, +): DependencyValidation { + const violations: DependencyViolation[] = []; + const redundant: DependencyRedundancy[] = []; + + const positionMap = new Map(); + for (let i = 0; i < order.length; i++) { + positionMap.set(order[i], i); + } + + const allKnownIds = new Set([...order, ...completedIds]); + + for (const [mid, deps] of depsMap) { + const midPos = positionMap.get(mid); + if (midPos === undefined) continue; // not in pending order + + for (const dep of deps) { + // Dep already completed — always satisfied + if (completedIds.has(dep)) continue; + + // Dep doesn't exist anywhere + if (!allKnownIds.has(dep)) { + violations.push({ + milestone: mid, + dependsOn: dep, + type: 'missing_dep', + message: `${mid} depends on ${dep}, but ${dep} does not exist.`, + }); + continue; + } + + const depPos = positionMap.get(dep); + if (depPos === undefined) continue; // dep not in pending order (edge case) + + if (depPos > midPos) { + // Dep comes AFTER this milestone in the order — violation + violations.push({ + milestone: mid, + dependsOn: dep, + type: 'would_block', + message: `${mid} cannot run before ${dep} — ${mid} depends_on: [${dep}].`, + }); + } else { + // Dep comes before — satisfied by position, redundant + redundant.push({ milestone: mid, dependsOn: dep }); + } + } + } + + // Check for circular dependencies + const visited = new Set(); + const inStack = new Set(); + + function hasCycle(node: string, path: string[]): string[] | null { + if (inStack.has(node)) return [...path, node]; + if (visited.has(node)) return null; + + visited.add(node); + inStack.add(node); + + const deps = depsMap.get(node) ?? []; + for (const dep of deps) { + if (completedIds.has(dep)) continue; + const cycle = hasCycle(dep, [...path, node]); + if (cycle) return cycle; + } + + inStack.delete(node); + return null; + } + + for (const mid of order) { + if (!visited.has(mid)) { + const cycle = hasCycle(mid, []); + if (cycle) { + const cycleStr = cycle.join(' → '); + violations.push({ + milestone: cycle[0], + dependsOn: cycle[cycle.length - 2], + type: 'circular', + message: `Circular dependency: ${cycleStr}`, + }); + break; // one cycle report is enough + } + } + } + + return { + valid: violations.length === 0, + violations, + redundant, + }; +} diff --git a/src/resources/extensions/gsd/queue-reorder-ui.ts b/src/resources/extensions/gsd/queue-reorder-ui.ts new file mode 100644 index 000000000..1a1d2c293 --- /dev/null +++ b/src/resources/extensions/gsd/queue-reorder-ui.ts @@ -0,0 +1,263 @@ +/** + * GSD Queue Reorder UI + * + * Interactive TUI overlay for reordering pending milestones. + * ↑/↓ navigates cursor. Space grabs/releases item for moving. + * While grabbed, ↑/↓ swaps the item with its neighbor. + * Enter confirms all changes. Esc cancels. + * Conflicting depends_on entries are auto-removed on confirm. + */ + +import type { ExtensionContext } from "@gsd/pi-coding-agent"; +import { type Theme } from "@gsd/pi-coding-agent"; +import { Key, matchesKey, truncateToWidth, type TUI } from "@gsd/pi-tui"; +import { makeUI, GLYPH } from "../shared/ui.js"; +import { validateQueueOrder, type DependencyValidation } from "./queue-order.js"; + +export interface ReorderItem { + id: string; + title: string; + dependsOn?: string[]; +} + +export interface ReorderResult { + order: string[]; + /** depends_on entries to remove from CONTEXT.md files */ + depsToRemove: Array<{ milestone: string; dep: string }>; +} + +/** + * Show the queue reorder overlay. + * Returns the new order + deps to remove, or null if cancelled. + */ +export async function showQueueReorder( + ctx: ExtensionContext, + completed: ReorderItem[], + pending: ReorderItem[], +): Promise { + if (!ctx.hasUI) return null; + if (pending.length < 2) return null; + + return ctx.ui.custom((tui: TUI, theme: Theme, _kb, done) => { + const items = [...pending]; + let cursor = 0; + let grabbed = false; + let cachedLines: string[] | undefined; + let validation: DependencyValidation; + + // Mutable deps map — tracks removals during this session + const liveDeps = new Map(); + for (const item of [...completed, ...pending]) { + if (item.dependsOn && item.dependsOn.length > 0) { + liveDeps.set(item.id, [...item.dependsOn]); + } + } + + const removedDeps: Array<{ milestone: string; dep: string }> = []; + const completedIds = new Set(completed.map(c => c.id)); + + function revalidate() { + validation = validateQueueOrder(items.map(i => i.id), liveDeps, completedIds); + } + + revalidate(); + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function swapItems(fromIdx: number, toIdx: number) { + if (toIdx < 0 || toIdx >= items.length) return; + const [item] = items.splice(fromIdx, 1); + items.splice(toIdx, 0, item); + cursor = toIdx; + revalidate(); + refresh(); + } + + function removeDep(milestone: string, dep: string) { + const deps = liveDeps.get(milestone); + if (!deps) return; + const idx = deps.indexOf(dep); + if (idx >= 0) { + deps.splice(idx, 1); + if (deps.length === 0) liveDeps.delete(milestone); + removedDeps.push({ milestone, dep }); + const item = items.find(i => i.id === milestone); + if (item?.dependsOn) { + item.dependsOn = item.dependsOn.filter(d => d !== dep); + } + revalidate(); + refresh(); + } + } + + function handleInput(data: string) { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + done(null); + return; + } + + // Confirm — auto-resolve would_block violations + if (matchesKey(data, Key.enter)) { + const wouldBlock = validation.violations.filter(v => v.type === 'would_block'); + for (const v of wouldBlock) { + removeDep(v.milestone, v.dependsOn); + } + done({ order: items.map(i => i.id), depsToRemove: removedDeps }); + return; + } + + // Space — toggle grab mode + if (data === " ") { + grabbed = !grabbed; + refresh(); + return; + } + + // ↑/↓ — move grabbed item OR navigate cursor + if (matchesKey(data, Key.up)) { + if (grabbed) { + swapItems(cursor, cursor - 1); + } else { + cursor = Math.max(0, cursor - 1); + refresh(); + } + return; + } + if (matchesKey(data, Key.down)) { + if (grabbed) { + swapItems(cursor, cursor + 1); + } else { + cursor = Math.min(items.length - 1, cursor + 1); + refresh(); + } + return; + } + + // 'd' — manually remove a dep on the cursor item + if (data === "d" || data === "D") { + const item = items[cursor]; + const deps = liveDeps.get(item.id); + if (deps) { + const activeDep = deps.find(d => !completedIds.has(d)); + if (activeDep) removeDep(item.id, activeDep); + } + return; + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + + const ui = makeUI(theme, width); + const lines: string[] = []; + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + const add = (s: string) => truncateToWidth(s, width); + + const headerText = grabbed ? " Queue Reorder — Moving Item" : " Queue Reorder"; + push(ui.bar(), ui.blank(), ui.header(headerText), ui.blank()); + + // Completed milestones (dimmed) + if (completed.length > 0) { + lines.push(add(theme.fg("dim", " Completed:"))); + for (const m of completed) { + const label = m.title && m.title !== m.id ? `${m.id} ${m.title}` : m.id; + lines.push(add(` ${theme.fg("dim", `${GLYPH.statusDone} ${label}`)}`)); + } + push(ui.blank()); + } + + // Pending milestones + const queueLabel = grabbed ? " Queue (space to release, ↑/↓ to move):" : " Queue (space to grab, ↑/↓ to navigate):"; + lines.push(add(theme.fg("text", queueLabel))); + + const violatedPairs = new Set( + validation.violations.filter(v => v.type === 'would_block').map(v => `${v.milestone}:${v.dependsOn}`), + ); + const redundantPairs = new Set( + validation.redundant.map(r => `${r.milestone}:${r.dependsOn}`), + ); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const isCursor = i === cursor; + const num = i + 1; + const label = item.title && item.title !== item.id ? `${item.id} ${item.title}` : item.id; + + if (isCursor && grabbed) { + lines.push(add(` ${theme.fg("warning", `▸▸ ${num}. ${label}`)}`)); + } else if (isCursor) { + lines.push(add(` ${theme.fg("accent", `${GLYPH.cursor} ${num}. ${label}`)}`)); + } else { + lines.push(add(` ${theme.fg("text", `${num}. ${label}`)}`)); + } + + // depends_on annotations + const deps = liveDeps.get(item.id) ?? []; + for (const dep of deps) { + if (completedIds.has(dep)) continue; + const pairKey = `${item.id}:${dep}`; + if (violatedPairs.has(pairKey)) { + lines.push(add(` ${theme.fg("warning", `${GLYPH.statusWarning} depends_on: ${dep} — auto-removed on confirm`)}`)); + } else if (redundantPairs.has(pairKey)) { + lines.push(add(` ${theme.fg("dim", `↳ depends_on: ${dep} (redundant)`)}`)); + } else { + lines.push(add(` ${theme.fg("dim", `↳ depends_on: ${dep}`)}`)); + } + } + + // Missing deps + for (const v of validation.violations.filter(v => v.milestone === item.id && v.type === 'missing_dep')) { + lines.push(add(` ${theme.fg("error", `${GLYPH.statusWarning} depends_on: ${v.dependsOn} (does not exist)`)}`)); + } + } + + // Removed deps feedback + if (removedDeps.length > 0) { + push(ui.blank()); + for (const r of removedDeps) { + lines.push(add(` ${theme.fg("success", `${GLYPH.statusDone} Removed: ${r.milestone} depends_on ${r.dep}`)}`)); + } + } + + // Circular warning + const circ = validation.violations.find(v => v.type === 'circular'); + if (circ) { + push(ui.blank()); + lines.push(add(` ${theme.fg("error", `${GLYPH.statusWarning} ${circ.message}`)}`)); + } + + push(ui.blank()); + + // Hints — context-sensitive based on grab state + const hints: string[] = []; + if (grabbed) { + hints.push("↑/↓ move item", "space release"); + } else { + hints.push("↑/↓ navigate", "space grab"); + } + const hasDeps = liveDeps.get(items[cursor]?.id)?.some(d => !completedIds.has(d)); + if (hasDeps) hints.push("d del dep"); + + const wouldBlockCount = validation.violations.filter(v => v.type === 'would_block').length; + if (wouldBlockCount > 0) { + hints.push(`enter (fixes ${wouldBlockCount} dep)`); + } else { + hints.push("enter ok"); + } + hints.push("esc"); + + push(ui.hints(hints), ui.bar()); + + cachedLines = lines; + return lines; + } + + return { render, invalidate: () => { cachedLines = undefined; }, handleInput }; + }, { + overlay: true, + overlayOptions: { width: "70%", minWidth: 50, maxHeight: "80%", anchor: "center" }, + }); +} diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 7818c75d9..9ec1c9a9d 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -224,9 +224,21 @@ async function _deriveStateImpl(basePath: string): Promise { const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); if (draftFile) activeMilestoneHasDraft = true; } - activeMilestone = { id: mid, title: mid }; - activeMilestoneFound = true; - registry.push({ id: mid, title: mid, status: 'active' }); + + // Check milestone-level dependencies before promoting to active. + // Without this, a queued milestone with depends_on in its CONTEXT + // frontmatter would be promoted to active even when its deps are unmet + // (the dep check only existed in the has-roadmap path previously). + const contextContent = contextFile ? await cachedLoadFile(contextFile) : null; + const deps = parseContextDependsOn(contextContent); + const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep)); + if (depsUnmet) { + registry.push({ id: mid, title: mid, status: 'pending', dependsOn: deps }); + } else { + activeMilestone = { id: mid, title: mid }; + activeMilestoneFound = true; + registry.push({ id: mid, title: mid, status: 'active', ...(deps.length > 0 ? { dependsOn: deps } : {}) }); + } } else { registry.push({ id: mid, title: mid, status: 'pending' }); } diff --git a/src/resources/extensions/gsd/tests/queue-order.test.ts b/src/resources/extensions/gsd/tests/queue-order.test.ts new file mode 100644 index 000000000..46ad7a82a --- /dev/null +++ b/src/resources/extensions/gsd/tests/queue-order.test.ts @@ -0,0 +1,204 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + loadQueueOrder, + saveQueueOrder, + sortByQueueOrder, + pruneQueueOrder, + validateQueueOrder, +} from '../queue-order.ts'; +import { createTestContext } from './test-helpers.ts'; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ─── Fixture Helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-queue-order-')); + mkdirSync(join(base, '.gsd'), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// sortByQueueOrder +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== sortByQueueOrder ==='); + +// Null order → default milestoneIdSort +{ + const result = sortByQueueOrder(['M003', 'M001', 'M002'], null); + assertEq(result, ['M001', 'M002', 'M003'], 'null order falls back to numeric sort'); +} + +// Custom order → exact sequence +{ + const result = sortByQueueOrder(['M001', 'M002', 'M003'], ['M003', 'M001', 'M002']); + assertEq(result, ['M003', 'M001', 'M002'], 'custom order produces exact sequence'); +} + +// Custom order with new IDs → appended at end in numeric order +{ + const result = sortByQueueOrder(['M001', 'M002', 'M003', 'M004'], ['M003', 'M001']); + assertEq(result, ['M003', 'M001', 'M002', 'M004'], 'new IDs appended in numeric order'); +} + +// Custom order with deleted IDs → silently skipped +{ + const result = sortByQueueOrder(['M001', 'M003'], ['M003', 'M002', 'M001']); + assertEq(result, ['M003', 'M001'], 'deleted IDs in order are skipped'); +} + +// Empty custom order → all IDs in numeric order +{ + const result = sortByQueueOrder(['M002', 'M001'], []); + assertEq(result, ['M001', 'M002'], 'empty custom order falls back to numeric sort'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// loadQueueOrder / saveQueueOrder +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== loadQueueOrder / saveQueueOrder ==='); + +// Load returns null when file doesn't exist +{ + const base = createFixtureBase(); + assertEq(loadQueueOrder(base), null, 'returns null when file missing'); + cleanup(base); +} + +// Save then load round-trip +{ + const base = createFixtureBase(); + saveQueueOrder(base, ['M003', 'M001', 'M002']); + const loaded = loadQueueOrder(base); + assertEq(loaded, ['M003', 'M001', 'M002'], 'round-trip preserves order'); + + // Verify file contains updatedAt + const raw = JSON.parse(readFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), 'utf-8')); + assertTrue(typeof raw.updatedAt === 'string' && raw.updatedAt.length > 0, 'file contains updatedAt'); + + cleanup(base); +} + +// Load returns null on corrupt JSON +{ + const base = createFixtureBase(); + writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), 'not json'); + assertEq(loadQueueOrder(base), null, 'returns null on corrupt JSON'); + cleanup(base); +} + +// Load returns null when order field is not an array +{ + const base = createFixtureBase(); + writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), '{"order": "invalid"}'); + assertEq(loadQueueOrder(base), null, 'returns null when order is not array'); + cleanup(base); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// pruneQueueOrder +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== pruneQueueOrder ==='); + +// Prune removes invalid IDs +{ + const base = createFixtureBase(); + saveQueueOrder(base, ['M001', 'M002', 'M003']); + pruneQueueOrder(base, ['M001', 'M003']); + assertEq(loadQueueOrder(base), ['M001', 'M003'], 'prune removes invalid IDs'); + cleanup(base); +} + +// Prune no-ops when file doesn't exist +{ + const base = createFixtureBase(); + pruneQueueOrder(base, ['M001']); // should not throw + assertTrue(!existsSync(join(base, '.gsd', 'QUEUE-ORDER.json')), 'prune does not create file'); + cleanup(base); +} + +// Prune no-ops when all IDs are valid +{ + const base = createFixtureBase(); + saveQueueOrder(base, ['M001', 'M002']); + pruneQueueOrder(base, ['M001', 'M002', 'M003']); + assertEq(loadQueueOrder(base), ['M001', 'M002'], 'prune is no-op when all valid'); + cleanup(base); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// validateQueueOrder +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== validateQueueOrder ==='); + +// Valid order with no dependencies +{ + const depsMap = new Map(); + const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set()); + assertTrue(result.valid, 'valid when no dependencies'); + assertEq(result.violations.length, 0, 'no violations'); + assertEq(result.redundant.length, 0, 'no redundancies'); +} + +// Dependency violation: M002 before M001, but M002 depends on M001 +{ + const depsMap = new Map([['M002', ['M001']]]); + const result = validateQueueOrder(['M002', 'M001'], depsMap, new Set()); + assertTrue(!result.valid, 'invalid when dep violated'); + assertEq(result.violations.length, 1, 'one violation'); + assertEq(result.violations[0].type, 'would_block', 'violation type is would_block'); + assertEq(result.violations[0].milestone, 'M002', 'violation milestone is M002'); + assertEq(result.violations[0].dependsOn, 'M001', 'violation dep is M001'); +} + +// Redundant dependency: M002 depends on M001, M001 comes first in order +{ + const depsMap = new Map([['M002', ['M001']]]); + const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set()); + assertTrue(result.valid, 'valid when dep satisfied by position'); + assertEq(result.redundant.length, 1, 'one redundancy'); + assertEq(result.redundant[0].milestone, 'M002', 'redundant milestone is M002'); +} + +// Completed dep is always satisfied +{ + const depsMap = new Map([['M002', ['M001']]]); + const result = validateQueueOrder(['M002'], depsMap, new Set(['M001'])); + assertTrue(result.valid, 'valid when dep is already completed'); + assertEq(result.violations.length, 0, 'no violations for completed dep'); +} + +// Missing dependency +{ + const depsMap = new Map([['M002', ['M099']]]); + const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set()); + assertTrue(!result.valid, 'invalid when dep does not exist'); + assertEq(result.violations[0].type, 'missing_dep', 'violation type is missing_dep'); +} + +// Circular dependency +{ + const depsMap = new Map([ + ['M001', ['M002']], + ['M002', ['M001']], + ]); + const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set()); + assertTrue(!result.valid, 'invalid on circular dependency'); + const circularViolation = result.violations.find(v => v.type === 'circular'); + assertTrue(!!circularViolation, 'circular violation detected'); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +report(); diff --git a/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts b/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts new file mode 100644 index 000000000..1077e70b1 --- /dev/null +++ b/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts @@ -0,0 +1,281 @@ +/** + * End-to-end integration tests for the Queue Reorder feature. + * + * Verifies the full chain: QUEUE-ORDER.json + findMilestoneIds() + deriveState() + * + depends_on removal from CONTEXT.md files. + * + * These tests simulate what happens when a user reorders milestones and confirms: + * 1. QUEUE-ORDER.json is written with the new order + * 2. depends_on is removed from CONTEXT.md frontmatter + * 3. deriveState() picks the correct milestone as active + * 4. A fresh deriveState() call (simulating new session) also works + */ + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { deriveState, invalidateStateCache } from '../state.ts'; +import { findMilestoneIds } from '../guided-flow.ts'; +import { saveQueueOrder, loadQueueOrder } from '../queue-order.ts'; +import { parseContextDependsOn } from '../files.ts'; +import { createTestContext } from './test-helpers.ts'; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ─── Fixture Helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-reorder-e2e-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +function writeMilestoneDir(base: string, mid: string): void { + mkdirSync(join(base, '.gsd', 'milestones', mid), { recursive: true }); +} + +function writeContext(base: string, mid: string, frontmatter: string, body: string = ''): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + const fm = frontmatter ? `---\n${frontmatter}\n---\n\n` : ''; + writeFileSync(join(dir, `${mid}-CONTEXT.md`), `${fm}# ${mid}: Test\n\n${body}`); +} + +function writeCompleteMilestone(base: string, mid: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-ROADMAP.md`), `# ${mid}: Complete + +**Vision:** Done. + +## Slices + +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeFileSync(join(dir, `${mid}-SUMMARY.md`), `# ${mid} Summary\n\nComplete.`); +} + +function readContextFile(base: string, mid: string): string { + return readFileSync(join(base, '.gsd', 'milestones', mid, `${mid}-CONTEXT.md`), 'utf-8'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test: Queue order changes milestone activation +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== E2E: queue-order changes active milestone ==='); +{ + const base = createFixtureBase(); + try { + // Setup: M007 complete, M008 and M009 pending (no context, no roadmap) + writeCompleteMilestone(base, 'M007'); + writeMilestoneDir(base, 'M008'); + writeContext(base, 'M008', '', 'Multi-Session Parallel Orchestration'); + writeMilestoneDir(base, 'M009'); + writeContext(base, 'M009', '', 'Context-Budget Visibility'); + + // Without custom order: M008 comes first (numeric sort) + invalidateStateCache(); + const stateBefore = await deriveState(base); + assertEq(stateBefore.activeMilestone?.id, 'M008', 'before reorder: M008 is active'); + + // Save custom order: M009 before M008 + saveQueueOrder(base, ['M009', 'M008']); + + // With custom order: M009 should be active + invalidateStateCache(); + const stateAfter = await deriveState(base); + assertEq(stateAfter.activeMilestone?.id, 'M009', 'after reorder: M009 is active'); + + // findMilestoneIds respects the order + const ids = findMilestoneIds(base); + const m008Idx = ids.indexOf('M008'); + const m009Idx = ids.indexOf('M009'); + assertTrue(m009Idx < m008Idx, 'findMilestoneIds: M009 comes before M008'); + + } finally { + cleanup(base); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test: Reorder + depends_on removal = correct state +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== E2E: reorder with depends_on removal ==='); +{ + const base = createFixtureBase(); + try { + // Setup: M007 complete, M008 depends_on M009, M009 no deps + writeCompleteMilestone(base, 'M007'); + writeContext(base, 'M008', 'depends_on: [M009]', 'Multi-Session Parallel'); + writeContext(base, 'M009', '', 'Context-Budget Visibility'); + + // Before: M008 depends on M009, so deriveState skips M008, M009 is active + invalidateStateCache(); + const stateBefore = await deriveState(base); + assertEq(stateBefore.activeMilestone?.id, 'M009', 'before: M009 active (M008 dep-blocked)'); + + // Simulate reorder confirm: save order M009→M008, remove depends_on from M008 + saveQueueOrder(base, ['M009', 'M008']); + + // Remove depends_on from M008-CONTEXT.md (simulating what handleQueueReorder does) + const contextContent = readContextFile(base, 'M008'); + const newContent = contextContent.replace(/---\ndepends_on: \[M009\]\n---\n\n/, ''); + writeFileSync(join(base, '.gsd', 'milestones', 'M008', 'M008-CONTEXT.md'), newContent); + + // Verify: depends_on is gone + const updatedContent = readContextFile(base, 'M008'); + const deps = parseContextDependsOn(updatedContent); + assertEq(deps.length, 0, 'depends_on removed from M008-CONTEXT.md'); + + // Verify: deriveState still picks M009 (it's first in queue order) + invalidateStateCache(); + const stateAfter = await deriveState(base); + assertEq(stateAfter.activeMilestone?.id, 'M009', 'after: M009 still active (first in queue)'); + + // Verify: M008 is now pending (not dep-blocked) + const m008Entry = stateAfter.registry.find(m => m.id === 'M008'); + assertEq(m008Entry?.status, 'pending', 'M008 is pending (not dep-blocked)'); + assertTrue(!m008Entry?.dependsOn || m008Entry.dependsOn.length === 0, 'M008 has no dependsOn'); + + } finally { + cleanup(base); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test: Fresh deriveState (simulating new session) respects queue order +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== E2E: fresh session respects queue order ==='); +{ + const base = createFixtureBase(); + try { + writeCompleteMilestone(base, 'M007'); + writeContext(base, 'M008', '', 'Parallel Orchestration'); + writeContext(base, 'M009', '', 'Budget Visibility'); + + // Save queue order + saveQueueOrder(base, ['M009', 'M008']); + + // Simulate fresh session — invalidate all caches + invalidateStateCache(); + + // Derive state — should read QUEUE-ORDER.json from disk + const state = await deriveState(base); + assertEq(state.activeMilestone?.id, 'M009', 'fresh session: M009 is active'); + + // Verify queue order persisted + const order = loadQueueOrder(base); + assertEq(order, ['M009', 'M008'], 'QUEUE-ORDER.json persisted correctly'); + + } finally { + cleanup(base); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test: Queue order with newly added milestones +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== E2E: new milestones appended to queue ==='); +{ + const base = createFixtureBase(); + try { + writeCompleteMilestone(base, 'M007'); + writeContext(base, 'M008', '', 'Parallel'); + writeContext(base, 'M009', '', 'Visibility'); + + // Custom order only has M009, M008 + saveQueueOrder(base, ['M009', 'M008']); + + // Add M010 (not in queue order) + writeContext(base, 'M010', '', 'New feature'); + + invalidateStateCache(); + const ids = findMilestoneIds(base); + + // M009 first, M008 second, M010 appended at end + const m009Idx = ids.indexOf('M009'); + const m008Idx = ids.indexOf('M008'); + const m010Idx = ids.indexOf('M010'); + assertTrue(m009Idx < m008Idx, 'M009 before M008'); + assertTrue(m008Idx < m010Idx, 'M008 before M010 (new milestone appended)'); + + // M009 is still active (first non-complete in queue order) + const state = await deriveState(base); + assertEq(state.activeMilestone?.id, 'M009', 'M009 still active after M010 added'); + + } finally { + cleanup(base); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test: No queue order file = default numeric sort (backward compat) +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== E2E: backward compat without QUEUE-ORDER.json ==='); +{ + const base = createFixtureBase(); + try { + writeCompleteMilestone(base, 'M007'); + writeContext(base, 'M008', '', 'Parallel'); + writeContext(base, 'M009', '', 'Visibility'); + + // No QUEUE-ORDER.json — default numeric sort + invalidateStateCache(); + const state = await deriveState(base); + assertEq(state.activeMilestone?.id, 'M008', 'no queue order: M008 active (numeric)'); + + const ids = findMilestoneIds(base); + assertTrue(ids.indexOf('M008') < ids.indexOf('M009'), 'default sort: M008 before M009'); + + } finally { + cleanup(base); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test: depends_on inline array format removal +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== E2E: depends_on inline format preserved after partial removal ==='); +{ + const base = createFixtureBase(); + try { + writeCompleteMilestone(base, 'M007'); + // M008 depends on both M009 and M010 + writeContext(base, 'M008', 'depends_on: [M009, M010]', 'Parallel'); + writeContext(base, 'M009', '', 'Visibility'); + writeContext(base, 'M010', '', 'Other'); + + // Verify both deps are parsed + const contentBefore = readContextFile(base, 'M008'); + const depsBefore = parseContextDependsOn(contentBefore); + assertEq(depsBefore.length, 2, 'M008 has 2 deps before'); + + // Simulate removing only M009 dep (keep M010) + const content = readContextFile(base, 'M008'); + const updated = content.replace('depends_on: [M009, M010]', 'depends_on: [M010]'); + writeFileSync(join(base, '.gsd', 'milestones', 'M008', 'M008-CONTEXT.md'), updated); + + // Verify only M010 remains + const contentAfter = readContextFile(base, 'M008'); + const depsAfter = parseContextDependsOn(contentAfter); + assertEq(depsAfter.length, 1, 'M008 has 1 dep after removal'); + assertEq(depsAfter[0], 'M010', 'remaining dep is M010'); + + } finally { + cleanup(base); + } +} + +report();