sprag-cli 3.59.0 → 3.61.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.ko.md +17 -0
- package/README.md +23 -0
- package/bin/cli.js +51 -3
- package/docs/CODEX.ko.md +69 -6
- package/docs/CODEX.md +84 -12
- package/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/caps-cache.js +3 -3
- package/src/codex-brief.js +4 -5
- package/src/codex-cache-policy.js +3 -3
- package/src/codex-delegation.js +114 -15
- package/src/codex-doc2md-ledger.js +3 -3
- package/src/codex-doctor.js +2 -2
- package/src/codex-hooks.js +4 -5
- package/src/codex-installer.js +3 -3
- package/src/codex-ledger.js +25 -10
- package/src/codex-panel-session.js +3 -3
- package/src/codex-panel-state.js +51 -5
- package/src/codex-route-scan.js +12 -7
- package/src/commands/brief.js +1 -1
- package/src/commands/codex-delegate.js +81 -6
- package/src/commands/codex.js +8 -2
- package/src/commands/compact-window.js +6 -2
- package/src/commands/install.js +4 -1
- package/src/commands/korean.js +69 -3
- package/src/commands/route-scan.js +6 -4
- package/src/commands/saved.js +40 -1
- package/src/ctx-window-cache.js +14 -0
- package/src/formatters/statusline.js +167 -18
- package/src/harness-analyzer.cjs +8 -2
- package/src/harness.js +32 -23
- package/src/installer.js +52 -0
- package/src/model-alias.js +3 -3
- package/src/model-rules.js +14 -9
- package/src/preset-ratchet.js +3 -3
- package/src/reply-language.cjs +167 -0
- package/src/route-inject.js +57 -8
- package/src/route-scan.js +51 -11
- package/src/savings-ledger.js +3 -2
- package/src/seed-rules.js +32 -23
- package/src/session-cache.js +12 -4
- package/src/shared-model-rules.js +73 -0
- package/src/state-file.js +99 -2
- package/src/statusline-data.js +97 -0
- package/src/update-check.js +46 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
495
|
-
//
|
|
496
|
-
//
|
|
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
|
|
522
|
-
? `${c(
|
|
523
|
-
:
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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];
|
package/src/harness-analyzer.cjs
CHANGED
|
@@ -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
|
-
|
|
248
|
-
|
|
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
|
-
//
|
|
618
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
644
|
+
warnings.push(`ratchet? #${id}`);
|
|
639
645
|
}
|
|
640
|
-
|
|
641
|
-
|
|
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 (
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
package/src/model-alias.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
158
|
-
renameSync(tmp, target);
|
|
158
|
+
writeViaTmp(tmp, target, JSON.stringify(map, null, 2));
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/**
|
package/src/model-rules.js
CHANGED
|
@@ -147,15 +147,17 @@ export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
|
|
|
147
147
|
// this, so an XDG_CONFIG_HOME override moves every state file together.
|
|
148
148
|
const stateDir = userDataDir;
|
|
149
149
|
|
|
150
|
-
export function modelRulesPath() {
|
|
151
|
-
return join(
|
|
150
|
+
export function modelRulesPath({ dir = stateDir() } = {}) {
|
|
151
|
+
return join(dir, 'model-rules.json');
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
export function loadModelRules() {
|
|
154
|
+
export function loadModelRules({ dir = stateDir(), strict = false } = {}) {
|
|
155
155
|
try {
|
|
156
|
-
const data = JSON.parse(readFileSync(modelRulesPath(), 'utf8'));
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
const data = JSON.parse(readFileSync(modelRulesPath({ dir }), 'utf8'));
|
|
157
|
+
if (!Array.isArray(data?.rules)) throw new Error('Invalid registry');
|
|
158
|
+
return data;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (strict && error.code !== 'ENOENT') throw new Error('Cannot parse model rules; file left unchanged.');
|
|
159
161
|
return { rules: [] };
|
|
160
162
|
}
|
|
161
163
|
}
|
|
@@ -189,8 +191,9 @@ export function saveModelRules(data) {
|
|
|
189
191
|
|
|
190
192
|
/** Add (or re-activate) a promoted rule; returns the stored entry. */
|
|
191
193
|
export function addModelRule(entry) {
|
|
192
|
-
const data = loadModelRules();
|
|
193
|
-
const existing = data.rules.find((r) => r.signature === entry.signature && r.scope === entry.scope
|
|
194
|
+
const data = loadModelRules({ strict: true });
|
|
195
|
+
const existing = data.rules.find((r) => r.signature === entry.signature && r.scope === entry.scope &&
|
|
196
|
+
(r.scope === 'global' || r.targetRoot === entry.targetRoot));
|
|
194
197
|
if (existing) {
|
|
195
198
|
Object.assign(existing, entry, { status: 'active' });
|
|
196
199
|
saveModelRules(data);
|
|
@@ -581,7 +584,9 @@ export function refreshModelRules(episodeStats, delegatedStats = new Map(), { no
|
|
|
581
584
|
}
|
|
582
585
|
// Window snapshot, not a running total: these describe the current scan
|
|
583
586
|
// window so a rule that stopped firing decays to zero instead of coasting
|
|
584
|
-
// on old credit.
|
|
587
|
+
// on old credit. savedUsd is the one figure `sprag saved reset` narrows: the
|
|
588
|
+
// scan only adds runs that started after the routing mark (see
|
|
589
|
+
// tallyDelegatedRun), while runs and the error rate keep the whole window.
|
|
585
590
|
r.delegatedRuns = d ? d.runs : 0;
|
|
586
591
|
r.delegatedErrRate = d && d.runs > 0 ? d.errRuns / d.runs : 0;
|
|
587
592
|
r.savedUsd = d ? Math.round(d.savedUsd * 100) / 100 : 0;
|
package/src/preset-ratchet.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
51
|
-
renameSync(tmp, file);
|
|
51
|
+
writeViaTmp(tmp, file, text);
|
|
52
52
|
return true;
|
|
53
53
|
}
|
|
54
54
|
|