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:
parent
b873f8112f
commit
7bef5a8f8d
12 changed files with 1233 additions and 17 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
100
src/resources/extensions/gsd/export.ts
Normal file
100
src/resources/extensions/gsd/export.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
162
src/resources/extensions/gsd/history.ts
Normal file
162
src/resources/extensions/gsd/history.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
88
src/resources/extensions/gsd/notifications.ts
Normal file
88
src/resources/extensions/gsd/notifications.ts
Normal 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, '\\"');
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
67
src/resources/extensions/gsd/tests/notifications.test.ts
Normal file
67
src/resources/extensions/gsd/tests/notifications.test.ts
Normal 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);
|
||||
});
|
||||
136
src/resources/extensions/gsd/tests/undo.test.ts
Normal file
136
src/resources/extensions/gsd/tests/undo.test.ts
Normal 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"]);
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
219
src/resources/extensions/gsd/undo.ts
Normal file
219
src/resources/extensions/gsd/undo.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue