taskplane 0.30.4 → 0.30.6

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.
@@ -50,6 +50,12 @@ import {
50
50
  rename as fsRename,
51
51
  } from "fs/promises";
52
52
  import { execFileSync } from "child_process";
53
+ import { assessEngineLiveness } from "./engine-identity.ts";
54
+ import {
55
+ isProcessAlive as registryIsProcessAlive,
56
+ isTerminalStatus as registryIsTerminalStatus,
57
+ readRegistrySnapshot,
58
+ } from "./process-registry.ts";
53
59
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
54
60
  import type { Model, Api } from "@mariozechner/pi-ai";
55
61
  import type {
@@ -2383,6 +2389,15 @@ Use these to:
2383
2389
  issue using the patterns in supervisor-primer.md and take appropriate
2384
2390
  recovery action based on your autonomy level (${autonomyLabel}).
2385
2391
 
2392
+ 2a. **Adjudicate reviews.** You are notified at every review boundary, and get
2393
+ an urgent (steer) \`review-intervention-needed\` alert when a step's reviews
2394
+ spiral (repeated non-approve) or a worker trips the order-of-operations guard.
2395
+ Actively adjudicate — don't just relay to the operator. Use the finding
2396
+ **trend** to tell converging (\`dropping\` — let it run) from circling
2397
+ (\`flat\`/\`rising\` — intervene), then steer the worker to a resolution
2398
+ (implement the remaining valid findings, or stop and log a blocker) via
2399
+ \`send_agent_message\`. Follow **Playbook D** in supervisor-primer.md.
2400
+
2386
2401
  3. **Keep the operator informed.** Provide clear, natural status updates.
2387
2402
  When the operator asks "how's it going?" — read batch state and summarize.
2388
2403
 
@@ -2430,24 +2445,23 @@ ${autonomyGuidance}
2430
2445
 
2431
2446
  ## Audit Trail
2432
2447
 
2433
- Log every recovery action to \`${actionsPath}\` as a single-line JSON entry.
2448
+ Log every recovery action with the **\`log_recovery_action\` tool** it appends
2449
+ to \`${actionsPath}\` with a **code-stamped timestamp and batchId**.
2434
2450
 
2435
- **Format** (one JSON object per line):
2436
- \`\`\`json
2437
- {"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"}"}
2438
- \`\`\`
2451
+ **NEVER hand-write \`actions.jsonl\`** (no bash \`echo >>\`): you have no reliable
2452
+ clock, so hand-written entries carry fabricated timestamps and break the audit
2453
+ trail's integrity as evidence.
2439
2454
 
2440
2455
  **Rules:**
2441
- 1. For **destructive** actions: write a "pending" entry BEFORE executing, then
2442
- write a result entry AFTER with "success" or "failure" and detail.
2443
- 2. For **diagnostic** and **tier0_known** actions: write a single result entry
2444
- AFTER execution.
2445
- 3. Include optional fields when relevant: \`waveIndex\`, \`laneNumber\`, \`taskId\`, \`durationMs\`.
2446
- 4. Use the \`bash\` tool to append entries. Example:
2447
- \`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}\`
2456
+ 1. For **destructive** actions: call \`log_recovery_action(..., result="pending")\`
2457
+ BEFORE executing, then call again AFTER with \`"success"\` or \`"failure"\` and detail.
2458
+ 2. For **diagnostic** and **tier0_known** actions: one call AFTER execution.
2459
+ 3. Include \`waveIndex\`, \`laneNumber\`, \`taskId\` when relevant.
2460
+ 4. Stick to the schema fields do not invent ad-hoc field names.
2448
2461
 
2449
2462
  **Why this matters:** When you're taken over by another session or the operator
2450
- asks "what did you do?", the audit trail is the definitive record.
2463
+ asks "what did you do?", the audit trail is the definitive record — and its
2464
+ timestamps are only trustworthy because code stamps them.
2451
2465
 
2452
2466
  ## Operational Knowledge
2453
2467
 
@@ -3200,13 +3214,18 @@ export async function deactivateSupervisor(
3200
3214
  *
3201
3215
  * @since TP-128
3202
3216
  */
3203
- export async function transitionToRoutingMode(
3204
- pi: ExtensionAPI,
3205
- state: SupervisorState,
3206
- routingContext: SupervisorRoutingContext,
3207
- ): Promise<void> {
3208
- if (!state.active) return;
3209
-
3217
+ /**
3218
+ * Tear down batch-monitoring infrastructure (event tailer, heartbeat timer,
3219
+ * lockfile). Idempotent — safe to call multiple times.
3220
+ *
3221
+ * Extracted from `transitionToRoutingMode` (#621) so the batch-end epilogue can
3222
+ * stop background timers EAGERLY when it must defer its display banners past an
3223
+ * in-flight tool call. Stopping the heartbeat immediately prevents a
3224
+ * timer-origin `pi.sendMessage(..., {triggerTurn:false})` from splicing a custom
3225
+ * entry between an assistant `tool_use` and its `tool_result` during the defer
3226
+ * window.
3227
+ */
3228
+ export function stopBatchMonitoring(state: SupervisorState): void {
3210
3229
  // Tear down batch-monitoring infrastructure
3211
3230
  stopEventTailer(state.eventTailer);
3212
3231
 
@@ -3223,6 +3242,16 @@ export async function transitionToRoutingMode(
3223
3242
  }
3224
3243
  }
3225
3244
  state.lockSessionId = "";
3245
+ }
3246
+
3247
+ export async function transitionToRoutingMode(
3248
+ pi: ExtensionAPI,
3249
+ state: SupervisorState,
3250
+ routingContext: SupervisorRoutingContext,
3251
+ ): Promise<void> {
3252
+ if (!state.active) return;
3253
+
3254
+ stopBatchMonitoring(state);
3226
3255
 
3227
3256
  // Present deferred batch summary if any
3228
3257
  if (state.pendingSummaryDeps && state.batchStateRef && state.stateRoot) {
@@ -3548,8 +3577,12 @@ export function isProcessAlive(pid: number): boolean {
3548
3577
  try {
3549
3578
  process.kill(pid, 0);
3550
3579
  return true;
3551
- } catch {
3552
- return false;
3580
+ } catch (err: unknown) {
3581
+ // #631: only ESRCH (no such process) is "dead". EPERM = exists without
3582
+ // signal permission; unknown errors fail closed (alive) — this feeds the
3583
+ // lock-takeover ownership decision.
3584
+ const code = (err as { code?: string } | null)?.code;
3585
+ return code !== "ESRCH";
3553
3586
  }
3554
3587
  }
3555
3588
 
@@ -3682,6 +3715,64 @@ export function buildTakeoverSummary(stateRoot: string, batchState: PersistedBat
3682
3715
  `**Tasks:** ${succeeded} succeeded, ${failed} failed, ${running} running, ${pending} pending`,
3683
3716
  );
3684
3717
 
3718
+ // #631: ownership evidence — is the previous ENGINE still running? A dead
3719
+ // supervisor pid does not imply a dead engine (forked child). This line is
3720
+ // what tells the replacement operator whether orch_resume can proceed.
3721
+ const activePhase =
3722
+ batchState.phase === "executing" ||
3723
+ batchState.phase === "launching" ||
3724
+ batchState.phase === "merging" ||
3725
+ batchState.phase === "planning";
3726
+ const liveness = assessEngineLiveness(stateRoot, batchState.batchId);
3727
+ if (liveness.identity?.taskplaneBuild) {
3728
+ lines.push(
3729
+ `**Build:** taskplane ${liveness.identity.taskplaneVersion ?? "?"} (build ${liveness.identity.taskplaneBuild}) drove this batch`,
3730
+ );
3731
+ }
3732
+ if (liveness.status === "alive") {
3733
+ lines.push(
3734
+ `**Engine:** ⚠️ PID ${liveness.identity!.pid} is still ALIVE (forked by supervisor PID ${liveness.identity!.supervisorPid}). ` +
3735
+ `This session has no engine attached; recovery tools will refuse until it exits (it pauses itself on supervisor disconnect) or is terminated.`,
3736
+ );
3737
+ } else if (liveness.status === "dead" || liveness.status === "exited") {
3738
+ lines.push(
3739
+ `**Engine:** PID ${liveness.identity!.pid} is ${liveness.status}${liveness.identity!.exitReason ? ` (${liveness.identity!.exitReason})` : ""}` +
3740
+ (activePhase
3741
+ ? ` — persisted phase "${batchState.phase}" is an orphan; orch_resume(force=true) reconciles and re-drives it.`
3742
+ : "."),
3743
+ );
3744
+ } else if (activePhase) {
3745
+ lines.push(
3746
+ `**Engine:** no identity recorded for this batch (pre-#631 engine or never forked). Recovery tools decide from the previous supervisor's liveness.`,
3747
+ );
3748
+ }
3749
+
3750
+ // #631/#630: workers the registry still calls "running" whose process is gone.
3751
+ // Operators should NOT hand-edit registry.json for these — resume's liveness
3752
+ // check (`!terminal && isProcessAlive(pid)`) already treats them as dead.
3753
+ try {
3754
+ const registry = readRegistrySnapshot(stateRoot, batchState.batchId);
3755
+ if (registry) {
3756
+ const deadRunning = Object.values(registry.agents).filter(
3757
+ (m) => !registryIsTerminalStatus(m.status) && !registryIsProcessAlive(m.pid),
3758
+ );
3759
+ if (deadRunning.length > 0) {
3760
+ lines.push("");
3761
+ lines.push(
3762
+ `**Dead agents still marked ${deadRunning[0].status} in the registry** (${deadRunning.length}):`,
3763
+ );
3764
+ for (const m of deadRunning) {
3765
+ lines.push(
3766
+ ` - ${m.agentId} (${m.role}${m.taskId ? `, ${m.taskId}` : ""}) PID ${m.pid} — process gone; registry last updated ${new Date(registry.updatedAt).toISOString()}. ` +
3767
+ `No registry edit needed: orch_resume reconciles it (re-execute in the existing worktree).`,
3768
+ );
3769
+ }
3770
+ }
3771
+ }
3772
+ } catch {
3773
+ /* best effort */
3774
+ }
3775
+
3685
3776
  // Recent actions from audit trail (using readAuditTrail helper)
3686
3777
  const recentActions = readAuditTrail(stateRoot, { limit: 5 });
3687
3778
  if (recentActions.length > 0) {
@@ -3817,6 +3908,56 @@ export function safeSendMessageFromTimer(
3817
3908
  }
3818
3909
  }
3819
3910
 
3911
+ /**
3912
+ * Stale-safe wrapper for a UI / side-effect call that touches a possibly-stale
3913
+ * `ExtensionContext` or `ExtensionAPI` from a long-lived ASYNC callback —
3914
+ * engine-worker IPC handlers (`child.on("message"|"error"|"exit")`), widget
3915
+ * refresh, the batch-end epilogue, and supervisor-alert delivery (#620).
3916
+ *
3917
+ * Pi invalidates captured `ctx`/`pi` handles on session replacement/reload, and
3918
+ * also at the end of headless `-p` runs while the forked engine worker is still
3919
+ * emitting IPC. Every `ctx` accessor (`ctx.ui`, `ctx.isIdle()`, …) and the
3920
+ * `pi.send*` methods then call Pi's `assertActive`, which throws
3921
+ * `"This extension ctx is stale after session replacement or reload"`. Such a
3922
+ * throw inside a `child_process` / EventEmitter callback is an
3923
+ * `uncaughtException` that kills the supervising Pi process.
3924
+ *
3925
+ * Return value semantics (to prevent caller misuse):
3926
+ * - `false` → STALE only. The session is gone; caller should skip any
3927
+ * further UI work for this event.
3928
+ * - `true` → success OR a non-stale failure that was logged. NOT a
3929
+ * success-only signal — a `true` may mean "logged and continued".
3930
+ *
3931
+ * Any non-stale error is logged (so genuine failures still surface in
3932
+ * stderr/telemetry) but is deliberately NOT rethrown:
3933
+ * rethrowing from an IPC/EventEmitter callback would re-introduce the exact
3934
+ * process-fatal crash class this guards against. This mirrors the proven #597
3935
+ * `safeSendMessageFromTimer` contract, generalized to any thunk so it can wrap
3936
+ * `ctx.ui.notify`, `ctx.ui.setWidget`, and `pi.sendUserMessage` alike.
3937
+ *
3938
+ * @since #620
3939
+ */
3940
+ export function safeCtxCallFromCallback(fn: () => void, label = "ui"): boolean {
3941
+ try {
3942
+ fn();
3943
+ return true;
3944
+ } catch (err) {
3945
+ if (isStaleExtensionCtx(err)) {
3946
+ // Pi replaced/ended the session — no live UI sink. Skip, never crash.
3947
+ return false;
3948
+ }
3949
+ // Not stale: surface it (so real failures are visible) but do not rethrow,
3950
+ // because a throw from an async IPC callback is a process-fatal uncaught
3951
+ // exception — the very failure mode #620 fixes.
3952
+ console.error(
3953
+ `[taskplane] ${label} call from async callback threw (non-stale): ${
3954
+ err instanceof Error ? (err.stack ?? err.message) : String(err)
3955
+ }`,
3956
+ );
3957
+ return true;
3958
+ }
3959
+ }
3960
+
3820
3961
  export function startHeartbeat(
3821
3962
  stateRoot: string,
3822
3963
  state: SupervisorState,
@@ -3968,6 +4109,18 @@ interface ParsedEvent {
3968
4109
  suggestion?: string;
3969
4110
  affectedTaskIds?: string[];
3970
4111
  message?: string;
4112
+ // ── Review-boundary optional fields ──────────────────────────
4113
+ agentId?: string;
4114
+ reviewStep?: number;
4115
+ reviewType?: string;
4116
+ disposition?: string;
4117
+ reviewRound?: number;
4118
+ reviewLabel?: string;
4119
+ reviewPath?: string;
4120
+ findingCounts?: Record<string, number>;
4121
+ findingTrend?: "dropping" | "flat" | "rising";
4122
+ findingDeltas?: Record<string, number>;
4123
+ findingMixed?: boolean;
3971
4124
  }
3972
4125
 
3973
4126
  /**
@@ -3992,6 +4145,11 @@ const SIGNIFICANT_EVENT_TYPES = new Set<UnifiedEventType>([
3992
4145
  "batch_complete",
3993
4146
  "batch_paused",
3994
4147
  "tier0_escalation",
4148
+ // Review boundaries: surfaced at EVERY start/end so the supervisor can
4149
+ // adjudicate each revision case-by-case (not coalesced into digests).
4150
+ "review_started",
4151
+ "review_completed",
4152
+ "review_failed",
3995
4153
  ]);
3996
4154
 
3997
4155
  /**
@@ -4233,6 +4391,38 @@ export function parseJsonlLines(data: string, partialLine: string): [ParsedEvent
4233
4391
  *
4234
4392
  * @since TP-041
4235
4393
  */
4394
+ /**
4395
+ * Compact "where" descriptor for a review-boundary notification: task, step,
4396
+ * and lane so the supervisor can address the right worker when adjudicating.
4397
+ */
4398
+ function reviewLocation(event: ParsedEvent): string {
4399
+ const parts: string[] = [];
4400
+ if (event.taskId) parts.push(`task ${event.taskId}`);
4401
+ if (typeof event.reviewStep === "number") parts.push(`step ${event.reviewStep}`);
4402
+ if (typeof event.laneNumber === "number") parts.push(`lane ${event.laneNumber}`);
4403
+ return parts.length > 0 ? parts.join(", ") : "a step";
4404
+ }
4405
+
4406
+ /**
4407
+ * Compact adjudication signals for a review notification: round, finding counts,
4408
+ * and severity trend — the three signals an adjudicating supervisor uses to tell
4409
+ * "converging (let it run)" from "circling (intervene)" at a glance.
4410
+ */
4411
+ function reviewSignals(event: ParsedEvent): string {
4412
+ const bits: string[] = [];
4413
+ if (typeof event.reviewRound === "number") bits.push(`round ${event.reviewRound}`);
4414
+ if (event.findingCounts && Object.keys(event.findingCounts).length > 0) {
4415
+ const counts = Object.entries(event.findingCounts)
4416
+ .map(([k, v]) => `${k}:${v}`)
4417
+ .join(" ");
4418
+ const trend = event.findingTrend
4419
+ ? `, trend ${event.findingTrend}${event.findingMixed ? " (mixed)" : ""}`
4420
+ : "";
4421
+ bits.push(`findings ${counts}${trend}`);
4422
+ }
4423
+ return bits.length > 0 ? ` [${bits.join("; ")}]` : "";
4424
+ }
4425
+
4236
4426
  export function formatEventNotification(
4237
4427
  event: ParsedEvent,
4238
4428
  autonomy: SupervisorAutonomyLevel,
@@ -4278,6 +4468,31 @@ export function formatEventNotification(
4278
4468
  const mins = event.stalledMinutes ?? "?";
4279
4469
  return `🔒 Merge agent on lane ${lane} appears stuck (no output for ${mins} min). Consider killing and retrying.`;
4280
4470
  }
4471
+ case "review_started": {
4472
+ const loc = reviewLocation(event);
4473
+ const typeLabel = event.reviewType ? `${event.reviewType} ` : "";
4474
+ return `🔍 **Review starting** — ${typeLabel}review of ${loc}.`;
4475
+ }
4476
+ case "review_completed": {
4477
+ const loc = reviewLocation(event);
4478
+ const disp = (event.disposition || "UNKNOWN").toUpperCase();
4479
+ const icon =
4480
+ disp === "APPROVE" ? "✅" : disp === "REFUSED" ? "⛔" : disp === "UNKNOWN" ? "❔" : "🔁";
4481
+ const tail =
4482
+ disp === "APPROVE"
4483
+ ? ""
4484
+ : disp === "REFUSED"
4485
+ ? " — reviewer refused (step marked complete before review). The worker must revert and re-review."
4486
+ : " — changes requested. Watch for repeated revisions on this step.";
4487
+ return `${icon} **Review ${disp}** — ${loc}.${reviewSignals(event)}${tail}`;
4488
+ }
4489
+ case "review_failed": {
4490
+ const loc = reviewLocation(event);
4491
+ return (
4492
+ `⚠️ **Reviewer unavailable** — ${loc}. The reviewer subprocess failed or produced no ` +
4493
+ `verdict (not a revision spiral — a broken-reviewer signal). Consider checking reviewer config.`
4494
+ );
4495
+ }
4281
4496
  case "batch_complete": {
4282
4497
  const parts: string[] = [];
4283
4498
  if (event.succeededTasks !== undefined) parts.push(`${event.succeededTasks} succeeded`);
@@ -4399,14 +4614,22 @@ export function shouldNotify(
4399
4614
  eventType: UnifiedEventType,
4400
4615
  autonomy: SupervisorAutonomyLevel,
4401
4616
  ): boolean {
4402
- // Always notify for terminal/failure events regardless of autonomy
4617
+ // Always notify for terminal/failure events regardless of autonomy.
4618
+ // Review boundaries are included on purpose: the supervisor must be informed
4619
+ // at EVERY review start/end in ALL autonomy levels — in autonomous mode this
4620
+ // is precisely when it adjudicates revisions case-by-case (operator-as-alarm
4621
+ // is the opposite of autonomous execution). Suppressing review_* in
4622
+ // autonomous mode would silently disable the feature where it matters most.
4403
4623
  if (
4404
4624
  eventType === "batch_complete" ||
4405
4625
  eventType === "batch_paused" ||
4406
4626
  eventType === "merge_failed" ||
4407
4627
  eventType === "merge_health_dead" ||
4408
4628
  eventType === "merge_health_stuck" ||
4409
- eventType === "tier0_escalation"
4629
+ eventType === "tier0_escalation" ||
4630
+ eventType === "review_started" ||
4631
+ eventType === "review_completed" ||
4632
+ eventType === "review_failed"
4410
4633
  ) {
4411
4634
  return true;
4412
4635
  }
@@ -369,6 +369,10 @@ export interface TaskRunnerConfig {
369
369
  tools: string;
370
370
  /** Package specifiers to exclude from extension forwarding (exact match). @since TP-180 */
371
371
  excludeExtensions?: string[];
372
+ /** Ordered severity vocabulary for review finding-count analysis (review-boundary notifications). */
373
+ severityLabels?: string[];
374
+ /** Revision-spiral detection tuning. */
375
+ spiral?: import("./config-schema.ts").ReviewSpiralConfig;
372
376
  };
373
377
  /**
374
378
  * Worker agent model/thinking/tools configuration.
@@ -384,6 +388,8 @@ export interface TaskRunnerConfig {
384
388
  tools: string;
385
389
  /** Package specifiers to exclude from extension forwarding (exact match). @since TP-180 */
386
390
  excludeExtensions?: string[];
391
+ /** Exit-intercept supervisor-reply window in seconds (default 60; 15..1800). */
392
+ exitInterceptTimeoutSec?: number;
387
393
  };
388
394
  /** Worker agent extension exclusion list. @since TP-180 */
389
395
  workerExcludeExtensions?: string[];
@@ -561,6 +567,13 @@ export class WorktreeError extends Error {
561
567
  * catching errors for expected idempotent scenarios.
562
568
  */
563
569
  export interface RemoveWorktreeResult {
570
+ /**
571
+ * #628: removal was refused because the worktree has uncommitted changes and
572
+ * the caller did not pass allowDirty. The worktree and branch are preserved.
573
+ */
574
+ refusedDirty?: boolean;
575
+ /** Number of uncommitted paths found when refusedDirty is true. */
576
+ dirtyFileCount?: number;
564
577
  /** Whether the worktree directory was removed in this call */
565
578
  removed: boolean;
566
579
  /** Whether the worktree was already absent (idempotent no-op) */
@@ -1132,8 +1145,15 @@ export interface WaveExecutionResult {
1132
1145
  stoppedEarly: boolean;
1133
1146
  /** Task IDs that failed (including stalled) */
1134
1147
  failedTaskIds: string[];
1135
- /** Task IDs that were skipped (due to pause, prior failure, or policy) */
1148
+ /** Task IDs that were skipped (due to prior failure in lane, or policy) */
1136
1149
  skippedTaskIds: string[];
1150
+ /**
1151
+ * Task IDs that did NOT run to a terminal state because the batch was PAUSED
1152
+ * while they were pending/holding. They remain `pending` (not skipped, not
1153
+ * counted) and re-execute on resume. A wave with any paused task is not
1154
+ * complete; the engine finalizes the batch as `paused` instead of merging.
1155
+ */
1156
+ pausedTaskIds?: string[];
1137
1157
  /** Task IDs that succeeded */
1138
1158
  succeededTaskIds: string[];
1139
1159
  /** Task IDs blocked for future waves (transitive dependents of failed tasks) */
@@ -1191,6 +1211,20 @@ export type OrchBatchPhase =
1191
1211
  * - Tracks pauseSignal for /orch-pause
1192
1212
  * - Accumulates wave results for summary
1193
1213
  */
1214
+ /**
1215
+ * Shared pause signal (engine ⇄ waves ⇄ lanes).
1216
+ *
1217
+ * `cause` says WHY the batch is paused: `operator` = /orch-pause (or an orphan
1218
+ * engine winding down), `abort` = stop-all failure policy / orch_abort,
1219
+ * `stop-wave` reserved for the stop-wave policy. Tier-0 retry may clear ONLY
1220
+ * a policy cause — one boolean let a successful retry erase an operator's
1221
+ * pause (Sage review of the 20260906T194514 incident).
1222
+ */
1223
+ export interface PauseSignal {
1224
+ paused: boolean;
1225
+ cause?: "operator" | "stop-wave" | "abort" | "merge-failure";
1226
+ }
1227
+
1194
1228
  export interface OrchBatchRuntimeState {
1195
1229
  /** Current execution phase */
1196
1230
  phase: OrchBatchPhase;
@@ -1200,10 +1234,18 @@ export interface OrchBatchRuntimeState {
1200
1234
  baseBranch: string;
1201
1235
  /** Orchestrator-managed branch name (e.g., 'orch/henry-20260318T140000'). Empty = legacy mode (merge into baseBranch directly). */
1202
1236
  orchBranch: string;
1237
+ /**
1238
+ * #610: epoch ms when this batch was integrated (manual or auto). Set in
1239
+ * memory by the integration path so a batch-end epilogue that was DEFERRED
1240
+ * behind the integrating turn is skipped instead of showing stale "ready for
1241
+ * integration" banners. Not persisted (the persisted checkpoint is deleted
1242
+ * on integration; batch-history carries its own integratedAt).
1243
+ */
1244
+ integratedAt?: number;
1203
1245
  /** Workspace execution mode (v2). Defaults to "repo" for backward compatibility. */
1204
1246
  mode: WorkspaceMode;
1205
1247
  /** Shared pause signal — set by /orch-pause, read by executeLane/executeWave */
1206
- pauseSignal: { paused: boolean };
1248
+ pauseSignal: PauseSignal;
1207
1249
  /** All wave results in order (grows as waves complete) */
1208
1250
  waveResults: WaveExecutionResult[];
1209
1251
  /** Current wave index (0-based into waves array, -1 if not started) */
@@ -2028,7 +2070,14 @@ export type EngineEventType =
2028
2070
  | "merge_health_dead"
2029
2071
  | "merge_health_stuck"
2030
2072
  | "batch_complete"
2031
- | "batch_paused";
2073
+ | "batch_paused"
2074
+ // Review boundaries (review-boundary supervisor notifications). Bridged from
2075
+ // the per-agent RuntimeAgentEvent review_* stream by lane-runner so the
2076
+ // supervisor's live events.jsonl tailer surfaces every review start/end and
2077
+ // can adjudicate revisions case-by-case.
2078
+ | "review_started"
2079
+ | "review_completed"
2080
+ | "review_failed";
2032
2081
 
2033
2082
  /**
2034
2083
  * Structured engine event written to `.pi/supervisor/events.jsonl`.
@@ -2101,6 +2150,31 @@ export interface EngineEvent {
2101
2150
  healthStatus?: MergeHealthStatus;
2102
2151
  /** Minutes since last activity (for merge_health_warning, merge_health_stuck) */
2103
2152
  stalledMinutes?: number;
2153
+
2154
+ // ── Review-boundary fields (review_started/completed/failed) ────
2155
+
2156
+ /** Worker agent ID that owns the review (for review_* events) */
2157
+ agentId?: string;
2158
+ /** Step number under review (for review_* events) */
2159
+ reviewStep?: number;
2160
+ /** Review type, e.g. "plan" | "code" (for review_* events) */
2161
+ reviewType?: string;
2162
+ /** Normalized reviewer verdict (for review_completed, review_failed) */
2163
+ disposition?: ReviewDisposition;
2164
+ /** Per-step review round (Nth verdict-producing review of this step) */
2165
+ reviewRound?: number;
2166
+ /** Human/file-correlation label, e.g. "R008-code-step4" */
2167
+ reviewLabel?: string;
2168
+ /** Review file path (relative), for correlation / optional re-parse */
2169
+ reviewPath?: string;
2170
+ /** Finding counts by severity label for this review */
2171
+ findingCounts?: Record<string, number>;
2172
+ /** Converging-vs-circling trend vs the previous round */
2173
+ findingTrend?: "dropping" | "flat" | "rising";
2174
+ /** Per-severity delta (curr - prev) */
2175
+ findingDeltas?: Record<string, number>;
2176
+ /** Whether severities moved in opposing directions */
2177
+ findingMixed?: boolean;
2104
2178
  }
2105
2179
 
2106
2180
  /**
@@ -2145,7 +2219,21 @@ export type SupervisorAlertCategory =
2145
2219
  | "worker-exit-intercept"
2146
2220
  | "segment-expansion-requested"
2147
2221
  | "segment-expansion-approved"
2148
- | "segment-expansion-rejected";
2222
+ | "segment-expansion-rejected"
2223
+ // Review-boundary supervisor notifications: an actionable escalation when a
2224
+ // step's reviews are spiraling (repeated non-APPROVE) or the worker tripped
2225
+ // the order-of-operations guard (REFUSED). Delivered `steer` (urgent) so the
2226
+ // supervisor can adjudicate mid-run. `context.reviewInterventionKind`
2227
+ // distinguishes the two situations.
2228
+ | "review-intervention-needed";
2229
+
2230
+ /** Which review situation triggered a `review-intervention-needed` alert. */
2231
+ export type ReviewInterventionKind =
2232
+ | "revision-spiral"
2233
+ | "order-violation"
2234
+ // #626 minimal cut: a task attempted to finalize (.DONE) while a step's
2235
+ // LATEST review verdict is still REVISE/RETHINK — finalization was refused.
2236
+ | "unresolved-verdict";
2149
2237
 
2150
2238
  /**
2151
2239
  * Structured context payload for supervisor alerts.
@@ -2215,6 +2303,31 @@ export interface SupervisorAlertContext {
2215
2303
  messageId?: string;
2216
2304
  /** Segment expansion request ID (for segment-expansion alerts) */
2217
2305
  expansionRequestId?: string;
2306
+ // ── Review-intervention fields (review-intervention-needed alerts) ────
2307
+ /** Which review situation triggered the escalation. */
2308
+ reviewInterventionKind?: ReviewInterventionKind;
2309
+ /** Step number under review. */
2310
+ reviewStep?: number;
2311
+ /** Review type ("plan" | "code"). */
2312
+ reviewType?: string;
2313
+ /** Per-step review round (Nth verdict-producing review of this step). */
2314
+ reviewRound?: number;
2315
+ /** Human/file-correlation label, e.g. "R008-code-step4". */
2316
+ reviewLabel?: string;
2317
+ /** Latest normalized disposition. */
2318
+ disposition?: ReviewDisposition;
2319
+ /** Recent disposition history for this step (oldest→newest, bounded). */
2320
+ recentDispositions?: ReviewDisposition[];
2321
+ /** Consecutive non-APPROVE count for this step at escalation time. */
2322
+ consecutiveNonApprove?: number;
2323
+ /** Finding counts by severity label for the latest review. */
2324
+ findingCounts?: Record<string, number>;
2325
+ /** Converging-vs-circling trend vs the previous round. */
2326
+ findingTrend?: "dropping" | "flat" | "rising";
2327
+ /** Per-severity delta (curr - prev) for the latest review. */
2328
+ findingDeltas?: Record<string, number>;
2329
+ /** Whether severities moved in opposing directions. */
2330
+ findingMixed?: boolean;
2218
2331
  /** Whether partial progress was preserved (for task-failure alerts) */
2219
2332
  partialProgress?: boolean;
2220
2333
  /** Batch progress summary */
@@ -4211,6 +4324,31 @@ export type RuntimeAgentEventType =
4211
4324
  // Exit interception (TP-172)
4212
4325
  | "exit_intercepted";
4213
4326
 
4327
+ /**
4328
+ * Normalized outcome of a `review_step` tool call, extracted from the reviewer
4329
+ * verdict the tool returns to the worker. Used by the review-boundary
4330
+ * notification pipeline (agent-host emits it in `review_completed`; the
4331
+ * supervisor adjudicates on it).
4332
+ *
4333
+ * - `APPROVE` — reviewer approved the step.
4334
+ * - `REVISE` — changes requested (spiral-relevant).
4335
+ * - `RETHINK` — reconsider the approach (spiral-relevant).
4336
+ * - `REFUSED` — the TP-186 death-spiral guard refused to spawn a reviewer
4337
+ * (step prematurely marked Complete). A correctness signal,
4338
+ * not a normal verdict.
4339
+ * - `UNAVAILABLE` — the reviewer subprocess failed / produced no output. A
4340
+ * "reviewer broken" signal (surfaced as `review_failed`), NOT
4341
+ * counted toward the revision spiral.
4342
+ * - `UNKNOWN` — verdict could not be parsed.
4343
+ */
4344
+ export type ReviewDisposition =
4345
+ | "APPROVE"
4346
+ | "REVISE"
4347
+ | "RETHINK"
4348
+ | "REFUSED"
4349
+ | "UNAVAILABLE"
4350
+ | "UNKNOWN";
4351
+
4214
4352
  // ── Runtime V2 Path Helpers (TP-102) ─────────────────────────────────
4215
4353
 
4216
4354
  /**