sprag-cli 3.49.0 → 3.50.1
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 +14 -2
- package/package.json +2 -1
- package/src/effort-palette.js +145 -0
- package/src/formatters/statusline.js +38 -28
- package/src/route-inject.js +6 -1
- package/src/route-scan.js +12 -1
- package/src/stdin-payload.js +13 -0
- package/src/ultracode.js +202 -0
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "3.50.1",
|
|
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
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* the
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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)
|
|
227
|
-
*
|
|
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.
|
|
252
|
-
//
|
|
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] :
|
|
255
|
-
|
|
256
|
-
|
|
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
|
/**
|
package/src/route-inject.js
CHANGED
|
@@ -19,11 +19,13 @@
|
|
|
19
19
|
* there are no tool calls yet)
|
|
20
20
|
* - the text trips ESCALATE_RE (design, analysis, deploy, merge …), where the
|
|
21
21
|
* top tier is the right answer and a nudge downward would be wrong
|
|
22
|
+
* - the text trips EDIT_RE (implement, add, fix …): editing is not delegable
|
|
23
|
+
* however much the sentence also says "install" or "check"
|
|
22
24
|
* - no registered rule covers that category
|
|
23
25
|
* A hint that fires on the wrong request costs more than one that stays quiet.
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
|
-
import { categorize, ESCALATE_RE } from './route-scan.js';
|
|
28
|
+
import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
|
|
27
29
|
import { loadModelRules, budgetCapPhrase } from './model-rules.js';
|
|
28
30
|
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
29
31
|
import { userLanguage } from './config.js';
|
|
@@ -48,6 +50,9 @@ export function routeHint(text, { rules, lang = userLanguage(), root } = {}) {
|
|
|
48
50
|
// before classification because such a request often looks like a light
|
|
49
51
|
// "run" or "check" in its wording.
|
|
50
52
|
if (ESCALATE_RE.test(t)) return null;
|
|
53
|
+
// Same for implementation work dressed as a run or a check: an edit is not
|
|
54
|
+
// delegable, and with no tool calls yet only the wording can say so.
|
|
55
|
+
if (EDIT_RE.test(t)) return null;
|
|
51
56
|
|
|
52
57
|
const cat = categorize(t, null);
|
|
53
58
|
if (!cat) return null;
|
package/src/route-scan.js
CHANGED
|
@@ -51,7 +51,18 @@ export const MIN_DELEGABLE_OUT = 100;
|
|
|
51
51
|
// Irreversible/external actions (store submission, deploy, release, merge)
|
|
52
52
|
// are included — they may look like light "run" episodes in the logs, but
|
|
53
53
|
// delegating them defeats the harness's default-safe-path rule.
|
|
54
|
-
|
|
54
|
+
// Measured 2026-09-20 on 1,276 local prompts: '퍼블리시' (npm publish, 15 hits)
|
|
55
|
+
// and '이유' (root-cause asks) were slipping through as run/translate, and a
|
|
56
|
+
// bare '머지' matched inside '나머지' ("나머지 진행하자" ×7) — hence the lookbehind.
|
|
57
|
+
export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인 분석|개선할|검토해보|비교|왜 |이유|제출|배포|출시|퍼블리시|릴리스|릴리즈|(?<!나)머지|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge/i;
|
|
58
|
+
|
|
59
|
+
// Implementation asks phrased around a run/check verb ("설치와 동시에 설정되도록
|
|
60
|
+
// 하자", "피드백 확인해주고 아이콘도 만들자"). Editing is not delegable, and at
|
|
61
|
+
// prompt time there is no tool mix to reveal it, so the wording has to. Offline
|
|
62
|
+
// classification does not use this: a write-dominant tool mix already excludes
|
|
63
|
+
// such episodes in behaviorPool(). Same corpus: 21 of 1,276 prompts gated, all
|
|
64
|
+
// but two of them implementation requests that had been read as run/check.
|
|
65
|
+
export const EDIT_RE = /구현|추가하자|만들자|넣자|넣어줘|수정해|고쳐|바꿔|되도록|되게|리팩/;
|
|
55
66
|
// A pattern must recur this often before we nag about it.
|
|
56
67
|
export const MIN_RECURRENCE = 3;
|
|
57
68
|
|
package/src/stdin-payload.js
CHANGED
|
@@ -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
|
+
}
|
package/src/ultracode.js
ADDED
|
@@ -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
|
+
}
|