sprag-cli 3.62.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 +10 -5
- package/docs/CODEX.md +20 -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 +38 -9
- 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 +4 -4
- package/src/commands/codex.js +8 -3
- 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/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,7 +164,6 @@ 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
|
|
161
|
-
sprag delegate on --agent codex
|
|
162
167
|
# 자동으로 정해진 난도별 모델을 바꾸고 싶을 때만:
|
|
163
168
|
sprag delegate shared map T2 --from PARENT_MODEL --model SMALL_MODEL --effort high --agent codex
|
|
164
169
|
sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort medium --agent codex
|
|
@@ -169,10 +174,10 @@ sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort m
|
|
|
169
174
|
|
|
170
175
|
`map` 줄은 선택 사항입니다. 매핑하지 않은 난도는 Claude Code가 `haiku`와 `sonnet`을 실제 모델로
|
|
171
176
|
풀듯이 자동으로 정해집니다. 부모 모델의 공급자에서 가격이 매겨져 있고(게이트웨이 `/model/info` 가격,
|
|
172
|
-
OpenAI 직접 호출이면 공개 가격), 부모보다 싸며, 이 사용자의 Codex 세션에서 이미
|
|
173
|
-
가장 싼 모델이 T2, 그다음 모델이 T1이 됩니다. 그런 모델이 하나뿐이면 T1은 메인 모델이 처리하고, 하나도
|
|
174
|
-
없으면 두 난도 모두 메인 모델이 처리합니다.
|
|
175
|
-
|
|
177
|
+
OpenAI 직접 호출이면 공개 가격), 부모보다 싸며, 이 사용자의 Codex 세션에서 이미 실행됐거나(OpenAI 기본 공급자라면 Codex가 이 계정에 보여 주는
|
|
178
|
+
`models_cache.json` 모델 목록에 있는) 모델 가운데 가장 싼 모델이 T2, 그다음 모델이 T1이 됩니다. 그런 모델이 하나뿐이면 T1은 메인 모델이 처리하고, 하나도
|
|
179
|
+
없으면 두 난도 모두 메인 모델이 처리합니다. 위임은 기본으로 켜져 있고(`sprag delegate off --agent codex`로 끕니다), 자동 결정은 위임이 켜진 동안
|
|
180
|
+
동작합니다. 아무것도 저장하지 않으므로 가격이나 실행 이력, 모델 목록이 바뀌면 그대로 따라갑니다.
|
|
176
181
|
`delegate shared status`와 설치 출력에 정해진 모델이 표시되고, 정해지지 않으면 그 이유가 표시됩니다.
|
|
177
182
|
끄려면 `sprag delegate shared auto off --agent codex`를 실행하십시오.
|
|
178
183
|
|
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,7 +231,6 @@ 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
|
|
226
|
-
sprag delegate on --agent codex
|
|
227
234
|
# optional, to override the automatic tier models:
|
|
228
235
|
sprag delegate shared map T2 --from PARENT_MODEL --model SMALL_MODEL --effort high --agent codex
|
|
229
236
|
sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort medium --agent codex
|
|
@@ -232,11 +239,13 @@ sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort m
|
|
|
232
239
|
The map lines are optional. A tier without a mapping resolves automatically,
|
|
233
240
|
the way Claude Code resolves `haiku` and `sonnet`: the cheapest model that is
|
|
234
241
|
priced on the parent's provider (gateway `/model/info` prices, or OpenAI list
|
|
235
|
-
prices for direct calls), costs less than the parent, and
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
240
249
|
install output show the resolved models and, when nothing resolves, why.
|
|
241
250
|
`sprag delegate shared auto off --agent codex` turns it off.
|
|
242
251
|
|
|
@@ -352,7 +361,8 @@ after an hour. `--refresh` bypasses that schedule.
|
|
|
352
361
|
|
|
353
362
|
Codex 0.159.2 keeps about 2,450 tokens of one hook's context and cuts the
|
|
354
363
|
middle of anything longer. SessionStart therefore fits its parts whole within a
|
|
355
|
-
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),
|
|
356
366
|
cohesion guidance, the document note, then offers. Rules and guidance that do
|
|
357
367
|
not fit are written to a per-project file under the Sprag data directory, and
|
|
358
368
|
the context opens with an instruction to read it. `codex exec` runs, recognized
|
|
@@ -364,8 +374,8 @@ compatible Codex ratchet presets. Ratchet and skip decisions remain agent-specif
|
|
|
364
374
|
an approved common policy suppresses duplicate offers within its scope in either
|
|
365
375
|
agent. Both seed acceptance and
|
|
366
376
|
route approval require the user's choice of global or project scope. A project
|
|
367
|
-
route is saved for the project where it was observed.
|
|
368
|
-
`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.
|
|
369
379
|
|
|
370
380
|
## Reports
|
|
371
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
|
@@ -19,6 +19,13 @@ const MARKER = '<!-- sprag:codex:delegation -->';
|
|
|
19
19
|
const safeId = (value) => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,255}$/.test(value);
|
|
20
20
|
const validEffort = (value) => value === undefined || ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(value);
|
|
21
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
|
+
|
|
22
29
|
export function validateCodexTarget(model, effort) {
|
|
23
30
|
if (!safeId(model)) throw new Error('Provide a literal Codex model ID.');
|
|
24
31
|
if (!validEffort(effort)) {
|
|
@@ -56,13 +63,30 @@ function observedCodexModels(dir, provider) {
|
|
|
56
63
|
} catch { return []; }
|
|
57
64
|
}
|
|
58
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
|
+
|
|
59
80
|
/**
|
|
60
81
|
* The models Codex resolves T2 and T1 to when the user mapped none, the way
|
|
61
|
-
* Claude Code resolves `haiku` and `sonnet`.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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 }.
|
|
66
90
|
*/
|
|
67
91
|
export function autoCodexSharedTargets({ dir = userDataDir(), home = codexUserDir(), model, provider, now = Date.now() } = {}) {
|
|
68
92
|
if (!safeId(model) || !safeId(provider)) return { targets: [], reason: 'unknown parent model or provider' };
|
|
@@ -71,10 +95,15 @@ export function autoCodexSharedTargets({ dir = userDataDir(), home = codexUserDi
|
|
|
71
95
|
const cost = (m) => codexRunCost(RANKING_USAGE, book.prices[m]);
|
|
72
96
|
const parent = cost(model);
|
|
73
97
|
if (parent === null) return { targets: [], reason: `${model} is not priced on ${provider}` };
|
|
74
|
-
const
|
|
98
|
+
const available = [...new Set([...observedCodexModels(dir, provider), ...(provider === 'openai' ? catalogCodexModels(home) : [])])];
|
|
99
|
+
const cheaper = available
|
|
75
100
|
.filter((m) => m !== model && safeId(m)).map((m) => ({ model: m, usd: cost(m) }))
|
|
76
101
|
.filter((x) => x.usd !== null && x.usd < parent).sort((a, b) => a.usd - b.usd || a.model.localeCompare(b.model));
|
|
77
|
-
if (!cheaper.length)
|
|
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
|
+
}
|
|
78
107
|
const targets = cheaper.slice(0, 2).map((x, i) => ({ tier: i === 0 ? 'T2' : 'T1', from: model, model: x.model, provider, auto: true }));
|
|
79
108
|
return { targets, reason: null };
|
|
80
109
|
}
|
|
@@ -86,7 +115,7 @@ export function autoCodexSharedTargets({ dir = userDataDir(), home = codexUserDi
|
|
|
86
115
|
*/
|
|
87
116
|
export function codexTierTargets({ cfg = loadConfig(), dir = userDataDir(), home = codexUserDir(), model, provider = codexProviderName() } = {}) {
|
|
88
117
|
const explicit = codexSharedTargets(cfg).filter((t) => t.from === model && t.provider === provider);
|
|
89
|
-
if (cfg
|
|
118
|
+
if (!codexDelegateEnabled(cfg) || cfg.codex?.sharedRules?.auto === false) return explicit;
|
|
90
119
|
const auto = autoCodexSharedTargets({ dir, home, model, provider }).targets
|
|
91
120
|
.filter((t) => !explicit.some((e) => e.tier === t.tier));
|
|
92
121
|
return [...explicit, ...auto];
|
|
@@ -307,7 +336,7 @@ function customRole(role, root, home) {
|
|
|
307
336
|
// Kept for Codex versions that do send spawn_agent through PreToolUse; on
|
|
308
337
|
// 0.159.2 the hook never fires for it, so codexRouteHint above is the live path.
|
|
309
338
|
export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules: given, dir = userDataDir(), record = recordCodexDelegation } = {}) {
|
|
310
|
-
if (cfg
|
|
339
|
+
if (!codexDelegateEnabled(cfg)) return null;
|
|
311
340
|
const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
|
|
312
341
|
if (!['spawn_agent', 'Agent'].includes(tool)) return null;
|
|
313
342
|
const input = payload.tool_input;
|
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
|
};
|
|
@@ -2,7 +2,7 @@ import { loadConfig, saveConfig } from '../config.js';
|
|
|
2
2
|
import { configureCodexHooks } from '../codex-installer.js';
|
|
3
3
|
import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule,
|
|
4
4
|
validateCodexSharedTarget, codexSharedTargets, codexDelegateMinContext, DEFAULT_DELEGATE_MIN_CONTEXT,
|
|
5
|
-
autoCodexSharedTargets } from '../codex-delegation.js';
|
|
5
|
+
autoCodexSharedTargets, codexDelegateEnabled } from '../codex-delegation.js';
|
|
6
6
|
import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
|
|
7
7
|
import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
|
|
8
8
|
import { codexRuleHealth, codexRulesInReview } from '../codex-rule-health.js';
|
|
@@ -32,7 +32,7 @@ function printShared(cfg, { details = false } = {}) {
|
|
|
32
32
|
const tiers = [...new Set(policies.map((r) => r.tier))];
|
|
33
33
|
for (const tier of tiers) {
|
|
34
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
|
|
35
|
+
if (t) console.log(`${tier} -> ${t.model}${t.effort ? ` (${t.effort})` : ''}${t.auto ? ' (auto: cheaper priced model Codex runs or lists)' : ''}`);
|
|
36
36
|
}
|
|
37
37
|
const missing = tiers.filter((tier) => !own(tier) && !auto.targets.some((a) => a.tier === tier));
|
|
38
38
|
if (missing.length) {
|
|
@@ -100,7 +100,7 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
100
100
|
}
|
|
101
101
|
if (action !== 'status') saveConfig(cfg);
|
|
102
102
|
printShared(cfg, { details: true });
|
|
103
|
-
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.`);
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
if (sub === 'rules') {
|
|
@@ -145,7 +145,7 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
145
145
|
if (target !== undefined) cfg.codex.delegateTarget = target;
|
|
146
146
|
saveConfig(cfg);
|
|
147
147
|
}
|
|
148
|
-
console.log(`Codex delegate: ${cfg
|
|
148
|
+
console.log(`Codex delegate: ${codexDelegateEnabled(cfg) ? 'on' : 'off'} (model routing and guidance)`);
|
|
149
149
|
console.log(`Default target: ${cfg.codex?.delegateTarget?.model || 'inherit (no override)'}`);
|
|
150
150
|
// Turning delegation off must work even when the rule file is damaged.
|
|
151
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';
|
|
@@ -408,6 +408,10 @@ export async function run({ args, version }) {
|
|
|
408
408
|
if (process.env.CTS_NO_HARNESS !== '1') {
|
|
409
409
|
const h = initCodexHarness({ root, scope: 'global' });
|
|
410
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); }
|
|
411
415
|
}
|
|
412
416
|
try {
|
|
413
417
|
const { preparePresetRatchet } = await import('../preset-ratchet.js');
|
|
@@ -426,9 +430,9 @@ export async function run({ args, version }) {
|
|
|
426
430
|
const auto = parent ? autoCodexSharedTargets({ model: parent, provider }) : { targets: [], reason: 'Codex config names no default model' };
|
|
427
431
|
console.log(auto.targets.length
|
|
428
432
|
? `Delegation tiers for ${parent} on ${provider} (auto): ${auto.targets.map((t) => `${t.tier} -> ${t.model}`).join(', ')}. Change: sprag delegate shared status --agent codex`
|
|
429
|
-
: `Delegation tiers: not resolved yet (${auto.reason}). They resolve once
|
|
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`);
|
|
430
434
|
} catch (e) { debug('codex:auto-tiers', e); }
|
|
431
|
-
console.log('First-use delegation: sprag seed --agent codex to approve policies
|
|
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.');
|
|
432
436
|
console.log('Korean guidance is opt-in: sprag korean on --agent codex');
|
|
433
437
|
return;
|
|
434
438
|
}
|
|
@@ -513,6 +517,7 @@ export async function run({ args, version }) {
|
|
|
513
517
|
const { setKoreanStyleEnabled } = await import('../korean-style.js');
|
|
514
518
|
if (args[1] === 'on') configureCodexHooks();
|
|
515
519
|
setKoreanStyleEnabled(args[1] === 'on');
|
|
520
|
+
try { syncCodexKoreanBlock({ cfg: loadConfig() }); } catch (e) { debug('codex:korean-sync', e); }
|
|
516
521
|
console.log(`Korean guidance: ${args[1]} (shared Sprag preference). Review/trust the Sprag hooks in Codex /hooks.`);
|
|
517
522
|
return;
|
|
518
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(),
|