sprag-cli 3.44.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,11 +66,13 @@ 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)에 정리했습니다.
73
73
 
74
+ JetBrains IDE 내장 터미널에서는 이모지 대신 한 칸 글리프로 표시합니다(`◉ Cache hit 98.8% · ◧ Ctx 47% of 1M`). IDE 기본 폰트에 이모지 글리프가 없어서 그대로 두면 `Cache expires 4:545` 처럼 이전 프레임 문자가 남기 때문입니다. 자세한 사정과 `sprag mode narrow` · `mode icon-force` 사용법은 [statusline 문서의 라벨 모드](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.md#라벨-모드)에 있습니다.
75
+
74
76
  ## 실제 효과: 도입 전후 리포트
75
77
 
76
78
  ![sprag: harness와 ratchet 도입 효과](./docs/harness-impact.png)
package/README.md CHANGED
@@ -72,11 +72,13 @@ 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)에 정리했습니다.
79
79
 
80
+ JetBrains IDE 내장 터미널에서는 이모지 대신 한 칸 글리프로 표시합니다(`◉ Cache hit 98.8% · ◧ Ctx 47% of 1M`). IDE 기본 폰트에 이모지 글리프가 없어서 그대로 두면 `Cache expires 4:545` 처럼 이전 프레임 문자가 남기 때문입니다. 자세한 사정과 `sprag mode narrow` · `mode icon-force` 사용법은 [statusline 문서의 라벨 모드](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.md#라벨-모드)에 있습니다.
81
+
80
82
  ## 실제 효과: 도입 전후 리포트
81
83
 
82
84
  ![sprag: harness와 ratchet 도입 효과](./docs/harness-impact.png)
@@ -209,11 +211,13 @@ The measured −18.6% comes from the harness and ratchet; routing and conversion
209
211
 
210
212
  ```
211
213
  🔀 Routing saved $2.09 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57
212
- 🚨 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
213
215
  ```
214
216
 
215
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).
216
218
 
219
+ Inside a JetBrains IDE terminal the chips render as single-cell glyphs instead of emoji (`◉ Cache hit 98.8% · ◧ Ctx 47% of 1M`). The IDE's default font has no emoji glyphs, so leaving them in leaves debris from the previous frame behind — `Cache expires 4:545` and the like. [Label modes](./docs/STATUSLINE.md#label-modes) covers the detail, plus `sprag mode narrow` and `mode icon-force`.
220
+
217
221
  ## Real-world impact — before/after report
218
222
 
219
223
  ![sprag — harness + ratchet adoption impact](./docs/harness-impact.png)
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
 
@@ -397,9 +402,10 @@ async function main() {
397
402
  process.exit(1);
398
403
  }
399
404
  const { formatReport } = await import('../src/formatters/statusline.js');
400
- const isIcon = hasFlag('--icon')
401
- ? true
402
- : (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon);
405
+ // Same resolver as the real statusline path: a demo that renders emoji
406
+ // where the real line would not is worse than no demo at all.
407
+ const { resolveLabelMode } = await import('../src/statusline-mode.js');
408
+ const { mode: labelMode } = resolveLabelMode({ hasFlag, cfg });
403
409
  const isVerbose = hasFlag('--verbose')
404
410
  ? true
405
411
  : (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
@@ -413,7 +419,7 @@ async function main() {
413
419
  color: colorOk,
414
420
  verbose: isVerbose,
415
421
  timer: showTimer,
416
- mode: isIcon ? 'icon' : 'text',
422
+ mode: labelMode,
417
423
  segments,
418
424
  singleLine: hasFlag('--single-line'),
419
425
  });
@@ -480,9 +486,11 @@ async function main() {
480
486
  const { statuslineDefaults } = await import('../src/config.js');
481
487
  const cfg = statuslineDefaults();
482
488
  const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
483
- const isIcon = hasFlag('--icon')
484
- ? true
485
- : (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon);
489
+ // Third caller of the same decision — the no-session line has to agree
490
+ // with the populated one, or IntelliJ users watch icons appear and
491
+ // disappear as the analysis window empties and fills.
492
+ const { resolveLabelMode } = await import('../src/statusline-mode.js');
493
+ const { mode: labelMode } = resolveLabelMode({ hasFlag, cfg });
486
494
  const stdinJson = readStdinJson();
487
495
  const caps = extractCaps(stdinJson);
488
496
  const model = extractModel(stdinJson);
@@ -494,7 +502,7 @@ async function main() {
494
502
  }
495
503
  console.log(formatNoSession(
496
504
  { caps, model, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
497
- { color: colorOk, mode: isIcon ? 'icon' : 'text' },
505
+ { color: colorOk, mode: labelMode },
498
506
  ));
499
507
  return;
500
508
  }
@@ -749,21 +757,11 @@ async function main() {
749
757
  const { statuslineDefaults } = await import('../src/config.js');
750
758
  const cfg = statuslineDefaults();
751
759
 
752
- // IntelliJ's Claude Code plugin renders the statusline through a custom
753
- // widget that fuses prior frames with the new one when emoji are present,
754
- // producing garbage like "59:548" that no ANSI escape can clean up
755
- // (verified: emitting the same output directly into JediTerm renders
756
- // cleanly, so the bug is in the plugin's render path, not the terminal).
757
- // Force text mode unconditionally inside IntelliJ — even past an explicit
758
- // `--icon` flag, since wrappers commonly hardcode `--icon` and the user
759
- // can't easily edit them; icon mode is just broken there.
760
- const isIntelliJ = process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm';
761
- // CLI flags take precedence; otherwise fall back to persisted config.
762
- const isIcon = isIntelliJ
763
- ? false
764
- : (hasFlag('--icon')
765
- ? true
766
- : (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon));
760
+ // Icon vs text is decided in one module shared with the `--demo` path, so
761
+ // the two cannot drift apart again. It also owns the IntelliJ downgrade and
762
+ // the escape hatch out of it (SPRAG_ICON=force / `sprag mode icon-force`).
763
+ const { resolveLabelMode } = await import('../src/statusline-mode.js');
764
+ const { mode: labelMode } = resolveLabelMode({ hasFlag, cfg });
767
765
  const isVerbose = hasFlag('--verbose')
768
766
  ? true
769
767
  : (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
@@ -779,7 +777,7 @@ async function main() {
779
777
  color: colorOk,
780
778
  verbose: isVerbose,
781
779
  timer: showTimer,
782
- mode: isIcon ? 'icon' : 'text',
780
+ mode: labelMode,
783
781
  segments,
784
782
  // macOS builds of Claude Code have rendered only the first line of a
785
783
  // multi-line statusline in some versions (anthropics/claude-code#35176)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.44.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": [
@@ -21,7 +21,10 @@
21
21
  "agent": "haiku-runner",
22
22
  "example": "npm test 돌려봐",
23
23
  "exampleEn": "run npm test",
24
- "budget": { "calls": 8, "out": 1500 }
24
+ "budget": {
25
+ "calls": 8,
26
+ "out": 1500
27
+ }
25
28
  },
26
29
  {
27
30
  "id": "run-t1",
@@ -32,7 +35,10 @@
32
35
  "agent": "sonnet",
33
36
  "example": "빌드하고 실패한 테스트만 보여줘",
34
37
  "exampleEn": "build it and show only the failing tests",
35
- "budget": { "calls": null, "out": 6000 }
38
+ "budget": {
39
+ "calls": null,
40
+ "out": 6000
41
+ }
36
42
  },
37
43
  {
38
44
  "id": "explore-t2",
@@ -43,7 +49,10 @@
43
49
  "agent": "haiku-explore",
44
50
  "example": "이 설정값 어느 파일에 있어?",
45
51
  "exampleEn": "which file holds this config value?",
46
- "budget": { "calls": 8, "out": 1500 }
52
+ "budget": {
53
+ "calls": 8,
54
+ "out": 1500
55
+ }
47
56
  },
48
57
  {
49
58
  "id": "explore-t1",
@@ -54,7 +63,10 @@
54
63
  "agent": "sonnet",
55
64
  "example": "이 함수를 호출하는 곳 전부 찾아줘",
56
65
  "exampleEn": "find every caller of this function",
57
- "budget": { "calls": null, "out": 6000 }
66
+ "budget": {
67
+ "calls": null,
68
+ "out": 6000
69
+ }
58
70
  },
59
71
  {
60
72
  "id": "check-t2",
@@ -65,7 +77,10 @@
65
77
  "agent": "haiku-explore",
66
78
  "example": "지금 배포된 버전이 뭔지 확인해줘",
67
79
  "exampleEn": "check which version is deployed right now",
68
- "budget": { "calls": 8, "out": 1500 }
80
+ "budget": {
81
+ "calls": 8,
82
+ "out": 1500
83
+ }
69
84
  },
70
85
  {
71
86
  "id": "check-t1",
@@ -76,7 +91,10 @@
76
91
  "agent": "sonnet",
77
92
  "example": "마이그레이션이 실제로 적용됐는지 확인해줘",
78
93
  "exampleEn": "verify the migration actually applied",
79
- "budget": { "calls": null, "out": 6000 }
94
+ "budget": {
95
+ "calls": null,
96
+ "out": 6000
97
+ }
80
98
  },
81
99
  {
82
100
  "id": "paste-t2",
@@ -87,7 +105,10 @@
87
105
  "agent": "haiku-explore",
88
106
  "example": "이 에러 로그는 무슨 뜻이야?",
89
107
  "exampleEn": "what does this error log mean?",
90
- "budget": { "calls": 8, "out": 1500 }
108
+ "budget": {
109
+ "calls": 8,
110
+ "out": 1500
111
+ }
91
112
  },
92
113
  {
93
114
  "id": "paste-t1",
@@ -98,7 +119,10 @@
98
119
  "agent": "sonnet",
99
120
  "example": "이 스택트레이스에서 어느 모듈이 죽은 건지 짚어줘",
100
121
  "exampleEn": "point out which module failed in this stack trace",
101
- "budget": { "calls": null, "out": 8000 }
122
+ "budget": {
123
+ "calls": null,
124
+ "out": 8000
125
+ }
102
126
  },
103
127
  {
104
128
  "id": "read-t2",
@@ -109,7 +133,24 @@
109
133
  "agent": "haiku-explore",
110
134
  "example": "이 README 요약해줘",
111
135
  "exampleEn": "summarize this README",
112
- "budget": { "calls": 8, "out": 1900 }
136
+ "budget": {
137
+ "calls": 8,
138
+ "out": 1900
139
+ }
140
+ },
141
+ {
142
+ "id": "read-t1",
143
+ "tier": "T1",
144
+ "category": "read",
145
+ "label": "읽기·요약·설명",
146
+ "labelEn": "reading / summarizing / explaining",
147
+ "agent": "sonnet",
148
+ "example": "이 함수가 하는 일을 단계별로 정리해줘",
149
+ "exampleEn": "walk through what this function does, step by step",
150
+ "budget": {
151
+ "calls": null,
152
+ "out": 8000
153
+ }
113
154
  }
114
155
  ]
115
156
  }
@@ -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
+ }
@@ -22,10 +22,22 @@ export async function run({ hasFlag }) {
22
22
  process.exit(1);
23
23
  }
24
24
  const ctx = readStdinJson() || {};
25
+ const parts = [];
25
26
  try {
26
27
  const { runBrief } = await import('../brief.js');
27
28
  const out = await runBrief({ sessionId: ctx.session_id, transcriptPath: ctx.transcript_path });
28
- if (out) console.log(out);
29
+ if (out) parts.push(out);
29
30
  } catch (e) { debug('brief:hook', e); /* briefing is best-effort — never block a prompt */ }
31
+ // State the matching delegation rule as a fact about this request. Registered
32
+ // rules were going unused because applying one meant scanning a list and
33
+ // resolving it against a general "don't spawn subagents" instruction; naming
34
+ // the match here removes both steps. Silent unless it is sure — see
35
+ // src/route-inject.js.
36
+ try {
37
+ const { routeHint } = await import('../route-inject.js');
38
+ const hint = routeHint(ctx.prompt);
39
+ if (hint) parts.push(hint);
40
+ } catch (e) { debug('brief:route-hint', e); }
41
+ if (parts.length) console.log(parts.join('\n'));
30
42
  return;
31
43
  }
@@ -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;
@@ -10,12 +10,28 @@
10
10
  export async function run({ args }) {
11
11
  const { applyMode, loadConfig, configPath, statuslineDefaults, userLanguage, VALID_KEYWORDS } =
12
12
  await import('../config.js');
13
+ const { resolveLabelMode, isJetBrainsTerminal } = await import('../statusline-mode.js');
13
14
  const words = args.slice(1);
14
15
  if (words.length === 0) {
15
16
  const eff = statuslineDefaults();
16
17
  const raw = loadConfig();
17
18
  console.log('Statusline (effective):');
18
- console.log(` icon: ${eff.icon}`);
19
+ console.log(` labels: ${eff.labels}`);
20
+ // What renders can differ from the stored preference: inside IntelliJ the
21
+ // emoji set steps down to narrow glyphs, because the IDE font lacks the
22
+ // emoji and the fallback breaks the cell grid. Saying so here is the
23
+ // difference between a two-second answer and a hunt through the source for
24
+ // why `--icon` appeared to do nothing.
25
+ const { mode: labelMode, reason } = resolveLabelMode({ cfg: eff });
26
+ if (labelMode !== eff.labels) {
27
+ const why = reason === 'intellij-narrow'
28
+ ? ' (IntelliJ: emoji garble in the IDE font. `sprag mode icon-force` keeps them anyway)'
29
+ : reason === 'intellij-forced'
30
+ ? ' (icon-force)'
31
+ : '';
32
+ console.log(` renders: ${labelMode}${why}`);
33
+ }
34
+ console.log(` icon-force: ${eff.iconForce}`);
19
35
  console.log(` verbose: ${eff.verbose}`);
20
36
  console.log(` timer: ${eff.timer}`);
21
37
  console.log(` color: ${eff.color}`);
@@ -40,7 +56,11 @@ export async function run({ args }) {
40
56
  }
41
57
  const eff = statuslineDefaults();
42
58
  console.log(`Updated: ${applied.join(', ')}`);
43
- console.log(`Now: icon=${eff.icon} verbose=${eff.verbose} timer=${eff.timer} color=${eff.color} window=${eff.windowLabel} language=${userLanguage()}`);
59
+ console.log(`Now: labels=${eff.labels} icon-force=${eff.iconForce} verbose=${eff.verbose} timer=${eff.timer} color=${eff.color} window=${eff.windowLabel} language=${userLanguage()}`);
60
+ const { mode: renders } = resolveLabelMode({ cfg: eff });
61
+ if (renders !== eff.labels) {
62
+ console.log(`Renders as: ${renders}${isJetBrainsTerminal() ? ' (IntelliJ detected)' : ''}`);
63
+ }
44
64
  console.log('Statusline picks up the change on the next refresh (~1s).');
45
65
  return;
46
66
  }
@@ -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');
package/src/config.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { userDataDir } from './paths.js';
15
+ import { labelPreference } from './statusline-mode.js';
15
16
 
16
17
  const CONFIG_DIR = userDataDir();
17
18
  const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
@@ -38,8 +39,17 @@ export function saveConfig(cfg) {
38
39
  // Orthogonal — `mode icon verbose` flips both without resetting the rest.
39
40
  // Statusline-only toggles. Stored under `cfg.statusline`.
40
41
  const KEYWORDS = {
41
- icon: { key: 'icon', value: true },
42
- text: { key: 'icon', value: false },
42
+ // Label style is one setting with three values, so the keywords write one key
43
+ // rather than a pair of booleans that could contradict each other.
44
+ icon: { key: 'labels', value: 'icon' },
45
+ narrow: { key: 'labels', value: 'narrow' },
46
+ text: { key: 'labels', value: 'text' },
47
+ // Opt back into icons inside IntelliJ, where they are downgraded by default
48
+ // for the plugin's statusline widget. Scoped to that guard only: outside
49
+ // IntelliJ it changes nothing, because `icon` / `text` already decide there.
50
+ // See src/statusline-mode.js.
51
+ 'icon-force': { key: 'iconForce', value: true },
52
+ 'no-icon-force': { key: 'iconForce', value: false },
43
53
  verbose: { key: 'verbose', value: true },
44
54
  compact: { key: 'verbose', value: false },
45
55
  timer: { key: 'timer', value: true },
@@ -171,8 +181,17 @@ export function statuslineDefaults() {
171
181
  } else {
172
182
  windowHours = 24; // default: last 1 day
173
183
  }
184
+ // Three-valued label style, with the legacy boolean honored for configs
185
+ // written before it existed. labelPreference() owns that fallback so the
186
+ // resolver and this function cannot disagree about what an old config means.
187
+ const labels = labelPreference(s);
174
188
  return {
175
- icon: s.icon !== false,
189
+ labels,
190
+ // Kept for callers that only ask "emoji or not".
191
+ icon: labels === 'icon',
192
+ // Off unless asked for: narrow labels are the IntelliJ default now, and only
193
+ // the user knows whether they would rather have garbled emoji than glyphs.
194
+ iconForce: s.iconForce === true,
176
195
  verbose: s.verbose !== false,
177
196
  timer: s.timer !== false,
178
197
  color: s.color !== false,