feat: add /gsd next (step mode), make bare /gsd default to step mode, delete /gsd-run

- /gsd next: same state machine as /gsd auto but pauses between units
  with a wizard showing what completed and what's next
- /gsd (bare): now defaults to step mode instead of the old guided flow
- /gsd auto: unchanged — continuous execution without pausing
- Deleted /gsd-run slash command (redundant with /gsd auto)
- Step mode preserves through discuss → auto-start transition
- User can switch from step → auto mid-session via wizard option
- Progress widget shows NEXT/AUTO based on current mode
This commit is contained in:
Lex Christopherson 2026-03-11 15:12:28 -06:00
parent 8bd27f74e0
commit 5bb3229a85
6 changed files with 158 additions and 56 deletions

4
.gitignore vendored
View file

@ -33,4 +33,6 @@ dist/
.bg_shell
.gsd*.tgz
.artifacts/
AGENTS.md
AGENTS.md
.bg-shell/
TODOS.md

View file

@ -64,11 +64,13 @@ import {
} from "./worktree.ts";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
import { showNextAction } from "../shared/next-action-ui.js";
// ─── State ────────────────────────────────────────────────────────────────────
let active = false;
let paused = false;
let stepMode = false;
let verbose = false;
let cmdCtx: ExtensionCommandContext | null = null;
let basePath = "";
@ -101,6 +103,7 @@ let idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
export interface AutoDashboardData {
active: boolean;
paused: boolean;
stepMode: boolean;
startTime: number;
elapsed: number;
currentUnit: { type: string; id: string; startedAt: number } | null;
@ -117,6 +120,7 @@ export function getAutoDashboardData(): AutoDashboardData {
return {
active,
paused,
stepMode,
startTime: autoStartTime,
elapsed: (active || paused) ? Date.now() - autoStartTime : 0,
currentUnit: currentUnit ? { ...currentUnit } : null,
@ -137,6 +141,10 @@ export function isAutoPaused(): boolean {
return paused;
}
export function isStepMode(): boolean {
return stepMode;
}
function clearUnitTimeout(): void {
if (unitTimeoutHandle) {
clearTimeout(unitTimeoutHandle);
@ -173,6 +181,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
resetMetrics();
active = false;
paused = false;
stepMode = false;
lastUnit = null;
currentUnit = null;
currentMilestoneId = null;
@ -207,8 +216,9 @@ export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Pro
// — all needed for resume and dashboard display
ctx?.ui.setStatus("gsd-auto", "paused");
ctx?.ui.setWidget("gsd-progress", undefined);
const resumeCmd = stepMode ? "/gsd next" : "/gsd auto";
ctx?.ui.notify(
"Auto-mode paused (Escape). Type to interact, or /gsd auto to resume.",
`${stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
"info",
);
}
@ -218,19 +228,24 @@ export async function startAuto(
pi: ExtensionAPI,
base: string,
verboseMode: boolean,
options?: { step?: boolean },
): Promise<void> {
const requestedStepMode = options?.step ?? false;
// If resuming from paused state, just re-activate and dispatch next unit.
// The conversation is still intact — no need to reinitialize everything.
if (paused) {
paused = false;
active = true;
verbose = verboseMode;
// Allow switching between step/auto on resume
stepMode = requestedStepMode;
cmdCtx = ctx;
basePath = base;
// Re-initialize metrics in case ledger was lost during pause
if (!getLedger()) initMetrics(base);
ctx.ui.setStatus("gsd-auto", "auto");
ctx.ui.notify("Auto-mode resumed.", "info");
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
await dispatchNextUnit(ctx, pi);
return;
}
@ -286,7 +301,7 @@ export async function startAuto(
// No active work at all — start a new milestone via the discuss flow.
if (!state.activeMilestone || state.phase === "complete") {
const { showSmartEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base);
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
return;
}
@ -298,13 +313,14 @@ export async function startAuto(
const hasContext = !!(contextFile && await loadFile(contextFile));
if (!hasContext) {
const { showSmartEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base);
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
return;
}
// Has context, no roadmap — auto-mode will research + plan it
}
active = true;
stepMode = requestedStepMode;
verbose = verboseMode;
cmdCtx = ctx;
basePath = base;
@ -324,12 +340,13 @@ export async function startAuto(
snapshotSkills();
}
ctx.ui.setStatus("gsd-auto", "auto");
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
const modeLabel = stepMode ? "Step-mode" : "Auto-mode";
const pendingCount = state.registry.filter(m => m.status !== 'complete').length;
const scopeMsg = pendingCount > 1
? `Will loop through ${pendingCount} milestones.`
: "Will loop until milestone complete.";
ctx.ui.notify(`Auto-mode started. ${scopeMsg}`, "info");
ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
// Dispatch the first unit
await dispatchNextUnit(ctx, pi);
@ -361,9 +378,117 @@ export async function handleAgentEnd(
}
}
// In step mode, pause and show a wizard instead of immediately dispatching
if (stepMode) {
await showStepWizard(ctx, pi);
return;
}
await dispatchNextUnit(ctx, pi);
}
// ─── Step Mode Wizard ─────────────────────────────────────────────────────
/**
* Show the step-mode wizard after a unit completes.
* Derives the next unit from disk state and presents it to the user.
* If the user confirms, dispatches the next unit. If not, pauses.
*/
async function showStepWizard(
ctx: ExtensionContext,
pi: ExtensionAPI,
): Promise<void> {
if (!cmdCtx) return;
const state = await deriveState(basePath);
const mid = state.activeMilestone?.id;
// Build summary of what just completed
const justFinished = currentUnit
? `${unitVerb(currentUnit.type)} ${currentUnit.id}`
: "previous unit";
// If no active milestone or everything is complete, stop
if (!mid || state.phase === "complete") {
await stopAuto(ctx, pi);
return;
}
// Peek at what's next by examining state
const nextDesc = describeNextUnit(state);
const choice = await showNextAction(cmdCtx, {
title: `GSD — ${justFinished} complete`,
summary: [
`${mid}: ${state.activeMilestone?.title ?? mid}`,
...(state.activeSlice ? [`${state.activeSlice.id}: ${state.activeSlice.title}`] : []),
],
actions: [
{
id: "continue",
label: nextDesc.label,
description: nextDesc.description,
recommended: true,
},
{
id: "auto",
label: "Switch to auto",
description: "Continue without pausing between steps.",
},
{
id: "status",
label: "View status",
description: "Open the dashboard.",
},
],
notYetMessage: "Run /gsd next when ready to continue.",
});
if (choice === "continue") {
await dispatchNextUnit(ctx, pi);
} else if (choice === "auto") {
stepMode = false;
ctx.ui.setStatus("gsd-auto", "auto");
ctx.ui.notify("Switched to auto-mode.", "info");
await dispatchNextUnit(ctx, pi);
} else if (choice === "status") {
// Show status then re-show the wizard
const { fireStatusViaCommand } = await import("./commands.js");
await fireStatusViaCommand(ctx as ExtensionCommandContext);
await showStepWizard(ctx, pi);
} else {
// "not_yet" — pause
await pauseAuto(ctx, pi);
}
}
/**
* Describe what the next unit will be, based on current state.
*/
function describeNextUnit(state: GSDState): { label: string; description: string } {
const sid = state.activeSlice?.id;
const sTitle = state.activeSlice?.title;
const tid = state.activeTask?.id;
const tTitle = state.activeTask?.title;
switch (state.phase) {
case "pre-planning":
return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." };
case "planning":
return { label: `Plan ${sid}: ${sTitle}`, description: "Research and decompose into tasks." };
case "executing":
return { label: `Execute ${tid}: ${tTitle}`, description: "Run the next task in a fresh session." };
case "summarizing":
return { label: `Complete ${sid}: ${sTitle}`, description: "Write summary, UAT, and merge to main." };
case "replanning-slice":
return { label: `Replan ${sid}: ${sTitle}`, description: "Blocker found — replan the slice." };
case "completing-milestone":
return { label: "Complete milestone", description: "Write milestone summary." };
default:
return { label: "Continue", description: "Execute the next step." };
}
}
// ─── Progress Widget ──────────────────────────────────────────────────────
function unitVerb(unitType: string): string {
@ -464,7 +589,8 @@ function updateProgressWidget(
? theme.fg("accent", GLYPH.statusActive)
: theme.fg("dim", GLYPH.statusPending);
const elapsed = formatAutoElapsed();
const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", "AUTO")}`;
const modeTag = stepMode ? "NEXT" : "AUTO";
const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`;
const headerRight = elapsed ? theme.fg("dim", elapsed) : "";
lines.push(rightAlign(headerLeft, headerRight, width));

View file

@ -10,8 +10,8 @@ import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { deriveState } from "./state.js";
import { GSDDashboardOverlay } from "./dashboard-overlay.js";
import { showSmartEntry, showQueue, showDiscuss } from "./guided-flow.js";
import { startAuto, stopAuto, isAutoActive, isAutoPaused } from "./auto.js";
import { showQueue, showDiscuss } from "./guided-flow.js";
import { startAuto, stopAuto, isAutoActive, isAutoPaused, isStepMode } from "./auto.js";
import {
getGlobalGSDPreferencesPath,
getLegacyGlobalGSDPreferencesPath,
@ -52,10 +52,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
export function registerGSDCommand(pi: ExtensionAPI): void {
pi.registerCommand("gsd", {
description: "GSD — Get Shit Done: /gsd auto|stop|status|queue|prefs|doctor|migrate",
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate",
getArgumentCompletions: (prefix: string) => {
const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"];
const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"];
const parts = prefix.trim().split(/\s+/);
if (parts.length <= 1) {
@ -112,6 +112,12 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
return;
}
if (trimmed === "next" || trimmed.startsWith("next ")) {
const verboseMode = trimmed.includes("--verbose");
await startAuto(ctx, pi, process.cwd(), verboseMode, { step: true });
return;
}
if (trimmed === "auto" || trimmed.startsWith("auto ")) {
const verboseMode = trimmed.includes("--verbose");
await startAuto(ctx, pi, process.cwd(), verboseMode);
@ -143,12 +149,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
}
if (trimmed === "") {
await showSmartEntry(ctx, pi, process.cwd());
// Bare /gsd defaults to step mode
await startAuto(ctx, pi, process.cwd(), false, { step: true });
return;
}
ctx.ui.notify(
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate <path>.`,
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate <path>.`,
"warning",
);
},

View file

@ -31,13 +31,14 @@ let pendingAutoStart: {
pi: ExtensionAPI;
basePath: string;
milestoneId: string; // the milestone being discussed
step?: boolean; // preserve step mode through discuss → auto transition
} | null = null;
/** Called from agent_end to check if auto-mode should start after discuss */
export function checkAutoStartAfterDiscuss(): boolean {
if (!pendingAutoStart) return false;
const { ctx, pi, basePath, milestoneId } = pendingAutoStart;
const { ctx, pi, basePath, milestoneId, step } = pendingAutoStart;
// Don't fire until the discuss phase has actually produced a context file
// for the milestone being discussed. agent_end fires after every LLM turn,
@ -47,7 +48,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
if (!contextFile) return false; // no context yet — keep waiting
pendingAutoStart = null;
startAuto(ctx, pi, basePath, false).catch(() => {});
startAuto(ctx, pi, basePath, false, { step }).catch(() => {});
return true;
}
@ -435,7 +436,9 @@ export async function showSmartEntry(
ctx: ExtensionCommandContext,
pi: ExtensionAPI,
basePath: string,
options?: { step?: boolean },
): Promise<void> {
const stepMode = options?.step;
// ── Ensure git repo exists — GSD needs it for branch-per-slice ──────
try {
@ -501,7 +504,7 @@ export async function showSmartEntry(
if (isFirst) {
// First ever — skip wizard, just ask directly
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
dispatchWorkflow(pi, buildDiscussPrompt(nextId,
`New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`,
basePath
@ -522,7 +525,7 @@ export async function showSmartEntry(
});
if (choice === "new_milestone") {
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
dispatchWorkflow(pi, buildDiscussPrompt(nextId,
`New milestone ${nextId}.`,
basePath
@ -560,7 +563,7 @@ export async function showSmartEntry(
const milestoneIds = findMilestoneIds(basePath);
const nextId = `M${String(milestoneIds.length + 1).padStart(3, "0")}`;
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
dispatchWorkflow(pi, buildDiscussPrompt(nextId,
`New milestone ${nextId}.`,
basePath

View file

@ -1,34 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
export default function gsdRun(pi: ExtensionAPI) {
pi.registerCommand("gsd-run", {
description: "Read GSD-WORKFLOW.md and execute — lightweight protocol-driven GSD",
async handler(args: string, ctx: ExtensionCommandContext) {
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
let workflow: string;
try {
workflow = readFileSync(workflowPath, "utf-8");
} catch {
ctx.ui.notify(`Cannot read ${workflowPath}`, "error");
return;
}
const userNote = (typeof args === "string" ? args : "").trim();
const noteSection = userNote
? `\n\n## User Note\n\n${userNote}\n`
: "";
pi.sendMessage(
{
customType: "gsd-run",
content: `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}${noteSection}`,
display: false,
},
{ triggerTurn: true },
);
},
});
}

View file

@ -2,13 +2,11 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import createSlashCommand from "./create-slash-command.js";
import createExtension from "./create-extension.js";
import auditCommand from "./audit.js";
import gsdRun from "./gsd-run.js";
import clearCommand from "./clear.js";
export default function slashCommands(pi: ExtensionAPI) {
createSlashCommand(pi);
createExtension(pi);
auditCommand(pi);
gsdRun(pi);
clearCommand(pi);
}