sprag-cli 3.58.1 → 3.60.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.
@@ -74,6 +74,50 @@ function formatPct(v) {
74
74
  return `${(v * 100).toFixed(1)}%`;
75
75
  }
76
76
 
77
+ /**
78
+ * Elapsed time as one short unit: `<1m`, `45m`, `6h`, `3d`. Floors rather than
79
+ * rounds, so a figure never claims more age than it has. Null for an input that
80
+ * is not a usable duration (a future timestamp reads as clock skew, not as
81
+ * "negative age").
82
+ */
83
+ function formatAge(ms) {
84
+ if (!Number.isFinite(ms) || ms < 0) return null;
85
+ const min = Math.floor(ms / 60000);
86
+ if (min < 1) return '<1m';
87
+ if (min < 60) return `${min}m`;
88
+ const h = Math.floor(min / 60);
89
+ if (h < 24) return `${h}h`;
90
+ return `${Math.floor(h / 24)}d`;
91
+ }
92
+
93
+ /**
94
+ * The reset mark a savings counter counts from, as a short local date: `10/9`,
95
+ * with the year prepended only when it is not the current one. Null when the
96
+ * counter was never reset (or the mark is unusable), which is also how the
97
+ * callers decide whether a zero total is worth showing.
98
+ */
99
+ function formatSince(ms, now = Date.now()) {
100
+ const t = Number(ms);
101
+ if (ms == null || !Number.isFinite(t) || t <= 0) return null;
102
+ const d = new Date(t);
103
+ if (Number.isNaN(d.getTime())) return null;
104
+ const md = `${d.getMonth() + 1}/${d.getDate()}`;
105
+ return d.getFullYear() === new Date(now).getFullYear() ? md : `${d.getFullYear()}/${md}`;
106
+ }
107
+
108
+ /** A route-scan older than this makes the savings figures worth a caveat. */
109
+ const SCAN_STALE_MS = 3 * 24 * 3600 * 1000;
110
+
111
+ /** Epoch ms from a number or an ISO string; null for anything else. */
112
+ function toEpochMs(v) {
113
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
114
+ if (typeof v === 'string') {
115
+ const t = Date.parse(v);
116
+ return Number.isFinite(t) ? t : null;
117
+ }
118
+ return null;
119
+ }
120
+
77
121
  /**
78
122
  * Render the gauge for `pct` (0..100) as a row of separate ticks.
79
123
  *
@@ -296,7 +340,14 @@ function buildVersionSeg(version, update, c, isIcon, verbose, g) {
296
340
  : `v${version} → ${update.latest}`;
297
341
  return `${c(YELLOW)}${isIcon ? `${g.upgrade} ` : ''}${body}${c(RESET)}`;
298
342
  }
299
- return `${c(GRAY)}v${version}${c(RESET)}`;
343
+ // Verbose mode only: how long ago the registry was last asked. A bare version
344
+ // cannot tell "up to date as of this morning" from "never checked since the
345
+ // network went away", and the second is the one worth noticing. Needs a
346
+ // `latest` on record, since a check that never answered confirmed nothing.
347
+ const age = verbose && update && update.latest && !update.available
348
+ ? formatAge(Date.now() - (toEpochMs(update.checkedAt) ?? NaN))
349
+ : null;
350
+ return `${c(GRAY)}v${version}${age ? ` ✓${age}` : ''}${c(RESET)}`;
300
351
  }
301
352
 
302
353
  /**
@@ -313,7 +364,11 @@ function buildHarnessSeg(c, isIcon, g, liveWindow = null) {
313
364
  // Warning state outranks the N/5 count — a runtime issue (repeated
314
365
  // error / no-evidence / racing edits) is more actionable than a
315
366
  // missing ratchet section. Always red so it stands out.
316
- return `${c(RED)}${icon}${g.spike} ${harnessInfo.warning}${c(RESET)}`;
367
+ // Only the first warning in precedence order is named; the count of the
368
+ // rest rides along so a lower-priority problem is not invisible for as
369
+ // long as a higher one is showing.
370
+ const more = Number(harnessInfo.moreWarnings) || 0;
371
+ return `${c(RED)}${icon}${g.spike} ${harnessInfo.warning}${more > 0 ? ` +${more}` : ''}${c(RESET)}`;
317
372
  }
318
373
  if (harnessInfo.custom) return `${c(CYAN)}${icon} custom${c(RESET)}`;
319
374
  const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
@@ -323,6 +378,42 @@ function buildHarnessSeg(c, isIcon, g, liveWindow = null) {
323
378
  }
324
379
  }
325
380
 
381
+ /**
382
+ * Delegation-rule registry chip: `rules 10 · ⚠1`. Rendered only when
383
+ * `--segments` names `rules`, since it describes configuration rather than
384
+ * anything the user can act on in the next minute; the 🅷 chip already raises
385
+ * `rule-health` when one needs attention.
386
+ * `⚠N` is the number of rules flagged for review, and is left off at zero so a
387
+ * healthy registry reads as a plain count.
388
+ */
389
+ function buildRulesSeg(stats, c, g) {
390
+ if (!stats) return null;
391
+ const total = Number(stats.total) || 0;
392
+ if (total <= 0) return null;
393
+ const review = Number(stats.review) || 0;
394
+ const flagged = review > 0 ? ` ${c(GRAY)}·${c(RESET)} ${c(YELLOW)}${g.spike}${review}${c(RESET)}` : '';
395
+ return `${c(GRAY)}rules ${total}${c(RESET)}${flagged}`;
396
+ }
397
+
398
+ /**
399
+ * This session's subagent runs: `3 (haiku 2 · sonnet 1)`. Counts every
400
+ * subagent the session started, not only the ones a routing rule sent, so it
401
+ * answers "how much did this session fan out" rather than "how many rules
402
+ * fired". Null when the session ran none, which is the common case.
403
+ */
404
+ function buildSessionDelegationSeg(sd, c, isIcon, verbose, g) {
405
+ const total = Number(sd && sd.total) || 0;
406
+ if (total <= 0) return null;
407
+ const families = Array.isArray(sd.families)
408
+ ? sd.families.filter((f) => f && Number(f.runs) > 0).map((f) => `${f.family} ${f.runs}`)
409
+ : [];
410
+ const label = isIcon
411
+ ? `${g.routing} ${verbose ? 'Subagents this session' : 'this session'}`
412
+ : 'Subagents this session';
413
+ const split = families.length ? ` ${c(GRAY)}(${families.join(' · ')})${c(RESET)}` : '';
414
+ return `${c(CYAN)}${label} ${total}${c(RESET)}${split}`;
415
+ }
416
+
326
417
  /**
327
418
  * Cap-warn chip builder — shared by the full report and the no-session
328
419
  * fallback line. At 90%+ the user wants to know "when can I send again", so
@@ -386,7 +477,7 @@ export function formatNoSession({ caps = null, model = null, effort = null, wind
386
477
  * @param {boolean} [opts.verbose=false] - longer layout with labels
387
478
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
388
479
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
389
- * @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness, korean, model, effort, hit, ttl, month, saved, delegated, doc2md, ctx, period, `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
480
+ * @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness, korean, model, effort, hit, ttl, month, saved, delegated, doc2md, ctx, period, `subagents` (this session's subagent runs; also rides with `delegated`), `rules` (only when named here), `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
390
481
  * @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
391
482
  */
392
483
  /**
@@ -491,9 +582,10 @@ export function formatReport(data, { color = true, verbose = false, timer = true
491
582
 
492
583
  // Delegation savings — a DIFFERENT number from "Cache saved" above, which
493
584
  // covers the prompt cache only. This one is what running work on a cheaper
494
- // tier saved, the lifetime total of the delegation ledger. Hidden
495
- // when zero or absent: a permanent "$0" is noise for direct-API users and
496
- // for anyone who has not delegated yet.
585
+ // tier saved, the lifetime total of the delegation ledger (counted from the
586
+ // last `sprag saved reset`, when there was one). Hidden when zero or absent
587
+ // and never reset: a permanent "$0" is noise for direct-API users and for
588
+ // anyone who has not delegated yet.
497
589
  // "Routing saved" says what earned the money — work that ran on a cheaper
498
590
  // model instead of this one. It leads the line rather than trailing it
499
591
  // because it is the headline number of the whole tool, not a footnote.
@@ -518,11 +610,38 @@ export function formatReport(data, { color = true, verbose = false, timer = true
518
610
  // Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
519
611
  // appears exactly when learning has had its chance and still came up short.
520
612
  const unresolvedRuns = Number(data.unresolvedRuns) || 0;
521
- const delegateSeg = delegationSaved !== 0
522
- ? `${c(delegationSaved < 0 ? RED : GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
523
- : (unresolvedRuns >= MIN_VOTES
524
- ? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
525
- : null);
613
+ const unresolvedSeg = unresolvedRuns >= MIN_VOTES
614
+ ? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
615
+ : null;
616
+
617
+ // The counter's reset mark ("since 10/9"), when `sprag saved reset` was run.
618
+ // After a reset the total is legitimately zero, and a chip that vanished at
619
+ // zero made "just reset" look the same as "feature off". With a mark on
620
+ // record a zero is shown, dated; with none it stays hidden as before.
621
+ // Compact icon mode has no room for a sentence, so it gets `(10/9~)`.
622
+ const totals = data.delegationTotals;
623
+ const routingSince = formatSince(totals && totals.since);
624
+ const sinceWord = (since) => (since ? ` ${c(GRAY)}since ${since}${c(RESET)}` : '');
625
+ const sinceCompact = (since) => (since ? ` ${c(GRAY)}(${since}~)${c(RESET)}` : '');
626
+ const sinceInline = isIcon && !verbose ? sinceCompact : sinceWord;
627
+
628
+ // Savings come from the ledger route-scan fills, so they are only as fresh as
629
+ // the last scan. Said only once that has gone stale: a recent scan is the
630
+ // normal case and needs no caveat.
631
+ const scanAt = toEpochMs(data.routeScanAt);
632
+ const scanAge = scanAt !== null && Date.now() - scanAt > SCAN_STALE_MS ? formatAge(Date.now() - scanAt) : null;
633
+ const scanTag = scanAge
634
+ ? ` ${c(GRAY)}${verbose ? `last scan ${scanAge} ago` : `scan ${scanAge}`}${c(RESET)}`
635
+ : '';
636
+
637
+ const savedTone = delegationSaved < 0 ? RED : delegationSaved > 0 ? GREEN : GRAY;
638
+ const savingsChip = delegationSaved !== 0 || routingSince
639
+ ? `${c(savedTone)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}${sinceInline(routingSince)}${scanTag}`
640
+ : null;
641
+ // The unresolved count stands in only while there are no savings to show, so
642
+ // a dated zero keeps both: the reset and the dropped runs are separate facts.
643
+ const delegateSeg = savingsChip || (delegationSaved === 0 ? unresolvedSeg : null);
644
+ const delegateExtra = savingsChip && delegationSaved === 0 ? unresolvedSeg : null;
526
645
 
527
646
  // Document conversions — the same kind of number as "Routing saved", earned
528
647
  // a different way: a document read as Markdown instead of attached whole.
@@ -538,16 +657,22 @@ export function formatReport(data, { color = true, verbose = false, timer = true
538
657
  const doc2md = data.doc2mdTotals;
539
658
  const doc2mdUsd = Number(doc2md && doc2md.total) || 0;
540
659
  const doc2mdDocs = Number(doc2md && doc2md.docs) || 0;
660
+ // Same reset mark as the routing counter, and the same reason to show a dated
661
+ // zero: after `sprag saved reset` an empty counter and a disabled feature
662
+ // would otherwise look alike.
663
+ const doc2mdSince = formatSince(doc2md && doc2md.since);
541
664
  const doc2mdLabel = isIcon
542
665
  ? (verbose ? `${g.doc} Doc2md saved` : g.doc)
543
666
  : 'Doc2md saved';
544
667
  let doc2mdSeg = null;
545
668
  if (doc2mdUsd > 0) {
546
- doc2mdSeg = `${c(GREEN)}${doc2mdLabel}${c(RESET)} ${formatMoney(doc2mdUsd)}`
669
+ doc2mdSeg = `${c(GREEN)}${doc2mdLabel}${c(RESET)} ${formatMoney(doc2mdUsd)}${sinceInline(doc2mdSince)}`
547
670
  + (verbose ? ` ${c(GRAY)}· ${doc2mdDocs} docs${c(RESET)}` : '');
548
671
  } else if (doc2mdDocs > 0) {
549
672
  const label = isIcon ? g.doc : 'Doc2md';
550
- doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${c(RESET)}`;
673
+ doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${doc2mdSince ? ` ${isIcon && !verbose ? `(${doc2mdSince}~)` : `since ${doc2mdSince}`}` : ''}${c(RESET)}`;
674
+ } else if (doc2mdSince) {
675
+ doc2mdSeg = `${c(GRAY)}${doc2mdLabel} ${formatMoney(0)}${isIcon && !verbose ? ` (${doc2mdSince}~)` : ` since ${doc2mdSince}`}${c(RESET)}`;
551
676
  }
552
677
 
553
678
  // 이번 달 지출 세그먼트. 달력 월(1일 00시 기준) 지출 추정치라서 cap 이 없는
@@ -584,9 +709,11 @@ export function formatReport(data, { color = true, verbose = false, timer = true
584
709
  // nothing to mismatch.
585
710
  // icon: "🔀 Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
586
711
  // text: "Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
587
- const totals = data.delegationTotals;
588
712
  let totalsLine = null;
589
- if (!singleLine && totals && Number(totals.total) !== 0 && Number.isFinite(Number(totals.total))) {
713
+ const totalsNum = Number(totals && totals.total);
714
+ // A zero total gets its line only when a reset mark explains it; see the
715
+ // note above `routingSince`.
716
+ if (!singleLine && totals && Number.isFinite(totalsNum) && (totalsNum !== 0 || routingSince)) {
590
717
  const head = isIcon ? `${g.routing} Routing saved` : 'Routing saved';
591
718
  // Model changes behind the total, family-level and version-free: `opus →
592
719
  // haiku 2× $0.6`. Versions bump constantly and add nothing here — the
@@ -605,10 +732,12 @@ export function formatReport(data, { color = true, verbose = false, timer = true
605
732
  const pairText = pairs
606
733
  .map((p) => `${c(GRAY)}${p.from}→${p.to} ${p.runs}× ${formatMoney(p.usd)}${c(RESET)}`)
607
734
  .join(` ${c(GRAY)}·${c(RESET)} `);
608
- const tone = Number(totals.total) < 0 ? RED : GREEN; // a net loss must not read as a win
735
+ // A net loss must not read as a win, and a zero is neither.
736
+ const tone = totalsNum < 0 ? RED : totalsNum > 0 ? GREEN : GRAY;
609
737
  totalsLine =
610
738
  `${c(tone)}${c(BOLD)}${head}${c(RESET)} ` +
611
- `${c(tone)}${formatMoney(Number(totals.total) || 0)}${c(RESET)}` +
739
+ `${c(tone)}${formatMoney(totalsNum)}${c(RESET)}` +
740
+ sinceWord(routingSince) + scanTag +
612
741
  (pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
613
742
  }
614
743
 
@@ -637,6 +766,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
637
766
  doc2mdLine =
638
767
  `${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
639
768
  `${c(GREEN)}${formatMoney(doc2mdUsd)}${c(RESET)}` +
769
+ sinceWord(doc2mdSince) +
640
770
  (extText ? ` ${c(GRAY)}|${c(RESET)} ${extText}` : '');
641
771
  }
642
772
 
@@ -770,6 +900,13 @@ export function formatReport(data, { color = true, verbose = false, timer = true
770
900
  // Korean-style chip — rendered only when the session-start injection is on.
771
901
  const koreanSeg = buildKoreanSeg(c, isIcon, verbose, g);
772
902
 
903
+ // Delegation-rule registry (`rules 10 · ⚠1`), opt-in through `--segments`.
904
+ const rulesSeg = buildRulesSeg(data.ruleStats, c, g);
905
+
906
+ // This session's subagent runs by model family. It is a session figure, so it
907
+ // sits with the routing chips rather than with the lifetime totals above it.
908
+ const sessionDelegationSeg = buildSessionDelegationSeg(data.sessionDelegation, c, isIcon, verbose, g);
909
+
773
910
  // Model chip — pulled from Claude Code's stdin payload (`model.display_name`).
774
911
  // Cheap identity context: useful when the user toggles between Sonnet/Opus
775
912
  // mid-session and wants to confirm at a glance which one is answering.
@@ -896,13 +1033,25 @@ export function formatReport(data, { color = true, verbose = false, timer = true
896
1033
  case 'cap-warn': return [capWarnSeg];
897
1034
  case 'spike': return [spikeSeg];
898
1035
  case 'version': return [versionSeg];
1036
+ // `rules` renders only when asked for by name. Verbose is the shipped
1037
+ // default, so riding with the harness chip would put it on everyone's
1038
+ // line, and its ⚠ count repeats what `rule-health` already raises there.
899
1039
  case 'harness': return [harnessSeg];
1040
+ case 'rules': return [rulesSeg];
1041
+ case 'subagents': return [sessionDelegationSeg];
900
1042
  case 'korean': return [koreanSeg];
901
1043
  case 'model': return [modelSeg];
902
1044
  case 'effort': return [effortSeg];
903
1045
  // Dropped when the same story already owns a headline line above, which
904
1046
  // would otherwise repeat it on line 2.
905
- case 'delegated': return totalsLine ? [] : [delegateSeg];
1047
+ // The unresolved-runs chip survives the headline when that headline is a
1048
+ // dated zero, since a zero is exactly when the dropped runs matter.
1049
+ case 'delegated': {
1050
+ const base = totalsLine
1051
+ ? (totalsNum === 0 ? [unresolvedSeg] : [])
1052
+ : [delegateSeg, delegateExtra];
1053
+ return [...base, sessionDelegationSeg];
1054
+ }
906
1055
  case 'doc2md': return doc2mdLine ? [] : [doc2mdSeg];
907
1056
  case 'hit': return [hitSeg];
908
1057
  case 'ttl': return [ttlSeg];
@@ -244,8 +244,14 @@ function writeState(state) {
244
244
  if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
245
245
  // tmp + rename so the statusline never reads a half-written state file.
246
246
  const tmp = STATE_PATH + '.' + process.pid + '.tmp';
247
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
248
- fs.renameSync(tmp, STATE_PATH);
247
+ try {
248
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
249
+ fs.renameSync(tmp, STATE_PATH);
250
+ } catch (e) {
251
+ // A failed write or rename must not leave the temp file behind.
252
+ try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
253
+ throw e;
254
+ }
249
255
  } catch {
250
256
  // best-effort
251
257
  }
package/src/harness.js CHANGED
@@ -614,8 +614,14 @@ export function harnessStatusForStatusline(cfg, { root, liveWindow = null } = {}
614
614
  // user hasn't opted in, no point nagging.
615
615
  if (!status.hasFile && !existsSync(join(projectRoot, '.claude'))) return null;
616
616
  if (status.optOut) return null;
617
- // Attach a warning derived from the analyzer state file (if any). Precedence:
618
- // ratchet? > no-evidence > PEV-skip. Guards, in order:
617
+ // Warnings derived from the analyzer state file (if any), then the config
618
+ // and registry checks below. Every check runs and collects, in precedence
619
+ // order, so the chip can name the first and count the rest: stopping at the
620
+ // first hid everything behind it (a `PEV-skip` on screen meant a failing
621
+ // `rule-health` was invisible until the PEV one cleared).
622
+ //
623
+ // Precedence: ratchet? > no-evidence > PEV-skip > ratchet-unloaded >
624
+ // compact-window? > rule-health > route?. Guards on the state file, in order:
619
625
  // - freshness: the hook rewrites the state on every tool use, so anything
620
626
  // older than WARNING_TTL_MS is a dead session's leftovers — a red 🅷⚠
621
627
  // must never linger for days after the triggering session ended.
@@ -627,7 +633,7 @@ export function harnessStatusForStatusline(cfg, { root, liveWindow = null } = {}
627
633
  // every project.
628
634
  const WARNING_TTL_MS = 30 * 60 * 1000;
629
635
  const state = readHarnessState();
630
- let warning = null;
636
+ const warnings = [];
631
637
  if (state) {
632
638
  const ts = state.timestamp ? Date.parse(state.timestamp) : NaN;
633
639
  const fresh = Number.isFinite(ts) && Date.now() - ts <= WARNING_TTL_MS;
@@ -635,42 +641,45 @@ export function harnessStatusForStatusline(cfg, { root, liveWindow = null } = {}
635
641
  if (fresh && matches) {
636
642
  if (state.ratchetCandidate && state.ratchetCandidate.count >= 2) {
637
643
  const id = state.ratchetCandidate.id || 1;
638
- warning = `ratchet? #${id}`;
644
+ warnings.push(`ratchet? #${id}`);
639
645
  }
640
- else if (state.evidenceLow) warning = 'no-evidence';
641
- else if (state.pevSkip) warning = 'PEV-skip';
646
+ if (state.evidenceLow) warnings.push('no-evidence');
647
+ if (state.pevSkip) warnings.push('PEV-skip');
642
648
  }
643
649
  }
644
650
  // Config defect, above the optimization nudges: the harness block is there
645
651
  // but carries no `@` import, so every promoted ratchet rule is dead weight.
646
652
  // One `harness init` re-run fixes it and the warning goes away for good.
647
- if (!warning && status.hasBlock && !(status.hasRatchetImport && status.hasModelRatchetImport)) warning = 'ratchet-unloaded';
653
+ if (status.hasBlock && !(status.hasRatchetImport && status.hasModelRatchetImport)) warnings.push('ratchet-unloaded');
648
654
  // Same class of defect, one notch lower: the session runs on a 1M-context
649
655
  // model with no `autoCompactWindow` cap, so compaction only fires past 800k
650
656
  // and every request until then re-bills the whole context. 200k sessions are
651
657
  // exempt — the setting cannot change anything for them.
652
- if (!warning) {
653
- try {
654
- warning = compactWindowWarningForStatusline(projectRoot, cfg, { liveWindow });
655
- } catch { /* settings unreadable — stay silent */ }
656
- }
658
+ try {
659
+ const w = compactWindowWarningForStatusline(projectRoot, cfg, { liveWindow });
660
+ if (w) warnings.push(w);
661
+ } catch { /* settings unreadable — stay silent */ }
657
662
  // Below session-quality warnings: a promoted delegation rule whose
658
663
  // category started failing (`rule-health R<N>`) — the user approved that
659
664
  // rule, so its degradation outranks a mere new-candidate nudge.
660
- if (!warning) {
661
- try {
662
- warning = ruleHealthWarningForStatusline(projectRoot);
663
- } catch { /* registry unreadable — stay silent */ }
664
- }
665
+ try {
666
+ const w = ruleHealthWarningForStatusline(projectRoot);
667
+ if (w) warnings.push(w);
668
+ } catch { /* registry unreadable — stay silent */ }
665
669
  // Lowest precedence: route-scan delegation candidate (`route? R<N>`).
666
670
  // Session-quality warnings above always win — routing is an optimization
667
671
  // nudge, not a correctness signal. Cheap: one small cached-JSON read.
668
- if (!warning) {
669
- try {
670
- warning = routeWarningForStatusline(projectRoot);
671
- } catch { /* scan cache unreadable — stay silent */ }
672
- }
673
- return { ...status, warning };
672
+ try {
673
+ const w = routeWarningForStatusline(projectRoot);
674
+ if (w) warnings.push(w);
675
+ } catch { /* scan cache unreadable — stay silent */ }
676
+ // `warning` is the one shown; `moreWarnings` counts the ones it outranks.
677
+ // The checks after the state file are a few small JSON reads (settings,
678
+ // model-rules.json ~11KB, route-scan.json ~1KB). A render with no warning
679
+ // already ran all of them; the change is that they now also run while an
680
+ // earlier warning is up, so no render does more than the clean one did.
681
+ return { ...status, warning: warnings[0] || null, moreWarnings: Math.max(0, warnings.length - 1), warnings };
682
+
674
683
  }
675
684
 
676
685
  function escapeRe(s) {
package/src/installer.js CHANGED
@@ -640,6 +640,58 @@ export function removeKoreanLintHook() {
640
640
  return { path: file, action: 'removed' };
641
641
  }
642
642
 
643
+ // Registers the Stop hook that catches an English reply to a Korean prompt.
644
+ // The lint hook above watches files the model writes; this slip writes no
645
+ // file, so it needs its own checkpoint at the end of the turn. Installed and
646
+ // removed together with the lint hook by `korean on|off`. Idempotent.
647
+ const REPLY_LANGUAGE_HOOK_COMMAND = `${CLI} korean --reply-hook`;
648
+
649
+ export function installReplyLanguageHook() {
650
+ const dir = claudeUserDir();
651
+ const file = join(dir, 'settings.json');
652
+ mkdirSync(dir, { recursive: true });
653
+
654
+ const read = readSettings(file);
655
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
656
+ const settings = read.settings;
657
+
658
+ const hooksProblem = ensureHooksObject(settings);
659
+ if (hooksProblem) return { path: file, action: 'skipped', reason: hooksProblem };
660
+ if (settings.hooks.Stop !== undefined && !Array.isArray(settings.hooks.Stop)) {
661
+ return { path: file, action: 'skipped', reason: 'hooks.Stop is not an array — fix settings.json manually' };
662
+ }
663
+ const list = Array.isArray(settings.hooks.Stop) ? settings.hooks.Stop : [];
664
+ const existing = list.find((m) =>
665
+ Array.isArray(m?.hooks) && m.hooks.some((h) => isOurSubcommand(h?.command, 'korean', '--reply-hook')),
666
+ );
667
+ if (existing) return { path: file, action: 'exists' };
668
+
669
+ list.push({
670
+ hooks: [{ type: 'command', command: REPLY_LANGUAGE_HOOK_COMMAND, timeout: 10 }],
671
+ });
672
+ settings.hooks.Stop = list;
673
+ const writeProblem = writeSettings(file, settings);
674
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
675
+ return { path: file, action: 'created' };
676
+ }
677
+
678
+ export function removeReplyLanguageHook() {
679
+ const file = join(claudeUserDir(), 'settings.json');
680
+ if (!existsSync(file)) return { path: file, action: 'absent' };
681
+ const read = readSettings(file);
682
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
683
+ const settings = read.settings;
684
+ const list = settings?.hooks?.Stop;
685
+ if (!Array.isArray(list)) return { path: file, action: 'absent' };
686
+ const { kept, touched } = removeHooksWhere(list, (h) => isOurSubcommand(h?.command, 'korean', '--reply-hook'));
687
+ if (!touched) return { path: file, action: 'absent' };
688
+ if (kept.length === 0) delete settings.hooks.Stop;
689
+ else settings.hooks.Stop = kept;
690
+ const writeProblem = writeSettings(file, settings);
691
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
692
+ return { path: file, action: 'removed' };
693
+ }
694
+
643
695
  // Registers the PreToolUse hook that converts attached documents to Markdown
644
696
  // before the model reads them. PreToolUse rather than PostToolUse because the
645
697
  // point is to intervene before a pptx lands in the context window; afterwards
@@ -36,12 +36,13 @@
36
36
  * package is published to npm, so neither may live in the source.
37
37
  */
38
38
 
39
- import { readFileSync, writeFileSync, mkdirSync, existsSync, createReadStream, statSync, renameSync } from 'node:fs';
39
+ import { readFileSync, mkdirSync, existsSync, createReadStream, statSync } from 'node:fs';
40
40
  import { readFile, readdir } from 'node:fs/promises';
41
41
  import { createInterface } from 'node:readline';
42
42
  import { join, dirname, basename } from 'node:path';
43
43
  import { userDataDir, claudeUserDir } from './paths.js';
44
44
  import { gatewayBase } from './gateway-auth.js';
45
+ import { writeViaTmp } from './state-file.js';
45
46
 
46
47
  /** Marker returned when a gateway id could not be resolved to a model. */
47
48
  export const UNKNOWN_MODEL = 'unknown';
@@ -154,8 +155,7 @@ function writeProfileMapFile(map) {
154
155
  mkdirSync(dir, { recursive: true });
155
156
  const target = profileMapPath();
156
157
  const tmp = `${target}.tmp-${process.pid}`;
157
- writeFileSync(tmp, JSON.stringify(map, null, 2));
158
- renameSync(tmp, target);
158
+ writeViaTmp(tmp, target, JSON.stringify(map, null, 2));
159
159
  }
160
160
 
161
161
  /**
@@ -581,7 +581,9 @@ export function refreshModelRules(episodeStats, delegatedStats = new Map(), { no
581
581
  }
582
582
  // Window snapshot, not a running total: these describe the current scan
583
583
  // window so a rule that stopped firing decays to zero instead of coasting
584
- // on old credit.
584
+ // on old credit. savedUsd is the one figure `sprag saved reset` narrows: the
585
+ // scan only adds runs that started after the routing mark (see
586
+ // tallyDelegatedRun), while runs and the error rate keep the whole window.
585
587
  r.delegatedRuns = d ? d.runs : 0;
586
588
  r.delegatedErrRate = d && d.runs > 0 ? d.errRuns / d.runs : 0;
587
589
  r.savedUsd = d ? Math.round(d.savedUsd * 100) / 100 : 0;
@@ -17,7 +17,7 @@
17
17
  * a project rule is the user's placement decision, not a tool-wide default.
18
18
  */
19
19
 
20
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
21
  import { homedir } from 'node:os';
22
22
  import { dirname, join } from 'node:path';
23
23
  import { codexUserDir } from './agent.js';
@@ -30,6 +30,7 @@ import {
30
30
  renderPresetRatchet,
31
31
  } from './harness-templates.js';
32
32
  import { loadSeedState, ratchetPresets, saveSeedState } from './seed-rules.js';
33
+ import { writeViaTmp } from './state-file.js';
33
34
 
34
35
  export function presetRatchetPath({ agent = 'claude' } = {}) {
35
36
  return agent === 'codex'
@@ -47,8 +48,7 @@ function writeIfChanged(file, text) {
47
48
  if (before === text) return false;
48
49
  mkdirSync(dirname(file), { recursive: true });
49
50
  const tmp = `${file}.tmp-${process.pid}`;
50
- writeFileSync(tmp, text);
51
- renameSync(tmp, file);
51
+ writeViaTmp(tmp, file, text);
52
52
  return true;
53
53
  }
54
54