sprag-cli 3.60.0 → 3.62.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/docs/CODEX.ko.md +79 -6
- package/docs/CODEX.md +95 -12
- package/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/codex-delegation.js +162 -12
- package/src/codex-doctor.js +2 -2
- package/src/codex-hooks.js +1 -2
- package/src/codex-ledger.js +21 -5
- package/src/codex-route-scan.js +9 -4
- package/src/commands/brief.js +1 -1
- package/src/commands/codex-delegate.js +95 -6
- package/src/commands/codex.js +18 -2
- package/src/model-rules.js +11 -8
- package/src/route-inject.js +14 -5
- package/src/seed-rules.js +32 -23
- package/src/shared-model-rules.js +73 -0
package/docs/CODEX.ko.md
CHANGED
|
@@ -122,10 +122,10 @@ Codex 0.159.2에서는 `spawn_agent`가 `PreToolUse`를 거치지 않습니다(
|
|
|
122
122
|
|
|
123
123
|
이 안내는 부모 세션의 컨텍스트가 `codex.delegateMinContext`(기본 60000 토큰) 아래면 붙지
|
|
124
124
|
않습니다. 2026-09-30 실측에서 작은 세션은 스폰·대기·검증이라는 조율 비용이 자식이 아끼는
|
|
125
|
-
비용보다 커서, 문턱 아래에서는 안내를 붙이는 쪽이 오히려 손해였습니다.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
125
|
+
비용보다 커서, 문턱 아래에서는 안내를 붙이는 쪽이 오히려 손해였습니다. 기준은
|
|
126
|
+
`sprag delegate shared min-context <토큰 수|default> --agent codex`로 바꾸고, 현재 값은
|
|
127
|
+
`delegate shared status`에서 확인합니다. 이 값은 `sprag mode`가 "Stored config file"로 알려 주는
|
|
128
|
+
설정 파일에 `codex.delegateMinContext`로 저장됩니다. `PreToolUse` 재작성은 spawn을 훅으로 보내는 Codex 버전을 위해 남아
|
|
129
129
|
있을 뿐, 0.159.2에서는 호출되지 않습니다. 위임 원장은 자식이 쓴 토큰을 부모 모델 단가로 환산한
|
|
130
130
|
추정치이며, 부모가 조율에 쓴 턴은 빼지 않습니다. 끝나기 전에 중단된 자식은 부모의 일을 대신하지
|
|
131
131
|
못했으므로, 그 비용 전액을 손실로 기록하고 항목에 `aborted`를 표시합니다.
|
|
@@ -139,8 +139,81 @@ Codex 0.159.2는 훅 하나가 넘긴 컨텍스트를 약 2,450토큰까지만
|
|
|
139
139
|
못한 룰과 지침은 Sprag 데이터 디렉터리의 프로젝트별 파일에 쓰고, 컨텍스트 첫머리에서 그 파일을
|
|
140
140
|
먼저 읽으라고 안내합니다. rollout의 `originator`로 `codex exec` 실행을 알아보며, 답할 사람이
|
|
141
141
|
없으므로 seed 제안과 route 안내를 넣지 않습니다.
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
|
|
143
|
+
`seed`는 공통 위임 정책과 Codex용 래칫 프리셋을 제안합니다.
|
|
144
|
+
래칫과 거절 기록은 도구별로 분리하지만, 승인된 공통 정책은 원래 범위 안에서 중복 제안하지 않습니다.
|
|
145
|
+
|
|
146
|
+
### 공통 위임 정책
|
|
147
|
+
|
|
148
|
+
작업 유형, T1·T2 난도, 적용 범위와 상한은 Claude와 Codex가 같은 `model-rules.json`에서
|
|
149
|
+
읽습니다. 기존 Claude 활성 규칙도 복사 없이 표시하며, 삭제되거나 비활성·검토 상태인 규칙은
|
|
150
|
+
Codex에서 위임에 사용하지 않습니다. 프로젝트 정책은 작업 디렉터리에 대해 두 도구가 각각 정하는
|
|
151
|
+
프로젝트 루트 가운데 하나와 일치하면 적용합니다. Claude는 `CLAUDE.md`에서, Codex는 `AGENTS.md`에서
|
|
152
|
+
루트를 정하며, 정책의 루트와 작업 디렉터리의 루트는 심볼릭 링크를 푼 실제 경로로 비교합니다. 모델 ID, 역할, 사용량과 절감액은
|
|
153
|
+
도구별로 유지합니다.
|
|
154
|
+
|
|
155
|
+
Claude를 설치하지 않은 Codex 첫 사용자도 기록을 쌓기 전에 프리셋을 승인할 수 있습니다.
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
sprag seed --agent codex
|
|
159
|
+
sprag seed accept all --global --agent codex
|
|
160
|
+
sprag delegate shared status --agent codex
|
|
161
|
+
sprag delegate on --agent codex
|
|
162
|
+
# 자동으로 정해진 난도별 모델을 바꾸고 싶을 때만:
|
|
163
|
+
sprag delegate shared map T2 --from PARENT_MODEL --model SMALL_MODEL --effort high --agent codex
|
|
164
|
+
sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort medium --agent codex
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
등록 전에 전역과 프로젝트 범위를 선택합니다. 프로젝트에만 적용하려면 `--global` 대신
|
|
168
|
+
`--project`를 지정하십시오.
|
|
169
|
+
|
|
170
|
+
`map` 줄은 선택 사항입니다. 매핑하지 않은 난도는 Claude Code가 `haiku`와 `sonnet`을 실제 모델로
|
|
171
|
+
풀듯이 자동으로 정해집니다. 부모 모델의 공급자에서 가격이 매겨져 있고(게이트웨이 `/model/info` 가격,
|
|
172
|
+
OpenAI 직접 호출이면 공개 가격), 부모보다 싸며, 이 사용자의 Codex 세션에서 이미 실행된 모델 가운데
|
|
173
|
+
가장 싼 모델이 T2, 그다음 모델이 T1이 됩니다. 그런 모델이 하나뿐이면 T1은 메인 모델이 처리하고, 하나도
|
|
174
|
+
없으면 두 난도 모두 메인 모델이 처리합니다. 자동 결정은 `delegate on` 상태에서만 동작하고, route-scan이
|
|
175
|
+
기록한 실행 모델을 쓰며, 아무것도 저장하지 않으므로 가격이나 실행 이력이 바뀌면 그대로 따라갑니다.
|
|
176
|
+
`delegate shared status`와 설치 출력에 정해진 모델이 표시되고, 정해지지 않으면 그 이유가 표시됩니다.
|
|
177
|
+
끄려면 `sprag delegate shared auto off --agent codex`를 실행하십시오.
|
|
178
|
+
|
|
179
|
+
명시한 매핑은 해당 난도에서 자동 결정보다 우선합니다. 매핑은 출발 모델·공급자·난도가 일치할 때만
|
|
180
|
+
적용하고, 한 번 지정하면 승인된 모든 작업 유형에서 사용합니다. 공급자는 Codex 설정에서 읽으며
|
|
181
|
+
`--provider ID`로 지정할 수도 있습니다. 매핑이나 자동 결정만으로 절감 효과를 확인한 것은 아닙니다.
|
|
182
|
+
Codex에서 승인할 때 `.claude` 파일은 만들지 않으며, 나중에 Claude를 시작하면 같은 정책을 Claude용
|
|
183
|
+
모델 표기로 생성합니다.
|
|
184
|
+
|
|
185
|
+
프롬프트 훅은 단순 작업에 T2, 여러 단계가 필요한 작업에 T1을 제시하고, 매핑된 난도마다 route 줄을
|
|
186
|
+
따로 줍니다. 부모는 작업에 맞는 모델 하나를 선택하고 독립적인 작업 명세, 상한, `fork_turns "none"`과
|
|
187
|
+
선택한 난도의 route 줄만 전달한 뒤 결과를 기다려 검증합니다. `fork_turns "none"`은 반드시 지정해야
|
|
188
|
+
합니다. 전체 기록을 넘기는 포크는 부모 모델을 그대로 물려받아 모델 지정을 무시하기 때문입니다.
|
|
189
|
+
Codex는 사용자가 요청할 때만 스폰 모델을 지정하므로, 안내 문구는 사용자가 정책과 매핑을 승인했다는
|
|
190
|
+
사실을 함께 밝힙니다.
|
|
191
|
+
|
|
192
|
+
제시한 난도는 각각 프롬프트 route로 기록합니다. 이 경로는 `spawn_agent`가 `PreToolUse`를 거치지 않는
|
|
193
|
+
Codex 0.159.2에서 Codex 전용 규칙이 쓰는 경로와 같습니다. SubagentStart는 자식이 실행하는 모델과 같은
|
|
194
|
+
route에 자식을 연결하고, 부모에게 다음 프롬프트가 들어오면 고르지 않은 난도의 route를 닫습니다. 그래서
|
|
195
|
+
제시만 하고 연결된 자식이 없는 난도는 실행으로 집계하지 않습니다. `PreToolUse`가 실행되는 버전에서는
|
|
196
|
+
공통 정책에 해당하는 스폰이 모델을 지정하지 않으면 스폰 훅이 매핑된 선택지를 알려 주며 그 스폰을
|
|
197
|
+
거부하고, 난도는 부모가 고릅니다. 스폰 훅은 난도를 임의로 정하거나 명시된 모델·사용자 정의 역할을
|
|
198
|
+
덮어쓰지 않으며, 부모가 자기 모델을 명시하면 작업은 그 모델에 남습니다.
|
|
199
|
+
|
|
200
|
+
공통 정책 안내도 앞에서 설명한 Codex 전용 규칙과 같은 컨텍스트 기준을 따릅니다. 이 기준은 모델의
|
|
201
|
+
컨텍스트 창 크기를 바꾸지 않으며, 컨텍스트 기록이 없으면 안내하지 않습니다. 매핑은 지정한 출발 모델의 세션에만 적용되므로,
|
|
202
|
+
`status`는 Codex 설정의 기본 모델을 기준으로 빠진 매핑을 알려 줍니다.
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
sprag delegate rules --agent codex
|
|
206
|
+
sprag delegate shared off --agent codex
|
|
207
|
+
sprag delegate shared on --agent codex
|
|
208
|
+
sprag delegate shared unmap T2 --from PARENT_MODEL --agent codex
|
|
209
|
+
sprag delegate shared min-context 30000 --agent codex
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`shared off`는 공통 정책과 매핑을 보존하며 Codex 전용 규칙에는 영향을 주지 않습니다.
|
|
213
|
+
`unmap`은 해당 Codex 매핑만 제거합니다. 공통 정책은 `sprag route-scan rules [rm N]`에서
|
|
214
|
+
관리하며, 원본 삭제는 두 도구에 반영됩니다. Codex 실행 기록에는 원본 규칙 식별자와 난도를
|
|
215
|
+
남기지만 사용량·절감액을 Claude 통계에 더하지 않습니다. 안내 생성만으로 실행을 집계하지 않으며,
|
|
216
|
+
route 표지 또는 SubagentStart 연결이 매핑된 모델로 실행한 실제 자식 세션과 일치해야 기록을 연결합니다.
|
|
144
217
|
|
|
145
218
|
## 래칫 규칙
|
|
146
219
|
|
package/docs/CODEX.md
CHANGED
|
@@ -176,11 +176,11 @@ The hint is withheld below `codex.delegateMinContext` (default 60000 input
|
|
|
176
176
|
tokens, read from the parent's own rollout). Measured 2026-09-30: a small
|
|
177
177
|
parent session spends more on the coordinating turn (spawning, waiting,
|
|
178
178
|
verifying) than a delegated child saves, so under the floor Sprag leaves the
|
|
179
|
-
request on the main agent instead of proposing a loss.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
179
|
+
request on the main agent instead of proposing a loss. Change it with
|
|
180
|
+
`sprag delegate shared min-context <tokens|default> --agent codex`;
|
|
181
|
+
`delegate shared status` shows the current value. It is stored as
|
|
182
|
+
`codex.delegateMinContext` in the config file `sprag mode` prints under
|
|
183
|
+
"Stored config file".
|
|
184
184
|
|
|
185
185
|
The `PreToolUse` rewrite (native `spawn_agent` arguments using Codex's
|
|
186
186
|
`message`, `model`, and `reasoning_effort` fields, preserving explicit model
|
|
@@ -204,7 +204,87 @@ spawns without an explicit model. Category rules (`paste`, `translate`, `explore
|
|
|
204
204
|
`read`, `check`, `run`) reuse Sprag's classifier and escalation gates, require an
|
|
205
205
|
exact parent model, and prefer project rules over global rules. Matching prompts
|
|
206
206
|
request native delegation; unavailable subagent tools leave work on the main
|
|
207
|
-
agent.
|
|
207
|
+
agent. Codex-only rules take precedence over shared policies.
|
|
208
|
+
|
|
209
|
+
### Shared Delegation Policies
|
|
210
|
+
|
|
211
|
+
Work categories, T1/T2 difficulty, scope, and caps use the same
|
|
212
|
+
`model-rules.json` registry in Claude Code and Codex. Existing active Claude
|
|
213
|
+
policies are visible immediately; there is no copy or migration. Deleted,
|
|
214
|
+
disabled, and review-state policies are not routed by Codex. Project policies
|
|
215
|
+
remain project-scoped. A project policy applies when its root is the root either
|
|
216
|
+
agent assigns to the working directory (Claude stops at `CLAUDE.md`, Codex at
|
|
217
|
+
`AGENTS.md`), compared as real paths. Model IDs, roles, usage, and savings are
|
|
218
|
+
not shared.
|
|
219
|
+
|
|
220
|
+
A Codex-first user needs no Claude installation or transcript history:
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
sprag seed --agent codex
|
|
224
|
+
sprag seed accept all --global --agent codex # or --project, after choosing scope
|
|
225
|
+
sprag delegate shared status --agent codex
|
|
226
|
+
sprag delegate on --agent codex
|
|
227
|
+
# optional, to override the automatic tier models:
|
|
228
|
+
sprag delegate shared map T2 --from PARENT_MODEL --model SMALL_MODEL --effort high --agent codex
|
|
229
|
+
sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort medium --agent codex
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The map lines are optional. A tier without a mapping resolves automatically,
|
|
233
|
+
the way Claude Code resolves `haiku` and `sonnet`: the cheapest model that is
|
|
234
|
+
priced on the parent's provider (gateway `/model/info` prices, or OpenAI list
|
|
235
|
+
prices for direct calls), costs less than the parent, and has already run in
|
|
236
|
+
your Codex sessions becomes T2, and the next one up becomes T1. With a single
|
|
237
|
+
such model T1 stays on the main agent; with none, both do. Resolution needs
|
|
238
|
+
`delegate on`, uses the route scan's observed models, and follows price or
|
|
239
|
+
history changes without storing anything. `delegate shared status` and the
|
|
240
|
+
install output show the resolved models and, when nothing resolves, why.
|
|
241
|
+
`sprag delegate shared auto off --agent codex` turns it off.
|
|
242
|
+
|
|
243
|
+
An explicit mapping wins for its tier. Each one applies to one exact parent
|
|
244
|
+
model, provider, and tier, across every approved category; `--provider ID`
|
|
245
|
+
overrides the provider read from Codex config. Mappings and automatic choices
|
|
246
|
+
do not prove savings, and no Haiku/Sonnet-to-GPT equivalence is assumed.
|
|
247
|
+
Codex seed acceptance writes the common registry without creating `.claude`;
|
|
248
|
+
Claude renders its own view when it is installed or starts later.
|
|
249
|
+
|
|
250
|
+
The prompt hook offers T2 for a simple bounded task and T1 for multi-step work,
|
|
251
|
+
and gives each mapped tier its own route line. The caller chooses at most one
|
|
252
|
+
model and passes a self-contained task, the cap, `fork_turns "none"`, and only
|
|
253
|
+
the chosen tier's route line. `fork_turns "none"` is required: a full-history
|
|
254
|
+
fork inherits the parent model and ignores a model override. The hint states
|
|
255
|
+
that the user approved the policy and mapping, because Codex sets a spawn model
|
|
256
|
+
only on the user's request. The parent waits for and verifies the result.
|
|
257
|
+
|
|
258
|
+
Each offered tier is recorded as a prompt route, the same path Codex-only rules
|
|
259
|
+
use on Codex 0.159.2, where `spawn_agent` bypasses `PreToolUse`. SubagentStart
|
|
260
|
+
binds the child to the route whose model it runs, and the parent's next prompt
|
|
261
|
+
closes the tier it did not choose, so an offered tier never counts as a run
|
|
262
|
+
without a matching child. Where `PreToolUse` does run, a spawn that matches a
|
|
263
|
+
shared policy without naming a model is denied with the mapped choices so the
|
|
264
|
+
caller picks the tier. The spawn hook does not guess difficulty or override an
|
|
265
|
+
explicit model or custom role, and naming the parent's own model keeps the task
|
|
266
|
+
on that model.
|
|
267
|
+
|
|
268
|
+
Shared hints use the same context gate as Codex-only rules, described above;
|
|
269
|
+
it does not change any model's context window, and missing context stays below
|
|
270
|
+
the gate. `status` checks mappings against the default model in Codex config, because a
|
|
271
|
+
mapping routes only sessions on the parent model it names.
|
|
272
|
+
|
|
273
|
+
```sh
|
|
274
|
+
sprag delegate rules --agent codex
|
|
275
|
+
sprag delegate shared off --agent codex
|
|
276
|
+
sprag delegate shared on --agent codex
|
|
277
|
+
sprag delegate shared unmap T2 --from PARENT_MODEL --agent codex
|
|
278
|
+
sprag delegate shared min-context 30000 --agent codex
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`shared off` retains policies and mappings and leaves Codex-only rules intact.
|
|
282
|
+
`unmap` removes only that Codex mapping. Edit/remove common policies through
|
|
283
|
+
`sprag route-scan rules [rm N]`; removal affects both consumers. Codex routing
|
|
284
|
+
records retain the source policy signature and tier, but their token and savings
|
|
285
|
+
totals never enter Claude's statistics. A hint is not proof that a child ran:
|
|
286
|
+
accounting requires the route marker or a SubagentStart binding to a matching
|
|
287
|
+
child rollout that ran the mapped model.
|
|
208
288
|
|
|
209
289
|
The routing ledger (`Routing Saved` below) prices a child's own tokens at the
|
|
210
290
|
parent model's rate as the counterfactual; it does not charge or subtract the
|
|
@@ -279,8 +359,10 @@ the context opens with an instruction to read it. `codex exec` runs, recognized
|
|
|
279
359
|
from the rollout's `originator`, get no seed offer or route notice, since no one
|
|
280
360
|
can answer them.
|
|
281
361
|
|
|
282
|
-
`seed` offers
|
|
283
|
-
|
|
362
|
+
`seed` offers common delegation policies without Claude model names, plus
|
|
363
|
+
compatible Codex ratchet presets. Ratchet and skip decisions remain agent-specific;
|
|
364
|
+
an approved common policy suppresses duplicate offers within its scope in either
|
|
365
|
+
agent. Both seed acceptance and
|
|
284
366
|
route approval require the user's choice of global or project scope. A project
|
|
285
367
|
route is saved for the project where it was observed. Enable delegation with
|
|
286
368
|
`sprag delegate on --agent codex` and trust the hooks before expecting rules to run.
|
|
@@ -348,9 +430,10 @@ sprag route-scan savings --refresh --agent codex
|
|
|
348
430
|
sprag route-scan savings --format json --agent codex
|
|
349
431
|
```
|
|
350
432
|
|
|
351
|
-
The separate Codex ledger joins a route ID
|
|
352
|
-
|
|
353
|
-
|
|
433
|
+
The separate Codex ledger joins a spawn-time route ID, or a validated shared
|
|
434
|
+
prompt-hint marker on clients that skip the spawn hook, to the child's own
|
|
435
|
+
rollout. An unrelated explicit model choice or cheaper subagent earns no credit.
|
|
436
|
+
The estimate holds the child's measured tokens
|
|
354
437
|
constant and prices them at the parent and child rates. Negative differences
|
|
355
438
|
remain negative; this is not a comparison of actual billed totals.
|
|
356
439
|
|
|
@@ -525,7 +608,7 @@ Document counts are not a measurement of Codex billing or avoided tokens.
|
|
|
525
608
|
|
|
526
609
|
## Limits And Removal
|
|
527
610
|
|
|
528
|
-
Claude's cap/TTL heuristics, Haiku/Sonnet
|
|
611
|
+
Claude's cap/TTL heuristics, Haiku/Sonnet model identifiers, and the Claude
|
|
529
612
|
`compact-window` command do not apply to Codex. Codex provides its own
|
|
530
613
|
`model_auto_compact_token_limit` setting; Sprag does not apply Claude's 1M
|
|
531
614
|
heuristic or change that setting. Codex's native `tui.status_line` accepts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.62.0",
|
|
4
4
|
"description": "Lower your Claude Code bill without changing how you work: simple tasks go to cheaper models, documents go in as compact Markdown, and the statusline shows spend and savings live. Works with Codex too.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/agent.js
CHANGED
|
@@ -14,6 +14,15 @@ export function codexProviderName({ home = codexUserDir() } = {}) {
|
|
|
14
14
|
} catch (e) { return e.code === 'ENOENT' ? 'openai' : null; }
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** The default model from Codex config, or null when it names none or cannot be read. */
|
|
18
|
+
export function codexModelName({ home = codexUserDir() } = {}) {
|
|
19
|
+
try {
|
|
20
|
+
const cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8'));
|
|
21
|
+
const model = cfg.profiles?.[cfg.profile]?.model || cfg.model;
|
|
22
|
+
return typeof model === 'string' && model ? model : null;
|
|
23
|
+
} catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
/** Remove the global selector before positional subcommand parsers see it. */
|
|
18
27
|
export function selectAgent(argv) {
|
|
19
28
|
const args = [];
|
package/src/codex-delegation.js
CHANGED
|
@@ -9,6 +9,10 @@ import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
|
|
|
9
9
|
import { looksPasted } from './route-inject.js';
|
|
10
10
|
import { newRouteId, recordCodexDelegation } from './codex-ledger.js';
|
|
11
11
|
import { readCodexTailLines } from './codex-parser.js';
|
|
12
|
+
import { loadConfig } from './config.js';
|
|
13
|
+
import { loadSharedModelRules, sharedModelRulesForProject, sharedRuleBounds, SHARED_RULE_TIERS } from './shared-model-rules.js';
|
|
14
|
+
import { codexPriceBook, readCodexPrices } from './codex-cache-policy.js';
|
|
15
|
+
import { codexRunCost } from './gateway-prices.js';
|
|
12
16
|
|
|
13
17
|
export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
|
|
14
18
|
const MARKER = '<!-- sprag:codex:delegation -->';
|
|
@@ -23,6 +27,81 @@ export function validateCodexTarget(model, effort) {
|
|
|
23
27
|
return { model, ...(effort === undefined ? {} : { effort }) };
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
export function validateCodexSharedTarget({ tier, from, model, effort, provider }) {
|
|
31
|
+
validateCodexTarget(model, effort);
|
|
32
|
+
if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1 or T2.');
|
|
33
|
+
if (!safeId(from) || from === model) throw new Error('--from must name a different parent model.');
|
|
34
|
+
if (!safeId(provider)) throw new Error('A shared model mapping requires a Codex provider ID.');
|
|
35
|
+
return { tier, from, model, ...(effort === undefined ? {} : { effort }), provider };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function codexSharedTargets(cfg) {
|
|
39
|
+
const targets = cfg?.codex?.sharedRules?.targets;
|
|
40
|
+
if (!Array.isArray(targets)) return [];
|
|
41
|
+
return targets.flatMap((target) => {
|
|
42
|
+
try { return [validateCodexSharedTarget(target)]; } catch { return []; }
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A request of this shape (input to output about 5:1) ranks models by price.
|
|
47
|
+
const RANKING_USAGE = { input: 10000, output: 2000 };
|
|
48
|
+
const routeScanFile = (dir) => join(dir, 'codex-route-scan.json');
|
|
49
|
+
|
|
50
|
+
function observedCodexModels(dir, provider) {
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(readFileSync(routeScanFile(dir), 'utf8'));
|
|
53
|
+
const prefix = `${provider}|`;
|
|
54
|
+
return (Array.isArray(data?.observedModels) ? data.observedModels : [])
|
|
55
|
+
.filter((key) => typeof key === 'string' && key.startsWith(prefix)).map((key) => key.slice(prefix.length));
|
|
56
|
+
} catch { return []; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The models Codex resolves T2 and T1 to when the user mapped none, the way
|
|
61
|
+
* Claude Code resolves `haiku` and `sonnet`. Only a model that is priced on the
|
|
62
|
+
* parent's provider, costs less than the parent, and has already run in this
|
|
63
|
+
* user's Codex sessions qualifies, so the hint never names a model the gateway
|
|
64
|
+
* cannot serve. The cheapest is T2 and the next one up is T1; with a single
|
|
65
|
+
* candidate T1 stays on the main agent. Returns { targets, reason }.
|
|
66
|
+
*/
|
|
67
|
+
export function autoCodexSharedTargets({ dir = userDataDir(), home = codexUserDir(), model, provider, now = Date.now() } = {}) {
|
|
68
|
+
if (!safeId(model) || !safeId(provider)) return { targets: [], reason: 'unknown parent model or provider' };
|
|
69
|
+
const book = codexPriceBook(provider, readCodexPrices({ now, home, dir }), { home });
|
|
70
|
+
if (!book) return { targets: [], reason: `no price table for ${provider}` };
|
|
71
|
+
const cost = (m) => codexRunCost(RANKING_USAGE, book.prices[m]);
|
|
72
|
+
const parent = cost(model);
|
|
73
|
+
if (parent === null) return { targets: [], reason: `${model} is not priced on ${provider}` };
|
|
74
|
+
const cheaper = [...new Set(observedCodexModels(dir, provider))]
|
|
75
|
+
.filter((m) => m !== model && safeId(m)).map((m) => ({ model: m, usd: cost(m) }))
|
|
76
|
+
.filter((x) => x.usd !== null && x.usd < parent).sort((a, b) => a.usd - b.usd || a.model.localeCompare(b.model));
|
|
77
|
+
if (!cheaper.length) return { targets: [], reason: `no cheaper priced model has run in your Codex sessions on ${provider}` };
|
|
78
|
+
const targets = cheaper.slice(0, 2).map((x, i) => ({ tier: i === 0 ? 'T2' : 'T1', from: model, model: x.model, provider, auto: true }));
|
|
79
|
+
return { targets, reason: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Explicit mappings win per tier. Without one, a tier resolves automatically
|
|
84
|
+
* unless the user turned that off; resolution runs only for an enabled Codex
|
|
85
|
+
* delegation, so a caller without the hook's config never routes by accident.
|
|
86
|
+
*/
|
|
87
|
+
export function codexTierTargets({ cfg = loadConfig(), dir = userDataDir(), home = codexUserDir(), model, provider = codexProviderName() } = {}) {
|
|
88
|
+
const explicit = codexSharedTargets(cfg).filter((t) => t.from === model && t.provider === provider);
|
|
89
|
+
if (cfg.codex?.delegate !== true || cfg.codex?.sharedRules?.auto === false) return explicit;
|
|
90
|
+
const auto = autoCodexSharedTargets({ dir, home, model, provider }).targets
|
|
91
|
+
.filter((t) => !explicit.some((e) => e.tier === t.tier));
|
|
92
|
+
return [...explicit, ...auto];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function sharedCodexModelRules({ cfg = loadConfig(), dir = userDataDir(), home = codexUserDir(), root, start = root,
|
|
96
|
+
model, provider = codexProviderName() } = {}) {
|
|
97
|
+
if (cfg.codex?.sharedRules?.enabled === false) return [];
|
|
98
|
+
const targets = codexTierTargets({ cfg, dir, home, model, provider });
|
|
99
|
+
return sharedModelRulesForProject(loadSharedModelRules({ dir }), start).flatMap((rule) => {
|
|
100
|
+
const target = targets.find((t) => t.tier === rule.tier);
|
|
101
|
+
return target ? [{ ...rule, ...target, source: 'shared', status: 'active' }] : [];
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
26
105
|
const rulesFile = (dir) => join(dir, 'codex-model-rules.json');
|
|
27
106
|
export function loadCodexModelRules({ dir = userDataDir() } = {}) {
|
|
28
107
|
try {
|
|
@@ -92,6 +171,16 @@ export function matchCodexModelRule(text, { model, root, provider = codexProvide
|
|
|
92
171
|
return usable.find((r) => r.scope === 'project') || usable[0] || null;
|
|
93
172
|
}
|
|
94
173
|
|
|
174
|
+
/** A Codex-only rule wins; otherwise the shared policies mapped for this parent, T2 first. */
|
|
175
|
+
function matchingRoutes(text, opts) {
|
|
176
|
+
const own = matchCodexModelRule(text, { ...opts, rules: opts.rules ?? loadCodexModelRules({ dir: opts.dir }) });
|
|
177
|
+
if (own) return [own];
|
|
178
|
+
const category = codexPromptCategory(text);
|
|
179
|
+
if (!category) return [];
|
|
180
|
+
return sharedCodexModelRules(opts).filter((r) => r.category === category.id)
|
|
181
|
+
.sort((a, b) => SHARED_RULE_TIERS.indexOf(a.tier) - SHARED_RULE_TIERS.indexOf(b.tier));
|
|
182
|
+
}
|
|
183
|
+
|
|
95
184
|
/**
|
|
96
185
|
* Input tokens from the rollout's last token_count record, or 0 when the
|
|
97
186
|
* transcript is missing or unreadable. Only the tail is read: the newest
|
|
@@ -111,32 +200,85 @@ export function codexContextTokens(transcriptPath) {
|
|
|
111
200
|
return 0;
|
|
112
201
|
}
|
|
113
202
|
|
|
203
|
+
export const DEFAULT_DELEGATE_MIN_CONTEXT = 60000;
|
|
204
|
+
|
|
205
|
+
/** Parent input tokens below which the prompt hint stays silent. */
|
|
206
|
+
export function codexDelegateMinContext(cfg) {
|
|
207
|
+
const value = cfg?.codex?.delegateMinContext;
|
|
208
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : DEFAULT_DELEGATE_MIN_CONTEXT;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Codex tells the model to set spawn_agent's model only on the user's request,
|
|
212
|
+
// so the shared hint says where this request came from.
|
|
213
|
+
const SHARED_APPROVAL = 'The user approved this policy and its model mapping, so setting model here follows the user\'s request.';
|
|
214
|
+
const SHARED_TIER_GUIDANCE = 'Choose T2 only for a simple bounded lookup or command. Use T1 for multi-step tool chains or work spanning multiple files. If the required tier is not mapped, keep the task on the main agent. Do not delegate design judgement, diagnosis, or irreversible actions.';
|
|
215
|
+
const WAIT_TEXT = 'Wait for it to finish (call wait_agent with timeout_ms of at least 120000, and again if it times out) and do not do the delegated task yourself while it runs; then verify its result and report unfinished work. Do not spawn another CLI process. '
|
|
216
|
+
+ 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
|
|
217
|
+
|
|
218
|
+
function sharedTargetText(rule) {
|
|
219
|
+
return `${rule.tier}: model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''}. ${sharedRuleBounds(rule)}`.trim();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sharedDenyReason(routes) {
|
|
223
|
+
return `[Sprag model routing] This spawn matches the approved shared ${routes[0].category} policy but names no model. ${SHARED_APPROVAL} `
|
|
224
|
+
// A full-history fork inherits the parent model and refuses overrides.
|
|
225
|
+
+ 'Spawn again with fork_turns "none" and the model and reasoning_effort of the tier you choose, keeping the route line the routing hint gave for that tier:\n'
|
|
226
|
+
+ routes.map(sharedTargetText).join('\n')
|
|
227
|
+
+ `\n${SHARED_TIER_GUIDANCE} To keep the task on your own model, pass that model explicitly. If a model cannot be set, do the task on the main agent instead of spawning again.`;
|
|
228
|
+
}
|
|
229
|
+
|
|
114
230
|
/**
|
|
115
231
|
* A prompt-time routing suggestion for Codex 0.159.2, where `spawn_agent`
|
|
116
232
|
* never reaches PreToolUse (see codexDelegationTool below). Gated on parent
|
|
117
233
|
* context size: a small parent's own coordination overhead outweighs the
|
|
118
234
|
* savings from a delegated child (measured 2026-09-30, see docs/CODEX.md).
|
|
235
|
+
*
|
|
236
|
+
* A shared policy offers both mapped tiers and records one route per tier;
|
|
237
|
+
* SubagentStart binds the child to the route whose model it runs, and the
|
|
238
|
+
* next prompt closes the tier that was not chosen.
|
|
119
239
|
*/
|
|
120
240
|
export function codexRouteHint(payload, opts = {}) {
|
|
241
|
+
// The hook passes its loaded config. Without one there are no shared
|
|
242
|
+
// mappings and the default gate applies, so a caller never routes from the
|
|
243
|
+
// user's real config by accident.
|
|
244
|
+
const cfg = opts.cfg ?? {};
|
|
121
245
|
const provider = payload?.model_provider || codexProviderName();
|
|
122
|
-
const
|
|
246
|
+
const routes = matchingRoutes(payload?.prompt, { cfg, provider, ...opts, model: payload?.model,
|
|
247
|
+
root: opts.root || payload?.cwd, start: payload?.cwd });
|
|
248
|
+
const rule = routes[0];
|
|
123
249
|
if (!rule) return null;
|
|
124
|
-
const minContext = opts.minContext ??
|
|
250
|
+
const minContext = opts.minContext ?? codexDelegateMinContext(cfg);
|
|
125
251
|
const ctx = opts.contextTokens ?? codexContextTokens(payload?.transcript_path);
|
|
126
252
|
if (ctx < minContext) return null;
|
|
127
|
-
const id = newRouteId();
|
|
128
253
|
const record = opts.record ?? recordCodexDelegation;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
254
|
+
// Tiers offered by one hint are alternatives. SubagentStart binds by model
|
|
255
|
+
// alone, so it must know which records compete for the same child.
|
|
256
|
+
const offerId = routes.length > 1 ? newRouteId() : null;
|
|
257
|
+
const recordRoute = (route) => {
|
|
258
|
+
const id = newRouteId();
|
|
259
|
+
const shared = route.source === 'shared';
|
|
260
|
+
try {
|
|
261
|
+
record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
|
|
262
|
+
source: shared ? 'shared' : 'rule', category: route.category, scope: route.scope, from: payload?.model, to: route.model,
|
|
263
|
+
provider: route.provider || provider, effort: route.effort ?? null, contextTokens: ctx, targetRoot: route.targetRoot ?? null,
|
|
264
|
+
...(shared ? { tier: route.tier, sharedSignature: route.sharedSignature, ...(offerId ? { offerId } : {}), ...(route.auto ? { mapping: 'auto' } : {}) }
|
|
265
|
+
: { ruleCreatedAt: route.createdAt ?? null }) }, { dir: opts.dir });
|
|
266
|
+
} catch { /* Accounting is optional; the hint still applies. */ }
|
|
267
|
+
return id;
|
|
268
|
+
};
|
|
269
|
+
if (rule.source === 'shared') {
|
|
270
|
+
const targets = routes.map((route) => `${sharedTargetText(route)} Route line: <!-- sprag:codex:route id=${recordRoute(route)} -->`);
|
|
271
|
+
return `[Sprag model routing] This request matches the approved shared ${rule.category} policy. ${SHARED_APPROVAL} ${SHARED_TIER_GUIDANCE}\n`
|
|
272
|
+
+ `${targets.join('\n')}\n`
|
|
273
|
+
+ 'Spawn at most one sub-agent with the selected model, reasoning_effort when specified, and fork_turns "none"; a full-history fork ignores the model. '
|
|
274
|
+
+ 'Give it a self-contained task message with the selected cap that ends with that tier\'s route line only. '
|
|
275
|
+
+ WAIT_TEXT;
|
|
276
|
+
}
|
|
277
|
+
const id = recordRoute(rule);
|
|
135
278
|
return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
|
|
136
279
|
+ `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
|
|
137
280
|
+ `Give it a self-contained task message that ends with the line <!-- sprag:codex:route id=${id} -->. `
|
|
138
|
-
+
|
|
139
|
-
+ 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
|
|
281
|
+
+ WAIT_TEXT;
|
|
140
282
|
}
|
|
141
283
|
|
|
142
284
|
function customRole(role, root, home) {
|
|
@@ -177,7 +319,15 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
|
|
|
177
319
|
let rules = given;
|
|
178
320
|
if (!rules) { try { rules = loadCodexModelRules({ dir }); } catch { rules = []; } }
|
|
179
321
|
const provider = payload.model_provider || codexProviderName({ home });
|
|
180
|
-
const
|
|
322
|
+
const routes = explicit ? [] : matchingRoutes(input.message, { model: payload.model, provider, root, start: payload.cwd, rules, cfg, dir, home });
|
|
323
|
+
// Only the caller can choose a shared policy's tier. A shared match without a
|
|
324
|
+
// model goes back to the caller with the mapped choices instead of a guessed
|
|
325
|
+
// downgrade, or a default target that ignores the tier.
|
|
326
|
+
if (routes[0]?.source === 'shared') {
|
|
327
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
|
|
328
|
+
permissionDecisionReason: sharedDenyReason(routes) } };
|
|
329
|
+
}
|
|
330
|
+
const rule = routes[0] ?? null;
|
|
181
331
|
const target = rule || (!explicit && cfg.codex?.delegateTarget);
|
|
182
332
|
const updatedInput = { ...input };
|
|
183
333
|
let routeLine = '';
|
package/src/codex-doctor.js
CHANGED
|
@@ -17,13 +17,13 @@ export const CODEX_CAPABILITIES = [
|
|
|
17
17
|
{ feature: 'korean', support: 'supported', command: 'sprag korean status --agent codex', note: 'Session guidance and recognized write-target lint; shared preference.' },
|
|
18
18
|
{ feature: 'cohesion', support: 'supported', command: 'sprag cohesion status --agent codex', note: 'Shared English guidance preference.' },
|
|
19
19
|
{ feature: 'doc2md', support: 'supported', command: 'sprag doc2md status --agent codex', note: 'Shared converter, prompt paths, supported file reads, and recognized document/cache write guards; not arbitrary shell extraction.' },
|
|
20
|
-
{ feature: 'delegate', support: 'supported', command: 'sprag delegate on --agent codex', note: '
|
|
20
|
+
{ feature: 'delegate', support: 'supported', command: 'sprag delegate on --agent codex', note: 'Native routing and shared delegation policies; tier models resolve from priced models already run in Codex, or from explicit mappings. Explicit models/custom roles preserved; bounds are guidance, not enforced limits.' },
|
|
21
21
|
{ feature: 'routing-saved', support: 'partial', command: 'sprag route-scan savings --agent codex', note: 'Sprag-routed child usage with matching LiteLLM prices; counterfactual estimate, not billed savings. Unpriced runs stay unknown.' },
|
|
22
22
|
{ feature: 'cache-timer', support: 'partial', command: 'sprag cache status --refresh --agent codex', note: 'Documented minimum retention after recorded cache usage; LiteLLM deployments resolved through model/info. Unknown routes show age; exact expiry is not reported.' },
|
|
23
23
|
{ feature: 'mode', support: 'partial', command: 'sprag mode --agent codex', note: 'Labels, color, and timer apply to panel/statusline; Claude TTL buckets do not.' },
|
|
24
24
|
{ feature: 'upgrade', support: 'manual', command: 'sprag upgrade --print --agent codex', note: 'Skip the Claude npm postinstall; reinstall Codex hooks after upgrade.' },
|
|
25
25
|
{ feature: 'route-scan', support: 'supported', command: 'sprag route-scan --agent codex', note: 'Recurring simple turns grouped by project, provider, and parent model; explicit approval before routing.' },
|
|
26
|
-
{ feature: 'seed', support: 'supported', command: 'sprag seed --agent codex', note: '
|
|
26
|
+
{ feature: 'seed', support: 'supported', command: 'sprag seed --agent codex', note: 'Shared delegation policies plus agent-compatible ratchet presets. Explicit scope; tier models resolve automatically or by mapping. No Claude installation needed.' },
|
|
27
27
|
{ feature: 'transcript ratchet', support: 'supported', command: 'sprag --statusline --agent codex', note: 'Recent repeated tool failures become ratchet? candidates; no automatic rule registration.' },
|
|
28
28
|
{ feature: 'compact-window', support: 'native', note: 'Codex uses model_auto_compact_token_limit; no Claude autoCompactWindow heuristic.' },
|
|
29
29
|
{ feature: 'cost / exact cache expiry', support: 'unavailable', note: 'Not measured in rollout logs; never estimated with Anthropic prices.' },
|
package/src/codex-hooks.js
CHANGED
|
@@ -212,8 +212,7 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
|
|
|
212
212
|
try { closeStaleCodexRoutes(payload); } catch (e) { debug('codex:close-routes', e); }
|
|
213
213
|
try { if (unsettledCodexRoutes() > 0) refreshLedger(); } catch (e) { debug('codex:ledger-refresh', e); }
|
|
214
214
|
try {
|
|
215
|
-
parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' })
|
|
216
|
-
minContext: Number.isFinite(cfg?.codex?.delegateMinContext) ? cfg.codex.delegateMinContext : undefined }));
|
|
215
|
+
parts.push(codexRouteHint(payload, { cfg, root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) }));
|
|
217
216
|
} catch (e) { debug('codex:route-hint', e); }
|
|
218
217
|
}
|
|
219
218
|
if (cfg?.codex?.brief !== false) {
|
package/src/codex-ledger.js
CHANGED
|
@@ -88,20 +88,24 @@ export function readCodexBindings({ dir = userDataDir() } = {}) {
|
|
|
88
88
|
return out;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/**
|
|
92
|
-
function
|
|
91
|
+
/** When a later prompt closed each route no child had claimed. Kept in the binds file as `{ id, closed: true, at }` lines. */
|
|
92
|
+
function closedCodexRouteTimes({ dir = userDataDir() } = {}) {
|
|
93
93
|
let text;
|
|
94
|
-
try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return new
|
|
95
|
-
const out = new
|
|
94
|
+
try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return new Map(); }
|
|
95
|
+
const out = new Map();
|
|
96
96
|
for (const line of text.split('\n')) {
|
|
97
97
|
try {
|
|
98
98
|
const b = JSON.parse(line);
|
|
99
|
-
if (b?.closed === true && /^[0-9a-f]{16}$/.test(b.id)) out.
|
|
99
|
+
if (b?.closed === true && /^[0-9a-f]{16}$/.test(b.id) && !out.has(b.id)) out.set(b.id, Number.isFinite(b.at) ? b.at : -Infinity);
|
|
100
100
|
} catch { /* Partial trailing line. */ }
|
|
101
101
|
}
|
|
102
102
|
return out;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function closedCodexRouteIds(opts) {
|
|
106
|
+
return new Set(closedCodexRouteTimes(opts).keys());
|
|
107
|
+
}
|
|
108
|
+
|
|
105
109
|
/**
|
|
106
110
|
* Close the routes this parent was hinted in an earlier turn and never spawned.
|
|
107
111
|
*
|
|
@@ -142,6 +146,9 @@ export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now
|
|
|
142
146
|
if (!pending.length) return null;
|
|
143
147
|
// Oldest first: spawns are recorded in order, so the earliest unbound record belongs to the earliest child.
|
|
144
148
|
const chosen = pending.reduce((a, b) => (b.at < a.at ? b : a));
|
|
149
|
+
// Tiers one hint offered together can map to the same model; the model then
|
|
150
|
+
// cannot say which tier this child is. Leave it to the child's route marker.
|
|
151
|
+
if (chosen.offerId && pending.some((r) => r !== chosen && r.offerId === chosen.offerId)) return null;
|
|
145
152
|
recordCodexBinding({ id: chosen.id, childSessionId: payload.agent_id, at: now }, { dir });
|
|
146
153
|
return chosen.id;
|
|
147
154
|
}
|
|
@@ -220,6 +227,7 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
220
227
|
if (!pending.length) return ledger;
|
|
221
228
|
const byId = new Map(pending.map((r) => [r.id, r]));
|
|
222
229
|
const bindMap = new Map(readCodexBindings({ dir }).filter((b) => byId.has(b.id)).map((b) => [b.childSessionId, b.id]));
|
|
230
|
+
const closedAt = closedCodexRouteTimes({ dir });
|
|
223
231
|
const oldest = Math.min(...pending.map((r) => r.at));
|
|
224
232
|
const files = await discoverCodexSessionFiles({ home, days: Math.max(1, (now - oldest) / 86400000 + 1) });
|
|
225
233
|
let changed = false;
|
|
@@ -232,7 +240,14 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
232
240
|
// The id must come from a spawn this parent made, after the record was written.
|
|
233
241
|
if (!route || (route.parentSessionId && run.parentThreadId && route.parentSessionId !== run.parentThreadId)) continue;
|
|
234
242
|
if (route.provider && route.provider !== run.provider) continue;
|
|
243
|
+
// A shared policy names its model only through the caller's choice, so
|
|
244
|
+
// credit it only for a child of that parent that ran the mapped model.
|
|
245
|
+
if (route.source === 'shared' && (run.model !== route.to || !route.parentSessionId ||
|
|
246
|
+
run.parentThreadId !== route.parentSessionId)) continue;
|
|
235
247
|
if (Number.isFinite(run.startedAt) && run.startedAt < route.at - 5000) continue;
|
|
248
|
+
// A closed route ended with its hinted turn. Only a child that started
|
|
249
|
+
// before the close can be its run; a later marker is a copy, not a spawn.
|
|
250
|
+
if (closedAt.has(route.id) && !(Number.isFinite(run.startedAt) && run.startedAt < closedAt.get(route.id))) continue;
|
|
236
251
|
if (seen.has(route.id)) continue;
|
|
237
252
|
seen.add(route.id);
|
|
238
253
|
const to = run.model || route.to;
|
|
@@ -249,6 +264,7 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
249
264
|
const usd = actual === null || counterfactual === null ? null : roundUsd(run.aborted ? -actual : counterfactual - actual);
|
|
250
265
|
const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
|
|
251
266
|
childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
|
|
267
|
+
...(route.source === 'shared' ? { tier: route.tier, sharedSignature: route.sharedSignature } : {}),
|
|
252
268
|
from: route.from, to, provider,
|
|
253
269
|
...(route.targetRoot !== undefined ? { targetRoot: route.targetRoot } : {}),
|
|
254
270
|
...(route.ruleCreatedAt !== undefined ? { ruleCreatedAt: route.ruleCreatedAt } : {}), tokens: run.tokens, calls: run.calls, toolErrors: run.toolErrors, usd, rates: usd === null ? null : rates,
|
package/src/codex-route-scan.js
CHANGED
|
@@ -8,7 +8,7 @@ import { findProjectRoot } from './harness.js';
|
|
|
8
8
|
import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
|
|
9
9
|
import { categorize, isSkippable, tierOf, calibrateThresholds, MIN_RECURRENCE,
|
|
10
10
|
RESCAN_MIN_INTERVAL_MS, RESCAN_BIG_DELTA_BYTES, RESCAN_MAX_AGE_MS } from './route-scan.js';
|
|
11
|
-
import { codexPromptCategory, loadCodexModelRules } from './codex-delegation.js';
|
|
11
|
+
import { codexPromptCategory, loadCodexModelRules, sharedCodexModelRules, codexSharedTargets } from './codex-delegation.js';
|
|
12
12
|
import { codexRunCost, codexPriceBook, isDirectOpenAI, readCodexPrices } from './codex-cache-policy.js';
|
|
13
13
|
import { OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
|
|
14
14
|
import { refreshCodexLedger } from './codex-ledger.js';
|
|
@@ -149,15 +149,20 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
|
|
|
149
149
|
}
|
|
150
150
|
let rules = [];
|
|
151
151
|
try { rules = loadCodexModelRules({ dir }); } catch { /* An unreadable rule file only means candidates may repeat. */ }
|
|
152
|
-
const covered = (g) =>
|
|
153
|
-
(
|
|
154
|
-
|
|
152
|
+
const covered = (g) => {
|
|
153
|
+
if (rules.some((r) => r.category === g.category && r.from === g.from &&
|
|
154
|
+
(!r.provider || r.provider === g.provider) &&
|
|
155
|
+
(r.scope === 'global' || r.targetRoot === g.projectRoot))) return true;
|
|
156
|
+
const shared = sharedCodexModelRules({ cfg, dir, root: g.projectRoot, model: g.from, provider: g.provider });
|
|
157
|
+
return ['T1', 'T2'].every((tier) => !g.tiers[tier] || shared.some((r) => r.category === g.category && r.tier === tier));
|
|
158
|
+
};
|
|
155
159
|
const known = new Set([...observed, ...rules.filter((r) => r.provider).map((r) => `${r.provider}|${r.model}`)]);
|
|
156
160
|
const target = cfg?.codex?.delegateTarget?.model;
|
|
157
161
|
if (target) {
|
|
158
162
|
if (prices?.provider) known.add(`${prices.provider}|${target}`);
|
|
159
163
|
if (isDirectOpenAI('openai', { home })) known.add(`openai|${target}`);
|
|
160
164
|
}
|
|
165
|
+
for (const target of codexSharedTargets(cfg)) known.add(`${target.provider}|${target.model}`);
|
|
161
166
|
const prev = readCodexRouteScan({ dir });
|
|
162
167
|
const resolved = new Set(prev?.resolved || []);
|
|
163
168
|
const candidates = [...groups.values()]
|
package/src/commands/brief.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function run({ hasFlag }) {
|
|
|
49
49
|
// differ. Claude Code sends `cwd` for that reason and the PreToolUse hook
|
|
50
50
|
// already reads it (src/delegation-guard.js). A payload without the field
|
|
51
51
|
// leaves root undefined, which is the behaviour this had before.
|
|
52
|
-
const match = routeMatch(ctx.prompt, { sessionRank });
|
|
52
|
+
const match = routeMatch(ctx.prompt, { sessionRank, root: ctx.cwd });
|
|
53
53
|
const hint = routeHint(ctx.prompt, { sessionRank, root: ctx.cwd, match });
|
|
54
54
|
if (hint) parts.push(hint);
|
|
55
55
|
// The delegation guard repeats these caps to the subagent, so the limit it
|
|
@@ -1,20 +1,108 @@
|
|
|
1
1
|
import { loadConfig, saveConfig } from '../config.js';
|
|
2
2
|
import { configureCodexHooks } from '../codex-installer.js';
|
|
3
|
-
import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule
|
|
3
|
+
import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule,
|
|
4
|
+
validateCodexSharedTarget, codexSharedTargets, codexDelegateMinContext, DEFAULT_DELEGATE_MIN_CONTEXT,
|
|
5
|
+
autoCodexSharedTargets } from '../codex-delegation.js';
|
|
4
6
|
import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
|
|
5
7
|
import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
|
|
6
8
|
import { codexRuleHealth, codexRulesInReview } from '../codex-rule-health.js';
|
|
7
9
|
import { signedUsd } from '../money.js';
|
|
8
|
-
import { codexProviderName } from '../agent.js';
|
|
10
|
+
import { codexProviderName, codexModelName } from '../agent.js';
|
|
11
|
+
import { loadSharedModelRules, sharedModelRulesForProject, SHARED_RULE_TIERS } from '../shared-model-rules.js';
|
|
12
|
+
|
|
13
|
+
// Starts from the working directory, as the hooks do, so a project policy
|
|
14
|
+
// stored under either agent's project root is counted.
|
|
15
|
+
function printShared(cfg, { details = false } = {}) {
|
|
16
|
+
const policies = sharedModelRulesForProject(loadSharedModelRules(), process.cwd());
|
|
17
|
+
const targets = codexSharedTargets(cfg);
|
|
18
|
+
const provider = codexProviderName();
|
|
19
|
+
const parent = codexModelName();
|
|
20
|
+
console.log(`Shared delegation policies: ${policies.length} in this project (${cfg.codex?.sharedRules?.enabled === false ? 'off' : 'on'}); Codex tier mappings: ${targets.length}`);
|
|
21
|
+
if (!policies.length) console.log('First use: sprag seed --agent codex. Approve policies with seed accept <id>|all --global|--project; no Claude installation or history is required.');
|
|
22
|
+
if (details) {
|
|
23
|
+
for (const r of policies) console.log(`shared ${r.tier} ${r.category} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}`);
|
|
24
|
+
for (const t of targets) console.log(`${t.provider} | ${t.from} | ${t.tier} -> ${t.model}${t.effort ? ` (${t.effort})` : ''}`);
|
|
25
|
+
}
|
|
26
|
+
// A mapping routes only sessions on the parent model it names, so a tier
|
|
27
|
+
// mapped for another model still leaves the configured model unrouted. A tier
|
|
28
|
+
// without one resolves automatically, the way Claude Code resolves haiku/sonnet.
|
|
29
|
+
const own = (tier) => targets.find((t) => t.provider === provider && t.tier === tier && (!parent || t.from === parent));
|
|
30
|
+
const autoOn = cfg.codex?.sharedRules?.auto !== false;
|
|
31
|
+
const auto = autoOn && parent ? autoCodexSharedTargets({ model: parent, provider }) : { targets: [], reason: autoOn ? 'Codex config names no default model' : 'off' };
|
|
32
|
+
const tiers = [...new Set(policies.map((r) => r.tier))];
|
|
33
|
+
for (const tier of tiers) {
|
|
34
|
+
const t = own(tier) || auto.targets.find((a) => a.tier === tier);
|
|
35
|
+
if (t) console.log(`${tier} -> ${t.model}${t.effort ? ` (${t.effort})` : ''}${t.auto ? ' (auto: cheaper priced model already run in Codex)' : ''}`);
|
|
36
|
+
}
|
|
37
|
+
const missing = tiers.filter((tier) => !own(tier) && !auto.targets.some((a) => a.tier === tier));
|
|
38
|
+
if (missing.length) {
|
|
39
|
+
console.log(`Unmapped tiers for ${parent || 'any parent model'} on ${provider || 'unknown provider'}: ${missing.join(', ')}`
|
|
40
|
+
+ `${autoOn && auto.reason ? ` (auto: ${auto.reason})` : ''}. `
|
|
41
|
+
+ `Set: sprag delegate shared map <T1|T2> --from ${parent || '<parent-model>'} --model <target-model> --agent codex`);
|
|
42
|
+
}
|
|
43
|
+
console.log(`Automatic tier models: ${autoOn ? 'on' : 'off'} (turn ${autoOn ? 'off' : 'on'}: sprag delegate shared auto ${autoOn ? 'off' : 'on'} --agent codex)`);
|
|
44
|
+
if (!parent && targets.length) console.log('Codex config names no default model; each mapping routes only sessions on its --from model.');
|
|
45
|
+
const minimum = codexDelegateMinContext(cfg);
|
|
46
|
+
console.log(`Shared prompt guidance starts at ${minimum} parent input tokens${cfg.codex?.delegateMinContext === minimum ? '' : ' (default)'}. `
|
|
47
|
+
+ 'Change: sprag delegate shared min-context <tokens|default> --agent codex');
|
|
48
|
+
console.log('Shared policies keep their original scope and bounds. Models require an exact parent/provider mapping; agent-specific roles and usage totals are not imported.');
|
|
49
|
+
}
|
|
9
50
|
|
|
10
51
|
export function run({ args, getArg, hasFlag, root }) {
|
|
11
52
|
const sub = args[1] || 'status';
|
|
12
53
|
if (args.some((arg) => arg.startsWith('--hook'))) throw new Error('Use codex-hook for Codex hook payloads.');
|
|
13
|
-
for (const flag of ['--model', '--effort', '--from']) {
|
|
54
|
+
for (const flag of ['--model', '--effort', '--from', '--provider']) {
|
|
14
55
|
if (args.some((arg) => arg === flag || arg.startsWith(`${flag}=`)) && (!getArg(flag) || getArg(flag).startsWith('-'))) {
|
|
15
56
|
throw new Error(`${flag} requires a value.`);
|
|
16
57
|
}
|
|
17
58
|
}
|
|
59
|
+
const cfg = loadConfig();
|
|
60
|
+
if (sub === 'shared') {
|
|
61
|
+
const action = args[2] || 'status';
|
|
62
|
+
if (!['status', 'on', 'off', 'map', 'unmap', 'min-context', 'auto'].includes(action)) {
|
|
63
|
+
throw new Error('Usage: delegate shared status|on|off|map <T1|T2>|unmap <T1|T2>|min-context <tokens|default>|auto on|off --agent codex');
|
|
64
|
+
}
|
|
65
|
+
if (hasFlag('--global') || hasFlag('--project')) throw new Error('Shared policies retain their approved scope; mappings do not change it.');
|
|
66
|
+
const provided = (flag) => args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
67
|
+
if (!['map', 'unmap'].includes(action) && ['--model', '--effort', '--from', '--provider'].some(provided)) {
|
|
68
|
+
throw new Error('Model options require delegate shared map or unmap.');
|
|
69
|
+
}
|
|
70
|
+
const targets = codexSharedTargets(cfg);
|
|
71
|
+
if (action === 'map' || action === 'unmap') {
|
|
72
|
+
const tier = args[3];
|
|
73
|
+
const from = getArg('--from');
|
|
74
|
+
const provider = getArg('--provider') ?? codexProviderName();
|
|
75
|
+
if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1 or T2.');
|
|
76
|
+
const same = (t) => t.tier === tier && t.from === from && t.provider === provider;
|
|
77
|
+
if (action === 'map') {
|
|
78
|
+
const target = validateCodexSharedTarget({ tier, from, provider, model: getArg('--model'), effort: getArg('--effort') });
|
|
79
|
+
cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, targets: [...targets.filter((t) => !same(t)), target] } };
|
|
80
|
+
} else {
|
|
81
|
+
if (provided('--model') || provided('--effort')) throw new Error('unmap accepts only tier, --from and --provider.');
|
|
82
|
+
if (!targets.some(same)) throw new Error('No matching shared tier mapping; specify --from and the correct provider.');
|
|
83
|
+
cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, targets: targets.filter((t) => !same(t)) } };
|
|
84
|
+
}
|
|
85
|
+
} else if (action === 'auto') {
|
|
86
|
+
if (!['on', 'off'].includes(args[3])) throw new Error('Usage: delegate shared auto on|off --agent codex');
|
|
87
|
+
cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, auto: args[3] === 'on' } };
|
|
88
|
+
} else if (action === 'min-context') {
|
|
89
|
+
const value = args[3];
|
|
90
|
+
if (value === 'default') {
|
|
91
|
+
cfg.codex = { ...cfg.codex };
|
|
92
|
+
delete cfg.codex.delegateMinContext;
|
|
93
|
+
} else if (/^\d+$/.test(value ?? '') && Number.isSafeInteger(Number(value))) {
|
|
94
|
+
cfg.codex = { ...cfg.codex, delegateMinContext: Number(value) };
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error(`min-context takes a whole number of parent input tokens, or default (${DEFAULT_DELEGATE_MIN_CONTEXT}).`);
|
|
97
|
+
}
|
|
98
|
+
} else if (action !== 'status') {
|
|
99
|
+
cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, enabled: action === 'on' } };
|
|
100
|
+
}
|
|
101
|
+
if (action !== 'status') saveConfig(cfg);
|
|
102
|
+
printShared(cfg, { details: true });
|
|
103
|
+
console.log(`Codex delegation is ${cfg.codex?.delegate === true ? 'on' : 'off; enable with sprag delegate on --agent codex'}. Target availability and lower cost must be verified with your provider.`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
18
106
|
if (sub === 'rules') {
|
|
19
107
|
if (args[2] === 'add') {
|
|
20
108
|
if (hasFlag('--global') === hasFlag('--project')) throw new Error('Choose --global or --project for the rule.');
|
|
@@ -31,7 +119,7 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
31
119
|
} else if (args[2] === 'rm') removeCodexModelRule(Number(args[3]));
|
|
32
120
|
else if (args[2]) throw new Error('Usage: delegate rules [add <category|R<N>> --from <model> --model <model> --global|--project | rm <N>]');
|
|
33
121
|
const rules = loadCodexModelRules();
|
|
34
|
-
if (!rules.length) console.log('No Codex model rules.
|
|
122
|
+
if (!rules.length) console.log('No Codex-only model rules.');
|
|
35
123
|
let events = [];
|
|
36
124
|
try { events = Object.values(loadCodexLedger().events); } catch { /* No ledger yet: rules list without outcomes. */ }
|
|
37
125
|
const inReview = new Set(codexRulesInReview({ rules, events, root: null }).map((x) => x.index));
|
|
@@ -41,10 +129,10 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
41
129
|
const warn = inReview.has(i + 1) ? ` | rule-health: ${h.errs}/${h.runs} runs failed, narrow the condition or remove (delegate rules rm ${i + 1} --agent codex)` : '';
|
|
42
130
|
console.log(`#${i + 1} ${r.category} | ${r.from} -> ${r.model} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}${outcome}${warn}`);
|
|
43
131
|
});
|
|
132
|
+
printShared(cfg, { details: true });
|
|
44
133
|
return;
|
|
45
134
|
}
|
|
46
|
-
if (!['on', 'off', 'status', 'model'].includes(sub)) throw new Error('Usage: delegate on|off|status|model <model>|rules --agent codex');
|
|
47
|
-
const cfg = loadConfig();
|
|
135
|
+
if (!['on', 'off', 'status', 'model'].includes(sub)) throw new Error('Usage: delegate on|off|status|model <model>|rules|shared --agent codex');
|
|
48
136
|
let target;
|
|
49
137
|
if (sub === 'model') target = args[2] === 'off' ? null : validateCodexTarget(args[2], getArg('--effort'));
|
|
50
138
|
else if (getArg('--model') !== undefined) target = validateCodexTarget(getArg('--model'), getArg('--effort'));
|
|
@@ -63,6 +151,7 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
63
151
|
let ruleCount;
|
|
64
152
|
try { ruleCount = String(loadCodexModelRules().length); } catch (e) { ruleCount = `unreadable (${e.message})`; }
|
|
65
153
|
console.log(`Codex rules: ${ruleCount}`);
|
|
154
|
+
printShared(cfg);
|
|
66
155
|
console.log('Explicit spawn models and custom roles are preserved. Targets must be available from your Codex provider.');
|
|
67
156
|
const saved = codexRoutingSavedTotals();
|
|
68
157
|
console.log(saved.priced
|
package/src/commands/codex.js
CHANGED
|
@@ -26,11 +26,17 @@ Usage: sprag <command> --agent codex [options]
|
|
|
26
26
|
saved [reset|undo] Show, reset or restore the saved-money counters
|
|
27
27
|
delegate on|off|status Native model routing and SubagentStart guidance
|
|
28
28
|
delegate model <model> Default target for spawns without an explicit model
|
|
29
|
-
delegate rules Codex
|
|
29
|
+
delegate rules Codex rules and shared delegation policies
|
|
30
|
+
delegate shared on|off|status Reuse approved policies; keep original scopes
|
|
31
|
+
delegate shared map T1|T2 Map a tier: --from <parent> --model <target>
|
|
32
|
+
Optional --provider <id> and --effort <level>
|
|
33
|
+
delegate shared unmap T1|T2 Remove a mapping: --from <parent> [--provider <id>]
|
|
34
|
+
delegate shared min-context N Parent input tokens before shared guidance; default resets
|
|
35
|
+
delegate shared auto on|off Resolve unmapped tiers from priced models already run in Codex
|
|
30
36
|
route-scan [--refresh] Discover recurring simple turns in local logs
|
|
31
37
|
route-scan dismiss R<N> Dismiss a candidate without registering a rule
|
|
32
38
|
route-scan savings Attributed routing estimates; --refresh or --format json
|
|
33
|
-
seed [accept|skip|reset]
|
|
39
|
+
seed [accept|skip|reset] Shared delegation and Codex ratchet presets; explicit scope
|
|
34
40
|
cache [auto|off|<duration>] Display timer policy (e.g. 30m); never changes retention
|
|
35
41
|
cache status --refresh Resolve the session's gateway deployment and cache policy
|
|
36
42
|
harness init|uninit|check|promote|list|rm|prune
|
|
@@ -413,6 +419,16 @@ export async function run({ args, version }) {
|
|
|
413
419
|
console.log('After approval, start or resume Codex and run: sprag panel doctor --agent codex');
|
|
414
420
|
if (panel.mode === 'window') console.log('Some Codex versions defer SessionStart until the first prompt. For immediate opening on typing codex: sprag panel shell install --agent codex (optional zsh integration).');
|
|
415
421
|
console.log('Existing config.toml, Claude Code settings, and ratchet rules are preserved.');
|
|
422
|
+
try {
|
|
423
|
+
const { codexModelName, codexProviderName } = await import('../agent.js');
|
|
424
|
+
const { autoCodexSharedTargets } = await import('../codex-delegation.js');
|
|
425
|
+
const parent = codexModelName(), provider = codexProviderName();
|
|
426
|
+
const auto = parent ? autoCodexSharedTargets({ model: parent, provider }) : { targets: [], reason: 'Codex config names no default model' };
|
|
427
|
+
console.log(auto.targets.length
|
|
428
|
+
? `Delegation tiers for ${parent} on ${provider} (auto): ${auto.targets.map((t) => `${t.tier} -> ${t.model}`).join(', ')}. Change: sprag delegate shared status --agent codex`
|
|
429
|
+
: `Delegation tiers: not resolved yet (${auto.reason}). They resolve once a cheaper priced model has run in Codex, or map one: sprag delegate shared map T2 --from <parent> --model <target> --agent codex`);
|
|
430
|
+
} catch (e) { debug('codex:auto-tiers', e); }
|
|
431
|
+
console.log('First-use delegation: sprag seed --agent codex to approve policies, then sprag delegate on --agent codex. No Claude installation is required.');
|
|
416
432
|
console.log('Korean guidance is opt-in: sprag korean on --agent codex');
|
|
417
433
|
return;
|
|
418
434
|
}
|
package/src/model-rules.js
CHANGED
|
@@ -147,15 +147,17 @@ export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
|
|
|
147
147
|
// this, so an XDG_CONFIG_HOME override moves every state file together.
|
|
148
148
|
const stateDir = userDataDir;
|
|
149
149
|
|
|
150
|
-
export function modelRulesPath() {
|
|
151
|
-
return join(
|
|
150
|
+
export function modelRulesPath({ dir = stateDir() } = {}) {
|
|
151
|
+
return join(dir, 'model-rules.json');
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
export function loadModelRules() {
|
|
154
|
+
export function loadModelRules({ dir = stateDir(), strict = false } = {}) {
|
|
155
155
|
try {
|
|
156
|
-
const data = JSON.parse(readFileSync(modelRulesPath(), 'utf8'));
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
const data = JSON.parse(readFileSync(modelRulesPath({ dir }), 'utf8'));
|
|
157
|
+
if (!Array.isArray(data?.rules)) throw new Error('Invalid registry');
|
|
158
|
+
return data;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (strict && error.code !== 'ENOENT') throw new Error('Cannot parse model rules; file left unchanged.');
|
|
159
161
|
return { rules: [] };
|
|
160
162
|
}
|
|
161
163
|
}
|
|
@@ -189,8 +191,9 @@ export function saveModelRules(data) {
|
|
|
189
191
|
|
|
190
192
|
/** Add (or re-activate) a promoted rule; returns the stored entry. */
|
|
191
193
|
export function addModelRule(entry) {
|
|
192
|
-
const data = loadModelRules();
|
|
193
|
-
const existing = data.rules.find((r) => r.signature === entry.signature && r.scope === entry.scope
|
|
194
|
+
const data = loadModelRules({ strict: true });
|
|
195
|
+
const existing = data.rules.find((r) => r.signature === entry.signature && r.scope === entry.scope &&
|
|
196
|
+
(r.scope === 'global' || r.targetRoot === entry.targetRoot));
|
|
194
197
|
if (existing) {
|
|
195
198
|
Object.assign(existing, entry, { status: 'active' });
|
|
196
199
|
saveModelRules(data);
|
package/src/route-inject.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
import { categorizeScored, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
|
|
35
35
|
import { loadModelRules, budgetCapPhrase, ruleBudget } from './model-rules.js';
|
|
36
|
+
import { sharedProjectRoots, realProjectRoot, scopeRank } from './shared-model-rules.js';
|
|
36
37
|
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
37
38
|
import { userLanguage } from './config.js';
|
|
38
39
|
import { aliasForRole, resolveModelAlias } from './model-alias.js';
|
|
@@ -121,7 +122,7 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
|
|
|
121
122
|
* model filters nothing.
|
|
122
123
|
* @returns {{cat: object, t2: object|undefined, t1: object|undefined}|null}
|
|
123
124
|
*/
|
|
124
|
-
export function routeMatch(text, { rules, sessionRank = null } = {}) {
|
|
125
|
+
export function routeMatch(text, { rules, sessionRank = null, root } = {}) {
|
|
125
126
|
const t = String(text || '').trim();
|
|
126
127
|
if (t.length < MIN_LEN) return null;
|
|
127
128
|
// Judgement and irreversible work stay on the top tier. This check comes
|
|
@@ -148,7 +149,13 @@ export function routeMatch(text, { rules, sessionRank = null } = {}) {
|
|
|
148
149
|
if (cat.id === 'paste' && !looksPasted(t)) return null;
|
|
149
150
|
|
|
150
151
|
const all = rules || loadModelRules().rules || [];
|
|
151
|
-
|
|
152
|
+
// The registry is shared with Codex, so a project rule approved in one
|
|
153
|
+
// project must not route another. A session whose directory is unknown gets
|
|
154
|
+
// global rules only.
|
|
155
|
+
const roots = root ? sharedProjectRoots(root) : [];
|
|
156
|
+
const inScope = (r) => r.scope !== 'project' ||
|
|
157
|
+
(typeof r.targetRoot === 'string' && roots.includes(realProjectRoot(r.targetRoot)));
|
|
158
|
+
const matched = all.filter((r) => r && r.category === cat.id && inScope(r));
|
|
152
159
|
if (!matched.length) return null;
|
|
153
160
|
// A rule only saves anything when its target tier is cheaper than the model
|
|
154
161
|
// reading the hint. A T1 rule states "delegate to model: sonnet", which in a
|
|
@@ -161,8 +168,10 @@ export function routeMatch(text, { rules, sessionRank = null } = {}) {
|
|
|
161
168
|
: matched.filter((r) => worthDelegating(r.tier, sessionRank));
|
|
162
169
|
if (!usable.length) return null;
|
|
163
170
|
|
|
164
|
-
|
|
165
|
-
const
|
|
171
|
+
// The closest scope states the cap, whatever order the registry holds.
|
|
172
|
+
const nearest = [...usable].sort((a, b) => scopeRank(b) - scopeRank(a));
|
|
173
|
+
const t2 = nearest.find((r) => r.tier === 'T2');
|
|
174
|
+
const t1 = nearest.find((r) => r.tier === 'T1');
|
|
166
175
|
return t2 || t1 ? { cat, t2, t1 } : null;
|
|
167
176
|
}
|
|
168
177
|
|
|
@@ -186,7 +195,7 @@ export function matchCaps(match) {
|
|
|
186
195
|
* @returns {string|null} the line to inject, or null to stay quiet
|
|
187
196
|
*/
|
|
188
197
|
export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match } = {}) {
|
|
189
|
-
const found = match === undefined ? routeMatch(text, { rules, sessionRank }) : match;
|
|
198
|
+
const found = match === undefined ? routeMatch(text, { rules, sessionRank, root }) : match;
|
|
190
199
|
if (!found) return null;
|
|
191
200
|
const { cat, t2, t1 } = found;
|
|
192
201
|
const ko = lang === 'ko';
|
package/src/seed-rules.js
CHANGED
|
@@ -25,16 +25,16 @@
|
|
|
25
25
|
|
|
26
26
|
import { readFileSync } from 'node:fs';
|
|
27
27
|
import { CLI_NAME } from './cli-name.js';
|
|
28
|
-
import { join, dirname } from 'node:path';
|
|
28
|
+
import { join, dirname, resolve } from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
30
|
import { createHash } from 'node:crypto';
|
|
31
|
+
import { sharedModelRulesForProject, loadSharedModelRules, sharedProjectRoots, realProjectRoot } from './shared-model-rules.js';
|
|
31
32
|
import { userDataDir } from './paths.js';
|
|
32
33
|
import { userLanguage } from './config.js';
|
|
33
34
|
import { writeStateFile } from './state-file.js';
|
|
34
35
|
import {
|
|
35
36
|
addModelRule,
|
|
36
37
|
composeRuleText,
|
|
37
|
-
loadModelRules,
|
|
38
38
|
modelRuleBaseText,
|
|
39
39
|
syncAllFiles,
|
|
40
40
|
} from './model-rules.js';
|
|
@@ -102,22 +102,12 @@ const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/, '').
|
|
|
102
102
|
* Everything still worth asking about: presets the user has not answered and
|
|
103
103
|
* that are not already covered by a rule they have.
|
|
104
104
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* whose own route-scan already promoted that shape does not need ours, and
|
|
108
|
-
* offering it anyway would read as the tool failing to notice its own state.
|
|
105
|
+
* A shared policy covers its tier and category only within its approved scope.
|
|
106
|
+
* A decision in another project must not suppress this project's offer.
|
|
109
107
|
*/
|
|
110
108
|
export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(), agent = 'claude' } = {}) {
|
|
111
109
|
const { decided } = loadSeedState({ agent });
|
|
112
|
-
|
|
113
|
-
// Claude tier presets name models Codex does not run.
|
|
114
|
-
const registered = agent === 'codex' ? [] : (() => {
|
|
115
|
-
try {
|
|
116
|
-
return loadModelRules().rules;
|
|
117
|
-
} catch {
|
|
118
|
-
return [];
|
|
119
|
-
}
|
|
120
|
-
})();
|
|
110
|
+
const registered = sharedModelRulesForProject(loadSharedModelRules({ includeInactive: true }), root);
|
|
121
111
|
const haveModel = new Set(registered.map((r) => `${r.tier}|${r.category}`));
|
|
122
112
|
|
|
123
113
|
const haveText = new Set();
|
|
@@ -128,16 +118,23 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(),
|
|
|
128
118
|
}
|
|
129
119
|
|
|
130
120
|
const out = [];
|
|
131
|
-
|
|
132
|
-
|
|
121
|
+
const roots = sharedProjectRoots(root);
|
|
122
|
+
for (const p of modelPresets()) {
|
|
123
|
+
const decision = decided[p.id];
|
|
124
|
+
if (decision?.action === 'skipped') continue;
|
|
125
|
+
// An answer recorded before decisions kept targetRoot cannot name its
|
|
126
|
+
// project, so it stays decided everywhere, as it was when it was given.
|
|
127
|
+
if (decision?.action === 'accepted' && (decision.scope !== 'project' || !decision.targetRoot ||
|
|
128
|
+
roots.includes(realProjectRoot(decision.targetRoot)))) continue;
|
|
133
129
|
if (haveModel.has(`${p.tier}|${p.category}`)) continue;
|
|
134
130
|
out.push({
|
|
135
131
|
kind: 'model',
|
|
136
132
|
id: p.id,
|
|
137
133
|
tier: p.tier,
|
|
138
134
|
label: lang === 'ko' ? p.label : p.labelEn,
|
|
139
|
-
agent: p.agent,
|
|
140
|
-
ruleText:
|
|
135
|
+
agent: agent === 'codex' ? `Codex ${p.tier} mapping` : p.agent,
|
|
136
|
+
ruleText: agent === 'codex' ? codexSeedText(p, lang)
|
|
137
|
+
: composeRuleText(modelRuleBaseText(p, lang), { budget: p.budget }, lang),
|
|
141
138
|
preset: p,
|
|
142
139
|
});
|
|
143
140
|
}
|
|
@@ -151,6 +148,13 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(),
|
|
|
151
148
|
return out;
|
|
152
149
|
}
|
|
153
150
|
|
|
151
|
+
function codexSeedText(p, lang) {
|
|
152
|
+
const text = lang === 'ko'
|
|
153
|
+
? `"${p.label}" 유형의 ${p.tier === 'T2' ? '단순' : '중간 난도'} 작업은 ${p.tier}에 위임합니다. 실행 모델은 Codex가 자동으로 정하거나 매핑으로 지정합니다.`
|
|
154
|
+
: `${p.tier === 'T2' ? 'Simple' : 'Moderate'} "${p.labelEn}" tasks use ${p.tier}. Codex resolves the execution model automatically or from a mapping.`;
|
|
155
|
+
return composeRuleText(text, p, lang);
|
|
156
|
+
}
|
|
157
|
+
|
|
154
158
|
/** One pending seed by id, or null. Ids are matched exactly. */
|
|
155
159
|
export function findSeed(id, opts = {}) {
|
|
156
160
|
return pendingSeeds(opts).find((s) => s.id === id) || null;
|
|
@@ -222,12 +226,16 @@ export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = u
|
|
|
222
226
|
// Marks the rule as seeded rather than measured, so the rendered file does
|
|
223
227
|
// not report someone else's recurrence count as this user's evidence.
|
|
224
228
|
origin: 'preset',
|
|
229
|
+
approvedBy: agent,
|
|
225
230
|
promotedAt: today,
|
|
226
231
|
lastSeen: today,
|
|
227
232
|
});
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
233
|
+
// Codex-first installs need only the registry. Claude renders its view when
|
|
234
|
+
// it is installed or starts a session, without creating .claude here.
|
|
235
|
+
const written = agent === 'codex' ? [] : syncAllFiles();
|
|
236
|
+
recordDecision(id, 'accepted', { scope, targetRoot: scope === 'project' ? resolve(root) : null }, { agent });
|
|
237
|
+
return { id, kind: 'model', scope, paths: written,
|
|
238
|
+
rule: agent === 'codex' ? codexSeedText(p, lang) : composeRuleText(entry.rule, entry, lang) };
|
|
231
239
|
}
|
|
232
240
|
|
|
233
241
|
/**
|
|
@@ -277,13 +285,14 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
|
|
|
277
285
|
// Codex hook text stays English; the rule text itself follows the user's language.
|
|
278
286
|
const cmd = (s) => `${CLI_NAME} seed ${s} --agent codex`;
|
|
279
287
|
return [
|
|
280
|
-
`[Sprag seed] ${pending.length} recommended Codex ratchet rule(s) from the bundled presets are not registered yet.`,
|
|
288
|
+
`[Sprag seed] ${pending.length} recommended shared delegation and Codex ratchet rule(s) from the bundled presets are not registered yet.`,
|
|
281
289
|
'Ask in your first reply, after answering any request the user opened with. Offer these choices in this order:',
|
|
282
290
|
` 1. Register all, globally: ${cmd('accept all --global')}`,
|
|
283
291
|
` 2. Register all, this project only: ${cmd('accept all --project')}`,
|
|
284
292
|
' 3. Decide one at a time: ask about each rule below and run its command as soon as they answer',
|
|
285
293
|
` 4. Register none: ${cmd('skip all')} (never offered again)`,
|
|
286
294
|
'Always confirm the scope with the user before running an accept command.',
|
|
295
|
+
'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.',
|
|
287
296
|
...pending.map((s) => ` [${s.id}] ${s.ruleText}`),
|
|
288
297
|
].join('\n');
|
|
289
298
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { loadModelRules, ruleBudget } from './model-rules.js';
|
|
4
|
+
import { findProjectRoot } from './harness.js';
|
|
5
|
+
|
|
6
|
+
export const SHARED_RULE_TIERS = ['T2', 'T1'];
|
|
7
|
+
const CATEGORIES = new Set(['paste', 'translate', 'explore', 'read', 'check', 'run']);
|
|
8
|
+
|
|
9
|
+
/** A root as a real path, so a symlinked checkout or macOS /var names one directory once. */
|
|
10
|
+
export function realProjectRoot(path) {
|
|
11
|
+
try { return realpathSync(path); } catch { return resolve(path); }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every root either agent assigns to a working directory. Claude stops at
|
|
16
|
+
* CLAUDE.md and Codex at AGENTS.md, so a subdirectory holding only one of them
|
|
17
|
+
* gets a different root from each agent for the same project.
|
|
18
|
+
*/
|
|
19
|
+
export function sharedProjectRoots(start) {
|
|
20
|
+
const from = start || process.cwd();
|
|
21
|
+
return [...new Set(['claude', 'codex'].map((agent) => realProjectRoot(findProjectRoot(from, { agent }))))];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read a policy view of the existing registry, never its model roles or statistics. */
|
|
25
|
+
export function loadSharedModelRules({ includeInactive = false, ...opts } = {}) {
|
|
26
|
+
return loadModelRules(opts).rules.flatMap((r) => {
|
|
27
|
+
if (!r || !(r.status === 'active' || (includeInactive && ['off', 'review'].includes(r.status))) ||
|
|
28
|
+
!SHARED_RULE_TIERS.includes(r.tier) || !CATEGORIES.has(r.category) ||
|
|
29
|
+
!['global', 'project'].includes(r.scope)) return [];
|
|
30
|
+
if (r.scope === 'project' && (typeof r.targetRoot !== 'string' || !isAbsolute(r.targetRoot))) return [];
|
|
31
|
+
const targetRoot = r.scope === 'project' ? realProjectRoot(r.targetRoot) : null;
|
|
32
|
+
const budget = ruleBudget(r);
|
|
33
|
+
return [{ category: r.category, tier: r.tier, scope: r.scope, targetRoot,
|
|
34
|
+
sharedSignature: typeof r.signature === 'string' && r.signature.length <= 512 ? r.signature
|
|
35
|
+
: `${r.tier}|${r.category}|${targetRoot || '*'}`,
|
|
36
|
+
budget: {
|
|
37
|
+
calls: Number.isSafeInteger(budget?.calls) && budget.calls > 0 ? budget.calls : null,
|
|
38
|
+
out: Number.isSafeInteger(budget?.out) && budget.out > 0 ? budget.out : null,
|
|
39
|
+
} }];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How close a rule's scope is to the working directory: a project rule ranks
|
|
45
|
+
* by the depth of its root, so in a nested project the inner root wins, and
|
|
46
|
+
* any project rule outranks a global one. Registry order never decides.
|
|
47
|
+
*/
|
|
48
|
+
export function scopeRank(rule) {
|
|
49
|
+
return rule.scope === 'project' && typeof rule.targetRoot === 'string' ? realProjectRoot(rule.targetRoot).length : -1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One policy per category and tier, the closest scope first (see scopeRank).
|
|
54
|
+
* `start` is the working directory when known; a project policy applies when
|
|
55
|
+
* its root is the root either agent assigns to that directory.
|
|
56
|
+
*/
|
|
57
|
+
export function sharedModelRulesForProject(rules, start) {
|
|
58
|
+
const roots = new Set(sharedProjectRoots(start));
|
|
59
|
+
const chosen = new Map();
|
|
60
|
+
for (const rule of rules) {
|
|
61
|
+
if (rule.scope !== 'global' && !roots.has(rule.targetRoot)) continue;
|
|
62
|
+
const key = `${rule.category}|${rule.tier}`;
|
|
63
|
+
if (!chosen.has(key) || scopeRank(rule) > scopeRank(chosen.get(key))) chosen.set(key, rule);
|
|
64
|
+
}
|
|
65
|
+
return [...chosen.values()];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function sharedRuleBounds(rule) {
|
|
69
|
+
const caps = [];
|
|
70
|
+
if (rule.budget?.calls) caps.push(`${rule.budget.calls} tool calls`);
|
|
71
|
+
if (rule.budget?.out) caps.push(`${rule.budget.out} output tokens`);
|
|
72
|
+
return caps.length ? `Caller-approved cap: ${caps.join(' / ')}. Stop and report partial work before exceeding it.` : '';
|
|
73
|
+
}
|