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 CHANGED
@@ -1,6 +1,8 @@
1
1
  [한국어](./README.md) · **English**
2
2
 
3
3
  [![DeepPulse YouTube](https://img.shields.io/badge/YouTube-@DeepPulseKR-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseKR)
4
+ [![DeepPulseEN YouTube](https://img.shields.io/badge/YouTube-@DeepPulseEN-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseEN)
5
+ [![Homepage](https://img.shields.io/badge/Homepage-rootstudioyaml.github.io-2ea44f)](https://rootstudioyaml.github.io/)
4
6
  [![npm](https://img.shields.io/npm/v/claude-token-saver.svg)](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
- - [HNPulse KR](https://www.youtube.com/@DeepPulseKR) — Korean HN tech deep-dives
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
  [![DeepPulse YouTube](https://img.shields.io/badge/YouTube-@DeepPulseKR-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseKR)
4
+ [![DeepPulseEN YouTube](https://img.shields.io/badge/YouTube-@DeepPulseEN-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/@DeepPulseEN)
5
+ [![Homepage](https://img.shields.io/badge/Homepage-rootstudioyaml.github.io-2ea44f)](https://rootstudioyaml.github.io/)
4
6
  [![npm](https://img.shields.io/npm/v/claude-token-saver.svg)](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
- console.log(`🅷 ${s.configured}/${s.total} ${root}`);
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(`Project root: ${r.root}`);
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(`Project root: ${r.root}`);
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 300ms)
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 colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR;
822
- const gray = colorOk ? '\x1b[90m' : '';
823
- const reset = colorOk ? '\x1b[0m' : '';
824
- console.log(`${gray}🧠 no session data · ${days}d${reset}`);
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.14.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://www.youtube.com/@DeepPulseKR",
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
- let harnessSeg = null;
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}`. When a window is at >=90%
326
- // the cap-warn chip already shouts about it, so we suppress the always-on
327
- // segment to avoid duplicate noise.
328
- function buildUsageSeg({ labels, info, color: tone }) {
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 (info.usedPct >= 90) return null; // cap-warn chip handles this case
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 when <70%, yellow 70-89% (the segment is suppressed at
355
- // 90+% in favor of cap-warn). Lets the user spot "I'm getting close" without
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
- // Cap-warn chip leads everything when ANY rate-limit window is at 90%+.
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; safe on any ANSI-compatible terminal and
435
- // a no-op when stdout isn't a TTY.
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
  }
@@ -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 last 15 turns with no plan
16
- * signal (no TodoWrite, no "plan"/"Phase"/"단계" mention). Suggests the
17
- * model is racing through edits without a verify pass.
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
- const recent = entries.slice(-RECENT_TURNS * 2); // both user/assistant
171
- const assistants = [];
172
- for (let i = 0; i < recent.length; i++) {
173
- const e = recent[i];
174
- if (e && e.type === 'assistant') {
175
- const text = assistantText(e.message);
176
- let proof = looksLikeEvidence(text);
177
- // If the *next* entry is a user message with tool_result blocks, count
178
- // that as evidence for the assistant turn that triggered it.
179
- const next = recent[i + 1];
180
- if (!proof && next && next.type === 'user' && toolResultsIn(next.message).length > 0) {
181
- proof = true;
182
- }
183
- assistants.push(proof);
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
- if (assistants.length === 0) return null;
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 recent = entries.slice(-RECENT_TURNS);
193
- let toolUseCount = 0;
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
- toolUseCount += tus.length;
208
+ mutatingCount += tus.filter((t) => MUTATING_TOOL_RE.test(t.name || '')).length;
200
209
  if (looksLikePlanSignal(text, tus)) planSignal = true;
201
210
  }
202
- return toolUseCount >= PEV_TOOLUSE_THRESHOLD && !planSignal;
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
- export function harnessStatus(root = findProjectRoot()) {
76
- const path = claudeMdPath(root);
77
- if (!existsSync(path)) {
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
- root,
95
+ optOut: false,
96
+ custom: false,
97
+ file: filePath,
85
98
  };
86
99
  }
87
100
  let content = '';
88
101
  try {
89
- content = readFileSync(path, 'utf8');
102
+ content = readFileSync(filePath, 'utf8');
90
103
  } catch {
91
- return { configured: 0, total: HARNESS_SECTIONS.length, missing: [], hasBlock: false, hasFile: true, root };
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
- root,
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 = claudeMdPath(root);
130
- const rmPath = ratchetMdPath(root);
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
- // .claude/ratchet.md (only if missing — don't clobber user-grown rules)
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 = claudeMdPath(root);
186
- const rmPath = ratchetMdPath(root);
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. Only surfaces when the state's
304
- // sessionId or cwd matches this project, so unrelated sessions don't leak.
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 matches = (state.cwd && state.cwd === projectRoot) || !state.cwd;
309
- if (matches) {
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}`;