gsd-pi 2.5.0 → 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/dist/loader.js +21 -3
- package/dist/logo.d.ts +3 -3
- package/dist/logo.js +2 -2
- package/package.json +1 -1
- package/src/resources/extensions/get-secrets-from-user.ts +331 -59
- package/src/resources/extensions/gsd/auto.ts +80 -18
- package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
- package/src/resources/extensions/gsd/doctor.ts +23 -4
- package/src/resources/extensions/gsd/files.ts +115 -1
- package/src/resources/extensions/gsd/git-service.ts +67 -105
- package/src/resources/extensions/gsd/gitignore.ts +1 -0
- package/src/resources/extensions/gsd/guided-flow.ts +6 -3
- package/src/resources/extensions/gsd/preferences.ts +8 -0
- package/src/resources/extensions/gsd/prompts/complete-slice.md +7 -5
- package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
- package/src/resources/extensions/gsd/prompts/execute-task.md +2 -6
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
- package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
- package/src/resources/extensions/gsd/session-forensics.ts +19 -6
- package/src/resources/extensions/gsd/templates/plan.md +8 -10
- package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
- package/src/resources/extensions/gsd/templates/task-plan.md +6 -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 +106 -0
- package/src/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +401 -65
- package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
- package/src/resources/extensions/gsd/types.ts +27 -0
|
@@ -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,
|
|
@@ -56,7 +57,7 @@ import {
|
|
|
56
57
|
} from "./metrics.js";
|
|
57
58
|
import { join } from "node:path";
|
|
58
59
|
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
59
|
-
import { execSync } from "node:child_process";
|
|
60
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
60
61
|
import {
|
|
61
62
|
autoCommitCurrentBranch,
|
|
62
63
|
ensureSliceBranch,
|
|
@@ -373,7 +374,8 @@ export async function startAuto(
|
|
|
373
374
|
try {
|
|
374
375
|
execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" });
|
|
375
376
|
} catch {
|
|
376
|
-
|
|
377
|
+
const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
|
|
378
|
+
execFileSync("git", ["init", "-b", mainBranch], { cwd: base, stdio: "pipe" });
|
|
377
379
|
}
|
|
378
380
|
|
|
379
381
|
// Ensure .gitignore has baseline patterns
|
|
@@ -473,6 +475,24 @@ export async function startAuto(
|
|
|
473
475
|
: "Will loop until milestone complete.";
|
|
474
476
|
ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
|
|
475
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
|
+
|
|
476
496
|
// Self-heal: clear stale runtime records where artifacts already exist
|
|
477
497
|
await selfHealRuntimeRecords(base, ctx);
|
|
478
498
|
|
|
@@ -506,14 +526,15 @@ export async function handleAgentEnd(
|
|
|
506
526
|
}
|
|
507
527
|
|
|
508
528
|
// Post-hook: fix mechanical bookkeeping the LLM may have skipped.
|
|
509
|
-
// 1. Doctor handles: checkbox marking
|
|
529
|
+
// 1. Doctor handles: checkbox marking (task-level bookkeeping).
|
|
510
530
|
// 2. STATE.md is always rebuilt from disk state (purely derived, no LLM needed).
|
|
511
|
-
//
|
|
512
|
-
//
|
|
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.
|
|
513
534
|
try {
|
|
514
535
|
const scopeParts = currentUnit.id.split("/").slice(0, 2);
|
|
515
536
|
const doctorScope = scopeParts.join("/");
|
|
516
|
-
const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope });
|
|
537
|
+
const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope, fixLevel: "task" });
|
|
517
538
|
if (report.fixesApplied.length > 0) {
|
|
518
539
|
ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
|
|
519
540
|
}
|
|
@@ -675,7 +696,7 @@ function peekNext(unitType: string, state: GSDState): string {
|
|
|
675
696
|
const sid = state.activeSlice?.id ?? "";
|
|
676
697
|
switch (unitType) {
|
|
677
698
|
case "research-milestone": return "plan milestone roadmap";
|
|
678
|
-
case "plan-milestone": return "
|
|
699
|
+
case "plan-milestone": return "plan or execute first slice";
|
|
679
700
|
case "research-slice": return `plan ${sid}`;
|
|
680
701
|
case "plan-slice": return "execute first task";
|
|
681
702
|
case "execute-task": return `continue ${sid}`;
|
|
@@ -1022,14 +1043,35 @@ async function dispatchNextUnit(
|
|
|
1022
1043
|
midTitle = state.activeMilestone?.title;
|
|
1023
1044
|
} catch (error) {
|
|
1024
1045
|
const message = error instanceof Error ? error.message : String(error);
|
|
1046
|
+
|
|
1047
|
+
// Safety net: if mergeSliceToMain failed to clean up (or the error
|
|
1048
|
+
// came from switchToMain), ensure the working tree isn't left in a
|
|
1049
|
+
// conflicted/dirty merge state. Without this, state derivation reads
|
|
1050
|
+
// conflict-marker-filled files, produces a corrupt phase, and
|
|
1051
|
+
// dispatch loops forever (see: merge-bug-fix).
|
|
1052
|
+
try {
|
|
1053
|
+
const { runGit } = await import("./git-service.ts");
|
|
1054
|
+
const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
|
|
1055
|
+
if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
|
|
1056
|
+
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
|
1057
|
+
ctx.ui.notify(
|
|
1058
|
+
`Cleaned up conflicted merge state after failed squash-merge.`,
|
|
1059
|
+
"warning",
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
} catch { /* best-effort cleanup */ }
|
|
1063
|
+
|
|
1025
1064
|
ctx.ui.notify(
|
|
1026
|
-
`Slice merge failed
|
|
1065
|
+
`Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
|
|
1027
1066
|
"error",
|
|
1028
1067
|
);
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1068
|
+
if (currentUnit) {
|
|
1069
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1070
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1071
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1072
|
+
}
|
|
1073
|
+
await stopAuto(ctx, pi);
|
|
1074
|
+
return;
|
|
1033
1075
|
}
|
|
1034
1076
|
}
|
|
1035
1077
|
}
|
|
@@ -1157,9 +1199,19 @@ async function dispatchNextUnit(
|
|
|
1157
1199
|
const hasResearch = !!(researchFile && await loadFile(researchFile));
|
|
1158
1200
|
|
|
1159
1201
|
if (!hasResearch) {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1202
|
+
// Skip slice research for S01 when milestone research already exists —
|
|
1203
|
+
// the milestone research already covers the same ground for the first slice.
|
|
1204
|
+
const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
|
|
1205
|
+
const hasMilestoneResearch = !!(milestoneResearchFile && await loadFile(milestoneResearchFile));
|
|
1206
|
+
if (hasMilestoneResearch && sid === "S01") {
|
|
1207
|
+
unitType = "plan-slice";
|
|
1208
|
+
unitId = `${mid}/${sid}`;
|
|
1209
|
+
prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
|
1210
|
+
} else {
|
|
1211
|
+
unitType = "research-slice";
|
|
1212
|
+
unitId = `${mid}/${sid}`;
|
|
1213
|
+
prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
|
1214
|
+
}
|
|
1163
1215
|
} else {
|
|
1164
1216
|
unitType = "plan-slice";
|
|
1165
1217
|
unitId = `${mid}/${sid}`;
|
|
@@ -1323,14 +1375,22 @@ async function dispatchNextUnit(
|
|
|
1323
1375
|
|
|
1324
1376
|
// On crash recovery, prepend the full recovery briefing
|
|
1325
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;
|
|
1326
1380
|
let finalPrompt = prompt;
|
|
1327
1381
|
if (pendingCrashRecovery) {
|
|
1328
|
-
|
|
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}`;
|
|
1329
1386
|
pendingCrashRecovery = null;
|
|
1330
1387
|
} else if ((unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
|
|
1331
1388
|
const diagnostic = getDeepDiagnostic(basePath);
|
|
1332
1389
|
if (diagnostic) {
|
|
1333
|
-
|
|
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}`;
|
|
1334
1394
|
}
|
|
1335
1395
|
}
|
|
1336
1396
|
|
|
@@ -1623,6 +1683,7 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
|
|
|
1623
1683
|
|
|
1624
1684
|
const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
|
|
1625
1685
|
const outputAbsPath = resolveMilestoneFile(base, mid, "ROADMAP") ?? join(base, outputRelPath);
|
|
1686
|
+
const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS");
|
|
1626
1687
|
return loadPrompt("plan-milestone", {
|
|
1627
1688
|
milestoneId: mid, milestoneTitle: midTitle,
|
|
1628
1689
|
milestonePath: relMilestonePath(base, mid),
|
|
@@ -1630,6 +1691,7 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
|
|
|
1630
1691
|
researchPath: researchRel,
|
|
1631
1692
|
outputPath: outputRelPath,
|
|
1632
1693
|
outputAbsPath,
|
|
1694
|
+
secretsOutputPath,
|
|
1633
1695
|
inlinedContext,
|
|
1634
1696
|
});
|
|
1635
1697
|
}
|
|
@@ -47,6 +47,7 @@ Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md
|
|
|
47
47
|
- `snapshots`: boolean — create snapshot commits (WIP saves) during long-running tasks. Default: `false`.
|
|
48
48
|
- `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a slice branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `false`.
|
|
49
49
|
- `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content.
|
|
50
|
+
- `main_branch`: string — the primary branch name for new git repos (e.g., `"main"`, `"master"`, `"trunk"`). Also used by `getMainBranch()` as the preferred branch when auto-detection is ambiguous. Default: `"main"`.
|
|
50
51
|
|
|
51
52
|
---
|
|
52
53
|
|
|
@@ -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 {
|
|
@@ -13,8 +13,12 @@ import type {
|
|
|
13
13
|
Summary, SummaryFrontmatter, SummaryRequires, FileModified,
|
|
14
14
|
Continue, ContinueFrontmatter, ContinueStatus,
|
|
15
15
|
RequirementCounts,
|
|
16
|
+
SecretsManifest, SecretsManifestEntry, SecretsManifestEntryStatus,
|
|
17
|
+
ManifestStatus,
|
|
16
18
|
} from './types.ts';
|
|
17
19
|
|
|
20
|
+
import { checkExistingEnvKeys } from '../get-secrets-from-user.ts';
|
|
21
|
+
|
|
18
22
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
19
23
|
|
|
20
24
|
/**
|
|
@@ -263,6 +267,75 @@ export function parseRoadmap(content: string): Roadmap {
|
|
|
263
267
|
return { title, vision, successCriteria, slices, boundaryMap };
|
|
264
268
|
}
|
|
265
269
|
|
|
270
|
+
// ─── Secrets Manifest Parser ───────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
const VALID_STATUSES = new Set<SecretsManifestEntryStatus>(['pending', 'collected', 'skipped']);
|
|
273
|
+
|
|
274
|
+
export function parseSecretsManifest(content: string): SecretsManifest {
|
|
275
|
+
const milestone = extractBoldField(content, 'Milestone') || '';
|
|
276
|
+
const generatedAt = extractBoldField(content, 'Generated') || '';
|
|
277
|
+
|
|
278
|
+
const h3Sections = extractAllSections(content, 3);
|
|
279
|
+
const entries: SecretsManifestEntry[] = [];
|
|
280
|
+
|
|
281
|
+
for (const [heading, sectionContent] of h3Sections) {
|
|
282
|
+
const key = heading.trim();
|
|
283
|
+
if (!key) continue;
|
|
284
|
+
|
|
285
|
+
const service = extractBoldField(sectionContent, 'Service') || '';
|
|
286
|
+
const dashboardUrl = extractBoldField(sectionContent, 'Dashboard') || '';
|
|
287
|
+
const formatHint = extractBoldField(sectionContent, 'Format hint') || '';
|
|
288
|
+
const rawStatus = (extractBoldField(sectionContent, 'Status') || 'pending').toLowerCase().trim() as SecretsManifestEntryStatus;
|
|
289
|
+
const status: SecretsManifestEntryStatus = VALID_STATUSES.has(rawStatus) ? rawStatus : 'pending';
|
|
290
|
+
const destination = extractBoldField(sectionContent, 'Destination') || 'dotenv';
|
|
291
|
+
|
|
292
|
+
// Extract numbered guidance list (lines matching "1. ...", "2. ...", etc.)
|
|
293
|
+
const guidance: string[] = [];
|
|
294
|
+
for (const line of sectionContent.split('\n')) {
|
|
295
|
+
const numMatch = line.match(/^\s*\d+\.\s+(.+)/);
|
|
296
|
+
if (numMatch) {
|
|
297
|
+
guidance.push(numMatch[1].trim());
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
entries.push({ key, service, dashboardUrl, guidance, formatHint, status, destination });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { milestone, generatedAt, entries };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── Secrets Manifest Formatter ───────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
export function formatSecretsManifest(manifest: SecretsManifest): string {
|
|
310
|
+
const lines: string[] = [];
|
|
311
|
+
|
|
312
|
+
lines.push('# Secrets Manifest');
|
|
313
|
+
lines.push('');
|
|
314
|
+
lines.push(`**Milestone:** ${manifest.milestone}`);
|
|
315
|
+
lines.push(`**Generated:** ${manifest.generatedAt}`);
|
|
316
|
+
|
|
317
|
+
for (const entry of manifest.entries) {
|
|
318
|
+
lines.push('');
|
|
319
|
+
lines.push(`### ${entry.key}`);
|
|
320
|
+
lines.push('');
|
|
321
|
+
lines.push(`**Service:** ${entry.service}`);
|
|
322
|
+
if (entry.dashboardUrl) {
|
|
323
|
+
lines.push(`**Dashboard:** ${entry.dashboardUrl}`);
|
|
324
|
+
}
|
|
325
|
+
if (entry.formatHint) {
|
|
326
|
+
lines.push(`**Format hint:** ${entry.formatHint}`);
|
|
327
|
+
}
|
|
328
|
+
lines.push(`**Status:** ${entry.status}`);
|
|
329
|
+
lines.push(`**Destination:** ${entry.destination}`);
|
|
330
|
+
lines.push('');
|
|
331
|
+
for (let i = 0; i < entry.guidance.length; i++) {
|
|
332
|
+
lines.push(`${i + 1}. ${entry.guidance[i]}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return lines.join('\n') + '\n';
|
|
337
|
+
}
|
|
338
|
+
|
|
266
339
|
// ─── Slice Plan Parser ─────────────────────────────────────────────────────
|
|
267
340
|
|
|
268
341
|
export function parsePlan(content: string): SlicePlan {
|
|
@@ -730,3 +803,44 @@ export async function inlinePriorMilestoneSummary(mid: string, base: string): Pr
|
|
|
730
803
|
if (!content) return null;
|
|
731
804
|
return `### Prior Milestone Summary\nSource: \`${relPath}\`\n\n${content.trim()}`;
|
|
732
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
|
+
}
|