sprag-cli 3.44.0 → 3.45.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
@@ -71,6 +71,8 @@ gemini-2.5-pro 57.9% $734 ▰▰▰▰▰▰▰▰▰▰▰
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
@@ -77,6 +77,8 @@ gemini-2.5-pro 57.9% $734 ▰▰▰▰▰▰▰▰▰▰▰
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)
@@ -214,6 +216,8 @@ The measured −18.6% comes from the harness and ratchet; routing and conversion
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
@@ -397,9 +397,10 @@ async function main() {
397
397
  process.exit(1);
398
398
  }
399
399
  const { formatReport } = await import('../src/formatters/statusline.js');
400
- const isIcon = hasFlag('--icon')
401
- ? true
402
- : (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon);
400
+ // Same resolver as the real statusline path: a demo that renders emoji
401
+ // where the real line would not is worse than no demo at all.
402
+ const { resolveLabelMode } = await import('../src/statusline-mode.js');
403
+ const { mode: labelMode } = resolveLabelMode({ hasFlag, cfg });
403
404
  const isVerbose = hasFlag('--verbose')
404
405
  ? true
405
406
  : (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
@@ -413,7 +414,7 @@ async function main() {
413
414
  color: colorOk,
414
415
  verbose: isVerbose,
415
416
  timer: showTimer,
416
- mode: isIcon ? 'icon' : 'text',
417
+ mode: labelMode,
417
418
  segments,
418
419
  singleLine: hasFlag('--single-line'),
419
420
  });
@@ -480,9 +481,11 @@ async function main() {
480
481
  const { statuslineDefaults } = await import('../src/config.js');
481
482
  const cfg = statuslineDefaults();
482
483
  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);
484
+ // Third caller of the same decision — the no-session line has to agree
485
+ // with the populated one, or IntelliJ users watch icons appear and
486
+ // disappear as the analysis window empties and fills.
487
+ const { resolveLabelMode } = await import('../src/statusline-mode.js');
488
+ const { mode: labelMode } = resolveLabelMode({ hasFlag, cfg });
486
489
  const stdinJson = readStdinJson();
487
490
  const caps = extractCaps(stdinJson);
488
491
  const model = extractModel(stdinJson);
@@ -494,7 +497,7 @@ async function main() {
494
497
  }
495
498
  console.log(formatNoSession(
496
499
  { caps, model, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
497
- { color: colorOk, mode: isIcon ? 'icon' : 'text' },
500
+ { color: colorOk, mode: labelMode },
498
501
  ));
499
502
  return;
500
503
  }
@@ -749,21 +752,11 @@ async function main() {
749
752
  const { statuslineDefaults } = await import('../src/config.js');
750
753
  const cfg = statuslineDefaults();
751
754
 
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));
755
+ // Icon vs text is decided in one module shared with the `--demo` path, so
756
+ // the two cannot drift apart again. It also owns the IntelliJ downgrade and
757
+ // the escape hatch out of it (SPRAG_ICON=force / `sprag mode icon-force`).
758
+ const { resolveLabelMode } = await import('../src/statusline-mode.js');
759
+ const { mode: labelMode } = resolveLabelMode({ hasFlag, cfg });
767
760
  const isVerbose = hasFlag('--verbose')
768
761
  ? true
769
762
  : (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
@@ -779,7 +772,7 @@ async function main() {
779
772
  color: colorOk,
780
773
  verbose: isVerbose,
781
774
  timer: showTimer,
782
- mode: isIcon ? 'icon' : 'text',
775
+ mode: labelMode,
783
776
  segments,
784
777
  // macOS builds of Claude Code have rendered only the first line of a
785
778
  // 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.45.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": {
@@ -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
  }
@@ -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
  }
@@ -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
  }
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,