sprag-cli 3.48.0 → 3.49.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 +9 -1
- package/package.json +1 -1
- package/src/formatters/statusline.js +62 -5
- package/src/glyphs.js +8 -0
- package/src/stdin-payload.js +27 -0
package/bin/cli.js
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
readStdinJson,
|
|
35
35
|
extractCaps,
|
|
36
36
|
extractContextUsage,
|
|
37
|
+
extractEffort,
|
|
37
38
|
extractModel,
|
|
38
39
|
} from '../src/stdin-payload.js';
|
|
39
40
|
|
|
@@ -514,6 +515,7 @@ async function main() {
|
|
|
514
515
|
const stdinJson = readStdinJson();
|
|
515
516
|
const caps = extractCaps(stdinJson);
|
|
516
517
|
const model = extractModel(stdinJson);
|
|
518
|
+
const effort = extractEffort(stdinJson);
|
|
517
519
|
if (caps || model) {
|
|
518
520
|
try {
|
|
519
521
|
const { persistSnapshot } = await import('../src/caps-cache.js');
|
|
@@ -521,7 +523,7 @@ async function main() {
|
|
|
521
523
|
} catch (e) { debug('caps-cache:persist', e); }
|
|
522
524
|
}
|
|
523
525
|
console.log(formatNoSession(
|
|
524
|
-
{ caps, model, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
|
|
526
|
+
{ caps, model, effort, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
|
|
525
527
|
{ color: colorOk, mode: labelMode },
|
|
526
528
|
));
|
|
527
529
|
return;
|
|
@@ -567,6 +569,11 @@ async function main() {
|
|
|
567
569
|
const stdinJson = readStdinJson();
|
|
568
570
|
let caps = extractCaps(stdinJson);
|
|
569
571
|
let model = extractModel(stdinJson);
|
|
572
|
+
// Not persisted to the caps cache the way caps/model are: the cache exists so
|
|
573
|
+
// the table view (which pipes no stdin) can still show them, and a level the
|
|
574
|
+
// user has since changed would be worse there than no chip at all. The effort
|
|
575
|
+
// chip is a live-statusline reading only.
|
|
576
|
+
const effort = extractEffort(stdinJson);
|
|
570
577
|
const ctxLive = extractContextUsage(stdinJson);
|
|
571
578
|
if (isStatusline && (caps || model)) {
|
|
572
579
|
try {
|
|
@@ -758,6 +765,7 @@ async function main() {
|
|
|
758
765
|
spikeChip,
|
|
759
766
|
caps,
|
|
760
767
|
model,
|
|
768
|
+
effort,
|
|
761
769
|
delegationSaved,
|
|
762
770
|
delegationTotals,
|
|
763
771
|
doc2mdTotals,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.49.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": {
|
|
@@ -208,6 +208,54 @@ function buildKoreanSeg(c, isIcon, verbose, g) {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
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.
|
|
217
|
+
*
|
|
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.
|
|
225
|
+
*
|
|
226
|
+
* An unlisted level (a new one Anthropic ships) renders in the identity tone
|
|
227
|
+
* rather than not at all.
|
|
228
|
+
*/
|
|
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
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Effort chip builder — the level from Claude Code's stdin payload
|
|
240
|
+
* (`effort.level`, see extractEffort).
|
|
241
|
+
*
|
|
242
|
+
* The level is a word ("high"), so icon modes need no label beside the glyph:
|
|
243
|
+
* `🔬 high` already reads. Text mode names the field, because a bare "high"
|
|
244
|
+
* next to a model name would be anyone's guess.
|
|
245
|
+
*/
|
|
246
|
+
function buildEffortSeg(effort, c, isIcon, g) {
|
|
247
|
+
if (typeof effort !== 'string' || effort.length === 0) return null;
|
|
248
|
+
// `hasOwn` rather than a bare index, for the reason model-alias.js gives at
|
|
249
|
+
// its own lookup: the level is a string off a JSON payload, and a level of
|
|
250
|
+
// '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
|
|
253
|
+
// `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)}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
211
259
|
/**
|
|
212
260
|
* Version segment builder — "which copy of this tool am I looking at", plus
|
|
213
261
|
* the upgrade nudge when a newer one has been published.
|
|
@@ -289,9 +337,10 @@ function buildCapWarnSeg(capWarn, c, isIcon, g, color, mode) {
|
|
|
289
337
|
* The stdin payload (rate limits, model) is still live in that case, and a
|
|
290
338
|
* 90%+ cap warning is exactly the kind of signal that must not disappear
|
|
291
339
|
* just because the user has been idle past the window — so cap-warn,
|
|
292
|
-
* harness, and
|
|
340
|
+
* harness, model, and effort chips still render around the "no session data"
|
|
341
|
+
* note.
|
|
293
342
|
*/
|
|
294
|
-
export function formatNoSession({ caps = null, model = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
|
|
343
|
+
export function formatNoSession({ caps = null, model = null, effort = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
|
|
295
344
|
const c = (v) => (color ? v : '');
|
|
296
345
|
// narrow shares the icon layout and swaps only the glyphs, so both modes take
|
|
297
346
|
// the same branches from here on.
|
|
@@ -307,6 +356,8 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
|
|
|
307
356
|
if (typeof model === 'string' && model.length > 0) {
|
|
308
357
|
segs.push(isIcon ? `${c(MAGENTA)}${g.model} ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
|
|
309
358
|
}
|
|
359
|
+
const effortSeg = buildEffortSeg(effort, c, isIcon, g);
|
|
360
|
+
if (effortSeg) segs.push(effortSeg);
|
|
310
361
|
segs.push(`${c(GRAY)}${g.hit} no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
|
|
311
362
|
if (versionSeg && !(update && update.available)) segs.push(versionSeg);
|
|
312
363
|
return segs.join(' · ') + (color ? '\x1b[K' : '');
|
|
@@ -319,7 +370,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
|
|
|
319
370
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
320
371
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
321
372
|
* @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.
|
|
373
|
+
* @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
374
|
* @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
375
|
*/
|
|
325
376
|
/**
|
|
@@ -341,7 +392,7 @@ export const DEFAULT_SEGMENT_ORDER = [
|
|
|
341
392
|
'cap-warn', 'spike',
|
|
342
393
|
'usage', 'ctx',
|
|
343
394
|
'ttl',
|
|
344
|
-
'model',
|
|
395
|
+
'model', 'effort',
|
|
345
396
|
// Grouped by the timeframe each figure covers, because mixing them was the
|
|
346
397
|
// problem: a lifetime total, a windowed rate and a calendar-month estimate sat
|
|
347
398
|
// interleaved, with one window label at the end of the line that appeared to
|
|
@@ -359,7 +410,7 @@ export const DEFAULT_SEGMENT_ORDER = [
|
|
|
359
410
|
];
|
|
360
411
|
|
|
361
412
|
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;
|
|
413
|
+
const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model, effort } = data;
|
|
363
414
|
const { hitRate } = summary;
|
|
364
415
|
|
|
365
416
|
// Hit rate → color signal
|
|
@@ -711,6 +762,11 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
711
762
|
}
|
|
712
763
|
}
|
|
713
764
|
|
|
765
|
+
// Effort chip — how hard the session is being worked (`/effort`). It follows
|
|
766
|
+
// the model chip because it qualifies it: the same model answers differently
|
|
767
|
+
// at `low` and at `max`, and the pair is one glance.
|
|
768
|
+
const effortSeg = buildEffortSeg(effort, c, isIcon, g);
|
|
769
|
+
|
|
714
770
|
// Always-on usage segments — what /usage shows in Claude Code, mirrored
|
|
715
771
|
// to the statusline so the user doesn't have to slash-command for it.
|
|
716
772
|
// Today the stdin payload exposes the 5h ("Current session") and 7-day
|
|
@@ -820,6 +876,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
820
876
|
case 'harness': return [harnessSeg];
|
|
821
877
|
case 'korean': return [koreanSeg];
|
|
822
878
|
case 'model': return [modelSeg];
|
|
879
|
+
case 'effort': return [effortSeg];
|
|
823
880
|
// Dropped when the same story already owns a headline line above, which
|
|
824
881
|
// would otherwise repeat it on line 2.
|
|
825
882
|
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
|
package/src/stdin-payload.js
CHANGED
|
@@ -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,29 @@ 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
|
+
}
|