gsd-pi 2.23.0 → 2.24.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 (121) hide show
  1. package/dist/cli.js +12 -3
  2. package/dist/headless.d.ts +4 -0
  3. package/dist/headless.js +118 -10
  4. package/dist/help-text.js +22 -7
  5. package/dist/resource-loader.js +64 -9
  6. package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
  7. package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
  8. package/dist/resources/extensions/gsd/auto-recovery.ts +41 -2
  9. package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
  10. package/dist/resources/extensions/gsd/auto.ts +123 -41
  11. package/dist/resources/extensions/gsd/commands.ts +176 -10
  12. package/dist/resources/extensions/gsd/complexity.ts +1 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.ts +38 -0
  14. package/dist/resources/extensions/gsd/doctor.ts +56 -11
  15. package/dist/resources/extensions/gsd/exit-command.ts +2 -2
  16. package/dist/resources/extensions/gsd/gitignore.ts +1 -0
  17. package/dist/resources/extensions/gsd/guided-flow.ts +75 -0
  18. package/dist/resources/extensions/gsd/index.ts +34 -1
  19. package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  20. package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
  21. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  22. package/dist/resources/extensions/gsd/preferences.ts +65 -1
  23. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  24. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  25. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
  26. package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
  27. package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
  28. package/dist/resources/extensions/gsd/state.ts +72 -30
  29. package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  30. package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  31. package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  32. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
  33. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  34. package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  35. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  36. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  37. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  38. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  39. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  40. package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  41. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  42. package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  43. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  44. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  45. package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  46. package/dist/resources/extensions/gsd/types.ts +15 -1
  47. package/dist/resources/extensions/subagent/index.ts +5 -0
  48. package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
  49. package/dist/update-check.d.ts +9 -0
  50. package/dist/update-check.js +97 -0
  51. package/package.json +6 -1
  52. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  53. package/packages/pi-ai/dist/providers/anthropic.js +16 -7
  54. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  55. package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
  56. package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
  57. package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
  58. package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
  59. package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
  60. package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
  61. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  62. package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
  63. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  64. package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
  65. package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
  66. package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
  67. package/packages/pi-ai/src/providers/anthropic.ts +21 -8
  68. package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
  69. package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
  70. package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
  71. package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
  72. package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
  73. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  74. package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
  75. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  76. package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
  77. package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
  78. package/scripts/postinstall.js +7 -109
  79. package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
  80. package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
  81. package/src/resources/extensions/gsd/auto-recovery.ts +41 -2
  82. package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
  83. package/src/resources/extensions/gsd/auto.ts +123 -41
  84. package/src/resources/extensions/gsd/commands.ts +176 -10
  85. package/src/resources/extensions/gsd/complexity.ts +1 -0
  86. package/src/resources/extensions/gsd/dashboard-overlay.ts +38 -0
  87. package/src/resources/extensions/gsd/doctor.ts +56 -11
  88. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  89. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  90. package/src/resources/extensions/gsd/guided-flow.ts +75 -0
  91. package/src/resources/extensions/gsd/index.ts +34 -1
  92. package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  93. package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
  94. package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  95. package/src/resources/extensions/gsd/preferences.ts +65 -1
  96. package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  97. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  98. package/src/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
  99. package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
  100. package/src/resources/extensions/gsd/session-status-io.ts +197 -0
  101. package/src/resources/extensions/gsd/state.ts +72 -30
  102. package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  103. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  104. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  105. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
  106. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  107. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  108. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  109. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  110. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  111. package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  112. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  113. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  114. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  115. package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  116. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  117. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  118. package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  119. package/src/resources/extensions/gsd/types.ts +15 -1
  120. package/src/resources/extensions/subagent/index.ts +5 -0
  121. package/src/resources/extensions/subagent/worker-registry.ts +99 -0
@@ -11,6 +11,7 @@ import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
11
11
  import { nativeIsRepo, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js";
12
12
  import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js";
13
13
  import { ensureGitignore } from "./gitignore.js";
14
+ import { readAllSessionStatuses, isSessionStale, removeSessionStatus } from "./session-status-io.js";
14
15
 
15
16
  export type DoctorSeverity = "info" | "warning" | "error";
16
17
  export type DoctorIssueCode =
@@ -24,6 +25,7 @@ export type DoctorIssueCode =
24
25
  | "all_tasks_done_roadmap_not_checked"
25
26
  | "slice_checked_missing_summary"
26
27
  | "slice_checked_missing_uat"
28
+ | "all_slices_done_missing_milestone_validation"
27
29
  | "all_slices_done_missing_milestone_summary"
28
30
  | "task_done_must_haves_not_verified"
29
31
  | "active_requirement_missing_owner"
@@ -36,6 +38,7 @@ export type DoctorIssueCode =
36
38
  | "tracked_runtime_files"
37
39
  | "legacy_slice_branches"
38
40
  | "stale_crash_lock"
41
+ | "stale_parallel_session"
39
42
  | "orphaned_completed_units"
40
43
  | "stale_hook_state"
41
44
  | "activity_log_bloat"
@@ -710,6 +713,31 @@ async function checkRuntimeHealth(
710
713
  // Non-fatal — crash lock check failed
711
714
  }
712
715
 
716
+ // ── Stale parallel sessions ────────────────────────────────────────────
717
+ try {
718
+ const parallelStatuses = readAllSessionStatuses(basePath);
719
+ for (const status of parallelStatuses) {
720
+ if (isSessionStale(status)) {
721
+ issues.push({
722
+ severity: "warning",
723
+ code: "stale_parallel_session",
724
+ scope: "project",
725
+ unitId: status.milestoneId,
726
+ message: `Stale parallel session for ${status.milestoneId} (PID ${status.pid}, started ${new Date(status.startedAt).toISOString()}, last heartbeat ${new Date(status.lastHeartbeat).toISOString()}) — process is no longer running`,
727
+ file: `.gsd/parallel/${status.milestoneId}.status.json`,
728
+ fixable: true,
729
+ });
730
+
731
+ if (shouldFix("stale_parallel_session")) {
732
+ removeSessionStatus(basePath, status.milestoneId);
733
+ fixesApplied.push(`cleaned up stale parallel session for ${status.milestoneId}`);
734
+ }
735
+ }
736
+ }
737
+ } catch {
738
+ // Non-fatal — parallel session check failed
739
+ }
740
+
713
741
  // ── Orphaned completed-units keys ─────────────────────────────────────
714
742
  try {
715
743
  const completedKeysFile = join(root, "completed-units.json");
@@ -1066,11 +1094,13 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
1066
1094
  const tasksDir = resolveTasksDir(basePath, milestoneId, slice.id);
1067
1095
  if (!tasksDir) {
1068
1096
  issues.push({
1069
- severity: "error",
1097
+ severity: slice.done ? "warning" : "error",
1070
1098
  code: "missing_tasks_dir",
1071
1099
  scope: "slice",
1072
1100
  unitId,
1073
- message: `Missing tasks directory for ${unitId}`,
1101
+ message: slice.done
1102
+ ? `Missing tasks directory for ${unitId} (slice is complete — cosmetic only)`
1103
+ : `Missing tasks directory for ${unitId}`,
1074
1104
  file: relSlicePath(basePath, milestoneId, slice.id),
1075
1105
  fixable: true,
1076
1106
  });
@@ -1084,15 +1114,17 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
1084
1114
  const planContent = planPath ? await loadFile(planPath) : null;
1085
1115
  const plan = planContent ? parsePlan(planContent) : null;
1086
1116
  if (!plan) {
1087
- issues.push({
1088
- severity: "warning",
1089
- code: "missing_slice_plan",
1090
- scope: "slice",
1091
- unitId,
1092
- message: `Slice ${unitId} has no plan file`,
1093
- file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"),
1094
- fixable: false,
1095
- });
1117
+ if (!slice.done) {
1118
+ issues.push({
1119
+ severity: "warning",
1120
+ code: "missing_slice_plan",
1121
+ scope: "slice",
1122
+ unitId,
1123
+ message: `Slice ${unitId} has no plan file`,
1124
+ file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"),
1125
+ fixable: false,
1126
+ });
1127
+ }
1096
1128
  continue;
1097
1129
  }
1098
1130
 
@@ -1255,6 +1287,19 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
1255
1287
  }
1256
1288
  }
1257
1289
 
1290
+ // Milestone-level check: all slices done but no validation file
1291
+ if (isMilestoneComplete(roadmap) && !resolveMilestoneFile(basePath, milestoneId, "VALIDATION") && !resolveMilestoneFile(basePath, milestoneId, "SUMMARY")) {
1292
+ issues.push({
1293
+ severity: "info",
1294
+ code: "all_slices_done_missing_milestone_validation",
1295
+ scope: "milestone",
1296
+ unitId: milestoneId,
1297
+ message: `All slices are done but ${milestoneId}-VALIDATION.md is missing — milestone is in validating-milestone phase`,
1298
+ file: relMilestoneFile(basePath, milestoneId, "VALIDATION"),
1299
+ fixable: false,
1300
+ });
1301
+ }
1302
+
1258
1303
  // Milestone-level check: all slices done but no milestone summary
1259
1304
  if (isMilestoneComplete(roadmap) && !resolveMilestoneFile(basePath, milestoneId, "SUMMARY")) {
1260
1305
  issues.push({
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
2
2
 
3
- type StopAutoFn = (ctx: ExtensionCommandContext, pi: ExtensionAPI) => Promise<void>;
3
+ type StopAutoFn = (ctx: ExtensionCommandContext, pi: ExtensionAPI, reason?: string) => Promise<void>;
4
4
 
5
5
  export function registerExitCommand(
6
6
  pi: ExtensionAPI,
@@ -11,7 +11,7 @@ export function registerExitCommand(
11
11
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
12
12
  // Stop auto-mode first so locks and activity state are cleaned up before shutdown.
13
13
  const stopAuto = deps.stopAuto ?? (await import("./auto.js")).stopAuto;
14
- await stopAuto(ctx, pi);
14
+ await stopAuto(ctx, pi, "Graceful exit");
15
15
  ctx.shutdown();
16
16
  },
17
17
  });
@@ -19,6 +19,7 @@ const GSD_RUNTIME_PATTERNS = [
19
19
  ".gsd/forensics/",
20
20
  ".gsd/runtime/",
21
21
  ".gsd/worktrees/",
22
+ ".gsd/parallel/",
22
23
  ".gsd/auto.lock",
23
24
  ".gsd/metrics.json",
24
25
  ".gsd/completed-units.json",
@@ -201,6 +201,81 @@ function buildDiscussPrompt(nextId: string, preamble: string, _basePath: string)
201
201
  });
202
202
  }
203
203
 
204
+ /**
205
+ * Build the discuss prompt for headless milestone creation.
206
+ * Uses the discuss-headless prompt template with seed context injected.
207
+ */
208
+ function buildHeadlessDiscussPrompt(nextId: string, seedContext: string, _basePath: string): string {
209
+ const milestoneRel = `.gsd/milestones/${nextId}`;
210
+ const inlinedTemplates = [
211
+ inlineTemplate("project", "Project"),
212
+ inlineTemplate("requirements", "Requirements"),
213
+ inlineTemplate("context", "Context"),
214
+ inlineTemplate("roadmap", "Roadmap"),
215
+ inlineTemplate("decisions", "Decisions"),
216
+ ].join("\n\n---\n\n");
217
+ return loadPrompt("discuss-headless", {
218
+ milestoneId: nextId,
219
+ seedContext,
220
+ contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`,
221
+ roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`,
222
+ inlinedTemplates,
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Bootstrap a .gsd/ project from scratch for headless use.
228
+ * Ensures git repo, .gsd/ structure, gitignore, and preferences all exist.
229
+ */
230
+ function bootstrapGsdProject(basePath: string): void {
231
+ if (!nativeIsRepo(basePath)) {
232
+ const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
233
+ nativeInit(basePath, mainBranch);
234
+ }
235
+
236
+ const root = gsdRoot(basePath);
237
+ mkdirSync(join(root, "milestones"), { recursive: true });
238
+ mkdirSync(join(root, "runtime"), { recursive: true });
239
+
240
+ const commitDocs = loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs;
241
+ ensureGitignore(basePath, { commitDocs });
242
+ ensurePreferences(basePath);
243
+ untrackRuntimeFiles(basePath);
244
+ }
245
+
246
+ /**
247
+ * Headless milestone creation from a seed specification document.
248
+ * Bootstraps the project if needed, generates the next milestone ID,
249
+ * and dispatches the headless discuss prompt (no Q&A rounds).
250
+ */
251
+ export async function showHeadlessMilestoneCreation(
252
+ ctx: ExtensionCommandContext,
253
+ pi: ExtensionAPI,
254
+ basePath: string,
255
+ seedContext: string,
256
+ ): Promise<void> {
257
+ // Ensure .gsd/ is bootstrapped
258
+ bootstrapGsdProject(basePath);
259
+
260
+ // Generate next milestone ID
261
+ const existingIds = findMilestoneIds(basePath);
262
+ const prefs = loadEffectiveGSDPreferences();
263
+ const nextId = nextMilestoneId(existingIds, prefs?.preferences?.unique_milestone_ids ?? false);
264
+
265
+ // Create milestone directory
266
+ const milestoneDir = join(basePath, ".gsd", "milestones", nextId, "slices");
267
+ mkdirSync(milestoneDir, { recursive: true });
268
+
269
+ // Build and dispatch the headless discuss prompt
270
+ const prompt = buildHeadlessDiscussPrompt(nextId, seedContext, basePath);
271
+
272
+ // Set pending auto start (auto-mode triggers on "Milestone X ready." via checkAutoStartAfterDiscuss)
273
+ pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
274
+
275
+ // Dispatch
276
+ dispatchWorkflow(pi, prompt);
277
+ }
278
+
204
279
  export function findMilestoneIds(basePath: string): string[] {
205
280
  const dir = milestonesDir(basePath);
206
281
  try {
@@ -129,6 +129,23 @@ export default function (pi: ExtensionAPI) {
129
129
  registerWorktreeCommand(pi);
130
130
  registerExitCommand(pi);
131
131
 
132
+ // ── EPIPE guard — prevent crash when stdout/stderr pipe closes unexpectedly ──
133
+ // Node.js throws a fatal `Error: write EPIPE` when the parent process closes
134
+ // its end of the stdio pipe (e.g. during shell/IPC teardown) while auto-mode
135
+ // is still writing diagnostics. Catching this here gives auto-mode a clean
136
+ // chance to persist state and pause instead of crashing (see issue #739).
137
+ if (!process.listeners("uncaughtException").some(l => l.name === "_gsdEpipeGuard")) {
138
+ const _gsdEpipeGuard = (err: Error): void => {
139
+ if ((err as NodeJS.ErrnoException).code === "EPIPE") {
140
+ // Pipe closed — nothing we can write; just exit cleanly
141
+ process.exit(0);
142
+ }
143
+ // Re-throw anything that isn't EPIPE so real crashes still surface
144
+ throw err;
145
+ };
146
+ process.on("uncaughtException", _gsdEpipeGuard);
147
+ }
148
+
132
149
  // ── /kill — immediate exit (bypass cleanup) ─────────────────────────────
133
150
  pi.registerCommand("kill", {
134
151
  description: "Exit GSD immediately (no cleanup)",
@@ -699,7 +716,23 @@ export default function (pi: ExtensionAPI) {
699
716
  }
700
717
  }
701
718
 
702
- await pauseAutoForProviderError(ctx.ui, errorDetail, () => pauseAuto(ctx, pi));
719
+ // Detect rate-limit errors and extract retry delay for auto-resume
720
+ const errorMsg = ("errorMessage" in lastMsg && lastMsg.errorMessage) ? String(lastMsg.errorMessage) : "";
721
+ const isRateLimit = /rate.?limit|too many requests|429/i.test(errorMsg);
722
+ const retryAfterMs = ("retryAfterMs" in lastMsg && typeof lastMsg.retryAfterMs === "number")
723
+ ? lastMsg.retryAfterMs
724
+ : (() => { const m = errorMsg.match(/reset in (\d+)s/i); return m ? Number(m[1]) * 1000 : undefined; })();
725
+
726
+ await pauseAutoForProviderError(ctx.ui, errorDetail, () => pauseAuto(ctx, pi), {
727
+ isRateLimit,
728
+ retryAfterMs,
729
+ resume: () => {
730
+ pi.sendMessage(
731
+ { customType: "gsd-auto-timeout-recovery", content: "Continue execution \u2014 rate limit window elapsed.", display: false },
732
+ { triggerTurn: true },
733
+ );
734
+ },
735
+ });
703
736
  return;
704
737
  }
705
738
 
@@ -0,0 +1,233 @@
1
+ /**
2
+ * GSD Parallel Eligibility — Milestone parallelism analysis.
3
+ *
4
+ * Analyzes which milestones can safely run in parallel by checking
5
+ * dependency satisfaction and file overlap across slice plans.
6
+ */
7
+
8
+ import { deriveState } from "./state.js";
9
+ import { parseRoadmap, parsePlan, loadFile } from "./files.js";
10
+ import { resolveMilestoneFile, resolveSliceFile } from "./paths.js";
11
+ import { findMilestoneIds } from "./guided-flow.js";
12
+ import type { MilestoneRegistryEntry } from "./types.js";
13
+
14
+ // ─── Types ───────────────────────────────────────────────────────────────────
15
+
16
+ export interface EligibilityResult {
17
+ milestoneId: string;
18
+ title: string;
19
+ eligible: boolean;
20
+ reason: string;
21
+ }
22
+
23
+ export interface ParallelCandidates {
24
+ eligible: EligibilityResult[];
25
+ ineligible: EligibilityResult[];
26
+ fileOverlaps: Array<{ mid1: string; mid2: string; files: string[] }>;
27
+ }
28
+
29
+ // ─── File Collection ─────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Collect all `filesLikelyTouched` across every slice plan in a milestone.
33
+ * Returns a deduplicated list of file paths.
34
+ */
35
+ async function collectTouchedFiles(
36
+ basePath: string,
37
+ milestoneId: string,
38
+ ): Promise<string[]> {
39
+ const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
40
+ if (!roadmapPath) return [];
41
+
42
+ const roadmapContent = await loadFile(roadmapPath);
43
+ if (!roadmapContent) return [];
44
+
45
+ const roadmap = parseRoadmap(roadmapContent);
46
+ const files = new Set<string>();
47
+
48
+ for (const slice of roadmap.slices) {
49
+ const planPath = resolveSliceFile(basePath, milestoneId, slice.id, "PLAN");
50
+ if (!planPath) continue;
51
+
52
+ const planContent = await loadFile(planPath);
53
+ if (!planContent) continue;
54
+
55
+ const plan = parsePlan(planContent);
56
+ for (const f of plan.filesLikelyTouched) {
57
+ files.add(f);
58
+ }
59
+ }
60
+
61
+ return [...files];
62
+ }
63
+
64
+ // ─── Overlap Detection ──────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Compare file sets across milestones and return pairs with overlapping files.
68
+ */
69
+ function detectFileOverlaps(
70
+ fileSets: Map<string, string[]>,
71
+ ): Array<{ mid1: string; mid2: string; files: string[] }> {
72
+ const overlaps: Array<{ mid1: string; mid2: string; files: string[] }> = [];
73
+ const ids = [...fileSets.keys()];
74
+
75
+ for (let i = 0; i < ids.length; i++) {
76
+ const files1 = new Set(fileSets.get(ids[i])!);
77
+ for (let j = i + 1; j < ids.length; j++) {
78
+ const files2 = fileSets.get(ids[j])!;
79
+ const shared = files2.filter(f => files1.has(f));
80
+ if (shared.length > 0) {
81
+ overlaps.push({ mid1: ids[i], mid2: ids[j], files: shared.sort() });
82
+ }
83
+ }
84
+ }
85
+
86
+ return overlaps;
87
+ }
88
+
89
+ // ─── Analysis ────────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Analyze milestones for parallel execution eligibility.
93
+ *
94
+ * A milestone is eligible if:
95
+ * 1. It is not complete
96
+ * 2. Its dependencies (`dependsOn`) are all complete
97
+ * 3. It does not have file overlap with other eligible milestones
98
+ * (overlaps are flagged as warnings but do not disqualify)
99
+ */
100
+ export async function analyzeParallelEligibility(
101
+ basePath: string,
102
+ ): Promise<ParallelCandidates> {
103
+ const milestoneIds = findMilestoneIds(basePath);
104
+ const state = await deriveState(basePath);
105
+ const registry = state.registry;
106
+
107
+ // Build a lookup for quick status checks
108
+ const registryMap = new Map<string, MilestoneRegistryEntry>();
109
+ for (const entry of registry) {
110
+ registryMap.set(entry.id, entry);
111
+ }
112
+
113
+ const eligible: EligibilityResult[] = [];
114
+ const ineligible: EligibilityResult[] = [];
115
+
116
+ for (const mid of milestoneIds) {
117
+ const entry = registryMap.get(mid);
118
+ const title = entry?.title ?? mid;
119
+ const status = entry?.status ?? "pending";
120
+
121
+ // Rule 1: skip complete milestones
122
+ if (status === "complete") {
123
+ ineligible.push({
124
+ milestoneId: mid,
125
+ title,
126
+ eligible: false,
127
+ reason: "Already complete.",
128
+ });
129
+ continue;
130
+ }
131
+
132
+ // Rule 2: check dependency satisfaction
133
+ const deps = entry?.dependsOn ?? [];
134
+ const unsatisfied = deps.filter(dep => {
135
+ const depEntry = registryMap.get(dep);
136
+ return !depEntry || depEntry.status !== "complete";
137
+ });
138
+
139
+ if (unsatisfied.length > 0) {
140
+ ineligible.push({
141
+ milestoneId: mid,
142
+ title,
143
+ eligible: false,
144
+ reason: `Blocked by incomplete dependencies: ${unsatisfied.join(", ")}.`,
145
+ });
146
+ continue;
147
+ }
148
+
149
+ eligible.push({
150
+ milestoneId: mid,
151
+ title,
152
+ eligible: true,
153
+ reason: "All dependencies satisfied.",
154
+ });
155
+ }
156
+
157
+ // Rule 3: check file overlap among eligible milestones
158
+ const fileSets = new Map<string, string[]>();
159
+ for (const result of eligible) {
160
+ const files = await collectTouchedFiles(basePath, result.milestoneId);
161
+ fileSets.set(result.milestoneId, files);
162
+ }
163
+
164
+ const fileOverlaps = detectFileOverlaps(fileSets);
165
+
166
+ // Annotate eligible milestones that have file overlaps
167
+ const overlappingIds = new Set<string>();
168
+ for (const overlap of fileOverlaps) {
169
+ overlappingIds.add(overlap.mid1);
170
+ overlappingIds.add(overlap.mid2);
171
+ }
172
+
173
+ for (const result of eligible) {
174
+ if (overlappingIds.has(result.milestoneId)) {
175
+ result.reason = "All dependencies satisfied. WARNING: has file overlap with another eligible milestone.";
176
+ }
177
+ }
178
+
179
+ return { eligible, ineligible, fileOverlaps };
180
+ }
181
+
182
+ // ─── Formatting ──────────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Produce a human-readable report of parallel eligibility analysis.
186
+ */
187
+ export function formatEligibilityReport(candidates: ParallelCandidates): string {
188
+ const lines: string[] = [];
189
+
190
+ lines.push("# Parallel Eligibility Report");
191
+ lines.push("");
192
+
193
+ // Eligible milestones
194
+ lines.push(`## Eligible for Parallel Execution (${candidates.eligible.length})`);
195
+ lines.push("");
196
+ if (candidates.eligible.length === 0) {
197
+ lines.push("No milestones are currently eligible for parallel execution.");
198
+ } else {
199
+ for (const e of candidates.eligible) {
200
+ lines.push(`- **${e.milestoneId}** — ${e.title}`);
201
+ lines.push(` ${e.reason}`);
202
+ }
203
+ }
204
+ lines.push("");
205
+
206
+ // Ineligible milestones
207
+ lines.push(`## Ineligible (${candidates.ineligible.length})`);
208
+ lines.push("");
209
+ if (candidates.ineligible.length === 0) {
210
+ lines.push("All milestones are eligible.");
211
+ } else {
212
+ for (const e of candidates.ineligible) {
213
+ lines.push(`- **${e.milestoneId}** — ${e.title}`);
214
+ lines.push(` ${e.reason}`);
215
+ }
216
+ }
217
+ lines.push("");
218
+
219
+ // File overlap warnings
220
+ if (candidates.fileOverlaps.length > 0) {
221
+ lines.push(`## File Overlap Warnings (${candidates.fileOverlaps.length})`);
222
+ lines.push("");
223
+ for (const overlap of candidates.fileOverlaps) {
224
+ lines.push(`- **${overlap.mid1}** <-> **${overlap.mid2}** — ${overlap.files.length} shared file(s):`);
225
+ for (const f of overlap.files) {
226
+ lines.push(` - \`${f}\``);
227
+ }
228
+ }
229
+ lines.push("");
230
+ }
231
+
232
+ return lines.join("\n");
233
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * GSD Parallel Merge — Worktree reconciliation for parallel milestones.
3
+ *
4
+ * Handles merging completed milestone worktrees back to main branch
5
+ * with safety checks for parallel execution context.
6
+ */
7
+
8
+ import { loadFile } from "./files.js";
9
+ import { resolveMilestoneFile } from "./paths.js";
10
+ import { mergeMilestoneToMain } from "./auto-worktree.js";
11
+ import { MergeConflictError } from "./git-service.js";
12
+ import { removeSessionStatus } from "./session-status-io.js";
13
+ import type { WorkerInfo } from "./parallel-orchestrator.js";
14
+
15
+ // ─── Types ─────────────────────────────────────────────────────────────────
16
+
17
+ export interface MergeResult {
18
+ milestoneId: string;
19
+ success: boolean;
20
+ commitMessage?: string;
21
+ pushed?: boolean;
22
+ error?: string;
23
+ conflictFiles?: string[];
24
+ }
25
+
26
+ export type MergeOrder = "sequential" | "by-completion";
27
+
28
+ // ─── Merge Queue ───────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Determine safe merge order for completed milestones.
32
+ * Sequential: merge in milestone ID order (M001 before M002).
33
+ * By-completion: merge in the order milestones finished.
34
+ */
35
+ export function determineMergeOrder(
36
+ workers: WorkerInfo[],
37
+ order: MergeOrder = "sequential",
38
+ ): string[] {
39
+ const completed = workers.filter(w => w.state === "stopped" && w.completedUnits > 0);
40
+ if (order === "by-completion") {
41
+ return completed
42
+ .sort((a, b) => a.startedAt - b.startedAt) // earliest first
43
+ .map(w => w.milestoneId);
44
+ }
45
+ return completed
46
+ .sort((a, b) => a.milestoneId.localeCompare(b.milestoneId))
47
+ .map(w => w.milestoneId);
48
+ }
49
+
50
+ /**
51
+ * Attempt to merge a single milestone's worktree back to main.
52
+ * Wraps mergeMilestoneToMain with error handling for parallel context.
53
+ */
54
+ export async function mergeCompletedMilestone(
55
+ basePath: string,
56
+ milestoneId: string,
57
+ ): Promise<MergeResult> {
58
+ try {
59
+ // Load the roadmap content (needed by mergeMilestoneToMain)
60
+ const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
61
+ if (!roadmapPath) {
62
+ return {
63
+ milestoneId,
64
+ success: false,
65
+ error: `No roadmap found for ${milestoneId}`,
66
+ };
67
+ }
68
+
69
+ const roadmapContent = await loadFile(roadmapPath);
70
+ if (!roadmapContent) {
71
+ return {
72
+ milestoneId,
73
+ success: false,
74
+ error: `Could not read roadmap for ${milestoneId}`,
75
+ };
76
+ }
77
+
78
+ // Attempt the merge
79
+ const result = mergeMilestoneToMain(basePath, milestoneId, roadmapContent);
80
+
81
+ // Clean up parallel session status
82
+ removeSessionStatus(basePath, milestoneId);
83
+
84
+ return {
85
+ milestoneId,
86
+ success: true,
87
+ commitMessage: result.commitMessage,
88
+ pushed: result.pushed,
89
+ };
90
+ } catch (err) {
91
+ if (err instanceof MergeConflictError) {
92
+ return {
93
+ milestoneId,
94
+ success: false,
95
+ error: `Merge conflict: ${err.conflictedFiles.length} conflicting file(s)`,
96
+ conflictFiles: err.conflictedFiles,
97
+ };
98
+ }
99
+ return {
100
+ milestoneId,
101
+ success: false,
102
+ error: err instanceof Error ? err.message : String(err),
103
+ };
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Merge all completed milestones in sequence.
109
+ * Stops on first conflict and returns results so far.
110
+ */
111
+ export async function mergeAllCompleted(
112
+ basePath: string,
113
+ workers: WorkerInfo[],
114
+ order: MergeOrder = "sequential",
115
+ ): Promise<MergeResult[]> {
116
+ const mergeOrder = determineMergeOrder(workers, order);
117
+ const results: MergeResult[] = [];
118
+
119
+ for (const mid of mergeOrder) {
120
+ const result = await mergeCompletedMilestone(basePath, mid);
121
+ results.push(result);
122
+
123
+ // Stop on first conflict — later merges may depend on this one
124
+ if (!result.success && result.conflictFiles) {
125
+ break;
126
+ }
127
+ }
128
+
129
+ return results;
130
+ }
131
+
132
+ /**
133
+ * Format merge results for display.
134
+ */
135
+ export function formatMergeResults(results: MergeResult[]): string {
136
+ if (results.length === 0) return "No completed milestones to merge.";
137
+
138
+ const lines: string[] = ["# Merge Results\n"];
139
+
140
+ for (const r of results) {
141
+ if (r.success) {
142
+ const pushStatus = r.pushed ? " (pushed)" : "";
143
+ lines.push(`- **${r.milestoneId}** — merged successfully${pushStatus}`);
144
+ } else if (r.conflictFiles) {
145
+ lines.push(`- **${r.milestoneId}** — CONFLICT (${r.conflictFiles.length} file(s)):`);
146
+ for (const f of r.conflictFiles) {
147
+ lines.push(` - \`${f}\``);
148
+ }
149
+ lines.push(` Resolve conflicts manually and run \`/gsd parallel merge ${r.milestoneId}\` to retry.`);
150
+ } else {
151
+ lines.push(`- **${r.milestoneId}** — failed: ${r.error}`);
152
+ }
153
+ }
154
+
155
+ return lines.join("\n");
156
+ }