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 +2 -0
- package/README.md +4 -0
- package/bin/cli.js +17 -24
- package/package.json +1 -1
- package/presets/model-rules.json +50 -9
- package/src/commands/brief.js +13 -1
- package/src/commands/mode.js +22 -2
- package/src/config.js +22 -3
- package/src/formatters/statusline.js +255 -100
- package/src/glyphs.js +78 -0
- package/src/litellm-budget.js +1 -1
- package/src/model-rules.js +53 -17
- package/src/route-inject.js +86 -0
- package/src/statusline-mode.js +95 -0
- package/src/window-labels.js +17 -7
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
|

|
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
|

|
|
@@ -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
|

|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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:
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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:
|
|
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
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
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:
|
|
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.
|
|
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": {
|
package/presets/model-rules.json
CHANGED
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
"agent": "haiku-runner",
|
|
22
22
|
"example": "npm test 돌려봐",
|
|
23
23
|
"exampleEn": "run npm test",
|
|
24
|
-
"budget": {
|
|
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": {
|
|
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": {
|
|
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": {
|
|
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": {
|
|
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": {
|
|
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": {
|
|
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": {
|
|
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": {
|
|
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
|
}
|
package/src/commands/brief.js
CHANGED
|
@@ -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)
|
|
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
|
}
|
package/src/commands/mode.js
CHANGED
|
@@ -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(`
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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,
|