claude-spotter 1.4.19 → 1.4.20

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.20 — 2026-07-13
4
+
5
+ ### Added
6
+
7
+ - **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を維持する。
8
+
3
9
  ## 1.4.19
4
10
 
5
11
  親セッションの暴走を誘発できたHook出力の信頼境界を修正する。監査用AIは内部で構造化判定を返すだけとし、
@@ -29,8 +35,10 @@ AI/backend/provider sentinel、unsafe ID、legacy pending非読取unlink、Stop
29
35
  読込例外の固定degradationを確認した。敵対的再監査は初回BLOCKER 2件(Codex読込例外の境界外、
30
36
  UI可視性の過剰主張)を検出・修正し、再監査BLOCKER 0。`npm pack --dry-run`は62 files、global
31
37
  `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へ揃える。
38
+ 確認した。公開前release gateでfull test、pack、秘密混入を再検証し、公開SHA`5393919`のCIは
39
+ macOS/Linux/Windows × Node 22.5/22.xの6/6 green。`v1.4.19` tag、npm `latest`、
40
+ [GitHub Release](https://github.com/kitepon-rgb/Spotter/releases/tag/v1.4.19)、registry由来global installを
41
+ 1.4.19へ揃え、global projector smokeとHook diagnosticsもgreen。
34
42
 
35
43
  ## 1.4.18
36
44
 
package/README.ja.md CHANGED
@@ -146,6 +146,54 @@ flowchart LR
146
146
 
147
147
  両者に共通するのは **「主体に頼らない仕組み」**。併用できます。
148
148
 
149
+ ### 任意の Throughline auditor context canary
150
+
151
+ 新規の project install では auditor context は **disabled** が既定です。これは project が所有する
152
+ opt-in であり、Spotter 全体の production 既定ではありません。現在の候補は Spotter リポジトリだけの
153
+ canary です。auditor-context オプションなしで `spotter install` を再実行した場合、既存プロジェクトの
154
+ 設定は保持されます。無効化へ戻すには次を実行します。
155
+
156
+ ```bash
157
+ spotter install -y --auditor-context disabled
158
+ ```
159
+
160
+ project を opt-in するには、絶対パスの Throughline 実行ファイルと、必要なら繰り返し指定できる
161
+ `--throughline-arg` を設定します。
162
+
163
+ ```bash
164
+ spotter install -y --auditor-context throughline \
165
+ --throughline-command /absolute/path/to/throughline
166
+ ```
167
+
168
+ Windows では shell injection を避けるため `.cmd` / `.bat` wrapper を意図的に拒否します。絶対パスの
169
+ `node.exe` を command にし、絶対パスの `throughline.mjs` を繰り返し引数として渡してください。
170
+
171
+ ```powershell
172
+ spotter install -y --auditor-context throughline `
173
+ --throughline-command 'C:\Program Files\nodejs\node.exe' `
174
+ --throughline-arg 'C:\absolute\path\to\throughline\bin\throughline.mjs'
175
+ ```
176
+
177
+ この connector の AI 呼出しは Codex CLI のみです。コンテキストを argv には載せず、AI へは stdin 経由で
178
+ 渡します。Haiku はこの context path に未対応であり、呼び出しません。Throughline 結果が `fresh` の時だけ
179
+ AI 呼出し候補になり、それ以外の status では AI を呼びません。enabled connector の障害は hidden fallback
180
+ ではなく固定 warning として出します。
181
+
182
+ Throughline から渡すのは fresh な完了済み L2 user/assistant pair だけです。直近 2 pair (N=2)、各 body は
183
+ 600 文字、合計は 4,000 文字に制限します。Spotter は Throughline の L2、`reason`、`raw` を親へ反射せず、
184
+ 親には安全な catalog tool ID から作る固定・非命令形の助言だけを渡します。`spotter doctor` は command / args
185
+ を表示せず、auditor-context mode と固定の availability detail だけを表示します。
186
+
187
+ v2 model-matrix では context choice を明示できます。
188
+
189
+ ```bash
190
+ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.json \
191
+ --recent-turns 2 --body-cap 600
192
+ ```
193
+
194
+ 評価結論は現在 N=2 / 600 です。ただし rollout 承認ではありません。Spotter リポジトリ canary の
195
+ 7 日・30 fresh result gate は未完了です。
196
+
149
197
  ## よく使うコマンド
150
198
 
151
199
  ```bash
@@ -172,7 +220,7 @@ spotter codex-hook install
172
220
  # Codex native hooks の修復 / 明示登録 (通常は spotter install が実行)
173
221
  spotter codex-hook diagnostics
174
222
  # Codex hook の登録/readiness を診断。trust は /hooks で review
175
- spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v1.json
223
+ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.json --recent-turns 2 --body-cap 600
176
224
  # pinned auditor model profile を再現可能に比較する experimental eval
177
225
  spotter uninstall # hook 登録を解除 (~/.spotter は残す)
178
226
  ```
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
+ ### Optional Throughline auditor context canary
150
+
151
+ Fresh project installs keep auditor context **disabled**. This is a project-owned
152
+ opt-in, not a production-wide default: the current candidate is a canary in the
153
+ Spotter repository only. Re-running `spotter install` without auditor-context
154
+ options preserves the project's existing setting. Disable it again with:
155
+
156
+ ```bash
157
+ spotter install -y --auditor-context disabled
158
+ ```
159
+
160
+ To opt a project in, configure a direct absolute Throughline executable and any
161
+ 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
+ Current evaluation conclusion is N=2 / 600. It is not a rollout approval: the
199
+ Spotter-repository canary still lacks its 7-day and 30-fresh-result gates.
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,7 +21,9 @@ 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
26
28
  (run inside each project you want audited)
27
29
  spotter install --user [-y] legacy: register globally in ~/.claude/settings.json
@@ -75,9 +77,8 @@ async function main() {
75
77
  const [cmd, ...rest] = argv;
76
78
  switch (cmd) {
77
79
  case 'install': {
78
- const target = rest.includes('--user') ? 'user' : 'project';
79
- const autoYes = rest.includes('-y') || rest.includes('--yes');
80
- await runInstall({ target, autoYes });
80
+ const options = parseInstallArgs(rest);
81
+ await runInstall(options);
81
82
  return;
82
83
  }
83
84
  case 'uninstall': {
@@ -140,6 +141,60 @@ async function main() {
140
141
  }
141
142
  }
142
143
 
144
+ function parseInstallArgs(argv) {
145
+ let target = 'project';
146
+ let autoYes = false;
147
+ let mode = null;
148
+ let command = null;
149
+ const args = [];
150
+ for (let index = 0; index < argv.length; index += 1) {
151
+ const arg = argv[index];
152
+ if (arg === '--user') {
153
+ if (target === 'user') throw invalidInstallArgs();
154
+ target = 'user';
155
+ } else if (arg === '-y' || arg === '--yes') {
156
+ if (autoYes) throw invalidInstallArgs();
157
+ autoYes = true;
158
+ } else if (arg === '--auditor-context') {
159
+ if (mode !== null) throw invalidInstallArgs();
160
+ mode = argv[++index];
161
+ if (mode !== 'disabled' && mode !== 'throughline') throw invalidInstallArgs();
162
+ } else if (arg === '--throughline-command') {
163
+ if (command !== null) throw invalidInstallArgs();
164
+ command = argv[++index];
165
+ if (typeof command !== 'string' || command.length === 0) throw invalidInstallArgs();
166
+ } else if (arg === '--throughline-arg') {
167
+ const value = argv[++index];
168
+ if (typeof value !== 'string' || value.length === 0) throw invalidInstallArgs();
169
+ args.push(value);
170
+ } else {
171
+ throw invalidInstallArgs();
172
+ }
173
+ }
174
+ if (mode === null && (command !== null || args.length > 0)) throw invalidInstallArgs();
175
+ if (mode === 'disabled' && (command !== null || args.length > 0)) throw invalidInstallArgs();
176
+ if (mode === 'throughline' && (command === null || !isAbsoluteCommand(command) || isShellWrapper(command))) throw invalidInstallArgs();
177
+ const auditorContext = mode === null ? undefined : mode === 'disabled'
178
+ ? { mode: 'disabled' }
179
+ : { mode: 'throughline', command, args };
180
+ return { target, autoYes, auditorContext };
181
+ }
182
+
183
+ function isAbsoluteCommand(value) {
184
+ return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value);
185
+ }
186
+
187
+ function isShellWrapper(value) {
188
+ return /\.(?:cmd|bat)$/i.test(value);
189
+ }
190
+
191
+ function invalidInstallArgs() {
192
+ const err = new Error('invalid install arguments');
193
+ err.stack = '';
194
+ err.exitCode = 2;
195
+ return err;
196
+ }
197
+
143
198
  main().catch((err) => {
144
199
  process.stderr.write(`spotter: ${err.stack || err.message || err}\n`);
145
200
  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.20",
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,43 @@ 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 || config?.mode === 'disabled') return disabled;
156
+ if (config?.mode !== 'throughline') {
157
+ return { ok: false, mode: 'unknown', detail: 'invalid configuration' };
158
+ }
159
+ if (
160
+ typeof config.command !== 'string' ||
161
+ !isAbsolute(config.command) ||
162
+ /\.(?:cmd|bat)$/i.test(config.command) ||
163
+ !Array.isArray(config.args) ||
164
+ config.args.some((arg) => typeof arg !== 'string' || arg.length === 0)
165
+ ) {
166
+ return { ok: false, mode: 'throughline', detail: 'invalid configuration' };
167
+ }
168
+ try {
169
+ await accessFn(config.command);
170
+ } catch {
171
+ return { ok: false, mode: 'throughline', detail: 'command unavailable' };
172
+ }
173
+ return { ok: true, mode: 'throughline', detail: 'command available' };
174
+ }
175
+
135
176
  function formatCodexAuditorModelSelection({ backend, selection }) {
136
177
  if (backend && backend !== 'codex-cli') return 'auditor-model=not-applicable';
137
178
  if (!selection || typeof selection !== 'object') return 'auditor-model=unknown';
@@ -57,6 +57,7 @@ export async function runInstall({
57
57
  refreshFn = refresh,
58
58
  codexCliPresentFn = isCodexCliPresent,
59
59
  installCodexHooksFn = installCodexHooks,
60
+ auditorContext,
60
61
  } = {}) {
61
62
  const settingsPath = target === 'user'
62
63
  ? join(homedir(), '.claude', 'settings.json')
@@ -84,10 +85,14 @@ export async function runInstall({
84
85
  const markerDir = join(cwd, '.spotter');
85
86
  const markerPath = join(markerDir, 'marker.json');
86
87
  await mkdir(markerDir, { recursive: true });
88
+ const preservedAuditorContext = auditorContext === undefined
89
+ ? await readExistingAuditorContext(markerPath)
90
+ : auditorContext;
87
91
  const marker = {
88
92
  markerVersion: MARKER_VERSION,
89
93
  spotterVersion: SPOTTER_VERSION,
90
94
  installedAt: new Date().toISOString(),
95
+ auditorContext: preservedAuditorContext,
91
96
  };
92
97
  await writeFile(markerPath, JSON.stringify(marker, null, 2) + '\n', 'utf8');
93
98
  console.log(` wrote ${markerPath}`);
@@ -171,6 +176,16 @@ export async function runInstall({
171
176
  }
172
177
  }
173
178
 
179
+ async function readExistingAuditorContext(markerPath) {
180
+ try {
181
+ const marker = JSON.parse(await readFile(markerPath, 'utf8'));
182
+ return marker?.auditorContext ?? { mode: 'disabled' };
183
+ } catch (err) {
184
+ if (err?.code === 'ENOENT') return { mode: 'disabled' };
185
+ throw err;
186
+ }
187
+ }
188
+
174
189
  export function codexInstallNextSteps(projectRoot) {
175
190
  return [
176
191
  'review the three Spotter hooks with Codex /hooks',
@@ -89,6 +89,13 @@ export function createHaikuAuditorBackend({
89
89
  },
90
90
  async judge(input = {}) {
91
91
  const stage = validateStage(input.stage);
92
+ if (input.recentContext !== undefined) {
93
+ throw new AuditorBackendError(
94
+ 'E_AUDITOR_CONTEXT_BACKEND_UNSUPPORTED',
95
+ 'recent conversation context is not supported by the haiku auditor backend',
96
+ { backend: 'haiku', stage },
97
+ );
98
+ }
92
99
  const prompt = buildStagePrompt(stage, input);
93
100
  let raw, meta;
94
101
  try {