gsd-pi 2.5.1 → 2.6.0
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 +1 -0
- package/dist/cli.js +7 -1
- package/package.json +1 -1
- package/src/resources/extensions/get-secrets-from-user.ts +271 -54
- package/src/resources/extensions/gsd/auto.ts +35 -7
- package/src/resources/extensions/gsd/doctor.ts +23 -4
- package/src/resources/extensions/gsd/files.ts +45 -1
- package/src/resources/extensions/gsd/git-service.ts +50 -9
- package/src/resources/extensions/gsd/session-forensics.ts +19 -6
- package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +196 -0
- package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +469 -0
- package/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +170 -0
- package/src/resources/extensions/gsd/tests/git-service.test.ts +43 -0
- package/src/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +190 -0
- package/src/resources/extensions/gsd/types.ts +7 -0
package/README.md
CHANGED
|
@@ -224,6 +224,7 @@ On first run, GSD launches a branded setup wizard that walks you through LLM pro
|
|
|
224
224
|
| `Ctrl+Alt+V` | Toggle voice transcription |
|
|
225
225
|
| `Ctrl+Alt+B` | Show background shell processes |
|
|
226
226
|
| `gsd config` | Re-run the setup wizard (LLM provider + tool keys) |
|
|
227
|
+
| `gsd --continue` (`-c`) | Resume the most recent session for the current directory |
|
|
227
228
|
|
|
228
229
|
---
|
|
229
230
|
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,9 @@ function parseCliArgs(argv) {
|
|
|
20
20
|
else if (arg === '--print' || arg === '-p') {
|
|
21
21
|
flags.print = true;
|
|
22
22
|
}
|
|
23
|
+
else if (arg === '--continue' || arg === '-c') {
|
|
24
|
+
flags.continue = true;
|
|
25
|
+
}
|
|
23
26
|
else if (arg === '--no-session') {
|
|
24
27
|
flags.noSession = true;
|
|
25
28
|
}
|
|
@@ -45,6 +48,7 @@ function parseCliArgs(argv) {
|
|
|
45
48
|
process.stdout.write('Options:\n');
|
|
46
49
|
process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n');
|
|
47
50
|
process.stdout.write(' --print, -p Single-shot print mode\n');
|
|
51
|
+
process.stdout.write(' --continue, -c Resume the most recent session\n');
|
|
48
52
|
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n');
|
|
49
53
|
process.stdout.write(' --no-session Disable session persistence\n');
|
|
50
54
|
process.stdout.write(' --extension <path> Load additional extension\n');
|
|
@@ -200,7 +204,9 @@ if (existsSync(sessionsDir)) {
|
|
|
200
204
|
// Non-fatal — don't block startup if migration fails
|
|
201
205
|
}
|
|
202
206
|
}
|
|
203
|
-
const sessionManager =
|
|
207
|
+
const sessionManager = cliFlags.continue
|
|
208
|
+
? SessionManager.continueRecent(cwd, projectSessionsDir)
|
|
209
|
+
: SessionManager.create(cwd, projectSessionsDir);
|
|
204
210
|
initResources(agentDir);
|
|
205
211
|
const resourceLoader = buildResourceLoader(agentDir);
|
|
206
212
|
await resourceLoader.reload();
|
package/package.json
CHANGED
|
@@ -10,9 +10,13 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
10
10
|
import { existsSync, statSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
|
|
13
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
14
|
-
import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
13
|
+
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
15
15
|
import { Type } from "@sinclair/typebox";
|
|
16
|
+
import { makeUI, type ProgressStatus } from "./shared/ui.js";
|
|
17
|
+
import { parseSecretsManifest, formatSecretsManifest } from "./gsd/files.js";
|
|
18
|
+
import { resolveMilestoneFile } from "./gsd/paths.js";
|
|
19
|
+
import type { SecretsManifestEntry } from "./gsd/types.js";
|
|
16
20
|
|
|
17
21
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
18
22
|
|
|
@@ -152,6 +156,7 @@ async function collectOneSecret(
|
|
|
152
156
|
totalPages: number,
|
|
153
157
|
keyName: string,
|
|
154
158
|
hint: string | undefined,
|
|
159
|
+
guidance?: string[],
|
|
155
160
|
): Promise<string | null> {
|
|
156
161
|
if (!ctx.hasUI) return null;
|
|
157
162
|
|
|
@@ -209,6 +214,21 @@ async function collectOneSecret(
|
|
|
209
214
|
if (hint) {
|
|
210
215
|
add(theme.fg("muted", ` ${hint}`));
|
|
211
216
|
}
|
|
217
|
+
|
|
218
|
+
// Guidance steps (numbered, dim, wrapped for long URLs)
|
|
219
|
+
if (guidance && guidance.length > 0) {
|
|
220
|
+
lines.push("");
|
|
221
|
+
for (let g = 0; g < guidance.length; g++) {
|
|
222
|
+
const prefix = ` ${g + 1}. `;
|
|
223
|
+
const step = guidance[g] as string;
|
|
224
|
+
const wrappedLines = wrapTextWithAnsi(step, width - 4);
|
|
225
|
+
for (let w = 0; w < wrappedLines.length; w++) {
|
|
226
|
+
const indent = w === 0 ? prefix : " ".repeat(prefix.length);
|
|
227
|
+
lines.push(theme.fg("dim", `${indent}${wrappedLines[w]}`));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
212
232
|
lines.push("");
|
|
213
233
|
|
|
214
234
|
// Masked preview
|
|
@@ -239,6 +259,248 @@ async function collectOneSecret(
|
|
|
239
259
|
});
|
|
240
260
|
}
|
|
241
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Exported wrapper around collectOneSecret for testing.
|
|
264
|
+
* Exposes the same interface with guidance parameter for test verification.
|
|
265
|
+
*/
|
|
266
|
+
export const collectOneSecretWithGuidance = collectOneSecret;
|
|
267
|
+
|
|
268
|
+
// ─── Summary Screen ───────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Read-only summary screen showing all manifest entries with status indicators.
|
|
272
|
+
* Follows the confirm-ui.ts pattern: render → any key → done.
|
|
273
|
+
*
|
|
274
|
+
* Status mapping:
|
|
275
|
+
* - collected → done
|
|
276
|
+
* - pending → pending
|
|
277
|
+
* - skipped → skipped
|
|
278
|
+
* - existing keys (in existingKeys) → done with "already set" annotation
|
|
279
|
+
*/
|
|
280
|
+
export async function showSecretsSummary(
|
|
281
|
+
ctx: { ui: any; hasUI: boolean },
|
|
282
|
+
entries: SecretsManifestEntry[],
|
|
283
|
+
existingKeys: string[],
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
if (!ctx.hasUI) return;
|
|
286
|
+
|
|
287
|
+
const existingSet = new Set(existingKeys);
|
|
288
|
+
|
|
289
|
+
await ctx.ui.custom<void>((tui: any, theme: Theme, _kb: any, done: () => void) => {
|
|
290
|
+
let cachedLines: string[] | undefined;
|
|
291
|
+
|
|
292
|
+
function handleInput(_data: string) {
|
|
293
|
+
// Any key dismisses
|
|
294
|
+
done();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function render(width: number): string[] {
|
|
298
|
+
if (cachedLines) return cachedLines;
|
|
299
|
+
|
|
300
|
+
const ui = makeUI(theme, width);
|
|
301
|
+
const lines: string[] = [];
|
|
302
|
+
const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
|
|
303
|
+
|
|
304
|
+
push(ui.bar());
|
|
305
|
+
push(ui.blank());
|
|
306
|
+
push(ui.header(" Secrets Summary"));
|
|
307
|
+
push(ui.blank());
|
|
308
|
+
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
let status: ProgressStatus;
|
|
311
|
+
let detail: string | undefined;
|
|
312
|
+
|
|
313
|
+
if (existingSet.has(entry.key)) {
|
|
314
|
+
status = "done";
|
|
315
|
+
detail = "already set";
|
|
316
|
+
} else if (entry.status === "collected") {
|
|
317
|
+
status = "done";
|
|
318
|
+
} else if (entry.status === "skipped") {
|
|
319
|
+
status = "skipped";
|
|
320
|
+
} else {
|
|
321
|
+
status = "pending";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
push(ui.progressItem(entry.key, status, { detail }));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
push(ui.blank());
|
|
328
|
+
push(ui.hints(["any key to continue"]));
|
|
329
|
+
push(ui.bar());
|
|
330
|
+
|
|
331
|
+
cachedLines = lines;
|
|
332
|
+
return lines;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
render,
|
|
337
|
+
invalidate: () => { cachedLines = undefined; },
|
|
338
|
+
handleInput,
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── Destination Write Helper ─────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Apply collected secrets to the target destination.
|
|
347
|
+
* Dotenv writes are handled directly; vercel/convex require pi.exec.
|
|
348
|
+
*/
|
|
349
|
+
async function applySecrets(
|
|
350
|
+
provided: Array<{ key: string; value: string }>,
|
|
351
|
+
destination: "dotenv" | "vercel" | "convex",
|
|
352
|
+
opts: {
|
|
353
|
+
envFilePath: string;
|
|
354
|
+
environment?: string;
|
|
355
|
+
exec?: (cmd: string, args: string[]) => Promise<{ code: number; stderr: string }>;
|
|
356
|
+
},
|
|
357
|
+
): Promise<{ applied: string[]; errors: string[] }> {
|
|
358
|
+
const applied: string[] = [];
|
|
359
|
+
const errors: string[] = [];
|
|
360
|
+
|
|
361
|
+
if (destination === "dotenv") {
|
|
362
|
+
for (const { key, value } of provided) {
|
|
363
|
+
try {
|
|
364
|
+
await writeEnvKey(opts.envFilePath, key, value);
|
|
365
|
+
applied.push(key);
|
|
366
|
+
} catch (err: any) {
|
|
367
|
+
errors.push(`${key}: ${err.message}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (destination === "vercel" && opts.exec) {
|
|
373
|
+
const env = opts.environment ?? "development";
|
|
374
|
+
for (const { key, value } of provided) {
|
|
375
|
+
try {
|
|
376
|
+
const result = await opts.exec("sh", [
|
|
377
|
+
"-c",
|
|
378
|
+
`printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`,
|
|
379
|
+
]);
|
|
380
|
+
if (result.code !== 0) {
|
|
381
|
+
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
|
382
|
+
} else {
|
|
383
|
+
applied.push(key);
|
|
384
|
+
}
|
|
385
|
+
} catch (err: any) {
|
|
386
|
+
errors.push(`${key}: ${err.message}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (destination === "convex" && opts.exec) {
|
|
392
|
+
for (const { key, value } of provided) {
|
|
393
|
+
try {
|
|
394
|
+
const result = await opts.exec("sh", [
|
|
395
|
+
"-c",
|
|
396
|
+
`npx convex env set ${key} ${shellEscapeSingle(value)}`,
|
|
397
|
+
]);
|
|
398
|
+
if (result.code !== 0) {
|
|
399
|
+
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
|
400
|
+
} else {
|
|
401
|
+
applied.push(key);
|
|
402
|
+
}
|
|
403
|
+
} catch (err: any) {
|
|
404
|
+
errors.push(`${key}: ${err.message}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { applied, errors };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─── Manifest Orchestrator ────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Full orchestrator: reads manifest, checks env, shows summary, collects
|
|
416
|
+
* only pending keys (with guidance + hint), updates manifest statuses,
|
|
417
|
+
* writes back, and applies collected values to the destination.
|
|
418
|
+
*
|
|
419
|
+
* Returns a structured result matching the tool result shape.
|
|
420
|
+
*/
|
|
421
|
+
export async function collectSecretsFromManifest(
|
|
422
|
+
base: string,
|
|
423
|
+
milestoneId: string,
|
|
424
|
+
ctx: { ui: any; hasUI: boolean; cwd: string },
|
|
425
|
+
): Promise<{ applied: string[]; skipped: string[]; existingSkipped: string[] }> {
|
|
426
|
+
// (a) Resolve manifest path
|
|
427
|
+
const manifestPath = resolveMilestoneFile(base, milestoneId, "SECRETS");
|
|
428
|
+
if (!manifestPath) {
|
|
429
|
+
throw new Error(`Secrets manifest not found for milestone ${milestoneId} in ${base}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// (b) Read and parse manifest
|
|
433
|
+
const content = await readFile(manifestPath, "utf8");
|
|
434
|
+
const manifest = parseSecretsManifest(content);
|
|
435
|
+
|
|
436
|
+
// (c) Check existing keys
|
|
437
|
+
const envPath = resolve(base, ".env");
|
|
438
|
+
const allKeys = manifest.entries.map((e) => e.key);
|
|
439
|
+
const existingKeys = await checkExistingEnvKeys(allKeys, envPath);
|
|
440
|
+
const existingSet = new Set(existingKeys);
|
|
441
|
+
|
|
442
|
+
// (d) Build categorization
|
|
443
|
+
const existingSkipped: string[] = [];
|
|
444
|
+
const alreadySkipped: string[] = [];
|
|
445
|
+
const pendingEntries: SecretsManifestEntry[] = [];
|
|
446
|
+
|
|
447
|
+
for (const entry of manifest.entries) {
|
|
448
|
+
if (existingSet.has(entry.key)) {
|
|
449
|
+
existingSkipped.push(entry.key);
|
|
450
|
+
} else if (entry.status === "skipped") {
|
|
451
|
+
alreadySkipped.push(entry.key);
|
|
452
|
+
} else if (entry.status === "pending") {
|
|
453
|
+
pendingEntries.push(entry);
|
|
454
|
+
}
|
|
455
|
+
// collected entries that are not in env are left as-is
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// (e) Show summary screen
|
|
459
|
+
await showSecretsSummary(ctx, manifest.entries, existingKeys);
|
|
460
|
+
|
|
461
|
+
// (f) Detect destination
|
|
462
|
+
const destination = detectDestination(ctx.cwd);
|
|
463
|
+
|
|
464
|
+
// (g) Collect only pending keys that are not already existing
|
|
465
|
+
const collected: CollectedSecret[] = [];
|
|
466
|
+
for (let i = 0; i < pendingEntries.length; i++) {
|
|
467
|
+
const entry = pendingEntries[i] as SecretsManifestEntry;
|
|
468
|
+
const value = await collectOneSecret(
|
|
469
|
+
ctx,
|
|
470
|
+
i,
|
|
471
|
+
pendingEntries.length,
|
|
472
|
+
entry.key,
|
|
473
|
+
entry.formatHint || undefined,
|
|
474
|
+
entry.guidance.length > 0 ? entry.guidance : undefined,
|
|
475
|
+
);
|
|
476
|
+
collected.push({ key: entry.key, value });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// (h) Update manifest entry statuses
|
|
480
|
+
for (const { key, value } of collected) {
|
|
481
|
+
const entry = manifest.entries.find((e) => e.key === key);
|
|
482
|
+
if (entry) {
|
|
483
|
+
entry.status = value !== null ? "collected" : "skipped";
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// (i) Write manifest back to disk
|
|
488
|
+
await writeFile(manifestPath, formatSecretsManifest(manifest), "utf8");
|
|
489
|
+
|
|
490
|
+
// (j) Apply collected values to destination
|
|
491
|
+
const provided = collected.filter((c) => c.value !== null) as Array<{ key: string; value: string }>;
|
|
492
|
+
const { applied } = await applySecrets(provided, destination, {
|
|
493
|
+
envFilePath: resolve(ctx.cwd, ".env"),
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const skipped = [
|
|
497
|
+
...alreadySkipped,
|
|
498
|
+
...collected.filter((c) => c.value === null).map((c) => c.key),
|
|
499
|
+
];
|
|
500
|
+
|
|
501
|
+
return { applied, skipped, existingSkipped };
|
|
502
|
+
}
|
|
503
|
+
|
|
242
504
|
// ─── Extension ────────────────────────────────────────────────────────────────
|
|
243
505
|
|
|
244
506
|
export default function secureEnv(pi: ExtensionAPI) {
|
|
@@ -299,64 +561,19 @@ export default function secureEnv(pi: ExtensionAPI) {
|
|
|
299
561
|
// Collect one key per page
|
|
300
562
|
for (let i = 0; i < params.keys.length; i++) {
|
|
301
563
|
const item = params.keys[i];
|
|
302
|
-
const value = await collectOneSecret(ctx, i, params.keys.length, item.key, item.hint);
|
|
564
|
+
const value = await collectOneSecret(ctx, i, params.keys.length, item.key, item.hint, item.guidance);
|
|
303
565
|
collected.push({ key: item.key, value });
|
|
304
566
|
}
|
|
305
567
|
|
|
306
568
|
const provided = collected.filter((c) => c.value !== null) as Array<{ key: string; value: string }>;
|
|
307
569
|
const skipped = collected.filter((c) => c.value === null).map((c) => c.key);
|
|
308
|
-
const applied: string[] = [];
|
|
309
|
-
const errors: string[] = [];
|
|
310
|
-
|
|
311
|
-
// Apply to destination
|
|
312
|
-
if (destination === "dotenv") {
|
|
313
|
-
const filePath = resolve(ctx.cwd, params.envFilePath ?? ".env");
|
|
314
|
-
for (const { key, value } of provided) {
|
|
315
|
-
try {
|
|
316
|
-
await writeEnvKey(filePath, key, value);
|
|
317
|
-
applied.push(key);
|
|
318
|
-
} catch (err: any) {
|
|
319
|
-
errors.push(`${key}: ${err.message}`);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
570
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
`printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`,
|
|
331
|
-
]);
|
|
332
|
-
if (result.code !== 0) {
|
|
333
|
-
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
|
334
|
-
} else {
|
|
335
|
-
applied.push(key);
|
|
336
|
-
}
|
|
337
|
-
} catch (err: any) {
|
|
338
|
-
errors.push(`${key}: ${err.message}`);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (destination === "convex") {
|
|
344
|
-
for (const { key, value } of provided) {
|
|
345
|
-
try {
|
|
346
|
-
const result = await pi.exec("sh", [
|
|
347
|
-
"-c",
|
|
348
|
-
`npx convex env set ${key} ${shellEscapeSingle(value)}`,
|
|
349
|
-
]);
|
|
350
|
-
if (result.code !== 0) {
|
|
351
|
-
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
|
352
|
-
} else {
|
|
353
|
-
applied.push(key);
|
|
354
|
-
}
|
|
355
|
-
} catch (err: any) {
|
|
356
|
-
errors.push(`${key}: ${err.message}`);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
571
|
+
// Apply to destination via shared helper
|
|
572
|
+
const { applied, errors } = await applySecrets(provided, destination, {
|
|
573
|
+
envFilePath: resolve(ctx.cwd, params.envFilePath ?? ".env"),
|
|
574
|
+
environment: params.environment,
|
|
575
|
+
exec: (cmd, args) => pi.exec(cmd, args),
|
|
576
|
+
});
|
|
360
577
|
|
|
361
578
|
const details: ToolResultDetails = {
|
|
362
579
|
destination,
|
|
@@ -18,9 +18,10 @@ import type {
|
|
|
18
18
|
|
|
19
19
|
import { deriveState } from "./state.js";
|
|
20
20
|
import type { GSDState } from "./types.js";
|
|
21
|
-
import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary } from "./files.js";
|
|
21
|
+
import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary, getManifestStatus } from "./files.js";
|
|
22
22
|
export { inlinePriorMilestoneSummary };
|
|
23
23
|
import type { UatType } from "./files.js";
|
|
24
|
+
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
|
|
24
25
|
import { loadPrompt } from "./prompt-loader.js";
|
|
25
26
|
import {
|
|
26
27
|
gsdRoot, resolveMilestoneFile, resolveSliceFile, resolveSlicePath,
|
|
@@ -474,6 +475,24 @@ export async function startAuto(
|
|
|
474
475
|
: "Will loop until milestone complete.";
|
|
475
476
|
ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
|
|
476
477
|
|
|
478
|
+
// Secrets collection gate — collect pending secrets before first dispatch
|
|
479
|
+
const mid = state.activeMilestone.id;
|
|
480
|
+
try {
|
|
481
|
+
const manifestStatus = await getManifestStatus(base, mid);
|
|
482
|
+
if (manifestStatus && manifestStatus.pending.length > 0) {
|
|
483
|
+
const result = await collectSecretsFromManifest(base, mid, ctx);
|
|
484
|
+
ctx.ui.notify(
|
|
485
|
+
`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
|
|
486
|
+
"info",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
} catch (err) {
|
|
490
|
+
ctx.ui.notify(
|
|
491
|
+
`Secrets collection error: ${err instanceof Error ? err.message : String(err)}`,
|
|
492
|
+
"warning",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
477
496
|
// Self-heal: clear stale runtime records where artifacts already exist
|
|
478
497
|
await selfHealRuntimeRecords(base, ctx);
|
|
479
498
|
|
|
@@ -507,14 +526,15 @@ export async function handleAgentEnd(
|
|
|
507
526
|
}
|
|
508
527
|
|
|
509
528
|
// Post-hook: fix mechanical bookkeeping the LLM may have skipped.
|
|
510
|
-
// 1. Doctor handles: checkbox marking
|
|
529
|
+
// 1. Doctor handles: checkbox marking (task-level bookkeeping).
|
|
511
530
|
// 2. STATE.md is always rebuilt from disk state (purely derived, no LLM needed).
|
|
512
|
-
//
|
|
513
|
-
//
|
|
531
|
+
// fixLevel:"task" ensures doctor only fixes task-level issues (e.g. marking
|
|
532
|
+
// checkboxes). Slice/milestone completion transitions (summary stubs,
|
|
533
|
+
// roadmap [x] marking) are left for the complete-slice dispatch unit.
|
|
514
534
|
try {
|
|
515
535
|
const scopeParts = currentUnit.id.split("/").slice(0, 2);
|
|
516
536
|
const doctorScope = scopeParts.join("/");
|
|
517
|
-
const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope });
|
|
537
|
+
const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope, fixLevel: "task" });
|
|
518
538
|
if (report.fixesApplied.length > 0) {
|
|
519
539
|
ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
|
|
520
540
|
}
|
|
@@ -1355,14 +1375,22 @@ async function dispatchNextUnit(
|
|
|
1355
1375
|
|
|
1356
1376
|
// On crash recovery, prepend the full recovery briefing
|
|
1357
1377
|
// On retry (stuck detection), prepend deep diagnostic from last attempt
|
|
1378
|
+
// Cap injected content to prevent unbounded prompt growth → OOM
|
|
1379
|
+
const MAX_RECOVERY_CHARS = 50_000;
|
|
1358
1380
|
let finalPrompt = prompt;
|
|
1359
1381
|
if (pendingCrashRecovery) {
|
|
1360
|
-
|
|
1382
|
+
const capped = pendingCrashRecovery.length > MAX_RECOVERY_CHARS
|
|
1383
|
+
? pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
|
|
1384
|
+
: pendingCrashRecovery;
|
|
1385
|
+
finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
|
|
1361
1386
|
pendingCrashRecovery = null;
|
|
1362
1387
|
} else if ((unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
|
|
1363
1388
|
const diagnostic = getDeepDiagnostic(basePath);
|
|
1364
1389
|
if (diagnostic) {
|
|
1365
|
-
|
|
1390
|
+
const cappedDiag = diagnostic.length > MAX_RECOVERY_CHARS
|
|
1391
|
+
? diagnostic.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...diagnostic truncated to prevent memory exhaustion]"
|
|
1392
|
+
: diagnostic;
|
|
1393
|
+
finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`;
|
|
1366
1394
|
}
|
|
1367
1395
|
}
|
|
1368
1396
|
|
|
@@ -422,10 +422,29 @@ export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string {
|
|
|
422
422
|
}).join("\n");
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
-
export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string }): Promise<DoctorReport> {
|
|
425
|
+
export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise<DoctorReport> {
|
|
426
426
|
const issues: DoctorIssue[] = [];
|
|
427
427
|
const fixesApplied: string[] = [];
|
|
428
428
|
const fix = options?.fix === true;
|
|
429
|
+
const fixLevel = options?.fixLevel ?? "all";
|
|
430
|
+
|
|
431
|
+
// Issue codes that represent completion state transitions — creating summary
|
|
432
|
+
// stubs, marking slices/milestones done in the roadmap. These belong to the
|
|
433
|
+
// dispatch lifecycle (complete-slice, complete-milestone units), not to
|
|
434
|
+
// mechanical post-hook bookkeeping. When fixLevel is "task", these are
|
|
435
|
+
// detected and reported but never auto-fixed.
|
|
436
|
+
const completionTransitionCodes = new Set<DoctorIssueCode>([
|
|
437
|
+
"all_tasks_done_missing_slice_summary",
|
|
438
|
+
"all_tasks_done_missing_slice_uat",
|
|
439
|
+
"all_tasks_done_roadmap_not_checked",
|
|
440
|
+
]);
|
|
441
|
+
|
|
442
|
+
/** Whether a given issue code should be auto-fixed at the current fixLevel. */
|
|
443
|
+
const shouldFix = (code: DoctorIssueCode): boolean => {
|
|
444
|
+
if (!fix) return false;
|
|
445
|
+
if (fixLevel === "task" && completionTransitionCodes.has(code)) return false;
|
|
446
|
+
return true;
|
|
447
|
+
};
|
|
429
448
|
|
|
430
449
|
const prefs = loadEffectiveGSDPreferences();
|
|
431
450
|
if (prefs) {
|
|
@@ -606,7 +625,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
606
625
|
file: relSliceFile(basePath, milestoneId, slice.id, "SUMMARY"),
|
|
607
626
|
fixable: true,
|
|
608
627
|
});
|
|
609
|
-
if (
|
|
628
|
+
if (shouldFix("all_tasks_done_missing_slice_summary")) await ensureSliceSummaryStub(basePath, milestoneId, slice.id, fixesApplied);
|
|
610
629
|
}
|
|
611
630
|
|
|
612
631
|
if (allTasksDone && !hasSliceUat) {
|
|
@@ -619,7 +638,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
619
638
|
file: `${relSlicePath(basePath, milestoneId, slice.id)}/${slice.id}-UAT.md`,
|
|
620
639
|
fixable: true,
|
|
621
640
|
});
|
|
622
|
-
if (
|
|
641
|
+
if (shouldFix("all_tasks_done_missing_slice_uat")) await ensureSliceUatStub(basePath, milestoneId, slice.id, fixesApplied);
|
|
623
642
|
}
|
|
624
643
|
|
|
625
644
|
if (allTasksDone && !slice.done) {
|
|
@@ -632,7 +651,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
632
651
|
file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
|
|
633
652
|
fixable: true,
|
|
634
653
|
});
|
|
635
|
-
if (
|
|
654
|
+
if (shouldFix("all_tasks_done_roadmap_not_checked") && (hasSliceSummary || issues.some(issue => issue.code === "all_tasks_done_missing_slice_summary" && issue.unitId === unitId))) {
|
|
636
655
|
await markSliceDoneInRoadmap(basePath, milestoneId, slice.id, fixesApplied);
|
|
637
656
|
}
|
|
638
657
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Pure functions, zero Pi dependencies — uses only Node built-ins.
|
|
5
5
|
|
|
6
6
|
import { promises as fs, readdirSync } from 'node:fs';
|
|
7
|
-
import { dirname } from 'node:path';
|
|
7
|
+
import { dirname, resolve } from 'node:path';
|
|
8
8
|
import { milestonesDir, resolveMilestoneFile, relMilestoneFile } from './paths.js';
|
|
9
9
|
|
|
10
10
|
import type {
|
|
@@ -14,8 +14,11 @@ import type {
|
|
|
14
14
|
Continue, ContinueFrontmatter, ContinueStatus,
|
|
15
15
|
RequirementCounts,
|
|
16
16
|
SecretsManifest, SecretsManifestEntry, SecretsManifestEntryStatus,
|
|
17
|
+
ManifestStatus,
|
|
17
18
|
} from './types.ts';
|
|
18
19
|
|
|
20
|
+
import { checkExistingEnvKeys } from '../get-secrets-from-user.ts';
|
|
21
|
+
|
|
19
22
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
20
23
|
|
|
21
24
|
/**
|
|
@@ -800,3 +803,44 @@ export async function inlinePriorMilestoneSummary(mid: string, base: string): Pr
|
|
|
800
803
|
if (!content) return null;
|
|
801
804
|
return `### Prior Milestone Summary\nSource: \`${relPath}\`\n\n${content.trim()}`;
|
|
802
805
|
}
|
|
806
|
+
|
|
807
|
+
// ─── Manifest Status ──────────────────────────────────────────────────────
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Read a secrets manifest from disk and cross-reference each entry's status
|
|
811
|
+
* with the current environment (.env + process.env).
|
|
812
|
+
*
|
|
813
|
+
* Returns `null` when no manifest file exists (path resolution failure or
|
|
814
|
+
* file not on disk) — callers can distinguish "no manifest" from "empty manifest".
|
|
815
|
+
*/
|
|
816
|
+
export async function getManifestStatus(
|
|
817
|
+
base: string, milestoneId: string,
|
|
818
|
+
): Promise<ManifestStatus | null> {
|
|
819
|
+
const resolvedPath = resolveMilestoneFile(base, milestoneId, 'SECRETS');
|
|
820
|
+
if (!resolvedPath) return null;
|
|
821
|
+
|
|
822
|
+
const content = await loadFile(resolvedPath);
|
|
823
|
+
if (!content) return null;
|
|
824
|
+
|
|
825
|
+
const manifest = parseSecretsManifest(content);
|
|
826
|
+
const keys = manifest.entries.map(e => e.key);
|
|
827
|
+
const existingKeys = await checkExistingEnvKeys(keys, resolve(base, '.env'));
|
|
828
|
+
const existingSet = new Set(existingKeys);
|
|
829
|
+
|
|
830
|
+
const result: ManifestStatus = {
|
|
831
|
+
pending: [],
|
|
832
|
+
collected: [],
|
|
833
|
+
skipped: [],
|
|
834
|
+
existing: [],
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
for (const entry of manifest.entries) {
|
|
838
|
+
if (existingSet.has(entry.key)) {
|
|
839
|
+
result.existing.push(entry.key);
|
|
840
|
+
} else {
|
|
841
|
+
result[entry.status].push(entry.key);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return result;
|
|
846
|
+
}
|