sprag-cli 3.72.0 → 3.74.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.74.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": {
@@ -5,17 +5,27 @@
5
5
  "and a remote bundle render through one path. A remote bundle replaces this list",
6
6
  "when its signature verifies; otherwise this file is used.",
7
7
  "Models are aliases on purpose (a gateway resolves them through ANTHROPIC_DEFAULT_*_MODEL),",
8
- "except opus-worker, which names the full id: a bare alias resolves to an older model.",
8
+ "except t1h-worker, which names the full id: a bare alias resolves to an older model.",
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
- "name": "haiku-explore",
19
+ "name": "t2-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",
@@ -27,14 +37,18 @@
27
37
  "en": "Cheap read-only exploration, lookup, status checks and summaries for delegated tasks. It has no MCP tools."
28
38
  },
29
39
  "body": {
30
- "ko": "맡은 역할: 읽기 전용 탐색, 조회, 상태 확인, 붙여넣은 로그나 텍스트를 두고 묻는 질문에 답합니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs general-purpose: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.",
31
- "en": "Your role: read-only exploration, lookups, status checks, and questions about pasted logs or text.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs general-purpose: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it."
40
+ "ko": "맡은 역할: 읽기 전용 탐색, 조회, 상태 확인, 붙여넣은 로그나 텍스트를 두고 묻는 질문에 답합니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs tools: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.",
41
+ "en": "Your role: read-only exploration, lookups, status checks, and questions about pasted logs or text.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs tools: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it."
32
42
  }
33
43
  },
34
44
  {
35
- "name": "haiku-runner",
45
+ "name": "t2-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",
@@ -46,14 +60,18 @@
46
60
  "en": "Cheap runner for build, test and git commands in delegated tasks. It has no MCP tools."
47
61
  },
48
62
  "body": {
49
- "ko": "맡은 역할: 전달받은 빌드·테스트·git 명령을 실행하고 결과를 보고합니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs general-purpose: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.\n전체 테스트, 빌드, 렌더처럼 프로세스를 여럿 띄우는 명령은 프로세스 상한을 걸고 실행하십시오. macOS 와 Linux 에서는 `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <명령>)` 으로 감싸고, Windows 에서는 그대로 실행합니다. 출력에 `spawn ... EAGAIN` 이나 `Resource temporarily unavailable` 이 보이면 테스트 실패가 아니라 프로세스 폭주이므로, 명령을 다시 돌리지 말고 그 줄을 인용해 보고하십시오.",
50
- "en": "Your role: run the build, test, or git commands you are given and report the result.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs general-purpose: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it.\nRun a command that starts many processes (a full test suite, a build, a render) under a process cap: on macOS and Linux wrap it as `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <command>)`, on Windows run it as is. If the output shows `spawn ... EAGAIN` or `Resource temporarily unavailable`, that is a process runaway, not a test failure: do not rerun the command; quote that line and report."
63
+ "ko": "맡은 역할: 전달받은 빌드·테스트·git 명령을 실행하고 결과를 보고합니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs tools: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.\n전체 테스트, 빌드, 렌더처럼 프로세스를 여럿 띄우는 명령은 프로세스 상한을 걸고 실행하십시오. macOS 와 Linux 에서는 `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <명령>)` 으로 감싸고, Windows 에서는 그대로 실행합니다. 출력에 `spawn ... EAGAIN` 이나 `Resource temporarily unavailable` 이 보이면 테스트 실패가 아니라 프로세스 폭주이므로, 명령을 다시 돌리지 말고 그 줄을 인용해 보고하십시오.",
64
+ "en": "Your role: run the build, test, or git commands you are given and report the result.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs tools: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it.\nRun a command that starts many processes (a full test suite, a build, a render) under a process cap: on macOS and Linux wrap it as `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <command>)`, on Windows run it as is. If the output shows `spawn ... EAGAIN` or `Resource temporarily unavailable`, that is a process runaway, not a test failure: do not rerun the command; quote that line and report."
51
65
  }
52
66
  },
53
67
  {
54
- "name": "haiku-translate",
68
+ "name": "t2-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",
@@ -66,14 +84,18 @@
66
84
  "en": "Cheap batch translation and structured text conversion for delegated tasks. It has no MCP tools."
67
85
  },
68
86
  "body": {
69
- "ko": "맡은 역할: 전달받은 파일이나 텍스트의 일괄 번역과 정형 텍스트 변환입니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs general-purpose: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.",
70
- "en": "Your role: batch translation and mechanical text conversion of the files or text you are given.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs general-purpose: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it."
87
+ "ko": "맡은 역할: 전달받은 파일이나 텍스트의 일괄 번역과 정형 텍스트 변환입니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs tools: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.",
88
+ "en": "Your role: batch translation and mechanical text conversion of the files or text you are given.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs tools: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it."
71
89
  }
72
90
  },
73
91
  {
74
- "name": "sonnet-worker",
92
+ "name": "t1-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",
@@ -88,14 +110,18 @@
88
110
  "en": "Mid-tier worker for multi-step tasks and implementation handed over with a fixed design. It has no MCP tools."
89
111
  },
90
112
  "body": {
91
- "ko": "맡은 역할: 여러 단계로 이어지는 작업과, 호출자가 설계를 확정한 구현을 맡습니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs general-purpose: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.\n전체 테스트, 빌드, 렌더처럼 프로세스를 여럿 띄우는 명령은 프로세스 상한을 걸고 실행하십시오. macOS 와 Linux 에서는 `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <명령>)` 으로 감싸고, Windows 에서는 그대로 실행합니다. 출력에 `spawn ... EAGAIN` 이나 `Resource temporarily unavailable` 이 보이면 테스트 실패가 아니라 프로세스 폭주이므로, 명령을 다시 돌리지 말고 그 줄을 인용해 보고하십시오.",
92
- "en": "Your role: multi-step work and the implementation half of a design the caller already fixed.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs general-purpose: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it.\nRun a command that starts many processes (a full test suite, a build, a render) under a process cap: on macOS and Linux wrap it as `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <command>)`, on Windows run it as is. If the output shows `spawn ... EAGAIN` or `Resource temporarily unavailable`, that is a process runaway, not a test failure: do not rerun the command; quote that line and report."
113
+ "ko": "맡은 역할: 여러 단계로 이어지는 작업과, 호출자가 설계를 확정한 구현을 맡습니다.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs tools: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.\n전체 테스트, 빌드, 렌더처럼 프로세스를 여럿 띄우는 명령은 프로세스 상한을 걸고 실행하십시오. macOS 와 Linux 에서는 `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <명령>)` 으로 감싸고, Windows 에서는 그대로 실행합니다. 출력에 `spawn ... EAGAIN` 이나 `Resource temporarily unavailable` 이 보이면 테스트 실패가 아니라 프로세스 폭주이므로, 명령을 다시 돌리지 말고 그 줄을 인용해 보고하십시오.",
114
+ "en": "Your role: multi-step work and the implementation half of a design the caller already fixed.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs tools: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it.\nRun a command that starts many processes (a full test suite, a build, a render) under a process cap: on macOS and Linux wrap it as `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <command>)`, on Windows run it as is. If the output shows `spawn ... EAGAIN` or `Resource temporarily unavailable`, that is a process runaway, not a test failure: do not rerun the command; quote that line and report."
93
115
  }
94
116
  },
95
117
  {
96
- "name": "opus-worker",
118
+ "name": "t1h-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",
@@ -110,8 +136,8 @@
110
136
  "en": "Heavy worker for Fable sessions: large implementation, debugging and refactoring handed over with a fixed design. It has no MCP tools."
111
137
  },
112
138
  "body": {
113
- "ko": "맡은 역할: 호출자가 설계를 확정한 대규모 구현과 디버깅입니다. 설계 판단과 되돌릴 수 없는 작업은 호출자에게 넘기십시오.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs general-purpose: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.\n전체 테스트, 빌드, 렌더처럼 프로세스를 여럿 띄우는 명령은 프로세스 상한을 걸고 실행하십시오. macOS 와 Linux 에서는 `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <명령>)` 으로 감싸고, Windows 에서는 그대로 실행합니다. 출력에 `spawn ... EAGAIN` 이나 `Resource temporarily unavailable` 이 보이면 테스트 실패가 아니라 프로세스 폭주이므로, 명령을 다시 돌리지 말고 그 줄을 인용해 보고하십시오.",
114
- "en": "Your role: large implementation and debugging work whose design the caller already fixed. Hand design judgement and irreversible actions back to the caller.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs general-purpose: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it.\nRun a command that starts many processes (a full test suite, a build, a render) under a process cap: on macOS and Linux wrap it as `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <command>)`, on Windows run it as is. If the output shows `spawn ... EAGAIN` or `Resource temporarily unavailable`, that is a process runaway, not a test failure: do not rerun the command; quote that line and report."
139
+ "ko": "맡은 역할: 호출자가 설계를 확정한 대규모 구현과 디버깅입니다. 설계 판단과 되돌릴 수 없는 작업은 호출자에게 넘기십시오.\n위임받은 작업만 하고, 호출자가 프롬프트에 적은 상한(도구 호출 횟수, 출력 토큰)을 넘기지 마십시오.\n호출자 프롬프트의 언어로 간결하게 보고하십시오.\nMCP 도구나 목록에 없는 도구가 필요하면 즉시 멈추고 \"needs tools: <필요한 도구>\" 한 줄로 보고하십시오. 부모가 다시 위임합니다.\n확인하지 못한 내용은 추측하지 말고 확인하지 못했다고 말하십시오.\n전체 테스트, 빌드, 렌더처럼 프로세스를 여럿 띄우는 명령은 프로세스 상한을 걸고 실행하십시오. macOS 와 Linux 에서는 `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <명령>)` 으로 감싸고, Windows 에서는 그대로 실행합니다. 출력에 `spawn ... EAGAIN` 이나 `Resource temporarily unavailable` 이 보이면 테스트 실패가 아니라 프로세스 폭주이므로, 명령을 다시 돌리지 말고 그 줄을 인용해 보고하십시오.",
140
+ "en": "Your role: large implementation and debugging work whose design the caller already fixed. Hand design judgement and irreversible actions back to the caller.\nDo only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.\nReport concisely, in the language of the caller's prompt.\nIf you need an MCP tool or any tool not on your list, stop at once and report one line: \"needs tools: <tools needed>\". The parent will delegate again.\nDo not guess at anything you could not verify; say that you could not verify it.\nRun a command that starts many processes (a full test suite, a build, a render) under a process cap: on macOS and Linux wrap it as `(ulimit -u $(( $(ps -u $(id -u) -o pid= | wc -l) + 150 )); <command>)`, on Windows run it as is. If the output shows `spawn ... EAGAIN` or `Resource temporarily unavailable`, that is a process runaway, not a test failure: do not rerun the command; quote that line and report."
115
141
  }
116
142
  }
117
143
  ]
@@ -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": {
@@ -29,8 +29,8 @@
29
29
  },
30
30
  {
31
31
  "id": "fix-b1da7a",
32
- "ko": "자동화 설계에서 vision/screenshot 루프가 보이면 → 호출 가능한 함수·API·MCP tool부터 찾는다. 통제 가능한 내부 도구는 무조건 API, 통제 불가 외부 SaaS만 vision 차선책. 인터페이스를 바꾼 뒤에 모델을 내린다 — 거꾸로 가면 토큰 폭발",
33
- "en": "When an automation design calls for a vision/screenshot loop → look for a callable function, API or MCP tool first. Anything you control gets an API; vision is the fallback only for external SaaS you cannot change. Fix the interface before lowering the model — doing it the other way round explodes the token cost"
32
+ "ko": "자동화 설계에서 vision/screenshot 루프가 보이면 → 호출 가능한 함수·API·MCP tool부터 찾는다. 통제 가능한 내부 도구는 무조건 API, 통제 불가 외부 SaaS만 vision 차선책. 인터페이스를 바꾼 뒤에 티어를 내린다(T1 → T2) — 거꾸로 가면 토큰 폭발",
33
+ "en": "When an automation design calls for a vision/screenshot loop → look for a callable function, API or MCP tool first. Anything you control gets an API; vision is the fallback only for external SaaS you cannot change. Change the interface first, then lower the tier (T1 → T2) — doing it the other way round explodes the token cost"
34
34
  },
35
35
  {
36
36
  "id": "fix-1dee5f",
@@ -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 에이전트(t2-explore, t2-runner)에 위임해 상위 모델 토큰을 아낀다. 티어를 실제 모델로 바꾸는 일은 도구가 맡는다",
43
+ "en": "Delegate simple requests (lookup, status checks, running commands) to a T2 agent (t2-explore, t2-runner) to keep the expensive model's tokens for real work. The tool turns the tier into a model"
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 에이전트(t1-worker)에 위임하며, T2에는 구현을 맡기지 않는다. 위임 프롬프트에는 설계, 수정할 파일, 제약, 완료 기준, 테스트 명령, 예산, 권장 티어와 그 이유를 적어 다른 문맥 없이 실행할 수 있게 한다. 이미 읽은 파일의 약 50줄 이하 변경이나 의존 단계가 하나뿐인 짧은 구현, 그리고 위임 도구나 하위 티어를 쓸 수 없는 경우는 메인 모델이 직접 처리한다. 위임한 구현은 끝날 때까지 기다리고 같은 구현을 중복 실행하지 않으며, 돌아온 diff와 테스트 결과를 메인 모델이 직접 검증한 뒤에 완료로 보고한다",
53
+ "en": "When an implementation spans several files → the main model settles the design and specification and delegates the code writing to a T1 agent (t1-worker), never T2. Write the delegation prompt so it runs without other context: design, files to change, constraints, completion criteria, test commands, budget, and the recommended tier 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 tier 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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * How a delegation target is phrased in generated rules.
3
3
  *
4
- * Rules used to name concrete subagents (`haiku-explore`, `haiku-runner`, …).
4
+ * Rules used to name concrete subagents (`t2-explore`, `t2-runner`, …).
5
5
  * Those live in the user's own `~/.claude/agents/` and are NOT shipped by this
6
6
  * package, so on any machine that never created them the generated rule told
7
7
  * the model to delegate to an agent that does not exist. The model tier is the
@@ -10,7 +10,7 @@
10
10
  * there, which keeps the tool-restricted preset in play for users who have it.
11
11
  *
12
12
  * A rule that names a tier alone (`sonnet`, `haiku`) resolves to that tier's
13
- * delegation-only agent (`sonnet-worker`, `haiku-explore`, installed by
13
+ * delegation-only agent (`t1-worker`, `t2-explore`, installed by
14
14
  * delegation-agents.js) when its file exists. A phrase that names an agent
15
15
  * always carries a general-purpose fallback, because Cowork reads no agents
16
16
  * directory and a copied rule would otherwise name an agent that is not there.
@@ -19,14 +19,35 @@
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
+ * Agents used to be named after their model. They are named by tier now, since
26
+ * the model behind a tier changes and a stale model name inside an agent name
27
+ * misleads. A registry, a hand-written rule or a user's own file may still carry
28
+ * the old name, so it keeps resolving to the new one.
29
+ */
30
+ export const LEGACY_AGENT_NAMES = {
31
+ 'haiku-explore': 't2-explore',
32
+ 'haiku-runner': 't2-runner',
33
+ 'haiku-translate': 't2-translate',
34
+ 'sonnet-worker': 't1-worker',
35
+ 'opus-worker': 't1h-worker',
36
+ };
37
+
38
+ /**
39
+ * Bare model alias each routing tier stands for. A rule says only its tier;
40
+ * where no agent serves that tier, this alias is what the text names.
41
+ */
42
+ export const TIER_MODEL = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
22
43
 
23
44
  /** Tier each preset agent is pinned to, for the `model:` half of the phrase. */
24
45
  const AGENT_MODEL = {
25
- 'haiku-explore': 'haiku',
26
- 'haiku-runner': 'haiku',
27
- 'haiku-translate': 'haiku',
28
- 'sonnet-worker': 'sonnet',
29
- 'opus-worker': 'opus',
46
+ 't2-explore': 'haiku',
47
+ 't2-runner': 'haiku',
48
+ 't2-translate': 'haiku',
49
+ 't1-worker': 'sonnet',
50
+ 't1h-worker': 'opus',
30
51
  // A T1 rule names its target by tier alone (`agent: 'sonnet'`). Without these
31
52
  // the phrase fell through to the haiku default, and a sonnet rule told the
32
53
  // session to delegate to haiku. resolveAgentName maps them to an agent file.
@@ -41,7 +62,7 @@ const AGENT_MODEL = {
41
62
  * `root: null` asks about the user directory alone. Text that every project
42
63
  * reads (the global ratchet-model.md) has to be worded from what every project
43
64
  * can see: checked against the current directory, the same rule read
44
- * `haiku-explore(model: haiku)` when the scan ran in a project that defines
65
+ * `t2-explore(model: haiku)` when the scan ran in a project that defines
45
66
  * the agent and `model: haiku` when it ran anywhere else, and each flip
46
67
  * rewrote a file sitting in the prompt-cache prefix of every session.
47
68
  */
@@ -51,47 +72,79 @@ export function agentExists(name, root = process.cwd()) {
51
72
  existsSync(join(claudeUserDir(), 'agents', `${name}.md`));
52
73
  }
53
74
 
75
+ /**
76
+ * The agent a model-routing rule delegates to, or null when no agent serves its
77
+ * tier. A rule carries only `category` and `tier`; the agents preset says which
78
+ * tier and categories each agent serves, so Claude and Codex read one key.
79
+ *
80
+ * (a) `rule.agent` when it is a non-empty string: legacy registries and the
81
+ * published v1 bundle still carry it, and it stays an explicit override,
82
+ * (b) else the first agent of that tier whose categories name the category,
83
+ * (c) else the first agent of that tier whose categories hold "*",
84
+ * (d) else null.
85
+ */
86
+ export function agentForRule(rule, { agents = agentDefinitions() } = {}) {
87
+ if (!rule) return null;
88
+ if (typeof rule.agent === 'string' && rule.agent.trim()) {
89
+ return Object.hasOwn(LEGACY_AGENT_NAMES, rule.agent) ? LEGACY_AGENT_NAMES[rule.agent] : rule.agent;
90
+ }
91
+ const list = (Array.isArray(agents) ? agents : []).filter((a) => a && a.tier === rule.tier);
92
+ const cats = (a) => (Array.isArray(a.categories) ? a.categories : []);
93
+ const exact = list.find((a) => cats(a).includes(rule.category));
94
+ if (exact) return exact.name;
95
+ const any = list.find((a) => cats(a).includes('*'));
96
+ return any ? any.name : null;
97
+ }
98
+
99
+ /** Model alias an agent (or a legacy agent name) is pinned to, or undefined. */
100
+ const modelOf = (name) => AGENT_MODEL[Object.hasOwn(LEGACY_AGENT_NAMES, name) ? LEGACY_AGENT_NAMES[name] : name];
101
+
54
102
  /** Agent a bare tier name stands for when that agent's file exists. */
55
- const TIER_AGENT = { sonnet: 'sonnet-worker', opus: 'opus-worker', haiku: 'haiku-explore' };
103
+ const TIER_AGENT = { sonnet: 't1-worker', opus: 't1h-worker', haiku: 't2-explore' };
56
104
 
57
105
  /**
58
106
  * The agent a rule target really names on this machine, or null when none is
59
107
  * installed. A name wins when its own file exists; a bare tier name falls back
60
- * to its delegation-only agent.
108
+ * to its delegation-only agent. A legacy name maps to its tier-named successor
109
+ * first; when only the legacy file is installed (a user-edited file sprag does
110
+ * not own, so it was never removed) the legacy name is returned, because that is
111
+ * the file the phrase has to point at.
61
112
  */
62
113
  export function resolveAgentName(name, root = process.cwd()) {
63
- if (agentExists(name, root)) return name;
64
- const alt = TIER_AGENT[name];
114
+ const current = Object.hasOwn(LEGACY_AGENT_NAMES, name) ? LEGACY_AGENT_NAMES[name] : name;
115
+ if (agentExists(current, root)) return current;
116
+ if (current !== name && agentExists(name, root)) return name;
117
+ const alt = TIER_AGENT[current];
65
118
  return alt && agentExists(alt, root) ? alt : null;
66
119
  }
67
120
 
68
121
  /**
69
122
  * Korean phrase for a delegation target, e.g.
70
- * agent present → `haiku-explore(model 인자 생략. …general-purpose에 model: haiku)`
123
+ * agent present → `t2-explore(model 인자 생략. …general-purpose에 model: haiku)`
71
124
  * agent absent → `model: haiku`
72
125
  *
73
126
  * A named agent carries its model in its own definition, and the Agent tool's
74
127
  * `model` argument overrides that definition. The old phrase
75
- * `haiku-explore(model: haiku)` read as "pass model: haiku", and Claude Code
128
+ * `t2-explore(model: haiku)` read as "pass model: haiku", and Claude Code
76
129
  * resolves the bare `haiku` alias to an older Haiku than a definition pinned to
77
130
  * a full id (claude-haiku-5-5), so following the rule undid the pin. The phrase
78
131
  * now says to leave the argument out.
79
132
  */
80
133
  export function agentPhrase(name, { model, root } = {}) {
81
134
  const resolved = resolveAgentName(name, root);
82
- if (!resolved) return `model: ${model || AGENT_MODEL[name] || 'haiku'}`;
83
- const tier = AGENT_MODEL[resolved] || model || 'haiku';
135
+ if (!resolved) return `model: ${model || modelOf(name) || 'haiku'}`;
136
+ const tier = modelOf(resolved) || model || 'haiku';
84
137
  return `${resolved}(model 인자 생략. 이 에이전트가 없거나 MCP 도구가 필요하면 general-purpose에 model: ${tier})`;
85
138
  }
86
139
 
87
140
  /**
88
141
  * English phrase, including the article, e.g.
89
- * agent present → `the haiku-explore subagent (omit the model argument; …)`
142
+ * agent present → `the t2-explore subagent (omit the model argument; …)`
90
143
  * agent absent → `a model: haiku subagent`
91
144
  */
92
145
  export function agentPhraseEn(name, { model, root } = {}) {
93
146
  const resolved = resolveAgentName(name, root);
94
- if (!resolved) return `a model: ${model || AGENT_MODEL[name] || 'haiku'} subagent`;
95
- const tier = AGENT_MODEL[resolved] || model || 'haiku';
147
+ if (!resolved) return `a model: ${model || modelOf(name) || 'haiku'} subagent`;
148
+ const tier = modelOf(resolved) || model || 'haiku';
96
149
  return `the ${resolved} subagent (omit the model argument; if it is unavailable or MCP tools are needed, use general-purpose with model: ${tier})`;
97
150
  }
@@ -13,6 +13,9 @@ import { loadConfig } from '../config.js';
13
13
  import { loadSharedModelRules, sharedModelRulesForProject, sharedRuleBounds, SHARED_RULE_TIERS } from '../shared-model-rules.js';
14
14
  import { codexPriceBook, readCodexPrices } from './cache-policy.js';
15
15
  import { codexRunCost } from '../gateway-prices.js';
16
+ import { agentForRule } from '../agents.js';
17
+ import { agentDefinitions } from '../presets-source.js';
18
+ import { firstSentence } from '../first-sentence.js';
16
19
 
17
20
  export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
18
21
  const MARKER = '<!-- sprag:codex:delegation -->';
@@ -124,13 +127,30 @@ export function codexTierTargets({ cfg = loadConfig(), dir = userDataDir(), home
124
127
  return [...explicit, ...auto];
125
128
  }
126
129
 
130
+ /**
131
+ * The delegation role a rule runs as: the agent agentForRule() picks for its
132
+ * tier and category, the same one Claude delegates to, and the first sentence
133
+ * of that agent's English body. Codex runs the role on the model mapped to the
134
+ * tier, so both tools show one value for one rule. Null when no agent serves it.
135
+ */
136
+ export function codexRoleFor(rule, { agents = agentDefinitions() } = {}) {
137
+ const role = agentForRule(rule, { agents });
138
+ if (!role) return { role: null, roleLine: null };
139
+ const def = agents.find((a) => a && a.name === role);
140
+ // The body opens with "Your role: …"; the hint already says "Role <name>:", so the prefix is dropped.
141
+ return { role, roleLine: def?.body?.en ? firstSentence(def.body.en.replace(/^\s*Your role:\s*/i, '')) : null };
142
+ }
143
+
127
144
  export function sharedCodexModelRules({ cfg = loadConfig(), dir = userDataDir(), home = codexUserDir(), root, start = root,
128
145
  model, provider = codexProviderName() } = {}) {
129
146
  if (cfg.codex?.sharedRules?.enabled === false) return [];
130
147
  const targets = codexTierTargets({ cfg, dir, home, model, provider });
148
+ let agents = null;
131
149
  return sharedModelRulesForProject(loadSharedModelRules({ dir }), start).flatMap((rule) => {
132
150
  const target = targets.find((t) => t.tier === rule.tier);
133
- return target ? [{ ...rule, ...target, source: 'shared', status: 'active' }] : [];
151
+ if (!target) return [];
152
+ agents ??= agentDefinitions();
153
+ return [{ ...rule, ...target, ...codexRoleFor(rule, { agents }), source: 'shared', status: 'active' }];
134
154
  });
135
155
  }
136
156
 
@@ -254,18 +274,21 @@ export function codexImplementationHint({ model, provider, sessionId, cfg = load
254
274
  if (!codexDelegateEnabled(cfg) || cfg.codex?.sharedRules?.enabled === false) return 'Implementation delegation is off. Keep the implementation on the main agent.';
255
275
  const target = codexTierTargets({ cfg, dir, home, model, provider }).find((t) => t.tier === 'T1');
256
276
  if (!target) return 'T1 is unresolved for this parent/provider. Keep the implementation on the main agent; do not fall back to T2.';
277
+ const { role, roleLine } = codexRoleFor({ tier: 'T1', category: 'implement' });
278
+ const asRole = role ? ` Run it as role ${role}${roleLine ? `: ${roleLine}` : '.'}` : '';
257
279
  const id = newRouteId();
258
280
  record({ id, parentSessionId: sessionId, via: 'prompt', source: 'implementation', tier: 'T1',
259
281
  from: model, to: target.model, provider, effort: target.effort ?? null }, { dir });
260
282
  return `[Sprag implementation delegation] The user-approved implementation-split policy keeps design and specification on the main agent. `
261
- + `Delegate only the implementation of that completed design to model ${target.model}${target.effort ? `, reasoning_effort ${target.effort}` : ''}, with fork_turns "none". `
283
+ + `Delegate only the implementation of that completed design to model ${target.model}${target.effort ? `, reasoning_effort ${target.effort}` : ''}, with fork_turns "none".${asRole} `
262
284
  + 'Never delegate design judgement or irreversible actions. Give one child a self-contained specification, owned files, constraints, test commands, completion criteria and the reason for this model choice. '
263
285
  + 'Cap: 8000 output tokens. Stop and report partial work before exceeding it. Do not recursively delegate. '
264
286
  + WAIT_TEXT + `\nEnd the child task message with this route line only:\n<!-- sprag:codex:route id=${id} -->`;
265
287
  }
266
288
 
267
289
  function sharedTargetText(rule) {
268
- return `${rule.tier}: model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''}. ${sharedRuleBounds(rule)}`.trim();
290
+ const role = rule.role ? ` Role ${rule.role}:${rule.roleLine ? ` ${rule.roleLine}` : ''}` : '';
291
+ return `${rule.tier}: model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''}.${role} ${sharedRuleBounds(rule)}`.trim();
269
292
  }
270
293
 
271
294
  function sharedDenyReason(routes) {
@@ -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');
@@ -27,7 +27,7 @@ const MARK_LINE = `# managed by sprag (${OWNER_MARK}); edits are overwritten`;
27
27
  //
28
28
  // Models are aliases on purpose: behind a gateway they resolve through
29
29
  // ANTHROPIC_DEFAULT_*_MODEL, and a full model id would bypass that mapping. The
30
- // exception is opus-worker, which names the full id: Claude Code resolves a bare
30
+ // exception is t1h-worker, which names the full id: Claude Code resolves a bare
31
31
  // alias to an older model than the one the tier means.
32
32
  //
33
33
  // Effort is a separate axis from the tier and is set by how the work gets
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The first sentence of a text, shared by the Codex seed offer (seed-rules.js)
3
+ * and the Codex delegation hints (codex/delegation.js). It has no imports so
4
+ * the per-prompt Codex hook can use it without loading the seed module.
5
+ *
6
+ * Codex keeps about 2,000 tokens of a hook's context and drops a part that does
7
+ * not fit whole, so listing every ratchet rule in full pushed the whole offer
8
+ * out. The offer only has to say what each rule is about; accepting writes the
9
+ * full text. A sentence ends at "다." or a full stop followed by whitespace
10
+ * (not an ellipsis), or at "다" before a newline. Without one, a text over 140
11
+ * characters is cut at the last space before 140 and marked with "…".
12
+ */
13
+ export const OFFER_CUT = 140;
14
+ export function firstSentence(text) {
15
+ const t = String(text ?? '').trim();
16
+ const m = /(?<!\.)\.(?=\s)|다(?=\n)/.exec(t);
17
+ if (m && m.index + 1 < t.length) return t.slice(0, m.index + m[0].length).trim();
18
+ if (t.length <= OFFER_CUT) return t;
19
+ const head = t.slice(0, OFFER_CUT);
20
+ const sp = head.lastIndexOf(' ');
21
+ return `${(sp > 0 ? head.slice(0, sp) : head).trimEnd()}…`;
22
+ }
@@ -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,12 @@ 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';
35
+ import { firstSentence, OFFER_CUT } from './first-sentence.js';
36
+
37
+ // Re-exported: the Codex delegation hints use the same cut through first-sentence.js,
38
+ // which stays dependency-free so the per-prompt hook does not load this module.
39
+ export { firstSentence, OFFER_CUT };
34
40
  import { userLanguage } from './config.js';
35
41
  import { writeStateFile } from './state-file.js';
36
42
  import {
@@ -130,7 +136,7 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(),
130
136
  id: p.id,
131
137
  tier: p.tier,
132
138
  label: lang === 'ko' ? p.label : p.labelEn,
133
- agent: agent === 'codex' ? `Codex ${p.tier} mapping` : p.agent,
139
+ agent: agent === 'codex' ? `Codex ${p.tier} mapping` : (agentForRule(p) ?? TIER_MODEL[p.tier]),
134
140
  ruleText: agent === 'codex' ? codexSeedText(p, lang)
135
141
  : composeRuleText(modelRuleBaseText(p, lang), { budget: p.budget }, lang),
136
142
  preset: p,
@@ -147,9 +153,17 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(),
147
153
  }
148
154
 
149
155
  function codexSeedText(p, lang) {
150
- const text = lang === 'ko'
151
- ? `"${p.label}" 유형의 ${p.tier === 'T2' ? '단순' : '중간 난도'} 작업은 ${p.tier}에 위임합니다. 실행 모델은 Codex가 자동으로 정하거나 매핑으로 지정합니다.`
152
- : `${p.tier === 'T2' ? 'Simple' : 'Moderate'} "${p.labelEn}" tasks use ${p.tier}. Codex resolves the execution model automatically or from a mapping.`;
156
+ // The role is the agent Claude delegates the same rule to; Codex runs that
157
+ // role on the model its tier mapping names, so both tools offer one value.
158
+ const role = agentForRule(p);
159
+ const level = lang === 'ko' ? (p.tier === 'T2' ? '단순' : '중간 난도') : (p.tier === 'T2' ? 'Simple' : 'Moderate');
160
+ const text = role
161
+ ? (lang === 'ko'
162
+ ? `"${p.label}" 유형의 ${level} 작업은 ${p.tier}에 위임합니다. 역할은 ${role}이고 실행 모델은 Codex가 티어 매핑으로 정합니다.`
163
+ : `${level} "${p.labelEn}" tasks use ${p.tier} as role ${role}; Codex resolves the model from the tier mapping.`)
164
+ : (lang === 'ko'
165
+ ? `"${p.label}" 유형의 ${level} 작업은 ${p.tier}에 위임합니다. 실행 모델은 Codex가 자동으로 정하거나 매핑으로 지정합니다.`
166
+ : `${level} "${p.labelEn}" tasks use ${p.tier}. Codex resolves the execution model automatically or from a mapping.`);
153
167
  return composeRuleText(text, p, lang);
154
168
  }
155
169
 
@@ -212,7 +226,9 @@ export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = u
212
226
  category: p.category,
213
227
  label: p.label,
214
228
  labelEn: p.labelEn,
215
- agent: p.agent,
229
+ // A preset names its tier; only a legacy (v1) bundle preset still names an
230
+ // agent, and that override is kept rather than lost on accept.
231
+ ...(typeof p.agent === 'string' && p.agent ? { agent: p.agent } : {}),
216
232
  scope,
217
233
  targetRoot: scope === 'project' ? root : null,
218
234
  project,
@@ -291,7 +307,7 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
291
307
  ` 4. Register none: ${cmd('skip all')} (never offered again)`,
292
308
  'Always confirm the scope with the user before running an accept command.',
293
309
  '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}`),
310
+ ...pending.map((s) => ` [${s.id}] ${s.kind === 'ratchet' ? firstSentence(s.ruleText) : s.ruleText}`),
295
311
  ].join('\n');
296
312
  }
297
313
  const models = pending.filter((s) => s.kind === 'model');