gsd-pi 0.3.1 → 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 (28) hide show
  1. package/dist/loader.js +0 -0
  2. package/dist/resource-loader.d.ts +3 -3
  3. package/dist/resource-loader.js +10 -4
  4. package/package.json +3 -1
  5. package/src/resources/extensions/browser-tools/index.ts +76 -6
  6. package/src/resources/extensions/gsd/auto.ts +188 -10
  7. package/src/resources/extensions/gsd/commands.ts +13 -6
  8. package/src/resources/extensions/gsd/doctor.ts +7 -0
  9. package/src/resources/extensions/gsd/guided-flow.ts +8 -5
  10. package/src/resources/extensions/gsd/index.ts +8 -0
  11. package/src/resources/extensions/gsd/prompts/discuss.md +70 -26
  12. package/src/resources/extensions/gsd/prompts/worktree-merge.md +51 -17
  13. package/src/resources/extensions/gsd/worktree-command.ts +219 -49
  14. package/src/resources/extensions/gsd/worktree-manager.ts +106 -16
  15. package/src/resources/extensions/mcporter/index.ts +410 -0
  16. package/src/resources/extensions/slash-commands/clear.ts +10 -0
  17. package/src/resources/extensions/slash-commands/index.ts +2 -2
  18. package/src/resources/extensions/voice/index.ts +176 -0
  19. package/src/resources/extensions/voice/speech-recognizer +0 -0
  20. package/src/resources/extensions/voice/speech-recognizer.swift +76 -0
  21. package/dist/modes/interactive/theme/dark.json +0 -85
  22. package/dist/modes/interactive/theme/light.json +0 -84
  23. package/dist/modes/interactive/theme/theme-schema.json +0 -335
  24. package/dist/modes/interactive/theme/theme.d.ts +0 -78
  25. package/dist/modes/interactive/theme/theme.d.ts.map +0 -1
  26. package/dist/modes/interactive/theme/theme.js +0 -949
  27. package/dist/modes/interactive/theme/theme.js.map +0 -1
  28. package/src/resources/extensions/slash-commands/gsd-run.ts +0 -34
package/dist/loader.js CHANGED
File without changes
@@ -15,8 +15,8 @@ import { DefaultResourceLoader } from '@mariozechner/pi-coding-agent';
15
15
  */
16
16
  export declare function initResources(agentDir: string): void;
17
17
  /**
18
- * Constructs a DefaultResourceLoader with no additionalExtensionPaths.
19
- * Extensions are synced to agentDir by initResources() and pi auto-discovers
20
- * them from ~/.gsd/agent/extensions/ via its normal agentDir scan.
18
+ * Constructs a DefaultResourceLoader that loads extensions from both
19
+ * ~/.gsd/agent/extensions/ (GSD's default) and ~/.pi/agent/extensions/ (pi's default).
20
+ * This allows users to use extensions from either location.
21
21
  */
22
22
  export declare function buildResourceLoader(agentDir: string): DefaultResourceLoader;
@@ -1,4 +1,5 @@
1
1
  import { DefaultResourceLoader } from '@mariozechner/pi-coding-agent';
2
+ import { homedir } from 'node:os';
2
3
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
4
  import { dirname, join, resolve } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
@@ -45,10 +46,15 @@ export function initResources(agentDir) {
45
46
  }
46
47
  }
47
48
  /**
48
- * Constructs a DefaultResourceLoader with no additionalExtensionPaths.
49
- * Extensions are synced to agentDir by initResources() and pi auto-discovers
50
- * them from ~/.gsd/agent/extensions/ via its normal agentDir scan.
49
+ * Constructs a DefaultResourceLoader that loads extensions from both
50
+ * ~/.gsd/agent/extensions/ (GSD's default) and ~/.pi/agent/extensions/ (pi's default).
51
+ * This allows users to use extensions from either location.
51
52
  */
52
53
  export function buildResourceLoader(agentDir) {
53
- return new DefaultResourceLoader({ agentDir });
54
+ const piAgentDir = join(homedir(), '.pi', 'agent');
55
+ const piExtensionsDir = join(piAgentDir, 'extensions');
56
+ return new DefaultResourceLoader({
57
+ agentDir,
58
+ additionalExtensionPaths: [piExtensionsDir],
59
+ });
54
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,6 +38,8 @@
38
38
  "test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test 'src/resources/extensions/gsd/tests/*.test.ts' 'src/resources/extensions/gsd/tests/*.test.mjs' 'src/tests/*.test.ts'",
39
39
  "dev": "tsc --watch",
40
40
  "postinstall": "node scripts/postinstall.js",
41
+ "pi:install-global": "node scripts/install-pi-global.js",
42
+ "pi:uninstall-global": "node scripts/uninstall-pi-global.js",
41
43
  "sync-pkg-version": "node scripts/sync-pkg-version.cjs",
42
44
  "prepublishOnly": "npm run sync-pkg-version && npm run build"
43
45
  },
@@ -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")
@@ -731,11 +731,75 @@ async function postActionSummary(p: Page, target?: Page | Frame): Promise<string
731
731
  }
732
732
  }
733
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
+
734
797
  /** Capture a JPEG screenshot for error debugging. Returns base64 or null. */
735
798
  async function captureErrorScreenshot(p: Page | null): Promise<{ data: string; mimeType: string } | null> {
736
799
  if (!p) return null;
737
800
  try {
738
- 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);
739
803
  return { data: buf.toString("base64"), mimeType: "image/jpeg" };
740
804
  } catch {
741
805
  return null;
@@ -1602,7 +1666,8 @@ export default function (pi: ExtensionAPI) {
1602
1666
 
1603
1667
  let screenshotContent: any[] = [];
1604
1668
  try {
1605
- 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);
1606
1671
  screenshotContent = [{ type: "image", data: buf.toString("base64"), mimeType: "image/jpeg" }];
1607
1672
  } catch {}
1608
1673
 
@@ -1744,7 +1809,8 @@ export default function (pi: ExtensionAPI) {
1744
1809
  // Include screenshot like navigate does
1745
1810
  let screenshotContent: any[] = [];
1746
1811
  try {
1747
- 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);
1748
1814
  screenshotContent = [{
1749
1815
  type: "image",
1750
1816
  data: buf.toString("base64"),
@@ -1805,23 +1871,27 @@ export default function (pi: ExtensionAPI) {
1805
1871
 
1806
1872
  let screenshotBuffer: Buffer;
1807
1873
  let mimeType: string;
1874
+ const quality = params.quality ?? 80;
1808
1875
 
1809
1876
  if (params.selector) {
1810
1877
  // Element screenshots: keep PNG (may have transparency)
1811
1878
  const locator = p.locator(params.selector).first();
1812
- screenshotBuffer = await locator.screenshot({ type: "png" });
1879
+ screenshotBuffer = await locator.screenshot({ type: "png", scale: "css" });
1813
1880
  mimeType = "image/png";
1814
1881
  } else {
1815
1882
  // Viewport/fullpage: use JPEG (3-5x smaller, fine for AI analysis)
1816
- const quality = params.quality ?? 80;
1817
1883
  screenshotBuffer = await p.screenshot({
1818
1884
  fullPage: params.fullPage ?? false,
1819
1885
  type: "jpeg",
1820
1886
  quality,
1887
+ scale: "css",
1821
1888
  });
1822
1889
  mimeType = "image/jpeg";
1823
1890
  }
1824
1891
 
1892
+ // Downscale if dimensions exceed API limit (1568px max)
1893
+ screenshotBuffer = await constrainScreenshot(p, screenshotBuffer, mimeType, quality);
1894
+
1825
1895
  const base64Data = screenshotBuffer.toString("base64");
1826
1896
  const title = await p.title();
1827
1897
  const url = p.url();
@@ -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 Shit 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`);
@@ -31,13 +31,14 @@ let pendingAutoStart: {
31
31
  pi: ExtensionAPI;
32
32
  basePath: string;
33
33
  milestoneId: string; // the milestone being discussed
34
+ step?: boolean; // preserve step mode through discuss → auto transition
34
35
  } | null = null;
35
36
 
36
37
  /** Called from agent_end to check if auto-mode should start after discuss */
37
38
  export function checkAutoStartAfterDiscuss(): boolean {
38
39
  if (!pendingAutoStart) return false;
39
40
 
40
- const { ctx, pi, basePath, milestoneId } = pendingAutoStart;
41
+ const { ctx, pi, basePath, milestoneId, step } = pendingAutoStart;
41
42
 
42
43
  // Don't fire until the discuss phase has actually produced a context file
43
44
  // for the milestone being discussed. agent_end fires after every LLM turn,
@@ -47,7 +48,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
47
48
  if (!contextFile) return false; // no context yet — keep waiting
48
49
 
49
50
  pendingAutoStart = null;
50
- startAuto(ctx, pi, basePath, false).catch(() => {});
51
+ startAuto(ctx, pi, basePath, false, { step }).catch(() => {});
51
52
  return true;
52
53
  }
53
54
 
@@ -435,7 +436,9 @@ export async function showSmartEntry(
435
436
  ctx: ExtensionCommandContext,
436
437
  pi: ExtensionAPI,
437
438
  basePath: string,
439
+ options?: { step?: boolean },
438
440
  ): Promise<void> {
441
+ const stepMode = options?.step;
439
442
 
440
443
  // ── Ensure git repo exists — GSD needs it for branch-per-slice ──────
441
444
  try {
@@ -501,7 +504,7 @@ export async function showSmartEntry(
501
504
 
502
505
  if (isFirst) {
503
506
  // First ever — skip wizard, just ask directly
504
- pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
507
+ pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
505
508
  dispatchWorkflow(pi, buildDiscussPrompt(nextId,
506
509
  `New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`,
507
510
  basePath
@@ -522,7 +525,7 @@ export async function showSmartEntry(
522
525
  });
523
526
 
524
527
  if (choice === "new_milestone") {
525
- pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
528
+ pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
526
529
  dispatchWorkflow(pi, buildDiscussPrompt(nextId,
527
530
  `New milestone ${nextId}.`,
528
531
  basePath
@@ -560,7 +563,7 @@ export async function showSmartEntry(
560
563
  const milestoneIds = findMilestoneIds(basePath);
561
564
  const nextId = `M${String(milestoneIds.length + 1).padStart(3, "0")}`;
562
565
 
563
- pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
566
+ pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
564
567
  dispatchWorkflow(pi, buildDiscussPrompt(nextId,
565
568
  `New milestone ${nextId}.`,
566
569
  basePath