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.
Files changed (33) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +7 -1
  3. package/dist/loader.js +21 -3
  4. package/dist/logo.d.ts +3 -3
  5. package/dist/logo.js +2 -2
  6. package/package.json +1 -1
  7. package/src/resources/extensions/get-secrets-from-user.ts +331 -59
  8. package/src/resources/extensions/gsd/auto.ts +80 -18
  9. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  10. package/src/resources/extensions/gsd/doctor.ts +23 -4
  11. package/src/resources/extensions/gsd/files.ts +115 -1
  12. package/src/resources/extensions/gsd/git-service.ts +67 -105
  13. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  14. package/src/resources/extensions/gsd/guided-flow.ts +6 -3
  15. package/src/resources/extensions/gsd/preferences.ts +8 -0
  16. package/src/resources/extensions/gsd/prompts/complete-slice.md +7 -5
  17. package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
  18. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -6
  19. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
  20. package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
  21. package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
  22. package/src/resources/extensions/gsd/session-forensics.ts +19 -6
  23. package/src/resources/extensions/gsd/templates/plan.md +8 -10
  24. package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
  25. package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
  26. package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +196 -0
  27. package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +469 -0
  28. package/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +170 -0
  29. package/src/resources/extensions/gsd/tests/git-service.test.ts +106 -0
  30. package/src/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
  31. package/src/resources/extensions/gsd/tests/parsers.test.ts +401 -65
  32. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
  33. 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
- execSync("git init", { cwd: base, stdio: "pipe" });
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, stub summaries/UATs.
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
- // This is more reliable than prompt instructions for mechanical tasks.
512
- // Scope to slice level (M001/S01) so doctor checks all tasks within the slice.
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 "research first slice";
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: ${message}`,
1065
+ `Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
1027
1066
  "error",
1028
1067
  );
1029
- // Re-derive state so dispatch can figure out what to do
1030
- state = await deriveState(basePath);
1031
- mid = state.activeMilestone?.id;
1032
- midTitle = state.activeMilestone?.title;
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
- unitType = "research-slice";
1161
- unitId = `${mid}/${sid}`;
1162
- prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
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
- finalPrompt = `${pendingCrashRecovery}\n\n---\n\n${finalPrompt}`;
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
- finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${diagnostic}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`;
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 (fix) await ensureSliceSummaryStub(basePath, milestoneId, slice.id, fixesApplied);
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 (fix) await ensureSliceUatStub(basePath, milestoneId, slice.id, fixesApplied);
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 (fix && (hasSliceSummary || issues.some(issue => issue.code === "all_tasks_done_missing_slice_summary" && issue.unitId === unitId))) {
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
+ }