@dieulc/workflow 0.9.1 → 0.10.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/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,
@@ -160,6 +163,8 @@ import {
160
163
  roleArgumentCompletions,
161
164
  saveRoleConfig,
162
165
  getRole,
166
+ userRolesPath,
167
+ projectRolesPath,
163
168
  formatRolesForDisplay,
164
169
  formatModelRef,
165
170
  parseModelRef,
@@ -201,11 +206,17 @@ import {
201
206
  PROFILE_ROLES,
202
207
  modelPickerItems,
203
208
  profileActionClosesPicker,
209
+ userProfilesPath,
210
+ projectProfilesPath,
204
211
  type ProfileFlowOutcome,
205
212
  type ProfileConfig,
206
213
  type Profile,
207
214
  type ProfileRoleName,
208
215
  } from "./profiles.ts";
216
+ import {
217
+ formatRetargetResult,
218
+ retargetProviderFiles,
219
+ } from "./provider-retarget.ts";
209
220
  import { FilterableList, type FilterableItem } from "./picker.ts";
210
221
  import { QUESTIONNAIRE_OVERLAY_MAX_HEIGHT } from "./questionnaire-layout.ts";
211
222
  import {
@@ -229,6 +240,7 @@ import {
229
240
  } from "./model-default.ts";
230
241
  import {
231
242
  applySubmittedPlan,
243
+ buildReviewerFallbacks,
232
244
  classifyReviewExecution,
233
245
  createReviewHandoffGate,
234
246
  DEFAULT_REVIEW_MODE_CONFIG,
@@ -238,25 +250,28 @@ import {
238
250
  planHash,
239
251
  readReviewModeConfig,
240
252
  resolveReviewPrompt,
241
- reviewFooterSegment,
253
+ nextReviewRound,
242
254
  reviewGatePhaseOnResume,
243
255
  reviewModeLockFooter,
244
256
  reviewModeLockKind,
245
257
  reviewModeLockNotice,
246
258
  reviewModeLockStatusLine,
247
259
  reviewNoticeText,
260
+ reviewPauseIsCurrent,
248
261
  reviewResumeGateOutcome,
249
262
  reviewResumeNotice,
250
263
  reviewResumePlanChoice,
251
264
  reviewEscapeAction,
265
+ reviewElapsedMs,
252
266
  reviewHandoffParks,
253
267
  reviewResumeSettles,
254
268
  reviewStateSummary,
255
- reviewStatusVisible,
269
+ reviewStatusText,
256
270
  shouldRouteInputToReviewer,
257
271
  writeReviewModeConfig,
258
272
  writeReviewPrompt,
259
273
  type ReviewFinding,
274
+ type ReviewFallbackModel,
260
275
  type ReviewExecutionResult,
261
276
  type ReviewHandoffGate,
262
277
  type ReviewSdkLoader,
@@ -278,12 +293,35 @@ import {
278
293
  type ReviewLineBuffer,
279
294
  type ReviewModeBandData,
280
295
  } from "./review-ui.ts";
296
+ import {
297
+ pendingHandoffIsStale,
298
+ reviewHandoffRecovery,
299
+ shouldHandoffManualResult,
300
+ type ReviewHandoffSource,
301
+ } from "./review-handoff.ts";
281
302
  import { seedPromptHistory, WorkflowTabEditor } from "./tab-editor.ts";
282
303
 
283
304
  // ── Plannotator Bridge ───────────────────────────────────────────────
284
305
  const PLANNOTATOR_REQUEST = "plannotator:request" as const;
285
306
  const PLANNOTATOR_REVIEW_RESULT = "plannotator:review-result" as const;
286
307
  const PLANNOTATOR_PLAN_APPROVED = "plannotator:plan-approved" as const;
308
+ const PLANNOTATOR_SESSION_OPENED = "plannotator:session-opened" as const;
309
+ const PLANNOTATOR_SESSION_CLOSED = "plannotator:session-closed" as const;
310
+
311
+ /** Structural shape of the optional Plannotator lifecycle events. */
312
+ interface PlannotatorSessionEventLike {
313
+ id?: string;
314
+ action?: string;
315
+ url?: string;
316
+ reviewId?: string;
317
+ requestId?: string;
318
+ outcome?: "approved" | "submitted" | "closed";
319
+ closedBy?: string;
320
+ }
321
+
322
+ type PlanReviewRequestOutcome =
323
+ | { status: "handled"; reviewId?: string; url?: string }
324
+ | { status: "timeout" | "error" | "unavailable"; reason: string };
287
325
 
288
326
  // ── Helpers ──────────────────────────────────────────────────────────
289
327
 
@@ -495,6 +533,10 @@ function getFinalOutput(messages: any[]): string {
495
533
  */
496
534
  export interface WorkflowExtensionDeps {
497
535
  loadSdk?: ReviewSdkLoader;
536
+ /** Test seam: how long a plan-review request waits for Plannotator. */
537
+ plannotatorRequestTimeoutMs?: number;
538
+ /** Test seam: how long a reload recovery status query waits. */
539
+ plannotatorRecoveryTimeoutMs?: number;
498
540
  }
499
541
 
500
542
  export default function workflowExtension(
@@ -1322,6 +1364,16 @@ export default function workflowExtension(
1322
1364
  let lastReviewedPlanHash: string | undefined;
1323
1365
  /** 1-based round within the current user-feedback cycle. */
1324
1366
  let reviewRound = 0;
1367
+ /**
1368
+ * Monotonic cycle token. A fresh prompt, an explicit refine/deny, or Build
1369
+ * entry starts a new cycle; async handoffs capture it so a superseded cycle
1370
+ * can never render, apply todos, or clear a newer gate.
1371
+ */
1372
+ let reviewCycleId = 0;
1373
+ /** Plan target the current review cycle belongs to (absolute, or null). */
1374
+ let reviewCyclePlanPath: string | null = null;
1375
+ /** Cycle that owns the parked gate; `0` when no gate is owned. */
1376
+ let reviewGateCycleId = 0;
1325
1377
  /** Findings carried into the next round so the reviewer can verify fixes. */
1326
1378
  let reviewPreviousFindings: ReviewFinding[] | undefined;
1327
1379
  /**
@@ -1343,9 +1395,22 @@ export default function workflowExtension(
1343
1395
  fileHashAtPause?: string;
1344
1396
  round: number;
1345
1397
  previousFindings: ReviewFinding[] | undefined;
1398
+ /** Cycle that recorded the pause; stale cycles are ignored. */
1399
+ cycleId: number;
1346
1400
  at: number;
1347
1401
  }
1348
1402
  | undefined;
1403
+
1404
+ /** The pause only counts while it belongs to the current review cycle. */
1405
+ function currentPausedReview(): typeof pausedReview {
1406
+ if (!pausedReview) return undefined;
1407
+ return reviewPauseIsCurrent({
1408
+ pauseCycle: pausedReview.cycleId,
1409
+ currentCycle: reviewCycleId,
1410
+ })
1411
+ ? pausedReview
1412
+ : undefined;
1413
+ }
1349
1414
  /** In-flight round input (set while `runRound` awaits) — the pause source. */
1350
1415
  let activeReview:
1351
1416
  | { planText: string; round: number; controller: AbortController }
@@ -1384,8 +1449,10 @@ export default function workflowExtension(
1384
1449
  */
1385
1450
  function reviewModeLockKindNow(): ReviewModeLockKind | undefined {
1386
1451
  const roundInFlight = activeReview !== undefined;
1387
- const paused = pausedReview !== undefined;
1388
- const gatePhase = reviewGatePhase;
1452
+ const paused = currentPausedReview() !== undefined;
1453
+ // A gate from a superseded cycle must not keep the modes locked.
1454
+ const gatePhase =
1455
+ reviewGateCycleId === reviewCycleId ? reviewGatePhase : "idle";
1389
1456
  let roundActive = false;
1390
1457
  try {
1391
1458
  roundActive = reviewRuntime?.isRoundActive() === true;
@@ -1488,6 +1555,48 @@ export default function workflowExtension(
1488
1555
  }
1489
1556
  }
1490
1557
 
1558
+ /**
1559
+ * Automatic provider-recovery chain: the parent model that just planned,
1560
+ * then the base reviewer and planner roles from roles.json (ignoring the
1561
+ * active profile). Deduped and thinking-clamped through the parent registry.
1562
+ */
1563
+ function reviewFallbackModels(): ReviewFallbackModel[] {
1564
+ try {
1565
+ ensureRoleConfig(reviewCwd());
1566
+ ensureProfilesConfig(reviewCwd());
1567
+ const parent = currentSessionCtx?.model;
1568
+ const parentThinking = currentSessionCtx?.thinkingLevel;
1569
+ const baseReviewer = getRole(roleConfig, REVIEW_ROLE_NAME)?.model;
1570
+ const basePlanner = getRole(roleConfig, "planner")?.model;
1571
+ return buildReviewerFallbacks({
1572
+ ...(parent
1573
+ ? {
1574
+ parentModel: {
1575
+ provider: parent.provider,
1576
+ id: parent.id,
1577
+ thinking: parentThinking ?? "high",
1578
+ },
1579
+ }
1580
+ : {}),
1581
+ ...(baseReviewer ? { baseReviewer } : {}),
1582
+ ...(basePlanner ? { basePlanner } : {}),
1583
+ clampThinking: (model) => {
1584
+ const found = (currentSessionCtx as any)?.modelRegistry?.find?.(
1585
+ model.provider,
1586
+ model.id,
1587
+ );
1588
+ return clampThinkingForModel(
1589
+ found,
1590
+ isValidThinkingLevel(model.thinking) ? model.thinking : "high",
1591
+ );
1592
+ },
1593
+ });
1594
+ } catch (_e) {
1595
+ void _e;
1596
+ return [];
1597
+ }
1598
+ }
1599
+
1491
1600
  /** Reviewer model label for the mode band (best-effort). */
1492
1601
  function reviewModelLabel(): string {
1493
1602
  // While a round runs, the band names the snapshotted model — the one
@@ -1805,23 +1914,24 @@ export default function workflowExtension(
1805
1914
  */
1806
1915
  function updateReviewStatus(ctx: ExtensionContext): void {
1807
1916
  try {
1808
- const paused = pausedReview !== undefined;
1917
+ const paused = currentPausedReview() !== undefined;
1809
1918
  let roundActive = false;
1810
1919
  try {
1811
1920
  roundActive = reviewRuntime?.isRoundActive() === true;
1812
1921
  } catch (_e) {
1813
1922
  void _e;
1814
1923
  }
1815
- const visible = reviewStatusVisible({
1924
+ // The review status is a LIVE indicator, not a result holder: the last
1925
+ // round's state stays for `/review-status` and the REVIEW SUMMARY band,
1926
+ // but the footer segment must disappear as soon as the round ends. A
1927
+ // retained terminal phase is not painted during a new round's cold boot,
1928
+ // and a pause from an old cycle is ignored by `currentPausedReview`.
1929
+ const reviewText = reviewStatusText({
1930
+ state: reviewState,
1816
1931
  roundInFlight: activeReview !== undefined,
1817
1932
  roundActive,
1818
1933
  paused,
1819
1934
  });
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
1935
  let lockText = "";
1826
1936
  try {
1827
1937
  lockText = reviewModeLockFooter(reviewModeLockKindNow());
@@ -1897,6 +2007,7 @@ export default function workflowExtension(
1897
2007
  if (reviewModelSnapshot) return reviewModelSnapshot;
1898
2008
  return resolveReviewerModel();
1899
2009
  },
2010
+ fallbackReviewerModels: () => reviewFallbackModels(),
1900
2011
  findModel: (provider: string, id: string) =>
1901
2012
  (currentSessionCtx as any)?.modelRegistry?.find?.(provider, id),
1902
2013
  // Structural probe of the live parent context, evaluated at reviewer
@@ -1996,7 +2107,7 @@ export default function workflowExtension(
1996
2107
  previousFindings: ReviewFinding[] | undefined;
1997
2108
  }): boolean {
1998
2109
  const hash = planHash(input.planText);
1999
- if (pausedReview?.planHash === hash) return false;
2110
+ if (currentPausedReview()?.planHash === hash) return false;
2000
2111
  // Snapshot the file as well as the text: the reviewer may already have
2001
2112
  // written its rewrite, and that must not look like an author edit later.
2002
2113
  const currentFile = readCurrentPlanText(reviewCwd());
@@ -2007,6 +2118,7 @@ export default function workflowExtension(
2007
2118
  currentFile !== undefined ? planHash(currentFile) : undefined,
2008
2119
  round: input.round,
2009
2120
  previousFindings: input.previousFindings,
2121
+ cycleId: reviewCycleId,
2010
2122
  at: Date.now(),
2011
2123
  };
2012
2124
  // A gated audit becomes a paused gate (the handoff keeps waiting). A
@@ -2059,7 +2171,7 @@ export default function workflowExtension(
2059
2171
  roundActive:
2060
2172
  activeReview !== undefined ||
2061
2173
  reviewRuntime?.isRoundActive() === true,
2062
- paused: pausedReview !== undefined,
2174
+ paused: currentPausedReview() !== undefined,
2063
2175
  gatedPause: reviewGatePhase === "paused",
2064
2176
  });
2065
2177
  if (action === "pause") {
@@ -2149,6 +2261,9 @@ export default function workflowExtension(
2149
2261
  try {
2150
2262
  const ctx = currentSessionCtx;
2151
2263
  if (!ctx || !(ctx as any).hasUI) return;
2264
+ if (message !== undefined) {
2265
+ (ctx as any).ui?.setWorkingVisible?.(true);
2266
+ }
2152
2267
  (ctx as any).ui?.setWorkingMessage?.(message);
2153
2268
  } catch (_e) {
2154
2269
  void _e;
@@ -2173,6 +2288,7 @@ export default function workflowExtension(
2173
2288
  // The parked handoff only awaits the gate; it does not re-read the phase
2174
2289
  // after this point.
2175
2290
  reviewGatePhase = "idle";
2291
+ reviewGateCycleId = 0;
2176
2292
  } catch (_e) {
2177
2293
  void _e;
2178
2294
  }
@@ -2219,6 +2335,9 @@ export default function workflowExtension(
2219
2335
  // follow `reviewState`; the spinner row needs an explicit message while
2220
2336
  // the child session streams off the main turn.
2221
2337
  setReviewWorkingMessage("🔍 Reviewing plan…");
2338
+ // Paint the live review line now, not after the child session's first
2339
+ // state event: a manual `/review` must look alive from the first tick.
2340
+ updateStatus(ctx as any);
2222
2341
  // One long-lived listener covers this round AND the paused window (see
2223
2342
  // ensureReviewEscListener for why it is no longer attached per round).
2224
2343
  ensureReviewEscListener(ctx);
@@ -2254,17 +2373,18 @@ export default function workflowExtension(
2254
2373
  // `review_submit_plan` that landed in that window changed the file after
2255
2374
  // the snapshot, so refresh it: otherwise `/review-resume` would read the
2256
2375
  // reviewer's own late write as an author edit and start a fresh round.
2257
- if (pausedReview?.planHash === hash) {
2376
+ const pausedNow = currentPausedReview();
2377
+ if (pausedNow?.planHash === hash) {
2258
2378
  const currentFile = readCurrentPlanText(reviewCwd());
2259
2379
  if (currentFile !== undefined) {
2260
- pausedReview.fileHashAtPause = planHash(currentFile);
2380
+ pausedNow.fileHashAtPause = planHash(currentFile);
2261
2381
  }
2262
2382
  }
2263
2383
  reviewState = res.state;
2264
2384
  updateStatus(ctx);
2265
2385
  // The snapshot is the ONLY pause signal. A pre-aborted host signal or a
2266
2386
  // host abort without this snapshot is an abort, never a pause.
2267
- const pausedHere = pausedReview?.planHash === hash;
2387
+ const pausedHere = pausedNow?.planHash === hash;
2268
2388
  if (res.ok) {
2269
2389
  lastReviewedPlanHash = res.planHash;
2270
2390
  abortedReviewHash = undefined;
@@ -2363,7 +2483,7 @@ export default function workflowExtension(
2363
2483
  detachHostSignal = undefined;
2364
2484
  // A pause keeps its own working message (pauseReviewRound sets it
2365
2485
  // after the abort lands); every other exit restores the default.
2366
- if (!(pausedReview && pausedReview.planHash === hash)) {
2486
+ if (!(currentPausedReview()?.planHash === hash)) {
2367
2487
  setReviewWorkingMessage(undefined);
2368
2488
  }
2369
2489
  // Ownership ended: refresh both surfaces now that `activeReview` is
@@ -2422,6 +2542,32 @@ export default function workflowExtension(
2422
2542
  };
2423
2543
  }
2424
2544
 
2545
+ // A fresh automatic round supersedes any paused one. Even when this
2546
+ // decision is skipped below, no stale pause/line may survive it.
2547
+ if (trigger === "auto" && pausedReview !== undefined) {
2548
+ pausedReview = undefined;
2549
+ try {
2550
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
2551
+ } catch (_e) {
2552
+ void _e;
2553
+ }
2554
+ }
2555
+
2556
+ // A new round can never inherit an older browser gate: the old tab's
2557
+ // decision would otherwise suppress this round's handoff after it lands.
2558
+ if (awaitingDecision) {
2559
+ supersedePendingHandoff(
2560
+ trigger === "manual"
2561
+ ? "a manual /review started"
2562
+ : "a new review round started",
2563
+ { force: true, nextPlanHash: hash, nextCycleId: reviewCycleId },
2564
+ );
2565
+ }
2566
+
2567
+ // A different plan file is a new target even inside one user run: reset the
2568
+ // budget/hashes/findings before the dedupe gates below.
2569
+ const cycleChanged = notePlanTargetForReviewCycle(currentPlanFile);
2570
+
2425
2571
  // Already reviewed this exact revision — do not re-review on re-emit.
2426
2572
  if (hash === lastReviewedPlanHash) {
2427
2573
  return {
@@ -2441,8 +2587,13 @@ export default function workflowExtension(
2441
2587
  };
2442
2588
  }
2443
2589
 
2444
- reviewRound += 1;
2445
- if (reviewRound > cfg.rounds) {
2590
+ const budget = nextReviewRound({
2591
+ round: reviewRound,
2592
+ maxRounds: cfg.rounds,
2593
+ newCycle: cycleChanged,
2594
+ });
2595
+ reviewRound = budget.round;
2596
+ if (!budget.allowed) {
2446
2597
  // Round cap reached: hand over with the outstanding findings visible.
2447
2598
  const unresolved = (reviewState?.findings ?? []).filter(
2448
2599
  (f) => f.disposition !== "rejected",
@@ -2481,6 +2632,11 @@ export default function workflowExtension(
2481
2632
 
2482
2633
  // A fresh round supersedes any paused one (resume uses its own path).
2483
2634
  pausedReview = undefined;
2635
+ try {
2636
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
2637
+ } catch (_e) {
2638
+ void _e;
2639
+ }
2484
2640
 
2485
2641
  try {
2486
2642
  return await executeReviewRound({
@@ -2549,6 +2705,137 @@ export default function workflowExtension(
2549
2705
  if (currentSessionCtx) updateStatus(currentSessionCtx);
2550
2706
  }
2551
2707
 
2708
+ type ReviewCycleReason =
2709
+ | "prompt"
2710
+ | "refine"
2711
+ | "deny"
2712
+ | "build"
2713
+ | "session";
2714
+
2715
+ /**
2716
+ * Start a new review cycle: one plan-authoring/feedback sequence.
2717
+ *
2718
+ * Every fresh prompt, explicit refine/deny, and Build entry owns a cycle.
2719
+ * The cycle token invalidates an old `runHandoff` continuation, the budget
2720
+ * restarts, and an abandoned pause/gate is cancelled (D2) so it cannot keep
2721
+ * the footer line, lock the modes, or short-circuit the next `agent_end`.
2722
+ * Findings survive a same-plan refinement and are cleared for a new file.
2723
+ * Never throws: a broken review surface must not block the prompt.
2724
+ */
2725
+ async function beginReviewCycle(opts: {
2726
+ reason: ReviewCycleReason;
2727
+ keepFindings?: boolean;
2728
+ planPath?: string | null;
2729
+ }): Promise<void> {
2730
+ const hadActive = activeReview !== undefined;
2731
+ const hadPause = currentPausedReview() !== undefined;
2732
+ const hadGate = reviewGatePhase !== "idle";
2733
+ // Synchronously first: any old handoff continuation is stale from here on.
2734
+ reviewCycleId += 1;
2735
+ if (
2736
+ opts.reason === "prompt" ||
2737
+ opts.reason === "build" ||
2738
+ opts.reason === "session"
2739
+ ) {
2740
+ supersedePendingHandoff(`a new ${opts.reason} cycle started`, {
2741
+ force: true,
2742
+ });
2743
+ manualReviewCycle = false;
2744
+ }
2745
+ if (hadActive || pausedReview !== undefined || hadGate) {
2746
+ try {
2747
+ await suspendReview();
2748
+ } catch (_e) {
2749
+ void _e;
2750
+ }
2751
+ }
2752
+ reviewRound = 0;
2753
+ lastReviewedPlanHash = undefined;
2754
+ abortedReviewHash = undefined;
2755
+ if (!hadActive) quietAbortHash = undefined;
2756
+
2757
+ const rawNext = opts.planPath ?? currentPlanFile ?? null;
2758
+ let next: string | null = null;
2759
+ if (rawNext) {
2760
+ try {
2761
+ next = path.isAbsolute(rawNext)
2762
+ ? rawNext
2763
+ : path.join(reviewCwd(), rawNext);
2764
+ } catch (_e) {
2765
+ void _e;
2766
+ next = rawNext;
2767
+ }
2768
+ }
2769
+ const pathChanged =
2770
+ reviewCyclePlanPath !== null &&
2771
+ next !== null &&
2772
+ !samePlanPath(next, reviewCyclePlanPath, reviewCwd());
2773
+ if (pathChanged || opts.keepFindings === false) {
2774
+ reviewPreviousFindings = undefined;
2775
+ }
2776
+ reviewCyclePlanPath = next;
2777
+ reviewGateCycleId = 0;
2778
+
2779
+ if (hadPause || hadGate) {
2780
+ notifyReview(
2781
+ "Paused review abandoned — new work started; the paused round will not resume.",
2782
+ "info",
2783
+ );
2784
+ }
2785
+ try {
2786
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
2787
+ } catch (_e) {
2788
+ void _e;
2789
+ }
2790
+ try {
2791
+ if (reviewEnabled() || hadActive || hadPause || hadGate || reviewState) {
2792
+ pi.appendEntry("workflow-review-cycle", {
2793
+ reason: opts.reason,
2794
+ cycleId: reviewCycleId,
2795
+ planPath: reviewCyclePlanPath,
2796
+ at: Date.now(),
2797
+ });
2798
+ }
2799
+ } catch (_e) {
2800
+ void _e;
2801
+ }
2802
+ }
2803
+
2804
+ /**
2805
+ * A different plan file is a new review target even inside one user run.
2806
+ * Resets the cycle budget and prior findings, but not the cycle token: the
2807
+ * handoff that owns this run must stay current.
2808
+ */
2809
+ function notePlanTargetForReviewCycle(
2810
+ rawPath: string | null | undefined,
2811
+ ): boolean {
2812
+ if (!rawPath) return false;
2813
+ const cwd = reviewCwd();
2814
+ let next: string;
2815
+ try {
2816
+ next = path.isAbsolute(rawPath) ? rawPath : path.join(cwd, rawPath);
2817
+ } catch (_e) {
2818
+ void _e;
2819
+ next = rawPath;
2820
+ }
2821
+ if (reviewCyclePlanPath === null) {
2822
+ reviewCyclePlanPath = next;
2823
+ return false;
2824
+ }
2825
+ if (samePlanPath(next, reviewCyclePlanPath, cwd)) return false;
2826
+ reviewCyclePlanPath = next;
2827
+ reviewRound = 0;
2828
+ lastReviewedPlanHash = undefined;
2829
+ abortedReviewHash = undefined;
2830
+ reviewPreviousFindings = undefined;
2831
+ try {
2832
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
2833
+ } catch (_e) {
2834
+ void _e;
2835
+ }
2836
+ return true;
2837
+ }
2838
+
2552
2839
  /**
2553
2840
  * Stop the reviewer and clear all Review Mode state. Never throws.
2554
2841
  *
@@ -2557,7 +2844,9 @@ export default function workflowExtension(
2557
2844
  * they are not guarded. Clearing the review state releases the derived
2558
2845
  * mode-switch lock (plan 2026-10-25).
2559
2846
  */
2560
- async function teardownReview(): Promise<void> {
2847
+ async function teardownReview(
2848
+ opts: { keepHandoff?: boolean } = {},
2849
+ ): Promise<void> {
2561
2850
  try {
2562
2851
  activeReview?.controller.abort();
2563
2852
  } catch (_e) {
@@ -2570,6 +2859,7 @@ export default function workflowExtension(
2570
2859
  }
2571
2860
  reviewRuntime = undefined;
2572
2861
  reviewState = undefined;
2862
+ if (opts.keepHandoff !== true) manualReviewCycle = false;
2573
2863
  lastReviewedPlanHash = undefined;
2574
2864
  reviewRound = 0;
2575
2865
  reviewPreviousFindings = undefined;
@@ -2584,11 +2874,12 @@ export default function workflowExtension(
2584
2874
  activeReview = undefined;
2585
2875
  abortedReviewHash = undefined;
2586
2876
  quietAbortHash = undefined;
2587
- // A teardown ends any pending handoff decision/correlation and the
2588
- // per-run provenance; a reload cannot inherit either.
2589
- const clearedDecision = clearPendingHandoffDecision();
2877
+ // A reload/resume keeps the browser correlation so the next session_start
2878
+ // can recover it; every other teardown ends the decision and provenance.
2879
+ const clearedDecision =
2880
+ opts.keepHandoff === true ? false : clearPendingHandoffDecision();
2590
2881
  resetPlanRunProvenance();
2591
- if (clearedDecision) {
2882
+ if (clearedDecision || opts.keepHandoff === true) {
2592
2883
  try {
2593
2884
  persistState();
2594
2885
  } catch (_e) {
@@ -2930,6 +3221,26 @@ export default function workflowExtension(
2930
3221
  writePolicyArgumentCompletions(prefix),
2931
3222
  });
2932
3223
 
3224
+ // ── /permissions: explain a command's verdict (diagnostics) ────────
3225
+ //
3226
+ // Answers "why was that blocked?" (or "would it be?") without touching a
3227
+ // gate: it reads the same analyzeCommand/explainCommandRefusal the gates
3228
+ // use, so the explanation cannot describe a policy the gate no longer
3229
+ // enforces. Read-only, so it is safe in every mode.
3230
+ pi.registerCommand("permissions", {
3231
+ description:
3232
+ "Explain why a shell command would be allowed or refused: /permissions explain <command>",
3233
+ handler: async (args, ctx) => {
3234
+ const raw = String(args ?? "").trim();
3235
+ const first = raw.split(/\s+/)[0]?.toLowerCase() ?? "";
3236
+ // `/permissions explain <cmd>` and `/permissions <cmd>` both work.
3237
+ const command = first === "explain" ? raw.slice("explain".length).trim() : raw;
3238
+ ctx.ui.notify(describeCommandPolicy(command).join("\n"), "info");
3239
+ },
3240
+ getArgumentCompletions: (prefix: string) =>
3241
+ permissionsArgumentCompletions(prefix),
3242
+ });
3243
+
2933
3244
  pi.registerCommand("review-prompt", {
2934
3245
  description:
2935
3246
  "Edit the reviewer's guidance (framework alignment / best practice) used by Review Mode",
@@ -2974,11 +3285,13 @@ export default function workflowExtension(
2974
3285
  const s = reviewState;
2975
3286
  const lockKind = reviewModeLockKindNow();
2976
3287
  if (!s) {
2977
- if (pausedReview) {
3288
+ const paused = currentPausedReview();
3289
+ if (paused) {
2978
3290
  ctx.ui.notify(
2979
3291
  [
2980
- `Review paused at round ${pausedReview.round} (plan hash ${pausedReview.planHash})`,
3292
+ `Review paused at round ${paused.round} (plan hash ${paused.planHash})`,
2981
3293
  " /review-resume to continue, /review-abort to stop.",
3294
+ pendingHandoffStatusLine(),
2982
3295
  reviewModeLockStatusLine(lockKind),
2983
3296
  ]
2984
3297
  .filter(Boolean)
@@ -2999,6 +3312,7 @@ export default function workflowExtension(
2999
3312
  reviewEnabled()
3000
3313
  ? "Review Mode is enabled but no review has run yet."
3001
3314
  : `Review Mode is OFF — automatic review disabled (/review-mode on to enable).${cfgLine} /review runs a one-shot review.`,
3315
+ pendingHandoffStatusLine(),
3002
3316
  reviewModeLockStatusLine(lockKind),
3003
3317
  ]
3004
3318
  .filter(Boolean)
@@ -3011,12 +3325,16 @@ export default function workflowExtension(
3011
3325
  reviewStateSummary(s),
3012
3326
  ` phase : ${s.phase} round: ${s.round} pass: ${s.pass}`,
3013
3327
  ` model : ${s.modelLabel}`,
3328
+ ` attempt : ${s.recoveryAttempts?.length ? s.recoveryAttempts.join(" → ") : "single model"}`,
3329
+ ` elapsed : ${Math.round(reviewElapsedMs(s) / 1000)}s${
3330
+ s.providerError ? ` provider: ${s.providerError.slice(0, 200)}` : ""
3331
+ }`,
3014
3332
  ` plan : ${s.planPath ?? "(unknown)"} hash ${s.planHash}`,
3015
3333
  ` explore : ${s.exploreUsed} subagent(s) tokens≈${s.tokenEstimate ?? "n/a"}`,
3016
3334
  s.verdict ? ` verdict : ${s.verdict}` : "",
3017
3335
  s.error ? ` error : ${s.error}` : "",
3018
- pausedReview
3019
- ? ` paused : round ${pausedReview.round} — /review-resume to continue, /review-abort to stop`
3336
+ currentPausedReview()
3337
+ ? ` paused : round ${currentPausedReview()!.round} — /review-resume to continue, /review-abort to stop`
3020
3338
  : "",
3021
3339
  abortedReviewHash
3022
3340
  ? ` note : revision ${abortedReviewHash} was aborted — it will not be auto-reviewed until the plan changes`
@@ -3024,6 +3342,7 @@ export default function workflowExtension(
3024
3342
  reviewRuntime?.isRoundActive()
3025
3343
  ? " live : round in flight — /role and /profile switches apply next round; /review say <msg> to steer"
3026
3344
  : "",
3345
+ pendingHandoffStatusLine(),
3027
3346
  reviewModeLockStatusLine(lockKind),
3028
3347
  ...s.findings.map(
3029
3348
  (f) =>
@@ -3181,6 +3500,7 @@ export default function workflowExtension(
3181
3500
  }
3182
3501
  currentPlanFile = target.abs;
3183
3502
  lastPlanWritePath = target.abs;
3503
+ notePlanTargetForReviewCycle(target.abs);
3184
3504
  persistState();
3185
3505
  lastReviewedPlanHash = undefined; // force a fresh review
3186
3506
  abortedReviewHash = undefined;
@@ -3197,6 +3517,16 @@ export default function workflowExtension(
3197
3517
  const offSuffix = reviewEnabled()
3198
3518
  ? ""
3199
3519
  : " (Review Mode stays OFF — /review-mode on for automatic review.)";
3520
+ if (
3521
+ await openManualHandoff(
3522
+ result,
3523
+ target.abs,
3524
+ ctx as any,
3525
+ offSuffix,
3526
+ )
3527
+ ) {
3528
+ return;
3529
+ }
3200
3530
  ctx.ui.notify(`${reviewResumeNotice(result)}${offSuffix}`, "info");
3201
3531
  },
3202
3532
  });
@@ -3209,7 +3539,7 @@ export default function workflowExtension(
3209
3539
  // Gate only on round ownership below — no `enabled` check here.
3210
3540
  if (!activeReview) {
3211
3541
  ctx.ui.notify(
3212
- pausedReview
3542
+ currentPausedReview()
3213
3543
  ? "Review is already paused — /review-resume to continue, /review-abort to stop."
3214
3544
  : "No review round running.",
3215
3545
  "info",
@@ -3229,7 +3559,7 @@ export default function workflowExtension(
3229
3559
  handler: async (_args, ctx) => {
3230
3560
  // Manual control: resume works whether or not Review Mode is on.
3231
3561
  // Gate only on paused-session state below — no `enabled` check here.
3232
- const paused = pausedReview;
3562
+ const paused = currentPausedReview();
3233
3563
  if (!paused) {
3234
3564
  ctx.ui.notify("No paused review — nothing to resume.", "info");
3235
3565
  return;
@@ -3241,6 +3571,9 @@ export default function workflowExtension(
3241
3571
  ctx.ui.notify("A review round is already running.", "warning");
3242
3572
  return;
3243
3573
  }
3574
+ // A gated resume is settled by its parked runHandoff; a manual pause
3575
+ // (idle gate) opens its own browser handoff after the round lands.
3576
+ const gatedResume = reviewGatePhase !== "idle";
3244
3577
  // A resumed gated review is auditing again. Without this, the parked
3245
3578
  // handoff keeps `reviewGatePhase === "paused"` for the whole round and
3246
3579
  // the lock/footer report the wrong state (plan 2026-10-25 follow-up).
@@ -3284,7 +3617,15 @@ export default function workflowExtension(
3284
3617
  planText: result.planText,
3285
3618
  });
3286
3619
  }
3287
- ctx.ui.notify(reviewResumeNotice(result), "info");
3620
+ if (!gatedResume && reviewResumeSettles(result)) {
3621
+ await openManualHandoff(
3622
+ result,
3623
+ currentPlanFile ?? "",
3624
+ ctx as any,
3625
+ );
3626
+ } else {
3627
+ ctx.ui.notify(reviewResumeNotice(result), "info");
3628
+ }
3288
3629
  return;
3289
3630
  }
3290
3631
  pausedReview = undefined;
@@ -3309,7 +3650,11 @@ export default function workflowExtension(
3309
3650
  planText: result.planText,
3310
3651
  });
3311
3652
  }
3312
- ctx.ui.notify(reviewResumeNotice(result), "info");
3653
+ if (!gatedResume && reviewResumeSettles(result)) {
3654
+ await openManualHandoff(result, currentPlanFile ?? "", ctx as any);
3655
+ } else {
3656
+ ctx.ui.notify(reviewResumeNotice(result), "info");
3657
+ }
3313
3658
  },
3314
3659
  });
3315
3660
 
@@ -3323,17 +3668,18 @@ export default function workflowExtension(
3323
3668
  const rt = reviewRuntime;
3324
3669
  const active =
3325
3670
  activeReview !== undefined || rt?.isRoundActive() === true;
3326
- if (!active && !pausedReview) {
3671
+ const paused = currentPausedReview();
3672
+ if (!active && !paused) {
3327
3673
  ctx.ui.notify("No review running or paused.", "info");
3328
3674
  return;
3329
3675
  }
3330
3676
  const targetHash =
3331
- pausedReview?.planHash ??
3677
+ paused?.planHash ??
3332
3678
  (activeReview ? planHash(activeReview.planText) : undefined);
3333
3679
  // Text the gate hands off when one is open (resolved below after the
3334
3680
  // abort lands; falls back to the author's draft when unknown).
3335
3681
  const abortedText =
3336
- pausedReview?.planText ?? activeReview?.planText;
3682
+ paused?.planText ?? activeReview?.planText;
3337
3683
  pausedReview = undefined;
3338
3684
  // An aborted revision is not auto-reviewed again until the plan changes.
3339
3685
  if (targetHash) abortedReviewHash = targetHash;
@@ -3812,6 +4158,16 @@ export default function workflowExtension(
3812
4158
  planFile: currentPlanFile,
3813
4159
  awaitingDecision,
3814
4160
  awaitingDecisionAt: awaitingDecision ? lastHandoffAt : undefined,
4161
+ // Full correlation: a restored gate must know which browser review it
4162
+ // belongs to, or it can only suppress the next handoff.
4163
+ lastReviewId: awaitingDecision ? lastReviewId : null,
4164
+ pendingHandoffPlanPath: awaitingDecision ? pendingHandoffPlanPath : null,
4165
+ pendingHandoffPlanHash: awaitingDecision ? pendingHandoffPlanHash : null,
4166
+ pendingHandoffRequestId: awaitingDecision ? pendingHandoffRequestId : null,
4167
+ pendingHandoffSource: awaitingDecision ? pendingHandoffSource : null,
4168
+ pendingHandoffCycleId: awaitingDecision ? pendingHandoffCycleId : 0,
4169
+ pendingHandoffReviewUrl: awaitingDecision ? pendingHandoffReviewUrl : null,
4170
+ manualReviewCycle,
3815
4171
  role: {
3816
4172
  activeRole: roleConfig.activeRole,
3817
4173
  // Session-scoped profile activation (see setActiveProfile): explicit
@@ -3920,8 +4276,14 @@ export default function workflowExtension(
3920
4276
  updateStatus(ctx);
3921
4277
  persistState();
3922
4278
  // Tab-to-Build leaves Plan behind; suspend review activity but keep the
3923
- // reviewer session warm for the next round (step 10).
4279
+ // reviewer session warm for the next round (step 10). End the review cycle
4280
+ // so its budget/findings cannot leak into the next Plan round.
3924
4281
  await suspendReview();
4282
+ try {
4283
+ await beginReviewCycle({ reason: "build", keepFindings: false });
4284
+ } catch (_e) {
4285
+ void _e;
4286
+ }
3925
4287
  }
3926
4288
 
3927
4289
  async function setDefaultMode(
@@ -4067,6 +4429,15 @@ export default function workflowExtension(
4067
4429
  // user just approved (step 10).
4068
4430
  await suspendReview();
4069
4431
  currentPlanFile = planPath;
4432
+ try {
4433
+ await beginReviewCycle({
4434
+ reason: "build",
4435
+ keepFindings: false,
4436
+ planPath,
4437
+ });
4438
+ } catch (_e) {
4439
+ void _e;
4440
+ }
4070
4441
  pi.setActiveTools(
4071
4442
  toolsBeforePlanMode ?? getBuildTools(pi.getActiveTools()),
4072
4443
  );
@@ -4093,6 +4464,365 @@ export default function workflowExtension(
4093
4464
  return currentSessionCtx;
4094
4465
  }
4095
4466
 
4467
+ /**
4468
+ * Adopt the review id/url Plannotator announced. This is the only place the
4469
+ * transcript handoff entry is written, so a `respond` callback and a
4470
+ * `session-opened` event for the same review cannot duplicate it.
4471
+ */
4472
+ function adoptPlannotatorSession(event: PlannotatorSessionEventLike): void {
4473
+ if (!awaitingDecision) return;
4474
+ if (typeof event.reviewId === "string" && event.reviewId) {
4475
+ lastReviewId = event.reviewId;
4476
+ }
4477
+ if (!lastReviewId) return;
4478
+ if (typeof event.url === "string" && event.url) {
4479
+ pendingHandoffReviewUrl = event.url;
4480
+ }
4481
+ if (recordedHandoffReviewId !== lastReviewId) {
4482
+ recordedHandoffReviewId = lastReviewId;
4483
+ try {
4484
+ pi.appendEntry("workflow-handoff-plannotator", {
4485
+ at: Date.now(),
4486
+ planPath: pendingHandoffPlanPath ?? currentPlanFile,
4487
+ reviewId: lastReviewId,
4488
+ ...(pendingHandoffReviewUrl
4489
+ ? { reviewUrl: pendingHandoffReviewUrl }
4490
+ : {}),
4491
+ planTextLen: pendingHandoffPlanTextLen,
4492
+ source: pendingHandoffSource ?? "auto",
4493
+ });
4494
+ } catch (_e) {
4495
+ void _e;
4496
+ }
4497
+ notifyReview(
4498
+ "Plan opened in browser for review. Approve or annotate there.",
4499
+ "info",
4500
+ );
4501
+ }
4502
+ try {
4503
+ persistState();
4504
+ } catch (_e) {
4505
+ void _e;
4506
+ }
4507
+ try {
4508
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
4509
+ } catch (_e) {
4510
+ void _e;
4511
+ }
4512
+ }
4513
+
4514
+ /** Settle the one in-flight plan-review request exactly once. */
4515
+ function settlePlanReviewRequest(outcome: PlanReviewRequestOutcome): boolean {
4516
+ const pending = pendingPlanReviewRequest;
4517
+ if (!pending || pending.settled) return false;
4518
+ pending.settled = true;
4519
+ pendingPlanReviewRequest = undefined;
4520
+ pending.finish(outcome);
4521
+ return true;
4522
+ }
4523
+
4524
+ /**
4525
+ * Ask Plannotator to open a plan review and wait for its lifecycle.
4526
+ *
4527
+ * The `respond` callback is the fast path; `plannotator:session-opened` is
4528
+ * the authoritative one (it carries the URL and survives a slow handler).
4529
+ * A timeout clears the gate so the caller can render its own fallback; it
4530
+ * never retries, because a late event would then open a duplicate review.
4531
+ */
4532
+ async function requestPlanReview(input: {
4533
+ planText: string;
4534
+ planPath: string;
4535
+ source: ReviewHandoffSource;
4536
+ ctx: ExtensionContext;
4537
+ cycleId?: number;
4538
+ }): Promise<{ ok: true; reviewId?: string } | { ok: false; reason: string }> {
4539
+ const bus: any = (pi as any).events;
4540
+ if (!plannotatorActive || !bus || typeof bus.emit !== "function") {
4541
+ return { ok: false, reason: "Plannotator is not available in this session" };
4542
+ }
4543
+ if (!input.planText.trim() || !input.planPath) {
4544
+ return { ok: false, reason: "the reviewed plan has no file to hand off" };
4545
+ }
4546
+ // A second request supersedes the first waiter without opening a race.
4547
+ settlePlanReviewRequest({
4548
+ status: "error",
4549
+ reason: "superseded by a newer handoff",
4550
+ });
4551
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4552
+ awaitingDecision = true;
4553
+ lastHandoffAt = Date.now();
4554
+ pendingHandoffPlanPath = input.planPath;
4555
+ pendingHandoffPlanHash = planHash(input.planText);
4556
+ pendingHandoffPlanTextLen = input.planText.length;
4557
+ pendingHandoffRequestId = requestId;
4558
+ pendingHandoffSource = input.source;
4559
+ pendingHandoffCycleId = input.cycleId ?? reviewCycleId;
4560
+ pendingHandoffReviewUrl = null;
4561
+ lastReviewId = null;
4562
+ recordedHandoffReviewId = null;
4563
+ persistState();
4564
+ const timeoutMs = Math.max(50, deps.plannotatorRequestTimeoutMs ?? 15_000);
4565
+ const response = await new Promise<PlanReviewRequestOutcome>((resolve) => {
4566
+ let settled = false;
4567
+ const settle = (outcome: PlanReviewRequestOutcome): void => {
4568
+ if (settled) return;
4569
+ settled = true;
4570
+ clearTimeout(timer);
4571
+ if (pendingPlanReviewRequest?.requestId === requestId) {
4572
+ pendingPlanReviewRequest = undefined;
4573
+ }
4574
+ resolve(outcome);
4575
+ };
4576
+ const timer = setTimeout(() => {
4577
+ settle({
4578
+ status: "timeout",
4579
+ reason: `Plannotator did not answer within ${Math.round(timeoutMs / 1000)}s`,
4580
+ });
4581
+ }, timeoutMs);
4582
+ pendingPlanReviewRequest = {
4583
+ requestId,
4584
+ settled: false,
4585
+ finish: settle,
4586
+ };
4587
+ try {
4588
+ bus.emit(PLANNOTATOR_REQUEST, {
4589
+ requestId,
4590
+ action: "plan-review",
4591
+ payload: {
4592
+ planContent: input.planText,
4593
+ planFilePath: input.planPath,
4594
+ reviewPending: false,
4595
+ reviewSummary: reviewState
4596
+ ? {
4597
+ modelLabel: reviewState.modelLabel,
4598
+ round: reviewState.round,
4599
+ verdict: reviewState.verdict,
4600
+ findings: reviewState.findings.length,
4601
+ }
4602
+ : undefined,
4603
+ },
4604
+ respond: (res: any) => {
4605
+ if (
4606
+ res &&
4607
+ res.status === "handled" &&
4608
+ typeof res.result?.reviewId === "string"
4609
+ ) {
4610
+ settle({
4611
+ status: "handled",
4612
+ reviewId: res.result.reviewId,
4613
+ });
4614
+ } else {
4615
+ settle({
4616
+ status: "error",
4617
+ reason: `Plannotator answered ${String(res?.status ?? "nothing")}`,
4618
+ });
4619
+ }
4620
+ },
4621
+ });
4622
+ } catch (e: any) {
4623
+ settle({
4624
+ status: "error",
4625
+ reason: `Plannotator request failed: ${String(e?.message ?? e)}`,
4626
+ });
4627
+ }
4628
+ });
4629
+ if (response.status === "handled") {
4630
+ adoptPlannotatorSession({
4631
+ ...(response.reviewId ? { reviewId: response.reviewId } : {}),
4632
+ ...(response.url ? { url: response.url } : {}),
4633
+ });
4634
+ return { ok: true, ...(response.reviewId ? { reviewId: response.reviewId } : {}) };
4635
+ }
4636
+ const changed = clearPendingHandoffDecision();
4637
+ if (changed) persistState();
4638
+ try {
4639
+ pi.appendEntry("workflow-handoff-plannotator-failed", {
4640
+ at: Date.now(),
4641
+ reason: response.reason,
4642
+ source: input.source,
4643
+ planPath: input.planPath,
4644
+ });
4645
+ } catch (_e) {
4646
+ void _e;
4647
+ }
4648
+ notifyReview(`Plannotator handoff unavailable — ${response.reason}.`, "warning");
4649
+ return { ok: false, reason: response.reason };
4650
+ }
4651
+
4652
+ /** Ask Plannotator for the durable status of one review. */
4653
+ async function queryPlannotatorReviewStatus(reviewId: string): Promise<{
4654
+ status: "pending" | "completed" | "missing";
4655
+ approved?: boolean;
4656
+ feedback?: string;
4657
+ savedPath?: string;
4658
+ }> {
4659
+ const bus: any = (pi as any).events;
4660
+ if (!plannotatorActive || !bus || typeof bus.emit !== "function") {
4661
+ return { status: "missing" };
4662
+ }
4663
+ const timeoutMs = Math.max(50, deps.plannotatorRecoveryTimeoutMs ?? 3_000);
4664
+ return await new Promise((resolve) => {
4665
+ let settled = false;
4666
+ const finish = (value: any): void => {
4667
+ if (settled) return;
4668
+ settled = true;
4669
+ clearTimeout(timer);
4670
+ resolve(
4671
+ value &&
4672
+ (value.status === "pending" ||
4673
+ value.status === "completed" ||
4674
+ value.status === "missing")
4675
+ ? value
4676
+ : { status: "missing" },
4677
+ );
4678
+ };
4679
+ const timer = setTimeout(() => finish({ status: "missing" }), timeoutMs);
4680
+ try {
4681
+ bus.emit(PLANNOTATOR_REQUEST, {
4682
+ requestId: `${Date.now()}-status`,
4683
+ action: "review-status",
4684
+ payload: { reviewId },
4685
+ respond: (res: any) => {
4686
+ if (res?.status === "handled" && res.result) finish(res.result);
4687
+ else finish({ status: "missing" });
4688
+ },
4689
+ });
4690
+ } catch {
4691
+ finish({ status: "missing" });
4692
+ }
4693
+ });
4694
+ }
4695
+
4696
+ /**
4697
+ * Recover the one pending browser handoff after a session reload/resume.
4698
+ *
4699
+ * `startup`/`new` cannot own the old loopback server, so they clear. For a
4700
+ * reload/resume/fork, Plannotator's durable `review-status` decides: pending
4701
+ * keeps the gate (when the plan hash still matches), completed applies the
4702
+ * recorded decision, and missing/changed clears with a visible reason.
4703
+ */
4704
+ async function recoverPendingHandoff(
4705
+ ctx: ExtensionContext,
4706
+ reason: string,
4707
+ ): Promise<void> {
4708
+ if (recoveryInFlight) return recoveryInFlight;
4709
+ recoveryInFlight = (async () => {
4710
+ const record = (action: string, detail: Record<string, unknown> = {}) => {
4711
+ try {
4712
+ pi.appendEntry("workflow-handoff-recovery", {
4713
+ action,
4714
+ reason,
4715
+ at: Date.now(),
4716
+ ...detail,
4717
+ });
4718
+ } catch (_e) {
4719
+ void _e;
4720
+ }
4721
+ };
4722
+ if (!awaitingDecision) return;
4723
+ if (reason === "startup" || reason === "new" || !lastReviewId) {
4724
+ const changed = clearPendingHandoffDecision();
4725
+ if (changed) persistState();
4726
+ record("clear", { why: "no restorable review" });
4727
+ return;
4728
+ }
4729
+ const currentFile = readCurrentPlanText(reviewCwd());
4730
+ const status = await queryPlannotatorReviewStatus(lastReviewId);
4731
+ const decision = reviewHandoffRecovery({
4732
+ status: status.status,
4733
+ approved: status.approved,
4734
+ feedback: status.feedback,
4735
+ savedPath: status.savedPath,
4736
+ pendingHash: pendingHandoffPlanHash ?? undefined,
4737
+ currentHash:
4738
+ currentFile !== undefined ? planHash(currentFile) : undefined,
4739
+ });
4740
+ if (decision.action === "keep") {
4741
+ if (pendingHandoffSource === "manual") manualReviewCycle = true;
4742
+ record("keep", {
4743
+ reviewId: lastReviewId,
4744
+ ...(pendingHandoffReviewUrl
4745
+ ? { reviewUrl: pendingHandoffReviewUrl }
4746
+ : {}),
4747
+ });
4748
+ notifyReview(
4749
+ `Browser review still open${pendingHandoffReviewUrl ? `: ${pendingHandoffReviewUrl}` : ""} — approve or annotate there.`,
4750
+ "info",
4751
+ );
4752
+ updateStatus(ctx as any);
4753
+ return;
4754
+ }
4755
+ if (decision.action === "apply") {
4756
+ record("apply", {
4757
+ reviewId: lastReviewId,
4758
+ approved: decision.approved,
4759
+ });
4760
+ if (decision.approved) {
4761
+ handlePlannotatorApprove(
4762
+ pendingHandoffPlanPath ?? currentPlanFile ?? "",
4763
+ currentFile ?? "",
4764
+ decision.feedback,
4765
+ ctx.cwd,
4766
+ );
4767
+ } else {
4768
+ await handlePlannotatorDeny(
4769
+ decision.feedback || "Plan needs revision.",
4770
+ );
4771
+ }
4772
+ return;
4773
+ }
4774
+ const changed = clearPendingHandoffDecision();
4775
+ if (changed) persistState();
4776
+ record("clear", { why: decision.reason, reviewId: lastReviewId ?? "" });
4777
+ notifyReview(
4778
+ `Previous browser review could not be recovered — ${decision.reason}.`,
4779
+ "warning",
4780
+ );
4781
+ updateStatus(ctx as any);
4782
+ })();
4783
+ try {
4784
+ await recoveryInFlight;
4785
+ } finally {
4786
+ recoveryInFlight = null;
4787
+ }
4788
+ }
4789
+
4790
+ /**
4791
+ * Open the browser handoff for a settled manual `/review` or resume.
4792
+ * Returns true when the browser owns the decision (or the result is not
4793
+ * handoff-eligible); false when the round paused/aborted and the caller
4794
+ * should show its normal resume notice.
4795
+ */
4796
+ async function openManualHandoff(
4797
+ result: ReviewExecutionResult,
4798
+ planPath: string,
4799
+ ctx: ExtensionContext,
4800
+ suffix = "",
4801
+ ): Promise<boolean> {
4802
+ if (!shouldHandoffManualResult(result)) return false;
4803
+ const previousManualCycle = manualReviewCycle;
4804
+ manualReviewCycle = true;
4805
+ const handoff = await requestPlanReview({
4806
+ planText: result.planText,
4807
+ planPath,
4808
+ source: "manual",
4809
+ ctx,
4810
+ });
4811
+ if (handoff.ok) {
4812
+ ctx.ui.notify(
4813
+ "Plan opened in browser for review. Approve or annotate there." + suffix,
4814
+ "info",
4815
+ );
4816
+ return true;
4817
+ }
4818
+ manualReviewCycle = previousManualCycle;
4819
+ ctx.ui.notify(
4820
+ `Manual review finished, but Plannotator is unavailable (${handoff.reason}). The reviewed plan is at ${planPath}.`,
4821
+ "warning",
4822
+ );
4823
+ return true;
4824
+ }
4825
+
4096
4826
  function handlePlannotatorApprove(
4097
4827
  planFilePath: string,
4098
4828
  planContent: string,
@@ -4101,10 +4831,18 @@ export default function workflowExtension(
4101
4831
  ) {
4102
4832
  const ctx = getActiveCtx();
4103
4833
  if (!ctx) return;
4104
- if (workflowMode !== "plan") return;
4834
+ // A manual `/review` handoff may be approved from Default/Build; an
4835
+ // automatic handoff only exists in Plan mode.
4836
+ if (workflowMode !== "plan" && pendingHandoffSource !== "manual") return;
4105
4837
  // A stale browser approve while a review runs must not clear the pending
4106
4838
  // decision or send the execute messages: the plan is still being audited.
4107
- if (blockModeSwitchDuringReview(ctx as any)) return;
4839
+ if (blockModeSwitchDuringReview(ctx as any)) {
4840
+ supersedePendingHandoff(
4841
+ "a browser decision arrived while a new review round was running",
4842
+ { force: true },
4843
+ );
4844
+ return;
4845
+ }
4108
4846
  // No prompt means no decision to consume. A fresh prompt/mode switch
4109
4847
  // clears this, so an old browser tab cannot approve the previous plan.
4110
4848
  if (!awaitingDecision) return;
@@ -4160,6 +4898,7 @@ export default function workflowExtension(
4160
4898
  } catch (_e) {
4161
4899
  void _e;
4162
4900
  }
4901
+ if (pendingHandoffSource === "manual") manualReviewCycle = false;
4163
4902
  clearPendingHandoffDecision();
4164
4903
  void enterBuildModeFromPlan(ctx as any, resolvedPlan)
4165
4904
  .then(() => updateStatus(ctx as any))
@@ -4205,20 +4944,39 @@ export default function workflowExtension(
4205
4944
  }
4206
4945
  }
4207
4946
 
4208
- function handlePlannotatorDeny(feedback: string) {
4947
+ async function handlePlannotatorDeny(feedback: string) {
4209
4948
  const ctx = getActiveCtx();
4210
4949
  if (!ctx) return;
4211
4950
  // Not a mode transition, but while a review owns the turn every browser
4212
4951
  // decision is stale: injecting feedback here would mutate the plan under
4213
4952
  // the reviewer. A legitimate deny only arrives after the gate resolves.
4214
- if (blockModeSwitchDuringReview(ctx as any)) return;
4953
+ if (blockModeSwitchDuringReview(ctx as any)) {
4954
+ supersedePendingHandoff(
4955
+ "a browser decision arrived while a new review round was running",
4956
+ { force: true },
4957
+ );
4958
+ return;
4959
+ }
4215
4960
  if (!awaitingDecision) return;
4961
+ const manualSource = pendingHandoffSource === "manual";
4962
+ if (manualSource && workflowMode !== "plan") {
4963
+ // A manual review can be launched from Default/Build. Its feedback is a
4964
+ // plan revision, so switch to Plan before the follow-up turn starts.
4965
+ try {
4966
+ await setPlanMode(ctx as any, { quiet: true });
4967
+ } catch (_e) {
4968
+ void _e;
4969
+ }
4970
+ notifyReview(
4971
+ "Review feedback — switched to Plan mode for the revision.",
4972
+ "info",
4973
+ );
4974
+ }
4216
4975
  clearPendingHandoffDecision();
4217
4976
  // Feedback cycle: the next plan revision gets a fresh review round, and the
4218
4977
  // outstanding findings are carried forward so the reviewer can confirm they
4219
4978
  // were actually addressed rather than re-raising them.
4220
- reviewRound = 0;
4221
- lastReviewedPlanHash = undefined;
4979
+ await beginReviewCycle({ reason: "deny", keepFindings: true });
4222
4980
  persistState();
4223
4981
  try {
4224
4982
  const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
@@ -4295,12 +5053,60 @@ export default function workflowExtension(
4295
5053
  (getActiveCtx() as any)?.cwd,
4296
5054
  );
4297
5055
  } else {
4298
- handlePlannotatorDeny(event.feedback || "Plan needs revision.");
5056
+ void handlePlannotatorDeny(
5057
+ event.feedback || "Plan needs revision.",
5058
+ );
4299
5059
  }
4300
5060
  } catch (_e) {
4301
5061
  void _e;
4302
5062
  }
4303
5063
  });
5064
+ // session-opened is authoritative: it carries the review id and URL even
5065
+ // when the request's respond callback is slow or lost. It can arrive
5066
+ // before or after respond; both paths settle the same waiter once.
5067
+ bus.on(PLANNOTATOR_SESSION_OPENED, (event: any) => {
5068
+ try {
5069
+ if (!event || typeof event !== "object") return;
5070
+ const matchesWaiting =
5071
+ typeof event.requestId === "string" &&
5072
+ event.requestId === pendingPlanReviewRequest?.requestId;
5073
+ const matchesCurrent =
5074
+ typeof event.requestId === "string" &&
5075
+ event.requestId === pendingHandoffRequestId;
5076
+ if (!matchesWaiting && !matchesCurrent) return;
5077
+ adoptPlannotatorSession({
5078
+ reviewId: event.reviewId,
5079
+ url: event.url,
5080
+ });
5081
+ if (matchesWaiting) {
5082
+ settlePlanReviewRequest({
5083
+ status: "handled",
5084
+ reviewId:
5085
+ typeof event.reviewId === "string" ? event.reviewId : undefined,
5086
+ url: typeof event.url === "string" ? event.url : undefined,
5087
+ });
5088
+ }
5089
+ } catch (_e) {
5090
+ void _e;
5091
+ }
5092
+ });
5093
+ // A review closed without a decision must not leave a phantom gate that
5094
+ // suppresses the next round. approved/submitted decisions still flow
5095
+ // through review-result (and, after a reload, review-status).
5096
+ bus.on(PLANNOTATOR_SESSION_CLOSED, (event: any) => {
5097
+ try {
5098
+ if (!event || typeof event !== "object") return;
5099
+ if (event.outcome !== "closed") return;
5100
+ if (!lastReviewId || event.reviewId !== lastReviewId) return;
5101
+ if (!awaitingDecision) return;
5102
+ supersedePendingHandoff(
5103
+ "the browser review closed without a decision",
5104
+ { force: true },
5105
+ );
5106
+ } catch (_e) {
5107
+ void _e;
5108
+ }
5109
+ });
4304
5110
  plannotatorListenersRegistered = true;
4305
5111
  plannotatorActive = true;
4306
5112
  const ctx = getActiveCtx();
@@ -5198,7 +6004,8 @@ export default function workflowExtension(
5198
6004
  "Usage: /profile (picker) | /profile list | /profile show <name> | " +
5199
6005
  "/profile use <name|none> | /profile create <name> | /profile edit <name> | " +
5200
6006
  "/profile rename <old> <new> | /profile delete <name> | " +
5201
- "/profile default [name|none] [global|project]";
6007
+ "/profile default [name|none] [global|project] | " +
6008
+ "/profile retarget-provider <old> <new> [global|project] [--apply]";
5202
6009
 
5203
6010
  /** Effective workflow.defaultProfile for display (never throws). */
5204
6011
  function currentDefaultProfileName(cwd?: string): string | undefined {
@@ -6609,6 +7416,79 @@ export default function workflowExtension(
6609
7416
  return;
6610
7417
  }
6611
7418
 
7419
+ if (sub === "retarget-provider" || sub === "retarget") {
7420
+ const [oldProvider, newProvider, ...flags] = rest;
7421
+ if (!oldProvider || !newProvider) {
7422
+ ctx.ui.notify(
7423
+ "Usage: /profile retarget-provider <old> <new> [global|project] [--apply]\n" +
7424
+ "Rewrite every role/profile model ref from one provider to another. " +
7425
+ "Without --apply it only prints the planned changes.",
7426
+ "info",
7427
+ );
7428
+ return;
7429
+ }
7430
+ if (oldProvider === newProvider) {
7431
+ ctx.ui.notify(
7432
+ `Provider is already "${oldProvider}" — nothing to retarget.`,
7433
+ "info",
7434
+ );
7435
+ return;
7436
+ }
7437
+ const apply = flags.some((flag) => {
7438
+ const value = flag.toLowerCase();
7439
+ return value === "--apply" || value === "apply";
7440
+ });
7441
+ const scope = flags.some((flag) => flag.toLowerCase() === "project")
7442
+ ? "project"
7443
+ : "global";
7444
+ const targetCwd = cwd ?? (ctx as any).cwd ?? process.cwd();
7445
+ const result = retargetProviderFiles({
7446
+ profilesPath:
7447
+ scope === "project"
7448
+ ? projectProfilesPath(targetCwd)
7449
+ : userProfilesPath(),
7450
+ rolesPath:
7451
+ scope === "project"
7452
+ ? projectRolesPath(targetCwd)
7453
+ : userRolesPath(),
7454
+ oldProvider,
7455
+ newProvider,
7456
+ dryRun: !apply,
7457
+ });
7458
+ const summary = formatRetargetResult(result);
7459
+ if (
7460
+ result.profiles.status === "invalid" ||
7461
+ result.roles.status === "invalid"
7462
+ ) {
7463
+ ctx.ui.notify(summary, "error");
7464
+ return;
7465
+ }
7466
+ if (!apply) {
7467
+ ctx.ui.notify(
7468
+ `${summary}\n\nScope: ${scope}. A malformed file blocks both writes.`,
7469
+ "info",
7470
+ );
7471
+ return;
7472
+ }
7473
+ // The files changed underneath the cached configs. Force a reload so
7474
+ // the active profile/role resolves the new provider immediately.
7475
+ roleConfigLoadedFor = undefined;
7476
+ profilesLoadedFor = undefined;
7477
+ invalidateEffectiveRoles();
7478
+ ensureRoleConfig(targetCwd);
7479
+ ensureProfilesConfig(targetCwd);
7480
+ updateStatus(ctx as any);
7481
+ if (workflowMode) {
7482
+ try {
7483
+ await applyModeModel(ctx as any);
7484
+ } catch (_e) {
7485
+ void _e;
7486
+ }
7487
+ }
7488
+ ctx.ui.notify(`${summary}\n\nScope: ${scope}.`, "info");
7489
+ return;
7490
+ }
7491
+
6612
7492
  if (sub === "default") {
6613
7493
  // /profile default [name|none] [global|project]
6614
7494
  const target = rest.find(
@@ -8133,31 +9013,39 @@ export default function workflowExtension(
8133
9013
  typeof d.awaitingDecisionAt === "number"
8134
9014
  ? d.awaitingDecisionAt
8135
9015
  : undefined;
8136
- // Stale guard: if awaitingDecision was persisted >60s ago (crash while modal open), reset
8137
- if (
8138
- awaitingDecision &&
8139
- lastHandoffAt &&
8140
- Date.now() - lastHandoffAt > 60_000
8141
- ) {
8142
- awaitingDecision = false;
8143
- lastHandoffAt = undefined;
8144
- try {
8145
- (ctx as any).ui?.notify?.(
8146
- "Recovered stale handoff gate — showing again",
8147
- "info",
8148
- );
8149
- } catch (_e) {
8150
- void _e;
8151
- }
8152
- try {
8153
- pi.appendEntry("workflow-handoff-stale-reset", {
8154
- at: Date.now(),
8155
- lastHandoffAt,
8156
- });
8157
- } catch (_e) {
8158
- void _e;
8159
- }
8160
- }
9016
+ // Restore the whole correlation, not just the boolean: recovery
9017
+ // and stale-browser correlation both need the review identity.
9018
+ lastReviewId =
9019
+ typeof (d as any).lastReviewId === "string"
9020
+ ? (d as any).lastReviewId
9021
+ : null;
9022
+ pendingHandoffPlanPath =
9023
+ typeof (d as any).pendingHandoffPlanPath === "string"
9024
+ ? (d as any).pendingHandoffPlanPath
9025
+ : null;
9026
+ pendingHandoffPlanHash =
9027
+ typeof (d as any).pendingHandoffPlanHash === "string"
9028
+ ? (d as any).pendingHandoffPlanHash
9029
+ : null;
9030
+ pendingHandoffRequestId =
9031
+ typeof (d as any).pendingHandoffRequestId === "string"
9032
+ ? (d as any).pendingHandoffRequestId
9033
+ : null;
9034
+ pendingHandoffSource =
9035
+ (d as any).pendingHandoffSource === "manual"
9036
+ ? "manual"
9037
+ : (d as any).pendingHandoffSource === "auto"
9038
+ ? "auto"
9039
+ : null;
9040
+ pendingHandoffCycleId =
9041
+ typeof (d as any).pendingHandoffCycleId === "number"
9042
+ ? (d as any).pendingHandoffCycleId
9043
+ : 0;
9044
+ pendingHandoffReviewUrl =
9045
+ typeof (d as any).pendingHandoffReviewUrl === "string"
9046
+ ? (d as any).pendingHandoffReviewUrl
9047
+ : null;
9048
+ manualReviewCycle = (d as any).manualReviewCycle === true;
8161
9049
  }
8162
9050
  }
8163
9051
  if (
@@ -8383,9 +9271,10 @@ export default function workflowExtension(
8383
9271
  resetPlanRunProvenance();
8384
9272
  lastPlanWritePath = currentPlanFile ?? null;
8385
9273
  lastPersistedPlanFile = currentPlanFile;
8386
- pendingHandoffPlanPath = null;
8387
- pendingHandoffPlanHash = null;
8388
- lastReviewId = null;
9274
+ // The pending correlation is restored above. Recovery (step 15) decides
9275
+ // whether it survives; do not null it here, or a restored gate can only
9276
+ // suppress the next handoff.
9277
+ if (!awaitingDecision) clearPendingHandoffDecision();
8389
9278
  currentSessionCtx = ctx as any;
8390
9279
  // Give Tab back to autocomplete while a suggestion list is visible; the raw
8391
9280
  // `tab` shortcut keeps cycling modes otherwise.
@@ -8418,10 +9307,14 @@ export default function workflowExtension(
8418
9307
  } catch (_e) {
8419
9308
  void _e;
8420
9309
  }
9310
+ // Recovery is asynchronous (it may query Plannotator); session start must
9311
+ // not wait up to the status timeout. The gate is restored/cleared before
9312
+ // the next user turn.
9313
+ void recoverPendingHandoff(ctx as ExtensionContext, event.reason);
8421
9314
  updateStatus(ctx as any);
8422
9315
  });
8423
9316
 
8424
- pi.on("session_shutdown", async (_event, ctx) => {
9317
+ pi.on("session_shutdown", async (event, ctx) => {
8425
9318
  // A write denial is run-scoped; the session is going away, so drop it.
8426
9319
  writeGateRunDenials.clear();
8427
9320
  writeGateTurnAnswer = null;
@@ -8440,11 +9333,17 @@ export default function workflowExtension(
8440
9333
  }
8441
9334
  }
8442
9335
  activeSubagents.clear();
8443
- // Ensure a handoff decision/provenance does not persist stale across restart
8444
- const clearedAtShutdown = clearPendingHandoffDecision();
9336
+ // A reload/resume/fork can recover a browser review in the replacement
9337
+ // session; quitting or starting a new session cannot own the old server.
9338
+ const keepPending =
9339
+ event.reason === "reload" ||
9340
+ event.reason === "resume" ||
9341
+ event.reason === "fork";
9342
+ const clearedAtShutdown =
9343
+ keepPending === true ? false : clearPendingHandoffDecision();
8445
9344
  resetPlanRunProvenance();
8446
9345
  resetSessionPlanProvenance();
8447
- if (clearedAtShutdown) {
9346
+ if (clearedAtShutdown || keepPending === true) {
8448
9347
  try {
8449
9348
  persistState();
8450
9349
  } catch (_e) {
@@ -8462,7 +9361,7 @@ export default function workflowExtension(
8462
9361
  pendingCommitFlow = null;
8463
9362
  // Release the reviewer session (and its custom tools) on shutdown so a
8464
9363
  // reload or quit never leaves an orphaned in-process session behind.
8465
- await teardownReview();
9364
+ await teardownReview({ keepHandoff: keepPending });
8466
9365
  currentSessionCtx = null;
8467
9366
  });
8468
9367
 
@@ -9485,6 +10384,18 @@ export default function workflowExtension(
9485
10384
  } catch (_e) {
9486
10385
  void _e;
9487
10386
  }
10387
+ // Every fresh prompt is a new review cycle: reset the round budget, cancel
10388
+ // an abandoned pause/gate, and tag the cycle so an old handoff cannot
10389
+ // render after this prompt.
10390
+ try {
10391
+ await beginReviewCycle({
10392
+ reason: "prompt",
10393
+ keepFindings: true,
10394
+ planPath: currentPlanFile,
10395
+ });
10396
+ } catch (_e) {
10397
+ void _e;
10398
+ }
9488
10399
  // Capture header for next checkpoint commit message (first line of prompt)
9489
10400
  try {
9490
10401
  const raw = String((event as any).prompt ?? (event as any).text ?? "");
@@ -9665,6 +10576,25 @@ export default function workflowExtension(
9665
10576
  /** Plan path/hash the currently rendered handoff prompt belongs to. */
9666
10577
  let pendingHandoffPlanPath: string | null = null;
9667
10578
  let pendingHandoffPlanHash: string | null = null;
10579
+ let pendingHandoffRequestId: string | null = null;
10580
+ let pendingHandoffSource: ReviewHandoffSource | null = null;
10581
+ let pendingHandoffCycleId = 0;
10582
+ let pendingHandoffReviewUrl: string | null = null;
10583
+ let pendingHandoffPlanTextLen = 0;
10584
+ /** The one in-flight `plannotator:request` waiter; settles exactly once. */
10585
+ let pendingPlanReviewRequest:
10586
+ | {
10587
+ requestId: string;
10588
+ settled: boolean;
10589
+ finish: (outcome: PlanReviewRequestOutcome) => void;
10590
+ }
10591
+ | undefined;
10592
+ /** The review id already recorded in the transcript (one entry per review). */
10593
+ let recordedHandoffReviewId: string | null = null;
10594
+ /** A manual `/review` owns the following feedback cycle (even when auto is off). */
10595
+ let manualReviewCycle = false;
10596
+ /** One reload recovery at a time (session_start may re-enter). */
10597
+ let recoveryInFlight: Promise<void> | null = null;
9668
10598
  /** Last path announced by the suppressed-handoff notice (per run). */
9669
10599
  let lastPlanReadyNoticeKey: string | null = null;
9670
10600
 
@@ -9689,18 +10619,88 @@ export default function workflowExtension(
9689
10619
  * switch supersedes it; stale browser events must not approve the old plan.
9690
10620
  * Returns true when persisted state changed so the caller can persist once.
9691
10621
  */
10622
+ /** One `/review-status` row for a browser handoff awaiting a decision. */
10623
+ function pendingHandoffStatusLine(): string {
10624
+ if (!awaitingDecision) return "";
10625
+ const age = lastHandoffAt
10626
+ ? Math.max(0, Math.round((Date.now() - lastHandoffAt) / 1000))
10627
+ : 0;
10628
+ return ` handoff : browser pending — ${lastReviewId ?? "review id unknown"}${
10629
+ pendingHandoffReviewUrl ? ` @ ${pendingHandoffReviewUrl}` : ""
10630
+ } (${pendingHandoffSource ?? "auto"}, ${age}s)`;
10631
+ }
10632
+
9692
10633
  function clearPendingHandoffDecision(): boolean {
9693
10634
  const changed =
9694
10635
  awaitingDecision === true ||
9695
10636
  lastHandoffAt !== undefined ||
9696
10637
  lastReviewId !== null ||
9697
10638
  pendingHandoffPlanPath !== null ||
9698
- pendingHandoffPlanHash !== null;
10639
+ pendingHandoffPlanHash !== null ||
10640
+ pendingHandoffRequestId !== null ||
10641
+ pendingHandoffSource !== null ||
10642
+ pendingHandoffCycleId !== 0 ||
10643
+ pendingHandoffReviewUrl !== null;
9699
10644
  awaitingDecision = false;
9700
10645
  lastHandoffAt = undefined;
9701
10646
  lastReviewId = null;
9702
10647
  pendingHandoffPlanPath = null;
9703
10648
  pendingHandoffPlanHash = null;
10649
+ pendingHandoffRequestId = null;
10650
+ pendingHandoffSource = null;
10651
+ pendingHandoffCycleId = 0;
10652
+ pendingHandoffReviewUrl = null;
10653
+ pendingHandoffPlanTextLen = 0;
10654
+ recordedHandoffReviewId = null;
10655
+ return changed;
10656
+ }
10657
+
10658
+ /**
10659
+ * Drop a gate that cannot belong to the round/plan about to start.
10660
+ *
10661
+ * A new automatic round, a manual `/review`, or a browser decision arriving
10662
+ * while another round runs all supersede the old gate. A duplicate event for
10663
+ * the same plan outside a new round must not clear a legitimate pending
10664
+ * prompt, so the pure `pendingHandoffIsStale` predicate decides.
10665
+ */
10666
+ function supersedePendingHandoff(
10667
+ reason: string,
10668
+ opts: {
10669
+ force?: boolean;
10670
+ nextCycleId?: number;
10671
+ nextPlanHash?: string;
10672
+ } = {},
10673
+ ): boolean {
10674
+ if (
10675
+ !pendingHandoffIsStale({
10676
+ awaiting: awaitingDecision,
10677
+ force: opts.force,
10678
+ pendingCycleId:
10679
+ pendingHandoffCycleId > 0 ? pendingHandoffCycleId : undefined,
10680
+ currentCycleId: opts.nextCycleId ?? reviewCycleId,
10681
+ pendingPlanHash: pendingHandoffPlanHash ?? undefined,
10682
+ nextPlanHash: opts.nextPlanHash,
10683
+ })
10684
+ ) {
10685
+ return false;
10686
+ }
10687
+ const changed = clearPendingHandoffDecision();
10688
+ if (changed) {
10689
+ notifyReview(`Plan handoff superseded — ${reason}.`, "info");
10690
+ try {
10691
+ pi.appendEntry("workflow-handoff-superseded", {
10692
+ reason,
10693
+ at: Date.now(),
10694
+ });
10695
+ } catch (_e) {
10696
+ void _e;
10697
+ }
10698
+ try {
10699
+ persistState();
10700
+ } catch (_e) {
10701
+ void _e;
10702
+ }
10703
+ }
9704
10704
  return changed;
9705
10705
  }
9706
10706
 
@@ -9769,6 +10769,7 @@ export default function workflowExtension(
9769
10769
  // The reviewer's `review_submit_plan` writes this path; a manual
9770
10770
  // `/review` must also audit the file the user just wrote.
9771
10771
  currentPlanFile = abs;
10772
+ notePlanTargetForReviewCycle(abs);
9772
10773
  // A run that starts in Default/Build and switches into Plan mid-run
9773
10774
  // still qualifies: what matters is the mode at write time.
9774
10775
  if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
@@ -9779,6 +10780,7 @@ export default function workflowExtension(
9779
10780
  planWritePathThisRun = raw;
9780
10781
  planWrittenThisRun = true;
9781
10782
  currentPlanFile = raw;
10783
+ notePlanTargetForReviewCycle(raw);
9782
10784
  if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
9783
10785
  }
9784
10786
  try {
@@ -9799,17 +10801,29 @@ export default function workflowExtension(
9799
10801
  // (Checked before the mutex: awaiting the in-flight handoff would block
9800
10802
  // for the whole review instead of short-circuiting.)
9801
10803
  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;
10804
+ // A gate from a superseded cycle must not swallow this run's handoff.
10805
+ // Settle it with the old pause text (the old runHandoff already holds the
10806
+ // author's plan) and continue as if it were idle.
10807
+ if (reviewGateCycleId !== reviewCycleId) {
10808
+ settleReviewGate({
10809
+ outcome: "unreviewed",
10810
+ planText: currentPausedReview()?.planText,
10811
+ });
10812
+ reviewGatePhase = "idle";
10813
+ reviewGateCycleId = 0;
10814
+ } else {
10815
+ try {
10816
+ ctx.ui.notify(
10817
+ reviewGatePhase === "paused"
10818
+ ? "Review paused — /review-resume to continue, /review-abort to hand off the plan as-is."
10819
+ : "Review running — ESC pauses it, /review-abort hands off the plan as-is.",
10820
+ "info",
10821
+ );
10822
+ } catch (_e) {
10823
+ void _e;
10824
+ }
10825
+ return;
9811
10826
  }
9812
- return;
9813
10827
  }
9814
10828
  // Mutex: serialize concurrent agent_end handoffs (bus may emit Promise.all)
9815
10829
  if (handoffInFlight) {
@@ -9831,6 +10845,10 @@ export default function workflowExtension(
9831
10845
  }
9832
10846
  }
9833
10847
  const runHandoff = async () => {
10848
+ // Capture the cycle that owns this handoff. A fresh prompt/refine/deny
10849
+ // increments `reviewCycleId`; every later continuation must stop instead
10850
+ // of rendering or applying a superseded plan.
10851
+ const handoffCycle = reviewCycleId;
9834
10852
  // Per-run handoff gate (plan 2026-10-25 follow-up): a Plan-mode run
9835
10853
  // that wrote no plan this run must not reconcile, review, or hand off
9836
10854
  // an older plan. This runs before the reviewer prefetch so a new task
@@ -9862,6 +10880,7 @@ export default function workflowExtension(
9862
10880
  : path.join(cwd, planWritePathThisRun);
9863
10881
  currentPlanFile = abs;
9864
10882
  lastPlanWritePath = abs;
10883
+ notePlanTargetForReviewCycle(abs);
9865
10884
  // Persist once per run even when the tool_result handler already set
9866
10885
  // the in-memory target: resume must find the plan without a mode
9867
10886
  // switch or a todo update happening to persist it first.
@@ -10213,17 +11232,20 @@ export default function workflowExtension(
10213
11232
  void _e;
10214
11233
  }
10215
11234
  };
10216
- if (planTextForRender && reviewEnabled()) {
11235
+ const reviewThisCycle = reviewEnabled() || manualReviewCycle;
11236
+ if (planTextForRender && reviewThisCycle) {
10217
11237
  const authorPlan: string = planTextForRender;
10218
11238
  reviewHandoffGate.reset();
10219
11239
  reviewGatePhase = "auditing";
11240
+ reviewGateCycleId = handoffCycle;
10220
11241
  try {
10221
11242
  const reviewed = await runReviewStep(
10222
11243
  authorPlan,
10223
11244
  ctx as ExtensionContext,
10224
11245
  (ctx as any).signal as AbortSignal | undefined,
10225
- "auto",
11246
+ manualReviewCycle ? "manual" : "auto",
10226
11247
  );
11248
+ if (handoffCycle !== reviewCycleId) return;
10227
11249
  const reviewedText = reviewed.planText;
10228
11250
  // Fresh read: the phase may have moved to "paused" (ESC /
10229
11251
  // /review-pause) while the round was returning. The `as` widens
@@ -10233,7 +11255,7 @@ export default function workflowExtension(
10233
11255
  if (
10234
11256
  reviewHandoffParks({
10235
11257
  phase: phaseAfterRound,
10236
- paused: pausedReview !== undefined,
11258
+ paused: currentPausedReview() !== undefined,
10237
11259
  settled: reviewHandoffGate.settled(),
10238
11260
  })
10239
11261
  ) {
@@ -10246,6 +11268,7 @@ export default function workflowExtension(
10246
11268
  const res = await reviewHandoffGate.wait(
10247
11269
  (ctx as any).signal as AbortSignal | undefined,
10248
11270
  );
11271
+ if (handoffCycle !== reviewCycleId) return;
10249
11272
  if (res === undefined) {
10250
11273
  // Host abort while parked: stop the reviewer, drop the pause,
10251
11274
  // do not auto-review this revision again, and hand the author's
@@ -10290,11 +11313,15 @@ export default function workflowExtension(
10290
11313
  } catch (_e) {
10291
11314
  void _e;
10292
11315
  } finally {
10293
- reviewGatePhase = "idle";
11316
+ if (reviewGateCycleId === handoffCycle) {
11317
+ reviewGatePhase = "idle";
11318
+ reviewGateCycleId = 0;
11319
+ }
10294
11320
  }
10295
11321
  // A mode switch mid-gate (e.g. Tab-to-Build) already left plan mode:
10296
11322
  // the gate settled as skipped, so there is nothing to hand off.
10297
11323
  if (workflowMode !== "plan") return;
11324
+ if (handoffCycle !== reviewCycleId) return;
10298
11325
  }
10299
11326
  // Fallback when hasUI is false: still show handoff via followUp message (Claude Code parity for headless)
10300
11327
  if (!hasUI && planTextForRender && !awaitingDecision) {
@@ -10331,114 +11358,15 @@ export default function workflowExtension(
10331
11358
  if (planTextForRender && hasUI && !awaitingDecision) {
10332
11359
  // ── Plannotator webview branch (browser review) ──────────
10333
11360
  if (plannotatorActive) {
10334
- awaitingDecision = true;
10335
- lastHandoffAt = Date.now();
10336
- pendingHandoffPlanPath = planPath;
10337
- pendingHandoffPlanHash = planHash(planTextForRender);
10338
- persistState();
10339
- try {
10340
- const bus: any = (pi as any).events;
10341
- const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10342
- const response: any = await new Promise((resolve) => {
10343
- let settled = false;
10344
- const timer = setTimeout(() => {
10345
- if (!settled) {
10346
- settled = true;
10347
- resolve({ status: "timeout" });
10348
- }
10349
- }, 5000);
10350
- try {
10351
- bus.emit(PLANNOTATOR_REQUEST, {
10352
- requestId,
10353
- action: "plan-review",
10354
- payload: {
10355
- planContent: planTextForRender!,
10356
- planFilePath: planPath,
10357
- // The handoff only renders after the gated review
10358
- // resolves, so a pending review is impossible here;
10359
- // the field stays for the external browser app.
10360
- reviewPending: false,
10361
- // Provenance so the browser/inline view can show that this
10362
- // plan was independently reviewed (and by what).
10363
- reviewSummary: reviewState
10364
- ? {
10365
- modelLabel: reviewState.modelLabel,
10366
- round: reviewState.round,
10367
- verdict: reviewState.verdict,
10368
- findings: reviewState.findings.length,
10369
- }
10370
- : undefined,
10371
- },
10372
- respond: (res: any) => {
10373
- if (!settled) {
10374
- settled = true;
10375
- clearTimeout(timer);
10376
- resolve(res);
10377
- }
10378
- },
10379
- });
10380
- } catch (e) {
10381
- if (!settled) {
10382
- settled = true;
10383
- clearTimeout(timer);
10384
- resolve({ status: "error", error: String(e) });
10385
- }
10386
- }
10387
- });
10388
- if (
10389
- response &&
10390
- response.status === "handled" &&
10391
- response.result &&
10392
- response.result.reviewId
10393
- ) {
10394
- lastReviewId = response.result.reviewId;
10395
- try {
10396
- pi.appendEntry("workflow-handoff-plannotator", {
10397
- at: Date.now(),
10398
- planPath,
10399
- reviewId: lastReviewId,
10400
- planTextLen: planTextForRender!.length,
10401
- source: planSource,
10402
- });
10403
- } catch (_e) {
10404
- void _e;
10405
- }
10406
- try {
10407
- currentSessionCtx = ctx as any;
10408
- (ctx as any).ui?.notify?.(
10409
- "Plan opened in browser for review. Approve or annotate there.",
10410
- "info",
10411
- );
10412
- updateStatus(ctx as any);
10413
- } catch (_e) {
10414
- void _e;
10415
- }
10416
- return; // plannotator owns the review — result arrives via plannotator:review-result / plan-approved events
10417
- }
10418
- if (response && response.status !== "timeout") {
10419
- try {
10420
- (ctx as any).ui?.notify?.(
10421
- `Plannotator unavailable (${response.status}), falling back to inline review`,
10422
- "warning",
10423
- );
10424
- } catch (_e) {
10425
- void _e;
10426
- }
10427
- }
10428
- } catch (e) {
10429
- try {
10430
- (ctx as any).ui?.notify?.(
10431
- `Plannotator request failed, falling back to inline: ${String(e).slice(0, 80)}`,
10432
- "warning",
10433
- );
10434
- } catch (_e) {
10435
- void _e;
10436
- }
10437
- }
10438
- // Browser branch failed — clear gate so inline can show
10439
- clearPendingHandoffDecision();
10440
- persistState();
10441
- // fall through to inline handoff below
11361
+ const handoff = await requestPlanReview({
11362
+ planText: planTextForRender,
11363
+ planPath,
11364
+ source: "auto",
11365
+ ctx: ctx as ExtensionContext,
11366
+ cycleId: handoffCycle,
11367
+ });
11368
+ if (handoff.ok) return;
11369
+ // The helper already cleared the gate; fall through to inline.
10442
11370
  }
10443
11371
  awaitingDecision = true;
10444
11372
  lastHandoffAt = Date.now();
@@ -10750,6 +11678,14 @@ export default function workflowExtension(
10750
11678
  "",
10751
11679
  );
10752
11680
  if (refinement?.trim()) {
11681
+ try {
11682
+ await beginReviewCycle({
11683
+ reason: "refine",
11684
+ keepFindings: true,
11685
+ });
11686
+ } catch (_e) {
11687
+ void _e;
11688
+ }
10753
11689
  const todoListText = formatTodoNumberedList(todoItems, {
10754
11690
  glyph: true,
10755
11691
  });