sprag-cli 3.48.0 → 3.50.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/bin/cli.js CHANGED
@@ -34,9 +34,13 @@ import {
34
34
  readStdinJson,
35
35
  extractCaps,
36
36
  extractContextUsage,
37
+ extractEffort,
37
38
  extractModel,
39
+ extractTranscriptPath,
38
40
  } from '../src/stdin-payload.js';
39
41
 
42
+ import { effortChipLevel } from '../src/ultracode.js';
43
+
40
44
  import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
41
45
  import {
42
46
  dailyTrend,
@@ -514,6 +518,9 @@ async function main() {
514
518
  const stdinJson = readStdinJson();
515
519
  const caps = extractCaps(stdinJson);
516
520
  const model = extractModel(stdinJson);
521
+ // Resolved the same way as on the populated line below, so the chip does
522
+ // not change its mind about the level when the analysis window empties.
523
+ const effort = effortChipLevel(extractEffort(stdinJson), extractTranscriptPath(stdinJson));
517
524
  if (caps || model) {
518
525
  try {
519
526
  const { persistSnapshot } = await import('../src/caps-cache.js');
@@ -521,7 +528,7 @@ async function main() {
521
528
  } catch (e) { debug('caps-cache:persist', e); }
522
529
  }
523
530
  console.log(formatNoSession(
524
- { caps, model, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
531
+ { caps, model, effort, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
525
532
  { color: colorOk, mode: labelMode },
526
533
  ));
527
534
  return;
@@ -567,6 +574,18 @@ async function main() {
567
574
  const stdinJson = readStdinJson();
568
575
  let caps = extractCaps(stdinJson);
569
576
  let model = extractModel(stdinJson);
577
+ // Not persisted to the caps cache the way caps/model are: the cache exists so
578
+ // the table view (which pipes no stdin) can still show them, and a level the
579
+ // user has since changed would be worse there than no chip at all. The effort
580
+ // chip is a live-statusline reading only.
581
+ //
582
+ // `ultracode` is the one level the payload cannot name — Claude Code raises it
583
+ // to xhigh and reports that — so the transcript settles which of the two this
584
+ // session is on. Only an xhigh payload can resolve to ultracode, so a session
585
+ // on any other level pays nothing for the check (see src/ultracode.js).
586
+ const effort = isStatusline
587
+ ? effortChipLevel(extractEffort(stdinJson), extractTranscriptPath(stdinJson))
588
+ : extractEffort(stdinJson);
570
589
  const ctxLive = extractContextUsage(stdinJson);
571
590
  if (isStatusline && (caps || model)) {
572
591
  try {
@@ -758,6 +777,7 @@ async function main() {
758
777
  spikeChip,
759
778
  caps,
760
779
  model,
780
+ effort,
761
781
  delegationSaved,
762
782
  delegationTotals,
763
783
  doc2mdTotals,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.48.0",
3
+ "version": "3.50.0",
4
4
  "description": "Sprag - a quality ratchet harness for AI coding agents: model-fitting delegation, ratchet rules, doc2md, Korean style gate. Same tool as claude-token-saver, under its new name.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "postinstall": "node bin/cli.js install || true",
14
14
  "publish:legacy": "node scripts/publish-legacy.mjs",
15
15
  "release:notes": "node scripts/release-notes.mjs",
16
+ "site:docs": "node scripts/build-site-docs.mjs",
16
17
  "test": "node --test"
17
18
  },
18
19
  "files": [
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Effort palette — Claude Code's own colors for the effort levels.
3
+ *
4
+ * The statusline used to tint the effort chip from this tool's muted palette,
5
+ * which meant the level you picked in `/effort` and the level the chip reported
6
+ * were the same word in different colors. These are the app's values instead, so
7
+ * the chip echoes the color the picker showed.
8
+ *
9
+ * The mapping is not per-level color keys — Claude Code 2.1.276 keeps a slider
10
+ * table whose entries name SEMANTIC theme colors, which is why grepping for
11
+ * `effort*` keys finds only the last row:
12
+ *
13
+ * lo=[{value:"low", label:"low", color:"warning"},
14
+ * {value:"medium",label:"medium",color:"success"},
15
+ * {value:"high", label:"high", color:"permission"},
16
+ * {value:"xhigh", label:"xhigh", color:"autoAccept-shimmer"},
17
+ * {value:"max", label:"max", color:"rainbow-animated"}]
18
+ *
19
+ * and ultracode is painted separately, in `effortUltra`:
20
+ *
21
+ * function qCe(h){if(h){let E=Vre(Po("theme","dark").value);return bt("effortUltra",E)("ultracode")}}
22
+ *
23
+ * Two of those five are animated, and the statusline cannot animate: Claude Code
24
+ * re-runs the command on an interval measured in seconds (`statusLine.refreshInterval`,
25
+ * clamped to `Math.max(1,n)*1000`) and each run is a new process with no frame
26
+ * state, so a cycling color would read as a flicker rather than a shimmer. Both
27
+ * are therefore rendered as their still form — `xhigh` takes the `autoAccept`
28
+ * base color, and `max` takes the rainbow as a gradient across the word instead
29
+ * of a cycle over time.
30
+ *
31
+ * VALUES are lifted verbatim from the six theme tables in the 2.1.276 binary.
32
+ * `dark` is the default (`Po("theme","dark")`), where `autoAccept` and
33
+ * `effortUltra` are the same color: Claude Code separates `xhigh` from
34
+ * `ultracode` by shimmering one of them, not by hue.
35
+ *
36
+ * Dropping the animation would therefore leave the top two levels identical, so
37
+ * `ultracode` takes the shimmer's own two endpoints as a GLOW instead — a ramp
38
+ * from `autoAccept` up to `autoAcceptShimmer` and back down, spread across the
39
+ * word. Same device as `max`: what the app says over time, the chip says across
40
+ * the letters. No new colors are invented, and the level that costs the most is
41
+ * the one that looks lit.
42
+ *
43
+ * An 8-color terminal has no ramp to spend, so there `ultracode` stays the flat
44
+ * bright magenta and the level word is the only thing separating it from xhigh.
45
+ */
46
+
47
+ import { readFileSync } from 'node:fs';
48
+ import { homedir } from 'node:os';
49
+ import { join } from 'node:path';
50
+
51
+ /** The theme names Claude Code accepts, from its own settings enum. */
52
+ export const THEME_NAMES = ['dark', 'light', 'light-daltonized', 'dark-daltonized', 'light-ansi', 'dark-ansi'];
53
+
54
+ /** 24-bit values, per theme, for the four semantic keys the slider names. */
55
+ const RGB = {
56
+ dark: { warning: [255, 193, 7], success: [78, 186, 101], permission: [177, 185, 249], autoAccept: [175, 135, 255], ultra: [175, 135, 255] },
57
+ light: { warning: [150, 108, 30], success: [44, 122, 57], permission: [87, 105, 247], autoAccept: [135, 0, 255], ultra: [135, 0, 255] },
58
+ 'dark-daltonized': { warning: [255, 204, 0], success: [51, 153, 255], permission: [153, 204, 255], autoAccept: [175, 135, 255], ultra: [175, 135, 255] },
59
+ 'light-daltonized': { warning: [255, 153, 0], success: [0, 102, 153], permission: [51, 102, 255], autoAccept: [135, 0, 255], ultra: [135, 0, 255] },
60
+ };
61
+
62
+ /**
63
+ * The 8-color forms, from Claude Code's two ANSI themes. These are what a
64
+ * terminal without 24-bit color gets, and they are also the whole palette for a
65
+ * user who picked an ANSI theme on purpose.
66
+ */
67
+ const ANSI = {
68
+ dark: { warning: '\x1b[93m', success: '\x1b[92m', permission: '\x1b[94m', autoAccept: '\x1b[95m', ultra: '\x1b[95m' },
69
+ light: { warning: '\x1b[33m', success: '\x1b[32m', permission: '\x1b[34m', autoAccept: '\x1b[35m', ultra: '\x1b[35m' },
70
+ };
71
+
72
+ /**
73
+ * `autoAcceptShimmer` — the bright end of the pair Claude Code shimmers between,
74
+ * per theme. Only the dark/light split matters here, since the daltonized themes
75
+ * carry the same purple pair as their plain counterparts.
76
+ */
77
+ const SHIMMER = {
78
+ dark: [208, 180, 255],
79
+ light: [208, 180, 255],
80
+ 'dark-daltonized': [208, 180, 255],
81
+ 'light-daltonized': [208, 180, 255],
82
+ };
83
+
84
+ /**
85
+ * The glow ramp for `ultracode`: base → shimmer → base, seven stops, so a word
86
+ * painted with it brightens toward the middle and settles again. Built by
87
+ * interpolation rather than hand-picked, so a theme whose purple differs still
88
+ * gets a smooth ramp.
89
+ */
90
+ function glowRamp(base, peak) {
91
+ const t = [0, 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0];
92
+ return t.map((k) => base.map((ch, i) => Math.round(ch + (peak[i] - ch) * k)));
93
+ }
94
+
95
+ /** `max` — rainbow_red … rainbow_violet. One table for every theme. */
96
+ const RAINBOW_RGB = [
97
+ [235, 95, 87], [245, 139, 87], [250, 195, 95], [145, 200, 130], [130, 170, 220], [155, 130, 200], [200, 130, 180],
98
+ ];
99
+ /** Same seven, in the order the ANSI themes name them (red, redBright, yellow, green, cyan, blue, magenta). */
100
+ const RAINBOW_ANSI = ['\x1b[31m', '\x1b[91m', '\x1b[33m', '\x1b[32m', '\x1b[36m', '\x1b[34m', '\x1b[35m'];
101
+
102
+ const esc = ([r, g, b]) => `\x1b[38;2;${r};${g};${b}m`;
103
+
104
+ /**
105
+ * The theme Claude Code is running, resolved the way it resolves it: the config
106
+ * value if set, `dark` otherwise. Read best-effort — an unreadable or absent
107
+ * config is the default theme, never an error, because this feeds a statusline.
108
+ */
109
+ export function claudeTheme() {
110
+ for (const path of [join(homedir(), '.claude.json'), join(homedir(), '.claude', 'settings.json')]) {
111
+ try {
112
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
113
+ const theme = raw && typeof raw.theme === 'string' ? raw.theme : null;
114
+ if (theme && THEME_NAMES.includes(theme)) return theme;
115
+ } catch { /* absent or malformed — keep looking, then default */ }
116
+ }
117
+ return 'dark';
118
+ }
119
+
120
+ /**
121
+ * Escapes for every effort level, keyed by level name. `max` is an ARRAY of
122
+ * escapes — one per rainbow stop — and every other level is a single escape, so
123
+ * a caller paints `max` character by character and the rest in one go.
124
+ *
125
+ * @param {object} [opts]
126
+ * @param {string} [opts.theme] - a Claude Code theme name; anything unknown reads as `dark`.
127
+ * @param {boolean} [opts.truecolor=true] - false emits the ANSI forms.
128
+ */
129
+ export function effortTones({ theme = claudeTheme(), truecolor = true } = {}) {
130
+ const ansiTheme = theme.startsWith('light') ? 'light' : 'dark';
131
+ // An ANSI theme is a deliberate choice, so it wins over terminal capability.
132
+ const useAnsi = !truecolor || theme.endsWith('-ansi');
133
+ const keys = useAnsi ? ANSI[ansiTheme] : (RGB[theme] || RGB.dark);
134
+ const tone = (key) => (useAnsi ? keys[key] : esc(keys[key]));
135
+ return {
136
+ low: tone('warning'),
137
+ medium: tone('success'),
138
+ high: tone('permission'),
139
+ xhigh: tone('autoAccept'),
140
+ max: useAnsi ? RAINBOW_ANSI : RAINBOW_RGB.map(esc),
141
+ ultracode: useAnsi
142
+ ? keys.ultra
143
+ : glowRamp(keys.ultra, SHIMMER[theme] || SHIMMER.dark).map(esc),
144
+ };
145
+ }
@@ -23,6 +23,7 @@ import { labelForKey } from '../window-labels.js';
23
23
  import { harnessStatusForStatusline } from '../harness.js';
24
24
  import { loadConfig } from '../config.js';
25
25
  import { koreanStyleEnabled } from '../korean-style.js';
26
+ import { effortTones } from '../effort-palette.js';
26
27
  import { MIN_VOTES } from '../model-alias.js';
27
28
 
28
29
  // The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
@@ -208,6 +209,63 @@ function buildKoreanSeg(c, isIcon, verbose, g) {
208
209
  }
209
210
  }
210
211
 
212
+ /**
213
+ * Effort tones are not this palette's to choose: they are Claude Code's own, so
214
+ * the chip shows a level in the color its `/effort` picker showed when the level
215
+ * was set. src/effort-palette.js holds the values and the evidence for them,
216
+ * including why `xhigh` and `ultracode` share a color once the shimmer that
217
+ * separates them in the app is dropped.
218
+ *
219
+ * This replaced a scheme of this tool's own devising (gray below default, the
220
+ * model chip's violet at default, bold above it). That scheme was internally
221
+ * tidy and still wrong in the only way that matters: the user picks a level in
222
+ * one UI and reads it back in another, and the two disagreed about what the
223
+ * level looks like. Matching the app also means the chip now uses yellow at
224
+ * `low`, which this line otherwise reserves for "act on this eventually" — a
225
+ * deliberate exception, since the color is quoting Claude Code rather than
226
+ * making a claim about the level.
227
+ *
228
+ * An unlisted level (a new one Anthropic ships) reads as `high`, which is what
229
+ * Claude Code resolves an unset effort to.
230
+ */
231
+ const EFFORT_TONES = effortTones({ truecolor: TRUECOLOR });
232
+
233
+ /**
234
+ * Effort chip builder — the level from Claude Code's stdin payload
235
+ * (`effort.level`, see extractEffort), or `ultracode` when the entry point
236
+ * resolved that from the transcript (see effortChipLevel).
237
+ *
238
+ * The level is a word ("high"), so icon modes need no label beside the glyph:
239
+ * `🔬 high` already reads. Text mode names the field, because a bare "high"
240
+ * next to a model name would be anyone's guess.
241
+ */
242
+ function buildEffortSeg(effort, c, isIcon, g) {
243
+ if (typeof effort !== 'string' || effort.length === 0) return null;
244
+ // `hasOwn` rather than a bare index, for the reason model-alias.js gives at
245
+ // its own lookup: the level is a string off a JSON payload, and a level of
246
+ // 'constructor' or '__proto__' would resolve through Object.prototype to a
247
+ // function or an object. A `||` fallback cannot catch that, since both are
248
+ // truthy — the tone would be interpolated as
249
+ // `function Object() { [native code] }` and take the whole line with it.
250
+ const tone = Object.hasOwn(EFFORT_TONES, effort) ? EFFORT_TONES[effort] : EFFORT_TONES.high;
251
+ const head = isIcon ? `${g.effort} ` : 'Effort ';
252
+ // `max` arrives as the rainbow's seven stops (Claude Code cycles them; see
253
+ // effort-palette.js for why a statusline cannot). The stops are spread across
254
+ // the word rather than cycled through it: "max" is three characters, so
255
+ // cycling would show only the first three stops — red, orange, yellow, which
256
+ // reads as a warm gradient and not as a rainbow. Spreading walks the whole
257
+ // arc whatever the word's length.
258
+ if (Array.isArray(tone)) {
259
+ const chars = [...effort];
260
+ const last = Math.max(1, chars.length - 1);
261
+ const word = chars
262
+ .map((ch, i) => `${c(tone[Math.round((i * (tone.length - 1)) / last)])}${ch}`)
263
+ .join('');
264
+ return `${c(tone[0])}${head}${word}${c(RESET)}`;
265
+ }
266
+ return `${c(tone)}${head}${effort}${c(RESET)}`;
267
+ }
268
+
211
269
  /**
212
270
  * Version segment builder — "which copy of this tool am I looking at", plus
213
271
  * the upgrade nudge when a newer one has been published.
@@ -289,9 +347,10 @@ function buildCapWarnSeg(capWarn, c, isIcon, g, color, mode) {
289
347
  * The stdin payload (rate limits, model) is still live in that case, and a
290
348
  * 90%+ cap warning is exactly the kind of signal that must not disappear
291
349
  * just because the user has been idle past the window — so cap-warn,
292
- * harness, and model chips still render around the "no session data" note.
350
+ * harness, model, and effort chips still render around the "no session data"
351
+ * note.
293
352
  */
294
- export function formatNoSession({ caps = null, model = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
353
+ export function formatNoSession({ caps = null, model = null, effort = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
295
354
  const c = (v) => (color ? v : '');
296
355
  // narrow shares the icon layout and swaps only the glyphs, so both modes take
297
356
  // the same branches from here on.
@@ -307,6 +366,8 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
307
366
  if (typeof model === 'string' && model.length > 0) {
308
367
  segs.push(isIcon ? `${c(MAGENTA)}${g.model} ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
309
368
  }
369
+ const effortSeg = buildEffortSeg(effort, c, isIcon, g);
370
+ if (effortSeg) segs.push(effortSeg);
310
371
  segs.push(`${c(GRAY)}${g.hit} no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
311
372
  if (versionSeg && !(update && update.available)) segs.push(versionSeg);
312
373
  return segs.join(' · ') + (color ? '\x1b[K' : '');
@@ -319,7 +380,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
319
380
  * @param {boolean} [opts.verbose=false] - longer layout with labels
320
381
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
321
382
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
322
- * @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, month, saved, delegated, doc2md, ctx, period, `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
383
+ * @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness, korean, model, effort, hit, ttl, month, saved, delegated, doc2md, ctx, period, `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
323
384
  * @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
324
385
  */
325
386
  /**
@@ -341,7 +402,7 @@ export const DEFAULT_SEGMENT_ORDER = [
341
402
  'cap-warn', 'spike',
342
403
  'usage', 'ctx',
343
404
  'ttl',
344
- 'model',
405
+ 'model', 'effort',
345
406
  // Grouped by the timeframe each figure covers, because mixing them was the
346
407
  // problem: a lifetime total, a windowed rate and a calendar-month estimate sat
347
408
  // interleaved, with one window label at the end of the line that appeared to
@@ -359,7 +420,7 @@ export const DEFAULT_SEGMENT_ORDER = [
359
420
  ];
360
421
 
361
422
  export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
362
- const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model } = data;
423
+ const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model, effort } = data;
363
424
  const { hitRate } = summary;
364
425
 
365
426
  // Hit rate → color signal
@@ -711,6 +772,11 @@ export function formatReport(data, { color = true, verbose = false, timer = true
711
772
  }
712
773
  }
713
774
 
775
+ // Effort chip — how hard the session is being worked (`/effort`). It follows
776
+ // the model chip because it qualifies it: the same model answers differently
777
+ // at `low` and at `max`, and the pair is one glance.
778
+ const effortSeg = buildEffortSeg(effort, c, isIcon, g);
779
+
714
780
  // Always-on usage segments — what /usage shows in Claude Code, mirrored
715
781
  // to the statusline so the user doesn't have to slash-command for it.
716
782
  // Today the stdin payload exposes the 5h ("Current session") and 7-day
@@ -820,6 +886,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
820
886
  case 'harness': return [harnessSeg];
821
887
  case 'korean': return [koreanSeg];
822
888
  case 'model': return [modelSeg];
889
+ case 'effort': return [effortSeg];
823
890
  // Dropped when the same story already owns a headline line above, which
824
891
  // would otherwise repeat it on line 2.
825
892
  case 'delegated': return totalsLine ? [] : [delegateSeg];
package/src/glyphs.js CHANGED
@@ -39,6 +39,13 @@ const ICON = {
39
39
  harness: '🅷',
40
40
  korean: '✍️',
41
41
  model: '🤖',
42
+ // Effort: a closer look at the same thing, which is what raising it buys.
43
+ // Width decided this as much as meaning. The obvious picks for "a level" are
44
+ // 🎚 and 🎛 (U+1F39A/1F39B), and both are East Asian Width N while every other
45
+ // glyph in this set is W — drawn from the color-emoji font they occupy two
46
+ // cells while being counted as one, and the line shifts on every repaint. 🔬
47
+ // is W, so it measures the same as its neighbours.
48
+ effort: '🔬',
42
49
  routing: '🔀',
43
50
  doc: '📄',
44
51
  hit: '🧠',
@@ -67,6 +74,7 @@ const NARROW = {
67
74
  harness: '⍟', // circled star: a score out of five
68
75
  korean: '⌨', // an input/typing convention rather than a hand
69
76
  model: '◈',
77
+ effort: '▲', // a magnitude: how hard the turn is being worked
70
78
  routing: '⇉', // sent down a different path
71
79
  doc: '≣', // stacked lines: a page of text
72
80
  hit: '◉', // bullseye: a cache hit
@@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs';
14
14
  * The blob shape (subset we consume):
15
15
  * {
16
16
  * "transcript_path": "...",
17
+ * "effort": { "level": "high" },
17
18
  * "rate_limits": {
18
19
  * "five_hour": { "used_percentage": 94, "resets_at": 1777099200 },
19
20
  * "seven_day": { "used_percentage": 7, "resets_at": 1777521600 }
@@ -120,3 +121,42 @@ export function extractModel(stdinJson) {
120
121
  if (typeof m.id === 'string' && m.id) return m.id;
121
122
  return null;
122
123
  }
124
+
125
+ /**
126
+ * The effort level the session is running at — what `/effort` sets.
127
+ *
128
+ * Claude Code 2.1.276 assembles the statusline payload with
129
+ * `...modelHasEffort(model) && { effort: { level: resolved } }`, so two facts
130
+ * follow. The level arrives already resolved against the session setting (the
131
+ * builder defaults it to `high` when nothing is set), which is why no default
132
+ * is applied here. And the key is dropped entirely for models that carry no
133
+ * effort setting at all (the claude-3 era, opus-4.0, opus-4.1), as it is on
134
+ * Claude Code builds older than the field itself — so a null means "nothing to
135
+ * show", never "something went wrong", and the chip simply does not render.
136
+ *
137
+ * A per-turn override (the transcript's `perTurnEffort`) is not visible here:
138
+ * the builder resolves the level without passing the per-turn argument, so what
139
+ * this returns is the session level even on a turn that ran at another one.
140
+ *
141
+ * Lowercased so the renderer can look a tone up by level; an unrecognised level
142
+ * still renders, just in the default tone.
143
+ */
144
+ export function extractEffort(stdinJson) {
145
+ const level = stdinJson?.effort?.level;
146
+ if (typeof level !== 'string') return null;
147
+ const trimmed = level.trim();
148
+ return trimmed === '' ? null : trimmed.toLowerCase();
149
+ }
150
+
151
+ /**
152
+ * The current session's transcript path, as Claude Code reports it.
153
+ *
154
+ * Read for one thing only: the effort chip's ultracode check, which the payload
155
+ * cannot answer on its own (see src/ultracode.js). Everything else about the
156
+ * current session comes from the analysis window, so a missing or non-string
157
+ * path is not worth a fallback — the caller simply skips the check.
158
+ */
159
+ export function extractTranscriptPath(stdinJson) {
160
+ const path = stdinJson?.transcript_path;
161
+ return typeof path === 'string' && path !== '' ? path : null;
162
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Ultracode detection — the one effort level Claude Code does not put in the
3
+ * statusline payload.
4
+ *
5
+ * `/effort ultracode` is "xhigh + dynamic workflow orchestration, this session
6
+ * only". Claude Code stores it as two things: the effort level, which it raises
7
+ * to `xhigh`, and a session flag for the orchestration half. Only the level
8
+ * reaches the statusline contract (2.1.276 builds the payload as
9
+ * `...modelHasEffort(model) && { effort: { level: resolved } }`, where `resolved`
10
+ * is validated against the level list and so can never read "ultracode"), so a
11
+ * session on ultracode and a session on plain xhigh arrive here identical.
12
+ *
13
+ * What does distinguish them is in the transcript. Claude Code injects a
14
+ * system-reminder when ultracode turns on and another when it turns off, and
15
+ * both are recorded as attachment entries:
16
+ *
17
+ * {"type":"attachment","attachment":{"type":"ultra_effort_enter","reminderType":"full"}}
18
+ * {"type":"attachment","attachment":{"type":"ultra_effort_exit"}}
19
+ *
20
+ * The `enter` attachment repeats while ultracode stays on — once on the turn it
21
+ * is switched on ("full"), then every tenth user turn as a reminder ("sparse",
22
+ * TURNS_BETWEEN_MAINTENANCE = 10) — so the most recent of the two markers is
23
+ * the current state, and an active session keeps refreshing it near the tail.
24
+ *
25
+ * A marker is only written when the NEXT turn is assembled, though, so on its own
26
+ * it leaves the chip a turn behind: you run `/effort ultracode`, nothing has been
27
+ * recorded yet, and the chip still says xhigh until you send something. The
28
+ * command's own output closes that gap, because Claude Code writes it to the
29
+ * transcript the moment the command returns:
30
+ *
31
+ * {"type":"user","message":{"content":"<local-command-stdout>Set effort level to
32
+ * ultracode (this session only): xhigh + dynamic workflow orchestration</local-command-stdout>"}}
33
+ *
34
+ * measured 7s, 34s and 72s ahead of the matching attachment in one session. Both
35
+ * directions are legible there: the ultracode row is the only one that names the
36
+ * level, and every other `/effort <level>` clears the flag as it sets the level
37
+ * (`effortUpdate:{value:e,ultracode:!1}`), so those rows mean "off". The bare
38
+ * query prints `Current effort level: …`, which reads the same way.
39
+ *
40
+ * Hence the read below: walk backwards from the end of the transcript and take
41
+ * whichever of the two signals comes last. The scan is capped (see SCAN_BYTES)
42
+ * because the statusline re-renders every few seconds and most sessions carry
43
+ * neither signal — an uncapped backwards scan would read the whole transcript,
44
+ * every render, to answer "no". The cap makes the failure one-directional: past
45
+ * it we report "not ultracode", which is what the chip showed before this
46
+ * existed.
47
+ */
48
+
49
+ import { openSync, readSync, closeSync, statSync } from 'node:fs';
50
+
51
+ /**
52
+ * How far back to look for a marker. Measured against real transcripts: the
53
+ * gap between two consecutive markers of an ultracode session ran to 2.2MB
54
+ * (ten heavy turns), and the tail after the last one to 0.9MB. 4MB clears both
55
+ * with room, and costs a single bounded read of a file the renderer's own
56
+ * session parse has already pulled into the page cache.
57
+ */
58
+ const SCAN_BYTES = 4 * 1024 * 1024;
59
+ /**
60
+ * Substrings worth parsing a line for. The first is the attachment; the other
61
+ * two are the shapes `/effort` prints. Cheap prefilters only — every hit is
62
+ * parsed and checked before it counts.
63
+ */
64
+ const MARKERS = ['ultra_effort_', 'Set effort level to', 'Current effort level:'];
65
+ const NEWLINE = 0x0a;
66
+ /** The levels Claude Code accepts; any of them being SET means ultracode is off. */
67
+ const PLAIN_LEVELS = /Set effort level to (?:low|medium|high|xhigh|max)\b/;
68
+ /** The bare query's answer, which names ultracode when it is on. */
69
+ const QUERY_LEVEL = /Current effort level: (\w+)/;
70
+
71
+ /**
72
+ * The state one transcript line carries, or null when it carries none.
73
+ *
74
+ * The line has to be parsed, not just matched: a transcript records tool output
75
+ * too, so a session that greps for these very strings (this feature was built
76
+ * in one) has lines that contain `ultra_effort_enter` as quoted text. Only an
77
+ * attachment entry of that type is a marker.
78
+ *
79
+ * Sidechain entries are skipped. A subagent runs its own effort resolution, so
80
+ * its reminder describes the subagent, not the session the chip is about.
81
+ */
82
+ function markerState(line) {
83
+ let entry;
84
+ try {
85
+ entry = JSON.parse(line);
86
+ } catch {
87
+ return null;
88
+ }
89
+ if (!entry || typeof entry !== 'object') return null;
90
+ if (entry.isSidechain === true) return null;
91
+ const type = entry.attachment && entry.attachment.type;
92
+ if (type === 'ultra_effort_enter') return true;
93
+ if (type === 'ultra_effort_exit') return false;
94
+ return commandState(entry);
95
+ }
96
+
97
+ /**
98
+ * The state one `/effort` command output reports, or null when the entry is not
99
+ * one.
100
+ *
101
+ * The filter is narrow on purpose, because these phrases travel. In the session
102
+ * this was built in, "Set effort level to …" appears six times outside a real
103
+ * command output: three times in assistant text that quoted it and three in tool
104
+ * results that printed it. Requiring a user entry whose content is a plain
105
+ * string carrying the `<local-command-stdout>` wrapper excluded all six and kept
106
+ * all twelve real ones — a tool result's content is an array of blocks, and
107
+ * quoted prose is not a user entry at all.
108
+ *
109
+ * A cancelled picker writes `<local-command-stdout>Cancelled</local-command-stdout>`,
110
+ * which matches nothing here and correctly changes no state.
111
+ */
112
+ function commandState(entry) {
113
+ if (entry.type !== 'user') return null;
114
+ const content = entry.message && entry.message.content;
115
+ if (typeof content !== 'string' || !content.includes('<local-command-stdout>')) return null;
116
+ if (content.includes('Set effort level to ultracode')) return true;
117
+ const query = content.match(QUERY_LEVEL);
118
+ if (query) return query[1] === 'ultracode';
119
+ if (PLAIN_LEVELS.test(content)) return false;
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Is ultracode on, according to this transcript?
125
+ *
126
+ * @param {string|null|undefined} transcriptPath - `transcript_path` from the statusline payload.
127
+ * @param {object} [opts]
128
+ * @param {number} [opts.scanBytes=SCAN_BYTES] - how many trailing bytes to search.
129
+ * @returns {boolean|null} true/false from the newest marker, null when there is
130
+ * none in range or the file cannot be read. Callers treat null as "not
131
+ * ultracode"; it is kept distinct from false so a caller can tell "the
132
+ * session turned it off" from "nothing said".
133
+ */
134
+ export function ultracodeFromTranscript(transcriptPath, { scanBytes = SCAN_BYTES } = {}) {
135
+ if (typeof transcriptPath !== 'string' || transcriptPath === '') return null;
136
+ let size;
137
+ try {
138
+ size = statSync(transcriptPath).size;
139
+ } catch {
140
+ return null;
141
+ }
142
+ if (size === 0) return null;
143
+
144
+ const start = Math.max(0, size - scanBytes);
145
+ const buf = Buffer.alloc(size - start);
146
+ let fd;
147
+ try {
148
+ fd = openSync(transcriptPath, 'r');
149
+ readSync(fd, buf, 0, buf.length, start);
150
+ } catch {
151
+ return null;
152
+ } finally {
153
+ if (fd !== undefined) try { closeSync(fd); } catch { /* already closed */ }
154
+ }
155
+
156
+ // Backwards by candidate rather than line by line: lastIndexOf is a memchr
157
+ // over the window, while splitting 4MB of JSONL into lines to walk the tail
158
+ // would allocate the whole transcript tail as strings on every render.
159
+ let from = buf.length;
160
+ while (from > 0) {
161
+ // The newest hit of any marker, so the attachment and the command output
162
+ // compete on position and the later one wins.
163
+ let hit = -1;
164
+ for (const marker of MARKERS) {
165
+ const at = buf.lastIndexOf(marker, from - 1, 'utf8');
166
+ if (at > hit) hit = at;
167
+ }
168
+ if (hit === -1) return null;
169
+ const lineStart = buf.lastIndexOf(NEWLINE, hit) + 1;
170
+ const nlAfter = buf.indexOf(NEWLINE, hit);
171
+ const lineEnd = nlAfter === -1 ? buf.length : nlAfter;
172
+ // A window that starts mid-line leaves its first line truncated; that line
173
+ // fails to parse and is skipped like any other non-marker.
174
+ const state = markerState(buf.toString('utf8', lineStart, lineEnd));
175
+ if (state !== null) return state;
176
+ if (lineStart === 0) return null;
177
+ from = lineStart;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * The level the effort chip should name.
184
+ *
185
+ * Reads the transcript only when the payload says `xhigh`, since that is the
186
+ * only level ultracode can present as — which keeps every other session, and
187
+ * every non-statusline caller, at zero extra I/O.
188
+ *
189
+ * The `xhigh` guard is also what keeps a stale marker honest. Dropping effort
190
+ * (say `/effort medium`) turns ultracode off, but the `exit` marker is only
191
+ * written when the next turn is assembled, so between those two moments the
192
+ * newest marker still says `enter` while the payload already says `medium`.
193
+ * Trusting the level here means the chip follows the payload, not the lag.
194
+ *
195
+ * @param {string|null} level - the level from the payload (see extractEffort).
196
+ * @param {string|null|undefined} transcriptPath
197
+ * @returns {string|null} `'ultracode'`, or `level` unchanged.
198
+ */
199
+ export function effortChipLevel(level, transcriptPath) {
200
+ if (level !== 'xhigh') return level;
201
+ return ultracodeFromTranscript(transcriptPath) === true ? 'ultracode' : level;
202
+ }