@dieulc/workflow 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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()`.
@@ -512,7 +568,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
512
568
  if (stop === "timeout") {
513
569
  const capMs = Math.max(1000, config().timeoutMs);
514
570
  deps.log(
515
- `Review Mode pass ${pass} hit its ${Math.round(
571
+ `pass ${pass} hit its ${Math.round(
516
572
  capMs / 1000,
517
573
  )}s limit — continuing with ${
518
574
  submitted ? "the submitted plan" : "the author's plan"
@@ -528,18 +584,70 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
528
584
  };
529
585
  }
530
586
 
531
- async function ensureStarted(): Promise<boolean> {
532
- if (session) return true;
587
+ /** Release the child session and its round state (step 10). Never throws. */
588
+ async function disposeSession(): Promise<void> {
589
+ try {
590
+ abortController?.abort();
591
+ await session?.abort();
592
+ } catch {
593
+ /* ignore */
594
+ }
595
+ try {
596
+ session?.dispose();
597
+ } catch {
598
+ /* ignore */
599
+ }
600
+ session = undefined;
601
+ state = undefined;
602
+ transcript = undefined;
603
+ sessionModelKey = undefined;
604
+ gate = createReviewRoundGate();
605
+ abortController = undefined;
606
+ try {
607
+ deps.onState?.(undefined);
608
+ } catch {
609
+ /* ignore */
610
+ }
611
+ }
612
+
613
+ /** Outcome of `ensureSession`: `created` is true only for a fresh boot. */
614
+ interface EnsureSessionResult {
615
+ ok: boolean;
616
+ created: boolean;
617
+ }
618
+
619
+ /**
620
+ * Idempotent session boot. `created` tells the caller whether THIS call paid
621
+ * for a new child session, which is what makes the transcript's run label
622
+ * truthful (fresh boot vs warm resume/next round).
623
+ */
624
+ async function ensureSession(): Promise<EnsureSessionResult> {
625
+ const modelRef = deps.reviewerModel();
626
+ const modelKey = modelRef
627
+ ? `${modelRef.provider}/${modelRef.id}/${modelRef.thinking}`
628
+ : "";
629
+ if (session) {
630
+ // Step 10: the session is bound to the model it was created with. A
631
+ // reviewer switch since then reboots it so the new model audits.
632
+ if (modelKey && sessionModelKey && modelKey !== sessionModelKey) {
633
+ deps.log(
634
+ "reviewer model changed — restarting the reviewer session.",
635
+ "info",
636
+ );
637
+ await disposeSession();
638
+ } else {
639
+ return { ok: true, created: false };
640
+ }
641
+ }
533
642
  if (starting) return starting;
534
- starting = (async () => {
643
+ starting = (async (): Promise<EnsureSessionResult> => {
535
644
  const cfg = config();
536
- const modelRef = deps.reviewerModel();
537
645
  if (!modelRef) {
538
646
  deps.log(
539
- "Review Mode: no reviewer model configured — run /role set reviewer <provider/model>",
647
+ "no reviewer model configured — run /role set reviewer <provider/model>",
540
648
  "warning",
541
649
  );
542
- return false;
650
+ return { ok: false, created: false };
543
651
  }
544
652
  try {
545
653
  // The SDK must be loaded BEFORE building the tools: review_bash is
@@ -553,7 +661,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
553
661
  cwd: deps.cwd,
554
662
  log: deps.log,
555
663
  explore: {
556
- budget: () => config().exploreBudget,
664
+ // Step 8: Planner recon rides along in the prompt — when it is
665
+ // present the reviewer verifies instead of re-exploring.
666
+ budget: () =>
667
+ (deps.hasRecon?.() ?? false) ? 0 : config().exploreBudget,
557
668
  run: (task, signal) => deps.runExplore(task, signal),
558
669
  onUsed: (used) => {
559
670
  if (state) state.exploreUsed = used;
@@ -642,22 +753,33 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
642
753
 
643
754
  if (!created.ok || !created.session) {
644
755
  deps.log(
645
- `Review Mode unavailable: ${created.error ?? "unknown error"} — the plan will be handed over unreviewed.`,
756
+ `reviewer unavailable: ${created.error ?? "unknown error"} — the plan will be handed over unreviewed.`,
646
757
  cfg.fallbackOnError === "block" ? "error" : "warning",
647
758
  );
648
- return false;
759
+ return { ok: false, created: false };
649
760
  }
650
761
 
651
762
  // One line per session, so "the reviewer has no tools" is visible
652
763
  // immediately instead of only after a failed pass (see /review-status).
653
764
  if (created.activeTools) {
654
765
  deps.log(
655
- `Review Mode: reviewer tools active — ${created.activeTools.join(", ")}`,
766
+ `reviewer tools active — ${created.activeTools.join(", ")}`,
656
767
  "info",
657
768
  );
658
769
  }
659
770
 
660
771
  session = created.session;
772
+ // Step 10: remember which model this session audits with.
773
+ sessionModelKey = modelKey;
774
+ // Config, not round state: report the missing cap once per live
775
+ // session, so a resume cannot replay it as if the reviewer had just
776
+ // started. Also avoids the old `Review Mode: Review Mode:` doubling.
777
+ if (cfg.timeoutMs <= 0) {
778
+ deps.log(
779
+ "no time limit configured — each pass ends when the reviewer submits or its run settles.",
780
+ "info",
781
+ );
782
+ }
661
783
  ensureTranscript();
662
784
  const child = created.session;
663
785
  session.subscribe((event) => {
@@ -710,13 +832,13 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
710
832
  }, 0);
711
833
  }
712
834
  });
713
- return true;
835
+ return { ok: true, created: true };
714
836
  } catch (e: unknown) {
715
837
  deps.log(
716
- `Review Mode failed to start: ${e instanceof Error ? e.message : String(e)}`,
838
+ `failed to start: ${e instanceof Error ? e.message : String(e)}`,
717
839
  "warning",
718
840
  );
719
- return false;
841
+ return { ok: false, created: false };
720
842
  } finally {
721
843
  starting = undefined;
722
844
  }
@@ -724,11 +846,20 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
724
846
  return starting;
725
847
  }
726
848
 
849
+ /** Public shape: idempotent, creates the reviewer session if it is not alive. */
850
+ async function ensureStarted(): Promise<boolean> {
851
+ return (await ensureSession()).ok;
852
+ }
853
+
727
854
  async function runRound(input: RunRoundInput): Promise<ReviewRoundResult> {
728
855
  const cfg = config();
729
856
  const hash = planHash(input.planText);
730
857
  const started = Date.now();
731
858
  const originalPlan = input.planText;
859
+ // Declared outside the try: a `let` inside the try block would not be
860
+ // visible in `finally` (block scoping), and the external listener must
861
+ // always be detached when the round ends.
862
+ let detachExternal: (() => void) | undefined;
732
863
 
733
864
  const fail = (
734
865
  reason: string,
@@ -768,10 +899,52 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
768
899
  return fail("no plan path — the reviewer has nowhere to submit");
769
900
  }
770
901
 
771
- const ready = await ensureStarted();
772
- if (!ready || !session) {
902
+ // A signal that is already aborted means the host turn this round would
903
+ // belong to is gone (e.g. the planner turn was interrupted). Do not boot
904
+ // a session, send a prompt or spend a pass on it: report a fast abort.
905
+ // The caller decides what that means — the automatic handoff skips the
906
+ // review and hands off unreviewed, while an explicit `/review-resume`
907
+ // passes no host signal at all (its stop paths are ESC / `/review-abort`).
908
+ // Crucially, a pre-aborted signal must never become a pause: doing so
909
+ // re-created `pausedReview` on every resume and parked the handoff
910
+ // forever (2026-10-25 stall).
911
+ if (input.signal?.aborted === true) {
912
+ if (!state || state.planHash !== hash) {
913
+ state = createReviewState({
914
+ planHash: hash,
915
+ planPath: input.planPath,
916
+ modelLabel: session?.modelLabel ?? "reviewer",
917
+ round: input.round,
918
+ now: started,
919
+ });
920
+ }
921
+ state.round = input.round;
922
+ state.planPath = input.planPath;
923
+ state.phase = "aborted";
924
+ state.error =
925
+ "aborted before start — the host turn was already aborted";
926
+ state.finishedAt = Date.now();
927
+ try {
928
+ deps.onState?.(state);
929
+ } catch {
930
+ /* status updates are best-effort */
931
+ }
932
+ return {
933
+ ok: false,
934
+ fallback: true,
935
+ planText: originalPlan,
936
+ planHash: hash,
937
+ state,
938
+ reason: "aborted-before-start",
939
+ abortedBeforeStart: true,
940
+ };
941
+ }
942
+
943
+ const start = await ensureSession();
944
+ if (!start.ok || !session) {
773
945
  return fail("reviewer session unavailable");
774
946
  }
947
+ const created = start.created;
775
948
  const child = session;
776
949
 
777
950
  if (!state || state.planHash !== hash || state.phase === "done") {
@@ -789,16 +962,33 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
789
962
 
790
963
  abortController = new AbortController();
791
964
  const rc = abortController;
965
+ // ESC cancels: link the host signal (when the caller threads one
966
+ // through) to the round's controller, so ESC aborts the pass race,
967
+ // the settle wait and the send alike. The existing `aborted` branch
968
+ // below then keeps the session warm and hands back the author's plan.
969
+ const externalSignal = input.signal;
970
+ if (externalSignal) {
971
+ if (externalSignal.aborted) {
972
+ rc.abort();
973
+ } else {
974
+ const onExternalAbort = () => {
975
+ try {
976
+ rc.abort();
977
+ } catch {
978
+ /* abort is best-effort */
979
+ }
980
+ };
981
+ externalSignal.addEventListener("abort", onExternalAbort, {
982
+ once: true,
983
+ });
984
+ detachExternal = () =>
985
+ externalSignal.removeEventListener("abort", onExternalAbort);
986
+ }
987
+ }
792
988
  // 0 (default) = no cap. A configured cap is the outer safety net; the
793
989
  // settle race below still ends a silent pass quickly either way.
794
990
  const limitMs = cfg.timeoutMs > 0 ? cfg.timeoutMs : undefined;
795
991
  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
992
 
803
993
  // ── Pass 1 ──────────────────────────────────────────────────────
804
994
  await settleInFlight(deadline, rc);
@@ -808,16 +998,26 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
808
998
  planPath: input.planPath,
809
999
  planText: originalPlan,
810
1000
  reviewPrompt: deps.promptText(),
811
- contextBlock: renderReviewContextBlock(deps.contextEntries()),
1001
+ contextBlock: renderReviewContextBlock(deps.contextEntries(), {
1002
+ // Step 7 — diet default (settings-overridable).
1003
+ maxChars: config().contextChars,
1004
+ }),
1005
+ // Step 8: inventory + recon so the reviewer verifies, not re-explores.
1006
+ manifest: deps.plannerManifest?.() ?? "",
812
1007
  previousFindings: input.previousFindings,
813
1008
  round: input.round,
814
1009
  totalRounds: cfg.rounds,
815
1010
  verify: cfg.verify && cfg.passes > 1,
816
1011
  });
817
1012
 
818
- const p1 = await runPass(1, deadline, rc, () =>
819
- child.prompt(pass1Prompt),
820
- );
1013
+ const p1 = await runPass(1, deadline, rc, () => {
1014
+ // Pushed inside `send`: an already-aborted pass never sends, so it must
1015
+ // not claim a start either.
1016
+ pushStatus(
1017
+ reviewRunLabel({ created, resumed: input.resumed === true, pass: 1 }),
1018
+ );
1019
+ return child.prompt(pass1Prompt);
1020
+ });
821
1021
 
822
1022
  if (p1.stop === "error") {
823
1023
  await abortChild();
@@ -872,7 +1072,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
872
1072
  // The protocol completed, the run did not: stop paying for it, but keep
873
1073
  // the submission — never discard real work over a slow model.
874
1074
  deps.log(
875
- "Review Mode pass 1 exceeded its time limit after submitting — using the submitted plan.",
1075
+ "pass 1 exceeded its time limit after submitting — using the submitted plan.",
876
1076
  "warning",
877
1077
  );
878
1078
  await abortChild();
@@ -897,9 +1097,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
897
1097
  rewrittenPlan: p1.submitted.planBody,
898
1098
  findings: state?.findings ?? [],
899
1099
  });
900
- const p2 = await runPass(2, deadline, rc, () =>
901
- child.followUp(pass2Prompt),
902
- );
1100
+ const p2 = await runPass(2, deadline, rc, () => {
1101
+ pushStatus(reviewRunLabel({ created, pass: 2 }));
1102
+ return child.followUp(pass2Prompt);
1103
+ });
903
1104
  // Name the provider when the verification pass died on one; pass 2
904
1105
  // stays best-effort either way — pass 1's plan is never lost to it.
905
1106
  const p2Provider = lastProviderError
@@ -909,8 +1110,8 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
909
1110
  // Verification is an enhancement: never lose pass 1's plan over it.
910
1111
  deps.log(
911
1112
  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.`,
1113
+ ? `verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
1114
+ : `verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
914
1115
  "warning",
915
1116
  );
916
1117
  } else if (p2.submitted) {
@@ -919,7 +1120,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
919
1120
  // The run settled without a submission AND a provider error was
920
1121
  // seen this pass: say why, then keep going with pass 1's rewrite.
921
1122
  deps.log(
922
- `Review Mode verification pass ended without a submission (${p2Provider}) — keeping pass 1's plan.`,
1123
+ `verification pass ended without a submission (${p2Provider}) — keeping pass 1's plan.`,
923
1124
  "warning",
924
1125
  );
925
1126
  }
@@ -949,6 +1150,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
949
1150
  `review round failed: ${e instanceof Error ? e.message : String(e)}`,
950
1151
  );
951
1152
  } finally {
1153
+ try {
1154
+ detachExternal?.();
1155
+ } catch {
1156
+ /* listener cleanup is best-effort */
1157
+ }
952
1158
  abortController = undefined;
953
1159
  roundActive = false;
954
1160
  }
@@ -1008,27 +1214,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
1008
1214
  }
1009
1215
  },
1010
1216
  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
- }
1217
+ await disposeSession();
1032
1218
  },
1033
1219
  };
1034
1220
  }
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")];