sprag-cli 3.68.0 → 3.69.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 CHANGED
@@ -5,7 +5,7 @@
5
5
  <img alt="Sprag" src="https://raw.githubusercontent.com/rootstudioyaml/sprag/main/site/assets/logo/sprag-lockup-light.svg" width="220">
6
6
  </picture>
7
7
 
8
- **Claude Code와 Codex가 쓰는 토큰을 첫 세션부터 줄입니다**
8
+ **Claude Code와 Codex에 붙이는 모델 위임 라우터. 조회·확인·테스트 실행은 더 싼 모델에 보내고, 아낀 금액은 상태줄에 보여 줍니다.**
9
9
 
10
10
  [![npm](https://img.shields.io/npm/v/sprag-cli.svg?label=sprag-cli)](https://www.npmjs.com/package/sprag-cli)
11
11
  [![downloads](https://img.shields.io/npm/dm/sprag-cli.svg)](https://www.npmjs.com/package/sprag-cli)
@@ -26,11 +26,11 @@
26
26
 
27
27
  ---
28
28
 
29
- **일하는 방식은 그대로 두고 토큰만 줄입니다.** 조회와 확인, 테스트 실행은 저렴한 모델에 넘기고, 문서는 가벼운 Markdown으로 바꿔 넣습니다(54만 토큰짜리 발표 자료가 2.3만 토큰이 됩니다). 세션마다 쓴 금액과 아낀 금액은 상태줄에 바로 나타납니다. `npm i -g sprag-cli` 하나로 설정이 끝나며, 프로젝트마다 따로 설정할 것은 없습니다.
29
+ **Sprag는 일마다 그 일을 한 번도 틀린 적 없는 가장 싼 모델에게 보냅니다.** Claude Code와 Codex는 프롬프트마다 모델을 바꿀 수 없으므로, 통하는 방법 하나를 씁니다. 메인 세션의 규칙이 조회, 상태 확인, 빌드·테스트 실행, 일괄 수정을 더 싼 모델로 고정한 서브에이전트에 넘깁니다. 규칙은 사용자의 세션 기록에서 나오고 하나씩 승인을 받으며, 위임한 실행마다 가격 차액을 원장에 적습니다. `npm i -g sprag-cli` 하나로 설정이 끝나며, 프로젝트마다 따로 설정할 것은 없습니다.
30
30
 
31
- **에이전트는 그대로 일하고, 퇴행만 막습니다.** 이름은 스프래그 클러치에서 왔습니다. 앞으로 도는 힘은 그대로 통과시키고, 역회전하는 순간 잠기는 부품입니다.
31
+ 두 가지가 함께 따라옵니다. 문서는 가벼운 Markdown으로 바뀌어 컨텍스트에 들어가고(54만 토큰짜리 발표 자료가 2.3만 토큰이 됩니다), 상태줄은 세션마다 쓴 금액과 아낀 금액을 보여 주다가 캐시·컨텍스트·사용량 한도에 문제가 생기면 경고 칩을 맨 앞에 띄웁니다.
32
32
 
33
- Sprag는 Claude Code와 Codex가 남기는 세션 기록을 읽어 반복 실패와 위임 후보를 찾습니다. 사용자가 승인한 규칙은 다음 세션에도 적용하며, 위임 기록과 모델 단가를 확인할 수 있으면 예상 차액을 원장에 적습니다. 캐시와 사용량 한도 문제는 상태줄이나 패널에서 확인할 수 있습니다.
33
+ 이름은 스프래그 클러치에서 왔습니다. 앞으로 도는 힘은 통과시키고 역회전하는 순간 잠기는 부품입니다. 기록에서 반복되는 실패는 세션 시작 때 불러오는 규칙이 되어, 에이전트는 그대로 일하고 퇴행만 걸립니다.
34
34
 
35
35
  세션 분석은 로컬에서 실행하므로 추가 모델 호출이나 API 키가 필요하지 않습니다. Codex의 선택 기능인 예산·단가 조회는 기존 인증 정보로 사용자가 설정한 LiteLLM에 접속합니다. CLI는 Node.js 20 이상과 TOML 파서 하나를 사용합니다.
36
36
 
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <img alt="Sprag" src="https://raw.githubusercontent.com/rootstudioyaml/sprag/main/site/assets/logo/sprag-lockup-light.svg" width="220">
6
6
  </picture>
7
7
 
8
- **Cut the tokens Claude Code and Codex spend, from the first session.**
8
+ **A model-delegation router for Claude Code and Codex: lookups, checks and test runs go to cheaper models, and the savings show up in your statusline.**
9
9
 
10
10
  [![npm](https://img.shields.io/npm/v/sprag-cli.svg?label=sprag-cli)](https://www.npmjs.com/package/sprag-cli)
11
11
  [![downloads](https://img.shields.io/npm/dm/sprag-cli.svg)](https://www.npmjs.com/package/sprag-cli)
@@ -26,20 +26,22 @@ Same 12 requests, two terminals: **$6.68 → $1.83 (−72.6%)** with Sprag.<br>
26
26
 
27
27
  ---
28
28
 
29
- **Sprag saves tokens without changing how you work.** Lookups, checks and test
30
- runs go to a cheaper model, documents reach the context as compact Markdown (a
31
- 540k-token deck becomes 23k tokens), and the statusline shows what each session
32
- spent and saved while it runs. `npm i -g sprag-cli` sets it all up; nothing to
33
- configure per project.
29
+ **Sprag routes each piece of work to the cheapest model that has never got it
30
+ wrong.** Claude Code and Codex cannot switch models per prompt, so Sprag does it
31
+ the one way that works: rules in the main session hand lookups, status checks,
32
+ build and test runs and batch edits to sub-agents pinned to a cheaper model. The
33
+ rules come from your own session logs, you approve each one, and every delegated
34
+ run writes its price difference to a ledger. `npm i -g sprag-cli` sets it all
35
+ up; nothing to configure per project.
34
36
 
35
- Sprag is named after the sprag clutch: forward motion passes, backspin locks.
36
- Your agent keeps working, it just stops regressing.
37
+ Two more things ride along. Documents reach the context as compact Markdown (a
38
+ 540k-token deck becomes 23k tokens), and the statusline shows what each session
39
+ spent and saved, with a warning chip in front when the cache, the context or a
40
+ rate limit needs attention.
37
41
 
38
- It reads the sessions you already ran, and what it finds there holds for every
39
- session after. Repeated failures become rules loaded at session start. Work that
40
- a cheaper tier has never got wrong goes to a sub-agent on that tier, and every
41
- delegated run writes its own price difference to a ledger. Cache and rate-limit
42
- trouble reaches your statusline while you can still act on it.
42
+ The name is the sprag clutch: forward motion passes, backspin locks. Repeated
43
+ failures in your logs become rules loaded at session start, so the agent keeps
44
+ working and only the regressions get caught.
43
45
 
44
46
  Session analysis runs locally on the logs Claude Code and Codex already write.
45
47
  No extra model calls or API key are needed for analysis. Optional Codex gateway
package/bin/cli.js CHANGED
@@ -59,6 +59,7 @@ import { chipForIssues } from '../src/advice.js';
59
59
  import { debug } from '../src/debug.js';
60
60
  import { createArgs } from '../src/cli-args.js';
61
61
  import { readUpdateChip } from '../src/update-notice.js';
62
+ import { readStarChip } from '../src/star-note.js';
62
63
  import { selectAgent } from '../src/agent.js';
63
64
 
64
65
  const selection = (() => {
@@ -940,6 +941,11 @@ async function main() {
940
941
  options: { days, windowHours, windowLabel, version: PKG_VERSION },
941
942
  // Cached-only; the background refresh it may trigger lands on a later render.
942
943
  update: format === 'statusline' ? readUpdateChip(PKG_VERSION) : null,
944
+ // The totals are already in hand above; passing them spares a second read of
945
+ // both ledgers on every render.
946
+ star: format === 'statusline'
947
+ ? readStarChip({ savedUsd: delegationSaved + (Number(doc2mdTotals?.total) || 0) })
948
+ : null,
943
949
  lastActivity,
944
950
  monthSpend: monthSpendInfo,
945
951
  modelShare,
@@ -998,8 +1004,29 @@ async function main() {
998
1004
  singleLine: hasFlag('--single-line'),
999
1005
  });
1000
1006
  } else {
1007
+ // The first screen is the pitch: what share of recent work a cheaper model
1008
+ // could take. The scan is local and writes only sprag's own cache file;
1009
+ // --project skips it because the scan covers all projects, not one.
1010
+ let routing = null;
1011
+ if (!projectFilter && process.env.CTS_NO_ROUTE_SCAN !== '1') {
1012
+ try {
1013
+ const rs = await import('../src/route-scan.js');
1014
+ let cache = rs.readRouteScan();
1015
+ if (!cache) {
1016
+ process.stderr.write('Scanning for delegation candidates (first run)...\n');
1017
+ cache = await rs.runRouteScan({ days: 14 });
1018
+ }
1019
+ routing = {
1020
+ days: cache.days,
1021
+ total: cache.totalEpisodes,
1022
+ easy: cache.easyEpisodes,
1023
+ scannedAt: cache.scannedAt,
1024
+ candidates: rs.openCandidates(cache).slice(0, 3),
1025
+ };
1026
+ } catch (e) { debug('report:routing', e); }
1027
+ }
1001
1028
  const { formatReport } = await import('../src/formatters/table.js');
1002
- output = formatReport(data, { color: tableColor() });
1029
+ output = formatReport({ ...data, routing }, { color: tableColor() });
1003
1030
  }
1004
1031
 
1005
1032
  if (format === 'statusline') {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.68.0",
4
- "description": "Cut Claude Code and Codex token costs: cheaper-model routing, compact Markdown for documents, and a live statusline of spend and savings.",
3
+ "version": "3.69.0",
4
+ "description": "Model-delegation router for Claude Code and Codex: lookups, checks and test runs go to cheaper models, documents go in as compact Markdown, and a live statusline shows spend and savings.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sprag": "bin/cli.js",
@@ -49,6 +49,10 @@
49
49
  "claude-code-statusline",
50
50
  "statusline",
51
51
  "model-routing",
52
+ "model-router",
53
+ "model-delegation",
54
+ "llm-router",
55
+ "claude-code-router",
52
56
  "subagent",
53
57
  "prompt-caching",
54
58
  "haiku",
@@ -22,10 +22,19 @@ const MARK_LINE = `# managed by sprag (${OWNER_MARK}); edits are overwritten`;
22
22
 
23
23
  // Models are aliases on purpose: behind a gateway they resolve through
24
24
  // ANTHROPIC_DEFAULT_*_MODEL, and a full model id would bypass that mapping.
25
+ //
26
+ // Effort is a separate axis from the tier and is set by how the work gets
27
+ // verified, not by how hard it is. Where the check is command output or a file
28
+ // comparison (lookups, test runs, mechanical conversion) extra thinking buys
29
+ // nothing, so those agents run at low. The worker implements a design the
30
+ // caller already fixed, against tests, so it takes the default for well-specified
31
+ // work, medium. The main session's effort is never touched here, and
32
+ // CLAUDE_CODE_EFFORT_LEVEL must stay unset: it overrides every agent's own value.
25
33
  export const DEFINITIONS = [
26
34
  {
27
35
  name: 'haiku-explore',
28
36
  model: 'haiku',
37
+ effort: 'low',
29
38
  tools: 'Read, Grep, Glob, Bash',
30
39
  role: 'Your role: read-only exploration, lookups, status checks, and questions about pasted logs or text.',
31
40
  description: 'Cheap read-only exploration, lookup, status checks and summaries for delegated tasks. It has no MCP tools.',
@@ -33,6 +42,7 @@ export const DEFINITIONS = [
33
42
  {
34
43
  name: 'haiku-runner',
35
44
  model: 'haiku',
45
+ effort: 'low',
36
46
  tools: 'Bash, Read, Grep, Glob',
37
47
  role: 'Your role: run the build, test, or git commands you are given and report the result.',
38
48
  description: 'Cheap runner for build, test and git commands in delegated tasks. It has no MCP tools.',
@@ -40,6 +50,7 @@ export const DEFINITIONS = [
40
50
  {
41
51
  name: 'haiku-translate',
42
52
  model: 'haiku',
53
+ effort: 'low',
43
54
  tools: 'Read, Grep, Glob, Edit, Write',
44
55
  role: 'Your role: batch translation and mechanical text conversion of the files or text you are given.',
45
56
  description: 'Cheap batch translation and structured text conversion for delegated tasks. It has no MCP tools.',
@@ -47,6 +58,7 @@ export const DEFINITIONS = [
47
58
  {
48
59
  name: 'sonnet-worker',
49
60
  model: 'sonnet',
61
+ effort: 'medium',
50
62
  tools: 'Read, Grep, Glob, Bash, Edit, Write, NotebookEdit',
51
63
  role: 'Your role: multi-step work and the implementation half of a design the caller already fixed.',
52
64
  description: 'Mid-tier worker for multi-step tasks and implementation handed over with a fixed design. It has no MCP tools.',
@@ -60,6 +72,7 @@ function render(def) {
60
72
  `description: ${def.description}`,
61
73
  `tools: ${def.tools}`,
62
74
  `model: ${def.model}`,
75
+ `effort: ${def.effort}`,
63
76
  MARK_LINE,
64
77
  '---',
65
78
  def.role,
@@ -34,6 +34,22 @@ function markShown(key, now) {
34
34
  writeFileSync(firstRunStatePath(), JSON.stringify(state) + '\n');
35
35
  }
36
36
 
37
+ /** When `key` was marked shown (epoch ms), or null. For notes that stay
38
+ * visible for a window after the first time instead of printing once. */
39
+ export function noteShownAt(key) {
40
+ const at = Number(load()[key]);
41
+ return Number.isFinite(at) && at > 0 ? at : null;
42
+ }
43
+
44
+ /** Persist `key` as shown at `now`. Best-effort, like `shouldShowOnce`. */
45
+ export function markNoteShown(key, now = Date.now()) {
46
+ try {
47
+ markShown(key, now);
48
+ } catch {
49
+ /* state dir unwritable — the note may show again, never a failure */
50
+ }
51
+ }
52
+
37
53
  /** True only the first time this key is asked about. Best-effort — a
38
54
  * read-only state dir just means the note repeats, never that we crash. */
39
55
  export function shouldShowOnce(key, { now = Date.now() } = {}) {
@@ -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),
@@ -181,17 +181,48 @@ function renderCapWarnSection(caps) {
181
181
  return lines;
182
182
  }
183
183
 
184
- export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }, { color } = {}) {
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(` Claude Token Saver — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
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));
@@ -272,13 +272,23 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
272
272
  ' 알려 주는 작업은 하위 티어가 안전하다. 검증 게이트가 품질 하한이 된다.',
273
273
  ' 틀려도 조용한 작업(진단, 근본 원인 판정, 보안, 설계)은 티어를 내리지',
274
274
  ' 않는다. 싼 모델은 결정을 옮겨 적을 뿐, 결정을 내리지 못한다.',
275
+ '- **effort**: 모델 티어와 다른 축이다. 설치된 서브에이전트는 검증 형태에 따라',
276
+ ' effort가 고정되어 있다. 명령 출력이나 파일 대조로 검증되는 위임(조회·명령',
277
+ ' 실행·정형 변환을 맡는 haiku 에이전트)은 low, 확정된 설계를 테스트에 맞춰',
278
+ ' 구현하는 위임(sonnet 워커)은 medium이다. 메인 세션의 effort는 바꾸지 않고,',
279
+ ' CLAUDE_CODE_EFFORT_LEVEL은 서브에이전트의 effort를 덮어쓰므로 설정하지 않는다.',
275
280
  '- **위임 배치**: 서브에이전트 비용의 지배 항은 생성이 아니라 컨텍스트',
276
281
  ' 적재다. 같은 파일들을 읽어야 하는 작업 여러 개는 에이전트 하나에 묶어',
277
282
  ' 보내고, 읽는 영역이 다른 작업만 나눈다.',
278
283
  implementationDelegationRule('claude', lang),
279
284
  '- **에스컬레이션**: 같은 에러 2회 반복, 답이 복수 후보로 갈림, 서브에이전트가',
280
- ' 질문을 멈추고 헛도는 징후가 보이면 즉시 인계한다. 대응 순서는',
281
- ' ① 재지시 후 재시도 → ② 한 티어 상향 → ③ 메인 모델이 직접 수습.',
285
+ ' 질문을 멈추고 헛도는 징후가 보이면 즉시 인계한다. 어느 축을 올릴지는 실패',
286
+ ' 종류로 정한다. 파일을 읽지 않았거나 테스트를 돌리지 않았거나 상한에 걸려',
287
+ ' 멈춘 누락형 실패는 같은 모델에서 effort를 한 단계 올려 한 번만 재시도하고,',
288
+ ' 컨텍스트를 다 보고도 틀린 오판형 실패는 모델을 한 티어 올린다. 순서는',
289
+ ' ① 재지시 후 재시도(누락형이면 effort 한 단계 위) → ② 한 티어 상향(오판형)',
290
+ ' → ③ 메인 모델이 직접 수습. effort를 올릴 때는 에이전트 파일을 고치지 않고',
291
+ ' Agent 도구 호출의 effort 인자에 한 단계 위 값(low → medium → high)을 넘긴다.',
282
292
  '',
283
293
  '## Rules',
284
294
  '',
@@ -323,14 +333,25 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
323
333
  ' the verification gate is the quality floor. Never drop the tier on work',
324
334
  ' that fails silently (diagnosis, root-cause calls, security posture,',
325
335
  ' design). Cheap models transcribe decisions; they do not make them.',
336
+ '- **Effort**: a separate axis from the tier. The installed subagents pin it',
337
+ ' by how their work is verified: low where the check is command output or a',
338
+ ' file comparison (the haiku agents for lookups, command runs and mechanical',
339
+ ' conversion), medium where a fixed design is implemented against tests (the',
340
+ ' sonnet worker). Leave the main session\'s effort alone, and never set',
341
+ ' CLAUDE_CODE_EFFORT_LEVEL: it overrides every subagent\'s own value.',
326
342
  '- **Batching**: a subagent\'s dominant cost is context ingestion, not',
327
343
  ' generation. Bundle tasks that read the same files into one agent; split',
328
344
  ' only when the areas they read differ.',
329
345
  implementationDelegationRule('claude', lang),
330
346
  '- **Escalation**: hand off immediately on the same error twice, an answer',
331
347
  ' splitting into multiple candidates, or a subagent spinning without asking',
332
- ' questions. Order: ① re-instruct and retry → ② move up one tier → ③ the',
333
- ' main model takes over directly.',
348
+ ' questions. Which axis moves depends on the failure: an omission (a file',
349
+ ' not read, tests not run, a cap hit mid-task) retries once on the same',
350
+ ' model one effort step up; a wrong answer with the full context in hand',
351
+ ' moves up one tier. Order: ① re-instruct and retry (effort +1 on an',
352
+ ' omission) → ② move up one tier (on a wrong answer) → ③ the main model',
353
+ ' takes over directly. To raise effort, pass the next level (low → medium →',
354
+ ' high) in the Agent call\'s effort argument instead of editing the agent file.',
334
355
  '',
335
356
  '## Rules',
336
357
  '',
@@ -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
+ }
package/src/changelog.js DELETED
@@ -1,70 +0,0 @@
1
- /**
2
- * changelog — read one version's section out of CHANGELOG.md, and say whether a
3
- * notes draft is still waiting on a person.
4
- *
5
- * Separate from scripts/release-notes.mjs, which is a CLI: importing that file
6
- * runs its argument parsing and exits, so the extraction it depends on cannot be
7
- * tested through it. The boundary logic is the part worth pinning — a section
8
- * pulled one line short drops the release's last item, and one line long carries
9
- * the previous release's first, and the upgrade offer shows either without
10
- * complaint.
11
- *
12
- * The draft marker lives here for a reason. Two scripts gate on it — the one that
13
- * writes drafts and the one that deploys — and they used to carry the string
14
- * separately. Renaming it in one left the other looking for text that no longer
15
- * existed, and a gate that greps for an absent string does not fail: it passes,
16
- * and an unreviewed draft goes out as the release. One definition, imported.
17
- */
18
-
19
- /**
20
- * The body of one version's section, heading excluded.
21
- *
22
- * @param {string} changelog - CHANGELOG.md contents
23
- * @param {string} version - e.g. "3.45.0"
24
- * @returns {string|null} the section text, or null when that version has none
25
- */
26
- export function sectionFor(changelog, version) {
27
- const lines = String(changelog).split(/\r?\n/);
28
- // The version has to match to its end: without the boundary, "3.42.0" would
29
- // also match the "### v3.42.7" heading and publish the wrong notes.
30
- const head = new RegExp(`^### v${String(version).replace(/\./g, '\\.')}(\\s|$|\\()`);
31
- const start = lines.findIndex((l) => head.test(l));
32
- if (start < 0) return null;
33
- const out = [];
34
- for (let i = start + 1; i < lines.length; i += 1) {
35
- // The next version heading ends this section, and so does a shallower one:
36
- // "## All releases" is a container, not part of any release.
37
- if (/^### v/.test(lines[i]) || /^## /.test(lines[i])) break;
38
- out.push(lines[i]);
39
- }
40
- return out.join('\n').trim();
41
- }
42
-
43
- /**
44
- * The line `release-notes.mjs` writes at the top of a fresh draft, and the line
45
- * every gate refuses on. A draft still carrying it has not been through a person.
46
- */
47
- export const DRAFT_MARKER =
48
- '<!-- REVIEW: edit this draft, add the English half, then delete this line -->';
49
-
50
- /** Heading that opens the Korean half of a bilingual release body. */
51
- export const KO_HEADING = '## 한국어';
52
-
53
- /**
54
- * The opening of the marker, which is what the gate actually matches. Spelled out
55
- * rather than sliced off `DRAFT_MARKER`, so that editing the marker's front does
56
- * not silently change what is being tested for.
57
- */
58
- const REVIEW_OPENING = '<!-- REVIEW:';
59
-
60
- /**
61
- * Whether a draft still needs review. Matches on the `<!-- REVIEW:` opening
62
- * rather than the whole line, so editing the wording of the instruction does not
63
- * quietly disarm the gate.
64
- *
65
- * @param {string} draft - the draft file's contents
66
- * @returns {boolean} true while the draft is unreviewed
67
- */
68
- export function needsReview(draft) {
69
- return String(draft || '').includes(REVIEW_OPENING);
70
- }