diff --git a/.gitignore b/.gitignore index 83ccc990f..35a2ad14c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ dist/ .bg_shell .gsd*.tgz .artifacts/ -AGENTS.md \ No newline at end of file +AGENTS.md +.bg-shell/ +TODOS.md \ No newline at end of file diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index a7326bf19..03eec7354 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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 | 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 { + 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 { + 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)); diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 65ac405a2..f682fe758 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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 .`, + `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 .`, "warning", ); }, diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 6fc60f9d1..775d14fd0 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -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 { + 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 diff --git a/src/resources/extensions/slash-commands/gsd-run.ts b/src/resources/extensions/slash-commands/gsd-run.ts deleted file mode 100644 index 21d26fa28..000000000 --- a/src/resources/extensions/slash-commands/gsd-run.ts +++ /dev/null @@ -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 }, - ); - }, - }); -} diff --git a/src/resources/extensions/slash-commands/index.ts b/src/resources/extensions/slash-commands/index.ts index 8d3be0e02..52ab77bf4 100644 --- a/src/resources/extensions/slash-commands/index.ts +++ b/src/resources/extensions/slash-commands/index.ts @@ -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); }