@dieulc/workflow 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/review.ts CHANGED
@@ -1460,14 +1460,91 @@ export function reviewStatusVisible(input: {
1460
1460
  roundInFlight?: boolean;
1461
1461
  roundActive?: boolean;
1462
1462
  paused?: boolean;
1463
+ /**
1464
+ * The retained round state's phase, when one exists. A retained *terminal*
1465
+ * phase must not make the line visible during a new round's cold boot
1466
+ * (`roundActive` flips before the first live phase event).
1467
+ */
1468
+ phase?: ReviewPhase;
1469
+ }): boolean {
1470
+ if (input.paused === true) return true;
1471
+ if (input.roundInFlight !== true && input.roundActive !== true) return false;
1472
+ // `undefined` means the round has not emitted a state yet; there is nothing
1473
+ // stale to paint and the lock/working message still describe the round.
1474
+ return input.phase === undefined || reviewIsActive(input.phase);
1475
+ }
1476
+
1477
+ /**
1478
+ * The text for the live review status surface, or `""` when nothing is live.
1479
+ *
1480
+ * Single source for the footer segment and the review widget: a retained
1481
+ * `reviewState` is used for `/review-status` and the summary band, but it is
1482
+ * never painted as a live line once its round is over. While paused it shows
1483
+ * the paused form (never the retained terminal summary).
1484
+ */
1485
+ export function reviewStatusText(input: {
1486
+ state: ReviewState | undefined;
1487
+ roundInFlight?: boolean;
1488
+ roundActive?: boolean;
1489
+ paused?: boolean;
1490
+ }): string {
1491
+ const visible = reviewStatusVisible({
1492
+ roundInFlight: input.roundInFlight,
1493
+ roundActive: input.roundActive,
1494
+ paused: input.paused,
1495
+ phase: input.state?.phase,
1496
+ });
1497
+ return visible ? reviewFooterSegment(input.state, input.paused === true) : "";
1498
+ }
1499
+
1500
+ /**
1501
+ * Whether a pause snapshot belongs to the current review cycle.
1502
+ *
1503
+ * Pauses are in-memory and cycle-tagged. A snapshot from an older cycle must
1504
+ * never keep the review line/lock alive or be resumed after new work started.
1505
+ */
1506
+ export function reviewPauseIsCurrent(input: {
1507
+ pauseCycle: number | undefined;
1508
+ currentCycle: number;
1463
1509
  }): boolean {
1464
1510
  return (
1465
- input.roundInFlight === true ||
1466
- input.roundActive === true ||
1467
- input.paused === true
1511
+ typeof input.pauseCycle === "number" &&
1512
+ input.pauseCycle === input.currentCycle
1468
1513
  );
1469
1514
  }
1470
1515
 
1516
+ /** Result of consuming one review round from the cycle budget. */
1517
+ export interface ReviewRoundBudget {
1518
+ /** The round number this attempt would use (1-based). */
1519
+ round: number;
1520
+ /** False when the cycle's `rounds` cap has already been consumed. */
1521
+ allowed: boolean;
1522
+ }
1523
+
1524
+ /**
1525
+ * Consume one round from a cycle's budget.
1526
+ *
1527
+ * A new cycle starts at round 1 regardless of the previous cycle's count; this
1528
+ * is the fix for the session-wide counter that made a second plan skip review
1529
+ * with `round limit (1) reached`. Within a cycle the existing cap applies.
1530
+ */
1531
+ export function nextReviewRound(input: {
1532
+ round: number;
1533
+ maxRounds: number;
1534
+ newCycle?: boolean;
1535
+ }): ReviewRoundBudget {
1536
+ const maxRaw = Number.isFinite(input.maxRounds)
1537
+ ? Math.trunc(input.maxRounds)
1538
+ : 1;
1539
+ const max = Math.max(1, maxRaw);
1540
+ const roundRaw = Number.isFinite(input.round)
1541
+ ? Math.trunc(input.round)
1542
+ : 0;
1543
+ const current = Math.max(0, roundRaw);
1544
+ const round = (input.newCycle === true ? 0 : current) + 1;
1545
+ return { round, allowed: round <= max };
1546
+ }
1547
+
1471
1548
  // ── Mode-switch lock (plan 2026-10-25) ───────────────────────────────
1472
1549
  //
1473
1550
  // While a review owns the turn, mode transitions are refused: switching to
package/shell-text.ts ADDED
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Workflow Extension — shell text analysis
3
+ *
4
+ * One quote-aware view of a shell command, shared by every policy check.
5
+ *
6
+ * WHY THIS EXISTS: the command policy used to answer its questions with four
7
+ * different scanners that disagreed about quotes. `splitCommandSegments` was
8
+ * quote-aware, `wordsOf` was quote-aware but not escape-aware, and both
9
+ * `forbiddenSyntaxReason` and the destructive-keyword denylist scanned the
10
+ * RAW text. The result was the wrong answer in both directions: `rg -n
11
+ * "rm -rf" src` was refused in Plan mode although it only searches for a
12
+ * string, and `rg -n 'a > b' docs` was classified as a *file write* although
13
+ * the `>` is inside single quotes.
14
+ *
15
+ * The fix is to ask each question against the view that can answer it. A
16
+ * shell command is three things at once:
17
+ *
18
+ * structural — what the shell will treat as an OPERATOR (`| & ; < > ( )`
19
+ * and a bare `&`). Quoted and backslash-escaped characters
20
+ * are masked, because they are literal text: `rg 'a > b'`
21
+ * writes nothing, `rg 'a' > out` does.
22
+ * expandable — what the shell will EXPAND. Single quotes and escapes are
23
+ * masked; double quotes are kept, because `$(…)`, `${…}` and
24
+ * backticks DO expand inside double quotes.
25
+ * words — what argv the process will receive, after quote removal.
26
+ * Flags must be read from here: `find . "-delete"` really
27
+ * deletes, because quote removal makes it a flag.
28
+ *
29
+ * Both masked views are the same length as the input and use a sentinel for
30
+ * masked characters, so an offset maps back to the original text. That is
31
+ * what lets a refusal name the offending construct without re-scanning.
32
+ *
33
+ * CONTRACT — `ShellSegment.words` is ARGV ONLY. Shell operators (`|`, `;`,
34
+ * `&&`, `||`, a newline, and redirects such as `>`, `>>`, `<`, `<<`, `2>&1`,
35
+ * `&>/dev/null`) are NOT words; redirects are reported separately in
36
+ * `redirects`. So `words[0]` is the command name and `words[1]` is its first
37
+ * argument, exactly what `firstWord` / `wordAt(s, 1)` meant before — and a
38
+ * leading `2>/dev/null` no longer hides the command.
39
+ *
40
+ * SEGMENTATION IS DELIBERATELY *NOT* POSIX. `splitSegments` reproduces the
41
+ * extension's original splitter bit-for-bit, because `gitCommitPolicyHit` and
42
+ * the write gate consume its exact output: a backslash consumes the next
43
+ * character even inside single quotes, segments are trimmed, empty segments
44
+ * are dropped, and a lone `&` does not split while `&&` does. `tokenizeWords`
45
+ * follows POSIX quoting (`\` inside double quotes escapes only `$`, `` ` ``,
46
+ * `"`, `\` and newline) — which is why `"C:\Program Files\Git\cmd\git.exe"`
47
+ * keeps its backslashes. The two passes coexist on purpose.
48
+ *
49
+ * Dependency-free (no Pi / pi-tui imports), like `utils.ts`, so it stays
50
+ * loadable in plain Node tests.
51
+ */
52
+
53
+ /**
54
+ * Sentinel that replaces a masked character. `\u0001` cannot appear in a
55
+ * shell command a terminal or a model produces, and unlike a space it never
56
+ * matches `\s`, so masking never invents a token boundary.
57
+ */
58
+ const SENTINEL = "\u0001";
59
+
60
+ /** How a word was quoted, for diagnostics and for policy decisions. */
61
+ export type QuoteKind = "none" | "single" | "double" | "mixed";
62
+
63
+ /** One argv word after quote removal. */
64
+ export interface ShellWord {
65
+ /** Exact source slice, quotes and escapes included. */
66
+ raw: string;
67
+ /** The value the process receives (quotes removed, POSIX unescaping). */
68
+ value: string;
69
+ /** Offset of `raw` within `ShellSegment.raw`. */
70
+ start: number;
71
+ /** Offset just past `raw` within `ShellSegment.raw`. */
72
+ end: number;
73
+ quoting: QuoteKind;
74
+ }
75
+
76
+ /** A redirection, which is not an argv word. */
77
+ export interface ShellRedirect {
78
+ /** Operator as written: `>`, `>>`, `<`, `<<`, `2>`, `&>`, `2>&`, `&>>`, … */
79
+ op: string;
80
+ /** Quote-removed target (`/dev/null`, `out.txt`, `1`), when there is one. */
81
+ target?: string;
82
+ /** Offsets within `ShellSegment.raw`. */
83
+ start: number;
84
+ end: number;
85
+ }
86
+
87
+ /** One top-level command of a compound command. */
88
+ export interface ShellSegment {
89
+ /** Trimmed source slice, exactly what `splitSegments` returns. */
90
+ raw: string;
91
+ /** Offset of `raw` within `ShellScan.raw` (absolute). */
92
+ start: number;
93
+ /** Offset just past `raw` within `ShellScan.raw` (absolute). */
94
+ end: number;
95
+ /** ARGV only: operators and redirects are absent (see the module doc). */
96
+ words: ShellWord[];
97
+ /** Redirections, in source order. */
98
+ redirects: ShellRedirect[];
99
+ /** The operator that ended this segment, if any. */
100
+ next?: "|" | ";" | "&&" | "||" | "\n";
101
+ }
102
+
103
+ /** The full analysis of one command string. */
104
+ export interface ShellScan {
105
+ raw: string;
106
+ segments: ShellSegment[];
107
+ /** Same length as `raw`: operator characters that are literal are masked. */
108
+ structural: string;
109
+ /** Same length as `raw`: text the shell will expand (`$(…)`, backticks). */
110
+ expandable: string;
111
+ }
112
+
113
+ /** One segment range, before tokenisation. */
114
+ interface SegmentRange {
115
+ raw: string;
116
+ start: number;
117
+ end: number;
118
+ next?: "|" | ";" | "&&" | "||" | "\n";
119
+ }
120
+
121
+ /**
122
+ * Split a compound command on top-level `|`, `;`, `&&`, `||` and newlines,
123
+ * returning absolute ranges.
124
+ *
125
+ * This is a faithful copy of the extension's original splitter (including its
126
+ * escape-before-quote ordering, which makes a backslash consume the next
127
+ * character even inside single quotes). Do not "fix" it to POSIX here: the
128
+ * git commit classifier and the write gate depend on the exact boundaries.
129
+ */
130
+ function splitSegmentRanges(command: string): SegmentRange[] {
131
+ const out: SegmentRange[] = [];
132
+ let current = "";
133
+ let currentStart = 0;
134
+ let quote: '"' | "'" | undefined;
135
+ let escaped = false;
136
+ const push = (next?: SegmentRange["next"]): void => {
137
+ const trimmed = current.trim();
138
+ if (trimmed) {
139
+ const lead = current.length - current.trimStart().length;
140
+ const start = currentStart + lead;
141
+ out.push({ raw: trimmed, start, end: start + trimmed.length, next });
142
+ }
143
+ current = "";
144
+ currentStart = 0;
145
+ };
146
+ for (let i = 0; i < command.length; i++) {
147
+ const ch = command[i];
148
+ if (!current && !escaped) currentStart = i;
149
+ if (escaped) {
150
+ current += ch;
151
+ escaped = false;
152
+ continue;
153
+ }
154
+ if (ch === "\\") {
155
+ current += ch;
156
+ escaped = true;
157
+ continue;
158
+ }
159
+ if (quote) {
160
+ current += ch;
161
+ if (ch === quote) quote = undefined;
162
+ continue;
163
+ }
164
+ if (ch === '"' || ch === "'") {
165
+ quote = ch;
166
+ current += ch;
167
+ continue;
168
+ }
169
+ if (ch === "\n" || ch === ";") {
170
+ push(ch);
171
+ continue;
172
+ }
173
+ if (ch === "|") {
174
+ const next = command[i + 1] === "|" ? "||" : "|";
175
+ push(next);
176
+ if (command[i + 1] === "|") i++;
177
+ continue;
178
+ }
179
+ if (ch === "&" && command[i + 1] === "&") {
180
+ push("&&");
181
+ i++;
182
+ continue;
183
+ }
184
+ current += ch;
185
+ }
186
+ push(undefined);
187
+ return out;
188
+ }
189
+
190
+ /**
191
+ * Split a compound command into trimmed segments.
192
+ *
193
+ * Quote-aware: `grep -E 'a|b' f` stays one segment. See
194
+ * `splitSegmentRanges` for the deliberate legacy quirks.
195
+ */
196
+ export function splitSegments(command: unknown): string[] {
197
+ const raw = typeof command === "string" ? command : "";
198
+ if (!raw) return [];
199
+ return splitSegmentRanges(raw).map((r) => r.raw);
200
+ }
201
+
202
+ /** Build the two masked views of a command (both same length as `raw`). */
203
+ function maskViews(command: string): {
204
+ structural: string;
205
+ expandable: string;
206
+ } {
207
+ const structural: string[] = [];
208
+ const expandable: string[] = [];
209
+ let quote: '"' | "'" | undefined;
210
+ let i = 0;
211
+ while (i < command.length) {
212
+ const ch = command[i];
213
+ // Single quotes: nothing is an operator and nothing expands.
214
+ if (quote === "'") {
215
+ structural.push(SENTINEL);
216
+ expandable.push(SENTINEL);
217
+ if (ch === "'") quote = undefined;
218
+ i++;
219
+ continue;
220
+ }
221
+ // Double quotes: operators are literal, but expansion still happens.
222
+ if (quote === '"') {
223
+ if (ch === "\\") {
224
+ const nx = command[i + 1];
225
+ if (nx !== undefined && '$`"\\\n'.includes(nx)) {
226
+ structural.push(SENTINEL);
227
+ expandable.push(SENTINEL);
228
+ structural.push(SENTINEL);
229
+ expandable.push(SENTINEL);
230
+ i += 2;
231
+ continue;
232
+ }
233
+ }
234
+ const isOperator = "|&;<>()\n\r".includes(ch);
235
+ structural.push(isOperator || ch === '"' ? SENTINEL : ch);
236
+ expandable.push(ch === '"' ? SENTINEL : ch);
237
+ if (ch === '"') quote = undefined;
238
+ i++;
239
+ continue;
240
+ }
241
+ // Unquoted.
242
+ if (ch === "'" || ch === '"') {
243
+ quote = ch;
244
+ structural.push(SENTINEL);
245
+ expandable.push(SENTINEL);
246
+ i++;
247
+ continue;
248
+ }
249
+ if (ch === "\\") {
250
+ structural.push(SENTINEL);
251
+ expandable.push(SENTINEL);
252
+ if (i + 1 < command.length) {
253
+ structural.push(SENTINEL);
254
+ expandable.push(SENTINEL);
255
+ i += 2;
256
+ } else {
257
+ i++;
258
+ }
259
+ continue;
260
+ }
261
+ structural.push(ch);
262
+ expandable.push(ch);
263
+ i++;
264
+ }
265
+ return { structural: structural.join(""), expandable: expandable.join("") };
266
+ }
267
+
268
+ /** Read a redirect target starting at `k`, honouring simple quoting. */
269
+ function readRedirectTarget(
270
+ segment: string,
271
+ k: number,
272
+ ): { value: string; end: number } | undefined {
273
+ let i = k;
274
+ while (i < segment.length && (segment[i] === " " || segment[i] === "\t")) {
275
+ i++;
276
+ }
277
+ if (i >= segment.length) return undefined;
278
+ const ch = segment[i];
279
+ if (ch === "'" || ch === '"') {
280
+ let j = i + 1;
281
+ let value = "";
282
+ while (j < segment.length && segment[j] !== ch) {
283
+ const c = segment[j];
284
+ if (ch === '"' && c === "\\") {
285
+ const nx = segment[j + 1];
286
+ if (nx !== undefined && '$`"\\\n'.includes(nx)) {
287
+ value += nx;
288
+ j += 2;
289
+ continue;
290
+ }
291
+ }
292
+ value += c;
293
+ j++;
294
+ }
295
+ return { value, end: j < segment.length ? j + 1 : j };
296
+ }
297
+ if (/[\s|&;<>()]/.test(ch)) return undefined;
298
+ let j = i;
299
+ let value = "";
300
+ while (j < segment.length && !/[\s|&;<>()]/.test(segment[j])) {
301
+ value += segment[j];
302
+ j++;
303
+ }
304
+ return { value, end: j };
305
+ }
306
+
307
+ /** The mutable word being assembled by `tokenizeWords`. */
308
+ interface WordBuilder {
309
+ start: number;
310
+ raw: string;
311
+ value: string;
312
+ sawNone: boolean;
313
+ sawSingle: boolean;
314
+ sawDouble: boolean;
315
+ }
316
+
317
+ function quoteKindOf(b: WordBuilder): QuoteKind {
318
+ const kinds = [b.sawNone, b.sawSingle, b.sawDouble].filter(Boolean).length;
319
+ if (kinds > 1) return "mixed";
320
+ if (b.sawSingle) return "single";
321
+ if (b.sawDouble) return "double";
322
+ return "none";
323
+ }
324
+
325
+ /**
326
+ * Tokenise one segment into argv words plus redirects.
327
+ *
328
+ * POSIX quoting: `\` outside quotes escapes the next character; inside single
329
+ * quotes it is literal; inside double quotes it escapes only `$`, `` ` ``,
330
+ * `"`, `\` and newline (so a Windows path keeps its backslashes). Operators
331
+ * and redirects never appear in `words` — that is what makes `words[0]` the
332
+ * command name even when the segment starts with `2>/dev/null`.
333
+ */
334
+ export function tokenizeWords(segment: unknown): {
335
+ words: ShellWord[];
336
+ redirects: ShellRedirect[];
337
+ } {
338
+ const s = typeof segment === "string" ? segment : "";
339
+ const words: ShellWord[] = [];
340
+ const redirects: ShellRedirect[] = [];
341
+ let word: WordBuilder | null = null;
342
+ const flush = (): void => {
343
+ if (!word) return;
344
+ const b = word;
345
+ words.push({
346
+ raw: b.raw,
347
+ value: b.value,
348
+ start: b.start,
349
+ end: b.start + b.raw.length,
350
+ quoting: quoteKindOf(b),
351
+ });
352
+ word = null;
353
+ };
354
+ const begin = (i: number): WordBuilder => ({
355
+ start: i,
356
+ raw: "",
357
+ value: "",
358
+ sawNone: false,
359
+ sawSingle: false,
360
+ sawDouble: false,
361
+ });
362
+
363
+ let i = 0;
364
+ while (i < s.length) {
365
+ const ch = s[i];
366
+
367
+ if (!word && /\s/.test(ch)) {
368
+ i++;
369
+ continue;
370
+ }
371
+
372
+ // Unquoted backslash escape.
373
+ if (ch === "\\") {
374
+ if (!word) word = begin(i);
375
+ const nx = s[i + 1];
376
+ word.raw += ch;
377
+ if (nx !== undefined) {
378
+ word.raw += nx;
379
+ word.value += nx;
380
+ i += 2;
381
+ } else {
382
+ word.value += ch;
383
+ i += 1;
384
+ }
385
+ word.sawNone = true;
386
+ continue;
387
+ }
388
+
389
+ // Single-quoted span: fully literal.
390
+ if (ch === "'") {
391
+ if (!word) word = begin(i);
392
+ word.sawSingle = true;
393
+ word.raw += ch;
394
+ i++;
395
+ while (i < s.length && s[i] !== "'") {
396
+ word.raw += s[i];
397
+ word.value += s[i];
398
+ i++;
399
+ }
400
+ if (i < s.length) {
401
+ word.raw += "'";
402
+ i++;
403
+ }
404
+ continue;
405
+ }
406
+
407
+ // Double-quoted span: expansion happens, backslash is narrow (POSIX).
408
+ if (ch === '"') {
409
+ if (!word) word = begin(i);
410
+ word.sawDouble = true;
411
+ word.raw += ch;
412
+ i++;
413
+ while (i < s.length && s[i] !== '"') {
414
+ const c = s[i];
415
+ if (c === "\\") {
416
+ const nx = s[i + 1];
417
+ if (nx !== undefined && '$`"\\\n'.includes(nx)) {
418
+ word.raw += c + nx;
419
+ word.value += nx;
420
+ i += 2;
421
+ continue;
422
+ }
423
+ word.raw += c;
424
+ word.value += c;
425
+ i++;
426
+ continue;
427
+ }
428
+ word.raw += c;
429
+ word.value += c;
430
+ i++;
431
+ }
432
+ if (i < s.length) {
433
+ word.raw += '"';
434
+ i++;
435
+ }
436
+ continue;
437
+ }
438
+
439
+ // `&>` / `&>>` — a redirect written with the ampersand first.
440
+ if (ch === "&" && s[i + 1] === ">") {
441
+ flush();
442
+ let op = "&>";
443
+ let j = i + 2;
444
+ if (s[j] === ">") {
445
+ op = "&>>";
446
+ j++;
447
+ }
448
+ const target = readRedirectTarget(s, j);
449
+ redirects.push({ op, target: target?.value, start: i, end: target?.end ?? j });
450
+ i = target?.end ?? j;
451
+ continue;
452
+ }
453
+
454
+ // `>` / `>>` / `<` / `<<`, with an optional fd prefix and `>&`/`<&`.
455
+ if (ch === ">" || ch === "<") {
456
+ let fdPrefix = "";
457
+ if (word && word.sawNone && /^\d+$/.test(word.value)) {
458
+ fdPrefix = word.value;
459
+ word = null;
460
+ } else {
461
+ flush();
462
+ }
463
+ let op = ch;
464
+ let j = i + 1;
465
+ if (s[j] === ch) {
466
+ op += ch;
467
+ j++;
468
+ }
469
+ if (s[j] === "&") {
470
+ op += "&";
471
+ j++;
472
+ let target = "";
473
+ while (j < s.length && /[0-9-]/.test(s[j])) {
474
+ target += s[j];
475
+ j++;
476
+ }
477
+ redirects.push({ op: fdPrefix + op, target, start: i, end: j });
478
+ i = j;
479
+ continue;
480
+ }
481
+ const target = readRedirectTarget(s, j);
482
+ redirects.push({
483
+ op: fdPrefix + op,
484
+ target: target?.value,
485
+ start: i,
486
+ end: target?.end ?? j,
487
+ });
488
+ i = target?.end ?? j;
489
+ continue;
490
+ }
491
+
492
+ // Whitespace ends a word. A lone `&` is a control operator, not a word.
493
+ if (/\s/.test(ch)) {
494
+ flush();
495
+ i++;
496
+ continue;
497
+ }
498
+
499
+ if (!word) word = begin(i);
500
+ word.raw += ch;
501
+ word.value += ch;
502
+ word.sawNone = true;
503
+ i++;
504
+ }
505
+ flush();
506
+ return { words, redirects };
507
+ }
508
+
509
+ /**
510
+ * Analyse a command once: segments, argv words, redirects and the two masked
511
+ * views. Total and never throws — non-strings analyse as empty.
512
+ */
513
+ export function scanShell(command: unknown): ShellScan {
514
+ const raw = typeof command === "string" ? command : "";
515
+ const { structural, expandable } = maskViews(raw);
516
+ const segments: ShellSegment[] = splitSegmentRanges(raw).map((r) => {
517
+ const { words, redirects } = tokenizeWords(r.raw);
518
+ const seg: ShellSegment = {
519
+ raw: r.raw,
520
+ start: r.start,
521
+ end: r.end,
522
+ words,
523
+ redirects,
524
+ };
525
+ if (r.next) seg.next = r.next;
526
+ return seg;
527
+ });
528
+ return { raw, segments, structural, expandable };
529
+ }
530
+
531
+ /** The sentinel used by the masked views (exported for tests). */
532
+ export const SHELL_MASK_SENTINEL = SENTINEL;
533
+
534
+ /** True when `text` contains only spaces before `index` (command position). */
535
+ export function atCommandPosition(text: string, index: number): boolean {
536
+ return text.slice(0, index).trim() === "";
537
+ }
538
+
539
+ /** A segment's argv joined by single spaces, for flag/keyword matching. */
540
+ export function wordsText(words: readonly ShellWord[]): string {
541
+ return words.map((w) => w.value).join(" ");
542
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Model-aware thinking levels for the role/profile editors (2026-10-31 UI plan).
3
+ *
4
+ * Pi's `getSupportedThinkingLevels` is the authority. The editors used to list
5
+ * every level (`VALID_THINKING_LEVELS`) for every model, so a profile could
6
+ * store `medium` for a model whose map makes it unsupported; Pi then silently
7
+ * clamped it on activation. These helpers keep the staged value inside the
8
+ * model's set and fall back to the full list only when the model is unknown
9
+ * (offline editing, hand-edited files) so a missing registry entry never
10
+ * blocks the picker.
11
+ */
12
+
13
+ import {
14
+ clampThinkingLevel,
15
+ getSupportedThinkingLevels,
16
+ } from "@earendil-works/pi-ai";
17
+
18
+ import { VALID_THINKING_LEVELS, type ThinkingLevel } from "./roles.ts";
19
+
20
+ function isKnownModel(model: unknown): model is Record<string, unknown> {
21
+ if (!model || typeof model !== "object") return false;
22
+ const record = model as Record<string, unknown>;
23
+ return typeof record.reasoning === "boolean";
24
+ }
25
+
26
+ function asThinkingLevel(value: string): ThinkingLevel | null {
27
+ return (VALID_THINKING_LEVELS as readonly string[]).includes(value)
28
+ ? (value as ThinkingLevel)
29
+ : null;
30
+ }
31
+
32
+ /** The model's supported levels, or all levels when the model is unknown. */
33
+ export function thinkingLevelsForModel(model: unknown): ThinkingLevel[] {
34
+ if (!isKnownModel(model)) return [...VALID_THINKING_LEVELS];
35
+ try {
36
+ const levels = getSupportedThinkingLevels(model as never)
37
+ .map((level) => asThinkingLevel(String(level)))
38
+ .filter((level): level is ThinkingLevel => level !== null);
39
+ return levels.length > 0 ? levels : [...VALID_THINKING_LEVELS];
40
+ } catch {
41
+ return [...VALID_THINKING_LEVELS];
42
+ }
43
+ }
44
+
45
+ /** Clamp a stored level to the model's set (no-op for an unknown model). */
46
+ export function clampThinkingForModel(model: unknown, level: ThinkingLevel): ThinkingLevel {
47
+ if (!isKnownModel(model)) return level;
48
+ try {
49
+ return asThinkingLevel(String(clampThinkingLevel(model as never, level))) ?? level;
50
+ } catch {
51
+ return level;
52
+ }
53
+ }
54
+
55
+ /** The pair an editor should stage after a model change / before a save. */
56
+ export function thinkingLevelsForPair(
57
+ model: unknown,
58
+ level: ThinkingLevel,
59
+ ): { levels: ThinkingLevel[]; level: ThinkingLevel } {
60
+ const levels = thinkingLevelsForModel(model);
61
+ if (levels.includes(level)) return { levels, level };
62
+ const clamped = clampThinkingForModel(model, level);
63
+ return {
64
+ levels,
65
+ level: levels.includes(clamped) ? clamped : (levels[0] ?? level),
66
+ };
67
+ }