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 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
- 없으면 두 난도 모두 메인 모델이 처리합니다. 자동 결정은 `delegate on` 상태에서만 동작하고, route-scan이
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 independently opt-in via `delegate on`. As of Codex 0.159.2,
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 has already run in
236
- your Codex sessions becomes T2, and the next one up becomes T1. With a single
237
- such model T1 stays on the main agent; with none, both do. Resolution needs
238
- `delegate on`, uses the route scan's observed models, and follows price or
239
- history changes without storing anything. `delegate shared status` and the
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. Enable delegation with
368
- `sprag delegate on --agent codex` and trust the hooks before expecting rules to run.
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
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "sprag-doc2md",
3
+ "version": "0.1.0",
4
+ "description": "Reads the Markdown that sprag doc2md converted instead of the binary document, with no denied Read in between",
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.62.0",
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",
@@ -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`. Only a model that is priced on the
62
- * parent's provider, costs less than the parent, and has already run in this
63
- * user's Codex sessions qualifies, so the hint never names a model the gateway
64
- * cannot serve. The cheapest is T2 and the next one up is T1; with a single
65
- * candidate T1 stays on the main agent. Returns { targets, reason }.
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 cheaper = [...new Set(observedCodexModels(dir, provider))]
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) return { targets: [], reason: `no cheaper priced model has run in your Codex sessions on ${provider}` };
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.codex?.delegate !== true || cfg.codex?.sharedRules?.auto === false) return explicit;
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.codex?.delegate !== true) return null;
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;
@@ -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
- // Codex reads the override instead of AGENTS.md when it is non-empty.
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
- function range(content) {
38
- const begin = content.indexOf(CODEX_BEGIN);
39
- const end = content.indexOf(CODEX_END);
40
- if ((begin < 0) !== (end < 0) || (begin >= 0 && end < begin) || content.indexOf(CODEX_BEGIN, begin + CODEX_BEGIN.length) >= 0) {
41
- throw new Error('Malformed Sprag Codex harness markers; repair them before updating this file.');
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 + CODEX_END.length];
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') : '';
@@ -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?.codex?.delegate !== true) return null;
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
- parts.push({ priority: 2, spill: true, text: koreanStyleInjection({ cfg }) },
197
- { priority: 3, spill: true, text: await cohesionInjection({ cfg }) });
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?.codex?.delegate === true) {
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); }
@@ -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?.codex?.delegate === true,
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 already run in Codex)' : ''}`);
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.codex?.delegate === true ? 'on' : 'off; enable with sprag delegate on --agent codex'}. Target availability and lower cost must be verified with your provider.`);
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.codex?.delegate === true ? 'on' : 'off'} (model routing and guidance)`);
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;
@@ -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 a cheaper priced model has run in Codex, or map one: sprag delegate shared map T2 --from <parent> --model <target> --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`);
430
434
  } catch (e) { debug('codex:auto-tiers', e); }
431
- console.log('First-use delegation: sprag seed --agent codex to approve policies, then sprag delegate on --agent codex. No Claude installation is required.');
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
  }
@@ -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');
@@ -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) {
@@ -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
- if (!hasReadHook || addedPrompt || addedWriteGuard) {
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(),