sprag-cli 3.45.0 → 3.46.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/README.ko.md CHANGED
@@ -66,7 +66,7 @@ gemini-2.5-pro 57.9% $734 ▰▰▰▰▰▰▰▰▰▰▰
66
66
 
67
67
  ```
68
68
  🔀 라우팅 절감 $2.09 | fable→sonnet 1회 $0.72 · opus→haiku 1회 $0.57
69
- 🚨 5H █████▓ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ██▒░░░ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
69
+ 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
70
70
  ```
71
71
 
72
72
  문제가 생기면 경고 칩이 줄 맨 앞으로 옵니다. `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL` 여덟 가지이며, 칩 문구를 그대로 말하면 Skill이 원인 코드와 조치 순서를 꺼내 줍니다. 세그먼트별 의미와 색 기준은 [statusline 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.md)에 정리했습니다.
package/README.md CHANGED
@@ -72,7 +72,7 @@ gemini-2.5-pro 57.9% $734 ▰▰▰▰▰▰▰▰▰▰▰
72
72
 
73
73
  ```
74
74
  🔀 라우팅 절감 $2.09 | fable→sonnet 1회 $0.72 · opus→haiku 1회 $0.57
75
- 🚨 5H █████▓ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ██▒░░░ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
75
+ 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
76
76
  ```
77
77
 
78
78
  문제가 생기면 경고 칩이 줄 맨 앞으로 옵니다. `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL` 여덟 가지이며, 칩 문구를 그대로 말하면 Skill이 원인 코드와 조치 순서를 꺼내 줍니다. 세그먼트별 의미와 색 기준은 [statusline 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.md)에 정리했습니다.
@@ -211,7 +211,7 @@ The measured −18.6% comes from the harness and ratchet; routing and conversion
211
211
 
212
212
  ```
213
213
  🔀 Routing saved $2.09 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57
214
- 🚨 5H █████▓ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ██▒░░░ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
214
+ 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
215
215
  ```
216
216
 
217
217
  When something is wrong the warning chip leads the line: `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL`. Say the chip wording inside Claude and the Skill surfaces the root-cause code and the fix. Segment-by-segment meanings live in [the statusline reference](./docs/STATUSLINE.md).
package/bin/cli.js CHANGED
@@ -325,7 +325,12 @@ async function main() {
325
325
  console.log('예산 캐시가 비어 있습니다. `sprag litellm-budget --refresh` 로 먼저 조회하십시오.');
326
326
  return;
327
327
  }
328
- for (const line of formatBudgetReport(state)) console.log(line);
328
+ // Same resolver the statusline uses: this report draws a gauge too, and a
329
+ // glyph the terminal's font lacks garbles here exactly as it does there.
330
+ const { resolveLabelMode } = await import('../src/statusline-mode.js');
331
+ const { statuslineDefaults } = await import('../src/config.js');
332
+ const { mode: labelMode } = resolveLabelMode({ hasFlag, cfg: statuslineDefaults() });
333
+ for (const line of formatBudgetReport(state, new Date(), labelMode)) console.log(line);
329
334
  return;
330
335
  }
331
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.45.0",
3
+ "version": "3.46.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
  "sync:ko": "node scripts/sync-readme-ko.mjs",
16
+ "release:notes": "node scripts/release-notes.mjs",
16
17
  "prepublishOnly": "node scripts/sync-readme-ko.mjs"
17
18
  },
18
19
  "files": [
@@ -0,0 +1,34 @@
1
+ /**
2
+ * changelog — read one version's section out of CHANGELOG.md.
3
+ *
4
+ * Separate from scripts/release-notes.mjs, which is a CLI: importing that file
5
+ * runs its argument parsing and exits, so the extraction it depends on cannot be
6
+ * tested through it. The boundary logic is the part worth pinning — a section
7
+ * pulled one line short drops the release's last item, and one line long carries
8
+ * the previous release's first, and the upgrade offer shows either without
9
+ * complaint.
10
+ */
11
+
12
+ /**
13
+ * The body of one version's section, heading excluded.
14
+ *
15
+ * @param {string} changelog - CHANGELOG.md contents
16
+ * @param {string} version - e.g. "3.45.0"
17
+ * @returns {string|null} the section text, or null when that version has none
18
+ */
19
+ export function sectionFor(changelog, version) {
20
+ const lines = String(changelog).split(/\r?\n/);
21
+ // The version has to match to its end: without the boundary, "3.42.0" would
22
+ // also match the "### v3.42.7" heading and publish the wrong notes.
23
+ const head = new RegExp(`^### v${String(version).replace(/\./g, '\\.')}(\\s|$|\\()`);
24
+ const start = lines.findIndex((l) => head.test(l));
25
+ if (start < 0) return null;
26
+ const out = [];
27
+ for (let i = start + 1; i < lines.length; i += 1) {
28
+ // The next version heading ends this section, and so does a shallower one:
29
+ // "## All releases" is a container, not part of any release.
30
+ if (/^### v/.test(lines[i]) || /^## /.test(lines[i])) break;
31
+ out.push(lines[i]);
32
+ }
33
+ return out.join('\n').trim();
34
+ }
@@ -71,6 +71,7 @@ export async function run({ hasFlag }) {
71
71
  const r = installAll({ force });
72
72
  print('skill', r.skill);
73
73
  print('SessionStart hook (route-scan)', r.sessionStartHook);
74
+ print('PostToolUse hook (delegation rescan)', r.delegationHook);
74
75
  print('UserPromptSubmit hook (brief)', r.briefHook);
75
76
  {
76
77
  let s = r.statusline;
@@ -174,11 +174,27 @@ export async function run({ args, hasFlag, numArg }) {
174
174
  const scan = rs.readRouteScan();
175
175
  if (scan?.unresolvedRuns > 0) {
176
176
  const ids = (scan.unresolvedModels || []).join(', ');
177
+ // Unlike the statusline chip, this listing reports even a single one: the
178
+ // user ran this command to find out why a rule reads as never fired, and
179
+ // there is room here to answer properly. But a gateway's opaque profile
180
+ // ARN is normally resolved by learning from observed runs, which needs
181
+ // MIN_VOTES of them — so below that, the honest advice is to wait rather
182
+ // than to go edit a config file.
183
+ const { MIN_VOTES } = await import('../model-alias.js');
184
+ const learning = scan.unresolvedRuns < MIN_VOTES;
185
+ const remedyKo = learning
186
+ ? `\n 위임이 ${MIN_VOTES}건 이상 쌓이면 자동으로 해석될 수 있습니다. 그때까지 기다렸다가 다시 확인하고,`
187
+ + '\n 게이트웨이가 모델 이름을 아예 알려 주지 않는 환경이라면 profile-map.json 의 modelAliases 에'
188
+ + '\n 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.'
189
+ : '\n profile-map.json 의 modelAliases 에 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.';
190
+ const remedyEn = learning
191
+ ? `\n These may resolve on their own once ${MIN_VOTES} or more delegated runs accumulate — check again then.`
192
+ + '\n If the gateway never reports a model name at all, map the id under modelAliases in'
193
+ + '\n profile-map.json and run route-scan --refresh.'
194
+ : '\n Map it under modelAliases in profile-map.json, then run route-scan --refresh.';
177
195
  console.log(lang === 'ko'
178
- ? `\n⚠ 해석되지 않은 모델 ID 때문에 위임 ${scan.unresolvedRuns}건이 집계에서 제외됐습니다${ids ? ` (${ids})` : ''}.`
179
- + '\n profile-map.json 의 modelAliases 에 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.'
180
- : `\n⚠ ${scan.unresolvedRuns} delegated run(s) were excluded — unpriceable model id${ids ? ` (${ids})` : ''}.`
181
- + '\n Map it under modelAliases in profile-map.json, then run route-scan --refresh.');
196
+ ? `\n⚠ 해석되지 않은 모델 ID 때문에 위임 ${scan.unresolvedRuns}건이 집계에서 제외됐습니다${ids ? ` (${ids})` : ''}.` + remedyKo
197
+ : `\n⚠ ${scan.unresolvedRuns} delegated run(s) were excluded — unpriceable model id${ids ? ` (${ids})` : ''}.` + remedyEn);
182
198
  }
183
199
  console.log(lang === 'ko'
184
200
  ? '\n제거: sprag route-scan rules rm <N>'
@@ -203,6 +219,33 @@ export async function run({ args, hasFlag, numArg }) {
203
219
  return;
204
220
  }
205
221
 
222
+ // --hook-delegated: PostToolUse hook for Task/Agent. A delegation just
223
+ // finished, which is the one event guaranteed to change what a scan
224
+ // produces — its savings and its error rate both move. Kicks a detached
225
+ // refresh so the statusline shows the new figure on its next render
226
+ // instead of waiting out the hourly gate, and prints nothing: a tool
227
+ // result is not a place for our commentary.
228
+ if (hasFlag('--hook-delegated')) {
229
+ const payload = readStdinJson() || {};
230
+ // Claude Code matches the hook by tool name already, but the payload is
231
+ // the only thing that proves it: a mis-registered matcher would otherwise
232
+ // have us rescanning after every Read.
233
+ const tool = payload.tool_name;
234
+ if (tool !== 'Task' && tool !== 'Agent') return;
235
+ try {
236
+ const cache = rs.readRouteScan();
237
+ if (!(await rs.shouldRescan(cache, { afterDelegation: true }))) return;
238
+ const { spawn } = await import('node:child_process');
239
+ spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
240
+ { detached: true, stdio: 'ignore', windowsHide: true }).unref();
241
+ } catch (e) {
242
+ // A missed refresh only means the figure lags, which is what happened
243
+ // before this hook existed. Never fail the tool call over it.
244
+ debug('route-scan:delegated-refresh', e);
245
+ }
246
+ return;
247
+ }
248
+
206
249
  // --hook: SessionStart hook mode. Never scans inline (session start must
207
250
  // stay fast) — reads the cache, kicks a detached refresh when stale, and
208
251
  // prints delegation-candidate context for the new session.
@@ -299,16 +342,32 @@ export async function run({ args, hasFlag, numArg }) {
299
342
  // `dismissed` is the whole point of asking once: a user who said no to
300
343
  // this version must not be asked again every `/clear`.
301
344
  if (u && u.available && !u.dismissed) {
345
+ // What the release adds, when we managed to read it. "A newer version
346
+ // exists" is not a reason to upgrade; the reason is in this list, and
347
+ // without it the user is being asked to decide on no information.
348
+ const notes = Array.isArray(u.highlights) ? u.highlights : [];
349
+ const koNotes = notes.length
350
+ ? [' 이 버전에서 새로 생긴 것:', ...notes.map((n) => ` · ${n}`)]
351
+ : [];
352
+ const enNotes = notes.length
353
+ ? [' What this release adds:', ...notes.map((n) => ` · ${n}`)]
354
+ : [];
302
355
  updateBlock = lang === 'ko'
303
356
  ? [
304
357
  `[sprag update] 새 버전이 나와 있습니다: v${u.current} → ${u.latest}.`,
305
- '사용자에게 지금 업그레이드할지 물어보고, 승낙하면 아래 명령을 실행하십시오. 묻지 않고 설치하지는 마십시오.',
306
- ' sprag upgrade # 업그레이드 실행',
358
+ ...koNotes,
359
+ notes.length
360
+ ? ' 위 내용을 사용자에게 보여 주고 업그레이드할지 물어보십시오. 승낙하면 아래 명령을 실행하고, 묻지 않고 설치하지는 마십시오.'
361
+ : ' 사용자에게 지금 업그레이드할지 물어보고, 승낙하면 아래 명령을 실행하십시오. 묻지 않고 설치하지는 마십시오.',
362
+ ' sprag upgrade # 업그레이드 실행',
307
363
  ' sprag update-check --dismiss # 사용자가 원치 않으면 (다음 버전이 나올 때까지 다시 묻지 않습니다)',
308
364
  ].join('\n')
309
365
  : [
310
366
  `[sprag update] A newer version is available: v${u.current} → ${u.latest}.`,
311
- 'Ask the user whether to upgrade now, and run the command below only if they agree. Do not install without asking.',
367
+ ...enNotes,
368
+ notes.length
369
+ ? ' Show that list to the user and ask whether to upgrade. Run the command below only if they agree; do not install without asking.'
370
+ : 'Ask the user whether to upgrade now, and run the command below only if they agree. Do not install without asking.',
312
371
  ' sprag upgrade # perform the upgrade',
313
372
  ' sprag update-check --dismiss # if they decline (stays quiet until a newer release)',
314
373
  ].join('\n');
@@ -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 { MIN_VOTES } from '../model-alias.js';
26
27
 
27
28
  // The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
28
29
  // next to each other — terminal palettes set them with unbalanced perceptual
@@ -67,88 +68,86 @@ function formatPct(v) {
67
68
  }
68
69
 
69
70
  /**
70
- * Render a 6-cell density-gradient gauge for `pct` (0..100). All cells share
71
- * the same Unicode "Block Elements" density family — `█` (100%) → `▓` (75%)
72
- * → `▒` (50%) → `░` (25%) — so the fill→empty boundary reads as one smooth
73
- * gradient instead of an awkward step.
71
+ * Render the gauge for `pct` (0..100) as a row of separate ticks.
74
72
  *
75
- * Earlier we used partial-fill glyphs (`▏▎▍▌▋▊▉`) for sub-cell precision, but
76
- * those have transparent halves that clash visually with the `░` track next
77
- * to them (the eye sees "solid edge | gap | dotted track" — three zones).
78
- * Density chars are the same shape, just darker/lighter, so the boundary
79
- * cell reads as a single smooth fade.
73
+ * The bar used to be six cells of one continuous block, with the boundary cell
74
+ * subdivided into eighths for sub-cell precision. That precision had no reader:
75
+ * every chip that draws a gauge prints the exact percentage immediately after it
76
+ * (`▰▰▰▱▱▱ 26%`), so the bar's job is the glance, not the number. What the solid
77
+ * run cost instead was legibility — adjacent full cells fuse into one shape, and
78
+ * a shape has no quantity you can read off it. Separate ticks do: twelve of them
79
+ * are countable at a glance and land on a familiar 1/12 grid.
80
80
  *
81
- * Each cell is ~17% wide; the boundary cell uses 3 intermediate density steps
82
- * for ~4% effective precision around the fill edge. Stable monospace width
83
- * across all terminal fonts that ship Block Elements (U+2580–U+259F).
81
+ * Two ends get special treatment, because they are the two readings that change
82
+ * a decision. A full row means the limit is reached, so nothing below 100% may
83
+ * draw one; conversely anything above zero draws at least one tick, so "barely
84
+ * started" never looks like "untouched". Rounding alone gets both wrong: it fills
85
+ * the last cell from 96% and leaves the first empty until 5%.
86
+ *
87
+ * Glyphs come from the mode's set (src/glyphs.js) rather than being fixed here.
88
+ * ▰▱ is the shape we want, but JetBrains Mono ships neither character, and a
89
+ * missing glyph is drawn by a fallback font whose advance width does not match
90
+ * the cell grid — which is the whole reason narrow mode exists. So narrow draws
91
+ * the same segmented bar with ■□, which that font does have.
92
+ *
93
+ * @param {number} pct
94
+ * @param {string} [mode] label mode; selects the tick glyphs
95
+ * @returns {{ filled: string, empty: string }} the two halves, so a caller can
96
+ * color them separately
84
97
  */
85
- export function gaugeParts(pct) {
86
- const cells = 6;
98
+ export function gaugeParts(pct, mode = 'icon') {
99
+ const CELLS = 12;
100
+ const g = glyphsFor(mode);
87
101
  const clamped = Math.max(0, Math.min(100, pct));
88
- const filled = (clamped / 100) * cells; // e.g. 4.32 cells filled
89
- const fullCells = Math.floor(filled);
90
- const remainder = filled - fullCells; // 0..1 — fill fraction of the boundary cell
91
- // Boundary cell in eighths. The earlier version had three steps (░ ▒ ▓), which
92
- // meant 25% and 31% drew the same bar; eighths keep the resolution the width
93
- // of the bar can carry, so the gauge moves as the number does. Every character
94
- // here is present in JetBrains Mono, which is why the gauge was the one part
95
- // of the line that never garbled under IntelliJ (see src/glyphs.js).
96
- const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
97
- // Empty cells use ▒ rather than ░. In JetBrains Mono the eighth blocks all sit
98
- // at yMin -300 / xMin 0, while ░ sits at yMin -240 / xMin 60 — about 4% of the
99
- // cell height and 10% of its width inset — so a bar mixing them looks visibly
100
- // misaligned, the empty half riding high and shifted inward. ▒ shares the box
101
- // with the filled glyphs and still reads clearly lighter than █.
102
- const step = Math.round(remainder * 8);
103
- // A boundary cell rounded up to 8/8 is a full cell, not a ninth glyph.
104
- const extra = step === 8 ? 1 : 0;
105
- const totalFull = Math.min(cells, fullCells + extra);
106
- const partial = totalFull < cells && extra === 0 ? EIGHTHS[step] : '';
107
- const usedCells = totalFull + (partial ? 1 : 0);
108
- const empty = '▒'.repeat(Math.max(0, cells - usedCells));
109
- return { filled: '█'.repeat(totalFull) + partial, empty };
102
+ let ticks = Math.round((clamped / 100) * CELLS);
103
+ // A full row is reserved for an actual 100%: at the cap the gauge alone has to
104
+ // say so, and 96% rounding up to twelve would spend that signal on a window
105
+ // that still has room.
106
+ if (ticks >= CELLS && clamped < 100) ticks = CELLS - 1;
107
+ // And any nonzero usage earns a tick, so an empty row means untouched.
108
+ if (ticks === 0 && clamped > 0) ticks = 1;
109
+ return {
110
+ filled: g.gaugeFull.repeat(ticks),
111
+ empty: g.gaugeEmpty.repeat(CELLS - ticks),
112
+ };
110
113
  }
111
114
 
112
115
  /**
113
116
  * The bar as one string, for callers that render it in a single color.
114
117
  * @param {number} pct
118
+ * @param {string} [mode] label mode; selects the tick glyphs
115
119
  * @returns {string}
116
120
  */
117
- export function gaugeBar(pct) {
118
- const { filled, empty } = gaugeParts(pct);
121
+ export function gaugeBar(pct, mode = 'icon') {
122
+ const { filled, empty } = gaugeParts(pct, mode);
119
123
  return filled + empty;
120
124
  }
121
125
 
122
126
  /**
123
- * The bar with the unused cells dimmed.
127
+ * The bar with the unused ticks dimmed.
124
128
  *
125
- * ▒ shares its glyph box with the filled blocks, which is why it replaced ░, but
126
- * it is also denser than ░ was — in one color the used and unused halves stop
127
- * reading as different. Graying the remainder restores the contrast that the
128
- * shading alone used to carry. The segment tone is reinstated at the end so the
129
- * text after the bar keeps the color it started in.
129
+ * The hollow tick already distinguishes the two halves by shape, so color is not
130
+ * carrying that distinction the way it had to when both halves were the same
131
+ * solid block. It still helps: graying the remainder keeps the filled run as the
132
+ * only thing at full brightness, which is what the eye should land on. The
133
+ * segment tone is reinstated at the end so the text after the bar keeps the color
134
+ * it started in.
130
135
  *
131
136
  * @param {number} pct
132
137
  * @param {(code: string) => string} c - color emitter (returns '' when color is off)
133
138
  * @param {string} tone - the segment's own color code
134
139
  * @param {boolean} color - whether ANSI is allowed. Passed explicitly rather
135
140
  * than inferred from `c` emitting nothing: that inference reads the emitter's
136
- * current implementation, and a caller that wraps or replaces `c` would flip
137
- * the track glyph as a side effect nobody asked for.
141
+ * current implementation, and a caller that wraps or replaces `c` would change
142
+ * behaviour here as a side effect nobody asked for.
143
+ * @param {string} [mode] label mode; selects the tick glyphs
138
144
  * @returns {string}
139
145
  */
140
- function gaugeColored(pct, c, tone, color) {
141
- const { filled, empty } = gaugeParts(pct);
146
+ function gaugeColored(pct, c, tone, color, mode = 'icon') {
147
+ const { filled, empty } = gaugeParts(pct, mode);
142
148
  if (!empty) return filled;
143
- // With color available, the unused cells are the same solid block as the used
144
- // ones, just gray. Shading characters (░ ▒) carry a dither pattern that reads
145
- // as noise beside a solid fill — the two halves look like different materials
146
- // rather than two states of one bar. One glyph in two colors reads as a track
147
- // filling up, and it sidesteps the glyph-box mismatch between the shades and
148
- // the blocks entirely. Without color there is nothing but texture to tell them
149
- // apart, so the shading stays.
150
- const track = color ? '█'.repeat(empty.length) : empty;
151
- return `${filled}${c(RESET)}${c(GRAY)}${track}${c(RESET)}${c(tone)}`;
149
+ if (!color) return filled + empty;
150
+ return `${filled}${c(RESET)}${c(GRAY)}${empty}${c(RESET)}${c(tone)}`;
152
151
  }
153
152
 
154
153
  /**
@@ -266,11 +265,9 @@ function buildHarnessSeg(c, isIcon, g) {
266
265
  * the wall-clock reset time rides along in the same `🔄 HH:MM` shape as the
267
266
  * always-on usage segments.
268
267
  */
269
- function buildCapWarnSeg(capWarn, c, isIcon, g, color) {
270
- // `color` is only for the gauge. At 90%+ the bar has no unused cells left, so
271
- // gaugeColored returns early and never reads it today — it is threaded through
272
- // anyway, because a change to the cap-warn threshold or the bar width would
273
- // otherwise start drawing the wrong track with nothing to catch it.
268
+ function buildCapWarnSeg(capWarn, c, isIcon, g, color, mode) {
269
+ // `color` and `mode` are both for the gauge: the first decides whether the
270
+ // unused ticks are dimmed, the second which pair of tick glyphs to draw.
274
271
  if (!capWarn) return null;
275
272
  const pct = Math.round(capWarn.usedPct);
276
273
  const clock = formatResetClock(capWarn.resetsAt);
@@ -281,7 +278,7 @@ function buildCapWarnSeg(capWarn, c, isIcon, g, color) {
281
278
  // Gauge keeps shape parity with the always-on usage segment — the
282
279
  // cap-warn is just the same gauge "filled to alarm". Visual continuity
283
280
  // helps the eye understand "this is the 5H bar I was watching, just red now."
284
- const bar = gaugeColored(pct, c, RED, color);
281
+ const bar = gaugeColored(pct, c, RED, color, mode);
285
282
  return `${c(BOLD)}${c(RED)}${g.capWarn} ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
286
283
  }
287
284
  return `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
@@ -301,7 +298,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
301
298
  const isIcon = mode === 'icon' || mode === 'narrow';
302
299
  const g = glyphsFor(mode);
303
300
  const segs = [];
304
- const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon, g, color);
301
+ const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon, g, color, mode);
305
302
  if (capSeg) segs.push(capSeg);
306
303
  const versionSeg = buildVersionSeg(version, update, c, isIcon, false, g);
307
304
  if (versionSeg && update && update.available) segs.push(versionSeg);
@@ -443,10 +440,18 @@ export function formatReport(data, { color = true, verbose = false, timer = true
443
440
  // identical, so users in the second case had no reason to suspect anything
444
441
  // was wrong. The count gets a chip; the explanation stays in `route-scan
445
442
  // rules`, where there is room for it.
443
+ //
444
+ // But only once there are enough of them to mean anything. A gateway reports
445
+ // an opaque profile ARN, and the fix for that is learned from observed runs —
446
+ // which needs MIN_VOTES of them before it can conclude anything. Below that
447
+ // threshold an unresolved run is not a problem, it is the first day of a
448
+ // working mechanism, and warning about it makes a fresh install look broken.
449
+ // Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
450
+ // appears exactly when learning has had its chance and still came up short.
446
451
  const unresolvedRuns = Number(data.unresolvedRuns) || 0;
447
452
  const delegateSeg = delegationSaved > 0
448
453
  ? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
449
- : (unresolvedRuns > 0
454
+ : (unresolvedRuns >= MIN_VOTES
450
455
  ? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
451
456
  : null);
452
457
 
@@ -750,7 +755,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
750
755
  const src = info.source === 'user' ? ' (user)' : '';
751
756
  if (isIcon) {
752
757
  const labelPart = labels.usageLabel ? `${labels.usageLabel}${src} ` : '';
753
- const bar = gaugeColored(pct, c, tone, color);
758
+ const bar = gaugeColored(pct, c, tone, color, mode);
754
759
  const winGlyph = mode === 'narrow' ? (labels.narrowIcon || labels.icon) : labels.icon;
755
760
  return `${c(tone)}${winGlyph} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
756
761
  }
@@ -787,7 +792,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
787
792
  }
788
793
  }
789
794
 
790
- const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon, g, color);
795
+ const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon, g, color, mode);
791
796
 
792
797
  // Warning chip leads — a glance at the statusline catches "something's wrong"
793
798
  // before parsing any numbers. Healthy states have no chip and look unchanged.
package/src/glyphs.js CHANGED
@@ -19,6 +19,13 @@
19
19
  * Per-window glyphs (the 5h star, the weekly grid, the budget box) live in
20
20
  * src/window-labels.js instead, next to the labels they belong to.
21
21
  *
22
+ * The gauge cells live here too, for the same reason the chip glyphs do. The
23
+ * shapes differ by mode rather than only the vocabulary: ▰▱ read as a row of
24
+ * separate ticks, which is what a gauge is, but JetBrains Mono ships neither
25
+ * (verified via its cmap: U+25B0 and U+25B1 are absent, U+25A0 and U+25A1 are
26
+ * present). So the narrow set draws the same segmented shape with ■□ instead of
27
+ * falling back to a font whose advance width would garble the line.
28
+ *
22
29
  * Width still matters as a second filter — a fullwidth glyph would occupy two
23
30
  * cells — so every narrow glyph is also East Asian Width N/Na. The test suite
24
31
  * enforces both properties.
@@ -40,6 +47,12 @@ const ICON = {
40
47
  ctx: '📦',
41
48
  saved: '💰',
42
49
  reset: '🔄',
50
+ // Gauge cells. Separate ticks rather than one continuous block: the bar is a
51
+ // count of how much of a budget is gone, and ticks are countable where a solid
52
+ // run is not. Precision is not this bar's job — an exact percentage is printed
53
+ // immediately after it in every chip that draws one.
54
+ gaugeFull: '▰',
55
+ gaugeEmpty: '▱',
43
56
  };
44
57
 
45
58
  /**
@@ -62,6 +75,10 @@ const NARROW = {
62
75
  ctx: '◧', // a box filling up
63
76
  saved: '✓',
64
77
  reset: '↩', // the window turning over
78
+ // Same segmented shape as the icon set, in the two glyphs this font has.
79
+ // ▰▱ (U+25B0/25B1) are absent from JetBrains Mono; ■□ (U+25A0/25A1) are there.
80
+ gaugeFull: '■',
81
+ gaugeEmpty: '□',
65
82
  };
66
83
 
67
84
  const SETS = { icon: ICON, narrow: NARROW };
package/src/installer.js CHANGED
@@ -244,8 +244,11 @@ export function installSessionStartHook() {
244
244
  return { path: file, action: 'skipped', reason: 'hooks.SessionStart is not an array — fix settings.json manually' };
245
245
  }
246
246
  const list = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
247
+ // Match the flag exactly. `route-scan --hook-delegated` contains
248
+ // `route-scan --hook` as a prefix, so a substring test would see that hook and
249
+ // conclude this one is already installed.
247
250
  const already = list.some((m) =>
248
- Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('route-scan --hook')),
251
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /route-scan --hook(?![\w-])/.test(h.command)),
249
252
  );
250
253
  if (already) return { path: file, action: 'exists' };
251
254
 
@@ -263,6 +266,51 @@ export function installSessionStartHook() {
263
266
  // conversation — the model cannot see statusline chips, so without this a
264
267
  // change that happens mid-session goes unexplained until the user asks.
265
268
  // Silent (no output, zero context cost) when nothing changed. Idempotent.
269
+ /**
270
+ * PostToolUse hook for Task/Agent. The statusline's `Routing saved` figure comes
271
+ * from a cached scan, and the gate in front of that scan holds a fresh
272
+ * delegation back an hour at best. That gate is there to skip scans that would
273
+ * produce identical output; a delegation is precisely the event that changes it,
274
+ * so this hook rescans on the spot. Detached, so the tool call does not wait.
275
+ */
276
+ const ROUTE_SCAN_DELEGATED_HOOK_COMMAND = 'claude-token-saver route-scan --hook-delegated';
277
+
278
+ export function installDelegationHook() {
279
+ const dir = claudeUserDir();
280
+ const file = join(dir, 'settings.json');
281
+ mkdirSync(dir, { recursive: true });
282
+
283
+ let settings = {};
284
+ if (existsSync(file)) {
285
+ try {
286
+ settings = JSON.parse(readFileSync(file, 'utf8'));
287
+ } catch (e) {
288
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
289
+ }
290
+ }
291
+
292
+ settings.hooks = settings.hooks || {};
293
+ if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
294
+ return { path: file, action: 'skipped', reason: 'hooks.PostToolUse is not an array — fix settings.json manually' };
295
+ }
296
+ const list = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
297
+ // Same exact-flag match as the SessionStart hook above, for the same reason:
298
+ // a substring test is what confused `--hook` with `--hook-delegated`, and a
299
+ // future `--hook-delegated-batch` would confuse this one in turn.
300
+ const already = list.some((m) =>
301
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /route-scan --hook-delegated(?![\w-])/.test(h.command)),
302
+ );
303
+ if (already) return { path: file, action: 'exists' };
304
+
305
+ list.push({
306
+ matcher: 'Task|Agent',
307
+ hooks: [{ type: 'command', command: ROUTE_SCAN_DELEGATED_HOOK_COMMAND, timeout: 10 }],
308
+ });
309
+ settings.hooks.PostToolUse = list;
310
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
311
+ return { path: file, action: 'created' };
312
+ }
313
+
266
314
  const BRIEF_HOOK_COMMAND = 'claude-token-saver brief --hook';
267
315
 
268
316
  export function installBriefHook() {
@@ -623,6 +671,7 @@ export function installAll({ force = false } = {}) {
623
671
  skill: installSkill({ force }),
624
672
  statusline: installStatusline({ force }),
625
673
  sessionStartHook: installSessionStartHook(),
674
+ delegationHook: installDelegationHook(),
626
675
  briefHook: installBriefHook(),
627
676
  cacheMonitorMigration: migrateLegacyCacheMonitorHook(),
628
677
  legacy: removeLegacyCommand(),
@@ -23,6 +23,7 @@ import { join } from 'node:path';
23
23
  import { homedir } from 'node:os';
24
24
  import { userDataDir } from './paths.js';
25
25
  import { gaugeBar, formatMoney } from './formatters/statusline.js';
26
+ import { labelForKey } from './window-labels.js';
26
27
  import { formatResetClock } from './format-time.js';
27
28
  import { cliEntryPath } from './update-check.js';
28
29
  import { debug } from './debug.js';
@@ -155,11 +156,16 @@ export function budgetWindow(env = process.env) {
155
156
  * 같은 모양(비주얼 바 + 백분율 + 금액)을 쓰되, 통계선에서는 폭이 부족해 생략하는
156
157
  * 잔여 금액과 예산 출처, 캐시 신선도를 함께 적습니다.
157
158
  *
159
+ * `mode` 는 통계선과 같은 판정 결과를 받습니다. 이 보고서도 결국 터미널에
160
+ * 출력되므로, IntelliJ 에서 실행하면 폰트에 없는 글리프가 대체 폰트로 그려져
161
+ * 통계선과 똑같이 어긋납니다. 게이지 눈금과 예산 아이콘을 함께 모드에 맞춥니다.
162
+ *
158
163
  * @param {object} state - readBudgetState() 가 돌려준 캐시 상태
159
164
  * @param {Date} [now]
165
+ * @param {string} [mode] - 라벨 모드 ('icon' | 'narrow' | 'text')
160
166
  * @returns {string[]} 출력할 줄들
161
167
  */
162
- export function formatBudgetReport(state, now = new Date()) {
168
+ export function formatBudgetReport(state, now = new Date(), mode = 'icon') {
163
169
  const max = Number(state?.maxBudget);
164
170
  const spend = Number(state?.spend);
165
171
  if (!Number.isFinite(max) || max <= 0) {
@@ -169,8 +175,16 @@ export function formatBudgetReport(state, now = new Date()) {
169
175
  const used = Number.isFinite(spend) ? Math.max(0, spend) : 0;
170
176
  const left = Math.max(0, max - used);
171
177
  const pct = Math.min(100, (used / max) * 100);
178
+ const win = labelForKey('litellm_budget');
179
+ // 통계선의 text 모드는 게이지를 아예 그리지 않지만 이 보고서는 그립니다. text 는
180
+ // 이모지를 피하려고 고른 모드이므로, 그 선택을 존중해 눈금도 폰트 커버리지가
181
+ // 확인된 narrow 쪽 글리프를 씁니다. 라벨은 아이콘 없이 이름만 적습니다.
182
+ const bare = mode === 'text';
183
+ const tickMode = mode === 'icon' ? 'icon' : 'narrow';
184
+ const icon = bare ? '' : (mode === 'narrow' ? (win.narrowIcon || win.icon) : win.icon);
185
+ const head = bare ? 'budget' : `${icon} budget`;
172
186
  const lines = [
173
- `💳 budget ${gaugeBar(pct)} ${Math.round(pct)}% ${formatMoney(used)}/${formatMoney(max)}`,
187
+ `${head} ${gaugeBar(pct, tickMode)} ${Math.round(pct)}% ${formatMoney(used)}/${formatMoney(max)}`,
174
188
  ` 사용 ${formatMoney(used)} · 잔여 ${formatMoney(left)} (${(100 - pct).toFixed(1)}%)`,
175
189
  ];
176
190
  const resetMs = Number(state?.budgetResetAt);
@@ -36,9 +36,12 @@ const MIN_LEN = 8;
36
36
  * @param {object} [opts]
37
37
  * @param {Array} [opts.rules] registered rules (defaults to the stored registry)
38
38
  * @param {string} [opts.lang] 'ko' | 'en'
39
+ * @param {string} [opts.root] project root used to look for a subagent's .md.
40
+ * The hint names the agent when that file exists and only its model tier when
41
+ * it does not, so a caller that needs a predictable phrase pins this.
39
42
  * @returns {string|null} the line to inject, or null to stay quiet
40
43
  */
41
- export function routeHint(text, { rules, lang = userLanguage() } = {}) {
44
+ export function routeHint(text, { rules, lang = userLanguage(), root } = {}) {
42
45
  const t = String(text || '').trim();
43
46
  if (t.length < MIN_LEN) return null;
44
47
  // Judgement and irreversible work stay on the top tier. This check comes
@@ -57,7 +60,12 @@ export function routeHint(text, { rules, lang = userLanguage() } = {}) {
57
60
  const t1 = matched.find((r) => r.tier === 'T1');
58
61
  const ko = lang === 'ko';
59
62
  const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
60
- const phrase = ko ? agentPhrase : agentPhraseEn;
63
+ // `root` is threaded through to agentPhrase, which names the subagent only
64
+ // when its .md actually exists on this machine. A caller that pins it can
65
+ // therefore get a deterministic phrase; a test that does not pin it reads
66
+ // whatever the developer happens to have installed.
67
+ const raw = ko ? agentPhrase : agentPhraseEn;
68
+ const phrase = (name) => raw(name, root === undefined ? undefined : { root });
61
69
 
62
70
  let target;
63
71
  if (t2 && t1) {
package/src/route-scan.js CHANGED
@@ -67,6 +67,18 @@ export const RESCAN_MIN_INTERVAL_MS = 60 * 60 * 1000; // never more than ho
67
67
  export const RESCAN_BIG_DELTA_BYTES = 5 * 1024 * 1024; // this much new data → rescan now
68
68
  export const RESCAN_MAX_AGE_MS = 24 * 60 * 60 * 1000; // any new data + a day old → rescan
69
69
 
70
+ // One event is exempt from all three: a delegation that just finished. The
71
+ // clauses above exist to skip scans that would produce identical output, and a
72
+ // delegation is the one thing guaranteed to change it — savings and the rule's
73
+ // own error rate both move. Its transcript is nowhere near 5MB, so the gate
74
+ // would otherwise hold the figure back an hour at best and a day at worst,
75
+ // which is exactly the window in which the user is asking whether delegating
76
+ // paid off. Measured on this machine: a scan is ~0.5s over 14 days / 67MB, and
77
+ // delegations run ~1.6×/day, so the exemption costs well under a second a day.
78
+ // A short floor still applies, so a fan-out of parallel agents finishing
79
+ // together triggers one scan rather than one per agent.
80
+ export const RESCAN_AFTER_DELEGATION_MS = 30 * 1000;
81
+
70
82
  // Categories → recommended subagent. Classification is behavior-first with
71
83
  // weighted keyword scoring as fallback — see categorize().
72
84
  const PASTE_MIN_LEN = 400;
@@ -740,12 +752,23 @@ export function readRouteScan() {
740
752
  /**
741
753
  * Data-driven rescan gate (see constants above). Cheap: one stat() per
742
754
  * transcript file (~32 files on measured data) — a few milliseconds.
755
+ *
756
+ * @param {object|null} cache - readRouteScan() result
757
+ * @param {object} [opts]
758
+ * @param {number} [opts.days] - analysis window
759
+ * @param {boolean} [opts.afterDelegation] - a delegation just finished, so this
760
+ * scan is known to produce different numbers. Skips the byte and age clauses
761
+ * and applies only the anti-thrash floor.
762
+ * @returns {Promise<boolean>}
743
763
  */
744
- export async function shouldRescan(cache, { days = 14 } = {}) {
764
+ export async function shouldRescan(cache, { days = 14, afterDelegation = false } = {}) {
745
765
  if (!cache?.scannedAt) return true;
746
766
  const ts = Date.parse(cache.scannedAt);
747
767
  if (!Number.isFinite(ts)) return true;
748
768
  const age = Date.now() - ts;
769
+ // A delegation just finished, so the numbers this scan produces are known to
770
+ // differ. Only the anti-thrash floor applies — see RESCAN_AFTER_DELEGATION_MS.
771
+ if (afterDelegation) return age >= RESCAN_AFTER_DELEGATION_MS;
749
772
  if (age < RESCAN_MIN_INTERVAL_MS) return false;
750
773
 
751
774
  let total = 0;
package/src/seed-rules.js CHANGED
@@ -243,42 +243,54 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
243
243
  const lines = [];
244
244
  if (lang === 'ko') {
245
245
  lines.push(`[claude-token-saver seed] 아직 등록되지 않은 추천 룰이 ${pending.length}건 있습니다 (패키지 동봉 프리셋).`);
246
- lines.push('**이 세션의 첫 응답에서 다른 작업보다 먼저 1번 룰의 등록 여부를 물어보십시오.** 사용자가 먼저 꺼낸 용건이 있으면 그 답변을 마친 직후에 이어서 묻고, 용건이 끝나기를 기다리지 마십시오. 사용자가 이 안내 문구에 대해 질문할 때까지 미루면 안 됩니다.');
247
- lines.push('사용자에게 **한 건씩 순서대로** 보여 주고 등록할지 물어보십시오. 한 번에 전체 등록을 권하지 말고, 답을 받은 뒤 바로 아래 명령을 실행하십시오.');
248
- lines.push('적용 범위를 물을 때는 선택지를 **글로벌(모든 프로젝트)을 먼저, 이 프로젝트만을 그다음**으로 제시하십시오.');
246
+ lines.push('**이 세션의 첫 응답에서 다른 작업보다 먼저 물어보십시오.** 사용자가 먼저 꺼낸 용건이 있으면 그 답변을 마친 직후에 이어서 묻고, 용건이 끝나기를 기다리지 마십시오. 사용자가 이 안내 문구에 대해 질문할 때까지 미루면 안 됩니다.');
247
+ lines.push(`첫 질문에서 다음 네 선택지를 **이 순서대로** 제시하십시오. ${pending.length}건을 한 건씩 답하는 것은 대부분의 사용자에게 과한 부담이므로, 일괄 등록이 맨 앞에 옵니다.`);
248
+ lines.push(' 1. 전부 글로벌로 등록 — `seed accept all --global`');
249
+ lines.push(' 2. 전부 이 프로젝트만 등록 — `seed accept all --project`');
250
+ lines.push(' 3. 한 건씩 보면서 결정 — 아래 목록을 순서대로 하나씩 묻고, 답을 받는 즉시 해당 명령을 실행합니다');
251
+ lines.push(' 4. 전부 등록하지 않음 — `seed skip all` (다시 묻지 않습니다)');
252
+ lines.push('3번을 고른 경우에만 한 건씩 진행하고, 그때도 적용 범위는 **글로벌(모든 프로젝트)을 먼저, 이 프로젝트만을 그다음**으로 제시하십시오.');
249
253
  if (models.length > 0) {
250
254
  lines.push(` · 모델 피팅 룰 ${models.length}건 — 등록하면 해당 유형 작업이 더 싼 서브에이전트로 위임됩니다.`);
251
255
  for (const s of models) {
252
256
  lines.push(` [${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`);
253
- lines.push(` "${s.ruleText}"`);
257
+ // No wrapping quotes: the composed rule text already opens with the
258
+ // quoted work-type name, so adding a pair produced `""명령 실행 …`.
259
+ lines.push(` ${s.ruleText}`);
254
260
  }
255
261
  }
256
262
  if (fixes.length > 0) {
257
263
  lines.push(` · 래칫 룰 ${fixes.length}건 — 같은 실수를 반복하지 않도록 세션마다 읽히는 규칙입니다.`);
258
264
  for (const s of fixes) lines.push(` [${s.id}] ${s.ruleText}`);
259
265
  }
260
- lines.push(' 등록: claude-token-saver seed accept <id> --global|--project # 적용 범위는 반드시 사용자에게 확인');
261
- lines.push(' 거절: claude-token-saver seed skip <id> # 다시 묻지 않습니다');
262
- lines.push(' (사용자가 "전부 등록"이라고 답하면 `seed accept all --global` 을 쓸 수 있습니다)');
266
+ lines.push(' 일괄: claude-token-saver seed accept all --global|--project # 적용 범위는 반드시 사용자에게 확인');
267
+ lines.push(' 개별: claude-token-saver seed accept <id> --global|--project');
268
+ lines.push(' 거절: claude-token-saver seed skip <id>|all # 다시 묻지 않습니다');
263
269
  } else {
264
270
  lines.push(`[claude-token-saver seed] ${pending.length} recommended rule(s) from the bundled presets are not registered yet.`);
265
- lines.push('**Ask about rule 1 in your very first reply of this session, before anything else.** If the user opened with their own request, answer it and then ask right away; do not wait for their task to finish, and never wait until they ask about this notice.');
266
- lines.push('Walk the user through them **one at a time** and ask about each. Do not push the whole set at once; run the matching command as soon as they answer.');
267
- lines.push('When you ask about the scope, offer **global (all projects) first, this project only second**.');
271
+ lines.push('**Ask in your very first reply of this session, before anything else.** If the user opened with their own request, answer it and then ask right away; do not wait for their task to finish, and never wait until they ask about this notice.');
272
+ lines.push(`Offer these four choices, **in this order**. Answering ${pending.length} rules one by one is more than most users want to do, so registering them all comes first.`);
273
+ lines.push(' 1. Register all, globally — `seed accept all --global`');
274
+ lines.push(' 2. Register all, this project only — `seed accept all --project`');
275
+ lines.push(' 3. Decide one at a time — walk the list below in order, running each command as soon as they answer');
276
+ lines.push(' 4. Register none — `seed skip all` (never offered again)');
277
+ lines.push('Only go rule-by-rule if they pick 3, and even then offer **global (all projects) first, this project only second**.');
268
278
  if (models.length > 0) {
269
279
  lines.push(` · ${models.length} model-fitting rule(s) — once registered, that kind of work goes to a cheaper subagent.`);
270
280
  for (const s of models) {
271
281
  lines.push(` [${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`);
272
- lines.push(` "${s.ruleText}"`);
282
+ // No wrapping quotes: the composed rule text already opens with the
283
+ // quoted work-type name, so adding a pair produced `""명령 실행 …`.
284
+ lines.push(` ${s.ruleText}`);
273
285
  }
274
286
  }
275
287
  if (fixes.length > 0) {
276
288
  lines.push(` · ${fixes.length} ratchet rule(s) — read at the start of every session so the same mistake is not repeated.`);
277
289
  for (const s of fixes) lines.push(` [${s.id}] ${s.ruleText}`);
278
290
  }
279
- lines.push(' register: claude-token-saver seed accept <id> --global|--project # ALWAYS confirm the scope with the user');
280
- lines.push(' decline: claude-token-saver seed skip <id> # never offered again');
281
- lines.push(' (if the user says "register them all", `seed accept all --global` does that)');
291
+ lines.push(' all: claude-token-saver seed accept all --global|--project # ALWAYS confirm the scope with the user');
292
+ lines.push(' one: claude-token-saver seed accept <id> --global|--project');
293
+ lines.push(' decline: claude-token-saver seed skip <id>|all # never offered again');
282
294
  }
283
295
  return lines.join('\n');
284
296
  }
@@ -10,7 +10,16 @@
10
10
  *
11
11
  * State lives next to the other user-data files:
12
12
  * { checkedAt: <ms>, latest: "3.25.0", current: "3.24.0",
13
- * dismissedVersion: "3.25.0"|undefined }
13
+ * dismissedVersion: "3.25.0"|undefined,
14
+ * highlights: ["…", "…"]|undefined, highlightsFor: "3.25.0"|undefined }
15
+ *
16
+ * `highlights` is what the new version actually adds, read from the GitHub
17
+ * release for that tag. A bare "v3.24 → v3.25 is available" gives the user
18
+ * nothing to weigh: the answer to "should I upgrade" is in what changed, and
19
+ * asking them to go find that themselves is how an upgrade notice becomes
20
+ * something to dismiss. The release body is fetched on the same detached,
21
+ * once-a-day pass as the version check, and its absence never blocks the
22
+ * notice — a version number alone is still worth saying.
14
23
  *
15
24
  * Opt out with CTS_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER (the de-facto
16
25
  * standard env var — anyone who set it for other CLIs meant us too).
@@ -102,6 +111,9 @@ export function updateStatus(currentVersion) {
102
111
  // means "ask me again in five minutes", forever.
103
112
  dismissed: available && s.dismissedVersion === latest,
104
113
  stale: age >= CHECK_INTERVAL_MS,
114
+ // Only when they describe THIS version. A cache written for an earlier
115
+ // release would otherwise sell the wrong upgrade.
116
+ highlights: Array.isArray(s.highlights) && s.highlightsFor === latest ? s.highlights : [],
105
117
  };
106
118
  }
107
119
 
@@ -143,6 +155,116 @@ export function cliEntryPath() {
143
155
  return join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cli.js');
144
156
  }
145
157
 
158
+ // Release notes come from the repository's own releases, because the registry
159
+ // does not carry them: `/latest` answers with package metadata and no changelog
160
+ // of any kind. Unauthenticated reads are allowed at 60/hour, and this runs once
161
+ // a day behind the same interval as the version check, so the budget is ample.
162
+ const RELEASES_API = 'https://api.github.com/repos/rootstudioyaml/sprag/releases/tags';
163
+ const MAX_HIGHLIGHTS = 3;
164
+ const MAX_HIGHLIGHT_LEN = 110;
165
+
166
+ /**
167
+ * The headline items of a release body, as short single lines.
168
+ *
169
+ * Release notes are written for people reading a web page: headings, prose
170
+ * paragraphs, links, and bold runs. A session-start notice has one line per
171
+ * item and no room for any of that, so this keeps only what reads as an item
172
+ * of a list and strips it to plain text. Anything that survives is a sentence
173
+ * the user can judge an upgrade by; anything that does not is left behind
174
+ * rather than truncated into nonsense.
175
+ *
176
+ * @param {string} body - the release body as published
177
+ * @returns {string[]} at most MAX_HIGHLIGHTS lines, or [] when nothing fits
178
+ */
179
+ export function releaseHighlights(body) {
180
+ const text = String(body || '');
181
+ if (!text.trim()) return [];
182
+ const out = [];
183
+ for (const raw of text.split(/\r?\n/)) {
184
+ const line = raw.trim();
185
+ // Bullets only. A heading names a section, and a prose paragraph in a
186
+ // release body is usually the rationale rather than the change itself.
187
+ const m = /^[-*+]\s+(.*)$/.exec(line);
188
+ if (!m) continue;
189
+ // Markup goes first, so the sentence split below sees the text a reader
190
+ // would: `**Bash writes are checked.**` is one sentence, not a bold run.
191
+ // One exception has to be caught before that: a stop followed straight by a
192
+ // code span (`…checked.\`install\` migrates…`) is a sentence boundary, and
193
+ // stripping the backtick first leaves a lowercase letter the split ignores.
194
+ // A space there is what the author meant, and it costs nothing if not.
195
+ let item = m[1]
196
+ .replace(/([.。])(`)/g, '$1 $2')
197
+ .replace(/`([^`]*)`/g, '$1') // code spans read fine as plain text
198
+ .replace(/\*\*([^*]*)\*\*/g, '$1') // bold
199
+ .replace(/\*([^*]*)\*/g, '$1') // italics
200
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links keep their label
201
+ .replace(/\s+/g, ' ')
202
+ .trim();
203
+ if (!item) continue;
204
+ // Release notes lead with the change and follow with the reasoning, often
205
+ // for several sentences. The first sentence is the change, so that is what
206
+ // a one-line notice keeps — cutting at a character count instead lands
207
+ // mid-clause and reads as a broken string rather than a short one.
208
+ //
209
+ // The break does not require a space after the stop: "…checked.The matcher…"
210
+ // is a typo a release body can carry, and requiring one there merged the two
211
+ // sentences into a single over-long line. A capital letter or a Hangul
212
+ // syllable directly after the stop is enough to say a new sentence started;
213
+ // a digit is not, so "0.5s" and "v3.45.0" stay intact.
214
+ //
215
+ // Abbreviations are excluded by name. "Fixed e.g. the matcher" would
216
+ // otherwise end after two words, which is worse than a long line: it reads
217
+ // as a complete thought and is not one. This list covers what actually turns
218
+ // up in release prose rather than trying to be exhaustive.
219
+ // `etc.` is deliberately absent: it ends a sentence as often as it continues
220
+ // one, so either choice is wrong half the time and the wrong one here merges
221
+ // two sentences rather than truncating one. Merging is the milder failure,
222
+ // and the length cap below bounds it.
223
+ const ABBREV = /\b(?:e\.g|i\.e|vs|cf|Dr|Mr|Ms|St|approx|no)\.$/i;
224
+ const parts = item.split(/(?<=[.。])(?:\s+|(?=[A-Z가-힣]))/);
225
+ let first = parts[0];
226
+ for (let k = 1; k < parts.length && ABBREV.test(first); k += 1) {
227
+ // The stop belonged to an abbreviation, so the sentence continues.
228
+ first = `${first} ${parts[k]}`;
229
+ }
230
+ item = first || item;
231
+ // Some first sentences are themselves a paragraph. Those get cut, but at a
232
+ // word boundary and with the ellipsis that says so.
233
+ if (item.length > MAX_HIGHLIGHT_LEN) {
234
+ const cut = item.slice(0, MAX_HIGHLIGHT_LEN);
235
+ const space = cut.lastIndexOf(' ');
236
+ item = (space > 40 ? cut.slice(0, space) : cut).replace(/[,;:—\-]$/, '').trim() + '…';
237
+ }
238
+ out.push(item);
239
+ if (out.length >= MAX_HIGHLIGHTS) break;
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /**
245
+ * Fetch the highlights for one version. Returns [] on any failure: the notice
246
+ * is worth showing with a bare version number, so a missing release, a rate
247
+ * limit, or an offline machine must not cost the user the notice itself.
248
+ */
249
+ export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
250
+ const controller = new AbortController();
251
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
252
+ try {
253
+ const res = await fetch(`${RELEASES_API}/v${version}`, {
254
+ signal: controller.signal,
255
+ headers: { accept: 'application/vnd.github+json' },
256
+ });
257
+ if (!res.ok) throw new Error(`releases responded ${res.status}`);
258
+ const body = await res.json();
259
+ return releaseHighlights(body && body.body);
260
+ } catch (e) {
261
+ debug('update-check:highlights', e);
262
+ return [];
263
+ } finally {
264
+ clearTimeout(timer);
265
+ }
266
+ }
267
+
146
268
  /**
147
269
  * Actually hit the registry and persist the answer. Only the detached child
148
270
  * and the explicit `update-check --refresh` command call this.
@@ -168,6 +290,21 @@ export async function refreshUpdateState(currentVersion) {
168
290
  if (prev.dismissedVersion && isNewer(latest, prev.dismissedVersion)) {
169
291
  delete next.dismissedVersion;
170
292
  }
293
+ // What the new version adds, so the notice can answer "why would I".
294
+ // Only worth a second request when there is actually an upgrade to describe,
295
+ // and only when we do not already hold the notes for that exact version.
296
+ if (isNewer(latest, currentVersion) && prev.highlightsFor !== latest) {
297
+ const highlights = await fetchHighlights(latest);
298
+ if (highlights.length) {
299
+ next.highlights = highlights;
300
+ next.highlightsFor = latest;
301
+ } else {
302
+ // Drop notes belonging to an older version rather than showing them
303
+ // against this one.
304
+ delete next.highlights;
305
+ delete next.highlightsFor;
306
+ }
307
+ }
171
308
  writeUpdateState(next);
172
309
  return { ok: true, latest };
173
310
  } catch (e) {