sprag-cli 3.63.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.
- package/bin/cli.js +3 -25
- package/docs/CODEX.ko.md +55 -1
- package/docs/CODEX.md +85 -3
- package/mod/sprag-delegation-meter/hooks/hooks.json +3 -0
- package/mod/sprag-delegation-meter/hooks/register.ts +197 -0
- package/mod/sprag-doc2md/hooks/hooks.json +3 -0
- package/mod/sprag-doc2md/hooks/register.ts +97 -0
- package/package.json +2 -2
- package/src/codex-brief.js +10 -1
- package/src/codex-delegation-diagnostics.js +42 -0
- package/src/codex-delegation.js +17 -0
- package/src/codex-doc2md-ledger.js +53 -8
- package/src/codex-doc2md.js +21 -4
- package/src/codex-doctor.js +9 -1
- package/src/codex-harness.js +2 -1
- package/src/codex-hooks.js +12 -3
- package/src/codex-installer.js +1 -0
- package/src/codex-ledger.js +25 -1
- package/src/codex-panel.js +33 -12
- package/src/codex-reply-language.js +43 -0
- package/src/codex-rule-health.js +23 -0
- package/src/commands/codex-delegate.js +16 -2
- package/src/commands/codex.js +40 -8
- package/src/commands/route-scan.js +2 -36
- package/src/commands/saved.js +10 -4
- package/src/commands/update-check.js +7 -4
- package/src/implementation-delegation.js +34 -0
- package/src/model-rules.js +3 -20
- package/src/reply-language.cjs +3 -2
- package/src/update-notice.js +37 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parse } from 'smol-toml';
|
|
5
|
+
import { codexUserDir, codexModelName } from './agent.js';
|
|
6
|
+
import { codexDelegationStatus } from './codex-ledger.js';
|
|
7
|
+
|
|
8
|
+
/** Local introspection only. A CLI feature flag cannot prove a model received a tool. */
|
|
9
|
+
export function codexDelegationDiagnostics({ home = codexUserDir(), model = codexModelName({ home }), sessionId, dir,
|
|
10
|
+
exec = execFileSync } = {}) {
|
|
11
|
+
const result = { model, version: null, flags: {}, configuredDisabled: false,
|
|
12
|
+
catalogAgentVersion: null, activity: codexDelegationStatus({ dir, sessionId }),
|
|
13
|
+
tools: 'unverified in the active session', warnings: [], errors: [] };
|
|
14
|
+
try {
|
|
15
|
+
const cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8'));
|
|
16
|
+
const profile = cfg.profiles?.[cfg.profile];
|
|
17
|
+
result.configuredDisabled = (profile?.agents?.enabled ?? cfg.agents?.enabled) === false ||
|
|
18
|
+
(profile?.features?.multi_agent ?? cfg.features?.multi_agent) === false;
|
|
19
|
+
} catch (e) { if (e.code !== 'ENOENT') result.errors.push('Cannot parse user config.'); }
|
|
20
|
+
const run = (args) => exec('codex', args, { encoding: 'utf8', timeout: 5000, maxBuffer: 20 * 1024 * 1024,
|
|
21
|
+
env: { ...process.env, CODEX_HOME: home }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
22
|
+
try { result.version = run(['--version']).trim().slice(0, 100); } catch { result.errors.push('Codex version unavailable.'); }
|
|
23
|
+
try {
|
|
24
|
+
for (const line of run(['features', 'list']).split('\n')) {
|
|
25
|
+
const m = /^(multi_agent(?:_v2(?:_dynamic_tools)?)?)\s+.+?\s+(true|false)\s*$/.exec(line);
|
|
26
|
+
if (m) result.flags[m[1]] = m[2] === 'true';
|
|
27
|
+
}
|
|
28
|
+
} catch { result.errors.push('Codex feature list unavailable.'); }
|
|
29
|
+
try {
|
|
30
|
+
const data = JSON.parse(run(['debug', 'models', '--bundled']));
|
|
31
|
+
const models = Array.isArray(data) ? data : data.models;
|
|
32
|
+
const found = models?.find((m) => m.slug === model);
|
|
33
|
+
if (['v1', 'v2'].includes(found?.multi_agent_version)) result.catalogAgentVersion = found.multi_agent_version;
|
|
34
|
+
} catch { result.errors.push('Bundled model catalog unavailable.'); }
|
|
35
|
+
if (result.configuredDisabled || result.flags.multi_agent === false) result.warnings.push('Agent tools are disabled in the inspected configuration.');
|
|
36
|
+
if (result.catalogAgentVersion === 'v2' && result.flags.multi_agent_v2 === false) {
|
|
37
|
+
result.warnings.push('The bundled model catalog names v2 while the v2 feature is off. This is a compatibility clue, not proof of the cause; no setting was changed.');
|
|
38
|
+
}
|
|
39
|
+
if (result.activity.offers && !result.activity.bound && !result.activity.settled) result.warnings.push('Sprag emitted offers but no child execution is connected.');
|
|
40
|
+
result.warnings.push('Confirm spawn_agent and wait_agent in the running session. If absent, continue on the main agent and report the limitation; do not launch another CLI as a substitute.');
|
|
41
|
+
return result;
|
|
42
|
+
}
|
package/src/codex-delegation.js
CHANGED
|
@@ -244,6 +244,23 @@ const SHARED_TIER_GUIDANCE = 'Choose T2 only for a simple bounded lookup or comm
|
|
|
244
244
|
const WAIT_TEXT = 'Wait for it to finish (call wait_agent with timeout_ms of at least 120000, and again if it times out) and do not do the delegated task yourself while it runs; then verify its result and report unfinished work. Do not spawn another CLI process. '
|
|
245
245
|
+ 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
|
|
246
246
|
|
|
247
|
+
/** Called explicitly by the parent after design, never by prompt classification. */
|
|
248
|
+
export function codexImplementationHint({ model, provider, sessionId, cfg = loadConfig(), dir = userDataDir(),
|
|
249
|
+
home = codexUserDir(), record = recordCodexDelegation } = {}) {
|
|
250
|
+
if (!safeId(model) || !safeId(provider) || !safeId(sessionId)) throw new Error('Implementation delegation requires --from, --provider and --session for the active parent.');
|
|
251
|
+
if (!codexDelegateEnabled(cfg) || cfg.codex?.sharedRules?.enabled === false) return 'Implementation delegation is off. Keep the implementation on the main agent.';
|
|
252
|
+
const target = codexTierTargets({ cfg, dir, home, model, provider }).find((t) => t.tier === 'T1');
|
|
253
|
+
if (!target) return 'T1 is unresolved for this parent/provider. Keep the implementation on the main agent; do not fall back to T2.';
|
|
254
|
+
const id = newRouteId();
|
|
255
|
+
record({ id, parentSessionId: sessionId, via: 'prompt', source: 'implementation', tier: 'T1',
|
|
256
|
+
from: model, to: target.model, provider, effort: target.effort ?? null }, { dir });
|
|
257
|
+
return `[Sprag implementation delegation] The user-approved implementation-split policy keeps design and specification on the main agent. `
|
|
258
|
+
+ `Delegate only the implementation of that completed design to model ${target.model}${target.effort ? `, reasoning_effort ${target.effort}` : ''}, with fork_turns "none". `
|
|
259
|
+
+ 'Never delegate design judgement or irreversible actions. Give one child a self-contained specification, owned files, constraints, test commands, completion criteria and the reason for this model choice. '
|
|
260
|
+
+ 'Cap: 8000 output tokens. Stop and report partial work before exceeding it. Do not recursively delegate. '
|
|
261
|
+
+ WAIT_TEXT + `\nEnd the child task message with this route line only:\n<!-- sprag:codex:route id=${id} -->`;
|
|
262
|
+
}
|
|
263
|
+
|
|
247
264
|
function sharedTargetText(rule) {
|
|
248
265
|
return `${rule.tier}: model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''}. ${sharedRuleBounds(rule)}`.trim();
|
|
249
266
|
}
|
|
@@ -5,43 +5,88 @@ import { codexUserDir } from './agent.js';
|
|
|
5
5
|
import { userDataDir } from './paths.js';
|
|
6
6
|
import { writeViaTmp } from './state-file.js';
|
|
7
7
|
import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
|
|
8
|
+
import { codexPriceBook, readCodexPrices } from './codex-cache-policy.js';
|
|
8
9
|
|
|
9
10
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
10
11
|
const ledgerDir = ({ home = codexUserDir(), dir = userDataDir() } = {}) => join(dir, 'codex-doc2md', hash(resolve(home)));
|
|
11
12
|
const finite = (n) => Number.isSafeInteger(n) && n >= 0;
|
|
12
13
|
|
|
14
|
+
/** Office XML versus the prepared Markdown, not native attachment billing. */
|
|
15
|
+
export function estimateCodexDocument(source, meta, { model, provider, home, dir, now = Date.now(), prices } = {}) {
|
|
16
|
+
const unknown = (reason) => ({ usd: null, reason });
|
|
17
|
+
if (meta.clipped || meta.truncated) return unknown('incomplete-text');
|
|
18
|
+
if (!['.pptx', '.docx', '.xlsx'].includes(extname(source).toLowerCase()) || !finite(meta.markupBytes) || meta.markupBytes === 0) {
|
|
19
|
+
return unknown('no-comparable-baseline');
|
|
20
|
+
}
|
|
21
|
+
if (!finite(meta.markdownBytes)) return unknown('missing-markdown-size');
|
|
22
|
+
if (typeof model !== 'string' || !model || typeof provider !== 'string' || !provider) return unknown('unknown-model-provider');
|
|
23
|
+
const book = codexPriceBook(provider, prices === undefined ? readCodexPrices({ home, dir, now }) : prices, { home });
|
|
24
|
+
const rate = book?.prices?.[model]?.input;
|
|
25
|
+
if (!Number.isFinite(rate) || rate < 0) return unknown('unpriced-model');
|
|
26
|
+
const baseline = Math.ceil(meta.markupBytes / 4), tokens = Math.ceil(meta.markdownBytes / 4);
|
|
27
|
+
return { usd: (baseline - tokens) * rate, baseline, tokens, savedTokens: baseline - tokens,
|
|
28
|
+
method: 'office-xml-vs-markdown-bytes/4', model, provider, inputRate: rate,
|
|
29
|
+
priceSource: book.source, priceCheckedAt: book.checkedAt };
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
// One file per source avoids lost updates when parallel hooks prepare documents.
|
|
14
|
-
export function recordCodexDocument(source, result, opts) {
|
|
33
|
+
export function recordCodexDocument(source, result, opts = {}) {
|
|
15
34
|
if (!result?.ok) return;
|
|
16
35
|
try {
|
|
17
36
|
const dir = ledgerDir(opts);
|
|
18
37
|
const file = join(dir, `${hash(realpathSync(source))}.json`);
|
|
19
38
|
const meta = result.meta || {};
|
|
20
|
-
const
|
|
39
|
+
const now = opts.now ?? Date.now();
|
|
40
|
+
const st = statSync(source);
|
|
41
|
+
const fingerprint = hash(JSON.stringify([st.size, st.mtimeMs, meta.markupBytes, meta.markdownBytes, !!(meta.clipped || meta.truncated)]));
|
|
42
|
+
let previous;
|
|
43
|
+
try { previous = JSON.parse(readFileSync(file, 'utf8')); } catch { /* First use. */ }
|
|
44
|
+
const same = previous?.version === 2 && previous.fingerprint === fingerprint;
|
|
45
|
+
const since = readResetMarks(opts.dir ?? userDataDir())['codex-docs'];
|
|
46
|
+
const estimate = estimateCodexDocument(source, meta, opts);
|
|
47
|
+
// Reusing a cache without session identity must not erase a priced snapshot.
|
|
48
|
+
const saving = same && !isBeforeReset(previous.at, since) && Number.isFinite(previous.saving?.usd) ? previous.saving : estimate;
|
|
49
|
+
const event = { version: 2, ext: extname(source).slice(1).toLowerCase(),
|
|
50
|
+
fingerprint, at: same && !isBeforeReset(previous.at, since) ? previous.at : now, saving,
|
|
21
51
|
sourceBytes: finite(meta.size) ? meta.size : null,
|
|
22
52
|
markdownBytes: finite(meta.markdownBytes) ? meta.markdownBytes : null,
|
|
23
53
|
clipped: !!(meta.clipped || meta.truncated) };
|
|
24
54
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
55
|
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
26
56
|
writeViaTmp(tmp, file, JSON.stringify(event) + '\n', { mode: 0o600 });
|
|
57
|
+
return saving;
|
|
27
58
|
} catch { /* Accounting must not prevent document conversion. */ }
|
|
28
59
|
}
|
|
29
60
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
export function codexDocumentTotals(opts) {
|
|
61
|
+
// Legacy records use mtime; new records retain their first priced observation.
|
|
62
|
+
export function codexDocumentTotals(opts = {}) {
|
|
33
63
|
const since = readResetMarks(opts?.dir ?? userDataDir())['codex-docs'];
|
|
34
|
-
const
|
|
64
|
+
const now = opts.now ?? Date.now();
|
|
65
|
+
const savings = { week: 0, month: 0, total: 0, priced: 0, unpriced: 0, savedTokens: 0, reasons: {} };
|
|
66
|
+
const total = { docs: 0, byExt: [], scope: 'codex-total', since, savings };
|
|
35
67
|
const byExt = new Map();
|
|
36
68
|
try {
|
|
37
69
|
const dir = ledgerDir(opts);
|
|
38
70
|
for (const file of readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name))) {
|
|
39
71
|
try {
|
|
40
|
-
if (since !== null && isBeforeReset(statSync(join(dir, file)).mtimeMs, since)) continue;
|
|
41
72
|
const event = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
42
|
-
if (event?.version
|
|
73
|
+
if (![1, 2].includes(event?.version) || !['pdf', 'pptx', 'xlsx', 'xls', 'docx', 'fig'].includes(event.ext)) continue;
|
|
74
|
+
const at = event.version === 2 ? event.at : statSync(join(dir, file)).mtimeMs;
|
|
75
|
+
if (isBeforeReset(at, since)) continue;
|
|
43
76
|
total.docs++;
|
|
44
77
|
byExt.set(event.ext, (byExt.get(event.ext) || 0) + 1);
|
|
78
|
+
const s = event.saving;
|
|
79
|
+
if (event.version === 2 && s?.method === 'office-xml-vs-markdown-bytes/4' && Number.isFinite(s.usd)) {
|
|
80
|
+
savings.priced++;
|
|
81
|
+
savings.total += s.usd;
|
|
82
|
+
savings.savedTokens += Number.isFinite(s.savedTokens) ? s.savedTokens : 0;
|
|
83
|
+
if (at <= now && now - at < 7 * 86400000) savings.week += s.usd;
|
|
84
|
+
if (at <= now && now - at < 30 * 86400000) savings.month += s.usd;
|
|
85
|
+
} else {
|
|
86
|
+
savings.unpriced++;
|
|
87
|
+
const reason = event.version === 1 ? 'legacy-record' : s?.reason || 'unknown';
|
|
88
|
+
savings.reasons[reason] = (savings.reasons[reason] || 0) + 1;
|
|
89
|
+
}
|
|
45
90
|
} catch { /* Ignore only the damaged record. */ }
|
|
46
91
|
}
|
|
47
92
|
} catch { /* No Codex document history yet. */ }
|
package/src/codex-doc2md.js
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import { realpathSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { realpathSync, readFileSync, writeFileSync, renameSync, rmSync, openSync, readSync, closeSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname, resolve } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { recordCodexDocument } from './codex-doc2md-ledger.js';
|
|
6
|
+
import { readCodexSnapshot } from './codex-parser.js';
|
|
6
7
|
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const doc2md = require('./doc2md.cjs');
|
|
9
10
|
const lint = require('./korean-lint.cjs');
|
|
10
11
|
const shellTools = new Set(['Bash', 'exec_command', 'shell_command', 'shell']);
|
|
11
12
|
|
|
13
|
+
/** Hook identity, never the user's default model or another project's session. */
|
|
14
|
+
export function codexDocumentContext(payload = {}) {
|
|
15
|
+
const snapshot = readCodexSnapshot(payload.transcript_path);
|
|
16
|
+
let provider, id, fd;
|
|
17
|
+
try {
|
|
18
|
+
fd = openSync(payload.transcript_path, 'r');
|
|
19
|
+
const buf = Buffer.alloc(65536);
|
|
20
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
21
|
+
const first = JSON.parse(buf.subarray(0, n).toString('utf8').split('\n')[0]);
|
|
22
|
+
if (first.type === 'session_meta') { provider = first.payload?.model_provider; id = first.payload?.id || first.payload?.session_id; }
|
|
23
|
+
} catch { /* Payload identity may still be complete. */ }
|
|
24
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
25
|
+
if (id && payload.session_id && id !== payload.session_id) return {};
|
|
26
|
+
return { model: payload.model || snapshot?.model, provider: payload.model_provider || provider || snapshot?.provider };
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
function canonicalPath(file) {
|
|
13
30
|
const missing = [];
|
|
14
31
|
for (let current = file; ; current = dirname(current)) {
|
|
@@ -47,8 +64,8 @@ export function convertCodexDocument(source, { convert = doc2md.convert, ...opts
|
|
|
47
64
|
tmp = null;
|
|
48
65
|
}
|
|
49
66
|
const { savedUsd, baselineTokens, ...meta } = result.meta || {};
|
|
50
|
-
const prepared = { ...result, cacheFile, meta };
|
|
51
|
-
recordCodexDocument(source, prepared, opts);
|
|
67
|
+
const prepared = { ...result, cacheFile, meta: { ...meta, markdownBytes: Buffer.byteLength(text, 'utf8') } };
|
|
68
|
+
prepared.meta.codexSaving = recordCodexDocument(source, prepared, opts);
|
|
52
69
|
return prepared;
|
|
53
70
|
} catch {
|
|
54
71
|
return { ok: false, reason: 'cache-unavailable' };
|
|
@@ -87,7 +104,7 @@ export function codexDocumentTool(payload, { convert = convertCodexDocument, gua
|
|
|
87
104
|
if (match) file = match[1] || match[2] || match[3];
|
|
88
105
|
}
|
|
89
106
|
if (typeof file !== 'string' || !doc2md.isTargetPath(file)) return null;
|
|
90
|
-
const result = convert(resolve(cwd, file));
|
|
107
|
+
const result = convert(resolve(cwd, file), codexDocumentContext(payload));
|
|
91
108
|
if (!result.ok) return { hookSpecificOutput: { hookEventName: 'PreToolUse',
|
|
92
109
|
...(result.reason === 'unsafe-archive' ? { permissionDecision: 'deny', permissionDecisionReason: '[doc2md] Unsafe archive; do not extract it.' }
|
|
93
110
|
: { additionalContext: `[doc2md] Conversion failed (${result.reason}). Do not claim this document was converted. ${codexDocumentNote()}` }) } };
|
package/src/codex-doctor.js
CHANGED
|
@@ -15,13 +15,18 @@ export const CODEX_CAPABILITIES = [
|
|
|
15
15
|
{ feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only records of context (80%/95%) and 5h/weekly rate-limit (90%) warnings, plus handoffs. Cache and TTL chips are not recorded; last shows the latest event.' },
|
|
16
16
|
{ feature: 'handoff', support: 'supported', command: 'sprag handoff --agent codex', note: 'Git state and recorded Codex session usage; never reads Claude caps.' },
|
|
17
17
|
{ feature: 'korean', support: 'supported', command: 'sprag korean status --agent codex', note: 'Session guidance and recognized write-target lint; shared preference.' },
|
|
18
|
+
{ feature: 'reply-language', support: 'supported', command: 'sprag korean reply on --agent codex', note: 'Trusted Stop hook checks the current Korean prompt and final reply; continuation, opt-out and explicit English requests are excluded.' },
|
|
18
19
|
{ feature: 'cohesion', support: 'supported', command: 'sprag cohesion status --agent codex', note: 'Shared English guidance preference.' },
|
|
19
20
|
{ feature: 'doc2md', support: 'supported', command: 'sprag doc2md status --agent codex', note: 'Shared converter, prompt paths, supported file reads, and recognized document/cache write guards; not arbitrary shell extraction.' },
|
|
21
|
+
{ feature: 'doc2md-saved', support: 'partial', command: 'sprag doc2md savings --agent codex', note: 'Office XML versus Markdown bytes/4 at the recorded Codex input price. Estimate only; incomplete or unsupported baselines and missing prices stay unknown.' },
|
|
22
|
+
{ feature: 'delegation-diagnostics', support: 'supported', command: 'sprag delegate doctor --agent codex', note: 'Local feature/catalog and matched-child evidence. Never claims that an enabled preference proves native tools are available.' },
|
|
20
23
|
{ feature: 'delegate', support: 'supported', command: 'sprag delegate on --agent codex', note: 'Native routing and shared delegation policies; tier models resolve from priced models already run in Codex, or from explicit mappings. Explicit models/custom roles preserved; bounds are guidance, not enforced limits.' },
|
|
21
24
|
{ feature: 'routing-saved', support: 'partial', command: 'sprag route-scan savings --agent codex', note: 'Sprag-routed child usage with matching LiteLLM prices; counterfactual estimate, not billed savings. Unpriced runs stay unknown.' },
|
|
22
25
|
{ feature: 'cache-timer', support: 'partial', command: 'sprag cache status --refresh --agent codex', note: 'Documented minimum retention after recorded cache usage; LiteLLM deployments resolved through model/info. Unknown routes show age; exact expiry is not reported.' },
|
|
23
26
|
{ feature: 'mode', support: 'partial', command: 'sprag mode --agent codex', note: 'Labels, color, and timer apply to panel/statusline; Claude TTL buckets do not.' },
|
|
24
27
|
{ feature: 'upgrade', support: 'manual', command: 'sprag upgrade --print --agent codex', note: 'Skip the Claude npm postinstall; reinstall Codex hooks after upgrade.' },
|
|
28
|
+
{ feature: 'update-notice', support: 'supported', command: 'sprag update-check --agent codex', note: 'Live panel refreshes in the background; one-shot statusline reads cached state; interactive SessionStart offers upgrades with consent.' },
|
|
29
|
+
{ feature: 'implementation-delegation', support: 'supported', command: 'sprag delegate implementation --agent codex --from MODEL --provider PROVIDER --session ID', note: 'Main agent designs and verifies; implementation uses resolved T1 only. Requires native subagent tools and records only matched child runs.' },
|
|
25
30
|
{ feature: 'route-scan', support: 'supported', command: 'sprag route-scan --agent codex', note: 'Recurring simple turns grouped by project, provider, and parent model; explicit approval before routing.' },
|
|
26
31
|
{ feature: 'seed', support: 'supported', command: 'sprag seed --agent codex', note: 'Shared delegation policies plus agent-compatible ratchet presets. Explicit scope; tier models resolve automatically or by mapping. No Claude installation needed.' },
|
|
27
32
|
{ feature: 'transcript ratchet', support: 'supported', command: 'sprag --statusline --agent codex', note: 'Recent repeated tool failures become ratchet? candidates; no automatic rule registration.' },
|
|
@@ -37,6 +42,8 @@ export async function codexDoctor({ root = process.cwd(), sessionId, days = 7 }
|
|
|
37
42
|
const cfg = config.present ? parse(readFileSync(file, 'utf8')) : {};
|
|
38
43
|
// Only expose an allowlist. Provider auth and arbitrary config may hold secrets.
|
|
39
44
|
config.hooksDisabled = cfg.features?.hooks === false || (cfg.features?.hooks === undefined && cfg.features?.codex_hooks === false);
|
|
45
|
+
config.agentsDisabled = cfg.agents?.enabled === false || cfg.features?.multi_agent === false;
|
|
46
|
+
config.agentsMaxThreads = cfg.agents?.max_concurrent_threads_per_session ?? cfg.agents?.max_threads ?? null;
|
|
40
47
|
config.statusLine = Array.isArray(cfg.tui?.status_line) ? cfg.tui.status_line.filter((item) => typeof item === 'string') : null;
|
|
41
48
|
config.autoCompactTokenLimit = Number.isFinite(cfg.model_auto_compact_token_limit) ? cfg.model_auto_compact_token_limit : null;
|
|
42
49
|
config.note = 'User config only; active profile, project, managed policy, and runtime overrides may differ.';
|
|
@@ -49,5 +56,6 @@ export async function codexDoctor({ root = process.cwd(), sessionId, days = 7 }
|
|
|
49
56
|
lastActivity: data.session.lastActivity, usageUpdates: data.session.requestCount } : null;
|
|
50
57
|
return { agent: 'codex', home, root, config, hooks, harness: data.harness, rules: data.rules,
|
|
51
58
|
preferences: { korean: data.korean, lint: data.lint, cohesion: data.cohesion, doc2md: data.doc2md, brief: data.brief, delegate: data.delegate },
|
|
52
|
-
session,
|
|
59
|
+
session, delegation: { ...data.delegation, tools: 'unknown; only the running agent can confirm spawn_agent and wait_agent availability' },
|
|
60
|
+
documents: data.documents, routingSaved: data.routingSaved, panel: panelHealth(root, { sessionId }), capabilities: CODEX_CAPABILITIES };
|
|
53
61
|
}
|
package/src/codex-harness.js
CHANGED
|
@@ -5,6 +5,7 @@ import { codexUserDir } from './agent.js';
|
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
6
|
import { koreanStyleEnabled, koreanStyleInjection } from './korean-style.js';
|
|
7
7
|
import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines, forkedCopyFilter } from './codex-parser.js';
|
|
8
|
+
import { implementationDelegationRule } from './implementation-delegation.js';
|
|
8
9
|
|
|
9
10
|
export const CODEX_BEGIN = '<!-- sprag:codex:harness:begin -->';
|
|
10
11
|
export const CODEX_END = '<!-- sprag:codex:harness:end -->';
|
|
@@ -39,7 +40,7 @@ function harnessFiles(root, scope) {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export function codexHarnessBlock() {
|
|
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`;
|
|
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`;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/** The [start, end) span of a marker block, null when absent; malformed markers throw. */
|
package/src/codex-hooks.js
CHANGED
|
@@ -10,13 +10,14 @@ 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';
|
|
13
|
+
import { codexDocumentTool, codexDocumentNote, convertCodexDocument, codexDocumentContext } from './codex-doc2md.js';
|
|
14
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,8 +163,14 @@ 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
175
|
if (event === 'subagent-start' && !codexDelegateEnabled(cfg)) return null;
|
|
169
176
|
if (event === 'subagent-start') {
|
|
@@ -203,6 +210,7 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
|
|
|
203
210
|
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) parts.push({ priority: 4, spill: true, text: codexDocumentNote() });
|
|
204
211
|
// Offers ask the user a question; a `codex exec` run has no one to answer it.
|
|
205
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() }) });
|
|
206
214
|
try { parts.push({ priority: 5, text: await routeNotice(root) }); } catch (e) { debug('codex:route-notice', e); }
|
|
207
215
|
try { parts.push({ priority: 6, text: seedOfferBlock({ root, agent: 'codex' }) }); } catch (e) { debug('codex:seed-offer', e); }
|
|
208
216
|
}
|
|
@@ -228,7 +236,8 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
|
|
|
228
236
|
catch (e) { debug('codex:brief', e); }
|
|
229
237
|
}
|
|
230
238
|
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
|
|
231
|
-
|
|
239
|
+
const identity = codexDocumentContext(payload);
|
|
240
|
+
parts.push(doc2md.contextForPrompt(payload, { lang: userLanguage(), agent: 'codex', convert: (source) => convertCodexDocument(source, identity) }));
|
|
232
241
|
}
|
|
233
242
|
return context('UserPromptSubmit', parts.filter(Boolean).join('\n\n'));
|
|
234
243
|
}
|
package/src/codex-installer.js
CHANGED
|
@@ -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';
|
package/src/codex-ledger.js
CHANGED
|
@@ -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
|
|
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,
|
package/src/codex-panel.js
CHANGED
|
@@ -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';
|
|
20
|
+
import { codexRoutingSavedTotals, loadCodexLedger, codexDelegationStatus } from './codex-ledger.js';
|
|
21
21
|
import { loadCodexModelRules, codexDelegateEnabled } from './codex-delegation.js';
|
|
22
|
-
import { codexRulesInReview } from './codex-rule-health.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),
|
|
@@ -140,6 +146,7 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
140
146
|
brief: cfg?.codex?.brief !== false,
|
|
141
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
|
-
|
|
280
|
-
|
|
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'} (
|
|
298
|
-
|
|
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
|
+
}
|
package/src/codex-rule-health.js
CHANGED
|
@@ -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
|