claude-spotter 1.4.19 → 1.4.21

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.21 — 2026-07-13
4
+
5
+ ### Changed
6
+
7
+ - **Throughline監査文脈をdefault-onへ変更。** `spotter install`がPATH上のThroughlineをabsolute pathへ解決できる場合、project markerへ既定設定する。marker v2の`origin:default|explicit`で旧既定disabledだけを移行し、明示OFFは再install後も維持する。POSIXはrealpath、Windows npm shimはabsolute `node.exe + throughline.mjs`を保存する。
8
+ - **不在時は明示disabled。** Throughlineが見つからない場合は固定理由付きdisabledとし、current-only監査や別backendへfallbackしない。installerとdoctorが状態・OFF手順を表示する。
9
+ - **実運用効果測定。** default-onで7日以上・fresh 30件以上を集め、過検出・見逃し・stale率・latencyから維持、修正、default-off rollbackを判断する。L2本文は評価ログへ保存しない。
10
+
11
+ ## 1.4.20 — 2026-07-13
12
+
13
+ ### Added
14
+
15
+ - **Throughline監査文脈のopt-in配布。** 新規projectは既定`disabled`のまま、ownerがabsoluteなThroughline commandとrepeatableな先頭引数で有効化できる。freshな完了L2 user/assistant pairだけを`N=2`、body 600文字、total 4,000文字に制限し、Codex CLIへstdinで渡す。Haikuはこのcontext経路では呼ばず、fresh以外でもAIを呼ばない。connector障害は固定警告に限定する。親出力は安全なcatalog tool ID由来の固定・非命令形助言だけで、L2、reason、provider rawを反射しない。7日・30 fresh resultのproduction default昇格gateは未完了のため、配布後もproject opt-inを維持する。
16
+
17
+ ### 検証・公開
18
+
19
+ Spotter 476 tests(474 pass / 2 skip)、Throughline 580 tests(全pass)、両CIのmacOS/Linux/Windows ×
20
+ Node matrix各6/6、packの秘密・開発者固有path scan、隔離tarball installを通過。公開commitは`7cbc3a1`、
21
+ npm `claude-spotter@1.4.20`、tag / GitHub Release `v1.4.20`、registry由来global installを同期した。
22
+ `spotter doctor`は0 warnings、監査文脈connectorはavailable。Codex hook trustだけは仕様上機械検証不能のため、
23
+ `/hooks`での人手確認を残す。
24
+
3
25
  ## 1.4.19
4
26
 
5
27
  親セッションの暴走を誘発できたHook出力の信頼境界を修正する。監査用AIは内部で構造化判定を返すだけとし、
@@ -29,8 +51,10 @@ AI/backend/provider sentinel、unsafe ID、legacy pending非読取unlink、Stop
29
51
  読込例外の固定degradationを確認した。敵対的再監査は初回BLOCKER 2件(Codex読込例外の境界外、
30
52
  UI可視性の過剰主張)を検出・修正し、再監査BLOCKER 0。`npm pack --dry-run`は62 files、global
31
53
  `spotter 1.4.19`へlocal installし、projector smoke・Hook diagnostics・global UserPromptSubmit smokeを
32
- 確認した。公開前release gateとしてfull test、pack、秘密混入、CI、registry tarballを再検証し、
33
- `v1.4.19` tag・npm `latest`・GitHub Release・global installを同一versionへ揃える。
54
+ 確認した。公開前release gateでfull test、pack、秘密混入を再検証し、公開SHA`5393919`のCIは
55
+ macOS/Linux/Windows × Node 22.5/22.xの6/6 green。`v1.4.19` tag、npm `latest`、
56
+ [GitHub Release](https://github.com/kitepon-rgb/Spotter/releases/tag/v1.4.19)、registry由来global installを
57
+ 1.4.19へ揃え、global projector smokeとHook diagnosticsもgreen。
34
58
 
35
59
  ## 1.4.18
36
60
 
package/README.ja.md CHANGED
@@ -146,6 +146,52 @@ flowchart LR
146
146
 
147
147
  両者に共通するのは **「主体に頼らない仕組み」**。併用できます。
148
148
 
149
+ ### Throughline auditor context(既定ON)
150
+
151
+ `spotter install`はPATH上のThroughlineを絶対パスへ解決できる場合、auditor contextを既定で有効化します。
152
+ 旧版の既定`disabled` markerも通常の再installで有効化されます。過去に明示的に無効化したprojectは
153
+ `origin: explicit`として保持され、勝手に再有効化されません。無効化するには次を実行します。
154
+
155
+ ```bash
156
+ spotter install -y --auditor-context disabled
157
+ ```
158
+
159
+ 自動解決できない環境で手動設定する場合は、絶対パスのThroughline実行ファイルと、必要なら
160
+ 繰り返し指定できる`--throughline-arg`を設定します。
161
+
162
+ ```bash
163
+ spotter install -y --auditor-context throughline \
164
+ --throughline-command /absolute/path/to/throughline
165
+ ```
166
+
167
+ Windows では shell injection を避けるため `.cmd` / `.bat` wrapper を意図的に拒否します。絶対パスの
168
+ `node.exe` を command にし、絶対パスの `throughline.mjs` を繰り返し引数として渡してください。
169
+
170
+ ```powershell
171
+ spotter install -y --auditor-context throughline `
172
+ --throughline-command 'C:\Program Files\nodejs\node.exe' `
173
+ --throughline-arg 'C:\absolute\path\to\throughline\bin\throughline.mjs'
174
+ ```
175
+
176
+ この connector の AI 呼出しは Codex CLI のみです。コンテキストを argv には載せず、AI へは stdin 経由で
177
+ 渡します。Haiku はこの context path に未対応であり、呼び出しません。Throughline 結果が `fresh` の時だけ
178
+ AI 呼出し候補になり、それ以外の status では AI を呼びません。enabled connector の障害は hidden fallback
179
+ ではなく固定 warning として出します。
180
+
181
+ Throughline から渡すのは fresh な完了済み L2 user/assistant pair だけです。直近 2 pair (N=2)、各 body は
182
+ 600 文字、合計は 4,000 文字に制限します。Spotter は Throughline の L2、`reason`、`raw` を親へ反射せず、
183
+ 親には安全な catalog tool ID から作る固定・非命令形の助言だけを渡します。`spotter doctor` は command / args
184
+ を表示せず、auditor-context mode と固定の availability detail だけを表示します。
185
+
186
+ v2 model-matrix では context choice を明示できます。
187
+
188
+ ```bash
189
+ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.json \
190
+ --recent-turns 2 --body-cap 600
191
+ ```
192
+
193
+ 評価結論はN=2 / 600です。既定ONの実運用で7日・30 fresh resultを集め、維持・修正・rollbackを判断します。
194
+
149
195
  ## よく使うコマンド
150
196
 
151
197
  ```bash
@@ -172,7 +218,7 @@ spotter codex-hook install
172
218
  # Codex native hooks の修復 / 明示登録 (通常は spotter install が実行)
173
219
  spotter codex-hook diagnostics
174
220
  # Codex hook の登録/readiness を診断。trust は /hooks で review
175
- spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v1.json
221
+ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.json --recent-turns 2 --body-cap 600
176
222
  # pinned auditor model profile を再現可能に比較する experimental eval
177
223
  spotter uninstall # hook 登録を解除 (~/.spotter は残す)
178
224
  ```
package/README.md CHANGED
@@ -146,6 +146,58 @@ The audited catalog is host-local: Claude uses `<project>/.spotter/tool-db.json`
146
146
 
147
147
  Both share the principle of **"don't rely on the primary agent to do it itself."** They compose well — you can run them together.
148
148
 
149
+ ### Throughline auditor context (default-on)
150
+
151
+ When `spotter install` can resolve Throughline on PATH to an absolute executable,
152
+ auditor context is enabled by default. A normal reinstall migrates legacy markers
153
+ whose disabled state came from the old default. An explicit project opt-out is
154
+ stored with `origin: explicit` and is never silently re-enabled. Disable it with:
155
+
156
+ ```bash
157
+ spotter install -y --auditor-context disabled
158
+ ```
159
+
160
+ When automatic discovery is unavailable, configure a direct absolute Throughline
161
+ executable and any leading arguments with repeatable `--throughline-arg`:
162
+
163
+ ```bash
164
+ spotter install -y --auditor-context throughline \
165
+ --throughline-command /absolute/path/to/throughline
166
+ ```
167
+
168
+ On Windows, `.cmd` and `.bat` wrappers are deliberately rejected to avoid shell
169
+ injection. Point at an absolute `node.exe` and pass the absolute
170
+ `throughline.mjs` path as a repeated argument instead:
171
+
172
+ ```powershell
173
+ spotter install -y --auditor-context throughline `
174
+ --throughline-command 'C:\Program Files\nodejs\node.exe' `
175
+ --throughline-arg 'C:\absolute\path\to\throughline\bin\throughline.mjs'
176
+ ```
177
+
178
+ The connector is Codex CLI-only. It sends no context in argv: the bounded
179
+ projection is supplied to that AI over stdin. Haiku does not support this
180
+ context path and is never called for it. Only a `fresh` Throughline result is
181
+ eligible for an AI call; every other status skips AI. An enabled connector
182
+ failure becomes a fixed warning, not a hidden fallback.
183
+
184
+ Throughline contributes only fresh, completed L2 user/assistant pairs: two
185
+ recent pairs (N=2), each body capped at 600 characters and 4,000 characters in
186
+ total. Spotter never reflects Throughline L2, `reason`, or `raw` to the parent.
187
+ The parent receives only fixed non-imperative advice built from safe catalog tool
188
+ IDs. `spotter doctor` displays the auditor-context mode and a fixed availability
189
+ detail without printing its command or arguments.
190
+
191
+ The v2 model-matrix can make the context choice explicit:
192
+
193
+ ```bash
194
+ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.json \
195
+ --recent-turns 2 --body-cap 600
196
+ ```
197
+
198
+ The evaluated setting is N=2 / 600. Default-on operation now collects the 7-day,
199
+ 30-fresh-result sample used to decide whether to keep, adjust, or roll back the default.
200
+
149
201
  ## Common commands
150
202
 
151
203
  ```bash
@@ -173,7 +225,7 @@ spotter codex-hook install
173
225
  # repair / explicitly register Codex native hooks (normally handled by spotter install)
174
226
  spotter codex-hook diagnostics
175
227
  # check Codex hook registration/readiness; trust is reviewed with /hooks
176
- spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v1.json
228
+ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.json --recent-turns 2 --body-cap 600
177
229
  # experimental reproducible comparison of pinned auditor model profiles
178
230
  spotter uninstall # remove hooks from this project (leaves ~/.spotter intact)
179
231
  ```
package/bin/spotter.mjs CHANGED
@@ -21,8 +21,12 @@ import { runSessionEnd } from '../src/hooks/session-end.mjs';
21
21
  const USAGE = `spotter — Claude Code tool-call auditor
22
22
 
23
23
  Usage:
24
- spotter install [-y] register hooks in <cwd>/.claude/settings.json
24
+ spotter install [-y] [--auditor-context disabled|throughline]
25
+ [--throughline-command ABS] [--throughline-arg VALUE]
26
+ register hooks in <cwd>/.claude/settings.json
25
27
  and create <cwd>/.spotter/marker.json
28
+ (Throughline on PATH enables context by default;
29
+ pass disabled for a persistent project opt-out)
26
30
  (run inside each project you want audited)
27
31
  spotter install --user [-y] legacy: register globally in ~/.claude/settings.json
28
32
  (NOT RECOMMENDED — fires for every Claude Code session
@@ -75,9 +79,8 @@ async function main() {
75
79
  const [cmd, ...rest] = argv;
76
80
  switch (cmd) {
77
81
  case 'install': {
78
- const target = rest.includes('--user') ? 'user' : 'project';
79
- const autoYes = rest.includes('-y') || rest.includes('--yes');
80
- await runInstall({ target, autoYes });
82
+ const options = parseInstallArgs(rest);
83
+ await runInstall(options);
81
84
  return;
82
85
  }
83
86
  case 'uninstall': {
@@ -140,6 +143,60 @@ async function main() {
140
143
  }
141
144
  }
142
145
 
146
+ function parseInstallArgs(argv) {
147
+ let target = 'project';
148
+ let autoYes = false;
149
+ let mode = null;
150
+ let command = null;
151
+ const args = [];
152
+ for (let index = 0; index < argv.length; index += 1) {
153
+ const arg = argv[index];
154
+ if (arg === '--user') {
155
+ if (target === 'user') throw invalidInstallArgs();
156
+ target = 'user';
157
+ } else if (arg === '-y' || arg === '--yes') {
158
+ if (autoYes) throw invalidInstallArgs();
159
+ autoYes = true;
160
+ } else if (arg === '--auditor-context') {
161
+ if (mode !== null) throw invalidInstallArgs();
162
+ mode = argv[++index];
163
+ if (mode !== 'disabled' && mode !== 'throughline') throw invalidInstallArgs();
164
+ } else if (arg === '--throughline-command') {
165
+ if (command !== null) throw invalidInstallArgs();
166
+ command = argv[++index];
167
+ if (typeof command !== 'string' || command.length === 0) throw invalidInstallArgs();
168
+ } else if (arg === '--throughline-arg') {
169
+ const value = argv[++index];
170
+ if (typeof value !== 'string' || value.length === 0) throw invalidInstallArgs();
171
+ args.push(value);
172
+ } else {
173
+ throw invalidInstallArgs();
174
+ }
175
+ }
176
+ if (mode === null && (command !== null || args.length > 0)) throw invalidInstallArgs();
177
+ if (mode === 'disabled' && (command !== null || args.length > 0)) throw invalidInstallArgs();
178
+ if (mode === 'throughline' && (command === null || !isAbsoluteCommand(command) || isShellWrapper(command))) throw invalidInstallArgs();
179
+ const auditorContext = mode === null ? undefined : mode === 'disabled'
180
+ ? { mode: 'disabled', origin: 'explicit' }
181
+ : { mode: 'throughline', command, args, origin: 'explicit' };
182
+ return { target, autoYes, auditorContext };
183
+ }
184
+
185
+ function isAbsoluteCommand(value) {
186
+ return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value);
187
+ }
188
+
189
+ function isShellWrapper(value) {
190
+ return /\.(?:cmd|bat)$/i.test(value);
191
+ }
192
+
193
+ function invalidInstallArgs() {
194
+ const err = new Error('invalid install arguments');
195
+ err.stack = '';
196
+ err.exitCode = 2;
197
+ return err;
198
+ }
199
+
143
200
  main().catch((err) => {
144
201
  process.stderr.write(`spotter: ${err.stack || err.message || err}\n`);
145
202
  process.exit(err.exitCode ?? 2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.4.19",
3
+ "version": "1.4.21",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@ export async function runAuditorCommand({ argv = process.argv.slice(2) } = {}) {
37
37
  }
38
38
  if (sub === 'model-matrix') {
39
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`);
40
+ process.stdout.write(`Usage: spotter auditor model-matrix --fixtures FILE [--profile baseline|luna|terra|terra-medium]...\n [--repeat N] [--recent-turns 0|1|2|3] [--body-cap CHARS] [--project DIR] [--output FILE]\n`);
41
41
  return;
42
42
  }
43
43
  await runAuditorModelMatrixCommand({ argv: argv.slice(1) });
@@ -4,7 +4,7 @@ import { promisify } from 'node:util';
4
4
  import { readFile, writeFile } from 'node:fs/promises';
5
5
  import { isAbsolute, relative, resolve, sep } from 'node:path';
6
6
  import { version } from '../version.mjs';
7
- import { createCodexCliAuditorBackend } from '../core/codex-cli-backend.mjs';
7
+ import { CODEX_AUDITOR_PROMPT_VERSION, createCodexCliAuditorBackend } from '../core/codex-cli-backend.mjs';
8
8
  import { CODEX_AUDITOR_MODEL_POLICY, resolveCodexAuditorModelSelection } from '../core/codex-auditor-model-policy.mjs';
9
9
 
10
10
  const execFileAsync = promisify(execFile);
@@ -21,6 +21,7 @@ export async function runAuditorModelMatrixCommand({
21
21
  const raw = await readFileFn(opts.fixturesPath);
22
22
  const fixtureBytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
23
23
  const fixture = parseAndValidateFixture(fixtureBytes.toString('utf8'));
24
+ const contextOptions = resolveContextOptions(opts, fixture.schema);
24
25
  if (fixture.cases.length * opts.repeat * opts.profiles.length > MAX_MODEL_MATRIX_RUNS) {
25
26
  throw new Error(`model-matrix run count exceeds maximum ${MAX_MODEL_MATRIX_RUNS}`);
26
27
  }
@@ -40,7 +41,7 @@ export async function runAuditorModelMatrixCommand({
40
41
  const modelSelection = selections[profile];
41
42
  try {
42
43
  const backend = backends[profile];
43
- const judgment = await backend.judge({ ...toAuditorInput(item), meta: { caseId: item.id, repeat, profile } });
44
+ const judgment = await backend.judge({ ...toAuditorInput(item, contextOptions), meta: { caseId: item.id, repeat, profile } });
44
45
  if (!sameSelection(modelSelection, judgment?.meta?.modelSelection)) {
45
46
  throw new Error('judgment model selection does not match backend model selection');
46
47
  }
@@ -87,6 +88,7 @@ export async function runAuditorModelMatrixCommand({
87
88
  schema: 'spotter.auditor_model_matrix.v1', generatedAt: generatedAt(), packageVersion: version,
88
89
  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
90
  codexCli: cliVersion, policy: { schema: CODEX_AUDITOR_MODEL_POLICY.schema, version: CODEX_AUDITOR_MODEL_POLICY.policyVersion },
91
+ auditorPromptVersion: CODEX_AUDITOR_PROMPT_VERSION,
90
92
  profiles: Object.fromEntries(Object.entries(selections).map(([profile, selection]) => [profile, {
91
93
  model: selection.effectiveModel,
92
94
  reasoningEffort: selection.effectiveReasoningEffort,
@@ -96,7 +98,7 @@ export async function runAuditorModelMatrixCommand({
96
98
  selection,
97
99
  }])), runs, summary: summarize(runs, opts.profiles), usageStatus: usage.status, tokenUsage: usage.summary,
98
100
  costStatus: 'not-available-chatgpt-plan', cost: null,
99
- evaluation: { repeat: opts.repeat, profiles: opts.profiles, maxRuns: MAX_MODEL_MATRIX_RUNS },
101
+ evaluation: { repeat: opts.repeat, profiles: opts.profiles, maxRuns: MAX_MODEL_MATRIX_RUNS, recentTurns: contextOptions.recentTurns, bodyCap: contextOptions.bodyCap },
100
102
  executionOrdering: 'case-repeat-profile', promotionEligible: false, blockingReasons: blockingReasons(runs, usage.status),
101
103
  };
102
104
  const json = JSON.stringify(artifact, null, 2) + '\n';
@@ -106,7 +108,7 @@ export async function runAuditorModelMatrixCommand({
106
108
  }
107
109
 
108
110
  function parseArgs(argv) {
109
- const opts = { fixturesPath: null, profiles: [], repeat: 1, projectRoot: process.cwd(), outputPath: null };
111
+ const opts = { fixturesPath: null, profiles: [], repeat: 1, projectRoot: process.cwd(), outputPath: null, recentTurns: null, bodyCap: null };
110
112
  for (let i = 0; i < argv.length; i += 1) {
111
113
  const arg = argv[i]; const value = () => { const v = argv[++i]; if (!v || v.startsWith('--')) throw new Error(`${arg} requires a value`); return v; };
112
114
  if (arg === '--fixtures') opts.fixturesPath = resolve(value());
@@ -114,10 +116,14 @@ function parseArgs(argv) {
114
116
  else if (arg === '--repeat') opts.repeat = Number(value());
115
117
  else if (arg === '--project') opts.projectRoot = resolve(value());
116
118
  else if (arg === '--output') opts.outputPath = resolve(value());
119
+ else if (arg === '--recent-turns') opts.recentTurns = Number(value());
120
+ else if (arg === '--body-cap') opts.bodyCap = Number(value());
117
121
  else throw new Error(`unknown auditor model-matrix option: ${arg}`);
118
122
  }
119
123
  if (!opts.fixturesPath) throw new Error('--fixtures FILE is required');
120
124
  if (!Number.isInteger(opts.repeat) || opts.repeat < 1) throw new Error('--repeat must be a positive integer');
125
+ if (opts.recentTurns !== null && (!Number.isInteger(opts.recentTurns) || opts.recentTurns < 0 || opts.recentTurns > 3)) throw new Error('--recent-turns must be 0, 1, 2, or 3');
126
+ if (opts.bodyCap !== null && (!Number.isInteger(opts.bodyCap) || opts.bodyCap <= 0)) throw new Error('--body-cap must be a positive integer');
121
127
  opts.profiles = opts.profiles.length ? opts.profiles : [...DEFAULT_PROFILES];
122
128
  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
129
  return opts;
@@ -126,7 +132,7 @@ function parseArgs(argv) {
126
132
  function parseAndValidateFixture(raw) {
127
133
  let fixture; try { fixture = JSON.parse(raw); } catch { throw new Error('fixture must be valid JSON'); }
128
134
  objectOnly(fixture, ['schema', 'catalog', 'cases'], 'fixture');
129
- if (fixture.schema !== 'spotter.auditor_model_fixtures.v1') throw new Error('unsupported fixture schema');
135
+ if (!['spotter.auditor_model_fixtures.v1', 'spotter.auditor_model_fixtures.v2'].includes(fixture.schema)) throw new Error('unsupported fixture schema');
130
136
  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
137
  const catalogNames = new Set();
132
138
  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); }
@@ -135,7 +141,7 @@ function parseAndValidateFixture(raw) {
135
141
  objectOnly(item, ['id', 'stage', 'input', 'expected'], 'case'); clean(item.id, 'case id');
136
142
  if (ids.has(item.id)) throw new Error('duplicate case id'); ids.add(item.id);
137
143
  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');
144
+ validateInput(fixture.schema, item.stage, item.input); objectOnly(item.expected, ['pass', 'missingTools'], 'case expected');
139
145
  if (typeof item.expected.pass !== 'boolean' || !Array.isArray(item.expected.missingTools)
140
146
  || new Set(item.expected.missingTools).size !== item.expected.missingTools.length
141
147
  || item.expected.missingTools.some((name) => typeof name !== 'string' || !catalogNames.has(name))
@@ -145,8 +151,9 @@ function parseAndValidateFixture(raw) {
145
151
  }
146
152
  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
153
  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 }; }
154
+ function validateInput(schema, stage, input) { if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('case input is invalid'); if (stage === 'user_input') { objectOnly(input, schema === 'spotter.auditor_model_fixtures.v2' ? ['userInput', 'recentContext'] : ['userInput'], 'user_input input'); clean(input.userInput, 'userInput'); if (schema === 'spotter.auditor_model_fixtures.v2') { if (!Array.isArray(input.recentContext) || input.recentContext.length < 1 || input.recentContext.length > 3) throw new Error('recentContext must contain one to three turns'); for (const turn of input.recentContext) { objectOnly(turn, ['user', 'assistant'], 'recentContext turn'); clean(turn.user, 'recentContext user'); clean(turn.assistant, 'recentContext assistant'); } } } 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'); } }
155
+ function resolveContextOptions(opts, schema) { if (schema === 'spotter.auditor_model_fixtures.v1') { if (opts.recentTurns !== null || opts.bodyCap !== null) throw new Error('--recent-turns and --body-cap require a v2 fixture'); return { recentTurns: null, bodyCap: null }; } return { recentTurns: opts.recentTurns ?? 2, bodyCap: opts.bodyCap ?? 1200 }; }
156
+ function toAuditorInput(item, contextOptions) { if (item.stage !== 'user_input') return { stage: item.stage, finalResponse: item.input.finalResponse, usedTools: item.input.usedTools }; const input = { stage: item.stage, userInput: item.input.userInput }; if (contextOptions.recentTurns > 0 && item.input.recentContext) input.recentContext = item.input.recentContext.slice(-contextOptions.recentTurns).map((turn) => ({ user: turn.user.slice(-contextOptions.bodyCap), assistant: turn.assistant.slice(-contextOptions.bodyCap) })); return input; }
150
157
  function isCleanString(value) { return typeof value === 'string' && value.length > 0 && value.trim() === value; }
151
158
  function cleanStrings(values = []) { return [...new Set(values.filter(isCleanString))]; }
152
159
  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 }; }
@@ -28,13 +28,16 @@ import {
28
28
  hookEventsPath,
29
29
  summarizeHookEvents,
30
30
  } from '../core/hook-event-log.mjs';
31
+ import {
32
+ loadAuditorContext,
33
+ readProjectAuditorContextConfig,
34
+ } from '../core/auditor-context.mjs';
31
35
 
32
36
  const HERE = dirname(fileURLToPath(import.meta.url));
33
37
  const PACKAGE_ROOT = resolve(HERE, '..', '..');
34
38
  const SPOTTER_BIN = join(PACKAGE_ROOT, 'bin', 'spotter.mjs');
35
39
  const CODEX_HOOK_TIMEOUT_SEC = 60;
36
40
  const DEFAULT_CODEX_HOOK_AUDITOR_TIMEOUT_MS = 20_000;
37
- const SHORT_PROMPT_MAX_CHARS = 10;
38
41
  const DEFAULT_CODEX_STOP_SHORT_FINAL_MAX_CHARS = 120;
39
42
  const CODEX_HOOK_FEATURE_NAMES = ['hooks', 'codex_hooks'];
40
43
 
@@ -114,6 +117,8 @@ export async function runCodexUserPromptSubmitHook({
114
117
  createAuditorBackendFn = createAuditorBackend,
115
118
  recordHookEventFn = appendCodexHookEvent,
116
119
  discardLegacyPendingFn = discardLegacyPending,
120
+ readAuditorContextConfigFn = readProjectAuditorContextConfig,
121
+ loadAuditorContextFn = loadAuditorContext,
117
122
  writeOutput = (text) => process.stdout.write(text),
118
123
  writeError = (text) => process.stderr.write(text),
119
124
  } = {}) {
@@ -122,16 +127,88 @@ export async function runCodexUserPromptSubmitHook({
122
127
  const projectRoot = findSpotterMarker(input.cwd);
123
128
  if (!projectRoot) return;
124
129
  const startedAt = Date.now();
130
+ let contextDurationMs = null;
125
131
 
126
132
  const prompt = requireString(input, 'prompt');
127
133
  const legacyPending = await discardLegacyPendingFn({ projectRoot, sessionId: codexSessionId(input) });
128
- if ([...prompt.trim()].length <= SHORT_PROMPT_MAX_CHARS) {
134
+ let context;
135
+ const contextStartedAt = Date.now();
136
+ try {
137
+ const config = await readAuditorContextConfigFn(projectRoot);
138
+ contextDurationMs = Date.now() - contextStartedAt;
139
+ if (config.mode === 'disabled') {
140
+ await recordCodexHookEventSafe(recordHookEventFn, {
141
+ projectRoot,
142
+ event: {
143
+ hook: 'UserPromptSubmit',
144
+ status: 'skipped',
145
+ reason: 'context_disabled',
146
+ contextStatus: 'disabled',
147
+ contextDurationMs,
148
+ legacyPendingDiagnostic: legacyPending.diagnostic,
149
+ durationMs: Date.now() - startedAt,
150
+ },
151
+ }, writeError);
152
+ return;
153
+ }
154
+ context = await loadAuditorContextFn({
155
+ config,
156
+ host: 'codex',
157
+ sessionId: requireCodexSessionId(input),
158
+ projectRoot,
159
+ transcriptPath: requireString(input, 'transcript_path'),
160
+ });
161
+ contextDurationMs = Date.now() - contextStartedAt;
162
+ } catch (err) {
163
+ contextDurationMs = Date.now() - contextStartedAt;
164
+ const failure = projectBackendFailure(err?.code);
165
+ safeWriteError(writeError, failure.stderr);
166
+ await recordCodexHookEventSafe(recordHookEventFn, {
167
+ projectRoot,
168
+ event: {
169
+ hook: 'UserPromptSubmit',
170
+ status: 'error',
171
+ code: failure.code,
172
+ reason: 'auditor_context',
173
+ contextDurationMs,
174
+ legacyPendingDiagnostic: legacyPending.diagnostic,
175
+ durationMs: Date.now() - startedAt,
176
+ },
177
+ }, writeError);
178
+ writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
179
+ return;
180
+ }
181
+ if (context.status === 'unavailable' || context.status === 'schema_mismatch') {
182
+ const failure = projectBackendFailure(context.status === 'unavailable'
183
+ ? 'E_AUDITOR_CONTEXT_UNAVAILABLE'
184
+ : 'E_AUDITOR_CONTEXT_SCHEMA');
185
+ safeWriteError(writeError, failure.stderr);
186
+ await recordCodexHookEventSafe(recordHookEventFn, {
187
+ projectRoot,
188
+ event: {
189
+ hook: 'UserPromptSubmit',
190
+ status: 'error',
191
+ code: failure.code,
192
+ reason: 'auditor_context_status',
193
+ contextDurationMs,
194
+ legacyPendingDiagnostic: legacyPending.diagnostic,
195
+ durationMs: Date.now() - startedAt,
196
+ },
197
+ }, writeError);
198
+ writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
199
+ return;
200
+ }
201
+ if (context.status !== 'fresh') {
129
202
  await recordCodexHookEventSafe(recordHookEventFn, {
130
203
  projectRoot,
131
204
  event: {
132
205
  hook: 'UserPromptSubmit',
133
206
  status: 'skipped',
134
- reason: 'short_prompt',
207
+ reason: 'context_not_fresh',
208
+ contextStatus: context.status,
209
+ contextTurns: 0,
210
+ contextChars: 0,
211
+ contextDurationMs,
135
212
  legacyPendingDiagnostic: legacyPending.diagnostic,
136
213
  durationMs: Date.now() - startedAt,
137
214
  },
@@ -145,7 +222,12 @@ export async function runCodexUserPromptSubmitHook({
145
222
  try {
146
223
  catalog = await readLocalFn({ projectRoot, hostAgent: 'codex' });
147
224
  backend = createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBackendFn });
148
- judgment = await backend.judge({ stage: 'user_input', userInput: prompt });
225
+ judgment = await backend.judge({
226
+ stage: 'user_input',
227
+ userInput: prompt,
228
+ recentContext: context.turns,
229
+ contextStatus: 'fresh',
230
+ });
149
231
  } catch (err) {
150
232
  const failure = projectBackendFailure(err?.code);
151
233
  safeWriteError(writeError, failure.stderr);
@@ -172,6 +254,10 @@ export async function runCodexUserPromptSubmitHook({
172
254
  backend: judgment.meta?.backend ?? backend.name ?? 'unknown',
173
255
  pass: judgment.pass,
174
256
  missingTools: projectToolIds(judgment.findings.map((finding) => finding.toolName)),
257
+ contextStatus: 'fresh',
258
+ contextTurns: context.stats.returnedTurns,
259
+ contextChars: context.stats.chars,
260
+ contextDurationMs,
175
261
  ...compactCodexModelSelectionForEvent(judgment.meta?.modelSelection),
176
262
  legacyPendingDiagnostic: legacyPending.diagnostic,
177
263
  backendDurationMs: judgment.meta?.durationMs ?? null,
@@ -621,6 +707,14 @@ function codexSessionId(payload) {
621
707
  return typeof value === 'string' && value.length > 0 ? value : null;
622
708
  }
623
709
 
710
+ function requireCodexSessionId(payload) {
711
+ const value = codexSessionId(payload);
712
+ if (value) return value;
713
+ const err = new Error('session_id is required');
714
+ err.code = 'E_AUDITOR_CONTEXT_INPUT';
715
+ throw err;
716
+ }
717
+
624
718
  // v1.4.19: legacy pending migration is handled only by `discardLegacyPending` on
625
719
  // UserPromptSubmit. Stop findings are never persisted for a future parent prompt.
626
720
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { access, readFile } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { isAbsolute, join } from 'node:path';
6
6
  import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { loadDb, globalDbPath, localDbPath } from '../tool-db/loader.mjs';
@@ -75,6 +75,10 @@ export async function runDoctor() {
75
75
  const sidecar = await codexSidecarAuditorReadiness(projectRoot);
76
76
  mark(sidecar.ok, `codex-sidecar auditor: ${sidecar.status}`, sidecar.detail);
77
77
  if (!sidecar.ok) warnings += 1;
78
+
79
+ const auditorContext = await inspectAuditorContextConfiguration({ projectRoot });
80
+ mark(auditorContext.ok, `auditor context: ${auditorContext.mode}`, auditorContext.detail);
81
+ if (!auditorContext.ok) warnings += 1;
78
82
  }
79
83
 
80
84
  // tool-db (host-specific global caches). Since v1.2.0 these are not part of
@@ -132,6 +136,50 @@ export async function inspectCodexHookConfiguration({ projectRoot = null, diagno
132
136
  };
133
137
  }
134
138
 
139
+ export async function inspectAuditorContextConfiguration({
140
+ projectRoot,
141
+ readFileFn = readFile,
142
+ accessFn = access,
143
+ } = {}) {
144
+ const disabled = { ok: true, mode: 'disabled', detail: 'disabled' };
145
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return disabled;
146
+
147
+ let marker;
148
+ try {
149
+ marker = JSON.parse(await readFileFn(join(projectRoot, '.spotter', 'marker.json'), 'utf8'));
150
+ } catch {
151
+ return { ok: false, mode: 'unknown', detail: 'marker unreadable' };
152
+ }
153
+
154
+ const config = marker?.auditorContext;
155
+ if (config === undefined) return disabled;
156
+ if (config?.mode === 'disabled') {
157
+ if (config.origin === 'explicit') return { ok: true, mode: 'disabled', detail: 'explicit project opt-out' };
158
+ if (config.reason === 'throughline_unavailable') {
159
+ return { ok: true, mode: 'disabled', detail: 'default disabled: Throughline unavailable' };
160
+ }
161
+ return disabled;
162
+ }
163
+ if (config?.mode !== 'throughline') {
164
+ return { ok: false, mode: 'unknown', detail: 'invalid configuration' };
165
+ }
166
+ if (
167
+ typeof config.command !== 'string' ||
168
+ !isAbsolute(config.command) ||
169
+ /\.(?:cmd|bat)$/i.test(config.command) ||
170
+ !Array.isArray(config.args) ||
171
+ config.args.some((arg) => typeof arg !== 'string' || arg.length === 0)
172
+ ) {
173
+ return { ok: false, mode: 'throughline', detail: 'invalid configuration' };
174
+ }
175
+ try {
176
+ await accessFn(config.command);
177
+ } catch {
178
+ return { ok: false, mode: 'throughline', detail: 'command unavailable' };
179
+ }
180
+ return { ok: true, mode: 'throughline', detail: 'command available' };
181
+ }
182
+
135
183
  function formatCodexAuditorModelSelection({ backend, selection }) {
136
184
  if (backend && backend !== 'codex-cli') return 'auditor-model=not-applicable';
137
185
  if (!selection || typeof selection !== 'object') return 'auditor-model=unknown';