sprag-cli 3.49.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
@@ -36,8 +36,11 @@ import {
36
36
  extractContextUsage,
37
37
  extractEffort,
38
38
  extractModel,
39
+ extractTranscriptPath,
39
40
  } from '../src/stdin-payload.js';
40
41
 
42
+ import { effortChipLevel } from '../src/ultracode.js';
43
+
41
44
  import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
42
45
  import {
43
46
  dailyTrend,
@@ -515,7 +518,9 @@ async function main() {
515
518
  const stdinJson = readStdinJson();
516
519
  const caps = extractCaps(stdinJson);
517
520
  const model = extractModel(stdinJson);
518
- const effort = extractEffort(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));
519
524
  if (caps || model) {
520
525
  try {
521
526
  const { persistSnapshot } = await import('../src/caps-cache.js');
@@ -573,7 +578,14 @@ async function main() {
573
578
  // the table view (which pipes no stdin) can still show them, and a level the
574
579
  // user has since changed would be worse there than no chip at all. The effort
575
580
  // chip is a live-statusline reading only.
576
- const effort = extractEffort(stdinJson);
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);
577
589
  const ctxLive = extractContextUsage(stdinJson);
578
590
  if (isStatusline && (caps || model)) {
579
591
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.49.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
@@ -209,35 +210,30 @@ function buildKoreanSeg(c, isIcon, verbose, g) {
209
210
  }
210
211
 
211
212
  /**
212
- * Effort tones. Claude Code resolves an unset effort to `high`, so `high` is the
213
- * level a session runs at when nobody has touched it — it gets the magenta
214
- * identity tone the model chip uses, and the two chips read as one group about
215
- * which model is answering and how hard. Levels below default recede to gray:
216
- * they spend less, and this tool has no business nagging about that.
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.
217
218
  *
218
- * The levels above default are the same magenta, bold. Not yellow, although
219
- * raising effort does buy quality with tokens per turn: yellow in this line is
220
- * the "you should do something eventually" tone (see buildVersionSeg), and an
221
- * effort level is a setting the user chose, not a state to fix. Someone who
222
- * pins `max` would read a permanently yellow chip as a standing complaint, and
223
- * a chip that is always warning-colored is how a line teaches the eye to skip
224
- * its warning color. Bold says "turned up" in the identity tone instead.
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.
225
227
  *
226
- * An unlisted level (a new one Anthropic ships) renders in the identity tone
227
- * rather than not at all.
228
+ * An unlisted level (a new one Anthropic ships) reads as `high`, which is what
229
+ * Claude Code resolves an unset effort to.
228
230
  */
229
- const EFFORT_TONES = {
230
- none: GRAY,
231
- low: GRAY,
232
- medium: GRAY,
233
- high: MAGENTA,
234
- xhigh: BOLD + MAGENTA,
235
- max: BOLD + MAGENTA,
236
- };
231
+ const EFFORT_TONES = effortTones({ truecolor: TRUECOLOR });
237
232
 
238
233
  /**
239
234
  * Effort chip builder — the level from Claude Code's stdin payload
240
- * (`effort.level`, see extractEffort).
235
+ * (`effort.level`, see extractEffort), or `ultracode` when the entry point
236
+ * resolved that from the transcript (see effortChipLevel).
241
237
  *
242
238
  * The level is a word ("high"), so icon modes need no label beside the glyph:
243
239
  * `🔬 high` already reads. Text mode names the field, because a bare "high"
@@ -248,12 +244,26 @@ function buildEffortSeg(effort, c, isIcon, g) {
248
244
  // `hasOwn` rather than a bare index, for the reason model-alias.js gives at
249
245
  // its own lookup: the level is a string off a JSON payload, and a level of
250
246
  // 'constructor' or '__proto__' would resolve through Object.prototype to a
251
- // function or an object. The `|| MAGENTA` fallback cannot catch that, since
252
- // both are truthy — the tone would be interpolated as
247
+ // function or an object. A `||` fallback cannot catch that, since both are
248
+ // truthy — the tone would be interpolated as
253
249
  // `function Object() { [native code] }` and take the whole line with it.
254
- const tone = Object.hasOwn(EFFORT_TONES, effort) ? EFFORT_TONES[effort] : MAGENTA;
255
- if (isIcon) return `${c(tone)}${g.effort} ${effort}${c(RESET)}`;
256
- return `${c(tone)}Effort ${effort}${c(RESET)}`;
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)}`;
257
267
  }
258
268
 
259
269
  /**
@@ -147,3 +147,16 @@ export function extractEffort(stdinJson) {
147
147
  const trimmed = level.trim();
148
148
  return trimmed === '' ? null : trimmed.toLowerCase();
149
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
+ }