claude-token-saver 2.9.3 → 2.10.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
@@ -12,6 +12,24 @@ A CLI to **diagnose and save tokens on Claude Code**. Cache hit rate, TTL countd
12
12
 
13
13
  ## Install
14
14
 
15
+ ### Prerequisite — Node.js (≥ 18)
16
+
17
+ `npm` ships with Node.js. Check whether it's installed:
18
+
19
+ ```bash
20
+ node -v # v18.0.0 or later is fine
21
+ ```
22
+
23
+ If not, install it:
24
+
25
+ - **macOS** — `brew install node` (Homebrew) or the installer at [nodejs.org](https://nodejs.org/)
26
+ - **Windows** — [nodejs.org](https://nodejs.org/) LTS installer, or `winget install OpenJS.NodeJS.LTS`
27
+ - **Linux / WSL** — your distro's package manager (`apt install nodejs npm`, etc.) or — recommended — [nvm](https://github.com/nvm-sh/nvm) for a user-scoped install (no sudo)
28
+
29
+ > Avoid installing globally with `sudo`. The postinstall hook writes the Skill into root's `~/.claude` instead of yours, and auto-registration silently misses. Use nvm/fnm/Volta, or set `npm config set prefix ~/.npm-global` first.
30
+
31
+ ### Install claude-token-saver
32
+
15
33
  ```bash
16
34
  # (existing users) remove the old package
17
35
  npm uninstall -g claude-cache-monitor
@@ -191,6 +209,9 @@ Node.js ≥ 18 · macOS / Windows / Linux / WSL · zero dependencies.
191
209
 
192
210
  ## Release notes
193
211
 
212
+ ### v2.9.4 (2026-04-27)
213
+ - README now opens with a Node.js prerequisite block (macOS / Windows / Linux). First-time visitors arriving from GitHub no longer hit `npm: command not found` with no guidance. Also flags the `sudo` global-install trap where postinstall writes the Skill under root's home instead of the user's.
214
+
194
215
  ### v2.9.3 (2026-04-27)
195
216
  - Skill body (`SKILL.md`) now instructs Claude to respond in the user's configured output language. Previously even when the CLI was on `mode ko`, Claude itself still narrated the answer in English ("All clear — no warnings…"), so the language toggle felt half-applied.
196
217
  - `installSkill` now auto-updates the on-disk `SKILL.md` whenever the bundled body differs, so upgrades pick up new instructions without `--force`.
package/README.md CHANGED
@@ -12,6 +12,24 @@ Claude Code의 **토큰 사용량을 진단·절약**하는 CLI. 캐시 히트
12
12
 
13
13
  ## 설치
14
14
 
15
+ ### 사전 준비 — Node.js (≥ 18) 필요
16
+
17
+ `npm`은 Node.js에 포함되어 있습니다. 설치돼 있는지 확인:
18
+
19
+ ```bash
20
+ node -v # v18.0.0 이상이면 OK
21
+ ```
22
+
23
+ 설치되어 있지 않다면:
24
+
25
+ - **macOS** — `brew install node` (Homebrew) 또는 [nodejs.org](https://nodejs.org/) 설치 프로그램
26
+ - **Windows** — [nodejs.org](https://nodejs.org/) LTS 설치 프로그램, 또는 `winget install OpenJS.NodeJS.LTS`
27
+ - **Linux / WSL** — 배포판 패키지 매니저(`apt install nodejs npm` 등) 또는 [nvm](https://github.com/nvm-sh/nvm)으로 사용자 영역 설치 (sudo 없이 가능, 추천)
28
+
29
+ > sudo로 글로벌 설치하면 postinstall 훅이 root의 `~/.claude`에 SKILL을 만들어 자동 등록이 어긋납니다. 가능하면 nvm/fnm/Volta로 사용자 영역에 Node를 설치하거나 `npm config set prefix ~/.npm-global` 같은 prefix 변경 후 사용하세요.
30
+
31
+ ### claude-token-saver 설치
32
+
15
33
  ```bash
16
34
  # (기존 사용자) 구 패키지 제거
17
35
  npm uninstall -g claude-cache-monitor
@@ -151,6 +169,9 @@ Node.js ≥ 18 · macOS / Linux / Windows / WSL · 의존성 0.
151
169
 
152
170
  ## 릴리스 노트
153
171
 
172
+ ### v2.9.4 (2026-04-27)
173
+ - README에 Node.js 사전 설치 안내 추가 (macOS/Windows/Linux별). GitHub에서 처음 본 사용자가 npm 명령부터 막히는 일을 방지. sudo 글로벌 설치 시 postinstall이 root 홈에 SKILL을 만드는 함정도 함께 안내.
174
+
154
175
  ### v2.9.3 (2026-04-27)
155
176
  - Skill 본문(`SKILL.md`)에 "사용자 설정 언어로 응답" 지시 추가. 이전엔 Skill이 호출돼도 Claude가 영어로 요약을 생성하는 탓에 `mode ko` 상태에서도 영문 답이 나왔습니다 (`All clear - no warnings...` 같은 문구).
156
177
  - `installSkill`이 번들된 SKILL.md와 디스크의 내용이 다르면 자동 갱신하도록 변경 (`--force` 없이도 업그레이드 시 새 지시가 적용됨).
package/bin/cli.js CHANGED
@@ -444,6 +444,133 @@ async function main() {
444
444
  return;
445
445
  }
446
446
 
447
+ // Subcommand: harness — manage the project's CLAUDE.md harness rules.
448
+ // claude-token-saver harness init # write CLAUDE.md (5 sections) + ratchet.md
449
+ // claude-token-saver harness check # show 🅷 N/5 + which sections are missing
450
+ // claude-token-saver harness promote "<rule>" # append a rule to ratchet.md
451
+ // claude-token-saver harness off | on # toggle the statusline 🅷 segment
452
+ if (args[0] === 'harness') {
453
+ const sub = args[1];
454
+ const { harnessInit, harnessStatus, harnessPromote, findProjectRoot } =
455
+ await import('../src/harness.js');
456
+ const { HARNESS_SECTIONS } = await import('../src/harness-templates.js');
457
+ const { loadConfig, saveConfig } = await import('../src/config.js');
458
+
459
+ if (!sub || sub === 'check') {
460
+ const root = findProjectRoot();
461
+ const s = harnessStatus(root);
462
+ console.log(`🅷 ${s.configured}/${s.total} — ${root}`);
463
+ console.log(`CLAUDE.md: ${s.hasFile ? 'present' : 'missing'}` +
464
+ (s.hasFile ? `, harness block: ${s.hasBlock ? 'yes' : 'no'}` : ''));
465
+ if (s.missing.length) {
466
+ console.log('Missing sections:');
467
+ for (const id of s.missing) {
468
+ const sec = HARNESS_SECTIONS.find((x) => x.id === id);
469
+ console.log(` - ${id}: ${sec ? sec.heading.replace(/^#+\s*/, '') : ''}`);
470
+ }
471
+ console.log('\nRun: claude-token-saver harness init');
472
+ } else {
473
+ console.log('All 5 harness sections present. ✅');
474
+ }
475
+ return;
476
+ }
477
+
478
+ if (sub === 'init') {
479
+ const force = hasFlag('--force');
480
+ const r = harnessInit({ force });
481
+ console.log(`Project root: ${r.root}`);
482
+ for (const p of r.backedUp) console.log(`Backed up: ${p}`);
483
+ for (const p of r.wrote) console.log(`Wrote: ${p}`);
484
+ for (const p of r.skipped) console.log(`Skipped: ${p}`);
485
+ console.log('\n🅷 Harness initialized. Statusline will show 🅷 5/5 on next refresh.');
486
+ return;
487
+ }
488
+
489
+ if (sub === 'promote') {
490
+ const raw = args.slice(2).join(' ').trim();
491
+ if (!raw) {
492
+ console.error('Usage: claude-token-saver harness promote <N> # from statusline 🅷⚠ ratchet? #N');
493
+ console.error(' or: claude-token-saver harness promote "<rule text>"');
494
+ process.exit(1);
495
+ }
496
+ let rule = raw;
497
+ // Numeric arg → look up candidate #N from analyzer state and turn its
498
+ // detected error pattern into a starter ratchet rule. Saves the user
499
+ // from retyping the error; they can edit ratchet.md afterward.
500
+ if (/^\d+$/.test(raw)) {
501
+ const n = parseInt(raw, 10);
502
+ const analyzer = await import('../src/harness-analyzer.cjs');
503
+ const a = analyzer.default || analyzer;
504
+ const state = a.readState();
505
+ const list = (state && state.ratchetCandidates) || [];
506
+ const cand = list.find((c) => c.id === n);
507
+ if (!cand) {
508
+ console.error(`No ratchet candidate #${n} in state. Run \`harness analyze\` or wait for the hook to populate it.`);
509
+ if (list.length) {
510
+ console.error('Available candidates:');
511
+ for (const c of list) console.error(` #${c.id} (×${c.count}): ${c.pattern}`);
512
+ }
513
+ process.exit(1);
514
+ }
515
+ rule = `반복 감지 ×${cand.count}: ${cand.pattern} — TODO: 원인·예방책 한 줄로`;
516
+ }
517
+ const r = harnessPromote(rule);
518
+ console.log(`Appended to ${r.path}:`);
519
+ console.log(` - ${rule}`);
520
+ if (/^\d+$/.test(raw)) {
521
+ console.log('\n👉 ratchet.md를 열어 TODO 부분을 실제 룰로 다듬어주세요.');
522
+ }
523
+ return;
524
+ }
525
+
526
+ if (sub === 'analyze') {
527
+ // Run the analyzer once against the most recent session JSONL under
528
+ // ~/.claude/projects/ and dump the resulting state. Useful for users
529
+ // who don't have the hook installed but want to see warnings.
530
+ const analyzer = await import('../src/harness-analyzer.cjs');
531
+ const { analyzeTranscript, writeState } = analyzer.default || analyzer;
532
+ const { readdirSync, statSync } = await import('node:fs');
533
+ const { join: pj } = await import('node:path');
534
+ const { homedir } = await import('node:os');
535
+ const dir = pj(homedir(), '.claude', 'projects');
536
+ let latest = null;
537
+ let latestMtime = 0;
538
+ try {
539
+ for (const subdir of readdirSync(dir)) {
540
+ const full = pj(dir, subdir);
541
+ if (!statSync(full).isDirectory()) continue;
542
+ for (const f of readdirSync(full)) {
543
+ if (!f.endsWith('.jsonl')) continue;
544
+ const fp = pj(full, f);
545
+ const m = statSync(fp).mtimeMs;
546
+ if (m > latestMtime) { latestMtime = m; latest = fp; }
547
+ }
548
+ }
549
+ } catch {}
550
+ if (!latest) {
551
+ console.error('No session transcripts found under ~/.claude/projects/');
552
+ process.exit(1);
553
+ }
554
+ const state = analyzeTranscript(latest, { cwd: process.cwd() });
555
+ if (state) writeState(state);
556
+ console.log(JSON.stringify(state, null, 2));
557
+ return;
558
+ }
559
+
560
+ if (sub === 'off' || sub === 'on') {
561
+ const cfg = loadConfig();
562
+ cfg.harness = cfg.harness || {};
563
+ cfg.harness.enabled = sub === 'on';
564
+ saveConfig(cfg);
565
+ console.log(`Statusline 🅷 segment: ${sub}`);
566
+ return;
567
+ }
568
+
569
+ console.error(`Unknown harness subcommand: ${sub}`);
570
+ console.error('Usage: claude-token-saver harness [check|init|promote "<rule>"|off|on]');
571
+ process.exit(1);
572
+ }
573
+
447
574
  // Hook management
448
575
  if (hasFlag('--install-hook')) {
449
576
  const { installHook } = await import('../src/hook-manager.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "2.9.3",
3
+ "version": "2.10.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": {
@@ -19,6 +19,8 @@
19
19
 
20
20
  import { formatResetClock } from '../format-time.js';
21
21
  import { labelForKey } from '../window-labels.js';
22
+ import { harnessStatusForStatusline } from '../harness.js';
23
+ import { loadConfig } from '../config.js';
22
24
 
23
25
  // The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
24
26
  // next to each other — terminal palettes set them with unbalanced perceptual
@@ -268,6 +270,31 @@ export function formatReport(data, { color = true, verbose = false, timer = true
268
270
  // Spike chip — one word only, keeps the statusline single-line.
269
271
  const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
270
272
 
273
+ // Harness 🅷 N/5 — project-scoped completeness of CLAUDE.md harness rules.
274
+ // Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
275
+ // otherwise renders 🅷 5/5 (green) / 🅷 N/5 (yellow) so the user can spot
276
+ // a missing section at a glance and know to run `harness init`.
277
+ let harnessSeg = null;
278
+ try {
279
+ const harnessInfo = harnessStatusForStatusline(loadConfig());
280
+ if (harnessInfo) {
281
+ const icon = isIcon ? '🅷' : 'H';
282
+ if (harnessInfo.warning) {
283
+ // Warning state outranks the N/5 count — a runtime issue (repeated
284
+ // error / no-evidence / racing edits) is more actionable than a
285
+ // missing ratchet section. Always red so it stands out.
286
+ harnessSeg = `${c(RED)}${icon}⚠ ${harnessInfo.warning}${c(RESET)}`;
287
+ } else {
288
+ const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
289
+ harnessSeg = `${c(tone)}${icon} ${harnessInfo.configured}/${harnessInfo.total}${c(RESET)}`;
290
+ }
291
+ }
292
+ } catch {
293
+ // Harness check is best-effort — never break the statusline if the file
294
+ // read fails (corrupted CLAUDE.md, permission issue, etc.).
295
+ harnessSeg = null;
296
+ }
297
+
271
298
  // Model chip — pulled from Claude Code's stdin payload (`model.display_name`).
272
299
  // Cheap identity context: useful when the user toggles between Sonnet/Opus
273
300
  // mid-session and wants to confirm at a glance which one is answering.
@@ -382,6 +409,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
382
409
  const segs = [];
383
410
  if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
384
411
  if (spikeSeg && want('spike')) segs.push(spikeSeg);
412
+ if (harnessSeg && want('harness')) segs.push(harnessSeg);
385
413
  if (modelSeg && want('model')) segs.push(modelSeg);
386
414
  if (want('hit')) segs.push(hitSeg);
387
415
  if (want('ttl')) segs.push(ttlSeg);
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Harness analyzer — scans a session transcript JSONL for warning signals
3
+ * and writes a small state file the statusline can read cheaply.
4
+ *
5
+ * Three signals (precedence: ratchet? > no-evidence > PEV-skip):
6
+ *
7
+ * 1. Ratchet candidate — same is_error tool_use_result appears 2+ times in
8
+ * the last 30 turns. Suggests the user codify a rule so it doesn't repeat.
9
+ *
10
+ * 2. Evidence rate — fraction of recent assistant messages that ship proof
11
+ * (code blocks, tool_use_result, "test"/"output"/"diff"/"screenshot"
12
+ * keywords). <30% → ⚠ no-evidence — high chance the model is reporting
13
+ * "done" without showing it.
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.
18
+ *
19
+ * CommonJS so hook.cjs can `require()` it without a bundler step.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+ const os = require('node:os');
27
+
28
+ const STATE_DIR = stateDir();
29
+ const STATE_PATH = path.join(STATE_DIR, 'harness-state.json');
30
+
31
+ const RECENT_TURNS = 15; // PEV / evidence window
32
+ const RATCHET_TURNS = 30; // ratchet-candidate window
33
+ const EVIDENCE_THRESHOLD = 0.3; // <30% → ⚠ no-evidence
34
+ const PEV_TOOLUSE_THRESHOLD = 5;
35
+
36
+ function stateDir() {
37
+ if (process.platform === 'win32') {
38
+ return path.join(process.env.APPDATA || os.homedir(), 'claude-token-saver');
39
+ }
40
+ if (process.platform === 'darwin') {
41
+ return path.join(os.homedir(), 'Library', 'Application Support', 'claude-token-saver');
42
+ }
43
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
44
+ return path.join(xdg, 'claude-token-saver');
45
+ }
46
+
47
+ function readJsonl(file) {
48
+ let raw;
49
+ try {
50
+ raw = fs.readFileSync(file, 'utf8');
51
+ } catch {
52
+ return [];
53
+ }
54
+ const out = [];
55
+ for (const line of raw.split('\n')) {
56
+ if (!line.trim()) continue;
57
+ try {
58
+ out.push(JSON.parse(line));
59
+ } catch {
60
+ // ignore corrupt line
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Pull the text payload out of an assistant message, regardless of whether
68
+ * Claude Code stored it as a string, a content-block array, or a mix.
69
+ */
70
+ function assistantText(msg) {
71
+ if (!msg) return '';
72
+ if (typeof msg.content === 'string') return msg.content;
73
+ if (Array.isArray(msg.content)) {
74
+ return msg.content
75
+ .map((b) => {
76
+ if (typeof b === 'string') return b;
77
+ if (b && b.type === 'text') return b.text || '';
78
+ return '';
79
+ })
80
+ .join('\n');
81
+ }
82
+ return '';
83
+ }
84
+
85
+ function toolUsesIn(msg) {
86
+ if (!msg || !Array.isArray(msg.content)) return [];
87
+ return msg.content.filter((b) => b && b.type === 'tool_use');
88
+ }
89
+
90
+ function toolResultsIn(msg) {
91
+ if (!msg || !Array.isArray(msg.content)) return [];
92
+ return msg.content.filter((b) => b && b.type === 'tool_result');
93
+ }
94
+
95
+ function looksLikeEvidence(text) {
96
+ if (!text) return false;
97
+ // Code block (``` …) is the cheapest evidence signal — almost always
98
+ // present when the assistant shows actual command output or a diff.
99
+ if (/```[\s\S]*?```/.test(text)) return true;
100
+ const lower = text.toLowerCase();
101
+ // Korean + English keywords. Order doesn't matter — first hit wins.
102
+ const evidenceWords = [
103
+ 'stdout', 'output', 'diff', 'screenshot', 'passed', 'pytest', 'jest',
104
+ 'test result', 'verified', 'verifying',
105
+ '출력', '결과', '스크린샷', '통과', '검증', '확인했', '확인됨',
106
+ ];
107
+ return evidenceWords.some((w) => lower.includes(w));
108
+ }
109
+
110
+ function looksLikePlanSignal(text, toolUses) {
111
+ if (toolUses.some((t) => /^todowrite$/i.test(t.name || ''))) return true;
112
+ if (!text) return false;
113
+ const lower = text.toLowerCase();
114
+ const planWords = ['plan', 'phase', 'step 1', 'step1', 'first,', 'next,',
115
+ '단계', '계획', '먼저', '다음으로', '순서대로'];
116
+ return planWords.some((w) => lower.includes(w));
117
+ }
118
+
119
+ function errorSignature(toolResult) {
120
+ if (!toolResult || toolResult.is_error !== true) return null;
121
+ const c = toolResult.content;
122
+ let txt = '';
123
+ if (typeof c === 'string') txt = c;
124
+ else if (Array.isArray(c)) {
125
+ txt = c.map((b) => (b && typeof b.text === 'string' ? b.text : '')).join(' ');
126
+ }
127
+ txt = txt.replace(/\s+/g, ' ').trim();
128
+ if (!txt) return null;
129
+ // First 80 chars is enough to dedupe most repeated errors without overfitting
130
+ // to volatile bits like timestamps or pids.
131
+ return txt.slice(0, 80);
132
+ }
133
+
134
+ /**
135
+ * Walk the last `RATCHET_TURNS` turns and surface error signatures that
136
+ * appear 2+ times. Returns the top candidate (or null).
137
+ */
138
+ function findRatchetCandidates(entries) {
139
+ const counts = new Map();
140
+ const recent = entries.slice(-RATCHET_TURNS);
141
+ for (const e of recent) {
142
+ const msg = e && e.message;
143
+ if (!msg) continue;
144
+ for (const tr of toolResultsIn(msg)) {
145
+ const sig = errorSignature(tr);
146
+ if (!sig) continue;
147
+ const cur = counts.get(sig) || { count: 0, lastAt: e.timestamp };
148
+ cur.count += 1;
149
+ cur.lastAt = e.timestamp || cur.lastAt;
150
+ counts.set(sig, cur);
151
+ }
152
+ }
153
+ const list = [];
154
+ for (const [sig, info] of counts.entries()) {
155
+ if (info.count < 2) continue;
156
+ list.push({ pattern: sig, count: info.count, lastAt: info.lastAt });
157
+ }
158
+ // Top 5 by count, ID assigned in rank order so `harness promote 1` always
159
+ // targets the most-repeated error — stable even as new candidates appear.
160
+ list.sort((a, b) => b.count - a.count);
161
+ return list.slice(0, 5).map((c, i) => ({ id: i + 1, ...c }));
162
+ }
163
+
164
+ /**
165
+ * Evidence rate — over the last RECENT_TURNS *assistant* messages, the
166
+ * fraction that ship proof (code block / keywords). Tool_result blocks in
167
+ * the immediate next user message also count as "shown the work."
168
+ */
169
+ 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);
184
+ }
185
+ }
186
+ if (assistants.length === 0) return null;
187
+ const proofCount = assistants.filter(Boolean).length;
188
+ return proofCount / assistants.length;
189
+ }
190
+
191
+ function computePevSkip(entries) {
192
+ const recent = entries.slice(-RECENT_TURNS);
193
+ let toolUseCount = 0;
194
+ let planSignal = false;
195
+ for (const e of recent) {
196
+ if (!e || e.type !== 'assistant') continue;
197
+ const text = assistantText(e.message);
198
+ const tus = toolUsesIn(e.message);
199
+ toolUseCount += tus.length;
200
+ if (looksLikePlanSignal(text, tus)) planSignal = true;
201
+ }
202
+ return toolUseCount >= PEV_TOOLUSE_THRESHOLD && !planSignal;
203
+ }
204
+
205
+ function analyzeTranscript(transcriptPath, opts) {
206
+ opts = opts || {};
207
+ const entries = readJsonl(transcriptPath);
208
+ if (entries.length === 0) return null;
209
+ const evidenceRate = computeEvidenceRate(entries);
210
+ const pevSkip = computePevSkip(entries);
211
+ const ratchetCandidates = findRatchetCandidates(entries);
212
+ return {
213
+ sessionId: opts.sessionId || null,
214
+ cwd: opts.cwd || null,
215
+ transcriptPath: transcriptPath,
216
+ timestamp: new Date().toISOString(),
217
+ evidenceRate: evidenceRate,
218
+ evidenceLow: evidenceRate !== null && evidenceRate < EVIDENCE_THRESHOLD,
219
+ pevSkip: pevSkip,
220
+ ratchetCandidate: ratchetCandidates[0] || null, // back-compat
221
+ ratchetCandidates: ratchetCandidates,
222
+ };
223
+ }
224
+
225
+ function writeState(state) {
226
+ if (!state) return;
227
+ try {
228
+ if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
229
+ fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + '\n');
230
+ } catch {
231
+ // best-effort
232
+ }
233
+ }
234
+
235
+ function readState() {
236
+ try {
237
+ if (!fs.existsSync(STATE_PATH)) return null;
238
+ return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ module.exports = {
245
+ analyzeTranscript,
246
+ writeState,
247
+ readState,
248
+ STATE_PATH,
249
+ EVIDENCE_THRESHOLD,
250
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Harness templates — single-file CLAUDE.md (5 sections) + ratchet.md.
3
+ *
4
+ * Section markers (HARNESS_SECTIONS) are the source of truth for completeness
5
+ * detection: harness/check counts how many of these headers appear in the
6
+ * project's CLAUDE.md, and the statusline 🅷 N/5 indicator reports the same.
7
+ */
8
+
9
+ export const HARNESS_BLOCK_BEGIN = '<!-- claude-token-saver:harness:begin -->';
10
+ export const HARNESS_BLOCK_END = '<!-- claude-token-saver:harness:end -->';
11
+
12
+ export const HARNESS_SECTIONS = [
13
+ { id: 'ratchet', heading: '### 1. Ratchet — 같은 실수는 두 번 안 한다' },
14
+ { id: 'evidence', heading: '### 2. Evidence — "다 됐어요" 금지' },
15
+ { id: 'pev', heading: '### 3. PEV — Plan → Execute → Verify' },
16
+ { id: 'structured', heading: '### 4. Structured Task — 입력 구조화' },
17
+ { id: 'safe-path', heading: '### 5. Default Safe Path — 파괴적 명령 항상 확인' },
18
+ ];
19
+
20
+ export function harnessClaudeMdBlock() {
21
+ const sections = HARNESS_SECTIONS.map((s) => s.heading).join('\n\n... (see full block below)');
22
+ return `${HARNESS_BLOCK_BEGIN}
23
+ ## 🅷 Harness Rules (claude-token-saver)
24
+
25
+ 이 섹션은 \`claude-token-saver harness init\`이 생성합니다. 5가지 원칙 모두를
26
+ 지키면 statusline에 \`🅷 5/5\`로 표시되고, 빠진 게 있으면 \`🅷 3/5\` 식으로
27
+ 경고합니다. 수정해도 무방하지만, 섹션 헤더(### 1. ~ ### 5.)는 검출용이므로
28
+ 지우지 마세요.
29
+
30
+ ${HARNESS_SECTIONS[0].heading}
31
+ - 같은 에러·오해·반복 작업이 한 번 더 발생하면 즉시 \`.claude/ratchet.md\`에
32
+ "조건 → 행동" 한 줄로 룰 추가.
33
+ - claude-token-saver가 후보를 감지하면 statusline에 \`🅷⚠ ratchet?\`로 알림.
34
+ \`claude-token-saver harness promote "<rule>"\`로 승인.
35
+ - 승인된 룰은 다음 세션부터 자동 적용.
36
+
37
+ ${HARNESS_SECTIONS[1].heading}
38
+ 완료 보고("다 됐어요", "테스트 통과") 시 다음 중 1개 이상을 항상 첨부:
39
+ - 테스트 실행 결과 (실제 stdout)
40
+ - 변경 파일 diff (file:line)
41
+ - UI 작업이면 스크린샷
42
+ - 명령 실행 출력
43
+
44
+ 증거 없는 완료 보고는 거짓일 확률이 매우 높음. 토큰 낭비의 주범.
45
+
46
+ ${HARNESS_SECTIONS[2].heading}
47
+ 3단계 이상 작업은 다음 사이클을 강제:
48
+ 1. **Plan** — 텍스트로 단계 명시 (TodoWrite 권장)
49
+ 2. **Execute** — 한 단계씩 실행, 결과 확인
50
+ 3. **Verify** — 테스트·실행·grep 등으로 결과 검증
51
+
52
+ Verify를 건너뛰면 statusline에 \`🅷⚠ PEV-skip\` 표시.
53
+ 0.85의 10제곱 ≈ 0.20 — 단계당 85%만 맞아도 10단계면 80% 실패.
54
+
55
+ ${HARNESS_SECTIONS[3].heading}
56
+ 새 작업 시작 시 다음 4줄을 먼저 채울 것 (입력이 구조화돼야 출력도 구조화됨):
57
+ - **목표:** 한 문장으로
58
+ - **제약:** 시간·범위·금지사항
59
+ - **검증 방법:** 어떻게 "됐다"고 판정할지
60
+ - **완료 기준:** 무엇이 통과하면 완료인지
61
+
62
+ ${HARNESS_SECTIONS[4].heading}
63
+ 다음 작업은 **항상** 사용자 확인 후 실행:
64
+ - 파괴적 명령: \`rm -rf\`, force push, drop table, kill process
65
+ - 외부 시스템: deploy, slack 발송, 댓글 작성, PR merge
66
+ - 비가역적: amend pushed commit, branch -D
67
+
68
+ 단순 read·local edit·테스트 실행은 묻지 말고 즉시 진행 (마찰 최소화).
69
+
70
+ ---
71
+
72
+ 📌 운영:
73
+ - \`claude-token-saver harness check\` — 현재 셋업 점수
74
+ - \`claude-token-saver harness promote "<룰>"\` — ratchet에 룰 추가
75
+ - \`claude-token-saver harness off\` — statusline 표시 끄기
76
+ ${HARNESS_BLOCK_END}
77
+ `;
78
+ }
79
+
80
+ export function harnessRatchetMdInitial() {
81
+ return `# Ratchet Rules (auto-grown by claude-token-saver)
82
+
83
+ 같은 실수가 두 번 발생하면 여기에 한 줄 추가됩니다. 형식: "YYYY-MM-DD: <조건> → <행동>".
84
+
85
+ \`claude-token-saver harness promote "<rule>"\`로 룰을 추가하면 자동으로
86
+ 이 파일에 append 됩니다.
87
+
88
+ ## Rules
89
+
90
+ `;
91
+ }
92
+
93
+ /**
94
+ * Append a rule to ratchet.md content. Safe to call on the initial template
95
+ * or on a user-edited file: we just add to the end. Rules are dated.
96
+ */
97
+ export function appendRatchetRule(existing, ruleText) {
98
+ const today = new Date().toISOString().slice(0, 10);
99
+ const line = `- ${today}: ${ruleText.trim()}\n`;
100
+ // Ensure trailing newline so the new rule lands on its own line.
101
+ const base = existing.endsWith('\n') ? existing : existing + '\n';
102
+ return base + line;
103
+ }
package/src/harness.js ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Harness module — manages CLAUDE.md (single file, 5 sections), ratchet.md,
3
+ * and reports completeness for the statusline 🅷 N/5 indicator.
4
+ *
5
+ * Detection is project-scoped: we look at the current working directory's
6
+ * CLAUDE.md (or the nearest one walking up to the git root). Statusline calls
7
+ * harnessStatus() per render — keep it cheap (read + regex, no parsing).
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
11
+ import { join, resolve, dirname } from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+ import {
14
+ HARNESS_SECTIONS,
15
+ HARNESS_BLOCK_BEGIN,
16
+ HARNESS_BLOCK_END,
17
+ harnessClaudeMdBlock,
18
+ harnessRatchetMdInitial,
19
+ appendRatchetRule,
20
+ } from './harness-templates.js';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ function readHarnessState() {
24
+ try {
25
+ const a = require('./harness-analyzer.cjs');
26
+ return a.readState();
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Walk up from `start` looking for a project root marker (CLAUDE.md, .git,
34
+ * or package.json). Falls back to `start` itself so harness commands always
35
+ * have *some* directory to write into, even outside a repo.
36
+ */
37
+ export function findProjectRoot(start = process.cwd()) {
38
+ let dir = resolve(start);
39
+ for (;;) {
40
+ if (
41
+ existsSync(join(dir, 'CLAUDE.md')) ||
42
+ existsSync(join(dir, '.git')) ||
43
+ existsSync(join(dir, 'package.json'))
44
+ ) {
45
+ return dir;
46
+ }
47
+ const parent = dirname(dir);
48
+ if (parent === dir) return resolve(start);
49
+ dir = parent;
50
+ }
51
+ }
52
+
53
+ function claudeMdPath(root) {
54
+ return join(root, 'CLAUDE.md');
55
+ }
56
+
57
+ function ratchetMdPath(root) {
58
+ return join(root, '.claude', 'ratchet.md');
59
+ }
60
+
61
+ /**
62
+ * Count how many of the 5 harness sections appear in the project's CLAUDE.md.
63
+ * Returns { configured, total, missing, hasBlock }. Cheap enough to call from
64
+ * statusline — single file read + regex.
65
+ */
66
+ export function harnessStatus(root = findProjectRoot()) {
67
+ const path = claudeMdPath(root);
68
+ if (!existsSync(path)) {
69
+ return {
70
+ configured: 0,
71
+ total: HARNESS_SECTIONS.length,
72
+ missing: HARNESS_SECTIONS.map((s) => s.id),
73
+ hasBlock: false,
74
+ hasFile: false,
75
+ root,
76
+ };
77
+ }
78
+ let content = '';
79
+ try {
80
+ content = readFileSync(path, 'utf8');
81
+ } catch {
82
+ return { configured: 0, total: HARNESS_SECTIONS.length, missing: [], hasBlock: false, hasFile: true, root };
83
+ }
84
+ const hasBlock = content.includes(HARNESS_BLOCK_BEGIN);
85
+ const present = [];
86
+ const missing = [];
87
+ for (const s of HARNESS_SECTIONS) {
88
+ if (content.includes(s.heading)) present.push(s.id);
89
+ else missing.push(s.id);
90
+ }
91
+ return {
92
+ configured: present.length,
93
+ total: HARNESS_SECTIONS.length,
94
+ missing,
95
+ hasBlock,
96
+ hasFile: true,
97
+ root,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * harness init — write CLAUDE.md (single file, 5 sections) + .claude/ratchet.md.
103
+ * If CLAUDE.md exists, back it up to CLAUDE.md.bak-YYYYMMDD-HHMMSS first
104
+ * (per user-confirmed design: backup, then overwrite with the harness block).
105
+ *
106
+ * Returns { wrote: [], backedUp: [], skipped: [] } so the CLI can report.
107
+ */
108
+ export function harnessInit({ root = findProjectRoot(), force = false } = {}) {
109
+ const cmPath = claudeMdPath(root);
110
+ const rmPath = ratchetMdPath(root);
111
+ const result = { wrote: [], backedUp: [], skipped: [], root };
112
+
113
+ // CLAUDE.md
114
+ const block = harnessClaudeMdBlock();
115
+ if (existsSync(cmPath)) {
116
+ const existing = readFileSync(cmPath, 'utf8');
117
+ if (existing.includes(HARNESS_BLOCK_BEGIN) && !force) {
118
+ // Already has a harness block — replace it in-place, preserving the
119
+ // user's other content above/below.
120
+ const re = new RegExp(
121
+ `${escapeRe(HARNESS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(HARNESS_BLOCK_END)}\\n?`,
122
+ 'm',
123
+ );
124
+ const next = existing.replace(re, block);
125
+ writeFileSync(cmPath, next);
126
+ result.wrote.push(cmPath + ' (block updated in place)');
127
+ } else {
128
+ // Backup, then overwrite with the harness block. User-confirmed design:
129
+ // single CLAUDE.md, backup-then-overwrite (not append) so a clean reset
130
+ // is always one command away.
131
+ const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15); // YYYYMMDDTHHMMSS
132
+ const bak = `${cmPath}.bak-${stamp}`;
133
+ writeFileSync(bak, existing);
134
+ writeFileSync(cmPath, block);
135
+ result.backedUp.push(bak);
136
+ result.wrote.push(cmPath);
137
+ }
138
+ } else {
139
+ writeFileSync(cmPath, block);
140
+ result.wrote.push(cmPath);
141
+ }
142
+
143
+ // .claude/ratchet.md (only if missing — don't clobber user-grown rules)
144
+ if (!existsSync(rmPath)) {
145
+ mkdirSync(dirname(rmPath), { recursive: true });
146
+ writeFileSync(rmPath, harnessRatchetMdInitial());
147
+ result.wrote.push(rmPath);
148
+ } else {
149
+ result.skipped.push(rmPath + ' (already exists)');
150
+ }
151
+
152
+ return result;
153
+ }
154
+
155
+ /**
156
+ * harness promote — append a one-line rule to .claude/ratchet.md.
157
+ * Creates the file from the initial template if missing.
158
+ */
159
+ export function harnessPromote(ruleText, { root = findProjectRoot() } = {}) {
160
+ const rmPath = ratchetMdPath(root);
161
+ let existing = '';
162
+ if (existsSync(rmPath)) {
163
+ existing = readFileSync(rmPath, 'utf8');
164
+ } else {
165
+ mkdirSync(dirname(rmPath), { recursive: true });
166
+ existing = harnessRatchetMdInitial();
167
+ }
168
+ const next = appendRatchetRule(existing, ruleText);
169
+ writeFileSync(rmPath, next);
170
+ return { path: rmPath, root };
171
+ }
172
+
173
+ /**
174
+ * Statusline segment shape for the 🅷 indicator. Returns null when the user
175
+ * has explicitly disabled harness display, or when there's no CLAUDE.md and
176
+ * no .claude/ at all (silent in non-init'd projects so we don't nag).
177
+ */
178
+ export function harnessStatusForStatusline(cfg, { root } = {}) {
179
+ if (cfg && cfg.harness && cfg.harness.enabled === false) return null;
180
+ const projectRoot = root || findProjectRoot();
181
+ const status = harnessStatus(projectRoot);
182
+ // Silent when the project has neither CLAUDE.md nor a .claude/ dir — the
183
+ // user hasn't opted in, no point nagging.
184
+ if (!status.hasFile && !existsSync(join(projectRoot, '.claude'))) return null;
185
+ // Attach a warning derived from the analyzer state file (if any). Precedence:
186
+ // ratchet? > no-evidence > PEV-skip. Only surfaces when the state's
187
+ // sessionId or cwd matches this project, so unrelated sessions don't leak.
188
+ const state = readHarnessState();
189
+ let warning = null;
190
+ if (state) {
191
+ const matches = (state.cwd && state.cwd === projectRoot) || !state.cwd;
192
+ if (matches) {
193
+ if (state.ratchetCandidate && state.ratchetCandidate.count >= 2) {
194
+ const id = state.ratchetCandidate.id || 1;
195
+ warning = `ratchet? #${id}`;
196
+ }
197
+ else if (state.evidenceLow) warning = 'no-evidence';
198
+ else if (state.pevSkip) warning = 'PEV-skip';
199
+ }
200
+ }
201
+ return { ...status, warning };
202
+ }
203
+
204
+ function escapeRe(s) {
205
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
206
+ }
package/src/hook.cjs CHANGED
@@ -168,3 +168,17 @@ if (hitRate < threshold && requests.size >= 5) {
168
168
  '\u26a0 Cache hit rate: ' + pct + '% (threshold: ' + (threshold * 100).toFixed(0) + '%) | 5m TTL: ' + pct5m + '% | ' + requests.size + ' API calls\n',
169
169
  );
170
170
  }
171
+
172
+ // Harness analysis \u2014 best-effort, never throws into the hook stream. The
173
+ // statusline picks up the resulting state file (`harness-state.json`) on
174
+ // the next render, so warnings appear within ~1s of the triggering turn.
175
+ try {
176
+ var harnessAnalyzer = require('./harness-analyzer.cjs');
177
+ var state = harnessAnalyzer.analyzeTranscript(sessionFile, {
178
+ sessionId: sessionId,
179
+ cwd: cwd,
180
+ });
181
+ if (state) harnessAnalyzer.writeState(state);
182
+ } catch (_) {
183
+ // analyzer is purely advisory \u2014 never break the hook on failure
184
+ }