@dieulc/workflow 0.7.0 → 0.8.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.
package/review-runtime.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  planHash,
33
33
  readProviderErrorEvent,
34
34
  reviewNeedsVerification,
35
+ reviewRunLabel,
35
36
  truncateErrorText,
36
37
  type CreateReviewSessionOptions,
37
38
  type CreateReviewSessionResult,
@@ -50,7 +51,7 @@ import {
50
51
  } from "./review.ts";
51
52
  import { appendProviderAuthHint } from "./review-providers.ts";
52
53
  import { createReviewTools } from "./review-tools.ts";
53
- import { renderReviewContextBlock, type ReviewFinding } from "./utils.ts";
54
+ import { buildPlanDiff, renderReviewContextBlock, type ReviewFinding } from "./utils.ts";
54
55
 
55
56
  /** Event `type` from a child-session event, or "" for junk. Never throws. */
56
57
  function readEventType(event: unknown): string {
@@ -91,6 +92,18 @@ export interface ReviewRuntimeDeps {
91
92
  readPlan: () => string | undefined;
92
93
  /** Pruned parent context entries (from ctx.sessionManager.getBranch()). */
93
94
  contextEntries: () => unknown[];
95
+ /**
96
+ * Whether the last Planner manifest carried recon summaries (step 8).
97
+ * When true the reviewer verifies instead of re-exploring: its explore
98
+ * budget is 0. Optional — absent means "no recon".
99
+ */
100
+ hasRecon?: () => boolean;
101
+ /**
102
+ * Planner-context manifest (file inventory + recon) for the pass-1 prompt
103
+ * (step 8). Optional: when absent the reviewer works from the transcript
104
+ * block alone, as before.
105
+ */
106
+ plannerManifest?: () => string;
94
107
  /**
95
108
  * Resolved reviewer guidance (review-prompt.md, else the built-in default).
96
109
  * A function, not a string: the user can edit the file between rounds.
@@ -129,6 +142,18 @@ export interface RunRoundInput {
129
142
  round: number;
130
143
  /** Findings from the previous round, so pass 2 can assert they were fixed. */
131
144
  previousFindings?: ReviewFinding[];
145
+ /**
146
+ * Host abort signal (ESC). When it fires the round stops promptly and the
147
+ * author's plan is kept — the same outcome as the internal abortRound().
148
+ * Linked to the round's own controller; detached when the round ends.
149
+ */
150
+ signal?: AbortSignal;
151
+ /**
152
+ * `/review-resume` is continuing a paused round on the warm session. Only the
153
+ * transcript label needs this: warmth alone cannot express intent, because a
154
+ * next round reuses the same session too.
155
+ */
156
+ resumed?: boolean;
132
157
  }
133
158
 
134
159
  export interface ReviewRuntime {
@@ -162,6 +187,8 @@ export function buildPass1Prompt(input: {
162
187
  planText: string;
163
188
  reviewPrompt: string;
164
189
  contextBlock: string;
190
+ /** Planner-context manifest (step 8): inventory + recon, trusted by default. */
191
+ manifest?: string;
165
192
  previousFindings?: ReviewFinding[];
166
193
  round: number;
167
194
  totalRounds: number;
@@ -178,13 +205,18 @@ export function buildPass1Prompt(input: {
178
205
  input.reviewPrompt,
179
206
  ];
180
207
 
208
+ if (input.manifest && input.manifest.trim()) {
209
+ parts.push("", input.manifest.trim());
210
+ }
211
+
181
212
  if (input.contextBlock.trim()) {
182
213
  parts.push(
183
214
  "",
184
- "## Shared context from Plan Mode",
215
+ "## Plan Mode transcript (abridged)",
185
216
  "",
186
- "This is what the author explored and was asked for. It is background, not",
187
- "ground truth — verify anything you rely on against the repository.",
217
+ "The author's own words and tool spew, truncated for size. Prefer the",
218
+ "shared-context inventory above; re-read anything you rely on with",
219
+ "read/grep.",
188
220
  "",
189
221
  input.contextBlock.trim(),
190
222
  );
@@ -209,12 +241,18 @@ export function buildPass1Prompt(input: {
209
241
  "The author has revised the plan in response. Confirm whether each was",
210
242
  "actually addressed, and say so in your findings rather than re-raising it.",
211
243
  "",
212
- ...input.previousFindings.map(
213
- (f) =>
214
- `- [${f.disposition}] ${f.summary}${
215
- f.file ? ` (${f.file}${f.lineRange ? `:${f.lineRange}` : ""})` : ""
216
- }`,
217
- ),
244
+ ...input.previousFindings.map((f) => {
245
+ const loc = f.file
246
+ ? ` (${f.file}${f.lineRange ? `:${f.lineRange}` : ""})`
247
+ : "";
248
+ // Step 8: carry the rationale so re-review rounds verify instead of
249
+ // re-discovering (clipped — the full text lives in the changelog).
250
+ const why = String(f.rationale ?? "")
251
+ .replace(/\s+/g, " ")
252
+ .trim();
253
+ const clipped = why.length > 200 ? `${why.slice(0, 200)}…` : why;
254
+ return `- [${f.disposition}] ${f.summary}${loc}${clipped ? ` — ${clipped}` : ""}`;
255
+ }),
218
256
  );
219
257
  }
220
258
 
@@ -241,22 +279,18 @@ export function buildPass2Prompt(input: {
241
279
  rewrittenPlan: string;
242
280
  findings: ReviewFinding[];
243
281
  }): string {
282
+ // Step 9: pass 2 checks the rewrite against a diff, not two full plans.
283
+ const diff = buildPlanDiff(input.originalPlan, input.rewrittenPlan);
244
284
  return [
245
285
  "[REVIEW MODE — PASS 2 of 2: VERIFY YOUR OWN REWRITE]",
246
286
  "",
247
287
  "You just rewrote the plan and submitted it. Now check your own work. This pass",
248
288
  "exists because a rewrite can silently drop a requirement the author cared about.",
249
289
  "",
250
- "## Original plan (as the author wrote it)",
290
+ "## What changed (unified diff of your rewrite)",
251
291
  "",
252
- "```markdown",
253
- input.originalPlan,
254
- "```",
255
- "",
256
- "## Your rewrite (currently on disk)",
257
- "",
258
- "```markdown",
259
- input.rewrittenPlan,
292
+ "```diff",
293
+ diff,
260
294
  "```",
261
295
  "",
262
296
  "## Findings you reported",
@@ -289,8 +323,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
289
323
  let state: ReviewState | undefined;
290
324
  let transcript: Transcript | undefined;
291
325
  let gate: ReviewRoundGate = createReviewRoundGate();
292
- let starting: Promise<boolean> | undefined;
326
+ let starting: Promise<EnsureSessionResult> | undefined;
293
327
  let abortController: AbortController | undefined;
328
+ /** `provider/id/thinking` the live session was created with (step 10). */
329
+ let sessionModelKey: string | undefined;
294
330
  /** True while `runRound` is in flight (gates prompt routing). */
295
331
  let roundActive = false;
296
332
  /**
@@ -339,6 +375,26 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
339
375
  return transcript;
340
376
  };
341
377
 
378
+ /**
379
+ * Emit a runtime-authored status line to the local transcript buffer and the
380
+ * parent stream. Used for run-start narration, which the child's
381
+ * `agent_start` cannot describe truthfully (see `reviewRunLabel`).
382
+ * Display-only: a broken sink must never break a review.
383
+ */
384
+ const pushStatus = (text: string): void => {
385
+ const op: ReviewTranscriptOp = { op: "new", kind: "status", text };
386
+ try {
387
+ ensureTranscript().apply(op);
388
+ } catch {
389
+ /* ignore */
390
+ }
391
+ try {
392
+ deps.onTranscriptOps?.([op]);
393
+ } catch {
394
+ /* ignore */
395
+ }
396
+ };
397
+
342
398
  /**
343
399
  * Completed reviewer runs (`agent_settled`). A pass is over when either its
344
400
  * `review_pass_done` or the end of its run arrives — see `runPass()`.
@@ -476,6 +532,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
476
532
  const remaining = (): number | undefined =>
477
533
  deadline === undefined ? undefined : Math.max(1, deadline - Date.now());
478
534
  let sendError: unknown;
535
+ // The losing waiters in the pass race must be cancelled when the winner
536
+ // settles. Otherwise their timers (up to `timeoutMs`, 5 minutes by
537
+ // default) stay referenced and keep the process alive after the round.
538
+ const waitAbort = new AbortController();
479
539
 
480
540
  const stop = await new Promise<PassStop>((resolve) => {
481
541
  let done = false;
@@ -486,6 +546,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
486
546
  done = true;
487
547
  if (timer !== undefined) clearTimeout(timer);
488
548
  rc.signal.removeEventListener("abort", onAbort);
549
+ waitAbort.abort();
489
550
  resolve(s);
490
551
  };
491
552
  if (rc.signal.aborted) {
@@ -495,10 +556,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
495
556
  const cap = remaining();
496
557
  if (cap !== undefined) timer = setTimeout(() => finish("timeout"), cap);
497
558
  rc.signal.addEventListener("abort", onAbort, { once: true });
498
- void gate.waitForPassDone(remaining(), rc.signal).then((result) => {
559
+ void gate.waitForPassDone(remaining(), waitAbort.signal).then((result) => {
499
560
  if (result) finish("passDone");
500
561
  });
501
- void waitForSettle(baseline, remaining(), rc.signal).then((did) => {
562
+ void waitForSettle(baseline, remaining(), waitAbort.signal).then((did) => {
502
563
  if (did) finish("settled");
503
564
  });
504
565
  void send().catch((e: unknown) => {
@@ -512,7 +573,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
512
573
  if (stop === "timeout") {
513
574
  const capMs = Math.max(1000, config().timeoutMs);
514
575
  deps.log(
515
- `Review Mode pass ${pass} hit its ${Math.round(
576
+ `pass ${pass} hit its ${Math.round(
516
577
  capMs / 1000,
517
578
  )}s limit — continuing with ${
518
579
  submitted ? "the submitted plan" : "the author's plan"
@@ -528,18 +589,70 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
528
589
  };
529
590
  }
530
591
 
531
- async function ensureStarted(): Promise<boolean> {
532
- if (session) return true;
592
+ /** Release the child session and its round state (step 10). Never throws. */
593
+ async function disposeSession(): Promise<void> {
594
+ try {
595
+ abortController?.abort();
596
+ await session?.abort();
597
+ } catch {
598
+ /* ignore */
599
+ }
600
+ try {
601
+ session?.dispose();
602
+ } catch {
603
+ /* ignore */
604
+ }
605
+ session = undefined;
606
+ state = undefined;
607
+ transcript = undefined;
608
+ sessionModelKey = undefined;
609
+ gate = createReviewRoundGate();
610
+ abortController = undefined;
611
+ try {
612
+ deps.onState?.(undefined);
613
+ } catch {
614
+ /* ignore */
615
+ }
616
+ }
617
+
618
+ /** Outcome of `ensureSession`: `created` is true only for a fresh boot. */
619
+ interface EnsureSessionResult {
620
+ ok: boolean;
621
+ created: boolean;
622
+ }
623
+
624
+ /**
625
+ * Idempotent session boot. `created` tells the caller whether THIS call paid
626
+ * for a new child session, which is what makes the transcript's run label
627
+ * truthful (fresh boot vs warm resume/next round).
628
+ */
629
+ async function ensureSession(): Promise<EnsureSessionResult> {
630
+ const modelRef = deps.reviewerModel();
631
+ const modelKey = modelRef
632
+ ? `${modelRef.provider}/${modelRef.id}/${modelRef.thinking}`
633
+ : "";
634
+ if (session) {
635
+ // Step 10: the session is bound to the model it was created with. A
636
+ // reviewer switch since then reboots it so the new model audits.
637
+ if (modelKey && sessionModelKey && modelKey !== sessionModelKey) {
638
+ deps.log(
639
+ "reviewer model changed — restarting the reviewer session.",
640
+ "info",
641
+ );
642
+ await disposeSession();
643
+ } else {
644
+ return { ok: true, created: false };
645
+ }
646
+ }
533
647
  if (starting) return starting;
534
- starting = (async () => {
648
+ starting = (async (): Promise<EnsureSessionResult> => {
535
649
  const cfg = config();
536
- const modelRef = deps.reviewerModel();
537
650
  if (!modelRef) {
538
651
  deps.log(
539
- "Review Mode: no reviewer model configured — run /role set reviewer <provider/model>",
652
+ "no reviewer model configured — run /role set reviewer <provider/model>",
540
653
  "warning",
541
654
  );
542
- return false;
655
+ return { ok: false, created: false };
543
656
  }
544
657
  try {
545
658
  // The SDK must be loaded BEFORE building the tools: review_bash is
@@ -553,7 +666,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
553
666
  cwd: deps.cwd,
554
667
  log: deps.log,
555
668
  explore: {
556
- budget: () => config().exploreBudget,
669
+ // Step 8: Planner recon rides along in the prompt — when it is
670
+ // present the reviewer verifies instead of re-exploring.
671
+ budget: () =>
672
+ (deps.hasRecon?.() ?? false) ? 0 : config().exploreBudget,
557
673
  run: (task, signal) => deps.runExplore(task, signal),
558
674
  onUsed: (used) => {
559
675
  if (state) state.exploreUsed = used;
@@ -642,22 +758,33 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
642
758
 
643
759
  if (!created.ok || !created.session) {
644
760
  deps.log(
645
- `Review Mode unavailable: ${created.error ?? "unknown error"} — the plan will be handed over unreviewed.`,
761
+ `reviewer unavailable: ${created.error ?? "unknown error"} — the plan will be handed over unreviewed.`,
646
762
  cfg.fallbackOnError === "block" ? "error" : "warning",
647
763
  );
648
- return false;
764
+ return { ok: false, created: false };
649
765
  }
650
766
 
651
767
  // One line per session, so "the reviewer has no tools" is visible
652
768
  // immediately instead of only after a failed pass (see /review-status).
653
769
  if (created.activeTools) {
654
770
  deps.log(
655
- `Review Mode: reviewer tools active — ${created.activeTools.join(", ")}`,
771
+ `reviewer tools active — ${created.activeTools.join(", ")}`,
656
772
  "info",
657
773
  );
658
774
  }
659
775
 
660
776
  session = created.session;
777
+ // Step 10: remember which model this session audits with.
778
+ sessionModelKey = modelKey;
779
+ // Config, not round state: report the missing cap once per live
780
+ // session, so a resume cannot replay it as if the reviewer had just
781
+ // started. Also avoids the old `Review Mode: Review Mode:` doubling.
782
+ if (cfg.timeoutMs <= 0) {
783
+ deps.log(
784
+ "no time limit configured — each pass ends when the reviewer submits or its run settles.",
785
+ "info",
786
+ );
787
+ }
661
788
  ensureTranscript();
662
789
  const child = created.session;
663
790
  session.subscribe((event) => {
@@ -710,13 +837,13 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
710
837
  }, 0);
711
838
  }
712
839
  });
713
- return true;
840
+ return { ok: true, created: true };
714
841
  } catch (e: unknown) {
715
842
  deps.log(
716
- `Review Mode failed to start: ${e instanceof Error ? e.message : String(e)}`,
843
+ `failed to start: ${e instanceof Error ? e.message : String(e)}`,
717
844
  "warning",
718
845
  );
719
- return false;
846
+ return { ok: false, created: false };
720
847
  } finally {
721
848
  starting = undefined;
722
849
  }
@@ -724,11 +851,20 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
724
851
  return starting;
725
852
  }
726
853
 
854
+ /** Public shape: idempotent, creates the reviewer session if it is not alive. */
855
+ async function ensureStarted(): Promise<boolean> {
856
+ return (await ensureSession()).ok;
857
+ }
858
+
727
859
  async function runRound(input: RunRoundInput): Promise<ReviewRoundResult> {
728
860
  const cfg = config();
729
861
  const hash = planHash(input.planText);
730
862
  const started = Date.now();
731
863
  const originalPlan = input.planText;
864
+ // Declared outside the try: a `let` inside the try block would not be
865
+ // visible in `finally` (block scoping), and the external listener must
866
+ // always be detached when the round ends.
867
+ let detachExternal: (() => void) | undefined;
732
868
 
733
869
  const fail = (
734
870
  reason: string,
@@ -768,10 +904,52 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
768
904
  return fail("no plan path — the reviewer has nowhere to submit");
769
905
  }
770
906
 
771
- const ready = await ensureStarted();
772
- if (!ready || !session) {
907
+ // A signal that is already aborted means the host turn this round would
908
+ // belong to is gone (e.g. the planner turn was interrupted). Do not boot
909
+ // a session, send a prompt or spend a pass on it: report a fast abort.
910
+ // The caller decides what that means — the automatic handoff skips the
911
+ // review and hands off unreviewed, while an explicit `/review-resume`
912
+ // passes no host signal at all (its stop paths are ESC / `/review-abort`).
913
+ // Crucially, a pre-aborted signal must never become a pause: doing so
914
+ // re-created `pausedReview` on every resume and parked the handoff
915
+ // forever (2026-10-25 stall).
916
+ if (input.signal?.aborted === true) {
917
+ if (!state || state.planHash !== hash) {
918
+ state = createReviewState({
919
+ planHash: hash,
920
+ planPath: input.planPath,
921
+ modelLabel: session?.modelLabel ?? "reviewer",
922
+ round: input.round,
923
+ now: started,
924
+ });
925
+ }
926
+ state.round = input.round;
927
+ state.planPath = input.planPath;
928
+ state.phase = "aborted";
929
+ state.error =
930
+ "aborted before start — the host turn was already aborted";
931
+ state.finishedAt = Date.now();
932
+ try {
933
+ deps.onState?.(state);
934
+ } catch {
935
+ /* status updates are best-effort */
936
+ }
937
+ return {
938
+ ok: false,
939
+ fallback: true,
940
+ planText: originalPlan,
941
+ planHash: hash,
942
+ state,
943
+ reason: "aborted-before-start",
944
+ abortedBeforeStart: true,
945
+ };
946
+ }
947
+
948
+ const start = await ensureSession();
949
+ if (!start.ok || !session) {
773
950
  return fail("reviewer session unavailable");
774
951
  }
952
+ const created = start.created;
775
953
  const child = session;
776
954
 
777
955
  if (!state || state.planHash !== hash || state.phase === "done") {
@@ -789,16 +967,33 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
789
967
 
790
968
  abortController = new AbortController();
791
969
  const rc = abortController;
970
+ // ESC cancels: link the host signal (when the caller threads one
971
+ // through) to the round's controller, so ESC aborts the pass race,
972
+ // the settle wait and the send alike. The existing `aborted` branch
973
+ // below then keeps the session warm and hands back the author's plan.
974
+ const externalSignal = input.signal;
975
+ if (externalSignal) {
976
+ if (externalSignal.aborted) {
977
+ rc.abort();
978
+ } else {
979
+ const onExternalAbort = () => {
980
+ try {
981
+ rc.abort();
982
+ } catch {
983
+ /* abort is best-effort */
984
+ }
985
+ };
986
+ externalSignal.addEventListener("abort", onExternalAbort, {
987
+ once: true,
988
+ });
989
+ detachExternal = () =>
990
+ externalSignal.removeEventListener("abort", onExternalAbort);
991
+ }
992
+ }
792
993
  // 0 (default) = no cap. A configured cap is the outer safety net; the
793
994
  // settle race below still ends a silent pass quickly either way.
794
995
  const limitMs = cfg.timeoutMs > 0 ? cfg.timeoutMs : undefined;
795
996
  const deadline = limitMs !== undefined ? started + limitMs : undefined;
796
- if (limitMs === undefined) {
797
- deps.log(
798
- "Review Mode: no time limit configured — each pass ends when the reviewer submits or its run settles.",
799
- "info",
800
- );
801
- }
802
997
 
803
998
  // ── Pass 1 ──────────────────────────────────────────────────────
804
999
  await settleInFlight(deadline, rc);
@@ -808,16 +1003,26 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
808
1003
  planPath: input.planPath,
809
1004
  planText: originalPlan,
810
1005
  reviewPrompt: deps.promptText(),
811
- contextBlock: renderReviewContextBlock(deps.contextEntries()),
1006
+ contextBlock: renderReviewContextBlock(deps.contextEntries(), {
1007
+ // Step 7 — diet default (settings-overridable).
1008
+ maxChars: config().contextChars,
1009
+ }),
1010
+ // Step 8: inventory + recon so the reviewer verifies, not re-explores.
1011
+ manifest: deps.plannerManifest?.() ?? "",
812
1012
  previousFindings: input.previousFindings,
813
1013
  round: input.round,
814
1014
  totalRounds: cfg.rounds,
815
1015
  verify: cfg.verify && cfg.passes > 1,
816
1016
  });
817
1017
 
818
- const p1 = await runPass(1, deadline, rc, () =>
819
- child.prompt(pass1Prompt),
820
- );
1018
+ const p1 = await runPass(1, deadline, rc, () => {
1019
+ // Pushed inside `send`: an already-aborted pass never sends, so it must
1020
+ // not claim a start either.
1021
+ pushStatus(
1022
+ reviewRunLabel({ created, resumed: input.resumed === true, pass: 1 }),
1023
+ );
1024
+ return child.prompt(pass1Prompt);
1025
+ });
821
1026
 
822
1027
  if (p1.stop === "error") {
823
1028
  await abortChild();
@@ -872,7 +1077,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
872
1077
  // The protocol completed, the run did not: stop paying for it, but keep
873
1078
  // the submission — never discard real work over a slow model.
874
1079
  deps.log(
875
- "Review Mode pass 1 exceeded its time limit after submitting — using the submitted plan.",
1080
+ "pass 1 exceeded its time limit after submitting — using the submitted plan.",
876
1081
  "warning",
877
1082
  );
878
1083
  await abortChild();
@@ -897,9 +1102,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
897
1102
  rewrittenPlan: p1.submitted.planBody,
898
1103
  findings: state?.findings ?? [],
899
1104
  });
900
- const p2 = await runPass(2, deadline, rc, () =>
901
- child.followUp(pass2Prompt),
902
- );
1105
+ const p2 = await runPass(2, deadline, rc, () => {
1106
+ pushStatus(reviewRunLabel({ created, pass: 2 }));
1107
+ return child.followUp(pass2Prompt);
1108
+ });
903
1109
  // Name the provider when the verification pass died on one; pass 2
904
1110
  // stays best-effort either way — pass 1's plan is never lost to it.
905
1111
  const p2Provider = lastProviderError
@@ -909,8 +1115,8 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
909
1115
  // Verification is an enhancement: never lose pass 1's plan over it.
910
1116
  deps.log(
911
1117
  p2Provider
912
- ? `Review Mode verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
913
- : `Review Mode verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
1118
+ ? `verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
1119
+ : `verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
914
1120
  "warning",
915
1121
  );
916
1122
  } else if (p2.submitted) {
@@ -919,7 +1125,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
919
1125
  // The run settled without a submission AND a provider error was
920
1126
  // seen this pass: say why, then keep going with pass 1's rewrite.
921
1127
  deps.log(
922
- `Review Mode verification pass ended without a submission (${p2Provider}) — keeping pass 1's plan.`,
1128
+ `verification pass ended without a submission (${p2Provider}) — keeping pass 1's plan.`,
923
1129
  "warning",
924
1130
  );
925
1131
  }
@@ -949,6 +1155,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
949
1155
  `review round failed: ${e instanceof Error ? e.message : String(e)}`,
950
1156
  );
951
1157
  } finally {
1158
+ try {
1159
+ detachExternal?.();
1160
+ } catch {
1161
+ /* listener cleanup is best-effort */
1162
+ }
952
1163
  abortController = undefined;
953
1164
  roundActive = false;
954
1165
  }
@@ -1008,27 +1219,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1008
1219
  }
1009
1220
  },
1010
1221
  async teardown() {
1011
- try {
1012
- abortController?.abort();
1013
- await session?.abort();
1014
- } catch {
1015
- /* ignore */
1016
- }
1017
- try {
1018
- session?.dispose();
1019
- } catch {
1020
- /* ignore */
1021
- }
1022
- session = undefined;
1023
- state = undefined;
1024
- transcript = undefined;
1025
- gate = createReviewRoundGate();
1026
- abortController = undefined;
1027
- try {
1028
- deps.onState?.(undefined);
1029
- } catch {
1030
- /* ignore */
1031
- }
1222
+ await disposeSession();
1032
1223
  },
1033
1224
  };
1034
1225
  }
package/review-ui.ts CHANGED
@@ -50,6 +50,13 @@ export interface ReviewModeBandData {
50
50
  round?: number;
51
51
  verdict?: string;
52
52
  reason?: string;
53
+ /**
54
+ * A round that continues a paused one (`/review-resume`). The band renders
55
+ * `resumed` instead of `sequential audit` so the two flows never look alike.
56
+ */
57
+ resumed?: boolean;
58
+ /** The round stopped for a pause; renders `REVIEW PAUSED`, not `REVIEW DONE`. */
59
+ paused?: boolean;
53
60
  }
54
61
 
55
62
  /** Payload of a `workflow-review-transcript` chunk entry. */
@@ -103,6 +110,10 @@ export function reviewBandKey(data: ReviewModeBandData | undefined): string {
103
110
  data?.modelLabel ?? "",
104
111
  data?.verdict ?? "",
105
112
  data?.reason ?? "",
113
+ // A resumed (or paused) band is never the same event as the fresh band it
114
+ // continues, even when mode/round/model/verdict/reason happen to match.
115
+ data?.resumed === true ? "resumed" : "",
116
+ data?.paused === true ? "paused" : "",
106
117
  ].join("|");
107
118
  }
108
119
 
@@ -295,19 +306,28 @@ export function formatReviewModeBand(
295
306
  }
296
307
  if (mode === "review") {
297
308
  const bits: string[] = [];
298
- if (typeof data?.round === "number" && data.round > 1) {
309
+ const resumed = data?.resumed === true;
310
+ if (resumed) {
311
+ bits.push("resumed");
312
+ // Always name the round on a resume: "resumed · round 1" reads as a
313
+ // continuation, while a bare "resumed" hides which round continues.
314
+ if (typeof data?.round === "number") bits.push(`round ${data.round}`);
315
+ } else if (typeof data?.round === "number" && data.round > 1) {
299
316
  bits.push(`round ${data.round}`);
300
317
  }
301
318
  if (typeof data?.modelLabel === "string" && data.modelLabel) {
302
319
  bits.push(data.modelLabel);
303
320
  }
304
- bits.push("sequential audit");
321
+ // "sequential audit" describes a fresh flow; a resumed round continues the
322
+ // flow it was paused in.
323
+ if (!resumed) bits.push("sequential audit");
305
324
  const title = theme.fg("accent", theme.bold("▌ REVIEW MODE"));
306
325
  return [title + theme.fg("muted", ` ${bits.join(" · ")}`)];
307
326
  }
308
327
  if (mode === "review-done") {
328
+ const paused = data?.paused === true;
309
329
  const clean = data?.verdict === "approve" && !data?.reason;
310
- const color: ThemeColor = clean ? "success" : "warning";
330
+ const color: ThemeColor = paused || !clean ? "warning" : "success";
311
331
  let detail = "";
312
332
  if (data?.reason) {
313
333
  detail = ` ${data.reason}`;
@@ -315,7 +335,10 @@ export function formatReviewModeBand(
315
335
  detail = ` verdict: ${data.verdict}`;
316
336
  }
317
337
  return [
318
- theme.fg(color, theme.bold("▌ REVIEW DONE")) + theme.fg("muted", detail),
338
+ theme.fg(
339
+ color,
340
+ theme.bold(paused ? "▌ REVIEW PAUSED" : "▌ REVIEW DONE"),
341
+ ) + theme.fg("muted", detail),
319
342
  ];
320
343
  }
321
344
  return [theme.fg("muted", "▌ review")];