gsd-pi 2.37.1-dev.d3ace49 → 2.38.0-dev.96dc7fb
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/dist/cli.js +9 -0
- package/dist/extension-discovery.d.ts +5 -3
- package/dist/extension-discovery.js +14 -9
- package/dist/resources/extensions/browser-tools/package.json +3 -1
- package/dist/resources/extensions/cmux/index.js +55 -1
- package/dist/resources/extensions/context7/package.json +1 -1
- package/dist/resources/extensions/google-search/package.json +3 -1
- package/dist/resources/extensions/gsd/auto-loop.js +7 -1
- package/dist/resources/extensions/gsd/auto-start.js +6 -1
- package/dist/resources/extensions/gsd/auto-worktree-sync.js +11 -4
- package/dist/resources/extensions/gsd/captures.js +9 -1
- package/dist/resources/extensions/gsd/commands-handlers.js +16 -3
- package/dist/resources/extensions/gsd/commands.js +20 -1
- package/dist/resources/extensions/gsd/doctor-checks.js +82 -0
- package/dist/resources/extensions/gsd/doctor-environment.js +78 -0
- package/dist/resources/extensions/gsd/doctor-format.js +15 -0
- package/dist/resources/extensions/gsd/doctor.js +184 -11
- package/dist/resources/extensions/gsd/package.json +1 -1
- package/dist/resources/extensions/gsd/worktree.js +35 -16
- package/dist/resources/extensions/subagent/index.js +12 -3
- package/dist/resources/extensions/universal-config/package.json +1 -1
- package/dist/welcome-screen.d.ts +12 -0
- package/dist/welcome-screen.js +53 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/package-manager.js +8 -4
- package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/package-manager.ts +8 -4
- package/pkg/package.json +1 -1
- package/src/resources/extensions/cmux/index.ts +57 -1
- package/src/resources/extensions/gsd/auto-loop.ts +13 -1
- package/src/resources/extensions/gsd/auto-start.ts +7 -1
- package/src/resources/extensions/gsd/auto-worktree-sync.ts +12 -3
- package/src/resources/extensions/gsd/captures.ts +10 -1
- package/src/resources/extensions/gsd/commands-handlers.ts +17 -2
- package/src/resources/extensions/gsd/commands.ts +21 -1
- package/src/resources/extensions/gsd/doctor-checks.ts +75 -0
- package/src/resources/extensions/gsd/doctor-environment.ts +82 -1
- package/src/resources/extensions/gsd/doctor-format.ts +20 -0
- package/src/resources/extensions/gsd/doctor-types.ts +16 -1
- package/src/resources/extensions/gsd/doctor.ts +177 -13
- package/src/resources/extensions/gsd/tests/cmux.test.ts +93 -0
- package/src/resources/extensions/gsd/tests/doctor-enhancements.test.ts +266 -0
- package/src/resources/extensions/gsd/tests/worktree.test.ts +47 -0
- package/src/resources/extensions/gsd/worktree.ts +35 -15
- package/src/resources/extensions/subagent/index.ts +12 -3
package/dist/cli.js
CHANGED
|
@@ -505,6 +505,15 @@ if (enabledModelPatterns && enabledModelPatterns.length > 0) {
|
|
|
505
505
|
session.setScopedModels(scopedModels);
|
|
506
506
|
}
|
|
507
507
|
}
|
|
508
|
+
// Welcome screen — shown on every fresh interactive session before TUI takes over
|
|
509
|
+
{
|
|
510
|
+
const { printWelcomeScreen } = await import('./welcome-screen.js');
|
|
511
|
+
printWelcomeScreen({
|
|
512
|
+
version: process.env.GSD_VERSION || '0.0.0',
|
|
513
|
+
modelName: settingsManager.getDefaultModel() || undefined,
|
|
514
|
+
provider: settingsManager.getDefaultProvider() || undefined,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
508
517
|
const interactiveMode = new InteractiveMode(session);
|
|
509
518
|
markStartup('InteractiveMode');
|
|
510
519
|
printStartupTimings();
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolves the entry-point file(s) for a single extension directory.
|
|
3
3
|
*
|
|
4
|
-
* 1. If the directory contains a package.json with a `pi
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 1. If the directory contains a package.json with a `pi` manifest object,
|
|
5
|
+
* the manifest is authoritative:
|
|
6
|
+
* - `pi.extensions` array → resolve each entry relative to the directory.
|
|
7
|
+
* - `pi: {}` (no extensions) → return empty (library opt-out, e.g. cmux).
|
|
8
|
+
* 2. Only when no `pi` manifest exists does it fall back to `index.ts` → `index.js`.
|
|
7
9
|
*/
|
|
8
10
|
export declare function resolveExtensionEntries(dir: string): string[];
|
|
9
11
|
/**
|
|
@@ -6,24 +6,29 @@ function isExtensionFile(name) {
|
|
|
6
6
|
/**
|
|
7
7
|
* Resolves the entry-point file(s) for a single extension directory.
|
|
8
8
|
*
|
|
9
|
-
* 1. If the directory contains a package.json with a `pi
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* 1. If the directory contains a package.json with a `pi` manifest object,
|
|
10
|
+
* the manifest is authoritative:
|
|
11
|
+
* - `pi.extensions` array → resolve each entry relative to the directory.
|
|
12
|
+
* - `pi: {}` (no extensions) → return empty (library opt-out, e.g. cmux).
|
|
13
|
+
* 2. Only when no `pi` manifest exists does it fall back to `index.ts` → `index.js`.
|
|
12
14
|
*/
|
|
13
15
|
export function resolveExtensionEntries(dir) {
|
|
14
16
|
const packageJsonPath = join(dir, 'package.json');
|
|
15
17
|
if (existsSync(packageJsonPath)) {
|
|
16
18
|
try {
|
|
17
19
|
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
if (pkg?.pi && typeof pkg.pi === 'object') {
|
|
21
|
+
// When a pi manifest exists, it is authoritative — don't fall through
|
|
22
|
+
// to index.ts/index.js auto-detection. This allows library directories
|
|
23
|
+
// (like cmux) to opt out by declaring "pi": {} with no extensions.
|
|
24
|
+
const declared = pkg.pi.extensions;
|
|
25
|
+
if (!Array.isArray(declared) || declared.length === 0) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return declared
|
|
21
29
|
.filter((entry) => typeof entry === 'string')
|
|
22
30
|
.map((entry) => resolve(dir, entry))
|
|
23
31
|
.filter((entry) => existsSync(entry));
|
|
24
|
-
if (resolved.length > 0) {
|
|
25
|
-
return resolved;
|
|
26
|
-
}
|
|
27
32
|
}
|
|
28
33
|
}
|
|
29
34
|
catch {
|
|
@@ -237,11 +237,14 @@ export class CmuxClient {
|
|
|
237
237
|
return extractSurfaceIds(parsed);
|
|
238
238
|
}
|
|
239
239
|
async createSplit(direction) {
|
|
240
|
+
return this.createSplitFrom(this.config.surfaceId, direction);
|
|
241
|
+
}
|
|
242
|
+
async createSplitFrom(sourceSurfaceId, direction) {
|
|
240
243
|
if (!this.config.splits)
|
|
241
244
|
return null;
|
|
242
245
|
const before = new Set(await this.listSurfaceIds());
|
|
243
246
|
const args = ["new-split", direction];
|
|
244
|
-
const scopedArgs = this.appendSurface(this.appendWorkspace(args),
|
|
247
|
+
const scopedArgs = this.appendSurface(this.appendWorkspace(args), sourceSurfaceId);
|
|
245
248
|
await this.runAsync(scopedArgs);
|
|
246
249
|
const after = await this.listSurfaceIds();
|
|
247
250
|
for (const id of after) {
|
|
@@ -250,6 +253,57 @@ export class CmuxClient {
|
|
|
250
253
|
}
|
|
251
254
|
return null;
|
|
252
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Create a grid of surfaces for parallel agent execution.
|
|
258
|
+
*
|
|
259
|
+
* Layout strategy (gsd stays in the original surface):
|
|
260
|
+
* 1 agent: [gsd | A]
|
|
261
|
+
* 2 agents: [gsd | A]
|
|
262
|
+
* [ | B]
|
|
263
|
+
* 3 agents: [gsd | A]
|
|
264
|
+
* [ C | B]
|
|
265
|
+
* 4 agents: [gsd | A]
|
|
266
|
+
* [ C | B] (D splits from B downward)
|
|
267
|
+
* [ | D]
|
|
268
|
+
*
|
|
269
|
+
* Returns surface IDs in order, or empty array on failure.
|
|
270
|
+
*/
|
|
271
|
+
async createGridLayout(count) {
|
|
272
|
+
if (!this.config.splits || count <= 0)
|
|
273
|
+
return [];
|
|
274
|
+
const surfaces = [];
|
|
275
|
+
// First split: create right column from the gsd surface
|
|
276
|
+
const rightCol = await this.createSplitFrom(this.config.surfaceId, "right");
|
|
277
|
+
if (!rightCol)
|
|
278
|
+
return [];
|
|
279
|
+
surfaces.push(rightCol);
|
|
280
|
+
if (count === 1)
|
|
281
|
+
return surfaces;
|
|
282
|
+
// Second split: split right column down → bottom-right
|
|
283
|
+
const bottomRight = await this.createSplitFrom(rightCol, "down");
|
|
284
|
+
if (!bottomRight)
|
|
285
|
+
return surfaces;
|
|
286
|
+
surfaces.push(bottomRight);
|
|
287
|
+
if (count === 2)
|
|
288
|
+
return surfaces;
|
|
289
|
+
// Third split: split gsd surface down → bottom-left
|
|
290
|
+
const bottomLeft = await this.createSplitFrom(this.config.surfaceId, "down");
|
|
291
|
+
if (!bottomLeft)
|
|
292
|
+
return surfaces;
|
|
293
|
+
surfaces.push(bottomLeft);
|
|
294
|
+
if (count === 3)
|
|
295
|
+
return surfaces;
|
|
296
|
+
// Fourth+: split subsequent surfaces down from the last created
|
|
297
|
+
let lastSurface = bottomRight;
|
|
298
|
+
for (let i = 3; i < count; i++) {
|
|
299
|
+
const next = await this.createSplitFrom(lastSurface, "down");
|
|
300
|
+
if (!next)
|
|
301
|
+
break;
|
|
302
|
+
surfaces.push(next);
|
|
303
|
+
lastSurface = next;
|
|
304
|
+
}
|
|
305
|
+
return surfaces;
|
|
306
|
+
}
|
|
253
307
|
async sendSurface(surfaceId, text) {
|
|
254
308
|
const payload = text.endsWith("\n") ? text : `${text}\n`;
|
|
255
309
|
const stdout = await this.runAsync(["send-surface", "--surface", surfaceId, payload]);
|
|
@@ -373,7 +373,7 @@ export async function autoLoop(ctx, pi, s, deps) {
|
|
|
373
373
|
await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
374
374
|
}
|
|
375
375
|
const incomplete = state.registry.filter((m) => m.status !== "complete" && m.status !== "parked");
|
|
376
|
-
if (incomplete.length === 0) {
|
|
376
|
+
if (incomplete.length === 0 && state.registry.length > 0) {
|
|
377
377
|
// All milestones complete — merge milestone branch before stopping
|
|
378
378
|
if (s.currentMilestoneId) {
|
|
379
379
|
deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
|
|
@@ -382,6 +382,12 @@ export async function autoLoop(ctx, pi, s, deps) {
|
|
|
382
382
|
deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, "All milestones complete.", "success");
|
|
383
383
|
await deps.stopAuto(ctx, pi, "All milestones complete");
|
|
384
384
|
}
|
|
385
|
+
else if (incomplete.length === 0 && state.registry.length === 0) {
|
|
386
|
+
// Empty registry — no milestones visible, likely a path resolution bug
|
|
387
|
+
const diag = `basePath=${s.basePath}, phase=${state.phase}`;
|
|
388
|
+
ctx.ui.notify(`No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, "error");
|
|
389
|
+
await deps.stopAuto(ctx, pi, `No milestones found — check basePath resolution`);
|
|
390
|
+
}
|
|
385
391
|
else if (state.phase === "blocked") {
|
|
386
392
|
const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
|
|
387
393
|
await deps.stopAuto(ctx, pi, blockerMsg);
|
|
@@ -303,11 +303,16 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
|
|
|
303
303
|
// ── Auto-worktree setup ──
|
|
304
304
|
s.originalBasePath = base;
|
|
305
305
|
const isUnderGsdWorktrees = (p) => {
|
|
306
|
+
// Direct layout: /.gsd/worktrees/
|
|
306
307
|
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
|
|
307
308
|
if (p.includes(marker))
|
|
308
309
|
return true;
|
|
309
310
|
const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`;
|
|
310
|
-
|
|
311
|
+
if (p.endsWith(worktreesSuffix))
|
|
312
|
+
return true;
|
|
313
|
+
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
|
|
314
|
+
const symlinkRe = new RegExp(`\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees(?:\\${pathSep}|$)`);
|
|
315
|
+
return symlinkRe.test(p);
|
|
311
316
|
};
|
|
312
317
|
if (s.currentMilestoneId &&
|
|
313
318
|
shouldUseWorktreeIsolation() &&
|
|
@@ -115,10 +115,17 @@ export function checkResourcesStale(versionOnStart) {
|
|
|
115
115
|
* Returns the corrected base path.
|
|
116
116
|
*/
|
|
117
117
|
export function escapeStaleWorktree(base) {
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
// Direct layout: /.gsd/worktrees/
|
|
119
|
+
const directMarker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
|
|
120
|
+
let idx = base.indexOf(directMarker);
|
|
121
|
+
if (idx === -1) {
|
|
122
|
+
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
|
|
123
|
+
const symlinkRe = new RegExp(`\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees\\${pathSep}`);
|
|
124
|
+
const match = base.match(symlinkRe);
|
|
125
|
+
if (!match || match.index === undefined)
|
|
126
|
+
return base;
|
|
127
|
+
idx = match.index;
|
|
128
|
+
}
|
|
122
129
|
// base is inside .gsd/worktrees/<something> — extract the project root
|
|
123
130
|
const projectRoot = base.slice(0, idx);
|
|
124
131
|
try {
|
|
@@ -30,8 +30,16 @@ const VALID_CLASSIFICATIONS = [
|
|
|
30
30
|
*/
|
|
31
31
|
export function resolveCapturesPath(basePath) {
|
|
32
32
|
const resolved = resolve(basePath);
|
|
33
|
+
// Direct layout: /.gsd/worktrees/
|
|
33
34
|
const worktreeMarker = `${sep}.gsd${sep}worktrees${sep}`;
|
|
34
|
-
|
|
35
|
+
let idx = resolved.indexOf(worktreeMarker);
|
|
36
|
+
if (idx === -1) {
|
|
37
|
+
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
|
|
38
|
+
const symlinkRe = new RegExp(`\\${sep}\\.gsd\\${sep}projects\\${sep}[a-f0-9]+\\${sep}worktrees\\${sep}`);
|
|
39
|
+
const match = resolved.match(symlinkRe);
|
|
40
|
+
if (match && match.index !== undefined)
|
|
41
|
+
idx = match.index;
|
|
42
|
+
}
|
|
35
43
|
if (idx !== -1) {
|
|
36
44
|
// basePath is inside a worktree — resolve to project root
|
|
37
45
|
const projectRoot = resolved.slice(0, idx);
|
|
@@ -10,7 +10,7 @@ import { deriveState } from "./state.js";
|
|
|
10
10
|
import { gsdRoot } from "./paths.js";
|
|
11
11
|
import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js";
|
|
12
12
|
import { appendOverride, appendKnowledge } from "./files.js";
|
|
13
|
-
import { formatDoctorIssuesForPrompt, formatDoctorReport, runGSDDoctor, selectDoctorScope, filterDoctorIssues, } from "./doctor.js";
|
|
13
|
+
import { formatDoctorIssuesForPrompt, formatDoctorReport, formatDoctorReportJson, runGSDDoctor, selectDoctorScope, filterDoctorIssues, } from "./doctor.js";
|
|
14
14
|
import { isAutoActive } from "./auto.js";
|
|
15
15
|
import { projectRoot } from "./commands.js";
|
|
16
16
|
import { loadPrompt } from "./prompt-loader.js";
|
|
@@ -28,15 +28,28 @@ export function dispatchDoctorHeal(pi, scope, reportText, structuredIssues) {
|
|
|
28
28
|
}
|
|
29
29
|
export async function handleDoctor(args, ctx, pi) {
|
|
30
30
|
const trimmed = args.trim();
|
|
31
|
-
|
|
31
|
+
// Extract flags before positional parsing
|
|
32
|
+
const jsonMode = trimmed.includes("--json");
|
|
33
|
+
const dryRun = trimmed.includes("--dry-run");
|
|
34
|
+
const includeBuild = trimmed.includes("--build");
|
|
35
|
+
const includeTests = trimmed.includes("--test");
|
|
36
|
+
const stripped = trimmed.replace(/--json|--dry-run|--build|--test/g, "").trim();
|
|
37
|
+
const parts = stripped ? stripped.split(/\s+/) : [];
|
|
32
38
|
const mode = parts[0] === "fix" || parts[0] === "heal" || parts[0] === "audit" ? parts[0] : "doctor";
|
|
33
39
|
const requestedScope = mode === "doctor" ? parts[0] : parts[1];
|
|
34
40
|
const scope = await selectDoctorScope(projectRoot(), requestedScope);
|
|
35
41
|
const effectiveScope = mode === "audit" ? requestedScope : scope;
|
|
36
42
|
const report = await runGSDDoctor(projectRoot(), {
|
|
37
|
-
fix: mode === "fix" || mode === "heal",
|
|
43
|
+
fix: mode === "fix" || mode === "heal" || dryRun,
|
|
44
|
+
dryRun,
|
|
38
45
|
scope: effectiveScope,
|
|
46
|
+
includeBuild,
|
|
47
|
+
includeTests,
|
|
39
48
|
});
|
|
49
|
+
if (jsonMode) {
|
|
50
|
+
ctx.ui.notify(formatDoctorReportJson(report), "info");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
40
53
|
const reportText = formatDoctorReport(report, {
|
|
41
54
|
scope: effectiveScope,
|
|
42
55
|
includeWarnings: mode === "audit",
|
|
@@ -135,7 +135,7 @@ async function guardRemoteSession(ctx, pi) {
|
|
|
135
135
|
}
|
|
136
136
|
export function registerGSDCommand(pi) {
|
|
137
137
|
pi.registerCommand("gsd", {
|
|
138
|
-
description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|update",
|
|
138
|
+
description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|capture|triage|dispatch|history|undo|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update",
|
|
139
139
|
getArgumentCompletions: (prefix) => {
|
|
140
140
|
const subcommands = [
|
|
141
141
|
{ cmd: "help", desc: "Categorized command reference with descriptions" },
|
|
@@ -186,7 +186,11 @@ export function registerGSDCommand(pi) {
|
|
|
186
186
|
{ cmd: "templates", desc: "List available workflow templates" },
|
|
187
187
|
{ cmd: "extensions", desc: "Manage extensions (list, enable, disable, info)" },
|
|
188
188
|
];
|
|
189
|
+
const hasTrailingSpace = prefix.endsWith(" ");
|
|
189
190
|
const parts = prefix.trim().split(/\s+/);
|
|
191
|
+
if (hasTrailingSpace && parts.length >= 1) {
|
|
192
|
+
parts.push("");
|
|
193
|
+
}
|
|
190
194
|
if (parts.length <= 1) {
|
|
191
195
|
return subcommands
|
|
192
196
|
.filter((item) => item.cmd.startsWith(parts[0] ?? ""))
|
|
@@ -468,6 +472,10 @@ export function registerGSDCommand(pi) {
|
|
|
468
472
|
{ cmd: "fix", desc: "Auto-fix detected issues" },
|
|
469
473
|
{ cmd: "heal", desc: "AI-driven deep healing" },
|
|
470
474
|
{ cmd: "audit", desc: "Run health audit without fixing" },
|
|
475
|
+
{ cmd: "--dry-run", desc: "Show what --fix would change without applying" },
|
|
476
|
+
{ cmd: "--json", desc: "Output report as JSON (CI/tooling friendly)" },
|
|
477
|
+
{ cmd: "--build", desc: "Include slow build health check (npm run build)" },
|
|
478
|
+
{ cmd: "--test", desc: "Include slow test health check (npm test)" },
|
|
471
479
|
];
|
|
472
480
|
if (parts.length <= 2) {
|
|
473
481
|
return modes
|
|
@@ -491,6 +499,17 @@ export function registerGSDCommand(pi) {
|
|
|
491
499
|
.filter((p) => p.cmd.startsWith(phasePrefix))
|
|
492
500
|
.map((p) => ({ value: `dispatch ${p.cmd}`, label: p.cmd, description: p.desc }));
|
|
493
501
|
}
|
|
502
|
+
if (parts[0] === "rate" && parts.length <= 2) {
|
|
503
|
+
const tierPrefix = parts[1] ?? "";
|
|
504
|
+
const tiers = [
|
|
505
|
+
{ cmd: "over", desc: "Model was overqualified for this task" },
|
|
506
|
+
{ cmd: "ok", desc: "Model was appropriate for this task" },
|
|
507
|
+
{ cmd: "under", desc: "Model was underqualified for this task" },
|
|
508
|
+
];
|
|
509
|
+
return tiers
|
|
510
|
+
.filter((t) => t.cmd.startsWith(tierPrefix))
|
|
511
|
+
.map((t) => ({ value: `rate ${t.cmd}`, label: t.cmd, description: t.desc }));
|
|
512
|
+
}
|
|
494
513
|
return [];
|
|
495
514
|
},
|
|
496
515
|
async handler(args, ctx) {
|
|
@@ -618,6 +618,88 @@ export async function checkRuntimeHealth(basePath, issues, fixesApplied, shouldF
|
|
|
618
618
|
catch {
|
|
619
619
|
// Non-fatal — external state check failed
|
|
620
620
|
}
|
|
621
|
+
// ── Metrics ledger integrity ───────────────────────────────────────────
|
|
622
|
+
try {
|
|
623
|
+
const metricsPath = join(root, "metrics.json");
|
|
624
|
+
if (existsSync(metricsPath)) {
|
|
625
|
+
try {
|
|
626
|
+
const raw = readFileSync(metricsPath, "utf-8");
|
|
627
|
+
const ledger = JSON.parse(raw);
|
|
628
|
+
if (ledger.version !== 1 || !Array.isArray(ledger.units)) {
|
|
629
|
+
issues.push({
|
|
630
|
+
severity: "warning",
|
|
631
|
+
code: "metrics_ledger_corrupt",
|
|
632
|
+
scope: "project",
|
|
633
|
+
unitId: "project",
|
|
634
|
+
message: "metrics.json has an unexpected structure (version !== 1 or units is not an array) — metrics data may be unreliable",
|
|
635
|
+
file: ".gsd/metrics.json",
|
|
636
|
+
fixable: false,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
issues.push({
|
|
642
|
+
severity: "warning",
|
|
643
|
+
code: "metrics_ledger_corrupt",
|
|
644
|
+
scope: "project",
|
|
645
|
+
unitId: "project",
|
|
646
|
+
message: "metrics.json is not valid JSON — metrics data may be corrupt",
|
|
647
|
+
file: ".gsd/metrics.json",
|
|
648
|
+
fixable: false,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
// Non-fatal — metrics check failed
|
|
655
|
+
}
|
|
656
|
+
// ── Large planning file detection ──────────────────────────────────────
|
|
657
|
+
// Files over 100KB can cause LLM context pressure. Report the worst offenders.
|
|
658
|
+
try {
|
|
659
|
+
const MAX_FILE_BYTES = 100 * 1024; // 100KB
|
|
660
|
+
const milestonesPath = milestonesDir(basePath);
|
|
661
|
+
if (existsSync(milestonesPath)) {
|
|
662
|
+
const largeFiles = [];
|
|
663
|
+
function scanForLargeFiles(dir, depth = 0) {
|
|
664
|
+
if (depth > 6)
|
|
665
|
+
return;
|
|
666
|
+
try {
|
|
667
|
+
for (const entry of readdirSync(dir)) {
|
|
668
|
+
const full = join(dir, entry);
|
|
669
|
+
try {
|
|
670
|
+
const s = statSync(full);
|
|
671
|
+
if (s.isDirectory()) {
|
|
672
|
+
scanForLargeFiles(full, depth + 1);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (entry.endsWith(".md") && s.size > MAX_FILE_BYTES) {
|
|
676
|
+
largeFiles.push({ path: full.replace(basePath + "/", ""), sizeKB: Math.round(s.size / 1024) });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
catch { /* skip entry */ }
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
catch { /* skip dir */ }
|
|
683
|
+
}
|
|
684
|
+
scanForLargeFiles(milestonesPath);
|
|
685
|
+
if (largeFiles.length > 0) {
|
|
686
|
+
largeFiles.sort((a, b) => b.sizeKB - a.sizeKB);
|
|
687
|
+
const worst = largeFiles[0];
|
|
688
|
+
issues.push({
|
|
689
|
+
severity: "warning",
|
|
690
|
+
code: "large_planning_file",
|
|
691
|
+
scope: "project",
|
|
692
|
+
unitId: "project",
|
|
693
|
+
message: `${largeFiles.length} planning file(s) exceed 100KB — largest: ${worst.path} (${worst.sizeKB}KB). Large files cause LLM context pressure.`,
|
|
694
|
+
file: worst.path,
|
|
695
|
+
fixable: false,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// Non-fatal — large file scan failed
|
|
702
|
+
}
|
|
621
703
|
}
|
|
622
704
|
/**
|
|
623
705
|
* Build STATE.md markdown content from derived state.
|
|
@@ -355,6 +355,63 @@ function checkGitRemote(basePath) {
|
|
|
355
355
|
}
|
|
356
356
|
return { name: "git_remote", status: "ok", message: "Git remote reachable" };
|
|
357
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* Check if the project build passes (opt-in slow check, use --build flag).
|
|
360
|
+
* Runs npm run build and reports failure as env_build.
|
|
361
|
+
*/
|
|
362
|
+
function checkBuildHealth(basePath) {
|
|
363
|
+
const pkgPath = join(basePath, "package.json");
|
|
364
|
+
if (!existsSync(pkgPath))
|
|
365
|
+
return null;
|
|
366
|
+
try {
|
|
367
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
368
|
+
const buildScript = pkg.scripts?.build;
|
|
369
|
+
if (!buildScript)
|
|
370
|
+
return null;
|
|
371
|
+
const result = tryExec("npm run build 2>&1", basePath);
|
|
372
|
+
if (result === null) {
|
|
373
|
+
return {
|
|
374
|
+
name: "build",
|
|
375
|
+
status: "error",
|
|
376
|
+
message: "Build failed — npm run build exited non-zero",
|
|
377
|
+
detail: "Fix build errors before dispatching work",
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return { name: "build", status: "ok", message: "Build passes" };
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Check if tests pass (opt-in slow check, use --test flag).
|
|
388
|
+
* Runs npm test and reports failures as env_test.
|
|
389
|
+
*/
|
|
390
|
+
function checkTestHealth(basePath) {
|
|
391
|
+
const pkgPath = join(basePath, "package.json");
|
|
392
|
+
if (!existsSync(pkgPath))
|
|
393
|
+
return null;
|
|
394
|
+
try {
|
|
395
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
396
|
+
const testScript = pkg.scripts?.test;
|
|
397
|
+
// Skip if no test script or the default placeholder
|
|
398
|
+
if (!testScript || testScript.includes("no test specified"))
|
|
399
|
+
return null;
|
|
400
|
+
const result = tryExec("npm test 2>&1", basePath);
|
|
401
|
+
if (result === null) {
|
|
402
|
+
return {
|
|
403
|
+
name: "test",
|
|
404
|
+
status: "warning",
|
|
405
|
+
message: "Tests failing — npm test exited non-zero",
|
|
406
|
+
detail: "Fix failing tests before shipping",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return { name: "test", status: "ok", message: "Tests pass" };
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
358
415
|
// ── Public API ─────────────────────────────────────────────────────────────
|
|
359
416
|
/**
|
|
360
417
|
* Run all environment health checks. Returns structured results for
|
|
@@ -394,6 +451,24 @@ export function runFullEnvironmentChecks(basePath) {
|
|
|
394
451
|
results.push(remoteCheck);
|
|
395
452
|
return results;
|
|
396
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Run slow opt-in checks (build and/or test).
|
|
456
|
+
* These are never run on the pre-dispatch gate — only on explicit /gsd doctor --build/--test.
|
|
457
|
+
*/
|
|
458
|
+
export function runSlowEnvironmentChecks(basePath, options) {
|
|
459
|
+
const results = [];
|
|
460
|
+
if (options?.includeBuild) {
|
|
461
|
+
const buildCheck = checkBuildHealth(basePath);
|
|
462
|
+
if (buildCheck)
|
|
463
|
+
results.push(buildCheck);
|
|
464
|
+
}
|
|
465
|
+
if (options?.includeTests) {
|
|
466
|
+
const testCheck = checkTestHealth(basePath);
|
|
467
|
+
if (testCheck)
|
|
468
|
+
results.push(testCheck);
|
|
469
|
+
}
|
|
470
|
+
return results;
|
|
471
|
+
}
|
|
397
472
|
/**
|
|
398
473
|
* Convert environment check results to DoctorIssue format for the doctor pipeline.
|
|
399
474
|
*/
|
|
@@ -417,6 +492,9 @@ export async function checkEnvironmentHealth(basePath, issues, options) {
|
|
|
417
492
|
const results = options?.includeRemote
|
|
418
493
|
? runFullEnvironmentChecks(basePath)
|
|
419
494
|
: runEnvironmentChecks(basePath);
|
|
495
|
+
if (options?.includeBuild || options?.includeTests) {
|
|
496
|
+
results.push(...runSlowEnvironmentChecks(basePath, options));
|
|
497
|
+
}
|
|
420
498
|
issues.push(...environmentResultsToDoctorIssues(results));
|
|
421
499
|
}
|
|
422
500
|
/**
|
|
@@ -69,3 +69,18 @@ export function formatDoctorIssuesForPrompt(issues) {
|
|
|
69
69
|
return `- [${prefix}] ${issue.unitId} | ${issue.code} | ${issue.message}${issue.file ? ` | file: ${issue.file}` : ""} | fixable: ${issue.fixable ? "yes" : "no"}`;
|
|
70
70
|
}).join("\n");
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Serialize a doctor report to JSON — suitable for CI/tooling integration.
|
|
74
|
+
* Usage: /gsd doctor --json
|
|
75
|
+
*/
|
|
76
|
+
export function formatDoctorReportJson(report) {
|
|
77
|
+
return JSON.stringify({
|
|
78
|
+
ok: report.ok,
|
|
79
|
+
basePath: report.basePath,
|
|
80
|
+
generatedAt: new Date().toISOString(),
|
|
81
|
+
summary: summarizeDoctorIssues(report.issues),
|
|
82
|
+
issues: report.issues,
|
|
83
|
+
fixesApplied: report.fixesApplied,
|
|
84
|
+
...(report.timing ? { timing: report.timing } : {}),
|
|
85
|
+
}, null, 2);
|
|
86
|
+
}
|