@dieulc/workflow 0.8.0 → 0.9.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
@@ -57,6 +57,8 @@ import {
57
57
  todoKey,
58
58
  isPlanWritePath,
59
59
  findPlanWritePathInMessages,
60
+ findLatestPlanWriteInBranch,
61
+ choosePlanPath,
60
62
  shouldHandoffPlan,
61
63
  getUtcDatePrefix,
62
64
  normalizePlanPath,
@@ -82,6 +84,7 @@ import {
82
84
  type TodoStatus,
83
85
  type TodoItem,
84
86
  type TodoUpdateEntry,
87
+ type PlanPathSource,
85
88
  } from "./utils.ts";
86
89
  import {
87
90
  WORKFLOW_ROLE_ENV,
@@ -164,12 +167,21 @@ import {
164
167
  roleIcon,
165
168
  BUILT_IN_ROLES,
166
169
  REVIEW_ROLE_NAME,
167
- VALID_THINKING_LEVELS,
168
170
  type RoleConfig,
169
171
  type Role,
170
172
  type RoleModel,
171
173
  type ThinkingLevel,
172
174
  } from "./roles.ts";
175
+ import {
176
+ clampThinkingForModel,
177
+ thinkingLevelsForModel,
178
+ thinkingLevelsForPair,
179
+ } from "./thinking-levels.ts";
180
+ import {
181
+ buildWorkflowStatusSegments,
182
+ setWorkflowStatus,
183
+ WORKFLOW_STATUS_KEYS,
184
+ } from "./workflow-status.ts";
173
185
  import {
174
186
  defaultProfileConfig,
175
187
  loadProfilesDetailed,
@@ -247,6 +259,7 @@ import {
247
259
  type ReviewFinding,
248
260
  type ReviewExecutionResult,
249
261
  type ReviewHandoffGate,
262
+ type ReviewSdkLoader,
250
263
  type ReviewHandoffPhase,
251
264
  type ReviewHandoffResult,
252
265
  type ReviewModeLockKind,
@@ -475,7 +488,19 @@ function getFinalOutput(messages: any[]): string {
475
488
 
476
489
  // ── Main Extension ───────────────────────────────────────────────────
477
490
 
478
- export default function workflowExtension(pi: ExtensionAPI) {
491
+ /**
492
+ * Optional seams for tests. Production callers (Pi) pass only `pi`, so the
493
+ * default remains byte-for-byte the shipped extension; tests inject a fake
494
+ * reviewer SDK to exercise `/review` and the handoff without a provider.
495
+ */
496
+ export interface WorkflowExtensionDeps {
497
+ loadSdk?: ReviewSdkLoader;
498
+ }
499
+
500
+ export default function workflowExtension(
501
+ pi: ExtensionAPI,
502
+ deps: WorkflowExtensionDeps = {},
503
+ ) {
479
504
  // Review Mode streams the reviewer's output into the main transcript as
480
505
  // display-only entries. Registering once here keeps /reload idempotent.
481
506
  registerReviewEntryRenderers(pi);
@@ -557,6 +582,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
557
582
  | { provider: string; id: string; thinking?: string }
558
583
  | undefined;
559
584
  let currentPlanFile: string | undefined;
585
+ /** Last plan path written into a persisted `workflow` entry. */
586
+ let lastPersistedPlanFile: string | undefined;
560
587
  let awaitingDecision = false;
561
588
  let lastHandoffAt: number | undefined;
562
589
  let handoffInFlight: Promise<void> | null = null;
@@ -1026,9 +1053,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
1026
1053
  if (found) {
1027
1054
  await withPersistSuppressed(async () => {
1028
1055
  await (pi as any).setModel?.(found);
1029
- if (m.thinking && isValidThinkingLevel(m.thinking)) {
1030
- (pi as any).setThinkingLevel?.(m.thinking);
1031
- }
1056
+ applyThinkingForModel(ctx, found, m.thinking, "Default mode");
1032
1057
  });
1033
1058
  if (!opts?.quiet) {
1034
1059
  ctx.ui.notify(
@@ -1205,8 +1230,6 @@ export default function workflowExtension(pi: ExtensionAPI) {
1205
1230
  // Profile prefix (⚡ name) when one is active, then the role label.
1206
1231
  const profileLabel = activeProfileLabel();
1207
1232
  const roleLabel = activeRoleLabel();
1208
- const statusParts = [profileLabel, roleLabel].filter(Boolean);
1209
- const suffix = statusParts.length ? ` | ${statusParts.join(" | ")}` : "";
1210
1233
  // Commit-policy surfacing (workflow.commitPolicy): always visible so the
1211
1234
  // user can see at a glance what the agent may do to git history. Read
1212
1235
  // live so /commit-policy changes show up without a reload.
@@ -1219,7 +1242,6 @@ export default function workflowExtension(pi: ExtensionAPI) {
1219
1242
  } catch {
1220
1243
  commitMode = "never";
1221
1244
  }
1222
- const policy = ` · commits:${commitMode}`;
1223
1245
  // Write-policy surfacing (workflow.writePolicy): the active file-write
1224
1246
  // permission, visible at a glance like the commit policy. Read live so a
1225
1247
  // /write-policy change shows up without a reload. It only applies in
@@ -1233,67 +1255,38 @@ export default function workflowExtension(pi: ExtensionAPI) {
1233
1255
  } catch {
1234
1256
  writeMode = "allow";
1235
1257
  }
1236
- const writePolicy =
1237
- workflowMode === null ? ` · writes:${writeMode}` : "";
1238
- const staleMarker = todoStale ? " ⚠" : "";
1239
- // Review segment (plan 2026-10-23 follow-up): one footer owner — the
1240
- // round already shows `reviewStateSummary` in its widget, so the footer
1241
- // composes the same string instead of opening a second status key.
1242
- let reviewSegment = "";
1243
- try {
1244
- if (
1245
- reviewStatusVisible({
1246
- roundInFlight: activeReview !== undefined,
1247
- roundActive: reviewRuntime?.isRoundActive() === true,
1248
- paused: pausedReview !== undefined,
1249
- })
1250
- ) {
1251
- const seg = reviewFooterSegment(
1252
- reviewState,
1253
- pausedReview !== undefined,
1254
- );
1255
- if (seg) reviewSegment = ` · ${seg}`;
1256
- }
1257
- // Lock marker is independent of `reviewState`: it must show during the
1258
- // cold boot (before the first phase event) and while the gated handoff
1259
- // audit has returned but not yet rendered.
1260
- const lockFooter = reviewModeLockFooter(reviewModeLockKindNow());
1261
- if (lockFooter) reviewSegment += ` · ${lockFooter}`;
1262
- } catch (_e) {
1263
- void _e;
1264
- reviewSegment = "";
1265
- }
1266
- const done = todoItems.filter((t) => isDone(t)).length;
1267
- const total = todoItems.length;
1268
- if (workflowMode === "build") {
1269
- const label =
1270
- total > 0
1271
- ? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}${reviewSegment}`
1272
- : `▶ build${suffix}${policy}${writePolicy}${reviewSegment}`;
1273
- ctx.ui.setStatus("workflow", ctx.ui.theme.fg("accent", label));
1274
- } else if (workflowMode === "plan") {
1275
- const base = plannotatorActive
1276
- ? "⏸ plan — reviewing in browser"
1277
- : "⏸ plan";
1278
- const label =
1279
- total > 0
1280
- ? `${base} ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}${reviewSegment}`
1281
- : `${base}${suffix}${policy}${writePolicy}${reviewSegment}`;
1282
- ctx.ui.setStatus("workflow", ctx.ui.theme.fg("warning", label));
1283
- } else if (total > 0) {
1284
- ctx.ui.setStatus(
1285
- "workflow",
1286
- ctx.ui.theme.fg(
1287
- "muted",
1288
- `◦ ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}${reviewSegment}`,
1289
- ),
1290
- );
1291
- } else {
1292
- ctx.ui.setStatus(
1293
- "workflow",
1294
- ctx.ui.theme.fg("muted", `◦ commits:${commitMode}${writePolicy}${reviewSegment}`),
1295
- );
1296
- }
1258
+ const segments = buildWorkflowStatusSegments({
1259
+ mode: workflowMode,
1260
+ plannotatorActive,
1261
+ done: todoItems.filter((t) => isDone(t)).length,
1262
+ total: todoItems.length,
1263
+ stale: todoStale,
1264
+ profileLabel,
1265
+ roleLabel,
1266
+ commitMode,
1267
+ writeMode: workflowMode === null ? writeMode : null,
1268
+ });
1269
+ const modeTone =
1270
+ workflowMode === "build"
1271
+ ? "accent"
1272
+ : workflowMode === "plan"
1273
+ ? "warning"
1274
+ : "muted";
1275
+ setWorkflowStatus(
1276
+ ctx.ui,
1277
+ WORKFLOW_STATUS_KEYS.mode,
1278
+ ctx.ui.theme.fg(modeTone, segments.mode),
1279
+ );
1280
+ setWorkflowStatus(
1281
+ ctx.ui,
1282
+ WORKFLOW_STATUS_KEYS.profile,
1283
+ segments.profile ? ctx.ui.theme.fg("muted", segments.profile) : "",
1284
+ );
1285
+ setWorkflowStatus(
1286
+ ctx.ui,
1287
+ WORKFLOW_STATUS_KEYS.policy,
1288
+ ctx.ui.theme.fg("dim", segments.policy),
1289
+ );
1297
1290
  if (todoItems.length > 0) {
1298
1291
  ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
1299
1292
  return {
@@ -1601,8 +1594,6 @@ export default function workflowExtension(pi: ExtensionAPI) {
1601
1594
  reviewTruncationNoted = false;
1602
1595
  }
1603
1596
 
1604
- const REVIEW_STATUS_WIDGET = "workflow-review-status";
1605
-
1606
1597
  function reviewAgentDir(): string {
1607
1598
  try {
1608
1599
  return getAgentDir();
@@ -1635,6 +1626,121 @@ export default function workflowExtension(pi: ExtensionAPI) {
1635
1626
  }
1636
1627
  }
1637
1628
 
1629
+ interface ReviewPlanTarget {
1630
+ abs: string;
1631
+ text: string;
1632
+ source: PlanPathSource;
1633
+ display: string;
1634
+ }
1635
+
1636
+ type ReviewPlanResolution =
1637
+ | { kind: "ok"; target: ReviewPlanTarget }
1638
+ | { kind: "no-plan"; recent: string[] }
1639
+ | { kind: "error"; message: string };
1640
+
1641
+ /** Newest `.pi/plans/*.md` files, relative to cwd, newest first. */
1642
+ function listRecentPlanFiles(cwd: string, limit = 3): string[] {
1643
+ try {
1644
+ const dir = path.join(cwd, CONFIG_DIR_NAME, "plans");
1645
+ return fs
1646
+ .readdirSync(dir)
1647
+ .filter((f) => f.toLowerCase().endsWith(".md"))
1648
+ .map((f) => {
1649
+ const abs = path.join(dir, f);
1650
+ return { abs, mtime: fs.statSync(abs).mtimeMs };
1651
+ })
1652
+ .sort((a, b) => b.mtime - a.mtime)
1653
+ .slice(0, limit)
1654
+ .map((r) => path.relative(cwd, r.abs) || r.abs);
1655
+ } catch (_e) {
1656
+ void _e;
1657
+ return [];
1658
+ }
1659
+ }
1660
+
1661
+ /**
1662
+ * Resolve the plan a manual `/review` should audit.
1663
+ *
1664
+ * Priority (plan 2026-10-31): explicit path → this run's write → the
1665
+ * session's last write → the handoff target → the newest write in the
1666
+ * session branch. A candidate that no longer exists or is empty is skipped.
1667
+ * An explicit path is authoritative: if it is unreadable or outside
1668
+ * `.pi/plans/`, the command reports that instead of silently falling back
1669
+ * to a different file.
1670
+ */
1671
+ function resolveReviewTarget(
1672
+ ctx: any,
1673
+ explicitArg: string | undefined,
1674
+ ): ReviewPlanResolution {
1675
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
1676
+ const display = (abs: string) => path.relative(cwd, abs) || abs;
1677
+ const candidates: Array<{ path: string; source: PlanPathSource }> = [];
1678
+ const push = (raw: string | null | undefined, source: PlanPathSource) => {
1679
+ if (typeof raw !== "string" || raw.length === 0) return;
1680
+ candidates.push({
1681
+ path: path.isAbsolute(raw) ? raw : path.join(cwd, raw),
1682
+ source,
1683
+ });
1684
+ };
1685
+ if (explicitArg) {
1686
+ const abs = path.isAbsolute(explicitArg)
1687
+ ? explicitArg
1688
+ : path.join(cwd, explicitArg);
1689
+ if (!isPlanWritePath(abs, cwd)) {
1690
+ return {
1691
+ kind: "error",
1692
+ message: `Review only reads plans under ${path.join(CONFIG_DIR_NAME, "plans")}${path.sep} — got ${explicitArg}`,
1693
+ };
1694
+ }
1695
+ push(abs, "explicit");
1696
+ } else {
1697
+ push(planWritePathThisRun, "run");
1698
+ push(lastPlanWritePath, "session");
1699
+ push(currentPlanFile, "handoff");
1700
+ try {
1701
+ const branch = (ctx as any)?.sessionManager?.getBranch?.() ?? [];
1702
+ push(findLatestPlanWriteInBranch(branch, cwd), "branch");
1703
+ } catch (_e) {
1704
+ void _e;
1705
+ }
1706
+ }
1707
+ const read = (abs: string): string | undefined => {
1708
+ try {
1709
+ if (!fs.statSync(abs).isFile()) return undefined;
1710
+ const text = fs.readFileSync(abs, "utf8");
1711
+ return text.length > 0 ? text : undefined;
1712
+ } catch {
1713
+ return undefined;
1714
+ }
1715
+ };
1716
+ const chosen = choosePlanPath(candidates, (abs) => read(abs) !== undefined);
1717
+ if (!chosen) {
1718
+ if (explicitArg) {
1719
+ return {
1720
+ kind: "error",
1721
+ message: `Plan file not readable: ${explicitArg}`,
1722
+ };
1723
+ }
1724
+ return { kind: "no-plan", recent: listRecentPlanFiles(cwd) };
1725
+ }
1726
+ const text = read(chosen.path);
1727
+ if (text === undefined) {
1728
+ return {
1729
+ kind: "error",
1730
+ message: `Plan file not readable: ${display(chosen.path)}`,
1731
+ };
1732
+ }
1733
+ return {
1734
+ kind: "ok",
1735
+ target: {
1736
+ abs: chosen.path,
1737
+ text,
1738
+ source: chosen.source,
1739
+ display: display(chosen.path),
1740
+ },
1741
+ };
1742
+ }
1743
+
1638
1744
  function reviewConfigNow() {
1639
1745
  try {
1640
1746
  return readReviewModeConfig(reviewCwd(), reviewAgentDir());
@@ -1711,26 +1817,23 @@ export default function workflowExtension(pi: ExtensionAPI) {
1711
1817
  roundActive,
1712
1818
  paused,
1713
1819
  });
1714
- // The widget is a LIVE review indicator, not a result holder: the last
1820
+ // The review status is a LIVE indicator, not a result holder: the last
1715
1821
  // round's state stays for `/review-status` and the REVIEW SUMMARY band,
1716
- // but the editor line must disappear as soon as the round ends. While
1822
+ // but the footer segment must disappear as soon as the round ends. While
1717
1823
  // paused it shows the paused form (never the terminal "aborted" summary).
1718
- const text = visible ? reviewFooterSegment(reviewState, paused) : "";
1719
- if (!visible || !text) {
1720
- ctx.ui.setWidget(REVIEW_STATUS_WIDGET, undefined);
1721
- return;
1824
+ const reviewText = visible ? reviewFooterSegment(reviewState, paused) : "";
1825
+ let lockText = "";
1826
+ try {
1827
+ lockText = reviewModeLockFooter(reviewModeLockKindNow());
1828
+ } catch (_e) {
1829
+ void _e;
1722
1830
  }
1723
- ctx.ui.setWidget(REVIEW_STATUS_WIDGET, (_tui: any, theme: any) => ({
1724
- render: (w: number) => [
1725
- truncateToWidth(
1726
- theme.fg("muted", text),
1727
- Math.max(10, Number(w) || 40),
1728
- "…",
1729
- ),
1730
- ],
1731
- invalidate: () => {},
1732
- dispose: () => {},
1733
- }));
1831
+ const text = [reviewText, lockText].filter(Boolean).join(" · ");
1832
+ setWorkflowStatus(
1833
+ ctx.ui,
1834
+ WORKFLOW_STATUS_KEYS.review,
1835
+ text ? ctx.ui.theme.fg("muted", text) : "",
1836
+ );
1734
1837
  } catch (_e) {
1735
1838
  void _e;
1736
1839
  }
@@ -1873,6 +1976,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
1873
1976
  }
1874
1977
  },
1875
1978
  onTranscriptOps: (ops) => pushReviewOps(ops),
1979
+ ...(deps.loadSdk ? { loadSdk: deps.loadSdk } : {}),
1876
1980
  });
1877
1981
  return reviewRuntime;
1878
1982
  }
@@ -2496,7 +2600,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
2496
2600
  resetReviewStream();
2497
2601
  detachReviewEscListenerNow();
2498
2602
  try {
2499
- currentSessionCtx?.ui.setWidget(REVIEW_STATUS_WIDGET, undefined);
2603
+ currentSessionCtx?.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
2500
2604
  } catch (_e) {
2501
2605
  void _e;
2502
2606
  }
@@ -2932,14 +3036,34 @@ export default function workflowExtension(pi: ExtensionAPI) {
2932
3036
 
2933
3037
  pi.registerCommand("review", {
2934
3038
  description:
2935
- "Run a one-shot Review round now; works whether Review Mode is on or off (mode stays OFF); /review say <msg> talks to the in-flight reviewer",
3039
+ "Run a one-shot Review round now: /review [path] works whether Review Mode is on or off (mode stays OFF); /review say <msg> talks to the in-flight reviewer",
3040
+ getArgumentCompletions: (prefix: string) => {
3041
+ const cwd = (currentSessionCtx as any)?.cwd ?? process.cwd();
3042
+ const items = [
3043
+ {
3044
+ value: "say",
3045
+ label: "say",
3046
+ description: "Send a message to the in-flight reviewer",
3047
+ },
3048
+ ...listRecentPlanFiles(cwd, 8).map((p) => ({
3049
+ value: p,
3050
+ label: p,
3051
+ description: "Review this plan",
3052
+ })),
3053
+ ];
3054
+ const p = prefix.trim();
3055
+ const filtered = items.filter(
3056
+ (i) => i.value.startsWith(p) || i.label.startsWith(p),
3057
+ );
3058
+ return filtered.length ? filtered : null;
3059
+ },
2936
3060
  handler: async (args, ctx) => {
2937
3061
  // Explicit reviewer steering (plan 2026-10-23, step 3): the only path
2938
3062
  // that delivers user text into an in-flight review round. Plain chat
2939
3063
  // stays with the plan agent (see the `input` handler).
2940
3064
  const raw = (args || "").trim();
2941
- if (/^say\b/i.test(raw)) {
2942
- const message = raw.replace(/^say\b/i, "").trim();
3065
+ if (/^say(?:\s|$)/i.test(raw)) {
3066
+ const message = raw.replace(/^say(?:\s|$)/i, "").trim();
2943
3067
  if (!message) {
2944
3068
  ctx.ui.notify(
2945
3069
  "Usage: /review say <message> — send a message to the in-flight reviewer.",
@@ -2978,27 +3102,45 @@ export default function workflowExtension(pi: ExtensionAPI) {
2978
3102
  // Manual one-shot review: works whether or not Review Mode is on.
2979
3103
  // No `enabled` check here — readReviewModeConfig never throws and
2980
3104
  // runReviewStep("manual") supplies defaults when unreadable.
2981
- const planPath = currentPlanFile;
2982
- if (!planPath) {
3105
+ //
3106
+ // The plan is resolved independently of handoff eligibility: a run that
3107
+ // started in Default and switched to Plan, or a plan written before a
3108
+ // mode switch, must still be auditable (plan 2026-10-31).
3109
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
3110
+ let explicit: string | undefined = raw.length
3111
+ ? raw.replace(/^(["'])(.*)\1$/, "$2").trim()
3112
+ : undefined;
3113
+ if (explicit) {
3114
+ const abs = path.isAbsolute(explicit)
3115
+ ? explicit
3116
+ : path.join(cwd, explicit);
3117
+ // Mirror the write-path auto-correction: a mistyped UTC date prefix
3118
+ // should resolve to the file the extension actually wrote.
3119
+ const normalized = normalizePlanPath(abs, cwd, getUtcDatePrefix());
3120
+ explicit =
3121
+ normalized.corrected &&
3122
+ fs.existsSync(normalized.path) &&
3123
+ !fs.existsSync(abs)
3124
+ ? normalized.path
3125
+ : abs;
3126
+ }
3127
+ const resolution = resolveReviewTarget(ctx, explicit);
3128
+ if (resolution.kind === "no-plan") {
3129
+ const recent = resolution.recent;
3130
+ const hint = recent.length
3131
+ ? `\nRecent plans:\n${recent.map((r) => ` /review ${r}`).join("\n")}`
3132
+ : "";
2983
3133
  ctx.ui.notify(
2984
- "No plan file yet — write one to .pi/plans/ first.",
3134
+ `No plan file found for this session — run /review <path> with a file under .pi/plans/.${hint}`,
2985
3135
  "warning",
2986
3136
  );
2987
3137
  return;
2988
3138
  }
2989
- let text: string | undefined;
2990
- try {
2991
- const abs = path.isAbsolute(planPath)
2992
- ? planPath
2993
- : path.join((ctx as any).cwd ?? process.cwd(), planPath);
2994
- text = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : undefined;
2995
- } catch (_e) {
2996
- void _e;
2997
- }
2998
- if (!text) {
2999
- ctx.ui.notify(`Plan file not readable: ${planPath}`, "warning");
3139
+ if (resolution.kind === "error") {
3140
+ ctx.ui.notify(resolution.message, "warning");
3000
3141
  return;
3001
3142
  }
3143
+ const target = resolution.target;
3002
3144
  // The gated handoff owns the reviewer while it is open: a fresh round
3003
3145
  // here would race the handoff it is about to render. Steer the running
3004
3146
  // round with `/review say`, pause with ESC, or stop it with
@@ -3031,12 +3173,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
3031
3173
  void _e;
3032
3174
  }
3033
3175
  }
3176
+ // The reviewer writes through `currentPlanFile`; point it at the file
3177
+ // `/review` actually read before the round starts. A fallback source is
3178
+ // announced so the user can see which plan is being audited.
3179
+ if (target.source !== "run" && target.source !== "explicit") {
3180
+ notifyReview(`Reviewing ${target.display}`, "info");
3181
+ }
3182
+ currentPlanFile = target.abs;
3183
+ lastPlanWritePath = target.abs;
3184
+ persistState();
3034
3185
  lastReviewedPlanHash = undefined; // force a fresh review
3035
3186
  abortedReviewHash = undefined;
3036
3187
  pausedReview = undefined;
3037
3188
  reviewRound = 0;
3038
3189
  const result = await runReviewStep(
3039
- text,
3190
+ target.text,
3040
3191
  ctx as any,
3041
3192
  (ctx as any).signal as AbortSignal | undefined,
3042
3193
  "manual",
@@ -3316,9 +3467,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
3316
3467
  ) {
3317
3468
  await withPersistSuppressed(async () => {
3318
3469
  try {
3319
- if (m.thinking && isValidThinkingLevel(m.thinking)) {
3320
- (pi as any).setThinkingLevel?.(m.thinking);
3321
- }
3470
+ applyThinkingForModel(
3471
+ ctx,
3472
+ current,
3473
+ m.thinking,
3474
+ `Workflow ${workflowMode ?? "mode"}`,
3475
+ );
3322
3476
  } catch (_e) {
3323
3477
  void _e;
3324
3478
  }
@@ -3353,9 +3507,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
3353
3507
  }
3354
3508
  await withPersistSuppressed(async () => {
3355
3509
  try {
3356
- if (m.thinking && isValidThinkingLevel(m.thinking)) {
3357
- (pi as any).setThinkingLevel?.(m.thinking);
3358
- }
3510
+ applyThinkingForModel(ctx, found, m.thinking, `Workflow ${workflowMode ?? "mode"}`);
3359
3511
  } catch (_e) {
3360
3512
  void _e;
3361
3513
  }
@@ -3417,12 +3569,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
3417
3569
  current.id === defaultModel.id
3418
3570
  ) {
3419
3571
  try {
3420
- if (
3421
- defaultModel.thinking &&
3422
- isValidThinkingLevel(defaultModel.thinking)
3423
- ) {
3424
- (pi as any).setThinkingLevel?.(defaultModel.thinking);
3425
- }
3572
+ applyThinkingForModel(
3573
+ ctx,
3574
+ current,
3575
+ defaultModel.thinking as ThinkingLevel | undefined,
3576
+ "Default mode",
3577
+ );
3426
3578
  } catch (_e) {
3427
3579
  void _e;
3428
3580
  }
@@ -3438,12 +3590,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
3438
3590
  if (!found) return; // registry no longer has it — keep current model
3439
3591
  try {
3440
3592
  await (pi as any).setModel?.(found);
3441
- if (
3442
- defaultModel.thinking &&
3443
- isValidThinkingLevel(defaultModel.thinking)
3444
- ) {
3445
- (pi as any).setThinkingLevel?.(defaultModel.thinking);
3446
- }
3593
+ applyThinkingForModel(
3594
+ ctx,
3595
+ found,
3596
+ defaultModel.thinking as ThinkingLevel | undefined,
3597
+ "Default mode",
3598
+ );
3447
3599
  if (!opts?.quiet) {
3448
3600
  ctx.ui.notify(
3449
3601
  `Default mode: model → ${defaultModel.provider}/${defaultModel.id}${defaultModel.thinking ? ` (${defaultModel.thinking})` : ""}`,
@@ -3605,6 +3757,31 @@ export default function workflowExtension(pi: ExtensionAPI) {
3605
3757
  }
3606
3758
  }
3607
3759
 
3760
+ /**
3761
+ * Apply a role/default thinking level, clamped to the model Pi will actually
3762
+ * use. The role config is shared with terminal Pi and may predate the model's
3763
+ * current `thinkingLevelMap`; clamping here keeps the live session valid.
3764
+ *
3765
+ * The clamp is **silent**: the host already shows the live thinking level, and
3766
+ * notifying on every mode switch buried the transcript and toasts in the same
3767
+ * advisory (2026-10-31 plan). Explicit `/role set` validation still warns
3768
+ * because that is a direct user edit.
3769
+ */
3770
+ function applyThinkingForModel(
3771
+ _ctx: ExtensionContext | null,
3772
+ model: any,
3773
+ level: ThinkingLevel | undefined,
3774
+ _label: string,
3775
+ ): void {
3776
+ if (!level || !isValidThinkingLevel(level)) return;
3777
+ const clamped = clampThinkingForModel(model, level);
3778
+ try {
3779
+ (pi as any).setThinkingLevel?.(clamped);
3780
+ } catch (_e) {
3781
+ void _e;
3782
+ }
3783
+ }
3784
+
3608
3785
  /**
3609
3786
  * Read the persisted default from disk. Pure: it never mutates the in-memory
3610
3787
  * `defaultModel`, because precedence is resolved in one place at session
@@ -3645,6 +3822,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
3645
3822
  defaultModel,
3646
3823
  lastModeSwitch,
3647
3824
  } as any);
3825
+ lastPersistedPlanFile = currentPlanFile;
3648
3826
  }
3649
3827
 
3650
3828
  async function setPlanMode(
@@ -3666,9 +3844,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
3666
3844
  if (workflowMode === null) captureDefaultModel(ctx);
3667
3845
  workflowMode = "plan";
3668
3846
  syncLegacyFlags();
3669
- // Entering Plan starts a new handoff cycle. Old plan/todos stay as
3670
- // reference, but only the next fresh prompt can make a run eligible.
3671
- resetPlanRunProvenance();
3847
+ // Do NOT reset per-run provenance here. A fresh prompt already does it at
3848
+ // run start; preserving a plan write made earlier in THIS run is what lets
3849
+ // `/review` (and the "Plan file ready" notice) see it after a mid-run
3850
+ // switch. The mode-at-write flag keeps a Default-written plan from being
3851
+ // auto-handoffed unless the write itself happened under Plan.
3672
3852
  clearPendingHandoffDecision();
3673
3853
  if (toolsBeforePlanMode === undefined)
3674
3854
  toolsBeforePlanMode = pi.getActiveTools();
@@ -4853,21 +5033,28 @@ export default function workflowExtension(pi: ExtensionAPI) {
4853
5033
  }
4854
5034
  thinking = thinkingRaw.toLowerCase() as any;
4855
5035
  }
4856
- // validate against registry (warn-only — model may be added later)
5036
+ // Validate against the registry. A missing model is warn-only (it may be
5037
+ // added later), but a known model clamps the stored level to its map.
5038
+ let found: any;
4857
5039
  try {
4858
- const reg = (ctx as any)?.modelRegistry;
4859
- if (
4860
- reg &&
4861
- typeof reg.find === "function" &&
4862
- !reg.find(provider, id)
4863
- ) {
5040
+ found = (ctx as any)?.modelRegistry?.find?.(provider, id);
5041
+ } catch (_e) {
5042
+ void _e;
5043
+ }
5044
+ if (!found) {
5045
+ ctx.ui.notify(
5046
+ `Warning: ${provider}/${id} not in model registry — saved anyway.`,
5047
+ "warning",
5048
+ );
5049
+ } else {
5050
+ const pair = thinkingLevelsForPair(found, thinking);
5051
+ if (pair.level !== thinking) {
4864
5052
  ctx.ui.notify(
4865
- `Warning: ${provider}/${id} not in model registry — saved anyway.`,
5053
+ `Thinking ${thinking} is not supported by ${provider}/${id}; using ${pair.level}.`,
4866
5054
  "warning",
4867
5055
  );
4868
5056
  }
4869
- } catch (_e) {
4870
- void _e;
5057
+ thinking = pair.level;
4871
5058
  }
4872
5059
  role.model = { provider, id, thinking };
4873
5060
  const saved = saveAndSync(
@@ -5736,12 +5923,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
5736
5923
  const buildModelItems = (): FilterableItem[] =>
5737
5924
  modelPickerItems(showAll ? catalog.all : catalog.scoped);
5738
5925
  const modelLists = new Map<string, FilterableList>();
5739
- const thinkingList = new FilterableList({
5740
- items: VALID_THINKING_LEVELS.map((t) => ({ value: t, label: t })),
5741
- maxVisible: Math.min(VALID_THINKING_LEVELS.length, 8),
5742
- theme: themeList,
5743
- noMatchText: "No matching thinking levels",
5744
- });
5926
+ const thinkingLists = new Map<string, FilterableList>();
5745
5927
  const getModelList = (roleName: string): FilterableList => {
5746
5928
  let list = modelLists.get(roleName);
5747
5929
  if (!list) {
@@ -5760,6 +5942,26 @@ export default function workflowExtension(pi: ExtensionAPI) {
5760
5942
  }
5761
5943
  return list;
5762
5944
  };
5945
+ /** One list per model: the supported set differs per role/default tab. */
5946
+ const getThinkingList = (roleName: string): FilterableList => {
5947
+ let list = thinkingLists.get(roleName);
5948
+ if (!list) {
5949
+ const st = staged.get(roleName)!;
5950
+ const levels = thinkingLevelsForModel(
5951
+ registryModel(ctx, st.provider, st.id),
5952
+ );
5953
+ const idx = levels.indexOf(st.thinking);
5954
+ list = new FilterableList({
5955
+ items: levels.map((t) => ({ value: t, label: t })),
5956
+ maxVisible: Math.min(levels.length, 8),
5957
+ theme: themeList,
5958
+ initialIndex: Math.max(0, idx),
5959
+ noMatchText: "No matching thinking levels",
5960
+ });
5961
+ thinkingLists.set(roleName, list);
5962
+ }
5963
+ return list;
5964
+ };
5763
5965
  // Ctrl+O re-scopes the rows: keep each tab's query and re-filter instead
5764
5966
  // of rebuilding the lists from scratch.
5765
5967
  const rebuildModelLists = () => {
@@ -5768,11 +5970,14 @@ export default function workflowExtension(pi: ExtensionAPI) {
5768
5970
  };
5769
5971
  const syncThinkingCursor = (roleName: string) => {
5770
5972
  const st = staged.get(roleName)!;
5771
- // Clear first: the shared list may still be filtered from a previous
5772
- // visit, and the index refers to the full VALID_THINKING_LEVELS order.
5773
- thinkingList.clearFilter();
5774
- const idx = VALID_THINKING_LEVELS.indexOf(st.thinking);
5775
- thinkingList.setSelectedIndex(idx >= 0 ? idx : 0);
5973
+ const list = getThinkingList(roleName);
5974
+ // Clear first: the list may still be filtered from a previous visit.
5975
+ list.clearFilter();
5976
+ const levels = thinkingLevelsForModel(
5977
+ registryModel(ctx, st.provider, st.id),
5978
+ );
5979
+ const idx = levels.indexOf(st.thinking);
5980
+ list.setSelectedIndex(idx >= 0 ? idx : 0);
5776
5981
  };
5777
5982
  const commitModelSelection = (roleName: string) => {
5778
5983
  const sel = getModelList(roleName).getSelectedItem();
@@ -5780,11 +5985,23 @@ export default function workflowExtension(pi: ExtensionAPI) {
5780
5985
  const { provider, id } = parseModelRef(sel.value);
5781
5986
  if (!provider || !id) return false;
5782
5987
  const st = staged.get(roleName)!;
5783
- staged.set(roleName, { ...st, provider, id });
5988
+ // A new model can drop the staged level (DeepSeek has no `medium`).
5989
+ const pair = thinkingLevelsForPair(
5990
+ registryModel(ctx, provider, id),
5991
+ st.thinking,
5992
+ );
5993
+ staged.set(roleName, {
5994
+ ...st,
5995
+ provider,
5996
+ id,
5997
+ thinking: pair.level,
5998
+ });
5999
+ // The old list belongs to the old model; rebuild on the next visit.
6000
+ thinkingLists.delete(roleName);
5784
6001
  return true;
5785
6002
  };
5786
6003
  const commitThinkingSelection = (roleName: string) => {
5787
- const sel = thinkingList.getSelectedItem();
6004
+ const sel = getThinkingList(roleName).getSelectedItem();
5788
6005
  if (!sel || !isValidThinkingLevel(sel.value)) return false;
5789
6006
  const st = staged.get(roleName)!;
5790
6007
  staged.set(roleName, { ...st, thinking: sel.value });
@@ -5842,7 +6059,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
5842
6059
  // (thinking → model) or cancels the editor.
5843
6060
  if (matchesKey(data, Key.escape)) {
5844
6061
  const activeList =
5845
- phase === "model" ? getModelList(roleName) : thinkingList;
6062
+ phase === "model" ? getModelList(roleName) : getThinkingList(roleName);
5846
6063
  if (activeList.handleInput(data)) {
5847
6064
  cached = undefined;
5848
6065
  tui.requestRender();
@@ -5871,7 +6088,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
5871
6088
  return;
5872
6089
  }
5873
6090
  const activeList =
5874
- phase === "model" ? getModelList(roleName) : thinkingList;
6091
+ phase === "model" ? getModelList(roleName) : getThinkingList(roleName);
5875
6092
  activeList.handleInput(data);
5876
6093
  cached = undefined;
5877
6094
  tui.requestRender();
@@ -5899,7 +6116,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
5899
6116
  const chips: string[] = [];
5900
6117
  for (let i = 0; i < tabIds.length; i++) {
5901
6118
  const roleName = tabIds[i];
5902
- const st = staged.get(roleName)!;
6119
+ const st = staged.get(roleName);
6120
+ if (!st) continue;
5903
6121
  const base =
5904
6122
  roleName === "default"
5905
6123
  ? existing?.defaultModel
@@ -5952,7 +6170,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
5952
6170
  );
5953
6171
  lines.push("");
5954
6172
  for (const roleName of tabIds) {
5955
- const st = staged.get(roleName)!;
6173
+ const st = staged.get(roleName);
6174
+ if (!st) continue;
5956
6175
  lines.push(
5957
6176
  ...wrap(
5958
6177
  ` ${theme.fg("muted", `${roleIcon(roleName)} ${roleName}: `)}${theme.fg(
@@ -5966,8 +6185,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
5966
6185
  }
5967
6186
  } else {
5968
6187
  const roleName = tabIds[currentTab];
5969
- const st = staged.get(roleName)!;
5970
- const meta = roleMeta.get(roleName)!;
6188
+ const st = staged.get(roleName);
6189
+ const meta = roleMeta.get(roleName);
6190
+ if (!st || !meta) return cached ?? lines;
5971
6191
  lines.push(
5972
6192
  ...wrap(
5973
6193
  ` ${theme.fg("text", `${roleIcon(roleName)} ${roleName} — ${meta.description}`)}`,
@@ -6025,9 +6245,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
6025
6245
  lines.push(
6026
6246
  ...wrap(` ${theme.fg("accent", "Thinking level:")}`),
6027
6247
  );
6028
- for (const l of thinkingList.renderQuery(Math.max(1, W - 2)))
6248
+ const list = getThinkingList(roleName);
6249
+ for (const l of list.renderQuery(Math.max(1, W - 2)))
6029
6250
  lines.push(` ${l}`);
6030
- for (const l of thinkingList.renderList(Math.max(1, W - 2)))
6251
+ for (const l of list.renderList(Math.max(1, W - 2)))
6031
6252
  lines.push(` ${l}`);
6032
6253
  }
6033
6254
  }
@@ -6063,21 +6284,30 @@ export default function workflowExtension(pi: ExtensionAPI) {
6063
6284
  );
6064
6285
  return "cancelled";
6065
6286
  }
6066
- // Validate against registry (warn-only, matching /role set).
6287
+ // Validate against registry (warn-only, matching /role set). A known model
6288
+ // also clamps its staged thinking level so this save cannot write a pair
6289
+ // the model does not support.
6067
6290
  try {
6068
6291
  const reg = (ctx as any)?.modelRegistry;
6069
6292
  if (reg && typeof reg.find === "function") {
6070
6293
  for (const [roleName, st] of saved.staged) {
6071
- if (
6072
- st.provider &&
6073
- st.id &&
6074
- typeof reg.find === "function" &&
6075
- !reg.find(st.provider, st.id)
6076
- )
6294
+ if (!st.provider || !st.id) continue;
6295
+ const model = reg.find(st.provider, st.id);
6296
+ if (!model) {
6077
6297
  ctx.ui.notify(
6078
6298
  `Warning: ${st.provider}/${st.id} (${roleName}) not in model registry — saved anyway.`,
6079
6299
  "warning",
6080
6300
  );
6301
+ continue;
6302
+ }
6303
+ const pair = thinkingLevelsForPair(model, st.thinking);
6304
+ if (pair.level !== st.thinking) {
6305
+ ctx.ui.notify(
6306
+ `Thinking ${st.thinking} → ${pair.level} for ${roleName} (${st.provider}/${st.id} does not support ${st.thinking}).`,
6307
+ "warning",
6308
+ );
6309
+ st.thinking = pair.level;
6310
+ }
6081
6311
  }
6082
6312
  }
6083
6313
  } catch (_e) {
@@ -6611,15 +6841,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
6611
6841
  const buildModelItems = (): FilterableItem[] =>
6612
6842
  modelPickerItems(showAll ? catalog.all : catalog.scoped);
6613
6843
  const modelLists = new Map<string, FilterableList>();
6614
- const thinkingList = new FilterableList({
6615
- items: VALID_THINKING_LEVELS.map((t) => ({
6616
- value: t,
6617
- label: t,
6618
- })),
6619
- maxVisible: Math.min(VALID_THINKING_LEVELS.length, 8),
6620
- theme: listTheme,
6621
- noMatchText: "No matching thinking levels",
6622
- });
6844
+ const thinkingLists = new Map<string, FilterableList>();
6623
6845
  const getModelList = (role: Role): FilterableList => {
6624
6846
  let list = modelLists.get(role.name);
6625
6847
  if (!list) {
@@ -6638,6 +6860,26 @@ export default function workflowExtension(pi: ExtensionAPI) {
6638
6860
  }
6639
6861
  return list;
6640
6862
  };
6863
+ /** One list per role: the supported set follows that role's model. */
6864
+ const getThinkingList = (role: Role): FilterableList => {
6865
+ let list = thinkingLists.get(role.name);
6866
+ if (!list) {
6867
+ const st = staged.get(role.name)!;
6868
+ const levels = thinkingLevelsForModel(
6869
+ registryModel(ctx, st.provider, st.id),
6870
+ );
6871
+ const idx = levels.indexOf(st.thinking);
6872
+ list = new FilterableList({
6873
+ items: levels.map((t) => ({ value: t, label: t })),
6874
+ maxVisible: Math.min(levels.length, 8),
6875
+ theme: listTheme,
6876
+ initialIndex: Math.max(0, idx),
6877
+ noMatchText: "No matching thinking levels",
6878
+ });
6879
+ thinkingLists.set(role.name, list);
6880
+ }
6881
+ return list;
6882
+ };
6641
6883
  // Ctrl+O re-scopes the rows: keep each tab's query and re-filter
6642
6884
  // instead of rebuilding the lists from scratch.
6643
6885
  const rebuildModelLists = () => {
@@ -6646,11 +6888,14 @@ export default function workflowExtension(pi: ExtensionAPI) {
6646
6888
  };
6647
6889
  const syncThinkingCursor = (role: Role) => {
6648
6890
  const st = staged.get(role.name)!;
6649
- // Clear first: the shared list may still be filtered from a previous
6650
- // visit, and the index refers to the full VALID_THINKING_LEVELS order.
6651
- thinkingList.clearFilter();
6652
- const idx = VALID_THINKING_LEVELS.indexOf(st.thinking);
6653
- thinkingList.setSelectedIndex(idx >= 0 ? idx : 0);
6891
+ const list = getThinkingList(role);
6892
+ // Clear first: the list may still be filtered from a previous visit.
6893
+ list.clearFilter();
6894
+ const levels = thinkingLevelsForModel(
6895
+ registryModel(ctx, st.provider, st.id),
6896
+ );
6897
+ const idx = levels.indexOf(st.thinking);
6898
+ list.setSelectedIndex(idx >= 0 ? idx : 0);
6654
6899
  };
6655
6900
  const isSubmitTab = () => currentTab === tabs.length;
6656
6901
 
@@ -6660,13 +6905,22 @@ export default function workflowExtension(pi: ExtensionAPI) {
6660
6905
  const { provider, id } = parseModelRef(sel.value);
6661
6906
  if (!provider || !id) return false;
6662
6907
  const st = staged.get(role.name)!;
6663
- staged.set(role.name, { ...st, provider, id });
6664
- // If the chosen model is unknown to the registry, keep it —
6665
- // save path warns, matching /role set behavior.
6908
+ const pair = thinkingLevelsForPair(
6909
+ registryModel(ctx, provider, id),
6910
+ st.thinking,
6911
+ );
6912
+ staged.set(role.name, {
6913
+ ...st,
6914
+ provider,
6915
+ id,
6916
+ thinking: pair.level,
6917
+ });
6918
+ // The old list belongs to the old model; rebuild on the next visit.
6919
+ thinkingLists.delete(role.name);
6666
6920
  return true;
6667
6921
  };
6668
6922
  const commitThinkingSelection = (role: Role) => {
6669
- const sel = thinkingList.getSelectedItem();
6923
+ const sel = getThinkingList(role).getSelectedItem();
6670
6924
  if (!sel || !isValidThinkingLevel(sel.value)) return false;
6671
6925
  const st = staged.get(role.name)!;
6672
6926
  staged.set(role.name, { ...st, thinking: sel.value });
@@ -6723,7 +6977,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
6723
6977
  // (thinking → model) or cancels the picker.
6724
6978
  if (matchesKey(data, Key.escape)) {
6725
6979
  const activeList =
6726
- phase === "model" ? getModelList(role) : thinkingList;
6980
+ phase === "model" ? getModelList(role) : getThinkingList(role);
6727
6981
  if (activeList.handleInput(data)) {
6728
6982
  cached = undefined;
6729
6983
  tui.requestRender();
@@ -6752,7 +7006,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
6752
7006
  return;
6753
7007
  }
6754
7008
  const activeList =
6755
- phase === "model" ? getModelList(role) : thinkingList;
7009
+ phase === "model" ? getModelList(role) : getThinkingList(role);
6756
7010
  activeList.handleInput(data);
6757
7011
  cached = undefined;
6758
7012
  tui.requestRender();
@@ -6895,9 +7149,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
6895
7149
  lines.push(
6896
7150
  ...wrap(` ${theme.fg("accent", "Thinking level:")}`),
6897
7151
  );
6898
- for (const l of thinkingList.renderQuery(Math.max(1, W - 2)))
7152
+ const list = getThinkingList(role);
7153
+ for (const l of list.renderQuery(Math.max(1, W - 2)))
6899
7154
  lines.push(` ${l}`);
6900
- for (const l of thinkingList.renderList(Math.max(1, W - 2)))
7155
+ for (const l of list.renderList(Math.max(1, W - 2)))
6901
7156
  lines.push(` ${l}`);
6902
7157
  }
6903
7158
  }
@@ -6931,16 +7186,28 @@ export default function workflowExtension(pi: ExtensionAPI) {
6931
7186
  ctx.ui.notify("Role picker cancelled.", "info");
6932
7187
  return;
6933
7188
  }
6934
- // Validate against registry (warn-only, matching /role set) then save.
7189
+ // Validate against registry (warn-only, matching /role set) and clamp each
7190
+ // staged thinking level to the model it will run on.
6935
7191
  try {
6936
7192
  const reg = (ctx as any)?.modelRegistry;
6937
7193
  if (reg && typeof reg.find === "function") {
6938
7194
  for (const [name, st] of saved) {
6939
- if (!reg.find(st.provider, st.id))
7195
+ const model = reg.find(st.provider, st.id);
7196
+ if (!model) {
6940
7197
  ctx.ui.notify(
6941
7198
  `Warning: ${st.provider}/${st.id} (${name}) not in model registry — saved anyway.`,
6942
7199
  "warning",
6943
7200
  );
7201
+ continue;
7202
+ }
7203
+ const pair = thinkingLevelsForPair(model, st.thinking);
7204
+ if (pair.level !== st.thinking) {
7205
+ ctx.ui.notify(
7206
+ `Thinking ${st.thinking} → ${pair.level} for ${name} (${st.provider}/${st.id} does not support ${st.thinking}).`,
7207
+ "warning",
7208
+ );
7209
+ st.thinking = pair.level;
7210
+ }
6944
7211
  }
6945
7212
  }
6946
7213
  } catch (_e) {
@@ -7750,6 +8017,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
7750
8017
  // ── Session & Agent lifecycle ────────────────────────────────────
7751
8018
 
7752
8019
  pi.on("session_start", async (event, ctx) => {
8020
+ // A session boundary owns the session-scoped plan fallback; the restore
8021
+ // loop below re-seeds it from the persisted `planFile`.
8022
+ resetSessionPlanProvenance();
7753
8023
  // A reloaded/forked session must never inherit a stale write denial.
7754
8024
  writeGateRunDenials.clear();
7755
8025
  writeGateTurnAnswer = null;
@@ -7797,7 +8067,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
7797
8067
  if (Array.isArray(d.todos)) todoItems = ensureTodoLabels(d.todos);
7798
8068
  todoPlanPath = d.planFile;
7799
8069
  toolsBeforePlanMode = d.toolsBeforePlanMode;
7800
- currentPlanFile = d.planFile;
8070
+ // An entry without a planFile must not erase a value restored
8071
+ // from an earlier entry (the entry shape has changed over time).
8072
+ if (typeof d.planFile === "string" && d.planFile) {
8073
+ currentPlanFile = d.planFile;
8074
+ }
7801
8075
  // restore role state (file config is source of truth for roles;
7802
8076
  // session entry carries the latest activeRole) and the session's
7803
8077
  // own profile activation
@@ -8041,12 +8315,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
8041
8315
  await withPersistSuppressed(async () => {
8042
8316
  const ok = await (pi as any)?.setModel?.(found);
8043
8317
  if (ok === false) throw new Error("setModel returned false");
8044
- if (
8045
- effectiveDefault.thinking &&
8046
- isValidThinkingLevel(effectiveDefault.thinking)
8047
- ) {
8048
- (pi as any).setThinkingLevel?.(effectiveDefault.thinking);
8049
- }
8318
+ applyThinkingForModel(
8319
+ ctx as any,
8320
+ found,
8321
+ effectiveDefault.thinking,
8322
+ "Session start",
8323
+ );
8050
8324
  });
8051
8325
  } catch (e: any) {
8052
8326
  // Auth missing or the provider rejected the switch: keep Pi's pick.
@@ -8107,6 +8381,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
8107
8381
  // pending browser correlation. The persisted awaitingDecision recovery
8108
8382
  // above is kept for a recent modal; the first fresh prompt supersedes it.
8109
8383
  resetPlanRunProvenance();
8384
+ lastPlanWritePath = currentPlanFile ?? null;
8385
+ lastPersistedPlanFile = currentPlanFile;
8110
8386
  pendingHandoffPlanPath = null;
8111
8387
  pendingHandoffPlanHash = null;
8112
8388
  lastReviewId = null;
@@ -8167,6 +8443,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
8167
8443
  // Ensure a handoff decision/provenance does not persist stale across restart
8168
8444
  const clearedAtShutdown = clearPendingHandoffDecision();
8169
8445
  resetPlanRunProvenance();
8446
+ resetSessionPlanProvenance();
8170
8447
  if (clearedAtShutdown) {
8171
8448
  try {
8172
8449
  persistState();
@@ -8174,7 +8451,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
8174
8451
  void _e;
8175
8452
  }
8176
8453
  }
8177
- ctx.ui.setStatus("workflow", undefined);
8454
+ ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.mode, undefined);
8455
+ ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.profile, undefined);
8456
+ ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.policy, undefined);
8457
+ ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
8178
8458
  ctx.ui.setWidget("workflow-todos", undefined);
8179
8459
  // Drop any /commit authorization or flow: a reload/quit must never leave
8180
8460
  // an exemption armed for whatever runs next.
@@ -8191,6 +8471,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
8191
8471
  todoItems = [];
8192
8472
  todoPlanPath = undefined;
8193
8473
  btwNotes = [];
8474
+ // A rewind is a branch boundary: no per-run write from the old branch
8475
+ // may authorize a handoff or a manual review on the new one.
8476
+ resetPlanRunProvenance();
8477
+ clearPendingHandoffDecision();
8194
8478
  // A rewind must not preserve a /commit exemption across branches.
8195
8479
  commitAuthorization = null;
8196
8480
  pendingCommitFlow = null;
@@ -8214,6 +8498,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
8214
8498
  todoItems = ensureTodoLabels(e.message.details.todos);
8215
8499
  }
8216
8500
  }
8501
+ // Re-derive the plan from the branch we rewound to. Keeping the old
8502
+ // value would let `/review` audit a plan that no longer exists here.
8503
+ const latest = findLatestPlanWriteInBranch(branch, ctx.cwd);
8504
+ currentPlanFile = latest;
8505
+ lastPlanWritePath = latest ?? null;
8217
8506
  } catch (_e) {
8218
8507
  void _e;
8219
8508
  }
@@ -9179,24 +9468,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
9179
9468
  // moved on, so a stale tab must not approve the previous plan. Follow-up
9180
9469
  // runs keep the current run's provenance.
9181
9470
  try {
9182
- const fw =
9183
- (event as any).followUp === true ||
9184
- (event as any).deliverAs === "followUp";
9185
- if (!fw) {
9186
- const age = lastHandoffAt ? Date.now() - lastHandoffAt : Infinity;
9187
- const cleared = clearPendingHandoffDecision();
9188
- resetPlanRunProvenance();
9189
- handoffEligibleThisRun = workflowMode === "plan";
9190
- if (cleared) {
9191
- persistState();
9192
- try {
9193
- pi.appendEntry("workflow-handoff-reset-before-start", {
9194
- at: Date.now(),
9195
- age,
9196
- });
9197
- } catch (_e) {
9198
- void _e;
9199
- }
9471
+ const age = lastHandoffAt ? Date.now() - lastHandoffAt : Infinity;
9472
+ const cleared = clearPendingHandoffDecision();
9473
+ resetPlanRunProvenance();
9474
+ if (cleared) {
9475
+ persistState();
9476
+ try {
9477
+ pi.appendEntry("workflow-handoff-reset-before-start", {
9478
+ at: Date.now(),
9479
+ age,
9480
+ });
9481
+ } catch (_e) {
9482
+ void _e;
9200
9483
  }
9201
9484
  }
9202
9485
  } catch (_e) {
@@ -9365,23 +9648,40 @@ export default function workflowExtension(pi: ExtensionAPI) {
9365
9648
  });
9366
9649
 
9367
9650
  // Plan file detection + full render + decision gate
9651
+ //
9652
+ // Two lifetimes (plan 2026-10-31):
9653
+ // - per-run: `planWritten*` / `planWritePathThisRun` decide the automatic
9654
+ // handoff for THIS run and reset on every fresh prompt/mode switch;
9655
+ // - session: `lastPlanWritePath` is the manual `/review` fallback and must
9656
+ // survive mode switches and fresh prompts. Only a session boundary clears
9657
+ // it. (`currentPlanFile` is the reviewer/Build target and is kept too.)
9368
9658
  let lastPlanWritePath: string | null = null;
9369
- /** This user run started in Plan mode; a Build-originated run never hands off. */
9370
- let handoffEligibleThisRun = false;
9371
9659
  /** This run wrote/edited a plan file under .pi/plans/. */
9372
9660
  let planWrittenThisRun = false;
9661
+ /** This run wrote a plan while Plan mode was active (a mid-run switch counts). */
9662
+ let planWrittenInPlanModeThisRun = false;
9373
9663
  /** Absolute path of this run's last plan write. */
9374
9664
  let planWritePathThisRun: string | null = null;
9375
9665
  /** Plan path/hash the currently rendered handoff prompt belongs to. */
9376
9666
  let pendingHandoffPlanPath: string | null = null;
9377
9667
  let pendingHandoffPlanHash: string | null = null;
9668
+ /** Last path announced by the suppressed-handoff notice (per run). */
9669
+ let lastPlanReadyNoticeKey: string | null = null;
9378
9670
 
9379
- /** Clear per-run handoff provenance (fresh prompt / mode switch / session boundary). */
9671
+ /** Clear per-run handoff provenance (fresh prompt / mode switch / run end). */
9380
9672
  function resetPlanRunProvenance(): void {
9381
- handoffEligibleThisRun = false;
9382
9673
  planWrittenThisRun = false;
9674
+ planWrittenInPlanModeThisRun = false;
9383
9675
  planWritePathThisRun = null;
9676
+ lastPlanReadyNoticeKey = null;
9677
+ // Deliberately NOT lastPlanWritePath: a mode switch or a new prompt must
9678
+ // not make the plan the user just wrote unreachable to `/review`.
9679
+ }
9680
+
9681
+ /** Clear the session-scoped plan fallback (session start/shutdown only). */
9682
+ function resetSessionPlanProvenance(): void {
9384
9683
  lastPlanWritePath = null;
9684
+ lastPersistedPlanFile = undefined;
9385
9685
  }
9386
9686
 
9387
9687
  /**
@@ -9466,11 +9766,20 @@ export default function workflowExtension(pi: ExtensionAPI) {
9466
9766
  // handoff. `lastPlanWritePath` alone is session-lifetime stale.
9467
9767
  planWritePathThisRun = abs;
9468
9768
  planWrittenThisRun = true;
9769
+ // The reviewer's `review_submit_plan` writes this path; a manual
9770
+ // `/review` must also audit the file the user just wrote.
9771
+ currentPlanFile = abs;
9772
+ // A run that starts in Default/Build and switches into Plan mid-run
9773
+ // still qualifies: what matters is the mode at write time.
9774
+ if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
9469
9775
  } catch (_e) {
9470
9776
  void _e;
9471
- lastPlanWritePath = String(p);
9472
- planWritePathThisRun = String(p);
9777
+ const raw = String(p);
9778
+ lastPlanWritePath = raw;
9779
+ planWritePathThisRun = raw;
9473
9780
  planWrittenThisRun = true;
9781
+ currentPlanFile = raw;
9782
+ if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
9474
9783
  }
9475
9784
  try {
9476
9785
  pi.appendEntry("workflow-handoff-resolve", {
@@ -9538,16 +9847,59 @@ export default function workflowExtension(pi: ExtensionAPI) {
9538
9847
  : path.join(cwd, found);
9539
9848
  lastPlanWritePath = planWritePathThisRun;
9540
9849
  planWrittenThisRun = true;
9850
+ // The fallback only scans THIS run and we are in Plan mode now, so
9851
+ // this current-run write is attributable to the Plan cycle.
9852
+ planWrittenInPlanModeThisRun = true;
9541
9853
  }
9542
9854
  }
9855
+ // Persist the plan target once per run, in every mode, before the
9856
+ // eligibility gate. A plan written in Default/Build still needs to be
9857
+ // reachable by `/review` and by a later resume.
9858
+ if (planWritePathThisRun) {
9859
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
9860
+ const abs = path.isAbsolute(planWritePathThisRun)
9861
+ ? planWritePathThisRun
9862
+ : path.join(cwd, planWritePathThisRun);
9863
+ currentPlanFile = abs;
9864
+ lastPlanWritePath = abs;
9865
+ // Persist once per run even when the tool_result handler already set
9866
+ // the in-memory target: resume must find the plan without a mode
9867
+ // switch or a todo update happening to persist it first.
9868
+ if (currentPlanFile !== lastPersistedPlanFile) persistState();
9869
+ }
9543
9870
  if (workflowMode === "plan") {
9544
9871
  const eligible = shouldHandoffPlan({
9545
9872
  mode: workflowMode,
9546
- runStartedInPlan: handoffEligibleThisRun,
9547
9873
  planWrittenThisRun,
9874
+ planWrittenInPlanMode: planWrittenInPlanModeThisRun,
9548
9875
  });
9549
9876
  if (!eligible) {
9550
- if (!planWrittenThisRun) clearPendingHandoffDecision();
9877
+ if (planWrittenThisRun) {
9878
+ // The plan is real and current, but it was written before this
9879
+ // run entered Plan mode. Do not hand it off automatically; make
9880
+ // the manual path discoverable instead.
9881
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
9882
+ const abs = planWritePathThisRun
9883
+ ? path.isAbsolute(planWritePathThisRun)
9884
+ ? planWritePathThisRun
9885
+ : path.join(cwd, planWritePathThisRun)
9886
+ : undefined;
9887
+ const key = abs ?? planWritePathThisRun;
9888
+ if (key && key !== lastPlanReadyNoticeKey) {
9889
+ lastPlanReadyNoticeKey = key;
9890
+ const rel = abs ? path.relative(cwd, abs) || abs : key;
9891
+ try {
9892
+ ctx.ui.notify(
9893
+ `Plan file ready: ${rel}. Run /review to audit it.`,
9894
+ "info",
9895
+ );
9896
+ } catch (_e) {
9897
+ void _e;
9898
+ }
9899
+ }
9900
+ } else {
9901
+ clearPendingHandoffDecision();
9902
+ }
9551
9903
  return;
9552
9904
  }
9553
9905
  }
@@ -10667,6 +11019,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
10667
11019
  }
10668
11020
  toolsBeforePlanMode = undefined;
10669
11021
  }
11022
+ // Leaving Plan starts a new cycle; entering Plan preserves a plan
11023
+ // written earlier in this run (see setPlanMode).
11024
+ if (!state.active) resetPlanRunProvenance();
11025
+ clearPendingHandoffDecision();
10670
11026
  // Find a ctx to update status — use a no-op if none
10671
11027
  // Status will be refreshed on next session_start/turn anyway
10672
11028
  persistState();