@dieulc/workflow 0.2.0 → 0.3.1

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,931 @@
1
+ /**
2
+ * Workflow Extension — Review Mode runtime
3
+ *
4
+ * The orchestrator that ties the pure pieces (review.ts), the tools
5
+ * (review-tools.ts) and the parent extension together.
6
+ *
7
+ * Flow for one Plan↔Review round:
8
+ *
9
+ * ensureStarted() create the reviewer session (idempotent)
10
+ * ├─ gate.reset() fresh pass-1 slots
11
+ * ├─ prompt(PASS_1_PROMPT) review criteria + plan + context + guidance
12
+ * ├─ await submit + passDone (settle race; optional timeoutMs cap)
13
+ * ├─ gate.reset()
14
+ * ├─ prompt(PASS_2_PROMPT) only when verify is on AND pass 1 was material
15
+ * └─ await passDone
16
+ *
17
+ * Every failure path returns `{ ok: false, fallback: true }` so the caller can
18
+ * hand the author's original plan to the user: a broken reviewer must never
19
+ * block the plan (config.fallbackOnError = "skip").
20
+ *
21
+ * Decisions are made here in TypeScript, not by asking the model to follow
22
+ * instructions — the reviewer only supplies judgement.
23
+ */
24
+
25
+ import {
26
+ DEFAULT_REVIEW_MODE_CONFIG,
27
+ createReviewRoundGate,
28
+ createReviewSession,
29
+ createReviewState,
30
+ planHash,
31
+ reviewNeedsVerification,
32
+ type CreateReviewSessionOptions,
33
+ type CreateReviewSessionResult,
34
+ type ReviewChildSession,
35
+ type ReviewModeConfig,
36
+ type ReviewPassResult,
37
+ type ReviewPhase,
38
+ type ReviewRoundGate,
39
+ type ReviewRoundResult,
40
+ type ReviewSdkLoader,
41
+ type ReviewState,
42
+ type ReviewSubmitPayloadLike,
43
+ type ReviewTranscriptOp,
44
+ type ReviewVerdict,
45
+ ReviewTranscript as Transcript,
46
+ } from "./review.ts";
47
+ import { createReviewTools } from "./review-tools.ts";
48
+ import { renderReviewContextBlock, type ReviewFinding } from "./utils.ts";
49
+
50
+ /** Event `type` from a child-session event, or "" for junk. Never throws. */
51
+ function readEventType(event: unknown): string {
52
+ const type = (event as { type?: unknown } | undefined)?.type;
53
+ return typeof type === "string" ? type : "";
54
+ }
55
+
56
+ /**
57
+ * Bound for the pre-pass "wait for the previous run to settle" step in
58
+ * unlimited mode. Without a deadline this is the only thing stopping a hung
59
+ * earlier run from blocking pass 1 before the settle race is even armed.
60
+ */
61
+ const SETTLE_IN_FLIGHT_FALLBACK_MS = 60_000;
62
+
63
+ /** What the runtime needs from the parent extension. */
64
+ export interface ReviewRuntimeDeps {
65
+ cwd: string;
66
+ agentDir: string;
67
+ config: () => ReviewModeConfig | undefined;
68
+ /** Resolve the reviewer role's model (falls back to the planner when unset). */
69
+ reviewerModel: () =>
70
+ | { provider: string; id: string; thinking: string }
71
+ | undefined;
72
+ /** Resolve a model from the parent's registry. */
73
+ findModel: (provider: string, id: string) => unknown;
74
+ /** Plan path Plan Mode chose (absolute or cwd-relative), if known. */
75
+ planPath: () => string | undefined;
76
+ /** Current plan text on disk, if it exists. */
77
+ readPlan: () => string | undefined;
78
+ /** Pruned parent context entries (from ctx.sessionManager.getBranch()). */
79
+ contextEntries: () => unknown[];
80
+ /**
81
+ * Resolved reviewer guidance (review-prompt.md, else the built-in default).
82
+ * A function, not a string: the user can edit the file between rounds.
83
+ */
84
+ promptText: () => string;
85
+ /** Read-only reconnaissance runner (explorer role) reused from `explore`. */
86
+ runExplore: (task: string, signal?: AbortSignal) => Promise<string>;
87
+ /** Persist the reviewed plan. */
88
+ writePlan: (input: {
89
+ planPath: string;
90
+ planText: string;
91
+ planBody: string;
92
+ verdict: ReviewVerdict;
93
+ summary: string;
94
+ findings: ReviewFinding[];
95
+ round: number;
96
+ modelLabel: string;
97
+ }) => { ok: boolean; path?: string; error?: string };
98
+ log: (message: string, level?: "info" | "warning" | "error") => void;
99
+ notify: (message: string, level?: "info" | "warning" | "error") => void;
100
+ /** Fired on every state transition (drives the status strip). */
101
+ onState?: (state: ReviewState | undefined) => void;
102
+ /**
103
+ * Fired for every transcript op the reviewer produces, so the parent can
104
+ * stream the review into the main Pi transcript. Best-effort: a throwing
105
+ * callback must never affect the review.
106
+ */
107
+ onTranscriptOps?: (ops: ReviewTranscriptOp[]) => void;
108
+ /** Injected in tests. */
109
+ loadSdk?: ReviewSdkLoader;
110
+ }
111
+
112
+ export interface RunRoundInput {
113
+ planText: string;
114
+ planPath: string;
115
+ round: number;
116
+ /** Findings from the previous round, so pass 2 can assert they were fixed. */
117
+ previousFindings?: ReviewFinding[];
118
+ }
119
+
120
+ export interface ReviewRuntime {
121
+ /** Idempotent: creates the reviewer session if it is not alive. */
122
+ ensureStarted(): Promise<boolean>;
123
+ /** Run one full review round. Never throws. */
124
+ runRound(input: RunRoundInput): Promise<ReviewRoundResult>;
125
+ /** Current state snapshot. */
126
+ getState(): ReviewState | undefined;
127
+ /** Reviewer transcript lines (feeds the main-transcript stream). */
128
+ getTranscript(): string[];
129
+ isAlive(): boolean;
130
+ /** True while `runRound` is in flight — used to route prompt input. */
131
+ isRoundActive(): boolean;
132
+ /** Cancel the in-flight review but keep the session. */
133
+ abortRound(): Promise<void>;
134
+ /**
135
+ * Deliver a message the user typed while Review Mode was the active mode.
136
+ *
137
+ * Returns false when it could not be delivered, so the caller can fall back to
138
+ * the plan agent rather than swallowing the message into the void.
139
+ */
140
+ sendUserMessage(text: string, images?: unknown[]): Promise<boolean>;
141
+ /** Abort and release everything (approve / reload / shutdown). */
142
+ teardown(): Promise<void>;
143
+ }
144
+
145
+ /** Prompt for pass 1: review and rewrite. */
146
+ export function buildPass1Prompt(input: {
147
+ planPath: string;
148
+ planText: string;
149
+ reviewPrompt: string;
150
+ contextBlock: string;
151
+ previousFindings?: ReviewFinding[];
152
+ round: number;
153
+ totalRounds: number;
154
+ verify: boolean;
155
+ }): string {
156
+ const parts: string[] = [
157
+ `[REVIEW MODE — PASS 1 of ${input.verify ? 2 : 1} — round ${input.round}/${input.totalRounds}]`,
158
+ "",
159
+ "Audit the plan below against this repository, then rewrite it and submit the",
160
+ "result with `review_submit_plan`, then call `review_pass_done`.",
161
+ "",
162
+ "## Your review guidance (authoritative)",
163
+ "",
164
+ input.reviewPrompt,
165
+ ];
166
+
167
+ if (input.contextBlock.trim()) {
168
+ parts.push(
169
+ "",
170
+ "## Shared context from Plan Mode",
171
+ "",
172
+ "This is what the author explored and was asked for. It is background, not",
173
+ "ground truth — verify anything you rely on against the repository.",
174
+ "",
175
+ input.contextBlock.trim(),
176
+ );
177
+ }
178
+
179
+ parts.push(
180
+ "",
181
+ "## Plan under review",
182
+ "",
183
+ `File: ${input.planPath}`,
184
+ "",
185
+ "```markdown",
186
+ input.planText,
187
+ "```",
188
+ );
189
+
190
+ if (input.previousFindings && input.previousFindings.length > 0) {
191
+ parts.push(
192
+ "",
193
+ "## Findings from the previous round",
194
+ "",
195
+ "The author has revised the plan in response. Confirm whether each was",
196
+ "actually addressed, and say so in your findings rather than re-raising it.",
197
+ "",
198
+ ...input.previousFindings.map(
199
+ (f) =>
200
+ `- [${f.disposition}] ${f.summary}${
201
+ f.file ? ` (${f.file}${f.lineRange ? `:${f.lineRange}` : ""})` : ""
202
+ }`,
203
+ ),
204
+ );
205
+ }
206
+
207
+ parts.push(
208
+ "",
209
+ "## Required output",
210
+ "",
211
+ "1. `review_submit_plan` with the COMPLETE revised plan (readable markdown,",
212
+ " original structure preserved, no `Review changes` section — it is generated).",
213
+ "2. `review_pass_done` with your verdict and the same findings.",
214
+ "",
215
+ "You may run read-only and test/lint/typecheck commands, but you cannot build or",
216
+ "write any file other than the plan. Say what you could not verify instead of",
217
+ "assuming it passes.",
218
+ );
219
+
220
+ return parts.join("\n");
221
+ }
222
+
223
+ /** Prompt for pass 2: verify the reviewer's own rewrite. */
224
+ export function buildPass2Prompt(input: {
225
+ planPath: string;
226
+ originalPlan: string;
227
+ rewrittenPlan: string;
228
+ findings: ReviewFinding[];
229
+ }): string {
230
+ return [
231
+ "[REVIEW MODE — PASS 2 of 2: VERIFY YOUR OWN REWRITE]",
232
+ "",
233
+ "You just rewrote the plan and submitted it. Now check your own work. This pass",
234
+ "exists because a rewrite can silently drop a requirement the author cared about.",
235
+ "",
236
+ "## Original plan (as the author wrote it)",
237
+ "",
238
+ "```markdown",
239
+ input.originalPlan,
240
+ "```",
241
+ "",
242
+ "## Your rewrite (currently on disk)",
243
+ "",
244
+ "```markdown",
245
+ input.rewrittenPlan,
246
+ "```",
247
+ "",
248
+ "## Findings you reported",
249
+ "",
250
+ ...(input.findings.length
251
+ ? input.findings.map(
252
+ (f) =>
253
+ `- [${f.disposition}] (severity ${f.severity}) ${f.summary}${f.rationale ? ` — ${f.rationale}` : ""}`,
254
+ )
255
+ : ["- (none reported)"]),
256
+ "",
257
+ "## Check, in order",
258
+ "",
259
+ "1. Is every finding you marked `accepted` actually reflected in the rewrite?",
260
+ "2. Did you drop or weaken any requirement, step, invariant, or verification",
261
+ " item that was present in the original? List anything lost — this is the most",
262
+ " important check.",
263
+ "3. Does the rewrite still describe a plan that can be executed without guessing?",
264
+ "",
265
+ "## Required output",
266
+ "",
267
+ "If the rewrite is correct, resubmit it unchanged with an empty findings array and",
268
+ "verdict `approve`. If you lost something or a finding is not addressed, fix it and",
269
+ "resubmit with the corrected plan. Then call `review_pass_done` with pass 2.",
270
+ ].join("\n");
271
+ }
272
+
273
+ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
274
+ let session: ReviewChildSession | undefined;
275
+ let state: ReviewState | undefined;
276
+ let transcript: Transcript | undefined;
277
+ let gate: ReviewRoundGate = createReviewRoundGate();
278
+ let starting: Promise<boolean> | undefined;
279
+ let abortController: AbortController | undefined;
280
+ /** True while `runRound` is in flight (gates prompt routing). */
281
+ let roundActive = false;
282
+
283
+ const config = (): ReviewModeConfig => {
284
+ try {
285
+ return deps.config() ?? (DEFAULT_REVIEW_MODE_CONFIG as ReviewModeConfig);
286
+ } catch {
287
+ return DEFAULT_REVIEW_MODE_CONFIG as ReviewModeConfig;
288
+ }
289
+ };
290
+
291
+ const setPhase = (phase: ReviewPhase): void => {
292
+ if (state) state.phase = phase;
293
+ try {
294
+ deps.onState?.(state);
295
+ } catch {
296
+ /* status updates are best-effort */
297
+ }
298
+ };
299
+
300
+ const ensureTranscript = (): Transcript => {
301
+ if (!transcript) transcript = new Transcript();
302
+ return transcript;
303
+ };
304
+
305
+ /**
306
+ * Completed reviewer runs (`agent_settled`). A pass is over when either its
307
+ * `review_pass_done` or the end of its run arrives — see `runPass()`.
308
+ */
309
+ let settleCount = 0;
310
+ let settleWaiters: Array<() => void> = [];
311
+
312
+ /**
313
+ * Mark that a reviewer run has fully settled.
314
+ *
315
+ * `agent_settled` — not `agent_end` — is the reliable "this pass is over"
316
+ * signal: it is emitted after every post-run continuation (provider retry,
317
+ * auto-compaction), so a pass is never judged finished while Pi is still
318
+ * working on it.
319
+ */
320
+ function noteSettled(): void {
321
+ settleCount += 1;
322
+ for (const resolve of settleWaiters.splice(0)) {
323
+ try {
324
+ resolve();
325
+ } catch {
326
+ /* a waiter must never break the settle path */
327
+ }
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Resolve `true` when a run settles after `baseline`; `false` on timeout or
333
+ * abort. Edge-tolerant, like `ReviewRoundGate`: a settle that already
334
+ * happened resolves immediately.
335
+ */
336
+ function waitForSettle(
337
+ baseline: number,
338
+ timeoutMs?: number,
339
+ signal?: AbortSignal,
340
+ ): Promise<boolean> {
341
+ if (settleCount > baseline) return Promise.resolve(true);
342
+ if (signal?.aborted) return Promise.resolve(false);
343
+ return new Promise<boolean>((resolve) => {
344
+ let settled = false;
345
+ let timer: ReturnType<typeof setTimeout> | undefined;
346
+ const waiter = () => finish(true);
347
+ const remove = () => {
348
+ const i = settleWaiters.indexOf(waiter);
349
+ if (i >= 0) settleWaiters.splice(i, 1);
350
+ };
351
+ const onAbort = () => finish(false);
352
+ const finish = (value: boolean): void => {
353
+ if (settled) return;
354
+ settled = true;
355
+ remove();
356
+ if (timer !== undefined) clearTimeout(timer);
357
+ signal?.removeEventListener("abort", onAbort);
358
+ resolve(value);
359
+ };
360
+ if (
361
+ typeof timeoutMs === "number" &&
362
+ Number.isFinite(timeoutMs) &&
363
+ timeoutMs > 0
364
+ ) {
365
+ timer = setTimeout(() => finish(false), Math.trunc(timeoutMs));
366
+ }
367
+ signal?.addEventListener("abort", onAbort, { once: true });
368
+ settleWaiters.push(waiter);
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Wait for any in-flight reviewer run to end before starting a pass, so the
374
+ * settle the pass then races belongs to THAT pass rather than the previous
375
+ * one (a reviewer that calls `review_pass_done` keeps streaming until its
376
+ * turn ends).
377
+ */
378
+ async function settleInFlight(
379
+ deadline: number | undefined,
380
+ rc: AbortController,
381
+ ): Promise<void> {
382
+ if (!session || !session.isStreaming()) return;
383
+ // Unlimited mode still needs a bound here: a hung previous run must not
384
+ // block pass 1 forever before the settle race is even armed.
385
+ const waitMs =
386
+ deadline === undefined
387
+ ? SETTLE_IN_FLIGHT_FALLBACK_MS
388
+ : Math.max(1, deadline - Date.now());
389
+ await waitForSettle(settleCount, waitMs, rc.signal);
390
+ }
391
+
392
+ /** Abort the reviewer's in-flight run. Best-effort; never throws. */
393
+ async function abortChild(): Promise<void> {
394
+ try {
395
+ await session?.abort();
396
+ } catch {
397
+ /* cancellation is best-effort */
398
+ }
399
+ }
400
+
401
+ function errorText(e: unknown): string {
402
+ return e instanceof Error ? e.message : String(e);
403
+ }
404
+
405
+ /** Why a pass stopped waiting. */
406
+ type PassStop = "passDone" | "settled" | "timeout" | "aborted" | "error";
407
+
408
+ interface PassOutcome {
409
+ submitted: ReviewSubmitPayloadLike | undefined;
410
+ passDone: ReviewPassResult | undefined;
411
+ stop: PassStop;
412
+ /** Set only when `stop === "error"`. */
413
+ error?: unknown;
414
+ }
415
+
416
+ /**
417
+ * Drive one pass to a terminal state.
418
+ *
419
+ * The terminal state is whichever comes first of:
420
+ * - `review_pass_done` (the reviewer completed the protocol), or
421
+ * - the reviewer's run settling (`agent_settled`), or
422
+ * - the round deadline, or
423
+ * - the caller aborting,
424
+ * - a prompt/follow-up failure such as a rejected preflight.
425
+ *
426
+ * Racing the settle is what makes a silent reviewer fail in seconds instead
427
+ * of waiting out `timeoutMs`: `session.prompt()` resolves when the run ends,
428
+ * so a `review_pass_done` that has not arrived by then never will. The send
429
+ * itself is never awaited unguarded — the deadline covers the run too.
430
+ */
431
+ async function runPass(
432
+ pass: 1 | 2,
433
+ deadline: number | undefined,
434
+ rc: AbortController,
435
+ send: () => Promise<void>,
436
+ ): Promise<PassOutcome> {
437
+ const baseline = settleCount;
438
+ // undefined ⇒ no cap (default): the pass ends on passDone/settle/abort/error.
439
+ const remaining = (): number | undefined =>
440
+ deadline === undefined ? undefined : Math.max(1, deadline - Date.now());
441
+ let sendError: unknown;
442
+
443
+ const stop = await new Promise<PassStop>((resolve) => {
444
+ let done = false;
445
+ let timer: ReturnType<typeof setTimeout> | undefined;
446
+ const onAbort = () => finish("aborted");
447
+ const finish = (s: PassStop): void => {
448
+ if (done) return;
449
+ done = true;
450
+ if (timer !== undefined) clearTimeout(timer);
451
+ rc.signal.removeEventListener("abort", onAbort);
452
+ resolve(s);
453
+ };
454
+ if (rc.signal.aborted) {
455
+ finish("aborted");
456
+ return;
457
+ }
458
+ const cap = remaining();
459
+ if (cap !== undefined) timer = setTimeout(() => finish("timeout"), cap);
460
+ rc.signal.addEventListener("abort", onAbort, { once: true });
461
+ void gate.waitForPassDone(remaining(), rc.signal).then((result) => {
462
+ if (result) finish("passDone");
463
+ });
464
+ void waitForSettle(baseline, remaining(), rc.signal).then((did) => {
465
+ if (did) finish("settled");
466
+ });
467
+ void send().catch((e: unknown) => {
468
+ sendError = e;
469
+ finish("error");
470
+ });
471
+ });
472
+
473
+ const submitted = gate.lastSubmitted();
474
+ const passDone = gate.lastPassDone();
475
+ if (stop === "timeout") {
476
+ const capMs = Math.max(1000, config().timeoutMs);
477
+ deps.log(
478
+ `Review Mode pass ${pass} hit its ${Math.round(
479
+ capMs / 1000,
480
+ )}s limit — continuing with ${
481
+ submitted ? "the submitted plan" : "the author's plan"
482
+ }.`,
483
+ "warning",
484
+ );
485
+ }
486
+ return {
487
+ submitted,
488
+ passDone,
489
+ stop,
490
+ ...(sendError === undefined ? {} : { error: sendError }),
491
+ };
492
+ }
493
+
494
+ async function ensureStarted(): Promise<boolean> {
495
+ if (session) return true;
496
+ if (starting) return starting;
497
+ starting = (async () => {
498
+ const cfg = config();
499
+ const modelRef = deps.reviewerModel();
500
+ if (!modelRef) {
501
+ deps.log(
502
+ "Review Mode: no reviewer model configured — run /role set reviewer <provider/model>",
503
+ "warning",
504
+ );
505
+ return false;
506
+ }
507
+ try {
508
+ // The SDK must be loaded BEFORE building the tools: review_bash is
509
+ // constructed from Pi's own `createBashToolDefinition`, so passing an
510
+ // unresolved SDK here would silently drop the reviewer's shell access.
511
+ const sdk = await (
512
+ deps.loadSdk ?? (() => import("@earendil-works/pi-coding-agent"))
513
+ )();
514
+
515
+ const built = createReviewTools(sdk, {
516
+ cwd: deps.cwd,
517
+ log: deps.log,
518
+ explore: {
519
+ budget: () => config().exploreBudget,
520
+ run: (task, signal) => deps.runExplore(task, signal),
521
+ onUsed: (used) => {
522
+ if (state) state.exploreUsed = used;
523
+ },
524
+ log: deps.log,
525
+ },
526
+ submit: {
527
+ planPath: () => deps.planPath(),
528
+ originalPlan: () => deps.readPlan(),
529
+ modelLabel: () => session?.modelLabel ?? "reviewer",
530
+ submit: async (payload) => {
531
+ const planPath = deps.planPath();
532
+ if (!planPath) return { ok: false, error: "no plan path" };
533
+ const written = deps.writePlan({
534
+ planPath,
535
+ planText: payload.planText,
536
+ planBody: payload.planBody,
537
+ verdict: payload.verdict,
538
+ summary: payload.summary,
539
+ findings: payload.findings,
540
+ round: state?.round ?? 1,
541
+ modelLabel: session?.modelLabel ?? "reviewer",
542
+ });
543
+ if (!written.ok) return { ok: false, error: written.error };
544
+ if (state) {
545
+ state.rewrittenPlan = payload.planText;
546
+ state.findings = payload.findings;
547
+ state.verdict = payload.verdict;
548
+ }
549
+ gate.notifySubmitted(payload);
550
+ return { ok: true, path: written.path };
551
+ },
552
+ log: deps.log,
553
+ },
554
+ passDone: {
555
+ hasSubmitted: () => gate.lastSubmitted() !== undefined,
556
+ onPassDone: (result) => {
557
+ if (state) {
558
+ state.passes.push(result);
559
+ state.pass = result.pass;
560
+ state.verdict = result.verdict;
561
+ // Findings from the authoritative pass replace earlier guesses.
562
+ if (result.findings.length > 0) {
563
+ state.findings = result.findings;
564
+ }
565
+ }
566
+ gate.notifyPassDone(result);
567
+ },
568
+ willVerify: () => {
569
+ const cfg2 = config();
570
+ return (
571
+ cfg2.verify &&
572
+ cfg2.passes > 1 &&
573
+ state?.pass === 1 &&
574
+ reviewNeedsVerification(state?.findings ?? [])
575
+ );
576
+ },
577
+ log: deps.log,
578
+ },
579
+ });
580
+ if (built.unavailable.length > 0) {
581
+ deps.log(
582
+ `Review Mode: some reviewer tools are unavailable — ${built.unavailable.join(", ")}`,
583
+ "warning",
584
+ );
585
+ }
586
+
587
+ const createOptions: CreateReviewSessionOptions = {
588
+ cwd: deps.cwd,
589
+ agentDir: deps.agentDir,
590
+ modelRef,
591
+ findModel: deps.findModel,
592
+ reviewPrompt: deps.promptText(),
593
+ customTools: built.tools,
594
+ // Reuse the already-resolved module instead of importing twice.
595
+ loadSdk: async () => sdk,
596
+ };
597
+
598
+ const created: CreateReviewSessionResult =
599
+ await createReviewSession(createOptions);
600
+
601
+ if (!created.ok || !created.session) {
602
+ deps.log(
603
+ `Review Mode unavailable: ${created.error ?? "unknown error"} — the plan will be handed over unreviewed.`,
604
+ cfg.fallbackOnError === "block" ? "error" : "warning",
605
+ );
606
+ return false;
607
+ }
608
+
609
+ // One line per session, so "the reviewer has no tools" is visible
610
+ // immediately instead of only after a failed pass (see /review-status).
611
+ if (created.activeTools) {
612
+ deps.log(
613
+ `Review Mode: reviewer tools active — ${created.activeTools.join(", ")}`,
614
+ "info",
615
+ );
616
+ }
617
+
618
+ session = created.session;
619
+ ensureTranscript();
620
+ const child = created.session;
621
+ session.subscribe((event) => {
622
+ let ops: ReviewTranscriptOp[] = [];
623
+ try {
624
+ ops = ensureTranscript().ingest(event);
625
+ } catch {
626
+ /* a malformed event must not break the review */
627
+ }
628
+ if (ops.length > 0) {
629
+ try {
630
+ deps.onTranscriptOps?.(ops);
631
+ } catch {
632
+ /* streaming to the main transcript is display-only: never fatal */
633
+ }
634
+ }
635
+ const type = readEventType(event);
636
+ if (type === "agent_settled") {
637
+ noteSettled();
638
+ } else if (type === "agent_end") {
639
+ // Fallback for a runtime without `agent_settled`: a retry or a
640
+ // compaction continuation continues after agent_end, so only count
641
+ // the event once the session is really idle — and only for the
642
+ // session that is still current.
643
+ setTimeout(() => {
644
+ if (session === child && !child.isStreaming()) noteSettled();
645
+ }, 0);
646
+ }
647
+ });
648
+ return true;
649
+ } catch (e: unknown) {
650
+ deps.log(
651
+ `Review Mode failed to start: ${e instanceof Error ? e.message : String(e)}`,
652
+ "warning",
653
+ );
654
+ return false;
655
+ } finally {
656
+ starting = undefined;
657
+ }
658
+ })();
659
+ return starting;
660
+ }
661
+
662
+ async function runRound(input: RunRoundInput): Promise<ReviewRoundResult> {
663
+ const cfg = config();
664
+ const hash = planHash(input.planText);
665
+ const started = Date.now();
666
+ const originalPlan = input.planText;
667
+
668
+ const fail = (
669
+ reason: string,
670
+ planText = originalPlan,
671
+ ): ReviewRoundResult => {
672
+ if (state) {
673
+ state.phase = "failed";
674
+ state.error = reason;
675
+ state.finishedAt = Date.now();
676
+ }
677
+ try {
678
+ deps.onState?.(state);
679
+ } catch {
680
+ /* ignore */
681
+ }
682
+ return {
683
+ ok: false,
684
+ fallback: true,
685
+ planText,
686
+ planHash: hash,
687
+ ...(state
688
+ ? { state }
689
+ : {
690
+ state: createReviewState({
691
+ planHash: hash,
692
+ modelLabel: "unavailable",
693
+ }),
694
+ }),
695
+ reason,
696
+ };
697
+ };
698
+
699
+ try {
700
+ roundActive = true;
701
+ // A review with nowhere to submit can only fail: do not spend a run on it.
702
+ if (!input.planPath) {
703
+ return fail("no plan path — the reviewer has nowhere to submit");
704
+ }
705
+
706
+ const ready = await ensureStarted();
707
+ if (!ready || !session) {
708
+ return fail("reviewer session unavailable");
709
+ }
710
+ const child = session;
711
+
712
+ if (!state || state.planHash !== hash || state.phase === "done") {
713
+ state = createReviewState({
714
+ planHash: hash,
715
+ planPath: input.planPath,
716
+ modelLabel: child.modelLabel,
717
+ round: input.round,
718
+ now: started,
719
+ });
720
+ }
721
+ state.round = input.round;
722
+ state.planPath = input.planPath;
723
+ setPhase("reviewing");
724
+
725
+ abortController = new AbortController();
726
+ const rc = abortController;
727
+ // 0 (default) = no cap. A configured cap is the outer safety net; the
728
+ // settle race below still ends a silent pass quickly either way.
729
+ const limitMs = cfg.timeoutMs > 0 ? cfg.timeoutMs : undefined;
730
+ const deadline = limitMs !== undefined ? started + limitMs : undefined;
731
+ if (limitMs === undefined) {
732
+ deps.log(
733
+ "Review Mode: no time limit configured — each pass ends when the reviewer submits or its run settles.",
734
+ "info",
735
+ );
736
+ }
737
+
738
+ // ── Pass 1 ──────────────────────────────────────────────────────
739
+ await settleInFlight(deadline, rc);
740
+ gate.reset();
741
+ const pass1Prompt = buildPass1Prompt({
742
+ planPath: input.planPath,
743
+ planText: originalPlan,
744
+ reviewPrompt: deps.promptText(),
745
+ contextBlock: renderReviewContextBlock(deps.contextEntries()),
746
+ previousFindings: input.previousFindings,
747
+ round: input.round,
748
+ totalRounds: cfg.rounds,
749
+ verify: cfg.verify && cfg.passes > 1,
750
+ });
751
+
752
+ const p1 = await runPass(1, deadline, rc, () =>
753
+ child.prompt(pass1Prompt),
754
+ );
755
+
756
+ if (p1.stop === "error") {
757
+ await abortChild();
758
+ return fail(`could not prompt the reviewer: ${errorText(p1.error)}`);
759
+ }
760
+ if (p1.stop === "aborted" || rc.signal.aborted) {
761
+ await abortChild();
762
+ if (state) state.phase = "aborted";
763
+ return {
764
+ ok: false,
765
+ fallback: true,
766
+ planText: p1.submitted?.planText ?? originalPlan,
767
+ planHash: hash,
768
+ state: state as ReviewState,
769
+ reason: "aborted",
770
+ };
771
+ }
772
+ if (!p1.submitted) {
773
+ // The run is over (or out of time) and nothing reached the plan file.
774
+ // Fail now — waiting longer cannot produce a submission.
775
+ await abortChild();
776
+ return fail(
777
+ p1.stop === "timeout"
778
+ ? `the reviewer did not finish within ${Math.round((limitMs ?? 0) / 1000)}s and submitted no plan`
779
+ : "the reviewer finished without submitting a plan — it has no write path unless review_submit_plan succeeds",
780
+ originalPlan,
781
+ );
782
+ }
783
+ if (p1.stop === "timeout") {
784
+ // The protocol completed, the run did not: stop paying for it, but keep
785
+ // the submission — never discard real work over a slow model.
786
+ deps.log(
787
+ "Review Mode pass 1 exceeded its time limit after submitting — using the submitted plan.",
788
+ "warning",
789
+ );
790
+ await abortChild();
791
+ }
792
+
793
+ let finalPlan = p1.submitted.planText;
794
+
795
+ // ── Pass 2 (self-verification) ───────────────────────────────────
796
+ const wantVerify =
797
+ cfg.verify &&
798
+ cfg.passes > 1 &&
799
+ reviewNeedsVerification(state?.findings ?? []);
800
+
801
+ if (wantVerify) {
802
+ setPhase("verifying");
803
+ await settleInFlight(deadline, rc);
804
+ gate.reset();
805
+ const pass2Prompt = buildPass2Prompt({
806
+ planPath: input.planPath,
807
+ originalPlan,
808
+ rewrittenPlan: p1.submitted.planBody,
809
+ findings: state?.findings ?? [],
810
+ });
811
+ const p2 = await runPass(2, deadline, rc, () =>
812
+ child.followUp(pass2Prompt),
813
+ );
814
+ if (p2.error !== undefined) {
815
+ // Verification is an enhancement: never lose pass 1's plan over it.
816
+ deps.log(
817
+ `Review Mode verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
818
+ "warning",
819
+ );
820
+ } else if (p2.submitted) {
821
+ finalPlan = p2.submitted.planText;
822
+ }
823
+ if (p2.stop === "timeout") await abortChild();
824
+ }
825
+
826
+ if (state) {
827
+ state.phase = "done";
828
+ state.rewrittenPlan = finalPlan;
829
+ state.finishedAt = Date.now();
830
+ }
831
+ try {
832
+ deps.onState?.(state);
833
+ } catch {
834
+ /* ignore */
835
+ }
836
+
837
+ return {
838
+ ok: true,
839
+ fallback: false,
840
+ planText: finalPlan,
841
+ planHash: planHash(finalPlan),
842
+ state: state as ReviewState,
843
+ };
844
+ } catch (e: unknown) {
845
+ return fail(
846
+ `review round failed: ${e instanceof Error ? e.message : String(e)}`,
847
+ );
848
+ } finally {
849
+ abortController = undefined;
850
+ roundActive = false;
851
+ }
852
+ }
853
+
854
+ return {
855
+ ensureStarted,
856
+ runRound,
857
+ getState: () => state,
858
+ getTranscript: () => {
859
+ try {
860
+ return transcript?.toDisplayLines() ?? [];
861
+ } catch {
862
+ return [];
863
+ }
864
+ },
865
+ isAlive: () => Boolean(session),
866
+ isRoundActive: () => roundActive,
867
+ async abortRound() {
868
+ try {
869
+ abortController?.abort();
870
+ await session?.abort();
871
+ } catch {
872
+ /* cancellation is best-effort */
873
+ }
874
+ if (state && state.phase !== "done") state.phase = "aborted";
875
+ },
876
+ async sendUserMessage(text: string, images?: unknown[]): Promise<boolean> {
877
+ if (!session) return false;
878
+ const trimmed = String(text ?? "").trim();
879
+ if (!trimmed) return false;
880
+ try {
881
+ // SAFETY: the child is an SDK `AgentSession`. `ReviewChildSession` is a
882
+ // deliberately narrow structural type that only declares what the review
883
+ // round itself needs (prompt/followUp/abort/subscribe), so `steer` and
884
+ // `isStreaming` have to be re-declared here. Both are feature-detected
885
+ // below before use, so the widening cannot cause a missing-method call.
886
+ const child = session as unknown as {
887
+ isStreaming?: boolean;
888
+ steer?: (message: string) => Promise<void>;
889
+ prompt: (message: string, options?: unknown) => Promise<void>;
890
+ };
891
+ const hasImages = Array.isArray(images) && images.length > 0;
892
+ if (child.isStreaming === true && typeof child.steer === "function") {
893
+ // Mid-pass: steer rather than queueing a whole new turn.
894
+ await child.steer(trimmed);
895
+ } else {
896
+ await child.prompt(trimmed, hasImages ? { images } : undefined);
897
+ }
898
+ return true;
899
+ } catch (e: any) {
900
+ deps.notify(
901
+ `could not deliver your message to the reviewer (${String(e?.message ?? e)}); it went to the plan agent instead`,
902
+ "warning",
903
+ );
904
+ return false;
905
+ }
906
+ },
907
+ async teardown() {
908
+ try {
909
+ abortController?.abort();
910
+ await session?.abort();
911
+ } catch {
912
+ /* ignore */
913
+ }
914
+ try {
915
+ session?.dispose();
916
+ } catch {
917
+ /* ignore */
918
+ }
919
+ session = undefined;
920
+ state = undefined;
921
+ transcript = undefined;
922
+ gate = createReviewRoundGate();
923
+ abortController = undefined;
924
+ try {
925
+ deps.onState?.(undefined);
926
+ } catch {
927
+ /* ignore */
928
+ }
929
+ },
930
+ };
931
+ }