claude-token-saver 2.14.2 → 2.16.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.en.md +10 -1
- package/README.md +7 -0
- package/bin/cli.js +54 -13
- package/package.json +2 -2
- package/src/formatters/statusline.js +90 -57
- package/src/harness-analyzer.cjs +35 -26
- package/src/harness.js +67 -20
package/README.en.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
[한국어](./README.md) · **English**
|
|
2
2
|
|
|
3
3
|
[](https://www.youtube.com/@DeepPulseKR)
|
|
4
|
+
[](https://www.youtube.com/@DeepPulseEN)
|
|
5
|
+
[](https://rootstudioyaml.github.io/)
|
|
4
6
|
[](https://www.npmjs.com/package/claude-token-saver)
|
|
5
7
|
|
|
6
8
|
# claude-token-saver
|
|
@@ -256,7 +258,9 @@ Node.js ≥ 18 · macOS / Windows / Linux / WSL · zero dependencies.
|
|
|
256
258
|
|
|
257
259
|
- [GitHub Issue #46829](https://github.com/anthropics/claude-code/issues/46829) — cache TTL regression
|
|
258
260
|
- [HN discussion](https://news.ycombinator.com/item?id=47736476) — 168 points, 142 comments
|
|
259
|
-
- [
|
|
261
|
+
- [DeepPulse KR](https://www.youtube.com/@DeepPulseKR) — Korean HN tech deep-dives
|
|
262
|
+
- [DeepPulse EN](https://www.youtube.com/@DeepPulseEN) — English HN tech deep-dives
|
|
263
|
+
- [Homepage](https://rootstudioyaml.github.io/) — project site
|
|
260
264
|
|
|
261
265
|
## Known environment quirks
|
|
262
266
|
|
|
@@ -264,6 +268,11 @@ Node.js ≥ 18 · macOS / Windows / Linux / WSL · zero dependencies.
|
|
|
264
268
|
|
|
265
269
|
## Release notes
|
|
266
270
|
|
|
271
|
+
### v2.15.0 (2026-06-13)
|
|
272
|
+
- **Global harness init** — `harness init`/`uninit`/`check` gain the same scope concept ratchet already had. `harness init --global` installs the 5 sections into `~/.claude/CLAUDE.md` (+ `~/.claude/ratchet.md`) once, so they **apply to every project**. The no-flag default stays `project` (back-compat).
|
|
273
|
+
- `harness check` now treats global as a **fallback** — a project with no local block but a global harness installed reports `🅷 5/5 (covered by global)`, matching how Claude Code loads the global `CLAUDE.md` for every project. Use `--project`/`--global` to inspect a single scope.
|
|
274
|
+
- npm package homepage changed to `https://rootstudioyaml.github.io/`; README adds the **@DeepPulseEN** channel and a homepage badge.
|
|
275
|
+
|
|
267
276
|
### v2.13.3 (2026-05-04)
|
|
268
277
|
- "Real-world impact" section restructured as a **harness + ratchet adoption cost-savings report**. Added Max(1h)/Pro(5m) cache TTL distinction (different hit-rate headroom), the handoff-before-expiry workflow, and the 2026-05-09 refresh promise. Chart title updated to match.
|
|
269
278
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
**한국어** · [English](./README.en.md)
|
|
2
2
|
|
|
3
3
|
[](https://www.youtube.com/@DeepPulseKR)
|
|
4
|
+
[](https://www.youtube.com/@DeepPulseEN)
|
|
5
|
+
[](https://rootstudioyaml.github.io/)
|
|
4
6
|
[](https://www.npmjs.com/package/claude-token-saver)
|
|
5
7
|
|
|
6
8
|
# claude-token-saver
|
|
@@ -224,6 +226,11 @@ Node.js ≥ 18 · macOS / Linux / Windows / WSL · 의존성 0.
|
|
|
224
226
|
|
|
225
227
|
## 릴리스 노트
|
|
226
228
|
|
|
229
|
+
### v2.15.0 (2026-06-13)
|
|
230
|
+
- **글로벌 harness init** — `harness init`/`uninit`/`check`에 ratchet과 동일한 스코프 개념 도입. `harness init --global`이 `~/.claude/CLAUDE.md`(+ `~/.claude/ratchet.md`)에 5개 섹션을 한 번에 깔아 **모든 프로젝트에 적용**됩니다. 무플래그 기본값은 종전대로 `project`(하위호환).
|
|
231
|
+
- `harness check`는 이제 글로벌을 **fallback**으로 인정 — 로컬 블록이 없어도 글로벌 harness가 깔려 있으면 `🅷 5/5 (covered by global)`로 표시(Claude Code가 전역 `CLAUDE.md`를 모든 프로젝트에 로드하는 실제 동작과 일치). `--project`/`--global`로 특정 스코프만 조회 가능.
|
|
232
|
+
- npm 패키지 homepage를 `https://rootstudioyaml.github.io/`로 변경, README에 **@DeepPulseEN** 채널·홈페이지 배지 추가.
|
|
233
|
+
|
|
227
234
|
### v2.13.3 (2026-05-04)
|
|
228
235
|
- "실제 효과" 섹션을 **harness + ratchet 도입 전후 비용 절감 리포트** 형태로 재구성. Max(1h)/Pro(5m) 캐시 TTL 차이에 따른 히트율 개선 여지 차이 설명, 만료 직전 handoff 워크플로 안내, 7일치 누적 시점(2026-05-09) 갱신 예고 추가. 차트 제목도 동일하게 갱신.
|
|
229
236
|
|
package/bin/cli.js
CHANGED
|
@@ -452,24 +452,44 @@ async function main() {
|
|
|
452
452
|
// claude-token-saver harness off | on # toggle the statusline 🅷 segment
|
|
453
453
|
if (args[0] === 'harness') {
|
|
454
454
|
const sub = args[1];
|
|
455
|
+
// Scope flags for init/uninit/check (same convention as promote/list/rm):
|
|
456
|
+
// --global | --project | --scope=global|project | --scope global|project
|
|
457
|
+
const parseHarnessScope = (argv, dflt) => {
|
|
458
|
+
for (let i = 0; i < argv.length; i++) {
|
|
459
|
+
const a = argv[i];
|
|
460
|
+
if (a === '--global') return 'global';
|
|
461
|
+
if (a === '--project') return 'project';
|
|
462
|
+
if (a === '--scope' && (argv[i + 1] === 'global' || argv[i + 1] === 'project')) return argv[i + 1];
|
|
463
|
+
if (a.startsWith('--scope=')) {
|
|
464
|
+
const v = a.slice('--scope='.length);
|
|
465
|
+
if (v === 'global' || v === 'project') return v;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return dflt;
|
|
469
|
+
};
|
|
455
470
|
const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessListRules, harnessRmRule, findProjectRoot } =
|
|
456
471
|
await import('../src/harness.js');
|
|
457
472
|
const { HARNESS_SECTIONS } = await import('../src/harness-templates.js');
|
|
458
473
|
const { loadConfig, saveConfig } = await import('../src/config.js');
|
|
459
474
|
|
|
460
475
|
if (!sub || sub === 'check') {
|
|
476
|
+
const scope = parseHarnessScope(args.slice(2), 'auto'); // auto = project, else global fallback
|
|
461
477
|
const root = findProjectRoot();
|
|
462
|
-
const s = harnessStatus(root);
|
|
463
|
-
|
|
478
|
+
const s = harnessStatus(root, { scope });
|
|
479
|
+
// Only call out "covered by global" when we *fell back* to it (auto), not
|
|
480
|
+
// when the user explicitly asked for the global scope.
|
|
481
|
+
const via = (scope === 'auto' && s.source === 'global') ? ' (covered by global ~/.claude/CLAUDE.md)' : '';
|
|
482
|
+
console.log(`🅷 ${s.configured}/${s.total} — ${s.file}${via}`);
|
|
464
483
|
console.log(`CLAUDE.md: ${s.hasFile ? 'present' : 'missing'}` +
|
|
465
|
-
(s.hasFile ? `, harness block: ${s.hasBlock ? 'yes' : 'no'}` : ''));
|
|
484
|
+
(s.hasFile ? `, harness block: ${s.hasBlock ? 'yes' : 'no'}` : '') + ` [${s.source}]`);
|
|
466
485
|
if (s.missing.length) {
|
|
467
486
|
console.log('Missing sections:');
|
|
468
487
|
for (const id of s.missing) {
|
|
469
488
|
const sec = HARNESS_SECTIONS.find((x) => x.id === id);
|
|
470
489
|
console.log(` - ${id}: ${sec ? sec.heading.replace(/^#+\s*/, '') : ''}`);
|
|
471
490
|
}
|
|
472
|
-
console.log('\nRun: claude-token-saver harness init');
|
|
491
|
+
console.log('\nRun: claude-token-saver harness init (this project)');
|
|
492
|
+
console.log(' or: claude-token-saver harness init --global (all projects, ~/.claude/CLAUDE.md)');
|
|
473
493
|
} else {
|
|
474
494
|
console.log('All 5 harness sections present. ✅');
|
|
475
495
|
}
|
|
@@ -477,9 +497,10 @@ async function main() {
|
|
|
477
497
|
}
|
|
478
498
|
|
|
479
499
|
if (sub === 'init') {
|
|
500
|
+
const scope = parseHarnessScope(args.slice(2), 'project'); // default project (back-compat)
|
|
480
501
|
const force = hasFlag('--force');
|
|
481
|
-
const r = harnessInit({ force });
|
|
482
|
-
console.log(`
|
|
502
|
+
const r = harnessInit({ force, scope });
|
|
503
|
+
console.log(`Scope: ${scope}${scope === 'global' ? ' (~/.claude/CLAUDE.md — applies to all projects)' : ` (${r.root})`}`);
|
|
483
504
|
for (const p of r.backedUp) console.log(`Backed up: ${p}`);
|
|
484
505
|
for (const p of r.wrote) console.log(`Wrote: ${p}`);
|
|
485
506
|
for (const p of r.skipped) console.log(`Skipped: ${p}`);
|
|
@@ -609,9 +630,10 @@ async function main() {
|
|
|
609
630
|
}
|
|
610
631
|
|
|
611
632
|
if (sub === 'uninit' || sub === 'remove') {
|
|
633
|
+
const scope = parseHarnessScope(args.slice(2), 'project');
|
|
612
634
|
const purgeRatchet = args.includes('--purge-ratchet');
|
|
613
|
-
const r = harnessUninit({ purgeRatchet });
|
|
614
|
-
console.log(`
|
|
635
|
+
const r = harnessUninit({ purgeRatchet, scope });
|
|
636
|
+
console.log(`Scope: ${scope}${scope === 'global' ? ' (~/.claude/CLAUDE.md)' : ` (${r.root})`}`);
|
|
615
637
|
r.removed.forEach((f) => console.log(` removed: ${f}`));
|
|
616
638
|
r.backedUp.forEach((f) => console.log(` backup: ${f}`));
|
|
617
639
|
r.skipped.forEach((f) => console.log(` skip: ${f}`));
|
|
@@ -816,12 +838,31 @@ async function main() {
|
|
|
816
838
|
const sessions = await parseAllSessions({ days, projectFilter });
|
|
817
839
|
|
|
818
840
|
if (sessions.length === 0) {
|
|
819
|
-
// Statusline must always emit a single line (no multi-line help spam every
|
|
841
|
+
// Statusline must always emit a single line (no multi-line help spam every
|
|
842
|
+
// 300ms) — but the stdin payload (rate limits, model) is still live even
|
|
843
|
+
// with an empty analysis window (e.g. `mode 1h` + idle), and cap-warn /
|
|
844
|
+
// harness are exactly the signals that must not vanish then.
|
|
820
845
|
if (format === 'statusline') {
|
|
821
|
-
const
|
|
822
|
-
const
|
|
823
|
-
const
|
|
824
|
-
|
|
846
|
+
const { formatNoSession } = await import('../src/formatters/statusline.js');
|
|
847
|
+
const { statuslineDefaults } = await import('../src/config.js');
|
|
848
|
+
const cfg = statuslineDefaults();
|
|
849
|
+
const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
|
|
850
|
+
const isIcon = hasFlag('--icon')
|
|
851
|
+
? true
|
|
852
|
+
: (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon);
|
|
853
|
+
const stdinJson = readStdinJson();
|
|
854
|
+
const caps = extractCaps(stdinJson);
|
|
855
|
+
const model = extractModel(stdinJson);
|
|
856
|
+
if (caps || model) {
|
|
857
|
+
try {
|
|
858
|
+
const { persistSnapshot } = await import('../src/caps-cache.js');
|
|
859
|
+
persistSnapshot({ caps, model });
|
|
860
|
+
} catch { /* non-critical */ }
|
|
861
|
+
}
|
|
862
|
+
console.log(formatNoSession(
|
|
863
|
+
{ caps, model, windowLabel },
|
|
864
|
+
{ color: colorOk, mode: isIcon ? 'icon' : 'text' },
|
|
865
|
+
));
|
|
825
866
|
return;
|
|
826
867
|
}
|
|
827
868
|
console.log('No session data found for the given period.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "Save tokens on Claude Code — spike diagnosis, 1M-context detection, TTL countdown, statusline. (formerly claude-cache-monitor)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"type": "git",
|
|
38
38
|
"url": "git+https://github.com/rootstudioyaml/claude-token-saver.git"
|
|
39
39
|
},
|
|
40
|
-
"homepage": "https://
|
|
40
|
+
"homepage": "https://rootstudioyaml.github.io/",
|
|
41
41
|
"bugs": {
|
|
42
42
|
"url": "https://github.com/rootstudioyaml/claude-token-saver/issues"
|
|
43
43
|
},
|
|
@@ -137,6 +137,73 @@ export function pickCapWarn(caps) {
|
|
|
137
137
|
return candidates[0];
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Harness 🅷 segment builder — shared by the full report and the no-session
|
|
142
|
+
* fallback line. Best-effort: never throws into the statusline (corrupted
|
|
143
|
+
* CLAUDE.md, permission issue, etc. → null).
|
|
144
|
+
*/
|
|
145
|
+
function buildHarnessSeg(c, isIcon) {
|
|
146
|
+
try {
|
|
147
|
+
const harnessInfo = harnessStatusForStatusline(loadConfig());
|
|
148
|
+
if (!harnessInfo) return null;
|
|
149
|
+
const icon = isIcon ? '🅷' : 'H';
|
|
150
|
+
if (harnessInfo.warning) {
|
|
151
|
+
// Warning state outranks the N/5 count — a runtime issue (repeated
|
|
152
|
+
// error / no-evidence / racing edits) is more actionable than a
|
|
153
|
+
// missing ratchet section. Always red so it stands out.
|
|
154
|
+
return `${c(RED)}${icon}⚠ ${harnessInfo.warning}${c(RESET)}`;
|
|
155
|
+
}
|
|
156
|
+
if (harnessInfo.custom) return `${c(CYAN)}${icon} custom${c(RESET)}`;
|
|
157
|
+
const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
|
|
158
|
+
return `${c(tone)}${icon} ${harnessInfo.configured}/${harnessInfo.total}${c(RESET)}`;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Cap-warn chip builder — shared by the full report and the no-session
|
|
166
|
+
* fallback line. At 90%+ the user wants to know "when can I send again", so
|
|
167
|
+
* the wall-clock reset time rides along in the same `🔄 HH:MM` shape as the
|
|
168
|
+
* always-on usage segments.
|
|
169
|
+
*/
|
|
170
|
+
function buildCapWarnSeg(capWarn, c, isIcon) {
|
|
171
|
+
if (!capWarn) return null;
|
|
172
|
+
const pct = Math.round(capWarn.usedPct);
|
|
173
|
+
const clock = formatResetClock(capWarn.resetsAt);
|
|
174
|
+
const clockTail = clock ? ` 🔄 ${clock}` : '';
|
|
175
|
+
if (isIcon) {
|
|
176
|
+
// Gauge keeps shape parity with the always-on usage segment — the
|
|
177
|
+
// cap-warn is just the same gauge "filled to alarm". Visual continuity
|
|
178
|
+
// helps the eye understand "this is the 5H bar I was watching, just red now."
|
|
179
|
+
const bar = gaugeBar(pct);
|
|
180
|
+
return `${c(BOLD)}${c(RED)}🚨 ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
|
|
181
|
+
}
|
|
182
|
+
return `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Fallback line for when no session data exists in the analysis window.
|
|
187
|
+
* The stdin payload (rate limits, model) is still live in that case, and a
|
|
188
|
+
* 90%+ cap warning is exactly the kind of signal that must not disappear
|
|
189
|
+
* just because the user has been idle past the window — so cap-warn,
|
|
190
|
+
* harness, and model chips still render around the "no session data" note.
|
|
191
|
+
*/
|
|
192
|
+
export function formatNoSession({ caps = null, model = null, windowLabel = '' } = {}, { color = true, mode = 'icon' } = {}) {
|
|
193
|
+
const c = (v) => (color ? v : '');
|
|
194
|
+
const isIcon = mode === 'icon';
|
|
195
|
+
const segs = [];
|
|
196
|
+
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon);
|
|
197
|
+
if (capSeg) segs.push(capSeg);
|
|
198
|
+
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
199
|
+
if (harnessSeg) segs.push(harnessSeg);
|
|
200
|
+
if (typeof model === 'string' && model.length > 0) {
|
|
201
|
+
segs.push(isIcon ? `${c(MAGENTA)}🤖 ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
|
|
202
|
+
}
|
|
203
|
+
segs.push(`${c(GRAY)}🧠 no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
|
|
204
|
+
return segs.join(' · ') + (color ? '\x1b[K' : '');
|
|
205
|
+
}
|
|
206
|
+
|
|
140
207
|
/**
|
|
141
208
|
* @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
|
|
142
209
|
* @param {object} [opts]
|
|
@@ -144,7 +211,7 @@ export function pickCapWarn(caps) {
|
|
|
144
211
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
145
212
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
146
213
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
147
|
-
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, model, hit, ttl, saved, ctx, period, plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
214
|
+
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, harness, model, hit, ttl, saved, ctx, period, plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
148
215
|
*/
|
|
149
216
|
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
|
|
150
217
|
const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps, model } = data;
|
|
@@ -279,28 +346,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
279
346
|
// Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
|
|
280
347
|
// otherwise renders 🅷 5/5 (green) / 🅷 N/5 (yellow) so the user can spot
|
|
281
348
|
// a missing section at a glance and know to run `harness init`.
|
|
282
|
-
|
|
283
|
-
try {
|
|
284
|
-
const harnessInfo = harnessStatusForStatusline(loadConfig());
|
|
285
|
-
if (harnessInfo) {
|
|
286
|
-
const icon = isIcon ? '🅷' : 'H';
|
|
287
|
-
if (harnessInfo.warning) {
|
|
288
|
-
// Warning state outranks the N/5 count — a runtime issue (repeated
|
|
289
|
-
// error / no-evidence / racing edits) is more actionable than a
|
|
290
|
-
// missing ratchet section. Always red so it stands out.
|
|
291
|
-
harnessSeg = `${c(RED)}${icon}⚠ ${harnessInfo.warning}${c(RESET)}`;
|
|
292
|
-
} else if (harnessInfo.custom) {
|
|
293
|
-
harnessSeg = `${c(CYAN)}${icon} custom${c(RESET)}`;
|
|
294
|
-
} else {
|
|
295
|
-
const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
|
|
296
|
-
harnessSeg = `${c(tone)}${icon} ${harnessInfo.configured}/${harnessInfo.total}${c(RESET)}`;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
} catch {
|
|
300
|
-
// Harness check is best-effort — never break the statusline if the file
|
|
301
|
-
// read fails (corrupted CLAUDE.md, permission issue, etc.).
|
|
302
|
-
harnessSeg = null;
|
|
303
|
-
}
|
|
349
|
+
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
304
350
|
|
|
305
351
|
// Model chip — pulled from Claude Code's stdin payload (`model.display_name`).
|
|
306
352
|
// Cheap identity context: useful when the user toggles between Sonnet/Opus
|
|
@@ -322,12 +368,14 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
322
368
|
// Today the stdin payload exposes the 5h ("Current session") and 7-day
|
|
323
369
|
// rolling ("Current week") windows; if Anthropic ships more (e.g. a
|
|
324
370
|
// Sonnet-only weekly), they render automatically with derived labels.
|
|
325
|
-
// Each renders as `{label} {pct}% · {countdown}`.
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
371
|
+
// Each renders as `{label} {pct}% · {countdown}`. The window promoted to the
|
|
372
|
+
// cap-warn chip is suppressed here to avoid duplicate noise — but ONLY that
|
|
373
|
+
// one. When several windows are at 90%+ the chip shows just the most
|
|
374
|
+
// imminent, so the others must keep their always-on segment (red) or they'd
|
|
375
|
+
// vanish from the statusline entirely at the worst possible moment.
|
|
376
|
+
function buildUsageSeg({ labels, info, color: tone, suppressed }) {
|
|
329
377
|
if (!info || !Number.isFinite(info.usedPct)) return null;
|
|
330
|
-
if (
|
|
378
|
+
if (suppressed) return null; // cap-warn chip handles this window
|
|
331
379
|
const pct = Math.round(info.usedPct);
|
|
332
380
|
// Show only the wall-clock reset time (e.g. `🔄 21:10`). Absolute time
|
|
333
381
|
// doesn't tick second-by-second so the statusline reads stable, and the
|
|
@@ -351,14 +399,20 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
351
399
|
}
|
|
352
400
|
return `${c(tone)}${labels.short} cap ${pct}%${tail}${c(RESET)}`;
|
|
353
401
|
}
|
|
354
|
-
// Color tone: green
|
|
355
|
-
//
|
|
356
|
-
// waiting for the alarm chip.
|
|
402
|
+
// Color tone: green <70%, yellow 70-89%, red 90+% (a 90+% window only
|
|
403
|
+
// renders here when a *different* window won the cap-warn chip slot).
|
|
357
404
|
function usageTone(info) {
|
|
358
405
|
if (!info || !Number.isFinite(info.usedPct)) return GRAY;
|
|
406
|
+
if (info.usedPct >= 90) return RED;
|
|
359
407
|
if (info.usedPct >= 70) return YELLOW;
|
|
360
408
|
return GREEN;
|
|
361
409
|
}
|
|
410
|
+
// Cap-warn chip — leads everything when ANY rate-limit window is at 90%+.
|
|
411
|
+
// It's the most actionable signal we can show: no point optimizing cache
|
|
412
|
+
// hits if you're about to be rate-limited anyway. The chip body matches the
|
|
413
|
+
// English shape `🚨 5H 94%` / `🚨 7D 92%` so history parsers can dedupe on it.
|
|
414
|
+
// Computed before the usage segments so they know which window it claimed.
|
|
415
|
+
const capWarn = pickCapWarn(caps);
|
|
362
416
|
const usageSegs = [];
|
|
363
417
|
if (caps && Array.isArray(caps.windows)) {
|
|
364
418
|
for (const win of caps.windows) {
|
|
@@ -367,34 +421,13 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
367
421
|
labels,
|
|
368
422
|
info: win,
|
|
369
423
|
color: usageTone(win),
|
|
424
|
+
suppressed: !!capWarn && capWarn.key === win.key,
|
|
370
425
|
});
|
|
371
426
|
if (seg) usageSegs.push({ key: win.key, seg });
|
|
372
427
|
}
|
|
373
428
|
}
|
|
374
429
|
|
|
375
|
-
|
|
376
|
-
// It's the most actionable signal we can show: no point optimizing cache
|
|
377
|
-
// hits if you're about to be rate-limited anyway. The chip body matches the
|
|
378
|
-
// English shape `🚨 5H 94%` / `🚨 7D 92%` so history parsers can dedupe on it.
|
|
379
|
-
const capWarn = pickCapWarn(caps);
|
|
380
|
-
let capWarnSeg = null;
|
|
381
|
-
if (capWarn) {
|
|
382
|
-
const pct = Math.round(capWarn.usedPct);
|
|
383
|
-
// At 90%+ the user wants to know "when can I send again" — wall-clock is
|
|
384
|
-
// the actionable bit. Same `🔄 HH:MM` shape as the always-on segments so
|
|
385
|
-
// the icon's meaning carries over to the alarm chip.
|
|
386
|
-
const clock = formatResetClock(capWarn.resetsAt);
|
|
387
|
-
const clockTail = clock ? ` 🔄 ${clock}` : '';
|
|
388
|
-
if (isIcon) {
|
|
389
|
-
// Gauge keeps shape parity with the always-on usage segment — the
|
|
390
|
-
// cap-warn is just the same gauge "filled to alarm". Visual continuity
|
|
391
|
-
// helps the eye understand "this is the 5H bar I was watching, just red now."
|
|
392
|
-
const bar = gaugeBar(pct);
|
|
393
|
-
capWarnSeg = `${c(BOLD)}${c(RED)}🚨 ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
|
|
394
|
-
} else {
|
|
395
|
-
capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
430
|
+
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon);
|
|
398
431
|
|
|
399
432
|
// Warning chip leads — a glance at the statusline catches "something's wrong"
|
|
400
433
|
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
|
@@ -431,7 +464,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
431
464
|
if (want('period')) segs.push(periodSeg);
|
|
432
465
|
// Trailing erase-to-end-of-line so any leftover characters from a previous
|
|
433
466
|
// (longer) statusline render don't bleed into ours. \x1b[K is the standard
|
|
434
|
-
// "erase from cursor to EOL" CSI
|
|
435
|
-
//
|
|
436
|
-
return segs.join(' · ') + '\x1b[K';
|
|
467
|
+
// "erase from cursor to EOL" CSI. Only emitted when color (i.e. ANSI) is
|
|
468
|
+
// allowed — --no-color/NO_COLOR consumers expect escape-free output.
|
|
469
|
+
return segs.join(' · ') + (color ? '\x1b[K' : '');
|
|
437
470
|
}
|
package/src/harness-analyzer.cjs
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
* keywords). <30% → ⚠ no-evidence — high chance the model is reporting
|
|
13
13
|
* "done" without showing it.
|
|
14
14
|
*
|
|
15
|
-
* 3. PEV-skip — many tool_use calls (5+) in the
|
|
16
|
-
* signal (no TodoWrite, no
|
|
17
|
-
* model is racing through
|
|
15
|
+
* 3. PEV-skip — many *mutating* tool_use calls (Edit/Write/Bash…, 5+) in the
|
|
16
|
+
* last 15 assistant turns with no plan signal (no TodoWrite, no
|
|
17
|
+
* "plan"/"Phase"/"단계" mention). Suggests the model is racing through
|
|
18
|
+
* edits without a verify pass. Read-only exploration (Read/Grep/Glob)
|
|
19
|
+
* deliberately doesn't count — reading five files is research, not racing.
|
|
18
20
|
*
|
|
19
21
|
* CommonJS so hook.cjs can `require()` it without a bundler step.
|
|
20
22
|
*/
|
|
@@ -28,10 +30,14 @@ const os = require('node:os');
|
|
|
28
30
|
const STATE_DIR = stateDir();
|
|
29
31
|
const STATE_PATH = path.join(STATE_DIR, 'harness-state.json');
|
|
30
32
|
|
|
31
|
-
const RECENT_TURNS = 15; // PEV / evidence window
|
|
33
|
+
const RECENT_TURNS = 15; // PEV / evidence window (assistant turns)
|
|
32
34
|
const RATCHET_TURNS = 30; // ratchet-candidate window
|
|
33
35
|
const EVIDENCE_THRESHOLD = 0.3; // <30% → ⚠ no-evidence
|
|
34
36
|
const PEV_TOOLUSE_THRESHOLD = 5;
|
|
37
|
+
// Tools that change state. Only these count toward PEV-skip — an agentic
|
|
38
|
+
// session trivially racks up 5+ *read* tool calls (Read/Grep/Glob) while
|
|
39
|
+
// researching, which is exactly the behavior we don't want to punish.
|
|
40
|
+
const MUTATING_TOOL_RE = /^(edit|write|multiedit|notebookedit|bash)$/i;
|
|
35
41
|
|
|
36
42
|
function stateDir() {
|
|
37
43
|
if (process.platform === 'win32') {
|
|
@@ -167,39 +173,42 @@ function findRatchetCandidates(entries) {
|
|
|
167
173
|
* the immediate next user message also count as "shown the work."
|
|
168
174
|
*/
|
|
169
175
|
function computeEvidenceRate(entries) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
176
|
+
// Window by *assistant turns*, not raw JSONL entries — one agentic turn can
|
|
177
|
+
// span dozens of entries, so an entry-sliced window covered only 1-2 real
|
|
178
|
+
// turns and made the rate jumpy.
|
|
179
|
+
const idxs = [];
|
|
180
|
+
for (let i = 0; i < entries.length; i++) {
|
|
181
|
+
if (entries[i] && entries[i].type === 'assistant') idxs.push(i);
|
|
182
|
+
}
|
|
183
|
+
const recentIdxs = idxs.slice(-RECENT_TURNS);
|
|
184
|
+
if (recentIdxs.length === 0) return null;
|
|
185
|
+
let proofCount = 0;
|
|
186
|
+
for (const i of recentIdxs) {
|
|
187
|
+
const text = assistantText(entries[i].message);
|
|
188
|
+
let proof = looksLikeEvidence(text);
|
|
189
|
+
// If the *next* entry is a user message with tool_result blocks, count
|
|
190
|
+
// that as evidence for the assistant turn that triggered it.
|
|
191
|
+
const next = entries[i + 1];
|
|
192
|
+
if (!proof && next && next.type === 'user' && toolResultsIn(next.message).length > 0) {
|
|
193
|
+
proof = true;
|
|
184
194
|
}
|
|
195
|
+
if (proof) proofCount++;
|
|
185
196
|
}
|
|
186
|
-
|
|
187
|
-
const proofCount = assistants.filter(Boolean).length;
|
|
188
|
-
return proofCount / assistants.length;
|
|
197
|
+
return proofCount / recentIdxs.length;
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
function computePevSkip(entries) {
|
|
192
|
-
const
|
|
193
|
-
|
|
201
|
+
const assistants = entries.filter((e) => e && e.type === 'assistant');
|
|
202
|
+
const recent = assistants.slice(-RECENT_TURNS);
|
|
203
|
+
let mutatingCount = 0;
|
|
194
204
|
let planSignal = false;
|
|
195
205
|
for (const e of recent) {
|
|
196
|
-
if (!e || e.type !== 'assistant') continue;
|
|
197
206
|
const text = assistantText(e.message);
|
|
198
207
|
const tus = toolUsesIn(e.message);
|
|
199
|
-
|
|
208
|
+
mutatingCount += tus.filter((t) => MUTATING_TOOL_RE.test(t.name || '')).length;
|
|
200
209
|
if (looksLikePlanSignal(text, tus)) planSignal = true;
|
|
201
210
|
}
|
|
202
|
-
return
|
|
211
|
+
return mutatingCount >= PEV_TOOLUSE_THRESHOLD && !planSignal;
|
|
203
212
|
}
|
|
204
213
|
|
|
205
214
|
function analyzeTranscript(transcriptPath, opts) {
|
package/src/harness.js
CHANGED
|
@@ -67,28 +67,43 @@ function resolveRatchetPath(scope, root) {
|
|
|
67
67
|
return scope === 'global' ? globalRatchetMdPath() : ratchetMdPath(root);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Global harness lives in ~/.claude/CLAUDE.md — Claude Code loads this for every
|
|
71
|
+
// project, so a global init makes the 5 harness sections apply everywhere
|
|
72
|
+
// (mirrors the project/global split that ratchet.md already has).
|
|
73
|
+
function globalClaudeMdPath() {
|
|
74
|
+
return join(homedir(), '.claude', 'CLAUDE.md');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveClaudeMdPath(scope, root) {
|
|
78
|
+
return scope === 'global' ? globalClaudeMdPath() : claudeMdPath(root);
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
/**
|
|
71
82
|
* Count how many of the 5 harness sections appear in the project's CLAUDE.md.
|
|
72
83
|
* Returns { configured, total, missing, hasBlock }. Cheap enough to call from
|
|
73
84
|
* statusline — single file read + regex.
|
|
74
85
|
*/
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (!existsSync(
|
|
86
|
+
// Count harness sections in a single CLAUDE.md file. Shared by both scopes.
|
|
87
|
+
function statusForFile(filePath) {
|
|
88
|
+
if (!existsSync(filePath)) {
|
|
78
89
|
return {
|
|
79
90
|
configured: 0,
|
|
80
91
|
total: HARNESS_SECTIONS.length,
|
|
81
92
|
missing: HARNESS_SECTIONS.map((s) => s.id),
|
|
82
93
|
hasBlock: false,
|
|
83
94
|
hasFile: false,
|
|
84
|
-
|
|
95
|
+
optOut: false,
|
|
96
|
+
custom: false,
|
|
97
|
+
file: filePath,
|
|
85
98
|
};
|
|
86
99
|
}
|
|
87
100
|
let content = '';
|
|
88
101
|
try {
|
|
89
|
-
content = readFileSync(
|
|
102
|
+
content = readFileSync(filePath, 'utf8');
|
|
90
103
|
} catch {
|
|
91
|
-
|
|
104
|
+
// Unreadable file (permissions, etc.) — report every section missing so
|
|
105
|
+
// `harness check` can't print "All 5 sections present ✅" over a 0/5.
|
|
106
|
+
return { configured: 0, total: HARNESS_SECTIONS.length, missing: HARNESS_SECTIONS.map((s) => s.id), hasBlock: false, hasFile: true, optOut: false, custom: false, file: filePath };
|
|
92
107
|
}
|
|
93
108
|
const hasBlock = content.includes(HARNESS_BLOCK_BEGIN);
|
|
94
109
|
// Opt-out marker — when the user intentionally customizes the harness block
|
|
@@ -114,10 +129,30 @@ export function harnessStatus(root = findProjectRoot()) {
|
|
|
114
129
|
hasFile: true,
|
|
115
130
|
optOut,
|
|
116
131
|
custom,
|
|
117
|
-
|
|
132
|
+
file: filePath,
|
|
118
133
|
};
|
|
119
134
|
}
|
|
120
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Harness status for a project, with scope control:
|
|
138
|
+
* scope 'project' — count only <root>/CLAUDE.md
|
|
139
|
+
* scope 'global' — count only ~/.claude/CLAUDE.md
|
|
140
|
+
* scope 'auto' (default) — use the project file if it carries the harness
|
|
141
|
+
* block, otherwise fall back to the global file. This makes a project that
|
|
142
|
+
* relies on a globally-installed harness report 🅷 5/5 (covered by global),
|
|
143
|
+
* matching reality: Claude Code loads ~/.claude/CLAUDE.md for every project.
|
|
144
|
+
* The returned `source` ('project'|'global') tells callers which file was used.
|
|
145
|
+
*/
|
|
146
|
+
export function harnessStatus(root = findProjectRoot(), { scope = 'auto' } = {}) {
|
|
147
|
+
if (scope === 'project') return { ...statusForFile(claudeMdPath(root)), root, source: 'project' };
|
|
148
|
+
if (scope === 'global') return { ...statusForFile(globalClaudeMdPath()), root, source: 'global' };
|
|
149
|
+
const project = statusForFile(claudeMdPath(root));
|
|
150
|
+
if (project.hasBlock) return { ...project, root, source: 'project' };
|
|
151
|
+
const global = statusForFile(globalClaudeMdPath());
|
|
152
|
+
if (global.hasBlock) return { ...global, root, source: 'global' };
|
|
153
|
+
return { ...project, root, source: 'project' };
|
|
154
|
+
}
|
|
155
|
+
|
|
121
156
|
/**
|
|
122
157
|
* harness init — write CLAUDE.md (single file, 5 sections) + .claude/ratchet.md.
|
|
123
158
|
* If CLAUDE.md exists, back it up to CLAUDE.md.bak-YYYYMMDD-HHMMSS first
|
|
@@ -125,10 +160,10 @@ export function harnessStatus(root = findProjectRoot()) {
|
|
|
125
160
|
*
|
|
126
161
|
* Returns { wrote: [], backedUp: [], skipped: [] } so the CLI can report.
|
|
127
162
|
*/
|
|
128
|
-
export function harnessInit({ root = findProjectRoot(), force = false } = {}) {
|
|
129
|
-
const cmPath =
|
|
130
|
-
const rmPath =
|
|
131
|
-
const result = { wrote: [], backedUp: [], skipped: [], root };
|
|
163
|
+
export function harnessInit({ root = findProjectRoot(), force = false, scope = 'project' } = {}) {
|
|
164
|
+
const cmPath = resolveClaudeMdPath(scope, root); // global → ~/.claude/CLAUDE.md
|
|
165
|
+
const rmPath = resolveRatchetPath(scope, root); // global → ~/.claude/ratchet.md
|
|
166
|
+
const result = { wrote: [], backedUp: [], skipped: [], root, scope };
|
|
132
167
|
|
|
133
168
|
// CLAUDE.md
|
|
134
169
|
const block = harnessClaudeMdBlock();
|
|
@@ -158,11 +193,12 @@ export function harnessInit({ root = findProjectRoot(), force = false } = {}) {
|
|
|
158
193
|
result.wrote.push(cmPath + ' (harness block appended)');
|
|
159
194
|
}
|
|
160
195
|
} else {
|
|
196
|
+
mkdirSync(dirname(cmPath), { recursive: true }); // global: ensure ~/.claude exists
|
|
161
197
|
writeFileSync(cmPath, block);
|
|
162
198
|
result.wrote.push(cmPath);
|
|
163
199
|
}
|
|
164
200
|
|
|
165
|
-
//
|
|
201
|
+
// ratchet.md (only if missing — don't clobber user-grown rules)
|
|
166
202
|
if (!existsSync(rmPath)) {
|
|
167
203
|
mkdirSync(dirname(rmPath), { recursive: true });
|
|
168
204
|
writeFileSync(rmPath, harnessRatchetMdInitial());
|
|
@@ -181,10 +217,10 @@ export function harnessInit({ root = findProjectRoot(), force = false } = {}) {
|
|
|
181
217
|
*
|
|
182
218
|
* Returns { removed: [], backedUp: [], skipped: [] }.
|
|
183
219
|
*/
|
|
184
|
-
export function harnessUninit({ root = findProjectRoot(), purgeRatchet = false } = {}) {
|
|
185
|
-
const cmPath =
|
|
186
|
-
const rmPath =
|
|
187
|
-
const result = { removed: [], backedUp: [], skipped: [], root };
|
|
220
|
+
export function harnessUninit({ root = findProjectRoot(), purgeRatchet = false, scope = 'project' } = {}) {
|
|
221
|
+
const cmPath = resolveClaudeMdPath(scope, root);
|
|
222
|
+
const rmPath = resolveRatchetPath(scope, root);
|
|
223
|
+
const result = { removed: [], backedUp: [], skipped: [], root, scope };
|
|
188
224
|
|
|
189
225
|
if (existsSync(cmPath)) {
|
|
190
226
|
const existing = readFileSync(cmPath, 'utf8');
|
|
@@ -300,13 +336,24 @@ export function harnessStatusForStatusline(cfg, { root } = {}) {
|
|
|
300
336
|
if (!status.hasFile && !existsSync(join(projectRoot, '.claude'))) return null;
|
|
301
337
|
if (status.optOut) return null;
|
|
302
338
|
// Attach a warning derived from the analyzer state file (if any). Precedence:
|
|
303
|
-
// ratchet? > no-evidence > PEV-skip.
|
|
304
|
-
//
|
|
339
|
+
// ratchet? > no-evidence > PEV-skip. Guards, in order:
|
|
340
|
+
// - freshness: the hook rewrites the state on every tool use, so anything
|
|
341
|
+
// older than WARNING_TTL_MS is a dead session's leftovers — a red 🅷⚠
|
|
342
|
+
// must never linger for days after the triggering session ended.
|
|
343
|
+
// - project match: state.cwd is the *session* cwd, which may be a subdir
|
|
344
|
+
// of the repo, while projectRoot is the walked-up root. Normalize both
|
|
345
|
+
// through findProjectRoot so launching Claude Code in a subdirectory
|
|
346
|
+
// still surfaces (and correctly scopes) the warning. A state with no
|
|
347
|
+
// cwd at all is unattributable — stay silent rather than leak it into
|
|
348
|
+
// every project.
|
|
349
|
+
const WARNING_TTL_MS = 30 * 60 * 1000;
|
|
305
350
|
const state = readHarnessState();
|
|
306
351
|
let warning = null;
|
|
307
352
|
if (state) {
|
|
308
|
-
const
|
|
309
|
-
|
|
353
|
+
const ts = state.timestamp ? Date.parse(state.timestamp) : NaN;
|
|
354
|
+
const fresh = Number.isFinite(ts) && Date.now() - ts <= WARNING_TTL_MS;
|
|
355
|
+
const matches = !!state.cwd && findProjectRoot(state.cwd) === projectRoot;
|
|
356
|
+
if (fresh && matches) {
|
|
310
357
|
if (state.ratchetCandidate && state.ratchetCandidate.count >= 2) {
|
|
311
358
|
const id = state.ratchetCandidate.id || 1;
|
|
312
359
|
warning = `ratchet? #${id}`;
|