@dieulc/workflow 0.8.1 → 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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/index.ts +254 -171
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -11,7 +11,7 @@ Plan↔Build mode with an independent **Review Mode** reviewer, questionnaire, s
11
11
  - **Todos** — plan-faithful todo list (global step refs + statuses + phase groups) that persists across Plan/Build/Default modes. `workflow_todo` (`list/update/done/pending/add/toggle/sync/clear`) with industry-style wholesale `update` (send the COMPLETE list; omitted items are removed) plus idempotent `done`/`pending`, lenient `[DONE:<ref>]` markers, throttled Claude-Code-style reminders, a tool-result footer, and a stale ⚠ notice — but **no forced turns and no heuristic auto-completion** (reference: Claude Code / OpenCode / Codex practice).
12
12
  - **Rewind** — shadow bare-repo checkpoints per cwd (`agent/checkpoints/checkpoint-<slug>.git`), `isDirty` + `createSafetySnapshot` + `restoreCode`
13
13
  - **Plannotator bridge** — `plannotator:request/review-result/plan-approved` events, inline markdown handoff (`ScrollView` → inline scrollback), `getUtcDatePrefix` / `normalizePlanPath` for `.pi/plans/<UTC-date>-<slug>.md`
14
- - **Model roles** — Planner/Explorer/Builder roles with one model + thinking level each (`roles.ts`, `~/.pi/agent/roles.json` v2). `/plan` switches to the planner model, `/build` to the builder model, `explore` subagents use the explorer model. `/role` opens a tabbed picker (scoped models first, `Ctrl+O` for all).
14
+ - **Model roles** — Planner/Explorer/Builder roles with one model + thinking level each (`roles.ts`, `~/.pi/agent/roles.json` v2). `/plan` switches to the planner model, `/build` to the builder model, `explore` subagents use the explorer model. `/role` opens a tabbed picker (scoped models first, `Ctrl+O` for all). The thinking list follows the selected model's `thinkingLevelMap`; unsupported stored levels are clamped on save/activation with a warning.
15
15
  - **Write permission** — a user-controlled `allow`/`ask`/`never` permission for file writes in **Default** mode, the mode that is otherwise ungated. `/write-policy` sets it; see [Write permission](#write-permission) below.
16
16
  - **Model profiles** — named combinations of role models (`profiles.ts`, `~/.pi/agent/profiles.json` v1). Create a profile per provider or per budget, switch with `/profile` — activation is session-scoped, so two concurrent sessions can run different profiles. With no profile active the roles.json flow applies unchanged. See [Model Profiles](#model-profiles).
17
17
 
package/index.ts CHANGED
@@ -167,12 +167,21 @@ import {
167
167
  roleIcon,
168
168
  BUILT_IN_ROLES,
169
169
  REVIEW_ROLE_NAME,
170
- VALID_THINKING_LEVELS,
171
170
  type RoleConfig,
172
171
  type Role,
173
172
  type RoleModel,
174
173
  type ThinkingLevel,
175
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";
176
185
  import {
177
186
  defaultProfileConfig,
178
187
  loadProfilesDetailed,
@@ -1044,9 +1053,7 @@ export default function workflowExtension(
1044
1053
  if (found) {
1045
1054
  await withPersistSuppressed(async () => {
1046
1055
  await (pi as any).setModel?.(found);
1047
- if (m.thinking && isValidThinkingLevel(m.thinking)) {
1048
- (pi as any).setThinkingLevel?.(m.thinking);
1049
- }
1056
+ applyThinkingForModel(ctx, found, m.thinking, "Default mode");
1050
1057
  });
1051
1058
  if (!opts?.quiet) {
1052
1059
  ctx.ui.notify(
@@ -1223,8 +1230,6 @@ export default function workflowExtension(
1223
1230
  // Profile prefix (⚡ name) when one is active, then the role label.
1224
1231
  const profileLabel = activeProfileLabel();
1225
1232
  const roleLabel = activeRoleLabel();
1226
- const statusParts = [profileLabel, roleLabel].filter(Boolean);
1227
- const suffix = statusParts.length ? ` | ${statusParts.join(" | ")}` : "";
1228
1233
  // Commit-policy surfacing (workflow.commitPolicy): always visible so the
1229
1234
  // user can see at a glance what the agent may do to git history. Read
1230
1235
  // live so /commit-policy changes show up without a reload.
@@ -1237,7 +1242,6 @@ export default function workflowExtension(
1237
1242
  } catch {
1238
1243
  commitMode = "never";
1239
1244
  }
1240
- const policy = ` · commits:${commitMode}`;
1241
1245
  // Write-policy surfacing (workflow.writePolicy): the active file-write
1242
1246
  // permission, visible at a glance like the commit policy. Read live so a
1243
1247
  // /write-policy change shows up without a reload. It only applies in
@@ -1251,67 +1255,38 @@ export default function workflowExtension(
1251
1255
  } catch {
1252
1256
  writeMode = "allow";
1253
1257
  }
1254
- const writePolicy =
1255
- workflowMode === null ? ` · writes:${writeMode}` : "";
1256
- const staleMarker = todoStale ? " ⚠" : "";
1257
- // Review segment (plan 2026-10-23 follow-up): one footer owner — the
1258
- // round already shows `reviewStateSummary` in its widget, so the footer
1259
- // composes the same string instead of opening a second status key.
1260
- let reviewSegment = "";
1261
- try {
1262
- if (
1263
- reviewStatusVisible({
1264
- roundInFlight: activeReview !== undefined,
1265
- roundActive: reviewRuntime?.isRoundActive() === true,
1266
- paused: pausedReview !== undefined,
1267
- })
1268
- ) {
1269
- const seg = reviewFooterSegment(
1270
- reviewState,
1271
- pausedReview !== undefined,
1272
- );
1273
- if (seg) reviewSegment = ` · ${seg}`;
1274
- }
1275
- // Lock marker is independent of `reviewState`: it must show during the
1276
- // cold boot (before the first phase event) and while the gated handoff
1277
- // audit has returned but not yet rendered.
1278
- const lockFooter = reviewModeLockFooter(reviewModeLockKindNow());
1279
- if (lockFooter) reviewSegment += ` · ${lockFooter}`;
1280
- } catch (_e) {
1281
- void _e;
1282
- reviewSegment = "";
1283
- }
1284
- const done = todoItems.filter((t) => isDone(t)).length;
1285
- const total = todoItems.length;
1286
- if (workflowMode === "build") {
1287
- const label =
1288
- total > 0
1289
- ? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}${reviewSegment}`
1290
- : `▶ build${suffix}${policy}${writePolicy}${reviewSegment}`;
1291
- ctx.ui.setStatus("workflow", ctx.ui.theme.fg("accent", label));
1292
- } else if (workflowMode === "plan") {
1293
- const base = plannotatorActive
1294
- ? "⏸ plan — reviewing in browser"
1295
- : "⏸ plan";
1296
- const label =
1297
- total > 0
1298
- ? `${base} ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}${reviewSegment}`
1299
- : `${base}${suffix}${policy}${writePolicy}${reviewSegment}`;
1300
- ctx.ui.setStatus("workflow", ctx.ui.theme.fg("warning", label));
1301
- } else if (total > 0) {
1302
- ctx.ui.setStatus(
1303
- "workflow",
1304
- ctx.ui.theme.fg(
1305
- "muted",
1306
- `◦ ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}${reviewSegment}`,
1307
- ),
1308
- );
1309
- } else {
1310
- ctx.ui.setStatus(
1311
- "workflow",
1312
- ctx.ui.theme.fg("muted", `◦ commits:${commitMode}${writePolicy}${reviewSegment}`),
1313
- );
1314
- }
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
+ );
1315
1290
  if (todoItems.length > 0) {
1316
1291
  ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
1317
1292
  return {
@@ -1619,8 +1594,6 @@ export default function workflowExtension(
1619
1594
  reviewTruncationNoted = false;
1620
1595
  }
1621
1596
 
1622
- const REVIEW_STATUS_WIDGET = "workflow-review-status";
1623
-
1624
1597
  function reviewAgentDir(): string {
1625
1598
  try {
1626
1599
  return getAgentDir();
@@ -1844,26 +1817,23 @@ export default function workflowExtension(
1844
1817
  roundActive,
1845
1818
  paused,
1846
1819
  });
1847
- // 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
1848
1821
  // round's state stays for `/review-status` and the REVIEW SUMMARY band,
1849
- // 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
1850
1823
  // paused it shows the paused form (never the terminal "aborted" summary).
1851
- const text = visible ? reviewFooterSegment(reviewState, paused) : "";
1852
- if (!visible || !text) {
1853
- ctx.ui.setWidget(REVIEW_STATUS_WIDGET, undefined);
1854
- return;
1824
+ const reviewText = visible ? reviewFooterSegment(reviewState, paused) : "";
1825
+ let lockText = "";
1826
+ try {
1827
+ lockText = reviewModeLockFooter(reviewModeLockKindNow());
1828
+ } catch (_e) {
1829
+ void _e;
1855
1830
  }
1856
- ctx.ui.setWidget(REVIEW_STATUS_WIDGET, (_tui: any, theme: any) => ({
1857
- render: (w: number) => [
1858
- truncateToWidth(
1859
- theme.fg("muted", text),
1860
- Math.max(10, Number(w) || 40),
1861
- "…",
1862
- ),
1863
- ],
1864
- invalidate: () => {},
1865
- dispose: () => {},
1866
- }));
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
+ );
1867
1837
  } catch (_e) {
1868
1838
  void _e;
1869
1839
  }
@@ -2630,7 +2600,7 @@ export default function workflowExtension(
2630
2600
  resetReviewStream();
2631
2601
  detachReviewEscListenerNow();
2632
2602
  try {
2633
- currentSessionCtx?.ui.setWidget(REVIEW_STATUS_WIDGET, undefined);
2603
+ currentSessionCtx?.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
2634
2604
  } catch (_e) {
2635
2605
  void _e;
2636
2606
  }
@@ -3497,9 +3467,12 @@ export default function workflowExtension(
3497
3467
  ) {
3498
3468
  await withPersistSuppressed(async () => {
3499
3469
  try {
3500
- if (m.thinking && isValidThinkingLevel(m.thinking)) {
3501
- (pi as any).setThinkingLevel?.(m.thinking);
3502
- }
3470
+ applyThinkingForModel(
3471
+ ctx,
3472
+ current,
3473
+ m.thinking,
3474
+ `Workflow ${workflowMode ?? "mode"}`,
3475
+ );
3503
3476
  } catch (_e) {
3504
3477
  void _e;
3505
3478
  }
@@ -3534,9 +3507,7 @@ export default function workflowExtension(
3534
3507
  }
3535
3508
  await withPersistSuppressed(async () => {
3536
3509
  try {
3537
- if (m.thinking && isValidThinkingLevel(m.thinking)) {
3538
- (pi as any).setThinkingLevel?.(m.thinking);
3539
- }
3510
+ applyThinkingForModel(ctx, found, m.thinking, `Workflow ${workflowMode ?? "mode"}`);
3540
3511
  } catch (_e) {
3541
3512
  void _e;
3542
3513
  }
@@ -3598,12 +3569,12 @@ export default function workflowExtension(
3598
3569
  current.id === defaultModel.id
3599
3570
  ) {
3600
3571
  try {
3601
- if (
3602
- defaultModel.thinking &&
3603
- isValidThinkingLevel(defaultModel.thinking)
3604
- ) {
3605
- (pi as any).setThinkingLevel?.(defaultModel.thinking);
3606
- }
3572
+ applyThinkingForModel(
3573
+ ctx,
3574
+ current,
3575
+ defaultModel.thinking as ThinkingLevel | undefined,
3576
+ "Default mode",
3577
+ );
3607
3578
  } catch (_e) {
3608
3579
  void _e;
3609
3580
  }
@@ -3619,12 +3590,12 @@ export default function workflowExtension(
3619
3590
  if (!found) return; // registry no longer has it — keep current model
3620
3591
  try {
3621
3592
  await (pi as any).setModel?.(found);
3622
- if (
3623
- defaultModel.thinking &&
3624
- isValidThinkingLevel(defaultModel.thinking)
3625
- ) {
3626
- (pi as any).setThinkingLevel?.(defaultModel.thinking);
3627
- }
3593
+ applyThinkingForModel(
3594
+ ctx,
3595
+ found,
3596
+ defaultModel.thinking as ThinkingLevel | undefined,
3597
+ "Default mode",
3598
+ );
3628
3599
  if (!opts?.quiet) {
3629
3600
  ctx.ui.notify(
3630
3601
  `Default mode: model → ${defaultModel.provider}/${defaultModel.id}${defaultModel.thinking ? ` (${defaultModel.thinking})` : ""}`,
@@ -3786,6 +3757,31 @@ export default function workflowExtension(
3786
3757
  }
3787
3758
  }
3788
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
+
3789
3785
  /**
3790
3786
  * Read the persisted default from disk. Pure: it never mutates the in-memory
3791
3787
  * `defaultModel`, because precedence is resolved in one place at session
@@ -5037,21 +5033,28 @@ export default function workflowExtension(
5037
5033
  }
5038
5034
  thinking = thinkingRaw.toLowerCase() as any;
5039
5035
  }
5040
- // 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;
5041
5039
  try {
5042
- const reg = (ctx as any)?.modelRegistry;
5043
- if (
5044
- reg &&
5045
- typeof reg.find === "function" &&
5046
- !reg.find(provider, id)
5047
- ) {
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) {
5048
5052
  ctx.ui.notify(
5049
- `Warning: ${provider}/${id} not in model registry — saved anyway.`,
5053
+ `Thinking ${thinking} is not supported by ${provider}/${id}; using ${pair.level}.`,
5050
5054
  "warning",
5051
5055
  );
5052
5056
  }
5053
- } catch (_e) {
5054
- void _e;
5057
+ thinking = pair.level;
5055
5058
  }
5056
5059
  role.model = { provider, id, thinking };
5057
5060
  const saved = saveAndSync(
@@ -5920,12 +5923,7 @@ export default function workflowExtension(
5920
5923
  const buildModelItems = (): FilterableItem[] =>
5921
5924
  modelPickerItems(showAll ? catalog.all : catalog.scoped);
5922
5925
  const modelLists = new Map<string, FilterableList>();
5923
- const thinkingList = new FilterableList({
5924
- items: VALID_THINKING_LEVELS.map((t) => ({ value: t, label: t })),
5925
- maxVisible: Math.min(VALID_THINKING_LEVELS.length, 8),
5926
- theme: themeList,
5927
- noMatchText: "No matching thinking levels",
5928
- });
5926
+ const thinkingLists = new Map<string, FilterableList>();
5929
5927
  const getModelList = (roleName: string): FilterableList => {
5930
5928
  let list = modelLists.get(roleName);
5931
5929
  if (!list) {
@@ -5944,6 +5942,26 @@ export default function workflowExtension(
5944
5942
  }
5945
5943
  return list;
5946
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
+ };
5947
5965
  // Ctrl+O re-scopes the rows: keep each tab's query and re-filter instead
5948
5966
  // of rebuilding the lists from scratch.
5949
5967
  const rebuildModelLists = () => {
@@ -5952,11 +5970,14 @@ export default function workflowExtension(
5952
5970
  };
5953
5971
  const syncThinkingCursor = (roleName: string) => {
5954
5972
  const st = staged.get(roleName)!;
5955
- // Clear first: the shared list may still be filtered from a previous
5956
- // visit, and the index refers to the full VALID_THINKING_LEVELS order.
5957
- thinkingList.clearFilter();
5958
- const idx = VALID_THINKING_LEVELS.indexOf(st.thinking);
5959
- 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);
5960
5981
  };
5961
5982
  const commitModelSelection = (roleName: string) => {
5962
5983
  const sel = getModelList(roleName).getSelectedItem();
@@ -5964,11 +5985,23 @@ export default function workflowExtension(
5964
5985
  const { provider, id } = parseModelRef(sel.value);
5965
5986
  if (!provider || !id) return false;
5966
5987
  const st = staged.get(roleName)!;
5967
- 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);
5968
6001
  return true;
5969
6002
  };
5970
6003
  const commitThinkingSelection = (roleName: string) => {
5971
- const sel = thinkingList.getSelectedItem();
6004
+ const sel = getThinkingList(roleName).getSelectedItem();
5972
6005
  if (!sel || !isValidThinkingLevel(sel.value)) return false;
5973
6006
  const st = staged.get(roleName)!;
5974
6007
  staged.set(roleName, { ...st, thinking: sel.value });
@@ -6026,7 +6059,7 @@ export default function workflowExtension(
6026
6059
  // (thinking → model) or cancels the editor.
6027
6060
  if (matchesKey(data, Key.escape)) {
6028
6061
  const activeList =
6029
- phase === "model" ? getModelList(roleName) : thinkingList;
6062
+ phase === "model" ? getModelList(roleName) : getThinkingList(roleName);
6030
6063
  if (activeList.handleInput(data)) {
6031
6064
  cached = undefined;
6032
6065
  tui.requestRender();
@@ -6055,7 +6088,7 @@ export default function workflowExtension(
6055
6088
  return;
6056
6089
  }
6057
6090
  const activeList =
6058
- phase === "model" ? getModelList(roleName) : thinkingList;
6091
+ phase === "model" ? getModelList(roleName) : getThinkingList(roleName);
6059
6092
  activeList.handleInput(data);
6060
6093
  cached = undefined;
6061
6094
  tui.requestRender();
@@ -6212,9 +6245,10 @@ export default function workflowExtension(
6212
6245
  lines.push(
6213
6246
  ...wrap(` ${theme.fg("accent", "Thinking level:")}`),
6214
6247
  );
6215
- 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)))
6216
6250
  lines.push(` ${l}`);
6217
- for (const l of thinkingList.renderList(Math.max(1, W - 2)))
6251
+ for (const l of list.renderList(Math.max(1, W - 2)))
6218
6252
  lines.push(` ${l}`);
6219
6253
  }
6220
6254
  }
@@ -6250,21 +6284,30 @@ export default function workflowExtension(
6250
6284
  );
6251
6285
  return "cancelled";
6252
6286
  }
6253
- // 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.
6254
6290
  try {
6255
6291
  const reg = (ctx as any)?.modelRegistry;
6256
6292
  if (reg && typeof reg.find === "function") {
6257
6293
  for (const [roleName, st] of saved.staged) {
6258
- if (
6259
- st.provider &&
6260
- st.id &&
6261
- typeof reg.find === "function" &&
6262
- !reg.find(st.provider, st.id)
6263
- )
6294
+ if (!st.provider || !st.id) continue;
6295
+ const model = reg.find(st.provider, st.id);
6296
+ if (!model) {
6264
6297
  ctx.ui.notify(
6265
6298
  `Warning: ${st.provider}/${st.id} (${roleName}) not in model registry — saved anyway.`,
6266
6299
  "warning",
6267
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
+ }
6268
6311
  }
6269
6312
  }
6270
6313
  } catch (_e) {
@@ -6798,15 +6841,7 @@ export default function workflowExtension(
6798
6841
  const buildModelItems = (): FilterableItem[] =>
6799
6842
  modelPickerItems(showAll ? catalog.all : catalog.scoped);
6800
6843
  const modelLists = new Map<string, FilterableList>();
6801
- const thinkingList = new FilterableList({
6802
- items: VALID_THINKING_LEVELS.map((t) => ({
6803
- value: t,
6804
- label: t,
6805
- })),
6806
- maxVisible: Math.min(VALID_THINKING_LEVELS.length, 8),
6807
- theme: listTheme,
6808
- noMatchText: "No matching thinking levels",
6809
- });
6844
+ const thinkingLists = new Map<string, FilterableList>();
6810
6845
  const getModelList = (role: Role): FilterableList => {
6811
6846
  let list = modelLists.get(role.name);
6812
6847
  if (!list) {
@@ -6825,6 +6860,26 @@ export default function workflowExtension(
6825
6860
  }
6826
6861
  return list;
6827
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
+ };
6828
6883
  // Ctrl+O re-scopes the rows: keep each tab's query and re-filter
6829
6884
  // instead of rebuilding the lists from scratch.
6830
6885
  const rebuildModelLists = () => {
@@ -6833,11 +6888,14 @@ export default function workflowExtension(
6833
6888
  };
6834
6889
  const syncThinkingCursor = (role: Role) => {
6835
6890
  const st = staged.get(role.name)!;
6836
- // Clear first: the shared list may still be filtered from a previous
6837
- // visit, and the index refers to the full VALID_THINKING_LEVELS order.
6838
- thinkingList.clearFilter();
6839
- const idx = VALID_THINKING_LEVELS.indexOf(st.thinking);
6840
- 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);
6841
6899
  };
6842
6900
  const isSubmitTab = () => currentTab === tabs.length;
6843
6901
 
@@ -6847,13 +6905,22 @@ export default function workflowExtension(
6847
6905
  const { provider, id } = parseModelRef(sel.value);
6848
6906
  if (!provider || !id) return false;
6849
6907
  const st = staged.get(role.name)!;
6850
- staged.set(role.name, { ...st, provider, id });
6851
- // If the chosen model is unknown to the registry, keep it —
6852
- // 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);
6853
6920
  return true;
6854
6921
  };
6855
6922
  const commitThinkingSelection = (role: Role) => {
6856
- const sel = thinkingList.getSelectedItem();
6923
+ const sel = getThinkingList(role).getSelectedItem();
6857
6924
  if (!sel || !isValidThinkingLevel(sel.value)) return false;
6858
6925
  const st = staged.get(role.name)!;
6859
6926
  staged.set(role.name, { ...st, thinking: sel.value });
@@ -6910,7 +6977,7 @@ export default function workflowExtension(
6910
6977
  // (thinking → model) or cancels the picker.
6911
6978
  if (matchesKey(data, Key.escape)) {
6912
6979
  const activeList =
6913
- phase === "model" ? getModelList(role) : thinkingList;
6980
+ phase === "model" ? getModelList(role) : getThinkingList(role);
6914
6981
  if (activeList.handleInput(data)) {
6915
6982
  cached = undefined;
6916
6983
  tui.requestRender();
@@ -6939,7 +7006,7 @@ export default function workflowExtension(
6939
7006
  return;
6940
7007
  }
6941
7008
  const activeList =
6942
- phase === "model" ? getModelList(role) : thinkingList;
7009
+ phase === "model" ? getModelList(role) : getThinkingList(role);
6943
7010
  activeList.handleInput(data);
6944
7011
  cached = undefined;
6945
7012
  tui.requestRender();
@@ -7082,9 +7149,10 @@ export default function workflowExtension(
7082
7149
  lines.push(
7083
7150
  ...wrap(` ${theme.fg("accent", "Thinking level:")}`),
7084
7151
  );
7085
- 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)))
7086
7154
  lines.push(` ${l}`);
7087
- for (const l of thinkingList.renderList(Math.max(1, W - 2)))
7155
+ for (const l of list.renderList(Math.max(1, W - 2)))
7088
7156
  lines.push(` ${l}`);
7089
7157
  }
7090
7158
  }
@@ -7118,16 +7186,28 @@ export default function workflowExtension(
7118
7186
  ctx.ui.notify("Role picker cancelled.", "info");
7119
7187
  return;
7120
7188
  }
7121
- // 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.
7122
7191
  try {
7123
7192
  const reg = (ctx as any)?.modelRegistry;
7124
7193
  if (reg && typeof reg.find === "function") {
7125
7194
  for (const [name, st] of saved) {
7126
- if (!reg.find(st.provider, st.id))
7195
+ const model = reg.find(st.provider, st.id);
7196
+ if (!model) {
7127
7197
  ctx.ui.notify(
7128
7198
  `Warning: ${st.provider}/${st.id} (${name}) not in model registry — saved anyway.`,
7129
7199
  "warning",
7130
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
+ }
7131
7211
  }
7132
7212
  }
7133
7213
  } catch (_e) {
@@ -8235,12 +8315,12 @@ export default function workflowExtension(
8235
8315
  await withPersistSuppressed(async () => {
8236
8316
  const ok = await (pi as any)?.setModel?.(found);
8237
8317
  if (ok === false) throw new Error("setModel returned false");
8238
- if (
8239
- effectiveDefault.thinking &&
8240
- isValidThinkingLevel(effectiveDefault.thinking)
8241
- ) {
8242
- (pi as any).setThinkingLevel?.(effectiveDefault.thinking);
8243
- }
8318
+ applyThinkingForModel(
8319
+ ctx as any,
8320
+ found,
8321
+ effectiveDefault.thinking,
8322
+ "Session start",
8323
+ );
8244
8324
  });
8245
8325
  } catch (e: any) {
8246
8326
  // Auth missing or the provider rejected the switch: keep Pi's pick.
@@ -8371,7 +8451,10 @@ export default function workflowExtension(
8371
8451
  void _e;
8372
8452
  }
8373
8453
  }
8374
- 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);
8375
8458
  ctx.ui.setWidget("workflow-todos", undefined);
8376
8459
  // Drop any /commit authorization or flow: a reload/quit must never leave
8377
8460
  // an exemption armed for whatever runs next.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@dieulc/workflow",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
7
7
  "description": "Plan↔Build mode with an independent Review Mode reviewer, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
8
8
  "license": "MIT",
9
9
  "scripts": {
10
- "test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
10
+ "test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts thinking-levels.test.ts workflow-status.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json",
12
12
  "check:policy": "node --experimental-transform-types scripts/verify-write-policy.mjs"
13
13
  },