feat: QOL improvements — 8 new commands, budget enforcement, notifications (#441)

* feat: add QOL commands — pause, history, undo, skip, export, cleanup, dry-run, budget enforcement, notifications

Add 8 new /gsd subcommands and enhance auto-mode with budget enforcement,
context monitoring, and desktop notifications.

New commands:
- /gsd pause — graceful pause (finish current unit, then stop)
- /gsd history [N] [--cost|--phase|--model] — view unit execution history
- /gsd undo [--force] — rollback last completed unit (revert git + state)
- /gsd skip <unit-id> — mark unit complete without executing
- /gsd next --dry-run — preview next unit with estimated cost/duration
- /gsd export [--json|--markdown] — generate session report
- /gsd cleanup branches — delete merged GSD branches
- /gsd cleanup snapshots — prune old snapshot refs

Auto-mode enhancements:
- Budget enforcement with 3 modes (warn/pause/halt) and threshold alerts at 75%/90%/100%
- Context window monitoring with auto-pause when approaching limits
- Desktop notifications (macOS osascript, Linux notify-send) on milestone complete, blocked, loop detected

New preferences:
- budget_enforcement: warn | pause | halt (default: pause)
- context_pause_threshold: number (% context window, 0 to disable)
- notifications.enabled: boolean

New files: notifications.ts, history.ts, undo.ts, export.ts
Modified: commands.ts, auto.ts, types.ts, preferences.ts, metrics.ts

* fix: harden qol notifications and undo paths

* fix: finish qol review follow-ups

---------

Co-authored-by: TÂCHES <afromanguy@me.com>
This commit is contained in:
Flux Labs 2026-03-15 17:28:04 -05:00 committed by GitHub
parent b873f8112f
commit 7bef5a8f8d
12 changed files with 1233 additions and 17 deletions

View file

@ -17,7 +17,7 @@ import type {
} from "@gsd/pi-coding-agent";
import { deriveState, invalidateStateCache } from "./state.js";
import type { GSDState } from "./types.js";
import type { BudgetEnforcementMode, GSDState } from "./types.js";
import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary, getManifestStatus, clearParseCache } from "./files.js";
export { inlinePriorMilestoneSummary };
import type { UatType } from "./files.js";
@ -42,6 +42,7 @@ import {
writeUnitRuntimeRecord,
} from "./unit-runtime.js";
import { resolveAutoSupervisorConfig, resolveModelForUnit, resolveModelWithFallbacksForUnit, resolveSkillDiscoveryMode, loadEffectiveGSDPreferences } from "./preferences.js";
import { sendDesktopNotification } from "./notifications.js";
import type { GSDPreferences } from "./preferences.js";
import {
checkPostUnitHooks,
@ -186,6 +187,7 @@ let currentUnit: { type: string; id: string; startedAt: number } | null = null;
/** Track current milestone to detect transitions */
let currentMilestoneId: string | null = null;
let lastBudgetAlertLevel: BudgetAlertLevel = 0;
/** Model the user had selected before auto-mode started */
let originalModelId: string | null = null;
@ -207,6 +209,31 @@ const DISPATCH_GAP_TIMEOUT_MS = 5_000; // 5 seconds
/** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
let _sigtermHandler: (() => void) | null = null;
type BudgetAlertLevel = 0 | 75 | 90 | 100;
export function getBudgetAlertLevel(budgetPct: number): BudgetAlertLevel {
if (budgetPct >= 1.0) return 100;
if (budgetPct >= 0.90) return 90;
if (budgetPct >= 0.75) return 75;
return 0;
}
export function getNewBudgetAlertLevel(previousLevel: BudgetAlertLevel, budgetPct: number): BudgetAlertLevel | null {
const currentLevel = getBudgetAlertLevel(budgetPct);
if (currentLevel === 0 || currentLevel <= previousLevel) return null;
return currentLevel;
}
export function getBudgetEnforcementAction(
enforcement: BudgetEnforcementMode,
budgetPct: number,
): "none" | "warn" | "pause" | "halt" {
if (budgetPct < 1.0) return "none";
if (enforcement === "halt") return "halt";
if (enforcement === "pause") return "pause";
return "warn";
}
/**
* Register a SIGTERM handler that clears the lock file and exits cleanly.
* Captures the active base path at registration time so the handler
@ -410,6 +437,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
stepMode = false;
unitDispatchCount.clear();
unitRecoveryCount.clear();
lastBudgetAlertLevel = 0;
unitLifetimeDispatches.clear();
currentUnit = null;
currentMilestoneId = null;
@ -670,6 +698,7 @@ export async function startAuto(
basePath = base;
unitDispatchCount.clear();
unitRecoveryCount.clear();
lastBudgetAlertLevel = 0;
unitLifetimeDispatches.clear();
completedKeySet.clear();
loadPersistedKeys(base, completedKeySet);
@ -1546,6 +1575,7 @@ async function dispatchNextUnit(
`Milestone ${currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
"info",
);
sendDesktopNotification("GSD", `Milestone ${currentMilestoneId} complete!`, "success", "milestone");
// Reset stuck detection for new milestone
unitDispatchCount.clear();
unitRecoveryCount.clear();
@ -1565,6 +1595,7 @@ async function dispatchNextUnit(
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
await stopAuto(ctx, pi);
return;
}
@ -1646,7 +1677,6 @@ async function dispatchNextUnit(
if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
completedKeySet.clear();
} catch { /* non-fatal */ }
// ── Milestone merge: squash-merge milestone branch to main before stopping ──
if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath) {
try {
@ -1666,7 +1696,7 @@ async function dispatchNextUnit(
);
}
}
sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
await stopAuto(ctx, pi);
return;
}
@ -1678,7 +1708,9 @@ async function dispatchNextUnit(
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
await stopAuto(ctx, pi);
ctx.ui.notify(`Blocked: ${state.blockers.join(", ")}. Fix and run /gsd auto.`, "warning");
const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
sendDesktopNotification("GSD", blockerMsg, "error", "attention");
return;
}
@ -1686,16 +1718,58 @@ async function dispatchNextUnit(
// Ensures the UAT file and slice summary are both on main when UAT runs.
const prefs = loadEffectiveGSDPreferences()?.preferences;
// Budget ceiling guard — pause before starting next unit if ceiling is hit
// Budget ceiling guard — enforce budget with configurable action
const budgetCeiling = prefs?.budget_ceiling;
if (budgetCeiling !== undefined) {
if (budgetCeiling !== undefined && budgetCeiling > 0) {
const currentLedger = getLedger();
const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
if (totalCost >= budgetCeiling) {
ctx.ui.notify(
`Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}). Pausing auto-mode — /gsd auto to continue.`,
"warning",
);
const budgetPct = totalCost / budgetCeiling;
const budgetAlertLevel = getBudgetAlertLevel(budgetPct);
const newBudgetAlertLevel = getNewBudgetAlertLevel(lastBudgetAlertLevel, budgetPct);
const enforcement = prefs?.budget_enforcement ?? "pause";
const budgetEnforcementAction = getBudgetEnforcementAction(enforcement, budgetPct);
if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
const msg = `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}).`;
lastBudgetAlertLevel = newBudgetAlertLevel;
if (budgetEnforcementAction === "halt") {
ctx.ui.notify(`${msg} Stopping auto-mode.`, "error");
sendDesktopNotification("GSD", msg, "error", "budget");
await stopAuto(ctx, pi);
return;
}
if (budgetEnforcementAction === "pause") {
ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
sendDesktopNotification("GSD", msg, "warning", "budget");
await pauseAuto(ctx, pi);
return;
}
ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
sendDesktopNotification("GSD", msg, "warning", "budget");
} else if (newBudgetAlertLevel === 90) {
lastBudgetAlertLevel = newBudgetAlertLevel;
ctx.ui.notify(`Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
sendDesktopNotification("GSD", `Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
} else if (newBudgetAlertLevel === 75) {
lastBudgetAlertLevel = newBudgetAlertLevel;
ctx.ui.notify(`Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info");
sendDesktopNotification("GSD", `Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info", "budget");
} else if (budgetAlertLevel === 0) {
lastBudgetAlertLevel = 0;
}
} else {
lastBudgetAlertLevel = 0;
}
// Context window guard — pause if approaching context limits
const contextThreshold = prefs?.context_pause_threshold ?? 0; // 0 = disabled by default
if (contextThreshold > 0 && cmdCtx) {
const contextUsage = cmdCtx.getContextUsage();
if (contextUsage && contextUsage.percent >= contextThreshold) {
const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning");
sendDesktopNotification("GSD", `Context ${contextUsage.percent}% — paused`, "warning", "attention");
await pauseAuto(ctx, pi);
return;
}
@ -2058,6 +2132,7 @@ async function dispatchNextUnit(
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
await stopAuto(ctx, pi);
sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error");
ctx.ui.notify(
`Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`,
"error",

View file

@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
import { deriveState } from "./state.js";
import { GSDDashboardOverlay } from "./dashboard-overlay.js";
import { showQueue, showDiscuss } from "./guided-flow.js";
import { startAuto, stopAuto, isAutoActive, isAutoPaused, isStepMode } from "./auto.js";
import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode } from "./auto.js";
import {
getGlobalGSDPreferencesPath,
getLegacyGlobalGSDPreferencesPath,
@ -33,6 +33,9 @@ import {
import { loadPrompt } from "./prompt-loader.js";
import { handleMigrate } from "./migrate/command.js";
import { handleRemote } from "../remote-questions/remote-command.js";
import { handleHistory } from "./history.js";
import { handleUndo } from "./undo.js";
import { handleExport } from "./export.js";
function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
@ -54,10 +57,13 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
export function registerGSDCommand(pi: ExtensionAPI): void {
pi.registerCommand("gsd", {
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|config|hooks|doctor|migrate|remote",
description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote",
getArgumentCompletions: (prefix: string) => {
const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "config", "hooks", "doctor", "migrate", "remote"];
const subcommands = [
"next", "auto", "stop", "pause", "status", "queue", "discuss",
"history", "undo", "skip", "export", "cleanup", "prefs",
"config", "hooks", "doctor", "migrate", "remote",
];
const parts = prefix.trim().split(/\s+/);
if (parts.length <= 1) {
@ -87,6 +93,38 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
.map((cmd) => ({ value: `remote ${cmd}`, label: cmd }));
}
if (parts[0] === "next" && parts.length <= 2) {
const flagPrefix = parts[1] ?? "";
return ["--verbose", "--dry-run"]
.filter((f) => f.startsWith(flagPrefix))
.map((f) => ({ value: `next ${f}`, label: f }));
}
if (parts[0] === "history" && parts.length <= 2) {
const flagPrefix = parts[1] ?? "";
return ["--cost", "--phase", "--model", "10", "20", "50"]
.filter((f) => f.startsWith(flagPrefix))
.map((f) => ({ value: `history ${f}`, label: f }));
}
if (parts[0] === "undo" && parts.length <= 2) {
return [{ value: "undo --force", label: "--force" }];
}
if (parts[0] === "export" && parts.length <= 2) {
const flagPrefix = parts[1] ?? "";
return ["--json", "--markdown"]
.filter((f) => f.startsWith(flagPrefix))
.map((f) => ({ value: `export ${f}`, label: f }));
}
if (parts[0] === "cleanup" && parts.length <= 2) {
const subPrefix = parts[1] ?? "";
return ["branches", "snapshots"]
.filter((cmd) => cmd.startsWith(subPrefix))
.map((cmd) => ({ value: `cleanup ${cmd}`, label: cmd }));
}
if (parts[0] === "doctor") {
const modePrefix = parts[1] ?? "";
const modes = ["fix", "heal", "audit"];
@ -122,6 +160,10 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
}
if (trimmed === "next" || trimmed.startsWith("next ")) {
if (trimmed.includes("--dry-run")) {
await handleDryRun(ctx, process.cwd());
return;
}
const verboseMode = trimmed.includes("--verbose");
await startAuto(ctx, pi, process.cwd(), verboseMode, { step: true });
return;
@ -142,6 +184,49 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
return;
}
if (trimmed === "pause") {
if (!isAutoActive()) {
if (isAutoPaused()) {
ctx.ui.notify("Auto-mode is already paused. /gsd auto to resume.", "info");
} else {
ctx.ui.notify("Auto-mode is not running.", "info");
}
return;
}
await pauseAuto(ctx, pi);
return;
}
if (trimmed === "history" || trimmed.startsWith("history ")) {
await handleHistory(trimmed.replace(/^history\s*/, "").trim(), ctx, process.cwd());
return;
}
if (trimmed === "undo" || trimmed.startsWith("undo ")) {
await handleUndo(trimmed.replace(/^undo\s*/, "").trim(), ctx, pi, process.cwd());
return;
}
if (trimmed.startsWith("skip ")) {
await handleSkip(trimmed.replace(/^skip\s*/, "").trim(), ctx, process.cwd());
return;
}
if (trimmed === "export" || trimmed.startsWith("export ")) {
await handleExport(trimmed.replace(/^export\s*/, "").trim(), ctx, process.cwd());
return;
}
if (trimmed === "cleanup branches") {
await handleCleanupBranches(ctx, process.cwd());
return;
}
if (trimmed === "cleanup snapshots") {
await handleCleanupSnapshots(ctx, process.cwd());
return;
}
if (trimmed === "queue") {
await showQueue(ctx, pi, process.cwd());
return;
@ -180,7 +265,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
}
ctx.ui.notify(
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs, /gsd config, /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
`Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip <unit>|export|cleanup|prefs|config|hooks|doctor|migrate|remote.`,
"warning",
);
},
@ -626,3 +711,221 @@ async function ensurePreferencesFile(
}
}
// ─── Skip handler ─────────────────────────────────────────────────────────────
async function handleSkip(unitArg: string, ctx: ExtensionCommandContext, basePath: string): Promise<void> {
if (!unitArg) {
ctx.ui.notify("Usage: /gsd skip <unit-id> (e.g., /gsd skip execute-task/M001/S01/T03 or /gsd skip T03)", "info");
return;
}
const { existsSync: fileExists, writeFileSync: writeFile, mkdirSync: mkDir, readFileSync: readFile } = await import("node:fs");
const { join: pathJoin } = await import("node:path");
const completedKeysFile = pathJoin(basePath, ".gsd", "completed-units.json");
let keys: string[] = [];
try {
if (fileExists(completedKeysFile)) {
keys = JSON.parse(readFile(completedKeysFile, "utf-8"));
}
} catch { /* start fresh */ }
// Normalize: accept "execute-task/M001/S01/T03", "M001/S01/T03", or just "T03"
let skipKey = unitArg;
if (!skipKey.includes("execute-task") && !skipKey.includes("plan-") && !skipKey.includes("research-") && !skipKey.includes("complete-")) {
const state = await deriveState(basePath);
const mid = state.activeMilestone?.id;
const sid = state.activeSlice?.id;
if (unitArg.match(/^T\d+$/i) && mid && sid) {
skipKey = `execute-task/${mid}/${sid}/${unitArg.toUpperCase()}`;
} else if (unitArg.match(/^S\d+$/i) && mid) {
skipKey = `plan-slice/${mid}/${unitArg.toUpperCase()}`;
} else if (unitArg.includes("/")) {
skipKey = `execute-task/${unitArg}`;
}
}
if (keys.includes(skipKey)) {
ctx.ui.notify(`Already skipped: ${skipKey}`, "info");
return;
}
keys.push(skipKey);
mkDir(pathJoin(basePath, ".gsd"), { recursive: true });
writeFile(completedKeysFile, JSON.stringify(keys), "utf-8");
ctx.ui.notify(`Skipped: ${skipKey}. Will not be dispatched in auto-mode.`, "success");
}
// ─── Dry-run handler ──────────────────────────────────────────────────────────
async function handleDryRun(ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const state = await deriveState(basePath);
if (!state.activeMilestone) {
ctx.ui.notify("No active milestone — nothing to dispatch.", "info");
return;
}
const { getLedger, getProjectTotals, formatCost, formatTokenCount, loadLedgerFromDisk } = await import("./metrics.js");
const { loadEffectiveGSDPreferences: loadPrefs } = await import("./preferences.js");
const { formatDuration } = await import("./history.js");
const ledger = getLedger();
const units = ledger?.units ?? loadLedgerFromDisk(basePath)?.units ?? [];
const prefs = loadPrefs()?.preferences;
let nextType = "unknown";
let nextId = "unknown";
const mid = state.activeMilestone.id;
const midTitle = state.activeMilestone.title;
if (state.phase === "pre-planning") {
nextType = "research-milestone";
nextId = mid;
} else if (state.phase === "planning" && state.activeSlice) {
nextType = "plan-slice";
nextId = `${mid}/${state.activeSlice.id}`;
} else if (state.phase === "executing" && state.activeTask && state.activeSlice) {
nextType = "execute-task";
nextId = `${mid}/${state.activeSlice.id}/${state.activeTask.id}`;
} else if (state.phase === "summarizing" && state.activeSlice) {
nextType = "complete-slice";
nextId = `${mid}/${state.activeSlice.id}`;
} else if (state.phase === "completing-milestone") {
nextType = "complete-milestone";
nextId = mid;
} else {
nextType = state.phase;
nextId = mid;
}
const sameTypeUnits = units.filter(u => u.type === nextType);
const avgCost = sameTypeUnits.length > 0
? sameTypeUnits.reduce((s, u) => s + u.cost, 0) / sameTypeUnits.length
: null;
const avgDuration = sameTypeUnits.length > 0
? sameTypeUnits.reduce((s, u) => s + (u.finishedAt - u.startedAt), 0) / sameTypeUnits.length
: null;
const totals = units.length > 0 ? getProjectTotals(units) : null;
const budgetRemaining = prefs?.budget_ceiling && totals
? prefs.budget_ceiling - totals.cost
: null;
const lines = [
`Dry-run preview:`,
``,
` Next unit: ${nextType}`,
` ID: ${nextId}`,
` Milestone: ${mid}: ${midTitle}`,
` Phase: ${state.phase}`,
` Est. cost: ${avgCost !== null ? `${formatCost(avgCost)} (avg of ${sameTypeUnits.length} similar)` : "unknown (first of this type)"}`,
` Est. duration: ${avgDuration !== null ? formatDuration(avgDuration) : "unknown"}`,
` Spent so far: ${totals ? formatCost(totals.cost) : "$0"}`,
` Budget left: ${budgetRemaining !== null ? formatCost(budgetRemaining) : "no ceiling set"}`,
];
if (state.progress) {
const p = state.progress;
lines.push(` Progress: ${p.tasks?.done ?? 0}/${p.tasks?.total ?? "?"} tasks, ${p.slices?.done ?? 0}/${p.slices?.total ?? "?"} slices`);
}
ctx.ui.notify(lines.join("\n"), "info");
}
// ─── Branch cleanup handler ──────────────────────────────────────────────────
async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const { execFileSync } = await import("node:child_process");
let branches: string[];
try {
const output = execFileSync("git", ["branch", "--list", "gsd/*"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
branches = output.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean);
} catch {
ctx.ui.notify("No GSD branches found.", "info");
return;
}
if (branches.length === 0) {
ctx.ui.notify("No GSD branches to clean up.", "info");
return;
}
let mainBranch: string;
try {
mainBranch = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], { cwd: basePath, timeout: 5000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] })
.trim().replace("origin/", "");
} catch {
mainBranch = "main";
}
let merged: string[];
try {
const output = execFileSync("git", ["branch", "--merged", mainBranch, "--list", "gsd/*"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
merged = output.split("\n").map(b => b.trim()).filter(Boolean);
} catch {
merged = [];
}
if (merged.length === 0) {
ctx.ui.notify(`${branches.length} GSD branches found, none are merged into ${mainBranch} yet.`, "info");
return;
}
let deleted = 0;
for (const branch of merged) {
try {
execFileSync("git", ["branch", "-d", branch], { cwd: basePath, timeout: 5000, stdio: "ignore" });
deleted++;
} catch { /* skip branches that can't be deleted */ }
}
ctx.ui.notify(`Cleaned up ${deleted} merged branches. ${branches.length - deleted} remain.`, "success");
}
// ─── Snapshot cleanup handler ─────────────────────────────────────────────────
async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const { execFileSync } = await import("node:child_process");
let refs: string[];
try {
const output = execFileSync("git", ["for-each-ref", "refs/gsd/snapshots/", "--format=%(refname)"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
refs = output.split("\n").filter(Boolean);
} catch {
ctx.ui.notify("No snapshot refs found.", "info");
return;
}
if (refs.length === 0) {
ctx.ui.notify("No snapshot refs to clean up.", "info");
return;
}
const byLabel = new Map<string, string[]>();
for (const ref of refs) {
const parts = ref.split("/");
const label = parts.slice(0, -1).join("/");
if (!byLabel.has(label)) byLabel.set(label, []);
byLabel.get(label)!.push(ref);
}
let pruned = 0;
for (const [, labelRefs] of byLabel) {
const sorted = labelRefs.sort();
for (const old of sorted.slice(0, -5)) {
try {
execFileSync("git", ["update-ref", "-d", old], { cwd: basePath, timeout: 5000, stdio: "ignore" });
pruned++;
} catch { /* skip */ }
}
}
ctx.ui.notify(`Pruned ${pruned} old snapshot refs. ${refs.length - pruned} remain.`, "success");
}

View file

@ -0,0 +1,100 @@
// GSD Extension — Session/Milestone Export
// Generate shareable reports of milestone work in JSON or markdown format.
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, basename } from "node:path";
import {
getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice,
aggregateByModel, formatCost, formatTokenCount,
} from "./metrics.js";
import type { UnitMetrics } from "./metrics.js";
import { gsdRoot } from "./paths.js";
import { formatDuration } from "./history.js";
/**
* Export session/milestone data to JSON or markdown.
*/
export async function handleExport(args: string, ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const format = args.includes("--json") ? "json" : "markdown";
const ledger = getLedger();
let units: UnitMetrics[];
if (ledger && ledger.units.length > 0) {
units = ledger.units;
} else {
const { loadLedgerFromDisk } = await import("./metrics.js");
const diskLedger = loadLedgerFromDisk(basePath);
if (!diskLedger || diskLedger.units.length === 0) {
ctx.ui.notify("Nothing to export — no units executed yet.", "info");
return;
}
units = diskLedger.units;
}
const projectName = basename(basePath);
const exportDir = gsdRoot(basePath);
mkdirSync(exportDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
if (format === "json") {
const report = {
exportedAt: new Date().toISOString(),
project: projectName,
totals: getProjectTotals(units),
byPhase: aggregateByPhase(units),
bySlice: aggregateBySlice(units),
byModel: aggregateByModel(units),
units,
};
const outPath = join(exportDir, `export-${timestamp}.json`);
writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n", "utf-8");
ctx.ui.notify(`Exported to ${outPath}`, "success");
} else {
const totals = getProjectTotals(units);
const phases = aggregateByPhase(units);
const slices = aggregateBySlice(units);
const md = [
`# GSD Session Report — ${projectName}`,
``,
`**Generated**: ${new Date().toISOString()}`,
`**Units completed**: ${totals.units}`,
`**Total cost**: ${formatCost(totals.cost)}`,
`**Total tokens**: ${formatTokenCount(totals.tokens.total)}`,
`**Total duration**: ${formatDuration(totals.duration)}`,
`**Tool calls**: ${totals.toolCalls}`,
``,
`## Cost by Phase`,
``,
`| Phase | Units | Cost | Tokens | Duration |`,
`|-------|-------|------|--------|----------|`,
...phases.map(p =>
`| ${p.phase} | ${p.units} | ${formatCost(p.cost)} | ${formatTokenCount(p.tokens.total)} | ${formatDuration(p.duration)} |`,
),
``,
`## Cost by Slice`,
``,
`| Slice | Units | Cost | Tokens | Duration |`,
`|-------|-------|------|--------|----------|`,
...slices.map(s =>
`| ${s.sliceId} | ${s.units} | ${formatCost(s.cost)} | ${formatTokenCount(s.tokens.total)} | ${formatDuration(s.duration)} |`,
),
``,
`## Unit History`,
``,
`| Type | ID | Model | Cost | Tokens | Duration |`,
`|------|-----|-------|------|--------|----------|`,
...units.map(u =>
`| ${u.type} | ${u.id} | ${u.model.replace(/^claude-/, "")} | ${formatCost(u.cost)} | ${formatTokenCount(u.tokens.total)} | ${formatDuration(u.finishedAt - u.startedAt)} |`,
),
``,
].join("\n");
const outPath = join(exportDir, `export-${timestamp}.md`);
writeFileSync(outPath, md, "utf-8");
ctx.ui.notify(`Exported to ${outPath}`, "success");
}
}

View file

@ -0,0 +1,162 @@
// GSD Extension — Session History View
// Human-readable display of past auto-mode unit executions.
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
import {
getLedger, getProjectTotals, formatCost, formatTokenCount,
aggregateBySlice, aggregateByPhase, aggregateByModel, loadLedgerFromDisk,
} from "./metrics.js";
import type { UnitMetrics } from "./metrics.js";
/**
* Show recent unit execution history with cost, tokens, and duration.
*/
export async function handleHistory(args: string, ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const ledger = getLedger();
// If ledger is null (metrics not initialized from auto-mode), try loading from disk
let units: UnitMetrics[];
if (ledger && ledger.units.length > 0) {
units = ledger.units;
} else {
const diskLedger = loadLedgerFromDisk(basePath);
if (!diskLedger || diskLedger.units.length === 0) {
ctx.ui.notify("No history — no units have been executed yet.", "info");
return;
}
units = diskLedger.units;
}
const parsedLimit = parseInt(args.replace(/--\w+/g, "").trim(), 10);
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20;
const showCost = args.includes("--cost");
const showPhase = args.includes("--phase");
const showModel = args.includes("--model");
if (showCost) {
return showCostBreakdown(units, ctx);
}
if (showPhase) {
return showPhaseBreakdown(units, ctx);
}
if (showModel) {
return showModelBreakdown(units, ctx);
}
const display = units.slice(-limit).reverse();
const totals = getProjectTotals(units);
const lines: string[] = [
`Last ${display.length} of ${units.length} units | Total: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens`,
"",
padRight("Time", 14) + padRight("Type", 20) + padRight("ID", 16) + padRight("Model", 14) + padRight("Cost", 10) + padRight("Tokens", 10) + "Duration",
"─".repeat(98),
];
for (const u of display) {
lines.push(
padRight(formatRelativeTime(u.finishedAt), 14) +
padRight(u.type, 20) +
padRight(truncate(u.id, 15), 16) +
padRight(shortModel(u.model), 14) +
padRight(formatCost(u.cost), 10) +
padRight(formatTokenCount(u.tokens.total), 10) +
formatDuration(u.finishedAt - u.startedAt),
);
}
ctx.ui.notify(lines.join("\n"), "info");
}
function showCostBreakdown(units: UnitMetrics[], ctx: ExtensionCommandContext): void {
const slices = aggregateBySlice(units);
const lines = [
"Cost by slice:",
"",
padRight("Slice", 16) + padRight("Units", 8) + padRight("Cost", 10) + "Tokens",
"─".repeat(50),
];
for (const s of slices) {
lines.push(
padRight(s.sliceId, 16) +
padRight(String(s.units), 8) +
padRight(formatCost(s.cost), 10) +
formatTokenCount(s.tokens.total),
);
}
ctx.ui.notify(lines.join("\n"), "info");
}
function showPhaseBreakdown(units: UnitMetrics[], ctx: ExtensionCommandContext): void {
const phases = aggregateByPhase(units);
const lines = [
"Cost by phase:",
"",
padRight("Phase", 16) + padRight("Units", 8) + padRight("Cost", 10) + padRight("Tokens", 10) + "Duration",
"─".repeat(60),
];
for (const p of phases) {
lines.push(
padRight(p.phase, 16) +
padRight(String(p.units), 8) +
padRight(formatCost(p.cost), 10) +
padRight(formatTokenCount(p.tokens.total), 10) +
formatDuration(p.duration),
);
}
ctx.ui.notify(lines.join("\n"), "info");
}
function showModelBreakdown(units: UnitMetrics[], ctx: ExtensionCommandContext): void {
const models = aggregateByModel(units);
const lines = [
"Cost by model:",
"",
padRight("Model", 24) + padRight("Units", 8) + padRight("Cost", 10) + "Tokens",
"─".repeat(56),
];
for (const m of models) {
lines.push(
padRight(shortModel(m.model), 24) +
padRight(String(m.units), 8) +
padRight(formatCost(m.cost), 10) +
formatTokenCount(m.tokens.total),
);
}
ctx.ui.notify(lines.join("\n"), "info");
}
// ─── Formatting helpers ──────────────────────────────────────────────────────
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const secs = Math.floor(ms / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const remSecs = secs % 60;
if (mins < 60) return `${mins}m ${remSecs}s`;
const hours = Math.floor(mins / 60);
const remMins = mins % 60;
return `${hours}h ${remMins}m`;
}
function formatRelativeTime(timestamp: number): string {
const diff = Date.now() - timestamp;
if (diff < 60_000) return "just now";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
function shortModel(model: string): string {
return model.replace(/^claude-/, "").replace(/^anthropic\//, "");
}
function truncate(s: string, maxLen: number): string {
return s.length > maxLen ? s.slice(0, maxLen - 1) + "…" : s;
}
function padRight(s: string, len: number): string {
return s.length >= len ? s.slice(0, len) : s + " ".repeat(len - s.length);
}

View file

@ -347,6 +347,23 @@ function metricsPath(base: string): string {
return join(gsdRoot(base), "metrics.json");
}
/**
* Load ledger from disk without initializing in-memory state.
* Used by history/export commands outside of auto-mode.
*/
export function loadLedgerFromDisk(base: string): MetricsLedger | null {
try {
const raw = readFileSync(metricsPath(base), "utf-8");
const parsed = JSON.parse(raw);
if (parsed.version === 1 && Array.isArray(parsed.units)) {
return parsed as MetricsLedger;
}
} catch {
// File doesn't exist or is corrupt
}
return null;
}
function loadLedger(base: string): MetricsLedger {
try {
const raw = readFileSync(metricsPath(base), "utf-8");

View file

@ -0,0 +1,88 @@
// GSD Extension — Desktop Notification Helper
// Cross-platform desktop notifications for auto-mode events.
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { execFileSync } from "node:child_process";
import type { NotificationPreferences } from "./types.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
export type NotifyLevel = "info" | "success" | "warning" | "error";
export type NotificationKind = "complete" | "error" | "budget" | "milestone" | "attention";
interface NotificationCommand {
file: string;
args: string[];
}
/**
* Send a native desktop notification. Non-blocking, non-fatal.
* macOS: osascript, Linux: notify-send, Windows: skipped.
*/
export function sendDesktopNotification(
title: string,
message: string,
level: NotifyLevel = "info",
kind: NotificationKind = "complete",
): void {
if (!shouldSendDesktopNotification(kind)) return;
try {
const command = buildDesktopNotificationCommand(process.platform, title, message, level);
if (!command) return;
execFileSync(command.file, command.args, { timeout: 3000, stdio: "ignore" });
} catch {
// Non-fatal — desktop notifications are best-effort
}
}
export function shouldSendDesktopNotification(
kind: NotificationKind,
preferences: NotificationPreferences | undefined = loadEffectiveGSDPreferences()?.preferences.notifications,
): boolean {
if (preferences?.enabled === false) return false;
switch (kind) {
case "error":
return preferences?.on_error ?? true;
case "budget":
return preferences?.on_budget ?? true;
case "milestone":
return preferences?.on_milestone ?? true;
case "attention":
return preferences?.on_attention ?? true;
case "complete":
default:
return preferences?.on_complete ?? true;
}
}
export function buildDesktopNotificationCommand(
platform: NodeJS.Platform,
title: string,
message: string,
level: NotifyLevel = "info",
): NotificationCommand | null {
const normalizedTitle = normalizeNotificationText(title);
const normalizedMessage = normalizeNotificationText(message);
if (platform === "darwin") {
const sound = level === "error" ? 'sound name "Basso"' : 'sound name "Glass"';
const script = `display notification "${escapeAppleScript(normalizedMessage)}" with title "${escapeAppleScript(normalizedTitle)}" ${sound}`;
return { file: "osascript", args: ["-e", script] };
}
if (platform === "linux") {
const urgency = level === "error" ? "critical" : level === "warning" ? "normal" : "low";
return { file: "notify-send", args: ["-u", urgency, normalizedTitle, normalizedMessage] };
}
return null;
}
function normalizeNotificationText(s: string): string {
return s.replace(/\r?\n/g, " ").trim();
}
function escapeAppleScript(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}

View file

@ -3,7 +3,7 @@ import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import { getAgentDir } from "@gsd/pi-coding-agent";
import type { GitPreferences } from "./git-service.js";
import type { PostUnitHookConfig, PreDispatchHookConfig } from "./types.js";
import type { PostUnitHookConfig, PreDispatchHookConfig, BudgetEnforcementMode, NotificationPreferences } from "./types.js";
import { VALID_BRANCH_NAME } from "./git-service.js";
const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
@ -92,6 +92,9 @@ export interface GSDPreferences {
uat_dispatch?: boolean;
unique_milestone_ids?: boolean;
budget_ceiling?: number;
budget_enforcement?: BudgetEnforcementMode;
context_pause_threshold?: number;
notifications?: NotificationPreferences;
remote_questions?: RemoteQuestionsConfig;
git?: GitPreferences;
post_unit_hooks?: PostUnitHookConfig[];

View file

@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getBudgetAlertLevel,
getBudgetEnforcementAction,
getNewBudgetAlertLevel,
} from "../auto.js";
test("getBudgetAlertLevel returns the expected threshold bucket", () => {
assert.equal(getBudgetAlertLevel(0.10), 0);
assert.equal(getBudgetAlertLevel(0.75), 75);
assert.equal(getBudgetAlertLevel(0.89), 75);
assert.equal(getBudgetAlertLevel(0.90), 90);
assert.equal(getBudgetAlertLevel(1.00), 100);
});
test("getNewBudgetAlertLevel only emits once per threshold", () => {
assert.equal(getNewBudgetAlertLevel(0, 0.74), null);
assert.equal(getNewBudgetAlertLevel(0, 0.75), 75);
assert.equal(getNewBudgetAlertLevel(75, 0.80), null);
assert.equal(getNewBudgetAlertLevel(75, 0.90), 90);
assert.equal(getNewBudgetAlertLevel(90, 0.95), null);
assert.equal(getNewBudgetAlertLevel(90, 1.0), 100);
assert.equal(getNewBudgetAlertLevel(100, 1.2), null);
});
test("getBudgetEnforcementAction maps the configured ceiling behavior", () => {
assert.equal(getBudgetEnforcementAction("warn", 0.99), "none");
assert.equal(getBudgetEnforcementAction("warn", 1.0), "warn");
assert.equal(getBudgetEnforcementAction("pause", 1.0), "pause");
assert.equal(getBudgetEnforcementAction("halt", 1.0), "halt");
});

View file

@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildDesktopNotificationCommand,
shouldSendDesktopNotification,
} from "../notifications.js";
import type { NotificationPreferences } from "../types.js";
test("shouldSendDesktopNotification honors granular preferences", () => {
const prefs: NotificationPreferences = {
enabled: true,
on_complete: false,
on_error: true,
on_budget: false,
on_milestone: true,
on_attention: false,
};
assert.equal(shouldSendDesktopNotification("complete", prefs), false);
assert.equal(shouldSendDesktopNotification("error", prefs), true);
assert.equal(shouldSendDesktopNotification("budget", prefs), false);
assert.equal(shouldSendDesktopNotification("milestone", prefs), true);
assert.equal(shouldSendDesktopNotification("attention", prefs), false);
});
test("shouldSendDesktopNotification disables all categories when notifications are disabled", () => {
const prefs: NotificationPreferences = { enabled: false, on_error: true, on_milestone: true };
assert.equal(shouldSendDesktopNotification("error", prefs), false);
assert.equal(shouldSendDesktopNotification("milestone", prefs), false);
});
test("buildDesktopNotificationCommand uses argument arrays for macOS notifications", () => {
const command = buildDesktopNotificationCommand(
"darwin",
`Bob's "Milestone"`,
`Budget!\nPath: C:\\temp`,
"error",
);
assert.ok(command);
assert.equal(command.file, "osascript");
assert.deepEqual(command.args.slice(0, 1), ["-e"]);
assert.match(command.args[1], /Bob's \\"Milestone\\"/);
assert.match(command.args[1], /Budget! Path: C:\\\\temp/);
assert.doesNotMatch(command.args[1], /\n/);
});
test("buildDesktopNotificationCommand preserves literal shell characters on linux", () => {
const command = buildDesktopNotificationCommand(
"linux",
`Bob's $PATH !`,
"line 1\nline 2",
"warning",
);
assert.ok(command);
assert.deepEqual(command, {
file: "notify-send",
args: ["-u", "normal", `Bob's $PATH !`, "line 1 line 2"],
});
});
test("buildDesktopNotificationCommand skips unsupported platforms", () => {
assert.equal(buildDesktopNotificationCommand("win32", "Title", "Message"), null);
});

View file

@ -0,0 +1,136 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
extractCommitShas,
findCommitsForUnit,
handleUndo,
uncheckTaskInPlan,
} from "../undo.js";
function makeTempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), `${prefix}-`));
}
test("handleUndo without --force only warns and leaves completed units intact", async () => {
const base = makeTempDir("gsd-undo-confirm");
try {
mkdirSync(join(base, ".gsd"), { recursive: true });
writeFileSync(
join(base, ".gsd", "completed-units.json"),
JSON.stringify(["execute-task/M001/S01/T01"]),
"utf-8",
);
const notifications: Array<{ message: string; level: string }> = [];
const ctx = {
ui: {
notify(message: string, level: string) {
notifications.push({ message, level });
},
},
};
await handleUndo("", ctx as any, {} as any, base);
assert.equal(notifications.length, 1);
assert.equal(notifications[0]?.level, "warning");
assert.match(notifications[0]?.message ?? "", /Run \/gsd undo --force to confirm\./);
assert.deepEqual(
JSON.parse(readFileSync(join(base, ".gsd", "completed-units.json"), "utf-8")),
["execute-task/M001/S01/T01"],
);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("uncheckTaskInPlan flips a checked task back to unchecked", () => {
const base = makeTempDir("gsd-undo-plan");
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
mkdirSync(sliceDir, { recursive: true });
const planFile = join(sliceDir, "S01-PLAN.md");
writeFileSync(
planFile,
[
"# Slice Plan",
"",
"- [x] **T01**: Ship the feature",
"- [ ] **T02**: Follow-up",
].join("\n"),
"utf-8",
);
assert.equal(uncheckTaskInPlan(base, "M001", "S01", "T01"), true);
assert.match(readFileSync(planFile, "utf-8"), /- \[ \] \*\*T01\*\*: Ship the feature/);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("findCommitsForUnit reads the newest matching activity log and dedupes SHAs", () => {
const base = makeTempDir("gsd-undo-activity");
try {
const activityDir = join(base, ".gsd", "activity");
mkdirSync(activityDir, { recursive: true });
writeFileSync(
join(activityDir, "2026-03-14-execute-task-M001-S01-T01.jsonl"),
`${JSON.stringify({
message: {
content: [
{ type: "tool_result", content: "[main abc1234] old commit" },
],
},
})}\n`,
"utf-8",
);
writeFileSync(
join(activityDir, "2026-03-15-execute-task-M001-S01-T01.jsonl"),
[
JSON.stringify({
message: {
content: [
{ type: "tool_result", content: "[main deadbee] new commit\n[main cafe123] another commit" },
{ type: "tool_result", content: "[main deadbee] duplicate commit" },
],
},
}),
"{not-json}",
].join("\n"),
"utf-8",
);
assert.deepEqual(
findCommitsForUnit(activityDir, "execute-task", "M001/S01/T01"),
["deadbee", "cafe123"],
);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("extractCommitShas returns unique commit hashes from git output blocks", () => {
const content = [
"[main abc1234] first commit",
"[feature deadbeef] second commit",
"[main abc1234] duplicate commit",
].join("\n");
assert.deepEqual(extractCommitShas(content), ["abc1234", "deadbeef"]);
});
test("extractCommitShas ignores malformed commit tokens", () => {
const content = [
"[main abc1234; touch /tmp/pwned] not a real sha token",
"[main not-a-sha] ignored",
"[main 1234567] valid",
].join("\n");
assert.deepEqual(extractCommitShas(content), ["1234567"]);
});

View file

@ -234,6 +234,19 @@ export interface HookDispatchResult {
unitId: string;
}
// ─── Budget & Notification Types ──────────────────────────────────────────
export type BudgetEnforcementMode = 'warn' | 'pause' | 'halt';
export interface NotificationPreferences {
enabled?: boolean; // default true
on_complete?: boolean; // notify on each unit completion
on_error?: boolean; // notify on errors
on_budget?: boolean; // notify on budget thresholds
on_milestone?: boolean; // notify when milestone finishes
on_attention?: boolean; // notify when manual attention needed
}
// ─── Pre-Dispatch Hook Types ──────────────────────────────────────────────
export interface PreDispatchHookConfig {

View file

@ -0,0 +1,219 @@
// GSD Extension — Undo Last Unit
// Rollback the most recent completed unit: revert git, remove state, uncheck plans.
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type { ExtensionCommandContext, ExtensionAPI } from "@gsd/pi-coding-agent";
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { deriveState, invalidateStateCache } from "./state.js";
import { gsdRoot, resolveTasksDir, resolveSlicePath, buildTaskFileName } from "./paths.js";
import { sendDesktopNotification } from "./notifications.js";
/**
* Undo the last completed unit: revert git commits, remove from completed-units,
* delete summary artifacts, and uncheck the task in PLAN.
*/
export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi: ExtensionAPI, basePath: string): Promise<void> {
const force = args.includes("--force");
// 1. Load completed-units.json
const completedKeysFile = join(gsdRoot(basePath), "completed-units.json");
if (!existsSync(completedKeysFile)) {
ctx.ui.notify("Nothing to undo — no completed units found.", "info");
return;
}
let keys: string[];
try {
keys = JSON.parse(readFileSync(completedKeysFile, "utf-8"));
} catch {
ctx.ui.notify("Nothing to undo — completed-units.json is corrupt.", "warning");
return;
}
if (keys.length === 0) {
ctx.ui.notify("Nothing to undo — no completed units.", "info");
return;
}
// Get the last completed unit
const lastKey = keys[keys.length - 1];
const sepIdx = lastKey.indexOf("/");
const unitType = sepIdx >= 0 ? lastKey.slice(0, sepIdx) : lastKey;
const unitId = sepIdx >= 0 ? lastKey.slice(sepIdx + 1) : lastKey;
if (!force) {
ctx.ui.notify(
`Will undo: ${unitType} (${unitId})\n` +
`This will:\n` +
` - Remove from completed-units.json\n` +
` - Delete summary artifacts\n` +
` - Uncheck task in PLAN (if execute-task)\n` +
` - Attempt to revert associated git commits\n\n` +
`Run /gsd undo --force to confirm.`,
"warning",
);
return;
}
// 2. Remove from completed-units.json
keys = keys.filter(k => k !== lastKey);
writeFileSync(completedKeysFile, JSON.stringify(keys), "utf-8");
// 3. Delete summary artifact
const parts = unitId.split("/");
let summaryRemoved = false;
if (parts.length === 3) {
// Task-level: M001/S01/T01
const [mid, sid, tid] = parts;
const tasksDir = resolveTasksDir(basePath, mid, sid);
if (tasksDir) {
const summaryFile = join(tasksDir, buildTaskFileName(tid, "SUMMARY"));
if (existsSync(summaryFile)) {
unlinkSync(summaryFile);
summaryRemoved = true;
}
}
} else if (parts.length === 2) {
// Slice-level: M001/S01
const [mid, sid] = parts;
const slicePath = resolveSlicePath(basePath, mid, sid);
if (slicePath) {
// Try common summary filenames
for (const suffix of ["SUMMARY", "COMPLETE"]) {
const candidates = findFileWithPrefix(slicePath, sid, suffix);
for (const f of candidates) {
unlinkSync(f);
summaryRemoved = true;
}
}
}
}
// 4. Uncheck task in PLAN if execute-task
let planUpdated = false;
if (unitType === "execute-task" && parts.length === 3) {
const [mid, sid, tid] = parts;
planUpdated = uncheckTaskInPlan(basePath, mid, sid, tid);
}
// 5. Try to revert git commits from activity log
let commitsReverted = 0;
const activityDir = join(gsdRoot(basePath), "activity");
if (existsSync(activityDir)) {
const commits = findCommitsForUnit(activityDir, unitType, unitId);
if (commits.length > 0) {
for (const sha of commits.reverse()) {
try {
execFileSync("git", ["revert", "--no-commit", sha], { cwd: basePath, timeout: 10000, stdio: "ignore" });
commitsReverted++;
} catch {
// Revert conflict or already reverted — skip
try { execFileSync("git", ["revert", "--abort"], { cwd: basePath, timeout: 5000, stdio: "ignore" }); } catch { /* no-op */ }
break;
}
}
}
}
// 6. Re-derive state
invalidateStateCache();
await deriveState(basePath);
// Build result message
const results: string[] = [`Undone: ${unitType} (${unitId})`];
results.push(` - Removed from completed-units.json`);
if (summaryRemoved) results.push(` - Deleted summary artifact`);
if (planUpdated) results.push(` - Unchecked task in PLAN`);
if (commitsReverted > 0) {
results.push(` - Reverted ${commitsReverted} commit(s) (staged, not committed)`);
results.push(` Review with 'git diff --cached' then 'git commit' or 'git reset HEAD'`);
}
ctx.ui.notify(results.join("\n"), "success");
sendDesktopNotification("GSD", `Undone: ${unitType} (${unitId})`, "info", "complete");
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
export function uncheckTaskInPlan(basePath: string, mid: string, sid: string, tid: string): boolean {
const slicePath = resolveSlicePath(basePath, mid, sid);
if (!slicePath) return false;
// Find the PLAN file
const planCandidates = findFileWithPrefix(slicePath, sid, "PLAN");
if (planCandidates.length === 0) return false;
const planFile = planCandidates[0];
let content = readFileSync(planFile, "utf-8");
// Match checked task line: - [x] **T01** or - [x] T01:
const regex = new RegExp(`^(\\s*-\\s*)\\[x\\](\\s*\\**${tid}\\**[:\\s])`, "mi");
if (regex.test(content)) {
content = content.replace(regex, "$1[ ]$2");
writeFileSync(planFile, content, "utf-8");
return true;
}
return false;
}
function findFileWithPrefix(dir: string, prefix: string, suffix: string): string[] {
try {
const files = readdirSync(dir);
return files
.filter(f => f.includes(suffix) && (f.startsWith(prefix) || f.startsWith(`${prefix}-`)))
.map(f => join(dir, f));
} catch {
return [];
}
}
export function findCommitsForUnit(activityDir: string, unitType: string, unitId: string): string[] {
const safeUnitId = unitId.replace(/\//g, "-");
const commits: string[] = [];
try {
const files = readdirSync(activityDir)
.filter(f => f.includes(unitType) && f.includes(safeUnitId) && f.endsWith(".jsonl"))
.sort()
.reverse();
if (files.length === 0) return [];
// Parse the most recent activity log for this unit
const content = readFileSync(join(activityDir, files[0]), "utf-8");
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
// Look for tool results containing git commit output
if (entry?.message?.content) {
const blocks = Array.isArray(entry.message.content) ? entry.message.content : [];
for (const block of blocks) {
if (block.type === "tool_result" && typeof block.content === "string") {
for (const sha of extractCommitShas(block.content)) {
if (!commits.includes(sha)) {
commits.push(sha);
}
}
}
}
}
} catch { /* malformed JSON line — skip */ }
}
} catch { /* activity dir issues — skip */ }
return commits;
}
export function extractCommitShas(content: string): string[] {
const commits: string[] = [];
for (const match of content.matchAll(/\[[\w/.-]+\s+([a-f0-9]{7,40})\]/g)) {
const sha = match[1];
if (sha && !commits.includes(sha)) {
commits.push(sha);
}
}
return commits;
}