taskplane 0.6.0 → 0.7.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.
@@ -0,0 +1,3659 @@
1
+ /**
2
+ * Supervisor agent module — activates an interactive LLM agent in the pi
3
+ * session after `/orch` starts a non-blocking batch.
4
+ *
5
+ * The supervisor monitors engine events, handles failures, and keeps the
6
+ * operator informed. It shares the pi session, so the operator can converse
7
+ * naturally ("how's it going?", "fix it", "I'm going to bed") while the
8
+ * batch runs.
9
+ *
10
+ * Key components:
11
+ * - System prompt design (identity, context, capabilities, standing orders)
12
+ * - Activation after engine starts (via pi.sendMessage with triggerTurn)
13
+ * - System prompt persistence across turns (via before_agent_start event)
14
+ * - Model inheritance + config override
15
+ * - Lockfile + heartbeat for session takeover prevention (Step 2)
16
+ * - Startup detection + stale lock takeover with rehydration (Step 2)
17
+ * - Event tailer: batch-scoped consumption of events.jsonl (Step 3)
18
+ * - Proactive notifications with autonomy-aware verbosity (Step 3)
19
+ * - Task completion digest coalescing (Step 3)
20
+ * - Engine event consumption + proactive notifications (Step 3)
21
+ * - Recovery action classification model (Step 4)
22
+ * - Audit trail logging to actions.jsonl (Step 4)
23
+ * - Autonomy-driven confirmation behavior (Step 4)
24
+ *
25
+ * @module supervisor
26
+ * @since TP-041
27
+ */
28
+
29
+ import { join, dirname } from "path";
30
+ import { fileURLToPath } from "url";
31
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync, statSync, openSync, readSync, closeSync, appendFileSync } from "fs";
32
+ import { execFileSync } from "child_process";
33
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
34
+ import type { Model, Api } from "@mariozechner/pi-ai";
35
+ import type { OrchBatchRuntimeState, OrchestratorConfig, PersistedBatchState, EngineEvent, EngineEventType } from "./types.ts";
36
+ import type { Tier0Event, Tier0EventType } from "./persistence.ts";
37
+
38
+ // ── Recovery Action Classification (TP-041 Step 4) ───────────────────
39
+
40
+ /**
41
+ * Recovery action classification.
42
+ *
43
+ * Determines whether an action requires operator confirmation based
44
+ * on the current autonomy level. From spec §6.3:
45
+ *
46
+ * - **diagnostic**: Reading state, running non-mutating commands.
47
+ * Always allowed at all autonomy levels.
48
+ * - **tier0_known**: Known recovery patterns (session restart, worktree
49
+ * cleanup, merge retry). Automatic in supervised/autonomous modes.
50
+ * - **destructive**: State mutations, git operations that alter history,
51
+ * session kills, batch-state edits. Requires confirmation in
52
+ * interactive mode, conditional in supervised mode.
53
+ *
54
+ * Decision matrix:
55
+ *
56
+ * | Classification | Interactive | Supervised | Autonomous |
57
+ * |----------------|-------------|---------------|------------|
58
+ * | diagnostic | auto | auto | auto |
59
+ * | tier0_known | ASK | auto | auto |
60
+ * | destructive | ASK | ASK | auto |
61
+ *
62
+ * @since TP-041
63
+ */
64
+ export type RecoveryActionClassification = "diagnostic" | "tier0_known" | "destructive";
65
+
66
+ /**
67
+ * Determines whether operator confirmation is required for a given
68
+ * action classification at a given autonomy level.
69
+ *
70
+ * @param classification - The action's classification
71
+ * @param autonomy - Current supervisor autonomy level
72
+ * @returns true if the supervisor should ask the operator before executing
73
+ *
74
+ * @since TP-041
75
+ */
76
+ export function requiresConfirmation(
77
+ classification: RecoveryActionClassification,
78
+ autonomy: SupervisorAutonomyLevel,
79
+ ): boolean {
80
+ // Diagnostics never require confirmation
81
+ if (classification === "diagnostic") return false;
82
+
83
+ // Autonomous mode never asks
84
+ if (autonomy === "autonomous") return false;
85
+
86
+ // Interactive mode asks for everything non-diagnostic
87
+ if (autonomy === "interactive") return true;
88
+
89
+ // Supervised mode: auto for tier0_known, ask for destructive
90
+ return classification === "destructive";
91
+ }
92
+
93
+ /**
94
+ * Examples of actions in each classification category.
95
+ *
96
+ * Used by the system prompt to give the supervisor concrete guidance
97
+ * on how to classify its recovery actions.
98
+ *
99
+ * @since TP-041
100
+ */
101
+ export const ACTION_CLASSIFICATION_EXAMPLES: Readonly<Record<RecoveryActionClassification, readonly string[]>> = {
102
+ diagnostic: [
103
+ "Reading batch-state.json, STATUS.md, events.jsonl, merge results",
104
+ "Running git status, git log, git diff",
105
+ "Running test suites (npx vitest run, etc.)",
106
+ "Listing tmux sessions (tmux list-sessions)",
107
+ "Checking worktree health (git worktree list)",
108
+ "Reading any file for diagnostics",
109
+ ],
110
+ tier0_known: [
111
+ "Restarting a crashed tmux worker session",
112
+ "Cleaning up stale worktrees for retry",
113
+ "Retrying a timed-out merge",
114
+ "Resetting a session name collision",
115
+ "Clearing a git lock file (.git/index.lock)",
116
+ ],
117
+ destructive: [
118
+ "Killing a tmux session (tmux kill-session)",
119
+ "Editing batch-state.json fields",
120
+ "Running git reset, git merge, git checkout -B",
121
+ "Removing worktrees (git worktree remove)",
122
+ "Modifying STATUS.md or .DONE files",
123
+ "Deleting git branches (git branch -D)",
124
+ "Skipping tasks or waves",
125
+ ],
126
+ };
127
+
128
+
129
+ // ── Audit Trail (TP-041 Step 4) ──────────────────────────────────────
130
+
131
+ /**
132
+ * Structured audit trail entry written to `.pi/supervisor/actions.jsonl`.
133
+ *
134
+ * Every supervisor recovery action produces one entry. Destructive actions
135
+ * MUST be logged **before** execution (pre-action entry with result="pending"),
136
+ * then updated with the outcome after execution (result entry).
137
+ *
138
+ * Non-destructive diagnostics may be logged post-execution for completeness,
139
+ * but pre-action logging is not required.
140
+ *
141
+ * Schema contract: these fields are stable for takeover rehydration
142
+ * (buildTakeoverSummary reads this file). Adding new optional fields
143
+ * is safe; removing or renaming existing fields is a breaking change.
144
+ *
145
+ * @since TP-041
146
+ */
147
+ export interface AuditTrailEntry {
148
+ /** ISO 8601 timestamp of this log entry */
149
+ ts: string;
150
+ /** Action identifier — what the supervisor did (e.g., "merge_retry", "kill_session", "read_state") */
151
+ action: string;
152
+ /** Recovery action classification */
153
+ classification: RecoveryActionClassification;
154
+ /** Human-readable context — why this action was taken */
155
+ context: string;
156
+ /** Command or operation executed (e.g., "git merge --no-ff task/lane-2", "read batch-state.json") */
157
+ command: string;
158
+ /** Outcome of the action: "pending" (pre-action), "success", "failure", "skipped" */
159
+ result: "pending" | "success" | "failure" | "skipped";
160
+ /** Result detail — error message on failure, summary on success */
161
+ detail: string;
162
+ /** Batch ID for correlation */
163
+ batchId: string;
164
+ /** Optional: wave index if the action is wave-scoped */
165
+ waveIndex?: number;
166
+ /** Optional: lane number if the action is lane-scoped */
167
+ laneNumber?: number;
168
+ /** Optional: task ID if the action is task-scoped */
169
+ taskId?: string;
170
+ /** Optional: duration in milliseconds (populated on result entries) */
171
+ durationMs?: number;
172
+ }
173
+
174
+ /**
175
+ * Resolve the audit trail file path.
176
+ *
177
+ * @param stateRoot - Root path for .pi/ state directory
178
+ * @returns Absolute path to actions.jsonl
179
+ *
180
+ * @since TP-041
181
+ */
182
+ export function auditTrailPath(stateRoot: string): string {
183
+ return join(stateRoot, ".pi", "supervisor", "actions.jsonl");
184
+ }
185
+
186
+ /**
187
+ * Append a single audit trail entry to actions.jsonl.
188
+ *
189
+ * Best-effort and non-fatal: logging failures do not crash or block
190
+ * recovery actions. If the file or directory doesn't exist, it is
191
+ * created. If the append fails, the error is silently swallowed.
192
+ *
193
+ * @param stateRoot - Root path for .pi/ state directory
194
+ * @param entry - The audit entry to append
195
+ *
196
+ * @since TP-041
197
+ */
198
+ export function appendAuditEntry(stateRoot: string, entry: AuditTrailEntry): void {
199
+ try {
200
+ const dir = join(stateRoot, ".pi", "supervisor");
201
+ if (!existsSync(dir)) {
202
+ mkdirSync(dir, { recursive: true });
203
+ }
204
+ const path = auditTrailPath(stateRoot);
205
+ const line = JSON.stringify(entry) + "\n";
206
+ appendFileSync(path, line, "utf-8");
207
+ } catch {
208
+ // Best-effort: logging failures must not crash recovery
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Log a recovery action to the audit trail.
214
+ *
215
+ * Convenience wrapper around appendAuditEntry that fills in timestamp
216
+ * and batchId automatically from the supervisor state.
217
+ *
218
+ * For destructive actions, call this BEFORE execution with result="pending",
219
+ * then call again AFTER execution with the actual result.
220
+ *
221
+ * @param stateRoot - Root path for .pi/ state directory
222
+ * @param batchId - Current batch ID
223
+ * @param fields - Action fields (action, classification, context, command, result, detail, etc.)
224
+ *
225
+ * @since TP-041
226
+ */
227
+ export function logRecoveryAction(
228
+ stateRoot: string,
229
+ batchId: string,
230
+ fields: Omit<AuditTrailEntry, "ts" | "batchId">,
231
+ ): void {
232
+ const entry: AuditTrailEntry = {
233
+ ts: new Date().toISOString(),
234
+ batchId,
235
+ ...fields,
236
+ };
237
+ appendAuditEntry(stateRoot, entry);
238
+ }
239
+
240
+ /**
241
+ * Read audit trail entries from actions.jsonl.
242
+ *
243
+ * Returns parsed entries, skipping malformed lines (best-effort).
244
+ * Useful for:
245
+ * - Takeover rehydration (buildTakeoverSummary)
246
+ * - Test verification
247
+ * - Operator "what happened?" queries
248
+ *
249
+ * @param stateRoot - Root path for .pi/ state directory
250
+ * @param options - Optional filters: limit (max entries, from tail), batchId (filter by batch)
251
+ * @returns Array of parsed audit entries (most recent last)
252
+ *
253
+ * @since TP-041
254
+ */
255
+ export function readAuditTrail(
256
+ stateRoot: string,
257
+ options?: { limit?: number; batchId?: string },
258
+ ): AuditTrailEntry[] {
259
+ const path = auditTrailPath(stateRoot);
260
+ if (!existsSync(path)) return [];
261
+
262
+ try {
263
+ const raw = readFileSync(path, "utf-8").trim();
264
+ if (!raw) return [];
265
+
266
+ const lines = raw.split("\n");
267
+ const entries: AuditTrailEntry[] = [];
268
+
269
+ for (const line of lines) {
270
+ const trimmed = line.trim();
271
+ if (!trimmed) continue;
272
+ try {
273
+ const parsed = JSON.parse(trimmed) as AuditTrailEntry;
274
+ // Minimal validation: must have ts, action, batchId
275
+ if (typeof parsed.ts !== "string" || typeof parsed.action !== "string") continue;
276
+
277
+ // Apply batchId filter if specified
278
+ if (options?.batchId && parsed.batchId !== options.batchId) continue;
279
+
280
+ entries.push(parsed);
281
+ } catch {
282
+ // Skip malformed lines
283
+ }
284
+ }
285
+
286
+ // Apply tail limit if specified
287
+ if (options?.limit && entries.length > options.limit) {
288
+ return entries.slice(-options.limit);
289
+ }
290
+
291
+ return entries;
292
+ } catch {
293
+ return [];
294
+ }
295
+ }
296
+
297
+
298
+ // ── Branch Protection Detection (TP-043) ─────────────────────────────
299
+
300
+ /**
301
+ * Result of branch protection detection.
302
+ *
303
+ * - `protected`: Branch has protection rules enabled (require PRs)
304
+ * - `unprotected`: No protection rules found (direct push/merge OK)
305
+ * - `unknown`: Detection failed (no `gh` CLI, no remote, auth issues, etc.)
306
+ *
307
+ * @since TP-043
308
+ */
309
+ export type BranchProtectionStatus = "protected" | "unprotected" | "unknown";
310
+
311
+ /**
312
+ * Detect whether a branch has protection rules on GitHub.
313
+ *
314
+ * Uses `gh api repos/{owner}/{repo}/branches/{branch}/protection`:
315
+ * - HTTP 200 → protected (rules exist)
316
+ * - HTTP 404 → unprotected (no rules)
317
+ * - Any error → unknown (gh unavailable, no remote, auth issue, etc.)
318
+ *
319
+ * Extracts owner/repo from the git remote URL via `gh repo view`.
320
+ *
321
+ * @param branch - Branch name to check (e.g., "main")
322
+ * @param cwd - Working directory with the git repo
323
+ * @returns Branch protection status
324
+ *
325
+ * @since TP-043
326
+ */
327
+ export function detectBranchProtection(
328
+ branch: string,
329
+ cwd: string,
330
+ ): BranchProtectionStatus {
331
+ try {
332
+ // Get owner/repo from gh (handles SSH, HTTPS, and gh-specific remotes)
333
+ const repoInfo = execFileSync("gh", ["repo", "view", "--json", "owner,name", "--jq", ".owner.login + \"/\" + .name"], {
334
+ encoding: "utf-8",
335
+ timeout: 15_000,
336
+ cwd,
337
+ stdio: ["pipe", "pipe", "pipe"],
338
+ }).trim();
339
+
340
+ if (!repoInfo || !repoInfo.includes("/")) {
341
+ return "unknown";
342
+ }
343
+
344
+ // Check branch protection via GitHub API
345
+ const result = execFileSync("gh", ["api", `repos/${repoInfo}/branches/${branch}/protection`, "--silent"], {
346
+ encoding: "utf-8",
347
+ timeout: 15_000,
348
+ cwd,
349
+ stdio: ["pipe", "pipe", "pipe"],
350
+ });
351
+
352
+ // If we get here (no error), the API returned 200 → branch is protected
353
+ return "protected";
354
+ } catch (err: unknown) {
355
+ const e = err as { stderr?: string; status?: number };
356
+ const stderr = e.stderr || "";
357
+
358
+ // gh api returns exit code 1 with "HTTP 404" for unprotected branches
359
+ if (stderr.includes("HTTP 404") || stderr.includes("Not Found")) {
360
+ return "unprotected";
361
+ }
362
+
363
+ // Any other error (no gh, no auth, no remote, network, etc.)
364
+ return "unknown";
365
+ }
366
+ }
367
+
368
+
369
+ // ── Supervisor-Managed Integration Flow (TP-043) ─────────────────────
370
+
371
+ /**
372
+ * Integration plan describes the supervisor's proposed integration action.
373
+ *
374
+ * Built after analyzing the batch state, branch relationships, and
375
+ * branch protection status. Presented to the operator in supervised mode;
376
+ * executed directly in auto mode.
377
+ *
378
+ * @since TP-043
379
+ */
380
+ export interface IntegrationPlan {
381
+ /** The integration mode to use: ff, merge, or pr */
382
+ mode: "ff" | "merge" | "pr";
383
+ /** Orch branch to integrate from */
384
+ orchBranch: string;
385
+ /** Base branch to integrate into */
386
+ baseBranch: string;
387
+ /** Batch ID for logging/audit */
388
+ batchId: string;
389
+ /** Whether the base branch is protected */
390
+ branchProtection: BranchProtectionStatus;
391
+ /** Human-readable rationale for the chosen mode */
392
+ rationale: string;
393
+ /** Number of succeeded tasks (for summary) */
394
+ succeededTasks: number;
395
+ /** Number of failed tasks (for summary) */
396
+ failedTasks: number;
397
+ }
398
+
399
+ /**
400
+ * Build an integration plan based on the batch state and branch status.
401
+ *
402
+ * Mode selection logic:
403
+ * 1. If base branch is protected → PR mode (can't push directly)
404
+ * 2. If branches have diverged → merge mode (ff not possible)
405
+ * 3. Otherwise → ff mode (cleanest)
406
+ *
407
+ * @param batchState - Runtime batch state (orchBranch, baseBranch, counts)
408
+ * @param cwd - Working directory with the git repo
409
+ * @returns Integration plan, or null if integration is not possible
410
+ *
411
+ * @since TP-043
412
+ */
413
+ export function buildIntegrationPlan(
414
+ batchState: OrchBatchRuntimeState,
415
+ cwd: string,
416
+ protectionOverride?: BranchProtectionStatus,
417
+ ): IntegrationPlan | null {
418
+ if (!batchState.orchBranch || !batchState.baseBranch) {
419
+ return null;
420
+ }
421
+
422
+ if (batchState.succeededTasks === 0) {
423
+ return null; // Nothing to integrate
424
+ }
425
+
426
+ const orchBranch = batchState.orchBranch;
427
+ const baseBranch = batchState.baseBranch;
428
+ const batchId = batchState.batchId;
429
+
430
+ // Step 1: Check branch protection (injectable for testing)
431
+ const protection = protectionOverride ?? detectBranchProtection(baseBranch, cwd);
432
+
433
+ if (protection === "protected") {
434
+ return {
435
+ mode: "pr",
436
+ orchBranch,
437
+ baseBranch,
438
+ batchId,
439
+ branchProtection: protection,
440
+ rationale: `Base branch \`${baseBranch}\` is protected — creating a pull request for review.`,
441
+ succeededTasks: batchState.succeededTasks,
442
+ failedTasks: batchState.failedTasks,
443
+ };
444
+ }
445
+
446
+ if (protection === "unknown") {
447
+ // Safe fallback: when protection status can't be determined
448
+ // (gh CLI unavailable, no remote, etc.), default to PR mode
449
+ // to avoid accidentally pushing to a protected branch.
450
+ return {
451
+ mode: "pr",
452
+ orchBranch,
453
+ baseBranch,
454
+ batchId,
455
+ branchProtection: protection,
456
+ rationale: `Could not detect branch protection for \`${baseBranch}\` — defaulting to PR mode for safety.`,
457
+ succeededTasks: batchState.succeededTasks,
458
+ failedTasks: batchState.failedTasks,
459
+ };
460
+ }
461
+
462
+ // Step 2: Check ff-ability (is baseBranch ancestor of orchBranch?)
463
+ try {
464
+ execFileSync("git", ["merge-base", "--is-ancestor", baseBranch, orchBranch], {
465
+ encoding: "utf-8",
466
+ timeout: 10_000,
467
+ cwd,
468
+ stdio: ["pipe", "pipe", "pipe"],
469
+ });
470
+ // If no error, baseBranch is ancestor → ff is possible
471
+ return {
472
+ mode: "ff",
473
+ orchBranch,
474
+ baseBranch,
475
+ batchId,
476
+ branchProtection: protection,
477
+ rationale: `Branches are linear — fast-forward merge (cleanest history).`,
478
+ succeededTasks: batchState.succeededTasks,
479
+ failedTasks: batchState.failedTasks,
480
+ };
481
+ } catch {
482
+ // Branches have diverged — need merge commit
483
+ return {
484
+ mode: "merge",
485
+ orchBranch,
486
+ baseBranch,
487
+ batchId,
488
+ branchProtection: protection,
489
+ rationale: `Branches have diverged — creating a merge commit.`,
490
+ succeededTasks: batchState.succeededTasks,
491
+ failedTasks: batchState.failedTasks,
492
+ };
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Format an integration plan as a human-readable notification.
498
+ *
499
+ * Used in supervised mode to present the plan for operator confirmation.
500
+ *
501
+ * @param plan - The integration plan to format
502
+ * @returns Formatted notification string
503
+ *
504
+ * @since TP-043
505
+ */
506
+ export function formatIntegrationPlan(plan: IntegrationPlan): string {
507
+ const modeLabels: Record<string, string> = {
508
+ ff: "fast-forward merge",
509
+ merge: "merge commit",
510
+ pr: "pull request",
511
+ };
512
+
513
+ const lines: string[] = [];
514
+ lines.push(`🔀 **Integration Plan**`);
515
+ lines.push(``);
516
+ lines.push(`- **Mode:** ${modeLabels[plan.mode] || plan.mode}`);
517
+ lines.push(`- **From:** \`${plan.orchBranch}\` → \`${plan.baseBranch}\``);
518
+ lines.push(`- **Tasks:** ${plan.succeededTasks} succeeded${plan.failedTasks > 0 ? `, ${plan.failedTasks} failed` : ""}`);
519
+ lines.push(`- **Rationale:** ${plan.rationale}`);
520
+
521
+ if (plan.branchProtection === "protected") {
522
+ lines.push(`- **Note:** Branch protection detected — PR mode is required.`);
523
+ }
524
+
525
+ return lines.join("\n");
526
+ }
527
+
528
+ /**
529
+ * Format a message describing the integration outcome for the supervisor
530
+ * to present to the operator.
531
+ *
532
+ * @param plan - The integration plan that was executed
533
+ * @param success - Whether the integration succeeded
534
+ * @param detail - Additional detail (PR URL, error message, etc.)
535
+ * @returns Formatted outcome message
536
+ *
537
+ * @since TP-043
538
+ */
539
+ export function formatIntegrationOutcome(
540
+ plan: IntegrationPlan,
541
+ success: boolean,
542
+ detail: string,
543
+ ): string {
544
+ if (success) {
545
+ const modeLabel = plan.mode === "ff" ? "Fast-forwarded" : plan.mode === "merge" ? "Merged" : "Created PR for";
546
+ return `✅ **Integration complete!** ${modeLabel} \`${plan.orchBranch}\` → \`${plan.baseBranch}\`.\n${detail}`;
547
+ }
548
+ return `❌ **Integration failed** (\`${plan.orchBranch}\` → \`${plan.baseBranch}\`).\n${detail}`;
549
+ }
550
+
551
+ /**
552
+ * Integration executor callback type.
553
+ *
554
+ * Wraps `executeIntegration` from extension.ts to avoid circular imports.
555
+ * The callback receives the plan mode and context, and returns the result.
556
+ *
557
+ * @since TP-043 R002
558
+ */
559
+ export type IntegrationExecutor = (
560
+ mode: "ff" | "merge" | "pr",
561
+ context: { orchBranch: string; baseBranch: string; batchId: string; currentBranch: string; notices: string[] },
562
+ ) => { success: boolean; integratedLocally: boolean; commitCount: string; message: string; error?: string };
563
+
564
+ /**
565
+ * Dependencies for programmatic CI polling and PR merge (R002-2).
566
+ *
567
+ * Injected alongside the IntegrationExecutor to provide gh CLI access
568
+ * for CI status checks and PR merge operations.
569
+ *
570
+ * @since TP-043
571
+ */
572
+ export interface CiDeps {
573
+ /** Run an arbitrary command (e.g., gh CLI) in the repo root. */
574
+ runCommand: (cmd: string, args: string[]) => { ok: boolean; stdout: string; stderr: string };
575
+ /** Run a git command in the repo root. */
576
+ runGit: (args: string[]) => { ok: boolean; stdout: string; stderr: string };
577
+ /** Delete the batch state file. */
578
+ deleteBatchState: () => void;
579
+ }
580
+
581
+ /**
582
+ * Poll PR CI status checks programmatically.
583
+ *
584
+ * Polls `gh pr checks <branch> --json name,state,conclusion` up to
585
+ * maxAttempts times with a delay between each poll. Returns a summary
586
+ * of the CI outcome.
587
+ *
588
+ * @param orchBranch - The branch the PR was created from
589
+ * @param deps - CI deps (runCommand for gh CLI)
590
+ * @param maxAttempts - Maximum polling attempts (default: 30 → ~5 min at 10s intervals)
591
+ * @param delayMs - Delay between polls in ms (default: 10_000 → 10s)
592
+ * @returns CI check result
593
+ *
594
+ * @since TP-043
595
+ */
596
+ export async function pollPrCiStatus(
597
+ orchBranch: string,
598
+ deps: CiDeps,
599
+ maxAttempts: number = 30,
600
+ delayMs: number = 10_000,
601
+ ): Promise<{ status: "pass" | "fail" | "timeout" | "no-checks"; detail: string }> {
602
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
603
+ // Wait before polling (except first attempt — check immediately)
604
+ if (attempt > 1) {
605
+ await new Promise(resolve => setTimeout(resolve, delayMs));
606
+ }
607
+
608
+ const result = deps.runCommand("gh", [
609
+ "pr", "checks", orchBranch, "--json", "name,state,conclusion",
610
+ ]);
611
+
612
+ if (!result.ok) {
613
+ // gh pr checks failed — may be no PR or no checks configured
614
+ if (result.stderr.includes("no checks") || result.stderr.includes("no status checks")) {
615
+ return { status: "no-checks", detail: "No CI checks are configured for this repository." };
616
+ }
617
+ // On first attempt, the PR may not be fully created yet — retry
618
+ if (attempt === 1) continue;
619
+ return { status: "fail", detail: `Failed to query PR checks: ${result.stderr}` };
620
+ }
621
+
622
+ // Parse the JSON array of checks
623
+ let checks: Array<{ name: string; state: string; conclusion: string }>;
624
+ try {
625
+ checks = JSON.parse(result.stdout);
626
+ } catch {
627
+ continue; // Malformed output — retry
628
+ }
629
+
630
+ if (checks.length === 0) {
631
+ return { status: "no-checks", detail: "No CI checks are configured for this repository." };
632
+ }
633
+
634
+ // Check if all checks are complete
635
+ const allComplete = checks.every(c =>
636
+ c.state === "COMPLETED" || c.state === "completed",
637
+ );
638
+ if (!allComplete) continue; // Some still pending — keep polling
639
+
640
+ // All complete — check conclusions
641
+ const allPassing = checks.every(c =>
642
+ c.conclusion === "SUCCESS" || c.conclusion === "success" ||
643
+ c.conclusion === "NEUTRAL" || c.conclusion === "neutral" ||
644
+ c.conclusion === "SKIPPED" || c.conclusion === "skipped",
645
+ );
646
+
647
+ if (allPassing) {
648
+ return { status: "pass", detail: `All ${checks.length} CI check(s) passed.` };
649
+ }
650
+
651
+ // Some checks failed
652
+ const failed = checks.filter(c =>
653
+ c.conclusion !== "SUCCESS" && c.conclusion !== "success" &&
654
+ c.conclusion !== "NEUTRAL" && c.conclusion !== "neutral" &&
655
+ c.conclusion !== "SKIPPED" && c.conclusion !== "skipped",
656
+ );
657
+ const failedNames = failed.map(c => `${c.name}: ${c.conclusion}`).join(", ");
658
+ return { status: "fail", detail: `CI check(s) failed: ${failedNames}` };
659
+ }
660
+
661
+ return { status: "timeout", detail: `CI checks did not complete within ${maxAttempts} polling attempts.` };
662
+ }
663
+
664
+ /**
665
+ * Merge a PR via gh CLI after CI passes.
666
+ *
667
+ * Tries squash merge first (cleanest for integration PRs), then falls
668
+ * back to regular merge if squash is not allowed by repo rules.
669
+ *
670
+ * @param orchBranch - The branch the PR was created from
671
+ * @param deps - CI deps (runCommand for gh CLI)
672
+ * @returns Merge result
673
+ *
674
+ * @since TP-043
675
+ */
676
+ export function mergePr(
677
+ orchBranch: string,
678
+ deps: CiDeps,
679
+ ): { success: boolean; detail: string } {
680
+ // Try squash merge first
681
+ const squashResult = deps.runCommand("gh", [
682
+ "pr", "merge", orchBranch, "--squash", "--delete-branch",
683
+ ]);
684
+ if (squashResult.ok) {
685
+ return { success: true, detail: "PR merged (squash) and remote branch deleted." };
686
+ }
687
+
688
+ // Squash not allowed — try regular merge
689
+ const mergeResult = deps.runCommand("gh", [
690
+ "pr", "merge", orchBranch, "--merge", "--delete-branch",
691
+ ]);
692
+ if (mergeResult.ok) {
693
+ return { success: true, detail: "PR merged and remote branch deleted." };
694
+ }
695
+
696
+ return {
697
+ success: false,
698
+ detail: `PR merge failed: ${mergeResult.stderr || squashResult.stderr}`,
699
+ };
700
+ }
701
+
702
+ /**
703
+ * Dependencies for batch summary generation within integration flows.
704
+ *
705
+ * Passed through triggerSupervisorIntegration to ensure summary is
706
+ * generated before supervisor deactivation on all terminal paths.
707
+ *
708
+ * @since TP-043
709
+ */
710
+ export interface SummaryDeps {
711
+ /** Operator identifier for file naming */
712
+ opId: string;
713
+ /** Batch diagnostics (taskExits, batchCost) — null if unavailable */
714
+ diagnostics: { taskExits: Record<string, { classification: string; cost: number; durationSec: number }>; batchCost: number } | null;
715
+ /** Merge results for cost breakdown */
716
+ mergeResults: Array<{ waveIndex: number; status: string; failedLane: number | null; failureReason: string | null }>;
717
+ }
718
+
719
+ /**
720
+ * Execute the full PR lifecycle: poll CI, merge on success, clean up.
721
+ *
722
+ * Called after `executeIntegration("pr", ...)` succeeds (PR created).
723
+ * Polls CI status, merges when checks pass, reports failures.
724
+ * Always generates batch summary and deactivates the supervisor at
725
+ * the end (deterministic shutdown).
726
+ *
727
+ * @param plan - Integration plan (for branch/batch info)
728
+ * @param ciDeps - CI deps for gh CLI operations
729
+ * @param pi - ExtensionAPI for messaging
730
+ * @param state - Supervisor state (for deactivation)
731
+ * @param batchState - Runtime batch state (for summary generation)
732
+ * @param summaryDeps - Summary generation dependencies (optional, skipped if null)
733
+ *
734
+ * @since TP-043
735
+ */
736
+ async function handlePrLifecycle(
737
+ plan: IntegrationPlan,
738
+ ciDeps: CiDeps,
739
+ pi: ExtensionAPI,
740
+ state: SupervisorState,
741
+ batchState?: OrchBatchRuntimeState,
742
+ summaryDeps?: SummaryDeps | null,
743
+ ): Promise<void> {
744
+ // Poll CI status
745
+ const ciResult = await pollPrCiStatus(plan.orchBranch, ciDeps);
746
+
747
+ if (ciResult.status === "pass" || ciResult.status === "no-checks") {
748
+ // CI passed (or no checks) — merge the PR
749
+ const mergeOutcome = mergePr(plan.orchBranch, ciDeps);
750
+ if (mergeOutcome.success) {
751
+ // Clean up local state after remote merge
752
+ ciDeps.deleteBatchState();
753
+ ciDeps.runGit(["branch", "-D", plan.orchBranch]);
754
+ pi.sendMessage(
755
+ {
756
+ customType: "supervisor-integration-result",
757
+ content: [{
758
+ type: "text",
759
+ text:
760
+ `✅ **Integration complete!** PR merged into \`${plan.baseBranch}\`.\n` +
761
+ `${ciResult.detail}\n${mergeOutcome.detail}`,
762
+ }],
763
+ display: "Integration complete — PR merged",
764
+ },
765
+ { triggerTurn: false },
766
+ );
767
+ } else {
768
+ pi.sendMessage(
769
+ {
770
+ customType: "supervisor-integration-result",
771
+ content: [{
772
+ type: "text",
773
+ text:
774
+ `⚠️ **CI passed but merge failed.** ${mergeOutcome.detail}\n` +
775
+ `The PR is still open — merge manually on GitHub.`,
776
+ }],
777
+ display: "CI passed but PR merge failed",
778
+ },
779
+ { triggerTurn: false },
780
+ );
781
+ }
782
+ } else if (ciResult.status === "fail") {
783
+ pi.sendMessage(
784
+ {
785
+ customType: "supervisor-integration-result",
786
+ content: [{
787
+ type: "text",
788
+ text:
789
+ `❌ **CI checks failed.** ${ciResult.detail}\n` +
790
+ `The PR is still open. Fix the issues and merge manually, or close and retry.`,
791
+ }],
792
+ display: "CI checks failed — manual intervention needed",
793
+ },
794
+ { triggerTurn: false },
795
+ );
796
+ } else {
797
+ // timeout
798
+ pi.sendMessage(
799
+ {
800
+ customType: "supervisor-integration-result",
801
+ content: [{
802
+ type: "text",
803
+ text:
804
+ `⏰ **CI check timeout.** ${ciResult.detail}\n` +
805
+ `The PR is still open. Check CI status manually and merge when ready.`,
806
+ }],
807
+ display: "CI check timeout — check manually",
808
+ },
809
+ { triggerTurn: false },
810
+ );
811
+ }
812
+
813
+ // TP-043: Generate batch summary before deactivation
814
+ if (batchState && summaryDeps && state.stateRoot) {
815
+ presentBatchSummary(pi, batchState, state.stateRoot, summaryDeps.opId, summaryDeps.diagnostics, summaryDeps.mergeResults);
816
+ }
817
+
818
+ // Always deactivate after PR lifecycle completes (R002 issue #3)
819
+ deactivateSupervisor(pi, state);
820
+ }
821
+
822
+ /**
823
+ * Trigger the supervisor-managed integration flow after batch completion.
824
+ *
825
+ * Called from the engine's onTerminal callback when integration mode is
826
+ * "supervised" or "auto" and batch phase is "completed" (R002-1).
827
+ *
828
+ * **Auto mode (R002-2):** Executes integration programmatically via the
829
+ * provided executor (which wraps `executeIntegration` from extension.ts).
830
+ * For PR mode, programmatically polls CI status and merges on success.
831
+ * Reports outcome and deactivates supervisor deterministically — no path
832
+ * leaves the supervisor alive without a code-driven shutdown.
833
+ *
834
+ * **Supervised mode:** Presents the integration plan and asks the LLM to
835
+ * confirm with the operator. After confirmation, directs the LLM to run
836
+ * `/orch-integrate --{mode}` which uses the established execution path
837
+ * (resolveIntegrationContext + executeIntegration). This avoids duplicating
838
+ * integration logic via free-form git/gh instructions.
839
+ *
840
+ * If no integration is possible (no orch branch, no succeeded tasks),
841
+ * the supervisor is deactivated immediately.
842
+ *
843
+ * @param pi - ExtensionAPI for sending messages and deactivation
844
+ * @param state - Supervisor state (for deactivation if no integration needed)
845
+ * @param batchState - Runtime batch state
846
+ * @param integrationMode - "supervised" or "auto"
847
+ * @param cwd - Working directory for git operations
848
+ * @param executor - Integration executor callback (wraps executeIntegration to avoid circular imports)
849
+ * @param ciDeps - CI deps for programmatic PR polling and merge (auto/PR mode)
850
+ * @param summaryDeps - Optional summary deps for batch summary generation on all terminal paths
851
+ *
852
+ * @since TP-043
853
+ */
854
+ export function triggerSupervisorIntegration(
855
+ pi: ExtensionAPI,
856
+ state: SupervisorState,
857
+ batchState: OrchBatchRuntimeState,
858
+ integrationMode: "supervised" | "auto",
859
+ cwd: string,
860
+ executor?: IntegrationExecutor,
861
+ ciDeps?: CiDeps,
862
+ summaryDeps?: SummaryDeps | null,
863
+ ): void {
864
+ // TP-043: Helper to generate summary before deactivation
865
+ const summarizeAndDeactivate = () => {
866
+ if (summaryDeps && state.stateRoot) {
867
+ presentBatchSummary(pi, batchState, state.stateRoot, summaryDeps.opId, summaryDeps.diagnostics, summaryDeps.mergeResults);
868
+ }
869
+ deactivateSupervisor(pi, state);
870
+ };
871
+
872
+ // Build integration plan
873
+ const plan = buildIntegrationPlan(batchState, cwd);
874
+
875
+ if (!plan) {
876
+ // No integration possible — deactivate supervisor
877
+ pi.sendMessage(
878
+ {
879
+ customType: "supervisor-integration",
880
+ content: [{
881
+ type: "text",
882
+ text: `📋 **Batch complete.** No integration needed (no orch branch or no succeeded tasks). Supervisor deactivating.`,
883
+ }],
884
+ display: "No integration needed — supervisor deactivating",
885
+ },
886
+ { triggerTurn: false },
887
+ );
888
+ summarizeAndDeactivate();
889
+ return;
890
+ }
891
+
892
+ // Format the plan for reporting
893
+ const planText = formatIntegrationPlan(plan);
894
+
895
+ if (integrationMode === "supervised") {
896
+ // Supervised mode: present plan, ask LLM to confirm with operator,
897
+ // then direct it to /orch-integrate (established execution path).
898
+ const modeFlag = plan.mode === "ff" ? "" : plan.mode === "merge" ? " --merge" : " --pr";
899
+ pi.sendMessage(
900
+ {
901
+ customType: "supervisor-integration",
902
+ content: [{
903
+ type: "text",
904
+ text:
905
+ `🏁 **Batch complete!** Ready to integrate.\n\n` +
906
+ planText + `\n\n` +
907
+ `**Action required:** Ask the operator for confirmation.\n\n` +
908
+ `Say something like: "The batch completed successfully. I'd like to integrate ` +
909
+ `the changes from \`${plan.orchBranch}\` into \`${plan.baseBranch}\` using ` +
910
+ `${plan.mode === "ff" ? "fast-forward" : plan.mode === "merge" ? "a merge commit" : "a pull request"}. ` +
911
+ `${plan.rationale} Shall I proceed?"\n\n` +
912
+ `If the operator confirms, run: \`/orch-integrate${modeFlag}\`\n` +
913
+ `If the operator declines, acknowledge and deactivate.\n` +
914
+ `If the operator wants a different mode, adjust the flag:\n` +
915
+ ` - Fast-forward: \`/orch-integrate\`\n` +
916
+ ` - Merge commit: \`/orch-integrate --merge\`\n` +
917
+ ` - Pull request: \`/orch-integrate --pr\``,
918
+ }],
919
+ display: "Integration plan ready — awaiting operator confirmation",
920
+ },
921
+ { triggerTurn: true, deliverAs: "nextTurn" },
922
+ );
923
+
924
+ // TP-043 R004: Defer summary until after integration completes (or operator declines).
925
+ // Store deps on supervisor state so /orch-integrate completion or deactivateSupervisor
926
+ // can present the summary at the correct time.
927
+ if (summaryDeps) {
928
+ state.pendingSummaryDeps = summaryDeps;
929
+ }
930
+ return;
931
+ }
932
+
933
+ // ── Auto mode: execute integration programmatically (R002-2) ──
934
+
935
+ if (!executor) {
936
+ // Fallback: no executor provided — instruct operator to use /orch-integrate.
937
+ // This should not happen in normal operation but prevents a crash.
938
+ const modeFlag = plan.mode === "ff" ? "" : plan.mode === "merge" ? " --merge" : " --pr";
939
+ pi.sendMessage(
940
+ {
941
+ customType: "supervisor-integration",
942
+ content: [{
943
+ type: "text",
944
+ text:
945
+ `🏁 **Batch complete!** Integration executor unavailable.\n\n` +
946
+ planText + `\n\n` +
947
+ `Run \`/orch-integrate${modeFlag}\` to integrate manually.`,
948
+ }],
949
+ display: "Auto-integration fallback — run /orch-integrate",
950
+ },
951
+ { triggerTurn: false },
952
+ );
953
+ summarizeAndDeactivate();
954
+ return;
955
+ }
956
+
957
+ // Execute the integration synchronously using the provided executor
958
+ const context = {
959
+ orchBranch: plan.orchBranch,
960
+ baseBranch: plan.baseBranch,
961
+ batchId: plan.batchId,
962
+ currentBranch: plan.baseBranch,
963
+ notices: [],
964
+ };
965
+
966
+ let result = executor(plan.mode, context);
967
+
968
+ // If ff fails, automatically fall back to merge mode
969
+ if (!result.success && plan.mode === "ff") {
970
+ const fallbackResult = executor("merge", context);
971
+ if (fallbackResult.success) {
972
+ result = fallbackResult;
973
+ result.message = `⚠️ Fast-forward failed (branches diverged). Fell back to merge.\n${result.message}`;
974
+ }
975
+ // If merge also fails, result stays as the merge failure
976
+ }
977
+
978
+ if (result.success) {
979
+ const outcomeText = formatIntegrationOutcome(plan, true, result.message);
980
+
981
+ if (plan.mode === "pr" || !result.integratedLocally) {
982
+ // PR mode: integration created a PR but didn't merge locally.
983
+ // Programmatically poll CI status and merge (R002-2).
984
+ pi.sendMessage(
985
+ {
986
+ customType: "supervisor-integration-progress",
987
+ content: [{
988
+ type: "text",
989
+ text: `${outcomeText}\n\n⏳ Waiting for CI checks to complete...`,
990
+ }],
991
+ display: "PR created — polling CI status",
992
+ },
993
+ { triggerTurn: false },
994
+ );
995
+
996
+ if (ciDeps) {
997
+ // Fire-and-forget — handlePrLifecycle handles messaging,
998
+ // summary generation, and deterministic deactivation internally.
999
+ handlePrLifecycle(plan, ciDeps, pi, state, batchState, summaryDeps).catch((err: unknown) => {
1000
+ const msg = err instanceof Error ? err.message : String(err);
1001
+ pi.sendMessage(
1002
+ {
1003
+ customType: "supervisor-integration-result",
1004
+ content: [{
1005
+ type: "text",
1006
+ text: `❌ **CI monitoring crashed:** ${msg}\nThe PR is still open — check status and merge manually.`,
1007
+ }],
1008
+ display: "CI monitoring crashed",
1009
+ },
1010
+ { triggerTurn: false },
1011
+ );
1012
+ summarizeAndDeactivate();
1013
+ });
1014
+ } else {
1015
+ // No CI deps — can't poll. Report and deactivate.
1016
+ pi.sendMessage(
1017
+ {
1018
+ customType: "supervisor-integration-result",
1019
+ content: [{
1020
+ type: "text",
1021
+ text: `PR created. CI polling unavailable — check status and merge manually on GitHub.`,
1022
+ }],
1023
+ display: "PR created — merge manually",
1024
+ },
1025
+ { triggerTurn: false },
1026
+ );
1027
+ summarizeAndDeactivate();
1028
+ }
1029
+ return;
1030
+ }
1031
+
1032
+ // Local integration succeeded (ff or merge) — report and deactivate
1033
+ pi.sendMessage(
1034
+ {
1035
+ customType: "supervisor-integration-result",
1036
+ content: [{
1037
+ type: "text",
1038
+ text: outcomeText,
1039
+ }],
1040
+ display: `Integration complete (${plan.mode})`,
1041
+ },
1042
+ { triggerTurn: false },
1043
+ );
1044
+ summarizeAndDeactivate();
1045
+ } else {
1046
+ // Integration failed — report the error and deactivate
1047
+ const errorDetail = result.error || result.message || "Unknown integration error";
1048
+ const outcomeText = formatIntegrationOutcome(plan, false, errorDetail);
1049
+
1050
+ pi.sendMessage(
1051
+ {
1052
+ customType: "supervisor-integration-result",
1053
+ content: [{
1054
+ type: "text",
1055
+ text:
1056
+ outcomeText + `\n\n` +
1057
+ `Run \`/orch-integrate\` manually to retry with a different mode.`,
1058
+ }],
1059
+ display: "Integration failed — run /orch-integrate manually",
1060
+ },
1061
+ { triggerTurn: false },
1062
+ );
1063
+ summarizeAndDeactivate();
1064
+ }
1065
+ }
1066
+
1067
+
1068
+ // ── Batch Summary Generation (TP-043 Step 2) ────────────────────────
1069
+
1070
+ /**
1071
+ * Data required to generate a batch summary.
1072
+ *
1073
+ * Assembled from runtime and persisted state. Pure data — no side effects.
1074
+ *
1075
+ * @since TP-043
1076
+ */
1077
+ export interface BatchSummaryData {
1078
+ /** Batch ID */
1079
+ batchId: string;
1080
+ /** Batch phase at summary generation time */
1081
+ phase: string;
1082
+ /** Epoch ms when batch started */
1083
+ startedAt: number;
1084
+ /** Epoch ms when batch ended (null if still running) */
1085
+ endedAt: number | null;
1086
+ /** Total tasks in batch */
1087
+ totalTasks: number;
1088
+ /** Tasks completed successfully */
1089
+ succeededTasks: number;
1090
+ /** Tasks that failed */
1091
+ failedTasks: number;
1092
+ /** Tasks skipped */
1093
+ skippedTasks: number;
1094
+ /** Tasks blocked */
1095
+ blockedTasks: number;
1096
+ /** Batch cost in USD (from diagnostics) */
1097
+ batchCost: number;
1098
+ /** Wave plan (array of arrays of task IDs per wave) */
1099
+ wavePlan: string[][];
1100
+ /** Wave results with timing data */
1101
+ waveResults: Array<{
1102
+ waveIndex: number;
1103
+ startedAt: number;
1104
+ endedAt: number;
1105
+ succeededTaskIds: string[];
1106
+ failedTaskIds: string[];
1107
+ skippedTaskIds: string[];
1108
+ overallStatus: string;
1109
+ }>;
1110
+ /** Per-task exit summaries keyed by task ID (from diagnostics) */
1111
+ taskExits: Record<string, { classification: string; cost: number; durationSec: number }>;
1112
+ /** Merge results per wave */
1113
+ mergeResults: Array<{
1114
+ waveIndex: number;
1115
+ status: string;
1116
+ failedLane: number | null;
1117
+ failureReason: string | null;
1118
+ }>;
1119
+ /** Audit trail entries for the batch */
1120
+ auditEntries: AuditTrailEntry[];
1121
+ /** Tier 0 events from events.jsonl (recovery attempts, successes, exhausted, escalations) */
1122
+ tier0Events: Tier0EventSummary[];
1123
+ /** Errors accumulated during the batch */
1124
+ errors: string[];
1125
+ }
1126
+
1127
+ /**
1128
+ * Compact representation of a Tier 0 event for batch summary display.
1129
+ *
1130
+ * Extracted from events.jsonl, filtered to tier0_* event types and
1131
+ * the current batchId.
1132
+ *
1133
+ * @since TP-043
1134
+ */
1135
+ export interface Tier0EventSummary {
1136
+ /** ISO 8601 timestamp */
1137
+ timestamp: string;
1138
+ /** Event type (tier0_recovery_attempt, tier0_recovery_success, etc.) */
1139
+ type: string;
1140
+ /** Recovery pattern being applied */
1141
+ pattern: string;
1142
+ /** Current attempt number (1-based) */
1143
+ attempt: number;
1144
+ /** Maximum attempts allowed */
1145
+ maxAttempts: number;
1146
+ /** Affected task ID (if task-scoped) */
1147
+ taskId?: string;
1148
+ /** Resolution description (for success events) */
1149
+ resolution?: string;
1150
+ /** Error message (for exhausted events) */
1151
+ error?: string;
1152
+ /** Suggested remediation (for exhausted events) */
1153
+ suggestion?: string;
1154
+ /** Affected task IDs (for escalation context) */
1155
+ affectedTaskIds?: string[];
1156
+ }
1157
+
1158
+ /**
1159
+ * Tier 0 event types relevant to batch summary incidents.
1160
+ *
1161
+ * @since TP-043
1162
+ */
1163
+ const TIER0_SUMMARY_TYPES = new Set([
1164
+ "tier0_recovery_attempt",
1165
+ "tier0_recovery_success",
1166
+ "tier0_recovery_exhausted",
1167
+ "tier0_escalation",
1168
+ ]);
1169
+
1170
+ /**
1171
+ * Read Tier 0 events from events.jsonl, filtered by batchId.
1172
+ *
1173
+ * Parses each line as JSON, filters for tier0_* event types matching
1174
+ * the given batchId. Returns compact summaries sorted by timestamp.
1175
+ *
1176
+ * Best-effort: returns empty array if file doesn't exist or parsing fails.
1177
+ * Reuses the same parsing pattern as the event tailer (supervisor.ts:2493+).
1178
+ *
1179
+ * @param stateRoot - Root path for .pi/ state directory
1180
+ * @param batchId - Batch ID to filter events
1181
+ * @returns Array of Tier 0 event summaries (chronological order)
1182
+ *
1183
+ * @since TP-043
1184
+ */
1185
+ export function readTier0EventsForBatch(
1186
+ stateRoot: string,
1187
+ batchId: string,
1188
+ ): Tier0EventSummary[] {
1189
+ const eventsPath = join(stateRoot, ".pi", "supervisor", "events.jsonl");
1190
+ if (!existsSync(eventsPath)) return [];
1191
+
1192
+ try {
1193
+ const raw = readFileSync(eventsPath, "utf-8").trim();
1194
+ if (!raw) return [];
1195
+
1196
+ const results: Tier0EventSummary[] = [];
1197
+
1198
+ for (const line of raw.split("\n")) {
1199
+ const trimmed = line.trim();
1200
+ if (!trimmed) continue;
1201
+ try {
1202
+ const parsed = JSON.parse(trimmed);
1203
+ // Must match batchId and be a Tier 0 event type
1204
+ if (parsed.batchId !== batchId) continue;
1205
+ if (!TIER0_SUMMARY_TYPES.has(parsed.type)) continue;
1206
+
1207
+ results.push({
1208
+ timestamp: parsed.timestamp ?? "",
1209
+ type: parsed.type,
1210
+ pattern: parsed.pattern ?? "unknown",
1211
+ attempt: parsed.attempt ?? 0,
1212
+ maxAttempts: parsed.maxAttempts ?? 0,
1213
+ ...(parsed.taskId ? { taskId: parsed.taskId } : {}),
1214
+ ...(parsed.resolution ? { resolution: parsed.resolution } : {}),
1215
+ ...(parsed.error ? { error: parsed.error } : {}),
1216
+ ...(parsed.suggestion ? { suggestion: parsed.suggestion } : {}),
1217
+ ...(parsed.affectedTaskIds?.length ? { affectedTaskIds: parsed.affectedTaskIds } : {}),
1218
+ });
1219
+ } catch {
1220
+ // Skip malformed lines
1221
+ }
1222
+ }
1223
+
1224
+ return results;
1225
+ } catch {
1226
+ return [];
1227
+ }
1228
+ }
1229
+
1230
+ /**
1231
+ * Format a duration in milliseconds to a human-readable string.
1232
+ *
1233
+ * @since TP-043
1234
+ */
1235
+ function formatDurationMs(ms: number): string {
1236
+ if (ms < 0) ms = 0;
1237
+ const totalSecs = Math.floor(ms / 1000);
1238
+ if (totalSecs < 60) return `${totalSecs}s`;
1239
+ const mins = Math.floor(totalSecs / 60);
1240
+ const secs = totalSecs % 60;
1241
+ if (mins < 60) return `${mins}m${secs > 0 ? ` ${secs}s` : ""}`;
1242
+ const hours = Math.floor(mins / 60);
1243
+ const remainMins = mins % 60;
1244
+ return `${hours}h${remainMins > 0 ? ` ${remainMins}m` : ""}`;
1245
+ }
1246
+
1247
+ /**
1248
+ * Collect summary data from runtime batch state.
1249
+ *
1250
+ * Gathers data from OrchBatchRuntimeState, BatchDiagnostics, merge results,
1251
+ * and the audit trail. This function reads state — the formatter
1252
+ * (`formatBatchSummary`) is pure.
1253
+ *
1254
+ * @param batchState - Runtime batch state
1255
+ * @param stateRoot - Root path for .pi/ state directory
1256
+ * @param diagnostics - Batch diagnostics (taskExits, batchCost) or null
1257
+ * @param mergeResults - Persisted merge results or empty array
1258
+ * @returns Summary data ready for formatting
1259
+ *
1260
+ * @since TP-043
1261
+ */
1262
+ export function collectBatchSummaryData(
1263
+ batchState: OrchBatchRuntimeState,
1264
+ stateRoot: string,
1265
+ diagnostics?: { taskExits: Record<string, { classification: string; cost: number; durationSec: number }>; batchCost: number } | null,
1266
+ mergeResults?: Array<{ waveIndex: number; status: string; failedLane: number | null; failureReason: string | null }>,
1267
+ ): BatchSummaryData {
1268
+ // Read audit trail for incidents
1269
+ const auditEntries = readAuditTrail(stateRoot, { batchId: batchState.batchId });
1270
+
1271
+ // Read Tier 0 events from events.jsonl for recovery/escalation incidents (R003)
1272
+ const tier0Events = readTier0EventsForBatch(stateRoot, batchState.batchId);
1273
+
1274
+ // Extract wave results (may not exist if batch failed during planning)
1275
+ const waveResults = (batchState.waveResults || []).map(wr => ({
1276
+ waveIndex: wr.waveIndex,
1277
+ startedAt: wr.startedAt,
1278
+ endedAt: wr.endedAt,
1279
+ succeededTaskIds: wr.succeededTaskIds || [],
1280
+ failedTaskIds: wr.failedTaskIds || [],
1281
+ skippedTaskIds: wr.skippedTaskIds || [],
1282
+ overallStatus: wr.overallStatus || "unknown",
1283
+ }));
1284
+
1285
+ return {
1286
+ batchId: batchState.batchId,
1287
+ phase: batchState.phase,
1288
+ startedAt: batchState.startedAt,
1289
+ endedAt: batchState.endedAt,
1290
+ totalTasks: batchState.totalTasks,
1291
+ succeededTasks: batchState.succeededTasks,
1292
+ failedTasks: batchState.failedTasks,
1293
+ skippedTasks: batchState.skippedTasks,
1294
+ blockedTasks: batchState.blockedTasks,
1295
+ batchCost: diagnostics?.batchCost ?? 0,
1296
+ wavePlan: [], // Not directly available on runtime state — use waveResults
1297
+ waveResults,
1298
+ taskExits: diagnostics?.taskExits ?? {},
1299
+ mergeResults: mergeResults ?? [],
1300
+ auditEntries,
1301
+ tier0Events,
1302
+ errors: batchState.errors || [],
1303
+ };
1304
+ }
1305
+
1306
+ /**
1307
+ * Format a batch summary as a structured markdown string.
1308
+ *
1309
+ * Pure function — no I/O, no side effects. Follows the format specified
1310
+ * in spec §9.2: header with duration/cost/result, wave timeline, incidents,
1311
+ * recommendations, and cost breakdown by wave.
1312
+ *
1313
+ * When data is unavailable (no diagnostics, no audit trail, etc.), sections
1314
+ * are emitted with "Not available" rather than omitted — ensuring a complete
1315
+ * skeleton is always produced.
1316
+ *
1317
+ * @param data - Collected batch summary data
1318
+ * @returns Formatted markdown string
1319
+ *
1320
+ * @since TP-043
1321
+ */
1322
+ export function formatBatchSummary(data: BatchSummaryData): string {
1323
+ const lines: string[] = [];
1324
+
1325
+ // ── Header ───────────────────────────────────────────────────
1326
+ lines.push(`# Batch Summary: ${data.batchId}`);
1327
+ lines.push("");
1328
+
1329
+ // Duration
1330
+ const duration = data.endedAt && data.startedAt
1331
+ ? formatDurationMs(data.endedAt - data.startedAt)
1332
+ : "In progress";
1333
+ lines.push(`**Duration:** ${duration}`);
1334
+
1335
+ // Cost
1336
+ if (data.batchCost > 0) {
1337
+ lines.push(`**Cost:** $${data.batchCost.toFixed(2)}`);
1338
+ } else {
1339
+ lines.push(`**Cost:** Not available`);
1340
+ }
1341
+
1342
+ // Result
1343
+ const resultParts: string[] = [];
1344
+ resultParts.push(`${data.succeededTasks}/${data.totalTasks} tasks succeeded`);
1345
+ if (data.failedTasks > 0) resultParts.push(`${data.failedTasks} failed`);
1346
+ if (data.skippedTasks > 0) resultParts.push(`${data.skippedTasks} skipped`);
1347
+ if (data.blockedTasks > 0) resultParts.push(`${data.blockedTasks} blocked`);
1348
+ lines.push(`**Result:** ${resultParts.join(", ")}`);
1349
+ lines.push(`**Phase:** ${data.phase}`);
1350
+ lines.push("");
1351
+
1352
+ // ── Wave Timeline ────────────────────────────────────────────
1353
+ lines.push("## Wave Timeline");
1354
+ lines.push("");
1355
+
1356
+ if (data.waveResults.length === 0) {
1357
+ lines.push("No wave data available.");
1358
+ } else {
1359
+ for (const wave of data.waveResults) {
1360
+ const waveNum = wave.waveIndex + 1;
1361
+ const taskCount = wave.succeededTaskIds.length + wave.failedTaskIds.length + wave.skippedTaskIds.length;
1362
+ const waveDuration = formatDurationMs(wave.endedAt - wave.startedAt);
1363
+
1364
+ // Check for merge result for this wave
1365
+ const mergeResult = data.mergeResults.find(mr => mr.waveIndex === wave.waveIndex);
1366
+ let mergeInfo = "";
1367
+ if (mergeResult) {
1368
+ if (mergeResult.status === "succeeded") {
1369
+ mergeInfo = " ✅";
1370
+ } else if (mergeResult.status === "failed") {
1371
+ mergeInfo = ` ❌ (merge failed: ${mergeResult.failureReason || "unknown"})`;
1372
+ } else if (mergeResult.status === "partial") {
1373
+ mergeInfo = ` ⚠️ (partial merge)`;
1374
+ }
1375
+ }
1376
+
1377
+ const statusIcon = wave.overallStatus === "succeeded" ? "✅"
1378
+ : wave.overallStatus === "failed" ? "❌"
1379
+ : wave.overallStatus === "partial" ? "⚠️"
1380
+ : wave.overallStatus === "aborted" ? "🛑"
1381
+ : "❓";
1382
+
1383
+ lines.push(`- Wave ${waveNum} (${taskCount} tasks): ${waveDuration} ${statusIcon}${mergeInfo}`);
1384
+
1385
+ // Show failed tasks inline
1386
+ if (wave.failedTaskIds.length > 0) {
1387
+ lines.push(` - Failed: ${wave.failedTaskIds.join(", ")}`);
1388
+ }
1389
+ }
1390
+ }
1391
+ lines.push("");
1392
+
1393
+ // ── Incidents & Recoveries ───────────────────────────────────
1394
+ lines.push("## Incidents");
1395
+ lines.push("");
1396
+
1397
+ // Extract incidents from audit trail: non-diagnostic actions
1398
+ const incidents = data.auditEntries.filter(
1399
+ e => e.classification !== "diagnostic" && e.result !== "pending",
1400
+ );
1401
+
1402
+ const hasTier0Events = data.tier0Events.length > 0;
1403
+ const hasAuditIncidents = incidents.length > 0;
1404
+ const hasErrors = data.errors.length > 0;
1405
+
1406
+ if (!hasAuditIncidents && !hasTier0Events && !hasErrors) {
1407
+ lines.push("No incidents recorded.");
1408
+ } else {
1409
+ // ── Tier 0 Recovery Events (from events.jsonl) ───────────
1410
+ if (hasTier0Events) {
1411
+ lines.push("### Tier 0 Recoveries");
1412
+ lines.push("");
1413
+
1414
+ // Group Tier 0 events by pattern for readability
1415
+ const byPattern = new Map<string, typeof data.tier0Events>();
1416
+ for (const evt of data.tier0Events) {
1417
+ const key = evt.pattern;
1418
+ if (!byPattern.has(key)) byPattern.set(key, []);
1419
+ byPattern.get(key)!.push(evt);
1420
+ }
1421
+
1422
+ for (const [pattern, events] of byPattern) {
1423
+ const attempts = events.filter(e => e.type === "tier0_recovery_attempt").length;
1424
+ const successes = events.filter(e => e.type === "tier0_recovery_success").length;
1425
+ const exhausted = events.filter(e => e.type === "tier0_recovery_exhausted").length;
1426
+ const escalations = events.filter(e => e.type === "tier0_escalation").length;
1427
+
1428
+ const statusIcon = exhausted > 0 || escalations > 0 ? "❌"
1429
+ : successes > 0 ? "✅"
1430
+ : "⏳";
1431
+
1432
+ lines.push(`- **${pattern}** ${statusIcon} — ${attempts} attempt(s), ${successes} success(es), ${exhausted} exhausted`);
1433
+
1434
+ // Show affected tasks
1435
+ const taskIds = new Set<string>();
1436
+ for (const evt of events) {
1437
+ if (evt.taskId) taskIds.add(evt.taskId);
1438
+ if (evt.affectedTaskIds) {
1439
+ for (const tid of evt.affectedTaskIds) taskIds.add(tid);
1440
+ }
1441
+ }
1442
+ if (taskIds.size > 0) {
1443
+ lines.push(` - Affected tasks: ${[...taskIds].join(", ")}`);
1444
+ }
1445
+
1446
+ // Show escalation details
1447
+ for (const evt of events.filter(e => e.type === "tier0_escalation")) {
1448
+ if (evt.suggestion) {
1449
+ lines.push(` - Escalation: ${evt.suggestion}`);
1450
+ }
1451
+ }
1452
+
1453
+ // Show resolution details
1454
+ for (const evt of events.filter(e => e.type === "tier0_recovery_success")) {
1455
+ if (evt.resolution) {
1456
+ lines.push(` - Resolution: ${evt.resolution}`);
1457
+ }
1458
+ }
1459
+
1460
+ // Show error details for exhausted
1461
+ for (const evt of events.filter(e => e.type === "tier0_recovery_exhausted")) {
1462
+ if (evt.error) {
1463
+ lines.push(` - Error: ${evt.error}`);
1464
+ }
1465
+ }
1466
+ }
1467
+ lines.push("");
1468
+ }
1469
+
1470
+ // ── Supervisor Actions (from audit trail) ────────────────
1471
+ if (hasAuditIncidents) {
1472
+ if (hasTier0Events) {
1473
+ lines.push("### Supervisor Actions");
1474
+ lines.push("");
1475
+ }
1476
+
1477
+ let incidentNum = 0;
1478
+ for (const entry of incidents) {
1479
+ incidentNum++;
1480
+ const resultIcon = entry.result === "success" ? "✅"
1481
+ : entry.result === "failure" ? "❌"
1482
+ : entry.result === "skipped" ? "⏭️"
1483
+ : "❓";
1484
+ lines.push(`${incidentNum}. **${entry.action}** (${entry.classification}) ${resultIcon}`);
1485
+ lines.push(` ${entry.context}`);
1486
+ if (entry.detail && entry.detail !== entry.context) {
1487
+ lines.push(` Result: ${entry.detail}`);
1488
+ }
1489
+ if (entry.durationMs !== undefined) {
1490
+ lines.push(` Duration: ${formatDurationMs(entry.durationMs)}`);
1491
+ }
1492
+ }
1493
+ lines.push("");
1494
+ }
1495
+
1496
+ // Add errors that weren't captured in audit trail
1497
+ if (hasErrors) {
1498
+ lines.push("### Errors");
1499
+ for (const error of data.errors) {
1500
+ lines.push(`- ${error}`);
1501
+ }
1502
+ }
1503
+ }
1504
+ lines.push("");
1505
+
1506
+ // ── Recommendations ──────────────────────────────────────────
1507
+ lines.push("## Recommendations");
1508
+ lines.push("");
1509
+
1510
+ const recommendations: string[] = [];
1511
+
1512
+ // Timeout recommendations: look for merge failures in audit trail
1513
+ const mergeFailures = data.mergeResults.filter(mr => mr.status === "failed");
1514
+ if (mergeFailures.length > 0) {
1515
+ recommendations.push("- Consider increasing `merge.timeoutMinutes` — merge failures were detected during this batch.");
1516
+ }
1517
+
1518
+ // Failure rate recommendations
1519
+ if (data.totalTasks > 0 && data.failedTasks > 0) {
1520
+ const failureRate = data.failedTasks / data.totalTasks;
1521
+ if (failureRate > 0.3) {
1522
+ recommendations.push("- High failure rate (" + Math.round(failureRate * 100) + "%) — consider reducing task scope or adding more context to PROMPT.md files.");
1523
+ }
1524
+ }
1525
+
1526
+ // Long-running task recommendations
1527
+ const longTasks = Object.entries(data.taskExits).filter(([, exit]) => exit.durationSec > 3600);
1528
+ if (longTasks.length > 0) {
1529
+ const names = longTasks.map(([id]) => id).join(", ");
1530
+ recommendations.push(`- Long-running tasks detected (${names}): ${longTasks.length} task(s) exceeded 1 hour — consider splitting into smaller tasks.`);
1531
+ }
1532
+
1533
+ // Recovery recommendations — check both audit trail and Tier 0 events
1534
+ const recoveryExhaustedAudit = data.auditEntries.filter(e => e.action === "tier0_recovery_exhausted" || (e.classification === "tier0_known" && e.result === "failure"));
1535
+ const recoveryExhaustedTier0 = data.tier0Events.filter(e => e.type === "tier0_recovery_exhausted");
1536
+ const escalationsTier0 = data.tier0Events.filter(e => e.type === "tier0_escalation");
1537
+ if (recoveryExhaustedAudit.length > 0 || recoveryExhaustedTier0.length > 0) {
1538
+ recommendations.push("- Recovery budget was exhausted for some issues — review recurring failures and consider addressing root causes.");
1539
+ }
1540
+ if (escalationsTier0.length > 0) {
1541
+ const uniqueSuggestions = [...new Set(escalationsTier0.map(e => e.suggestion).filter(Boolean))];
1542
+ if (uniqueSuggestions.length > 0) {
1543
+ for (const suggestion of uniqueSuggestions) {
1544
+ recommendations.push(`- Tier 0 escalation: ${suggestion}`);
1545
+ }
1546
+ }
1547
+ }
1548
+
1549
+ // Blocked tasks recommendations
1550
+ if (data.blockedTasks > 0) {
1551
+ recommendations.push(`- ${data.blockedTasks} task(s) were blocked due to upstream failures — fix failed tasks and re-run with \`/orch-resume\`.`);
1552
+ }
1553
+
1554
+ if (recommendations.length === 0) {
1555
+ lines.push("No recommendations — batch ran smoothly.");
1556
+ } else {
1557
+ for (const rec of recommendations) {
1558
+ lines.push(rec);
1559
+ }
1560
+ }
1561
+ lines.push("");
1562
+
1563
+ // ── Cost Breakdown by Wave ───────────────────────────────────
1564
+ lines.push("## Cost Breakdown");
1565
+ lines.push("");
1566
+
1567
+ if (Object.keys(data.taskExits).length === 0) {
1568
+ lines.push("Cost data not available (no telemetry recorded).");
1569
+ } else {
1570
+ // Build per-wave cost table
1571
+ lines.push("| Wave | Tasks | Cost | Duration |");
1572
+ lines.push("|------|-------|------|----------|");
1573
+
1574
+ let totalCost = 0;
1575
+ for (const wave of data.waveResults) {
1576
+ const waveNum = wave.waveIndex + 1;
1577
+ const allTaskIds = [...wave.succeededTaskIds, ...wave.failedTaskIds, ...wave.skippedTaskIds];
1578
+ let waveCost = 0;
1579
+ let waveDurationSec = 0;
1580
+
1581
+ for (const taskId of allTaskIds) {
1582
+ const exit = data.taskExits[taskId];
1583
+ if (exit) {
1584
+ waveCost += exit.cost;
1585
+ waveDurationSec += exit.durationSec;
1586
+ }
1587
+ }
1588
+
1589
+ totalCost += waveCost;
1590
+ const waveDurationStr = formatDurationMs(waveDurationSec * 1000);
1591
+ lines.push(`| ${waveNum} | ${allTaskIds.length} | $${waveCost.toFixed(2)} | ${waveDurationStr} |`);
1592
+ }
1593
+
1594
+ lines.push(`| **Total** | **${data.totalTasks}** | **$${totalCost.toFixed(2)}** | **${duration}** |`);
1595
+ }
1596
+ lines.push("");
1597
+
1598
+ // ── Footer ───────────────────────────────────────────────────
1599
+ lines.push("---");
1600
+ lines.push(`*Generated at ${new Date().toISOString()}*`);
1601
+
1602
+ return lines.join("\n");
1603
+ }
1604
+
1605
+ /**
1606
+ * Generate and write the batch summary file.
1607
+ *
1608
+ * Collects data from the runtime batch state, formats it, and writes to
1609
+ * `.pi/supervisor/{opId}-{batchId}-summary.md`.
1610
+ *
1611
+ * Best-effort and non-fatal: if the file cannot be written, the error is
1612
+ * swallowed. The caller should also present the summary in conversation.
1613
+ *
1614
+ * @param batchState - Runtime batch state
1615
+ * @param stateRoot - Root path for .pi/ state directory
1616
+ * @param opId - Operator identifier (for file naming)
1617
+ * @param diagnostics - Batch diagnostics or null
1618
+ * @param mergeResults - Persisted merge results or empty array
1619
+ * @returns The formatted summary markdown string (for conversation presentation)
1620
+ *
1621
+ * @since TP-043
1622
+ */
1623
+ export function generateBatchSummary(
1624
+ batchState: OrchBatchRuntimeState,
1625
+ stateRoot: string,
1626
+ opId: string,
1627
+ diagnostics?: { taskExits: Record<string, { classification: string; cost: number; durationSec: number }>; batchCost: number } | null,
1628
+ mergeResults?: Array<{ waveIndex: number; status: string; failedLane: number | null; failureReason: string | null }>,
1629
+ ): string {
1630
+ const data = collectBatchSummaryData(batchState, stateRoot, diagnostics, mergeResults);
1631
+ const markdown = formatBatchSummary(data);
1632
+
1633
+ // Write to file — best-effort, non-fatal
1634
+ try {
1635
+ const dir = join(stateRoot, ".pi", "supervisor");
1636
+ if (!existsSync(dir)) {
1637
+ mkdirSync(dir, { recursive: true });
1638
+ }
1639
+ const filename = `${opId}-${batchState.batchId}-summary.md`;
1640
+ const filepath = join(dir, filename);
1641
+ writeFileSync(filepath, markdown, "utf-8");
1642
+ } catch {
1643
+ // Best-effort: file write failure must not block summary presentation
1644
+ }
1645
+
1646
+ return markdown;
1647
+ }
1648
+
1649
+ /**
1650
+ * Present a batch summary to the operator via a supervisor message.
1651
+ *
1652
+ * Generates the summary file and sends a concise version in conversation.
1653
+ * The full summary is available in the written file.
1654
+ *
1655
+ * @param pi - ExtensionAPI for sending messages
1656
+ * @param batchState - Runtime batch state
1657
+ * @param stateRoot - Root path for .pi/ state directory
1658
+ * @param opId - Operator identifier
1659
+ * @param diagnostics - Batch diagnostics or null
1660
+ * @param mergeResults - Persisted merge results or empty array
1661
+ *
1662
+ * @since TP-043
1663
+ */
1664
+ export function presentBatchSummary(
1665
+ pi: ExtensionAPI,
1666
+ batchState: OrchBatchRuntimeState,
1667
+ stateRoot: string,
1668
+ opId: string,
1669
+ diagnostics?: { taskExits: Record<string, { classification: string; cost: number; durationSec: number }>; batchCost: number } | null,
1670
+ mergeResults?: Array<{ waveIndex: number; status: string; failedLane: number | null; failureReason: string | null }>,
1671
+ ): void {
1672
+ const summary = generateBatchSummary(batchState, stateRoot, opId, diagnostics, mergeResults);
1673
+
1674
+ // Build a concise conversation message (full details in the file)
1675
+ const duration = batchState.endedAt && batchState.startedAt
1676
+ ? formatDurationMs(batchState.endedAt - batchState.startedAt)
1677
+ : "in progress";
1678
+ const cost = (diagnostics?.batchCost ?? 0) > 0
1679
+ ? `$${(diagnostics?.batchCost ?? 0).toFixed(2)}`
1680
+ : "not tracked";
1681
+ const filename = `${opId}-${batchState.batchId}-summary.md`;
1682
+
1683
+ const conciseText =
1684
+ `📊 **Batch Summary** — ${batchState.batchId}\n\n` +
1685
+ `- **Result:** ${batchState.succeededTasks}/${batchState.totalTasks} tasks succeeded\n` +
1686
+ `- **Duration:** ${duration}\n` +
1687
+ `- **Cost:** ${cost}\n` +
1688
+ (batchState.failedTasks > 0 ? `- **Failed:** ${batchState.failedTasks} task(s)\n` : "") +
1689
+ `\nFull summary written to \`.pi/supervisor/${filename}\`.`;
1690
+
1691
+ pi.sendMessage(
1692
+ {
1693
+ customType: "supervisor-batch-summary",
1694
+ content: [{ type: "text", text: conciseText }],
1695
+ display: `Batch summary: ${batchState.succeededTasks}/${batchState.totalTasks} succeeded`,
1696
+ },
1697
+ { triggerTurn: false },
1698
+ );
1699
+ }
1700
+
1701
+
1702
+ // ── Supervisor Config Types ──────────────────────────────────────────
1703
+
1704
+ /**
1705
+ * Autonomy level for the supervisor agent.
1706
+ *
1707
+ * Controls how much the supervisor does automatically vs. asking the operator.
1708
+ *
1709
+ * - `interactive`: Ask before any recovery action
1710
+ * - `supervised`: Tier 0 patterns auto, novel recovery asks
1711
+ * - `autonomous`: Handle everything, pause only when stuck
1712
+ *
1713
+ * @since TP-041
1714
+ */
1715
+ export type SupervisorAutonomyLevel = "interactive" | "supervised" | "autonomous";
1716
+
1717
+ /**
1718
+ * Supervisor configuration resolved from project config + user preferences.
1719
+ *
1720
+ * @since TP-041
1721
+ */
1722
+ export interface SupervisorConfig {
1723
+ /** Model to use for supervisor agent. Empty string = inherit session model. */
1724
+ model: string;
1725
+ /** Autonomy level controlling confirmation behavior. */
1726
+ autonomy: SupervisorAutonomyLevel;
1727
+ }
1728
+
1729
+ /** Default supervisor config values. */
1730
+ export const DEFAULT_SUPERVISOR_CONFIG: SupervisorConfig = {
1731
+ model: "",
1732
+ autonomy: "supervised",
1733
+ };
1734
+
1735
+ // ── System Prompt ────────────────────────────────────────────────────
1736
+
1737
+ /**
1738
+ * Path to the supervisor primer markdown file, resolved relative to this
1739
+ * module's directory (extensions/taskplane/).
1740
+ */
1741
+ function resolvePrimerPath(): string {
1742
+ try {
1743
+ const thisDir = dirname(fileURLToPath(import.meta.url));
1744
+ return join(thisDir, "supervisor-primer.md");
1745
+ } catch {
1746
+ // Fallback for environments where import.meta.url is unavailable
1747
+ return join(__dirname, "supervisor-primer.md");
1748
+ }
1749
+ }
1750
+
1751
+ /**
1752
+ * Build the supervisor system prompt.
1753
+ *
1754
+ * The prompt establishes:
1755
+ * 1. **Identity**: "You are the batch supervisor"
1756
+ * 2. **Context**: Batch metadata, file paths, wave plan
1757
+ * 3. **Capabilities**: Full tool access for monitoring and recovery
1758
+ * 4. **Standing orders**: Monitor events, handle failures, keep operator informed
1759
+ * 5. **Primer reference**: Read supervisor-primer.md for detailed operational knowledge
1760
+ *
1761
+ * The prompt is rebuilt on every LLM turn from the live batchState reference,
1762
+ * ensuring it always reflects the latest batch metadata (including batchId,
1763
+ * wave counts, and task counts that are populated asynchronously by the engine).
1764
+ *
1765
+ * @param batchState - Current batch runtime state (live reference)
1766
+ * @param config - Orchestrator configuration
1767
+ * @param supervisorConfig - Supervisor-specific configuration
1768
+ * @param stateRoot - Root path for .pi/ state directory
1769
+ * @returns The complete system prompt string
1770
+ *
1771
+ * @since TP-041
1772
+ */
1773
+ export function buildSupervisorSystemPrompt(
1774
+ batchState: OrchBatchRuntimeState,
1775
+ config: OrchestratorConfig,
1776
+ supervisorConfig: SupervisorConfig,
1777
+ stateRoot: string,
1778
+ ): string {
1779
+ const primerPath = resolvePrimerPath();
1780
+ const batchStatePath = join(stateRoot, ".pi", "batch-state.json");
1781
+ const eventsPath = join(stateRoot, ".pi", "supervisor", "events.jsonl");
1782
+ const autonomyLabel = supervisorConfig.autonomy;
1783
+
1784
+ // Build wave plan summary
1785
+ const waveSummary = batchState.totalWaves > 0
1786
+ ? `${batchState.currentWaveIndex + 1}/${batchState.totalWaves} waves`
1787
+ : "planning";
1788
+
1789
+ const actionsPath = auditTrailPath(stateRoot);
1790
+ const integrationMode = config.orchestrator.integration;
1791
+
1792
+ // TP-043: Build guardrails section dynamically based on integration mode.
1793
+ // When integration is "supervised" or "auto", the supervisor is allowed to
1794
+ // push branches and create PRs as part of post-batch integration.
1795
+ const guardrailsSection = integrationMode === "supervised" || integrationMode === "auto"
1796
+ ? `## What You Must NEVER Do
1797
+
1798
+ 1. Never delete \`.pi/batch-state.json\` without operator approval
1799
+ 2. Never modify task code (files that workers wrote)
1800
+ 3. Never modify PROMPT.md files
1801
+ 4. Never \`git reset --hard\` with uncommitted changes
1802
+ 5. Never skip tasks/waves without telling the operator
1803
+ 6. Never create GitHub releases
1804
+
1805
+ ## Integration Permissions (mode: ${integrationMode})
1806
+
1807
+ You are authorized to perform integration operations after batch completion:
1808
+ - \`git push origin <orch-branch>\` — push the orch branch for PR creation
1809
+ - \`gh pr create\` — create pull requests for integration
1810
+ - \`git merge --ff-only\` or \`git merge --no-edit\` — local branch integration
1811
+ - \`git branch -D <orch-branch>\` — cleanup after successful integration
1812
+
1813
+ ${integrationMode === "supervised" ? `**Supervised mode:** Before executing integration, describe your plan and ask the operator for confirmation.` : `**Auto mode:** Execute integration directly. Report the outcome to the operator. Pause only on errors or conflicts.`}`
1814
+ : `## What You Must NEVER Do
1815
+
1816
+ 1. Never \`git push\` to any remote
1817
+ 2. Never delete \`.pi/batch-state.json\` without operator approval
1818
+ 3. Never modify task code (files that workers wrote)
1819
+ 4. Never modify PROMPT.md files
1820
+ 5. Never \`git reset --hard\` with uncommitted changes
1821
+ 6. Never skip tasks/waves without telling the operator
1822
+ 7. Never create PRs or GitHub releases`;
1823
+
1824
+ const prompt = `# Supervisor Agent
1825
+
1826
+ You are the **batch supervisor** — a persistent agent that monitors a Taskplane
1827
+ orchestration batch, handles failures, and keeps the operator informed.
1828
+
1829
+ ## Identity
1830
+
1831
+ You share this terminal session with the human operator. After \`/orch\` started
1832
+ a batch, you activated to supervise it. The operator can talk to you naturally
1833
+ at any time. You are a senior engineer on call for this batch.
1834
+
1835
+ ## Current Batch Context
1836
+
1837
+ - **Batch ID:** ${batchState.batchId || "(initializing — read batch state file)"}
1838
+ - **Phase:** ${batchState.phase}
1839
+ - **Base branch:** ${batchState.baseBranch}
1840
+ - **Orch branch:** ${batchState.orchBranch || "(legacy mode)"}
1841
+ - **Progress:** ${waveSummary}, ${batchState.totalTasks} total tasks
1842
+ - **Succeeded:** ${batchState.succeededTasks} | **Failed:** ${batchState.failedTasks} | **Skipped:** ${batchState.skippedTasks} | **Blocked:** ${batchState.blockedTasks}
1843
+ - **Autonomy:** ${autonomyLabel}
1844
+
1845
+ ## Key File Paths
1846
+
1847
+ - **Batch state:** \`${batchStatePath}\`
1848
+ - **Engine events:** \`${eventsPath}\`
1849
+ - **Audit trail:** \`${actionsPath}\`
1850
+ - **State root:** \`${stateRoot}\`
1851
+
1852
+ ## Capabilities
1853
+
1854
+ You have full tool access: \`read\`, \`write\`, \`edit\`, \`bash\`, \`grep\`, \`find\`, \`ls\`.
1855
+ Use these to:
1856
+ - Read batch state, STATUS.md files, merge results, event logs
1857
+ - Run git commands for diagnostics and manual merge recovery
1858
+ - Edit batch-state.json for state repairs (when needed)
1859
+ - Manage tmux sessions (list, kill, attach)
1860
+ - Run verification commands (tests)
1861
+
1862
+ ## Standing Orders
1863
+
1864
+ 1. **Monitor engine events.** Periodically read \`${eventsPath}\` to track
1865
+ batch progress. Report significant events to the operator proactively:
1866
+ - Wave starts/completions
1867
+ - Task failures requiring attention
1868
+ - Merge successes/failures
1869
+ - Batch completion
1870
+
1871
+ 2. **Handle failures.** When tasks fail or merges time out, diagnose the
1872
+ issue using the patterns in supervisor-primer.md and take appropriate
1873
+ recovery action based on your autonomy level (${autonomyLabel}).
1874
+
1875
+ 3. **Keep the operator informed.** Provide clear, natural status updates.
1876
+ When the operator asks "how's it going?" — read batch state and summarize.
1877
+
1878
+ 4. **Log all recovery actions** to the audit trail (see Audit Trail section below).
1879
+
1880
+ 5. **Respect your autonomy level** (see Recovery Action Classification below).
1881
+
1882
+ ## Recovery Action Classification
1883
+
1884
+ Every action you take falls into one of three categories:
1885
+
1886
+ ### Diagnostic (always allowed — no confirmation needed)
1887
+ - Reading batch-state.json, STATUS.md, events.jsonl, merge results
1888
+ - Running \`git status\`, \`git log\`, \`git diff\`
1889
+ - Running test suites (\`npx vitest run\`, etc.)
1890
+ - Listing tmux sessions (\`tmux list-sessions\`)
1891
+ - Checking worktree health (\`git worktree list\`)
1892
+ - Reading any file for diagnostics
1893
+
1894
+ ### Tier 0 Known (known recovery patterns)
1895
+ - Restarting a crashed tmux worker session
1896
+ - Cleaning up stale worktrees for retry
1897
+ - Retrying a timed-out merge
1898
+ - Resetting a session name collision
1899
+ - Clearing a git lock file (\`.git/index.lock\`)
1900
+
1901
+ ### Destructive (state mutations, irreversible operations)
1902
+ - Killing a tmux session (\`tmux kill-session\`)
1903
+ - Editing batch-state.json fields
1904
+ - Running \`git reset\`, \`git merge\`, \`git checkout -B\`
1905
+ - Removing worktrees (\`git worktree remove\`)
1906
+ - Modifying STATUS.md or .DONE files
1907
+ - Deleting git branches (\`git branch -D\`)
1908
+ - Skipping tasks or waves
1909
+
1910
+ ### Autonomy Decision Table (current level: ${autonomyLabel})
1911
+
1912
+ | Classification | Interactive | Supervised | Autonomous |
1913
+ |----------------|-------------|------------|------------|
1914
+ | Diagnostic | ✅ auto | ✅ auto | ✅ auto |
1915
+ | Tier 0 Known | ❓ ASK | ✅ auto | ✅ auto |
1916
+ | Destructive | ❓ ASK | ❓ ASK | ✅ auto |
1917
+
1918
+ ${autonomyLabel === "interactive" ? `**Your current level is INTERACTIVE.** ASK the operator before any Tier 0 Known or Destructive action. Explain what you want to do, why, and what the alternatives are. Let the operator decide.` : ""}${autonomyLabel === "supervised" ? `**Your current level is SUPERVISED.** Execute Tier 0 Known patterns automatically (retries, cleanup, session restarts). ASK before Destructive actions (manual merges, state editing, skipping tasks, killing sessions). Always explain what you did and why.` : ""}${autonomyLabel === "autonomous" ? `**Your current level is AUTONOMOUS.** Execute all recovery actions automatically. Pause and summarize only when you're genuinely stuck and cannot resolve the issue. The operator trusts you to make reasonable decisions.` : ""}
1919
+
1920
+ ## Audit Trail
1921
+
1922
+ Log every recovery action to \`${actionsPath}\` as a single-line JSON entry.
1923
+
1924
+ **Format** (one JSON object per line):
1925
+ \`\`\`json
1926
+ {"ts":"<ISO 8601>","action":"<action_name>","classification":"<diagnostic|tier0_known|destructive>","context":"<why>","command":"<what>","result":"<pending|success|failure|skipped>","detail":"<outcome>","batchId":"${batchState.batchId || "BATCH_ID"}"}
1927
+ \`\`\`
1928
+
1929
+ **Rules:**
1930
+ 1. For **destructive** actions: write a "pending" entry BEFORE executing, then
1931
+ write a result entry AFTER with "success" or "failure" and detail.
1932
+ 2. For **diagnostic** and **tier0_known** actions: write a single result entry
1933
+ AFTER execution.
1934
+ 3. Include optional fields when relevant: \`waveIndex\`, \`laneNumber\`, \`taskId\`, \`durationMs\`.
1935
+ 4. Use the \`bash\` tool to append entries. Example:
1936
+ \`echo '{"ts":"...","action":"merge_retry","classification":"tier0_known","context":"merge timeout on wave 2","command":"git merge --no-ff task/lane-2","result":"success","detail":"merged with 0 conflicts","batchId":"..."}' >> ${actionsPath}\`
1937
+
1938
+ **Why this matters:** When you're taken over by another session or the operator
1939
+ asks "what did you do?", the audit trail is the definitive record.
1940
+
1941
+ ## Operational Knowledge
1942
+
1943
+ **IMPORTANT:** Read \`${primerPath}\` for your complete operational runbook.
1944
+ It contains:
1945
+ - Architecture details and wave lifecycle
1946
+ - Common failure patterns and recovery procedures
1947
+ - Batch state editing guide (safe vs. dangerous edits)
1948
+ - Git operations reference
1949
+ - Communication guidelines
1950
+
1951
+ Read it now before doing anything else. It is your primary reference.
1952
+
1953
+ ${guardrailsSection}
1954
+
1955
+ ## Startup Checklist
1956
+
1957
+ Now that you've activated:
1958
+ 1. Read the supervisor primer at \`${primerPath}\`
1959
+ 2. Read \`${batchStatePath}\` for full batch metadata
1960
+ 3. Read \`${eventsPath}\` for any events already emitted
1961
+ 4. Report to the operator: batch status, wave progress, what you're monitoring
1962
+ `;
1963
+
1964
+ return prompt;
1965
+ }
1966
+
1967
+
1968
+ // ── Routing System Prompt (TP-042) ───────────────────────────────────
1969
+
1970
+ /**
1971
+ * Build the supervisor system prompt for routing mode (no active batch).
1972
+ *
1973
+ * Used when `/orch` is called with no arguments and the supervisor is activated
1974
+ * to guide the operator through onboarding, batch planning, or other
1975
+ * conversational flows. The prompt includes:
1976
+ *
1977
+ * 1. **Identity**: "You are the project supervisor"
1978
+ * 2. **Routing state**: What was detected (no-config, pending-tasks, etc.)
1979
+ * 3. **Script guidance**: Which onboarding/returning-user script to follow
1980
+ * 4. **Primer reference**: Read supervisor-primer.md for detailed scripts
1981
+ * 5. **Capabilities**: Full tool access for project analysis and config generation
1982
+ *
1983
+ * The prompt directs the supervisor to the correct script in the primer based
1984
+ * on the routing state, implementing the Script 1/2/3 trigger discrimination
1985
+ * from spec §14.4.
1986
+ *
1987
+ * @param routingContext - The routing context from /orch no-args detection
1988
+ * @param stateRoot - Root path for .pi/ state directory (may be empty for no-config)
1989
+ * @returns The complete system prompt string
1990
+ *
1991
+ * @since TP-042
1992
+ */
1993
+ export function buildRoutingSystemPrompt(
1994
+ routingContext: SupervisorRoutingContext,
1995
+ stateRoot: string,
1996
+ ): string {
1997
+ const primerPath = resolvePrimerPath();
1998
+
1999
+ // Map routing state to the appropriate script section in the primer
2000
+ let scriptGuidance: string;
2001
+ switch (routingContext.routingState) {
2002
+ case "no-config":
2003
+ scriptGuidance = `## Your Mission: Onboarding
2004
+
2005
+ This project has no Taskplane configuration. You need to determine which
2006
+ onboarding script to follow from the primer's "Onboarding Scripts" section:
2007
+
2008
+ 1. **Read the primer** at \`${primerPath}\` — specifically the "Onboarding Scripts" section
2009
+ 2. **Analyze the project** to determine its maturity:
2010
+ - No \`.pi/\` directory AND minimal code → **Script 1: First Time Ever** or **Script 2: New/Empty Project**
2011
+ - No \`.pi/\` directory AND substantial code → **Script 3: Established Project**
2012
+ - The scripts describe specific triggers and exploration steps
2013
+ 3. **Follow the matched script** — it guides the conversation, exploration,
2014
+ and artifact generation
2015
+ 4. **Delegate to Script 4** (Task Area Design) and **Script 5** (Git Branching)
2016
+ as sub-flows during onboarding — the main scripts tell you when
2017
+
2018
+ ### Key Onboarding Artifacts to Create
2019
+
2020
+ When the conversation reaches the config generation phase, create ALL of these
2021
+ (idempotent — create only if they don't already exist):
2022
+
2023
+ - \`.pi/taskplane-config.json\` — project configuration (task areas, lanes, review level, etc.)
2024
+ - \`{task_area}/CONTEXT.md\` — one per task area, describing scope and conventions
2025
+ - \`.pi/agents/task-worker.md\` — worker prompt overrides (can start empty with a brief comment)
2026
+ - \`.pi/agents/task-reviewer.md\` — reviewer prompt overrides (can start empty with a brief comment)
2027
+ - \`.pi/agents/task-merger.md\` — merger prompt overrides (can start empty with a brief comment)
2028
+ - \`.gitignore\` entries — add Taskplane working file patterns if not already present
2029
+
2030
+ Use conservative creation: check if each file exists before writing. If files
2031
+ already exist (partial setup), read and merge rather than overwrite.`;
2032
+ break;
2033
+
2034
+ case "pending-tasks":
2035
+ scriptGuidance = `## Your Mission: Batch Planning
2036
+
2037
+ This project has Taskplane configured and has pending tasks ready to execute.
2038
+ Follow the primer's **"Script 6: Batch Planning"** section (pending-tasks path).
2039
+
2040
+ 1. **Read the primer** at \`${primerPath}\` — specifically Script 6's exploration
2041
+ phase and "pending tasks exist" conversation flow
2042
+ 2. **Review pending tasks** — scan task areas for folders without \`.DONE\` files,
2043
+ read each PROMPT.md header for size/deps/title, list them for the operator
2044
+ 3. **Explain dependencies and wave structure** if tasks have dependency chains
2045
+ 4. **Offer to plan and start a batch** — suggest \`/orch-plan all\` to preview
2046
+ wave breakdown, or \`/orch all\` to start directly
2047
+ 5. **Surface supplementary items** — check CONTEXT.md tech debt sections and
2048
+ GitHub Issues (\`gh issue list\` if available) for additional work to include
2049
+ 6. **Offer a health check** (Script 7) if the operator wants to verify project
2050
+ state before starting`;
2051
+ break;
2052
+
2053
+ case "no-tasks":
2054
+ scriptGuidance = `## Your Mission: Task Creation Guidance
2055
+
2056
+ This project has Taskplane configured but no pending tasks.
2057
+ Follow the primer's **"Script 6: Batch Planning"** section
2058
+ (specifically the "no pending tasks" conversation flow).
2059
+
2060
+ 1. **Read the primer** at \`${primerPath}\` — specifically Script 6's exploration
2061
+ phase and "no pending tasks" conversation flow
2062
+ 2. **Run the exploration phase** — scan CONTEXT.md tech debt sections, check
2063
+ GitHub Issues (\`gh issue list\` if available), grep for TODO/FIXME comments
2064
+ 3. **Present a source inventory** — group potential work items by source
2065
+ (GitHub Issues, tech debt, TODOs) with counts
2066
+ 4. **Help the operator create tasks** — offer to generate task packets from
2067
+ GitHub Issues, tech debt items, or a new spec described in conversation
2068
+ 5. **Offer a health check** (Script 7) if the operator prefers to assess
2069
+ project state rather than create tasks
2070
+ 6. **Graceful fallback**: If \`gh\` CLI is unavailable, skip GitHub checks and
2071
+ mention it to the operator — continue with CONTEXT.md and TODO scanning`;
2072
+ break;
2073
+
2074
+ case "completed-batch":
2075
+ scriptGuidance = `## Your Mission: Integration & Retrospective
2076
+
2077
+ A completed batch exists that hasn't been integrated yet.
2078
+
2079
+ 1. **Read the primer** at \`${primerPath}\` — specifically Script 8 (Post-Batch Retrospective)
2080
+ and Script 7 (Health Check) sections
2081
+ 2. **Explain the orch branch model** — work is on the orch branch, not yet on the working branch
2082
+ 3. **Guide the operator** toward \`/orch-integrate\` to bring the batch's work into their branch
2083
+ 4. **Offer to run a health check** (Script 7) if they want to verify state first
2084
+ 5. **Run a retrospective** (Script 8) — read batch-state.json and the audit
2085
+ trail (\`.pi/supervisor/actions.jsonl\`) to summarize batch outcomes, highlight
2086
+ incidents, and recommend improvements. Present this either before or after
2087
+ integration based on what the operator prefers.
2088
+ 6. **Surface next steps** — check for pending tasks and offer to plan the next batch`;
2089
+ break;
2090
+
2091
+ default:
2092
+ scriptGuidance = `## Your Mission: Project Assistance
2093
+
2094
+ Detected state: ${routingContext.routingState}
2095
+
2096
+ 1. **Read the primer** at \`${primerPath}\`
2097
+ 2. **Assess the situation** and help the operator with their next step
2098
+ 3. **Offer relevant guidance** based on what you discover`;
2099
+ break;
2100
+ }
2101
+
2102
+ const prompt = `# Project Supervisor
2103
+
2104
+ You are the **project supervisor** — a conversational agent that helps operators
2105
+ set up, plan, and manage their Taskplane project. You were activated because the
2106
+ operator typed \`/orch\` without arguments, and I detected the project state.
2107
+
2108
+ ## Identity
2109
+
2110
+ You share this terminal session with the human operator. You are a senior
2111
+ engineer helping them get the most out of Taskplane. Be conversational, helpful,
2112
+ and adaptive — follow the scripts as guides, not rigid templates. If the
2113
+ operator wants to skip ahead or go minimal, respect that.
2114
+
2115
+ ## Detected State
2116
+
2117
+ **Routing state:** ${routingContext.routingState}
2118
+ **Context:** ${routingContext.contextMessage}
2119
+
2120
+ ${scriptGuidance}
2121
+
2122
+ ## Capabilities
2123
+
2124
+ You have full tool access: \`read\`, \`write\`, \`edit\`, \`bash\`, \`grep\`, \`find\`, \`ls\`.
2125
+ Use these to:
2126
+ - Analyze project structure (read files, list directories, grep for patterns)
2127
+ - Read existing configuration and docs
2128
+ - Generate configuration files and CONTEXT.md documents
2129
+ - Run git commands for branch analysis
2130
+ - Run \`gh\` CLI commands for GitHub integration (issues, branch protection)
2131
+ - Create task folders and PROMPT.md files
2132
+
2133
+ ## Operational Knowledge
2134
+
2135
+ **IMPORTANT:** Read \`${primerPath}\` for your complete operational runbook.
2136
+ It contains:
2137
+ - Onboarding scripts (Scripts 1-5) with detailed conversation guides
2138
+ - Returning user scripts (Scripts 6-8) for batch planning, health checks, and retrospectives
2139
+ - Project detection heuristics and exploration checklists
2140
+ - Config generation templates and conventions
2141
+
2142
+ Read the relevant script section now before starting the conversation.
2143
+
2144
+ ## Communication Style
2145
+
2146
+ - Be conversational, not robotic — you're having a dialog, not running a wizard
2147
+ - Show what you discover as you explore ("I can see you have a TypeScript project with...")
2148
+ - Ask questions when choices matter, propose defaults when they don't
2149
+ - Summarize what you'll create before writing files — let the operator confirm
2150
+ - If the operator says "just give me defaults", do it and move on
2151
+
2152
+ ## What You Must NEVER Do
2153
+
2154
+ 1. Never start a batch execution (that's \`/orch all\` or \`/orch <areas>\`)
2155
+ 2. Never modify existing code files (only create config/scaffolding)
2156
+ 3. Never \`git push\` to any remote
2157
+ 4. Never overwrite existing config files without asking
2158
+ 5. Never make assumptions about project conventions — detect them
2159
+ `;
2160
+
2161
+ return prompt;
2162
+ }
2163
+
2164
+
2165
+ // ── Activation ───────────────────────────────────────────────────────
2166
+
2167
+ /**
2168
+ * Supervisor activation state.
2169
+ *
2170
+ * Tracks whether the supervisor is active for the current batch,
2171
+ * preventing duplicate activations and enabling guard logic for
2172
+ * the before_agent_start hook.
2173
+ *
2174
+ * The prompt is rebuilt dynamically each turn from the live batchState
2175
+ * reference, ensuring it always has current metadata (batchId, wave/task
2176
+ * counts are populated asynchronously by the engine after planning).
2177
+ *
2178
+ * @since TP-041
2179
+ */
2180
+ export interface SupervisorState {
2181
+ /** Whether the supervisor is currently active */
2182
+ active: boolean;
2183
+ /** Batch ID the supervisor is monitoring (empty if inactive or pre-planning) */
2184
+ batchId: string;
2185
+ /** Supervisor configuration */
2186
+ config: SupervisorConfig;
2187
+
2188
+ // ── Live references for dynamic prompt rebuild ──────────────────
2189
+ /** Live reference to the batch state (for dynamic prompt rebuild) */
2190
+ batchStateRef: OrchBatchRuntimeState | null;
2191
+ /** Orchestrator config reference (for dynamic prompt rebuild) */
2192
+ orchConfigRef: OrchestratorConfig | null;
2193
+ /** State root path (for dynamic prompt rebuild) */
2194
+ stateRoot: string;
2195
+
2196
+ // ── Model override tracking ────────────────────────────────────
2197
+ /** Model that was active before supervisor activation (for restoration) */
2198
+ previousModel: Model<Api> | null;
2199
+ /** Whether we switched models on activation (determines if we restore) */
2200
+ didSwitchModel: boolean;
2201
+
2202
+ // ── Lockfile + Heartbeat (Step 2) ──────────────────────────────
2203
+ /** Session ID written to the lockfile (for yield detection) */
2204
+ lockSessionId: string;
2205
+ /** Heartbeat timer handle (null when not active) */
2206
+ heartbeatTimer: ReturnType<typeof setInterval> | null;
2207
+
2208
+ // ── Event Tailer (Step 3) ──────────────────────────────────────
2209
+ /** Event tailer state for consuming engine events */
2210
+ eventTailer: EventTailerState;
2211
+
2212
+ // ── Routing Context (TP-042) ───────────────────────────────────
2213
+ /** When non-null, supervisor is in routing mode (onboarding / returning-user flows) */
2214
+ routingContext: SupervisorRoutingContext | null;
2215
+
2216
+ // ── Deferred Summary (TP-043 R004) ─────────────────────────────
2217
+ /**
2218
+ * When non-null, a batch summary is pending presentation. Used in supervised
2219
+ * mode where summary must wait until /orch-integrate completes (or operator
2220
+ * declines and supervisor deactivates).
2221
+ */
2222
+ pendingSummaryDeps: SummaryDeps | null;
2223
+ }
2224
+
2225
+ /**
2226
+ * Create fresh (inactive) supervisor state.
2227
+ */
2228
+ export function freshSupervisorState(): SupervisorState {
2229
+ return {
2230
+ active: false,
2231
+ batchId: "",
2232
+ config: { ...DEFAULT_SUPERVISOR_CONFIG },
2233
+ batchStateRef: null,
2234
+ orchConfigRef: null,
2235
+ stateRoot: "",
2236
+ previousModel: null,
2237
+ didSwitchModel: false,
2238
+ lockSessionId: "",
2239
+ heartbeatTimer: null,
2240
+ eventTailer: freshEventTailerState(),
2241
+ routingContext: null,
2242
+ pendingSummaryDeps: null,
2243
+ };
2244
+ }
2245
+
2246
+ /**
2247
+ * Resolve a model string (e.g., "anthropic/claude-sonnet-4" or "claude-sonnet-4")
2248
+ * to a Model object from the model registry.
2249
+ *
2250
+ * Format: "provider/modelId" or just "modelId" (searches all providers).
2251
+ *
2252
+ * @returns The resolved Model, or undefined if not found
2253
+ * @since TP-041
2254
+ */
2255
+ function resolveModelFromString(
2256
+ modelStr: string,
2257
+ ctx: ExtensionContext,
2258
+ ): Model<Api> | undefined {
2259
+ if (!modelStr) return undefined;
2260
+
2261
+ // Try "provider/id" format first
2262
+ const slashIdx = modelStr.indexOf("/");
2263
+ if (slashIdx > 0) {
2264
+ const provider = modelStr.substring(0, slashIdx);
2265
+ const id = modelStr.substring(slashIdx + 1);
2266
+ return ctx.modelRegistry.find(provider, id);
2267
+ }
2268
+
2269
+ // No provider prefix — search all models for matching id
2270
+ const allModels = ctx.modelRegistry.getAll();
2271
+ return allModels.find((m) => m.id === modelStr);
2272
+ }
2273
+
2274
+ /**
2275
+ * Optional routing context for /orch no-args activation.
2276
+ *
2277
+ * When provided, the supervisor is activated in "routing mode" — it handles
2278
+ * onboarding, batch planning, or other conversational flows instead of
2279
+ * batch monitoring. Lockfile/heartbeat/event-tailer are skipped because
2280
+ * there's no active batch to monitor.
2281
+ *
2282
+ * @since TP-042
2283
+ */
2284
+ export interface SupervisorRoutingContext {
2285
+ /** The detected project state (e.g., "no-config", "pending-tasks") */
2286
+ routingState: string;
2287
+ /** Human-readable context message for the supervisor's first turn */
2288
+ contextMessage: string;
2289
+ }
2290
+
2291
+ /**
2292
+ * Activate the supervisor agent in the current pi session.
2293
+ *
2294
+ * This is called after `startBatchAsync()` in the `/orch` command handler,
2295
+ * or directly by the `/orch` no-args routing logic (TP-042).
2296
+ *
2297
+ * It:
2298
+ * 1. Stores live references to batchState/config for dynamic prompt rebuild
2299
+ * 2. Optionally switches model via pi.setModel() if supervisor.model is configured
2300
+ * 3. Sends an activation message via pi.sendMessage() with triggerTurn=true
2301
+ * to kick off the supervisor's first turn
2302
+ *
2303
+ * When `routingContext` is provided (TP-042 no-args routing), lockfile/heartbeat
2304
+ * and event tailer are skipped — there's no active batch to monitor. The
2305
+ * activation message uses the routing context instead of batch metadata.
2306
+ *
2307
+ * The system prompt is NOT cached at activation time — it is rebuilt dynamically
2308
+ * on every LLM turn by the before_agent_start hook. This ensures the prompt
2309
+ * always has current batch metadata, even though batchId/wave/task counts are
2310
+ * populated asynchronously by the engine after planning.
2311
+ *
2312
+ * @param pi - The ExtensionAPI instance
2313
+ * @param state - Mutable supervisor state to populate
2314
+ * @param batchState - Current batch runtime state (live reference)
2315
+ * @param orchConfig - Orchestrator configuration
2316
+ * @param supervisorConfig - Supervisor-specific configuration
2317
+ * @param stateRoot - Root path for .pi/ state directory
2318
+ * @param ctx - Extension context (for model resolution)
2319
+ * @param routingContext - Optional routing context for /orch no-args (TP-042)
2320
+ *
2321
+ * @since TP-041
2322
+ */
2323
+ export async function activateSupervisor(
2324
+ pi: ExtensionAPI,
2325
+ state: SupervisorState,
2326
+ batchState: OrchBatchRuntimeState,
2327
+ orchConfig: OrchestratorConfig,
2328
+ supervisorConfig: SupervisorConfig,
2329
+ stateRoot: string,
2330
+ ctx: ExtensionContext,
2331
+ routingContext?: SupervisorRoutingContext,
2332
+ ): Promise<void> {
2333
+ // Store live references for dynamic prompt rebuild
2334
+ state.active = true;
2335
+ state.batchId = batchState.batchId; // May be empty pre-planning — that's OK
2336
+ state.config = { ...supervisorConfig };
2337
+ state.batchStateRef = batchState;
2338
+ state.orchConfigRef = orchConfig;
2339
+ state.stateRoot = stateRoot;
2340
+
2341
+ // ── TP-042 R004: Clear routing context on non-routing activation ──
2342
+ // If a previous activation set routingContext (onboarding/returning-user),
2343
+ // clear it now so the before_agent_start hook switches to batch-monitoring
2344
+ // prompt instead of keeping the stale routing prompt.
2345
+ state.routingContext = routingContext ?? null;
2346
+
2347
+ // ── Model override ───────────────────────────────────────────────
2348
+ // If supervisor.model is configured, switch to it. Store the previous
2349
+ // model for restoration on deactivation.
2350
+ state.previousModel = ctx.model ?? null;
2351
+ state.didSwitchModel = false;
2352
+
2353
+ if (supervisorConfig.model) {
2354
+ const targetModel = resolveModelFromString(supervisorConfig.model, ctx);
2355
+ if (targetModel) {
2356
+ const success = await pi.setModel(targetModel);
2357
+ if (success) {
2358
+ state.didSwitchModel = true;
2359
+ }
2360
+ // If setModel fails (no API key), fall through to session model
2361
+ }
2362
+ // If model not found in registry, fall through to session model (inheritance)
2363
+ }
2364
+
2365
+ // ── TP-042: Routing mode — skip batch monitoring infrastructure ──
2366
+ // When activated via /orch no-args routing, there's no active batch.
2367
+ // Skip lockfile/heartbeat/event-tailer and send routing context message.
2368
+ // routingContext was already stored above (via routingContext ?? null).
2369
+ if (routingContext) {
2370
+ pi.sendMessage(
2371
+ {
2372
+ customType: "supervisor-routing",
2373
+ content: [
2374
+ {
2375
+ type: "text",
2376
+ text:
2377
+ `🔀 **Supervisor activated** (${routingContext.routingState}).\n\n` +
2378
+ routingContext.contextMessage,
2379
+ },
2380
+ ],
2381
+ display: `Supervisor activated — ${routingContext.routingState}`,
2382
+ },
2383
+ { triggerTurn: true, deliverAs: "nextTurn" },
2384
+ );
2385
+ return;
2386
+ }
2387
+
2388
+ // ── Lockfile + Heartbeat (Step 2) ────────────────────────────────
2389
+ // Write lockfile to claim supervisor role. Generate a unique session ID
2390
+ // for yield detection (if another session force-takes over, our heartbeat
2391
+ // will detect the sessionId mismatch and yield).
2392
+ const sessionId = `pi-${process.pid}-${Date.now()}`;
2393
+ state.lockSessionId = sessionId;
2394
+
2395
+ const lock: SupervisorLockfile = {
2396
+ pid: process.pid,
2397
+ sessionId,
2398
+ batchId: batchState.batchId || "(initializing)",
2399
+ startedAt: new Date().toISOString(),
2400
+ heartbeat: new Date().toISOString(),
2401
+ };
2402
+ writeLockfile(stateRoot, lock);
2403
+
2404
+ // Start heartbeat timer — updates lockfile every 30s, detects takeover
2405
+ state.heartbeatTimer = startHeartbeat(stateRoot, state, pi);
2406
+
2407
+ // ── Event tailer (Step 3) ────────────────────────────────────
2408
+ // Start tailing events.jsonl for proactive notifications.
2409
+ // Initializes byte offset to current file size so we skip stale events.
2410
+ // Idempotent — safe even if called from takeover paths that may have
2411
+ // started a tailer previously (stopEventTailer is called in deactivate).
2412
+ startEventTailer(pi, state.eventTailer, state);
2413
+
2414
+ // Send activation message to trigger the supervisor's first turn.
2415
+ // The content is generic — specific counts may not be available yet
2416
+ // since the engine sets batchId/totalWaves/totalTasks asynchronously.
2417
+ // The supervisor's first action (per standing orders) is to read the
2418
+ // batch state file for full metadata.
2419
+ pi.sendMessage(
2420
+ {
2421
+ customType: "supervisor-activation",
2422
+ content: [
2423
+ {
2424
+ type: "text",
2425
+ text:
2426
+ `🔀 **Batch started.** ` +
2427
+ `Supervisor activated (autonomy: ${supervisorConfig.autonomy}).\n\n` +
2428
+ `Read your operational primer and batch state, then report initial status to the operator.`,
2429
+ },
2430
+ ],
2431
+ display: "Supervisor activated" + (batchState.batchId ? ` for batch ${batchState.batchId}` : ""),
2432
+ },
2433
+ { triggerTurn: true, deliverAs: "nextTurn" },
2434
+ );
2435
+ }
2436
+
2437
+ /**
2438
+ * Deactivate the supervisor agent.
2439
+ *
2440
+ * Called when a batch completes, fails terminally, is stopped, or is aborted.
2441
+ * Clears the supervisor state so the before_agent_start hook stops
2442
+ * injecting the supervisor system prompt. Restores the previous model
2443
+ * if one was switched on activation.
2444
+ *
2445
+ * Safe to call multiple times (idempotent) — subsequent calls are no-ops.
2446
+ *
2447
+ * @param pi - The ExtensionAPI instance (for model restoration)
2448
+ * @param state - Supervisor state to clear
2449
+ *
2450
+ * @since TP-041
2451
+ */
2452
+ export async function deactivateSupervisor(
2453
+ pi: ExtensionAPI,
2454
+ state: SupervisorState,
2455
+ ): Promise<void> {
2456
+ if (!state.active) return; // Already inactive — idempotent guard
2457
+
2458
+ // ── Stop event tailer (Step 3) ───────────────────────────────
2459
+ stopEventTailer(state.eventTailer);
2460
+
2461
+ // ── Stop heartbeat timer (Step 2) ────────────────────────────
2462
+ if (state.heartbeatTimer) {
2463
+ clearInterval(state.heartbeatTimer);
2464
+ state.heartbeatTimer = null;
2465
+ }
2466
+
2467
+ // ── Remove lockfile (Step 2) ─────────────────────────────────
2468
+ // Only remove if we still own it (our sessionId matches).
2469
+ // If another session force-took-over, the lockfile belongs to them.
2470
+ if (state.stateRoot && state.lockSessionId) {
2471
+ const currentLock = readLockfile(state.stateRoot);
2472
+ if (!currentLock || currentLock.sessionId === state.lockSessionId) {
2473
+ removeLockfile(state.stateRoot);
2474
+ }
2475
+ }
2476
+
2477
+ // ── TP-043 R004: Present deferred batch summary ─────────────
2478
+ // If a batch summary was deferred (supervised mode awaiting integration
2479
+ // confirmation), present it now — before we clear state refs.
2480
+ if (state.pendingSummaryDeps && state.batchStateRef && state.stateRoot) {
2481
+ const deps = state.pendingSummaryDeps;
2482
+ presentBatchSummary(pi, state.batchStateRef, state.stateRoot, deps.opId, deps.diagnostics, deps.mergeResults);
2483
+ state.pendingSummaryDeps = null;
2484
+ }
2485
+
2486
+ // Restore previous model if we switched on activation
2487
+ if (state.didSwitchModel && state.previousModel) {
2488
+ try {
2489
+ await pi.setModel(state.previousModel);
2490
+ } catch {
2491
+ // Non-fatal — model may no longer be available
2492
+ }
2493
+ }
2494
+
2495
+ state.active = false;
2496
+ state.batchId = "";
2497
+ state.batchStateRef = null;
2498
+ state.orchConfigRef = null;
2499
+ state.stateRoot = "";
2500
+ state.previousModel = null;
2501
+ state.didSwitchModel = false;
2502
+ state.lockSessionId = "";
2503
+ state.routingContext = null;
2504
+ state.pendingSummaryDeps = null;
2505
+ }
2506
+
2507
+ /**
2508
+ * Register the before_agent_start hook for persistent system prompt injection.
2509
+ *
2510
+ * While the supervisor is active, every LLM turn gets the supervisor system
2511
+ * prompt injected. The prompt is rebuilt dynamically from the live batchState
2512
+ * reference, ensuring it always reflects the latest batch metadata (batchId,
2513
+ * wave/task counts populated asynchronously by the engine after planning).
2514
+ *
2515
+ * When the supervisor is inactive (no batch running), this hook is a no-op
2516
+ * and the original system prompt is used unmodified.
2517
+ *
2518
+ * @param pi - The ExtensionAPI instance
2519
+ * @param state - Supervisor state (checked on each turn)
2520
+ *
2521
+ * @since TP-041
2522
+ */
2523
+ export function registerSupervisorPromptHook(
2524
+ pi: ExtensionAPI,
2525
+ state: SupervisorState,
2526
+ ): void {
2527
+ pi.on("before_agent_start", (_event) => {
2528
+ if (!state.active) {
2529
+ return undefined; // No-op: don't modify system prompt
2530
+ }
2531
+
2532
+ // ── TP-042: Routing mode — use onboarding/returning-user prompt ──
2533
+ // When routingContext is set, we're in a conversational flow (onboarding,
2534
+ // batch planning, etc.), not batch monitoring. Use the routing prompt
2535
+ // which includes script guidance from the primer.
2536
+ if (state.routingContext) {
2537
+ const systemPrompt = buildRoutingSystemPrompt(
2538
+ state.routingContext,
2539
+ state.stateRoot,
2540
+ );
2541
+ return { systemPrompt };
2542
+ }
2543
+
2544
+ // ── Batch monitoring mode — use standard supervisor prompt ──
2545
+ if (!state.batchStateRef || !state.orchConfigRef) {
2546
+ return undefined; // No-op: missing batch state for prompt rebuild
2547
+ }
2548
+
2549
+ // Rebuild prompt dynamically from live batchState reference.
2550
+ // This ensures the prompt always has current metadata, even though
2551
+ // batchId/totalWaves/totalTasks are populated asynchronously.
2552
+ const systemPrompt = buildSupervisorSystemPrompt(
2553
+ state.batchStateRef,
2554
+ state.orchConfigRef,
2555
+ state.config,
2556
+ state.stateRoot,
2557
+ );
2558
+
2559
+ return {
2560
+ systemPrompt,
2561
+ };
2562
+ });
2563
+ }
2564
+
2565
+ /**
2566
+ * Resolve supervisor configuration from available sources.
2567
+ *
2568
+ * Resolution order (highest precedence first):
2569
+ * 1. User preferences (supervisorModel → orchestrator.supervisor.model)
2570
+ * 2. Project config (orchestrator.supervisor section in taskplane-config.json)
2571
+ * 3. Defaults (model="" = inherit session model, autonomy="supervised")
2572
+ *
2573
+ * This function is a convenience wrapper for cases where the full config
2574
+ * loading pipeline has already run. For direct config loading, use
2575
+ * `loadSupervisorConfig()` from config.ts instead.
2576
+ *
2577
+ * @param supervisorSection - Pre-loaded supervisor config section (or undefined for defaults)
2578
+ * @returns Resolved supervisor configuration
2579
+ *
2580
+ * @since TP-041
2581
+ */
2582
+ export function resolveSupervisorConfig(
2583
+ supervisorSection?: Partial<SupervisorConfig>,
2584
+ ): SupervisorConfig {
2585
+ if (!supervisorSection) return { ...DEFAULT_SUPERVISOR_CONFIG };
2586
+ return {
2587
+ model: supervisorSection.model ?? DEFAULT_SUPERVISOR_CONFIG.model,
2588
+ autonomy: supervisorSection.autonomy ?? DEFAULT_SUPERVISOR_CONFIG.autonomy,
2589
+ };
2590
+ }
2591
+
2592
+
2593
+ // ── Lockfile Types + Helpers (TP-041 Step 2) ─────────────────────────
2594
+
2595
+ /** Heartbeat interval in milliseconds (30 seconds). */
2596
+ export const HEARTBEAT_INTERVAL_MS = 30_000;
2597
+
2598
+ /** Staleness threshold: if heartbeat is older than this, lock is stale (90s = 3 missed heartbeats). */
2599
+ export const STALE_LOCK_THRESHOLD_MS = 90_000;
2600
+
2601
+ /**
2602
+ * Supervisor lockfile shape — written to `.pi/supervisor/lock.json`.
2603
+ *
2604
+ * The lockfile enforces a 1:1 ratio between supervisors and batches.
2605
+ * Only one supervisor session may be active per project at a time.
2606
+ *
2607
+ * @since TP-041
2608
+ */
2609
+ export interface SupervisorLockfile {
2610
+ /** Process ID of the supervisor session */
2611
+ pid: number;
2612
+ /** Unique session identifier (from pi session) */
2613
+ sessionId: string;
2614
+ /** Batch ID being supervised */
2615
+ batchId: string;
2616
+ /** ISO 8601 timestamp when this supervisor started */
2617
+ startedAt: string;
2618
+ /** ISO 8601 timestamp of most recent heartbeat */
2619
+ heartbeat: string;
2620
+ }
2621
+
2622
+ /**
2623
+ * Result of checking the supervisor lockfile on startup.
2624
+ *
2625
+ * @since TP-041
2626
+ */
2627
+ export type LockfileCheckResult =
2628
+ | { status: "no-active-batch" }
2629
+ | { status: "no-lockfile"; batchState: PersistedBatchState }
2630
+ | { status: "stale"; lock: SupervisorLockfile; batchState: PersistedBatchState }
2631
+ | { status: "live"; lock: SupervisorLockfile; batchState: PersistedBatchState }
2632
+ | { status: "corrupt"; batchState: PersistedBatchState };
2633
+
2634
+ /**
2635
+ * Resolve the lockfile path for a given state root.
2636
+ */
2637
+ export function lockfilePath(stateRoot: string): string {
2638
+ return join(stateRoot, ".pi", "supervisor", "lock.json");
2639
+ }
2640
+
2641
+ /**
2642
+ * Read and parse the supervisor lockfile.
2643
+ *
2644
+ * Returns null if the file doesn't exist. If the file is corrupt/malformed,
2645
+ * returns null (treat as stale per R003 suggestion — caller should rewrite).
2646
+ *
2647
+ * @param stateRoot - Root path for .pi/ state directory
2648
+ * @returns Parsed lockfile or null
2649
+ *
2650
+ * @since TP-041
2651
+ */
2652
+ export function readLockfile(stateRoot: string): SupervisorLockfile | null {
2653
+ const path = lockfilePath(stateRoot);
2654
+ if (!existsSync(path)) return null;
2655
+
2656
+ try {
2657
+ const raw = readFileSync(path, "utf-8");
2658
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
2659
+
2660
+ // Validate required fields
2661
+ if (
2662
+ typeof parsed.pid !== "number" ||
2663
+ typeof parsed.sessionId !== "string" ||
2664
+ typeof parsed.batchId !== "string" ||
2665
+ typeof parsed.startedAt !== "string" ||
2666
+ typeof parsed.heartbeat !== "string"
2667
+ ) {
2668
+ return null; // Malformed — treat as stale/absent
2669
+ }
2670
+
2671
+ return parsed as unknown as SupervisorLockfile;
2672
+ } catch {
2673
+ return null; // Corrupt JSON — treat as stale/absent
2674
+ }
2675
+ }
2676
+
2677
+ /**
2678
+ * Write the supervisor lockfile atomically (temp file + rename).
2679
+ *
2680
+ * Creates the `.pi/supervisor/` directory if it doesn't exist.
2681
+ * Uses temp+rename to prevent partial writes from corrupting the file.
2682
+ *
2683
+ * @param stateRoot - Root path for .pi/ state directory
2684
+ * @param lock - Lockfile data to write
2685
+ *
2686
+ * @since TP-041
2687
+ */
2688
+ export function writeLockfile(stateRoot: string, lock: SupervisorLockfile): void {
2689
+ const dir = join(stateRoot, ".pi", "supervisor");
2690
+ if (!existsSync(dir)) {
2691
+ mkdirSync(dir, { recursive: true });
2692
+ }
2693
+
2694
+ const finalPath = lockfilePath(stateRoot);
2695
+ const tmpPath = finalPath + ".tmp";
2696
+ const json = JSON.stringify(lock, null, 2) + "\n";
2697
+
2698
+ writeFileSync(tmpPath, json, "utf-8");
2699
+ renameSync(tmpPath, finalPath);
2700
+ }
2701
+
2702
+ /**
2703
+ * Remove the supervisor lockfile.
2704
+ *
2705
+ * Safe to call when the file doesn't exist (no-op).
2706
+ *
2707
+ * @param stateRoot - Root path for .pi/ state directory
2708
+ *
2709
+ * @since TP-041
2710
+ */
2711
+ export function removeLockfile(stateRoot: string): void {
2712
+ const path = lockfilePath(stateRoot);
2713
+ try {
2714
+ if (existsSync(path)) {
2715
+ unlinkSync(path);
2716
+ }
2717
+ } catch {
2718
+ // Best-effort — if we can't remove it, it'll be detected as stale on next startup
2719
+ }
2720
+ }
2721
+
2722
+ /**
2723
+ * Check whether a process with the given PID is alive.
2724
+ *
2725
+ * Uses `process.kill(pid, 0)` which sends signal 0 (no-op) — throws
2726
+ * if the process doesn't exist, returns true if it does.
2727
+ *
2728
+ * @param pid - Process ID to check
2729
+ * @returns true if the process is alive
2730
+ *
2731
+ * @since TP-041
2732
+ */
2733
+ export function isProcessAlive(pid: number): boolean {
2734
+ try {
2735
+ process.kill(pid, 0);
2736
+ return true;
2737
+ } catch {
2738
+ return false;
2739
+ }
2740
+ }
2741
+
2742
+ /**
2743
+ * Check whether a lockfile's heartbeat is stale.
2744
+ *
2745
+ * A heartbeat is stale if it's older than STALE_LOCK_THRESHOLD_MS (90s).
2746
+ * This accounts for 3 missed 30-second heartbeat intervals.
2747
+ *
2748
+ * @param lock - Lockfile to check
2749
+ * @returns true if the heartbeat is stale
2750
+ *
2751
+ * @since TP-041
2752
+ */
2753
+ export function isLockStale(lock: SupervisorLockfile): boolean {
2754
+ const heartbeatTime = new Date(lock.heartbeat).getTime();
2755
+ if (isNaN(heartbeatTime)) return true; // Invalid date — treat as stale
2756
+ return Date.now() - heartbeatTime > STALE_LOCK_THRESHOLD_MS;
2757
+ }
2758
+
2759
+ // ── Terminal Phase Detection ─────────────────────────────────────────
2760
+
2761
+ /**
2762
+ * Phases that indicate a batch is terminal (no longer active).
2763
+ * If batch-state.json has one of these phases, there's no active batch
2764
+ * and no lockfile arbitration is needed.
2765
+ */
2766
+ const TERMINAL_PHASES = new Set<string>([
2767
+ "idle", "completed", "failed", "stopped",
2768
+ ]);
2769
+
2770
+ /**
2771
+ * Check whether a batch phase is terminal (no active batch).
2772
+ *
2773
+ * @since TP-041
2774
+ */
2775
+ export function isBatchTerminal(phase: string): boolean {
2776
+ return TERMINAL_PHASES.has(phase);
2777
+ }
2778
+
2779
+ // ── Startup Detection (Section 13.10) ────────────────────────────────
2780
+
2781
+ /**
2782
+ * Check startup state: is there an active batch and an existing lockfile?
2783
+ *
2784
+ * Implements the startup gate from spec Section 13.10:
2785
+ * 1. Check for active batch (.pi/batch-state.json with non-terminal phase)
2786
+ * 2. If no active batch, return early (no lockfile arbitration needed)
2787
+ * 3. If active batch, check lockfile state (absent, stale, live, corrupt)
2788
+ *
2789
+ * @param stateRoot - Root path for .pi/ state directory
2790
+ * @param loadBatchStateFn - Function to load batch state (injectable for testing)
2791
+ * @returns LockfileCheckResult describing the current state
2792
+ *
2793
+ * @since TP-041
2794
+ */
2795
+ export function checkSupervisorLockOnStartup(
2796
+ stateRoot: string,
2797
+ loadBatchStateFn: (root: string) => PersistedBatchState | null,
2798
+ ): LockfileCheckResult {
2799
+ // ── Step 1: Check for active batch ───────────────────────────
2800
+ let batchState: PersistedBatchState | null;
2801
+ try {
2802
+ batchState = loadBatchStateFn(stateRoot);
2803
+ } catch {
2804
+ // Batch state unreadable — no active batch to supervise
2805
+ return { status: "no-active-batch" };
2806
+ }
2807
+
2808
+ if (!batchState || isBatchTerminal(batchState.phase)) {
2809
+ return { status: "no-active-batch" };
2810
+ }
2811
+
2812
+ // ── Step 2: Active batch exists — check lockfile ─────────────
2813
+ const lock = readLockfile(stateRoot);
2814
+
2815
+ if (!lock) {
2816
+ // No lockfile (or corrupt) — check if the file exists but was corrupt
2817
+ const lockPath = lockfilePath(stateRoot);
2818
+ if (existsSync(lockPath)) {
2819
+ // File exists but couldn't be parsed — corrupt
2820
+ return { status: "corrupt", batchState };
2821
+ }
2822
+ // No lockfile at all — become the supervisor
2823
+ return { status: "no-lockfile", batchState };
2824
+ }
2825
+
2826
+ // ── Step 3: Lockfile exists — live or stale? ─────────────────
2827
+ if (!isProcessAlive(lock.pid) || isLockStale(lock)) {
2828
+ return { status: "stale", lock, batchState };
2829
+ }
2830
+
2831
+ return { status: "live", lock, batchState };
2832
+ }
2833
+
2834
+ // ── Rehydration Summary ──────────────────────────────────────────────
2835
+
2836
+ /**
2837
+ * Build a rehydration summary for the operator after a takeover.
2838
+ *
2839
+ * Reads:
2840
+ * 1. Batch state for current wave, task statuses, phase
2841
+ * 2. `.pi/supervisor/actions.jsonl` for what the previous supervisor did
2842
+ * 3. `.pi/supervisor/events.jsonl` for recent engine events
2843
+ *
2844
+ * Returns a human-readable summary string.
2845
+ *
2846
+ * @param stateRoot - Root path for .pi/ state directory
2847
+ * @param batchState - Current batch state
2848
+ * @returns Summary string for the operator
2849
+ *
2850
+ * @since TP-041
2851
+ */
2852
+ export function buildTakeoverSummary(
2853
+ stateRoot: string,
2854
+ batchState: PersistedBatchState,
2855
+ ): string {
2856
+ const lines: string[] = [];
2857
+
2858
+ lines.push(`📋 **Taking over batch ${batchState.batchId}**`);
2859
+ lines.push("");
2860
+ lines.push(`**Phase:** ${batchState.phase}`);
2861
+ lines.push(`**Wave:** ${batchState.currentWaveIndex + 1}/${batchState.wavePlan?.length ?? batchState.totalWaves ?? "?"}`);
2862
+ lines.push(`**Base branch:** ${batchState.baseBranch}`);
2863
+
2864
+ // Task summary from persisted state
2865
+ const tasks = batchState.tasks ?? [];
2866
+ const succeeded = tasks.filter((t) => t.status === "succeeded").length;
2867
+ const failed = tasks.filter((t) => t.status === "failed").length;
2868
+ const running = tasks.filter((t) => t.status === "running").length;
2869
+ const pending = tasks.filter((t) => t.status === "pending").length;
2870
+ lines.push(`**Tasks:** ${succeeded} succeeded, ${failed} failed, ${running} running, ${pending} pending`);
2871
+
2872
+ // Recent actions from audit trail (using readAuditTrail helper)
2873
+ const recentActions = readAuditTrail(stateRoot, { limit: 5 });
2874
+ if (recentActions.length > 0) {
2875
+ lines.push("");
2876
+ lines.push(`**Previous supervisor actions** (last ${recentActions.length}):`);
2877
+ for (const action of recentActions) {
2878
+ lines.push(` - ${action.action ?? "unknown"}: ${action.context ?? ""}`);
2879
+ }
2880
+ }
2881
+
2882
+ // Recent engine events
2883
+ const eventsPath = join(stateRoot, ".pi", "supervisor", "events.jsonl");
2884
+ if (existsSync(eventsPath)) {
2885
+ try {
2886
+ const eventsRaw = readFileSync(eventsPath, "utf-8").trim();
2887
+ if (eventsRaw) {
2888
+ const eventLines = eventsRaw.split("\n");
2889
+ const recentEvents = eventLines.slice(-5); // Last 5 events
2890
+ lines.push("");
2891
+ lines.push(`**Recent engine events** (last ${recentEvents.length}):`);
2892
+ for (const line of recentEvents) {
2893
+ try {
2894
+ const event = JSON.parse(line) as Record<string, unknown>;
2895
+ lines.push(` - [${event.type ?? "?"}] ${event.message ?? event.taskId ?? ""}`);
2896
+ } catch {
2897
+ lines.push(` - (unparseable event)`);
2898
+ }
2899
+ }
2900
+ }
2901
+ } catch {
2902
+ // Best-effort — events file may not exist
2903
+ }
2904
+ }
2905
+
2906
+ return lines.join("\n");
2907
+ }
2908
+
2909
+ // ── Heartbeat Timer ──────────────────────────────────────────────────
2910
+
2911
+ /**
2912
+ * Start the heartbeat timer for the supervisor lockfile.
2913
+ *
2914
+ * Updates the lockfile's `heartbeat` field every HEARTBEAT_INTERVAL_MS.
2915
+ * Also checks if the lockfile has been taken over by another session
2916
+ * (force takeover detection) — if the sessionId no longer matches,
2917
+ * the previous session yields gracefully.
2918
+ *
2919
+ * @param stateRoot - Root path for .pi/ state directory
2920
+ * @param state - Supervisor state (used for yield detection)
2921
+ * @param pi - ExtensionAPI for deactivation on yield
2922
+ * @returns Timer handle (for cleanup via clearInterval)
2923
+ *
2924
+ * @since TP-041
2925
+ */
2926
+ export function startHeartbeat(
2927
+ stateRoot: string,
2928
+ state: SupervisorState,
2929
+ pi: ExtensionAPI,
2930
+ ): ReturnType<typeof setInterval> {
2931
+ const sessionId = state.lockSessionId;
2932
+
2933
+ const timer = setInterval(() => {
2934
+ if (!state.active) {
2935
+ clearInterval(timer);
2936
+ return;
2937
+ }
2938
+
2939
+ // Read current lockfile to detect force takeover
2940
+ const currentLock = readLockfile(stateRoot);
2941
+ if (currentLock && currentLock.sessionId !== sessionId) {
2942
+ // Another session has taken over — yield gracefully
2943
+ clearInterval(timer);
2944
+ pi.sendMessage(
2945
+ {
2946
+ customType: "supervisor-yield",
2947
+ content: [{
2948
+ type: "text",
2949
+ text: "⚡ Another session has taken over supervisor duties. Yielding.",
2950
+ }],
2951
+ display: "Supervisor yielded to another session",
2952
+ },
2953
+ { triggerTurn: false },
2954
+ );
2955
+ deactivateSupervisor(pi, state);
2956
+ return;
2957
+ }
2958
+
2959
+ // Update heartbeat
2960
+ try {
2961
+ const lock = readLockfile(stateRoot);
2962
+ if (lock && lock.sessionId === sessionId) {
2963
+ lock.heartbeat = new Date().toISOString();
2964
+ writeLockfile(stateRoot, lock);
2965
+ }
2966
+ } catch {
2967
+ // Best-effort heartbeat — don't crash the supervisor
2968
+ }
2969
+ }, HEARTBEAT_INTERVAL_MS);
2970
+
2971
+ // Unref the timer so it doesn't prevent Node.js from exiting
2972
+ if (timer && typeof timer === "object" && "unref" in timer) {
2973
+ timer.unref();
2974
+ }
2975
+
2976
+ return timer;
2977
+ }
2978
+
2979
+
2980
+ // ── Engine Event Consumption + Notifications (TP-041 Step 3) ─────────
2981
+
2982
+ /**
2983
+ * Polling interval for the event tailer (10 seconds).
2984
+ *
2985
+ * Balances responsiveness (operator sees events quickly) with resource
2986
+ * efficiency (avoid excessive file reads). Chosen to be shorter than
2987
+ * the heartbeat interval (30s) so the supervisor reports events before
2988
+ * the next heartbeat.
2989
+ *
2990
+ * @since TP-041
2991
+ */
2992
+ export const EVENT_POLL_INTERVAL_MS = 10_000;
2993
+
2994
+ /**
2995
+ * Coalescing window for task_complete digests (30 seconds).
2996
+ *
2997
+ * Instead of emitting one notification per task completion, the tailer
2998
+ * buffers completions and emits a periodic digest. This prevents turn
2999
+ * spam when many tasks complete in quick succession.
3000
+ *
3001
+ * @since TP-041
3002
+ */
3003
+ export const TASK_DIGEST_INTERVAL_MS = 30_000;
3004
+
3005
+ /**
3006
+ * All known event types that appear in the unified events.jsonl.
3007
+ * Used for type narrowing when parsing lines.
3008
+ *
3009
+ * @since TP-041
3010
+ */
3011
+ type UnifiedEventType = EngineEventType | Tier0EventType;
3012
+
3013
+ /**
3014
+ * A parsed event from the unified events.jsonl file.
3015
+ *
3016
+ * The file contains both EngineEvent and Tier0Event entries; we use
3017
+ * a discriminated union on the `type` field. For parsing safety, we
3018
+ * use a minimal common shape plus the union type.
3019
+ *
3020
+ * @since TP-041
3021
+ */
3022
+ interface ParsedEvent {
3023
+ timestamp: string;
3024
+ type: UnifiedEventType;
3025
+ batchId: string;
3026
+ waveIndex: number;
3027
+ // ── EngineEvent-specific optional fields ─────────────────────
3028
+ phase?: string;
3029
+ taskIds?: string[];
3030
+ laneCount?: number;
3031
+ taskId?: string;
3032
+ durationMs?: number;
3033
+ outcome?: string;
3034
+ reason?: string;
3035
+ partialProgress?: boolean;
3036
+ laneNumber?: number;
3037
+ error?: string;
3038
+ testCount?: number;
3039
+ totalWaves?: number;
3040
+ succeededTasks?: number;
3041
+ failedTasks?: number;
3042
+ skippedTasks?: number;
3043
+ blockedTasks?: number;
3044
+ batchDurationMs?: number;
3045
+ // ── Tier0Event-specific optional fields ──────────────────────
3046
+ pattern?: string;
3047
+ attempt?: number;
3048
+ maxAttempts?: number;
3049
+ classification?: string;
3050
+ resolution?: string;
3051
+ suggestion?: string;
3052
+ affectedTaskIds?: string[];
3053
+ message?: string;
3054
+ }
3055
+
3056
+ /**
3057
+ * Event types that are considered "significant" for proactive notification.
3058
+ *
3059
+ * - Engine lifecycle: wave_start, merge_success, merge_failed, batch_complete, batch_paused
3060
+ * - Tier 0 escalation: tier0_escalation (requires supervisor/operator attention)
3061
+ *
3062
+ * task_complete and task_failed are coalesced into periodic digests
3063
+ * rather than individual notifications.
3064
+ *
3065
+ * @since TP-041
3066
+ */
3067
+ const SIGNIFICANT_EVENT_TYPES = new Set<UnifiedEventType>([
3068
+ "wave_start",
3069
+ "merge_start",
3070
+ "merge_success",
3071
+ "merge_failed",
3072
+ "batch_complete",
3073
+ "batch_paused",
3074
+ "tier0_escalation",
3075
+ ]);
3076
+
3077
+ /**
3078
+ * Event types that are coalesced into periodic digests.
3079
+ *
3080
+ * @since TP-041
3081
+ */
3082
+ const DIGEST_EVENT_TYPES = new Set<UnifiedEventType>([
3083
+ "task_complete",
3084
+ "task_failed",
3085
+ "tier0_recovery_attempt",
3086
+ "tier0_recovery_success",
3087
+ "tier0_recovery_exhausted",
3088
+ ]);
3089
+
3090
+ /**
3091
+ * Buffered task events for digest coalescing.
3092
+ *
3093
+ * @since TP-041
3094
+ */
3095
+ interface TaskDigestBuffer {
3096
+ /** Completed task IDs since last digest */
3097
+ completed: string[];
3098
+ /** Failed task IDs since last digest */
3099
+ failed: string[];
3100
+ /** Tier 0 recovery attempts since last digest */
3101
+ recoveryAttempts: number;
3102
+ /** Tier 0 recovery successes since last digest */
3103
+ recoverySuccesses: number;
3104
+ /** Tier 0 recovery exhausted since last digest */
3105
+ recoveryExhausted: number;
3106
+ }
3107
+
3108
+ /**
3109
+ * Event tailer state — tracks the byte offset cursor, digest buffer,
3110
+ * and timer handles for the polling loop and digest flush.
3111
+ *
3112
+ * @since TP-041
3113
+ */
3114
+ export interface EventTailerState {
3115
+ /** Whether the tailer is currently running */
3116
+ running: boolean;
3117
+ /** Byte offset into events.jsonl — only bytes after this are new */
3118
+ byteOffset: number;
3119
+ /** Partial line buffer (when a read ends mid-line) */
3120
+ partialLine: string;
3121
+ /** Active batch ID to filter events against */
3122
+ batchId: string;
3123
+ /** Task digest buffer for coalescing task_complete/task_failed */
3124
+ digestBuffer: TaskDigestBuffer;
3125
+ /** Polling timer handle */
3126
+ pollTimer: ReturnType<typeof setInterval> | null;
3127
+ /** Digest flush timer handle */
3128
+ digestTimer: ReturnType<typeof setInterval> | null;
3129
+ }
3130
+
3131
+ /**
3132
+ * Create a fresh (stopped) event tailer state.
3133
+ *
3134
+ * @since TP-041
3135
+ */
3136
+ export function freshEventTailerState(): EventTailerState {
3137
+ return {
3138
+ running: false,
3139
+ byteOffset: 0,
3140
+ partialLine: "",
3141
+ batchId: "",
3142
+ digestBuffer: freshDigestBuffer(),
3143
+ pollTimer: null,
3144
+ digestTimer: null,
3145
+ };
3146
+ }
3147
+
3148
+ /**
3149
+ * Create a fresh digest buffer.
3150
+ *
3151
+ * @since TP-041
3152
+ */
3153
+ function freshDigestBuffer(): TaskDigestBuffer {
3154
+ return {
3155
+ completed: [],
3156
+ failed: [],
3157
+ recoveryAttempts: 0,
3158
+ recoverySuccesses: 0,
3159
+ recoveryExhausted: 0,
3160
+ };
3161
+ }
3162
+
3163
+ /**
3164
+ * Check if a digest buffer has any content worth flushing.
3165
+ *
3166
+ * @since TP-041
3167
+ */
3168
+ function isDigestEmpty(buf: TaskDigestBuffer): boolean {
3169
+ return (
3170
+ buf.completed.length === 0 &&
3171
+ buf.failed.length === 0 &&
3172
+ buf.recoveryAttempts === 0 &&
3173
+ buf.recoverySuccesses === 0 &&
3174
+ buf.recoveryExhausted === 0
3175
+ );
3176
+ }
3177
+
3178
+ /**
3179
+ * Read new bytes from the events JSONL file starting at the given offset.
3180
+ *
3181
+ * Uses low-level file descriptor operations for efficient tailing without
3182
+ * reading the entire file. Returns the raw UTF-8 string of new bytes,
3183
+ * or empty string if no new data.
3184
+ *
3185
+ * @param eventsPath - Full path to events.jsonl
3186
+ * @param byteOffset - Start reading from this byte offset
3187
+ * @returns [newData, newByteOffset] — the new data and the updated offset
3188
+ *
3189
+ * @since TP-041
3190
+ */
3191
+ export function readNewBytes(eventsPath: string, byteOffset: number): [string, number] {
3192
+ if (!existsSync(eventsPath)) return ["", byteOffset];
3193
+
3194
+ let fileSize: number;
3195
+ try {
3196
+ fileSize = statSync(eventsPath).size;
3197
+ } catch {
3198
+ return ["", byteOffset];
3199
+ }
3200
+
3201
+ if (fileSize <= byteOffset) return ["", byteOffset];
3202
+
3203
+ const bytesToRead = fileSize - byteOffset;
3204
+ const buffer = Buffer.alloc(bytesToRead);
3205
+
3206
+ let fd: number | null = null;
3207
+ try {
3208
+ fd = openSync(eventsPath, "r");
3209
+ readSync(fd, buffer, 0, bytesToRead, byteOffset);
3210
+ } catch {
3211
+ return ["", byteOffset];
3212
+ } finally {
3213
+ if (fd !== null) {
3214
+ try { closeSync(fd); } catch { /* best-effort */ }
3215
+ }
3216
+ }
3217
+
3218
+ return [buffer.toString("utf-8"), fileSize];
3219
+ }
3220
+
3221
+ /**
3222
+ * Parse JSONL lines from raw data, handling partial lines.
3223
+ *
3224
+ * Returns parsed events and any remaining partial line (incomplete
3225
+ * trailing data that doesn't end with a newline).
3226
+ *
3227
+ * Malformed/partial JSON lines are skipped (best-effort, per R005 suggestion).
3228
+ *
3229
+ * @param data - Raw string data from the file
3230
+ * @param partialLine - Leftover partial line from previous read
3231
+ * @returns [parsedEvents, remainingPartialLine]
3232
+ *
3233
+ * @since TP-041
3234
+ */
3235
+ export function parseJsonlLines(
3236
+ data: string,
3237
+ partialLine: string,
3238
+ ): [ParsedEvent[], string] {
3239
+ const combined = partialLine + data;
3240
+ const lines = combined.split("\n");
3241
+
3242
+ // Last element is either empty (if data ended with \n) or a partial line
3243
+ const remaining = lines.pop() ?? "";
3244
+
3245
+ const events: ParsedEvent[] = [];
3246
+ for (const line of lines) {
3247
+ const trimmed = line.trim();
3248
+ if (!trimmed) continue; // Skip empty lines
3249
+
3250
+ try {
3251
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
3252
+ // Minimal validation: must have timestamp, type, batchId
3253
+ if (
3254
+ typeof parsed.timestamp === "string" &&
3255
+ typeof parsed.type === "string" &&
3256
+ typeof parsed.batchId === "string"
3257
+ ) {
3258
+ events.push(parsed as unknown as ParsedEvent);
3259
+ }
3260
+ } catch {
3261
+ // Malformed line — skip and continue (R005 suggestion)
3262
+ }
3263
+ }
3264
+
3265
+ return [events, remaining];
3266
+ }
3267
+
3268
+ /**
3269
+ * Format a significant event into an operator-facing notification string.
3270
+ *
3271
+ * The notification style varies by event type and autonomy level.
3272
+ *
3273
+ * @param event - The parsed event to format
3274
+ * @param autonomy - Current autonomy level
3275
+ * @returns Formatted notification string
3276
+ *
3277
+ * @since TP-041
3278
+ */
3279
+ export function formatEventNotification(
3280
+ event: ParsedEvent,
3281
+ autonomy: SupervisorAutonomyLevel,
3282
+ ): string {
3283
+ const waveNum = event.waveIndex >= 0 ? event.waveIndex + 1 : "?";
3284
+
3285
+ switch (event.type) {
3286
+ case "wave_start": {
3287
+ const taskCount = event.taskIds?.length ?? 0;
3288
+ const laneInfo = event.laneCount ? ` across ${event.laneCount} lanes` : "";
3289
+ return `🌊 **Wave ${waveNum} starting** with ${taskCount} task(s)${laneInfo}.`;
3290
+ }
3291
+ case "merge_start": {
3292
+ return `🔀 Wave ${waveNum} merge starting...`;
3293
+ }
3294
+ case "merge_success": {
3295
+ const waveProg = event.totalWaves
3296
+ ? ` (${waveNum}/${event.totalWaves})`
3297
+ : "";
3298
+ const testInfo = event.testCount ? ` Tests pass (${event.testCount}).` : " Tests pass.";
3299
+ return `✅ **Wave ${waveNum} merged successfully**${waveProg}.${testInfo}`;
3300
+ }
3301
+ case "merge_failed": {
3302
+ const reason = event.reason || event.error || "unknown reason";
3303
+ const laneInfo = event.laneNumber !== undefined ? ` (lane ${event.laneNumber})` : "";
3304
+ if (autonomy === "autonomous") {
3305
+ return `⚠️ Wave ${waveNum} merge failed${laneInfo}: ${reason}. Attempting recovery...`;
3306
+ }
3307
+ return `⚠️ **Wave ${waveNum} merge failed**${laneInfo}: ${reason}.\n` +
3308
+ ` Recovery may be needed. Check the merge logs for details.`;
3309
+ }
3310
+ case "batch_complete": {
3311
+ const parts: string[] = [];
3312
+ if (event.succeededTasks !== undefined) parts.push(`${event.succeededTasks} succeeded`);
3313
+ if (event.failedTasks !== undefined && event.failedTasks > 0) parts.push(`${event.failedTasks} failed`);
3314
+ if (event.skippedTasks !== undefined && event.skippedTasks > 0) parts.push(`${event.skippedTasks} skipped`);
3315
+ if (event.blockedTasks !== undefined && event.blockedTasks > 0) parts.push(`${event.blockedTasks} blocked`);
3316
+ const summary = parts.length > 0 ? parts.join(", ") : "all tasks processed";
3317
+ const duration = event.batchDurationMs
3318
+ ? ` in ${formatDuration(event.batchDurationMs)}`
3319
+ : "";
3320
+ return `🏁 **Batch complete!** ${summary}${duration}.`;
3321
+ }
3322
+ case "batch_paused": {
3323
+ const reason = event.reason || "unknown reason";
3324
+ if (autonomy === "interactive") {
3325
+ return `⏸️ **Batch paused:** ${reason}\n` +
3326
+ ` What would you like to do? Options: fix the issue, skip the task, or abort.`;
3327
+ }
3328
+ return `⏸️ **Batch paused:** ${reason}`;
3329
+ }
3330
+ case "tier0_escalation": {
3331
+ const pattern = event.pattern || "unknown";
3332
+ const suggestion = event.suggestion || "Manual intervention needed.";
3333
+ if (autonomy === "autonomous") {
3334
+ return `⚡ **Tier 0 escalation** (${pattern}): Investigating automatically. ${suggestion}`;
3335
+ }
3336
+ if (autonomy === "interactive") {
3337
+ return `❌ **Tier 0 escalation** (${pattern}): ${suggestion}\n` +
3338
+ ` Need your input on how to proceed.`;
3339
+ }
3340
+ // supervised
3341
+ return `⚡ **Tier 0 escalation** (${pattern}): ${suggestion}\n` +
3342
+ ` Diagnosing — will ask if novel recovery is needed.`;
3343
+ }
3344
+ default:
3345
+ return `📌 Event: ${event.type} (wave ${waveNum})`;
3346
+ }
3347
+ }
3348
+
3349
+ /**
3350
+ * Format a task digest buffer into a summary notification.
3351
+ *
3352
+ * @param buf - Digest buffer to format
3353
+ * @param autonomy - Current autonomy level
3354
+ * @returns Formatted digest string, or null if buffer is empty
3355
+ *
3356
+ * @since TP-041
3357
+ */
3358
+ export function formatTaskDigest(
3359
+ buf: TaskDigestBuffer,
3360
+ autonomy: SupervisorAutonomyLevel,
3361
+ ): string | null {
3362
+ if (isDigestEmpty(buf)) return null;
3363
+
3364
+ const parts: string[] = [];
3365
+
3366
+ if (buf.completed.length > 0) {
3367
+ if (autonomy === "interactive") {
3368
+ // Show individual task IDs in interactive mode
3369
+ parts.push(`✓ ${buf.completed.length} task(s) completed: ${buf.completed.join(", ")}`);
3370
+ } else {
3371
+ parts.push(`✓ ${buf.completed.length} task(s) completed`);
3372
+ }
3373
+ }
3374
+
3375
+ if (buf.failed.length > 0) {
3376
+ // Always show failed task IDs — they need attention
3377
+ parts.push(`✗ ${buf.failed.length} task(s) failed: ${buf.failed.join(", ")}`);
3378
+ }
3379
+
3380
+ if (buf.recoveryAttempts > 0 && autonomy !== "autonomous") {
3381
+ const successRate = buf.recoverySuccesses > 0
3382
+ ? ` (${buf.recoverySuccesses} succeeded)`
3383
+ : "";
3384
+ parts.push(`🔄 ${buf.recoveryAttempts} recovery attempt(s)${successRate}`);
3385
+ }
3386
+
3387
+ if (buf.recoveryExhausted > 0) {
3388
+ parts.push(`⚠️ ${buf.recoveryExhausted} recovery budget(s) exhausted`);
3389
+ }
3390
+
3391
+ if (parts.length === 0) return null;
3392
+
3393
+ return `📊 **Progress update:**\n ${parts.join("\n ")}`;
3394
+ }
3395
+
3396
+ /**
3397
+ * Format a duration in milliseconds to a human-readable string.
3398
+ *
3399
+ * @since TP-041
3400
+ */
3401
+ function formatDuration(ms: number): string {
3402
+ const secs = Math.floor(ms / 1000);
3403
+ if (secs < 60) return `${secs}s`;
3404
+ const mins = Math.floor(secs / 60);
3405
+ const remainSecs = secs % 60;
3406
+ if (mins < 60) return `${mins}m${remainSecs > 0 ? ` ${remainSecs}s` : ""}`;
3407
+ const hours = Math.floor(mins / 60);
3408
+ const remainMins = mins % 60;
3409
+ return `${hours}h${remainMins > 0 ? ` ${remainMins}m` : ""}`;
3410
+ }
3411
+
3412
+ /**
3413
+ * Should a notification for this event type be sent at the given autonomy level?
3414
+ *
3415
+ * Controls notification frequency:
3416
+ * - **interactive**: all significant events + verbose digests
3417
+ * - **supervised**: all significant events + concise digests
3418
+ * - **autonomous**: only failures, escalations, and batch completion; skip routine
3419
+ *
3420
+ * @since TP-041
3421
+ */
3422
+ export function shouldNotify(
3423
+ eventType: UnifiedEventType,
3424
+ autonomy: SupervisorAutonomyLevel,
3425
+ ): boolean {
3426
+ // Always notify for terminal/failure events regardless of autonomy
3427
+ if (
3428
+ eventType === "batch_complete" ||
3429
+ eventType === "batch_paused" ||
3430
+ eventType === "merge_failed" ||
3431
+ eventType === "tier0_escalation"
3432
+ ) {
3433
+ return true;
3434
+ }
3435
+
3436
+ // Autonomous mode: skip routine progress events
3437
+ if (autonomy === "autonomous") {
3438
+ return false;
3439
+ }
3440
+
3441
+ // Interactive and supervised: notify for all significant events
3442
+ return SIGNIFICANT_EVENT_TYPES.has(eventType);
3443
+ }
3444
+
3445
+ /**
3446
+ * Process a batch of parsed events: filter to active batch, classify,
3447
+ * and emit notifications or buffer for digest.
3448
+ *
3449
+ * @param events - Parsed events from the JSONL file
3450
+ * @param tailer - Event tailer state (for batchId filter + digest buffer)
3451
+ * @param autonomy - Current autonomy level
3452
+ * @param notify - Callback to emit a notification to the operator
3453
+ * @param onBatchComplete - Optional callback fired when batch_complete event is detected (TP-043)
3454
+ *
3455
+ * @since TP-041
3456
+ */
3457
+ export function processEvents(
3458
+ events: ParsedEvent[],
3459
+ tailer: EventTailerState,
3460
+ autonomy: SupervisorAutonomyLevel,
3461
+ notify: (text: string) => void,
3462
+ onBatchComplete?: (event: ParsedEvent) => void,
3463
+ ): void {
3464
+ for (const event of events) {
3465
+ // ── Batch-scoped filter (R005-1) ─────────────────────────
3466
+ // Skip events from other batches. When batchId is empty
3467
+ // (pre-planning), accept all events — we'll get the real
3468
+ // batchId on the first event.
3469
+ if (tailer.batchId && event.batchId && event.batchId !== tailer.batchId) {
3470
+ continue;
3471
+ }
3472
+
3473
+ // Update batchId if we were waiting for it (pre-planning)
3474
+ if (!tailer.batchId && event.batchId) {
3475
+ tailer.batchId = event.batchId;
3476
+ }
3477
+
3478
+ // ── TP-043: Trigger integration flow on batch_complete ──
3479
+ if (event.type === "batch_complete" && onBatchComplete) {
3480
+ onBatchComplete(event);
3481
+ }
3482
+
3483
+ // ── Classify: significant (immediate) vs digest (buffered) ──
3484
+ if (DIGEST_EVENT_TYPES.has(event.type)) {
3485
+ // Buffer for digest coalescing
3486
+ bufferDigestEvent(event, tailer.digestBuffer);
3487
+ } else if (shouldNotify(event.type, autonomy)) {
3488
+ // Emit immediate notification
3489
+ const text = formatEventNotification(event, autonomy);
3490
+ notify(text);
3491
+ }
3492
+ // Other event types (merge_start in autonomous mode, etc.) are silently consumed
3493
+ }
3494
+ }
3495
+
3496
+ /**
3497
+ * Buffer a digest-class event into the digest buffer.
3498
+ *
3499
+ * @since TP-041
3500
+ */
3501
+ function bufferDigestEvent(event: ParsedEvent, buf: TaskDigestBuffer): void {
3502
+ switch (event.type) {
3503
+ case "task_complete":
3504
+ if (event.taskId) buf.completed.push(event.taskId);
3505
+ break;
3506
+ case "task_failed":
3507
+ if (event.taskId) buf.failed.push(event.taskId);
3508
+ break;
3509
+ case "tier0_recovery_attempt":
3510
+ buf.recoveryAttempts++;
3511
+ break;
3512
+ case "tier0_recovery_success":
3513
+ buf.recoverySuccesses++;
3514
+ break;
3515
+ case "tier0_recovery_exhausted":
3516
+ buf.recoveryExhausted++;
3517
+ break;
3518
+ }
3519
+ }
3520
+
3521
+ /**
3522
+ * Start the event tailer — polls events.jsonl for new events and
3523
+ * emits proactive notifications to the operator.
3524
+ *
3525
+ * The tailer:
3526
+ * 1. Polls at EVENT_POLL_INTERVAL_MS for new bytes in events.jsonl
3527
+ * 2. Parses new JSONL lines, filtering to active batchId
3528
+ * 3. Significant events → immediate notification via pi.sendMessage
3529
+ * 4. task_complete/task_failed → buffered into periodic digests
3530
+ *
3531
+ * Idempotent: safe to call when already running (no-op).
3532
+ *
3533
+ * @param pi - ExtensionAPI for sending notifications
3534
+ * @param tailer - Event tailer state (mutated)
3535
+ * @param supervisorState - Supervisor state (for config + stateRoot)
3536
+ *
3537
+ * @since TP-041
3538
+ */
3539
+ export function startEventTailer(
3540
+ pi: ExtensionAPI,
3541
+ tailer: EventTailerState,
3542
+ supervisorState: SupervisorState,
3543
+ ): void {
3544
+ if (tailer.running) return; // Idempotent guard (R005-2)
3545
+
3546
+ const stateRoot = supervisorState.stateRoot;
3547
+ const eventsPath = join(stateRoot, ".pi", "supervisor", "events.jsonl");
3548
+ const autonomy = supervisorState.config.autonomy;
3549
+
3550
+ tailer.running = true;
3551
+ tailer.batchId = supervisorState.batchId;
3552
+
3553
+ // Initialize byte offset to current file size so we only process
3554
+ // events emitted after activation (not stale events from previous batches).
3555
+ // For takeover paths, the activation message's standing orders tell the
3556
+ // supervisor to read the full events file manually for context.
3557
+ if (existsSync(eventsPath)) {
3558
+ try {
3559
+ tailer.byteOffset = statSync(eventsPath).size;
3560
+ } catch {
3561
+ tailer.byteOffset = 0;
3562
+ }
3563
+ } else {
3564
+ tailer.byteOffset = 0;
3565
+ }
3566
+
3567
+ // Notification callback — sends as a supervisor event message
3568
+ const notify = (text: string) => {
3569
+ if (!supervisorState.active) return; // Guard: don't notify after deactivation
3570
+ pi.sendMessage(
3571
+ {
3572
+ customType: "supervisor-event",
3573
+ content: [{ type: "text", text }],
3574
+ display: text.replace(/\*\*/g, "").substring(0, 80),
3575
+ },
3576
+ { triggerTurn: true, deliverAs: "nextTurn" },
3577
+ );
3578
+ };
3579
+
3580
+ // ── TP-043: Integration is triggered by triggerSupervisorIntegration() ──
3581
+ // called from the onTerminal callback in startBatchAsync (extension.ts),
3582
+ // gated on phase === "completed" (R002-1). For auto mode, integration is
3583
+ // executed programmatically via the executor callback (R002-2). The event
3584
+ // tailer does NOT duplicate the integration trigger — batch_complete events
3585
+ // are handled via the normal notification path (formatEventNotification).
3586
+
3587
+ // ── Poll timer ───────────────────────────────────────────────
3588
+ tailer.pollTimer = setInterval(() => {
3589
+ if (!supervisorState.active || !tailer.running) {
3590
+ stopEventTailer(tailer);
3591
+ return;
3592
+ }
3593
+
3594
+ const [newData, newOffset] = readNewBytes(eventsPath, tailer.byteOffset);
3595
+ if (!newData) return; // No new data
3596
+
3597
+ tailer.byteOffset = newOffset;
3598
+ const [events, remaining] = parseJsonlLines(newData, tailer.partialLine);
3599
+ tailer.partialLine = remaining;
3600
+
3601
+ processEvents(events, tailer, autonomy, notify);
3602
+ }, EVENT_POLL_INTERVAL_MS);
3603
+
3604
+ // ── Digest flush timer ───────────────────────────────────────
3605
+ tailer.digestTimer = setInterval(() => {
3606
+ if (!supervisorState.active || !tailer.running) {
3607
+ stopEventTailer(tailer);
3608
+ return;
3609
+ }
3610
+
3611
+ if (isDigestEmpty(tailer.digestBuffer)) return;
3612
+
3613
+ const digest = formatTaskDigest(tailer.digestBuffer, autonomy);
3614
+ if (digest) {
3615
+ notify(digest);
3616
+ }
3617
+
3618
+ // Reset buffer
3619
+ tailer.digestBuffer = freshDigestBuffer();
3620
+ }, TASK_DIGEST_INTERVAL_MS);
3621
+
3622
+ // Unref timers so they don't prevent Node.js exit
3623
+ if (tailer.pollTimer && typeof tailer.pollTimer === "object" && "unref" in tailer.pollTimer) {
3624
+ tailer.pollTimer.unref();
3625
+ }
3626
+ if (tailer.digestTimer && typeof tailer.digestTimer === "object" && "unref" in tailer.digestTimer) {
3627
+ tailer.digestTimer.unref();
3628
+ }
3629
+ }
3630
+
3631
+ /**
3632
+ * Stop the event tailer.
3633
+ *
3634
+ * Clears timers and flushes any remaining digest buffer (best-effort,
3635
+ * the final digest is not sent — it would be stale).
3636
+ *
3637
+ * Idempotent: safe to call when already stopped (no-op).
3638
+ *
3639
+ * @param tailer - Event tailer state (mutated)
3640
+ *
3641
+ * @since TP-041
3642
+ */
3643
+ export function stopEventTailer(tailer: EventTailerState): void {
3644
+ if (!tailer.running) return; // Idempotent guard
3645
+
3646
+ if (tailer.pollTimer) {
3647
+ clearInterval(tailer.pollTimer);
3648
+ tailer.pollTimer = null;
3649
+ }
3650
+
3651
+ if (tailer.digestTimer) {
3652
+ clearInterval(tailer.digestTimer);
3653
+ tailer.digestTimer = null;
3654
+ }
3655
+
3656
+ tailer.running = false;
3657
+ tailer.partialLine = "";
3658
+ tailer.digestBuffer = freshDigestBuffer();
3659
+ }