diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a465f2b..b48ce6f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,57 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.14.4] - 2026-03-15 + +### Fixed +- **Session cwd update** — `newSession()` now updates the LLM's perceived working directory to reflect `process.chdir()` into auto-worktrees. Previously the system prompt was frozen at the original project root, causing the LLM to `cd` back and write files to the wrong location. This was the root cause of complete-slice and plan-slice loops in worktree-based projects. + +## [2.14.3] - 2026-03-15 + +### Fixed +- **Copy planning artifacts into new auto-worktrees** — `createAutoWorktree` now copies `.gsd/milestones/`, `DECISIONS.md`, `REQUIREMENTS.md`, `PROJECT.md` from the source repo into the worktree. Prevents plan-slice loops in projects with pre-v2.14.0 `.gitignore`. + +## [2.14.2] - 2026-03-15 + +### Fixed +- **Dispatch reentrancy deadlock** — `_dispatching` flag was never reset after first dispatch, permanently blocking all subsequent unit dispatches. Wrapped in try/finally. +- **`.gitignore` self-heal** — existing projects with blanket `.gsd/` ignore now auto-remove it on next auto-mode start, replacing with explicit runtime-only patterns so planning artifacts are tracked in git. +- **Discuss depth verification** — render summary as chat text (markdown renders), use ask_user_questions for short confirmation only. + +## [2.14.1] - 2026-03-15 + +### Fixed +- **Quiet auto-mode warnings** — internal recovery machinery (dispatch gap watchdog, model fallback chain) downgraded to verbose-only. Users only see warnings when action is needed. +- **Dispatch recovery hardening** — artifact fallback when completion key missing, TUI freeze prevention, reentrancy guard, atomic writes, stale runtime record cleanup + +## [2.14.0] - 2026-03-15 + +### Added +- **Discussion manifest** — mechanical process verification for multi-milestone context discussions +- **Session-internal `/gsd config`** — configure GSD settings within a running session +- **Model selection UI** — select list instead of free-text input for model preferences +- **Startup performance** — faster GSD launch via optimized initialization + +### Changed +- **Branchless worktree architecture** — eliminated slice branches entirely. All work commits sequentially on `milestone/` within auto-mode worktrees. No branch creation, switching, or merging within a worktree. ~2600 lines of merge/conflict/branch-switching code removed. +- **`.gitignore` overhaul** — planning artifacts (`.gsd/milestones/`) are tracked in git naturally. Only runtime files are gitignored. No more force-add hacks. +- **Multi-milestone enforcement** — `depends_on` frontmatter enforced in multi-milestone CONTEXT.md + +### Fixed +- **Auto-mode loop detection failures** — artifacts on wrong branch or invisible after branch switch no longer possible (root cause eliminated by branchless architecture) +- **Nested worktree creation** — auto-mode no longer creates worktrees inside existing manual worktrees, preventing wrong-repo state reads and "All milestones complete" false positives +- **Dispatch recovery hardening** — artifact fallback when completion key missing, TUI freeze prevention on cascading skips, reentrancy guard, atomic writes, stale runtime record cleanup, git index.lock cleanup +- **Hook orchestration** — finalize runtime records, add supervision, fix retry +- **Empty slice plan stays in planning** — no longer incorrectly transitions to summarizing +- **Prefs wizard** — launch directly from `/gsd prefs`, fix parse/serialize cycle for empty arrays +- **Discussion routing** — `/gsd discuss` routes to draft when phase is needs-discussion + +### Removed +- `ensureSliceBranch()`, `switchToMain()`, `mergeSliceToMain()`, `mergeSliceToMilestone()` +- `shouldUseWorktreeIsolation()`, `getMergeToMainMode()`, `buildFixMergePrompt()` +- `withMergeHeal()`, `recoverCheckout()`, `fix-merge` unit type +- `git.isolation` and `git.merge_to_main` preferences (deprecated with warnings) + ## [2.13.1] - 2026-03-15 ### Fixed @@ -607,7 +658,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - License updated to MIT -[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.13.1...HEAD +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.14.4...HEAD +[2.14.4]: https://github.com/gsd-build/gsd-2/compare/v2.14.3...v2.14.4 +[2.14.3]: https://github.com/gsd-build/gsd-2/compare/v2.14.2...v2.14.3 +[2.14.2]: https://github.com/gsd-build/gsd-2/compare/v2.14.1...v2.14.2 +[2.14.1]: https://github.com/gsd-build/gsd-2/compare/v2.14.0...v2.14.1 +[2.14.0]: https://github.com/gsd-build/gsd-2/compare/v2.13.1...v2.14.0 [2.13.1]: https://github.com/gsd-build/gsd-2/compare/v2.13.0...v2.13.1 [2.13.0]: https://github.com/gsd-build/gsd-2/compare/v2.12.0...v2.13.0 [2.12.0]: https://github.com/gsd-build/gsd-2/compare/v2.11.1...v2.12.0 diff --git a/README.md b/README.md index 0040ca909..f14071a0f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ GSD v2 solves all of these because it's not a prompt framework anymore — it's | Context management | Hope the LLM doesn't fill up | Fresh session per task, programmatic | | Auto mode | LLM self-loop | State machine reading `.gsd/` files | | Crash recovery | None | Lock files + session forensics | -| Git strategy | LLM writes git commands | Programmatic branch-per-slice, squash merge | +| Git strategy | LLM writes git commands | Worktree isolation, sequential commits, squash merge | | Cost tracking | None | Per-unit token/cost ledger with dashboard | | Stuck detection | None | Retry once, then stop with diagnostics | | Timeout supervision | None | Soft/idle/hard timeouts with recovery steering | @@ -111,7 +111,7 @@ Auto mode is a state machine driven by files on disk. It reads `.gsd/STATE.md`, 2. **Context pre-loading** — The dispatch prompt includes inlined task plans, slice plans, prior task summaries, dependency summaries, roadmap excerpts, and decisions register. The LLM starts with everything it needs instead of spending tool calls reading files. -3. **Git branch-per-slice** — Each slice gets its own branch (`gsd/M001/S01`). Tasks commit atomically on the branch. When the slice completes, it's squash-merged to main (or whichever branch you started from) as one clean commit. +3. **Git worktree isolation** — Each milestone runs in its own git worktree with a `milestone/` branch. All slice work commits sequentially — no branch switching, no merge conflicts. When the milestone completes, it's squash-merged to main as one clean commit. 4. **Crash recovery** — A lock file tracks the current unit. If the session dies, the next `/gsd auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. @@ -268,7 +268,7 @@ gsd/M001/S01 (deleted after merge): feat(S01/T01): core types and interfaces ``` -One commit per slice on main (or whichever branch you started from). Squash commits are the permanent record — branches are deleted after merge. Git bisect works. Individual slices are revertable. +One squash commit per milestone on main (or whichever branch you started from). The worktree is torn down after merge. Git bisect works. Individual milestones are revertable. ### Verification diff --git a/native/npm/darwin-arm64/package.json b/native/npm/darwin-arm64/package.json index 2010c6379..5fbead5da 100644 --- a/native/npm/darwin-arm64/package.json +++ b/native/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-arm64", - "version": "2.13.1", + "version": "2.14.4", "description": "GSD native engine binary for macOS ARM64", "os": [ "darwin" diff --git a/native/npm/darwin-x64/package.json b/native/npm/darwin-x64/package.json index f0202c4ca..1bba41646 100644 --- a/native/npm/darwin-x64/package.json +++ b/native/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-x64", - "version": "2.13.1", + "version": "2.14.4", "description": "GSD native engine binary for macOS Intel", "os": [ "darwin" diff --git a/native/npm/linux-arm64-gnu/package.json b/native/npm/linux-arm64-gnu/package.json index 56b72b63e..0c8f04b9c 100644 --- a/native/npm/linux-arm64-gnu/package.json +++ b/native/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-arm64-gnu", - "version": "2.13.1", + "version": "2.14.4", "description": "GSD native engine binary for Linux ARM64 (glibc)", "os": [ "linux" diff --git a/native/npm/linux-x64-gnu/package.json b/native/npm/linux-x64-gnu/package.json index 58d815262..5e6c4a8be 100644 --- a/native/npm/linux-x64-gnu/package.json +++ b/native/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-x64-gnu", - "version": "2.13.1", + "version": "2.14.4", "description": "GSD native engine binary for Linux x64 (glibc)", "os": [ "linux" diff --git a/native/npm/win32-x64-msvc/package.json b/native/npm/win32-x64-msvc/package.json index aad127b1b..b0074d7b0 100644 --- a/native/npm/win32-x64-msvc/package.json +++ b/native/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-win32-x64-msvc", - "version": "2.13.1", + "version": "2.14.4", "description": "GSD native engine binary for Windows x64 (MSVC)", "os": [ "win32" diff --git a/package-lock.json b/package-lock.json index f21f2dcba..94a0f0abd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "2.13.1", + "version": "2.14.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "2.13.1", + "version": "2.14.4", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 46e596bef..2dd2c9a89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "2.13.1", + "version": "2.14.4", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index 6fc9a9853..2e8fac03a 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -1354,6 +1354,9 @@ export class AgentSession { this._disconnectFromAgent(); await this.abort(); this.agent.reset(); + // Update cwd to current process directory — auto-mode may have chdir'd + // into a worktree since the original session was created. + this._cwd = process.cwd(); this.sessionManager.newSession({ parentSession: options?.parentSession }); this.agent.sessionId = this.sessionManager.getSessionId(); this._steeringMessages = []; diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts new file mode 100644 index 000000000..2131f3a7f --- /dev/null +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -0,0 +1,432 @@ +/** + * Auto-mode Dashboard — progress widget rendering, elapsed time formatting, + * unit description helpers, and slice progress caching. + * + * Pure functions that accept specific parameters — no module-level globals + * or AutoContext dependency. State accessors are passed as callbacks. + */ + +import type { ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import type { GSDState } from "./types.js"; +import { getCurrentBranch } from "./worktree.js"; +import { getActiveHook } from "./post-unit-hooks.js"; +import { getLedger, getProjectTotals, formatCost, formatTokenCount } from "./metrics.js"; +import { + resolveMilestoneFile, + resolveSliceFile, +} from "./paths.js"; +import { parseRoadmap, parsePlan } from "./files.js"; +import { readFileSync, existsSync } from "node:fs"; +import { truncateToWidth, visibleWidth } from "@gsd/pi-tui"; +import { makeUI, GLYPH, INDENT } from "../shared/ui.js"; + +// ─── Dashboard Data ─────────────────────────────────────────────────────────── + +/** Dashboard data for the overlay */ +export interface AutoDashboardData { + active: boolean; + paused: boolean; + stepMode: boolean; + startTime: number; + elapsed: number; + currentUnit: { type: string; id: string; startedAt: number } | null; + completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[]; + basePath: string; + /** Running cost and token totals from metrics ledger */ + totalCost: number; + totalTokens: number; +} + +// ─── Unit Description Helpers ───────────────────────────────────────────────── + +export function unitVerb(unitType: string): string { + if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`; + switch (unitType) { + case "research-milestone": + case "research-slice": return "researching"; + case "plan-milestone": + case "plan-slice": return "planning"; + case "execute-task": return "executing"; + case "complete-slice": return "completing"; + case "replan-slice": return "replanning"; + case "reassess-roadmap": return "reassessing"; + case "run-uat": return "running UAT"; + default: return unitType; + } +} + +export function unitPhaseLabel(unitType: string): string { + if (unitType.startsWith("hook/")) return "HOOK"; + switch (unitType) { + case "research-milestone": return "RESEARCH"; + case "research-slice": return "RESEARCH"; + case "plan-milestone": return "PLAN"; + case "plan-slice": return "PLAN"; + case "execute-task": return "EXECUTE"; + case "complete-slice": return "COMPLETE"; + case "replan-slice": return "REPLAN"; + case "reassess-roadmap": return "REASSESS"; + case "run-uat": return "UAT"; + default: return unitType.toUpperCase(); + } +} + +function peekNext(unitType: string, state: GSDState): string { + // Show active hook info in progress display + const activeHookState = getActiveHook(); + if (activeHookState) { + return `hook: ${activeHookState.hookName} (cycle ${activeHookState.cycle})`; + } + + const sid = state.activeSlice?.id ?? ""; + if (unitType.startsWith("hook/")) return `continue ${sid}`; + switch (unitType) { + case "research-milestone": return "plan milestone roadmap"; + case "plan-milestone": return "plan or execute first slice"; + case "research-slice": return `plan ${sid}`; + case "plan-slice": return "execute first task"; + case "execute-task": return `continue ${sid}`; + case "complete-slice": return "reassess roadmap"; + case "replan-slice": return `re-execute ${sid}`; + case "reassess-roadmap": return "advance to next slice"; + case "run-uat": return "reassess roadmap"; + default: return ""; + } +} + +/** + * Describe what the next unit will be, based on current state. + */ +export 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 "needs-discussion": + return { label: "Discuss milestone draft", description: "Milestone has a draft context — needs discussion before planning." }; + 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." }; + } +} + +// ─── Elapsed Time Formatting ────────────────────────────────────────────────── + +/** Format elapsed time since auto-mode started */ +export function formatAutoElapsed(autoStartTime: number): string { + if (!autoStartTime) return ""; + const ms = Date.now() - autoStartTime; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return `${m}m${rs > 0 ? ` ${rs}s` : ""}`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h ${rm}m`; +} + +/** Format token counts for compact display */ +export function formatWidgetTokens(count: number): string { + if (count < 1000) return count.toString(); + if (count < 10000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1000000) return `${Math.round(count / 1000)}k`; + if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; + return `${Math.round(count / 1000000)}M`; +} + +// ─── Slice Progress Cache ───────────────────────────────────────────────────── + +/** Cached slice progress for the widget — avoid async in render */ +let cachedSliceProgress: { + done: number; + total: number; + milestoneId: string; + /** Real task progress for the active slice, if its plan file exists */ + activeSliceTasks: { done: number; total: number } | null; +} | null = null; + +export function updateSliceProgressCache(base: string, mid: string, activeSid?: string): void { + try { + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + if (!roadmapFile) return; + const content = readFileSync(roadmapFile, "utf-8"); + const roadmap = parseRoadmap(content); + + let activeSliceTasks: { done: number; total: number } | null = null; + if (activeSid) { + try { + const planFile = resolveSliceFile(base, mid, activeSid, "PLAN"); + if (planFile && existsSync(planFile)) { + const planContent = readFileSync(planFile, "utf-8"); + const plan = parsePlan(planContent); + activeSliceTasks = { + done: plan.tasks.filter(t => t.done).length, + total: plan.tasks.length, + }; + } + } catch { + // Non-fatal — just omit task count + } + } + + cachedSliceProgress = { + done: roadmap.slices.filter(s => s.done).length, + total: roadmap.slices.length, + milestoneId: mid, + activeSliceTasks, + }; + } catch { + // Non-fatal — widget just won't show progress bar + } +} + +export function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null } | null { + return cachedSliceProgress; +} + +export function clearSliceProgressCache(): void { + cachedSliceProgress = null; +} + +// ─── Footer Factory ─────────────────────────────────────────────────────────── + +/** + * Footer factory that renders zero lines — hides the built-in footer entirely. + * All footer info (pwd, branch, tokens, cost, model) is shown inside the + * progress widget instead, so there's no gap or redundancy. + */ +export const hideFooter = () => ({ + render(_width: number): string[] { return []; }, + invalidate() {}, + dispose() {}, +}); + +// ─── Progress Widget ────────────────────────────────────────────────────────── + +/** State accessors passed to updateProgressWidget to avoid direct global access */ +export interface WidgetStateAccessors { + getAutoStartTime(): number; + isStepMode(): boolean; + getCmdCtx(): ExtensionCommandContext | null; + getBasePath(): string; + isVerbose(): boolean; +} + +export function updateProgressWidget( + ctx: ExtensionContext, + unitType: string, + unitId: string, + state: GSDState, + accessors: WidgetStateAccessors, +): void { + if (!ctx.hasUI) return; + + const verb = unitVerb(unitType); + const phaseLabel = unitPhaseLabel(unitType); + const mid = state.activeMilestone; + const slice = state.activeSlice; + const task = state.activeTask; + const next = peekNext(unitType, state); + + // Cache git branch at widget creation time (not per render) + let cachedBranch: string | null = null; + try { cachedBranch = getCurrentBranch(accessors.getBasePath()); } catch { /* not in git repo */ } + + // Cache pwd with ~ substitution + let widgetPwd = process.cwd(); + const widgetHome = process.env.HOME || process.env.USERPROFILE; + if (widgetHome && widgetPwd.startsWith(widgetHome)) { + widgetPwd = `~${widgetPwd.slice(widgetHome.length)}`; + } + if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`; + + ctx.ui.setWidget("gsd-progress", (tui, theme) => { + let pulseBright = true; + let cachedLines: string[] | undefined; + let cachedWidth: number | undefined; + + const pulseTimer = setInterval(() => { + pulseBright = !pulseBright; + cachedLines = undefined; + tui.requestRender(); + }, 800); + + return { + render(width: number): string[] { + if (cachedLines && cachedWidth === width) return cachedLines; + + const ui = makeUI(theme, width); + const lines: string[] = []; + const pad = INDENT.base; + + // ── Line 1: Top bar ─────────────────────────────────────────────── + lines.push(...ui.bar()); + + const dot = pulseBright + ? theme.fg("accent", GLYPH.statusActive) + : theme.fg("dim", GLYPH.statusPending); + const elapsed = formatAutoElapsed(accessors.getAutoStartTime()); + const modeTag = accessors.isStepMode() ? "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)); + + lines.push(""); + + if (mid) { + lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}`, width)); + } + + if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") { + lines.push(truncateToWidth( + `${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`, + width, + )); + } + + lines.push(""); + + const target = task ? `${task.id}: ${task.title}` : unitId; + const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; + const phaseBadge = theme.fg("dim", phaseLabel); + lines.push(rightAlign(actionLeft, phaseBadge, width)); + lines.push(""); + + if (mid) { + const roadmapSlices = getRoadmapSlicesSync(); + if (roadmapSlices) { + const { done, total, activeSliceTasks } = roadmapSlices; + const barWidth = Math.max(8, Math.min(24, Math.floor(width * 0.3))); + const pct = total > 0 ? done / total : 0; + const filled = Math.round(pct * barWidth); + const bar = theme.fg("success", "█".repeat(filled)) + + theme.fg("dim", "░".repeat(barWidth - filled)); + + let meta = theme.fg("dim", `${done}/${total} slices`); + + if (activeSliceTasks && activeSliceTasks.total > 0) { + meta += theme.fg("dim", ` · task ${activeSliceTasks.done + 1}/${activeSliceTasks.total}`); + } + + lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width)); + } + } + + lines.push(""); + + if (next) { + lines.push(truncateToWidth( + `${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${next}`)}`, + width, + )); + } + + // ── Footer info (pwd, tokens, cost, context, model) ────────────── + lines.push(""); + lines.push(truncateToWidth(theme.fg("dim", `${pad}${widgetPwd}`), width, theme.fg("dim", "…"))); + + // Token stats from current unit session + cumulative cost from metrics + { + const cmdCtx = accessors.getCmdCtx(); + let totalInput = 0, totalOutput = 0; + let totalCacheRead = 0, totalCacheWrite = 0; + if (cmdCtx) { + for (const entry of cmdCtx.sessionManager.getEntries()) { + if (entry.type === "message" && (entry as any).message?.role === "assistant") { + const u = (entry as any).message.usage; + if (u) { + totalInput += u.input || 0; + totalOutput += u.output || 0; + totalCacheRead += u.cacheRead || 0; + totalCacheWrite += u.cacheWrite || 0; + } + } + } + } + const mLedger = getLedger(); + const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null; + const cumulativeCost = autoTotals?.cost ?? 0; + + const cxUsage = cmdCtx?.getContextUsage?.(); + const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0; + const cxPctVal = cxUsage?.percent ?? 0; + const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?"; + + const sp: string[] = []; + if (totalInput) sp.push(`↑${formatWidgetTokens(totalInput)}`); + if (totalOutput) sp.push(`↓${formatWidgetTokens(totalOutput)}`); + if (totalCacheRead) sp.push(`R${formatWidgetTokens(totalCacheRead)}`); + if (totalCacheWrite) sp.push(`W${formatWidgetTokens(totalCacheWrite)}`); + if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`); + + const cxDisplay = cxPct === "?" + ? `?/${formatWidgetTokens(cxWindow)}` + : `${cxPct}%/${formatWidgetTokens(cxWindow)}`; + if (cxPctVal > 90) { + sp.push(theme.fg("error", cxDisplay)); + } else if (cxPctVal > 70) { + sp.push(theme.fg("warning", cxDisplay)); + } else { + sp.push(cxDisplay); + } + + const sLeft = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p)) + .join(theme.fg("dim", " ")); + + const modelId = cmdCtx?.model?.id ?? ""; + const modelProvider = cmdCtx?.model?.provider ?? ""; + const modelPhase = phaseLabel ? theme.fg("dim", `[${phaseLabel}] `) : ""; + const modelDisplay = modelProvider && modelId + ? `${modelProvider}/${modelId}` + : modelId; + const sRight = modelDisplay + ? `${modelPhase}${theme.fg("dim", modelDisplay)}` + : ""; + lines.push(rightAlign(`${pad}${sLeft}`, sRight, width)); + } + + const hintParts: string[] = []; + hintParts.push("esc pause"); + hintParts.push(process.platform === "darwin" ? "⌃⌥G dashboard" : "Ctrl+Alt+G dashboard"); + lines.push(...ui.hints(hintParts)); + + lines.push(...ui.bar()); + + cachedLines = lines; + cachedWidth = width; + return lines; + }, + invalidate() { + cachedLines = undefined; + cachedWidth = undefined; + }, + dispose() { + clearInterval(pulseTimer); + }, + }; + }); +} + +// ─── Right-align Helper ─────────────────────────────────────────────────────── + +/** Right-align helper: build a line with left content and right content. */ +function rightAlign(left: string, right: string, width: number): string { + const leftVis = visibleWidth(left); + const rightVis = visibleWidth(right); + const gap = Math.max(1, width - leftVis - rightVis); + return truncateToWidth(left + " ".repeat(gap) + right, width); +} diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts new file mode 100644 index 000000000..301578b15 --- /dev/null +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -0,0 +1,785 @@ +/** + * Auto-mode Prompt Builders — construct dispatch prompts for each unit type. + * + * Pure async functions that load templates and inline file content. No module-level + * state, no globals — every dependency is passed as a parameter or imported as a + * utility. + */ + +import { loadFile, parseContinue, parseRoadmap, parseSummary, extractUatType } from "./files.js"; +import type { UatType } from "./files.js"; +import { loadPrompt, inlineTemplate } from "./prompt-loader.js"; +import { + resolveMilestoneFile, resolveSliceFile, resolveSlicePath, + resolveTasksDir, resolveTaskFiles, resolveTaskFile, + relMilestoneFile, relSliceFile, relSlicePath, relMilestonePath, + resolveGsdRootFile, relGsdRootFile, +} from "./paths.js"; +import { resolveSkillDiscoveryMode } from "./preferences.js"; +import type { GSDState } from "./types.js"; +import type { GSDPreferences } from "./preferences.js"; +import { join } from "node:path"; +import { existsSync } from "node:fs"; + +// ─── Inline Helpers ─────────────────────────────────────────────────────── + +/** + * Load a file and format it for inlining into a prompt. + * Returns the content wrapped with a source path header, or a fallback + * message if the file doesn't exist. This eliminates tool calls — the LLM + * gets the content directly instead of "Read this file:". + */ +export async function inlineFile( + absPath: string | null, relPath: string, label: string, +): Promise { + const content = absPath ? await loadFile(absPath) : null; + if (!content) { + return `### ${label}\nSource: \`${relPath}\`\n\n_(not found — file does not exist yet)_`; + } + return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`; +} + +/** + * Load a file for inlining, returning null if it doesn't exist. + * Use when the file is optional and should be omitted entirely if absent. + */ +export async function inlineFileOptional( + absPath: string | null, relPath: string, label: string, +): Promise { + const content = absPath ? await loadFile(absPath) : null; + if (!content) return null; + return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`; +} + +/** + * Load and inline dependency slice summaries (full content, not just paths). + */ +export async function inlineDependencySummaries( + mid: string, sid: string, base: string, +): Promise { + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (!roadmapContent) return "- (no dependencies)"; + + const roadmap = parseRoadmap(roadmapContent); + const sliceEntry = roadmap.slices.find(s => s.id === sid); + if (!sliceEntry || sliceEntry.depends.length === 0) return "- (no dependencies)"; + + const sections: string[] = []; + const seen = new Set(); + for (const dep of sliceEntry.depends) { + if (seen.has(dep)) continue; + seen.add(dep); + const summaryFile = resolveSliceFile(base, mid, dep, "SUMMARY"); + const summaryContent = summaryFile ? await loadFile(summaryFile) : null; + const relPath = relSliceFile(base, mid, dep, "SUMMARY"); + if (summaryContent) { + sections.push(`#### ${dep} Summary\nSource: \`${relPath}\`\n\n${summaryContent.trim()}`); + } else { + sections.push(`- \`${relPath}\` _(not found)_`); + } + } + return sections.join("\n\n"); +} + +/** + * Load a well-known .gsd/ root file for optional inlining. + * Handles the existsSync check internally. + */ +export async function inlineGsdRootFile( + base: string, filename: string, label: string, +): Promise { + const key = filename.replace(/\.md$/i, "").toUpperCase() as "PROJECT" | "DECISIONS" | "QUEUE" | "STATE" | "REQUIREMENTS"; + const absPath = resolveGsdRootFile(base, key); + if (!existsSync(absPath)) return null; + return inlineFileOptional(absPath, relGsdRootFile(key), label); +} + +// ─── Skill Discovery ────────────────────────────────────────────────────── + +/** + * Build the skill discovery template variables for research prompts. + * Returns { skillDiscoveryMode, skillDiscoveryInstructions } for template substitution. + */ +export function buildSkillDiscoveryVars(): { skillDiscoveryMode: string; skillDiscoveryInstructions: string } { + const mode = resolveSkillDiscoveryMode(); + + if (mode === "off") { + return { + skillDiscoveryMode: "off", + skillDiscoveryInstructions: " Skill discovery is disabled. Skip this step.", + }; + } + + const autoInstall = mode === "auto"; + const instructions = ` + Identify the key technologies, frameworks, and services this work depends on (e.g. Stripe, Clerk, Supabase, JUCE, SwiftUI). + For each, check if a professional agent skill already exists: + - First check \`\` in your system prompt — a skill may already be installed. + - For technologies without an installed skill, run: \`npx skills find ""\` + - Only consider skills that are **directly relevant** to core technologies — not tangentially related. + - Evaluate results by install count and relevance to the actual work.${autoInstall + ? ` + - Install relevant skills: \`npx skills add -g -y\` + - Record installed skills in the "Skills Discovered" section of your research output. + - Installed skills will automatically appear in subsequent units' system prompts — no manual steps needed.` + : ` + - Note promising skills in your research output with their install commands, but do NOT install them. + - The user will decide which to install.` + }`; + + return { + skillDiscoveryMode: mode, + skillDiscoveryInstructions: instructions, + }; +} + +// ─── Text Helpers ────────────────────────────────────────────────────────── + +export function extractMarkdownSection(content: string, heading: string): string | null { + const match = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m").exec(content); + if (!match) return null; + + const start = match.index + match[0].length; + const rest = content.slice(start); + const nextHeading = rest.match(/^##\s+/m); + const end = nextHeading?.index ?? rest.length; + return rest.slice(0, end).trim(); +} + +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function oneLine(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +// ─── Section Builders ────────────────────────────────────────────────────── + +export function buildResumeSection( + continueContent: string | null, + legacyContinueContent: string | null, + continueRelPath: string, + legacyContinueRelPath: string | null, +): string { + const resolvedContent = continueContent ?? legacyContinueContent; + const resolvedRelPath = continueContent ? continueRelPath : legacyContinueRelPath; + + if (!resolvedContent || !resolvedRelPath) { + return ["## Resume State", "- No continue file present. Start from the top of the task plan."].join("\n"); + } + + const cont = parseContinue(resolvedContent); + const lines = [ + "## Resume State", + `Source: \`${resolvedRelPath}\``, + `- Status: ${cont.frontmatter.status || "in_progress"}`, + ]; + + if (cont.frontmatter.step && cont.frontmatter.totalSteps) { + lines.push(`- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`); + } + if (cont.completedWork) lines.push(`- Completed: ${oneLine(cont.completedWork)}`); + if (cont.remainingWork) lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`); + if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`); + if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`); + + return lines.join("\n"); +} + +export async function buildCarryForwardSection(priorSummaryPaths: string[], base: string): Promise { + if (priorSummaryPaths.length === 0) { + return ["## Carry-Forward Context", "- No prior task summaries in this slice."].join("\n"); + } + + const items = await Promise.all(priorSummaryPaths.map(async (relPath) => { + const absPath = join(base, relPath); + const content = await loadFile(absPath); + if (!content) return `- \`${relPath}\``; + + const summary = parseSummary(content); + const provided = summary.frontmatter.provides.slice(0, 2).join("; "); + const decisions = summary.frontmatter.key_decisions.slice(0, 2).join("; "); + const patterns = summary.frontmatter.patterns_established.slice(0, 2).join("; "); + const diagnostics = extractMarkdownSection(content, "Diagnostics"); + + const parts = [summary.title || relPath]; + if (summary.oneLiner) parts.push(summary.oneLiner); + if (provided) parts.push(`provides: ${provided}`); + if (decisions) parts.push(`decisions: ${decisions}`); + if (patterns) parts.push(`patterns: ${patterns}`); + if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`); + + return `- \`${relPath}\` — ${parts.join(" | ")}`; + })); + + return ["## Carry-Forward Context", ...items].join("\n"); +} + +export function extractSliceExecutionExcerpt(content: string | null, relPath: string): string { + if (!content) { + return [ + "## Slice Plan Excerpt", + `Slice plan not found at dispatch time. Read \`${relPath}\` before running slice-level verification.`, + ].join("\n"); + } + + const lines = content.split("\n"); + const goalLine = lines.find(l => l.startsWith("**Goal:**"))?.trim(); + const demoLine = lines.find(l => l.startsWith("**Demo:**"))?.trim(); + + const verification = extractMarkdownSection(content, "Verification"); + const observability = extractMarkdownSection(content, "Observability / Diagnostics"); + + const parts = ["## Slice Plan Excerpt", `Source: \`${relPath}\``]; + if (goalLine) parts.push(goalLine); + if (demoLine) parts.push(demoLine); + if (verification) { + parts.push("", "### Slice Verification", verification.trim()); + } + if (observability) { + parts.push("", "### Slice Observability / Diagnostics", observability.trim()); + } + + return parts.join("\n"); +} + +// ─── Prior Task Summaries ────────────────────────────────────────────────── + +export async function getPriorTaskSummaryPaths( + mid: string, sid: string, currentTid: string, base: string, +): Promise { + const tDir = resolveTasksDir(base, mid, sid); + if (!tDir) return []; + + const summaryFiles = resolveTaskFiles(tDir, "SUMMARY"); + const currentNum = parseInt(currentTid.replace(/^T/, ""), 10); + const sRel = relSlicePath(base, mid, sid); + + return summaryFiles + .filter(f => { + const num = parseInt(f.replace(/^T/, ""), 10); + return num < currentNum; + }) + .map(f => `${sRel}/tasks/${f}`); +} + +// ─── Adaptive Replanning Checks ──────────────────────────────────────────── + +/** + * Check if the most recently completed slice needs reassessment. + * Returns { sliceId } if reassessment is needed, null otherwise. + * + * Skips reassessment when: + * - No roadmap exists yet + * - No slices are completed + * - The last completed slice already has an assessment file + * - All slices are complete (milestone done — no point reassessing) + */ +export async function checkNeedsReassessment( + base: string, mid: string, state: GSDState, +): Promise<{ sliceId: string } | null> { + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (!roadmapContent) return null; + + const roadmap = parseRoadmap(roadmapContent); + const completedSlices = roadmap.slices.filter(s => s.done); + const incompleteSlices = roadmap.slices.filter(s => !s.done); + + // No completed slices or all slices done — skip + if (completedSlices.length === 0 || incompleteSlices.length === 0) return null; + + // Check the last completed slice + const lastCompleted = completedSlices[completedSlices.length - 1]; + const assessmentFile = resolveSliceFile(base, mid, lastCompleted.id, "ASSESSMENT"); + const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile)); + + if (hasAssessment) return null; + + // Also need a summary to reassess against + const summaryFile = resolveSliceFile(base, mid, lastCompleted.id, "SUMMARY"); + const hasSummary = !!(summaryFile && await loadFile(summaryFile)); + + if (!hasSummary) return null; + + return { sliceId: lastCompleted.id }; +} + +/** + * Check if the most recently completed slice needs a UAT run. + * Returns { sliceId, uatType } if UAT should be dispatched, null otherwise. + * + * Skips when: + * - No roadmap or no completed slices + * - All slices are done (milestone complete path — reassessment handles it) + * - uat_dispatch preference is not enabled + * - No UAT file exists for the slice + * - UAT result file already exists (idempotent — already ran) + */ +export async function checkNeedsRunUat( + base: string, mid: string, state: GSDState, prefs: GSDPreferences | undefined, +): Promise<{ sliceId: string; uatType: UatType } | null> { + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (!roadmapContent) return null; + + const roadmap = parseRoadmap(roadmapContent); + const completedSlices = roadmap.slices.filter(s => s.done); + const incompleteSlices = roadmap.slices.filter(s => !s.done); + + // No completed slices — nothing to UAT yet + if (completedSlices.length === 0) return null; + + // All slices done — milestone complete path, skip (reassessment handles) + if (incompleteSlices.length === 0) return null; + + // uat_dispatch must be opted in + if (!prefs?.uat_dispatch) return null; + + // Take the last completed slice + const lastCompleted = completedSlices[completedSlices.length - 1]; + const sid = lastCompleted.id; + + // UAT file must exist + const uatFile = resolveSliceFile(base, mid, sid, "UAT"); + if (!uatFile) return null; + const uatContent = await loadFile(uatFile); + if (!uatContent) return null; + + // If UAT result already exists, skip (idempotent) + const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT"); + if (uatResultFile) { + const hasResult = !!(await loadFile(uatResultFile)); + if (hasResult) return null; + } + + // Classify UAT type; unknown type → treat as human-experience (human review) + const uatType = extractUatType(uatContent) ?? "human-experience"; + + return { sliceId: sid, uatType }; +} + +// ─── Prompt Builders ────────────────────────────────────────────────────── + +export async function buildResearchMilestonePrompt(mid: string, midTitle: string, base: string): Promise { + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + + const inlined: string[] = []; + inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context")); + const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + if (projectInline) inlined.push(projectInline); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + inlined.push(inlineTemplate("research", "Research")); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const outputRelPath = relMilestoneFile(base, mid, "RESEARCH"); + return loadPrompt("research-milestone", { + milestoneId: mid, milestoneTitle: midTitle, + milestonePath: relMilestonePath(base, mid), + contextPath: contextRel, + outputPath: outputRelPath, + inlinedContext, + ...buildSkillDiscoveryVars(), + }); +} + +export async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: string): Promise { + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + const researchPath = resolveMilestoneFile(base, mid, "RESEARCH"); + const researchRel = relMilestoneFile(base, mid, "RESEARCH"); + + const inlined: string[] = []; + inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context")); + const researchInline = await inlineFileOptional(researchPath, researchRel, "Milestone Research"); + if (researchInline) inlined.push(researchInline); + const { inlinePriorMilestoneSummary } = await import("./files.js"); + const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base); + if (priorSummaryInline) inlined.push(priorSummaryInline); + const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + if (projectInline) inlined.push(projectInline); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + inlined.push(inlineTemplate("roadmap", "Roadmap")); + inlined.push(inlineTemplate("decisions", "Decisions")); + inlined.push(inlineTemplate("plan", "Slice Plan")); + inlined.push(inlineTemplate("task-plan", "Task Plan")); + inlined.push(inlineTemplate("secrets-manifest", "Secrets Manifest")); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const outputRelPath = relMilestoneFile(base, mid, "ROADMAP"); + const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS"); + return loadPrompt("plan-milestone", { + milestoneId: mid, milestoneTitle: midTitle, + milestonePath: relMilestonePath(base, mid), + contextPath: contextRel, + researchPath: researchRel, + outputPath: outputRelPath, + secretsOutputPath, + inlinedContext, + }); +} + +export async function buildResearchSlicePrompt( + mid: string, _midTitle: string, sid: string, sTitle: string, base: string, +): Promise { + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + const milestoneResearchPath = resolveMilestoneFile(base, mid, "RESEARCH"); + const milestoneResearchRel = relMilestoneFile(base, mid, "RESEARCH"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); + const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context"); + if (contextInline) inlined.push(contextInline); + const researchInline = await inlineFileOptional(milestoneResearchPath, milestoneResearchRel, "Milestone Research"); + if (researchInline) inlined.push(researchInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + inlined.push(inlineTemplate("research", "Research")); + + const depContent = await inlineDependencySummaries(mid, sid, base); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH"); + return loadPrompt("research-slice", { + milestoneId: mid, sliceId: sid, sliceTitle: sTitle, + slicePath: relSlicePath(base, mid, sid), + roadmapPath: roadmapRel, + contextPath: contextRel, + milestoneResearchPath: milestoneResearchRel, + outputPath: outputRelPath, + inlinedContext, + dependencySummaries: depContent, + ...buildSkillDiscoveryVars(), + }); +} + +export async function buildPlanSlicePrompt( + mid: string, _midTitle: string, sid: string, sTitle: string, base: string, +): Promise { + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH"); + const researchRel = relSliceFile(base, mid, sid, "RESEARCH"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); + const researchInline = await inlineFileOptional(researchPath, researchRel, "Slice Research"); + if (researchInline) inlined.push(researchInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + inlined.push(inlineTemplate("plan", "Slice Plan")); + inlined.push(inlineTemplate("task-plan", "Task Plan")); + + const depContent = await inlineDependencySummaries(mid, sid, base); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const outputRelPath = relSliceFile(base, mid, sid, "PLAN"); + return loadPrompt("plan-slice", { + milestoneId: mid, sliceId: sid, sliceTitle: sTitle, + slicePath: relSlicePath(base, mid, sid), + roadmapPath: roadmapRel, + researchPath: researchRel, + outputPath: outputRelPath, + inlinedContext, + dependencySummaries: depContent, + }); +} + +export async function buildExecuteTaskPrompt( + mid: string, sid: string, sTitle: string, + tid: string, tTitle: string, base: string, +): Promise { + + const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base); + const priorLines = priorSummaries.length > 0 + ? priorSummaries.map(p => `- \`${p}\``).join("\n") + : "- (no prior tasks)"; + + const taskPlanPath = resolveTaskFile(base, mid, sid, tid, "PLAN"); + const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null; + const taskPlanRelPath = relSlicePath(base, mid, sid) + `/tasks/${tid}-PLAN.md`; + const taskPlanInline = taskPlanContent + ? [ + "## Inlined Task Plan (authoritative local execution contract)", + `Source: \`${taskPlanRelPath}\``, + "", + taskPlanContent.trim(), + ].join("\n") + : [ + "## Inlined Task Plan (authoritative local execution contract)", + `Task plan not found at dispatch time. Read \`${taskPlanRelPath}\` before executing.`, + ].join("\n"); + + const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); + const slicePlanContent = slicePlanPath ? await loadFile(slicePlanPath) : null; + const slicePlanExcerpt = extractSliceExecutionExcerpt(slicePlanContent, relSliceFile(base, mid, sid, "PLAN")); + + // Check for continue file (new naming or legacy) + const continueFile = resolveSliceFile(base, mid, sid, "CONTINUE"); + const legacyContinueDir = resolveSlicePath(base, mid, sid); + const legacyContinuePath = legacyContinueDir ? join(legacyContinueDir, "continue.md") : null; + const continueContent = continueFile ? await loadFile(continueFile) : null; + const legacyContinueContent = !continueContent && legacyContinuePath ? await loadFile(legacyContinuePath) : null; + const continueRelPath = relSliceFile(base, mid, sid, "CONTINUE"); + const resumeSection = buildResumeSection( + continueContent, + legacyContinueContent, + continueRelPath, + legacyContinuePath ? `${relSlicePath(base, mid, sid)}/continue.md` : null, + ); + + const carryForwardSection = await buildCarryForwardSection(priorSummaries, base); + const inlinedTemplates = [ + inlineTemplate("task-summary", "Task Summary"), + inlineTemplate("decisions", "Decisions"), + ].join("\n\n---\n\n"); + + const taskSummaryPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`; + + return loadPrompt("execute-task", { + milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle, + planPath: relSliceFile(base, mid, sid, "PLAN"), + slicePath: relSlicePath(base, mid, sid), + taskPlanPath: taskPlanRelPath, + taskPlanInline, + slicePlanExcerpt, + carryForwardSection, + resumeSection, + priorTaskLines: priorLines, + taskSummaryPath, + inlinedTemplates, + }); +} + +export async function buildCompleteSlicePrompt( + mid: string, _midTitle: string, sid: string, sTitle: string, base: string, +): Promise { + + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); + const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); + inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Slice Plan")); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + + // Inline all task summaries for this slice + const tDir = resolveTasksDir(base, mid, sid); + if (tDir) { + const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort(); + for (const file of summaryFiles) { + const absPath = join(tDir, file); + const content = await loadFile(absPath); + const sRel = relSlicePath(base, mid, sid); + const relPath = `${sRel}/tasks/${file}`; + if (content) { + inlined.push(`### Task Summary: ${file.replace(/-SUMMARY\.md$/i, "")}\nSource: \`${relPath}\`\n\n${content.trim()}`); + } + } + } + inlined.push(inlineTemplate("slice-summary", "Slice Summary")); + inlined.push(inlineTemplate("uat", "UAT")); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const sliceRel = relSlicePath(base, mid, sid); + const sliceSummaryPath = `${sliceRel}/${sid}-SUMMARY.md`; + const sliceUatPath = `${sliceRel}/${sid}-UAT.md`; + + return loadPrompt("complete-slice", { + milestoneId: mid, sliceId: sid, sliceTitle: sTitle, + slicePath: sliceRel, + roadmapPath: roadmapRel, + inlinedContext, + sliceSummaryPath, + sliceUatPath, + }); +} + +export async function buildCompleteMilestonePrompt( + mid: string, midTitle: string, base: string, +): Promise { + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); + + // Inline all slice summaries (deduplicated by slice ID) + const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; + if (roadmapContent) { + const roadmap = parseRoadmap(roadmapContent); + const seenSlices = new Set(); + for (const slice of roadmap.slices) { + if (seenSlices.has(slice.id)) continue; + seenSlices.add(slice.id); + const summaryPath = resolveSliceFile(base, mid, slice.id, "SUMMARY"); + const summaryRel = relSliceFile(base, mid, slice.id, "SUMMARY"); + inlined.push(await inlineFile(summaryPath, summaryRel, `${slice.id} Summary`)); + } + } + + // Inline root GSD files + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + if (projectInline) inlined.push(projectInline); + // Inline milestone context file (milestone-level, not GSD root) + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context"); + if (contextInline) inlined.push(contextInline); + inlined.push(inlineTemplate("milestone-summary", "Milestone Summary")); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const milestoneSummaryPath = `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`; + + return loadPrompt("complete-milestone", { + milestoneId: mid, + milestoneTitle: midTitle, + roadmapPath: roadmapRel, + inlinedContext, + milestoneSummaryPath, + }); +} + +export async function buildReplanSlicePrompt( + mid: string, midTitle: string, sid: string, sTitle: string, base: string, +): Promise { + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); + const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); + inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Current Slice Plan")); + + // Find the blocker task summary — the completed task with blocker_discovered: true + let blockerTaskId = ""; + const tDir = resolveTasksDir(base, mid, sid); + if (tDir) { + const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort(); + for (const file of summaryFiles) { + const absPath = join(tDir, file); + const content = await loadFile(absPath); + if (!content) continue; + const summary = parseSummary(content); + const sRel = relSlicePath(base, mid, sid); + const relPath = `${sRel}/tasks/${file}`; + if (summary.frontmatter.blocker_discovered) { + blockerTaskId = summary.frontmatter.id || file.replace(/-SUMMARY\.md$/i, ""); + inlined.push(`### Blocker Task Summary: ${blockerTaskId}\nSource: \`${relPath}\`\n\n${content.trim()}`); + } + } + } + + // Inline decisions + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const replanPath = `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`; + + return loadPrompt("replan-slice", { + milestoneId: mid, + sliceId: sid, + sliceTitle: sTitle, + slicePath: relSlicePath(base, mid, sid), + planPath: slicePlanRel, + blockerTaskId, + inlinedContext, + replanPath, + }); +} + +export async function buildRunUatPrompt( + mid: string, sliceId: string, uatPath: string, uatContent: string, base: string, +): Promise { + const inlined: string[] = []; + inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`)); + + const summaryPath = resolveSliceFile(base, mid, sliceId, "SUMMARY"); + const summaryRel = relSliceFile(base, mid, sliceId, "SUMMARY"); + if (summaryPath) { + const summaryInline = await inlineFileOptional(summaryPath, summaryRel, `${sliceId} Summary`); + if (summaryInline) inlined.push(summaryInline); + } + + const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + if (projectInline) inlined.push(projectInline); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const uatResultPath = relSliceFile(base, mid, sliceId, "UAT-RESULT"); + const uatType = extractUatType(uatContent) ?? "human-experience"; + + return loadPrompt("run-uat", { + milestoneId: mid, + sliceId, + uatPath, + uatResultPath, + uatType, + inlinedContext, + }); +} + +export async function buildReassessRoadmapPrompt( + mid: string, midTitle: string, completedSliceId: string, base: string, +): Promise { + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const summaryPath = resolveSliceFile(base, mid, completedSliceId, "SUMMARY"); + const summaryRel = relSliceFile(base, mid, completedSliceId, "SUMMARY"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Current Roadmap")); + inlined.push(await inlineFile(summaryPath, summaryRel, `${completedSliceId} Summary`)); + const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + if (projectInline) inlined.push(projectInline); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const assessmentPath = relSliceFile(base, mid, completedSliceId, "ASSESSMENT"); + + return loadPrompt("reassess-roadmap", { + milestoneId: mid, + milestoneTitle: midTitle, + completedSliceId, + roadmapPath: roadmapRel, + completedSliceSummaryPath: summaryRel, + assessmentPath, + inlinedContext, + }); +} diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts new file mode 100644 index 000000000..6ac6c1dd5 --- /dev/null +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -0,0 +1,450 @@ +/** + * Auto-mode Recovery — artifact resolution, verification, blocker placeholders, + * skip artifacts, completed-unit persistence, merge state reconciliation, + * self-heal runtime records, and loop remediation steps. + * + * Pure functions that receive all needed state as parameters — no module-level + * globals or AutoContext dependency. + */ + +import type { ExtensionContext } from "@gsd/pi-coding-agent"; +import { + clearUnitRuntimeRecord, +} from "./unit-runtime.js"; +import { runGit } from "./git-service.js"; +import { + resolveMilestonePath, + resolveSlicePath, + resolveSliceFile, + resolveTasksDir, + relMilestoneFile, + relSliceFile, + relSlicePath, + relTaskFile, + buildMilestoneFileName, + buildSliceFileName, + buildTaskFileName, + resolveMilestoneFile, + clearPathCache, +} from "./paths.js"; +import { parseRoadmap } from "./files.js"; +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs"; +import { dirname, join } from "node:path"; + +// ─── Artifact Resolution & Verification ─────────────────────────────────────── + +/** + * Resolve the expected artifact for a unit to an absolute path. + */ +export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null { + const parts = unitId.split("/"); + const mid = parts[0]!; + const sid = parts[1]; + switch (unitType) { + case "research-milestone": { + const dir = resolveMilestonePath(base, mid); + return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null; + } + case "plan-milestone": { + const dir = resolveMilestonePath(base, mid); + return dir ? join(dir, buildMilestoneFileName(mid, "ROADMAP")) : null; + } + case "research-slice": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "RESEARCH")) : null; + } + case "plan-slice": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "PLAN")) : null; + } + case "reassess-roadmap": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "ASSESSMENT")) : null; + } + case "run-uat": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null; + } + case "execute-task": { + const tid = parts[2]; + const dir = resolveSlicePath(base, mid, sid!); + return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null; + } + case "complete-slice": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "SUMMARY")) : null; + } + case "complete-milestone": { + const dir = resolveMilestonePath(base, mid); + return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null; + } + default: + return null; + } +} + +/** + * Check whether the expected artifact(s) for a unit exist on disk. + * Returns true if all required artifacts exist, or if the unit type has no + * single verifiable artifact (e.g., replan-slice). + * + * complete-slice requires both SUMMARY and UAT files — verifying only + * the summary allowed the unit to be marked complete when the LLM + * skipped writing the UAT file (see #176). + */ +export function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean { + // Clear stale directory listing cache so artifact checks see fresh disk state (#431) + clearPathCache(); + + // Hook units have no standard artifact — always pass. Their lifecycle + // is managed by the hook engine, not the artifact verification system. + if (unitType.startsWith("hook/")) return true; + + + const absPath = resolveExpectedArtifactPath(unitType, unitId, base); + // Unit types with no verifiable artifact always pass (e.g. replan-slice). + // For all other types, null means the parent directory is missing on disk + // — treat as stale completion state so the key gets evicted (#313). + if (!absPath) return unitType === "replan-slice"; + if (!existsSync(absPath)) return false; + + // execute-task must also have its checkbox marked [x] in the slice plan + if (unitType === "execute-task") { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + const tid = parts[2]; + if (mid && sid && tid) { + const planAbs = resolveSliceFile(base, mid, sid, "PLAN"); + if (planAbs && existsSync(planAbs)) { + const planContent = readFileSync(planAbs, "utf-8"); + const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^- \\[[xX]\\] \\*\\*${escapedTid}:`, "m"); + if (!re.test(planContent)) return false; + } + } + } + + // complete-slice must also produce a UAT file AND mark the slice [x] in the roadmap. + // Without the roadmap check, a crash after writing SUMMARY+UAT but before updating + // the roadmap causes an infinite skip loop: the idempotency key says "done" but the + // state machine keeps returning the same complete-slice unit (roadmap still shows + // the slice incomplete), so dispatchNextUnit recurses forever. + if (unitType === "complete-slice") { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + if (mid && sid) { + const dir = resolveSlicePath(base, mid, sid); + if (dir) { + const uatPath = join(dir, buildSliceFileName(sid, "UAT")); + if (!existsSync(uatPath)) return false; + } + // Verify the roadmap has the slice marked [x]. If not, the completion + // record is stale — the unit must re-run to update the roadmap. + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + if (roadmapFile && existsSync(roadmapFile)) { + try { + const roadmapContent = readFileSync(roadmapFile, "utf-8"); + const roadmap = parseRoadmap(roadmapContent); + const slice = roadmap.slices.find(s => s.id === sid); + if (slice && !slice.done) return false; + } catch { /* corrupt roadmap — be lenient and treat as verified */ } + } + } + } + + return true; +} + +/** + * Write a placeholder artifact so the pipeline can advance past a stuck unit. + * Returns the relative path written, or null if the path couldn't be resolved. + */ +export function writeBlockerPlaceholder(unitType: string, unitId: string, base: string, reason: string): string | null { + const absPath = resolveExpectedArtifactPath(unitType, unitId, base); + if (!absPath) return null; + const dir = dirname(absPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const content = [ + `# BLOCKER — auto-mode recovery failed`, + ``, + `Unit \`${unitType}\` for \`${unitId}\` failed to produce this artifact after idle recovery exhausted all retries.`, + ``, + `**Reason**: ${reason}`, + ``, + `This placeholder was written by auto-mode so the pipeline can advance.`, + `Review and replace this file before relying on downstream artifacts.`, + ].join("\n"); + writeFileSync(absPath, content, "utf-8"); + return diagnoseExpectedArtifact(unitType, unitId, base); +} + +export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + switch (unitType) { + case "research-milestone": + return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`; + case "plan-milestone": + return `${relMilestoneFile(base, mid!, "ROADMAP")} (milestone roadmap)`; + case "research-slice": + return `${relSliceFile(base, mid!, sid!, "RESEARCH")} (slice research)`; + case "plan-slice": + return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`; + case "execute-task": { + const tid = parts[2]; + return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`; + } + case "complete-slice": + return `Slice ${sid} marked [x] in ${relMilestoneFile(base, mid!, "ROADMAP")} + summary + UAT written`; + case "replan-slice": + return `${relSliceFile(base, mid!, sid!, "REPLAN")} + updated ${relSliceFile(base, mid!, sid!, "PLAN")}`; + case "reassess-roadmap": + return `${relSliceFile(base, mid!, sid!, "ASSESSMENT")} (roadmap reassessment)`; + case "run-uat": + return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`; + case "complete-milestone": + return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`; + default: + return null; + } +} + +// ─── Skip / Blocker Artifact Generation ─────────────────────────────────────── + +/** + * Write skip artifacts for a stuck execute-task: a blocker task summary and + * the [x] checkbox in the slice plan. Returns true if artifacts were written. + */ +export function skipExecuteTask( + base: string, mid: string, sid: string, tid: string, + status: { summaryExists: boolean; taskChecked: boolean }, + reason: string, maxAttempts: number, +): boolean { + // Write a blocker task summary if missing. + if (!status.summaryExists) { + const tasksDir = resolveTasksDir(base, mid, sid); + const sDir = resolveSlicePath(base, mid, sid); + const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null); + if (!targetDir) return false; + if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); + const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY")); + const content = [ + `# BLOCKER — task skipped by auto-mode recovery`, + ``, + `Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) failed to complete after ${reason} recovery exhausted ${maxAttempts} attempts.`, + ``, + `This placeholder was written by auto-mode so the pipeline can advance.`, + `Review this task manually and replace this file with a real summary.`, + ].join("\n"); + writeFileSync(summaryPath, content, "utf-8"); + } + + // Mark [x] in the slice plan if not already checked. + if (!status.taskChecked) { + const planAbs = resolveSliceFile(base, mid, sid, "PLAN"); + if (planAbs && existsSync(planAbs)) { + const planContent = readFileSync(planAbs, "utf-8"); + const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^(- \\[) \\] (\\*\\*${escapedTid}:)`, "m"); + if (re.test(planContent)) { + writeFileSync(planAbs, planContent.replace(re, "$1x] $2"), "utf-8"); + } + } + } + + return true; +} + +// ─── Disk-backed completed-unit helpers ─────────────────────────────────────── + +/** Path to the persisted completed-unit keys file. */ +export function completedKeysPath(base: string): string { + return join(base, ".gsd", "completed-units.json"); +} + +/** Write a completed unit key to disk (read-modify-write append to set). */ +export function persistCompletedKey(base: string, key: string): void { + const file = completedKeysPath(base); + let keys: string[] = []; + try { + if (existsSync(file)) { + keys = JSON.parse(readFileSync(file, "utf-8")); + } + } catch { /* corrupt file — start fresh */ } + if (!keys.includes(key)) { + keys.push(key); + // Atomic write: tmp file + rename prevents partial writes on crash + const tmpFile = file + ".tmp"; + writeFileSync(tmpFile, JSON.stringify(keys), "utf-8"); + renameSync(tmpFile, file); + } +} + +/** Remove a stale completed unit key from disk. */ +export function removePersistedKey(base: string, key: string): void { + const file = completedKeysPath(base); + try { + if (existsSync(file)) { + let keys: string[] = JSON.parse(readFileSync(file, "utf-8")); + keys = keys.filter(k => k !== key); + writeFileSync(file, JSON.stringify(keys), "utf-8"); + } + } catch { /* non-fatal */ } +} + +/** Load all completed unit keys from disk into the in-memory set. */ +export function loadPersistedKeys(base: string, target: Set): void { + const file = completedKeysPath(base); + try { + if (existsSync(file)) { + const keys: string[] = JSON.parse(readFileSync(file, "utf-8")); + for (const k of keys) target.add(k); + } + } catch { /* non-fatal */ } +} + +// ─── Merge State Reconciliation ─────────────────────────────────────────────── + +/** + * Detect leftover merge state from a prior session and reconcile it. + * If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved. + * If resolved: finalize the commit. If still conflicted: abort and reset. + * + * Returns true if state was dirty and re-derivation is needed. + */ +export function reconcileMergeState(basePath: string, ctx: ExtensionContext): boolean { + const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD"); + const squashMsgPath = join(basePath, ".git", "SQUASH_MSG"); + const hasMergeHead = existsSync(mergeHeadPath); + const hasSquashMsg = existsSync(squashMsgPath); + if (!hasMergeHead && !hasSquashMsg) return false; + + const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true }); + if (!unmerged || !unmerged.trim()) { + // All conflicts resolved — finalize the merge/squash commit + try { + runGit(basePath, ["commit", "--no-edit"], { allowFailure: false }); + const mode = hasMergeHead ? "merge" : "squash commit"; + ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info"); + } catch { + // Commit may already exist; non-fatal + } + } else { + // Still conflicted — abort and reset + if (hasMergeHead) { + runGit(basePath, ["merge", "--abort"], { allowFailure: true }); + } else if (hasSquashMsg) { + try { unlinkSync(squashMsgPath); } catch { /* best-effort */ } + } + runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true }); + ctx.ui.notify( + "Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.", + "warning", + ); + } + return true; +} + +// ─── Self-Heal Runtime Records ──────────────────────────────────────────────── + +/** + * Self-heal: scan runtime records in .gsd/ and clear any where the expected + * artifact already exists on disk. This repairs incomplete closeouts from + * prior crashes — preventing spurious re-dispatch of already-completed units. + */ +export async function selfHealRuntimeRecords( + base: string, + ctx: ExtensionContext, + completedKeySet: Set, +): Promise { + try { + const { listUnitRuntimeRecords } = await import("./unit-runtime.js"); + const records = listUnitRuntimeRecords(base); + let healed = 0; + const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour + const now = Date.now(); + for (const record of records) { + const { unitType, unitId } = record; + const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base); + + // Case 1: Artifact exists — unit completed but closeout didn't finish + if (artifactPath && existsSync(artifactPath)) { + clearUnitRuntimeRecord(base, unitType, unitId); + // Also persist completion key if missing + const key = `${unitType}/${unitId}`; + if (!completedKeySet.has(key)) { + persistCompletedKey(base, key); + completedKeySet.add(key); + } + healed++; + continue; + } + + // Case 2: No artifact but record is stale (dispatched > 1h ago, process crashed) + const age = now - (record.startedAt ?? 0); + if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) { + clearUnitRuntimeRecord(base, unitType, unitId); + healed++; + continue; + } + } + if (healed > 0) { + ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s).`, "info"); + } + } catch { + // Non-fatal — self-heal should never block auto-mode start + } +} + +// ─── Loop Remediation ───────────────────────────────────────────────────────── + +/** + * Build concrete, manual remediation steps for a loop-detected unit failure. + * These are shown when automatic reconciliation is not possible. + */ +export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + const tid = parts[2]; + switch (unitType) { + case "execute-task": { + if (!mid || !sid || !tid) break; + const planRel = relSliceFile(base, mid, sid, "PLAN"); + const summaryRel = relTaskFile(base, mid, sid, tid, "SUMMARY"); + return [ + ` 1. Write ${summaryRel} (even a partial summary is sufficient to unblock the pipeline)`, + ` 2. Mark ${tid} [x] in ${planRel}: change "- [ ] **${tid}:" → "- [x] **${tid}:"`, + ` 3. Run \`gsd doctor\` to reconcile .gsd/ state`, + ` 4. Resume auto-mode — it will pick up from the next task`, + ].join("\n"); + } + case "plan-slice": + case "research-slice": { + if (!mid || !sid) break; + const artifactRel = unitType === "plan-slice" + ? relSliceFile(base, mid, sid, "PLAN") + : relSliceFile(base, mid, sid, "RESEARCH"); + return [ + ` 1. Write ${artifactRel} manually (or with the LLM in interactive mode)`, + ` 2. Run \`gsd doctor\` to reconcile .gsd/ state`, + ` 3. Resume auto-mode`, + ].join("\n"); + } + case "complete-slice": { + if (!mid || !sid) break; + return [ + ` 1. Write the slice summary and UAT file for ${sid} in ${relSlicePath(base, mid, sid)}`, + ` 2. Mark ${sid} [x] in ${relMilestoneFile(base, mid, "ROADMAP")}`, + ` 3. Run \`gsd doctor\` to reconcile .gsd/ state`, + ` 4. Resume auto-mode`, + ].join("\n"); + } + default: + break; + } + return null; +} diff --git a/src/resources/extensions/gsd/auto-supervisor.ts b/src/resources/extensions/gsd/auto-supervisor.ts new file mode 100644 index 000000000..742d30b91 --- /dev/null +++ b/src/resources/extensions/gsd/auto-supervisor.ts @@ -0,0 +1,59 @@ +/** + * Auto-mode Supervisor — SIGTERM handling and working-tree activity detection. + * + * Pure functions — no module-level globals or AutoContext dependency. + */ + +import { clearLock } from "./crash-recovery.js"; +import { execSync } from "node:child_process"; + +// ─── SIGTERM Handling ───────────────────────────────────────────────────────── + +/** + * Register a SIGTERM handler that clears the lock file and exits cleanly. + * Captures the active base path at registration time so the handler + * always references the correct path even if the module variable changes. + * Removes any previously registered handler before installing the new one. + * + * Returns the new handler so the caller can store and deregister it later. + */ +export function registerSigtermHandler( + currentBasePath: string, + previousHandler: (() => void) | null, +): () => void { + if (previousHandler) process.off("SIGTERM", previousHandler); + const handler = () => { + clearLock(currentBasePath); + process.exit(0); + }; + process.on("SIGTERM", handler); + return handler; +} + +/** Deregister the SIGTERM handler (called on stop/pause). */ +export function deregisterSigtermHandler(handler: (() => void) | null): void { + if (handler) { + process.off("SIGTERM", handler); + } +} + +// ─── Working Tree Activity Detection ────────────────────────────────────────── + +/** + * Detect whether the agent is producing work on disk by checking git for + * any working-tree changes (staged, unstaged, or untracked). Returns true + * if there are uncommitted changes — meaning the agent is actively working, + * even though it hasn't signaled progress through runtime records. + */ +export function detectWorkingTreeActivity(cwd: string): boolean { + try { + const out = execSync("git status --porcelain", { + cwd, + stdio: ["pipe", "pipe", "pipe"], + timeout: 5000, + }); + return out.toString().trim().length > 0; + } catch { + return false; + } +} diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index d06d25449..df0efb87c 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -6,7 +6,7 @@ * manages create, enter, detect, and teardown for auto-mode worktrees. */ -import { existsSync, readFileSync, realpathSync, utimesSync } from "node:fs"; +import { existsSync, cpSync, readFileSync, realpathSync, utimesSync } from "node:fs"; import { join, resolve } from "node:path"; import { execSync, execFileSync } from "node:child_process"; import { @@ -90,6 +90,14 @@ export function autoWorktreeBranch(milestoneId: string): string { export function createAutoWorktree(basePath: string, milestoneId: string): string { const branch = autoWorktreeBranch(milestoneId); const info = createWorktree(basePath, milestoneId, { branch }); + + // Copy .gsd/ planning artifacts from the source repo into the new worktree. + // Worktrees are fresh git checkouts — untracked files don't carry over. + // Planning artifacts may be untracked if the project's .gitignore had a + // blanket .gsd/ rule (pre-v2.14.0). Without this copy, auto-mode loops + // on plan-slice because the plan file doesn't exist in the worktree. + copyPlanningArtifacts(basePath, info.path); + const previousCwd = process.cwd(); try { @@ -107,6 +115,36 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin return info.path; } +/** + * Copy .gsd/ planning artifacts from source repo to a new worktree. + * Copies milestones/, DECISIONS.md, REQUIREMENTS.md, PROJECT.md, QUEUE.md. + * Skips runtime files (auto.lock, metrics.json, etc.) and the worktrees/ dir. + * Best-effort — failures are non-fatal since auto-mode can recreate artifacts. + */ +function copyPlanningArtifacts(srcBase: string, wtPath: string): void { + const srcGsd = join(srcBase, ".gsd"); + const dstGsd = join(wtPath, ".gsd"); + if (!existsSync(srcGsd)) return; + + // Copy milestones/ directory (planning files, roadmaps, plans, research) + const srcMilestones = join(srcGsd, "milestones"); + if (existsSync(srcMilestones)) { + try { + cpSync(srcMilestones, join(dstGsd, "milestones"), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } + + // Copy top-level planning files + for (const file of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "QUEUE.md"]) { + const src = join(srcGsd, file); + if (existsSync(src)) { + try { + cpSync(src, join(dstGsd, file), { force: true }); + } catch { /* non-fatal */ } + } + } +} + /** * Teardown an auto-worktree: chdir back to original base, then remove * the worktree and its branch. diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index faeacdc81..962e7a9ab 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -17,17 +17,15 @@ import type { } from "@gsd/pi-coding-agent"; import { deriveState, invalidateStateCache } from "./state.js"; -import type { 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"; +import type { BudgetEnforcementMode, GSDState } from "./types.js"; +import { loadFile, parseRoadmap, getManifestStatus, clearParseCache } from "./files.js"; +export { inlinePriorMilestoneSummary } from "./files.js"; import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; -import { loadPrompt, inlineTemplate } from "./prompt-loader.js"; import { gsdRoot, resolveMilestoneFile, resolveSliceFile, resolveSlicePath, - resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFiles, resolveTaskFile, - relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relMilestonePath, - milestonesDir, resolveGsdRootFile, relGsdRootFile, + resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFile, + relMilestoneFile, relSliceFile, relSlicePath, relMilestonePath, + milestonesDir, buildMilestoneFileName, buildSliceFileName, buildTaskFileName, clearPathCache, } from "./paths.js"; @@ -41,7 +39,8 @@ import { readUnitRuntimeRecord, writeUnitRuntimeRecord, } from "./unit-runtime.js"; -import { resolveAutoSupervisorConfig, resolveModelForUnit, resolveModelWithFallbacksForUnit, resolveSkillDiscoveryMode, loadEffectiveGSDPreferences } from "./preferences.js"; +import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences } from "./preferences.js"; +import { sendDesktopNotification } from "./notifications.js"; import type { GSDPreferences } from "./preferences.js"; import { checkPostUnitHooks, @@ -53,7 +52,6 @@ import { persistHookState, restoreHookState, clearPersistedHookState, - formatHookStatus, } from "./post-unit-hooks.js"; import { validatePlanBoundary, @@ -68,9 +66,9 @@ import { initMetrics, resetMetrics, snapshotUnitMetrics, getLedger, getProjectTotals, formatCost, formatTokenCount, } from "./metrics.js"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { sep as pathSep } from "node:path"; -import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, renameSync, statSync } from "node:fs"; +import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs"; import { execSync, execFileSync } from "node:child_process"; import { autoCommitCurrentBranch, @@ -94,58 +92,53 @@ import { getAutoWorktreeOriginalBase, mergeMilestoneToMain, } from "./auto-worktree.js"; -import type { GitPreferences } from "./git-service.js"; -import { truncateToWidth, visibleWidth } from "@gsd/pi-tui"; -import { makeUI, GLYPH, INDENT } from "../shared/ui.js"; import { showNextAction } from "../shared/next-action-ui.js"; - -// ─── Disk-backed completed-unit helpers ─────────────────────────────────────── - -/** Path to the persisted completed-unit keys file. */ -function completedKeysPath(base: string): string { - return join(base, ".gsd", "completed-units.json"); -} - -/** Write a completed unit key to disk (read-modify-write append to set). */ -function persistCompletedKey(base: string, key: string): void { - const file = completedKeysPath(base); - let keys: string[] = []; - try { - if (existsSync(file)) { - keys = JSON.parse(readFileSync(file, "utf-8")); - } - } catch { /* corrupt file — start fresh */ } - if (!keys.includes(key)) { - keys.push(key); - // Atomic write: tmp file + rename prevents partial writes on crash - const tmpFile = file + ".tmp"; - writeFileSync(tmpFile, JSON.stringify(keys), "utf-8"); - renameSync(tmpFile, file); - } -} - -/** Remove a stale completed unit key from disk. */ -function removePersistedKey(base: string, key: string): void { - const file = completedKeysPath(base); - try { - if (existsSync(file)) { - let keys: string[] = JSON.parse(readFileSync(file, "utf-8")); - keys = keys.filter(k => k !== key); - writeFileSync(file, JSON.stringify(keys), "utf-8"); - } - } catch { /* non-fatal */ } -} - -/** Load all completed unit keys from disk into the in-memory set. */ -function loadPersistedKeys(base: string, target: Set): void { - const file = completedKeysPath(base); - try { - if (existsSync(file)) { - const keys: string[] = JSON.parse(readFileSync(file, "utf-8")); - for (const k of keys) target.add(k); - } - } catch { /* non-fatal */ } -} +import { + resolveExpectedArtifactPath, + verifyExpectedArtifact, + writeBlockerPlaceholder, + diagnoseExpectedArtifact, + skipExecuteTask, + completedKeysPath, + persistCompletedKey, + removePersistedKey, + loadPersistedKeys, + selfHealRuntimeRecords, + buildLoopRemediationSteps, + reconcileMergeState, +} from "./auto-recovery.js"; +import { + buildResearchMilestonePrompt, + buildPlanMilestonePrompt, + buildResearchSlicePrompt, + buildPlanSlicePrompt, + buildExecuteTaskPrompt, + buildCompleteSlicePrompt, + buildCompleteMilestonePrompt, + buildReplanSlicePrompt, + buildRunUatPrompt, + buildReassessRoadmapPrompt, + checkNeedsReassessment, + checkNeedsRunUat, +} from "./auto-prompts.js"; +import { + type AutoDashboardData, + updateProgressWidget as _updateProgressWidget, + updateSliceProgressCache, + clearSliceProgressCache, + describeNextUnit as _describeNextUnit, + unitVerb, + unitPhaseLabel, + formatAutoElapsed as _formatAutoElapsed, + formatWidgetTokens, + hideFooter, + type WidgetStateAccessors, +} from "./auto-dashboard.js"; +import { + registerSigtermHandler as _registerSigtermHandler, + deregisterSigtermHandler as _deregisterSigtermHandler, + detectWorkingTreeActivity, +} from "./auto-supervisor.js"; // ─── State ──────────────────────────────────────────────────────────────────── @@ -186,6 +179,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,63 +201,43 @@ 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; -/** - * Register a SIGTERM handler that clears the lock file and exits cleanly. - * Captures the active base path at registration time so the handler - * always references the correct path even if the module variable changes. - * Removes any previously registered handler before installing the new one. - */ +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"; +} + +/** Wrapper: register SIGTERM handler and store reference. */ function registerSigtermHandler(currentBasePath: string): void { - if (_sigtermHandler) process.off("SIGTERM", _sigtermHandler); - _sigtermHandler = () => { - clearLock(currentBasePath); - process.exit(0); - }; - process.on("SIGTERM", _sigtermHandler); + _sigtermHandler = _registerSigtermHandler(currentBasePath, _sigtermHandler); } -/** Deregister the SIGTERM handler (called on stop/pause). */ +/** Wrapper: deregister SIGTERM handler and clear reference. */ function deregisterSigtermHandler(): void { - if (_sigtermHandler) { - process.off("SIGTERM", _sigtermHandler); - _sigtermHandler = null; - } + _deregisterSigtermHandler(_sigtermHandler); + _sigtermHandler = null; } -/** Format token counts for compact display */ -function formatWidgetTokens(count: number): string { - if (count < 1000) return count.toString(); - if (count < 10000) return `${(count / 1000).toFixed(1)}k`; - if (count < 1000000) return `${Math.round(count / 1000)}k`; - if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; - return `${Math.round(count / 1000000)}M`; -} - -/** - * Footer factory that renders zero lines — hides the built-in footer entirely. - * All footer info (pwd, branch, tokens, cost, model) is shown inside the - * progress widget instead, so there's no gap or redundancy. - */ -const hideFooter = () => ({ - render(_width: number): string[] { return []; }, - invalidate() {}, - dispose() {}, -}); - -/** Dashboard data for the overlay */ -export interface AutoDashboardData { - active: boolean; - paused: boolean; - stepMode: boolean; - startTime: number; - elapsed: number; - currentUnit: { type: string; id: string; startedAt: number } | null; - completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[]; - basePath: string; - /** Running cost and token totals from metrics ledger */ - totalCost: number; - totalTokens: number; -} +export { type AutoDashboardData } from "./auto-dashboard.js"; export function getAutoDashboardData(): AutoDashboardData { const ledger = getLedger(); @@ -335,10 +309,12 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void // Auto-mode is active but no unit was dispatched — the state machine stalled. // Re-derive state and attempt a fresh dispatch. - ctx.ui.notify( - "Dispatch gap detected — no unit dispatched after previous unit completed. Re-evaluating state.", - "warning", - ); + if (verbose) { + ctx.ui.notify( + "Dispatch gap detected — re-evaluating state.", + "info", + ); + } try { await dispatchNextUnit(ctx, pi); @@ -408,11 +384,12 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi stepMode = false; unitDispatchCount.clear(); unitRecoveryCount.clear(); + lastBudgetAlertLevel = 0; unitLifetimeDispatches.clear(); currentUnit = null; currentMilestoneId = null; originalBasePath = ""; - cachedSliceProgress = null; + clearSliceProgressCache(); pendingCrashRecovery = null; _handlingAgentEnd = false; ctx?.ui.setStatus("gsd-auto", undefined); @@ -458,50 +435,6 @@ export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Pro ); } -/** - * Self-heal: scan runtime records in .gsd/ and clear any where the expected - * artifact already exists on disk. This repairs incomplete closeouts from - * prior crashes — preventing spurious re-dispatch of already-completed units. - */ -async function selfHealRuntimeRecords(base: string, ctx: ExtensionContext): Promise { - try { - const { listUnitRuntimeRecords } = await import("./unit-runtime.js"); - const records = listUnitRuntimeRecords(base); - let healed = 0; - const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour - const now = Date.now(); - for (const record of records) { - const { unitType, unitId } = record; - const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base); - - // Case 1: Artifact exists — unit completed but closeout didn't finish - if (artifactPath && existsSync(artifactPath)) { - clearUnitRuntimeRecord(base, unitType, unitId); - // Also persist completion key if missing - const key = `${unitType}/${unitId}`; - if (!completedKeySet.has(key)) { - persistCompletedKey(base, key); - completedKeySet.add(key); - } - healed++; - continue; - } - - // Case 2: No artifact but record is stale (dispatched > 1h ago, process crashed) - const age = now - (record.startedAt ?? 0); - if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) { - clearUnitRuntimeRecord(base, unitType, unitId); - healed++; - continue; - } - } - if (healed > 0) { - ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s).`, "info"); - } - } catch { - // Non-fatal — self-heal should never block auto-mode start - } -} export async function startAuto( ctx: ExtensionCommandContext, @@ -571,7 +504,7 @@ export async function startAuto( } } catch { /* non-fatal */ } // Self-heal: clear stale runtime records where artifacts already exist - await selfHealRuntimeRecords(base, ctx); + await selfHealRuntimeRecords(base, ctx, completedKeySet); invalidateStateCache(); clearParseCache(); clearPathCache(); @@ -668,6 +601,7 @@ export async function startAuto( basePath = base; unitDispatchCount.clear(); unitRecoveryCount.clear(); + lastBudgetAlertLevel = 0; unitLifetimeDispatches.clear(); completedKeySet.clear(); loadPersistedKeys(base, completedKeySet); @@ -776,7 +710,7 @@ export async function startAuto( } // Self-heal: clear stale runtime records where artifacts already exist - await selfHealRuntimeRecords(base, ctx); + await selfHealRuntimeRecords(base, ctx, completedKeySet); // Self-heal: remove stale .git/index.lock from prior crash. // A stale lock file blocks all git operations (commit, merge, checkout). @@ -1091,7 +1025,7 @@ async function showStepWizard( } // Peek at what's next by examining state - const nextDesc = describeNextUnit(state); + const nextDesc = _describeNextUnit(state); const choice = await showNextAction(cmdCtx, { title: `GSD — ${justFinished} complete`, @@ -1138,356 +1072,27 @@ async function showStepWizard( } } -/** - * Describe what the next unit will be, based on current state. - */ -export 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 "needs-discussion": - return { label: "Discuss milestone draft", description: "Milestone has a draft context — needs discussion before planning." }; - 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 { - if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`; - switch (unitType) { - case "research-milestone": - case "research-slice": return "researching"; - case "plan-milestone": - case "plan-slice": return "planning"; - case "execute-task": return "executing"; - case "complete-slice": return "completing"; - case "replan-slice": return "replanning"; - case "reassess-roadmap": return "reassessing"; - case "run-uat": return "running UAT"; - default: return unitType; - } -} - -function unitPhaseLabel(unitType: string): string { - if (unitType.startsWith("hook/")) return "HOOK"; - switch (unitType) { - case "research-milestone": return "RESEARCH"; - case "research-slice": return "RESEARCH"; - case "plan-milestone": return "PLAN"; - case "plan-slice": return "PLAN"; - case "execute-task": return "EXECUTE"; - case "complete-slice": return "COMPLETE"; - case "replan-slice": return "REPLAN"; - case "reassess-roadmap": return "REASSESS"; - case "run-uat": return "UAT"; - default: return unitType.toUpperCase(); - } -} - -function peekNext(unitType: string, state: GSDState): string { - // Show active hook info in progress display - const activeHookState = getActiveHook(); - if (activeHookState) { - return `hook: ${activeHookState.hookName} (cycle ${activeHookState.cycle})`; - } - - const sid = state.activeSlice?.id ?? ""; - if (unitType.startsWith("hook/")) return `continue ${sid}`; - switch (unitType) { - case "research-milestone": return "plan milestone roadmap"; - case "plan-milestone": return "plan or execute first slice"; - case "research-slice": return `plan ${sid}`; - case "plan-slice": return "execute first task"; - case "execute-task": return `continue ${sid}`; - case "complete-slice": return "reassess roadmap"; - case "replan-slice": return `re-execute ${sid}`; - case "reassess-roadmap": return "advance to next slice"; - case "run-uat": return "reassess roadmap"; - default: return ""; - } -} - - - -/** Right-align helper: build a line with left content and right content. */ -function rightAlign(left: string, right: string, width: number): string { - const leftVis = visibleWidth(left); - const rightVis = visibleWidth(right); - const gap = Math.max(1, width - leftVis - rightVis); - return truncateToWidth(left + " ".repeat(gap) + right, width); -} +// describeNextUnit is imported from auto-dashboard.ts and re-exported +export { describeNextUnit } from "./auto-dashboard.js"; +/** Thin wrapper: delegates to auto-dashboard.ts, passing state accessors. */ function updateProgressWidget( ctx: ExtensionContext, unitType: string, unitId: string, state: GSDState, ): void { - if (!ctx.hasUI) return; - - const verb = unitVerb(unitType); - const phaseLabel = unitPhaseLabel(unitType); - const mid = state.activeMilestone; - const slice = state.activeSlice; - const task = state.activeTask; - const next = peekNext(unitType, state); - - // Cache git branch at widget creation time (not per render) - let cachedBranch: string | null = null; - try { cachedBranch = getCurrentBranch(basePath); } catch { /* not in git repo */ } - - // Cache pwd with ~ substitution - let widgetPwd = process.cwd(); - const widgetHome = process.env.HOME || process.env.USERPROFILE; - if (widgetHome && widgetPwd.startsWith(widgetHome)) { - widgetPwd = `~${widgetPwd.slice(widgetHome.length)}`; - } - if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`; - - ctx.ui.setWidget("gsd-progress", (tui, theme) => { - let pulseBright = true; - let cachedLines: string[] | undefined; - let cachedWidth: number | undefined; - - const pulseTimer = setInterval(() => { - pulseBright = !pulseBright; - cachedLines = undefined; - tui.requestRender(); - }, 800); - - return { - render(width: number): string[] { - if (cachedLines && cachedWidth === width) return cachedLines; - - const ui = makeUI(theme, width); - const lines: string[] = []; - const pad = INDENT.base; - - // ── Line 1: Top bar ─────────────────────────────────────────────── - lines.push(...ui.bar()); - - const dot = pulseBright - ? theme.fg("accent", GLYPH.statusActive) - : theme.fg("dim", GLYPH.statusPending); - const elapsed = formatAutoElapsed(); - 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)); - - lines.push(""); - - if (mid) { - lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}`, width)); - } - - if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") { - lines.push(truncateToWidth( - `${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`, - width, - )); - } - - lines.push(""); - - const target = task ? `${task.id}: ${task.title}` : unitId; - const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; - const phaseBadge = theme.fg("dim", phaseLabel); - lines.push(rightAlign(actionLeft, phaseBadge, width)); - lines.push(""); - - if (mid) { - const roadmapSlices = getRoadmapSlicesSync(); - if (roadmapSlices) { - const { done, total, activeSliceTasks } = roadmapSlices; - const barWidth = Math.max(8, Math.min(24, Math.floor(width * 0.3))); - const pct = total > 0 ? done / total : 0; - const filled = Math.round(pct * barWidth); - const bar = theme.fg("success", "█".repeat(filled)) - + theme.fg("dim", "░".repeat(barWidth - filled)); - - let meta = theme.fg("dim", `${done}/${total} slices`); - - if (activeSliceTasks && activeSliceTasks.total > 0) { - meta += theme.fg("dim", ` · task ${activeSliceTasks.done + 1}/${activeSliceTasks.total}`); - } - - lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width)); - } - } - - lines.push(""); - - if (next) { - lines.push(truncateToWidth( - `${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${next}`)}`, - width, - )); - } - - // ── Footer info (pwd, tokens, cost, context, model) ────────────── - lines.push(""); - lines.push(truncateToWidth(theme.fg("dim", `${pad}${widgetPwd}`), width, theme.fg("dim", "…"))); - - // Token stats from current unit session + cumulative cost from metrics - { - let totalInput = 0, totalOutput = 0; - let totalCacheRead = 0, totalCacheWrite = 0; - if (cmdCtx) { - for (const entry of cmdCtx.sessionManager.getEntries()) { - if (entry.type === "message" && (entry as any).message?.role === "assistant") { - const u = (entry as any).message.usage; - if (u) { - totalInput += u.input || 0; - totalOutput += u.output || 0; - totalCacheRead += u.cacheRead || 0; - totalCacheWrite += u.cacheWrite || 0; - } - } - } - } - const mLedger = getLedger(); - const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null; - const cumulativeCost = autoTotals?.cost ?? 0; - - const cxUsage = cmdCtx?.getContextUsage?.(); - const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0; - const cxPctVal = cxUsage?.percent ?? 0; - const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?"; - - const sp: string[] = []; - if (totalInput) sp.push(`↑${formatWidgetTokens(totalInput)}`); - if (totalOutput) sp.push(`↓${formatWidgetTokens(totalOutput)}`); - if (totalCacheRead) sp.push(`R${formatWidgetTokens(totalCacheRead)}`); - if (totalCacheWrite) sp.push(`W${formatWidgetTokens(totalCacheWrite)}`); - if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`); - - const cxDisplay = cxPct === "?" - ? `?/${formatWidgetTokens(cxWindow)}` - : `${cxPct}%/${formatWidgetTokens(cxWindow)}`; - if (cxPctVal > 90) { - sp.push(theme.fg("error", cxDisplay)); - } else if (cxPctVal > 70) { - sp.push(theme.fg("warning", cxDisplay)); - } else { - sp.push(cxDisplay); - } - - const sLeft = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p)) - .join(theme.fg("dim", " ")); - - const modelId = cmdCtx?.model?.id ?? ""; - const modelProvider = cmdCtx?.model?.provider ?? ""; - const modelPhase = phaseLabel ? theme.fg("dim", `[${phaseLabel}] `) : ""; - const modelDisplay = modelProvider && modelId - ? `${modelProvider}/${modelId}` - : modelId; - const sRight = modelDisplay - ? `${modelPhase}${theme.fg("dim", modelDisplay)}` - : ""; - lines.push(rightAlign(`${pad}${sLeft}`, sRight, width)); - } - - const hintParts: string[] = []; - hintParts.push("esc pause"); - hintParts.push(process.platform === "darwin" ? "⌃⌥G dashboard" : "Ctrl+Alt+G dashboard"); - lines.push(...ui.hints(hintParts)); - - lines.push(...ui.bar()); - - cachedLines = lines; - cachedWidth = width; - return lines; - }, - invalidate() { - cachedLines = undefined; - cachedWidth = undefined; - }, - dispose() { - clearInterval(pulseTimer); - }, - }; - }); + _updateProgressWidget(ctx, unitType, unitId, state, widgetStateAccessors); } -/** Format elapsed time since auto-mode started */ -function formatAutoElapsed(): string { - if (!autoStartTime) return ""; - const ms = Date.now() - autoStartTime; - const s = Math.floor(ms / 1000); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rs = s % 60; - if (m < 60) return `${m}m${rs > 0 ? ` ${rs}s` : ""}`; - const h = Math.floor(m / 60); - const rm = m % 60; - return `${h}h ${rm}m`; -} - -/** Cached slice progress for the widget — avoid async in render */ -let cachedSliceProgress: { - done: number; - total: number; - milestoneId: string; - /** Real task progress for the active slice, if its plan file exists */ - activeSliceTasks: { done: number; total: number } | null; -} | null = null; - -function updateSliceProgressCache(base: string, mid: string, activeSid?: string): void { - try { - const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); - if (!roadmapFile) return; - const content = readFileSync(roadmapFile, "utf-8"); - const roadmap = parseRoadmap(content); - - let activeSliceTasks: { done: number; total: number } | null = null; - if (activeSid) { - try { - const planFile = resolveSliceFile(base, mid, activeSid, "PLAN"); - if (planFile && existsSync(planFile)) { - const planContent = readFileSync(planFile, "utf-8"); - const plan = parsePlan(planContent); - activeSliceTasks = { - done: plan.tasks.filter(t => t.done).length, - total: plan.tasks.length, - }; - } - } catch { - // Non-fatal — just omit task count - } - } - - cachedSliceProgress = { - done: roadmap.slices.filter(s => s.done).length, - total: roadmap.slices.length, - milestoneId: mid, - activeSliceTasks, - }; - } catch { - // Non-fatal — widget just won't show progress bar - } -} - -function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null } | null { - return cachedSliceProgress; -} +/** State accessors for the widget — closures over module globals. */ +const widgetStateAccessors: WidgetStateAccessors = { + getAutoStartTime: () => autoStartTime, + isStepMode: () => stepMode, + getCmdCtx: () => cmdCtx, + getBasePath: () => basePath, + isVerbose: () => verbose, +}; // ─── Core Loop ──────────────────────────────────────────────────────────────── @@ -1507,7 +1112,7 @@ async function dispatchNextUnit( ): Promise { if (!active || !cmdCtx) { if (active && !cmdCtx) { - ctx.ui.notify("Auto-mode dispatch failed: no command context. Run /gsd auto to restart.", "error"); + ctx.ui.notify("Auto-mode session expired. Run /gsd auto to restart.", "info"); } return; } @@ -1518,7 +1123,7 @@ async function dispatchNextUnit( return; // Another dispatch is in progress — bail silently } _dispatching = true; - + try { // Recursion depth guard: when many units are skipped in sequence (e.g., after // crash recovery with 10+ completed units), recursive dispatchNextUnit calls // can freeze the TUI or overflow the stack. Yield generously after MAX_SKIP_DEPTH. @@ -1544,6 +1149,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(); @@ -1563,6 +1169,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; } @@ -1576,44 +1183,13 @@ async function dispatchNextUnit( } // ── Mid-merge safety check: detect leftover merge state from a prior session ── - // If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved. - // If resolved: finalize the commit. If still conflicted: abort and reset. - { - const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD"); - const squashMsgPath = join(basePath, ".git", "SQUASH_MSG"); - const hasMergeHead = existsSync(mergeHeadPath); - const hasSquashMsg = existsSync(squashMsgPath); - if (hasMergeHead || hasSquashMsg) { - const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true }); - if (!unmerged || !unmerged.trim()) { - // All conflicts resolved — finalize the merge/squash commit - try { - runGit(basePath, ["commit", "--no-edit"], { allowFailure: false }); - const mode = hasMergeHead ? "merge" : "squash commit"; - ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info"); - } catch { - // Commit may already exist; non-fatal - } - } else { - // Still conflicted — abort and reset - if (hasMergeHead) { - runGit(basePath, ["merge", "--abort"], { allowFailure: true }); - } else if (hasSquashMsg) { - try { unlinkSync(squashMsgPath); } catch { /* best-effort */ } - } - runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true }); - ctx.ui.notify( - "Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.", - "warning", - ); - } - invalidateStateCache(); - clearParseCache(); - clearPathCache(); - state = await deriveState(basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; - } + if (reconcileMergeState(basePath, ctx)) { + invalidateStateCache(); + clearParseCache(); + clearPathCache(); + state = await deriveState(basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; } // After merge guard removal (branchless architecture), mid/midTitle could be undefined @@ -1644,7 +1220,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 { @@ -1664,7 +1239,7 @@ async function dispatchNextUnit( ); } } - + sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone"); await stopAuto(ctx, pi); return; } @@ -1676,7 +1251,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; } @@ -1684,16 +1261,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; } @@ -1861,7 +1480,7 @@ async function dispatchNextUnit( saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); } await stopAuto(ctx, pi); - ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning"); + ctx.ui.notify(`Unhandled phase "${state.phase}" — run /gsd doctor to diagnose.`, "info"); return; } } @@ -2056,6 +1675,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", @@ -2181,7 +1801,7 @@ async function dispatchNextUnit( const result = await cmdCtx!.newSession(); if (result.cancelled) { await stopAuto(ctx, pi); - ctx.ui.notify("New session cancelled — auto-mode stopped.", "warning"); + ctx.ui.notify("Auto-mode stopped.", "info"); return; } @@ -2287,7 +1907,7 @@ async function dispatchNextUnit( } } if (!model) { - ctx.ui.notify(`Model ${modelId} not found in available models, trying fallback.`, "warning"); + if (verbose) ctx.ui.notify(`Model ${modelId} not found, trying fallback.`, "info"); continue; } @@ -2303,25 +1923,14 @@ async function dispatchNextUnit( } else { const nextModel = modelsToTry[modelsToTry.indexOf(modelId) + 1]; if (nextModel) { - ctx.ui.notify( - `Failed to set model ${modelId}, trying fallback ${nextModel}...`, - "warning", - ); + if (verbose) ctx.ui.notify(`Failed to set model ${modelId}, trying ${nextModel}...`, "info"); } else { - ctx.ui.notify( - `Failed to set model ${modelId} and all fallbacks exhausted. Using default model.`, - "warning", - ); + ctx.ui.notify(`All preferred models unavailable for ${unitType}. Using default.`, "warning"); } } } - if (!modelSet) { - ctx.ui.notify( - `Could not set any preferred model for ${unitType}. Continuing with default.`, - "warning", - ); - } + // modelSet=false is already handled by the "all fallbacks exhausted" warning above } // Start progress-aware supervision: a soft warning, an idle watchdog, and @@ -2434,764 +2043,9 @@ async function dispatchNextUnit( ); await pauseAuto(ctx, pi); } -} - -// ─── Skill Discovery ────────────────────────────────────────────────────────── - -/** - * Build the skill discovery template variables for research prompts. - * Returns { skillDiscoveryMode, skillDiscoveryInstructions } for template substitution. - */ -function buildSkillDiscoveryVars(): { skillDiscoveryMode: string; skillDiscoveryInstructions: string } { - const mode = resolveSkillDiscoveryMode(); - - if (mode === "off") { - return { - skillDiscoveryMode: "off", - skillDiscoveryInstructions: " Skill discovery is disabled. Skip this step.", - }; + } finally { + _dispatching = false; } - - const autoInstall = mode === "auto"; - const instructions = ` - Identify the key technologies, frameworks, and services this work depends on (e.g. Stripe, Clerk, Supabase, JUCE, SwiftUI). - For each, check if a professional agent skill already exists: - - First check \`\` in your system prompt — a skill may already be installed. - - For technologies without an installed skill, run: \`npx skills find ""\` - - Only consider skills that are **directly relevant** to core technologies — not tangentially related. - - Evaluate results by install count and relevance to the actual work.${autoInstall - ? ` - - Install relevant skills: \`npx skills add -g -y\` - - Record installed skills in the "Skills Discovered" section of your research output. - - Installed skills will automatically appear in subsequent units' system prompts — no manual steps needed.` - : ` - - Note promising skills in your research output with their install commands, but do NOT install them. - - The user will decide which to install.` - }`; - - return { - skillDiscoveryMode: mode, - skillDiscoveryInstructions: instructions, - }; -} - -// ─── Inline Helpers ─────────────────────────────────────────────────────────── - -/** - * Load a file and format it for inlining into a prompt. - * Returns the content wrapped with a source path header, or a fallback - * message if the file doesn't exist. This eliminates tool calls — the LLM - * gets the content directly instead of "Read this file:". - */ -async function inlineFile( - absPath: string | null, relPath: string, label: string, -): Promise { - const content = absPath ? await loadFile(absPath) : null; - if (!content) { - return `### ${label}\nSource: \`${relPath}\`\n\n_(not found — file does not exist yet)_`; - } - return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`; -} - -/** - * Load a file for inlining, returning null if it doesn't exist. - * Use when the file is optional and should be omitted entirely if absent. - */ -async function inlineFileOptional( - absPath: string | null, relPath: string, label: string, -): Promise { - const content = absPath ? await loadFile(absPath) : null; - if (!content) return null; - return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`; -} - -/** - * Load and inline dependency slice summaries (full content, not just paths). - */ -async function inlineDependencySummaries( - mid: string, sid: string, base: string, -): Promise { - const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; - if (!roadmapContent) return "- (no dependencies)"; - - const roadmap = parseRoadmap(roadmapContent); - const sliceEntry = roadmap.slices.find(s => s.id === sid); - if (!sliceEntry || sliceEntry.depends.length === 0) return "- (no dependencies)"; - - const sections: string[] = []; - const seen = new Set(); - for (const dep of sliceEntry.depends) { - if (seen.has(dep)) continue; - seen.add(dep); - const summaryFile = resolveSliceFile(base, mid, dep, "SUMMARY"); - const summaryContent = summaryFile ? await loadFile(summaryFile) : null; - const relPath = relSliceFile(base, mid, dep, "SUMMARY"); - if (summaryContent) { - sections.push(`#### ${dep} Summary\nSource: \`${relPath}\`\n\n${summaryContent.trim()}`); - } else { - sections.push(`- \`${relPath}\` _(not found)_`); - } - } - return sections.join("\n\n"); -} - -/** - * Load a well-known .gsd/ root file for optional inlining. - * Handles the existsSync check internally. - */ -async function inlineGsdRootFile( - base: string, filename: string, label: string, -): Promise { - const key = filename.replace(/\.md$/i, "").toUpperCase() as "PROJECT" | "DECISIONS" | "QUEUE" | "STATE" | "REQUIREMENTS"; - const absPath = resolveGsdRootFile(base, key); - if (!existsSync(absPath)) return null; - return inlineFileOptional(absPath, relGsdRootFile(key), label); -} - -// ─── Prompt Builders ────────────────────────────────────────────────────────── - -async function buildResearchMilestonePrompt(mid: string, midTitle: string, base: string): Promise { - const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); - const contextRel = relMilestoneFile(base, mid, "CONTEXT"); - - const inlined: string[] = []; - inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context")); - const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); - if (projectInline) inlined.push(projectInline); - const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); - if (requirementsInline) inlined.push(requirementsInline); - const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); - if (decisionsInline) inlined.push(decisionsInline); - inlined.push(inlineTemplate("research", "Research")); - - const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; - - const outputRelPath = relMilestoneFile(base, mid, "RESEARCH"); - return loadPrompt("research-milestone", { - milestoneId: mid, milestoneTitle: midTitle, - milestonePath: relMilestonePath(base, mid), - contextPath: contextRel, - outputPath: outputRelPath, - inlinedContext, - ...buildSkillDiscoveryVars(), - }); -} - -async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: string): Promise { - const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); - const contextRel = relMilestoneFile(base, mid, "CONTEXT"); - const researchPath = resolveMilestoneFile(base, mid, "RESEARCH"); - const researchRel = relMilestoneFile(base, mid, "RESEARCH"); - - const inlined: string[] = []; - inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context")); - const researchInline = await inlineFileOptional(researchPath, researchRel, "Milestone Research"); - if (researchInline) inlined.push(researchInline); - const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base); - if (priorSummaryInline) inlined.push(priorSummaryInline); - const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); - if (projectInline) inlined.push(projectInline); - const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); - if (requirementsInline) inlined.push(requirementsInline); - const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); - if (decisionsInline) inlined.push(decisionsInline); - inlined.push(inlineTemplate("roadmap", "Roadmap")); - inlined.push(inlineTemplate("decisions", "Decisions")); - inlined.push(inlineTemplate("plan", "Slice Plan")); - inlined.push(inlineTemplate("task-plan", "Task Plan")); - inlined.push(inlineTemplate("secrets-manifest", "Secrets Manifest")); - - const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; - - const outputRelPath = relMilestoneFile(base, mid, "ROADMAP"); - const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS"); - return loadPrompt("plan-milestone", { - milestoneId: mid, milestoneTitle: midTitle, - milestonePath: relMilestonePath(base, mid), - contextPath: contextRel, - researchPath: researchRel, - outputPath: outputRelPath, - secretsOutputPath, - inlinedContext, - }); -} - -async function buildResearchSlicePrompt( - mid: string, _midTitle: string, sid: string, sTitle: string, base: string, -): Promise { - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); - const contextRel = relMilestoneFile(base, mid, "CONTEXT"); - const milestoneResearchPath = resolveMilestoneFile(base, mid, "RESEARCH"); - const milestoneResearchRel = relMilestoneFile(base, mid, "RESEARCH"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context"); - if (contextInline) inlined.push(contextInline); - const researchInline = await inlineFileOptional(milestoneResearchPath, milestoneResearchRel, "Milestone Research"); - if (researchInline) inlined.push(researchInline); - const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); - if (decisionsInline) inlined.push(decisionsInline); - const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); - if (requirementsInline) inlined.push(requirementsInline); - inlined.push(inlineTemplate("research", "Research")); - - const depContent = await inlineDependencySummaries(mid, sid, base); - - const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; - - const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH"); - return loadPrompt("research-slice", { - milestoneId: mid, sliceId: sid, sliceTitle: sTitle, - slicePath: relSlicePath(base, mid, sid), - roadmapPath: roadmapRel, - contextPath: contextRel, - milestoneResearchPath: milestoneResearchRel, - outputPath: outputRelPath, - inlinedContext, - dependencySummaries: depContent, - ...buildSkillDiscoveryVars(), - }); -} - -async function buildPlanSlicePrompt( - mid: string, _midTitle: string, sid: string, sTitle: string, base: string, -): Promise { - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH"); - const researchRel = relSliceFile(base, mid, sid, "RESEARCH"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - const researchInline = await inlineFileOptional(researchPath, researchRel, "Slice Research"); - if (researchInline) inlined.push(researchInline); - const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); - if (decisionsInline) inlined.push(decisionsInline); - const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); - if (requirementsInline) inlined.push(requirementsInline); - inlined.push(inlineTemplate("plan", "Slice Plan")); - inlined.push(inlineTemplate("task-plan", "Task Plan")); - - const depContent = await inlineDependencySummaries(mid, sid, base); - - const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; - - const outputRelPath = relSliceFile(base, mid, sid, "PLAN"); - return loadPrompt("plan-slice", { - milestoneId: mid, sliceId: sid, sliceTitle: sTitle, - slicePath: relSlicePath(base, mid, sid), - roadmapPath: roadmapRel, - researchPath: researchRel, - outputPath: outputRelPath, - inlinedContext, - dependencySummaries: depContent, - }); -} - -async function buildExecuteTaskPrompt( - mid: string, sid: string, sTitle: string, - tid: string, tTitle: string, base: string, -): Promise { - - const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base); - const priorLines = priorSummaries.length > 0 - ? priorSummaries.map(p => `- \`${p}\``).join("\n") - : "- (no prior tasks)"; - - const taskPlanPath = resolveTaskFile(base, mid, sid, tid, "PLAN"); - const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null; - const taskPlanRelPath = relTaskFile(base, mid, sid, tid, "PLAN"); - const taskPlanInline = taskPlanContent - ? [ - "## Inlined Task Plan (authoritative local execution contract)", - `Source: \`${taskPlanRelPath}\``, - "", - taskPlanContent.trim(), - ].join("\n") - : [ - "## Inlined Task Plan (authoritative local execution contract)", - `Task plan not found at dispatch time. Read \`${taskPlanRelPath}\` before executing.`, - ].join("\n"); - - const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); - const slicePlanContent = slicePlanPath ? await loadFile(slicePlanPath) : null; - const slicePlanExcerpt = extractSliceExecutionExcerpt(slicePlanContent, relSliceFile(base, mid, sid, "PLAN")); - - // Check for continue file (new naming or legacy) - const continueFile = resolveSliceFile(base, mid, sid, "CONTINUE"); - const legacyContinueDir = resolveSlicePath(base, mid, sid); - const legacyContinuePath = legacyContinueDir ? join(legacyContinueDir, "continue.md") : null; - const continueContent = continueFile ? await loadFile(continueFile) : null; - const legacyContinueContent = !continueContent && legacyContinuePath ? await loadFile(legacyContinuePath) : null; - const continueRelPath = relSliceFile(base, mid, sid, "CONTINUE"); - const resumeSection = buildResumeSection( - continueContent, - legacyContinueContent, - continueRelPath, - legacyContinuePath ? `${relSlicePath(base, mid, sid)}/continue.md` : null, - ); - - const carryForwardSection = await buildCarryForwardSection(priorSummaries, base); - const inlinedTemplates = [ - inlineTemplate("task-summary", "Task Summary"), - inlineTemplate("decisions", "Decisions"), - ].join("\n\n---\n\n"); - - const taskSummaryPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`; - - return loadPrompt("execute-task", { - milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle, - planPath: relSliceFile(base, mid, sid, "PLAN"), - slicePath: relSlicePath(base, mid, sid), - taskPlanPath: taskPlanRelPath, - taskPlanInline, - slicePlanExcerpt, - carryForwardSection, - resumeSection, - priorTaskLines: priorLines, - taskSummaryPath, - inlinedTemplates, - }); -} - -async function buildCompleteSlicePrompt( - mid: string, _midTitle: string, sid: string, sTitle: string, base: string, -): Promise { - - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); - const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Slice Plan")); - const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); - if (requirementsInline) inlined.push(requirementsInline); - - // Inline all task summaries for this slice - const tDir = resolveTasksDir(base, mid, sid); - if (tDir) { - const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort(); - for (const file of summaryFiles) { - const absPath = join(tDir, file); - const content = await loadFile(absPath); - const sRel = relSlicePath(base, mid, sid); - const relPath = `${sRel}/tasks/${file}`; - if (content) { - inlined.push(`### Task Summary: ${file.replace(/-SUMMARY\.md$/i, "")}\nSource: \`${relPath}\`\n\n${content.trim()}`); - } - } - } - inlined.push(inlineTemplate("slice-summary", "Slice Summary")); - inlined.push(inlineTemplate("uat", "UAT")); - - const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; - - const sliceRel = relSlicePath(base, mid, sid); - const sliceSummaryPath = `${sliceRel}/${sid}-SUMMARY.md`; - const sliceUatPath = `${sliceRel}/${sid}-UAT.md`; - - return loadPrompt("complete-slice", { - milestoneId: mid, sliceId: sid, sliceTitle: sTitle, - slicePath: sliceRel, - roadmapPath: roadmapRel, - inlinedContext, - sliceSummaryPath, - sliceUatPath, - }); -} - -async function buildCompleteMilestonePrompt( - mid: string, midTitle: string, base: string, -): Promise { - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - - // Inline all slice summaries (deduplicated by slice ID) - const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; - if (roadmapContent) { - const roadmap = parseRoadmap(roadmapContent); - const seenSlices = new Set(); - for (const slice of roadmap.slices) { - if (seenSlices.has(slice.id)) continue; - seenSlices.add(slice.id); - const summaryPath = resolveSliceFile(base, mid, slice.id, "SUMMARY"); - const summaryRel = relSliceFile(base, mid, slice.id, "SUMMARY"); - inlined.push(await inlineFile(summaryPath, summaryRel, `${slice.id} Summary`)); - } - } - - // Inline root GSD files - const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); - if (requirementsInline) inlined.push(requirementsInline); - const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); - if (decisionsInline) inlined.push(decisionsInline); - const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); - if (projectInline) inlined.push(projectInline); - // Inline milestone context file (milestone-level, not GSD root) - const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); - const contextRel = relMilestoneFile(base, mid, "CONTEXT"); - const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context"); - if (contextInline) inlined.push(contextInline); - inlined.push(inlineTemplate("milestone-summary", "Milestone Summary")); - - const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; - - const milestoneSummaryPath = `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`; - - return loadPrompt("complete-milestone", { - milestoneId: mid, - milestoneTitle: midTitle, - roadmapPath: roadmapRel, - inlinedContext, - milestoneSummaryPath, - }); -} - -// ─── Replan Slice Prompt ─────────────────────────────────────────────────────── - -async function buildReplanSlicePrompt( - mid: string, midTitle: string, sid: string, sTitle: string, base: string, -): Promise { - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); - const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Current Slice Plan")); - - // Find the blocker task summary — the completed task with blocker_discovered: true - let blockerTaskId = ""; - const tDir = resolveTasksDir(base, mid, sid); - if (tDir) { - const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort(); - for (const file of summaryFiles) { - const absPath = join(tDir, file); - const content = await loadFile(absPath); - if (!content) continue; - const summary = parseSummary(content); - const sRel = relSlicePath(base, mid, sid); - const relPath = `${sRel}/tasks/${file}`; - if (summary.frontmatter.blocker_discovered) { - blockerTaskId = summary.frontmatter.id || file.replace(/-SUMMARY\.md$/i, ""); - inlined.push(`### Blocker Task Summary: ${blockerTaskId}\nSource: \`${relPath}\`\n\n${content.trim()}`); - } - } - } - - // Inline decisions - const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); - if (decisionsInline) inlined.push(decisionsInline); - - const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; - - const replanPath = `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`; - - return loadPrompt("replan-slice", { - milestoneId: mid, - sliceId: sid, - sliceTitle: sTitle, - slicePath: relSlicePath(base, mid, sid), - planPath: slicePlanRel, - blockerTaskId, - inlinedContext, - replanPath, - }); -} - -// ─── Adaptive Replanning ────────────────────────────────────────────────────── - -/** - * Check if the most recently completed slice needs reassessment. - * Returns { sliceId } if reassessment is needed, null otherwise. - * - * Skips reassessment when: - * - No roadmap exists yet - * - No slices are completed - * - The last completed slice already has an assessment file - * - All slices are complete (milestone done — no point reassessing) - */ -async function checkNeedsReassessment( - base: string, mid: string, state: GSDState, -): Promise<{ sliceId: string } | null> { - const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; - if (!roadmapContent) return null; - - const roadmap = parseRoadmap(roadmapContent); - const completedSlices = roadmap.slices.filter(s => s.done); - const incompleteSlices = roadmap.slices.filter(s => !s.done); - - // No completed slices or all slices done — skip - if (completedSlices.length === 0 || incompleteSlices.length === 0) return null; - - // Check the last completed slice - const lastCompleted = completedSlices[completedSlices.length - 1]; - const assessmentFile = resolveSliceFile(base, mid, lastCompleted.id, "ASSESSMENT"); - const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile)); - - if (hasAssessment) return null; - - // Also need a summary to reassess against - const summaryFile = resolveSliceFile(base, mid, lastCompleted.id, "SUMMARY"); - const hasSummary = !!(summaryFile && await loadFile(summaryFile)); - - if (!hasSummary) return null; - - return { sliceId: lastCompleted.id }; -} - -/** - * Check if the most recently completed slice needs a UAT run. - * Returns { sliceId, uatType } if UAT should be dispatched, null otherwise. - * - * Skips when: - * - No roadmap or no completed slices - * - All slices are done (milestone complete path — reassessment handles it) - * - uat_dispatch preference is not enabled - * - No UAT file exists for the slice - * - UAT result file already exists (idempotent — already ran) - */ -async function checkNeedsRunUat( - base: string, mid: string, state: GSDState, prefs: GSDPreferences | undefined, -): Promise<{ sliceId: string; uatType: UatType } | null> { - const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; - if (!roadmapContent) return null; - - const roadmap = parseRoadmap(roadmapContent); - const completedSlices = roadmap.slices.filter(s => s.done); - const incompleteSlices = roadmap.slices.filter(s => !s.done); - - // No completed slices — nothing to UAT yet - if (completedSlices.length === 0) return null; - - // All slices done — milestone complete path, skip (reassessment handles) - if (incompleteSlices.length === 0) return null; - - // uat_dispatch must be opted in - if (!prefs?.uat_dispatch) return null; - - // Take the last completed slice - const lastCompleted = completedSlices[completedSlices.length - 1]; - const sid = lastCompleted.id; - - // UAT file must exist - const uatFile = resolveSliceFile(base, mid, sid, "UAT"); - if (!uatFile) return null; - const uatContent = await loadFile(uatFile); - if (!uatContent) return null; - - // If UAT result already exists, skip (idempotent) - const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT"); - if (uatResultFile) { - const hasResult = !!(await loadFile(uatResultFile)); - if (hasResult) return null; - } - - // Classify UAT type; unknown type → treat as human-experience (human review) - const uatType = extractUatType(uatContent) ?? "human-experience"; - - return { sliceId: sid, uatType }; -} - -async function buildRunUatPrompt( - mid: string, sliceId: string, uatPath: string, uatContent: string, base: string, -): Promise { - const inlined: string[] = []; - inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`)); - - const summaryPath = resolveSliceFile(base, mid, sliceId, "SUMMARY"); - const summaryRel = relSliceFile(base, mid, sliceId, "SUMMARY"); - if (summaryPath) { - const summaryInline = await inlineFileOptional(summaryPath, summaryRel, `${sliceId} Summary`); - if (summaryInline) inlined.push(summaryInline); - } - - const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); - if (projectInline) inlined.push(projectInline); - - const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; - - const uatResultPath = relSliceFile(base, mid, sliceId, "UAT-RESULT"); - const uatType = extractUatType(uatContent) ?? "human-experience"; - - return loadPrompt("run-uat", { - milestoneId: mid, - sliceId, - uatPath, - uatResultPath, - uatType, - inlinedContext, - }); -} - -async function buildReassessRoadmapPrompt( - mid: string, midTitle: string, completedSliceId: string, base: string, -): Promise { - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const summaryPath = resolveSliceFile(base, mid, completedSliceId, "SUMMARY"); - const summaryRel = relSliceFile(base, mid, completedSliceId, "SUMMARY"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Current Roadmap")); - inlined.push(await inlineFile(summaryPath, summaryRel, `${completedSliceId} Summary`)); - const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); - if (projectInline) inlined.push(projectInline); - const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); - if (requirementsInline) inlined.push(requirementsInline); - const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); - if (decisionsInline) inlined.push(decisionsInline); - - const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; - - const assessmentPath = relSliceFile(base, mid, completedSliceId, "ASSESSMENT"); - - return loadPrompt("reassess-roadmap", { - milestoneId: mid, - milestoneTitle: midTitle, - completedSliceId, - roadmapPath: roadmapRel, - completedSliceSummaryPath: summaryRel, - assessmentPath, - inlinedContext, - }); -} - -function extractSliceExecutionExcerpt(content: string | null, relPath: string): string { - if (!content) { - return [ - "## Slice Plan Excerpt", - `Slice plan not found at dispatch time. Read \`${relPath}\` before running slice-level verification.`, - ].join("\n"); - } - - const lines = content.split("\n"); - const goalLine = lines.find(l => l.startsWith("**Goal:**"))?.trim(); - const demoLine = lines.find(l => l.startsWith("**Demo:**"))?.trim(); - - const verification = extractMarkdownSection(content, "Verification"); - const observability = extractMarkdownSection(content, "Observability / Diagnostics"); - - const parts = ["## Slice Plan Excerpt", `Source: \`${relPath}\``]; - if (goalLine) parts.push(goalLine); - if (demoLine) parts.push(demoLine); - if (verification) { - parts.push("", "### Slice Verification", verification.trim()); - } - if (observability) { - parts.push("", "### Slice Observability / Diagnostics", observability.trim()); - } - - return parts.join("\n"); -} - -function extractMarkdownSection(content: string, heading: string): string | null { - const match = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m").exec(content); - if (!match) return null; - - const start = match.index + match[0].length; - const rest = content.slice(start); - const nextHeading = rest.match(/^##\s+/m); - const end = nextHeading?.index ?? rest.length; - return rest.slice(0, end).trim(); -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function buildResumeSection( - continueContent: string | null, - legacyContinueContent: string | null, - continueRelPath: string, - legacyContinueRelPath: string | null, -): string { - const resolvedContent = continueContent ?? legacyContinueContent; - const resolvedRelPath = continueContent ? continueRelPath : legacyContinueRelPath; - - if (!resolvedContent || !resolvedRelPath) { - return ["## Resume State", "- No continue file present. Start from the top of the task plan."].join("\n"); - } - - const cont = parseContinue(resolvedContent); - const lines = [ - "## Resume State", - `Source: \`${resolvedRelPath}\``, - `- Status: ${cont.frontmatter.status || "in_progress"}`, - ]; - - if (cont.frontmatter.step && cont.frontmatter.totalSteps) { - lines.push(`- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`); - } - if (cont.completedWork) lines.push(`- Completed: ${oneLine(cont.completedWork)}`); - if (cont.remainingWork) lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`); - if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`); - if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`); - - return lines.join("\n"); -} - -async function buildCarryForwardSection(priorSummaryPaths: string[], base: string): Promise { - if (priorSummaryPaths.length === 0) { - return ["## Carry-Forward Context", "- No prior task summaries in this slice."].join("\n"); - } - - const items = await Promise.all(priorSummaryPaths.map(async (relPath) => { - const absPath = join(base, relPath); - const content = await loadFile(absPath); - if (!content) return `- \`${relPath}\``; - - const summary = parseSummary(content); - const provided = summary.frontmatter.provides.slice(0, 2).join("; "); - const decisions = summary.frontmatter.key_decisions.slice(0, 2).join("; "); - const patterns = summary.frontmatter.patterns_established.slice(0, 2).join("; "); - const diagnostics = extractMarkdownSection(content, "Diagnostics"); - - const parts = [summary.title || relPath]; - if (summary.oneLiner) parts.push(summary.oneLiner); - if (provided) parts.push(`provides: ${provided}`); - if (decisions) parts.push(`decisions: ${decisions}`); - if (patterns) parts.push(`patterns: ${patterns}`); - if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`); - - return `- \`${relPath}\` — ${parts.join(" | ")}`; - })); - - return ["## Carry-Forward Context", ...items].join("\n"); -} - -function oneLine(text: string): string { - return text.replace(/\s+/g, " ").trim(); -} - -async function getPriorTaskSummaryPaths( - mid: string, sid: string, currentTid: string, base: string, -): Promise { - const tDir = resolveTasksDir(base, mid, sid); - if (!tDir) return []; - - const summaryFiles = resolveTaskFiles(tDir, "SUMMARY"); - const currentNum = parseInt(currentTid.replace(/^T/, ""), 10); - const sRel = relSlicePath(base, mid, sid); - - return summaryFiles - .filter(f => { - const num = parseInt(f.replace(/^T/, ""), 10); - return num < currentNum; - }) - .map(f => `${sRel}/tasks/${f}`); } // ─── Preconditions ──────────────────────────────────────────────────────────── @@ -3532,294 +2386,11 @@ async function recoverTimedOutUnit( return "paused"; } -/** - * Write skip artifacts for a stuck execute-task: a blocker task summary and - * the [x] checkbox in the slice plan. Returns true if artifacts were written. - */ -export function skipExecuteTask( - base: string, mid: string, sid: string, tid: string, - status: { summaryExists: boolean; taskChecked: boolean }, - reason: string, maxAttempts: number, -): boolean { - // Write a blocker task summary if missing. - if (!status.summaryExists) { - const tasksDir = resolveTasksDir(base, mid, sid); - const sDir = resolveSlicePath(base, mid, sid); - const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null); - if (!targetDir) return false; - if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); - const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY")); - const content = [ - `# BLOCKER — task skipped by auto-mode recovery`, - ``, - `Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) failed to complete after ${reason} recovery exhausted ${maxAttempts} attempts.`, - ``, - `This placeholder was written by auto-mode so the pipeline can advance.`, - `Review this task manually and replace this file with a real summary.`, - ].join("\n"); - writeFileSync(summaryPath, content, "utf-8"); - } - - // Mark [x] in the slice plan if not already checked. - if (!status.taskChecked) { - const planAbs = resolveSliceFile(base, mid, sid, "PLAN"); - if (planAbs && existsSync(planAbs)) { - const planContent = readFileSync(planAbs, "utf-8"); - const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`^(- \\[) \\] (\\*\\*${escapedTid}:)`, "m"); - if (re.test(planContent)) { - writeFileSync(planAbs, planContent.replace(re, "$1x] $2"), "utf-8"); - } - } - } - - return true; -} - -/** - * Detect whether the agent is producing work on disk by checking git for - * any working-tree changes (staged, unstaged, or untracked). Returns true - * if there are uncommitted changes — meaning the agent is actively working, - * even though it hasn't signaled progress through runtime records. - */ -function detectWorkingTreeActivity(cwd: string): boolean { - try { - const out = execSync("git status --porcelain", { - cwd, - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, - }); - return out.toString().trim().length > 0; - } catch { - return false; - } -} - -/** - * Resolve the expected artifact for a non-execute-task unit to an absolute path. - * Returns null for unit types that don't produce a single file (execute-task, - * complete-slice, replan-slice). - */ -export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null { - const parts = unitId.split("/"); - const mid = parts[0]!; - const sid = parts[1]; - switch (unitType) { - case "research-milestone": { - const dir = resolveMilestonePath(base, mid); - return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null; - } - case "plan-milestone": { - const dir = resolveMilestonePath(base, mid); - return dir ? join(dir, buildMilestoneFileName(mid, "ROADMAP")) : null; - } - case "research-slice": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "RESEARCH")) : null; - } - case "plan-slice": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "PLAN")) : null; - } - case "reassess-roadmap": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "ASSESSMENT")) : null; - } - case "run-uat": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null; - } - case "execute-task": { - const tid = parts[2]; - const dir = resolveSlicePath(base, mid, sid!); - return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null; - } - case "complete-slice": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "SUMMARY")) : null; - } - case "complete-milestone": { - const dir = resolveMilestonePath(base, mid); - return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null; - } - default: - return null; - } -} - -/** - * Check whether the expected artifact(s) for a unit exist on disk. - * Returns true if all required artifacts exist, or if the unit type has no - * single verifiable artifact (e.g., replan-slice). - * - * complete-slice requires both SUMMARY and UAT files — verifying only - * the summary allowed the unit to be marked complete when the LLM - * skipped writing the UAT file (see #176). - */ -export function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean { - // Clear stale directory listing cache so artifact checks see fresh disk state (#431) - clearPathCache(); - - // Hook units have no standard artifact — always pass. Their lifecycle - // is managed by the hook engine, not the artifact verification system. - if (unitType.startsWith("hook/")) return true; - - - const absPath = resolveExpectedArtifactPath(unitType, unitId, base); - // Unit types with no verifiable artifact always pass (e.g. replan-slice). - // For all other types, null means the parent directory is missing on disk - // — treat as stale completion state so the key gets evicted (#313). - if (!absPath) return unitType === "replan-slice"; - if (!existsSync(absPath)) return false; - - // execute-task must also have its checkbox marked [x] in the slice plan - if (unitType === "execute-task") { - const parts = unitId.split("/"); - const mid = parts[0]; - const sid = parts[1]; - const tid = parts[2]; - if (mid && sid && tid) { - const planAbs = resolveSliceFile(base, mid, sid, "PLAN"); - if (planAbs && existsSync(planAbs)) { - const planContent = readFileSync(planAbs, "utf-8"); - const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`^- \\[[xX]\\] \\*\\*${escapedTid}:`, "m"); - if (!re.test(planContent)) return false; - } - } - } - - // complete-slice must also produce a UAT file AND mark the slice [x] in the roadmap. - // Without the roadmap check, a crash after writing SUMMARY+UAT but before updating - // the roadmap causes an infinite skip loop: the idempotency key says "done" but the - // state machine keeps returning the same complete-slice unit (roadmap still shows - // the slice incomplete), so dispatchNextUnit recurses forever. - if (unitType === "complete-slice") { - const parts = unitId.split("/"); - const mid = parts[0]; - const sid = parts[1]; - if (mid && sid) { - const dir = resolveSlicePath(base, mid, sid); - if (dir) { - const uatPath = join(dir, buildSliceFileName(sid, "UAT")); - if (!existsSync(uatPath)) return false; - } - // Verify the roadmap has the slice marked [x]. If not, the completion - // record is stale — the unit must re-run to update the roadmap. - const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); - if (roadmapFile && existsSync(roadmapFile)) { - try { - const roadmapContent = readFileSync(roadmapFile, "utf-8"); - const roadmap = parseRoadmap(roadmapContent); - const slice = roadmap.slices.find(s => s.id === sid); - if (slice && !slice.done) return false; - } catch { /* corrupt roadmap — be lenient and treat as verified */ } - } - } - } - - return true; -} - -/** - * Write a placeholder artifact so the pipeline can advance past a stuck unit. - * Returns the relative path written, or null if the path couldn't be resolved. - */ -export function writeBlockerPlaceholder(unitType: string, unitId: string, base: string, reason: string): string | null { - const absPath = resolveExpectedArtifactPath(unitType, unitId, base); - if (!absPath) return null; - const dir = dirname(absPath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const content = [ - `# BLOCKER — auto-mode recovery failed`, - ``, - `Unit \`${unitType}\` for \`${unitId}\` failed to produce this artifact after idle recovery exhausted all retries.`, - ``, - `**Reason**: ${reason}`, - ``, - `This placeholder was written by auto-mode so the pipeline can advance.`, - `Review and replace this file before relying on downstream artifacts.`, - ].join("\n"); - writeFileSync(absPath, content, "utf-8"); - return diagnoseExpectedArtifact(unitType, unitId, base); -} - -function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null { - const parts = unitId.split("/"); - const mid = parts[0]; - const sid = parts[1]; - switch (unitType) { - case "research-milestone": - return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`; - case "plan-milestone": - return `${relMilestoneFile(base, mid!, "ROADMAP")} (milestone roadmap)`; - case "research-slice": - return `${relSliceFile(base, mid!, sid!, "RESEARCH")} (slice research)`; - case "plan-slice": - return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`; - case "execute-task": { - const tid = parts[2]; - return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`; - } - case "complete-slice": - return `Slice ${sid} marked [x] in ${relMilestoneFile(base, mid!, "ROADMAP")} + summary + UAT written`; - case "replan-slice": - return `${relSliceFile(base, mid!, sid!, "REPLAN")} + updated ${relSliceFile(base, mid!, sid!, "PLAN")}`; - case "reassess-roadmap": - return `${relSliceFile(base, mid!, sid!, "ASSESSMENT")} (roadmap reassessment)`; - case "run-uat": - return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`; - case "complete-milestone": - return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`; - default: - return null; - } -} - -/** - * Build concrete, manual remediation steps for a loop-detected unit failure. - * These are shown when automatic reconciliation is not possible. - */ -export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null { - const parts = unitId.split("/"); - const mid = parts[0]; - const sid = parts[1]; - const tid = parts[2]; - switch (unitType) { - case "execute-task": { - if (!mid || !sid || !tid) break; - const planRel = relSliceFile(base, mid, sid, "PLAN"); - const summaryRel = relTaskFile(base, mid, sid, tid, "SUMMARY"); - return [ - ` 1. Write ${summaryRel} (even a partial summary is sufficient to unblock the pipeline)`, - ` 2. Mark ${tid} [x] in ${planRel}: change "- [ ] **${tid}:" → "- [x] **${tid}:"`, - ` 3. Run \`gsd doctor\` to reconcile .gsd/ state`, - ` 4. Resume auto-mode — it will pick up from the next task`, - ].join("\n"); - } - case "plan-slice": - case "research-slice": { - if (!mid || !sid) break; - const artifactRel = unitType === "plan-slice" - ? relSliceFile(base, mid, sid, "PLAN") - : relSliceFile(base, mid, sid, "RESEARCH"); - return [ - ` 1. Write ${artifactRel} manually (or with the LLM in interactive mode)`, - ` 2. Run \`gsd doctor\` to reconcile .gsd/ state`, - ` 3. Resume auto-mode`, - ].join("\n"); - } - case "complete-slice": { - if (!mid || !sid) break; - return [ - ` 1. Write the slice summary and UAT file for ${sid} in ${relSlicePath(base, mid, sid)}`, - ` 2. Mark ${sid} [x] in ${relMilestoneFile(base, mid, "ROADMAP")}`, - ` 3. Run \`gsd doctor\` to reconcile .gsd/ state`, - ` 4. Resume auto-mode`, - ].join("\n"); - } - default: - break; - } - return null; -} +// Re-export recovery functions for external consumers +export { + resolveExpectedArtifactPath, + verifyExpectedArtifact, + writeBlockerPlaceholder, + skipExecuteTask, + buildLoopRemediationSteps, +} from "./auto-recovery.js"; diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 7aefa0270..1c130f7f9 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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 , or /gsd remote [slack|discord|status|disconnect].`, + `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip |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 { + if (!unitArg) { + ctx.ui.notify("Usage: /gsd skip (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 { + 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 { + 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 { + 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(); + 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"); +} diff --git a/src/resources/extensions/gsd/export.ts b/src/resources/extensions/gsd/export.ts new file mode 100644 index 000000000..d799da718 --- /dev/null +++ b/src/resources/extensions/gsd/export.ts @@ -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 + +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 { + 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"); + } +} diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 008ce7dcd..afde88d66 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -87,6 +87,28 @@ export function ensureGitignore(basePath: string): boolean { existing = readFileSync(gitignorePath, "utf-8"); } + // Self-heal: remove blanket ".gsd/" lines from pre-v2.14.0 projects. + // The blanket ignore prevented planning artifacts (.gsd/milestones/) from + // being tracked in git, causing artifacts to vanish in worktrees and + // triggering loop detection failures. Replace with explicit runtime-only + // ignores so planning files are tracked naturally. + let modified = false; + const lines = existing.split("\n"); + const filteredLines = lines.filter(line => { + const trimmed = line.trim(); + // Remove standalone ".gsd/" lines (blanket ignore) but keep specific + // .gsd/ subpath patterns like ".gsd/activity/" or ".gsd/auto.lock" + if (trimmed === ".gsd/" || trimmed === ".gsd") { + modified = true; + return false; + } + return true; + }); + if (modified) { + existing = filteredLines.join("\n"); + writeFileSync(gitignorePath, existing, "utf-8"); + } + // Parse existing lines (trimmed, ignoring comments and blanks) const existingLines = new Set( existing @@ -98,7 +120,7 @@ export function ensureGitignore(basePath: string): boolean { // Find patterns not yet present const missing = BASELINE_PATTERNS.filter((p) => !existingLines.has(p)); - if (missing.length === 0) return false; + if (missing.length === 0) return modified; // Build the block to append const block = [ diff --git a/src/resources/extensions/gsd/history.ts b/src/resources/extensions/gsd/history.ts new file mode 100644 index 000000000..3fa80d3a2 --- /dev/null +++ b/src/resources/extensions/gsd/history.ts @@ -0,0 +1,162 @@ +// GSD Extension — Session History View +// Human-readable display of past auto-mode unit executions. +// Copyright (c) 2026 Jeremy McSpadden + +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 { + 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); +} diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index 84c5e72d0..767f15356 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -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"); diff --git a/src/resources/extensions/gsd/notifications.ts b/src/resources/extensions/gsd/notifications.ts new file mode 100644 index 000000000..579db6ae8 --- /dev/null +++ b/src/resources/extensions/gsd/notifications.ts @@ -0,0 +1,88 @@ +// GSD Extension — Desktop Notification Helper +// Cross-platform desktop notifications for auto-mode events. +// Copyright (c) 2026 Jeremy McSpadden + +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, '\\"'); +} diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 1b3d9eabc..f44078da0 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -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[]; diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index e6f4ff3c4..d66ca2932 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -91,13 +91,19 @@ Do not count the reflection step as a question round. Rounds start after reflect ## Depth Verification -Before moving to the wrap-up gate, present a structured depth summary to the user via `ask_user_questions`. This is a checkpoint — show what you captured across the depth checklist dimensions, using the user's own terminology and framing. +Before moving to the wrap-up gate, present a structured depth summary as a checkpoint. -The question should summarize: what you understood them to be building, what shaped your understanding most (their emphasis, constraints, concerns), and any areas where you're least confident in your understanding. Frame it as: "Before we move to planning, here's what I captured — did I get the depth right?" +**Print the summary as normal chat text first** — this is where the formatting renders properly. Structure the summary across the depth checklist dimensions using the user's own terminology and framing. Cover: what you understood them to be building, what shaped your understanding most (their emphasis, constraints, concerns), and any areas where you're least confident in your understanding. -**Convention:** The question ID must contain `depth_verification` (e.g., `depth_verification_summary`). This naming convention enables downstream mechanical detection of this step. +**Then** use `ask_user_questions` with a short confirmation question — NOT the summary itself. The question field is designed for single sentences, not multi-paragraph summaries. -Offer two options: "Yes, you got it (Recommended)" and "Not quite — let me clarify." If they clarify, absorb the correction and re-verify. +**Convention:** The question ID must contain `depth_verification` (e.g., `depth_verification_confirm`). This naming convention enables downstream mechanical detection of this step. + +Example flow: +1. Print in chat: the full depth summary with markdown formatting (headers, bold, bullets) +2. Call `ask_user_questions` with: header "Depth Check", question "Did I capture the depth right?", options "Yes, you got it (Recommended)" and "Not quite — let me clarify" + +If they clarify, absorb the correction and re-verify. ## Wrap-up Gate diff --git a/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts b/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts new file mode 100644 index 000000000..b4f93847f --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts @@ -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"); +}); diff --git a/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts b/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts index 01811aabe..13650a257 100644 --- a/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts +++ b/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts @@ -81,9 +81,15 @@ const autoSource = readFileSync( "utf-8", ); -// Check describeNextUnit has the case -const hasDescribeCase = autoSource.includes('case "needs-discussion"'); -assert(hasDescribeCase, "auto.ts describeNextUnit should have 'needs-discussion' case"); +// describeNextUnit was extracted to auto-dashboard.ts — check there for the case +const dashboardSource = readFileSync( + join(import.meta.dirname, "..", "auto-dashboard.ts"), + "utf-8", +); + +// Check describeNextUnit has the case (in auto-dashboard.ts) +const hasDescribeCase = dashboardSource.includes('case "needs-discussion"'); +assert(hasDescribeCase, "auto-dashboard.ts describeNextUnit should have 'needs-discussion' case"); // Check dispatchNextUnit has the branch const hasDispatchBranch = autoSource.includes('state.phase === "needs-discussion"'); diff --git a/src/resources/extensions/gsd/tests/notifications.test.ts b/src/resources/extensions/gsd/tests/notifications.test.ts new file mode 100644 index 000000000..f889ab2b0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/notifications.test.ts @@ -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); +}); diff --git a/src/resources/extensions/gsd/tests/undo.test.ts b/src/resources/extensions/gsd/tests/undo.test.ts new file mode 100644 index 000000000..6aee92930 --- /dev/null +++ b/src/resources/extensions/gsd/tests/undo.test.ts @@ -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"]); +}); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index c119a7393..52a50d7d4 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -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 { diff --git a/src/resources/extensions/gsd/undo.ts b/src/resources/extensions/gsd/undo.ts new file mode 100644 index 000000000..41b909e37 --- /dev/null +++ b/src/resources/extensions/gsd/undo.ts @@ -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 + +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 { + 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; +}