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 +21 -0
- package/README.md +21 -0
- package/bin/cli.js +127 -0
- package/package.json +1 -1
- package/src/formatters/statusline.js +28 -0
- package/src/harness-analyzer.cjs +250 -0
- package/src/harness-templates.js +103 -0
- package/src/harness.js +206 -0
- package/src/hook.cjs +14 -0
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
|
@@ -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
|
+
}
|