sprag-cli 3.45.0 → 3.46.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/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
 
@@ -1,9 +1,9 @@
1
1
  # Claude Code statusline (Windows PowerShell version)
2
2
  # Mirrors the POSIX sh script — prints "user@host:cwd" then appends
3
- # claude-token-saver output as a second segment.
3
+ # sprag output as a second segment.
4
4
  #
5
5
  # Install:
6
- # 1) npm install -g claude-token-saver
6
+ # 1) npm install -g sprag-cli
7
7
  # 2) Save this file as: %USERPROFILE%\.claude\statusline-command.ps1
8
8
  # 3) In %USERPROFILE%\.claude\settings.json add:
9
9
  # {
@@ -30,14 +30,20 @@ Write-Host -NoNewline "$esc[01;32m$env:USERNAME@$env:COMPUTERNAME$esc[00m`:$esc[
30
30
  # 2) cache monitor (appended). Separator " | ". Falls back silently.
31
31
  Write-Host -NoNewline " $esc[90m|$esc[00m "
32
32
 
33
- $cacheMonitor = Get-Command claude-token-saver -ErrorAction SilentlyContinue
33
+ # `claude-token-saver` is the older binary name, still installed by the current
34
+ # package; the npx fallback names `sprag-cli`, since the old package name is
35
+ # deprecated on npm and would fetch a version that stopped receiving releases.
36
+ $cacheMonitor = Get-Command sprag -ErrorAction SilentlyContinue
37
+ if (-not $cacheMonitor) {
38
+ $cacheMonitor = Get-Command claude-token-saver -ErrorAction SilentlyContinue
39
+ }
34
40
  if ($cacheMonitor) {
35
41
  try {
36
- & claude-token-saver --statusline --icon 2>$null
42
+ & $cacheMonitor.Name --statusline --icon 2>$null
37
43
  } catch { }
38
44
  } else {
39
45
  # fallback: npx (first run downloads the package; subsequent runs are warm)
40
46
  try {
41
- & npx --yes claude-token-saver@latest --statusline --icon 2>$null
47
+ & npx --yes sprag-cli@latest --statusline --icon 2>$null
42
48
  } catch { }
43
49
  }
@@ -1,9 +1,9 @@
1
1
  #!/bin/sh
2
2
  # Claude Code statusline (POSIX sh — works on macOS, Linux, and WSL)
3
- # Prints "user@host:cwd" then appends claude-token-saver as a second segment.
3
+ # Prints "user@host:cwd" then appends sprag as a second segment.
4
4
  #
5
5
  # Install:
6
- # 1) npm install -g claude-token-saver
6
+ # 1) npm install -g sprag-cli
7
7
  # 2) Save this file as: ~/.claude/statusline-command.sh
8
8
  # chmod +x ~/.claude/statusline-command.sh (optional)
9
9
  # 3) In ~/.claude/settings.json:
@@ -29,8 +29,13 @@ printf '\033[01;32m%s@%s\033[00m:\033[01;34m%s\033[00m' "$(whoami)" "$(hostname
29
29
 
30
30
  # 2) cache monitor (appended). Separator " | ". Falls back silently.
31
31
  printf ' \033[90m|\033[00m '
32
- if command -v claude-token-saver >/dev/null 2>&1; then
32
+ # `claude-token-saver` is the older binary name, still installed by the current
33
+ # package; the npx fallback names `sprag-cli`, since the old package name is
34
+ # deprecated on npm and would fetch a version that stopped receiving releases.
35
+ if command -v sprag >/dev/null 2>&1; then
36
+ sprag --statusline --icon 2>/dev/null || true
37
+ elif command -v claude-token-saver >/dev/null 2>&1; then
33
38
  claude-token-saver --statusline --icon 2>/dev/null || true
34
39
  else
35
- npx --yes claude-token-saver@latest --statusline --icon 2>/dev/null || true
40
+ npx --yes sprag-cli@latest --statusline --icon 2>/dev/null || true
36
41
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.45.0",
3
+ "version": "3.46.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": {
@@ -8,12 +8,14 @@
8
8
  "claude-token-saver": "bin/cli.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --test",
11
+ "deploy": "node scripts/deploy.mjs",
12
12
  "docs:statusline": "node scripts/docs-statusline.mjs",
13
13
  "postinstall": "node bin/cli.js install || true",
14
+ "prepublishOnly": "node scripts/sync-readme-ko.mjs",
14
15
  "publish:legacy": "node scripts/publish-legacy.mjs",
16
+ "release:notes": "node scripts/release-notes.mjs",
15
17
  "sync:ko": "node scripts/sync-readme-ko.mjs",
16
- "prepublishOnly": "node scripts/sync-readme-ko.mjs"
18
+ "test": "node --test"
17
19
  },
18
20
  "files": [
19
21
  "bin/",
@@ -0,0 +1,70 @@
1
+ /**
2
+ * changelog — read one version's section out of CHANGELOG.md, and say whether a
3
+ * notes draft is still waiting on a person.
4
+ *
5
+ * Separate from scripts/release-notes.mjs, which is a CLI: importing that file
6
+ * runs its argument parsing and exits, so the extraction it depends on cannot be
7
+ * tested through it. The boundary logic is the part worth pinning — a section
8
+ * pulled one line short drops the release's last item, and one line long carries
9
+ * the previous release's first, and the upgrade offer shows either without
10
+ * complaint.
11
+ *
12
+ * The draft marker lives here for a reason. Two scripts gate on it — the one that
13
+ * writes drafts and the one that deploys — and they used to carry the string
14
+ * separately. Renaming it in one left the other looking for text that no longer
15
+ * existed, and a gate that greps for an absent string does not fail: it passes,
16
+ * and an unreviewed draft goes out as the release. One definition, imported.
17
+ */
18
+
19
+ /**
20
+ * The body of one version's section, heading excluded.
21
+ *
22
+ * @param {string} changelog - CHANGELOG.md contents
23
+ * @param {string} version - e.g. "3.45.0"
24
+ * @returns {string|null} the section text, or null when that version has none
25
+ */
26
+ export function sectionFor(changelog, version) {
27
+ const lines = String(changelog).split(/\r?\n/);
28
+ // The version has to match to its end: without the boundary, "3.42.0" would
29
+ // also match the "### v3.42.7" heading and publish the wrong notes.
30
+ const head = new RegExp(`^### v${String(version).replace(/\./g, '\\.')}(\\s|$|\\()`);
31
+ const start = lines.findIndex((l) => head.test(l));
32
+ if (start < 0) return null;
33
+ const out = [];
34
+ for (let i = start + 1; i < lines.length; i += 1) {
35
+ // The next version heading ends this section, and so does a shallower one:
36
+ // "## All releases" is a container, not part of any release.
37
+ if (/^### v/.test(lines[i]) || /^## /.test(lines[i])) break;
38
+ out.push(lines[i]);
39
+ }
40
+ return out.join('\n').trim();
41
+ }
42
+
43
+ /**
44
+ * The line `release-notes.mjs` writes at the top of a fresh draft, and the line
45
+ * every gate refuses on. A draft still carrying it has not been through a person.
46
+ */
47
+ export const DRAFT_MARKER =
48
+ '<!-- REVIEW: edit this draft, add the English half, then delete this line -->';
49
+
50
+ /** Heading that opens the Korean half of a bilingual release body. */
51
+ export const KO_HEADING = '## 한국어';
52
+
53
+ /**
54
+ * The opening of the marker, which is what the gate actually matches. Spelled out
55
+ * rather than sliced off `DRAFT_MARKER`, so that editing the marker's front does
56
+ * not silently change what is being tested for.
57
+ */
58
+ const REVIEW_OPENING = '<!-- REVIEW:';
59
+
60
+ /**
61
+ * Whether a draft still needs review. Matches on the `<!-- REVIEW:` opening
62
+ * rather than the whole line, so editing the wording of the instruction does not
63
+ * quietly disarm the gate.
64
+ *
65
+ * @param {string} draft - the draft file's contents
66
+ * @returns {boolean} true while the draft is unreviewed
67
+ */
68
+ export function needsReview(draft) {
69
+ return String(draft || '').includes(REVIEW_OPENING);
70
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * cli-name — the name this tool tells the user to type.
3
+ *
4
+ * One definition, because the alternative was measured: the marker string in the
5
+ * release scripts lived in two files, one was renamed, and the gate that grepped
6
+ * for the other silently stopped gating. A command name printed for the user to
7
+ * copy fails more quietly still — nothing errors, the user simply types a name
8
+ * that is not the package they installed.
9
+ *
10
+ * `sprag-cli` is the package; `claude-token-saver` is the deprecated name it
11
+ * shipped under, and both binaries are still in `package.json`'s `bin`, so the
12
+ * old name keeps working for anyone whose settings or notes carry it.
13
+ *
14
+ * NOT the same thing as the on-disk identifiers. The state directory, the harness
15
+ * block markers, and the skill directory are all still spelled with the old name
16
+ * on purpose: renaming those would orphan existing users' ledgers and stop the
17
+ * harness markers matching text already in their CLAUDE.md. Those are addresses,
18
+ * not instructions, and an address only has to be stable.
19
+ */
20
+
21
+ /** The command name to print in anything a user is expected to type or read. */
22
+ export const CLI_NAME = 'sprag';
23
+
24
+ /** The deprecated name. Still a working binary; recognised, never printed. */
25
+ export const LEGACY_CLI_NAME = 'claude-token-saver';
@@ -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');
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Subcommand: uninstall — take the integration back out of ~/.claude.
3
3
  *
4
- * claude-token-saver uninstall # hooks, statusline, skill
5
- * claude-token-saver uninstall --purge # the above plus recorded state
4
+ * sprag uninstall # hooks, statusline, skill
5
+ * sprag uninstall --purge # the above plus recorded state
6
6
  *
7
7
  * Recorded savings are kept by default. Someone removing an integration is
8
8
  * usually not asking to throw away the ledger that says what it saved, and
@@ -12,6 +12,7 @@
12
12
  export async function run({ hasFlag, args = [] }) {
13
13
  const { uninstallAll } = await import('../installer.js');
14
14
  const { userLanguage } = await import('../config.js');
15
+ const { CLI_NAME } = await import('../cli-name.js');
15
16
  const lang = userLanguage();
16
17
  const purge = hasFlag('--purge') || args.includes('--purge');
17
18
 
@@ -36,7 +37,7 @@ export async function run({ hasFlag, args = [] }) {
36
37
  }
37
38
  if (!purge) {
38
39
  console.log(lang === 'ko'
39
- ? ' 기록된 절감액과 설정까지 지우려면 `claude-token-saver uninstall --purge` 를 실행하십시오.'
40
- : ' To remove recorded savings and settings too: `claude-token-saver uninstall --purge`.');
40
+ ? ` 기록된 절감액과 설정까지 지우려면 \`${CLI_NAME} uninstall --purge\` 를 실행하십시오.`
41
+ : ` To remove recorded savings and settings too: \`${CLI_NAME} uninstall --purge\`.`);
41
42
  }
42
43
  }
@@ -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.