@pi-agents/orchid 0.1.0-beta.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 (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Process Registry — Runtime V2 agent lifecycle management
3
+ *
4
+ * File-backed registry that replaces legacy session discovery as the
5
+ * authoritative source of truth for agent liveness, identity, and
6
+ * attribution.
7
+ *
8
+ * Key design rules:
9
+ * 1. Parent writes manifest BEFORE child is considered visible.
10
+ * 2. Parent updates manifest on every status transition.
11
+ * 3. Operator tools read the registry, not terminal-session probes.
12
+ * 4. Resume/cleanup validates pid + startedAt for orphan detection.
13
+ *
14
+ * File locations:
15
+ * .pi/runtime/{batchId}/registry.json — batch-level snapshot
16
+ * .pi/runtime/{batchId}/agents/{agentId}/manifest.json — per-agent
17
+ *
18
+ * @module orchid/process-registry
19
+ * @since TP-104
20
+ */
21
+
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ writeFileSync,
27
+ readdirSync,
28
+ rmSync,
29
+ appendFileSync,
30
+ renameSync,
31
+ } from "fs";
32
+ import { join, dirname } from "path";
33
+
34
+ import {
35
+ TERMINAL_AGENT_STATUSES,
36
+ runtimeRoot,
37
+ runtimeAgentDir,
38
+ runtimeManifestPath,
39
+ runtimeRegistryPath,
40
+ runtimeAgentEventsPath,
41
+ runtimeLaneSnapshotPath,
42
+ runtimeMergeSnapshotPath,
43
+ validateAgentManifest,
44
+ type RuntimeAgentId,
45
+ type RuntimeAgentManifest,
46
+ type RuntimeAgentRole,
47
+ type RuntimeAgentStatus,
48
+ type RuntimeRegistry,
49
+ type RuntimeMergeSnapshot,
50
+ type PacketPaths,
51
+ } from "./types.ts";
52
+
53
+ // TP-195: Re-export RuntimeRegistry so dynamic-import references in
54
+ // execution.ts (`import("./process-registry.ts").RuntimeRegistry`) resolve
55
+ // without each call site having to import directly from types.ts. Pure
56
+ // re-export — no runtime impact.
57
+ export type { RuntimeRegistry };
58
+
59
+ // ── Manifest Lifecycle ───────────────────────────────────────────────
60
+
61
+ /**
62
+ * Write or update an agent manifest atomically.
63
+ *
64
+ * Uses write-to-temp + rename for crash safety. Creates parent
65
+ * directories if they don't exist.
66
+ *
67
+ * @since TP-104
68
+ */
69
+ export function writeManifest(stateRoot: string, manifest: RuntimeAgentManifest): void {
70
+ const dir = runtimeAgentDir(stateRoot, manifest.batchId, manifest.agentId);
71
+ mkdirSync(dir, { recursive: true });
72
+ const path = runtimeManifestPath(stateRoot, manifest.batchId, manifest.agentId);
73
+ const tmpPath = path + ".tmp";
74
+ writeFileSync(tmpPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
75
+ // Atomic rename (same directory = safe on all platforms)
76
+ renameSync(tmpPath, path);
77
+ }
78
+
79
+ /**
80
+ * Read an agent manifest. Returns null if not found or malformed.
81
+ *
82
+ * @since TP-104
83
+ */
84
+ export function readManifest(
85
+ stateRoot: string,
86
+ batchId: string,
87
+ agentId: RuntimeAgentId,
88
+ ): RuntimeAgentManifest | null {
89
+ const path = runtimeManifestPath(stateRoot, batchId, agentId);
90
+ if (!existsSync(path)) return null;
91
+ try {
92
+ const raw = readFileSync(path, "utf-8");
93
+ const parsed = JSON.parse(raw);
94
+ const errors = validateAgentManifest(parsed);
95
+ if (errors.length > 0) {
96
+ console.error(`[process-registry] invalid manifest ${agentId}: ${errors.join(", ")}`);
97
+ return null;
98
+ }
99
+ return parsed as RuntimeAgentManifest;
100
+ } catch (err: any) {
101
+ console.error(`[process-registry] failed to read manifest ${agentId}: ${err?.message}`);
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Update an agent's status in its manifest.
108
+ *
109
+ * Reads the current manifest, updates the status field, and writes
110
+ * it back atomically. No-op if manifest doesn't exist.
111
+ *
112
+ * @since TP-104
113
+ */
114
+ export function updateManifestStatus(
115
+ stateRoot: string,
116
+ batchId: string,
117
+ agentId: RuntimeAgentId,
118
+ status: RuntimeAgentStatus,
119
+ ): void {
120
+ const manifest = readManifest(stateRoot, batchId, agentId);
121
+ if (!manifest) return;
122
+ manifest.status = status;
123
+ writeManifest(stateRoot, manifest);
124
+ }
125
+
126
+ /**
127
+ * Create a fresh RuntimeAgentManifest with required fields.
128
+ *
129
+ * @since TP-104
130
+ */
131
+ export function createManifest(opts: {
132
+ batchId: string;
133
+ agentId: RuntimeAgentId;
134
+ role: RuntimeAgentRole;
135
+ laneNumber: number | null;
136
+ taskId: string | null;
137
+ repoId: string;
138
+ pid: number;
139
+ parentPid: number;
140
+ cwd: string;
141
+ packet: PacketPaths | null;
142
+ }): RuntimeAgentManifest {
143
+ return {
144
+ batchId: opts.batchId,
145
+ agentId: opts.agentId,
146
+ role: opts.role,
147
+ laneNumber: opts.laneNumber,
148
+ taskId: opts.taskId,
149
+ repoId: opts.repoId,
150
+ pid: opts.pid,
151
+ parentPid: opts.parentPid,
152
+ startedAt: Date.now(),
153
+ status: "spawning",
154
+ cwd: opts.cwd,
155
+ packet: opts.packet,
156
+ };
157
+ }
158
+
159
+ // ── Registry Snapshot ────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Build a registry snapshot from all agent manifests in a batch.
163
+ *
164
+ * Scans the agents/ directory under the runtime root and reads all
165
+ * valid manifests.
166
+ *
167
+ * @since TP-104
168
+ */
169
+ export function buildRegistrySnapshot(stateRoot: string, batchId: string): RuntimeRegistry {
170
+ const agentsDir = join(runtimeRoot(stateRoot, batchId), "agents");
171
+ const agents: Record<RuntimeAgentId, RuntimeAgentManifest> = {};
172
+
173
+ if (existsSync(agentsDir)) {
174
+ try {
175
+ const entries = readdirSync(agentsDir, { withFileTypes: true });
176
+ for (const entry of entries) {
177
+ if (!entry.isDirectory()) continue;
178
+ const agentId = entry.name;
179
+ const manifest = readManifest(stateRoot, batchId, agentId);
180
+ if (manifest) {
181
+ agents[agentId] = manifest;
182
+ }
183
+ }
184
+ } catch (err: any) {
185
+ console.error(`[process-registry] failed to scan agents dir: ${err?.message}`);
186
+ }
187
+ }
188
+
189
+ return {
190
+ batchId,
191
+ updatedAt: Date.now(),
192
+ agents,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Write the registry snapshot to disk.
198
+ *
199
+ * @since TP-104
200
+ */
201
+ export function writeRegistrySnapshot(stateRoot: string, registry: RuntimeRegistry): void {
202
+ const path = runtimeRegistryPath(stateRoot, registry.batchId);
203
+ mkdirSync(dirname(path), { recursive: true });
204
+ const tmpPath = path + ".tmp";
205
+ writeFileSync(tmpPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
206
+ renameSync(tmpPath, path);
207
+ }
208
+
209
+ /**
210
+ * Read the registry snapshot from disk. Returns null if not found.
211
+ *
212
+ * @since TP-104
213
+ */
214
+ export function readRegistrySnapshot(stateRoot: string, batchId: string): RuntimeRegistry | null {
215
+ const path = runtimeRegistryPath(stateRoot, batchId);
216
+ if (!existsSync(path)) return null;
217
+ try {
218
+ return JSON.parse(readFileSync(path, "utf-8"));
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ // ── Liveness Checks ──────────────────────────────────────────────────
225
+
226
+ /**
227
+ * Check whether a process with the given PID is still alive.
228
+ *
229
+ * Uses `process.kill(pid, 0)` which sends no signal but checks existence.
230
+ * Returns false for PID 0, negative PIDs, and dead processes.
231
+ *
232
+ * @since TP-104
233
+ */
234
+ export function isProcessAlive(pid: number): boolean {
235
+ if (!pid || pid <= 0 || !Number.isFinite(pid)) return false;
236
+ try {
237
+ process.kill(pid, 0);
238
+ return true;
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Determine if an agent is in a terminal (non-alive) state.
246
+ *
247
+ * @since TP-104
248
+ */
249
+ export function isTerminalStatus(status: RuntimeAgentStatus): boolean {
250
+ return TERMINAL_AGENT_STATUSES.has(status);
251
+ }
252
+
253
+ /**
254
+ * Get all live (non-terminal) agents from a registry snapshot.
255
+ *
256
+ * @since TP-104
257
+ */
258
+ export function getLiveAgents(registry: RuntimeRegistry): RuntimeAgentManifest[] {
259
+ return Object.values(registry.agents).filter((m) => !isTerminalStatus(m.status));
260
+ }
261
+
262
+ /**
263
+ * Get all agents matching a specific role from a registry snapshot.
264
+ *
265
+ * @since TP-104
266
+ */
267
+ export function getAgentsByRole(
268
+ registry: RuntimeRegistry,
269
+ role: RuntimeAgentRole,
270
+ ): RuntimeAgentManifest[] {
271
+ return Object.values(registry.agents).filter((m) => m.role === role);
272
+ }
273
+
274
+ // ── Orphan Detection ─────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Detect orphaned agents — manifests that claim to be running but whose
278
+ * process is no longer alive.
279
+ *
280
+ * Returns agent IDs of orphans. Caller decides whether to terminate,
281
+ * update manifest status, or log.
282
+ *
283
+ * @since TP-104
284
+ */
285
+ export function detectOrphans(registry: RuntimeRegistry): RuntimeAgentId[] {
286
+ const orphans: RuntimeAgentId[] = [];
287
+ for (const manifest of Object.values(registry.agents)) {
288
+ if (isTerminalStatus(manifest.status)) continue;
289
+ if (!isProcessAlive(manifest.pid)) {
290
+ orphans.push(manifest.agentId);
291
+ }
292
+ }
293
+ return orphans;
294
+ }
295
+
296
+ /**
297
+ * Mark detected orphans as crashed in their manifests.
298
+ *
299
+ * @since TP-104
300
+ */
301
+ export function markOrphansCrashed(
302
+ stateRoot: string,
303
+ batchId: string,
304
+ orphanIds: RuntimeAgentId[],
305
+ ): void {
306
+ for (const agentId of orphanIds) {
307
+ updateManifestStatus(stateRoot, batchId, agentId, "crashed");
308
+ }
309
+ }
310
+
311
+ // ── Cleanup ──────────────────────────────────────────────────────────
312
+
313
+ /**
314
+ * Remove all runtime artifacts for a batch.
315
+ *
316
+ * Best-effort: logs errors but doesn't throw.
317
+ *
318
+ * @since TP-104
319
+ */
320
+ export function cleanupBatchRuntime(
321
+ stateRoot: string,
322
+ batchId: string,
323
+ ): { removed: boolean; error?: string } {
324
+ const root = runtimeRoot(stateRoot, batchId);
325
+ if (!existsSync(root)) return { removed: false };
326
+ try {
327
+ rmSync(root, { recursive: true, force: true });
328
+ return { removed: true };
329
+ } catch (err: any) {
330
+ console.error(`[process-registry] failed to cleanup batch runtime: ${err?.message}`);
331
+ return { removed: false, error: err?.message };
332
+ }
333
+ }
334
+
335
+ // ── Normalized Event Helpers ─────────────────────────────────────────
336
+
337
+ /**
338
+ * Append a normalized event to an agent's event log.
339
+ *
340
+ * Creates the events file and parent directories if they don't exist.
341
+ * Best-effort: logs errors but doesn't throw.
342
+ *
343
+ * @since TP-104
344
+ */
345
+ export function appendAgentEvent(
346
+ stateRoot: string,
347
+ batchId: string,
348
+ agentId: RuntimeAgentId,
349
+ event: Record<string, unknown>,
350
+ ): void {
351
+ const path = runtimeAgentEventsPath(stateRoot, batchId, agentId);
352
+ mkdirSync(dirname(path), { recursive: true });
353
+ try {
354
+ appendFileSync(path, JSON.stringify(event) + "\n", "utf-8");
355
+ } catch (err: any) {
356
+ console.error(`[process-registry] failed to append event for ${agentId}: ${err?.message}`);
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Write a lane snapshot to disk.
362
+ *
363
+ * @since TP-104
364
+ */
365
+ export function writeLaneSnapshot(
366
+ stateRoot: string,
367
+ batchId: string,
368
+ laneNumber: number,
369
+ snapshot: Record<string, unknown>,
370
+ ): void {
371
+ const path = runtimeLaneSnapshotPath(stateRoot, batchId, laneNumber);
372
+ mkdirSync(dirname(path), { recursive: true });
373
+ const tmpPath = path + ".tmp";
374
+ writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
375
+ renameSync(tmpPath, path);
376
+ }
377
+
378
+ /**
379
+ * Read a V2 lane snapshot from disk.
380
+ * Returns null if the file doesn't exist or is unreadable.
381
+ * @since TP-115
382
+ */
383
+ export function readLaneSnapshot(
384
+ stateRoot: string,
385
+ batchId: string,
386
+ laneNumber: number,
387
+ ): { taskId?: string | null; status: string; updatedAt?: number } | null {
388
+ try {
389
+ const p = runtimeLaneSnapshotPath(stateRoot, batchId, laneNumber);
390
+ if (!existsSync(p)) return null;
391
+ return JSON.parse(readFileSync(p, "utf-8"));
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Write a V2 merge agent snapshot to disk (atomic rename).
399
+ *
400
+ * Stored in the `lanes/` directory alongside lane snapshots so the dashboard
401
+ * server picks it up with the same scan that reads lane-N.json files.
402
+ *
403
+ * Filename includes BOTH waveIndex and mergeNumber so wave-N+1's merges
404
+ * cannot overwrite wave-N's snapshots before the dashboard polls them (#509).
405
+ *
406
+ * @param stateRoot - Repository root (where `.pi/` lives)
407
+ * @param batchId - Current batch identifier
408
+ * @param waveIndex - 0-based wave index for the merge
409
+ * @param mergeNumber - 1-indexed merge agent number
410
+ * @param snapshot - Snapshot data to persist
411
+ *
412
+ * @since TP-164 (waveIndex parameter added in #509 remediation)
413
+ */
414
+ export function writeMergeSnapshot(
415
+ stateRoot: string,
416
+ batchId: string,
417
+ waveIndex: number,
418
+ mergeNumber: number,
419
+ snapshot: RuntimeMergeSnapshot,
420
+ ): void {
421
+ const path = runtimeMergeSnapshotPath(stateRoot, batchId, waveIndex, mergeNumber);
422
+ mkdirSync(dirname(path), { recursive: true });
423
+ const tmpPath = path + ".tmp";
424
+ writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
425
+ renameSync(tmpPath, path);
426
+ }
427
+
428
+ /**
429
+ * Read a V2 merge agent snapshot from disk.
430
+ * Returns null if the file does not exist or is unreadable.
431
+ *
432
+ * @param stateRoot - Repository root (where `.pi/` lives)
433
+ * @param batchId - Current batch identifier
434
+ * @param waveIndex - 0-based wave index for the merge
435
+ * @param mergeNumber - 1-indexed merge agent number
436
+ *
437
+ * @since TP-164 (waveIndex parameter added in #509 remediation)
438
+ */
439
+ export function readMergeSnapshot(
440
+ stateRoot: string,
441
+ batchId: string,
442
+ waveIndex: number,
443
+ mergeNumber: number,
444
+ ): RuntimeMergeSnapshot | null {
445
+ try {
446
+ const p = runtimeMergeSnapshotPath(stateRoot, batchId, waveIndex, mergeNumber);
447
+ if (!existsSync(p)) return null;
448
+ return JSON.parse(readFileSync(p, "utf-8")) as RuntimeMergeSnapshot;
449
+ } catch {
450
+ return null;
451
+ }
452
+ }