sprag-cli 3.62.0 → 3.64.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.
@@ -1,11 +1,16 @@
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';
8
+ import { implementationDelegationRule } from './implementation-delegation.js';
6
9
 
7
10
  export const CODEX_BEGIN = '<!-- sprag:codex:harness:begin -->';
8
11
  export const CODEX_END = '<!-- sprag:codex:harness:end -->';
12
+ export const CODEX_KOREAN_BEGIN = '<!-- sprag:codex:korean-style:begin -->';
13
+ export const CODEX_KOREAN_END = '<!-- sprag:codex:korean-style:end -->';
9
14
  const sections = [
10
15
  ['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
16
  ['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 +21,15 @@ const sections = [
16
21
 
17
22
  const scopeDir = (root, scope) => (scope === 'global' ? codexUserDir() : root);
18
23
 
24
+ /** Codex reads the override instead of AGENTS.md when it is non-empty. */
25
+ function activeInstructionFile(dir) {
26
+ const override = join(dir, 'AGENTS.override.md');
27
+ return existsSync(override) && readFileSync(override, 'utf8').trim() ? override : join(dir, 'AGENTS.md');
28
+ }
29
+
19
30
  export function codexHarnessPaths(root, scope = 'project') {
20
31
  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');
32
+ const file = activeInstructionFile(dir);
24
33
  return { file, ratchet: scope === 'global' ? join(dir, 'ratchet.md') : join(dir, '.codex', 'ratchet.md') };
25
34
  }
26
35
 
@@ -31,18 +40,21 @@ function harnessFiles(root, scope) {
31
40
  }
32
41
 
33
42
  export function codexHarnessBlock() {
34
- 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`;
43
+ 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\n${implementationDelegationRule('codex')}\nAfter fixing the design, resolve T1 with \`sprag delegate implementation --agent codex --from <active-parent-model> --provider <active-provider> --session <parent-session-id>\`. This implementation-split policy is the user's delegation request, including selection of the returned model. Do not infer the active model from the default config. Send the returned route line only at the end of the child message, use fork_turns "none", and wait with wait_agent (timeout_ms at least 120000, repeating if needed). If tools are unavailable, do not run the resolver or launch another CLI; work on the main agent and disclose it.\n${CODEX_END}\n`;
35
44
  }
36
45
 
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.');
46
+ /** The [start, end) span of a marker block, null when absent; malformed markers throw. */
47
+ function range(content, beginMarker = CODEX_BEGIN, endMarker = CODEX_END, label = 'harness') {
48
+ const begin = content.indexOf(beginMarker);
49
+ const end = content.indexOf(endMarker);
50
+ if ((begin < 0) !== (end < 0) || (begin >= 0 && end < begin) || content.indexOf(beginMarker, begin + beginMarker.length) >= 0) {
51
+ throw new Error(`Malformed Sprag Codex ${label} markers; repair them before updating this file.`);
42
52
  }
43
- return begin < 0 ? null : [begin, end + CODEX_END.length];
53
+ return begin < 0 ? null : [begin, end + endMarker.length];
44
54
  }
45
55
 
56
+ const koreanRange = (content) => range(content, CODEX_KOREAN_BEGIN, CODEX_KOREAN_END, 'korean-style');
57
+
46
58
  export function initCodexHarness({ root, scope = 'project' }) {
47
59
  const paths = codexHarnessPaths(root, scope);
48
60
  const existing = existsSync(paths.file) ? readFileSync(paths.file, 'utf8') : '';
@@ -95,10 +107,74 @@ export function uninitCodexHarness({ root, scope = 'project' }) {
95
107
  writeFileSync(candidate, withoutBlock(existing, span));
96
108
  cleaned.push(candidate);
97
109
  }
110
+ // The Korean style block lives only in the global file; project scope never has one.
111
+ if (scope === 'global') {
112
+ for (const candidate of removeKoreanBlocks(scopeDir(root, scope))) if (!cleaned.includes(candidate)) cleaned.push(candidate);
113
+ }
98
114
  if (!cleaned.length) return { file, removed: false };
99
115
  return { file: cleaned.includes(file) ? file : cleaned[0], removed: true, ...(cleaned.length > 1 ? { files: cleaned } : {}) };
100
116
  }
101
117
 
118
+ /**
119
+ * Backup next to the file. A fresh install writes two backups in one
120
+ * millisecond (harness block, then Korean block); `wx` and a counter keep the
121
+ * second from overwriting the first, which holds the user's original text.
122
+ */
123
+ function backupFile(file, text) {
124
+ const base = `${file}.bak-${Date.now()}`;
125
+ for (let i = 0; ; i++) {
126
+ try { writeFileSync(i ? `${base}-${i}` : base, text, { flag: 'wx' }); return; }
127
+ catch (e) { if (e?.code !== 'EEXIST') throw e; }
128
+ }
129
+ }
130
+
131
+ /** Removes the Korean block from every instruction file of `dir` that holds one; returns those files. */
132
+ function removeKoreanBlocks(dir) {
133
+ const cleaned = [];
134
+ for (const candidate of [join(dir, 'AGENTS.override.md'), join(dir, 'AGENTS.md')]) {
135
+ if (!existsSync(candidate)) continue;
136
+ const existing = readFileSync(candidate, 'utf8');
137
+ const span = koreanRange(existing);
138
+ if (!span) continue;
139
+ const next = withoutBlock(existing, span);
140
+ if (next.trim()) backupFile(candidate, existing);
141
+ writeFileSync(candidate, next);
142
+ cleaned.push(candidate);
143
+ }
144
+ return cleaned;
145
+ }
146
+
147
+ /**
148
+ * Keeps the Korean style guide in the global Codex instruction file. Codex
149
+ * keeps only about 2,000 tokens of one hook's context, which the guide never
150
+ * fits, but it loads the global AGENTS.md in full every session. The block is
151
+ * written while the style is enabled and removed when it is not.
152
+ * Returns { action: 'written' | 'unchanged' | 'removed' | 'absent' | 'skipped', file }.
153
+ * `skipped` means the Codex directory does not exist, so nothing was touched.
154
+ */
155
+ export function syncCodexKoreanBlock({ cfg = loadConfig(), home } = {}) {
156
+ const dir = home ? resolve(home) : codexUserDir();
157
+ const file = activeInstructionFile(dir);
158
+ if (!existsSync(dir)) return { action: 'skipped', file };
159
+ const text = koreanStyleEnabled(cfg) ? koreanStyleInjection({ cfg }) : null;
160
+ if (!text) {
161
+ const cleaned = removeKoreanBlocks(dir);
162
+ return cleaned.length ? { action: 'removed', file: cleaned.includes(file) ? file : cleaned[0] } : { action: 'absent', file };
163
+ }
164
+ const block = `${CODEX_KOREAN_BEGIN}\n${text}\n${CODEX_KOREAN_END}`;
165
+ const existing = existsSync(file) ? readFileSync(file, 'utf8') : '';
166
+ const span = koreanRange(existing);
167
+ if (span && existing.slice(...span) === block) return { action: 'unchanged', file };
168
+ const body = existing.replace(/\n+$/, '');
169
+ const next = span ? existing.slice(0, span[0]) + block + existing.slice(span[1])
170
+ : body + (body ? '\n\n' : '') + block + '\n';
171
+ // Replacing our own block loses nothing; the first addition to a user file gets a copy.
172
+ if (!span && existing.trim()) backupFile(file, existing);
173
+ mkdirSync(dirname(file), { recursive: true });
174
+ writeFileSync(file, next);
175
+ return { action: 'written', file };
176
+ }
177
+
102
178
  export function codexHarnessStatus({ root, scope = 'project' }) {
103
179
  const { file } = codexHarnessPaths(root, scope);
104
180
  const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
@@ -6,17 +6,18 @@ 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
- import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
14
- import { codexDelegationTool, codexRouteHint } from './codex-delegation.js';
13
+ import { codexDocumentTool, codexDocumentNote, convertCodexDocument, codexDocumentContext } from './codex-doc2md.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';
18
18
  import { userDataDir } from './paths.js';
19
19
  import { debug } from './debug.js';
20
+ import { updateOffer } from './update-notice.js';
20
21
 
21
22
  const require = createRequire(import.meta.url);
22
23
  const lint = require('./korean-lint.cjs');
@@ -162,10 +163,16 @@ export function lintCodexTool(payload, { scope = 'all' } = {}) {
162
163
  return messages.length ? messages.join('\n\n') : null;
163
164
  }
164
165
 
165
- export async function codexHookOutput(event, payload, { cfg = loadConfig(), refreshLedger = defaultLedgerRefresh } = {}) {
166
+ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refreshLedger = defaultLedgerRefresh, version } = {}) {
166
167
  if (!payload || typeof payload !== 'object') return null;
168
+ if (event === 'stop') {
169
+ if (payload.stop_hook_active || !koreanStyleEnabled(cfg) || cfg?.koreanReplyCheck === false) return null;
170
+ const { checkCodexReplyLanguage } = await import('./codex-reply-language.js');
171
+ if (!checkCodexReplyLanguage(payload)) return null;
172
+ return { decision: 'block', reason: '사용자는 한국어로 질문했는데 마지막 답변이 영어로 작성됐습니다. 같은 내용을 한국어로 다시 답하십시오. 코드, 명령어, 오류 원문, 커밋 메시지는 그대로 유지하십시오.' };
173
+ }
167
174
  if (event === 'session-start' || event === 'subagent-start') {
168
- if (event === 'subagent-start' && cfg?.codex?.delegate !== true) return null;
175
+ if (event === 'subagent-start' && !codexDelegateEnabled(cfg)) return null;
169
176
  if (event === 'subagent-start') {
170
177
  try { bindCodexSubagent(payload); } catch (e) { debug('codex:bind', e); }
171
178
  }
@@ -193,11 +200,17 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
193
200
  }
194
201
  if (event === 'subagent-start') parts.push({ priority: 0, text:
195
202
  '[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 }) });
203
+ // Codex reads the Korean guide from the global AGENTS.md, so the hook adds it
204
+ // only when that block was just written (this session may have loaded the old
205
+ // file) or could not be synced. Children read the same file.
206
+ let korean = null;
207
+ try { korean = syncCodexKoreanBlock({ cfg }); } catch (e) { debug('codex:korean-sync', e); }
208
+ if (korean?.action !== 'unchanged') parts.push({ priority: 2, spill: true, text: koreanStyleInjection({ cfg }) });
209
+ parts.push({ priority: 3, spill: true, text: await cohesionInjection({ cfg }) });
198
210
  if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) parts.push({ priority: 4, spill: true, text: codexDocumentNote() });
199
211
  // Offers ask the user a question; a `codex exec` run has no one to answer it.
200
212
  if (event === 'session-start' && payload.source !== 'compact' && !isCodexExecSession(payload.transcript_path)) {
213
+ if (version) parts.push({ priority: 5, text: updateOffer(version, { agent: 'codex', lang: userLanguage() }) });
201
214
  try { parts.push({ priority: 5, text: await routeNotice(root) }); } catch (e) { debug('codex:route-notice', e); }
202
215
  try { parts.push({ priority: 6, text: seedOfferBlock({ root, agent: 'codex' }) }); } catch (e) { debug('codex:seed-offer', e); }
203
216
  }
@@ -206,7 +219,7 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
206
219
  }
207
220
  if (event === 'prompt') {
208
221
  const parts = [];
209
- if (cfg?.codex?.delegate === true) {
222
+ if (codexDelegateEnabled(cfg)) {
210
223
  // Before the hint for this prompt is recorded: a hint from an earlier turn
211
224
  // that no child claimed is closed, and children that did run get priced.
212
225
  try { closeStaleCodexRoutes(payload); } catch (e) { debug('codex:close-routes', e); }
@@ -223,7 +236,8 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
223
236
  catch (e) { debug('codex:brief', e); }
224
237
  }
225
238
  if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
226
- parts.push(doc2md.contextForPrompt(payload, { lang: userLanguage(), agent: 'codex', convert: convertCodexDocument }));
239
+ const identity = codexDocumentContext(payload);
240
+ parts.push(doc2md.contextForPrompt(payload, { lang: userLanguage(), agent: 'codex', convert: (source) => convertCodexDocument(source, identity) }));
227
241
  }
228
242
  return context('UserPromptSubmit', parts.filter(Boolean).join('\n\n'));
229
243
  }
@@ -9,6 +9,7 @@ export const CODEX_HANDLERS = {
9
9
  PreToolUse: { matcher: '^(Bash|Read|read_file|Edit|Write|Agent|spawn_agent|mcp__filesystem__read_(?:text_)?file|(?:functions\\.|tools\\.)?(?:exec_command|shell_command|shell|apply_patch|spawn_agent))$', command: 'sprag codex-hook --agent codex --event pre-tool', timeout: 600 },
10
10
  PostToolUse: { matcher: '^(Bash|apply_patch|Edit|Write|(?:functions\\.|tools\\.)?(?:exec_command|shell_command|shell|apply_patch))$', command: 'sprag codex-hook --agent codex --event post-tool', timeout: 15 },
11
11
  SubagentStart: { command: 'sprag codex-hook --agent codex --event subagent-start', timeout: 15 },
12
+ Stop: { command: 'sprag codex-hook --agent codex --event stop', timeout: 15 },
12
13
  };
13
14
  const ownCommands = new Set(Object.values(CODEX_HANDLERS).map((h) => h.command));
14
15
  const PANEL_COMMAND = 'sprag codex-hook --agent codex --event panel-start';
@@ -177,6 +177,29 @@ export function loadCodexLedger({ dir = userDataDir() } = {}) {
177
177
  return { version: 1, events: {} };
178
178
  }
179
179
 
180
+ /** Offers are not executions. Count tier alternatives as one offer per prompt. */
181
+ export function codexDelegationStatus({ dir = userDataDir(), sessionId, now = Date.now() } = {}) {
182
+ const routes = readCodexDelegations({ dir }).filter((r) => now - r.at < PENDING_TTL_MS && r.at <= now &&
183
+ (!sessionId || r.parentSessionId === sessionId));
184
+ const bound = new Set(readCodexBindings({ dir }).map((b) => b.id));
185
+ const events = loadCodexLedger({ dir }).events;
186
+ const closed = closedCodexRouteIds({ dir });
187
+ const offers = new Map();
188
+ for (const r of routes) {
189
+ const key = r.offerId || r.id;
190
+ if (!offers.has(key)) offers.set(key, []);
191
+ offers.get(key).push(r);
192
+ }
193
+ const result = { offers: offers.size, bound: 0, settled: 0, pending: 0, unclaimed: 0 };
194
+ for (const group of offers.values()) {
195
+ if (group.some((r) => events[r.id]?.complete || events[r.id]?.aborted)) result.settled++;
196
+ else if (group.some((r) => bound.has(r.id) || events[r.id])) result.bound++;
197
+ else if (group.every((r) => closed.has(r.id) || now - r.at >= BIND_TTL_MS)) result.unclaimed++;
198
+ else result.pending++;
199
+ }
200
+ return result;
201
+ }
202
+
180
203
  function saveLedger(data, dir) {
181
204
  mkdirSync(dir, { recursive: true });
182
205
  const file = ledgerFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
@@ -242,7 +265,7 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
242
265
  if (route.provider && route.provider !== run.provider) continue;
243
266
  // A shared policy names its model only through the caller's choice, so
244
267
  // credit it only for a child of that parent that ran the mapped model.
245
- if (route.source === 'shared' && (run.model !== route.to || !route.parentSessionId ||
268
+ if (['shared', 'implementation'].includes(route.source) && (run.model !== route.to || !route.parentSessionId ||
246
269
  run.parentThreadId !== route.parentSessionId)) continue;
247
270
  if (Number.isFinite(run.startedAt) && run.startedAt < route.at - 5000) continue;
248
271
  // A closed route ended with its hinted turn. Only a child that started
@@ -265,6 +288,7 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
265
288
  const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
266
289
  childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
267
290
  ...(route.source === 'shared' ? { tier: route.tier, sharedSignature: route.sharedSignature } : {}),
291
+ ...(route.source === 'implementation' ? { tier: route.tier } : {}),
268
292
  from: route.from, to, provider,
269
293
  ...(route.targetRoot !== undefined ? { targetRoot: route.targetRoot } : {}),
270
294
  ...(route.ruleCreatedAt !== undefined ? { ruleCreatedAt: route.ruleCreatedAt } : {}), tokens: run.tokens, calls: run.calls, toolErrors: run.toolErrors, usd, rates: usd === null ? null : rates,
@@ -17,15 +17,16 @@ import { formatResetIn } from './format-time.js';
17
17
  import { codexDocumentTotals } from './codex-doc2md-ledger.js';
18
18
  import { codexHookStatus } from './codex-installer.js';
19
19
  import { koreanStyleEnabled } from './korean-style.js';
20
- import { codexRoutingSavedTotals, loadCodexLedger } from './codex-ledger.js';
21
- import { loadCodexModelRules } from './codex-delegation.js';
22
- import { codexRulesInReview } from './codex-rule-health.js';
20
+ import { codexRoutingSavedTotals, loadCodexLedger, codexDelegationStatus } from './codex-ledger.js';
21
+ import { loadCodexModelRules, codexDelegateEnabled } from './codex-delegation.js';
22
+ import { codexRulesInReview, codexSharedRuleHealth } from './codex-rule-health.js';
23
23
  import { readCodexRouteScan, openCodexCandidates } from './codex-route-scan.js';
24
24
  import { codexBudgetProvider } from './codex-budget.js';
25
+ import { readUpdateChip } from './update-notice.js';
25
26
 
26
27
  export const CODEX_SEGMENT_ORDER = [
27
- 'cap-warn', 'usage', 'ctx', 'ttl', 'model', 'effort',
28
- 'hit', 'saved', 'doc2md', 'harness', 'korean', 'version',
28
+ 'cap-warn', 'update', 'usage', 'ctx', 'ttl', 'model', 'effort',
29
+ 'hit', 'saved', 'doc2md', 'routing', 'harness', 'korean', 'version',
29
30
  ];
30
31
 
31
32
  // Only cache full-session parses. A moving time cutoff would make an unchanged
@@ -34,7 +35,8 @@ export const CODEX_SEGMENT_ORDER = [
34
35
  const RATCHET_SIBLING_MS = 30 * 60000;
35
36
 
36
37
  export function createPanelReader({ root = process.cwd(), home, sessionId: pinnedId, sessionFile, days = 7, readBudget = () => null,
37
- readCachePolicy = (session) => readCodexCachePolicy(session, { home }), hasFlag = () => false } = {}) {
38
+ readCachePolicy = (session) => readCodexCachePolicy(session, { home }), hasFlag = () => false,
39
+ version, refreshUpdate = false } = {}) {
38
40
  if (pinnedId && sessionFile) throw new Error('Choose --session or --session-file, not both.');
39
41
  const cache = new Map();
40
42
  const ratchetCache = new Map();
@@ -121,11 +123,15 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
121
123
  const modelRules = loadCodexModelRules();
122
124
  if (modelRules.length) ruleReview = codexRulesInReview({ root: project, rules: modelRules, events: Object.values(loadCodexLedger().events) }).map((r) => r.index);
123
125
  } catch { /* Unreadable rules or ledger show no chip. */ }
126
+ let sharedRuleReview = [];
127
+ try { sharedRuleReview = codexSharedRuleHealth({ root: project }).filter((r) => r.health.status === 'review'); }
128
+ catch { /* Shared policy health must not hide the rest of the panel. */ }
124
129
  let routingSaved = null;
125
130
  try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: isDirectOpenAI(selected?.provider, home ? { home } : {}) || !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
126
131
  catch { /* Unreadable ledger reads as n/a. */ }
127
132
  return { session: selected, matches, root, sessionId, selection, bindingPending: !!sessionFile && !sessionId, days, harness, rules,
128
- ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, ruleReview, routingSaved,
133
+ ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, ruleReview, sharedRuleReview, routingSaved,
134
+ delegation: codexDelegationStatus({ sessionId: sessionId || selected?.sessionId }),
129
135
  labelMode: resolveLabelMode({ cfg: statuslineDefaults(), hasFlag }).mode,
130
136
  color: statuslineDefaults().color,
131
137
  timer: !hasFlag('--no-timer') && (hasFlag('--timer') || statuslineDefaults().timer),
@@ -138,8 +144,9 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
138
144
  lint: cfg?.koreanStyle?.lint || 'block',
139
145
  cohesion: cfg?.cohesion?.enabled === true,
140
146
  brief: cfg?.codex?.brief !== false,
141
- delegate: cfg?.codex?.delegate === true,
147
+ delegate: codexDelegateEnabled(cfg),
142
148
  doc2md: cfg?.codex?.doc2md !== false && process.env.CTS_NO_DOC2MD !== '1',
149
+ update: version ? readUpdateChip(version, { refresh: refreshUpdate }) : null,
143
150
  updatedAt: new Date() };
144
151
  };
145
152
  }
@@ -276,16 +283,27 @@ function panelGroups(data, { now, version = '', full = false, mode = full ? 'tex
276
283
  chip(`Reasoning ${short(s.reasoningOutputTokens)} (in output)`),
277
284
  ] : []);
278
285
  const docs = data.documents;
279
- register('doc2md', 'documents', docs?.scope === 'codex-total' && docs.docs > 0 ? [
280
- chip(`${icon('doc')}Doc2md ${number(docs.docs)} docs (Codex total)`, 32),
286
+ const docSavings = docs?.scope === 'codex-total' ? docs.savings : null;
287
+ register('doc2md', 'savings', [
288
+ chip(`Doc2md saved ${docSavings?.priced ? `${signedMoney(docSavings.total)} est. (XML vs MD)`
289
+ : `n/a (${docs?.docs ? `${docs.docs} unpriced docs` : 'no documents'})`}`, docSavings?.priced ? (docSavings.total < 0 ? 33 : 32) : 90),
290
+ ...(docSavings?.priced && docSavings.unpriced ? [chip(`${docSavings.unpriced} docs unpriced`)] : []),
291
+ ...(docs?.scope === 'codex-total' && docs.docs > 0 ? [chip(`${icon('doc')}Doc2md ${number(docs.docs)} docs (Codex total)`, 32),
281
292
  ...(docs.byExt || []).map((row) => chip(`${terminalText(row.ext)} ${number(row.docs)}x`)),
282
- ] : []);
293
+ ] : []),
294
+ ]);
295
+ const delegation = data.delegation;
296
+ register('routing', 'savings', [routingChip(data.routingSaved, chip),
297
+ delegation?.offers ? chip(`Delegation ${delegation.settled} completed / ${delegation.bound} bound / ${delegation.pending} pending / ${delegation.unclaimed} unclaimed (session)`,
298
+ delegation.unclaimed && !delegation.settled && !delegation.bound ? 33 : 90) : null,
299
+ ]);
283
300
  register('harness', 'preferences', [
284
301
  chip(`${icon('harness')}Harness ${h?.error ? 'ERROR' : h ? `${h.configured}/${h.total}` : 'n/a'}`, h?.configured === 5 ? 32 : 33),
285
302
  chip(`Ratchet ${data.rules?.global ?? '?'}/${data.rules?.project ?? '?'}`),
286
303
  // A repeated failure outranks a delegation candidate, as in the Claude statusline.
287
304
  data.ratchet?.length ? chip(`ratchet? #${data.ratchet[0].id} x${data.ratchet[0].count}`, 33) : null,
288
305
  data.ruleReview?.length ? chip(`rule-health #${data.ruleReview[0]}${data.ruleReview.length > 1 ? ` +${data.ruleReview.length - 1}` : ''}`, 33) : null,
306
+ data.sharedRuleReview?.length ? chip(`rule-health shared ${terminalText(data.sharedRuleReview[0].rule.tier)} ${terminalText(data.sharedRuleReview[0].rule.category)}${data.sharedRuleReview.length > 1 ? ` +${data.sharedRuleReview.length - 1}` : ''}`, 33) : null,
289
307
  data.route?.length ? chip(`route? R${data.route[0].id} ${terminalText(data.route[0].category)}${data.route.length > 1 ? ` +${data.route.length - 1}` : ''}`, 33) : null,
290
308
  ]);
291
309
  register('korean', 'preferences', [
@@ -294,8 +312,11 @@ function panelGroups(data, { now, version = '', full = false, mode = full ? 'tex
294
312
  chip(`${icon('doc')}Doc2md ${data.doc2md ? 'on' : 'off'} (prefs)`),
295
313
  ...(data.hooks ? [chip(`Hooks ${data.hooks.filter((hook) => hook.registered).length}/${data.hooks.length} registered`)] : []),
296
314
  ...(data.brief !== undefined ? [chip(`Brief ${data.brief ? 'on' : 'off'}`)] : []),
297
- ...(data.delegate !== undefined ? [chip(`Delegate ${data.delegate ? 'on' : 'off'} (model routing)`)] : []),
298
- routingChip(data.routingSaved, chip),
315
+ ...(data.delegate !== undefined ? [chip(`Delegate ${data.delegate ? 'on' : 'off'} (preference; tools unverified)`)] : []),
316
+ ]);
317
+ register('update', 'priority', [
318
+ data.update?.available && !data.update.dismissed
319
+ ? chip(`${icon('upgrade')}v${terminalText(version)} -> ${terminalText(data.update.latest)}`, 33) : null,
299
320
  ]);
300
321
  register('version', 'preferences', [
301
322
  chip(`SPRAG${version ? ` v${terminalText(version)}` : ''} / ${s ? age(s.lastActivity, now) : 'waiting'}`,
@@ -453,7 +474,7 @@ export async function runCodexPanel({ read, interval = 2000, once = false, color
453
474
  try { lastData = await read(); }
454
475
  catch (e) { lastData = { ...lastData, error: e.message }; }
455
476
  if (stopped) break;
456
- if (compact && !once) fitRows(formatCodexPanel(lastData, { columns: (output.columns || 100) - 1, rows: 10000, color: false, compact: true }).split('\n').length);
477
+ if (compact && !once) fitRows(formatCodexPanel(lastData, { columns: (output.columns || 100) - 1, rows: 10000, color: false, compact: true, version }).split('\n').length);
457
478
  draw();
458
479
  if (once) break;
459
480
  await wait();
@@ -0,0 +1,43 @@
1
+ import { createRequire } from 'node:module';
2
+ import { forkedCopyFilter, codexToolEvents } from './codex-parser.js';
3
+
4
+ const { checkReplyLanguage } = createRequire(import.meta.url)('./reply-language.cjs');
5
+
6
+ /** Ignore copied history and commentary; only the current turn's final reply counts. */
7
+ export function lastCodexTurn(lines, { turnId, sessionId } = {}) {
8
+ const copied = forkedCopyFilter();
9
+ let prompt = null, reply = '', currentTurn = null;
10
+ for (const line of lines) {
11
+ let e;
12
+ try { e = JSON.parse(line); } catch { continue; }
13
+ if (copied(e)) continue;
14
+ const p = e?.payload;
15
+ if (!p) continue;
16
+ if (e.type === 'session_meta' && sessionId && (p.id || p.session_id) !== sessionId) return null;
17
+ if (e.type === 'event_msg' && p.type === 'task_started') {
18
+ currentTurn = p.turn_id;
19
+ prompt = null;
20
+ reply = '';
21
+ }
22
+ for (const ev of codexToolEvents(e)) {
23
+ if (ev.kind !== 'user' || (sessionId && ev.threadId && ev.threadId !== sessionId)) continue;
24
+ currentTurn = ev.turnId || currentTurn;
25
+ prompt = ev.text;
26
+ reply = '';
27
+ }
28
+ if (e.type === 'response_item' && p.type === 'message' && p.role === 'assistant' && p.phase === 'final_answer') {
29
+ reply = (Array.isArray(p.content) ? p.content : []).filter((b) => b?.type === 'output_text' && typeof b.text === 'string').map((b) => b.text).join('\n');
30
+ }
31
+ if (e.type === 'event_msg' && p.type === 'task_complete' && (!p.turn_id || p.turn_id === currentTurn) &&
32
+ typeof p.last_agent_message === 'string') reply = p.last_agent_message;
33
+ }
34
+ if (prompt === null || (turnId && currentTurn !== turnId)) return null;
35
+ return { prompt, reply };
36
+ }
37
+
38
+ export function checkCodexReplyLanguage(payload) {
39
+ return checkReplyLanguage(payload.transcript_path, {
40
+ parseTurn: (lines) => lastCodexTurn(lines, { turnId: payload.turn_id, sessionId: payload.session_id }),
41
+ reply: payload.last_assistant_message,
42
+ });
43
+ }
@@ -2,6 +2,7 @@ import { resolve } from 'node:path';
2
2
  import { loadCodexLedger } from './codex-ledger.js';
3
3
  import { isFailedRun } from './route-scan.js';
4
4
  import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from './model-rules.js';
5
+ import { loadSharedModelRules, sharedModelRulesForProject, realProjectRoot } from './shared-model-rules.js';
5
6
 
6
7
  /**
7
8
  * Measured outcome of one Codex rule, judged as Claude's rule health judges
@@ -18,12 +19,34 @@ export function codexRuleHealth(rule, { events = Object.values(loadCodexLedger()
18
19
  (rule.scope !== 'project' || (typeof e.targetRoot === 'string' && e.targetRoot === rule.targetRoot)) &&
19
20
  // A rule deleted and added again starts a fresh record.
20
21
  (!Number.isFinite(since) || (Number.isFinite(e.ts) && e.ts >= since)));
22
+ return healthOf(runs.filter((e) => e.source !== 'shared'));
23
+ }
24
+
25
+ function healthOf(runs) {
21
26
  const errs = runs.filter((e) => e.aborted || isFailedRun({ toolErrors: e.toolErrors, calls: e.calls })).length;
22
27
  const saved = runs.filter((e) => Number.isFinite(e.usd)).reduce((sum, e) => sum + e.usd, 0);
23
28
  const review = runs.length >= HEALTH_MIN_SAMPLE_DELEGATED && wilsonLowerBound(errs, runs.length) > HEALTH_ERR_RATE;
24
29
  return { runs: runs.length, errs, rate: runs.length ? errs / runs.length : 0, saved, status: review ? 'review' : 'active' };
25
30
  }
26
31
 
32
+ /** Shared policies keep Codex outcomes separate, including each provider/model pair. */
33
+ export function codexSharedRuleHealth({ root = process.cwd(), dir, events = Object.values(loadCodexLedger({ dir }).events) } = {}) {
34
+ const policies = sharedModelRulesForProject(loadSharedModelRules({ dir }), root);
35
+ return policies.flatMap((rule) => {
36
+ const groups = new Map();
37
+ for (const e of events) {
38
+ if (e?.source !== 'shared' || !(e.complete || e.aborted) || e.sharedSignature !== rule.sharedSignature ||
39
+ e.category !== rule.category || e.tier !== rule.tier || e.scope !== rule.scope ||
40
+ (rule.scope === 'project' && (typeof e.targetRoot !== 'string' || realProjectRoot(e.targetRoot) !== rule.targetRoot))) continue;
41
+ const key = JSON.stringify([e.provider, e.from, e.to]);
42
+ if (!groups.has(key)) groups.set(key, []);
43
+ groups.get(key).push(e);
44
+ }
45
+ return [...groups.values()].map((runs) => ({ rule, provider: runs[0].provider, from: runs[0].from,
46
+ model: runs[0].to, health: healthOf(runs) }));
47
+ });
48
+ }
49
+
27
50
  /**
28
51
  * Rules in review that apply to the project at `root`: global rules, and
29
52
  * project rules whose targetRoot is `root`; a null `root` lists every rule. `index` is the 1-based number the
@@ -2,13 +2,14 @@ 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, codexImplementationHint } from '../codex-delegation.js';
6
6
  import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
7
7
  import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
8
- import { codexRuleHealth, codexRulesInReview } from '../codex-rule-health.js';
8
+ import { codexRuleHealth, codexRulesInReview, codexSharedRuleHealth } from '../codex-rule-health.js';
9
9
  import { signedUsd } from '../money.js';
10
10
  import { codexProviderName, codexModelName } from '../agent.js';
11
11
  import { loadSharedModelRules, sharedModelRulesForProject, SHARED_RULE_TIERS } from '../shared-model-rules.js';
12
+ import { codexDelegationDiagnostics } from '../codex-delegation-diagnostics.js';
12
13
 
13
14
  // Starts from the working directory, as the hooks do, so a project policy
14
15
  // stored under either agent's project root is counted.
@@ -22,6 +23,10 @@ function printShared(cfg, { details = false } = {}) {
22
23
  if (details) {
23
24
  for (const r of policies) console.log(`shared ${r.tier} ${r.category} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}`);
24
25
  for (const t of targets) console.log(`${t.provider} | ${t.from} | ${t.tier} -> ${t.model}${t.effort ? ` (${t.effort})` : ''}`);
26
+ for (const { rule, health, provider: source, from, model } of codexSharedRuleHealth({ root: process.cwd() })) {
27
+ console.log(`shared ${rule.tier} ${rule.category} | ${source} | ${from} -> ${model} | measured x${health.runs}, err ${Math.round(health.rate * 100)}%, saved ${signedUsd(health.saved, 4)}`
28
+ + (health.status === 'review' ? ` | rule-health: ${health.errs}/${health.runs} runs failed; review the mapping or pause with sprag delegate shared off --agent codex` : ''));
29
+ }
25
30
  }
26
31
  // A mapping routes only sessions on the parent model it names, so a tier
27
32
  // mapped for another model still leaves the configured model unrouted. A tier
@@ -32,7 +37,7 @@ function printShared(cfg, { details = false } = {}) {
32
37
  const tiers = [...new Set(policies.map((r) => r.tier))];
33
38
  for (const tier of tiers) {
34
39
  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)' : ''}`);
40
+ if (t) console.log(`${tier} -> ${t.model}${t.effort ? ` (${t.effort})` : ''}${t.auto ? ' (auto: cheaper priced model Codex runs or lists)' : ''}`);
36
41
  }
37
42
  const missing = tiers.filter((tier) => !own(tier) && !auto.targets.some((a) => a.tier === tier));
38
43
  if (missing.length) {
@@ -57,6 +62,15 @@ export function run({ args, getArg, hasFlag, root }) {
57
62
  }
58
63
  }
59
64
  const cfg = loadConfig();
65
+ if (sub === 'doctor') {
66
+ const report = codexDelegationDiagnostics({ sessionId: getArg('--session'), model: getArg('--from') || undefined });
67
+ console.log(JSON.stringify(report, null, 2));
68
+ return;
69
+ }
70
+ if (sub === 'implementation') {
71
+ console.log(codexImplementationHint({ model: getArg('--from'), provider: getArg('--provider'), sessionId: getArg('--session'), cfg }));
72
+ return;
73
+ }
60
74
  if (sub === 'shared') {
61
75
  const action = args[2] || 'status';
62
76
  if (!['status', 'on', 'off', 'map', 'unmap', 'min-context', 'auto'].includes(action)) {
@@ -100,7 +114,7 @@ export function run({ args, getArg, hasFlag, root }) {
100
114
  }
101
115
  if (action !== 'status') saveConfig(cfg);
102
116
  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.`);
117
+ 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
118
  return;
105
119
  }
106
120
  if (sub === 'rules') {
@@ -145,7 +159,7 @@ export function run({ args, getArg, hasFlag, root }) {
145
159
  if (target !== undefined) cfg.codex.delegateTarget = target;
146
160
  saveConfig(cfg);
147
161
  }
148
- console.log(`Codex delegate: ${cfg.codex?.delegate === true ? 'on' : 'off'} (model routing and guidance)`);
162
+ console.log(`Codex delegate: ${codexDelegateEnabled(cfg) ? 'on' : 'off'} (model routing and guidance)`);
149
163
  console.log(`Default target: ${cfg.codex?.delegateTarget?.model || 'inherit (no override)'}`);
150
164
  // Turning delegation off must work even when the rule file is damaged.
151
165
  let ruleCount;