gsd-pi 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +3 -1
  2. package/dist/cli.js +112 -5
  3. package/dist/loader.js +0 -0
  4. package/dist/resource-loader.d.ts +3 -3
  5. package/dist/resource-loader.js +10 -4
  6. package/dist/tool-bootstrap.d.ts +4 -0
  7. package/dist/tool-bootstrap.js +74 -0
  8. package/dist/wizard.js +15 -5
  9. package/package.json +6 -2
  10. package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +48 -0
  11. package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
  12. package/scripts/postinstall.js +8 -0
  13. package/src/resources/extensions/bg-shell/index.ts +57 -8
  14. package/src/resources/extensions/browser-tools/index.ts +80 -7
  15. package/src/resources/extensions/github/gh-api.ts +46 -30
  16. package/src/resources/extensions/gsd/auto.ts +188 -10
  17. package/src/resources/extensions/gsd/commands.ts +13 -6
  18. package/src/resources/extensions/gsd/doctor.ts +7 -0
  19. package/src/resources/extensions/gsd/guided-flow.ts +9 -6
  20. package/src/resources/extensions/gsd/index.ts +32 -2
  21. package/src/resources/extensions/gsd/prompts/discuss.md +73 -27
  22. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  23. package/src/resources/extensions/gsd/prompts/worktree-merge.md +51 -17
  24. package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
  25. package/src/resources/extensions/gsd/worktree-command.ts +219 -49
  26. package/src/resources/extensions/gsd/worktree-manager.ts +106 -16
  27. package/src/resources/extensions/mcporter/index.ts +410 -0
  28. package/src/resources/extensions/slash-commands/clear.ts +10 -0
  29. package/src/resources/extensions/slash-commands/index.ts +2 -2
  30. package/src/resources/extensions/voice/index.ts +176 -0
  31. package/src/resources/extensions/voice/speech-recognizer +0 -0
  32. package/src/resources/extensions/voice/speech-recognizer.swift +76 -0
  33. package/dist/modes/interactive/theme/dark.json +0 -85
  34. package/dist/modes/interactive/theme/light.json +0 -84
  35. package/dist/modes/interactive/theme/theme-schema.json +0 -335
  36. package/dist/modes/interactive/theme/theme.d.ts +0 -78
  37. package/dist/modes/interactive/theme/theme.d.ts.map +0 -1
  38. package/dist/modes/interactive/theme/theme.js +0 -949
  39. package/dist/modes/interactive/theme/theme.js.map +0 -1
  40. package/src/resources/extensions/slash-commands/gsd-run.ts +0 -34
@@ -8,7 +8,7 @@
8
8
  * - Every action returns feedback (accessibility snapshot, screenshots on navigate)
9
9
  * - Errors include visual debugging (screenshots on failure, surfaced JS errors)
10
10
  * - Smart waits (domcontentloaded + best-effort settle, not blocking networkidle)
11
- * - 2x DPI screenshots for readable text
11
+ * - Screenshots capped at 1568px max dimension (Anthropic API limit safety)
12
12
  * - JPEG for viewport screenshots (smaller), PNG for element crops (transparency)
13
13
  * - Auto-handles JS dialogs (alert/confirm/prompt) to prevent page freezes
14
14
  * - Auto-switches to new tabs (popups, target="_blank")
@@ -343,7 +343,10 @@ async function ensureBrowser(): Promise<{ browser: Browser; context: BrowserCont
343
343
  // Lazy import so playwright is only loaded when actually needed
344
344
  const { chromium } = await import("playwright");
345
345
 
346
- browser = await chromium.launch({ headless: false });
346
+ const launchOptions: Record<string, unknown> = { headless: false };
347
+ const customPath = process.env.BROWSER_PATH;
348
+ if (customPath) launchOptions.executablePath = customPath;
349
+ browser = await chromium.launch(launchOptions);
347
350
  context = await browser.newContext({
348
351
  deviceScaleFactor: 2,
349
352
  viewport: { width: 1280, height: 800 },
@@ -728,11 +731,75 @@ async function postActionSummary(p: Page, target?: Page | Frame): Promise<string
728
731
  }
729
732
  }
730
733
 
734
+ // Anthropic API rejects images > 2000px in multi-image requests.
735
+ // Cap at 1568px (recommended optimal size) to stay well within limits.
736
+ const MAX_SCREENSHOT_DIM = 1568;
737
+
738
+ /**
739
+ * If either dimension of the image buffer exceeds MAX_SCREENSHOT_DIM,
740
+ * downscale proportionally using the browser's canvas (zero dependencies).
741
+ * Returns the original buffer unchanged if already within limits.
742
+ */
743
+ async function constrainScreenshot(
744
+ page: Page,
745
+ buffer: Buffer,
746
+ mimeType: string,
747
+ quality: number,
748
+ ): Promise<Buffer> {
749
+ let width: number;
750
+ let height: number;
751
+
752
+ if (mimeType === "image/png") {
753
+ width = buffer.readUInt32BE(16);
754
+ height = buffer.readUInt32BE(20);
755
+ } else {
756
+ width = 0;
757
+ height = 0;
758
+ for (let i = 0; i < buffer.length - 8; i++) {
759
+ if (buffer[i] === 0xff && (buffer[i + 1] === 0xc0 || buffer[i + 1] === 0xc2)) {
760
+ height = buffer.readUInt16BE(i + 5);
761
+ width = buffer.readUInt16BE(i + 7);
762
+ break;
763
+ }
764
+ }
765
+ }
766
+
767
+ if (width <= MAX_SCREENSHOT_DIM && height <= MAX_SCREENSHOT_DIM) {
768
+ return buffer;
769
+ }
770
+
771
+ const b64 = buffer.toString("base64");
772
+ const result = await page.evaluate(
773
+ async ({ b64, mime, maxDim, q }) => {
774
+ const img = new Image();
775
+ await new Promise<void>((resolve, reject) => {
776
+ img.onload = () => resolve();
777
+ img.onerror = reject;
778
+ img.src = `data:${mime};base64,${b64}`;
779
+ });
780
+ const scale = Math.min(maxDim / img.width, maxDim / img.height);
781
+ const w = Math.round(img.width * scale);
782
+ const h = Math.round(img.height * scale);
783
+ const canvas = document.createElement("canvas");
784
+ canvas.width = w;
785
+ canvas.height = h;
786
+ const ctx = canvas.getContext("2d")!;
787
+ ctx.drawImage(img, 0, 0, w, h);
788
+ return canvas.toDataURL(mime, q / 100);
789
+ },
790
+ { b64, mime: mimeType, maxDim: MAX_SCREENSHOT_DIM, q: quality },
791
+ );
792
+
793
+ const resizedB64 = result.split(",")[1];
794
+ return Buffer.from(resizedB64, "base64");
795
+ }
796
+
731
797
  /** Capture a JPEG screenshot for error debugging. Returns base64 or null. */
732
798
  async function captureErrorScreenshot(p: Page | null): Promise<{ data: string; mimeType: string } | null> {
733
799
  if (!p) return null;
734
800
  try {
735
- const buf = await p.screenshot({ type: "jpeg", quality: 60 });
801
+ let buf = await p.screenshot({ type: "jpeg", quality: 60, scale: "css" });
802
+ buf = await constrainScreenshot(p, buf, "image/jpeg", 60);
736
803
  return { data: buf.toString("base64"), mimeType: "image/jpeg" };
737
804
  } catch {
738
805
  return null;
@@ -1599,7 +1666,8 @@ export default function (pi: ExtensionAPI) {
1599
1666
 
1600
1667
  let screenshotContent: any[] = [];
1601
1668
  try {
1602
- const buf = await p.screenshot({ type: "jpeg", quality: 80 });
1669
+ let buf = await p.screenshot({ type: "jpeg", quality: 80, scale: "css" });
1670
+ buf = await constrainScreenshot(p, buf, "image/jpeg", 80);
1603
1671
  screenshotContent = [{ type: "image", data: buf.toString("base64"), mimeType: "image/jpeg" }];
1604
1672
  } catch {}
1605
1673
 
@@ -1741,7 +1809,8 @@ export default function (pi: ExtensionAPI) {
1741
1809
  // Include screenshot like navigate does
1742
1810
  let screenshotContent: any[] = [];
1743
1811
  try {
1744
- const buf = await p.screenshot({ type: "jpeg", quality: 80 });
1812
+ let buf = await p.screenshot({ type: "jpeg", quality: 80, scale: "css" });
1813
+ buf = await constrainScreenshot(p, buf, "image/jpeg", 80);
1745
1814
  screenshotContent = [{
1746
1815
  type: "image",
1747
1816
  data: buf.toString("base64"),
@@ -1802,23 +1871,27 @@ export default function (pi: ExtensionAPI) {
1802
1871
 
1803
1872
  let screenshotBuffer: Buffer;
1804
1873
  let mimeType: string;
1874
+ const quality = params.quality ?? 80;
1805
1875
 
1806
1876
  if (params.selector) {
1807
1877
  // Element screenshots: keep PNG (may have transparency)
1808
1878
  const locator = p.locator(params.selector).first();
1809
- screenshotBuffer = await locator.screenshot({ type: "png" });
1879
+ screenshotBuffer = await locator.screenshot({ type: "png", scale: "css" });
1810
1880
  mimeType = "image/png";
1811
1881
  } else {
1812
1882
  // Viewport/fullpage: use JPEG (3-5x smaller, fine for AI analysis)
1813
- const quality = params.quality ?? 80;
1814
1883
  screenshotBuffer = await p.screenshot({
1815
1884
  fullPage: params.fullPage ?? false,
1816
1885
  type: "jpeg",
1817
1886
  quality,
1887
+ scale: "css",
1818
1888
  });
1819
1889
  mimeType = "image/jpeg";
1820
1890
  }
1821
1891
 
1892
+ // Downscale if dimensions exceed API limit (1568px max)
1893
+ screenshotBuffer = await constrainScreenshot(p, screenshotBuffer, mimeType, quality);
1894
+
1822
1895
  const base64Data = screenshotBuffer.toString("base64");
1823
1896
  const title = await p.title();
1824
1897
  const url = p.url();
@@ -6,20 +6,44 @@
6
6
  * Falls back to raw REST API with GITHUB_TOKEN env var.
7
7
  */
8
8
 
9
- import { execSync } from "node:child_process";
9
+ import { execSync, spawnSync, type SpawnSyncReturns } from "node:child_process";
10
10
 
11
11
  // ─── Auth detection ───────────────────────────────────────────────────────────
12
12
 
13
13
  let _useGhCli: boolean | null = null;
14
14
 
15
- function hasGhCli(): boolean {
15
+ let ghSpawnImpl = (args: string[], input?: string, cwd?: string): SpawnSyncReturns<string> =>
16
+ spawnSync("gh", args, {
17
+ cwd,
18
+ encoding: "utf8",
19
+ stdio: ["pipe", "pipe", "pipe"],
20
+ input,
21
+ });
22
+
23
+ function ghSpawn(args: string[], input?: string, cwd?: string): SpawnSyncReturns<string> {
24
+ return ghSpawnImpl(args, input, cwd);
25
+ }
26
+
27
+ export function resetGhCliDetectionForTests(): void {
28
+ _useGhCli = null;
29
+ ghSpawnImpl = (args: string[], input?: string, cwd?: string): SpawnSyncReturns<string> =>
30
+ spawnSync("gh", args, {
31
+ cwd,
32
+ encoding: "utf8",
33
+ stdio: ["pipe", "pipe", "pipe"],
34
+ input,
35
+ });
36
+ }
37
+
38
+ export function setGhSpawnForTests(fn: (args: string[], input?: string, cwd?: string) => SpawnSyncReturns<string>): void {
39
+ ghSpawnImpl = fn;
40
+ _useGhCli = null;
41
+ }
42
+
43
+ export function hasGhCli(): boolean {
16
44
  if (_useGhCli !== null) return _useGhCli;
17
- try {
18
- execSync("gh auth status", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
19
- _useGhCli = true;
20
- } catch {
21
- _useGhCli = false;
22
- }
45
+ const result = ghSpawn(["auth", "token"]);
46
+ _useGhCli = result.status === 0 && !result.error && !!result.stdout?.trim();
23
47
  return _useGhCli;
24
48
  }
25
49
 
@@ -120,11 +144,6 @@ export async function ghApi<T = unknown>(
120
144
  return fetchApi<T>(endpoint, method, options.params, options.body, token);
121
145
  }
122
146
 
123
- function shellEscape(s: string): string {
124
- // Single-quote wrapping, escaping any existing single quotes
125
- return "'" + s.replace(/'/g, "'\\''") + "'";
126
- }
127
-
128
147
  function ghCliApi<T>(
129
148
  endpoint: string,
130
149
  method: string,
@@ -132,39 +151,36 @@ function ghCliApi<T>(
132
151
  body?: Record<string, unknown>,
133
152
  cwd?: string,
134
153
  ): T {
135
- const parts = ["gh", "api", shellEscape(endpoint), "--method", method];
154
+ const args = ["api", endpoint, "--method", method];
136
155
 
137
156
  if (params) {
138
157
  for (const [key, val] of Object.entries(params)) {
139
158
  if (val === undefined) continue;
140
159
  if (Array.isArray(val)) {
141
160
  for (const v of val) {
142
- parts.push("-f", shellEscape(`${key}[]=${v}`));
161
+ args.push("-f", `${key}[]=${v}`);
143
162
  }
144
163
  } else {
145
- parts.push("-f", shellEscape(`${key}=${String(val)}`));
164
+ args.push("-f", `${key}=${String(val)}`);
146
165
  }
147
166
  }
148
167
  }
149
168
 
150
169
  if (body) {
151
- parts.push("--input", "-");
170
+ args.push("--input", "-");
152
171
  }
153
172
 
154
- try {
155
- const result = execSync(parts.join(" "), {
156
- cwd: cwd ?? process.cwd(),
157
- encoding: "utf8",
158
- stdio: ["pipe", "pipe", "pipe"],
159
- input: body ? JSON.stringify(body) : undefined,
160
- });
161
- if (!result.trim()) return {} as T;
162
- return JSON.parse(result) as T;
163
- } catch (e: unknown) {
164
- const err = e as { stderr?: string; stdout?: string; message?: string };
165
- const msg = err.stderr?.trim() || err.stdout?.trim() || err.message || String(e);
166
- throw new Error(`gh api error: ${msg}`);
173
+ const result = ghSpawn(args, body ? JSON.stringify(body) : undefined, cwd ?? process.cwd());
174
+
175
+ const stdout = result.stdout?.trim() ?? "";
176
+ const stderr = result.stderr?.trim() ?? "";
177
+
178
+ if (result.status !== 0) {
179
+ throw new Error(`gh api error: ${stderr || stdout || result.error?.message || `exit code ${result.status}`}`);
167
180
  }
181
+
182
+ if (!stdout) return {} as T;
183
+ return JSON.parse(stdout) as T;
168
184
  }
169
185
 
170
186
  async function fetchApi<T>(
@@ -18,7 +18,7 @@ import type {
18
18
 
19
19
  import { deriveState } from "./state.js";
20
20
  import type { GSDState } from "./types.js";
21
- import { loadFile, parseContinue, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary } from "./files.js";
21
+ import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary } from "./files.js";
22
22
  export { inlinePriorMilestoneSummary };
23
23
  import type { UatType } from "./files.js";
24
24
  import { loadPrompt } from "./prompt-loader.js";
@@ -36,7 +36,6 @@ import {
36
36
  clearUnitRuntimeRecord,
37
37
  formatExecuteTaskRecoveryStatus,
38
38
  inspectExecuteTaskDurability,
39
- recordUnitProgress,
40
39
  readUnitRuntimeRecord,
41
40
  writeUnitRuntimeRecord,
42
41
  } from "./unit-runtime.js";
@@ -49,6 +48,7 @@ import {
49
48
  formatValidationIssues,
50
49
  } from "./observability-validator.js";
51
50
  import { ensureGitignore } from "./gitignore.js";
51
+ import { runGSDDoctor, rebuildState } from "./doctor.js";
52
52
  import { snapshotSkills, clearSkillSnapshot } from "./skill-discovery.js";
53
53
  import {
54
54
  initMetrics, resetMetrics, snapshotUnitMetrics, getLedger,
@@ -65,11 +65,13 @@ import {
65
65
  } from "./worktree.ts";
66
66
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
67
67
  import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
68
+ import { showNextAction } from "../shared/next-action-ui.js";
68
69
 
69
70
  // ─── State ────────────────────────────────────────────────────────────────────
70
71
 
71
72
  let active = false;
72
73
  let paused = false;
74
+ let stepMode = false;
73
75
  let verbose = false;
74
76
  let cmdCtx: ExtensionCommandContext | null = null;
75
77
  let basePath = "";
@@ -102,6 +104,7 @@ let idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
102
104
  export interface AutoDashboardData {
103
105
  active: boolean;
104
106
  paused: boolean;
107
+ stepMode: boolean;
105
108
  startTime: number;
106
109
  elapsed: number;
107
110
  currentUnit: { type: string; id: string; startedAt: number } | null;
@@ -118,6 +121,7 @@ export function getAutoDashboardData(): AutoDashboardData {
118
121
  return {
119
122
  active,
120
123
  paused,
124
+ stepMode,
121
125
  startTime: autoStartTime,
122
126
  elapsed: (active || paused) ? Date.now() - autoStartTime : 0,
123
127
  currentUnit: currentUnit ? { ...currentUnit } : null,
@@ -138,6 +142,10 @@ export function isAutoPaused(): boolean {
138
142
  return paused;
139
143
  }
140
144
 
145
+ export function isStepMode(): boolean {
146
+ return stepMode;
147
+ }
148
+
141
149
  function clearUnitTimeout(): void {
142
150
  if (unitTimeoutHandle) {
143
151
  clearTimeout(unitTimeoutHandle);
@@ -174,6 +182,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
174
182
  resetMetrics();
175
183
  active = false;
176
184
  paused = false;
185
+ stepMode = false;
177
186
  lastUnit = null;
178
187
  currentUnit = null;
179
188
  currentMilestoneId = null;
@@ -208,8 +217,9 @@ export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Pro
208
217
  // — all needed for resume and dashboard display
209
218
  ctx?.ui.setStatus("gsd-auto", "paused");
210
219
  ctx?.ui.setWidget("gsd-progress", undefined);
220
+ const resumeCmd = stepMode ? "/gsd next" : "/gsd auto";
211
221
  ctx?.ui.notify(
212
- "Auto-mode paused (Escape). Type to interact, or /gsd auto to resume.",
222
+ `${stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
213
223
  "info",
214
224
  );
215
225
  }
@@ -219,19 +229,24 @@ export async function startAuto(
219
229
  pi: ExtensionAPI,
220
230
  base: string,
221
231
  verboseMode: boolean,
232
+ options?: { step?: boolean },
222
233
  ): Promise<void> {
234
+ const requestedStepMode = options?.step ?? false;
235
+
223
236
  // If resuming from paused state, just re-activate and dispatch next unit.
224
237
  // The conversation is still intact — no need to reinitialize everything.
225
238
  if (paused) {
226
239
  paused = false;
227
240
  active = true;
228
241
  verbose = verboseMode;
242
+ // Allow switching between step/auto on resume
243
+ stepMode = requestedStepMode;
229
244
  cmdCtx = ctx;
230
245
  basePath = base;
231
246
  // Re-initialize metrics in case ledger was lost during pause
232
247
  if (!getLedger()) initMetrics(base);
233
- ctx.ui.setStatus("gsd-auto", "auto");
234
- ctx.ui.notify("Auto-mode resumed.", "info");
248
+ ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
249
+ ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
235
250
  await dispatchNextUnit(ctx, pi);
236
251
  return;
237
252
  }
@@ -287,7 +302,7 @@ export async function startAuto(
287
302
  // No active work at all — start a new milestone via the discuss flow.
288
303
  if (!state.activeMilestone || state.phase === "complete") {
289
304
  const { showSmartEntry } = await import("./guided-flow.js");
290
- await showSmartEntry(ctx, pi, base);
305
+ await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
291
306
  return;
292
307
  }
293
308
 
@@ -299,13 +314,14 @@ export async function startAuto(
299
314
  const hasContext = !!(contextFile && await loadFile(contextFile));
300
315
  if (!hasContext) {
301
316
  const { showSmartEntry } = await import("./guided-flow.js");
302
- await showSmartEntry(ctx, pi, base);
317
+ await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
303
318
  return;
304
319
  }
305
320
  // Has context, no roadmap — auto-mode will research + plan it
306
321
  }
307
322
 
308
323
  active = true;
324
+ stepMode = requestedStepMode;
309
325
  verbose = verboseMode;
310
326
  cmdCtx = ctx;
311
327
  basePath = base;
@@ -325,12 +341,13 @@ export async function startAuto(
325
341
  snapshotSkills();
326
342
  }
327
343
 
328
- ctx.ui.setStatus("gsd-auto", "auto");
344
+ ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
345
+ const modeLabel = stepMode ? "Step-mode" : "Auto-mode";
329
346
  const pendingCount = state.registry.filter(m => m.status !== 'complete').length;
330
347
  const scopeMsg = pendingCount > 1
331
348
  ? `Will loop through ${pendingCount} milestones.`
332
349
  : "Will loop until milestone complete.";
333
- ctx.ui.notify(`Auto-mode started. ${scopeMsg}`, "info");
350
+ ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
334
351
 
335
352
  // Dispatch the first unit
336
353
  await dispatchNextUnit(ctx, pi);
@@ -360,11 +377,141 @@ export async function handleAgentEnd(
360
377
  } catch {
361
378
  // Non-fatal
362
379
  }
380
+
381
+ // Post-hook: fix mechanical bookkeeping the LLM may have skipped.
382
+ // 1. Doctor handles: checkbox marking, stub summaries/UATs.
383
+ // 2. STATE.md is always rebuilt from disk state (purely derived, no LLM needed).
384
+ // This is more reliable than prompt instructions for mechanical tasks.
385
+ // Scope to slice level (M001/S01) so doctor checks all tasks within the slice.
386
+ try {
387
+ const scopeParts = currentUnit.id.split("/").slice(0, 2);
388
+ const doctorScope = scopeParts.join("/");
389
+ const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope });
390
+ if (report.fixesApplied.length > 0) {
391
+ ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
392
+ }
393
+ } catch {
394
+ // Non-fatal — doctor failure should never block dispatch
395
+ }
396
+ try {
397
+ await rebuildState(basePath);
398
+ autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id);
399
+ } catch {
400
+ // Non-fatal
401
+ }
402
+ }
403
+
404
+ // In step mode, pause and show a wizard instead of immediately dispatching
405
+ if (stepMode) {
406
+ await showStepWizard(ctx, pi);
407
+ return;
363
408
  }
364
409
 
365
410
  await dispatchNextUnit(ctx, pi);
366
411
  }
367
412
 
413
+ // ─── Step Mode Wizard ─────────────────────────────────────────────────────
414
+
415
+ /**
416
+ * Show the step-mode wizard after a unit completes.
417
+ * Derives the next unit from disk state and presents it to the user.
418
+ * If the user confirms, dispatches the next unit. If not, pauses.
419
+ */
420
+ async function showStepWizard(
421
+ ctx: ExtensionContext,
422
+ pi: ExtensionAPI,
423
+ ): Promise<void> {
424
+ if (!cmdCtx) return;
425
+
426
+ const state = await deriveState(basePath);
427
+ const mid = state.activeMilestone?.id;
428
+
429
+ // Build summary of what just completed
430
+ const justFinished = currentUnit
431
+ ? `${unitVerb(currentUnit.type)} ${currentUnit.id}`
432
+ : "previous unit";
433
+
434
+ // If no active milestone or everything is complete, stop
435
+ if (!mid || state.phase === "complete") {
436
+ await stopAuto(ctx, pi);
437
+ return;
438
+ }
439
+
440
+ // Peek at what's next by examining state
441
+ const nextDesc = describeNextUnit(state);
442
+
443
+ const choice = await showNextAction(cmdCtx, {
444
+ title: `GSD — ${justFinished} complete`,
445
+ summary: [
446
+ `${mid}: ${state.activeMilestone?.title ?? mid}`,
447
+ ...(state.activeSlice ? [`${state.activeSlice.id}: ${state.activeSlice.title}`] : []),
448
+ ],
449
+ actions: [
450
+ {
451
+ id: "continue",
452
+ label: nextDesc.label,
453
+ description: nextDesc.description,
454
+ recommended: true,
455
+ },
456
+ {
457
+ id: "auto",
458
+ label: "Switch to auto",
459
+ description: "Continue without pausing between steps.",
460
+ },
461
+ {
462
+ id: "status",
463
+ label: "View status",
464
+ description: "Open the dashboard.",
465
+ },
466
+ ],
467
+ notYetMessage: "Run /gsd next when ready to continue.",
468
+ });
469
+
470
+ if (choice === "continue") {
471
+ await dispatchNextUnit(ctx, pi);
472
+ } else if (choice === "auto") {
473
+ stepMode = false;
474
+ ctx.ui.setStatus("gsd-auto", "auto");
475
+ ctx.ui.notify("Switched to auto-mode.", "info");
476
+ await dispatchNextUnit(ctx, pi);
477
+ } else if (choice === "status") {
478
+ // Show status then re-show the wizard
479
+ const { fireStatusViaCommand } = await import("./commands.js");
480
+ await fireStatusViaCommand(ctx as ExtensionCommandContext);
481
+ await showStepWizard(ctx, pi);
482
+ } else {
483
+ // "not_yet" — pause
484
+ await pauseAuto(ctx, pi);
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Describe what the next unit will be, based on current state.
490
+ */
491
+ function describeNextUnit(state: GSDState): { label: string; description: string } {
492
+ const sid = state.activeSlice?.id;
493
+ const sTitle = state.activeSlice?.title;
494
+ const tid = state.activeTask?.id;
495
+ const tTitle = state.activeTask?.title;
496
+
497
+ switch (state.phase) {
498
+ case "pre-planning":
499
+ return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." };
500
+ case "planning":
501
+ return { label: `Plan ${sid}: ${sTitle}`, description: "Research and decompose into tasks." };
502
+ case "executing":
503
+ return { label: `Execute ${tid}: ${tTitle}`, description: "Run the next task in a fresh session." };
504
+ case "summarizing":
505
+ return { label: `Complete ${sid}: ${sTitle}`, description: "Write summary, UAT, and merge to main." };
506
+ case "replanning-slice":
507
+ return { label: `Replan ${sid}: ${sTitle}`, description: "Blocker found — replan the slice." };
508
+ case "completing-milestone":
509
+ return { label: "Complete milestone", description: "Write milestone summary." };
510
+ default:
511
+ return { label: "Continue", description: "Execute the next step." };
512
+ }
513
+ }
514
+
368
515
  // ─── Progress Widget ──────────────────────────────────────────────────────
369
516
 
370
517
  function unitVerb(unitType: string): string {
@@ -465,7 +612,8 @@ function updateProgressWidget(
465
612
  ? theme.fg("accent", GLYPH.statusActive)
466
613
  : theme.fg("dim", GLYPH.statusPending);
467
614
  const elapsed = formatAutoElapsed();
468
- const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", "AUTO")}`;
615
+ const modeTag = stepMode ? "NEXT" : "AUTO";
616
+ const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`;
469
617
  const headerRight = elapsed ? theme.fg("dim", elapsed) : "";
470
618
  lines.push(rightAlign(headerLeft, headerRight, width));
471
619
 
@@ -985,6 +1133,17 @@ async function dispatchNextUnit(
985
1133
  if (!runtime) return;
986
1134
  if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return;
987
1135
 
1136
+ // Before triggering recovery, check if the agent is actually producing
1137
+ // work on disk. `git status --porcelain` is cheap and catches any
1138
+ // staged/unstaged/untracked changes the agent made since lastProgressAt.
1139
+ if (detectWorkingTreeActivity(basePath)) {
1140
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
1141
+ lastProgressAt: Date.now(),
1142
+ lastProgressKind: "filesystem-activity",
1143
+ });
1144
+ return;
1145
+ }
1146
+
988
1147
  if (currentUnit) {
989
1148
  const modelId = ctx.model?.id ?? "unknown";
990
1149
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
@@ -2136,6 +2295,25 @@ export function skipExecuteTask(
2136
2295
  return true;
2137
2296
  }
2138
2297
 
2298
+ /**
2299
+ * Detect whether the agent is producing work on disk by checking git for
2300
+ * any working-tree changes (staged, unstaged, or untracked). Returns true
2301
+ * if there are uncommitted changes — meaning the agent is actively working,
2302
+ * even though it hasn't signaled progress through runtime records.
2303
+ */
2304
+ function detectWorkingTreeActivity(cwd: string): boolean {
2305
+ try {
2306
+ const out = execSync("git status --porcelain", {
2307
+ cwd,
2308
+ stdio: ["pipe", "pipe", "pipe"],
2309
+ timeout: 5000,
2310
+ });
2311
+ return out.toString().trim().length > 0;
2312
+ } catch {
2313
+ return false;
2314
+ }
2315
+ }
2316
+
2139
2317
  /**
2140
2318
  * Resolve the expected artifact for a non-execute-task unit to an absolute path.
2141
2319
  * Returns null for unit types that don't produce a single file (execute-task,
@@ -10,8 +10,8 @@ import { join, dirname } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { deriveState } from "./state.js";
12
12
  import { GSDDashboardOverlay } from "./dashboard-overlay.js";
13
- import { showSmartEntry, showQueue, showDiscuss } from "./guided-flow.js";
14
- import { startAuto, stopAuto, isAutoActive, isAutoPaused } from "./auto.js";
13
+ import { showQueue, showDiscuss } from "./guided-flow.js";
14
+ import { startAuto, stopAuto, isAutoActive, isAutoPaused, isStepMode } from "./auto.js";
15
15
  import {
16
16
  getGlobalGSDPreferencesPath,
17
17
  getLegacyGlobalGSDPreferencesPath,
@@ -52,10 +52,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
52
52
 
53
53
  export function registerGSDCommand(pi: ExtensionAPI): void {
54
54
  pi.registerCommand("gsd", {
55
- description: "GSD — Get Stuff Done: /gsd auto|stop|status|queue|prefs|doctor|migrate",
55
+ description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate",
56
56
 
57
57
  getArgumentCompletions: (prefix: string) => {
58
- const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"];
58
+ const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"];
59
59
  const parts = prefix.trim().split(/\s+/);
60
60
 
61
61
  if (parts.length <= 1) {
@@ -112,6 +112,12 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
112
112
  return;
113
113
  }
114
114
 
115
+ if (trimmed === "next" || trimmed.startsWith("next ")) {
116
+ const verboseMode = trimmed.includes("--verbose");
117
+ await startAuto(ctx, pi, process.cwd(), verboseMode, { step: true });
118
+ return;
119
+ }
120
+
115
121
  if (trimmed === "auto" || trimmed.startsWith("auto ")) {
116
122
  const verboseMode = trimmed.includes("--verbose");
117
123
  await startAuto(ctx, pi, process.cwd(), verboseMode);
@@ -143,12 +149,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
143
149
  }
144
150
 
145
151
  if (trimmed === "") {
146
- await showSmartEntry(ctx, pi, process.cwd());
152
+ // Bare /gsd defaults to step mode
153
+ await startAuto(ctx, pi, process.cwd(), false, { step: true });
147
154
  return;
148
155
  }
149
156
 
150
157
  ctx.ui.notify(
151
- `Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate <path>.`,
158
+ `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate <path>.`,
152
159
  "warning",
153
160
  );
154
161
  },
@@ -147,6 +147,13 @@ async function updateStateFile(basePath: string, fixesApplied: string[]): Promis
147
147
  fixesApplied.push(`updated ${path}`);
148
148
  }
149
149
 
150
+ /** Rebuild STATE.md from current disk state. Exported for auto-mode post-hooks. */
151
+ export async function rebuildState(basePath: string): Promise<void> {
152
+ const state = await deriveState(basePath);
153
+ const path = resolveGsdRootFile(basePath, "STATE");
154
+ await saveFile(path, buildStateMarkdown(state));
155
+ }
156
+
150
157
  async function ensureSliceSummaryStub(basePath: string, milestoneId: string, sliceId: string, fixesApplied: string[]): Promise<void> {
151
158
  const path = join(resolveSlicePath(basePath, milestoneId, sliceId) ?? relSlicePath(basePath, milestoneId, sliceId), `${sliceId}-SUMMARY.md`);
152
159
  const absolute = resolveSliceFile(basePath, milestoneId, sliceId, "SUMMARY") ?? join(resolveSlicePath(basePath, milestoneId, sliceId)!, `${sliceId}-SUMMARY.md`);