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 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.48.0",
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 model chips still render around the "no session data" note.
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
@@ -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
+ }