feat: queue reorder — reorder milestone execution via /gsd queue (#460)

This commit is contained in:
deseltrus 2026-03-16 13:05:45 +01:00 committed by GitHub
parent 1d1b91f428
commit 0820b1196d
8 changed files with 1244 additions and 26 deletions

View file

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

View file

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

View file

@ -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<ReturnType<typeof deriveState>>,
): Promise<void> {
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<string, string[]>();
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<ReturnType<typeof deriveState>>,
): Promise<void> {
const milestoneIds = findMilestoneIds(basePath);
// ── Build existing milestones context for the prompt ────────────────
const existingContext = await buildExistingMilestonesContext(basePath, milestoneIds, state);

View file

@ -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<string, string[]>,
completedIds: Set<string>,
): DependencyValidation {
const violations: DependencyViolation[] = [];
const redundant: DependencyRedundancy[] = [];
const positionMap = new Map<string, number>();
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<string>();
const inStack = new Set<string>();
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,
};
}

View file

@ -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<ReorderResult | null> {
if (!ctx.hasUI) return null;
if (pending.length < 2) return null;
return ctx.ui.custom<ReorderResult | null>((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<string, string[]>();
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" },
});
}

View file

@ -224,9 +224,21 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
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' });
}

View file

@ -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<string, string[]>();
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<string, string[]>([['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<string, string[]>([['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<string, string[]>([['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<string, string[]>([['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<string, string[]>([
['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();

View file

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