sprag-cli 3.68.0 → 3.70.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/NOTICE +5 -2
- package/README.ko.md +14 -4
- package/README.md +25 -13
- package/bin/cli.js +36 -2
- package/docs/CODEX.ko.md +1 -0
- package/docs/CODEX.md +1 -0
- package/docs/TEAM.ko.md +119 -0
- package/docs/TEAM.md +119 -0
- package/package.json +9 -2
- package/src/agents.js +3 -1
- package/src/codex/delegation.js +8 -5
- package/src/codex/hooks.js +14 -2
- package/src/codex/route-scan.js +6 -4
- package/src/commands/codex-delegate.js +3 -3
- package/src/commands/codex.js +3 -3
- package/src/commands/harness.js +1 -1
- package/src/commands/route-scan.js +16 -3
- package/src/commands/team.js +278 -0
- package/src/cost.js +6 -2
- package/src/cowork-sync.js +5 -4
- package/src/delegation-agents.js +25 -0
- package/src/delegation-guard.js +3 -2
- package/src/first-run-note.js +16 -0
- package/src/formatters/statusline.js +13 -1
- package/src/formatters/table.js +33 -2
- package/src/harness-templates.js +10 -0
- package/src/model-rules.js +81 -31
- package/src/route-caps.js +3 -3
- package/src/route-inject.js +24 -15
- package/src/route-scan.js +26 -15
- package/src/shared-model-rules.js +1 -1
- package/src/star-note.js +62 -0
- package/src/team-ratchet.js +176 -0
- package/src/team-sync.js +326 -0
- package/src/changelog.js +0 -70
|
@@ -28,6 +28,7 @@ import { MIN_VOTES } from '../model-alias.js';
|
|
|
28
28
|
import { bedrockDisplayFromId } from '../stdin-payload.js';
|
|
29
29
|
import { modelFamily } from '../savings-ledger.js';
|
|
30
30
|
import { shareLegend } from '../model-share.js';
|
|
31
|
+
import { STAR_URL } from '../star-note.js';
|
|
31
32
|
|
|
32
33
|
// The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
|
|
33
34
|
// next to each other — terminal palettes set them with unbalanced perceptual
|
|
@@ -1032,6 +1033,13 @@ function buildDelegatedSeg(data, singleLine, c, isIcon, verbose, g, color) {
|
|
|
1032
1033
|
};
|
|
1033
1034
|
}
|
|
1034
1035
|
|
|
1036
|
+
/** The one-time star ask (see star-note.js); null unless the CLI passed a chip. */
|
|
1037
|
+
function buildStarSeg(star, c, isIcon) {
|
|
1038
|
+
if (!star || typeof star !== 'object' || !Number.isFinite(star.usd)) return null;
|
|
1039
|
+
const text = `${isIcon ? '⭐ ' : ''}${formatMoney(star.usd)} saved · star ${STAR_URL}`;
|
|
1040
|
+
return `${c(YELLOW)}${text}${c(RESET)}`;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1035
1043
|
/**
|
|
1036
1044
|
* Doc2md chip and headline line. As with routing, they are alternatives: money
|
|
1037
1045
|
* earns the headline line, and the chip is for what a line would be all frame
|
|
@@ -1142,6 +1150,8 @@ export const DEFAULT_SEGMENT_ORDER = [
|
|
|
1142
1150
|
// Lifetime, from the savings ledgers. Delegation stays ahead of the cache
|
|
1143
1151
|
// figure: routing is what the tool is for, the cache number is a brag stat.
|
|
1144
1152
|
'delegated', 'doc2md',
|
|
1153
|
+
// One-time star ask; renders only while readStarChip returns an object.
|
|
1154
|
+
'star',
|
|
1145
1155
|
// Analysis window. These two are the only chips measured over it, so the label
|
|
1146
1156
|
// sits with them and the three render as one group.
|
|
1147
1157
|
'hit', 'saved', 'period',
|
|
@@ -1182,6 +1192,7 @@ function segmentsNamed(name, s, usageByKey) {
|
|
|
1182
1192
|
// the session's own subagent runs.
|
|
1183
1193
|
case 'delegated': return [...s.delegated, s.subagents];
|
|
1184
1194
|
case 'doc2md': return s.doc2md;
|
|
1195
|
+
case 'star': return [s.star];
|
|
1185
1196
|
case 'hit': return [s.hit];
|
|
1186
1197
|
case 'ttl': return [s.ttl];
|
|
1187
1198
|
case 'month': return [s.month];
|
|
@@ -1287,7 +1298,7 @@ function buildHeadlines(delegated, doc2md, want, eol) {
|
|
|
1287
1298
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
1288
1299
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
1289
1300
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
1290
|
-
* @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness (a full score with no warning draws only when named here), korean (draws only when named here), 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.
|
|
1301
|
+
* @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness (a full score with no warning draws only when named here), korean (draws only when named here), model, effort, hit, ttl, month, saved, delegated, doc2md, star, 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.
|
|
1291
1302
|
* @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).
|
|
1292
1303
|
*/
|
|
1293
1304
|
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
|
|
@@ -1344,6 +1355,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
1344
1355
|
delegated: delegated.chips,
|
|
1345
1356
|
// The inline chip steps aside when a headline line owns the story.
|
|
1346
1357
|
doc2md: doc2md.headline ? [] : [doc2md.chip],
|
|
1358
|
+
star: buildStarSeg(data.star, c, isIcon),
|
|
1347
1359
|
// This session's subagent runs by model family. It is a session figure, so
|
|
1348
1360
|
// it sits with the routing chips rather than with the lifetime totals.
|
|
1349
1361
|
subagents: buildSessionDelegationSeg(data.sessionDelegation, c, isIcon, verbose, g),
|
package/src/formatters/table.js
CHANGED
|
@@ -181,17 +181,48 @@ function renderCapWarnSection(caps) {
|
|
|
181
181
|
return lines;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
// Opens the report with what the tool is for, before any warning. A first run
|
|
185
|
+
// (`npx sprag-cli`) has nothing installed yet, so the pitch has to be on screen.
|
|
186
|
+
function renderRoutingSection(routing) {
|
|
187
|
+
if (!routing || typeof routing !== 'object') return [];
|
|
188
|
+
const lines = [];
|
|
189
|
+
lines.push(b(` 🔀 Routing — last ${routing.days}d`));
|
|
190
|
+
lines.push(` ${'─'.repeat(50)}`);
|
|
191
|
+
const total = Number(routing.total) || 0;
|
|
192
|
+
if (total === 0) {
|
|
193
|
+
lines.push(` No Claude Code sessions in the last ${routing.days} days to scan.`);
|
|
194
|
+
lines.push('');
|
|
195
|
+
return lines;
|
|
196
|
+
}
|
|
197
|
+
const easy = Number(routing.easy) || 0;
|
|
198
|
+
const pct = Math.round(easy / total * 100);
|
|
199
|
+
lines.push(` ${easy} of ${total} requests (${pct}%) were lookups, checks or test runs a cheaper model handles.`);
|
|
200
|
+
const cands = Array.isArray(routing.candidates) ? routing.candidates : [];
|
|
201
|
+
if (cands.length === 0) {
|
|
202
|
+
lines.push(' No recurring delegable work yet (a pattern needs 3 repeats). Approved rules apply from the next session.');
|
|
203
|
+
} else {
|
|
204
|
+
for (const c of cands) {
|
|
205
|
+
lines.push(` R${c.id} "${c.labelEn || c.label}" ×${c.count} → delegate to ${c.agent}`);
|
|
206
|
+
}
|
|
207
|
+
lines.push(` Approve one: ${b(`${CLI_NAME} harness promote R${cands[0].id} --global`)} All candidates: ${b(`${CLI_NAME} route-scan`)}`);
|
|
208
|
+
}
|
|
209
|
+
lines.push('');
|
|
210
|
+
return lines;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps, routing }, { color } = {}) {
|
|
185
214
|
colorOk = color ?? !process.env.NO_COLOR;
|
|
186
215
|
const lines = [];
|
|
187
216
|
|
|
188
217
|
// Header
|
|
189
218
|
lines.push('');
|
|
190
|
-
lines.push(`
|
|
219
|
+
lines.push(` Sprag — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
|
|
191
220
|
lines.push(` (${CLI_NAME} v${options.version || ''})`.trimEnd());
|
|
192
221
|
lines.push(` ${'═'.repeat(50)}`);
|
|
193
222
|
lines.push('');
|
|
194
223
|
|
|
224
|
+
lines.push(...renderRoutingSection(routing));
|
|
225
|
+
|
|
195
226
|
// Cap warning leads — it's the most time-sensitive signal we can show.
|
|
196
227
|
if (caps) {
|
|
197
228
|
lines.push(...renderCapWarnSection(caps));
|
package/src/harness-templates.js
CHANGED
|
@@ -52,6 +52,16 @@ export function presetRatchetImportLine() {
|
|
|
52
52
|
}
|
|
53
53
|
export const PRESET_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-preset\.md\s*$/m;
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The team ratchet: rules the user's team approved, cached by `sprag team sync`
|
|
57
|
+
* and rendered into a tool-owned file. Unlike the others its import is not in
|
|
58
|
+
* the rendered block; it is added only for members, when the file exists.
|
|
59
|
+
*/
|
|
60
|
+
export function teamRatchetImportLine() {
|
|
61
|
+
return '@~/.claude/ratchet-team.md';
|
|
62
|
+
}
|
|
63
|
+
export const TEAM_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-team\.md\s*$/m;
|
|
64
|
+
|
|
55
65
|
/**
|
|
56
66
|
* Render ratchet-preset.md. `rules` is [{ id, text }] in display order. The
|
|
57
67
|
* file is loaded into every session, so the header stays short.
|
package/src/model-rules.js
CHANGED
|
@@ -72,7 +72,8 @@ export function wilsonLowerBound(errs, n) {
|
|
|
72
72
|
// route-scan's pre-calibration defaults. Kept as local constants rather than
|
|
73
73
|
// imported: route-scan imports this module, and a static back-import would
|
|
74
74
|
// close the cycle.
|
|
75
|
-
const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 }
|
|
75
|
+
const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 },
|
|
76
|
+
T1H: { calls: null, out: 30000 } };
|
|
76
77
|
|
|
77
78
|
/** The budget a rule is held to: its calibrated one, or the default of its tier. */
|
|
78
79
|
export function ruleBudget(rule) {
|
|
@@ -132,20 +133,27 @@ function shortExample(text) {
|
|
|
132
133
|
return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
// T1 is named through its delegation agent only when that file
|
|
136
|
-
// it the literal `model: sonnet`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
// T1 (and T1H) is named through its delegation agent only when that file
|
|
137
|
+
// exists; without it the literal `model: sonnet` / `model: opus` stays so
|
|
138
|
+
// output on such machines is unchanged.
|
|
139
|
+
function t1Target(root, ko, tier = 'sonnet') {
|
|
140
|
+
if (resolveAgentName(tier, root) === null) return null;
|
|
141
|
+
return (ko ? agentPhrase : agentPhraseEn)(tier, { root });
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
|
|
143
145
|
if (lang === 'ko') {
|
|
146
|
+
if (g.tier === 'T1H') {
|
|
147
|
+
return `"${g.label}" 고난도 구현 요청(예: "${shortExample(g.example)}")은 Fable·Mythos 세션에 한해 ${t1Target(root, true, 'opus') ?? 'model: opus'}에 위임`;
|
|
148
|
+
}
|
|
144
149
|
return g.tier === 'T2'
|
|
145
150
|
? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
|
|
146
151
|
: `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true) ?? 'model: sonnet'}에 위임`;
|
|
147
152
|
}
|
|
148
153
|
const example = g.exampleEn || g.example;
|
|
154
|
+
if (g.tier === 'T1H') {
|
|
155
|
+
return `Heavy "${g.labelEn}" implementation requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false, 'opus') ?? 'a model: opus subagent'}, in Fable/Mythos sessions only`;
|
|
156
|
+
}
|
|
149
157
|
return g.tier === 'T2'
|
|
150
158
|
? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
|
|
151
159
|
: `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false) ?? 'a model: sonnet subagent'}`;
|
|
@@ -272,13 +280,27 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
|
|
|
272
280
|
' 알려 주는 작업은 하위 티어가 안전하다. 검증 게이트가 품질 하한이 된다.',
|
|
273
281
|
' 틀려도 조용한 작업(진단, 근본 원인 판정, 보안, 설계)은 티어를 내리지',
|
|
274
282
|
' 않는다. 싼 모델은 결정을 옮겨 적을 뿐, 결정을 내리지 못한다.',
|
|
283
|
+
'- **effort**: 모델 티어와 다른 축이다. 설치된 서브에이전트는 검증 형태에 따라',
|
|
284
|
+
' effort가 고정되어 있다. 명령 출력이나 파일 대조로 검증되는 위임(조회·명령',
|
|
285
|
+
' 실행·정형 변환을 맡는 haiku 에이전트)은 low, 확정된 설계를 테스트에 맞춰',
|
|
286
|
+
' 구현하는 위임(sonnet 워커)은 medium, 범위가 큰 구현·디버깅 위임(opus 워커)은',
|
|
287
|
+
' high다. 메인 세션의 effort는 바꾸지 않고,',
|
|
288
|
+
' CLAUDE_CODE_EFFORT_LEVEL은 서브에이전트의 effort를 덮어쓰므로 설정하지 않는다.',
|
|
289
|
+
'- **고난도 구현(T1H)**: 설계는 메인이 확정하고, 범위가 크거나 sonnet 이 틀린',
|
|
290
|
+
' 적 있는 구현·디버깅은 opus 에 위임한다. Fable·Mythos 세션에서만 적용되며,',
|
|
291
|
+
' 설계 판단과 되돌릴 수 없는 작업은 메인 모델에 남긴다.',
|
|
275
292
|
'- **위임 배치**: 서브에이전트 비용의 지배 항은 생성이 아니라 컨텍스트',
|
|
276
293
|
' 적재다. 같은 파일들을 읽어야 하는 작업 여러 개는 에이전트 하나에 묶어',
|
|
277
294
|
' 보내고, 읽는 영역이 다른 작업만 나눈다.',
|
|
278
295
|
implementationDelegationRule('claude', lang),
|
|
279
296
|
'- **에스컬레이션**: 같은 에러 2회 반복, 답이 복수 후보로 갈림, 서브에이전트가',
|
|
280
|
-
' 질문을 멈추고 헛도는 징후가 보이면 즉시 인계한다.
|
|
281
|
-
'
|
|
297
|
+
' 질문을 멈추고 헛도는 징후가 보이면 즉시 인계한다. 어느 축을 올릴지는 실패',
|
|
298
|
+
' 종류로 정한다. 파일을 읽지 않았거나 테스트를 돌리지 않았거나 상한에 걸려',
|
|
299
|
+
' 멈춘 누락형 실패는 같은 모델에서 effort를 한 단계 올려 한 번만 재시도하고,',
|
|
300
|
+
' 컨텍스트를 다 보고도 틀린 오판형 실패는 모델을 한 티어 올린다. 순서는',
|
|
301
|
+
' ① 재지시 후 재시도(누락형이면 effort 한 단계 위) → ② 한 티어 상향(오판형)',
|
|
302
|
+
' → ③ 메인 모델이 직접 수습. effort를 올릴 때는 에이전트 파일을 고치지 않고',
|
|
303
|
+
' Agent 도구 호출의 effort 인자에 한 단계 위 값(low → medium → high)을 넘긴다.',
|
|
282
304
|
'',
|
|
283
305
|
'## Rules',
|
|
284
306
|
'',
|
|
@@ -323,14 +345,30 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
|
|
|
323
345
|
' the verification gate is the quality floor. Never drop the tier on work',
|
|
324
346
|
' that fails silently (diagnosis, root-cause calls, security posture,',
|
|
325
347
|
' design). Cheap models transcribe decisions; they do not make them.',
|
|
348
|
+
'- **Effort**: a separate axis from the tier. The installed subagents pin it',
|
|
349
|
+
' by how their work is verified: low where the check is command output or a',
|
|
350
|
+
' file comparison (the haiku agents for lookups, command runs and mechanical',
|
|
351
|
+
' conversion), medium where a fixed design is implemented against tests (the',
|
|
352
|
+
' sonnet worker), high for large implementation or debugging (the opus',
|
|
353
|
+
' worker). Leave the main session\'s effort alone, and never set',
|
|
354
|
+
' CLAUDE_CODE_EFFORT_LEVEL: it overrides every subagent\'s own value.',
|
|
355
|
+
'- **Heavy implementation (T1H)**: the main model fixes the design; large-scope',
|
|
356
|
+
' implementation or debugging, or work sonnet has already got wrong, goes to',
|
|
357
|
+
' opus. Applies in Fable/Mythos sessions only; design judgement and',
|
|
358
|
+
' irreversible work stay on the main model.',
|
|
326
359
|
'- **Batching**: a subagent\'s dominant cost is context ingestion, not',
|
|
327
360
|
' generation. Bundle tasks that read the same files into one agent; split',
|
|
328
361
|
' only when the areas they read differ.',
|
|
329
362
|
implementationDelegationRule('claude', lang),
|
|
330
363
|
'- **Escalation**: hand off immediately on the same error twice, an answer',
|
|
331
364
|
' splitting into multiple candidates, or a subagent spinning without asking',
|
|
332
|
-
' questions.
|
|
333
|
-
'
|
|
365
|
+
' questions. Which axis moves depends on the failure: an omission (a file',
|
|
366
|
+
' not read, tests not run, a cap hit mid-task) retries once on the same',
|
|
367
|
+
' model one effort step up; a wrong answer with the full context in hand',
|
|
368
|
+
' moves up one tier. Order: ① re-instruct and retry (effort +1 on an',
|
|
369
|
+
' omission) → ② move up one tier (on a wrong answer) → ③ the main model',
|
|
370
|
+
' takes over directly. To raise effort, pass the next level (low → medium →',
|
|
371
|
+
' high) in the Agent call\'s effort argument instead of editing the agent file.',
|
|
334
372
|
'',
|
|
335
373
|
'## Rules',
|
|
336
374
|
'',
|
|
@@ -363,13 +401,13 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
|
|
|
363
401
|
const saved = r.savedUsd ? `, saved ~${signedUsd(r.savedUsd)}` : '';
|
|
364
402
|
return `${base}, delegated ×${r.delegatedRuns} err ${pct(r.delegatedErrRate)}%${saved}`;
|
|
365
403
|
};
|
|
366
|
-
// Merged
|
|
404
|
+
// Merged rules carry one cap per tier, so they state them once rather than
|
|
367
405
|
// repeating the whole stop-condition per tier.
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
406
|
+
const TIER_MODEL = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
|
|
407
|
+
const mergedBudget = (tiers) => (ko ? '상한 ' : 'cap ') + tiers
|
|
408
|
+
.map((r) => `${TIER_MODEL[r.tier]} ${budgetCapPhrase(r, ko ? 'ko' : 'en')}`).join(' / ');
|
|
371
409
|
|
|
372
|
-
// A category can carry
|
|
410
|
+
// A category can carry a T2 (haiku), a T1 (sonnet) and a T1H (opus) rule. Tier is
|
|
373
411
|
// only known after an episode finishes, so two separate bullets give the
|
|
374
412
|
// reading LLM no way to pick one at request time — merge such pairs into a
|
|
375
413
|
// single conditional rule with explicit request-time criteria (default to
|
|
@@ -384,38 +422,50 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
|
|
|
384
422
|
// text stored on the rule is whatever wording and language were current when
|
|
385
423
|
// it was promoted, which is how an English line sat in a Korean file. A rule
|
|
386
424
|
// missing what the template needs still falls back to its stored text.
|
|
387
|
-
const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1') && r.label && (ko || r.labelEn)
|
|
425
|
+
const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1' || r.tier === 'T1H') && r.label && (ko || r.labelEn)
|
|
388
426
|
? modelRuleBaseText(r, lang, { root }) : r.rule);
|
|
427
|
+
const DELEGATION_TIERS = ['T2', 'T1', 'T1H'];
|
|
389
428
|
for (const group of byCategory.values()) {
|
|
390
|
-
const
|
|
391
|
-
const t1 = group.find((r) => r.tier === 'T1');
|
|
429
|
+
const picked = DELEGATION_TIERS.map((t) => group.find((r) => r.tier === t)).filter(Boolean);
|
|
392
430
|
// A second rule of a tier the line already covers (two global rules of one
|
|
393
431
|
// category, promoted from different projects) says nothing the line does
|
|
394
432
|
// not, so it adds no line of its own. Its review flag and its numbers stay
|
|
395
433
|
// visible on the line that speaks for it.
|
|
396
|
-
const extras =
|
|
397
|
-
const unknown = group.filter((r) => r
|
|
434
|
+
const extras = picked.length ? group.filter((r) => !picked.includes(r) && DELEGATION_TIERS.includes(r.tier)) : [];
|
|
435
|
+
const unknown = group.filter((r) => !picked.includes(r) && !extras.includes(r));
|
|
398
436
|
const extraHealth = extras.map(healthOf).join('');
|
|
399
437
|
const extraStats = extras.map((r) => ` / +${r.tier} ${statsOf(r)}`).join('');
|
|
400
|
-
if (
|
|
438
|
+
if (picked.length >= 2) {
|
|
401
439
|
// Seeded rules carry a localized example; scan-produced ones only have the
|
|
402
440
|
// user's own prompt, so exampleEn is a preference, not a requirement.
|
|
403
441
|
const ex = (r) => shortExample(ko ? r.example : (r.exampleEn || r.example));
|
|
404
|
-
|
|
442
|
+
const target = (r) => {
|
|
443
|
+
if (r.tier === 'T2') return (ko ? agentPhrase : agentPhraseEn)(r.agent, { root });
|
|
444
|
+
const name = r.tier === 'T1H' ? 'opus' : 'sonnet';
|
|
445
|
+
return t1Target(root, ko, name) ?? `model: ${name}`;
|
|
446
|
+
};
|
|
447
|
+
// One line per category, stating only what differs between the tiers.
|
|
405
448
|
// The exceptions this used to spell out (design judgement, irreversible
|
|
406
449
|
// work) are identical in every rule and now sit in the header's shared
|
|
407
450
|
// clauses, so a rule the model re-reads on every request is short enough to
|
|
408
451
|
// match at a glance.
|
|
452
|
+
const when = (r, first) => {
|
|
453
|
+
if (first) return ko ? '기본' : 'by default';
|
|
454
|
+
if (r.tier === 'T1H') return ko ? '범위가 크면(Fable·Mythos 세션 한정)' : 'for large scope (Fable/Mythos sessions only)';
|
|
455
|
+
return ko ? '여러 단계·여러 파일이 얽히면' : 'when it spans multiple steps or file edits';
|
|
456
|
+
};
|
|
457
|
+
const parts = picked.map((r, i) => (ko
|
|
458
|
+
? `${when(r, i === 0)} ${target(r)}(예: "${ex(r)}")`
|
|
459
|
+
: `${i === 0 ? `${target(r)} ${when(r, true)}` : `${target(r)} ${when(r, false)}`} (e.g. "${ex(r)}")`));
|
|
460
|
+
const head = picked[0];
|
|
409
461
|
const rule = ko
|
|
410
|
-
? `"${
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
} else if (t2 || t1) {
|
|
418
|
-
const r = t2 || t1;
|
|
462
|
+
? `"${head.label}": ${parts.join(', ')}. ${mergedBudget(picked)}`
|
|
463
|
+
: `"${head.labelEn || head.label}": ${parts.join(', ')}. ${mergedBudget(picked)}`;
|
|
464
|
+
const health = picked.map(healthOf).join('');
|
|
465
|
+
const stats = picked.map((r) => `${r.tier} ${statsOf(r)}`).join(' / ');
|
|
466
|
+
lines.push(`- ${rule}${health}${extraHealth} <!-- ${stats}${extraStats} -->`);
|
|
467
|
+
} else if (picked.length === 1) {
|
|
468
|
+
const r = picked[0];
|
|
419
469
|
lines.push(`- ${composeRuleText(baseTextOf(r), r, lang)}${healthOf(r)}${extraHealth} <!-- ${statsOf(r)}${extraStats} -->`);
|
|
420
470
|
}
|
|
421
471
|
for (const r of unknown) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
|
package/src/route-caps.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* rule clears it, so a cap never outlives the turn it was stated for.
|
|
14
14
|
*
|
|
15
15
|
* State: <stateDir>/route-caps.json
|
|
16
|
-
* { sessions: { [session_id]: { ts, T2?: { calls, out }, T1?: { calls, out } } } }
|
|
16
|
+
* { sessions: { [session_id]: { ts, T2?: { calls, out }, T1?: { calls, out }, T1H?: { calls, out } } } }
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { readFileSync } from 'node:fs';
|
|
@@ -49,7 +49,7 @@ export function recordRouteCaps(sessionId, caps, now = Date.now()) {
|
|
|
49
49
|
if (typeof sessionId !== 'string' || sessionId === '') return false;
|
|
50
50
|
const state = load();
|
|
51
51
|
const next = {};
|
|
52
|
-
for (const tier of ['T2', 'T1']) {
|
|
52
|
+
for (const tier of ['T2', 'T1', 'T1H']) {
|
|
53
53
|
if (validBudget(caps?.[tier])) next[tier] = { calls: caps[tier].calls ?? null, out: caps[tier].out };
|
|
54
54
|
}
|
|
55
55
|
const has = Object.keys(next).length > 0;
|
|
@@ -69,6 +69,6 @@ export function routeCapsFor(sessionId, now = Date.now()) {
|
|
|
69
69
|
const s = load().sessions[sessionId];
|
|
70
70
|
if (!s || !Number.isFinite(s.ts) || now - s.ts > TTL_MS) return null;
|
|
71
71
|
const out = {};
|
|
72
|
-
for (const tier of ['T2', 'T1']) if (validBudget(s[tier])) out[tier] = s[tier];
|
|
72
|
+
for (const tier of ['T2', 'T1', 'T1H']) if (validBudget(s[tier])) out[tier] = s[tier];
|
|
73
73
|
return Object.keys(out).length ? out : null;
|
|
74
74
|
}
|
package/src/route-inject.js
CHANGED
|
@@ -126,8 +126,8 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
|
|
|
126
126
|
* and saves nothing. Outside such a host an unset variable selects Claude
|
|
127
127
|
* Code's built-in default for the tier, so nothing is reported unreachable.
|
|
128
128
|
*/
|
|
129
|
-
const TIER_ALIAS_ENV = { T2: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', T1: 'ANTHROPIC_DEFAULT_SONNET_MODEL' };
|
|
130
|
-
const TIER_ALIAS = { T2: 'haiku', T1: 'sonnet' };
|
|
129
|
+
const TIER_ALIAS_ENV = { T2: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', T1: 'ANTHROPIC_DEFAULT_SONNET_MODEL', T1H: 'ANTHROPIC_DEFAULT_OPUS_MODEL' };
|
|
130
|
+
const TIER_ALIAS = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
|
|
131
131
|
|
|
132
132
|
export function unreachableTiers(env = process.env) {
|
|
133
133
|
if (env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST !== '1') return [];
|
|
@@ -205,7 +205,8 @@ export function routeMatch(text, { rules, sessionRank = null, root, env = proces
|
|
|
205
205
|
const nearest = [...reachable].sort((a, b) => scopeRank(b) - scopeRank(a));
|
|
206
206
|
const t2 = nearest.find((r) => r.tier === 'T2');
|
|
207
207
|
const t1 = nearest.find((r) => r.tier === 'T1');
|
|
208
|
-
|
|
208
|
+
const t1h = nearest.find((r) => r.tier === 'T1H');
|
|
209
|
+
return t2 || t1 || t1h ? { cat, t2, t1, t1h } : null;
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
/** The budgets a match states, by tier: what the delegation guard repeats to the subagent. */
|
|
@@ -214,6 +215,7 @@ export function matchCaps(match) {
|
|
|
214
215
|
const caps = {};
|
|
215
216
|
if (match.t2) caps.T2 = ruleBudget(match.t2);
|
|
216
217
|
if (match.t1) caps.T1 = ruleBudget(match.t1);
|
|
218
|
+
if (match.t1h) caps.T1H = ruleBudget(match.t1h);
|
|
217
219
|
return caps;
|
|
218
220
|
}
|
|
219
221
|
|
|
@@ -248,10 +250,10 @@ function unreachableNotice(label, tiers, ko) {
|
|
|
248
250
|
export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match, env } = {}) {
|
|
249
251
|
const found = match === undefined ? routeMatch(text, { rules, sessionRank, root, env }) : match;
|
|
250
252
|
if (!found) return null;
|
|
251
|
-
const { cat, t2, t1 } = found;
|
|
253
|
+
const { cat, t2, t1, t1h } = found;
|
|
252
254
|
const ko = lang === 'ko';
|
|
253
255
|
const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
|
|
254
|
-
if (!t2 && !t1 && found.unreachable?.length) return unreachableNotice(label, found.unreachable, ko);
|
|
256
|
+
if (!t2 && !t1 && !t1h && found.unreachable?.length) return unreachableNotice(label, found.unreachable, ko);
|
|
255
257
|
// `root` is threaded through to agentPhrase, which names the subagent only
|
|
256
258
|
// when its .md actually exists on this machine. A caller that pins it can
|
|
257
259
|
// therefore get a deterministic phrase; a test that does not pin it reads
|
|
@@ -262,21 +264,28 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
|
|
|
262
264
|
// T1 is named through its delegation agent only when that file exists here;
|
|
263
265
|
// otherwise the literal tier string stays, so output without agents is unchanged.
|
|
264
266
|
const t1Root = root === undefined ? undefined : root;
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
+
const tierPhrase = (name) => (resolveAgentName(name, t1Root) !== null ? phrase(name) : `model: ${name}`);
|
|
268
|
+
const picked = [t2, t1, t1h].filter(Boolean);
|
|
267
269
|
let target;
|
|
268
|
-
if (
|
|
270
|
+
if (picked.length >= 2) {
|
|
271
|
+
const named = (r) => (r.tier === 'T2' ? phrase(r.agent) : tierPhrase(r.tier === 'T1H' ? 'opus' : 'sonnet'));
|
|
272
|
+
const when = (r, i) => {
|
|
273
|
+
if (i === 0) return null;
|
|
274
|
+
if (r.tier === 'T1H') return ko ? '범위가 크면(Fable·Mythos 세션 한정)' : 'for large scope (Fable/Mythos sessions only)';
|
|
275
|
+
return ko ? '여러 단계·여러 파일이 얽히면' : 'when it spans multiple steps or file edits';
|
|
276
|
+
};
|
|
269
277
|
target = ko
|
|
270
|
-
?
|
|
271
|
-
: `${
|
|
278
|
+
? picked.map((r, i) => `${when(r, i) ?? '기본'} ${named(r)}`).join(', ')
|
|
279
|
+
: picked.map((r, i) => (i === 0 ? `${named(r)} by default` : `${named(r)} ${when(r, i)}`)).join(', ');
|
|
272
280
|
} else {
|
|
273
|
-
const only =
|
|
274
|
-
target =
|
|
281
|
+
const only = picked[0];
|
|
282
|
+
target = only.tier === 'T1H' ? tierPhrase('opus') : phrase(only.agent);
|
|
275
283
|
}
|
|
276
284
|
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
|
|
285
|
+
const MODEL_OF = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
|
|
286
|
+
const caps = picked.map((r) => budgetCapPhrase(r, lang));
|
|
287
|
+
const capText = picked.length >= 2
|
|
288
|
+
? (ko ? '상한 ' : 'cap ') + picked.map((r, i) => `${MODEL_OF[r.tier]} ${caps[i]}`).join(' / ')
|
|
280
289
|
: (ko ? `상한 ${caps[0]}` : `cap ${caps[0]}`);
|
|
281
290
|
|
|
282
291
|
// The marker line is the one in ratchet-model.md, so the user sees the same
|
package/src/route-scan.js
CHANGED
|
@@ -44,6 +44,10 @@ export const T2_OUT_CLAMP = [1000, 3000]; // default 1500 pre-calibration
|
|
|
44
44
|
export const T1_MAX_MUTATING = 6;
|
|
45
45
|
export const T1_MAX_ERRORS = 1;
|
|
46
46
|
export const T1_OUT_CLAMP = [5000, 15000]; // default 8000 pre-calibration
|
|
47
|
+
// T1H (opus, Fable sessions only): too heavy for sonnet but still bounded.
|
|
48
|
+
export const T1H_MAX_ERRORS = 3;
|
|
49
|
+
export const T1H_MAX_MUTATING = 20;
|
|
50
|
+
export const T1H_OUT_CLAMP = [15000, 60000]; // default 30000 pre-calibration
|
|
47
51
|
export const T0_MIN_ERRORS = 2; // repeated tool errors = hard, by outcome
|
|
48
52
|
export const T0_MIN_MUTATING = 7;
|
|
49
53
|
// Episodes below this output size carry no delegable work (conversational
|
|
@@ -65,7 +69,14 @@ export const MIN_DELEGABLE_OUT = 100;
|
|
|
65
69
|
// questions that merely contained "statusline". None gained a hint. Bare
|
|
66
70
|
// '리뷰' and '보안' are left out on purpose: they are product nouns in ordinary
|
|
67
71
|
// status checks ("리뷰 허브 배치 확인"), so only the request-shaped forms count.
|
|
68
|
-
|
|
72
|
+
//
|
|
73
|
+
// 2026-10-18: production-database work and a user's go-ahead ("운영디비 승인할게
|
|
74
|
+
// 너가 진행해줘", "승인할게 너가 직접 실행해줘") read as light run episodes and
|
|
75
|
+
// became Opus (T1H) candidates. Measured on 6,473 local prompts over 90 days:
|
|
76
|
+
// the two term groups catch 7 + 12 prompts, of which 4 were not already
|
|
77
|
+
// escalated, all of them approvals or production-DB asks. Bare '승인' is left
|
|
78
|
+
// out: "승인된 룰" is a product noun in sprag's own ratchet flow.
|
|
79
|
+
export const ESCALATE_RE = /운영\s*(?:db|디비|데이터베이스|서버|환경)|프로덕션|\bprod(?:uction)?\s*(?:db|database|server|env)|승인\s*(?:할게|했어|했으니|했습니다|합니다|해줄게|완료)|\bapproved?\b|설계|아키텍처|리팩토링|원인\s*(?:분석|파악|확인|규명|조사)|원인을?\s*찾|버그|개선할|개선점|개선\s*(?:사항|방안)|검토해보|검수|코드\s*리뷰|리뷰\s*해|취약|보안\s*(?:점검|검토|감사|리뷰|이슈)|비교|왜(?=[\s?]|$)|이유|제출|배포|출시|퍼블리시|릴리[스즈](?!\s*노트)|(?<!나)머지|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge|root cause|code review|security (?:review|audit)|vulnerabilit/i;
|
|
69
80
|
|
|
70
81
|
// Implementation asks phrased around a run/check verb ("설치와 동시에 설정되도록
|
|
71
82
|
// 하자", "피드백 확인해주고 아이콘도 만들자"). Editing is not delegable, and at
|
|
@@ -412,32 +423,30 @@ const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(
|
|
|
412
423
|
* Falls back to mid-clamp defaults when the sample is too small to trust.
|
|
413
424
|
*/
|
|
414
425
|
export function calibrateThresholds(episodeOuts) {
|
|
415
|
-
if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, calibrated: false };
|
|
426
|
+
if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, t1hOut: 30000, calibrated: false };
|
|
416
427
|
const sorted = [...episodeOuts].sort((a, b) => a - b);
|
|
417
428
|
return {
|
|
418
429
|
t2Out: clamp(Math.max(percentile(sorted, 0.25), 1500), T2_OUT_CLAMP),
|
|
419
430
|
t1Out: clamp(percentile(sorted, 0.75), T1_OUT_CLAMP),
|
|
431
|
+
t1hOut: clamp(percentile(sorted, 0.95), T1H_OUT_CLAMP),
|
|
420
432
|
calibrated: true,
|
|
421
433
|
};
|
|
422
434
|
}
|
|
423
435
|
|
|
424
436
|
/**
|
|
425
|
-
* Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1'|'T2',
|
|
437
|
+
* Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1H'|'T1'|'T2',
|
|
426
438
|
* or null when the episode carries nothing delegable. Order matters: hard
|
|
427
439
|
* evidence (errors, heavy mutation, big output, judgement keywords) wins
|
|
428
440
|
* before any cheap-band check.
|
|
429
441
|
*/
|
|
430
442
|
export function tierOf(ep, category, th) {
|
|
431
443
|
if (ep.out < MIN_DELEGABLE_OUT) return null;
|
|
432
|
-
if (
|
|
433
|
-
ep.errors >= T0_MIN_ERRORS ||
|
|
434
|
-
ep.mutating >= T0_MIN_MUTATING ||
|
|
435
|
-
ep.out > th.t1Out ||
|
|
436
|
-
ESCALATE_RE.test(ep.text)
|
|
437
|
-
) return 'T0';
|
|
444
|
+
if (ESCALATE_RE.test(ep.text)) return 'T0';
|
|
438
445
|
if (!category || ep.delegated > 0) return 'T0';
|
|
439
446
|
if (ep.calls <= T2_MAX_CALLS && ep.out <= th.t2Out && ep.mutating <= T2_MAX_MUTATING && ep.errors === 0) return 'T2';
|
|
440
447
|
if (ep.out <= th.t1Out && ep.mutating <= T1_MAX_MUTATING && ep.errors <= T1_MAX_ERRORS) return 'T1';
|
|
448
|
+
const t1hOut = th.t1hOut ?? 30000;
|
|
449
|
+
if (ep.errors <= T1H_MAX_ERRORS && ep.mutating <= T1H_MAX_MUTATING && ep.out <= t1hOut) return 'T1H';
|
|
441
450
|
return 'T0';
|
|
442
451
|
}
|
|
443
452
|
|
|
@@ -611,13 +620,13 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
611
620
|
const shapeTier = tierOf({ ...ep, errors: 0 }, cat, thresholds);
|
|
612
621
|
// Same rank gate as the candidate path below — a denominator counting
|
|
613
622
|
// episodes that can't produce a rule would skew that rule's error rate.
|
|
614
|
-
if ((shapeTier === 'T1' || shapeTier === 'T2') && worthDelegating(shapeTier, epRank)) {
|
|
623
|
+
if ((shapeTier === 'T1' || shapeTier === 'T2' || shapeTier === 'T1H') && worthDelegating(shapeTier, epRank)) {
|
|
615
624
|
bumpStats(`${shapeTier}|${cat.id}|${projectDir}`, ep);
|
|
616
625
|
bumpStats(`${shapeTier}|${cat.id}|*`, ep);
|
|
617
626
|
}
|
|
618
627
|
}
|
|
619
628
|
const tier = tierOf(ep, cat, thresholds);
|
|
620
|
-
if (tier !== 'T1' && tier !== 'T2') continue;
|
|
629
|
+
if (tier !== 'T1' && tier !== 'T2' && tier !== 'T1H') continue;
|
|
621
630
|
if (!worthDelegating(tier, epRank)) continue;
|
|
622
631
|
tieredEpisodes += 1;
|
|
623
632
|
const key = `${tier}|${cat.id}|${projectDir}`;
|
|
@@ -626,7 +635,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
626
635
|
category: cat.id,
|
|
627
636
|
label: cat.label,
|
|
628
637
|
labelEn: cat.labelEn,
|
|
629
|
-
agent: tier === 'T2' ? cat.agent : 'sonnet',
|
|
638
|
+
agent: tier === 'T2' ? cat.agent : tier === 'T1H' ? 'opus' : 'sonnet',
|
|
630
639
|
project: projectDir,
|
|
631
640
|
projectPath: '',
|
|
632
641
|
count: 0,
|
|
@@ -653,7 +662,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
653
662
|
// where a promoted rule's real success rate lives, so they get their own
|
|
654
663
|
// pass: join each episode to the subagent transcripts it spawned, and file
|
|
655
664
|
// the outcome under the tier that run's model represents — a haiku run is a
|
|
656
|
-
// T2 rule firing, a sonnet run a T1 one. Runs that were not a downgrade
|
|
665
|
+
// T2 rule firing, a sonnet run a T1 one, an opus run (Fable session) a T1H one. Runs that were not a downgrade
|
|
657
666
|
// (same tier or higher) carry no delegation saving and are skipped.
|
|
658
667
|
const delegatedStats = new Map(); // "tier|category|project" → outcome aggregate
|
|
659
668
|
let unresolvedRuns = 0; // delegated runs dropped for an unpriceable model id
|
|
@@ -774,7 +783,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
774
783
|
// time rather than hardcoded downstream.
|
|
775
784
|
const budgetOf = (g) => ({
|
|
776
785
|
calls: g.tier === 'T2' ? T2_MAX_CALLS + 2 : null,
|
|
777
|
-
out: g.tier === 'T2' ? thresholds.t2Out : thresholds.t1Out,
|
|
786
|
+
out: g.tier === 'T2' ? thresholds.t2Out : g.tier === 'T1H' ? thresholds.t1hOut : thresholds.t1Out,
|
|
778
787
|
});
|
|
779
788
|
// Shared with the seed presets (src/seed-rules.js) through model-rules.js —
|
|
780
789
|
// one template, so a reworded rule cannot mean two different things
|
|
@@ -945,7 +954,7 @@ export async function shouldRescan(cache, { days = 14, afterDelegation = false }
|
|
|
945
954
|
}
|
|
946
955
|
|
|
947
956
|
/**
|
|
948
|
-
* Human-readable labels for the T0/T1/T2 codes and scope values — used by
|
|
957
|
+
* Human-readable labels for the T0/T1H/T1/T2 codes and scope values — used by
|
|
949
958
|
* every user-facing listing so a bare code never appears without its meaning
|
|
950
959
|
* (first-time users can't be expected to know the tier vocabulary).
|
|
951
960
|
*/
|
|
@@ -953,11 +962,13 @@ export function tierLabel(tier, lang = 'ko') {
|
|
|
953
962
|
const ko = {
|
|
954
963
|
T2: '단순 작업, haiku급이면 충분',
|
|
955
964
|
T1: '중간 난도, sonnet급이면 충분',
|
|
965
|
+
T1H: '고난도 구현, opus급이면 충분 (Fable 세션 전용)',
|
|
956
966
|
T0: '고난도, 지금 모델 유지',
|
|
957
967
|
};
|
|
958
968
|
const en = {
|
|
959
969
|
T2: 'simple — haiku-class is enough',
|
|
960
970
|
T1: 'moderate — sonnet-class is enough',
|
|
971
|
+
T1H: 'heavy, opus-class is enough (Fable sessions only)',
|
|
961
972
|
T0: 'hard — stays on the session model',
|
|
962
973
|
};
|
|
963
974
|
return (lang === 'ko' ? ko : en)[tier] || tier;
|
|
@@ -3,7 +3,7 @@ import { isAbsolute, resolve } from 'node:path';
|
|
|
3
3
|
import { loadModelRules, ruleBudget } from './model-rules.js';
|
|
4
4
|
import { findProjectRoot } from './harness.js';
|
|
5
5
|
|
|
6
|
-
export const SHARED_RULE_TIERS = ['T2', 'T1'];
|
|
6
|
+
export const SHARED_RULE_TIERS = ['T2', 'T1', 'T1H'];
|
|
7
7
|
const CATEGORIES = new Set(['paste', 'translate', 'explore', 'read', 'check', 'run']);
|
|
8
8
|
|
|
9
9
|
/** A root as a real path, so a symlinked checkout or macOS /var names one directory once. */
|
package/src/star-note.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* star-note — a one-time "star the repo" ask in the statusline.
|
|
3
|
+
*
|
|
4
|
+
* The package is downloaded thousands of times a month and starred a handful
|
|
5
|
+
* of times: all of the value lands in the statusline, so users never see the
|
|
6
|
+
* repository. This asks once, at the moment the value is visible — when the
|
|
7
|
+
* savings sprag itself produced (routing + doc2md) first reach $10.
|
|
8
|
+
*
|
|
9
|
+
* Once, because a chip that keeps asking is an advertisement. For 24 hours,
|
|
10
|
+
* which is long enough to be seen across a day of sessions and short enough
|
|
11
|
+
* not to linger. CTS_NO_NOTE=1 suppresses it, the same opt-out as every
|
|
12
|
+
* other one-time note.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { delegationSavedTotals } from './savings-ledger.js';
|
|
17
|
+
import { noteShownAt, markNoteShown } from './first-run-note.js';
|
|
18
|
+
import { userDataDir } from './paths.js';
|
|
19
|
+
import { debug } from './debug.js';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
|
|
23
|
+
export const STAR_THRESHOLD_USD = 10;
|
|
24
|
+
export const STAR_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
export const STAR_URL = 'github.com/rootstudioyaml/sprag';
|
|
26
|
+
|
|
27
|
+
/** Lifetime dollars saved by routing and doc2md; an unreadable ledger counts as 0. */
|
|
28
|
+
export function toolSavedUsd(now = Date.now(), dir = userDataDir()) {
|
|
29
|
+
let total = 0;
|
|
30
|
+
try {
|
|
31
|
+
total += Number(delegationSavedTotals(now, dir).total) || 0;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
debug('star-note:routing', e);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
total += Number(require('./doc2md-ledger.cjs').doc2mdSavedTotals(dir).total) || 0;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
debug('star-note:doc2md', e);
|
|
39
|
+
}
|
|
40
|
+
return total;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `{ usd, shownAt }` while the chip should show, else null. Never throws. */
|
|
44
|
+
export function readStarChip({ now = Date.now(), dir = userDataDir(), savedUsd } = {}) {
|
|
45
|
+
try {
|
|
46
|
+
if (process.env.CTS_NO_NOTE === '1') return null;
|
|
47
|
+
const shownAt = noteShownAt('star');
|
|
48
|
+
if (shownAt === null) {
|
|
49
|
+
const usd = savedUsd ?? toolSavedUsd(now, dir);
|
|
50
|
+
if (!(usd >= STAR_THRESHOLD_USD)) return null;
|
|
51
|
+
markNoteShown('star', now);
|
|
52
|
+
return { usd, shownAt: now };
|
|
53
|
+
}
|
|
54
|
+
if (now - shownAt < STAR_WINDOW_MS) {
|
|
55
|
+
return { usd: savedUsd ?? toolSavedUsd(now, dir), shownAt };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
} catch (e) {
|
|
59
|
+
debug('star-note', e);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|