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.
- package/README.md +3 -1
- package/dist/cli.js +112 -5
- package/dist/loader.js +0 -0
- package/dist/resource-loader.d.ts +3 -3
- package/dist/resource-loader.js +10 -4
- package/dist/tool-bootstrap.d.ts +4 -0
- package/dist/tool-bootstrap.js +74 -0
- package/dist/wizard.js +15 -5
- package/package.json +6 -2
- package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +48 -0
- package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
- package/scripts/postinstall.js +8 -0
- package/src/resources/extensions/bg-shell/index.ts +57 -8
- package/src/resources/extensions/browser-tools/index.ts +80 -7
- package/src/resources/extensions/github/gh-api.ts +46 -30
- package/src/resources/extensions/gsd/auto.ts +188 -10
- package/src/resources/extensions/gsd/commands.ts +13 -6
- package/src/resources/extensions/gsd/doctor.ts +7 -0
- package/src/resources/extensions/gsd/guided-flow.ts +9 -6
- package/src/resources/extensions/gsd/index.ts +32 -2
- package/src/resources/extensions/gsd/prompts/discuss.md +73 -27
- package/src/resources/extensions/gsd/prompts/system.md +1 -1
- package/src/resources/extensions/gsd/prompts/worktree-merge.md +51 -17
- package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
- package/src/resources/extensions/gsd/worktree-command.ts +219 -49
- package/src/resources/extensions/gsd/worktree-manager.ts +106 -16
- package/src/resources/extensions/mcporter/index.ts +410 -0
- package/src/resources/extensions/slash-commands/clear.ts +10 -0
- package/src/resources/extensions/slash-commands/index.ts +2 -2
- package/src/resources/extensions/voice/index.ts +176 -0
- package/src/resources/extensions/voice/speech-recognizer +0 -0
- package/src/resources/extensions/voice/speech-recognizer.swift +76 -0
- package/dist/modes/interactive/theme/dark.json +0 -85
- package/dist/modes/interactive/theme/light.json +0 -84
- package/dist/modes/interactive/theme/theme-schema.json +0 -335
- package/dist/modes/interactive/theme/theme.d.ts +0 -78
- package/dist/modes/interactive/theme/theme.d.ts.map +0 -1
- package/dist/modes/interactive/theme/theme.js +0 -949
- package/dist/modes/interactive/theme/theme.js.map +0 -1
- 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
|
-
* -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
161
|
+
args.push("-f", `${key}[]=${v}`);
|
|
143
162
|
}
|
|
144
163
|
} else {
|
|
145
|
-
|
|
164
|
+
args.push("-f", `${key}=${String(val)}`);
|
|
146
165
|
}
|
|
147
166
|
}
|
|
148
167
|
}
|
|
149
168
|
|
|
150
169
|
if (body) {
|
|
151
|
-
|
|
170
|
+
args.push("--input", "-");
|
|
152
171
|
}
|
|
153
172
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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`);
|