claude-token-saver 2.14.1 → 2.15.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
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { readFileSync } from 'node:fs';
26
26
  import { fileURLToPath } from 'node:url';
27
- import { dirname, join } from 'node:path';
27
+ import { dirname, isAbsolute, join } from 'node:path';
28
28
 
29
29
  /**
30
30
  * Read the JSON blob Claude Code feeds the statusline command on stdin.
@@ -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}`));
@@ -804,12 +826,16 @@ async function main() {
804
826
  process.stderr.write('Scanning session files...\n');
805
827
  }
806
828
 
807
- // Exclude the current Claude Code session when computing lastActivity
808
- // otherwise the agent's own tool calls reset the countdown every few seconds.
829
+ // The current Claude Code session is only excluded from the lastActivity
830
+ // timer (so the agent's own tool calls don't reset the countdown). It MUST
831
+ // still feed ttlBreakdown — otherwise when the user's only recent traffic
832
+ // lives in the current session, the bucket signal collapses to empty and
833
+ // the statusline falsely flips to the 5m default. (See issue: Max users
834
+ // seeing "Cache expires 5:00" on idle even though their plan is 1h.)
809
835
  const excludeSessionPath =
810
836
  getArg('--exclude-session') || process.env.CACHE_MONITOR_EXCLUDE_SESSION || undefined;
811
837
 
812
- const sessions = await parseAllSessions({ days, projectFilter, excludeSessionPath });
838
+ const sessions = await parseAllSessions({ days, projectFilter });
813
839
 
814
840
  if (sessions.length === 0) {
815
841
  // Statusline must always emit a single line (no multi-line help spam every 300ms)
@@ -913,10 +939,16 @@ async function main() {
913
939
  }
914
940
 
915
941
  // Last API activity feeds the statusline TTL countdown.
916
- // For every session that wasn't excluded, take the full endTime (any API call
917
- // keeps the prefix cache warm — it doesn't matter whether it's user- or
918
- // agent-driven because the cache is shared across sessions by prefix content).
942
+ // For every session OTHER than the excluded (current) one, take the full
943
+ // endTime (any API call keeps the prefix cache warm — it doesn't matter
944
+ // whether it's user- or agent-driven because the cache is shared across
945
+ // sessions by prefix content). The current session is filtered here rather
946
+ // than at the parser, so its writes still inform ttlBreakdown above.
947
+ const excludeAbs = excludeSessionPath
948
+ ? (isAbsolute(excludeSessionPath) ? excludeSessionPath : join(process.cwd(), excludeSessionPath))
949
+ : null;
919
950
  const otherLastActivity = sessions
951
+ .filter((s) => !excludeAbs || s.filePath !== excludeAbs)
920
952
  .map((s) => (s.endTime ? s.endTime.getTime() : 0))
921
953
  .reduce((a, b) => Math.max(a, b), 0);
922
954
  // For the excluded (current) session, only the user's prompts count — the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "2.14.1",
3
+ "version": "2.15.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
  },
@@ -158,9 +158,14 @@ export function formatReport(data, { color = true, verbose = false, timer = true
158
158
 
159
159
  // TTL dominance → color signal (1h = good, 5m = warning).
160
160
  // The subscription plan fixes this, so the bucket rarely changes — it's the countdown that matters.
161
- const is1h = ttl.pct1h >= 0.5;
162
- const bucketLabel = is1h ? '1h' : '5m';
163
- const bucketColor = is1h ? GREEN : YELLOW;
161
+ // When ttl.total === 0 (no cache writes observed in the window), we cannot
162
+ // infer the bucket. Default to 1h-sized countdown rather than 5m so Max
163
+ // users on idle don't see a misleading "Cache expires 5:00". The bucket
164
+ // label is shown as "?" so the uncertainty is visible.
165
+ const hasTtlData = ttl.total > 0;
166
+ const is1h = hasTtlData ? ttl.pct1h >= 0.5 : true;
167
+ const bucketLabel = hasTtlData ? (is1h ? '1h' : '5m') : '?';
168
+ const bucketColor = hasTtlData ? (is1h ? GREEN : YELLOW) : GRAY;
164
169
  const ttlSeconds = is1h ? 3600 : 300;
165
170
 
166
171
  const savings = cost?.savings ?? 0;
package/src/harness.js CHANGED
@@ -67,28 +67,41 @@ 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
+ return { configured: 0, total: HARNESS_SECTIONS.length, missing: [], hasBlock: false, hasFile: true, optOut: false, custom: false, file: filePath };
92
105
  }
93
106
  const hasBlock = content.includes(HARNESS_BLOCK_BEGIN);
94
107
  // Opt-out marker — when the user intentionally customizes the harness block
@@ -114,10 +127,30 @@ export function harnessStatus(root = findProjectRoot()) {
114
127
  hasFile: true,
115
128
  optOut,
116
129
  custom,
117
- root,
130
+ file: filePath,
118
131
  };
119
132
  }
120
133
 
134
+ /**
135
+ * Harness status for a project, with scope control:
136
+ * scope 'project' — count only <root>/CLAUDE.md
137
+ * scope 'global' — count only ~/.claude/CLAUDE.md
138
+ * scope 'auto' (default) — use the project file if it carries the harness
139
+ * block, otherwise fall back to the global file. This makes a project that
140
+ * relies on a globally-installed harness report 🅷 5/5 (covered by global),
141
+ * matching reality: Claude Code loads ~/.claude/CLAUDE.md for every project.
142
+ * The returned `source` ('project'|'global') tells callers which file was used.
143
+ */
144
+ export function harnessStatus(root = findProjectRoot(), { scope = 'auto' } = {}) {
145
+ if (scope === 'project') return { ...statusForFile(claudeMdPath(root)), root, source: 'project' };
146
+ if (scope === 'global') return { ...statusForFile(globalClaudeMdPath()), root, source: 'global' };
147
+ const project = statusForFile(claudeMdPath(root));
148
+ if (project.hasBlock) return { ...project, root, source: 'project' };
149
+ const global = statusForFile(globalClaudeMdPath());
150
+ if (global.hasBlock) return { ...global, root, source: 'global' };
151
+ return { ...project, root, source: 'project' };
152
+ }
153
+
121
154
  /**
122
155
  * harness init — write CLAUDE.md (single file, 5 sections) + .claude/ratchet.md.
123
156
  * If CLAUDE.md exists, back it up to CLAUDE.md.bak-YYYYMMDD-HHMMSS first
@@ -125,10 +158,10 @@ export function harnessStatus(root = findProjectRoot()) {
125
158
  *
126
159
  * Returns { wrote: [], backedUp: [], skipped: [] } so the CLI can report.
127
160
  */
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 };
161
+ export function harnessInit({ root = findProjectRoot(), force = false, scope = 'project' } = {}) {
162
+ const cmPath = resolveClaudeMdPath(scope, root); // global → ~/.claude/CLAUDE.md
163
+ const rmPath = resolveRatchetPath(scope, root); // global → ~/.claude/ratchet.md
164
+ const result = { wrote: [], backedUp: [], skipped: [], root, scope };
132
165
 
133
166
  // CLAUDE.md
134
167
  const block = harnessClaudeMdBlock();
@@ -158,11 +191,12 @@ export function harnessInit({ root = findProjectRoot(), force = false } = {}) {
158
191
  result.wrote.push(cmPath + ' (harness block appended)');
159
192
  }
160
193
  } else {
194
+ mkdirSync(dirname(cmPath), { recursive: true }); // global: ensure ~/.claude exists
161
195
  writeFileSync(cmPath, block);
162
196
  result.wrote.push(cmPath);
163
197
  }
164
198
 
165
- // .claude/ratchet.md (only if missing — don't clobber user-grown rules)
199
+ // ratchet.md (only if missing — don't clobber user-grown rules)
166
200
  if (!existsSync(rmPath)) {
167
201
  mkdirSync(dirname(rmPath), { recursive: true });
168
202
  writeFileSync(rmPath, harnessRatchetMdInitial());
@@ -181,10 +215,10 @@ export function harnessInit({ root = findProjectRoot(), force = false } = {}) {
181
215
  *
182
216
  * Returns { removed: [], backedUp: [], skipped: [] }.
183
217
  */
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 };
218
+ export function harnessUninit({ root = findProjectRoot(), purgeRatchet = false, scope = 'project' } = {}) {
219
+ const cmPath = resolveClaudeMdPath(scope, root);
220
+ const rmPath = resolveRatchetPath(scope, root);
221
+ const result = { removed: [], backedUp: [], skipped: [], root, scope };
188
222
 
189
223
  if (existsSync(cmPath)) {
190
224
  const existing = readFileSync(cmPath, 'utf8');