taskplane 0.30.5 → 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.
@@ -23,13 +23,22 @@
23
23
 
24
24
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
25
25
  import { Type } from "@mariozechner/pi-ai";
26
- import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
26
+ import {
27
+ writeFileSync,
28
+ readFileSync,
29
+ readdirSync,
30
+ existsSync,
31
+ mkdirSync,
32
+ renameSync,
33
+ unlinkSync,
34
+ } from "fs";
27
35
  import { join, dirname } from "path";
28
36
  import { spawn as nodeSpawn } from "child_process";
29
37
  import { resolvePiCliPath, resolveTaskplaneAgentTemplate } from "./path-resolver.ts";
30
38
  import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
31
39
  import { randomBytes } from "crypto";
32
40
  import { buildExpansionRequestId, type SegmentExpansionRequest } from "./types.ts";
41
+ import { latestReviewFilesPerGate, parseReviewVerdict } from "./review-analysis.ts";
33
42
 
34
43
  /**
35
44
  * Resolve the outbox directory from environment variables.
@@ -174,6 +183,28 @@ function writeSegmentExpansionRequest(request: SegmentExpansionRequest): string
174
183
  * @param stepNum the step number being reviewed
175
184
  * @returns true iff the step is marked Complete in STATUS.md
176
185
  */
186
+ /**
187
+ * #629: does the LATEST review file for `{type}-step{N}` read REVISE/RETHINK?
188
+ * Used to exempt a remediation re-review from the TP-186 complete-step guard.
189
+ * Fail-closed: unreadable dir/file → false (guard stays in force).
190
+ */
191
+ export function hasOutstandingNonApproveReview(
192
+ reviewsDir: string,
193
+ reviewType: string,
194
+ stepNum: number,
195
+ ): boolean {
196
+ try {
197
+ if (!existsSync(reviewsDir)) return false;
198
+ const latest = latestReviewFilesPerGate(readdirSync(reviewsDir));
199
+ const filename = latest.get(`${reviewType.toLowerCase()}-step${stepNum}`);
200
+ if (!filename) return false;
201
+ const verdict = parseReviewVerdict(readFileSync(join(reviewsDir, filename), "utf-8"));
202
+ return verdict === "REVISE" || verdict === "RETHINK";
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
177
208
  export function isStepMarkedComplete(statusPath: string, stepNum: number): boolean {
178
209
  let content: string;
179
210
  try {
@@ -822,7 +853,18 @@ export default function (pi: ExtensionAPI) {
822
853
  // violated the Order of Operations contract; the only safe path
823
854
  // is to revert STATUS first, then re-call review_step. Plan
824
855
  // reviews are exempt because they fire BEFORE implementation.
825
- if (reviewType !== "plan" && isStepMarkedComplete(statusPath, stepNum)) {
856
+ //
857
+ // #629 exemption: when the step is Complete but the LATEST review for
858
+ // this gate is REVISE/RETHINK, the runtime's finalize gate has refused
859
+ // (or will refuse) the task and spawned a remediation iteration whose
860
+ // whole purpose is to re-run this review. That state IS the anomaly
861
+ // being repaired — a re-review is the correct action, not an order
862
+ // violation. Refusing here would make the documented remedy unreachable.
863
+ if (
864
+ reviewType !== "plan" &&
865
+ isStepMarkedComplete(statusPath, stepNum) &&
866
+ !hasOutstandingNonApproveReview(reviewsDir, reviewType, stepNum)
867
+ ) {
826
868
  const taskIdMatch = statusPath.match(/[\\/]([A-Z]{2,}-\d+)[^\\/]*[\\/]STATUS\.md$/);
827
869
  const taskId = taskIdMatch ? taskIdMatch[1] : "<TASK-ID>";
828
870
  const refusal = [
@@ -944,13 +986,23 @@ export default function (pi: ExtensionAPI) {
944
986
  // Read review output and extract verdict
945
987
  if (existsSync(outputPath)) {
946
988
  const reviewContent = readFileSync(outputPath, "utf-8");
947
- const verdictMatch = reviewContent.match(/###?\s*Verdict[:\s]*(APPROVE|REVISE|RETHINK)/i);
948
- let verdict = verdictMatch ? verdictMatch[1].toUpperCase() : "UNKNOWN";
989
+ // #624 (severity upgrade): robust, FAIL-CLOSED verdict extraction. The
990
+ // old regex ('###?\s*Verdict[:\s]*…') missed common reviewer format
991
+ // variants, and its fallback checked the substring "approve" FIRST — so
992
+ // a REVISE review whose body merely contained "approve" (e.g. "cannot
993
+ // approve") was returned to the worker as APPROVE. Workers then marked
994
+ // steps complete and advanced past unaddressed findings. The review
995
+ // gate must NEVER fail open: APPROVE is only ever taken from an
996
+ // explicit Verdict line (parseReviewVerdict handles heading/bold/plain/
997
+ // dash/bracket variants and skips the template placeholder). The body
998
+ // fallback below may only produce fail-closed guesses (REVISE/RETHINK).
999
+ const parsedVerdict = parseReviewVerdict(reviewContent);
1000
+ let verdict: string = parsedVerdict ?? "UNKNOWN";
949
1001
  if (verdict === "UNKNOWN") {
950
1002
  const lower = reviewContent.toLowerCase();
951
- if (lower.includes("approve") && !lower.includes("do not approve")) verdict = "APPROVE";
952
- else if (lower.includes("revise") || lower.includes("changes requested")) verdict = "REVISE";
953
- else if (lower.includes("rethink")) verdict = "RETHINK";
1003
+ if (/\brevise\b/.test(lower) || lower.includes("changes requested")) verdict = "REVISE";
1004
+ else if (/\brethink\b/.test(lower)) verdict = "RETHINK";
1005
+ // NO approve fallback an approval must be explicit.
954
1006
  }
955
1007
 
956
1008
  // Log review in STATUS.md execution log
@@ -986,7 +1038,13 @@ export default function (pi: ExtensionAPI) {
986
1038
  } else {
987
1039
  return {
988
1040
  content: [
989
- { type: "text" as const, text: `Review complete (verdict unclear). See ${reviewFile}` },
1041
+ {
1042
+ type: "text" as const,
1043
+ text:
1044
+ `Review complete (verdict unclear). Read ${reviewFile} and follow its ` +
1045
+ `Verdict line — do NOT treat this as an approval or mark the step complete ` +
1046
+ `without an explicit APPROVE.`,
1047
+ },
990
1048
  ],
991
1049
  details: undefined,
992
1050
  };
@@ -40,6 +40,7 @@ import type {
40
40
  RuntimeAgentEventType,
41
41
  RuntimeAgentManifest,
42
42
  PacketPaths,
43
+ ReviewDisposition,
43
44
  } from "./types.ts";
44
45
 
45
46
  import {
@@ -144,6 +145,65 @@ export function buildWorkerToolsAllowlist(userTools: string | undefined | null):
144
145
  return Array.from(merged).join(",");
145
146
  }
146
147
 
148
+ /**
149
+ * Normalize the freeform text a `review_step` tool call returns into a
150
+ * {@link ReviewDisposition}. The tool returns clean leading tokens
151
+ * (`APPROVE`, `REVISE: …`, `RETHINK — …`, `UNAVAILABLE — …`, `REFUSED: …`),
152
+ * but this parser is defensive: it matches a leading verdict token
153
+ * case-insensitively and falls back to a substring scan, returning `UNKNOWN`
154
+ * when nothing recognizable is present.
155
+ *
156
+ * Pure and side-effect-free so it can be unit-tested directly.
157
+ *
158
+ * @since review-boundary notifications
159
+ */
160
+ /**
161
+ * Extract the review file path from a review_step tool return. The tool appends
162
+ * "Full review: .reviews/R{NNN}-{type}-step{N}.md" (REVISE) or
163
+ * "See .reviews/R{NNN}-{type}-step{N}.md" (RETHINK). Returns undefined when no
164
+ * such reference is present (e.g. APPROVE returns just "APPROVE").
165
+ */
166
+ export function extractReviewPath(resultText: string | undefined | null): string | undefined {
167
+ if (!resultText || typeof resultText !== "string") return undefined;
168
+ const m = resultText.match(/(?:Full review:|See)\s+(\S*R\d+-[a-z]+-step\d+\.md)\b/i);
169
+ if (m) return m[1];
170
+ const m2 = resultText.match(/(\S*R\d+-[a-z]+-step\d+\.md)\b/i);
171
+ return m2 ? m2[1] : undefined;
172
+ }
173
+
174
+ export function normalizeReviewDisposition(
175
+ resultText: string | undefined | null,
176
+ ): ReviewDisposition {
177
+ if (!resultText || typeof resultText !== "string") return "UNKNOWN";
178
+ const text = resultText.trim();
179
+ if (text.length === 0) return "UNKNOWN";
180
+ // Leading-token match (the tool's canonical output shape).
181
+ const lead = text.toUpperCase();
182
+ if (/^APPROVE\b/.test(lead)) return "APPROVE";
183
+ if (/^REVISE\b/.test(lead)) return "REVISE";
184
+ if (/^RETHINK\b/.test(lead)) return "RETHINK";
185
+ if (/^REFUSED\b/.test(lead)) return "REFUSED";
186
+ if (/^UNAVAILABLE\b/.test(lead)) return "UNAVAILABLE";
187
+ // Defensive fallback: scan for the token anywhere, most-specific first, using
188
+ // word boundaries so substrings inside other words don't false-match.
189
+ // REFUSED and UNAVAILABLE are checked before REVISE/RETHINK because their
190
+ // bodies may quote a verdict word.
191
+ if (/\bREFUSED\b/.test(lead)) return "REFUSED";
192
+ if (/\bUNAVAILABLE\b/.test(lead)) return "UNAVAILABLE";
193
+ if (/\bRETHINK\b/.test(lead)) return "RETHINK";
194
+ if (/\bREVISE\b/.test(lead) || /\bCHANGES REQUESTED\b/.test(lead)) return "REVISE";
195
+ // Approve only on a clean, non-negated APPROVE token. Reject explicit
196
+ // negations ("do not approve", "not approved", "disapprove", "unapproved").
197
+ if (
198
+ /\bAPPROVE\b/.test(lead) &&
199
+ !/\bDO NOT APPROVE\b/.test(lead) &&
200
+ !/\bNOT APPROVE\b/.test(lead)
201
+ ) {
202
+ return "APPROVE";
203
+ }
204
+ return "UNKNOWN";
205
+ }
206
+
147
207
  // ── Conversation Payload Helpers (TP-111) ───────────────────────────────
148
208
 
149
209
  /** Maximum characters for conversation event text payloads. */
@@ -181,6 +241,37 @@ function extractAssistantText(message: Record<string, unknown>): string {
181
241
  return "";
182
242
  }
183
243
 
244
+ /**
245
+ * Extract text from a Pi RPC `tool_execution_end` result, which — like message
246
+ * content — may be a plain string, an array of `{type:"text",text}` blocks, or
247
+ * an object with a `content` field (the shape tool handlers return). The old
248
+ * `typeof event.result === "string" ? ... : String(event.output)` extraction
249
+ * silently produced an empty/garbage string for structured results, which made
250
+ * review_step verdicts unparseable and mis-fired "Reviewer unavailable" (#624).
251
+ */
252
+ export function extractToolResultText(event: { result?: unknown; output?: unknown }): string {
253
+ const fromValue = (val: unknown): string | null => {
254
+ if (typeof val === "string") return val;
255
+ if (Array.isArray(val)) {
256
+ const texts = val
257
+ .filter(
258
+ (b: unknown): b is { type: string; text: string } =>
259
+ typeof b === "object" &&
260
+ b !== null &&
261
+ (b as { type?: unknown }).type === "text" &&
262
+ typeof (b as { text?: unknown }).text === "string",
263
+ )
264
+ .map((b) => b.text);
265
+ if (texts.length > 0) return texts.join("\n");
266
+ }
267
+ if (val && typeof val === "object" && "content" in val) {
268
+ return extractAssistantText(val as Record<string, unknown>);
269
+ }
270
+ return null;
271
+ };
272
+ return fromValue(event.result) ?? fromValue(event.output) ?? "";
273
+ }
274
+
184
275
  // ── Types ────────────────────────────────────────────────────────────
185
276
 
186
277
  /**
@@ -250,6 +341,11 @@ export interface AgentHostOptions {
250
341
  * @since TP-172
251
342
  */
252
343
  maxExitInterceptions?: number;
344
+ /**
345
+ * Upper bound (ms) for one onPrematureExit intercept before the host stops
346
+ * waiting. Must exceed the lane's supervisor-reply window; default 120s.
347
+ */
348
+ exitInterceptSafetyMs?: number;
253
349
  }
254
350
 
255
351
  /**
@@ -396,6 +492,14 @@ export function spawnAgent(
396
492
  let lastTool = "",
397
493
  error: string | null = null;
398
494
  let contextUsage: AgentHostResult["contextUsage"] = null;
495
+ /**
496
+ * In-flight review_step boundary state (review-boundary notifications). Set at
497
+ * tool_execution_start so the end event and any crash-abort can carry the
498
+ * step/reviewType identity (tool_execution_end does not include the original
499
+ * args). review_step calls are sequential within a worker (the worker blocks
500
+ * on the verdict), so a single pending slot is sufficient.
501
+ */
502
+ let pendingReview: { step?: number; reviewType?: string } | null = null;
399
503
  let stderrBuffer = "";
400
504
  const STDERR_MAX = 2048;
401
505
  /** Last assistant message text captured from message_end events (TP-172) */
@@ -589,7 +693,10 @@ export function spawnAgent(
589
693
  try {
590
694
  appendFileSync(
591
695
  opts.steeringPendingPath,
592
- JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id }) + "\n",
696
+ // #630: `type` lets the lane-runner tell an acknowledgement (info)
697
+ // from a ruling/instruction (steer) for hold bookkeeping.
698
+ JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id, type: msg.type }) +
699
+ "\n",
593
700
  "utf-8",
594
701
  );
595
702
  } catch {
@@ -671,6 +778,19 @@ export function spawnAgent(
671
778
  : exitCode === 0 && agentEnded
672
779
  ? "agent_exited"
673
780
  : "agent_crashed";
781
+ // Review-boundary: if the worker died mid-review, close the dangling
782
+ // review_started so the supervisor doesn't see an orphaned "review
783
+ // starting" with no end. Emitted as review_failed (aborted) before the
784
+ // terminal exit event.
785
+ if (pendingReview) {
786
+ emitEvent("review_failed", {
787
+ step: pendingReview.step,
788
+ reviewType: pendingReview.reviewType,
789
+ disposition: "UNKNOWN",
790
+ summary: `review aborted (${exitEventType})`,
791
+ });
792
+ pendingReview = null;
793
+ }
674
794
  emitEvent(exitEventType, { exitCode, signal, durationMs: result.durationMs, timedOut });
675
795
 
676
796
  // Registry integration: update manifest to terminal status
@@ -781,17 +901,58 @@ export function spawnAgent(
781
901
  // TP-111: Bounded payload only — no raw args in durable event log
782
902
  const toolPath = event.args?.path ? String(event.args.path).slice(0, 200) : "";
783
903
  emitEvent("tool_call", { tool: toolName, path: toolPath, argsPreview: argPreview });
904
+ // Review-boundary notification: the review START. review_step spawns a
905
+ // reviewer; surfacing this lets the supervisor track review activity
906
+ // live (see lane-runner onEvent bridge + supervisor tailer).
907
+ if (toolName === "review_step") {
908
+ const reviewStep =
909
+ event.args && typeof event.args === "object"
910
+ ? (event.args as { step?: unknown }).step
911
+ : undefined;
912
+ const reviewType =
913
+ event.args && typeof event.args === "object"
914
+ ? (event.args as { type?: unknown }).type
915
+ : undefined;
916
+ const stepNum = typeof reviewStep === "number" ? reviewStep : undefined;
917
+ const rType = typeof reviewType === "string" ? reviewType : undefined;
918
+ // Remember identity so the end/abort events can carry step+reviewType
919
+ // (tool_execution_end omits args).
920
+ pendingReview = { step: stepNum, reviewType: rType };
921
+ emitEvent("review_requested", { step: stepNum, reviewType: rType });
922
+ }
784
923
  break;
785
924
  }
786
925
  case "tool_execution_end": {
787
- // TP-111: Include bounded result summary for dashboard display
788
- const toolResultSummary =
789
- typeof event.result === "string"
790
- ? event.result.slice(0, 200)
791
- : event.output
792
- ? String(event.output).slice(0, 200)
793
- : "";
926
+ // #624: extract robustly tool results are often structured content
927
+ // arrays, not plain strings; the old extraction produced "" for those.
928
+ const fullResult = extractToolResultText(event);
929
+ const toolResultSummary = fullResult.slice(0, 200);
794
930
  emitEvent("tool_result", { tool: event.toolName, summary: toolResultSummary });
931
+ // Review-boundary notification: the review END. Normalize the reviewer
932
+ // verdict and emit review_completed (APPROVE/REVISE/RETHINK/REFUSED) or
933
+ // review_failed. Only a GENUINE UNAVAILABLE (reviewer subprocess failed
934
+ // / produced no output) is the "broken reviewer" signal. A parse miss
935
+ // (UNKNOWN) must NOT masquerade as a broken reviewer (#624) — emit it as
936
+ // review_completed; lane-runner authoritatively resolves the verdict from
937
+ // the review file on disk.
938
+ if (event.toolName === "review_step") {
939
+ const disposition = normalizeReviewDisposition(fullResult);
940
+ const reviewFields = {
941
+ step: pendingReview?.step,
942
+ reviewType: pendingReview?.reviewType,
943
+ disposition,
944
+ summary: toolResultSummary,
945
+ // review_step embeds the review file path in its REVISE/RETHINK
946
+ // return; surfacing it lets lane-runner read the EXACT review file.
947
+ reviewPath: extractReviewPath(fullResult),
948
+ };
949
+ if (disposition === "UNAVAILABLE") {
950
+ emitEvent("review_failed", reviewFields);
951
+ } else {
952
+ emitEvent("review_completed", reviewFields);
953
+ }
954
+ pendingReview = null;
955
+ }
795
956
  break;
796
957
  }
797
958
  case "auto_retry_start": {
@@ -841,7 +1002,7 @@ export function spawnAgent(
841
1002
  const shouldIntercept = opts.onPrematureExit && exitInterceptionCount < maxExitInterceptions;
842
1003
  if (shouldIntercept) {
843
1004
  exitInterceptionCount++;
844
- const INTERCEPTION_TIMEOUT_MS = 120_000; // 2 minute safety timeout
1005
+ const INTERCEPTION_TIMEOUT_MS = opts.exitInterceptSafetyMs ?? 120_000; // safety timeout (> lane window)
845
1006
  // Wrap in Promise.resolve().then() to catch synchronous throws
846
1007
  const interceptPromise = Promise.resolve().then(() =>
847
1008
  opts.onPrematureExit!(lastAssistantMessage),
@@ -1268,6 +1268,9 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
1268
1268
  thinking: config.taskRunner.worker.thinking,
1269
1269
  tools: config.taskRunner.worker.tools,
1270
1270
  excludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
1271
+ ...(typeof config.taskRunner.worker.exitInterceptTimeoutSec === "number"
1272
+ ? { exitInterceptTimeoutSec: config.taskRunner.worker.exitInterceptTimeoutSec }
1273
+ : {}),
1271
1274
  },
1272
1275
  model_fallback: config.taskRunner.modelFallback ?? "inherit",
1273
1276
  reviewer: {
@@ -1275,6 +1278,12 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
1275
1278
  thinking: config.taskRunner.reviewer.thinking,
1276
1279
  tools: config.taskRunner.reviewer.tools,
1277
1280
  excludeExtensions: [...(config.taskRunner.reviewer.excludeExtensions ?? [])],
1281
+ ...(config.taskRunner.reviewer.severityLabels
1282
+ ? { severityLabels: [...config.taskRunner.reviewer.severityLabels] }
1283
+ : {}),
1284
+ ...(config.taskRunner.reviewer.spiral
1285
+ ? { spiral: { ...config.taskRunner.reviewer.spiral } }
1286
+ : {}),
1278
1287
  },
1279
1288
  workerExcludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
1280
1289
  };
@@ -118,9 +118,34 @@ export interface WorkerConfig {
118
118
  spawnMode?: "subprocess";
119
119
  /** Package specifiers to exclude from extension forwarding for worker agents (exact match). @since TP-180 */
120
120
  excludeExtensions?: string[];
121
+ /**
122
+ * How long (seconds) the lane waits for a supervisor reply when it intercepts a
123
+ * worker's premature exit, before letting the session close. Default 60. Raise
124
+ * it when the supervisor is often inside long tool calls (a blocking
125
+ * `--wait` mid-tool-call cannot answer in 60s — penster feedback #3, item 5).
126
+ * Bounded to 15..1800.
127
+ */
128
+ exitInterceptTimeoutSec?: number;
121
129
  }
122
130
 
123
131
  /** Reviewer agent configuration */
132
+ /**
133
+ * Revision-spiral detection tuning (review-boundary supervisor notifications).
134
+ * When a step is reviewed repeatedly without converging, the supervisor is
135
+ * escalated so it can adjudicate / steer the worker. Detection is notify-based,
136
+ * not a hard cap on revisions.
137
+ */
138
+ export interface ReviewSpiralConfig {
139
+ /** Master switch for spiral escalation (per-boundary notifications are always on). */
140
+ enabled: boolean;
141
+ /** Consecutive non-APPROVE reviews on the SAME step before the first escalation. */
142
+ threshold: number;
143
+ /** Minimum further non-APPROVE reviews between re-escalations (anti-spam spacing). */
144
+ cooldownReviews: number;
145
+ /** Whether UNAVAILABLE reviews count toward the spiral (default false: broken-reviewer signal, not a spiral). */
146
+ treatUnavailableAsNonApprove: boolean;
147
+ }
148
+
124
149
  export interface ReviewerConfig {
125
150
  /** Reviewer model (empty = inherit session model) */
126
151
  model: string;
@@ -130,6 +155,15 @@ export interface ReviewerConfig {
130
155
  thinking: string;
131
156
  /** Package specifiers to exclude from extension forwarding for reviewer agents (exact match). @since TP-180 */
132
157
  excludeExtensions?: string[];
158
+ /**
159
+ * Ordered severity vocabulary (highest severity first) used to bucket review
160
+ * findings for spiral-vs-converging analysis. Core default is generic
161
+ * (critical/important/minor); projects whose reviewer emits a different scheme
162
+ * (e.g. P0/P1/P2) override this. Never hardcode a project vocabulary in core.
163
+ */
164
+ severityLabels: string[];
165
+ /** Revision-spiral detection tuning. */
166
+ spiral: ReviewSpiralConfig;
133
167
  }
134
168
 
135
169
  /** Context/resource limits for task execution */
@@ -598,7 +632,19 @@ export const DEFAULT_TASK_RUNNER_SECTION: TaskRunnerSection = {
598
632
  // bridge tools are appended at the lane-runner spawn site by
599
633
  // `buildWorkerToolsAllowlist()`, not here.
600
634
  worker: { model: "", tools: DEFAULT_WORKER_USER_TOOLS, thinking: "", excludeExtensions: [] },
601
- reviewer: { model: "", tools: "read,bash,grep,find,ls", thinking: "on", excludeExtensions: [] },
635
+ reviewer: {
636
+ model: "",
637
+ tools: "read,bash,grep,find,ls",
638
+ thinking: "on",
639
+ excludeExtensions: [],
640
+ severityLabels: ["critical", "important", "minor"],
641
+ spiral: {
642
+ enabled: true,
643
+ threshold: 3,
644
+ cooldownReviews: 2,
645
+ treatUnavailableAsNonApprove: false,
646
+ },
647
+ },
602
648
  context: {
603
649
  workerContextWindow: 0,
604
650
  warnPercent: 85,
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * @module orch/diagnostic-reports
12
12
  */
13
- import { existsSync, mkdirSync, writeFileSync } from "fs";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
14
14
  import { join } from "path";
15
15
 
16
16
  import { execLog } from "./execution.ts";
@@ -92,6 +92,12 @@ export interface DiagnosticReportInput {
92
92
  totalTasks: number;
93
93
  /** State root path where `.pi/` lives */
94
94
  stateRoot: string;
95
+ /**
96
+ * #629: per-task cost (USD) from outcome telemetry for the CURRENT pass.
97
+ * `diagnostics.taskExits` is not populated by the v2 runtime, so without
98
+ * this every report read $0.00. Optional for backward compatibility.
99
+ */
100
+ taskCostUsd?: Record<string, number>;
95
101
  }
96
102
 
97
103
  // ── Diagnostics Directory ────────────────────────────────────────────
@@ -140,8 +146,8 @@ export function buildDiagnosticEvents(input: DiagnosticReportInput): DiagnosticE
140
146
  classification = task.exitDiagnostic.classification;
141
147
  }
142
148
 
143
- // Cost: from taskExits, else 0
144
- const cost = exitSummary?.cost ?? 0;
149
+ // Cost: from taskExits, else this pass's outcome telemetry, else 0
150
+ const cost = exitSummary?.cost ?? input.taskCostUsd?.[task.taskId] ?? 0;
145
151
 
146
152
  // Duration: from taskExits, else compute from timestamps, else 0
147
153
  let durationSec = 0;
@@ -177,6 +183,79 @@ export function buildDiagnosticEvents(input: DiagnosticReportInput): DiagnosticE
177
183
 
178
184
  // ── JSONL Generation ─────────────────────────────────────────────────
179
185
 
186
+ // ── Cross-pass evidence preservation (#629 side-effect 2) ──
187
+
188
+ /**
189
+ * Does this event carry any execution evidence? A task that was not executed
190
+ * in the current pass (e.g. a no-op resume) produces an evidence-empty event
191
+ * that would otherwise CLOBBER the prior pass's record when the report is
192
+ * rewritten (observed: a $55 / 1h42m run reported as $0 / 0s after two
193
+ * no-op resumes).
194
+ */
195
+ export function hasExecutionEvidence(evt: DiagnosticEvent): boolean {
196
+ return (
197
+ (evt.classification !== "unknown" && evt.classification !== "") ||
198
+ evt.cost > 0 ||
199
+ evt.durationSec > 0 ||
200
+ evt.retries > 0 ||
201
+ evt.startedAt !== null ||
202
+ evt.endedAt !== null
203
+ );
204
+ }
205
+
206
+ /**
207
+ * Merge the previous report's events into the current pass's events.
208
+ *
209
+ * Rule: CURRENT STATE always comes from the new pass (`status`, `phase`,
210
+ * `mode`, `batchId`) — a legitimate retry or skip must be reflected. Execution
211
+ * EVIDENCE is merged FIELD-WISE: each evidence field takes the new pass's
212
+ * value when it is present/non-zero, else the previous value. This matters
213
+ * because resume's reconciliation synthesizes outcomes for tasks it did NOT
214
+ * execute that still carry the persisted classification and a fresh
215
+ * `endTime` — an all-or-nothing rule would treat those as "evidence" and
216
+ * erase the prior cost. Cost is never summed across passes (each pass's
217
+ * telemetry is that attempt's cost; no double counting). Tasks present only
218
+ * in the previous report are dropped (the new wave plan is authoritative for
219
+ * membership). Pure; deterministic order follows `next`.
220
+ */
221
+ export function mergeDiagnosticEvents(
222
+ previous: DiagnosticEvent[],
223
+ next: DiagnosticEvent[],
224
+ ): DiagnosticEvent[] {
225
+ const prevByTask = new Map<string, DiagnosticEvent>();
226
+ for (const p of previous) prevByTask.set(p.taskId, p);
227
+ return next.map((n) => {
228
+ const p = prevByTask.get(n.taskId);
229
+ if (!p) return n;
230
+ const knownClass = n.classification && n.classification !== "unknown";
231
+ return {
232
+ ...n,
233
+ classification: knownClass ? n.classification : p.classification,
234
+ cost: n.cost > 0 ? n.cost : p.cost,
235
+ durationSec: n.durationSec > 0 ? n.durationSec : p.durationSec,
236
+ retries: n.retries > 0 ? n.retries : p.retries,
237
+ exitReason: n.exitReason || p.exitReason,
238
+ startedAt: n.startedAt ?? p.startedAt,
239
+ endedAt: n.endedAt ?? p.endedAt,
240
+ };
241
+ });
242
+ }
243
+
244
+ /** Parse a previously written events JSONL file (tolerant: bad lines skipped). */
245
+ export function parseEventsJsonl(content: string): DiagnosticEvent[] {
246
+ const out: DiagnosticEvent[] = [];
247
+ for (const line of content.split(/\r?\n/)) {
248
+ if (!line.trim()) continue;
249
+ try {
250
+ const obj = JSON.parse(line) as Partial<DiagnosticEvent>;
251
+ if (typeof obj.taskId === "string") out.push(obj as DiagnosticEvent);
252
+ } catch {
253
+ /* skip malformed */
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+
180
259
  /**
181
260
  * Serialize diagnostic events to JSONL format (one JSON object per line).
182
261
  */
@@ -222,7 +301,12 @@ export function buildMarkdownReport(
222
301
  const { succeededTasks, failedTasks, skippedTasks, blockedTasks, totalTasks } = input;
223
302
 
224
303
  const batchDurationSec = endedAt ? Math.round((endedAt - startedAt) / 1000) : 0;
225
- const batchCost = diagnostics.batchCost ?? 0;
304
+ // #629: header cost from batch diagnostics when populated, else the sum of
305
+ // per-task evidence (which survives no-op passes via mergeDiagnosticEvents).
306
+ const batchCost =
307
+ diagnostics.batchCost && diagnostics.batchCost > 0
308
+ ? diagnostics.batchCost
309
+ : events.reduce((sum, e) => sum + (Number.isFinite(e.cost) ? e.cost : 0), 0);
226
310
 
227
311
  const lines: string[] = [];
228
312
 
@@ -337,10 +421,21 @@ export function emitDiagnosticReports(input: DiagnosticReportInput): void {
337
421
  const opId = resolveOperatorId(input.orchConfig);
338
422
  const dir = ensureDiagnosticsDir(input.stateRoot);
339
423
 
340
- const events = buildDiagnosticEvents(input);
424
+ const jsonlPath = join(dir, `${opId}-${input.batchId}-events.jsonl`);
425
+
426
+ // #629: preserve prior-pass execution evidence for tasks this pass did
427
+ // not execute (state fields still come from this pass).
428
+ let events = buildDiagnosticEvents(input);
429
+ if (existsSync(jsonlPath)) {
430
+ try {
431
+ const previous = parseEventsJsonl(readFileSync(jsonlPath, "utf-8"));
432
+ events = mergeDiagnosticEvents(previous, events);
433
+ } catch {
434
+ /* unreadable prior report — proceed with this pass's events */
435
+ }
436
+ }
341
437
 
342
438
  // ── JSONL event log ──
343
- const jsonlPath = join(dir, `${opId}-${input.batchId}-events.jsonl`);
344
439
  const jsonlContent = eventsToJsonl(events);
345
440
  writeFileSync(jsonlPath, jsonlContent, "utf-8");
346
441
 
@@ -471,5 +566,13 @@ export function assembleDiagnosticInput(
471
566
  blockedTasks: batchState.blockedTasks,
472
567
  totalTasks: batchState.totalTasks,
473
568
  stateRoot,
569
+ taskCostUsd: (() => {
570
+ const costs: Record<string, number> = {};
571
+ for (const [taskId, outcome] of outcomeByTaskId) {
572
+ const c = outcome.telemetry?.costUsd;
573
+ if (typeof c === "number" && Number.isFinite(c) && c > 0) costs[taskId] = c;
574
+ }
575
+ return costs;
576
+ })(),
474
577
  };
475
578
  }
@@ -53,6 +53,7 @@ export interface SessionTokenCounts {
53
53
  * | `stall_timeout` | No STATUS.md progress for stall_timeout minutes |
54
54
  * | `user_killed` | User manually killed the session (e.g., forced process kill) |
55
55
  * | `spawn_failure` | Worker process never spawned (e.g., Pi CLI not findable, worktree provisioning) |
56
+ * | `review_gate_refusal`| Governance refusal: finalize blocked by an outstanding REVISE/RETHINK review verdict (#626/#629). The worker exited cleanly — NOT a crash, never auto-retried (the review file must change first) |
56
57
  * | `unknown` | Could not determine cause |
57
58
  *
58
59
  * Note: `spawn_failure` (TP-190, #561) is set BEFORE any agent process exists —
@@ -72,6 +73,7 @@ export type ExitClassification =
72
73
  | "stall_timeout"
73
74
  | "user_killed"
74
75
  | "spawn_failure"
76
+ | "review_gate_refusal"
75
77
  | "unknown";
76
78
 
77
79
  /**
@@ -88,6 +90,7 @@ export const EXIT_CLASSIFICATIONS: readonly ExitClassification[] = [
88
90
  "stall_timeout",
89
91
  "user_killed",
90
92
  "spawn_failure",
93
+ "review_gate_refusal",
91
94
  "unknown",
92
95
  ] as const;
93
96