claude-token-saver 2.9.4 β†’ 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/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.4",
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
+ }