sprag-cli 3.60.0 → 3.61.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 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
- 비용보다 커서, 문턱 아래에서는 안내를 붙이는 쪽이 오히려 손해였습니다. 아직 전용 CLI 명령은
126
- 없고, `sprag mode` 실행 시 "Stored config file"로 나오는 설정 파일(맥은
127
- `~/Library/Application Support/claude-token-saver/config.json`)에서 `codex.delegateMinContext`
128
- 값을 직접 고치면 됩니다. `PreToolUse` 재작성은 spawn을 훅으로 보내는 Codex 버전을 위해 남아
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,71 @@ Codex 0.159.2는 훅 하나가 넘긴 컨텍스트를 약 2,450토큰까지만
139
139
  못한 룰과 지침은 Sprag 데이터 디렉터리의 프로젝트별 파일에 쓰고, 컨텍스트 첫머리에서 그 파일을
140
140
  먼저 읽으라고 안내합니다. rollout의 `originator`로 `codex exec` 실행을 알아보며, 답할 사람이
141
141
  없으므로 seed 제안과 route 안내를 넣지 않습니다.
142
- `seed`는 Claude의 Haiku·Sonnet 프리셋을 가져오지 않으며,
143
- Codex의 수락·거절 기록도 Claude 기록과 분리합니다.
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 shared map T2 --from PARENT_MODEL --model SMALL_MODEL --effort high --agent codex
162
+ sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort medium --agent codex
163
+ sprag delegate on --agent codex
164
+ ```
165
+
166
+ 등록 전에 전역과 프로젝트 범위를 선택합니다. 프로젝트에만 적용하려면 `--global` 대신
167
+ `--project`를 지정하십시오. 위 모델명은 예시 자리표시자이므로, 공급자에서 사용할 수 있고
168
+ 부모보다 저렴한 실제 모델 ID로 바꿔야 합니다. 매핑을 저장했다는 사실만으로 사용 가능 여부나
169
+ 절감 효과를 확인한 것은 아닙니다. 공급자는 Codex 설정에서 읽으며 `--provider ID`로 지정할 수도 있습니다.
170
+
171
+ 매핑은 출발 모델·공급자·난도가 일치할 때만 적용하고, 한 번 지정하면 승인된 모든 작업 유형에서
172
+ 사용합니다. 매핑하지 않은 난도는 메인 모델이 처리합니다. Codex에서 승인할 때 `.claude` 파일은
173
+ 만들지 않으며, 나중에 Claude를 시작하면 같은 정책을 Claude용 모델 표기로 생성합니다.
174
+
175
+ 프롬프트 훅은 단순 작업에 T2, 여러 단계가 필요한 작업에 T1을 제시하고, 매핑된 난도마다 route 줄을
176
+ 따로 줍니다. 부모는 작업에 맞는 모델 하나를 선택하고 독립적인 작업 명세, 상한, `fork_turns "none"`과
177
+ 선택한 난도의 route 줄만 전달한 뒤 결과를 기다려 검증합니다. `fork_turns "none"`은 반드시 지정해야
178
+ 합니다. 전체 기록을 넘기는 포크는 부모 모델을 그대로 물려받아 모델 지정을 무시하기 때문입니다.
179
+ Codex는 사용자가 요청할 때만 스폰 모델을 지정하므로, 안내 문구는 사용자가 정책과 매핑을 승인했다는
180
+ 사실을 함께 밝힙니다.
181
+
182
+ 제시한 난도는 각각 프롬프트 route로 기록합니다. 이 경로는 `spawn_agent`가 `PreToolUse`를 거치지 않는
183
+ Codex 0.159.2에서 Codex 전용 규칙이 쓰는 경로와 같습니다. SubagentStart는 자식이 실행하는 모델과 같은
184
+ route에 자식을 연결하고, 부모에게 다음 프롬프트가 들어오면 고르지 않은 난도의 route를 닫습니다. 그래서
185
+ 제시만 하고 연결된 자식이 없는 난도는 실행으로 집계하지 않습니다. `PreToolUse`가 실행되는 버전에서는
186
+ 공통 정책에 해당하는 스폰이 모델을 지정하지 않으면 스폰 훅이 매핑된 선택지를 알려 주며 그 스폰을
187
+ 거부하고, 난도는 부모가 고릅니다. 스폰 훅은 난도를 임의로 정하거나 명시된 모델·사용자 정의 역할을
188
+ 덮어쓰지 않으며, 부모가 자기 모델을 명시하면 작업은 그 모델에 남습니다.
189
+
190
+ 공통 정책 안내도 앞에서 설명한 Codex 전용 규칙과 같은 컨텍스트 기준을 따릅니다. 이 기준은 모델의
191
+ 컨텍스트 창 크기를 바꾸지 않으며, 컨텍스트 기록이 없으면 안내하지 않습니다. 매핑은 지정한 출발 모델의 세션에만 적용되므로,
192
+ `status`는 Codex 설정의 기본 모델을 기준으로 빠진 매핑을 알려 줍니다.
193
+
194
+ ```sh
195
+ sprag delegate rules --agent codex
196
+ sprag delegate shared off --agent codex
197
+ sprag delegate shared on --agent codex
198
+ sprag delegate shared unmap T2 --from PARENT_MODEL --agent codex
199
+ sprag delegate shared min-context 30000 --agent codex
200
+ ```
201
+
202
+ `shared off`는 공통 정책과 매핑을 보존하며 Codex 전용 규칙에는 영향을 주지 않습니다.
203
+ `unmap`은 해당 Codex 매핑만 제거합니다. 공통 정책은 `sprag route-scan rules [rm N]`에서
204
+ 관리하며, 원본 삭제는 두 도구에 반영됩니다. Codex 실행 기록에는 원본 규칙 식별자와 난도를
205
+ 남기지만 사용량·절감액을 Claude 통계에 더하지 않습니다. 안내 생성만으로 실행을 집계하지 않으며,
206
+ route 표지 또는 SubagentStart 연결이 매핑된 모델로 실행한 실제 자식 세션과 일치해야 기록을 연결합니다.
144
207
 
145
208
  ## 래칫 규칙
146
209
 
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. There is no CLI flag for
180
- it yet; set it by editing `codex.delegateMinContext` (an integer) in the config
181
- file `sprag mode` prints under "Stored config file" (`~/Library/Application
182
- Support/claude-token-saver/config.json` on macOS, `$XDG_CONFIG_HOME/claude-token-saver/config.json`
183
- or `~/.config/claude-token-saver/config.json` on Linux).
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,76 @@ 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. Claude tier presets and history are not imported.
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 shared map T2 --from PARENT_MODEL --model SMALL_MODEL --effort high --agent codex
227
+ sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort medium --agent codex
228
+ sprag delegate on --agent codex
229
+ ```
230
+
231
+ Replace the model placeholders with available, lower-cost models from your
232
+ provider; mappings do not prove availability or savings. Each mapping applies
233
+ to one exact parent model, provider, and tier, across every approved category.
234
+ `--provider ID` overrides the provider read from Codex config. Without a mapping,
235
+ that tier stays on the main agent. No Haiku/Sonnet-to-GPT equivalence is assumed.
236
+ Codex seed acceptance writes the common registry without creating `.claude`;
237
+ Claude renders its own view when it is installed or starts later.
238
+
239
+ The prompt hook offers T2 for a simple bounded task and T1 for multi-step work,
240
+ and gives each mapped tier its own route line. The caller chooses at most one
241
+ model and passes a self-contained task, the cap, `fork_turns "none"`, and only
242
+ the chosen tier's route line. `fork_turns "none"` is required: a full-history
243
+ fork inherits the parent model and ignores a model override. The hint states
244
+ that the user approved the policy and mapping, because Codex sets a spawn model
245
+ only on the user's request. The parent waits for and verifies the result.
246
+
247
+ Each offered tier is recorded as a prompt route, the same path Codex-only rules
248
+ use on Codex 0.159.2, where `spawn_agent` bypasses `PreToolUse`. SubagentStart
249
+ binds the child to the route whose model it runs, and the parent's next prompt
250
+ closes the tier it did not choose, so an offered tier never counts as a run
251
+ without a matching child. Where `PreToolUse` does run, a spawn that matches a
252
+ shared policy without naming a model is denied with the mapped choices so the
253
+ caller picks the tier. The spawn hook does not guess difficulty or override an
254
+ explicit model or custom role, and naming the parent's own model keeps the task
255
+ on that model.
256
+
257
+ Shared hints use the same context gate as Codex-only rules, described above;
258
+ it does not change any model's context window, and missing context stays below
259
+ the gate. `status` checks mappings against the default model in Codex config, because a
260
+ mapping routes only sessions on the parent model it names.
261
+
262
+ ```sh
263
+ sprag delegate rules --agent codex
264
+ sprag delegate shared off --agent codex
265
+ sprag delegate shared on --agent codex
266
+ sprag delegate shared unmap T2 --from PARENT_MODEL --agent codex
267
+ sprag delegate shared min-context 30000 --agent codex
268
+ ```
269
+
270
+ `shared off` retains policies and mappings and leaves Codex-only rules intact.
271
+ `unmap` removes only that Codex mapping. Edit/remove common policies through
272
+ `sprag route-scan rules [rm N]`; removal affects both consumers. Codex routing
273
+ records retain the source policy signature and tier, but their token and savings
274
+ totals never enter Claude's statistics. A hint is not proof that a child ran:
275
+ accounting requires the route marker or a SubagentStart binding to a matching
276
+ child rollout that ran the mapped model.
208
277
 
209
278
  The routing ledger (`Routing Saved` below) prices a child's own tokens at the
210
279
  parent model's rate as the counterfactual; it does not charge or subtract the
@@ -279,8 +348,10 @@ the context opens with an instruction to read it. `codex exec` runs, recognized
279
348
  from the rollout's `originator`, get no seed offer or route notice, since no one
280
349
  can answer them.
281
350
 
282
- `seed` offers only compatible ratchet presets, not Claude model-tier presets.
283
- Accept and skip decisions are separate from Claude. Both seed acceptance and
351
+ `seed` offers common delegation policies without Claude model names, plus
352
+ compatible Codex ratchet presets. Ratchet and skip decisions remain agent-specific;
353
+ an approved common policy suppresses duplicate offers within its scope in either
354
+ agent. Both seed acceptance and
284
355
  route approval require the user's choice of global or project scope. A project
285
356
  route is saved for the project where it was observed. Enable delegation with
286
357
  `sprag delegate on --agent codex` and trust the hooks before expecting rules to run.
@@ -348,9 +419,10 @@ sprag route-scan savings --refresh --agent codex
348
419
  sprag route-scan savings --format json --agent codex
349
420
  ```
350
421
 
351
- The separate Codex ledger joins a route ID recorded by Sprag at spawn time to
352
- the child's own rollout. An explicit model choice or an unrelated cheaper
353
- subagent earns no credit. The estimate holds the child's measured tokens
422
+ The separate Codex ledger joins a spawn-time route ID, or a validated shared
423
+ prompt-hint marker on clients that skip the spawn hook, to the child's own
424
+ rollout. An unrelated explicit model choice or cheaper subagent earns no credit.
425
+ The estimate holds the child's measured tokens
354
426
  constant and prices them at the parent and child rates. Negative differences
355
427
  remain negative; this is not a comparison of actual billed totals.
356
428
 
@@ -525,7 +597,7 @@ Document counts are not a measurement of Codex billing or avoided tokens.
525
597
 
526
598
  ## Limits And Removal
527
599
 
528
- Claude's cap/TTL heuristics, Haiku/Sonnet tier presets, and the Claude
600
+ Claude's cap/TTL heuristics, Haiku/Sonnet model identifiers, and the Claude
529
601
  `compact-window` command do not apply to Codex. Codex provides its own
530
602
  `model_auto_compact_token_limit` setting; Sprag does not apply Claude's 1M
531
603
  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.60.0",
3
+ "version": "3.61.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 = [];
@@ -9,6 +9,8 @@ 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';
12
14
 
13
15
  export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
14
16
  const MARKER = '<!-- sprag:codex:delegation -->';
@@ -23,6 +25,32 @@ export function validateCodexTarget(model, effort) {
23
25
  return { model, ...(effort === undefined ? {} : { effort }) };
24
26
  }
25
27
 
28
+ export function validateCodexSharedTarget({ tier, from, model, effort, provider }) {
29
+ validateCodexTarget(model, effort);
30
+ if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1 or T2.');
31
+ if (!safeId(from) || from === model) throw new Error('--from must name a different parent model.');
32
+ if (!safeId(provider)) throw new Error('A shared model mapping requires a Codex provider ID.');
33
+ return { tier, from, model, ...(effort === undefined ? {} : { effort }), provider };
34
+ }
35
+
36
+ export function codexSharedTargets(cfg) {
37
+ const targets = cfg?.codex?.sharedRules?.targets;
38
+ if (!Array.isArray(targets)) return [];
39
+ return targets.flatMap((target) => {
40
+ try { return [validateCodexSharedTarget(target)]; } catch { return []; }
41
+ });
42
+ }
43
+
44
+ export function sharedCodexModelRules({ cfg = loadConfig(), dir = userDataDir(), root, start = root,
45
+ model, provider = codexProviderName() } = {}) {
46
+ if (cfg.codex?.sharedRules?.enabled === false) return [];
47
+ const targets = codexSharedTargets(cfg).filter((t) => t.from === model && t.provider === provider);
48
+ return sharedModelRulesForProject(loadSharedModelRules({ dir }), start).flatMap((rule) => {
49
+ const target = targets.find((t) => t.tier === rule.tier);
50
+ return target ? [{ ...rule, ...target, source: 'shared', status: 'active' }] : [];
51
+ });
52
+ }
53
+
26
54
  const rulesFile = (dir) => join(dir, 'codex-model-rules.json');
27
55
  export function loadCodexModelRules({ dir = userDataDir() } = {}) {
28
56
  try {
@@ -92,6 +120,16 @@ export function matchCodexModelRule(text, { model, root, provider = codexProvide
92
120
  return usable.find((r) => r.scope === 'project') || usable[0] || null;
93
121
  }
94
122
 
123
+ /** A Codex-only rule wins; otherwise the shared policies mapped for this parent, T2 first. */
124
+ function matchingRoutes(text, opts) {
125
+ const own = matchCodexModelRule(text, { ...opts, rules: opts.rules ?? loadCodexModelRules({ dir: opts.dir }) });
126
+ if (own) return [own];
127
+ const category = codexPromptCategory(text);
128
+ if (!category) return [];
129
+ return sharedCodexModelRules(opts).filter((r) => r.category === category.id)
130
+ .sort((a, b) => SHARED_RULE_TIERS.indexOf(a.tier) - SHARED_RULE_TIERS.indexOf(b.tier));
131
+ }
132
+
95
133
  /**
96
134
  * Input tokens from the rollout's last token_count record, or 0 when the
97
135
  * transcript is missing or unreadable. Only the tail is read: the newest
@@ -111,32 +149,85 @@ export function codexContextTokens(transcriptPath) {
111
149
  return 0;
112
150
  }
113
151
 
152
+ export const DEFAULT_DELEGATE_MIN_CONTEXT = 60000;
153
+
154
+ /** Parent input tokens below which the prompt hint stays silent. */
155
+ export function codexDelegateMinContext(cfg) {
156
+ const value = cfg?.codex?.delegateMinContext;
157
+ return Number.isSafeInteger(value) && value >= 0 ? value : DEFAULT_DELEGATE_MIN_CONTEXT;
158
+ }
159
+
160
+ // Codex tells the model to set spawn_agent's model only on the user's request,
161
+ // so the shared hint says where this request came from.
162
+ const SHARED_APPROVAL = 'The user approved this policy and its model mapping, so setting model here follows the user\'s request.';
163
+ 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.';
164
+ 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. '
165
+ + 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
166
+
167
+ function sharedTargetText(rule) {
168
+ return `${rule.tier}: model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''}. ${sharedRuleBounds(rule)}`.trim();
169
+ }
170
+
171
+ function sharedDenyReason(routes) {
172
+ return `[Sprag model routing] This spawn matches the approved shared ${routes[0].category} policy but names no model. ${SHARED_APPROVAL} `
173
+ // A full-history fork inherits the parent model and refuses overrides.
174
+ + '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'
175
+ + routes.map(sharedTargetText).join('\n')
176
+ + `\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.`;
177
+ }
178
+
114
179
  /**
115
180
  * A prompt-time routing suggestion for Codex 0.159.2, where `spawn_agent`
116
181
  * never reaches PreToolUse (see codexDelegationTool below). Gated on parent
117
182
  * context size: a small parent's own coordination overhead outweighs the
118
183
  * savings from a delegated child (measured 2026-09-30, see docs/CODEX.md).
184
+ *
185
+ * A shared policy offers both mapped tiers and records one route per tier;
186
+ * SubagentStart binds the child to the route whose model it runs, and the
187
+ * next prompt closes the tier that was not chosen.
119
188
  */
120
189
  export function codexRouteHint(payload, opts = {}) {
190
+ // The hook passes its loaded config. Without one there are no shared
191
+ // mappings and the default gate applies, so a caller never routes from the
192
+ // user's real config by accident.
193
+ const cfg = opts.cfg ?? {};
121
194
  const provider = payload?.model_provider || codexProviderName();
122
- const rule = matchCodexModelRule(payload?.prompt, { provider, ...opts, model: payload?.model, root: opts.root || payload?.cwd });
195
+ const routes = matchingRoutes(payload?.prompt, { cfg, provider, ...opts, model: payload?.model,
196
+ root: opts.root || payload?.cwd, start: payload?.cwd });
197
+ const rule = routes[0];
123
198
  if (!rule) return null;
124
- const minContext = opts.minContext ?? 60000;
199
+ const minContext = opts.minContext ?? codexDelegateMinContext(cfg);
125
200
  const ctx = opts.contextTokens ?? codexContextTokens(payload?.transcript_path);
126
201
  if (ctx < minContext) return null;
127
- const id = newRouteId();
128
202
  const record = opts.record ?? recordCodexDelegation;
129
- try {
130
- record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
131
- source: 'rule', category: rule.category, scope: rule.scope, from: payload?.model, to: rule.model,
132
- provider, effort: rule.effort ?? null, contextTokens: ctx,
133
- targetRoot: rule.targetRoot ?? null, ruleCreatedAt: rule.createdAt ?? null }, { dir: opts.dir });
134
- } catch { /* Accounting is optional; the hint still applies. */ }
203
+ // Tiers offered by one hint are alternatives. SubagentStart binds by model
204
+ // alone, so it must know which records compete for the same child.
205
+ const offerId = routes.length > 1 ? newRouteId() : null;
206
+ const recordRoute = (route) => {
207
+ const id = newRouteId();
208
+ const shared = route.source === 'shared';
209
+ try {
210
+ record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
211
+ source: shared ? 'shared' : 'rule', category: route.category, scope: route.scope, from: payload?.model, to: route.model,
212
+ provider: route.provider || provider, effort: route.effort ?? null, contextTokens: ctx, targetRoot: route.targetRoot ?? null,
213
+ ...(shared ? { tier: route.tier, sharedSignature: route.sharedSignature, ...(offerId ? { offerId } : {}) }
214
+ : { ruleCreatedAt: route.createdAt ?? null }) }, { dir: opts.dir });
215
+ } catch { /* Accounting is optional; the hint still applies. */ }
216
+ return id;
217
+ };
218
+ if (rule.source === 'shared') {
219
+ const targets = routes.map((route) => `${sharedTargetText(route)} Route line: <!-- sprag:codex:route id=${recordRoute(route)} -->`);
220
+ return `[Sprag model routing] This request matches the approved shared ${rule.category} policy. ${SHARED_APPROVAL} ${SHARED_TIER_GUIDANCE}\n`
221
+ + `${targets.join('\n')}\n`
222
+ + '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. '
223
+ + 'Give it a self-contained task message with the selected cap that ends with that tier\'s route line only. '
224
+ + WAIT_TEXT;
225
+ }
226
+ const id = recordRoute(rule);
135
227
  return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
136
228
  + `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
137
229
  + `Give it a self-contained task message that ends with the line <!-- sprag:codex:route id=${id} -->. `
138
- + '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. '
139
- + 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
230
+ + WAIT_TEXT;
140
231
  }
141
232
 
142
233
  function customRole(role, root, home) {
@@ -177,7 +268,15 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
177
268
  let rules = given;
178
269
  if (!rules) { try { rules = loadCodexModelRules({ dir }); } catch { rules = []; } }
179
270
  const provider = payload.model_provider || codexProviderName({ home });
180
- const rule = explicit ? null : matchCodexModelRule(input.message, { model: payload.model, provider, root, rules });
271
+ const routes = explicit ? [] : matchingRoutes(input.message, { model: payload.model, provider, root, start: payload.cwd, rules, cfg, dir });
272
+ // Only the caller can choose a shared policy's tier. A shared match without a
273
+ // model goes back to the caller with the mapped choices instead of a guessed
274
+ // downgrade, or a default target that ignores the tier.
275
+ if (routes[0]?.source === 'shared') {
276
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
277
+ permissionDecisionReason: sharedDenyReason(routes) } };
278
+ }
279
+ const rule = routes[0] ?? null;
181
280
  const target = rule || (!explicit && cfg.codex?.delegateTarget);
182
281
  const updatedInput = { ...input };
183
282
  let routeLine = '';
@@ -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: 'Opt-in native spawn model routing, scoped category rules, and SubagentStart guidance. Explicit models/custom roles preserved; no enforced budget.' },
20
+ { feature: 'delegate', support: 'supported', command: 'sprag delegate on --agent codex', note: 'Native routing and shared delegation policies with explicit Codex tier 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: 'Agent-compatible ratchet presets; separate decisions and explicit scope. No Claude tier presets.' },
26
+ { feature: 'seed', support: 'supported', command: 'sprag seed --agent codex', note: 'Shared delegation policies plus agent-compatible ratchet presets. Explicit scope; Codex model mappings required. 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.' },
@@ -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) {
@@ -88,20 +88,24 @@ export function readCodexBindings({ dir = userDataDir() } = {}) {
88
88
  return out;
89
89
  }
90
90
 
91
- /** Route ids a later prompt closed before any child claimed them. Kept in the binds file as `{ id, closed: true }` lines. */
92
- function closedCodexRouteIds({ dir = userDataDir() } = {}) {
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 Set(); }
95
- const out = new Set();
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.add(b.id);
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,
@@ -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) => rules.some((r) => r.category === g.category && r.from === g.from &&
153
- (!r.provider || r.provider === g.provider) &&
154
- (r.scope === 'global' || r.targetRoot === g.projectRoot));
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()]
@@ -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,94 @@
1
1
  import { loadConfig, saveConfig } from '../config.js';
2
2
  import { configureCodexHooks } from '../codex-installer.js';
3
- import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule } from '../codex-delegation.js';
3
+ import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule,
4
+ validateCodexSharedTarget, codexSharedTargets, codexDelegateMinContext, DEFAULT_DELEGATE_MIN_CONTEXT } from '../codex-delegation.js';
4
5
  import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
5
6
  import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
6
7
  import { codexRuleHealth, codexRulesInReview } from '../codex-rule-health.js';
7
8
  import { signedUsd } from '../money.js';
8
- import { codexProviderName } from '../agent.js';
9
+ import { codexProviderName, codexModelName } from '../agent.js';
10
+ import { loadSharedModelRules, sharedModelRulesForProject, SHARED_RULE_TIERS } from '../shared-model-rules.js';
11
+
12
+ // Starts from the working directory, as the hooks do, so a project policy
13
+ // stored under either agent's project root is counted.
14
+ function printShared(cfg, { details = false } = {}) {
15
+ const policies = sharedModelRulesForProject(loadSharedModelRules(), process.cwd());
16
+ const targets = codexSharedTargets(cfg);
17
+ const provider = codexProviderName();
18
+ const parent = codexModelName();
19
+ console.log(`Shared delegation policies: ${policies.length} in this project (${cfg.codex?.sharedRules?.enabled === false ? 'off' : 'on'}); Codex tier mappings: ${targets.length}`);
20
+ 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.');
21
+ if (details) {
22
+ for (const r of policies) console.log(`shared ${r.tier} ${r.category} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}`);
23
+ for (const t of targets) console.log(`${t.provider} | ${t.from} | ${t.tier} -> ${t.model}${t.effort ? ` (${t.effort})` : ''}`);
24
+ }
25
+ // A mapping routes only sessions on the parent model it names, so a tier
26
+ // mapped for another model still leaves the configured model unrouted.
27
+ const mapped = (tier) => targets.some((t) => t.provider === provider && t.tier === tier && (!parent || t.from === parent));
28
+ const missing = [...new Set(policies.map((r) => r.tier))].filter((tier) => !mapped(tier));
29
+ if (missing.length) {
30
+ console.log(`Unmapped tiers for ${parent || 'any parent model'} on ${provider || 'unknown provider'}: ${missing.join(', ')}. `
31
+ + `Set: sprag delegate shared map <T1|T2> --from ${parent || '<parent-model>'} --model <target-model> --agent codex`);
32
+ }
33
+ if (!parent && targets.length) console.log('Codex config names no default model; each mapping routes only sessions on its --from model.');
34
+ const minimum = codexDelegateMinContext(cfg);
35
+ console.log(`Shared prompt guidance starts at ${minimum} parent input tokens${cfg.codex?.delegateMinContext === minimum ? '' : ' (default)'}. `
36
+ + 'Change: sprag delegate shared min-context <tokens|default> --agent codex');
37
+ 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.');
38
+ }
9
39
 
10
40
  export function run({ args, getArg, hasFlag, root }) {
11
41
  const sub = args[1] || 'status';
12
42
  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']) {
43
+ for (const flag of ['--model', '--effort', '--from', '--provider']) {
14
44
  if (args.some((arg) => arg === flag || arg.startsWith(`${flag}=`)) && (!getArg(flag) || getArg(flag).startsWith('-'))) {
15
45
  throw new Error(`${flag} requires a value.`);
16
46
  }
17
47
  }
48
+ const cfg = loadConfig();
49
+ if (sub === 'shared') {
50
+ const action = args[2] || 'status';
51
+ if (!['status', 'on', 'off', 'map', 'unmap', 'min-context'].includes(action)) {
52
+ throw new Error('Usage: delegate shared status|on|off|map <T1|T2>|unmap <T1|T2>|min-context <tokens|default> --agent codex');
53
+ }
54
+ if (hasFlag('--global') || hasFlag('--project')) throw new Error('Shared policies retain their approved scope; mappings do not change it.');
55
+ const provided = (flag) => args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
56
+ if (!['map', 'unmap'].includes(action) && ['--model', '--effort', '--from', '--provider'].some(provided)) {
57
+ throw new Error('Model options require delegate shared map or unmap.');
58
+ }
59
+ const targets = codexSharedTargets(cfg);
60
+ if (action === 'map' || action === 'unmap') {
61
+ const tier = args[3];
62
+ const from = getArg('--from');
63
+ const provider = getArg('--provider') ?? codexProviderName();
64
+ if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1 or T2.');
65
+ const same = (t) => t.tier === tier && t.from === from && t.provider === provider;
66
+ if (action === 'map') {
67
+ const target = validateCodexSharedTarget({ tier, from, provider, model: getArg('--model'), effort: getArg('--effort') });
68
+ cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, targets: [...targets.filter((t) => !same(t)), target] } };
69
+ } else {
70
+ if (provided('--model') || provided('--effort')) throw new Error('unmap accepts only tier, --from and --provider.');
71
+ if (!targets.some(same)) throw new Error('No matching shared tier mapping; specify --from and the correct provider.');
72
+ cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, targets: targets.filter((t) => !same(t)) } };
73
+ }
74
+ } else if (action === 'min-context') {
75
+ const value = args[3];
76
+ if (value === 'default') {
77
+ cfg.codex = { ...cfg.codex };
78
+ delete cfg.codex.delegateMinContext;
79
+ } else if (/^\d+$/.test(value ?? '') && Number.isSafeInteger(Number(value))) {
80
+ cfg.codex = { ...cfg.codex, delegateMinContext: Number(value) };
81
+ } else {
82
+ throw new Error(`min-context takes a whole number of parent input tokens, or default (${DEFAULT_DELEGATE_MIN_CONTEXT}).`);
83
+ }
84
+ } else if (action !== 'status') {
85
+ cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, enabled: action === 'on' } };
86
+ }
87
+ if (action !== 'status') saveConfig(cfg);
88
+ printShared(cfg, { details: true });
89
+ 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.`);
90
+ return;
91
+ }
18
92
  if (sub === 'rules') {
19
93
  if (args[2] === 'add') {
20
94
  if (hasFlag('--global') === hasFlag('--project')) throw new Error('Choose --global or --project for the rule.');
@@ -31,7 +105,7 @@ export function run({ args, getArg, hasFlag, root }) {
31
105
  } else if (args[2] === 'rm') removeCodexModelRule(Number(args[3]));
32
106
  else if (args[2]) throw new Error('Usage: delegate rules [add <category|R<N>> --from <model> --model <model> --global|--project | rm <N>]');
33
107
  const rules = loadCodexModelRules();
34
- if (!rules.length) console.log('No Codex model rules. Claude rules are not imported.');
108
+ if (!rules.length) console.log('No Codex-only model rules.');
35
109
  let events = [];
36
110
  try { events = Object.values(loadCodexLedger().events); } catch { /* No ledger yet: rules list without outcomes. */ }
37
111
  const inReview = new Set(codexRulesInReview({ rules, events, root: null }).map((x) => x.index));
@@ -41,10 +115,10 @@ export function run({ args, getArg, hasFlag, root }) {
41
115
  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
116
  console.log(`#${i + 1} ${r.category} | ${r.from} -> ${r.model} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}${outcome}${warn}`);
43
117
  });
118
+ printShared(cfg, { details: true });
44
119
  return;
45
120
  }
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();
121
+ if (!['on', 'off', 'status', 'model'].includes(sub)) throw new Error('Usage: delegate on|off|status|model <model>|rules|shared --agent codex');
48
122
  let target;
49
123
  if (sub === 'model') target = args[2] === 'off' ? null : validateCodexTarget(args[2], getArg('--effort'));
50
124
  else if (getArg('--model') !== undefined) target = validateCodexTarget(getArg('--model'), getArg('--effort'));
@@ -63,6 +137,7 @@ export function run({ args, getArg, hasFlag, root }) {
63
137
  let ruleCount;
64
138
  try { ruleCount = String(loadCodexModelRules().length); } catch (e) { ruleCount = `unreadable (${e.message})`; }
65
139
  console.log(`Codex rules: ${ruleCount}`);
140
+ printShared(cfg);
66
141
  console.log('Explicit spawn models and custom roles are preserved. Targets must be available from your Codex provider.');
67
142
  const saved = codexRoutingSavedTotals();
68
143
  console.log(saved.priced
@@ -26,11 +26,16 @@ 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-only category rules; add/rm with explicit scope
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
30
35
  route-scan [--refresh] Discover recurring simple turns in local logs
31
36
  route-scan dismiss R<N> Dismiss a candidate without registering a rule
32
37
  route-scan savings Attributed routing estimates; --refresh or --format json
33
- seed [accept|skip|reset] Bundled Codex ratchet presets; acceptance needs a scope
38
+ seed [accept|skip|reset] Shared delegation and Codex ratchet presets; explicit scope
34
39
  cache [auto|off|<duration>] Display timer policy (e.g. 30m); never changes retention
35
40
  cache status --refresh Resolve the session's gateway deployment and cache policy
36
41
  harness init|uninit|check|promote|list|rm|prune
@@ -413,6 +418,7 @@ export async function run({ args, version }) {
413
418
  console.log('After approval, start or resume Codex and run: sprag panel doctor --agent codex');
414
419
  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
420
  console.log('Existing config.toml, Claude Code settings, and ratchet rules are preserved.');
421
+ console.log('First-use delegation: sprag seed --agent codex, then sprag delegate shared status --agent codex. Approve a scope and map the tiers before enabling delegation. No Claude installation is required.');
416
422
  console.log('Korean guidance is opt-in: sprag korean on --agent codex');
417
423
  return;
418
424
  }
@@ -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(stateDir(), 'model-rules.json');
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
- return Array.isArray(data.rules) ? data : { rules: [] };
158
- } catch {
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);
@@ -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
- const matched = all.filter((r) => r && r.category === cat.id);
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
- const t2 = usable.find((r) => r.tier === 'T2');
165
- const t1 = usable.find((r) => r.tier === 'T1');
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
- * "Already covered" is deliberately loose for model presets — any registered
106
- * rule for the same tier and category wins, whatever its scope or agent. A user
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
- // Codex model routing comes from its own measured route-scan candidates;
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
- for (const p of agent === 'codex' ? [] : modelPresets()) {
132
- if (decided[p.id]) continue;
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: composeRuleText(modelRuleBaseText(p, lang), { budget: p.budget }, lang),
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}. The execution model comes from the Codex 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
- const written = syncAllFiles();
229
- recordDecision(id, 'accepted', { scope }, { agent });
230
- return { id, kind: 'model', scope, paths: written, rule: composeRuleText(entry.rule, entry, lang) };
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 and explicit tier 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
+ }