sprag-cli 3.61.0 → 3.63.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 +23 -8
- package/docs/CODEX.md +31 -10
- package/mod/sprag-delegation-meter/.claude-plugin/plugin.json +6 -0
- package/mod/sprag-doc2md/.claude-plugin/plugin.json +6 -0
- package/package.json +4 -1
- package/src/codex-delegation.js +85 -5
- package/src/codex-doctor.js +2 -2
- package/src/codex-harness.js +85 -10
- package/src/codex-hooks.js +11 -6
- package/src/codex-panel.js +2 -2
- package/src/commands/codex-delegate.js +23 -9
- package/src/commands/codex.js +17 -2
- package/src/commands/doc2md.js +12 -0
- package/src/commands/install.js +8 -0
- package/src/commands/korean.js +3 -0
- package/src/installer.js +135 -4
- package/src/seed-rules.js +3 -3
package/docs/CODEX.ko.md
CHANGED
|
@@ -133,6 +133,12 @@ Codex 0.159.2에서는 `spawn_agent`가 `PreToolUse`를 거치지 않습니다(
|
|
|
133
133
|
SessionStart는 캐시된 후보를 읽고 필요할 때 별도 프로세스에서 갱신합니다. 명시적인 `--refresh`는
|
|
134
134
|
대기 주기를 건너뜁니다.
|
|
135
135
|
|
|
136
|
+
한국어 지침이 켜져 있는 동안 Sprag는 지침 전문을 전역 Codex `AGENTS.md`(`AGENTS.override.md`가
|
|
137
|
+
비어 있지 않으면 그 파일)의 `<!-- sprag:codex:korean-style:begin -->`과 `end` 표지 사이에 둡니다.
|
|
138
|
+
Codex는 이 파일을 세션마다 전부 읽지만 아래 훅 예산에는 지침이 들어가지 않기 때문입니다. 설치,
|
|
139
|
+
`korean on|off`, 세션 시작 때마다 이 블록을 맞추고, `sprag uninstall --agent codex`가 지웁니다. 훅은
|
|
140
|
+
블록을 방금 쓴 세션에서만 지침을 자기 컨텍스트에 함께 넣습니다.
|
|
141
|
+
|
|
136
142
|
Codex 0.159.2는 훅 하나가 넘긴 컨텍스트를 약 2,450토큰까지만 보존하고, 그보다 길면 가운데를
|
|
137
143
|
잘라 냅니다. 그래서 SessionStart는 추정치 2,000토큰 안에 들어가는 항목만 잘리지 않은 상태로
|
|
138
144
|
넣습니다. 우선순위는 ratchet 룰, 한국어 지침, 응집성 지침, 문서 변환 안내, 제안 순입니다. 들어가지
|
|
@@ -158,19 +164,28 @@ Claude를 설치하지 않은 Codex 첫 사용자도 기록을 쌓기 전에 프
|
|
|
158
164
|
sprag seed --agent codex
|
|
159
165
|
sprag seed accept all --global --agent codex
|
|
160
166
|
sprag delegate shared status --agent codex
|
|
167
|
+
# 자동으로 정해진 난도별 모델을 바꾸고 싶을 때만:
|
|
161
168
|
sprag delegate shared map T2 --from PARENT_MODEL --model SMALL_MODEL --effort high --agent codex
|
|
162
169
|
sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort medium --agent codex
|
|
163
|
-
sprag delegate on --agent codex
|
|
164
170
|
```
|
|
165
171
|
|
|
166
172
|
등록 전에 전역과 프로젝트 범위를 선택합니다. 프로젝트에만 적용하려면 `--global` 대신
|
|
167
|
-
`--project`를 지정하십시오.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
`--project`를 지정하십시오.
|
|
174
|
+
|
|
175
|
+
`map` 줄은 선택 사항입니다. 매핑하지 않은 난도는 Claude Code가 `haiku`와 `sonnet`을 실제 모델로
|
|
176
|
+
풀듯이 자동으로 정해집니다. 부모 모델의 공급자에서 가격이 매겨져 있고(게이트웨이 `/model/info` 가격,
|
|
177
|
+
OpenAI 직접 호출이면 공개 가격), 부모보다 싸며, 이 사용자의 Codex 세션에서 이미 실행됐거나(OpenAI 기본 공급자라면 Codex가 이 계정에 보여 주는
|
|
178
|
+
`models_cache.json` 모델 목록에 있는) 모델 가운데 가장 싼 모델이 T2, 그다음 모델이 T1이 됩니다. 그런 모델이 하나뿐이면 T1은 메인 모델이 처리하고, 하나도
|
|
179
|
+
없으면 두 난도 모두 메인 모델이 처리합니다. 위임은 기본으로 켜져 있고(`sprag delegate off --agent codex`로 끕니다), 자동 결정은 위임이 켜진 동안
|
|
180
|
+
동작합니다. 아무것도 저장하지 않으므로 가격이나 실행 이력, 모델 목록이 바뀌면 그대로 따라갑니다.
|
|
181
|
+
`delegate shared status`와 설치 출력에 정해진 모델이 표시되고, 정해지지 않으면 그 이유가 표시됩니다.
|
|
182
|
+
끄려면 `sprag delegate shared auto off --agent codex`를 실행하십시오.
|
|
183
|
+
|
|
184
|
+
명시한 매핑은 해당 난도에서 자동 결정보다 우선합니다. 매핑은 출발 모델·공급자·난도가 일치할 때만
|
|
185
|
+
적용하고, 한 번 지정하면 승인된 모든 작업 유형에서 사용합니다. 공급자는 Codex 설정에서 읽으며
|
|
186
|
+
`--provider ID`로 지정할 수도 있습니다. 매핑이나 자동 결정만으로 절감 효과를 확인한 것은 아닙니다.
|
|
187
|
+
Codex에서 승인할 때 `.claude` 파일은 만들지 않으며, 나중에 Claude를 시작하면 같은 정책을 Claude용
|
|
188
|
+
모델 표기로 생성합니다.
|
|
174
189
|
|
|
175
190
|
프롬프트 훅은 단순 작업에 T2, 여러 단계가 필요한 작업에 T1을 제시하고, 매핑된 난도마다 route 줄을
|
|
176
191
|
따로 줍니다. 부모는 작업에 맞는 모델 하나를 선택하고 독립적인 작업 명세, 상한, `fork_turns "none"`과
|
package/docs/CODEX.md
CHANGED
|
@@ -58,6 +58,14 @@ sprag korean lint warn --agent codex
|
|
|
58
58
|
sprag doc2md install-converter --agent codex
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
While Korean guidance is on, Sprag keeps it in the global Codex `AGENTS.md`
|
|
62
|
+
(or `AGENTS.override.md` when that is non-empty), between
|
|
63
|
+
`<!-- sprag:codex:korean-style:begin -->` and `...:end -->` markers, because
|
|
64
|
+
Codex loads that file in full every session and the hook budget below is too
|
|
65
|
+
small for the guide. `install`, `korean on|off`, and each session start keep the
|
|
66
|
+
block in step; `sprag uninstall --agent codex` removes it. The hook adds the
|
|
67
|
+
guide to its own context only in the session that just wrote the block.
|
|
68
|
+
|
|
61
69
|
`korean on`, `cohesion on`, `brief on`, `doc2md on`, and `delegate on`
|
|
62
70
|
register Codex hooks before saving the enabled preference. If hook registration
|
|
63
71
|
fails, the preference stays unchanged. Review new or changed definitions in
|
|
@@ -151,7 +159,7 @@ warning records. A handoff file is never overwritten and identifies quota
|
|
|
151
159
|
values as recorded snapshots, not live responses. Fill in its remaining-work
|
|
152
160
|
sections before using it to continue in another session.
|
|
153
161
|
|
|
154
|
-
Delegation is
|
|
162
|
+
Delegation is on by default; `sprag delegate off --agent codex` turns it off. As of Codex 0.159.2,
|
|
155
163
|
`spawn_agent` never reaches `PreToolUse` (measured directly: a recording hook
|
|
156
164
|
saw only `Bash` calls while a spawn happened), so the rewrite below cannot see
|
|
157
165
|
or steer it. The live path instead runs at prompt time: `UserPromptSubmit`
|
|
@@ -223,16 +231,28 @@ A Codex-first user needs no Claude installation or transcript history:
|
|
|
223
231
|
sprag seed --agent codex
|
|
224
232
|
sprag seed accept all --global --agent codex # or --project, after choosing scope
|
|
225
233
|
sprag delegate shared status --agent codex
|
|
234
|
+
# optional, to override the automatic tier models:
|
|
226
235
|
sprag delegate shared map T2 --from PARENT_MODEL --model SMALL_MODEL --effort high --agent codex
|
|
227
236
|
sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort medium --agent codex
|
|
228
|
-
sprag delegate on --agent codex
|
|
229
237
|
```
|
|
230
238
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
239
|
+
The map lines are optional. A tier without a mapping resolves automatically,
|
|
240
|
+
the way Claude Code resolves `haiku` and `sonnet`: the cheapest model that is
|
|
241
|
+
priced on the parent's provider (gateway `/model/info` prices, or OpenAI list
|
|
242
|
+
prices for direct calls), costs less than the parent, and is available becomes
|
|
243
|
+
T2, and the next one up becomes T1. A model is available when it has already
|
|
244
|
+
run in your Codex sessions, or, on the built-in OpenAI provider only, when
|
|
245
|
+
Codex lists it for your account in its `models_cache.json`. With a single
|
|
246
|
+
such model T1 stays on the main agent; with none, both do. Resolution runs
|
|
247
|
+
while delegation is on (the default) and follows price, history, and catalog
|
|
248
|
+
changes without storing anything. `delegate shared status` and the
|
|
249
|
+
install output show the resolved models and, when nothing resolves, why.
|
|
250
|
+
`sprag delegate shared auto off --agent codex` turns it off.
|
|
251
|
+
|
|
252
|
+
An explicit mapping wins for its tier. Each one applies to one exact parent
|
|
253
|
+
model, provider, and tier, across every approved category; `--provider ID`
|
|
254
|
+
overrides the provider read from Codex config. Mappings and automatic choices
|
|
255
|
+
do not prove savings, and no Haiku/Sonnet-to-GPT equivalence is assumed.
|
|
236
256
|
Codex seed acceptance writes the common registry without creating `.claude`;
|
|
237
257
|
Claude renders its own view when it is installed or starts later.
|
|
238
258
|
|
|
@@ -341,7 +361,8 @@ after an hour. `--refresh` bypasses that schedule.
|
|
|
341
361
|
|
|
342
362
|
Codex 0.159.2 keeps about 2,450 tokens of one hook's context and cuts the
|
|
343
363
|
middle of anything longer. SessionStart therefore fits its parts whole within a
|
|
344
|
-
2,000-token estimate, in priority order: ratchet rules, Korean guidance
|
|
364
|
+
2,000-token estimate, in priority order: ratchet rules, Korean guidance
|
|
365
|
+
(only while it is not yet in the global `AGENTS.md`; see below),
|
|
345
366
|
cohesion guidance, the document note, then offers. Rules and guidance that do
|
|
346
367
|
not fit are written to a per-project file under the Sprag data directory, and
|
|
347
368
|
the context opens with an instruction to read it. `codex exec` runs, recognized
|
|
@@ -353,8 +374,8 @@ compatible Codex ratchet presets. Ratchet and skip decisions remain agent-specif
|
|
|
353
374
|
an approved common policy suppresses duplicate offers within its scope in either
|
|
354
375
|
agent. Both seed acceptance and
|
|
355
376
|
route approval require the user's choice of global or project scope. A project
|
|
356
|
-
route is saved for the project where it was observed.
|
|
357
|
-
`sprag delegate
|
|
377
|
+
route is saved for the project where it was observed. Delegation is on by default
|
|
378
|
+
(`sprag delegate off --agent codex` turns it off); trust the hooks before expecting rules to run.
|
|
358
379
|
|
|
359
380
|
## Reports
|
|
360
381
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sprag-delegation-meter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Measures what each delegated subagent actually spent, from the API's own usage, and keeps one record per run",
|
|
5
|
+
"author": { "name": "DeepPulse", "url": "https://www.youtube.com/@DeepPulseKR" }
|
|
6
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.63.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": {
|
|
@@ -15,11 +15,14 @@
|
|
|
15
15
|
"release:notes": "node scripts/release-notes.mjs",
|
|
16
16
|
"site:docs": "node scripts/build-site-docs.mjs",
|
|
17
17
|
"test": "node --test",
|
|
18
|
+
"test:mods": "claude plugin test mod/sprag-doc2md && claude plugin test mod/sprag-delegation-meter",
|
|
18
19
|
"version": "node scripts/sync-site-version.mjs"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"bin/",
|
|
22
23
|
"src/",
|
|
24
|
+
"mod/*/.claude-plugin/plugin.json",
|
|
25
|
+
"mod/*/hooks/",
|
|
23
26
|
"presets/",
|
|
24
27
|
"examples/",
|
|
25
28
|
"README.md",
|
package/src/codex-delegation.js
CHANGED
|
@@ -11,12 +11,21 @@ import { newRouteId, recordCodexDelegation } from './codex-ledger.js';
|
|
|
11
11
|
import { readCodexTailLines } from './codex-parser.js';
|
|
12
12
|
import { loadConfig } from './config.js';
|
|
13
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';
|
|
14
16
|
|
|
15
17
|
export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
|
|
16
18
|
const MARKER = '<!-- sprag:codex:delegation -->';
|
|
17
19
|
const safeId = (value) => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,255}$/.test(value);
|
|
18
20
|
const validEffort = (value) => value === undefined || ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(value);
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Delegation is on unless the user turned it off. A fresh install writes no
|
|
24
|
+
* setting, so requiring `delegate === true` meant nothing ever delegated; only
|
|
25
|
+
* `sprag delegate off --agent codex` stores `false`.
|
|
26
|
+
*/
|
|
27
|
+
export function codexDelegateEnabled(cfg) { return cfg?.codex?.delegate !== false; }
|
|
28
|
+
|
|
20
29
|
export function validateCodexTarget(model, effort) {
|
|
21
30
|
if (!safeId(model)) throw new Error('Provide a literal Codex model ID.');
|
|
22
31
|
if (!validEffort(effort)) {
|
|
@@ -41,10 +50,81 @@ export function codexSharedTargets(cfg) {
|
|
|
41
50
|
});
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
|
|
53
|
+
// A request of this shape (input to output about 5:1) ranks models by price.
|
|
54
|
+
const RANKING_USAGE = { input: 10000, output: 2000 };
|
|
55
|
+
const routeScanFile = (dir) => join(dir, 'codex-route-scan.json');
|
|
56
|
+
|
|
57
|
+
function observedCodexModels(dir, provider) {
|
|
58
|
+
try {
|
|
59
|
+
const data = JSON.parse(readFileSync(routeScanFile(dir), 'utf8'));
|
|
60
|
+
const prefix = `${provider}|`;
|
|
61
|
+
return (Array.isArray(data?.observedModels) ? data.observedModels : [])
|
|
62
|
+
.filter((key) => typeof key === 'string' && key.startsWith(prefix)).map((key) => key.slice(prefix.length));
|
|
63
|
+
} catch { return []; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Model slugs Codex's own catalog lists for this account
|
|
68
|
+
* (`<codex home>/models_cache.json`, entries with visibility "list"). A new
|
|
69
|
+
* user has no session history, so the catalog is the only evidence of what
|
|
70
|
+
* Codex can run. Unreadable or malformed files give [].
|
|
71
|
+
*/
|
|
72
|
+
function catalogCodexModels(home) {
|
|
73
|
+
try {
|
|
74
|
+
const data = JSON.parse(readFileSync(join(home, 'models_cache.json'), 'utf8'));
|
|
75
|
+
return (Array.isArray(data?.models) ? data.models : [])
|
|
76
|
+
.filter((m) => m && m.visibility === 'list' && typeof m.slug === 'string').map((m) => m.slug);
|
|
77
|
+
} catch { return []; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The models Codex resolves T2 and T1 to when the user mapped none, the way
|
|
82
|
+
* Claude Code resolves `haiku` and `sonnet`. Candidates are the models that
|
|
83
|
+
* already ran in this user's Codex sessions plus, for the built-in OpenAI
|
|
84
|
+
* provider only, the models Codex's catalog lists for the account (a gateway
|
|
85
|
+
* serves its own set, which the catalog does not describe). A candidate
|
|
86
|
+
* qualifies only if it is priced on the parent's provider and costs less than
|
|
87
|
+
* the parent, so the hint never names a model the gateway cannot serve. The
|
|
88
|
+
* cheapest is T2 and the next one up is T1; with a single candidate T1 stays
|
|
89
|
+
* on the main agent. Returns { targets, reason }.
|
|
90
|
+
*/
|
|
91
|
+
export function autoCodexSharedTargets({ dir = userDataDir(), home = codexUserDir(), model, provider, now = Date.now() } = {}) {
|
|
92
|
+
if (!safeId(model) || !safeId(provider)) return { targets: [], reason: 'unknown parent model or provider' };
|
|
93
|
+
const book = codexPriceBook(provider, readCodexPrices({ now, home, dir }), { home });
|
|
94
|
+
if (!book) return { targets: [], reason: `no price table for ${provider}` };
|
|
95
|
+
const cost = (m) => codexRunCost(RANKING_USAGE, book.prices[m]);
|
|
96
|
+
const parent = cost(model);
|
|
97
|
+
if (parent === null) return { targets: [], reason: `${model} is not priced on ${provider}` };
|
|
98
|
+
const available = [...new Set([...observedCodexModels(dir, provider), ...(provider === 'openai' ? catalogCodexModels(home) : [])])];
|
|
99
|
+
const cheaper = available
|
|
100
|
+
.filter((m) => m !== model && safeId(m)).map((m) => ({ model: m, usd: cost(m) }))
|
|
101
|
+
.filter((x) => x.usd !== null && x.usd < parent).sort((a, b) => a.usd - b.usd || a.model.localeCompare(b.model));
|
|
102
|
+
if (!cheaper.length) {
|
|
103
|
+
return { targets: [], reason: available.length
|
|
104
|
+
? `${model} is already the cheapest available model on ${provider}`
|
|
105
|
+
: `no cheaper priced model has run in your Codex sessions on ${provider}` };
|
|
106
|
+
}
|
|
107
|
+
const targets = cheaper.slice(0, 2).map((x, i) => ({ tier: i === 0 ? 'T2' : 'T1', from: model, model: x.model, provider, auto: true }));
|
|
108
|
+
return { targets, reason: null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Explicit mappings win per tier. Without one, a tier resolves automatically
|
|
113
|
+
* unless the user turned that off; resolution runs only for an enabled Codex
|
|
114
|
+
* delegation, so a caller without the hook's config never routes by accident.
|
|
115
|
+
*/
|
|
116
|
+
export function codexTierTargets({ cfg = loadConfig(), dir = userDataDir(), home = codexUserDir(), model, provider = codexProviderName() } = {}) {
|
|
117
|
+
const explicit = codexSharedTargets(cfg).filter((t) => t.from === model && t.provider === provider);
|
|
118
|
+
if (!codexDelegateEnabled(cfg) || cfg.codex?.sharedRules?.auto === false) return explicit;
|
|
119
|
+
const auto = autoCodexSharedTargets({ dir, home, model, provider }).targets
|
|
120
|
+
.filter((t) => !explicit.some((e) => e.tier === t.tier));
|
|
121
|
+
return [...explicit, ...auto];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function sharedCodexModelRules({ cfg = loadConfig(), dir = userDataDir(), home = codexUserDir(), root, start = root,
|
|
45
125
|
model, provider = codexProviderName() } = {}) {
|
|
46
126
|
if (cfg.codex?.sharedRules?.enabled === false) return [];
|
|
47
|
-
const targets =
|
|
127
|
+
const targets = codexTierTargets({ cfg, dir, home, model, provider });
|
|
48
128
|
return sharedModelRulesForProject(loadSharedModelRules({ dir }), start).flatMap((rule) => {
|
|
49
129
|
const target = targets.find((t) => t.tier === rule.tier);
|
|
50
130
|
return target ? [{ ...rule, ...target, source: 'shared', status: 'active' }] : [];
|
|
@@ -210,7 +290,7 @@ export function codexRouteHint(payload, opts = {}) {
|
|
|
210
290
|
record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
|
|
211
291
|
source: shared ? 'shared' : 'rule', category: route.category, scope: route.scope, from: payload?.model, to: route.model,
|
|
212
292
|
provider: route.provider || provider, effort: route.effort ?? null, contextTokens: ctx, targetRoot: route.targetRoot ?? null,
|
|
213
|
-
...(shared ? { tier: route.tier, sharedSignature: route.sharedSignature, ...(offerId ? { offerId } : {}) }
|
|
293
|
+
...(shared ? { tier: route.tier, sharedSignature: route.sharedSignature, ...(offerId ? { offerId } : {}), ...(route.auto ? { mapping: 'auto' } : {}) }
|
|
214
294
|
: { ruleCreatedAt: route.createdAt ?? null }) }, { dir: opts.dir });
|
|
215
295
|
} catch { /* Accounting is optional; the hint still applies. */ }
|
|
216
296
|
return id;
|
|
@@ -256,7 +336,7 @@ function customRole(role, root, home) {
|
|
|
256
336
|
// Kept for Codex versions that do send spawn_agent through PreToolUse; on
|
|
257
337
|
// 0.159.2 the hook never fires for it, so codexRouteHint above is the live path.
|
|
258
338
|
export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules: given, dir = userDataDir(), record = recordCodexDelegation } = {}) {
|
|
259
|
-
if (cfg
|
|
339
|
+
if (!codexDelegateEnabled(cfg)) return null;
|
|
260
340
|
const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
|
|
261
341
|
if (!['spawn_agent', 'Agent'].includes(tool)) return null;
|
|
262
342
|
const input = payload.tool_input;
|
|
@@ -268,7 +348,7 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
|
|
|
268
348
|
let rules = given;
|
|
269
349
|
if (!rules) { try { rules = loadCodexModelRules({ dir }); } catch { rules = []; } }
|
|
270
350
|
const provider = payload.model_provider || codexProviderName({ home });
|
|
271
|
-
const routes = explicit ? [] : matchingRoutes(input.message, { model: payload.model, provider, root, start: payload.cwd, rules, cfg, dir });
|
|
351
|
+
const routes = explicit ? [] : matchingRoutes(input.message, { model: payload.model, provider, root, start: payload.cwd, rules, cfg, dir, home });
|
|
272
352
|
// Only the caller can choose a shared policy's tier. A shared match without a
|
|
273
353
|
// model goes back to the caller with the mapped choices instead of a guessed
|
|
274
354
|
// downgrade, or a default target that ignores the tier.
|
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: 'Native routing and shared delegation policies
|
|
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: 'Shared delegation policies plus agent-compatible ratchet presets. Explicit scope;
|
|
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-harness.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { codexUserDir } from './agent.js';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import { koreanStyleEnabled, koreanStyleInjection } from './korean-style.js';
|
|
5
7
|
import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines, forkedCopyFilter } from './codex-parser.js';
|
|
6
8
|
|
|
7
9
|
export const CODEX_BEGIN = '<!-- sprag:codex:harness:begin -->';
|
|
8
10
|
export const CODEX_END = '<!-- sprag:codex:harness:end -->';
|
|
11
|
+
export const CODEX_KOREAN_BEGIN = '<!-- sprag:codex:korean-style:begin -->';
|
|
12
|
+
export const CODEX_KOREAN_END = '<!-- sprag:codex:korean-style:end -->';
|
|
9
13
|
const sections = [
|
|
10
14
|
['Ratchet', 'When a mistake recurs, propose a concise condition/action rule. Ask the user for global or project scope before registering it with `sprag harness promote --agent codex --global|--project "rule"`.'],
|
|
11
15
|
['Evidence', 'Support completion claims with actual test results, command output, a file diff, or a screenshot. State any verification you could not run.'],
|
|
@@ -16,11 +20,15 @@ const sections = [
|
|
|
16
20
|
|
|
17
21
|
const scopeDir = (root, scope) => (scope === 'global' ? codexUserDir() : root);
|
|
18
22
|
|
|
23
|
+
/** Codex reads the override instead of AGENTS.md when it is non-empty. */
|
|
24
|
+
function activeInstructionFile(dir) {
|
|
25
|
+
const override = join(dir, 'AGENTS.override.md');
|
|
26
|
+
return existsSync(override) && readFileSync(override, 'utf8').trim() ? override : join(dir, 'AGENTS.md');
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export function codexHarnessPaths(root, scope = 'project') {
|
|
20
30
|
const dir = scopeDir(root, scope);
|
|
21
|
-
|
|
22
|
-
const override = join(dir, 'AGENTS.override.md');
|
|
23
|
-
const file = existsSync(override) && readFileSync(override, 'utf8').trim() ? override : join(dir, 'AGENTS.md');
|
|
31
|
+
const file = activeInstructionFile(dir);
|
|
24
32
|
return { file, ratchet: scope === 'global' ? join(dir, 'ratchet.md') : join(dir, '.codex', 'ratchet.md') };
|
|
25
33
|
}
|
|
26
34
|
|
|
@@ -34,15 +42,18 @@ export function codexHarnessBlock() {
|
|
|
34
42
|
return `${CODEX_BEGIN}\n## Sprag Harness\n\n${sections.map(([name, text], i) => `### ${i + 1}. ${name}\n${text}`).join('\n\n')}\n\nRead the global and project Codex ratchet files before working when present. Run \`sprag harness list --agent codex --global\` and \`sprag harness list --agent codex --project\` to retrieve them. These are instructions, not Claude-style @ imports.\nUse \`sprag doc2md <file> --agent codex\` before reading PDF, Office, or Figma documents. Delegate only through available Codex tools and configured roles; do not assume Claude model names or Task/Agent arguments. A [Sprag model routing] note on a request means the user has asked for that request to be delegated: spawn the sub-agent it describes (its model, reasoning_effort, fork_turns "none", and a self-contained message ending with the route line it gives), wait for it to finish instead of doing the same task yourself, then verify the result before replying.\n${CODEX_END}\n`;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
/** The [start, end) span of a marker block, null when absent; malformed markers throw. */
|
|
46
|
+
function range(content, beginMarker = CODEX_BEGIN, endMarker = CODEX_END, label = 'harness') {
|
|
47
|
+
const begin = content.indexOf(beginMarker);
|
|
48
|
+
const end = content.indexOf(endMarker);
|
|
49
|
+
if ((begin < 0) !== (end < 0) || (begin >= 0 && end < begin) || content.indexOf(beginMarker, begin + beginMarker.length) >= 0) {
|
|
50
|
+
throw new Error(`Malformed Sprag Codex ${label} markers; repair them before updating this file.`);
|
|
42
51
|
}
|
|
43
|
-
return begin < 0 ? null : [begin, end +
|
|
52
|
+
return begin < 0 ? null : [begin, end + endMarker.length];
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
const koreanRange = (content) => range(content, CODEX_KOREAN_BEGIN, CODEX_KOREAN_END, 'korean-style');
|
|
56
|
+
|
|
46
57
|
export function initCodexHarness({ root, scope = 'project' }) {
|
|
47
58
|
const paths = codexHarnessPaths(root, scope);
|
|
48
59
|
const existing = existsSync(paths.file) ? readFileSync(paths.file, 'utf8') : '';
|
|
@@ -95,10 +106,74 @@ export function uninitCodexHarness({ root, scope = 'project' }) {
|
|
|
95
106
|
writeFileSync(candidate, withoutBlock(existing, span));
|
|
96
107
|
cleaned.push(candidate);
|
|
97
108
|
}
|
|
109
|
+
// The Korean style block lives only in the global file; project scope never has one.
|
|
110
|
+
if (scope === 'global') {
|
|
111
|
+
for (const candidate of removeKoreanBlocks(scopeDir(root, scope))) if (!cleaned.includes(candidate)) cleaned.push(candidate);
|
|
112
|
+
}
|
|
98
113
|
if (!cleaned.length) return { file, removed: false };
|
|
99
114
|
return { file: cleaned.includes(file) ? file : cleaned[0], removed: true, ...(cleaned.length > 1 ? { files: cleaned } : {}) };
|
|
100
115
|
}
|
|
101
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Backup next to the file. A fresh install writes two backups in one
|
|
119
|
+
* millisecond (harness block, then Korean block); `wx` and a counter keep the
|
|
120
|
+
* second from overwriting the first, which holds the user's original text.
|
|
121
|
+
*/
|
|
122
|
+
function backupFile(file, text) {
|
|
123
|
+
const base = `${file}.bak-${Date.now()}`;
|
|
124
|
+
for (let i = 0; ; i++) {
|
|
125
|
+
try { writeFileSync(i ? `${base}-${i}` : base, text, { flag: 'wx' }); return; }
|
|
126
|
+
catch (e) { if (e?.code !== 'EEXIST') throw e; }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Removes the Korean block from every instruction file of `dir` that holds one; returns those files. */
|
|
131
|
+
function removeKoreanBlocks(dir) {
|
|
132
|
+
const cleaned = [];
|
|
133
|
+
for (const candidate of [join(dir, 'AGENTS.override.md'), join(dir, 'AGENTS.md')]) {
|
|
134
|
+
if (!existsSync(candidate)) continue;
|
|
135
|
+
const existing = readFileSync(candidate, 'utf8');
|
|
136
|
+
const span = koreanRange(existing);
|
|
137
|
+
if (!span) continue;
|
|
138
|
+
const next = withoutBlock(existing, span);
|
|
139
|
+
if (next.trim()) backupFile(candidate, existing);
|
|
140
|
+
writeFileSync(candidate, next);
|
|
141
|
+
cleaned.push(candidate);
|
|
142
|
+
}
|
|
143
|
+
return cleaned;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Keeps the Korean style guide in the global Codex instruction file. Codex
|
|
148
|
+
* keeps only about 2,000 tokens of one hook's context, which the guide never
|
|
149
|
+
* fits, but it loads the global AGENTS.md in full every session. The block is
|
|
150
|
+
* written while the style is enabled and removed when it is not.
|
|
151
|
+
* Returns { action: 'written' | 'unchanged' | 'removed' | 'absent' | 'skipped', file }.
|
|
152
|
+
* `skipped` means the Codex directory does not exist, so nothing was touched.
|
|
153
|
+
*/
|
|
154
|
+
export function syncCodexKoreanBlock({ cfg = loadConfig(), home } = {}) {
|
|
155
|
+
const dir = home ? resolve(home) : codexUserDir();
|
|
156
|
+
const file = activeInstructionFile(dir);
|
|
157
|
+
if (!existsSync(dir)) return { action: 'skipped', file };
|
|
158
|
+
const text = koreanStyleEnabled(cfg) ? koreanStyleInjection({ cfg }) : null;
|
|
159
|
+
if (!text) {
|
|
160
|
+
const cleaned = removeKoreanBlocks(dir);
|
|
161
|
+
return cleaned.length ? { action: 'removed', file: cleaned.includes(file) ? file : cleaned[0] } : { action: 'absent', file };
|
|
162
|
+
}
|
|
163
|
+
const block = `${CODEX_KOREAN_BEGIN}\n${text}\n${CODEX_KOREAN_END}`;
|
|
164
|
+
const existing = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
165
|
+
const span = koreanRange(existing);
|
|
166
|
+
if (span && existing.slice(...span) === block) return { action: 'unchanged', file };
|
|
167
|
+
const body = existing.replace(/\n+$/, '');
|
|
168
|
+
const next = span ? existing.slice(0, span[0]) + block + existing.slice(span[1])
|
|
169
|
+
: body + (body ? '\n\n' : '') + block + '\n';
|
|
170
|
+
// Replacing our own block loses nothing; the first addition to a user file gets a copy.
|
|
171
|
+
if (!span && existing.trim()) backupFile(file, existing);
|
|
172
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
173
|
+
writeFileSync(file, next);
|
|
174
|
+
return { action: 'written', file };
|
|
175
|
+
}
|
|
176
|
+
|
|
102
177
|
export function codexHarnessStatus({ root, scope = 'project' }) {
|
|
103
178
|
const { file } = codexHarnessPaths(root, scope);
|
|
104
179
|
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
package/src/codex-hooks.js
CHANGED
|
@@ -6,12 +6,12 @@ import { join, resolve } from 'node:path';
|
|
|
6
6
|
import { loadConfig, userLanguage } from './config.js';
|
|
7
7
|
import { koreanStyleInjection, koreanStyleEnabled } from './korean-style.js';
|
|
8
8
|
import { cohesionInjection } from './cohesion.js';
|
|
9
|
-
import { codexHarnessPaths } from './codex-harness.js';
|
|
9
|
+
import { codexHarnessPaths, syncCodexKoreanBlock } from './codex-harness.js';
|
|
10
10
|
import { writeViaTmp } from './state-file.js';
|
|
11
11
|
import { findProjectRoot } from './harness.js';
|
|
12
12
|
import { runCodexBrief } from './codex-brief.js';
|
|
13
13
|
import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
|
|
14
|
-
import { codexDelegationTool, codexRouteHint } from './codex-delegation.js';
|
|
14
|
+
import { codexDelegationTool, codexRouteHint, codexDelegateEnabled } from './codex-delegation.js';
|
|
15
15
|
import { bindCodexSubagent, closeStaleCodexRoutes, unsettledCodexRoutes } from './codex-ledger.js';
|
|
16
16
|
import { seedOfferBlock } from './seed-rules.js';
|
|
17
17
|
import { readCodexRouteScan, openCodexCandidates, shouldRescanCodex } from './codex-route-scan.js';
|
|
@@ -165,7 +165,7 @@ export function lintCodexTool(payload, { scope = 'all' } = {}) {
|
|
|
165
165
|
export async function codexHookOutput(event, payload, { cfg = loadConfig(), refreshLedger = defaultLedgerRefresh } = {}) {
|
|
166
166
|
if (!payload || typeof payload !== 'object') return null;
|
|
167
167
|
if (event === 'session-start' || event === 'subagent-start') {
|
|
168
|
-
if (event === 'subagent-start' && cfg
|
|
168
|
+
if (event === 'subagent-start' && !codexDelegateEnabled(cfg)) return null;
|
|
169
169
|
if (event === 'subagent-start') {
|
|
170
170
|
try { bindCodexSubagent(payload); } catch (e) { debug('codex:bind', e); }
|
|
171
171
|
}
|
|
@@ -193,8 +193,13 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
|
|
|
193
193
|
}
|
|
194
194
|
if (event === 'subagent-start') parts.push({ priority: 0, text:
|
|
195
195
|
'[Sprag Codex delegation]\nStay within the assigned task and any caller-provided budget. Return findings with file references and actual verification results; state unfinished work. Use only available Codex tools and configured roles. Do not infer a model tier or invent a tool-call budget.' });
|
|
196
|
-
|
|
197
|
-
|
|
196
|
+
// Codex reads the Korean guide from the global AGENTS.md, so the hook adds it
|
|
197
|
+
// only when that block was just written (this session may have loaded the old
|
|
198
|
+
// file) or could not be synced. Children read the same file.
|
|
199
|
+
let korean = null;
|
|
200
|
+
try { korean = syncCodexKoreanBlock({ cfg }); } catch (e) { debug('codex:korean-sync', e); }
|
|
201
|
+
if (korean?.action !== 'unchanged') parts.push({ priority: 2, spill: true, text: koreanStyleInjection({ cfg }) });
|
|
202
|
+
parts.push({ priority: 3, spill: true, text: await cohesionInjection({ cfg }) });
|
|
198
203
|
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) parts.push({ priority: 4, spill: true, text: codexDocumentNote() });
|
|
199
204
|
// Offers ask the user a question; a `codex exec` run has no one to answer it.
|
|
200
205
|
if (event === 'session-start' && payload.source !== 'compact' && !isCodexExecSession(payload.transcript_path)) {
|
|
@@ -206,7 +211,7 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
|
|
|
206
211
|
}
|
|
207
212
|
if (event === 'prompt') {
|
|
208
213
|
const parts = [];
|
|
209
|
-
if (cfg
|
|
214
|
+
if (codexDelegateEnabled(cfg)) {
|
|
210
215
|
// Before the hint for this prompt is recorded: a hint from an earlier turn
|
|
211
216
|
// that no child claimed is closed, and children that did run get priced.
|
|
212
217
|
try { closeStaleCodexRoutes(payload); } catch (e) { debug('codex:close-routes', e); }
|
package/src/codex-panel.js
CHANGED
|
@@ -18,7 +18,7 @@ import { codexDocumentTotals } from './codex-doc2md-ledger.js';
|
|
|
18
18
|
import { codexHookStatus } from './codex-installer.js';
|
|
19
19
|
import { koreanStyleEnabled } from './korean-style.js';
|
|
20
20
|
import { codexRoutingSavedTotals, loadCodexLedger } from './codex-ledger.js';
|
|
21
|
-
import { loadCodexModelRules } from './codex-delegation.js';
|
|
21
|
+
import { loadCodexModelRules, codexDelegateEnabled } from './codex-delegation.js';
|
|
22
22
|
import { codexRulesInReview } from './codex-rule-health.js';
|
|
23
23
|
import { readCodexRouteScan, openCodexCandidates } from './codex-route-scan.js';
|
|
24
24
|
import { codexBudgetProvider } from './codex-budget.js';
|
|
@@ -138,7 +138,7 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
138
138
|
lint: cfg?.koreanStyle?.lint || 'block',
|
|
139
139
|
cohesion: cfg?.cohesion?.enabled === true,
|
|
140
140
|
brief: cfg?.codex?.brief !== false,
|
|
141
|
-
delegate: cfg
|
|
141
|
+
delegate: codexDelegateEnabled(cfg),
|
|
142
142
|
doc2md: cfg?.codex?.doc2md !== false && process.env.CTS_NO_DOC2MD !== '1',
|
|
143
143
|
updatedAt: new Date() };
|
|
144
144
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { loadConfig, saveConfig } from '../config.js';
|
|
2
2
|
import { configureCodexHooks } from '../codex-installer.js';
|
|
3
3
|
import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule,
|
|
4
|
-
validateCodexSharedTarget, codexSharedTargets, codexDelegateMinContext, DEFAULT_DELEGATE_MIN_CONTEXT
|
|
4
|
+
validateCodexSharedTarget, codexSharedTargets, codexDelegateMinContext, DEFAULT_DELEGATE_MIN_CONTEXT,
|
|
5
|
+
autoCodexSharedTargets, codexDelegateEnabled } from '../codex-delegation.js';
|
|
5
6
|
import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
|
|
6
7
|
import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
|
|
7
8
|
import { codexRuleHealth, codexRulesInReview } from '../codex-rule-health.js';
|
|
@@ -23,13 +24,23 @@ function printShared(cfg, { details = false } = {}) {
|
|
|
23
24
|
for (const t of targets) console.log(`${t.provider} | ${t.from} | ${t.tier} -> ${t.model}${t.effort ? ` (${t.effort})` : ''}`);
|
|
24
25
|
}
|
|
25
26
|
// 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
|
-
|
|
28
|
-
const
|
|
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 Codex runs or lists)' : ''}`);
|
|
36
|
+
}
|
|
37
|
+
const missing = tiers.filter((tier) => !own(tier) && !auto.targets.some((a) => a.tier === tier));
|
|
29
38
|
if (missing.length) {
|
|
30
|
-
console.log(`Unmapped tiers for ${parent || 'any parent model'} on ${provider || 'unknown provider'}: ${missing.join(', ')}
|
|
39
|
+
console.log(`Unmapped tiers for ${parent || 'any parent model'} on ${provider || 'unknown provider'}: ${missing.join(', ')}`
|
|
40
|
+
+ `${autoOn && auto.reason ? ` (auto: ${auto.reason})` : ''}. `
|
|
31
41
|
+ `Set: sprag delegate shared map <T1|T2> --from ${parent || '<parent-model>'} --model <target-model> --agent codex`);
|
|
32
42
|
}
|
|
43
|
+
console.log(`Automatic tier models: ${autoOn ? 'on' : 'off'} (turn ${autoOn ? 'off' : 'on'}: sprag delegate shared auto ${autoOn ? 'off' : 'on'} --agent codex)`);
|
|
33
44
|
if (!parent && targets.length) console.log('Codex config names no default model; each mapping routes only sessions on its --from model.');
|
|
34
45
|
const minimum = codexDelegateMinContext(cfg);
|
|
35
46
|
console.log(`Shared prompt guidance starts at ${minimum} parent input tokens${cfg.codex?.delegateMinContext === minimum ? '' : ' (default)'}. `
|
|
@@ -48,8 +59,8 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
48
59
|
const cfg = loadConfig();
|
|
49
60
|
if (sub === 'shared') {
|
|
50
61
|
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
|
|
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');
|
|
53
64
|
}
|
|
54
65
|
if (hasFlag('--global') || hasFlag('--project')) throw new Error('Shared policies retain their approved scope; mappings do not change it.');
|
|
55
66
|
const provided = (flag) => args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
@@ -71,6 +82,9 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
71
82
|
if (!targets.some(same)) throw new Error('No matching shared tier mapping; specify --from and the correct provider.');
|
|
72
83
|
cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, targets: targets.filter((t) => !same(t)) } };
|
|
73
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' } };
|
|
74
88
|
} else if (action === 'min-context') {
|
|
75
89
|
const value = args[3];
|
|
76
90
|
if (value === 'default') {
|
|
@@ -86,7 +100,7 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
86
100
|
}
|
|
87
101
|
if (action !== 'status') saveConfig(cfg);
|
|
88
102
|
printShared(cfg, { details: true });
|
|
89
|
-
console.log(`Codex delegation is ${cfg
|
|
103
|
+
console.log(`Codex delegation is ${codexDelegateEnabled(cfg) ? 'on' : 'off; enable with sprag delegate on --agent codex'}. Target availability and lower cost must be verified with your provider.`);
|
|
90
104
|
return;
|
|
91
105
|
}
|
|
92
106
|
if (sub === 'rules') {
|
|
@@ -131,7 +145,7 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
131
145
|
if (target !== undefined) cfg.codex.delegateTarget = target;
|
|
132
146
|
saveConfig(cfg);
|
|
133
147
|
}
|
|
134
|
-
console.log(`Codex delegate: ${cfg
|
|
148
|
+
console.log(`Codex delegate: ${codexDelegateEnabled(cfg) ? 'on' : 'off'} (model routing and guidance)`);
|
|
135
149
|
console.log(`Default target: ${cfg.codex?.delegateTarget?.model || 'inherit (no override)'}`);
|
|
136
150
|
// Turning delegation off must work even when the rule file is damaged.
|
|
137
151
|
let ruleCount;
|
package/src/commands/codex.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createArgs } from '../cli-args.js';
|
|
2
2
|
import { configureCodexHooks } from '../codex-installer.js';
|
|
3
|
-
import { initCodexHarness, uninitCodexHarness, codexHarnessStatus } from '../codex-harness.js';
|
|
3
|
+
import { initCodexHarness, uninitCodexHarness, codexHarnessStatus, syncCodexKoreanBlock } from '../codex-harness.js';
|
|
4
4
|
import { findProjectRoot, harnessPromote, harnessListRules, harnessRmRule, harnessPrune } from '../harness.js';
|
|
5
5
|
import { loadConfig, saveConfig } from '../config.js';
|
|
6
6
|
import { resolve } from 'node:path';
|
|
@@ -32,6 +32,7 @@ Usage: sprag <command> --agent codex [options]
|
|
|
32
32
|
Optional --provider <id> and --effort <level>
|
|
33
33
|
delegate shared unmap T1|T2 Remove a mapping: --from <parent> [--provider <id>]
|
|
34
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
|
|
35
36
|
route-scan [--refresh] Discover recurring simple turns in local logs
|
|
36
37
|
route-scan dismiss R<N> Dismiss a candidate without registering a rule
|
|
37
38
|
route-scan savings Attributed routing estimates; --refresh or --format json
|
|
@@ -407,6 +408,10 @@ export async function run({ args, version }) {
|
|
|
407
408
|
if (process.env.CTS_NO_HARNESS !== '1') {
|
|
408
409
|
const h = initCodexHarness({ root, scope: 'global' });
|
|
409
410
|
console.log(`Codex harness: ${h.file}`);
|
|
411
|
+
try {
|
|
412
|
+
const k = syncCodexKoreanBlock({ cfg });
|
|
413
|
+
console.log(`Codex korean-style: ${k.file} (${k.action})`);
|
|
414
|
+
} catch (e) { debug('codex:korean-sync', e); }
|
|
410
415
|
}
|
|
411
416
|
try {
|
|
412
417
|
const { preparePresetRatchet } = await import('../preset-ratchet.js');
|
|
@@ -418,7 +423,16 @@ export async function run({ args, version }) {
|
|
|
418
423
|
console.log('After approval, start or resume Codex and run: sprag panel doctor --agent codex');
|
|
419
424
|
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).');
|
|
420
425
|
console.log('Existing config.toml, Claude Code settings, and ratchet rules are preserved.');
|
|
421
|
-
|
|
426
|
+
try {
|
|
427
|
+
const { codexModelName, codexProviderName } = await import('../agent.js');
|
|
428
|
+
const { autoCodexSharedTargets } = await import('../codex-delegation.js');
|
|
429
|
+
const parent = codexModelName(), provider = codexProviderName();
|
|
430
|
+
const auto = parent ? autoCodexSharedTargets({ model: parent, provider }) : { targets: [], reason: 'Codex config names no default model' };
|
|
431
|
+
console.log(auto.targets.length
|
|
432
|
+
? `Delegation tiers for ${parent} on ${provider} (auto): ${auto.targets.map((t) => `${t.tier} -> ${t.model}`).join(', ')}. Change: sprag delegate shared status --agent codex`
|
|
433
|
+
: `Delegation tiers: not resolved yet (${auto.reason}). They resolve once Codex lists or has run a cheaper priced model, or map one: sprag delegate shared map T2 --from <parent> --model <target> --agent codex`);
|
|
434
|
+
} catch (e) { debug('codex:auto-tiers', e); }
|
|
435
|
+
console.log('First-use delegation: sprag seed --agent codex to approve policies; delegation is on by default (turn off: sprag delegate off --agent codex). No Claude installation is required.');
|
|
422
436
|
console.log('Korean guidance is opt-in: sprag korean on --agent codex');
|
|
423
437
|
return;
|
|
424
438
|
}
|
|
@@ -503,6 +517,7 @@ export async function run({ args, version }) {
|
|
|
503
517
|
const { setKoreanStyleEnabled } = await import('../korean-style.js');
|
|
504
518
|
if (args[1] === 'on') configureCodexHooks();
|
|
505
519
|
setKoreanStyleEnabled(args[1] === 'on');
|
|
520
|
+
try { syncCodexKoreanBlock({ cfg: loadConfig() }); } catch (e) { debug('codex:korean-sync', e); }
|
|
506
521
|
console.log(`Korean guidance: ${args[1]} (shared Sprag preference). Review/trust the Sprag hooks in Codex /hooks.`);
|
|
507
522
|
return;
|
|
508
523
|
}
|
package/src/commands/doc2md.js
CHANGED
|
@@ -104,6 +104,7 @@ export async function run({ args, hasFlag }) {
|
|
|
104
104
|
console.log(res.action === 'skipped'
|
|
105
105
|
? `✗ ${res.reason}`
|
|
106
106
|
: `✓ Read hook ${res.action} (${res.path})`);
|
|
107
|
+
if (res.modReason) console.log(` mod: not registered — ${res.modReason}`);
|
|
107
108
|
// A registered hook with no converter behind it does nothing at all, and
|
|
108
109
|
// says nothing about it either, which reads as a broken feature. Offer the
|
|
109
110
|
// one command that closes the gap right where the gap is visible.
|
|
@@ -160,6 +161,17 @@ export async function run({ args, hasFlag }) {
|
|
|
160
161
|
console.log(` converter: ${python ? `markitdown via ${python}` : `not installed — ${doc2md.INSTALL_HINT}`}`);
|
|
161
162
|
console.log(` cache: ${dir} (${cached} file(s))`);
|
|
162
163
|
console.log(` hook: ${hookRegistered() ? 'registered on Read' : 'not registered'}`);
|
|
164
|
+
// The mod has the same two halves as the hook: `doc2md on` can leave it out
|
|
165
|
+
// (a shell value of the variable, a /plugin copy) while the hook goes in.
|
|
166
|
+
// A path the user added to their own shell variable counts as registered.
|
|
167
|
+
{
|
|
168
|
+
const { modDirsState, modDir, DOC2MD_MOD } = await import('../installer.js');
|
|
169
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
170
|
+
const inShell = (process.env.CLAUDE_CODE_PLUGIN_DIRS || '').split(sep).includes(modDir(DOC2MD_MOD));
|
|
171
|
+
console.log(` mod: ${modDirsState()[DOC2MD_MOD] || inShell
|
|
172
|
+
? 'registered (Claude Code 2.1.287+ reads the Markdown in the same Read)'
|
|
173
|
+
: 'not registered — `doc2md on` registers it or says why not'}`);
|
|
174
|
+
}
|
|
163
175
|
console.log('');
|
|
164
176
|
if (!python) console.log(`Install the converter: ${doc2md.INSTALL_HINT}`);
|
|
165
177
|
console.log('Enable with: sprag doc2md on');
|
package/src/commands/install.js
CHANGED
|
@@ -85,6 +85,8 @@ export async function run({ hasFlag }) {
|
|
|
85
85
|
print('skill', r.skill);
|
|
86
86
|
print('SessionStart hook (route-scan)', r.sessionStartHook);
|
|
87
87
|
print('PostToolUse hook (delegation rescan)', r.delegationHook);
|
|
88
|
+
print('mod (delegation meter, Claude Code 2.1.287+)', r.delegationMeterMod);
|
|
89
|
+
if (r.delegationMeterMod.reason) console.log(` ${r.delegationMeterMod.reason}`);
|
|
88
90
|
print('UserPromptSubmit hook (brief)', r.briefHook);
|
|
89
91
|
{
|
|
90
92
|
let s = r.statusline;
|
|
@@ -333,6 +335,7 @@ export async function run({ hasFlag }) {
|
|
|
333
335
|
console.log(res.action === 'skipped'
|
|
334
336
|
? ` doc2md: ${res.reason}`
|
|
335
337
|
: ` doc2md: Read/Write hook ${res.action} (${res.path})`);
|
|
338
|
+
if (res.modReason) console.log(` doc2md: mod not registered — ${res.modReason}`);
|
|
336
339
|
console.log(lang === 'ko'
|
|
337
340
|
? ` 대상 형식: ${doc2md.TARGET_EXTENSIONS.join(' ')} — 모델이 읽기 전에 Markdown으로 변환합니다.`
|
|
338
341
|
: ` formats: ${doc2md.TARGET_EXTENSIONS.join(' ')} — converted to Markdown before the model reads them.`);
|
|
@@ -449,6 +452,11 @@ export async function run({ hasFlag }) {
|
|
|
449
452
|
if (process.env.CTS_NO_HARNESS !== '1') {
|
|
450
453
|
const h = initCodexHarness({ scope: 'global' });
|
|
451
454
|
console.log(` codex: harness ${h.file}`);
|
|
455
|
+
try {
|
|
456
|
+
const { syncCodexKoreanBlock } = await import('../codex-harness.js');
|
|
457
|
+
const k = syncCodexKoreanBlock({ cfg: config });
|
|
458
|
+
console.log(` codex: korean-style ${k.action} (${k.file})`);
|
|
459
|
+
} catch (e) { debug('install:codex-korean', e); }
|
|
452
460
|
}
|
|
453
461
|
try { (await import('../preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch (e) { debug('install:codex-preset', e); }
|
|
454
462
|
if (!manual) {
|
package/src/commands/korean.js
CHANGED
|
@@ -53,6 +53,7 @@ export async function run({ args, hasFlag }) {
|
|
|
53
53
|
const sub = args[1] || 'status';
|
|
54
54
|
const ks = await import('../korean-style.js');
|
|
55
55
|
const { userLanguage, loadConfig, saveConfig } = await import('../config.js');
|
|
56
|
+
const { debug } = await import('../debug.js');
|
|
56
57
|
const lang = userLanguage();
|
|
57
58
|
|
|
58
59
|
// PostToolUse hook. Claude Code feeds the tool-call payload on stdin; we
|
|
@@ -222,6 +223,8 @@ export async function run({ args, hasFlag }) {
|
|
|
222
223
|
if (sub === 'on' || sub === 'off') {
|
|
223
224
|
const enabled = sub === 'on';
|
|
224
225
|
ks.setKoreanStyleEnabled(enabled);
|
|
226
|
+
// Codex reads the guide from its global AGENTS.md; keep that block in step.
|
|
227
|
+
try { (await import('../codex-harness.js')).syncCodexKoreanBlock({ cfg: loadConfig() }); } catch (e) { debug('korean:codex-sync', e); }
|
|
225
228
|
const {
|
|
226
229
|
installKoreanLintHook, removeKoreanLintHook,
|
|
227
230
|
installReplyLanguageHook, removeReplyLanguageHook,
|
package/src/installer.js
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync, rmSync, copyFileSync, renameSync, realpathSync, statSync } from 'node:fs';
|
|
17
17
|
import { loadConfig, saveConfig } from './config.js';
|
|
18
18
|
import { CLI_NAME, LEGACY_CLI_NAME } from './cli-name.js';
|
|
19
|
-
import { join } from 'node:path';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
20
21
|
import { claudeUserDir, userDataDir } from './paths.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -692,6 +693,122 @@ export function removeReplyLanguageHook() {
|
|
|
692
693
|
return { path: file, action: 'removed' };
|
|
693
694
|
}
|
|
694
695
|
|
|
696
|
+
// Mods: plugin folders shipped in this package's mod/ directory, which Claude
|
|
697
|
+
// Code (2.1.287 and later) runs inside its own process. They are loaded through
|
|
698
|
+
// CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, the one
|
|
699
|
+
// way to load a plugin folder that needs neither a marketplace nor the `claude`
|
|
700
|
+
// CLI at install time, so `npm i -g` stays the whole setup. An older Claude
|
|
701
|
+
// Code ignores the variable, and the settings hooks keep doing the work alone.
|
|
702
|
+
//
|
|
703
|
+
// The variable holds absolute paths, so an entry goes stale when the package
|
|
704
|
+
// moves (a Node version switch under nvm puts it in another global root). An
|
|
705
|
+
// entry counts as ours by its last two segments, `mod/<a name below>`, and
|
|
706
|
+
// adding a mod first drops every older path to it, so a reinstall repairs the
|
|
707
|
+
// list instead of growing it. Other folders in the variable are left alone.
|
|
708
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
709
|
+
const PLUGIN_DIRS_ENV = 'CLAUDE_CODE_PLUGIN_DIRS';
|
|
710
|
+
export const DOC2MD_MOD = 'sprag-doc2md';
|
|
711
|
+
export const DELEGATION_METER_MOD = 'sprag-delegation-meter';
|
|
712
|
+
export const MOD_NAMES = [DOC2MD_MOD, DELEGATION_METER_MOD];
|
|
713
|
+
|
|
714
|
+
export function modDir(name) {
|
|
715
|
+
return join(PACKAGE_ROOT, 'mod', name);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const pluginDirsSeparator = () => (process.platform === 'win32' ? ';' : ':');
|
|
719
|
+
|
|
720
|
+
function isOurModEntry(entry, names) {
|
|
721
|
+
const parts = String(entry).split(/[\\/]/).filter(Boolean);
|
|
722
|
+
return parts.length >= 2 && parts[parts.length - 2] === 'mod' && names.includes(parts[parts.length - 1]);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function pluginDirEntries(settings) {
|
|
726
|
+
const value = isPlainObject(settings.env) ? settings.env[PLUGIN_DIRS_ENV] : undefined;
|
|
727
|
+
return typeof value === 'string' ? value.split(pluginDirsSeparator()).filter(Boolean) : [];
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Point CLAUDE_CODE_PLUGIN_DIRS at this package's copy of one mod, in place on
|
|
732
|
+
* a settings object read by the caller. Returns true when the object changed,
|
|
733
|
+
* false when it already held exactly that, or a reason string when the mod is
|
|
734
|
+
* better left out: `env` is not something this can safely extend, or the user
|
|
735
|
+
* already loads mods in a way a write here would break or duplicate.
|
|
736
|
+
*
|
|
737
|
+
* `inherited` is the variable as this process received it. When settings.json
|
|
738
|
+
* has no value of its own, that is the user's shell value. Claude Code's docs
|
|
739
|
+
* say a settings `env` entry replaces the inherited value in most sessions, so
|
|
740
|
+
* a value written here would stop the user's own mods from loading; were this
|
|
741
|
+
* variable one of the exceptions, ours would be ignored instead. Either way
|
|
742
|
+
* the write is wrong, so the case is refused with the path to add by hand.
|
|
743
|
+
*/
|
|
744
|
+
function addModDir(settings, name, inherited = process.env[PLUGIN_DIRS_ENV]) {
|
|
745
|
+
if (settings.env !== undefined && !isPlainObject(settings.env)) return 'env is not an object — fix settings.json manually';
|
|
746
|
+
const current = settings.env?.[PLUGIN_DIRS_ENV];
|
|
747
|
+
if (current !== undefined && typeof current !== 'string') return `env.${PLUGIN_DIRS_ENV} is not a string — fix settings.json manually`;
|
|
748
|
+
// A copy installed through /plugin would run beside this one, every hook twice.
|
|
749
|
+
const installed = isPlainObject(settings.enabledPlugins)
|
|
750
|
+
? Object.keys(settings.enabledPlugins).find((id) => id.split('@')[0] === name && settings.enabledPlugins[id])
|
|
751
|
+
: undefined;
|
|
752
|
+
if (installed) return `${name} is already installed as the plugin ${installed} — that copy stays the one that runs`;
|
|
753
|
+
const target = modDir(name);
|
|
754
|
+
const sep = pluginDirsSeparator();
|
|
755
|
+
if (current === undefined && typeof inherited === 'string') {
|
|
756
|
+
const theirs = inherited.split(sep).filter((e) => e && !isOurModEntry(e, MOD_NAMES));
|
|
757
|
+
if (theirs.length) {
|
|
758
|
+
return `${PLUGIN_DIRS_ENV} is set in your shell (${theirs.join(sep)}), and a value in settings.json would replace it — add ${target} to that variable yourself`;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const before = pluginDirEntries(settings);
|
|
762
|
+
const stale = before.filter((e) => e !== target && isOurModEntry(e, [name]));
|
|
763
|
+
if (before.includes(target) && stale.length === 0) return false;
|
|
764
|
+
const entries = [...before.filter((e) => !isOurModEntry(e, [name])), target];
|
|
765
|
+
settings.env = { ...(settings.env || {}), [PLUGIN_DIRS_ENV]: entries.join(sep) };
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Drop every entry of the named mods, wherever it points. The variable goes
|
|
771
|
+
* when nothing is left in it, and `env` when this emptied it. Returns whether
|
|
772
|
+
* the object changed.
|
|
773
|
+
*/
|
|
774
|
+
function dropModDirs(settings, names) {
|
|
775
|
+
const before = pluginDirEntries(settings);
|
|
776
|
+
const kept = before.filter((e) => !isOurModEntry(e, names));
|
|
777
|
+
if (kept.length === before.length) return false;
|
|
778
|
+
if (kept.length) settings.env[PLUGIN_DIRS_ENV] = kept.join(pluginDirsSeparator());
|
|
779
|
+
else delete settings.env[PLUGIN_DIRS_ENV];
|
|
780
|
+
if (Object.keys(settings.env).length === 0) delete settings.env;
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Which mods settings.json currently loads from this package's own copy. */
|
|
785
|
+
export function modDirsState() {
|
|
786
|
+
const read = readSettings(join(claudeUserDir(), 'settings.json'));
|
|
787
|
+
const entries = read.state === 'ok' ? pluginDirEntries(read.settings) : [];
|
|
788
|
+
return Object.fromEntries(MOD_NAMES.map((name) => [name, entries.includes(modDir(name))]));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// The delegation meter only observes (see mod/sprag-delegation-meter), so it
|
|
792
|
+
// ships on with the rest of installAll(), beside the PostToolUse hook that
|
|
793
|
+
// feeds the transcript-based ledger it is measured against. uninstallAll()
|
|
794
|
+
// takes it out with every other mod.
|
|
795
|
+
export function installDelegationMeterMod() {
|
|
796
|
+
const dir = claudeUserDir();
|
|
797
|
+
const file = join(dir, 'settings.json');
|
|
798
|
+
mkdirSync(dir, { recursive: true });
|
|
799
|
+
const read = readSettings(file);
|
|
800
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
801
|
+
const settings = read.settings;
|
|
802
|
+
const changed = addModDir(settings, DELEGATION_METER_MOD);
|
|
803
|
+
if (typeof changed === 'string') return { path: file, action: 'skipped', reason: changed };
|
|
804
|
+
if (!changed) return { path: file, action: 'exists' };
|
|
805
|
+
const writeProblem = writeSettings(file, settings);
|
|
806
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
807
|
+
// The action names what happened to the file, as installDelegationGuardHook's
|
|
808
|
+
// does: one entry added to a settings.json already there is an update.
|
|
809
|
+
return { path: file, action: read.state === 'absent' ? 'created' : 'updated' };
|
|
810
|
+
}
|
|
811
|
+
|
|
695
812
|
// Registers the PreToolUse hook that converts attached documents to Markdown
|
|
696
813
|
// before the model reads them. PreToolUse rather than PostToolUse because the
|
|
697
814
|
// point is to intervene before a pptx lands in the context window; afterwards
|
|
@@ -775,14 +892,22 @@ export function installDoc2mdHook() {
|
|
|
775
892
|
}
|
|
776
893
|
}
|
|
777
894
|
|
|
778
|
-
|
|
895
|
+
// The mod answers a document Read with the converted Markdown in the same
|
|
896
|
+
// call, where the hook above can only refuse it (mod/sprag-doc2md). It rides
|
|
897
|
+
// this hook's on and off so the two never disagree about doc2md being on. An
|
|
898
|
+
// `env` this cannot extend costs only the mod: the hooks still go in.
|
|
899
|
+
const modChange = addModDir(settings, DOC2MD_MOD);
|
|
900
|
+
const addedMod = modChange === true;
|
|
901
|
+
const modNote = typeof modChange === 'string' ? { modReason: modChange } : {};
|
|
902
|
+
|
|
903
|
+
if (!hasReadHook || addedPrompt || addedWriteGuard || addedMod) {
|
|
779
904
|
{
|
|
780
905
|
const writeProblem = writeSettings(file, settings);
|
|
781
906
|
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
782
907
|
}
|
|
783
|
-
return { path: file, action: hasReadHook ? 'updated' : 'created' };
|
|
908
|
+
return { path: file, action: hasReadHook ? 'updated' : 'created', ...modNote };
|
|
784
909
|
}
|
|
785
|
-
return { path: file, action: 'exists' };
|
|
910
|
+
return { path: file, action: 'exists', ...modNote };
|
|
786
911
|
}
|
|
787
912
|
|
|
788
913
|
export function removeDoc2mdHook() {
|
|
@@ -805,6 +930,7 @@ export function removeDoc2mdHook() {
|
|
|
805
930
|
if (kept.length === 0) delete settings.hooks[event];
|
|
806
931
|
else settings.hooks[event] = kept;
|
|
807
932
|
}
|
|
933
|
+
if (dropModDirs(settings, [DOC2MD_MOD])) touched = true;
|
|
808
934
|
if (!touched) return { path: file, action: 'absent' };
|
|
809
935
|
{
|
|
810
936
|
const writeProblem = writeSettings(file, settings);
|
|
@@ -1022,6 +1148,10 @@ export function uninstallAll({ purge = false } = {}) {
|
|
|
1022
1148
|
}
|
|
1023
1149
|
if (isPlainObject(settings.hooks) && Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
1024
1150
|
|
|
1151
|
+
// Mods are found by their folder name, so a path left by an older install
|
|
1152
|
+
// location goes with the current one.
|
|
1153
|
+
if (dropModDirs(settings, MOD_NAMES)) result.removed.push(`env.${PLUGIN_DIRS_ENV} (mods)`);
|
|
1154
|
+
|
|
1025
1155
|
// Nothing of ours in the file means nothing to rewrite (and no backup to churn).
|
|
1026
1156
|
if (result.removed.length > 0) {
|
|
1027
1157
|
const writeProblem = writeSettings(file, settings);
|
|
@@ -1116,6 +1246,7 @@ export function installAll({ force = false } = {}) {
|
|
|
1116
1246
|
statusline: installStatusline({ force }),
|
|
1117
1247
|
sessionStartHook: installSessionStartHook(),
|
|
1118
1248
|
delegationHook: installDelegationHook(),
|
|
1249
|
+
delegationMeterMod: installDelegationMeterMod(),
|
|
1119
1250
|
briefHook: installBriefHook(),
|
|
1120
1251
|
cacheMonitorMigration: migrateLegacyCacheMonitorHook(),
|
|
1121
1252
|
legacy: removeLegacyCommand(),
|
package/src/seed-rules.js
CHANGED
|
@@ -150,8 +150,8 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(),
|
|
|
150
150
|
|
|
151
151
|
function codexSeedText(p, lang) {
|
|
152
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}.
|
|
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
155
|
return composeRuleText(text, p, lang);
|
|
156
156
|
}
|
|
157
157
|
|
|
@@ -292,7 +292,7 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
|
|
|
292
292
|
' 3. Decide one at a time: ask about each rule below and run its command as soon as they answer',
|
|
293
293
|
` 4. Register none: ${cmd('skip all')} (never offered again)`,
|
|
294
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
|
|
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.',
|
|
296
296
|
...pending.map((s) => ` [${s.id}] ${s.ruleText}`),
|
|
297
297
|
].join('\n');
|
|
298
298
|
}
|