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 +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/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
|
+
}
|