sprag-cli 3.72.0 → 3.73.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.72.0",
3
+ "version": "3.73.0",
4
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": {
@@ -9,13 +9,23 @@
9
9
  "Effort follows how the work is verified: low for lookups and command runs, medium for",
10
10
  "implementation against tests, high for the larger opus share.",
11
11
  "The files are rendered in English (description.en, body.en): they are model-facing",
12
- "prompts, and the Korean copy is what the /ops console shows next to it."
12
+ "prompts, and the Korean copy is what the /ops console shows next to it.",
13
+ "tier and categories say which model-routing rules an agent serves (agentForRule in",
14
+ "src/agents.js): a rule names only its category and tier, and \"*\" covers every category.",
15
+ "They are not written into the rendered files; Claude Code does not know those keys."
13
16
  ],
14
17
  "agents": [
15
18
  {
16
19
  "name": "haiku-explore",
17
20
  "model": "haiku",
18
21
  "effort": "low",
22
+ "tier": "T2",
23
+ "categories": [
24
+ "explore",
25
+ "read",
26
+ "check",
27
+ "paste"
28
+ ],
19
29
  "tools": [
20
30
  "Read",
21
31
  "Grep",
@@ -35,6 +45,10 @@
35
45
  "name": "haiku-runner",
36
46
  "model": "haiku",
37
47
  "effort": "low",
48
+ "tier": "T2",
49
+ "categories": [
50
+ "run"
51
+ ],
38
52
  "tools": [
39
53
  "Bash",
40
54
  "Read",
@@ -54,6 +68,10 @@
54
68
  "name": "haiku-translate",
55
69
  "model": "haiku",
56
70
  "effort": "low",
71
+ "tier": "T2",
72
+ "categories": [
73
+ "translate"
74
+ ],
57
75
  "tools": [
58
76
  "Read",
59
77
  "Grep",
@@ -74,6 +92,10 @@
74
92
  "name": "sonnet-worker",
75
93
  "model": "sonnet",
76
94
  "effort": "medium",
95
+ "tier": "T1",
96
+ "categories": [
97
+ "*"
98
+ ],
77
99
  "tools": [
78
100
  "Read",
79
101
  "Grep",
@@ -96,6 +118,10 @@
96
118
  "name": "opus-worker",
97
119
  "model": "claude-opus-5-5",
98
120
  "effort": "high",
121
+ "tier": "T1H",
122
+ "categories": [
123
+ "*"
124
+ ],
99
125
  "tools": [
100
126
  "Read",
101
127
  "Grep",
@@ -4,7 +4,8 @@
4
4
  "counterpart to presets/ratchet-rules.md. These were promoted from the author's",
5
5
  "own route-scan candidates over months of real sessions, then stripped of the",
6
6
  "originating examples, project paths and recurrence counts: what carries over is",
7
- "the shape of the work (category, tier, agent, cap), not the author's transcripts.",
7
+ "the shape of the work (category, tier, cap), not the author's transcripts. Which",
8
+ "agent serves a tier is said by presets/agents.json (tier, categories).",
8
9
  "Each entry is offered one at a time at session start; nothing is registered",
9
10
  "without the user saying yes. Caps are the defaults a scan would calibrate to;",
10
11
  "later scans refresh the stats on whatever the user accepts.",
@@ -18,7 +19,6 @@
18
19
  "category": "run",
19
20
  "label": "명령 실행 (빌드·테스트·git)",
20
21
  "labelEn": "running commands (build/test/git)",
21
- "agent": "haiku-runner",
22
22
  "example": "npm test 돌려봐",
23
23
  "exampleEn": "run npm test",
24
24
  "budget": {
@@ -32,7 +32,6 @@
32
32
  "category": "run",
33
33
  "label": "명령 실행 (빌드·테스트·git)",
34
34
  "labelEn": "running commands (build/test/git)",
35
- "agent": "sonnet",
36
35
  "example": "빌드하고 실패한 테스트만 보여줘",
37
36
  "exampleEn": "build it and show only the failing tests",
38
37
  "budget": {
@@ -46,7 +45,6 @@
46
45
  "category": "explore",
47
46
  "label": "탐색·조회 (파일/값 찾기)",
48
47
  "labelEn": "lookup (finding files/values)",
49
- "agent": "haiku-explore",
50
48
  "example": "이 설정값 어느 파일에 있어?",
51
49
  "exampleEn": "which file holds this config value?",
52
50
  "budget": {
@@ -60,7 +58,6 @@
60
58
  "category": "explore",
61
59
  "label": "탐색·조회 (파일/값 찾기)",
62
60
  "labelEn": "lookup (finding files/values)",
63
- "agent": "sonnet",
64
61
  "example": "이 함수를 호출하는 곳 전부 찾아줘",
65
62
  "exampleEn": "find every caller of this function",
66
63
  "budget": {
@@ -74,7 +71,6 @@
74
71
  "category": "check",
75
72
  "label": "상태 확인·검증",
76
73
  "labelEn": "status checks / verification",
77
- "agent": "haiku-explore",
78
74
  "example": "지금 배포된 버전이 뭔지 확인해줘",
79
75
  "exampleEn": "check which version is deployed right now",
80
76
  "budget": {
@@ -88,7 +84,6 @@
88
84
  "category": "check",
89
85
  "label": "상태 확인·검증",
90
86
  "labelEn": "status checks / verification",
91
- "agent": "sonnet",
92
87
  "example": "마이그레이션이 실제로 적용됐는지 확인해줘",
93
88
  "exampleEn": "verify the migration actually applied",
94
89
  "budget": {
@@ -102,7 +97,6 @@
102
97
  "category": "paste",
103
98
  "label": "붙여넣은 화면·로그 질문",
104
99
  "labelEn": "questions about pasted screens/logs",
105
- "agent": "haiku-explore",
106
100
  "example": "이 에러 로그는 무슨 뜻이야?",
107
101
  "exampleEn": "what does this error log mean?",
108
102
  "budget": {
@@ -116,7 +110,6 @@
116
110
  "category": "paste",
117
111
  "label": "붙여넣은 화면·로그 질문",
118
112
  "labelEn": "questions about pasted screens/logs",
119
- "agent": "sonnet",
120
113
  "example": "이 스택트레이스에서 어느 모듈이 죽은 건지 짚어줘",
121
114
  "exampleEn": "point out which module failed in this stack trace",
122
115
  "budget": {
@@ -130,7 +123,6 @@
130
123
  "category": "read",
131
124
  "label": "읽기·요약·설명",
132
125
  "labelEn": "reading / summarizing / explaining",
133
- "agent": "haiku-explore",
134
126
  "example": "이 README 요약해줘",
135
127
  "exampleEn": "summarize this README",
136
128
  "budget": {
@@ -144,7 +136,6 @@
144
136
  "category": "read",
145
137
  "label": "읽기·요약·설명",
146
138
  "labelEn": "reading / summarizing / explaining",
147
- "agent": "sonnet",
148
139
  "example": "이 함수가 하는 일을 단계별로 정리해줘",
149
140
  "exampleEn": "walk through what this function does, step by step",
150
141
  "budget": {
@@ -39,11 +39,8 @@
39
39
  },
40
40
  {
41
41
  "id": "fix-f558f9",
42
- "ko": "탐색·조회·상태 확인·명령 실행 같은 단순 요청은 `model: haiku` 서브에이전트로 위임해 상위 모델 토큰을 아낀다 (읽기 전용 preset 에이전트가 이미 있으면 그걸 쓰고, 없으면 티어만 내려서 위임)",
43
- "en": "Delegate simple requests — lookup, status checks, running commands — to a `model: haiku` subagent to keep the expensive model's tokens for real work (use an existing read-only preset agent when there is one; otherwise delegate with the tier lowered)",
44
- "agents": [
45
- "claude"
46
- ]
42
+ "ko": "탐색·조회·상태 확인·명령 실행 같은 단순 요청은 T2 티어에 위임해 상위 모델 토큰을 아낀다. 티어는 도구가 해석한다. Claude는 그 카테고리의 T2 에이전트 파일을, 없으면 `model: haiku`를 쓰고 Codex는 T2에 매핑된 모델을 쓴다",
43
+ "en": "Delegate simple requests — lookup, status checks, running commands — to the T2 tier to keep the expensive model's tokens for real work. Each tool resolves the tier: Claude uses that category's T2 agent file or `model: haiku`, Codex the model mapped to T2"
47
44
  },
48
45
  {
49
46
  "id": "fix-aa18bc",
@@ -52,11 +49,8 @@
52
49
  },
53
50
  {
54
51
  "id": "fix-777a15",
55
- "ko": "여러 파일에 걸친 구현을 맡으면 → 설계와 스펙 확정은 메인 모델이 하고 코드 작성은 서브에이전트에 위임한다(Claude는 `model: sonnet`, Codex는 명시적 매핑을 우선해 해석한 T1을 쓰며 haiku나 T2에는 구현을 맡기지 않는다). 위임 프롬프트에는 설계, 수정할 파일, 제약, 완료 기준, 테스트 명령, 예산, 권장 모델과 그 이유를 적어 다른 문맥 없이 실행할 수 있게 한다. 이미 읽은 파일의 약 50줄 이하 변경이나 의존 단계가 하나뿐인 짧은 구현, 그리고 위임 도구나 하위 모델을 쓸 수 없는 경우는 메인 모델이 직접 처리한다. 위임한 구현은 끝날 때까지 기다리고 같은 구현을 중복 실행하지 않으며, 돌아온 diff와 테스트 결과를 메인 모델이 직접 검증한 뒤에 완료로 보고한다",
56
- "en": "When an implementation spans several files → the main model settles the design and specification, then delegates the code writing to a subagent (Claude: `model: sonnet`; Codex: its resolved T1, explicit mapping first; never haiku or T2 for implementation). Write the delegation prompt so it runs without other context: design, files to change, constraints, completion criteria, test commands, budget, and the recommended model with a one-line reason. Keep changes of roughly 50 lines or less in files already read, a single short dependency chain, or cases where the subagent tools or the lower model are unavailable on the main model. Wait for the delegated work instead of duplicating it, and verify the returned diff and the actual test results before reporting completion",
57
- "agents": [
58
- "claude"
59
- ]
52
+ "ko": "여러 파일에 걸친 구현을 맡으면 → 설계와 스펙 확정은 메인 모델이 하고 코드 작성은 T1 티어에 위임하며, T2에는 구현을 맡기지 않는다. 티어는 도구가 해석한다. Claude는 그 카테고리의 T1 에이전트 파일을, 없으면 `model: sonnet`을 쓰고 Codex는 T1에 매핑된 모델을 쓴다. 위임 프롬프트에는 설계, 수정할 파일, 제약, 완료 기준, 테스트 명령, 예산, 권장 모델과 그 이유를 적어 다른 문맥 없이 실행할 수 있게 한다. 이미 읽은 파일의 약 50줄 이하 변경이나 의존 단계가 하나뿐인 짧은 구현, 그리고 위임 도구나 하위 모델을 쓸 수 없는 경우는 메인 모델이 직접 처리한다. 위임한 구현은 끝날 때까지 기다리고 같은 구현을 중복 실행하지 않으며, 돌아온 diff와 테스트 결과를 메인 모델이 직접 검증한 뒤에 완료로 보고한다",
53
+ "en": "When an implementation spans several files → the main model settles the design and specification and delegates the code writing to the T1 tier, never T2. Each tool resolves the tier: Claude uses that category's T1 agent file or `model: sonnet`, Codex the model mapped to T1. Write the delegation prompt so it runs without other context: design, files to change, constraints, completion criteria, test commands, budget, and the recommended model with a one-line reason. Keep changes of roughly 50 lines or less in files already read, a single short dependency chain, or cases where the subagent tools or the lower model are unavailable on the main model. Wait for the delegated work instead of duplicating it, and verify the returned diff and the actual test results before reporting completion"
60
54
  },
61
55
  {
62
56
  "id": "fix-bec8e9",
package/src/agents.js CHANGED
@@ -19,6 +19,13 @@
19
19
  import { existsSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { claudeUserDir } from './paths.js';
22
+ import { agentDefinitions } from './presets-source.js';
23
+
24
+ /**
25
+ * Bare model alias each routing tier stands for. A rule says only its tier;
26
+ * where no agent serves that tier, this alias is what the text names.
27
+ */
28
+ export const TIER_MODEL = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
22
29
 
23
30
  /** Tier each preset agent is pinned to, for the `model:` half of the phrase. */
24
31
  const AGENT_MODEL = {
@@ -51,6 +58,28 @@ export function agentExists(name, root = process.cwd()) {
51
58
  existsSync(join(claudeUserDir(), 'agents', `${name}.md`));
52
59
  }
53
60
 
61
+ /**
62
+ * The agent a model-routing rule delegates to, or null when no agent serves its
63
+ * tier. A rule carries only `category` and `tier`; the agents preset says which
64
+ * tier and categories each agent serves, so Claude and Codex read one key.
65
+ *
66
+ * (a) `rule.agent` when it is a non-empty string: legacy registries and the
67
+ * published v1 bundle still carry it, and it stays an explicit override,
68
+ * (b) else the first agent of that tier whose categories name the category,
69
+ * (c) else the first agent of that tier whose categories hold "*",
70
+ * (d) else null.
71
+ */
72
+ export function agentForRule(rule, { agents = agentDefinitions() } = {}) {
73
+ if (!rule) return null;
74
+ if (typeof rule.agent === 'string' && rule.agent.trim()) return rule.agent;
75
+ const list = (Array.isArray(agents) ? agents : []).filter((a) => a && a.tier === rule.tier);
76
+ const cats = (a) => (Array.isArray(a.categories) ? a.categories : []);
77
+ const exact = list.find((a) => cats(a).includes(rule.category));
78
+ if (exact) return exact.name;
79
+ const any = list.find((a) => cats(a).includes('*'));
80
+ return any ? any.name : null;
81
+ }
82
+
54
83
  /** Agent a bare tier name stands for when that agent's file exists. */
55
84
  const TIER_AGENT = { sonnet: 'sonnet-worker', opus: 'opus-worker', haiku: 'haiku-explore' };
56
85
 
@@ -92,8 +92,9 @@ export async function run({ args, hasFlag }) {
92
92
  }
93
93
  console.log('');
94
94
  console.log(ko ? `모델 라우팅 프리셋 ${out.model_rules.length}건` : `Model-routing presets (${out.model_rules.length})`);
95
+ const { agentForRule, TIER_MODEL } = await import('../agents.js');
95
96
  for (const m of out.model_rules) {
96
- console.log(` [${m.id}] ${m.tier} · ${ko ? m.label : m.labelEn} → ${m.agent}`);
97
+ console.log(` [${m.id}] ${m.tier} · ${ko ? m.label : m.labelEn} → ${agentForRule(m, { agents: out.agents }) ?? TIER_MODEL[m.tier]}`);
97
98
  }
98
99
  console.log('');
99
100
  console.log(ko ? `위임 에이전트 ${out.agents.length}개` : `Delegation agents (${out.agents.length})`);
@@ -11,6 +11,7 @@ const require = createRequire(import.meta.url);
11
11
  import { readStdinJson } from '../stdin-payload.js';
12
12
  import { debug } from '../debug.js';
13
13
  import { signedUsd } from '../money.js';
14
+ import { agentForRule, TIER_MODEL } from '../agents.js';
14
15
 
15
16
  /**
16
17
  * Whether the user turned doc2md on.
@@ -418,12 +419,12 @@ export async function run({ args, hasFlag, numArg }) {
418
419
  const rule = composed(lang === 'ko' ? c.rule : (c.ruleEn || c.rule), c);
419
420
  if (lang === 'ko') {
420
421
  lines.push(` 후보 R${c.id} — "${label}" 유형, ${c.count}회 반복 (프로젝트: ${c.project})`);
421
- lines.push(` 판정: ${tier} (${rs.tierLabel(tier)}) → ${c.agent} 서브에이전트 위임 권장 · 적용 범위 제안: ${rs.scopeLabel(c.suggestedScope)}`);
422
+ lines.push(` 판정: ${tier} (${rs.tierLabel(tier)}) → ${agentForRule(c) ?? TIER_MODEL[tier]} 서브에이전트 위임 권장 · 적용 범위 제안: ${rs.scopeLabel(c.suggestedScope)}`);
422
423
  lines.push(` 예시 요청: "${c.example}"`);
423
424
  lines.push(` 등록 시 ratchet-model.md에 기록될 룰: "${rule}"`);
424
425
  } else {
425
426
  lines.push(` Candidate R${c.id} — "${label}", seen ×${c.count} (project: ${c.project})`);
426
- lines.push(` verdict: ${tier} (${rs.tierLabel(tier, 'en')}) → delegate to the ${c.agent} subagent · suggested scope: ${rs.scopeLabel(c.suggestedScope, 'en')}`);
427
+ lines.push(` verdict: ${tier} (${rs.tierLabel(tier, 'en')}) → delegate to the ${agentForRule(c) ?? TIER_MODEL[tier]} subagent · suggested scope: ${rs.scopeLabel(c.suggestedScope, 'en')}`);
427
428
  lines.push(` example request: "${c.example}"`);
428
429
  lines.push(` rule that would be written to ratchet-model.md: "${rule}"`);
429
430
  }
@@ -510,10 +511,10 @@ export async function run({ args, hasFlag, numArg }) {
510
511
  const tier = c.tier || 'T2';
511
512
  if (lang === 'ko') {
512
513
  console.log(` R${c.id} "${c.label}" ×${c.count}회 [${c.project}]`);
513
- console.log(` 판정: ${tier} (${rs.tierLabel(tier)}) → ${c.agent} 위임 권장 · 적용 범위 제안: ${rs.scopeLabel(c.suggestedScope)}`);
514
+ console.log(` 판정: ${tier} (${rs.tierLabel(tier)}) → ${agentForRule(c) ?? TIER_MODEL[tier]} 위임 권장 · 적용 범위 제안: ${rs.scopeLabel(c.suggestedScope)}`);
514
515
  } else {
515
516
  console.log(` R${c.id} "${c.labelEn || c.label}" ×${c.count} [${c.project}]`);
516
- console.log(` verdict: ${tier} (${rs.tierLabel(tier, 'en')}) → delegate to ${c.agent} · suggested scope: ${rs.scopeLabel(c.suggestedScope, 'en')}`);
517
+ console.log(` verdict: ${tier} (${rs.tierLabel(tier, 'en')}) → delegate to ${agentForRule(c) ?? TIER_MODEL[tier]} · suggested scope: ${rs.scopeLabel(c.suggestedScope, 'en')}`);
517
518
  }
518
519
  console.log(` ${lang === 'ko' ? '예시' : 'example'}: "${c.example}"`);
519
520
  const mrList = await import('../model-rules.js');
@@ -5,6 +5,7 @@ import { ISSUE_MESSAGES } from '../advice.js';
5
5
  import { CLI_NAME } from '../cli-name.js';
6
6
  import { formatResetIn, formatResetClock } from '../format-time.js';
7
7
  import { labelForKey } from '../window-labels.js';
8
+ import { agentForRule, TIER_MODEL } from '../agents.js';
8
9
 
9
10
  const C = {
10
11
  reset: '\x1b[0m',
@@ -202,7 +203,7 @@ function renderRoutingSection(routing) {
202
203
  lines.push(' No recurring delegable work yet (a pattern needs 3 repeats). Approved rules apply from the next session.');
203
204
  } else {
204
205
  for (const c of cands) {
205
- lines.push(` R${c.id} "${c.labelEn || c.label}" ×${c.count} → delegate to ${c.agent}`);
206
+ lines.push(` R${c.id} "${c.labelEn || c.label}" ×${c.count} → delegate to ${agentForRule(c) ?? TIER_MODEL[c.tier || 'T2']}`);
206
207
  }
207
208
  lines.push(` Approve one: ${b(`${CLI_NAME} harness promote R${cands[0].id} --global`)} All candidates: ${b(`${CLI_NAME} route-scan`)}`);
208
209
  }
@@ -16,7 +16,8 @@
16
16
  * (rule-health, per docs/TIER_CRITERIA.md).
17
17
  *
18
18
  * Registry file (source of truth): <stateDir>/model-rules.json
19
- * { rules: [ { signature, tier, category, label, labelEn, agent, scope, // 'project'|'global'
19
+ * { rules: [ { signature, tier, category, label, labelEn, scope, // 'project'|'global'
20
+ * agent, // legacy only: honoured when present, never written
20
21
  * targetRoot, // project root path (project scope)
21
22
  * rule, example, count, errRate, promotedAt, lastSeen,
22
23
  * status } ] } // 'active' | 'review'
@@ -33,7 +34,7 @@ import { join, dirname } from 'node:path';
33
34
  import { homedir } from 'node:os';
34
35
  import { userDataDir } from './paths.js';
35
36
  import { userLanguage } from './config.js';
36
- import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
37
+ import { agentPhrase, agentPhraseEn, resolveAgentName, agentForRule, TIER_MODEL } from './agents.js';
37
38
  import { writeStateFile } from './state-file.js';
38
39
  import { signedUsd } from './money.js';
39
40
  import { implementationDelegationRule } from './implementation-delegation.js';
@@ -120,7 +121,8 @@ export function composeRuleText(baseText, rule, lang = userLanguage()) {
120
121
  * wording — two copies of this template would drift the moment one is reworded,
121
122
  * and the drift only shows up in a file the model reads as instructions.
122
123
  *
123
- * `g` carries { tier, label, labelEn, example, exampleEn, agent }.
124
+ * `g` carries { tier, category, label, labelEn, example, exampleEn } and, on a
125
+ * legacy rule, `agent`; agentForRule turns tier and category into the agent.
124
126
  */
125
127
  /**
126
128
  * Trim an example to something a reader takes in at a glance. Scan-derived
@@ -133,30 +135,34 @@ function shortExample(text) {
133
135
  return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
134
136
  }
135
137
 
138
+ /** The agent (or bare alias) a rule's text names, before the file check. */
139
+ const targetName = (g) => agentForRule(g) ?? TIER_MODEL[g.tier];
140
+
136
141
  // T1 (and T1H) is named through its delegation agent only when that file
137
142
  // exists; without it the literal `model: sonnet` / `model: opus` stays so
138
143
  // 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 });
144
+ function t1Target(root, ko, g) {
145
+ const name = targetName(g);
146
+ if (resolveAgentName(name, root) === null) return null;
147
+ return (ko ? agentPhrase : agentPhraseEn)(name, { root });
142
148
  }
143
149
 
144
150
  export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
145
151
  if (lang === 'ko') {
146
152
  if (g.tier === 'T1H') {
147
- return `"${g.label}" 고난도 구현 요청(예: "${shortExample(g.example)}")은 Fable·Mythos 세션에 한해 ${t1Target(root, true, 'opus') ?? 'model: opus'}에 위임`;
153
+ return `"${g.label}" 고난도 구현 요청(예: "${shortExample(g.example)}")은 Fable·Mythos 세션에 한해 ${t1Target(root, true, g) ?? `model: ${TIER_MODEL.T1H}`}에 위임`;
148
154
  }
149
155
  return g.tier === 'T2'
150
- ? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
151
- : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true) ?? 'model: sonnet'}에 위임`;
156
+ ? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(targetName(g), { root })}에 위임`
157
+ : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true, g) ?? `model: ${TIER_MODEL.T1}`}에 위임`;
152
158
  }
153
159
  const example = g.exampleEn || g.example;
154
160
  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`;
161
+ return `Heavy "${g.labelEn}" implementation requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false, g) ?? `a model: ${TIER_MODEL.T1H} subagent`}, in Fable/Mythos sessions only`;
156
162
  }
157
163
  return g.tier === 'T2'
158
- ? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
159
- : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false) ?? 'a model: sonnet subagent'}`;
164
+ ? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(targetName(g), { root })}`
165
+ : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false, g) ?? `a model: ${TIER_MODEL.T1} subagent`}`;
160
166
  }
161
167
 
162
168
  // See the note in route-scan.js — paths.js is the only place that resolves
@@ -403,7 +409,6 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
403
409
  };
404
410
  // Merged rules carry one cap per tier, so they state them once rather than
405
411
  // repeating the whole stop-condition per tier.
406
- const TIER_MODEL = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
407
412
  const mergedBudget = (tiers) => (ko ? '상한 ' : 'cap ') + tiers
408
413
  .map((r) => `${TIER_MODEL[r.tier]} ${budgetCapPhrase(r, ko ? 'ko' : 'en')}`).join(' / ');
409
414
 
@@ -440,9 +445,8 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
440
445
  // user's own prompt, so exampleEn is a preference, not a requirement.
441
446
  const ex = (r) => shortExample(ko ? r.example : (r.exampleEn || r.example));
442
447
  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}`;
448
+ if (r.tier === 'T2') return (ko ? agentPhrase : agentPhraseEn)(targetName(r), { root });
449
+ return t1Target(root, ko, r) ?? `model: ${TIER_MODEL[r.tier]}`;
446
450
  };
447
451
  // One line per category, stating only what differs between the tiers.
448
452
  // The exceptions this used to spell out (design judgement, irreversible
@@ -37,7 +37,10 @@ import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto
37
37
  * adding the new entry, releasing, then publishing with the new key; remove the
38
38
  * old entry one release later.
39
39
  */
40
- export const PRESET_SIGNING_KEYS = [];
40
+ export const PRESET_SIGNING_KEYS = [
41
+ // Created 2026-10-20 for sprag.io/ops; the private half lives in the superuser's browser and its backup file.
42
+ { kid: '8bc624f874f5620a', jwk: { kty: 'EC', crv: 'P-256', x: 'Lx3x--sWgDZ2_7jWxO3_ks86MtihDNRRbckITpR5bLc', y: 'L2D7puHKmZhQRh7mWjGYsmIX_UrPBYjKt3nH8B51Bhg' } },
43
+ ];
41
44
 
42
45
  export const PRESET_BUNDLE_FORMAT = 1;
43
46
  export const PRESET_MAX_BYTES = 200 * 1024;
@@ -90,7 +90,10 @@ export function bundleShapeError(b) {
90
90
  }
91
91
  for (const m of mr) {
92
92
  if (!m || !ID_RE.test(m.id) || !TIERS.includes(m.tier) || !ID_RE.test(m.category)) return 'shape';
93
- if (!str(m.label) || !str(m.labelEn) || !str(m.agent) || !LINE_RE.test(m.agent)) return 'shape';
93
+ if (!str(m.label) || !str(m.labelEn)) return 'shape';
94
+ // `agent` is optional: a rule names its tier, and the agents say which tier
95
+ // and categories they serve. A legacy rule that still names one is checked.
96
+ if (m.agent != null && (!str(m.agent) || !LINE_RE.test(m.agent))) return 'shape';
94
97
  if (m.budget != null && (typeof m.budget !== 'object' || Array.isArray(m.budget))) return 'shape';
95
98
  }
96
99
  for (const a of ag) {
@@ -98,6 +101,10 @@ export function bundleShapeError(b) {
98
101
  if (!Array.isArray(a.tools) || a.tools.length === 0 || a.tools.some((t) => typeof t !== 'string' || !TOOL_RE.test(t))) return 'shape';
99
102
  if (!a.description || !LINE_RE.test(a.description.en || '') || (a.description.ko != null && !LINE_RE.test(a.description.ko))) return 'shape';
100
103
  if (!a.body || !str(a.body.en)) return 'shape';
104
+ // tier and categories are optional (a legacy bundle lacks them), checked when present.
105
+ if (a.tier != null && !TIERS.includes(a.tier)) return 'shape';
106
+ if (a.categories != null && (!Array.isArray(a.categories) || a.categories.length === 0 ||
107
+ a.categories.some((c) => c !== '*' && (typeof c !== 'string' || !ID_RE.test(c))))) return 'shape';
101
108
  }
102
109
  if (!uniq(rr, 'id') || !uniq(mr, 'id') || !uniq(ag, 'name')) return 'shape';
103
110
  return null;
@@ -188,12 +195,21 @@ export function modelPresets(opts = {}) {
188
195
  return Array.isArray(data?.presets) ? data.presets : [];
189
196
  }
190
197
 
191
- /** Delegation agents: [{ name, model, effort, tools[], description{ko,en}, body{ko,en} }] */
198
+ // A legacy bundle has no tier or categories on its agents. Readers get one
199
+ // shape either way: categories always an array, tier a string or null.
200
+ function normaliseAgents(list) {
201
+ return (Array.isArray(list) ? list : []).filter(Boolean).map((a) => ({
202
+ ...a,
203
+ tier: typeof a.tier === 'string' && a.tier ? a.tier : null,
204
+ categories: Array.isArray(a.categories) ? a.categories.filter((c) => typeof c === 'string') : [],
205
+ }));
206
+ }
207
+
208
+ /** Delegation agents: [{ name, model, effort, tier, categories[], tools[], description{ko,en}, body{ko,en} }] */
192
209
  export function agentDefinitions(opts = {}) {
193
210
  const r = activeRemote(opts);
194
- if (r.bundle) return r.bundle.agents.map((a) => ({ ...a }));
195
- const data = readBundled('agents.json');
196
- return Array.isArray(data?.agents) ? data.agents : [];
211
+ if (r.bundle) return normaliseAgents(r.bundle.agents);
212
+ return normaliseAgents(readBundled('agents.json')?.agents);
197
213
  }
198
214
 
199
215
  /** The bundle the bundled files make: the shape the server seeds as version 1. */
@@ -34,7 +34,7 @@
34
34
  import { categorizeScored, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
35
35
  import { loadModelRules, budgetCapPhrase, ruleBudget } from './model-rules.js';
36
36
  import { sharedProjectRoots, realProjectRoot, scopeRank } from './shared-model-rules.js';
37
- import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
37
+ import { agentPhrase, agentPhraseEn, resolveAgentName, agentForRule, TIER_MODEL } from './agents.js';
38
38
  import { userLanguage } from './config.js';
39
39
  import { aliasForRole, resolveModelAlias } from './model-alias.js';
40
40
  import { isRecognizedModelId, modelRank } from './cost.js';
@@ -263,12 +263,17 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
263
263
 
264
264
  // T1 is named through its delegation agent only when that file exists here;
265
265
  // otherwise the literal tier string stays, so output without agents is unchanged.
266
+ // A rule names its tier; agentForRule picks the agent that serves it.
266
267
  const t1Root = root === undefined ? undefined : root;
267
- const tierPhrase = (name) => (resolveAgentName(name, t1Root) !== null ? phrase(name) : `model: ${name}`);
268
+ const nameOf = (r) => agentForRule(r) ?? TIER_MODEL[r.tier];
269
+ const tierPhrase = (r) => {
270
+ const name = nameOf(r);
271
+ return resolveAgentName(name, t1Root) !== null ? phrase(name) : `model: ${TIER_MODEL[r.tier]}`;
272
+ };
268
273
  const picked = [t2, t1, t1h].filter(Boolean);
269
274
  let target;
270
275
  if (picked.length >= 2) {
271
- const named = (r) => (r.tier === 'T2' ? phrase(r.agent) : tierPhrase(r.tier === 'T1H' ? 'opus' : 'sonnet'));
276
+ const named = (r) => (r.tier === 'T2' ? phrase(nameOf(r)) : tierPhrase(r));
272
277
  const when = (r, i) => {
273
278
  if (i === 0) return null;
274
279
  if (r.tier === 'T1H') return ko ? '범위가 크면(Fable·Mythos 세션 한정)' : 'for large scope (Fable/Mythos sessions only)';
@@ -279,13 +284,12 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
279
284
  : picked.map((r, i) => (i === 0 ? `${named(r)} by default` : `${named(r)} ${when(r, i)}`)).join(', ');
280
285
  } else {
281
286
  const only = picked[0];
282
- target = only.tier === 'T1H' ? tierPhrase('opus') : phrase(only.agent);
287
+ target = only.tier === 'T1H' ? tierPhrase(only) : phrase(nameOf(only));
283
288
  }
284
289
 
285
- const MODEL_OF = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
286
290
  const caps = picked.map((r) => budgetCapPhrase(r, lang));
287
291
  const capText = picked.length >= 2
288
- ? (ko ? '상한 ' : 'cap ') + picked.map((r, i) => `${MODEL_OF[r.tier]} ${caps[i]}`).join(' / ')
292
+ ? (ko ? '상한 ' : 'cap ') + picked.map((r, i) => `${TIER_MODEL[r.tier]} ${caps[i]}`).join(' / ')
289
293
  : (ko ? `상한 ${caps[0]}` : `cap ${caps[0]}`);
290
294
 
291
295
  // The marker line is the one in ratchet-model.md, so the user sees the same
package/src/route-scan.js CHANGED
@@ -30,7 +30,7 @@ import { sessionCost } from './claude-price.js';
30
30
  import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
31
31
  import { modelRuleBaseText } from './model-rules.js';
32
32
  import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
33
- import { modelPresets as sourceModelPresets } from './presets-source.js';
33
+ import { agentForRule } from './agents.js';
34
34
 
35
35
  // ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
36
36
  // T2 (haiku): finished in few calls, tiny output, near-zero mutation, no
@@ -115,7 +115,7 @@ export const RESCAN_MAX_AGE_MS = 24 * 60 * 60 * 1000; // any new data + a d
115
115
  // together triggers one scan rather than one per agent.
116
116
  export const RESCAN_AFTER_DELEGATION_MS = 30 * 1000;
117
117
 
118
- // Categories → recommended subagent. Classification is behavior-first with
118
+ // Categories a request is filed under. Classification is behavior-first with
119
119
  // weighted keyword scoring as fallback — see categorize().
120
120
  const PASTE_MIN_LEN = 400;
121
121
  const CATEGORIES = [
@@ -123,21 +123,18 @@ const CATEGORIES = [
123
123
  id: 'paste',
124
124
  label: '붙여넣은 화면·로그 질문',
125
125
  labelEn: 'questions about pasted screens/logs',
126
- agent: 'haiku-explore',
127
126
  kw: null, // matched by length, see categorize()
128
127
  },
129
128
  {
130
129
  id: 'translate',
131
130
  label: '배치 번역·정형 텍스트 변환',
132
131
  labelEn: 'batch translation / mechanical text transforms',
133
- agent: 'haiku-translate',
134
132
  kw: [[/번역|translate/i, 2], [/변환해|표로 정리|포맷팅/i, 1]],
135
133
  },
136
134
  {
137
135
  id: 'explore',
138
136
  label: '탐색·조회 (파일/값 찾기)',
139
137
  labelEn: 'lookup (finding files/values)',
140
- agent: 'haiku-explore',
141
138
  kw: [[/\bgrep\b|검색|\bsearch|\bfind\b/i, 2], [/찾아|어디|위치|목록|살펴/i, 1],
142
139
  [/정의(?:돼|되어)\s*있|어느\s*파일|어떤\s*파일에|\bwhich\s+file\b|\bwhere\s+(?:is|are)\b[^?\n]*\bdefined\b/i, 1]],
143
140
  },
@@ -145,34 +142,28 @@ const CATEGORIES = [
145
142
  id: 'read',
146
143
  label: '읽기·요약·설명',
147
144
  labelEn: 'reading / summarizing / explaining',
148
- agent: 'haiku-explore',
149
145
  kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|\bwhat\b/i, 1]],
150
146
  },
151
147
  {
152
148
  id: 'check',
153
149
  label: '상태 확인·검증',
154
150
  labelEn: 'status checks / verification',
155
- agent: 'haiku-explore',
156
151
  kw: [[/확인|검증|\bverif|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|\bcheck\b|\bstatus\b/i, 1]],
157
152
  },
158
153
  {
159
154
  id: 'run',
160
155
  label: '명령 실행 (빌드·테스트·git)',
161
156
  labelEn: 'running commands (build/test/git)',
162
- agent: 'haiku-runner',
163
157
  kw: [[/\bgit |\bcommit(?:s|ted)?\b|\bpush(?:ed|es)?\b|\bnpm |\bpip3? |빌드해|빌드 돌/i, 2], [/실행|돌려|\brun\b|\bbuild\b|빌드|테스트|설치/i, 1]],
164
158
  },
165
159
  ];
166
- // The T2 agent a category's candidates name follows the published model presets
167
- // when one covers that category (presets-source.js), so renaming an agent in the
168
- // /ops console renames it in new candidates too. Categories without a preset
169
- // (translate) keep the name above.
160
+ // The T2 agent a category's candidates name comes from the agents preset (the
161
+ // agent whose tier and categories cover it, see agentForRule), so renaming or
162
+ // re-scoping an agent in the /ops console carries over to new candidates.
170
163
  function t2AgentFor(cat) {
171
164
  try {
172
- const p = sourceModelPresets().find((x) => x.tier === 'T2' && x.category === cat.id);
173
- if (p && typeof p.agent === 'string' && p.agent) return p.agent;
174
- } catch { /* the built-in name below */ }
175
- return cat.agent;
165
+ return agentForRule({ tier: 'T2', category: cat.id }) ?? 'haiku';
166
+ } catch { return 'haiku'; }
176
167
  }
177
168
 
178
169
  // English keywords are matched as whole words. As bare substrings, `pip`
@@ -815,7 +806,6 @@ export async function runRouteScan({ days = 14 } = {}) {
815
806
  category: g.category,
816
807
  label: g.label,
817
808
  labelEn: g.labelEn,
818
- agent: g.agent,
819
809
  project: g.project,
820
810
  // Real session cwd for the project (munged `project` is lossy) — lets
821
811
  // `harness promote R<N> --project` write the rule into the project the
package/src/seed-rules.js CHANGED
@@ -31,6 +31,7 @@ import { join, resolve } from 'node:path';
31
31
  import { modelPresets as sourceModelPresets } from './presets-source.js';
32
32
  import { sharedModelRulesForProject, loadSharedModelRules, sharedProjectRoots, realProjectRoot } from './shared-model-rules.js';
33
33
  import { userDataDir } from './paths.js';
34
+ import { agentForRule, TIER_MODEL } from './agents.js';
34
35
  import { userLanguage } from './config.js';
35
36
  import { writeStateFile } from './state-file.js';
36
37
  import {
@@ -130,7 +131,7 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(),
130
131
  id: p.id,
131
132
  tier: p.tier,
132
133
  label: lang === 'ko' ? p.label : p.labelEn,
133
- agent: agent === 'codex' ? `Codex ${p.tier} mapping` : p.agent,
134
+ agent: agent === 'codex' ? `Codex ${p.tier} mapping` : (agentForRule(p) ?? TIER_MODEL[p.tier]),
134
135
  ruleText: agent === 'codex' ? codexSeedText(p, lang)
135
136
  : composeRuleText(modelRuleBaseText(p, lang), { budget: p.budget }, lang),
136
137
  preset: p,
@@ -212,7 +213,9 @@ export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = u
212
213
  category: p.category,
213
214
  label: p.label,
214
215
  labelEn: p.labelEn,
215
- agent: p.agent,
216
+ // A preset names its tier; only a legacy (v1) bundle preset still names an
217
+ // agent, and that override is kept rather than lost on accept.
218
+ ...(typeof p.agent === 'string' && p.agent ? { agent: p.agent } : {}),
216
219
  scope,
217
220
  targetRoot: scope === 'project' ? root : null,
218
221
  project,
@@ -276,6 +279,26 @@ export async function resetSeeds({ agent = 'claude' } = {}) {
276
279
  * asked for, and because a single "register all of these?" question invites a
277
280
  * yes to rules the user never read.
278
281
  */
282
+ /**
283
+ * The first sentence of a rule, for the Codex seed offer. Codex keeps about
284
+ * 2,000 tokens of a hook's context and drops a part that does not fit whole,
285
+ * so listing every ratchet rule in full pushed the whole offer out. The offer
286
+ * only has to say what each rule is about; accepting writes the full text.
287
+ * A sentence ends at "다." or a full stop followed by whitespace (not an
288
+ * ellipsis), or at "다" before a newline. Without one, a text over 140
289
+ * characters is cut at the last space before 140 and marked with "…".
290
+ */
291
+ export const OFFER_CUT = 140;
292
+ export function firstSentence(text) {
293
+ const t = String(text ?? '').trim();
294
+ const m = /(?<!\.)\.(?=\s)|다(?=\n)/.exec(t);
295
+ if (m && m.index + 1 < t.length) return t.slice(0, m.index + m[0].length).trim();
296
+ if (t.length <= OFFER_CUT) return t;
297
+ const head = t.slice(0, OFFER_CUT);
298
+ const sp = head.lastIndexOf(' ');
299
+ return `${(sp > 0 ? head.slice(0, sp) : head).trimEnd()}…`;
300
+ }
301
+
279
302
  export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot(), agent = 'claude' } = {}) {
280
303
  const pending = pendingSeeds({ lang, root, agent });
281
304
  if (pending.length === 0) return null;
@@ -291,7 +314,7 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
291
314
  ` 4. Register none: ${cmd('skip all')} (never offered again)`,
292
315
  'Always confirm the scope with the user before running an accept command.',
293
316
  'Delegation policies are shared across Claude and Codex; approval retains the chosen scope. Codex execution also requires delegate on; tier models resolve from cheaper priced models already run in Codex, or from mappings: sprag delegate shared status --agent codex. Never assume Claude model names are Codex models.',
294
- ...pending.map((s) => ` [${s.id}] ${s.ruleText}`),
317
+ ...pending.map((s) => ` [${s.id}] ${s.kind === 'ratchet' ? firstSentence(s.ruleText) : s.ruleText}`),
295
318
  ].join('\n');
296
319
  }
297
320
  const models = pending.filter((s) => s.kind === 'model');