@dieulc/workflow 0.9.1 → 0.9.2

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/index.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  normalizeStepText,
57
57
  todoKey,
58
58
  isPlanWritePath,
59
+ samePlanPath,
59
60
  findPlanWritePathInMessages,
60
61
  findLatestPlanWriteInBranch,
61
62
  choosePlanPath,
@@ -88,8 +89,10 @@ import {
88
89
  } from "./utils.ts";
89
90
  import {
90
91
  WORKFLOW_ROLE_ENV,
92
+ describeCommandPolicy,
91
93
  explainCommandRefusal,
92
94
  gitCommitPolicyHit,
95
+ permissionsArgumentCompletions,
93
96
  policyForRole,
94
97
  resolveSessionRole,
95
98
  subagentEnv,
@@ -238,13 +241,14 @@ import {
238
241
  planHash,
239
242
  readReviewModeConfig,
240
243
  resolveReviewPrompt,
241
- reviewFooterSegment,
244
+ nextReviewRound,
242
245
  reviewGatePhaseOnResume,
243
246
  reviewModeLockFooter,
244
247
  reviewModeLockKind,
245
248
  reviewModeLockNotice,
246
249
  reviewModeLockStatusLine,
247
250
  reviewNoticeText,
251
+ reviewPauseIsCurrent,
248
252
  reviewResumeGateOutcome,
249
253
  reviewResumeNotice,
250
254
  reviewResumePlanChoice,
@@ -252,7 +256,7 @@ import {
252
256
  reviewHandoffParks,
253
257
  reviewResumeSettles,
254
258
  reviewStateSummary,
255
- reviewStatusVisible,
259
+ reviewStatusText,
256
260
  shouldRouteInputToReviewer,
257
261
  writeReviewModeConfig,
258
262
  writeReviewPrompt,
@@ -1322,6 +1326,16 @@ export default function workflowExtension(
1322
1326
  let lastReviewedPlanHash: string | undefined;
1323
1327
  /** 1-based round within the current user-feedback cycle. */
1324
1328
  let reviewRound = 0;
1329
+ /**
1330
+ * Monotonic cycle token. A fresh prompt, an explicit refine/deny, or Build
1331
+ * entry starts a new cycle; async handoffs capture it so a superseded cycle
1332
+ * can never render, apply todos, or clear a newer gate.
1333
+ */
1334
+ let reviewCycleId = 0;
1335
+ /** Plan target the current review cycle belongs to (absolute, or null). */
1336
+ let reviewCyclePlanPath: string | null = null;
1337
+ /** Cycle that owns the parked gate; `0` when no gate is owned. */
1338
+ let reviewGateCycleId = 0;
1325
1339
  /** Findings carried into the next round so the reviewer can verify fixes. */
1326
1340
  let reviewPreviousFindings: ReviewFinding[] | undefined;
1327
1341
  /**
@@ -1343,9 +1357,22 @@ export default function workflowExtension(
1343
1357
  fileHashAtPause?: string;
1344
1358
  round: number;
1345
1359
  previousFindings: ReviewFinding[] | undefined;
1360
+ /** Cycle that recorded the pause; stale cycles are ignored. */
1361
+ cycleId: number;
1346
1362
  at: number;
1347
1363
  }
1348
1364
  | undefined;
1365
+
1366
+ /** The pause only counts while it belongs to the current review cycle. */
1367
+ function currentPausedReview(): typeof pausedReview {
1368
+ if (!pausedReview) return undefined;
1369
+ return reviewPauseIsCurrent({
1370
+ pauseCycle: pausedReview.cycleId,
1371
+ currentCycle: reviewCycleId,
1372
+ })
1373
+ ? pausedReview
1374
+ : undefined;
1375
+ }
1349
1376
  /** In-flight round input (set while `runRound` awaits) — the pause source. */
1350
1377
  let activeReview:
1351
1378
  | { planText: string; round: number; controller: AbortController }
@@ -1384,8 +1411,10 @@ export default function workflowExtension(
1384
1411
  */
1385
1412
  function reviewModeLockKindNow(): ReviewModeLockKind | undefined {
1386
1413
  const roundInFlight = activeReview !== undefined;
1387
- const paused = pausedReview !== undefined;
1388
- const gatePhase = reviewGatePhase;
1414
+ const paused = currentPausedReview() !== undefined;
1415
+ // A gate from a superseded cycle must not keep the modes locked.
1416
+ const gatePhase =
1417
+ reviewGateCycleId === reviewCycleId ? reviewGatePhase : "idle";
1389
1418
  let roundActive = false;
1390
1419
  try {
1391
1420
  roundActive = reviewRuntime?.isRoundActive() === true;
@@ -1805,23 +1834,24 @@ export default function workflowExtension(
1805
1834
  */
1806
1835
  function updateReviewStatus(ctx: ExtensionContext): void {
1807
1836
  try {
1808
- const paused = pausedReview !== undefined;
1837
+ const paused = currentPausedReview() !== undefined;
1809
1838
  let roundActive = false;
1810
1839
  try {
1811
1840
  roundActive = reviewRuntime?.isRoundActive() === true;
1812
1841
  } catch (_e) {
1813
1842
  void _e;
1814
1843
  }
1815
- const visible = reviewStatusVisible({
1844
+ // The review status is a LIVE indicator, not a result holder: the last
1845
+ // round's state stays for `/review-status` and the REVIEW SUMMARY band,
1846
+ // but the footer segment must disappear as soon as the round ends. A
1847
+ // retained terminal phase is not painted during a new round's cold boot,
1848
+ // and a pause from an old cycle is ignored by `currentPausedReview`.
1849
+ const reviewText = reviewStatusText({
1850
+ state: reviewState,
1816
1851
  roundInFlight: activeReview !== undefined,
1817
1852
  roundActive,
1818
1853
  paused,
1819
1854
  });
1820
- // The review status is a LIVE indicator, not a result holder: the last
1821
- // round's state stays for `/review-status` and the REVIEW SUMMARY band,
1822
- // but the footer segment must disappear as soon as the round ends. While
1823
- // paused it shows the paused form (never the terminal "aborted" summary).
1824
- const reviewText = visible ? reviewFooterSegment(reviewState, paused) : "";
1825
1855
  let lockText = "";
1826
1856
  try {
1827
1857
  lockText = reviewModeLockFooter(reviewModeLockKindNow());
@@ -1996,7 +2026,7 @@ export default function workflowExtension(
1996
2026
  previousFindings: ReviewFinding[] | undefined;
1997
2027
  }): boolean {
1998
2028
  const hash = planHash(input.planText);
1999
- if (pausedReview?.planHash === hash) return false;
2029
+ if (currentPausedReview()?.planHash === hash) return false;
2000
2030
  // Snapshot the file as well as the text: the reviewer may already have
2001
2031
  // written its rewrite, and that must not look like an author edit later.
2002
2032
  const currentFile = readCurrentPlanText(reviewCwd());
@@ -2007,6 +2037,7 @@ export default function workflowExtension(
2007
2037
  currentFile !== undefined ? planHash(currentFile) : undefined,
2008
2038
  round: input.round,
2009
2039
  previousFindings: input.previousFindings,
2040
+ cycleId: reviewCycleId,
2010
2041
  at: Date.now(),
2011
2042
  };
2012
2043
  // A gated audit becomes a paused gate (the handoff keeps waiting). A
@@ -2059,7 +2090,7 @@ export default function workflowExtension(
2059
2090
  roundActive:
2060
2091
  activeReview !== undefined ||
2061
2092
  reviewRuntime?.isRoundActive() === true,
2062
- paused: pausedReview !== undefined,
2093
+ paused: currentPausedReview() !== undefined,
2063
2094
  gatedPause: reviewGatePhase === "paused",
2064
2095
  });
2065
2096
  if (action === "pause") {
@@ -2173,6 +2204,7 @@ export default function workflowExtension(
2173
2204
  // The parked handoff only awaits the gate; it does not re-read the phase
2174
2205
  // after this point.
2175
2206
  reviewGatePhase = "idle";
2207
+ reviewGateCycleId = 0;
2176
2208
  } catch (_e) {
2177
2209
  void _e;
2178
2210
  }
@@ -2254,17 +2286,18 @@ export default function workflowExtension(
2254
2286
  // `review_submit_plan` that landed in that window changed the file after
2255
2287
  // the snapshot, so refresh it: otherwise `/review-resume` would read the
2256
2288
  // reviewer's own late write as an author edit and start a fresh round.
2257
- if (pausedReview?.planHash === hash) {
2289
+ const pausedNow = currentPausedReview();
2290
+ if (pausedNow?.planHash === hash) {
2258
2291
  const currentFile = readCurrentPlanText(reviewCwd());
2259
2292
  if (currentFile !== undefined) {
2260
- pausedReview.fileHashAtPause = planHash(currentFile);
2293
+ pausedNow.fileHashAtPause = planHash(currentFile);
2261
2294
  }
2262
2295
  }
2263
2296
  reviewState = res.state;
2264
2297
  updateStatus(ctx);
2265
2298
  // The snapshot is the ONLY pause signal. A pre-aborted host signal or a
2266
2299
  // host abort without this snapshot is an abort, never a pause.
2267
- const pausedHere = pausedReview?.planHash === hash;
2300
+ const pausedHere = pausedNow?.planHash === hash;
2268
2301
  if (res.ok) {
2269
2302
  lastReviewedPlanHash = res.planHash;
2270
2303
  abortedReviewHash = undefined;
@@ -2363,7 +2396,7 @@ export default function workflowExtension(
2363
2396
  detachHostSignal = undefined;
2364
2397
  // A pause keeps its own working message (pauseReviewRound sets it
2365
2398
  // after the abort lands); every other exit restores the default.
2366
- if (!(pausedReview && pausedReview.planHash === hash)) {
2399
+ if (!(currentPausedReview()?.planHash === hash)) {
2367
2400
  setReviewWorkingMessage(undefined);
2368
2401
  }
2369
2402
  // Ownership ended: refresh both surfaces now that `activeReview` is
@@ -2422,6 +2455,21 @@ export default function workflowExtension(
2422
2455
  };
2423
2456
  }
2424
2457
 
2458
+ // A fresh automatic round supersedes any paused one. Even when this
2459
+ // decision is skipped below, no stale pause/line may survive it.
2460
+ if (trigger === "auto" && pausedReview !== undefined) {
2461
+ pausedReview = undefined;
2462
+ try {
2463
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
2464
+ } catch (_e) {
2465
+ void _e;
2466
+ }
2467
+ }
2468
+
2469
+ // A different plan file is a new target even inside one user run: reset the
2470
+ // budget/hashes/findings before the dedupe gates below.
2471
+ const cycleChanged = notePlanTargetForReviewCycle(currentPlanFile);
2472
+
2425
2473
  // Already reviewed this exact revision — do not re-review on re-emit.
2426
2474
  if (hash === lastReviewedPlanHash) {
2427
2475
  return {
@@ -2441,8 +2489,13 @@ export default function workflowExtension(
2441
2489
  };
2442
2490
  }
2443
2491
 
2444
- reviewRound += 1;
2445
- if (reviewRound > cfg.rounds) {
2492
+ const budget = nextReviewRound({
2493
+ round: reviewRound,
2494
+ maxRounds: cfg.rounds,
2495
+ newCycle: cycleChanged,
2496
+ });
2497
+ reviewRound = budget.round;
2498
+ if (!budget.allowed) {
2446
2499
  // Round cap reached: hand over with the outstanding findings visible.
2447
2500
  const unresolved = (reviewState?.findings ?? []).filter(
2448
2501
  (f) => f.disposition !== "rejected",
@@ -2481,6 +2534,11 @@ export default function workflowExtension(
2481
2534
 
2482
2535
  // A fresh round supersedes any paused one (resume uses its own path).
2483
2536
  pausedReview = undefined;
2537
+ try {
2538
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
2539
+ } catch (_e) {
2540
+ void _e;
2541
+ }
2484
2542
 
2485
2543
  try {
2486
2544
  return await executeReviewRound({
@@ -2549,6 +2607,127 @@ export default function workflowExtension(
2549
2607
  if (currentSessionCtx) updateStatus(currentSessionCtx);
2550
2608
  }
2551
2609
 
2610
+ type ReviewCycleReason =
2611
+ | "prompt"
2612
+ | "refine"
2613
+ | "deny"
2614
+ | "build"
2615
+ | "session";
2616
+
2617
+ /**
2618
+ * Start a new review cycle: one plan-authoring/feedback sequence.
2619
+ *
2620
+ * Every fresh prompt, explicit refine/deny, and Build entry owns a cycle.
2621
+ * The cycle token invalidates an old `runHandoff` continuation, the budget
2622
+ * restarts, and an abandoned pause/gate is cancelled (D2) so it cannot keep
2623
+ * the footer line, lock the modes, or short-circuit the next `agent_end`.
2624
+ * Findings survive a same-plan refinement and are cleared for a new file.
2625
+ * Never throws: a broken review surface must not block the prompt.
2626
+ */
2627
+ async function beginReviewCycle(opts: {
2628
+ reason: ReviewCycleReason;
2629
+ keepFindings?: boolean;
2630
+ planPath?: string | null;
2631
+ }): Promise<void> {
2632
+ const hadActive = activeReview !== undefined;
2633
+ const hadPause = currentPausedReview() !== undefined;
2634
+ const hadGate = reviewGatePhase !== "idle";
2635
+ // Synchronously first: any old handoff continuation is stale from here on.
2636
+ reviewCycleId += 1;
2637
+ if (hadActive || pausedReview !== undefined || hadGate) {
2638
+ try {
2639
+ await suspendReview();
2640
+ } catch (_e) {
2641
+ void _e;
2642
+ }
2643
+ }
2644
+ reviewRound = 0;
2645
+ lastReviewedPlanHash = undefined;
2646
+ abortedReviewHash = undefined;
2647
+ if (!hadActive) quietAbortHash = undefined;
2648
+
2649
+ const rawNext = opts.planPath ?? currentPlanFile ?? null;
2650
+ let next: string | null = null;
2651
+ if (rawNext) {
2652
+ try {
2653
+ next = path.isAbsolute(rawNext)
2654
+ ? rawNext
2655
+ : path.join(reviewCwd(), rawNext);
2656
+ } catch (_e) {
2657
+ void _e;
2658
+ next = rawNext;
2659
+ }
2660
+ }
2661
+ const pathChanged =
2662
+ reviewCyclePlanPath !== null &&
2663
+ next !== null &&
2664
+ !samePlanPath(next, reviewCyclePlanPath, reviewCwd());
2665
+ if (pathChanged || opts.keepFindings === false) {
2666
+ reviewPreviousFindings = undefined;
2667
+ }
2668
+ reviewCyclePlanPath = next;
2669
+ reviewGateCycleId = 0;
2670
+
2671
+ if (hadPause || hadGate) {
2672
+ notifyReview(
2673
+ "Paused review abandoned — new work started; the paused round will not resume.",
2674
+ "info",
2675
+ );
2676
+ }
2677
+ try {
2678
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
2679
+ } catch (_e) {
2680
+ void _e;
2681
+ }
2682
+ try {
2683
+ if (reviewEnabled() || hadActive || hadPause || hadGate || reviewState) {
2684
+ pi.appendEntry("workflow-review-cycle", {
2685
+ reason: opts.reason,
2686
+ cycleId: reviewCycleId,
2687
+ planPath: reviewCyclePlanPath,
2688
+ at: Date.now(),
2689
+ });
2690
+ }
2691
+ } catch (_e) {
2692
+ void _e;
2693
+ }
2694
+ }
2695
+
2696
+ /**
2697
+ * A different plan file is a new review target even inside one user run.
2698
+ * Resets the cycle budget and prior findings, but not the cycle token: the
2699
+ * handoff that owns this run must stay current.
2700
+ */
2701
+ function notePlanTargetForReviewCycle(
2702
+ rawPath: string | null | undefined,
2703
+ ): boolean {
2704
+ if (!rawPath) return false;
2705
+ const cwd = reviewCwd();
2706
+ let next: string;
2707
+ try {
2708
+ next = path.isAbsolute(rawPath) ? rawPath : path.join(cwd, rawPath);
2709
+ } catch (_e) {
2710
+ void _e;
2711
+ next = rawPath;
2712
+ }
2713
+ if (reviewCyclePlanPath === null) {
2714
+ reviewCyclePlanPath = next;
2715
+ return false;
2716
+ }
2717
+ if (samePlanPath(next, reviewCyclePlanPath, cwd)) return false;
2718
+ reviewCyclePlanPath = next;
2719
+ reviewRound = 0;
2720
+ lastReviewedPlanHash = undefined;
2721
+ abortedReviewHash = undefined;
2722
+ reviewPreviousFindings = undefined;
2723
+ try {
2724
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
2725
+ } catch (_e) {
2726
+ void _e;
2727
+ }
2728
+ return true;
2729
+ }
2730
+
2552
2731
  /**
2553
2732
  * Stop the reviewer and clear all Review Mode state. Never throws.
2554
2733
  *
@@ -2930,6 +3109,26 @@ export default function workflowExtension(
2930
3109
  writePolicyArgumentCompletions(prefix),
2931
3110
  });
2932
3111
 
3112
+ // ── /permissions: explain a command's verdict (diagnostics) ────────
3113
+ //
3114
+ // Answers "why was that blocked?" (or "would it be?") without touching a
3115
+ // gate: it reads the same analyzeCommand/explainCommandRefusal the gates
3116
+ // use, so the explanation cannot describe a policy the gate no longer
3117
+ // enforces. Read-only, so it is safe in every mode.
3118
+ pi.registerCommand("permissions", {
3119
+ description:
3120
+ "Explain why a shell command would be allowed or refused: /permissions explain <command>",
3121
+ handler: async (args, ctx) => {
3122
+ const raw = String(args ?? "").trim();
3123
+ const first = raw.split(/\s+/)[0]?.toLowerCase() ?? "";
3124
+ // `/permissions explain <cmd>` and `/permissions <cmd>` both work.
3125
+ const command = first === "explain" ? raw.slice("explain".length).trim() : raw;
3126
+ ctx.ui.notify(describeCommandPolicy(command).join("\n"), "info");
3127
+ },
3128
+ getArgumentCompletions: (prefix: string) =>
3129
+ permissionsArgumentCompletions(prefix),
3130
+ });
3131
+
2933
3132
  pi.registerCommand("review-prompt", {
2934
3133
  description:
2935
3134
  "Edit the reviewer's guidance (framework alignment / best practice) used by Review Mode",
@@ -2974,10 +3173,11 @@ export default function workflowExtension(
2974
3173
  const s = reviewState;
2975
3174
  const lockKind = reviewModeLockKindNow();
2976
3175
  if (!s) {
2977
- if (pausedReview) {
3176
+ const paused = currentPausedReview();
3177
+ if (paused) {
2978
3178
  ctx.ui.notify(
2979
3179
  [
2980
- `Review paused at round ${pausedReview.round} (plan hash ${pausedReview.planHash})`,
3180
+ `Review paused at round ${paused.round} (plan hash ${paused.planHash})`,
2981
3181
  " /review-resume to continue, /review-abort to stop.",
2982
3182
  reviewModeLockStatusLine(lockKind),
2983
3183
  ]
@@ -3015,8 +3215,8 @@ export default function workflowExtension(
3015
3215
  ` explore : ${s.exploreUsed} subagent(s) tokens≈${s.tokenEstimate ?? "n/a"}`,
3016
3216
  s.verdict ? ` verdict : ${s.verdict}` : "",
3017
3217
  s.error ? ` error : ${s.error}` : "",
3018
- pausedReview
3019
- ? ` paused : round ${pausedReview.round} — /review-resume to continue, /review-abort to stop`
3218
+ currentPausedReview()
3219
+ ? ` paused : round ${currentPausedReview()!.round} — /review-resume to continue, /review-abort to stop`
3020
3220
  : "",
3021
3221
  abortedReviewHash
3022
3222
  ? ` note : revision ${abortedReviewHash} was aborted — it will not be auto-reviewed until the plan changes`
@@ -3181,6 +3381,7 @@ export default function workflowExtension(
3181
3381
  }
3182
3382
  currentPlanFile = target.abs;
3183
3383
  lastPlanWritePath = target.abs;
3384
+ notePlanTargetForReviewCycle(target.abs);
3184
3385
  persistState();
3185
3386
  lastReviewedPlanHash = undefined; // force a fresh review
3186
3387
  abortedReviewHash = undefined;
@@ -3209,7 +3410,7 @@ export default function workflowExtension(
3209
3410
  // Gate only on round ownership below — no `enabled` check here.
3210
3411
  if (!activeReview) {
3211
3412
  ctx.ui.notify(
3212
- pausedReview
3413
+ currentPausedReview()
3213
3414
  ? "Review is already paused — /review-resume to continue, /review-abort to stop."
3214
3415
  : "No review round running.",
3215
3416
  "info",
@@ -3229,7 +3430,7 @@ export default function workflowExtension(
3229
3430
  handler: async (_args, ctx) => {
3230
3431
  // Manual control: resume works whether or not Review Mode is on.
3231
3432
  // Gate only on paused-session state below — no `enabled` check here.
3232
- const paused = pausedReview;
3433
+ const paused = currentPausedReview();
3233
3434
  if (!paused) {
3234
3435
  ctx.ui.notify("No paused review — nothing to resume.", "info");
3235
3436
  return;
@@ -3323,17 +3524,18 @@ export default function workflowExtension(
3323
3524
  const rt = reviewRuntime;
3324
3525
  const active =
3325
3526
  activeReview !== undefined || rt?.isRoundActive() === true;
3326
- if (!active && !pausedReview) {
3527
+ const paused = currentPausedReview();
3528
+ if (!active && !paused) {
3327
3529
  ctx.ui.notify("No review running or paused.", "info");
3328
3530
  return;
3329
3531
  }
3330
3532
  const targetHash =
3331
- pausedReview?.planHash ??
3533
+ paused?.planHash ??
3332
3534
  (activeReview ? planHash(activeReview.planText) : undefined);
3333
3535
  // Text the gate hands off when one is open (resolved below after the
3334
3536
  // abort lands; falls back to the author's draft when unknown).
3335
3537
  const abortedText =
3336
- pausedReview?.planText ?? activeReview?.planText;
3538
+ paused?.planText ?? activeReview?.planText;
3337
3539
  pausedReview = undefined;
3338
3540
  // An aborted revision is not auto-reviewed again until the plan changes.
3339
3541
  if (targetHash) abortedReviewHash = targetHash;
@@ -3920,8 +4122,14 @@ export default function workflowExtension(
3920
4122
  updateStatus(ctx);
3921
4123
  persistState();
3922
4124
  // Tab-to-Build leaves Plan behind; suspend review activity but keep the
3923
- // reviewer session warm for the next round (step 10).
4125
+ // reviewer session warm for the next round (step 10). End the review cycle
4126
+ // so its budget/findings cannot leak into the next Plan round.
3924
4127
  await suspendReview();
4128
+ try {
4129
+ await beginReviewCycle({ reason: "build", keepFindings: false });
4130
+ } catch (_e) {
4131
+ void _e;
4132
+ }
3925
4133
  }
3926
4134
 
3927
4135
  async function setDefaultMode(
@@ -4067,6 +4275,15 @@ export default function workflowExtension(
4067
4275
  // user just approved (step 10).
4068
4276
  await suspendReview();
4069
4277
  currentPlanFile = planPath;
4278
+ try {
4279
+ await beginReviewCycle({
4280
+ reason: "build",
4281
+ keepFindings: false,
4282
+ planPath,
4283
+ });
4284
+ } catch (_e) {
4285
+ void _e;
4286
+ }
4070
4287
  pi.setActiveTools(
4071
4288
  toolsBeforePlanMode ?? getBuildTools(pi.getActiveTools()),
4072
4289
  );
@@ -4205,7 +4422,7 @@ export default function workflowExtension(
4205
4422
  }
4206
4423
  }
4207
4424
 
4208
- function handlePlannotatorDeny(feedback: string) {
4425
+ async function handlePlannotatorDeny(feedback: string) {
4209
4426
  const ctx = getActiveCtx();
4210
4427
  if (!ctx) return;
4211
4428
  // Not a mode transition, but while a review owns the turn every browser
@@ -4217,8 +4434,7 @@ export default function workflowExtension(
4217
4434
  // Feedback cycle: the next plan revision gets a fresh review round, and the
4218
4435
  // outstanding findings are carried forward so the reviewer can confirm they
4219
4436
  // were actually addressed rather than re-raising them.
4220
- reviewRound = 0;
4221
- lastReviewedPlanHash = undefined;
4437
+ await beginReviewCycle({ reason: "deny", keepFindings: true });
4222
4438
  persistState();
4223
4439
  try {
4224
4440
  const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
@@ -4295,7 +4511,9 @@ export default function workflowExtension(
4295
4511
  (getActiveCtx() as any)?.cwd,
4296
4512
  );
4297
4513
  } else {
4298
- handlePlannotatorDeny(event.feedback || "Plan needs revision.");
4514
+ void handlePlannotatorDeny(
4515
+ event.feedback || "Plan needs revision.",
4516
+ );
4299
4517
  }
4300
4518
  } catch (_e) {
4301
4519
  void _e;
@@ -9485,6 +9703,18 @@ export default function workflowExtension(
9485
9703
  } catch (_e) {
9486
9704
  void _e;
9487
9705
  }
9706
+ // Every fresh prompt is a new review cycle: reset the round budget, cancel
9707
+ // an abandoned pause/gate, and tag the cycle so an old handoff cannot
9708
+ // render after this prompt.
9709
+ try {
9710
+ await beginReviewCycle({
9711
+ reason: "prompt",
9712
+ keepFindings: true,
9713
+ planPath: currentPlanFile,
9714
+ });
9715
+ } catch (_e) {
9716
+ void _e;
9717
+ }
9488
9718
  // Capture header for next checkpoint commit message (first line of prompt)
9489
9719
  try {
9490
9720
  const raw = String((event as any).prompt ?? (event as any).text ?? "");
@@ -9769,6 +9999,7 @@ export default function workflowExtension(
9769
9999
  // The reviewer's `review_submit_plan` writes this path; a manual
9770
10000
  // `/review` must also audit the file the user just wrote.
9771
10001
  currentPlanFile = abs;
10002
+ notePlanTargetForReviewCycle(abs);
9772
10003
  // A run that starts in Default/Build and switches into Plan mid-run
9773
10004
  // still qualifies: what matters is the mode at write time.
9774
10005
  if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
@@ -9779,6 +10010,7 @@ export default function workflowExtension(
9779
10010
  planWritePathThisRun = raw;
9780
10011
  planWrittenThisRun = true;
9781
10012
  currentPlanFile = raw;
10013
+ notePlanTargetForReviewCycle(raw);
9782
10014
  if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
9783
10015
  }
9784
10016
  try {
@@ -9799,17 +10031,29 @@ export default function workflowExtension(
9799
10031
  // (Checked before the mutex: awaiting the in-flight handoff would block
9800
10032
  // for the whole review instead of short-circuiting.)
9801
10033
  if (reviewGatePhase !== "idle") {
9802
- try {
9803
- ctx.ui.notify(
9804
- reviewGatePhase === "paused"
9805
- ? "Review paused — /review-resume to continue, /review-abort to hand off the plan as-is."
9806
- : "Review running — ESC pauses it, /review-abort hands off the plan as-is.",
9807
- "info",
9808
- );
9809
- } catch (_e) {
9810
- void _e;
10034
+ // A gate from a superseded cycle must not swallow this run's handoff.
10035
+ // Settle it with the old pause text (the old runHandoff already holds the
10036
+ // author's plan) and continue as if it were idle.
10037
+ if (reviewGateCycleId !== reviewCycleId) {
10038
+ settleReviewGate({
10039
+ outcome: "unreviewed",
10040
+ planText: currentPausedReview()?.planText,
10041
+ });
10042
+ reviewGatePhase = "idle";
10043
+ reviewGateCycleId = 0;
10044
+ } else {
10045
+ try {
10046
+ ctx.ui.notify(
10047
+ reviewGatePhase === "paused"
10048
+ ? "Review paused — /review-resume to continue, /review-abort to hand off the plan as-is."
10049
+ : "Review running — ESC pauses it, /review-abort hands off the plan as-is.",
10050
+ "info",
10051
+ );
10052
+ } catch (_e) {
10053
+ void _e;
10054
+ }
10055
+ return;
9811
10056
  }
9812
- return;
9813
10057
  }
9814
10058
  // Mutex: serialize concurrent agent_end handoffs (bus may emit Promise.all)
9815
10059
  if (handoffInFlight) {
@@ -9831,6 +10075,10 @@ export default function workflowExtension(
9831
10075
  }
9832
10076
  }
9833
10077
  const runHandoff = async () => {
10078
+ // Capture the cycle that owns this handoff. A fresh prompt/refine/deny
10079
+ // increments `reviewCycleId`; every later continuation must stop instead
10080
+ // of rendering or applying a superseded plan.
10081
+ const handoffCycle = reviewCycleId;
9834
10082
  // Per-run handoff gate (plan 2026-10-25 follow-up): a Plan-mode run
9835
10083
  // that wrote no plan this run must not reconcile, review, or hand off
9836
10084
  // an older plan. This runs before the reviewer prefetch so a new task
@@ -9862,6 +10110,7 @@ export default function workflowExtension(
9862
10110
  : path.join(cwd, planWritePathThisRun);
9863
10111
  currentPlanFile = abs;
9864
10112
  lastPlanWritePath = abs;
10113
+ notePlanTargetForReviewCycle(abs);
9865
10114
  // Persist once per run even when the tool_result handler already set
9866
10115
  // the in-memory target: resume must find the plan without a mode
9867
10116
  // switch or a todo update happening to persist it first.
@@ -10217,6 +10466,7 @@ export default function workflowExtension(
10217
10466
  const authorPlan: string = planTextForRender;
10218
10467
  reviewHandoffGate.reset();
10219
10468
  reviewGatePhase = "auditing";
10469
+ reviewGateCycleId = handoffCycle;
10220
10470
  try {
10221
10471
  const reviewed = await runReviewStep(
10222
10472
  authorPlan,
@@ -10224,6 +10474,7 @@ export default function workflowExtension(
10224
10474
  (ctx as any).signal as AbortSignal | undefined,
10225
10475
  "auto",
10226
10476
  );
10477
+ if (handoffCycle !== reviewCycleId) return;
10227
10478
  const reviewedText = reviewed.planText;
10228
10479
  // Fresh read: the phase may have moved to "paused" (ESC /
10229
10480
  // /review-pause) while the round was returning. The `as` widens
@@ -10233,7 +10484,7 @@ export default function workflowExtension(
10233
10484
  if (
10234
10485
  reviewHandoffParks({
10235
10486
  phase: phaseAfterRound,
10236
- paused: pausedReview !== undefined,
10487
+ paused: currentPausedReview() !== undefined,
10237
10488
  settled: reviewHandoffGate.settled(),
10238
10489
  })
10239
10490
  ) {
@@ -10246,6 +10497,7 @@ export default function workflowExtension(
10246
10497
  const res = await reviewHandoffGate.wait(
10247
10498
  (ctx as any).signal as AbortSignal | undefined,
10248
10499
  );
10500
+ if (handoffCycle !== reviewCycleId) return;
10249
10501
  if (res === undefined) {
10250
10502
  // Host abort while parked: stop the reviewer, drop the pause,
10251
10503
  // do not auto-review this revision again, and hand the author's
@@ -10290,11 +10542,15 @@ export default function workflowExtension(
10290
10542
  } catch (_e) {
10291
10543
  void _e;
10292
10544
  } finally {
10293
- reviewGatePhase = "idle";
10545
+ if (reviewGateCycleId === handoffCycle) {
10546
+ reviewGatePhase = "idle";
10547
+ reviewGateCycleId = 0;
10548
+ }
10294
10549
  }
10295
10550
  // A mode switch mid-gate (e.g. Tab-to-Build) already left plan mode:
10296
10551
  // the gate settled as skipped, so there is nothing to hand off.
10297
10552
  if (workflowMode !== "plan") return;
10553
+ if (handoffCycle !== reviewCycleId) return;
10298
10554
  }
10299
10555
  // Fallback when hasUI is false: still show handoff via followUp message (Claude Code parity for headless)
10300
10556
  if (!hasUI && planTextForRender && !awaitingDecision) {
@@ -10750,6 +11006,14 @@ export default function workflowExtension(
10750
11006
  "",
10751
11007
  );
10752
11008
  if (refinement?.trim()) {
11009
+ try {
11010
+ await beginReviewCycle({
11011
+ reason: "refine",
11012
+ keepFindings: true,
11013
+ });
11014
+ } catch (_e) {
11015
+ void _e;
11016
+ }
10753
11017
  const todoListText = formatTodoNumberedList(todoItems, {
10754
11018
  glyph: true,
10755
11019
  });