@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.ts CHANGED
@@ -78,10 +78,21 @@ export interface ReviewModeConfig {
78
78
  fallbackOnError: ReviewFallbackOnError;
79
79
  /** Max `review_explore` subagents per review round (cost ceiling). */
80
80
  exploreBudget: number;
81
+ /**
82
+ * Max chars of shared Plan Mode context in the pass-1 prompt (step 7).
83
+ * Diet default keeps the review fast; the truncation marker tells the
84
+ * reviewer to re-read via read/grep anything it relies on.
85
+ */
86
+ contextChars: number;
87
+ /** Max pruned entries handed to the context renderer. */
88
+ contextEntries: number;
89
+ /** Max chars kept per tool-result string when pruning entries. */
90
+ contextToolChars: number;
81
91
  /**
82
92
  * Optional wall-clock cap for a single review round, in milliseconds.
83
- * 0 = unlimited (default). When unlimited, a pass ends on
84
- * `review_pass_done`, the run settling, an abort, or a prompt error.
93
+ * 0 = unlimited (explicit opt-out). Default 300_000 (5 minutes, step 10):
94
+ * a hung reviewer must not hold the handoff forever; the settle race still
95
+ * ends a silent pass in seconds either way.
85
96
  */
86
97
  timeoutMs: number;
87
98
  }
@@ -92,8 +103,11 @@ export const DEFAULT_REVIEW_MODE_CONFIG: Readonly<ReviewModeConfig> = {
92
103
  passes: 2,
93
104
  verify: true,
94
105
  fallbackOnError: "skip",
95
- exploreBudget: 3,
96
- timeoutMs: 0,
106
+ exploreBudget: 1,
107
+ timeoutMs: 300_000,
108
+ contextChars: 15_000,
109
+ contextEntries: 30,
110
+ contextToolChars: 1_000,
97
111
  };
98
112
 
99
113
  const ROUNDS_MIN = 1;
@@ -104,6 +118,12 @@ const EXPLORE_MIN = 0;
104
118
  const EXPLORE_MAX = 8;
105
119
  const TIMEOUT_MIN = 0;
106
120
  const TIMEOUT_MAX = 3_600_000;
121
+ const CONTEXT_CHARS_MIN = 1_000;
122
+ const CONTEXT_CHARS_MAX = 60_000;
123
+ const CONTEXT_ENTRIES_MIN = 1;
124
+ const CONTEXT_ENTRIES_MAX = 120;
125
+ const CONTEXT_TOOL_CHARS_MIN = 200;
126
+ const CONTEXT_TOOL_CHARS_MAX = 4_000;
107
127
 
108
128
  /** A JSON object parsed from an untrusted source (settings file, fragment). */
109
129
  export type JsonObject = Record<string, unknown>;
@@ -161,6 +181,24 @@ export function coerceReviewModeConfig(
161
181
  base.exploreBudget,
162
182
  ),
163
183
  timeoutMs: clampInt(r.timeoutMs, TIMEOUT_MIN, TIMEOUT_MAX, base.timeoutMs),
184
+ contextChars: clampInt(
185
+ r.contextChars,
186
+ CONTEXT_CHARS_MIN,
187
+ CONTEXT_CHARS_MAX,
188
+ base.contextChars,
189
+ ),
190
+ contextEntries: clampInt(
191
+ r.contextEntries,
192
+ CONTEXT_ENTRIES_MIN,
193
+ CONTEXT_ENTRIES_MAX,
194
+ base.contextEntries,
195
+ ),
196
+ contextToolChars: clampInt(
197
+ r.contextToolChars,
198
+ CONTEXT_TOOL_CHARS_MIN,
199
+ CONTEXT_TOOL_CHARS_MAX,
200
+ base.contextToolChars,
201
+ ),
164
202
  };
165
203
  }
166
204
 
@@ -280,7 +318,11 @@ export function readReviewModeConfig(
280
318
  }
281
319
 
282
320
  /**
283
- * The single gate used by every Review Mode hook.
321
+ * The automatic-review gate: true only when the persisted
322
+ * `workflow.reviewMode.enabled` flag is on. This gates AUTOMATIC review
323
+ * only (background handoff kickoff, reviewer prefetch, planner prompt
324
+ * note). Manual one-shot commands (`/review`, `/review say|pause|resume|
325
+ * abort|status`) intentionally bypass this gate — see `canRunManualReview`.
284
326
  * Never throws.
285
327
  */
286
328
  export function isReviewEnabled(cwd: string, agentDir: string): boolean {
@@ -291,6 +333,31 @@ export function isReviewEnabled(cwd: string, agentDir: string): boolean {
291
333
  }
292
334
  }
293
335
 
336
+ /**
337
+ * What caused a review round: `"auto"` is the background handoff audit
338
+ * (requires the persisted `enabled` flag); `"manual"` is an explicit
339
+ * user command (`/review`, `/review-resume` fresh-plan branch) and bypasses
340
+ * only the `enabled` check — limits, model and prompt still come from saved
341
+ * config. Never persisted; the manual path never flips `enabled`.
342
+ */
343
+ export type ReviewTrigger = "auto" | "manual";
344
+
345
+ /**
346
+ * The manual-review gate: true whenever review settings are readable,
347
+ * regardless of the persisted `enabled` flag. A one-shot `/review` uses the
348
+ * saved rounds/passes/verify/budget/timeout/model as-is and never flips
349
+ * `enabled`. Returns false only when settings cannot be read at all.
350
+ * Never throws.
351
+ */
352
+ export function canRunManualReview(cwd: string, agentDir: string): boolean {
353
+ try {
354
+ readReviewModeConfig(cwd, agentDir);
355
+ return true;
356
+ } catch {
357
+ return false;
358
+ }
359
+ }
360
+
294
361
  /**
295
362
  * Read-modify-write `workflow.reviewMode` into a settings file, preserving
296
363
  * every other key in that file (Pi owns settings.json — we must not clobber it).
@@ -707,9 +774,18 @@ export function shouldRouteInputToReviewer(input: {
707
774
  reviewEnabled: boolean;
708
775
  reviewActive: boolean;
709
776
  hasReviewerSession: boolean;
777
+ /**
778
+ * Live ad-hoc session override: true while a manual one-shot review
779
+ * owns a live reviewer session (`reviewSessionLive()` in index.ts).
780
+ * Lets `/review say` and plain-chat steering reach the reviewer even
781
+ * when the persisted `enabled` flag is off. Never persisted.
782
+ */
783
+ manualSessionActive?: boolean;
710
784
  }): boolean {
785
+ const allowed =
786
+ input.reviewEnabled === true || input.manualSessionActive === true;
711
787
  return (
712
- input.reviewEnabled === true &&
788
+ allowed &&
713
789
  input.reviewActive === true &&
714
790
  input.hasReviewerSession === true
715
791
  );
@@ -747,6 +823,166 @@ export interface ReviewRoundResult {
747
823
  planHash: string;
748
824
  state: ReviewState;
749
825
  reason?: string;
826
+ /**
827
+ * The round never started because the host signal was already aborted.
828
+ * A pre-aborted signal is an abort, never a pause (see
829
+ * `classifyReviewExecution`).
830
+ */
831
+ abortedBeforeStart?: boolean;
832
+ }
833
+
834
+ /**
835
+ * How one executed review round ended. Used for user notices and for deciding
836
+ * whether a paused gated handoff settles. `"paused"` is the only outcome that
837
+ * keeps the handoff open; everything else is terminal.
838
+ */
839
+ export type ReviewExecutionOutcome =
840
+ | "rewritten"
841
+ | "unchanged"
842
+ | "paused"
843
+ | "aborted"
844
+ | "skipped"
845
+ | "fallback";
846
+
847
+ /** Result of one `/review`, `/review-resume` or gated handoff execution. */
848
+ export interface ReviewExecutionResult {
849
+ /** Plan to hand off: the reviewer's rewrite or the author's text. */
850
+ planText: string;
851
+ outcome: ReviewExecutionOutcome;
852
+ /** Human-facing reason for paused / aborted / skipped / fallback. */
853
+ reason?: string;
854
+ /** True when `pausedReview` holds this revision and the gate stays open. */
855
+ paused: boolean;
856
+ }
857
+
858
+ /**
859
+ * Classify one round outcome from the pieces the orchestrator already knows.
860
+ *
861
+ * Invariant: a pre-aborted/stale host signal (`abortedBeforeStart`) is an
862
+ * abort, never a pause. Only an explicit pause snapshot may keep the gate
863
+ * open, so `paused` is decided by the caller from `pausedReview.planHash`.
864
+ */
865
+ export function classifyReviewExecution(input: {
866
+ ok: boolean;
867
+ aborted: boolean;
868
+ abortedBeforeStart?: boolean;
869
+ paused: boolean;
870
+ planChanged: boolean;
871
+ }): ReviewExecutionOutcome {
872
+ if (input.paused === true) return "paused";
873
+ if (input.ok === true) return input.planChanged ? "rewritten" : "unchanged";
874
+ if (input.aborted === true || input.abortedBeforeStart === true) {
875
+ return "aborted";
876
+ }
877
+ return "fallback";
878
+ }
879
+
880
+ /** True when a resume outcome ends the round (everything except a re-pause). */
881
+ export function reviewResumeSettles(result: ReviewExecutionResult): boolean {
882
+ return result.outcome !== "paused";
883
+ }
884
+
885
+ /**
886
+ * Gate outcome for a settled resume. Only a real rewrite counts as reviewed;
887
+ * an unchanged plan, a skip, an abort or a fallback hands off the author's
888
+ * plan. `undefined` means "keep the gate parked" (the round paused again).
889
+ */
890
+ export function reviewResumeGateOutcome(
891
+ result: ReviewExecutionResult,
892
+ ): "reviewed" | "unreviewed" | undefined {
893
+ if (result.outcome === "paused") return undefined;
894
+ return result.outcome === "rewritten" ? "reviewed" : "unreviewed";
895
+ }
896
+
897
+ /** One accurate user-facing notice for a resume result. */
898
+ export function reviewResumeNotice(result: ReviewExecutionResult): string {
899
+ const reason = result.reason ? ` (${result.reason})` : "";
900
+ switch (result.outcome) {
901
+ case "paused":
902
+ return "Review paused again — /review-resume to continue, /review-abort to stop.";
903
+ case "rewritten":
904
+ return "Review finished — plan rewritten and saved.";
905
+ case "unchanged":
906
+ return "Review finished — the reviewer kept the plan as-is.";
907
+ case "aborted":
908
+ return "Review stopped — the author's plan stands.";
909
+ case "skipped":
910
+ return `Review skipped${reason} — the plan will be handed over as-is.`;
911
+ case "fallback":
912
+ default:
913
+ return `Review could not complete${reason} — the plan will be handed over unreviewed (run /review to retry).`;
914
+ }
915
+ }
916
+
917
+ /**
918
+ * The transcript line for the run that is about to start.
919
+ *
920
+ * `agent_start` fires for every prompt on the same child session, so it cannot
921
+ * distinguish a cold reviewer boot from a warm resume or a next round. The
922
+ * runtime knows both facts (`created`, `resumed`) at the moment it sends the
923
+ * prompt, and this helper turns them into one truthful line.
924
+ *
925
+ * `created` wins over `resumed`: a resume whose session had to be rebuilt (the
926
+ * reviewer model changed) really did start a new session, and saying so is more
927
+ * useful than claiming a warm resume that did not happen.
928
+ */
929
+ export function reviewRunLabel(input: {
930
+ /** The runtime had to create a reviewer session for this round. */
931
+ created: boolean;
932
+ /** `/review-resume` is continuing a paused round on the warm session. */
933
+ resumed?: boolean;
934
+ /** 1 for the audit pass, 2 for the self-verification pass. */
935
+ pass?: 1 | 2;
936
+ }): string {
937
+ if (input.pass === 2) return "reviewer verifying — pass 2";
938
+ if (input.created === true) {
939
+ return input.resumed === true
940
+ ? "reviewer restarted — new reviewer session"
941
+ : "reviewer started";
942
+ }
943
+ return input.resumed === true ? "reviewer resumed" : "reviewer continuing";
944
+ }
945
+
946
+ /**
947
+ * Prefix a Review Mode notice exactly once.
948
+ *
949
+ * Callers already speak in sentences ("no reviewer model configured …"), while
950
+ * a few sources carried their own `Review Mode: `/`Review Mode ` prefix. Blindly
951
+ * interpolating produced `Review Mode: Review Mode: …`. Idempotent by design:
952
+ * anything already starting with `Review Mode` is returned unchanged.
953
+ */
954
+ export function reviewNoticeText(message: string): string {
955
+ const text = String(message ?? "").trim();
956
+ if (text.length === 0) return "Review Mode: ";
957
+ return /^Review Mode\b/.test(text) ? text : `Review Mode: ${text}`;
958
+ }
959
+
960
+ /** Whether `/review-resume` may reuse the paused round or must start fresh. */
961
+ export type ReviewResumePlanChoice = "fresh" | "warm";
962
+
963
+ /**
964
+ * Decide whether a paused review can still be resumed as-is.
965
+ *
966
+ * The plan *file* is not a reliable witness on its own: the reviewer writes its
967
+ * rewrite to that same file during the round (`review_submit_plan`), so after a
968
+ * pause during pass 2 the file no longer matches the text the round started
969
+ * with. Comparing against the hash captured at pause time means only a change
970
+ * made **after** the pause — an author edit — starts a fresh round.
971
+ *
972
+ * A missing file (or a snapshot from before this field existed) keeps today's
973
+ * behaviour: resume the paused text.
974
+ */
975
+ export function reviewResumePlanChoice(input: {
976
+ /** Hash of the text the paused round was auditing. */
977
+ pausedPlanHash: string;
978
+ /** Plan-file hash captured when the pause was recorded. */
979
+ pausedFileHash?: string;
980
+ /** Plan-file hash read by `/review-resume`; undefined when the file is gone. */
981
+ currentFileHash?: string;
982
+ }): ReviewResumePlanChoice {
983
+ if (input.currentFileHash === undefined) return "warm";
984
+ const baseline = input.pausedFileHash ?? input.pausedPlanHash;
985
+ return input.currentFileHash === baseline ? "warm" : "fresh";
750
986
  }
751
987
 
752
988
  const DISPOSITIONS: readonly ReviewDisposition[] = [
@@ -926,7 +1162,7 @@ export function applySubmittedPlan(
926
1162
 
927
1163
  try {
928
1164
  options.notify?.(
929
- `Review Mode submitted the plan (${options.verdict}, ${options.findings.length} finding${
1165
+ `submitted the plan (${options.verdict}, ${options.findings.length} finding${
930
1166
  options.findings.length === 1 ? "" : "s"
931
1167
  })`,
932
1168
  "info",
@@ -993,6 +1229,10 @@ function deferredWait<T>(
993
1229
  ): Promise<T | undefined> {
994
1230
  const existing = current();
995
1231
  if (existing !== undefined) return Promise.resolve(existing);
1232
+ // A signal that is already aborted must resolve immediately: its `abort`
1233
+ // event has already fired, so a listener registered below would never run
1234
+ // and the caller (the gated handoff) would wait forever.
1235
+ if (signal?.aborted === true) return Promise.resolve(undefined);
996
1236
  return new Promise<T | undefined>((resolve) => {
997
1237
  let settled = false;
998
1238
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -1073,6 +1313,260 @@ export function createReviewRoundGate(): ReviewRoundGate {
1073
1313
  };
1074
1314
  }
1075
1315
 
1316
+ // ── Gated handoff (plan 2026-10-23 follow-up) ───────────────────────────
1317
+ //
1318
+ // The handoff waits for the review instead of racing it: `runHandoff`
1319
+ // awaits this gate, and `/review-resume` / `/review-abort` / teardown
1320
+ // settle it. A pause does NOT resolve the gate — the waiter keeps waiting
1321
+ // for the resume/abort outcome. Exactly-once: the first `resolve` wins;
1322
+ // a reload drops the gate (in-memory only), so a fresh `agent_end` simply
1323
+ // reviews again instead of rendering a phantom handoff.
1324
+
1325
+ /** Lifecycle of the gated handoff review phase (in-memory, never persisted). */
1326
+ export type ReviewHandoffPhase = "idle" | "auditing" | "paused";
1327
+
1328
+ /** How the gated review phase ended, for handoff rendering. */
1329
+ export type ReviewHandoffOutcome = "reviewed" | "unreviewed";
1330
+
1331
+ /** The settled value the handoff renderer consumes. */
1332
+ export interface ReviewHandoffResult {
1333
+ outcome: ReviewHandoffOutcome;
1334
+ /** Final plan text; `undefined` means "keep the author's draft". */
1335
+ planText: string | undefined;
1336
+ }
1337
+
1338
+ export interface ReviewHandoffGate {
1339
+ /** Clear the slot for a new gated phase. */
1340
+ reset(): void;
1341
+ /** Settle the gate; the first call wins, later calls are no-ops. */
1342
+ resolve(result: ReviewHandoffResult): void;
1343
+ /** True once `resolve` has run. */
1344
+ settled(): boolean;
1345
+ /** Latest settled result, if any (read-only peek for the waiter). */
1346
+ peek(): ReviewHandoffResult | undefined;
1347
+ /**
1348
+ * Await settlement. Resolves `undefined` if the caller's signal aborts
1349
+ * first — the handoff then falls back to the author's draft.
1350
+ */
1351
+ wait(signal?: AbortSignal): Promise<ReviewHandoffResult | undefined>;
1352
+ }
1353
+
1354
+ export function createReviewHandoffGate(): ReviewHandoffGate {
1355
+ let result: ReviewHandoffResult | undefined;
1356
+ let waiters: Array<(value: ReviewHandoffResult | undefined) => void> = [];
1357
+ return {
1358
+ reset() {
1359
+ result = undefined;
1360
+ waiters = [];
1361
+ },
1362
+ resolve(r) {
1363
+ if (result !== undefined) return;
1364
+ result = r;
1365
+ for (const resolve of waiters.splice(0)) {
1366
+ try {
1367
+ resolve(r);
1368
+ } catch {
1369
+ /* ignore */
1370
+ }
1371
+ }
1372
+ },
1373
+ settled() {
1374
+ return result !== undefined;
1375
+ },
1376
+ peek() {
1377
+ return result;
1378
+ },
1379
+ wait: (signal) =>
1380
+ deferredWait<ReviewHandoffResult>(
1381
+ () => result,
1382
+ (resolve) => waiters.push(resolve),
1383
+ undefined,
1384
+ signal,
1385
+ ),
1386
+ };
1387
+ }
1388
+
1389
+ /**
1390
+ * Pure ESC-routing decision for the review input listener.
1391
+ *
1392
+ * The caller pre-filters raw input (`isKeyRelease` / `matchesKey(data,
1393
+ * Key.escape)`); this decides from round state only: pause exactly once per
1394
+ * round — a press while no round runs, or while already paused, falls
1395
+ * through so the editor, questionnaire and handoff selector keep ESC.
1396
+ */
1397
+ export function shouldPauseReviewOnEscape(input: {
1398
+ roundActive: boolean;
1399
+ paused: boolean;
1400
+ }): boolean {
1401
+ return input.roundActive === true && input.paused !== true;
1402
+ }
1403
+
1404
+ /** What the review ESC listener should do with one key press. */
1405
+ export type ReviewEscapeAction = "pause" | "swallow" | "pass";
1406
+
1407
+ /**
1408
+ * Route an ESC press while a review owns the turn.
1409
+ *
1410
+ * - A live, unpaused round pauses (exactly once per round).
1411
+ * - A paused **gated** handoff swallows ESC with a hint: letting it reach the
1412
+ * editor aborts the parked parent turn and poisons `ctx.signal` for every
1413
+ * later `/review-resume` (the 2026-10-25 stall). `/review-abort` is the
1414
+ * explicit exit.
1415
+ * - Everything else passes ESC through to the editor / questionnaire / handoff
1416
+ * selector.
1417
+ */
1418
+ export function reviewEscapeAction(input: {
1419
+ roundActive: boolean;
1420
+ paused: boolean;
1421
+ gatedPause: boolean;
1422
+ }): ReviewEscapeAction {
1423
+ if (
1424
+ shouldPauseReviewOnEscape({
1425
+ roundActive: input.roundActive,
1426
+ paused: input.paused,
1427
+ })
1428
+ ) {
1429
+ return "pause";
1430
+ }
1431
+ if (input.paused === true && input.gatedPause === true) return "swallow";
1432
+ return "pass";
1433
+ }
1434
+
1435
+ /**
1436
+ * Footer segment for a live review phase, composed into the single
1437
+ * `"workflow"` status by `updateStatus`. Empty when no review is live, so
1438
+ * callers can append unconditionally.
1439
+ */
1440
+ export function reviewFooterSegment(
1441
+ state: ReviewState | undefined,
1442
+ paused: boolean,
1443
+ ): string {
1444
+ if (paused) return "🔍 review paused — /review-resume to continue";
1445
+ if (!state) return "";
1446
+ return reviewStateSummary(state);
1447
+ }
1448
+
1449
+ /**
1450
+ * Whether the review status surfaces (footer segment and status widget) should
1451
+ * be visible at all.
1452
+ *
1453
+ * A review owns the turn only while a round is in flight (including the
1454
+ * cold-start `activeReview` window before `isRoundActive()` flips) or a pause
1455
+ * exists. `reviewState` alone is deliberately NOT enough: it is retained after
1456
+ * a round for `/review-status` and the REVIEW SUMMARY band, and using it to
1457
+ * drive the widget left a stale "🔍 review … aborted" line in Plan/Build mode.
1458
+ */
1459
+ export function reviewStatusVisible(input: {
1460
+ roundInFlight?: boolean;
1461
+ roundActive?: boolean;
1462
+ paused?: boolean;
1463
+ }): boolean {
1464
+ return (
1465
+ input.roundInFlight === true ||
1466
+ input.roundActive === true ||
1467
+ input.paused === true
1468
+ );
1469
+ }
1470
+
1471
+ // ── Mode-switch lock (plan 2026-10-25) ───────────────────────────────
1472
+ //
1473
+ // While a review owns the turn, mode transitions are refused: switching to
1474
+ // Build mid-review would swap the tool set and suspend the reviewer, and
1475
+ // switching to Default would leave the reviewer running outside Plan Mode.
1476
+ // The lock is a PROJECTION over existing state (never persisted), so every
1477
+ // existing exit — land, fail, pause/resume, abort, suspend, teardown —
1478
+ // releases it automatically. The post-review handoff prompt is deliberately
1479
+ // NOT part of the lock: the independent reviewer has already finished.
1480
+
1481
+ /** Which mode-switch lock is active, if any. Derived, never persisted. */
1482
+ export type ReviewModeLockKind = "running" | "paused";
1483
+
1484
+ export interface ReviewModeLockInput {
1485
+ /** `activeReview !== undefined` — set before the child session boots. */
1486
+ roundInFlight?: boolean;
1487
+ /** `reviewRuntime.isRoundActive()` at call time. */
1488
+ roundActive?: boolean;
1489
+ /** `pausedReview !== undefined`. */
1490
+ paused?: boolean;
1491
+ /** The gated handoff phase; `"auditing"` counts as running. */
1492
+ gatePhase?: ReviewHandoffPhase;
1493
+ }
1494
+
1495
+ /**
1496
+ * Decide whether a mode transition must be refused.
1497
+ *
1498
+ * A real pause snapshot wins over a round that is still aborting: ESC has
1499
+ * already been pressed, so the notice should tell the user how to resume.
1500
+ * A live round wins over a stale gate phase: `/review-resume` clears the
1501
+ * snapshot and starts the round before the parked handoff resets its phase,
1502
+ * so `gatePhase === "paused"` must not mask the running reviewer. A bare
1503
+ * paused gate (no round, no snapshot) is the fallback for the small window
1504
+ * between an abort and the handoff continuation.
1505
+ */
1506
+ export function reviewModeLockKind(
1507
+ input: ReviewModeLockInput,
1508
+ ): ReviewModeLockKind | undefined {
1509
+ if (input.paused === true) return "paused";
1510
+ if (input.roundInFlight === true || input.roundActive === true) {
1511
+ return "running";
1512
+ }
1513
+ if (input.gatePhase === "paused") return "paused";
1514
+ if (input.gatePhase === "auditing") return "running";
1515
+ return undefined;
1516
+ }
1517
+
1518
+ /**
1519
+ * Phase to use when `/review-resume` starts a paused gated round.
1520
+ * A gated pause becomes an active audit again; a manual review (idle gate)
1521
+ * is unchanged. This keeps a second ESC able to demote auditing → paused.
1522
+ */
1523
+ export function reviewGatePhaseOnResume(
1524
+ phase: ReviewHandoffPhase,
1525
+ ): ReviewHandoffPhase {
1526
+ return phase === "paused" ? "auditing" : phase;
1527
+ }
1528
+
1529
+ /**
1530
+ * Whether the gated handoff may park on the review gate.
1531
+ *
1532
+ * Invariant: parking requires all three — a paused phase, a pause snapshot for
1533
+ * the current revision, and an unsettled gate. A phase without a snapshot (a
1534
+ * stale/implicit pause) could never be released by `/review-resume`, so the
1535
+ * handoff must settle with the author's plan instead of waiting forever.
1536
+ */
1537
+ export function reviewHandoffParks(input: {
1538
+ phase: ReviewHandoffPhase;
1539
+ paused: boolean;
1540
+ settled: boolean;
1541
+ }): boolean {
1542
+ return (
1543
+ input.phase === "paused" &&
1544
+ input.paused === true &&
1545
+ input.settled !== true
1546
+ );
1547
+ }
1548
+
1549
+ /** One short notice for a blocked mode transition. */
1550
+ export function reviewModeLockNotice(kind: ReviewModeLockKind): string {
1551
+ return kind === "paused"
1552
+ ? "Review is paused — mode switching stays locked. /review-resume to continue, /review-abort to stop, /review-mode off to end review."
1553
+ : "Review is running — mode switching is locked. ESC or /review-pause to pause, /review-resume to continue, /review-abort to stop, /review-mode off to end review.";
1554
+ }
1555
+
1556
+ /** Footer marker appended to the single `"workflow"` status while locked. */
1557
+ export function reviewModeLockFooter(
1558
+ kind: ReviewModeLockKind | undefined,
1559
+ ): string {
1560
+ return kind ? "🔒 modes locked" : "";
1561
+ }
1562
+
1563
+ /** `/review-status` row describing the lock and its allowed exits. */
1564
+ export function reviewModeLockStatusLine(
1565
+ kind: ReviewModeLockKind | undefined,
1566
+ ): string {
1567
+ return kind ? ` lock : ${kind} — ${reviewModeLockNotice(kind)}` : "";
1568
+ }
1569
+
1076
1570
  // ── Step 16: reviewer transcript stream ──────────────────────────────
1077
1571
 
1078
1572
  /** Default cap on retained pane lines (the panes are a rolling window). */
@@ -1281,6 +1775,11 @@ export function formatActionLabel(toolName: unknown, args: unknown): string {
1281
1775
  * `auto_retry_end` or an `agent_end` that stopped on `stopReason === "error"`
1282
1776
  * appends an `error` line — a dead provider is then visible in the pane during
1283
1777
  * the review instead of resurfacing later as a mysterious "no plan" failure.
1778
+ *
1779
+ * Run-start lines are NOT produced here. `agent_start` fires for every prompt on
1780
+ * the same child session, so it cannot tell a cold boot from a warm resume or a
1781
+ * next round; the runtime emits the truthful line with `reviewRunLabel` at the
1782
+ * moment it sends the prompt.
1284
1783
  */
1285
1784
  export function translateReviewEvent(
1286
1785
  event: unknown,
@@ -1315,9 +1814,6 @@ export function translateReviewEvent(
1315
1814
  ];
1316
1815
  }
1317
1816
 
1318
- if (type === "agent_start") {
1319
- return [{ op: "new", kind: "status", text: "reviewer started" }];
1320
- }
1321
1817
  return providerErrorOps(e, type);
1322
1818
  }
1323
1819
 
@@ -1325,9 +1821,10 @@ export function translateReviewEvent(
1325
1821
  * Provider-error events → transcript lines, split out of
1326
1822
  * `translateReviewEvent` to keep the dispatcher flat:
1327
1823
  *
1328
- * - `agent_end` → status line, plus an `error` line when the run
1824
+ * - `agent_end` → neutral run-end line, plus an `error` line when the run
1329
1825
  * stopped on `stopReason === "error"` (auto-retry disabled, or the first
1330
- * failure was not retryable).
1826
+ * failure was not retryable). "Run ended" is truthful for a finished,
1827
+ * aborted or paused run alike.
1331
1828
  * - `auto_retry_start` → notice carrying the provider's message (truncated).
1332
1829
  * - `auto_retry_end` → `error` line when the retry budget is exhausted
1333
1830
  * (`success: false`); a recovered retry (`success: true`) stays silent.
@@ -1340,7 +1837,7 @@ function providerErrorOps(
1340
1837
  ): ReviewTranscriptOp[] {
1341
1838
  if (type === "agent_end") {
1342
1839
  const ops: ReviewTranscriptOp[] = [
1343
- { op: "new", kind: "status", text: "reviewer finished this run" },
1840
+ { op: "new", kind: "status", text: "reviewer run ended" },
1344
1841
  ];
1345
1842
  const failed = lastAssistantErrorStop(e.messages);
1346
1843
  if (failed) {
@@ -1484,15 +1981,18 @@ export const REVIEW_SYSTEM_PROMPT = [
1484
1981
  "",
1485
1982
  "## Tools",
1486
1983
  "",
1487
- "- `read`, `grep`, `find`, `ls` — inspect the repository freely. Always verify a",
1488
- " claim about the codebase with these before you rely on it.",
1984
+ "- `read`, `grep`, `find`, `ls` — inspect the repository freely. The pass-1",
1985
+ " prompt carries Plan Mode's file inventory and recon summaries: trust them",
1986
+ " for what they cover, and re-read the files your findings anchor (plus a",
1987
+ " spot check or two) before you cite them.",
1489
1988
  "- `review_bash` — a READ-ONLY/VERIFY shell on the same role allowlist Plan Mode",
1490
1989
  " uses (ls, cat, rg, fd, jq, diff, stat, git status/log/diff/show/branch, npm",
1491
1990
  " test, npm run lint/typecheck, eslint, tsc --noEmit, node --test, pytest, cargo",
1492
1991
  " clippy, ...). Mutating commands, redirects, installs and builds are refused.",
1493
1992
  " Verify a plan's claims by reading the code and, where useful, by running its",
1494
1993
  " tests or linters — but never assert that something passes unless you ran it.",
1495
- "- `review_explore` — delegate focused read-only reconnaissance to subagents.",
1994
+ "- `review_explore` — delegate focused read-only reconnaissance to subagents",
1995
+ " (rarely needed: the pass-1 prompt already carries Plan Mode's recon).",
1496
1996
  "- `review_submit_plan` — submit the final rewritten plan. This is the ONLY way",
1497
1997
  " your work reaches the user. A review that never submits is a failed review.",
1498
1998
  "- `review_pass_done` — end the current pass with a verdict and your findings.",