sprag-cli 3.56.0 → 3.58.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/README.ko.md +60 -40
- package/README.md +63 -41
- package/bin/cli.js +65 -19
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +5 -1
- package/docs/CODEX.md +5 -0
- package/package.json +14 -26
- package/src/agents.js +16 -2
- package/src/brief.js +17 -8
- package/src/claude-price.js +23 -2
- package/src/codex-brief.js +35 -2
- package/src/codex-delegation.js +12 -4
- package/src/codex-harness.js +43 -9
- package/src/codex-hooks.js +24 -3
- package/src/codex-ledger.js +61 -5
- package/src/codex-panel.js +42 -22
- package/src/codex-rule-health.js +24 -1
- package/src/commands/brief.js +14 -3
- package/src/commands/codex-delegate.js +7 -4
- package/src/commands/codex.js +2 -0
- package/src/commands/harness.js +6 -0
- package/src/commands/install.js +24 -13
- package/src/compact-window.js +12 -4
- package/src/config.js +3 -3
- package/src/cost.js +5 -2
- package/src/ctx-window-cache.js +61 -0
- package/src/delegation-guard.js +15 -9
- package/src/doc2md.cjs +22 -8
- package/src/fig2md.cjs +7 -1
- package/src/formatters/statusline.js +4 -4
- package/src/formatters/table.js +18 -10
- package/src/gateway-auth.js +31 -3
- package/src/harness.js +43 -12
- package/src/history.js +6 -2
- package/src/hook-manager.js +54 -32
- package/src/installer.js +315 -158
- package/src/korean-lint.cjs +40 -7
- package/src/litellm-budget.js +10 -1
- package/src/model-rules.js +88 -41
- package/src/month-spend.js +17 -11
- package/src/parser.js +232 -56
- package/src/postinstall.js +32 -0
- package/src/prompt.js +28 -0
- package/src/route-caps.js +74 -0
- package/src/route-inject.js +31 -7
- package/src/route-scan.js +78 -26
- package/src/savings-ledger.js +33 -5
- package/src/seed-rules.js +3 -4
- package/src/session-cache.js +35 -1
- package/src/state-file.js +43 -0
- package/src/stats.js +20 -5
- package/src/statusline-base.js +37 -0
- package/src/stdin-payload.js +36 -5
package/src/brief.js
CHANGED
|
@@ -29,6 +29,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, openSync,
|
|
|
29
29
|
import { join } from 'node:path';
|
|
30
30
|
import { userDataDir } from './paths.js';
|
|
31
31
|
import { resolveModelId, isOneMillionModel, effectiveWindow } from './compact-window.js';
|
|
32
|
+
import { liveWindowFor } from './ctx-window-cache.js';
|
|
32
33
|
|
|
33
34
|
// Context tiers as a fraction of the session's context window. Tier 1 warns
|
|
34
35
|
// (compaction/cost territory ahead), tier 2 urges wrapping up. A session only
|
|
@@ -83,14 +84,21 @@ function saveState(state, now) {
|
|
|
83
84
|
*
|
|
84
85
|
* `observedMax` stays as a floor: it proves a 1M window even when the model id
|
|
85
86
|
* is unreadable (env override, settings we do not resolve).
|
|
87
|
+
*
|
|
88
|
+
* `liveWindow` outranks both. It is the size Claude Code reported to the
|
|
89
|
+
* statusline for this session, so the briefing and the statusline count against
|
|
90
|
+
* the same window. The model id cannot stand in for it: a model that is 1M
|
|
91
|
+
* without a `[1m]` suffix reads as 200k there.
|
|
86
92
|
*/
|
|
87
|
-
export function ctxWindowFor(observedMax = 0, root = process.cwd()) {
|
|
93
|
+
export function ctxWindowFor(observedMax = 0, root = process.cwd(), { liveWindow = null } = {}) {
|
|
88
94
|
let modelWindow = observedMax > WINDOW_1M_MIN_INPUT ? 1_000_000 : 200_000;
|
|
95
|
+
const reported = Number.isFinite(liveWindow) && liveWindow > 0;
|
|
96
|
+
if (reported) modelWindow = liveWindow;
|
|
89
97
|
let window = modelWindow;
|
|
90
98
|
let compactCapped = false;
|
|
91
99
|
try {
|
|
92
100
|
const { model } = resolveModelId(root);
|
|
93
|
-
if (isOneMillionModel(model)) modelWindow = window = 1_000_000;
|
|
101
|
+
if (!reported && isOneMillionModel(model)) modelWindow = window = 1_000_000;
|
|
94
102
|
const cap = effectiveWindow(root).value;
|
|
95
103
|
if (cap !== null && cap < window) {
|
|
96
104
|
window = cap;
|
|
@@ -104,7 +112,7 @@ export function ctxWindowFor(observedMax = 0, root = process.cwd()) {
|
|
|
104
112
|
* Last request's input size for THIS session, from the transcript tail.
|
|
105
113
|
* Reads at most TAIL_BYTES — prompt-submit hooks must stay fast.
|
|
106
114
|
*/
|
|
107
|
-
export function sessionCtx(transcriptPath, { root = process.cwd() } = {}) {
|
|
115
|
+
export function sessionCtx(transcriptPath, { root = process.cwd(), liveWindow = null } = {}) {
|
|
108
116
|
let size;
|
|
109
117
|
try { size = statSync(transcriptPath).size; } catch { return null; }
|
|
110
118
|
const start = Math.max(0, size - TAIL_BYTES);
|
|
@@ -130,7 +138,7 @@ export function sessionCtx(transcriptPath, { root = process.cwd() } = {}) {
|
|
|
130
138
|
if (total > 0) { input = total; maxInput = Math.max(maxInput, total); }
|
|
131
139
|
}
|
|
132
140
|
if (input == null) return null;
|
|
133
|
-
const { window, modelWindow, compactCapped } = ctxWindowFor(maxInput, root);
|
|
141
|
+
const { window, modelWindow, compactCapped } = ctxWindowFor(maxInput, root, { liveWindow });
|
|
134
142
|
return { input, window, modelWindow, compactCapped, pct: input / window };
|
|
135
143
|
}
|
|
136
144
|
|
|
@@ -166,14 +174,15 @@ export function seedSessionBriefed(sessionId, signatures, now = Date.now()) {
|
|
|
166
174
|
* (recorded via seedSessionBriefed) are skipped; every other fresh signature
|
|
167
175
|
* is emitted, including on the session's first event.
|
|
168
176
|
*/
|
|
169
|
-
export async function runBrief({ sessionId, transcriptPath, now = Date.now() }) {
|
|
177
|
+
export async function runBrief({ sessionId, transcriptPath, root = process.cwd(), now = Date.now() }) {
|
|
170
178
|
if (!sessionId) return null;
|
|
179
|
+
const liveWindow = liveWindowFor(sessionId);
|
|
171
180
|
const state = loadState();
|
|
172
181
|
const s = state.sessions[sessionId] || { ctxTier: 0, briefed: [] };
|
|
173
182
|
const items = [];
|
|
174
183
|
|
|
175
184
|
// ── context tier crossing (per-session) ──
|
|
176
|
-
const ctx = transcriptPath ? sessionCtx(transcriptPath) : null;
|
|
185
|
+
const ctx = transcriptPath ? sessionCtx(transcriptPath, { root, liveWindow }) : null;
|
|
177
186
|
if (ctx) {
|
|
178
187
|
const tier = ctxTierOf(ctx.pct);
|
|
179
188
|
// The tier is not monotonic: auto-compaction drops the live context back to
|
|
@@ -238,7 +247,7 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
|
|
|
238
247
|
// never needed. 200k sessions are exempt (their window is already <= 200k).
|
|
239
248
|
try {
|
|
240
249
|
const cw = await import('./compact-window.js');
|
|
241
|
-
const st = cw.compactWindowStatus({ root
|
|
250
|
+
const st = cw.compactWindowStatus({ root, liveWindow });
|
|
242
251
|
if (!st.ok) {
|
|
243
252
|
const sig = `compact-window|${st.reason}`;
|
|
244
253
|
const briefed = new Set(s.briefed || []);
|
|
@@ -246,7 +255,7 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
|
|
|
246
255
|
briefed.add(sig);
|
|
247
256
|
s.briefed = [...briefed];
|
|
248
257
|
const now = st.window ? `현재 ${fmtK(st.window)}` : '현재 미설정';
|
|
249
|
-
items.push(`1M 컨텍스트 모델(${st.model})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 1M은 너무 크니 ${fmtK(st.recommendedMin)}~${fmtK(st.recommendedMax)} 범위를 권장합니다(그 범위 안이면 경고하지 않습니다). 1M 창 자체는 그대로 두고 압축 시점만 앞당깁니다. 등록: sprag compact-window set --global|--project [--value ${fmtK(st.recommendedMax)}] (기본 ${fmtK(st.recommended)}, 적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
|
|
258
|
+
items.push(`1M 컨텍스트 모델(${st.model || 'Claude Code 기본 모델'})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 1M은 너무 크니 ${fmtK(st.recommendedMin)}~${fmtK(st.recommendedMax)} 범위를 권장합니다(그 범위 안이면 경고하지 않습니다). 1M 창 자체는 그대로 두고 압축 시점만 앞당깁니다. 등록: sprag compact-window set --global|--project [--value ${fmtK(st.recommendedMax)}] (기본 ${fmtK(st.recommended)}, 적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
|
|
250
259
|
}
|
|
251
260
|
}
|
|
252
261
|
} catch { /* settings unreadable — other briefings above still apply */ }
|
package/src/claude-price.js
CHANGED
|
@@ -16,10 +16,31 @@ export function sessionCost(totals, model, { env = process.env, now } = {}) {
|
|
|
16
16
|
return price ? estimateCost(totals, model, { price }) : null;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/** One priced part per model that spent tokens, or the whole at its one model. */
|
|
20
|
+
function partsByModel(x) {
|
|
21
|
+
if (!x.modelTotals) return [{ totals: x.totals, model: x.model }];
|
|
22
|
+
return Object.entries(x.modelTotals).map(([model, totals]) => ({ totals, model }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a session's cost is made of: the session itself and each subagent run
|
|
27
|
+
* it spawned (parser.js attaches them as `subagentRuns`). A run has its own
|
|
28
|
+
* model, so it is priced on its own instead of at the session's rate, and a
|
|
29
|
+
* session or run that switched model is split again so each model's tokens
|
|
30
|
+
* meet that model's rate. `own` marks the parts that are the session itself.
|
|
31
|
+
*/
|
|
32
|
+
export function pricedParts(session) {
|
|
33
|
+
if (!session) return [];
|
|
34
|
+
return [
|
|
35
|
+
...partsByModel(session).map((part) => ({ ...part, own: true })),
|
|
36
|
+
...(session.subagentRuns || []).flatMap(partsByModel),
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Like estimateCostAcross, plus how many priced parts (a session, a subagent run, or one model's share of either) had no price and were left out. */
|
|
20
41
|
export function sessionCostAcross(sessions, opts = {}) {
|
|
21
42
|
let unpriced = 0;
|
|
22
|
-
const total = estimateCostAcross(sessions, (totals, model) => {
|
|
43
|
+
const total = estimateCostAcross((sessions || []).flatMap(pricedParts), (totals, model) => {
|
|
23
44
|
const c = sessionCost(totals, model, opts);
|
|
24
45
|
if (!c) unpriced++;
|
|
25
46
|
return c;
|
package/src/codex-brief.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
4
|
import { codexUserDir } from './agent.js';
|
|
5
5
|
import { userDataDir } from './paths.js';
|
|
6
6
|
import { readCodexSnapshot } from './codex-parser.js';
|
|
7
|
+
import { loadCodexModelRules } from './codex-delegation.js';
|
|
8
|
+
import { loadCodexLedger } from './codex-ledger.js';
|
|
9
|
+
import { codexRulesInReview } from './codex-rule-health.js';
|
|
7
10
|
|
|
11
|
+
const RESET_TOLERANCE_S = 300;
|
|
8
12
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
9
13
|
const stateDir = (dir) => join(dir, 'codex', hash(codexUserDir()), 'sessions');
|
|
10
14
|
const fresh = (value, now) => {
|
|
@@ -45,7 +49,7 @@ function readState(file) {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/** One file per home/session keeps unrelated sessions out of the same dedup state. */
|
|
48
|
-
export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now(), dir = userDataDir() } = {}) {
|
|
52
|
+
export function runCodexBrief({ sessionId, transcriptPath, cwd, root: projectRoot, now = Date.now(), dir = userDataDir() } = {}) {
|
|
49
53
|
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
50
54
|
const snapshot = readCodexSnapshot(transcriptPath);
|
|
51
55
|
if (!snapshot || (snapshot.sessionId && snapshot.sessionId !== sessionId)) return null;
|
|
@@ -54,6 +58,35 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now()
|
|
|
54
58
|
const file = join(directory, `${hash(sessionId)}.json`);
|
|
55
59
|
const state = readState(file);
|
|
56
60
|
const active = Array.isArray(state.active) ? state.active.filter((sig) => typeof sig === 'string') : [];
|
|
61
|
+
// Codex rewrites resets_at by about a second between records, so a limit
|
|
62
|
+
// already announced for the same window keeps its first signature.
|
|
63
|
+
for (const issue of issues) {
|
|
64
|
+
const m = /^limit:(primary|secondary):(\d+(?:\.\d+)?)$/.exec(issue.signature);
|
|
65
|
+
if (!m) continue;
|
|
66
|
+
const resetsAt = Number(m[2]);
|
|
67
|
+
const known = active.find((sig) => {
|
|
68
|
+
const k = /^limit:(primary|secondary):(\d+(?:\.\d+)?)$/.exec(sig);
|
|
69
|
+
return k && k[1] === m[1] && Math.abs(Number(k[2]) - resetsAt) <= RESET_TOLERANCE_S;
|
|
70
|
+
});
|
|
71
|
+
if (known) issue.signature = known;
|
|
72
|
+
}
|
|
73
|
+
// A failing model rule is announced once per session; the lines never block the other warnings.
|
|
74
|
+
try {
|
|
75
|
+
// The hook passes the project root its rule matching uses; a prompt sent from
|
|
76
|
+
// a subdirectory must still see the project's rules.
|
|
77
|
+
const root = resolve(projectRoot || cwd || snapshot.projectDir || '.');
|
|
78
|
+
const rules = loadCodexModelRules({ dir });
|
|
79
|
+
if (rules.length) {
|
|
80
|
+
const events = Object.values(loadCodexLedger({ dir }).events);
|
|
81
|
+
for (const { index, rule, health } of codexRulesInReview({ root, rules, events })) {
|
|
82
|
+
issues.push({ key: 'rule-health', tier: 1,
|
|
83
|
+
signature: `rule-health:${rule.scope}:${rule.targetRoot ?? ''}:${rule.category}:${rule.from}:${rule.model}:${rule.createdAt ?? ''}`,
|
|
84
|
+
level: 'warning',
|
|
85
|
+
message: `Codex model rule #${index} (${rule.category}, ${rule.from} -> ${rule.model}) failed ${health.errs} of ${health.runs} measured runs.`,
|
|
86
|
+
advice: `Narrow or remove it: sprag delegate rules rm ${index} --agent codex` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { /* An unreadable rule file or ledger only skips this line. */ }
|
|
57
90
|
const next = issues.filter((issue) => !active.includes(issue.signature) &&
|
|
58
91
|
!(issue.key === 'context' && issue.tier === 1 && active.includes('context:2')));
|
|
59
92
|
// Missing or stale measurements do not resolve an earlier warning. A fresh
|
package/src/codex-delegation.js
CHANGED
|
@@ -54,7 +54,10 @@ export function addCodexModelRule({ category, model, effort, from, scope, root,
|
|
|
54
54
|
const rules = loadCodexModelRules({ dir });
|
|
55
55
|
const targetRoot = scope === 'project' ? resolve(root) : null;
|
|
56
56
|
const rule = { category, model, effort, from, scope, targetRoot, ...(provider ? { provider } : {}), status: 'active', createdAt: new Date().toISOString() };
|
|
57
|
-
|
|
57
|
+
// A rule saved without a provider has no `provider` key, and a caller with
|
|
58
|
+
// none passes null. Compared as they are, undefined !== null, so re-adding
|
|
59
|
+
// the same rule stacked a duplicate instead of replacing it.
|
|
60
|
+
const old = rules.findIndex((r) => r.category === category && r.from === from && r.scope === scope && r.targetRoot === targetRoot && (r.provider ?? null) === (provider ?? null));
|
|
58
61
|
if (old < 0) rules.push(rule); else rules[old] = rule;
|
|
59
62
|
saveRules(rules, dir);
|
|
60
63
|
return rule;
|
|
@@ -126,7 +129,8 @@ export function codexRouteHint(payload, opts = {}) {
|
|
|
126
129
|
try {
|
|
127
130
|
record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
|
|
128
131
|
source: 'rule', category: rule.category, scope: rule.scope, from: payload?.model, to: rule.model,
|
|
129
|
-
provider, effort: rule.effort ?? null, contextTokens: ctx
|
|
132
|
+
provider, effort: rule.effort ?? null, contextTokens: ctx,
|
|
133
|
+
targetRoot: rule.targetRoot ?? null, ruleCreatedAt: rule.createdAt ?? null }, { dir: opts.dir });
|
|
130
134
|
} catch { /* Accounting is optional; the hint still applies. */ }
|
|
131
135
|
return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
|
|
132
136
|
+ `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
|
|
@@ -160,7 +164,7 @@ function customRole(role, root, home) {
|
|
|
160
164
|
|
|
161
165
|
// Kept for Codex versions that do send spawn_agent through PreToolUse; on
|
|
162
166
|
// 0.159.2 the hook never fires for it, so codexRouteHint above is the live path.
|
|
163
|
-
export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules, dir = userDataDir(), record = recordCodexDelegation } = {}) {
|
|
167
|
+
export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules: given, dir = userDataDir(), record = recordCodexDelegation } = {}) {
|
|
164
168
|
if (cfg.codex?.delegate !== true) return null;
|
|
165
169
|
const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
|
|
166
170
|
if (!['spawn_agent', 'Agent'].includes(tool)) return null;
|
|
@@ -169,6 +173,9 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
|
|
|
169
173
|
const role = input.agent_type || 'default';
|
|
170
174
|
if (!['default', 'worker', 'explorer'].includes(role) || customRole(role, payload.cwd || root, home)) return null;
|
|
171
175
|
const explicit = Object.hasOwn(input, 'model');
|
|
176
|
+
// An unreadable rule file must not stop the default target or the spawn itself.
|
|
177
|
+
let rules = given;
|
|
178
|
+
if (!rules) { try { rules = loadCodexModelRules({ dir }); } catch { rules = []; } }
|
|
172
179
|
const provider = payload.model_provider || codexProviderName({ home });
|
|
173
180
|
const rule = explicit ? null : matchCodexModelRule(input.message, { model: payload.model, provider, root, rules });
|
|
174
181
|
const target = rule || (!explicit && cfg.codex?.delegateTarget);
|
|
@@ -184,7 +191,8 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
|
|
|
184
191
|
try {
|
|
185
192
|
record({ id, parentSessionId: typeof payload.session_id === 'string' ? payload.session_id : null,
|
|
186
193
|
source: rule ? 'rule' : 'default', category: rule?.category ?? null, scope: rule?.scope ?? null,
|
|
187
|
-
from: payload.model, to: target.model, provider, effort: updatedInput.reasoning_effort ?? null
|
|
194
|
+
from: payload.model, to: target.model, provider, effort: updatedInput.reasoning_effort ?? null,
|
|
195
|
+
...(rule ? { targetRoot: rule.targetRoot ?? null, ruleCreatedAt: rule.createdAt ?? null } : {}) }, { dir });
|
|
188
196
|
routeLine = `\n<!-- sprag:codex:route id=${id} -->`;
|
|
189
197
|
} catch { /* Accounting is optional; the route itself still applies. */ }
|
|
190
198
|
}
|
package/src/codex-harness.js
CHANGED
|
@@ -14,14 +14,22 @@ const sections = [
|
|
|
14
14
|
['Default Safe Path', 'Ask before destructive or external actions unless already authorized. Proceed with reads, local edits, and tests within the granted permissions.'],
|
|
15
15
|
];
|
|
16
16
|
|
|
17
|
+
const scopeDir = (root, scope) => (scope === 'global' ? codexUserDir() : root);
|
|
18
|
+
|
|
17
19
|
export function codexHarnessPaths(root, scope = 'project') {
|
|
18
|
-
const dir = scope
|
|
20
|
+
const dir = scopeDir(root, scope);
|
|
19
21
|
// Codex reads the override instead of AGENTS.md when it is non-empty.
|
|
20
22
|
const override = join(dir, 'AGENTS.override.md');
|
|
21
23
|
const file = existsSync(override) && readFileSync(override, 'utf8').trim() ? override : join(dir, 'AGENTS.md');
|
|
22
24
|
return { file, ratchet: scope === 'global' ? join(dir, 'ratchet.md') : join(dir, '.codex', 'ratchet.md') };
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** Every instruction file of a scope that init may have written to, active or not. */
|
|
28
|
+
function harnessFiles(root, scope) {
|
|
29
|
+
const dir = scopeDir(root, scope);
|
|
30
|
+
return [join(dir, 'AGENTS.override.md'), join(dir, 'AGENTS.md')];
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
export function codexHarnessBlock() {
|
|
26
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`;
|
|
27
35
|
}
|
|
@@ -40,8 +48,11 @@ export function initCodexHarness({ root, scope = 'project' }) {
|
|
|
40
48
|
const existing = existsSync(paths.file) ? readFileSync(paths.file, 'utf8') : '';
|
|
41
49
|
const span = range(existing);
|
|
42
50
|
const block = codexHarnessBlock();
|
|
51
|
+
// Trailing newlines are dropped before the separator is added, so the gap
|
|
52
|
+
// above the block is always one blank line however the file ended.
|
|
53
|
+
const body = existing.replace(/\n+$/, '');
|
|
43
54
|
const next = span ? existing.slice(0, span[0]) + block.trimEnd() + existing.slice(span[1])
|
|
44
|
-
:
|
|
55
|
+
: body + (body ? '\n\n' : '') + block;
|
|
45
56
|
mkdirSync(dirname(paths.file), { recursive: true });
|
|
46
57
|
if (next !== existing) {
|
|
47
58
|
if (existing) writeFileSync(`${paths.file}.bak-${Date.now()}`, existing);
|
|
@@ -54,15 +65,38 @@ export function initCodexHarness({ root, scope = 'project' }) {
|
|
|
54
65
|
return paths;
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The file without the block, and without the blank lines init put around it.
|
|
70
|
+
* Cutting the block alone left those behind, so each install and uninstall
|
|
71
|
+
* added three more.
|
|
72
|
+
*/
|
|
73
|
+
function withoutBlock(text, [begin, end]) {
|
|
74
|
+
const before = text.slice(0, begin).replace(/\n+$/, '');
|
|
75
|
+
const after = text.slice(end).replace(/^\n+/, '');
|
|
76
|
+
if (!before) return after;
|
|
77
|
+
return after ? `${before}\n\n${after}` : `${before}\n`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Removes the block from every file of the scope that holds one. The active
|
|
82
|
+
* file alone is not enough: init writes AGENTS.md, and an AGENTS.override.md
|
|
83
|
+
* created afterwards becomes the active file while the block stays in the
|
|
84
|
+
* other one, where a later uninit never looked.
|
|
85
|
+
*/
|
|
57
86
|
export function uninitCodexHarness({ root, scope = 'project' }) {
|
|
58
87
|
const { file } = codexHarnessPaths(root, scope);
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
88
|
+
const cleaned = [];
|
|
89
|
+
for (const candidate of harnessFiles(root, scope)) {
|
|
90
|
+
if (!existsSync(candidate)) continue;
|
|
91
|
+
const existing = readFileSync(candidate, 'utf8');
|
|
92
|
+
const span = range(existing);
|
|
93
|
+
if (!span) continue;
|
|
94
|
+
writeFileSync(`${candidate}.bak-${Date.now()}`, existing);
|
|
95
|
+
writeFileSync(candidate, withoutBlock(existing, span));
|
|
96
|
+
cleaned.push(candidate);
|
|
97
|
+
}
|
|
98
|
+
if (!cleaned.length) return { file, removed: false };
|
|
99
|
+
return { file: cleaned.includes(file) ? file : cleaned[0], removed: true, ...(cleaned.length > 1 ? { files: cleaned } : {}) };
|
|
66
100
|
}
|
|
67
101
|
|
|
68
102
|
export function codexHarnessStatus({ root, scope = 'project' }) {
|
package/src/codex-hooks.js
CHANGED
|
@@ -11,7 +11,7 @@ import { findProjectRoot } from './harness.js';
|
|
|
11
11
|
import { runCodexBrief } from './codex-brief.js';
|
|
12
12
|
import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
|
|
13
13
|
import { codexDelegationTool, codexRouteHint } from './codex-delegation.js';
|
|
14
|
-
import { bindCodexSubagent } from './codex-ledger.js';
|
|
14
|
+
import { bindCodexSubagent, closeStaleCodexRoutes, unsettledCodexRoutes } from './codex-ledger.js';
|
|
15
15
|
import { seedOfferBlock } from './seed-rules.js';
|
|
16
16
|
import { readCodexRouteScan, openCodexCandidates, shouldRescanCodex } from './codex-route-scan.js';
|
|
17
17
|
import { userDataDir } from './paths.js';
|
|
@@ -120,6 +120,20 @@ function defaultRescan() {
|
|
|
120
120
|
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Prices the delegated runs the ledger has not joined yet, in a detached
|
|
125
|
+
* process. The ledger was refreshed only by the route scan, which the rescan
|
|
126
|
+
* gate holds back for up to a day, so "Routing saved" showed a delegation that
|
|
127
|
+
* long after it happened. Claude's side has the same shortcut on its
|
|
128
|
+
* PostToolUse hook; Codex has no hook for a finished child, so the next prompt
|
|
129
|
+
* of the parent is the earliest point that knows one was spawned.
|
|
130
|
+
*/
|
|
131
|
+
function defaultLedgerRefresh() {
|
|
132
|
+
if (process.env.CTS_NO_ROUTE_SCAN === '1') return;
|
|
133
|
+
spawn(process.execPath, [process.argv[1], 'route-scan', 'savings', '--refresh', '--quiet', '--agent', 'codex'],
|
|
134
|
+
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
135
|
+
}
|
|
136
|
+
|
|
123
137
|
export function lintCodexTool(payload, { scope = 'all' } = {}) {
|
|
124
138
|
if (!payload || typeof payload !== 'object') return null;
|
|
125
139
|
const input = payload.tool_input;
|
|
@@ -148,7 +162,7 @@ export function lintCodexTool(payload, { scope = 'all' } = {}) {
|
|
|
148
162
|
return messages.length ? messages.join('\n\n') : null;
|
|
149
163
|
}
|
|
150
164
|
|
|
151
|
-
export async function codexHookOutput(event, payload, { cfg = loadConfig() } = {}) {
|
|
165
|
+
export async function codexHookOutput(event, payload, { cfg = loadConfig(), refreshLedger = defaultLedgerRefresh } = {}) {
|
|
152
166
|
if (!payload || typeof payload !== 'object') return null;
|
|
153
167
|
if (event === 'session-start' || event === 'subagent-start') {
|
|
154
168
|
if (event === 'subagent-start' && cfg?.codex?.delegate !== true) return null;
|
|
@@ -193,13 +207,20 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig() } = {
|
|
|
193
207
|
if (event === 'prompt') {
|
|
194
208
|
const parts = [];
|
|
195
209
|
if (cfg?.codex?.delegate === true) {
|
|
210
|
+
// Before the hint for this prompt is recorded: a hint from an earlier turn
|
|
211
|
+
// that no child claimed is closed, and children that did run get priced.
|
|
212
|
+
try { closeStaleCodexRoutes(payload); } catch (e) { debug('codex:close-routes', e); }
|
|
213
|
+
try { if (unsettledCodexRoutes() > 0) refreshLedger(); } catch (e) { debug('codex:ledger-refresh', e); }
|
|
196
214
|
try {
|
|
197
215
|
parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }),
|
|
198
216
|
minContext: Number.isFinite(cfg?.codex?.delegateMinContext) ? cfg.codex.delegateMinContext : undefined }));
|
|
199
217
|
} catch (e) { debug('codex:route-hint', e); }
|
|
200
218
|
}
|
|
201
219
|
if (cfg?.codex?.brief !== false) {
|
|
202
|
-
try {
|
|
220
|
+
try {
|
|
221
|
+
parts.push(runCodexBrief({ sessionId: payload.session_id, transcriptPath: payload.transcript_path, cwd: payload.cwd,
|
|
222
|
+
root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) }));
|
|
223
|
+
}
|
|
203
224
|
catch (e) { debug('codex:brief', e); }
|
|
204
225
|
}
|
|
205
226
|
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
|
package/src/codex-ledger.js
CHANGED
|
@@ -87,19 +87,57 @@ export function readCodexBindings({ dir = userDataDir() } = {}) {
|
|
|
87
87
|
return out;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Route ids a later prompt closed before any child claimed them. Kept in the binds file as `{ id, closed: true }` lines. */
|
|
91
|
+
function closedCodexRouteIds({ dir = userDataDir() } = {}) {
|
|
92
|
+
let text;
|
|
93
|
+
try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return new Set(); }
|
|
94
|
+
const out = new Set();
|
|
95
|
+
for (const line of text.split('\n')) {
|
|
96
|
+
try {
|
|
97
|
+
const b = JSON.parse(line);
|
|
98
|
+
if (b?.closed === true && /^[0-9a-f]{16}$/.test(b.id)) out.add(b.id);
|
|
99
|
+
} catch { /* Partial trailing line. */ }
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Close the routes this parent was hinted in an earlier turn and never spawned.
|
|
106
|
+
*
|
|
107
|
+
* A hint asks for a spawn in the turn that carries it. Left open, its pending
|
|
108
|
+
* record kept matching for the whole bind window, so a child the parent spawned
|
|
109
|
+
* for an unrelated reason twenty minutes later, on the same model, was bound to
|
|
110
|
+
* it and its cost was booked as a routing saving. A new prompt from the user is
|
|
111
|
+
* what ends the hinted turn, so that is where the record is closed. Returns how
|
|
112
|
+
* many were closed; writes nothing when there are none.
|
|
113
|
+
*/
|
|
114
|
+
export function closeStaleCodexRoutes(payload, { dir = userDataDir(), now = Date.now() } = {}) {
|
|
115
|
+
if (typeof payload?.session_id !== 'string') return 0;
|
|
116
|
+
const sameTurn = (r) => typeof payload.turn_id === 'string' && r.turnId === payload.turn_id;
|
|
117
|
+
const open = readCodexDelegations({ dir }).filter((r) =>
|
|
118
|
+
r.via === 'prompt' && r.parentSessionId === payload.session_id && now - r.at < BIND_TTL_MS && !sameTurn(r));
|
|
119
|
+
if (!open.length) return 0;
|
|
120
|
+
const taken = new Set([...readCodexBindings({ dir }).map((b) => b.id), ...closedCodexRouteIds({ dir })]);
|
|
121
|
+
const stale = open.filter((r) => !taken.has(r.id));
|
|
122
|
+
for (const r of stale) appendFileSync(bindsFile(dir), JSON.stringify({ id: r.id, closed: true, at: now }) + '\n', { mode: 0o600 });
|
|
123
|
+
return stale.length;
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
/**
|
|
91
127
|
* Bind a just-spawned SubagentStart child to the pending route it most likely
|
|
92
128
|
* fulfills: same parent thread, same target model, recent enough, and not
|
|
93
|
-
* already claimed by another child
|
|
94
|
-
*
|
|
129
|
+
* already claimed by another child or closed by a later prompt. When both the
|
|
130
|
+
* record and the payload name a turn, the turns must agree. Returns the bound
|
|
131
|
+
* route id, or null when nothing matches or this child is already bound.
|
|
95
132
|
*/
|
|
96
133
|
export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now() } = {}) {
|
|
97
134
|
if (typeof payload?.agent_id !== 'string' || typeof payload?.session_id !== 'string' || typeof payload?.model !== 'string') return null;
|
|
98
135
|
const binds = readCodexBindings({ dir });
|
|
99
136
|
if (binds.some((b) => b.childSessionId === payload.agent_id)) return null;
|
|
100
|
-
const boundIds = new Set(binds.map((b) => b.id));
|
|
137
|
+
const boundIds = new Set([...binds.map((b) => b.id), ...closedCodexRouteIds({ dir })]);
|
|
138
|
+
const otherTurn = (r) => typeof payload.turn_id === 'string' && typeof r.turnId === 'string' && r.turnId !== payload.turn_id;
|
|
101
139
|
const pending = readCodexDelegations({ dir }).filter((r) =>
|
|
102
|
-
r.parentSessionId === payload.session_id && r.to === payload.model && now - r.at < BIND_TTL_MS && !boundIds.has(r.id));
|
|
140
|
+
r.parentSessionId === payload.session_id && r.to === payload.model && now - r.at < BIND_TTL_MS && !boundIds.has(r.id) && !otherTurn(r));
|
|
103
141
|
if (!pending.length) return null;
|
|
104
142
|
// Oldest first: spawns are recorded in order, so the earliest unbound record belongs to the earliest child.
|
|
105
143
|
const chosen = pending.reduce((a, b) => (b.at < a.at ? b : a));
|
|
@@ -107,6 +145,22 @@ export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now
|
|
|
107
145
|
return chosen.id;
|
|
108
146
|
}
|
|
109
147
|
|
|
148
|
+
/**
|
|
149
|
+
* How many bound routes the ledger has not finished pricing. A child was
|
|
150
|
+
* spawned for each, so its saving exists but is not in the ledger until the
|
|
151
|
+
* next refresh, and the route scan that refreshes it runs at most daily when
|
|
152
|
+
* little new data arrives. Callers use a non-zero count to refresh now. Bound
|
|
153
|
+
* within the bind window only: a child still running after that settles at the
|
|
154
|
+
* regular scan.
|
|
155
|
+
*/
|
|
156
|
+
export function unsettledCodexRoutes({ dir = userDataDir(), now = Date.now() } = {}) {
|
|
157
|
+
const recent = readCodexBindings({ dir }).filter((b) => now - b.at < BIND_TTL_MS);
|
|
158
|
+
if (!recent.length) return 0;
|
|
159
|
+
const pending = new Set(readCodexDelegations({ dir }).filter((r) => now - r.at < PENDING_TTL_MS).map((r) => r.id));
|
|
160
|
+
const events = loadCodexLedger({ dir }).events;
|
|
161
|
+
return recent.filter((b) => pending.has(b.id) && !(events[b.id]?.complete || events[b.id]?.aborted)).length;
|
|
162
|
+
}
|
|
163
|
+
|
|
110
164
|
export function loadCodexLedger({ dir = userDataDir() } = {}) {
|
|
111
165
|
try {
|
|
112
166
|
const data = JSON.parse(readFileSync(ledgerFile(dir), 'utf8'));
|
|
@@ -195,7 +249,9 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
195
249
|
const usd = actual === null || counterfactual === null ? null : roundUsd(run.aborted ? -actual : counterfactual - actual);
|
|
196
250
|
const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
|
|
197
251
|
childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
|
|
198
|
-
from: route.from, to, provider,
|
|
252
|
+
from: route.from, to, provider,
|
|
253
|
+
...(route.targetRoot !== undefined ? { targetRoot: route.targetRoot } : {}),
|
|
254
|
+
...(route.ruleCreatedAt !== undefined ? { ruleCreatedAt: route.ruleCreatedAt } : {}), tokens: run.tokens, calls: run.calls, toolErrors: run.toolErrors, usd, rates: usd === null ? null : rates,
|
|
199
255
|
priceCheckedAt: rates?.checkedAt ?? null, priceSource, complete: run.complete, ...(run.aborted ? { aborted: true } : {}) };
|
|
200
256
|
// A transient price outage must not erase a completed, unchanged priced run.
|
|
201
257
|
if (usd === null && sameRun && previous.complete && Number.isFinite(previous.usd) &&
|
package/src/codex-panel.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve, relative, isAbsolute } from 'node:path';
|
|
1
|
+
import { resolve, relative, isAbsolute, basename } from 'node:path';
|
|
2
2
|
import { realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { stat } from 'node:fs/promises';
|
|
4
4
|
import { readPanelBinding } from './codex-panel-session.js';
|
|
@@ -17,7 +17,9 @@ 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 } from './codex-ledger.js';
|
|
20
|
+
import { codexRoutingSavedTotals, loadCodexLedger } from './codex-ledger.js';
|
|
21
|
+
import { loadCodexModelRules } from './codex-delegation.js';
|
|
22
|
+
import { codexRulesInReview } from './codex-rule-health.js';
|
|
21
23
|
import { readCodexRouteScan, openCodexCandidates } from './codex-route-scan.js';
|
|
22
24
|
import { codexBudgetProvider } from './codex-budget.js';
|
|
23
25
|
|
|
@@ -64,29 +66,41 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
64
66
|
files ??= sessionFile && !sessionId ? [] : await discoverCodexSessionFiles({ home, days: sessionId ? Infinity : days });
|
|
65
67
|
const existing = new Set(files.map((f) => f.path));
|
|
66
68
|
for (const path of cache.keys()) if (!existing.has(path)) cache.delete(path);
|
|
69
|
+
// A rollout is named after its session id. With an id to look for, the
|
|
70
|
+
// files carrying it are parsed first; without this the first frame of a
|
|
71
|
+
// pinned panel parsed every rollout Codex has ever written to find one
|
|
72
|
+
// session. The full search still runs when no file by that name holds the
|
|
73
|
+
// session (a renamed rollout).
|
|
74
|
+
const named = sessionId ? files.filter((f) => basename(f.path).includes(sessionId)) : [];
|
|
75
|
+
const passes = named.length && named.length < files.length ? [named, files] : [files];
|
|
67
76
|
let selected = null;
|
|
68
77
|
let matches = 0;
|
|
69
|
-
const seen = new Set();
|
|
70
78
|
// Recent rollouts of the same project, for repeats across `codex exec` runs.
|
|
71
|
-
|
|
72
|
-
for (const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
let recent = [];
|
|
80
|
+
for (const pass of passes) {
|
|
81
|
+
if (selected) break;
|
|
82
|
+
matches = 0;
|
|
83
|
+
recent = [];
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const f of pass.slice().reverse()) {
|
|
86
|
+
let entry = cache.get(f.path);
|
|
87
|
+
if (!entry || entry.mtime !== f.mtime || entry.size !== f.size) {
|
|
88
|
+
try {
|
|
89
|
+
entry = { ...f, session: await parseCodexSessionFile(f.path) };
|
|
90
|
+
cache.set(f.path, entry);
|
|
91
|
+
} catch { continue; }
|
|
92
|
+
}
|
|
93
|
+
const s = entry.session;
|
|
94
|
+
if ((!sessionId || sessionFile) && s.isSubagent) continue;
|
|
95
|
+
if (sessionId ? s.sessionId !== sessionId : !insideProject(root, s.projectDir)) continue;
|
|
96
|
+
const key = s.sessionId || s.filePath;
|
|
97
|
+
if (seen.has(key)) continue;
|
|
98
|
+
seen.add(key);
|
|
99
|
+
matches++;
|
|
100
|
+
if (!sessionId && Date.now() - f.mtime < RATCHET_SIBLING_MS) recent.push(s.filePath);
|
|
101
|
+
// Activity timestamps survive archive moves that update filesystem mtime.
|
|
102
|
+
if (!selected || (s.lastActivity?.getTime() || 0) > (selected.lastActivity?.getTime() || 0)) selected = s;
|
|
79
103
|
}
|
|
80
|
-
const s = entry.session;
|
|
81
|
-
if ((!sessionId || sessionFile) && s.isSubagent) continue;
|
|
82
|
-
if (sessionId ? s.sessionId !== sessionId : !insideProject(root, s.projectDir)) continue;
|
|
83
|
-
const key = s.sessionId || s.filePath;
|
|
84
|
-
if (seen.has(key)) continue;
|
|
85
|
-
seen.add(key);
|
|
86
|
-
matches++;
|
|
87
|
-
if (!sessionId && Date.now() - f.mtime < RATCHET_SIBLING_MS) recent.push(s.filePath);
|
|
88
|
-
// Activity timestamps survive archive moves that update filesystem mtime.
|
|
89
|
-
if (!selected || (s.lastActivity?.getTime() || 0) > (selected.lastActivity?.getTime() || 0)) selected = s;
|
|
90
104
|
}
|
|
91
105
|
const project = selected?.projectDir ? findProjectRoot(selected.projectDir, { agent: 'codex' }) : root;
|
|
92
106
|
const cfg = loadConfig();
|
|
@@ -102,11 +116,16 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
102
116
|
}
|
|
103
117
|
let route = [];
|
|
104
118
|
try { route = openCodexCandidates(readCodexRouteScan(), { root: project }); } catch { /* No scan yet. */ }
|
|
119
|
+
let ruleReview = [];
|
|
120
|
+
try {
|
|
121
|
+
const modelRules = loadCodexModelRules();
|
|
122
|
+
if (modelRules.length) ruleReview = codexRulesInReview({ root: project, rules: modelRules, events: Object.values(loadCodexLedger().events) }).map((r) => r.index);
|
|
123
|
+
} catch { /* Unreadable rules or ledger show no chip. */ }
|
|
105
124
|
let routingSaved = null;
|
|
106
125
|
try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: isDirectOpenAI(selected?.provider, home ? { home } : {}) || !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
|
|
107
126
|
catch { /* Unreadable ledger reads as n/a. */ }
|
|
108
127
|
return { session: selected, matches, root, sessionId, selection, bindingPending: !!sessionFile && !sessionId, days, harness, rules,
|
|
109
|
-
ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, routingSaved,
|
|
128
|
+
ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, ruleReview, routingSaved,
|
|
110
129
|
labelMode: resolveLabelMode({ cfg: statuslineDefaults(), hasFlag }).mode,
|
|
111
130
|
color: statuslineDefaults().color,
|
|
112
131
|
timer: !hasFlag('--no-timer') && (hasFlag('--timer') || statuslineDefaults().timer),
|
|
@@ -266,6 +285,7 @@ function panelGroups(data, { now, version = '', full = false, mode = full ? 'tex
|
|
|
266
285
|
chip(`Ratchet ${data.rules?.global ?? '?'}/${data.rules?.project ?? '?'}`),
|
|
267
286
|
// A repeated failure outranks a delegation candidate, as in the Claude statusline.
|
|
268
287
|
data.ratchet?.length ? chip(`ratchet? #${data.ratchet[0].id} x${data.ratchet[0].count}`, 33) : null,
|
|
288
|
+
data.ruleReview?.length ? chip(`rule-health #${data.ruleReview[0]}${data.ruleReview.length > 1 ? ` +${data.ruleReview.length - 1}` : ''}`, 33) : null,
|
|
269
289
|
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,
|
|
270
290
|
]);
|
|
271
291
|
register('korean', 'preferences', [
|
package/src/codex-rule-health.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
1
2
|
import { loadCodexLedger } from './codex-ledger.js';
|
|
2
3
|
import { isFailedRun } from './route-scan.js';
|
|
3
4
|
import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from './model-rules.js';
|
|
@@ -9,10 +10,32 @@ import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from '
|
|
|
9
10
|
* lower bound of the failure rate clears HEALTH_ERR_RATE over enough runs.
|
|
10
11
|
*/
|
|
11
12
|
export function codexRuleHealth(rule, { events = Object.values(loadCodexLedger().events) } = {}) {
|
|
13
|
+
const since = Date.parse(rule.createdAt);
|
|
12
14
|
const runs = events.filter((e) => e && e.category === rule.category && e.from === rule.from && e.to === rule.model &&
|
|
13
|
-
(!rule.provider || !e.provider || e.provider === rule.provider) && (e.complete || e.aborted)
|
|
15
|
+
(!rule.provider || !e.provider || e.provider === rule.provider) && (e.complete || e.aborted) &&
|
|
16
|
+
(e.scope == null || e.scope === rule.scope) &&
|
|
17
|
+
// A project run counts only for the project it was recorded in; events from before the field existed cannot be placed.
|
|
18
|
+
(rule.scope !== 'project' || (typeof e.targetRoot === 'string' && e.targetRoot === rule.targetRoot)) &&
|
|
19
|
+
// A rule deleted and added again starts a fresh record.
|
|
20
|
+
(!Number.isFinite(since) || (Number.isFinite(e.ts) && e.ts >= since)));
|
|
14
21
|
const errs = runs.filter((e) => e.aborted || isFailedRun({ toolErrors: e.toolErrors, calls: e.calls })).length;
|
|
15
22
|
const saved = runs.filter((e) => Number.isFinite(e.usd)).reduce((sum, e) => sum + e.usd, 0);
|
|
16
23
|
const review = runs.length >= HEALTH_MIN_SAMPLE_DELEGATED && wilsonLowerBound(errs, runs.length) > HEALTH_ERR_RATE;
|
|
17
24
|
return { runs: runs.length, errs, rate: runs.length ? errs / runs.length : 0, saved, status: review ? 'review' : 'active' };
|
|
18
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rules in review that apply to the project at `root`: global rules, and
|
|
29
|
+
* project rules whose targetRoot is `root`; a null `root` lists every rule. `index` is the 1-based number the
|
|
30
|
+
* rule has in `delegate rules --agent codex`, so every surface names it alike.
|
|
31
|
+
*/
|
|
32
|
+
export function codexRulesInReview({ root, rules, events }) {
|
|
33
|
+
const here = root ? resolve(root) : null;
|
|
34
|
+
const out = [];
|
|
35
|
+
(rules || []).forEach((rule, i) => {
|
|
36
|
+
if (here && rule.scope === 'project' && rule.targetRoot !== here) return;
|
|
37
|
+
const health = codexRuleHealth(rule, { events });
|
|
38
|
+
if (health.status === 'review') out.push({ index: i + 1, rule, health });
|
|
39
|
+
});
|
|
40
|
+
return out;
|
|
41
|
+
}
|