claude-spotter 1.4.17 → 1.4.18
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/CHANGELOG.md +49 -2
- package/README.ja.md +8 -4
- package/README.md +8 -4
- package/bin/spotter.mjs +2 -0
- package/package.json +1 -1
- package/scripts/check-codex-model-policy.mjs +151 -0
- package/src/cli/auditor-cmd.mjs +11 -0
- package/src/cli/auditor-model-matrix-cmd.mjs +181 -0
- package/src/cli/codex-hook-cmd.mjs +104 -24
- package/src/cli/doctor.mjs +17 -1
- package/src/core/codex-auditor-model-policy.mjs +204 -0
- package/src/core/codex-cli-backend.mjs +152 -12
- package/src/hooks/lib.mjs +11 -0
- package/src/index.mjs +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.18
|
|
4
|
+
|
|
5
|
+
auditor model の更新を model 名の場当たり的な置換から切り離し、versioned policy と再現可能な比較 eval を
|
|
6
|
+
導入する。反復評価24/24 exactの `gpt-5.6-terra × medium` をowner裁定でproductionへ昇格した。
|
|
7
|
+
`latest` aliasやCodex CLIの暗黙既定、失敗時fallback、eval artifactからの自動昇格は使わない。
|
|
8
|
+
|
|
9
|
+
### 変更点
|
|
10
|
+
|
|
11
|
+
- **model policy**: 意味論的な auditor role、production selection、`gpt-5.6-luna × low` /
|
|
12
|
+
`gpt-5.6-terra × low` / `gpt-5.6-terra × medium` の評価 profile、policy version、検証状態を
|
|
13
|
+
単一 module に集約した。medium追加時にpolicy versionを2、production昇格時に3へ上げた。
|
|
14
|
+
- **backend / diagnostics**: model と reasoning effort を backend 生成時に一度だけ解決し、成功・失敗の
|
|
15
|
+
structured result と diagnostics に effective selection、選択元、policy version、検証状態を残す。
|
|
16
|
+
model invocation が失敗しても別 model へ retry しない。
|
|
17
|
+
- **比較 eval**: `spotter auditor model-matrix` を追加した。同じ versioned fixture を固定順序で実行し、
|
|
18
|
+
fixture hash、Codex CLI version、schema / exact match、false positive / negative、p50 / p95、timeout、
|
|
19
|
+
catalog 外 name と anomaly を bounded artifact に記録する。Codex JSONL `turn.completed.usage` から
|
|
20
|
+
token数だけを抽出し、raw event本文は保存しない。ChatGPTプランの金額costはAPI価格で代用せず
|
|
21
|
+
`not-available-chatgpt-plan` とし、artifact自身がmodel昇格を許可することはない。
|
|
22
|
+
- **usage-limit diagnostics**: Codex CLIが実測済みの利用上限文言で非ゼロ終了した場合をgenericな
|
|
23
|
+
`E_CODEX_CLI_EXIT` から `E_CODEX_CLI_USAGE_LIMIT` へ分離した。認証失効を先に判定し、一般的な429は
|
|
24
|
+
誤分類しない。Hookはリセット時刻まで待つかCodexプランを確認する復旧案内を表示し、fallbackは行わない。
|
|
25
|
+
- **model unavailable diagnostics**: Codex CLIが指定modelをChatGPTアカウントで利用できないと返した場合を
|
|
26
|
+
`E_CODEX_CLI_MODEL_UNAVAILABLE`へ分離した。認証・利用上限を優先し、providerのstdout/stderrはredact、
|
|
27
|
+
effective selectionとexit codeは診断用に保持する。別modelへのfallbackは行わない。
|
|
28
|
+
- **公式model更新監視**: OpenAI公式のlatest-model / ChatGPT pricing Markdownの両方に揃った完全3種familyだけを
|
|
29
|
+
数値版比較し、週次workflowが評価提案Issueを重複なく作る。policy書換え・model呼出・自動昇格は行わない。
|
|
30
|
+
- **運用SLO / Stop実測**: UserPromptSubmit / Stopのp50・p95・timeout率と品質gateを日本語で正本化した。
|
|
31
|
+
CLIのStop continuationはmax-1を確認した一方、App background/app-server taskはStop非発火だったため、
|
|
32
|
+
active Appのblock挙動が未確認のまま既存契約を変えず、pending deliveryを維持する。
|
|
33
|
+
|
|
34
|
+
### 検証
|
|
35
|
+
|
|
36
|
+
`auditor-model-matrix-cmd` / `auditor-cmd` / `cli` の targeted test 29 / 29 pass、全体
|
|
37
|
+
439 / 437 pass / 0 fail / 2 skip。
|
|
38
|
+
循環・非文字列 backend 応答、model selection 不一致、version 出力の秘密混入、dirty fixture、filtered
|
|
39
|
+
hallucination を敵対的に再検証し、blocker 0 を確認した。代表 fixture の repeat=1 operational smoke は
|
|
40
|
+
baseline / Luna / Terra の全12件が Codex CLI usage limit で `E_CODEX_CLI_EXIT` となったため、model 品質・
|
|
41
|
+
latency・availability の比較には使わなかった。Pro20回復後のrepeat=3を2回実行し、Terra lowは合算
|
|
42
|
+
23/24 exactでbaseline 18/24、Luna low 17/24より最良。usage対応runではtoken usageを36/36取得した。
|
|
43
|
+
Terra lowにも見逃しが1件出たためmediumを追加評価した。Terra mediumは2回のrepeat=3で合計24/24 exact、
|
|
44
|
+
FP/FN 0、timeout 0となり、owner裁定でproductionへ採用した。ChatGPTプランの金額costは取得不能として
|
|
45
|
+
明示した。採用runは全体p95 4.361秒で、stage別SLOとtimeout/workload感度も
|
|
46
|
+
[`docs/04_operational-slo.md`](https://github.com/kitepon-rgb/Spotter/blob/main/docs/04_operational-slo.md)へ固定した。
|
|
47
|
+
|
|
3
48
|
## 1.4.17
|
|
4
49
|
|
|
5
50
|
v1.4.16 で実装済みだった stale Unix socket recovery を、v1.4.16 tag を改変せずこの patch の実配布候補に含める。
|
|
@@ -25,8 +70,10 @@ daemon 異常死後の orphan socket を次回起動前に安全に除去する
|
|
|
25
70
|
|
|
26
71
|
`1c67698` の clean worktree から npm pack / temp prefix install / CLI version / Hook install・reinstall を
|
|
27
72
|
smoke し、`node --test` は 383 / 381 pass / 0 fail / 2 skip。targeted test と adversarial review も
|
|
28
|
-
blocker 0。
|
|
29
|
-
|
|
73
|
+
blocker 0。v1.4.17-only README / CHANGELOG を載せた local release candidate は `6ea6a2b`。
|
|
74
|
+
CLI help、58-entry pack、同じ full suite を再確認した。OS CI matrix はmacOS / Linux / Windows ×
|
|
75
|
+
Node 22.5.0 / 22.xの全6件がgreen。最終 SHA `7987f2a`をtag / npm / GitHub Releaseへ公開し、
|
|
76
|
+
npm `latest`とfresh global installの三者一致を確認した。
|
|
30
77
|
|
|
31
78
|
## 1.4.16
|
|
32
79
|
|
package/README.ja.md
CHANGED
|
@@ -172,6 +172,8 @@ spotter codex-hook install
|
|
|
172
172
|
# Codex native hooks の修復 / 明示登録 (通常は spotter install が実行)
|
|
173
173
|
spotter codex-hook diagnostics
|
|
174
174
|
# Codex hook の登録/readiness を診断。trust は /hooks で review
|
|
175
|
+
spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v1.json
|
|
176
|
+
# pinned auditor model profile を再現可能に比較する experimental eval
|
|
175
177
|
spotter uninstall # hook 登録を解除 (~/.spotter は残す)
|
|
176
178
|
```
|
|
177
179
|
|
|
@@ -190,9 +192,11 @@ Primary auditor backend policy: Claude hooks の auto selection は PATH に Cod
|
|
|
190
192
|
`SPOTTER_AUDITOR_BACKEND` の明示 override はどちらの host でも優先し、runtime failure では別 backend へ
|
|
191
193
|
hidden fallback しません。
|
|
192
194
|
Codex 側の SessionStart hook は `.spotter/tool-db.codex.json` を bg refresh し、Claude DB には触れません。
|
|
193
|
-
Codex CLI auditor
|
|
194
|
-
`
|
|
195
|
-
|
|
195
|
+
Codex CLI auditor は versioned product policy を使い、production は反復 fixture 評価を通過した
|
|
196
|
+
`gpt-5.6-terra × medium`。`gpt-5.6-luna × low` / `gpt-5.6-terra × low` は比較 profile として残し、
|
|
197
|
+
profile から production へ自動昇格しません。`latest` alias や
|
|
198
|
+
親 Codex の default を暗黙継承せず、失敗時に別 model へ retry しません。制御された実験では
|
|
199
|
+
`SPOTTER_CODEX_CLI_MODEL` / `SPOTTER_CODEX_CLI_REASONING_EFFORT` で上書きでき、diagnostics は unverified と表示します。
|
|
196
200
|
明示 smoke には `SPOTTER_AUDITOR_BACKEND=codex-sidecar` も使えます。
|
|
197
201
|
|
|
198
202
|
## 設計ドキュメント
|
|
@@ -207,7 +211,7 @@ Codex CLI auditor の子プロセスは、hook 判定を安く速く保つため
|
|
|
207
211
|
|
|
208
212
|
- v1.4.8 以降、Claude / Codex 両 host で `Stop` hook は **遅延配送 (deferred delivery)** に統一されています。`Stop` で見落としツールを検出した場合、Spotter は `<projectRoot>/.spotter/pending/<sessionId>.json` に指摘を積み、次の same-session `UserPromptSubmit` で `additionalContext` として配信します。当ターンの最初の応答は transcript にそのまま残ります
|
|
209
213
|
- pending ファイルは Claude / Codex が同じパス (`.spotter/pending/`) を共有します。host-neutral 設計です
|
|
210
|
-
- **Haiku の JSON スキーマ違反は v0.5.0 以降「想定済み異常」として session renew + `role_collapse_reset` で回復**します。**v1.4.15 以降、auditor/daemon の失敗はプロンプトをブロックしません**: `UserPromptSubmit` は `[Spotter からの警告]` を出して exit 0
|
|
214
|
+
- **Haiku の JSON スキーマ違反は v0.5.0 以降「想定済み異常」として session renew + `role_collapse_reset` で回復**します。**v1.4.15 以降、auditor/daemon の失敗はプロンプトをブロックしません**: `UserPromptSubmit` は `[Spotter からの警告]` を出して exit 0。`Stop` 失敗も warning pending に積み、次の same-session prompt で1回配信します。直後に session が終わる場合だけ、配送先となる次 prompt がありません
|
|
211
215
|
|
|
212
216
|
<details>
|
|
213
217
|
<summary><strong>📋 最近のハイライト</strong></summary>
|
package/README.md
CHANGED
|
@@ -173,6 +173,8 @@ spotter codex-hook install
|
|
|
173
173
|
# repair / explicitly register Codex native hooks (normally handled by spotter install)
|
|
174
174
|
spotter codex-hook diagnostics
|
|
175
175
|
# check Codex hook registration/readiness; trust is reviewed with /hooks
|
|
176
|
+
spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v1.json
|
|
177
|
+
# experimental reproducible comparison of pinned auditor model profiles
|
|
176
178
|
spotter uninstall # remove hooks from this project (leaves ~/.spotter intact)
|
|
177
179
|
```
|
|
178
180
|
|
|
@@ -191,10 +193,12 @@ otherwise the Haiku-compatible path. Codex native hooks automatically select Cod
|
|
|
191
193
|
`SPOTTER_AUDITOR_BACKEND` override wins on either host; runtime failure never triggers a hidden fallback.
|
|
192
194
|
The Codex SessionStart hook refreshes `.spotter/tool-db.codex.json` in the background
|
|
193
195
|
without touching the Claude DB.
|
|
194
|
-
Codex CLI auditor child processes
|
|
195
|
-
`
|
|
196
|
+
Codex CLI auditor child processes use a versioned product policy. The production selection is
|
|
197
|
+
`gpt-5.6-terra × medium`, promoted after repeated fixture evaluation. `gpt-5.6-luna × low` and
|
|
198
|
+
`gpt-5.6-terra × low` remain comparison profiles; profiles never trigger automatic upgrades.
|
|
199
|
+
Spotter does not inherit a `latest` alias or the parent Codex default, and an invocation failure never retries another model.
|
|
196
200
|
`SPOTTER_CODEX_CLI_MODEL` and `SPOTTER_CODEX_CLI_REASONING_EFFORT` can override
|
|
197
|
-
|
|
201
|
+
the production values for controlled experiments; diagnostics mark overrides as unverified.
|
|
198
202
|
`SPOTTER_AUDITOR_BACKEND=codex-sidecar` is available for explicit sidecar auditor smoke.
|
|
199
203
|
|
|
200
204
|
## Design docs
|
|
@@ -209,7 +213,7 @@ those values for smoke tests or controlled experiments.
|
|
|
209
213
|
|
|
210
214
|
- The `Stop` hook fires **after** the first answer has already been streamed. Spotter therefore queues a finding for the next same-session prompt instead of rewriting that answer. Detection accuracy in `UserPromptSubmit` (the *pre-response* stage) remains the primary quality axis
|
|
211
215
|
- `Stop` hook is **deferred** for both Claude and Codex hosts as of v1.4.8. When Spotter finds a missed tool at `Stop`, it appends the finding to `<projectRoot>/.spotter/pending/<sessionId>.json` and surfaces it on the next same-session `UserPromptSubmit` as `additionalContext`. The original assistant message stays as the turn's final transcript entry — no `decision:"block"` re-generation cycle. The same pending file is shared by Claude and Codex (host-neutral path)
|
|
212
|
-
- **Since v0.5.0, JSON schema violations from Haiku are treated as expected anomalies** (session renew + `role_collapse_reset`). **Since v1.4.15, an auditor/daemon failure no longer blocks the prompt**: `UserPromptSubmit` emits a loud `[Spotter からの警告]` and exits 0.
|
|
216
|
+
- **Since v0.5.0, JSON schema violations from Haiku are treated as expected anomalies** (session renew + `role_collapse_reset`). **Since v1.4.15, an auditor/daemon failure no longer blocks the prompt**: `UserPromptSubmit` emits a loud `[Spotter からの警告]` and exits 0. A `Stop` failure is queued as the same kind of warning and delivered once on the next same-session prompt. If the session ends immediately, no later prompt exists and that final warning cannot be surfaced
|
|
213
217
|
|
|
214
218
|
<details>
|
|
215
219
|
<summary><strong>📋 Recent highlights</strong></summary>
|
package/bin/spotter.mjs
CHANGED
|
@@ -50,6 +50,8 @@ Usage:
|
|
|
50
50
|
(experimental) run primary auditor backend once
|
|
51
51
|
spotter auditor matrix --stage STAGE --input FILE
|
|
52
52
|
(experimental) compare primary auditor backend matrix
|
|
53
|
+
spotter auditor model-matrix --fixtures FILE
|
|
54
|
+
(experimental) evaluate pinned Codex auditor profiles
|
|
53
55
|
spotter daemon start --session-id ID (internal) run session daemon
|
|
54
56
|
spotter hook <event> (internal) hook dispatch
|
|
55
57
|
events: session-start | user-prompt |
|
package/package.json
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { CODEX_AUDITOR_MODEL_POLICY } from '../src/core/codex-auditor-model-policy.mjs';
|
|
2
|
+
|
|
3
|
+
export const LATEST_MODELS_URL = 'https://developers.openai.com/api/docs/guides/latest-model.md';
|
|
4
|
+
export const PRICING_URL = 'https://learn.chatgpt.com/docs/pricing.md';
|
|
5
|
+
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
6
|
+
export const FETCH_TIMEOUT_MS = 15_000;
|
|
7
|
+
|
|
8
|
+
const ROLES = ['sol', 'terra', 'luna'];
|
|
9
|
+
const PROPOSAL = '同じfixtureでLuna low/Terra lowを比較し、品質不足時のみTerra mediumを評価する。productionの自動昇格・書換えは行わない。';
|
|
10
|
+
|
|
11
|
+
export class ModelPolicyCheckError extends Error {
|
|
12
|
+
constructor(code) {
|
|
13
|
+
super(code);
|
|
14
|
+
this.name = 'ModelPolicyCheckError';
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fail(code) { throw new ModelPolicyCheckError(code); }
|
|
20
|
+
|
|
21
|
+
function versionKey(major, minor) { return `${Number(major)}.${Number(minor)}`; }
|
|
22
|
+
|
|
23
|
+
export function compareVersions(left, right) {
|
|
24
|
+
const [leftMajor, leftMinor] = left.split('.').map(Number);
|
|
25
|
+
const [rightMajor, rightMinor] = right.split('.').map(Number);
|
|
26
|
+
return leftMajor - rightMajor || leftMinor - rightMinor;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extractCompleteFamilies(text, source) {
|
|
30
|
+
const regex = source === 'latest'
|
|
31
|
+
? /\bgpt-(\d+)\.(\d+)-(sol|terra|luna)\b/gi
|
|
32
|
+
: /\bGPT-(\d+)\.(\d+)\s+(Sol|Terra|Luna)\b/g;
|
|
33
|
+
const byVersion = new Map();
|
|
34
|
+
for (const match of text.matchAll(regex)) {
|
|
35
|
+
const version = versionKey(match[1], match[2]);
|
|
36
|
+
const roles = byVersion.get(version) ?? new Set();
|
|
37
|
+
roles.add(match[3].toLowerCase());
|
|
38
|
+
byVersion.set(version, roles);
|
|
39
|
+
}
|
|
40
|
+
return [...byVersion.entries()]
|
|
41
|
+
.filter(([, roles]) => ROLES.every((role) => roles.has(role)))
|
|
42
|
+
.map(([version]) => ({ version, models: ROLES.map((role) => `gpt-${version}-${role}`) }))
|
|
43
|
+
.sort((a, b) => compareVersions(a.version, b.version));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readBoundedBody(response) {
|
|
47
|
+
const declaredLength = Number(response.headers?.get?.('content-length'));
|
|
48
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) fail('E_MODEL_POLICY_CHECK_BODY_TOO_LARGE');
|
|
49
|
+
if (!response.body?.getReader) {
|
|
50
|
+
const text = await response.text();
|
|
51
|
+
if (Buffer.byteLength(text) > MAX_BODY_BYTES) fail('E_MODEL_POLICY_CHECK_BODY_TOO_LARGE');
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
const reader = response.body.getReader();
|
|
55
|
+
const chunks = [];
|
|
56
|
+
let size = 0;
|
|
57
|
+
try {
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { done, value } = await reader.read();
|
|
60
|
+
if (done) break;
|
|
61
|
+
size += value.byteLength;
|
|
62
|
+
if (size > MAX_BODY_BYTES) {
|
|
63
|
+
await reader.cancel();
|
|
64
|
+
fail('E_MODEL_POLICY_CHECK_BODY_TOO_LARGE');
|
|
65
|
+
}
|
|
66
|
+
chunks.push(value);
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
reader.releaseLock?.();
|
|
70
|
+
}
|
|
71
|
+
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function fetchMarkdown(url, { fetchFn = fetch, timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
77
|
+
try {
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await fetchFn(url, { signal: controller.signal, headers: { accept: 'text/markdown,text/plain;q=0.9' } });
|
|
81
|
+
} catch {
|
|
82
|
+
fail('E_MODEL_POLICY_CHECK_FETCH_FAILED');
|
|
83
|
+
}
|
|
84
|
+
if (!response || response.status !== 200) fail('E_MODEL_POLICY_CHECK_HTTP_STATUS');
|
|
85
|
+
return await readBoundedBody(response);
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timeout);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function policyProduction(policy) {
|
|
92
|
+
const model = policy?.production?.model;
|
|
93
|
+
const match = typeof model === 'string' && /^gpt-(\d+)\.(\d+)-(sol|terra|luna)$/.exec(model);
|
|
94
|
+
if (!match) fail('E_MODEL_POLICY_CHECK_INVALID_POLICY');
|
|
95
|
+
return { model, version: versionKey(match[1], match[2]) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runModelPolicyCheck({
|
|
99
|
+
fetchFn = fetch,
|
|
100
|
+
now = () => new Date(),
|
|
101
|
+
policy = CODEX_AUDITOR_MODEL_POLICY,
|
|
102
|
+
timeoutMs = FETCH_TIMEOUT_MS,
|
|
103
|
+
} = {}) {
|
|
104
|
+
const production = policyProduction(policy);
|
|
105
|
+
const [latestMarkdown, pricingMarkdown] = await Promise.all([
|
|
106
|
+
fetchMarkdown(LATEST_MODELS_URL, { fetchFn, timeoutMs }),
|
|
107
|
+
fetchMarkdown(PRICING_URL, { fetchFn, timeoutMs }),
|
|
108
|
+
]);
|
|
109
|
+
const latestFamilies = extractCompleteFamilies(latestMarkdown, 'latest');
|
|
110
|
+
const pricingFamilies = extractCompleteFamilies(pricingMarkdown, 'pricing');
|
|
111
|
+
if (latestFamilies.length === 0 || pricingFamilies.length === 0) fail('E_MODEL_POLICY_CHECK_REQUIRED_FAMILY_MISSING');
|
|
112
|
+
const latestDetected = latestFamilies.at(-1);
|
|
113
|
+
const pricingDetected = pricingFamilies.at(-1);
|
|
114
|
+
if (latestDetected.version !== pricingDetected.version) fail('E_MODEL_POLICY_CHECK_SOURCE_MISMATCH');
|
|
115
|
+
const pricingVersionSet = new Set(pricingFamilies.map(({ version }) => version));
|
|
116
|
+
const commonFamilies = latestFamilies.filter(({ version }) => pricingVersionSet.has(version));
|
|
117
|
+
const detectedFamily = latestDetected;
|
|
118
|
+
const status = compareVersions(detectedFamily.version, production.version) > 0 ? 'update-available' : 'current';
|
|
119
|
+
const diagnostics = status === 'current'
|
|
120
|
+
&& commonFamilies.some(({ version }) => version !== production.version)
|
|
121
|
+
? ['E_MODEL_POLICY_CHECK_NON_PRODUCTION_CANDIDATE_PRESENT']
|
|
122
|
+
: [];
|
|
123
|
+
return {
|
|
124
|
+
schema: 'spotter.codex_model_update_check.v1',
|
|
125
|
+
checkedAt: now().toISOString(),
|
|
126
|
+
policy: { version: policy.policyVersion, production: policy.production.model },
|
|
127
|
+
sources: [LATEST_MODELS_URL, PRICING_URL],
|
|
128
|
+
detectedFamily,
|
|
129
|
+
candidates: commonFamilies,
|
|
130
|
+
status,
|
|
131
|
+
proposal: status === 'update-available' ? PROPOSAL : null,
|
|
132
|
+
diagnostics,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function main({ stdout = process.stdout, stderr = process.stderr, run = runModelPolicyCheck } = {}) {
|
|
137
|
+
try {
|
|
138
|
+
const artifact = await run();
|
|
139
|
+
stdout.write(`${JSON.stringify(artifact)}\n`);
|
|
140
|
+
return 0;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const code = error instanceof ModelPolicyCheckError ? error.code : 'E_MODEL_POLICY_CHECK_UNEXPECTED';
|
|
143
|
+
stderr.write(`${code}\n`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (import.meta.url === new URL(process.argv[1], 'file:').href) {
|
|
149
|
+
const exitCode = await main();
|
|
150
|
+
process.exitCode = exitCode;
|
|
151
|
+
}
|
package/src/cli/auditor-cmd.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { createAuditorBackend } from '../core/auditor-backend.mjs';
|
|
4
4
|
import { readLocal } from '../tool-db/refresh.mjs';
|
|
5
|
+
import { runAuditorModelMatrixCommand } from './auditor-model-matrix-cmd.mjs';
|
|
5
6
|
|
|
6
7
|
const AUDITOR_USAGE = `spotter auditor — experimental primary auditor smoke commands
|
|
7
8
|
|
|
@@ -10,6 +11,8 @@ Usage:
|
|
|
10
11
|
[--project DIR] [--host-agent claude|codex|automation|unknown]
|
|
11
12
|
[--backend haiku|codex-cli|codex-sidecar|auto]
|
|
12
13
|
spotter auditor matrix --stage user_input|turn_end --input FILE [--project DIR]
|
|
14
|
+
spotter auditor model-matrix --fixtures FILE [--profile baseline|luna|terra|terra-medium]...
|
|
15
|
+
[--repeat N] [--project DIR] [--output FILE]
|
|
13
16
|
|
|
14
17
|
Input JSON:
|
|
15
18
|
user_input: {"user_input":"..."} or {"userInput":"..."}
|
|
@@ -32,6 +35,14 @@ export async function runAuditorCommand({ argv = process.argv.slice(2) } = {}) {
|
|
|
32
35
|
await runAuditorMatrixCommand({ argv: argv.slice(1) });
|
|
33
36
|
return;
|
|
34
37
|
}
|
|
38
|
+
if (sub === 'model-matrix') {
|
|
39
|
+
if (argv.slice(1).includes('--help') || argv.slice(1).includes('-h')) {
|
|
40
|
+
process.stdout.write(`Usage: spotter auditor model-matrix --fixtures FILE [--profile baseline|luna|terra|terra-medium]...\n [--repeat N] [--project DIR] [--output FILE]\n`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
await runAuditorModelMatrixCommand({ argv: argv.slice(1) });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
35
46
|
process.stderr.write(`unknown auditor subcommand: ${sub}\n${AUDITOR_USAGE}`);
|
|
36
47
|
process.exit(2);
|
|
37
48
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { version } from '../version.mjs';
|
|
7
|
+
import { createCodexCliAuditorBackend } from '../core/codex-cli-backend.mjs';
|
|
8
|
+
import { CODEX_AUDITOR_MODEL_POLICY, resolveCodexAuditorModelSelection } from '../core/codex-auditor-model-policy.mjs';
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const ALLOWED_PROFILES = ['baseline', 'luna', 'terra', 'terra-medium'];
|
|
12
|
+
const DEFAULT_PROFILES = ['baseline', 'luna', 'terra'];
|
|
13
|
+
export const MAX_MODEL_MATRIX_RUNS = 300;
|
|
14
|
+
|
|
15
|
+
export async function runAuditorModelMatrixCommand({
|
|
16
|
+
argv = [], env = process.env, now = () => Date.now(), readFileFn = readFile, writeFileFn = writeFile,
|
|
17
|
+
createBackendFn = createCodexCliAuditorBackend, resolveSelectionFn = resolveCodexAuditorModelSelection,
|
|
18
|
+
getCodexCliVersionFn = getCodexCliVersion, generatedAt = () => new Date().toISOString(), writeOutput = (text) => process.stdout.write(text),
|
|
19
|
+
} = {}) {
|
|
20
|
+
const opts = parseArgs(argv);
|
|
21
|
+
const raw = await readFileFn(opts.fixturesPath);
|
|
22
|
+
const fixtureBytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
23
|
+
const fixture = parseAndValidateFixture(fixtureBytes.toString('utf8'));
|
|
24
|
+
if (fixture.cases.length * opts.repeat * opts.profiles.length > MAX_MODEL_MATRIX_RUNS) {
|
|
25
|
+
throw new Error(`model-matrix run count exceeds maximum ${MAX_MODEL_MATRIX_RUNS}`);
|
|
26
|
+
}
|
|
27
|
+
const selections = Object.fromEntries(opts.profiles.map((profile) => [profile, resolveSelectionFn({ env, profile })]));
|
|
28
|
+
const backends = Object.fromEntries(opts.profiles.map((profile) => {
|
|
29
|
+
const backend = createBackendFn({ catalog: fixture.catalog, projectRoot: opts.projectRoot, env, modelProfile: profile });
|
|
30
|
+
if (!sameSelection(selections[profile], backend?.modelSelection)) {
|
|
31
|
+
throw new Error(`backend model selection does not match preflight profile: ${profile}`);
|
|
32
|
+
}
|
|
33
|
+
return [profile, backend];
|
|
34
|
+
}));
|
|
35
|
+
const cliVersion = normalizeCodexCliVersion(await getCodexCliVersionFn({ env }));
|
|
36
|
+
const runs = [];
|
|
37
|
+
let order = 0;
|
|
38
|
+
for (const item of fixture.cases) for (let repeat = 1; repeat <= opts.repeat; repeat += 1) for (const profile of opts.profiles) {
|
|
39
|
+
const startedAt = now();
|
|
40
|
+
const modelSelection = selections[profile];
|
|
41
|
+
try {
|
|
42
|
+
const backend = backends[profile];
|
|
43
|
+
const judgment = await backend.judge({ ...toAuditorInput(item), meta: { caseId: item.id, repeat, profile } });
|
|
44
|
+
if (!sameSelection(modelSelection, judgment?.meta?.modelSelection)) {
|
|
45
|
+
throw new Error('judgment model selection does not match backend model selection');
|
|
46
|
+
}
|
|
47
|
+
const rawFindings = Array.isArray(judgment?.findings) ? judgment.findings : [];
|
|
48
|
+
const rawActualTools = rawFindings.map((finding) => finding?.toolName);
|
|
49
|
+
const actualTools = rawActualTools.filter(isCleanString);
|
|
50
|
+
const invalidFindingCount = rawActualTools.length - actualTools.length;
|
|
51
|
+
const actualPass = typeof judgment?.pass === 'boolean' ? judgment.pass : null;
|
|
52
|
+
const rawDroppedTools = Array.isArray(judgment?.meta?.diagnostics?.droppedCatalogExternalNames)
|
|
53
|
+
? judgment.meta.diagnostics.droppedCatalogExternalNames
|
|
54
|
+
: [];
|
|
55
|
+
const rawAnomalies = Array.isArray(judgment?.anomalies) ? judgment.anomalies : [];
|
|
56
|
+
const droppedTools = cleanStrings(rawDroppedTools);
|
|
57
|
+
const anomalyTypes = cleanStrings(rawAnomalies.map((anomaly) => anomaly?.type));
|
|
58
|
+
const tokenUsage = safeTokenUsage(judgment?.meta?.diagnostics?.tokenUsage);
|
|
59
|
+
const schemaSuccess = actualPass !== null && Array.isArray(judgment?.findings)
|
|
60
|
+
&& invalidFindingCount === 0 && new Set(actualTools).size === actualTools.length
|
|
61
|
+
&& actualPass === (actualTools.length === 0);
|
|
62
|
+
runs.push(successRun({
|
|
63
|
+
order: ++order,
|
|
64
|
+
item,
|
|
65
|
+
repeat,
|
|
66
|
+
profile,
|
|
67
|
+
modelSelection,
|
|
68
|
+
durationMs: now() - startedAt,
|
|
69
|
+
schemaSuccess,
|
|
70
|
+
actualPass,
|
|
71
|
+
actualTools,
|
|
72
|
+
invalidFindingCount,
|
|
73
|
+
droppedTools,
|
|
74
|
+
droppedToolCount: rawDroppedTools.length,
|
|
75
|
+
anomalyTypes,
|
|
76
|
+
anomalyCount: rawAnomalies.length,
|
|
77
|
+
tokenUsage,
|
|
78
|
+
}));
|
|
79
|
+
} catch (err) {
|
|
80
|
+
runs.push({ order: ++order, caseId: item.id, repeat, profile, status: 'error', durationMs: now() - startedAt,
|
|
81
|
+
schemaSuccess: false, exactMatch: false, expected: item.expected, actual: { pass: null, missingTools: [], invalidFindingCount: 0, droppedCatalogExternalNames: [], droppedCatalogExternalNameCount: 0, anomalies: [], anomalyCount: 0 }, falsePositiveTools: [], falseNegativeTools: [], modelSelection,
|
|
82
|
+
tokenUsage: null, error: safeError(err) });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const usage = summarizeTokenUsage(runs, opts.profiles);
|
|
86
|
+
const artifact = {
|
|
87
|
+
schema: 'spotter.auditor_model_matrix.v1', generatedAt: generatedAt(), packageVersion: version,
|
|
88
|
+
fixture: { schema: fixture.schema, path: safeFixturePath(opts.fixturesPath, opts.projectRoot), sha256: createHash('sha256').update(fixtureBytes).digest('hex'), cases: fixture.cases.length, catalogCount: fixture.catalog.length },
|
|
89
|
+
codexCli: cliVersion, policy: { schema: CODEX_AUDITOR_MODEL_POLICY.schema, version: CODEX_AUDITOR_MODEL_POLICY.policyVersion },
|
|
90
|
+
profiles: Object.fromEntries(Object.entries(selections).map(([profile, selection]) => [profile, {
|
|
91
|
+
model: selection.effectiveModel,
|
|
92
|
+
reasoningEffort: selection.effectiveReasoningEffort,
|
|
93
|
+
verifiedAt: selection.effectiveVerifiedAt,
|
|
94
|
+
verificationScope: selection.effectiveVerificationScope ?? null,
|
|
95
|
+
status: selection.effectiveStatus ?? null,
|
|
96
|
+
selection,
|
|
97
|
+
}])), runs, summary: summarize(runs, opts.profiles), usageStatus: usage.status, tokenUsage: usage.summary,
|
|
98
|
+
costStatus: 'not-available-chatgpt-plan', cost: null,
|
|
99
|
+
evaluation: { repeat: opts.repeat, profiles: opts.profiles, maxRuns: MAX_MODEL_MATRIX_RUNS },
|
|
100
|
+
executionOrdering: 'case-repeat-profile', promotionEligible: false, blockingReasons: blockingReasons(runs, usage.status),
|
|
101
|
+
};
|
|
102
|
+
const json = JSON.stringify(artifact, null, 2) + '\n';
|
|
103
|
+
if (opts.outputPath) await writeFileFn(opts.outputPath, json, 'utf8');
|
|
104
|
+
writeOutput(json);
|
|
105
|
+
return artifact;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseArgs(argv) {
|
|
109
|
+
const opts = { fixturesPath: null, profiles: [], repeat: 1, projectRoot: process.cwd(), outputPath: null };
|
|
110
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
111
|
+
const arg = argv[i]; const value = () => { const v = argv[++i]; if (!v || v.startsWith('--')) throw new Error(`${arg} requires a value`); return v; };
|
|
112
|
+
if (arg === '--fixtures') opts.fixturesPath = resolve(value());
|
|
113
|
+
else if (arg === '--profile') opts.profiles.push(value());
|
|
114
|
+
else if (arg === '--repeat') opts.repeat = Number(value());
|
|
115
|
+
else if (arg === '--project') opts.projectRoot = resolve(value());
|
|
116
|
+
else if (arg === '--output') opts.outputPath = resolve(value());
|
|
117
|
+
else throw new Error(`unknown auditor model-matrix option: ${arg}`);
|
|
118
|
+
}
|
|
119
|
+
if (!opts.fixturesPath) throw new Error('--fixtures FILE is required');
|
|
120
|
+
if (!Number.isInteger(opts.repeat) || opts.repeat < 1) throw new Error('--repeat must be a positive integer');
|
|
121
|
+
opts.profiles = opts.profiles.length ? opts.profiles : [...DEFAULT_PROFILES];
|
|
122
|
+
if (new Set(opts.profiles).size !== opts.profiles.length || opts.profiles.some((profile) => !ALLOWED_PROFILES.includes(profile))) throw new Error('--profile must be baseline, luna, terra, or terra-medium without duplicates');
|
|
123
|
+
return opts;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseAndValidateFixture(raw) {
|
|
127
|
+
let fixture; try { fixture = JSON.parse(raw); } catch { throw new Error('fixture must be valid JSON'); }
|
|
128
|
+
objectOnly(fixture, ['schema', 'catalog', 'cases'], 'fixture');
|
|
129
|
+
if (fixture.schema !== 'spotter.auditor_model_fixtures.v1') throw new Error('unsupported fixture schema');
|
|
130
|
+
if (!Array.isArray(fixture.catalog) || !Array.isArray(fixture.cases) || fixture.cases.length === 0) throw new Error('fixture catalog and non-empty cases are required');
|
|
131
|
+
const catalogNames = new Set();
|
|
132
|
+
for (const tool of fixture.catalog) { objectOnly(tool, ['name', 'description'], 'catalog tool'); clean(tool.name, 'catalog name'); clean(tool.description, 'catalog description'); if (catalogNames.has(tool.name)) throw new Error('duplicate catalog name'); catalogNames.add(tool.name); }
|
|
133
|
+
const ids = new Set();
|
|
134
|
+
for (const item of fixture.cases) {
|
|
135
|
+
objectOnly(item, ['id', 'stage', 'input', 'expected'], 'case'); clean(item.id, 'case id');
|
|
136
|
+
if (ids.has(item.id)) throw new Error('duplicate case id'); ids.add(item.id);
|
|
137
|
+
if (!['user_input', 'turn_end'].includes(item.stage)) throw new Error('case stage is invalid');
|
|
138
|
+
validateInput(item.stage, item.input); objectOnly(item.expected, ['pass', 'missingTools'], 'case expected');
|
|
139
|
+
if (typeof item.expected.pass !== 'boolean' || !Array.isArray(item.expected.missingTools)
|
|
140
|
+
|| new Set(item.expected.missingTools).size !== item.expected.missingTools.length
|
|
141
|
+
|| item.expected.missingTools.some((name) => typeof name !== 'string' || !catalogNames.has(name))
|
|
142
|
+
|| item.expected.pass !== (item.expected.missingTools.length === 0)) throw new Error('case expected is invalid or references a catalog-external tool');
|
|
143
|
+
}
|
|
144
|
+
return fixture;
|
|
145
|
+
}
|
|
146
|
+
function objectOnly(value, keys, label) { if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((key) => !keys.includes(key))) throw new Error(`${label} has invalid fields`); }
|
|
147
|
+
function clean(value, label) { if (typeof value !== 'string' || value.length === 0 || value.trim() !== value) throw new Error(`${label} must be a clean non-empty string`); }
|
|
148
|
+
function validateInput(stage, input) { if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('case input is invalid'); if (stage === 'user_input') { objectOnly(input, ['userInput'], 'user_input input'); clean(input.userInput, 'userInput'); } else { objectOnly(input, ['finalResponse', 'usedTools'], 'turn_end input'); clean(input.finalResponse, 'finalResponse'); if (!Array.isArray(input.usedTools) || input.usedTools.some((tool) => typeof tool !== 'string' || tool.length === 0 || tool.trim() !== tool) || new Set(input.usedTools).size !== input.usedTools.length) throw new Error('usedTools is invalid'); } }
|
|
149
|
+
function toAuditorInput(item) { return item.stage === 'user_input' ? { stage: item.stage, userInput: item.input.userInput } : { stage: item.stage, finalResponse: item.input.finalResponse, usedTools: item.input.usedTools }; }
|
|
150
|
+
function isCleanString(value) { return typeof value === 'string' && value.length > 0 && value.trim() === value; }
|
|
151
|
+
function cleanStrings(values = []) { return [...new Set(values.filter(isCleanString))]; }
|
|
152
|
+
function successRun({ order, item, repeat, profile, modelSelection, durationMs, schemaSuccess, actualPass, actualTools, invalidFindingCount, droppedTools, droppedToolCount, anomalyTypes, anomalyCount, tokenUsage }) { const expectedTools = item.expected.missingTools; const fp = [...actualTools.filter((tool) => !expectedTools.includes(tool)), ...droppedTools.filter((tool) => !expectedTools.includes(tool))]; const fn = expectedTools.filter((tool) => !actualTools.includes(tool)); return { order, caseId: item.id, repeat, profile, status: 'success', durationMs, schemaSuccess, exactMatch: schemaSuccess && actualPass === item.expected.pass && actualTools.length === expectedTools.length && fp.length === 0 && fn.length === 0 && droppedToolCount === 0 && anomalyCount === 0, expected: item.expected, actual: { pass: actualPass, missingTools: actualTools, invalidFindingCount, droppedCatalogExternalNames: droppedTools, droppedCatalogExternalNameCount: droppedToolCount, anomalies: anomalyTypes, anomalyCount }, falsePositiveTools: fp, falseNegativeTools: fn, modelSelection, tokenUsage }; }
|
|
153
|
+
const SAFE_RUN_ERRORS = Object.freeze({
|
|
154
|
+
E_CODEX_CLI_AUTH: { name: 'AuditorBackendError', message: 'codex-cli authentication failed; run codex login' },
|
|
155
|
+
E_CODEX_CLI_EXIT: { name: 'AuditorBackendError', message: 'codex-cli invocation exited unsuccessfully' },
|
|
156
|
+
E_CODEX_CLI_MODEL_POLICY: { name: 'CodexAuditorModelPolicyError', message: 'codex-cli model policy validation failed' },
|
|
157
|
+
E_CODEX_CLI_NO_FINAL_JSON: { name: 'AuditorBackendError', message: 'codex-cli did not produce a final JSON result' },
|
|
158
|
+
E_CODEX_CLI_SCHEMA: { name: 'AuditorBackendError', message: 'codex-cli returned an invalid auditor result' },
|
|
159
|
+
E_CODEX_CLI_SPAWN: { name: 'AuditorBackendError', message: 'codex-cli invocation could not start' },
|
|
160
|
+
E_CODEX_CLI_TIMEOUT: { name: 'AuditorBackendError', message: 'codex-cli invocation timed out' },
|
|
161
|
+
E_CODEX_CLI_USAGE_LIMIT: { name: 'AuditorBackendError', message: 'codex-cli usage limit reached; wait for reset or change plan' },
|
|
162
|
+
E_AUDITOR_MODEL_MATRIX: { name: 'Error', message: 'auditor model-matrix run failed' },
|
|
163
|
+
});
|
|
164
|
+
const SAFE_DIAGNOSTIC_ENUMS = Object.freeze({
|
|
165
|
+
completionReason: new Set(['last_message_before_process_close']),
|
|
166
|
+
lastMessageCheck: new Set(['schema_valid', 'missing_last_message', 'schema_invalid_last_message']),
|
|
167
|
+
processCountMethod: new Set(['direct_child_spawn', 'direct_child_exec_file', 'spawn_failed', 'not_available', 'not_instrumented']),
|
|
168
|
+
});
|
|
169
|
+
function safeFixturePath(fixturePath, projectRoot) { const path = relative(projectRoot, fixturePath); if (path === '') return '.'; if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) return '<external-fixture>'; return path.split(sep).join('/'); }
|
|
170
|
+
function safeError(err) { const code = Object.hasOwn(SAFE_RUN_ERRORS, err?.code) ? err.code : 'E_AUDITOR_MODEL_MATRIX'; const safe = SAFE_RUN_ERRORS[code]; const diagnostics = compact(err?.diagnostics); const stage = ['user_input', 'turn_end', 'configuration'].includes(err?.stage) ? err.stage : 'unknown'; return { code, name: safe.name, message: safe.message, backend: 'codex-cli', stage, ...(diagnostics ? { diagnostics } : {}) }; }
|
|
171
|
+
function compact(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const out = {}; for (const key of ['durationMs', 'processCount', 'exitCode']) if (typeof value[key] === 'number' && Number.isFinite(value[key])) out[key] = value[key]; for (const key of ['stdoutTruncated', 'stderrTruncated']) if (typeof value[key] === 'boolean') out[key] = value[key]; for (const [key, allowed] of Object.entries(SAFE_DIAGNOSTIC_ENUMS)) if (allowed.has(value[key])) out[key] = value[key]; for (const key of ['stdout', 'stderr']) if (typeof value[key] === 'string' || Buffer.isBuffer(value[key])) out[`${key}Bytes`] = Buffer.byteLength(value[key]); return Object.keys(out).length ? out : null; }
|
|
172
|
+
export function percentile(values, ratio) { if (!values.length) return null; const sorted = [...values].sort((a, b) => a - b); return sorted[Math.ceil(sorted.length * ratio) - 1]; }
|
|
173
|
+
function summarize(rows, profiles) { const one = (items) => { const durations = items.map((item) => item.durationMs); const timeout = items.filter((item) => item.error?.code === 'E_CODEX_CLI_TIMEOUT').length; return { total: items.length, success: items.filter((item) => item.status === 'success').length, error: items.filter((item) => item.status === 'error').length, schemaSuccess: items.filter((item) => item.schemaSuccess).length, exactMatch: items.filter((item) => item.exactMatch).length, falsePositiveTools: items.reduce((sum, item) => sum + item.falsePositiveTools.length, 0), falseNegativeTools: items.reduce((sum, item) => sum + item.falseNegativeTools.length, 0), invalidFindingCount: items.reduce((sum, item) => sum + item.actual.invalidFindingCount, 0), anomalyCount: items.reduce((sum, item) => sum + item.actual.anomalyCount, 0), droppedCatalogExternalNameCount: items.reduce((sum, item) => sum + item.actual.droppedCatalogExternalNameCount, 0), durationMs: { p50: percentile(durations, 0.5), p95: percentile(durations, 0.95) }, timeoutCount: timeout, timeoutRate: items.length ? timeout / items.length : 0 }; }; return { ...one(rows), byProfile: Object.fromEntries(profiles.map((profile) => [profile, one(rows.filter((row) => row.profile === profile))])) }; }
|
|
174
|
+
function safeTokenUsage(value) { const keys = ['inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningOutputTokens', 'totalTokens']; if (!value || typeof value !== 'object' || keys.some((key) => !Number.isSafeInteger(value[key]) || value[key] < 0) || value.totalTokens !== value.inputTokens + value.outputTokens) return null; return Object.fromEntries(keys.map((key) => [key, value[key]])); }
|
|
175
|
+
function summarizeTokenUsage(runs, profiles) { const observed = runs.filter((run) => run.tokenUsage); const one = (items) => { const withUsage = items.filter((item) => item.tokenUsage); const total = (key) => withUsage.reduce((sum, item) => sum + item.tokenUsage[key], 0); const totals = { inputTokens: total('inputTokens'), cachedInputTokens: total('cachedInputTokens'), outputTokens: total('outputTokens'), reasoningOutputTokens: total('reasoningOutputTokens'), totalTokens: total('totalTokens') }; return { observedRuns: withUsage.length, totalRuns: items.length, totals, totalTokensPerRun: { p50: percentile(withUsage.map((item) => item.tokenUsage.totalTokens), 0.5), p95: percentile(withUsage.map((item) => item.tokenUsage.totalTokens), 0.95) } }; }; const status = observed.length === 0 ? 'not-available' : observed.length === runs.length ? 'complete' : 'partial'; return { status, summary: observed.length === 0 ? null : { ...one(runs), byProfile: Object.fromEntries(profiles.map((profile) => [profile, one(runs.filter((run) => run.profile === profile))])) } }; }
|
|
176
|
+
function blockingReasons(runs, usageStatus) { const reasons = []; if (usageStatus !== 'complete') reasons.push('usage_not_available'); reasons.push('cost_not_available'); if (runs.some((run) => run.status === 'error')) reasons.push('run_error'); if (runs.some((run) => !run.schemaSuccess)) reasons.push('schema_failure'); if (runs.some((run) => !run.exactMatch)) reasons.push('exact_mismatch'); if (runs.some((run) => run.actual.anomalyCount > 0 || run.actual.droppedCatalogExternalNameCount > 0)) reasons.push('quality_anomaly'); return reasons; }
|
|
177
|
+
function sameSelection(expected, actual) { if (!expected || !actual || typeof expected !== 'object' || typeof actual !== 'object') return false; const keys = ['effectiveModel', 'effectiveReasoningEffort', 'modelSource', 'effortSource', 'policySchema', 'policyVersion', 'policyVerifiedAt', 'policyVerificationScope', 'effectiveVerifiedAt', 'effectiveStatus', 'effectiveVerificationScope', 'role', 'availability']; return keys.every((key) => expected[key] === actual[key]); }
|
|
178
|
+
function byteCount(value) { return typeof value === 'string' || Buffer.isBuffer(value) ? Buffer.byteLength(value) : 0; }
|
|
179
|
+
function safeVersionCode(value) { return typeof value === 'string' && /^E_[A-Z0-9_]{1,80}$/.test(value) ? value : 'E_CODEX_CLI_VERSION_INVALID'; }
|
|
180
|
+
function normalizeCodexCliVersion(value) { const status = value?.status; if (status === 'available' && typeof value.version === 'string' && value.version.length <= 200 && !/[\r\n]/.test(value.version) && /^codex(?:-cli)?\s+v?\d+(?:\.\d+){1,3}(?:[-+][A-Za-z0-9.-]+)?$/.test(value.version)) return { status: 'available', version: value.version }; if (status === 'unavailable') return { status: 'unavailable', ...(value?.code ? { code: safeVersionCode(value.code) } : {}) }; return { status: 'error', code: safeVersionCode(value?.code), stdoutBytes: byteCount(value?.stdout ?? value?.version), stderrBytes: byteCount(value?.stderr) }; }
|
|
181
|
+
async function getCodexCliVersion({ env = process.env } = {}) { try { const { stdout } = await execFileAsync('codex', ['--version'], { timeout: 5_000, encoding: 'utf8', env }); return { status: 'available', version: stdout.trim() }; } catch (err) { return { status: err?.code === 'ENOENT' ? 'unavailable' : 'error', code: err?.code === 'ENOENT' ? 'E_CODEX_CLI_NOT_FOUND' : 'E_CODEX_CLI_VERSION_COMMAND', stdout: err?.stdout, stderr: err?.stderr }; } }
|