claude-token-saver 2.15.0 โ 2.16.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 +24 -5
- package/package.json +1 -1
- package/src/formatters/statusline.js +90 -57
- package/src/harness-analyzer.cjs +35 -26
- package/src/harness.js +18 -5
package/bin/cli.js
CHANGED
|
@@ -838,12 +838,31 @@ async function main() {
|
|
|
838
838
|
const sessions = await parseAllSessions({ days, projectFilter });
|
|
839
839
|
|
|
840
840
|
if (sessions.length === 0) {
|
|
841
|
-
// Statusline must always emit a single line (no multi-line help spam every
|
|
841
|
+
// Statusline must always emit a single line (no multi-line help spam every
|
|
842
|
+
// 300ms) โ but the stdin payload (rate limits, model) is still live even
|
|
843
|
+
// with an empty analysis window (e.g. `mode 1h` + idle), and cap-warn /
|
|
844
|
+
// harness are exactly the signals that must not vanish then.
|
|
842
845
|
if (format === 'statusline') {
|
|
843
|
-
const
|
|
844
|
-
const
|
|
845
|
-
const
|
|
846
|
-
|
|
846
|
+
const { formatNoSession } = await import('../src/formatters/statusline.js');
|
|
847
|
+
const { statuslineDefaults } = await import('../src/config.js');
|
|
848
|
+
const cfg = statuslineDefaults();
|
|
849
|
+
const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
|
|
850
|
+
const isIcon = hasFlag('--icon')
|
|
851
|
+
? true
|
|
852
|
+
: (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon);
|
|
853
|
+
const stdinJson = readStdinJson();
|
|
854
|
+
const caps = extractCaps(stdinJson);
|
|
855
|
+
const model = extractModel(stdinJson);
|
|
856
|
+
if (caps || model) {
|
|
857
|
+
try {
|
|
858
|
+
const { persistSnapshot } = await import('../src/caps-cache.js');
|
|
859
|
+
persistSnapshot({ caps, model });
|
|
860
|
+
} catch { /* non-critical */ }
|
|
861
|
+
}
|
|
862
|
+
console.log(formatNoSession(
|
|
863
|
+
{ caps, model, windowLabel },
|
|
864
|
+
{ color: colorOk, mode: isIcon ? 'icon' : 'text' },
|
|
865
|
+
));
|
|
847
866
|
return;
|
|
848
867
|
}
|
|
849
868
|
console.log('No session data found for the given period.');
|
package/package.json
CHANGED
|
@@ -137,6 +137,73 @@ export function pickCapWarn(caps) {
|
|
|
137
137
|
return candidates[0];
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Harness ๐
ท segment builder โ shared by the full report and the no-session
|
|
142
|
+
* fallback line. Best-effort: never throws into the statusline (corrupted
|
|
143
|
+
* CLAUDE.md, permission issue, etc. โ null).
|
|
144
|
+
*/
|
|
145
|
+
function buildHarnessSeg(c, isIcon) {
|
|
146
|
+
try {
|
|
147
|
+
const harnessInfo = harnessStatusForStatusline(loadConfig());
|
|
148
|
+
if (!harnessInfo) return null;
|
|
149
|
+
const icon = isIcon ? '๐
ท' : 'H';
|
|
150
|
+
if (harnessInfo.warning) {
|
|
151
|
+
// Warning state outranks the N/5 count โ a runtime issue (repeated
|
|
152
|
+
// error / no-evidence / racing edits) is more actionable than a
|
|
153
|
+
// missing ratchet section. Always red so it stands out.
|
|
154
|
+
return `${c(RED)}${icon}โ ${harnessInfo.warning}${c(RESET)}`;
|
|
155
|
+
}
|
|
156
|
+
if (harnessInfo.custom) return `${c(CYAN)}${icon} custom${c(RESET)}`;
|
|
157
|
+
const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
|
|
158
|
+
return `${c(tone)}${icon} ${harnessInfo.configured}/${harnessInfo.total}${c(RESET)}`;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Cap-warn chip builder โ shared by the full report and the no-session
|
|
166
|
+
* fallback line. At 90%+ the user wants to know "when can I send again", so
|
|
167
|
+
* the wall-clock reset time rides along in the same `๐ HH:MM` shape as the
|
|
168
|
+
* always-on usage segments.
|
|
169
|
+
*/
|
|
170
|
+
function buildCapWarnSeg(capWarn, c, isIcon) {
|
|
171
|
+
if (!capWarn) return null;
|
|
172
|
+
const pct = Math.round(capWarn.usedPct);
|
|
173
|
+
const clock = formatResetClock(capWarn.resetsAt);
|
|
174
|
+
const clockTail = clock ? ` ๐ ${clock}` : '';
|
|
175
|
+
if (isIcon) {
|
|
176
|
+
// Gauge keeps shape parity with the always-on usage segment โ the
|
|
177
|
+
// cap-warn is just the same gauge "filled to alarm". Visual continuity
|
|
178
|
+
// helps the eye understand "this is the 5H bar I was watching, just red now."
|
|
179
|
+
const bar = gaugeBar(pct);
|
|
180
|
+
return `${c(BOLD)}${c(RED)}๐จ ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
|
|
181
|
+
}
|
|
182
|
+
return `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Fallback line for when no session data exists in the analysis window.
|
|
187
|
+
* The stdin payload (rate limits, model) is still live in that case, and a
|
|
188
|
+
* 90%+ cap warning is exactly the kind of signal that must not disappear
|
|
189
|
+
* just because the user has been idle past the window โ so cap-warn,
|
|
190
|
+
* harness, and model chips still render around the "no session data" note.
|
|
191
|
+
*/
|
|
192
|
+
export function formatNoSession({ caps = null, model = null, windowLabel = '' } = {}, { color = true, mode = 'icon' } = {}) {
|
|
193
|
+
const c = (v) => (color ? v : '');
|
|
194
|
+
const isIcon = mode === 'icon';
|
|
195
|
+
const segs = [];
|
|
196
|
+
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon);
|
|
197
|
+
if (capSeg) segs.push(capSeg);
|
|
198
|
+
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
199
|
+
if (harnessSeg) segs.push(harnessSeg);
|
|
200
|
+
if (typeof model === 'string' && model.length > 0) {
|
|
201
|
+
segs.push(isIcon ? `${c(MAGENTA)}๐ค ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
|
|
202
|
+
}
|
|
203
|
+
segs.push(`${c(GRAY)}๐ง no session data${windowLabel ? ` ยท ${windowLabel}` : ''}${c(RESET)}`);
|
|
204
|
+
return segs.join(' ยท ') + (color ? '\x1b[K' : '');
|
|
205
|
+
}
|
|
206
|
+
|
|
140
207
|
/**
|
|
141
208
|
* @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
|
|
142
209
|
* @param {object} [opts]
|
|
@@ -144,7 +211,7 @@ export function pickCapWarn(caps) {
|
|
|
144
211
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
145
212
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
146
213
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses ๐ง โณ ๐ฐ instead of word labels.
|
|
147
|
-
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, model, hit, ttl, saved, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
214
|
+
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, harness, model, hit, ttl, saved, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
148
215
|
*/
|
|
149
216
|
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
|
|
150
217
|
const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps, model } = data;
|
|
@@ -279,28 +346,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
279
346
|
// Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
|
|
280
347
|
// otherwise renders ๐
ท 5/5 (green) / ๐
ท N/5 (yellow) so the user can spot
|
|
281
348
|
// a missing section at a glance and know to run `harness init`.
|
|
282
|
-
|
|
283
|
-
try {
|
|
284
|
-
const harnessInfo = harnessStatusForStatusline(loadConfig());
|
|
285
|
-
if (harnessInfo) {
|
|
286
|
-
const icon = isIcon ? '๐
ท' : 'H';
|
|
287
|
-
if (harnessInfo.warning) {
|
|
288
|
-
// Warning state outranks the N/5 count โ a runtime issue (repeated
|
|
289
|
-
// error / no-evidence / racing edits) is more actionable than a
|
|
290
|
-
// missing ratchet section. Always red so it stands out.
|
|
291
|
-
harnessSeg = `${c(RED)}${icon}โ ${harnessInfo.warning}${c(RESET)}`;
|
|
292
|
-
} else if (harnessInfo.custom) {
|
|
293
|
-
harnessSeg = `${c(CYAN)}${icon} custom${c(RESET)}`;
|
|
294
|
-
} else {
|
|
295
|
-
const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
|
|
296
|
-
harnessSeg = `${c(tone)}${icon} ${harnessInfo.configured}/${harnessInfo.total}${c(RESET)}`;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
} catch {
|
|
300
|
-
// Harness check is best-effort โ never break the statusline if the file
|
|
301
|
-
// read fails (corrupted CLAUDE.md, permission issue, etc.).
|
|
302
|
-
harnessSeg = null;
|
|
303
|
-
}
|
|
349
|
+
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
304
350
|
|
|
305
351
|
// Model chip โ pulled from Claude Code's stdin payload (`model.display_name`).
|
|
306
352
|
// Cheap identity context: useful when the user toggles between Sonnet/Opus
|
|
@@ -322,12 +368,14 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
322
368
|
// Today the stdin payload exposes the 5h ("Current session") and 7-day
|
|
323
369
|
// rolling ("Current week") windows; if Anthropic ships more (e.g. a
|
|
324
370
|
// Sonnet-only weekly), they render automatically with derived labels.
|
|
325
|
-
// Each renders as `{label} {pct}% ยท {countdown}`.
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
371
|
+
// Each renders as `{label} {pct}% ยท {countdown}`. The window promoted to the
|
|
372
|
+
// cap-warn chip is suppressed here to avoid duplicate noise โ but ONLY that
|
|
373
|
+
// one. When several windows are at 90%+ the chip shows just the most
|
|
374
|
+
// imminent, so the others must keep their always-on segment (red) or they'd
|
|
375
|
+
// vanish from the statusline entirely at the worst possible moment.
|
|
376
|
+
function buildUsageSeg({ labels, info, color: tone, suppressed }) {
|
|
329
377
|
if (!info || !Number.isFinite(info.usedPct)) return null;
|
|
330
|
-
if (
|
|
378
|
+
if (suppressed) return null; // cap-warn chip handles this window
|
|
331
379
|
const pct = Math.round(info.usedPct);
|
|
332
380
|
// Show only the wall-clock reset time (e.g. `๐ 21:10`). Absolute time
|
|
333
381
|
// doesn't tick second-by-second so the statusline reads stable, and the
|
|
@@ -351,14 +399,20 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
351
399
|
}
|
|
352
400
|
return `${c(tone)}${labels.short} cap ${pct}%${tail}${c(RESET)}`;
|
|
353
401
|
}
|
|
354
|
-
// Color tone: green
|
|
355
|
-
//
|
|
356
|
-
// waiting for the alarm chip.
|
|
402
|
+
// Color tone: green <70%, yellow 70-89%, red 90+% (a 90+% window only
|
|
403
|
+
// renders here when a *different* window won the cap-warn chip slot).
|
|
357
404
|
function usageTone(info) {
|
|
358
405
|
if (!info || !Number.isFinite(info.usedPct)) return GRAY;
|
|
406
|
+
if (info.usedPct >= 90) return RED;
|
|
359
407
|
if (info.usedPct >= 70) return YELLOW;
|
|
360
408
|
return GREEN;
|
|
361
409
|
}
|
|
410
|
+
// Cap-warn chip โ leads everything when ANY rate-limit window is at 90%+.
|
|
411
|
+
// It's the most actionable signal we can show: no point optimizing cache
|
|
412
|
+
// hits if you're about to be rate-limited anyway. The chip body matches the
|
|
413
|
+
// English shape `๐จ 5H 94%` / `๐จ 7D 92%` so history parsers can dedupe on it.
|
|
414
|
+
// Computed before the usage segments so they know which window it claimed.
|
|
415
|
+
const capWarn = pickCapWarn(caps);
|
|
362
416
|
const usageSegs = [];
|
|
363
417
|
if (caps && Array.isArray(caps.windows)) {
|
|
364
418
|
for (const win of caps.windows) {
|
|
@@ -367,34 +421,13 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
367
421
|
labels,
|
|
368
422
|
info: win,
|
|
369
423
|
color: usageTone(win),
|
|
424
|
+
suppressed: !!capWarn && capWarn.key === win.key,
|
|
370
425
|
});
|
|
371
426
|
if (seg) usageSegs.push({ key: win.key, seg });
|
|
372
427
|
}
|
|
373
428
|
}
|
|
374
429
|
|
|
375
|
-
|
|
376
|
-
// It's the most actionable signal we can show: no point optimizing cache
|
|
377
|
-
// hits if you're about to be rate-limited anyway. The chip body matches the
|
|
378
|
-
// English shape `๐จ 5H 94%` / `๐จ 7D 92%` so history parsers can dedupe on it.
|
|
379
|
-
const capWarn = pickCapWarn(caps);
|
|
380
|
-
let capWarnSeg = null;
|
|
381
|
-
if (capWarn) {
|
|
382
|
-
const pct = Math.round(capWarn.usedPct);
|
|
383
|
-
// At 90%+ the user wants to know "when can I send again" โ wall-clock is
|
|
384
|
-
// the actionable bit. Same `๐ HH:MM` shape as the always-on segments so
|
|
385
|
-
// the icon's meaning carries over to the alarm chip.
|
|
386
|
-
const clock = formatResetClock(capWarn.resetsAt);
|
|
387
|
-
const clockTail = clock ? ` ๐ ${clock}` : '';
|
|
388
|
-
if (isIcon) {
|
|
389
|
-
// Gauge keeps shape parity with the always-on usage segment โ the
|
|
390
|
-
// cap-warn is just the same gauge "filled to alarm". Visual continuity
|
|
391
|
-
// helps the eye understand "this is the 5H bar I was watching, just red now."
|
|
392
|
-
const bar = gaugeBar(pct);
|
|
393
|
-
capWarnSeg = `${c(BOLD)}${c(RED)}๐จ ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
|
|
394
|
-
} else {
|
|
395
|
-
capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
430
|
+
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon);
|
|
398
431
|
|
|
399
432
|
// Warning chip leads โ a glance at the statusline catches "something's wrong"
|
|
400
433
|
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
|
@@ -431,7 +464,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
431
464
|
if (want('period')) segs.push(periodSeg);
|
|
432
465
|
// Trailing erase-to-end-of-line so any leftover characters from a previous
|
|
433
466
|
// (longer) statusline render don't bleed into ours. \x1b[K is the standard
|
|
434
|
-
// "erase from cursor to EOL" CSI
|
|
435
|
-
//
|
|
436
|
-
return segs.join(' ยท ') + '\x1b[K';
|
|
467
|
+
// "erase from cursor to EOL" CSI. Only emitted when color (i.e. ANSI) is
|
|
468
|
+
// allowed โ --no-color/NO_COLOR consumers expect escape-free output.
|
|
469
|
+
return segs.join(' ยท ') + (color ? '\x1b[K' : '');
|
|
437
470
|
}
|
package/src/harness-analyzer.cjs
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
* keywords). <30% โ โ no-evidence โ high chance the model is reporting
|
|
13
13
|
* "done" without showing it.
|
|
14
14
|
*
|
|
15
|
-
* 3. PEV-skip โ many tool_use calls (5+) in the
|
|
16
|
-
* signal (no TodoWrite, no
|
|
17
|
-
* model is racing through
|
|
15
|
+
* 3. PEV-skip โ many *mutating* tool_use calls (Edit/Write/Bashโฆ, 5+) in the
|
|
16
|
+
* last 15 assistant turns with no plan signal (no TodoWrite, no
|
|
17
|
+
* "plan"/"Phase"/"๋จ๊ณ" mention). Suggests the model is racing through
|
|
18
|
+
* edits without a verify pass. Read-only exploration (Read/Grep/Glob)
|
|
19
|
+
* deliberately doesn't count โ reading five files is research, not racing.
|
|
18
20
|
*
|
|
19
21
|
* CommonJS so hook.cjs can `require()` it without a bundler step.
|
|
20
22
|
*/
|
|
@@ -28,10 +30,14 @@ const os = require('node:os');
|
|
|
28
30
|
const STATE_DIR = stateDir();
|
|
29
31
|
const STATE_PATH = path.join(STATE_DIR, 'harness-state.json');
|
|
30
32
|
|
|
31
|
-
const RECENT_TURNS = 15; // PEV / evidence window
|
|
33
|
+
const RECENT_TURNS = 15; // PEV / evidence window (assistant turns)
|
|
32
34
|
const RATCHET_TURNS = 30; // ratchet-candidate window
|
|
33
35
|
const EVIDENCE_THRESHOLD = 0.3; // <30% โ โ no-evidence
|
|
34
36
|
const PEV_TOOLUSE_THRESHOLD = 5;
|
|
37
|
+
// Tools that change state. Only these count toward PEV-skip โ an agentic
|
|
38
|
+
// session trivially racks up 5+ *read* tool calls (Read/Grep/Glob) while
|
|
39
|
+
// researching, which is exactly the behavior we don't want to punish.
|
|
40
|
+
const MUTATING_TOOL_RE = /^(edit|write|multiedit|notebookedit|bash)$/i;
|
|
35
41
|
|
|
36
42
|
function stateDir() {
|
|
37
43
|
if (process.platform === 'win32') {
|
|
@@ -167,39 +173,42 @@ function findRatchetCandidates(entries) {
|
|
|
167
173
|
* the immediate next user message also count as "shown the work."
|
|
168
174
|
*/
|
|
169
175
|
function computeEvidenceRate(entries) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
176
|
+
// Window by *assistant turns*, not raw JSONL entries โ one agentic turn can
|
|
177
|
+
// span dozens of entries, so an entry-sliced window covered only 1-2 real
|
|
178
|
+
// turns and made the rate jumpy.
|
|
179
|
+
const idxs = [];
|
|
180
|
+
for (let i = 0; i < entries.length; i++) {
|
|
181
|
+
if (entries[i] && entries[i].type === 'assistant') idxs.push(i);
|
|
182
|
+
}
|
|
183
|
+
const recentIdxs = idxs.slice(-RECENT_TURNS);
|
|
184
|
+
if (recentIdxs.length === 0) return null;
|
|
185
|
+
let proofCount = 0;
|
|
186
|
+
for (const i of recentIdxs) {
|
|
187
|
+
const text = assistantText(entries[i].message);
|
|
188
|
+
let proof = looksLikeEvidence(text);
|
|
189
|
+
// If the *next* entry is a user message with tool_result blocks, count
|
|
190
|
+
// that as evidence for the assistant turn that triggered it.
|
|
191
|
+
const next = entries[i + 1];
|
|
192
|
+
if (!proof && next && next.type === 'user' && toolResultsIn(next.message).length > 0) {
|
|
193
|
+
proof = true;
|
|
184
194
|
}
|
|
195
|
+
if (proof) proofCount++;
|
|
185
196
|
}
|
|
186
|
-
|
|
187
|
-
const proofCount = assistants.filter(Boolean).length;
|
|
188
|
-
return proofCount / assistants.length;
|
|
197
|
+
return proofCount / recentIdxs.length;
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
function computePevSkip(entries) {
|
|
192
|
-
const
|
|
193
|
-
|
|
201
|
+
const assistants = entries.filter((e) => e && e.type === 'assistant');
|
|
202
|
+
const recent = assistants.slice(-RECENT_TURNS);
|
|
203
|
+
let mutatingCount = 0;
|
|
194
204
|
let planSignal = false;
|
|
195
205
|
for (const e of recent) {
|
|
196
|
-
if (!e || e.type !== 'assistant') continue;
|
|
197
206
|
const text = assistantText(e.message);
|
|
198
207
|
const tus = toolUsesIn(e.message);
|
|
199
|
-
|
|
208
|
+
mutatingCount += tus.filter((t) => MUTATING_TOOL_RE.test(t.name || '')).length;
|
|
200
209
|
if (looksLikePlanSignal(text, tus)) planSignal = true;
|
|
201
210
|
}
|
|
202
|
-
return
|
|
211
|
+
return mutatingCount >= PEV_TOOLUSE_THRESHOLD && !planSignal;
|
|
203
212
|
}
|
|
204
213
|
|
|
205
214
|
function analyzeTranscript(transcriptPath, opts) {
|
package/src/harness.js
CHANGED
|
@@ -101,7 +101,9 @@ function statusForFile(filePath) {
|
|
|
101
101
|
try {
|
|
102
102
|
content = readFileSync(filePath, 'utf8');
|
|
103
103
|
} catch {
|
|
104
|
-
|
|
104
|
+
// Unreadable file (permissions, etc.) โ report every section missing so
|
|
105
|
+
// `harness check` can't print "All 5 sections present โ
" over a 0/5.
|
|
106
|
+
return { configured: 0, total: HARNESS_SECTIONS.length, missing: HARNESS_SECTIONS.map((s) => s.id), hasBlock: false, hasFile: true, optOut: false, custom: false, file: filePath };
|
|
105
107
|
}
|
|
106
108
|
const hasBlock = content.includes(HARNESS_BLOCK_BEGIN);
|
|
107
109
|
// Opt-out marker โ when the user intentionally customizes the harness block
|
|
@@ -334,13 +336,24 @@ export function harnessStatusForStatusline(cfg, { root } = {}) {
|
|
|
334
336
|
if (!status.hasFile && !existsSync(join(projectRoot, '.claude'))) return null;
|
|
335
337
|
if (status.optOut) return null;
|
|
336
338
|
// Attach a warning derived from the analyzer state file (if any). Precedence:
|
|
337
|
-
// ratchet? > no-evidence > PEV-skip.
|
|
338
|
-
//
|
|
339
|
+
// ratchet? > no-evidence > PEV-skip. Guards, in order:
|
|
340
|
+
// - freshness: the hook rewrites the state on every tool use, so anything
|
|
341
|
+
// older than WARNING_TTL_MS is a dead session's leftovers โ a red ๐
ทโ
|
|
342
|
+
// must never linger for days after the triggering session ended.
|
|
343
|
+
// - project match: state.cwd is the *session* cwd, which may be a subdir
|
|
344
|
+
// of the repo, while projectRoot is the walked-up root. Normalize both
|
|
345
|
+
// through findProjectRoot so launching Claude Code in a subdirectory
|
|
346
|
+
// still surfaces (and correctly scopes) the warning. A state with no
|
|
347
|
+
// cwd at all is unattributable โ stay silent rather than leak it into
|
|
348
|
+
// every project.
|
|
349
|
+
const WARNING_TTL_MS = 30 * 60 * 1000;
|
|
339
350
|
const state = readHarnessState();
|
|
340
351
|
let warning = null;
|
|
341
352
|
if (state) {
|
|
342
|
-
const
|
|
343
|
-
|
|
353
|
+
const ts = state.timestamp ? Date.parse(state.timestamp) : NaN;
|
|
354
|
+
const fresh = Number.isFinite(ts) && Date.now() - ts <= WARNING_TTL_MS;
|
|
355
|
+
const matches = !!state.cwd && findProjectRoot(state.cwd) === projectRoot;
|
|
356
|
+
if (fresh && matches) {
|
|
344
357
|
if (state.ratchetCandidate && state.ratchetCandidate.count >= 2) {
|
|
345
358
|
const id = state.ratchetCandidate.id || 1;
|
|
346
359
|
warning = `ratchet? #${id}`;
|