claude-spotter 1.5.3 → 1.5.5

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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.5 — 2026-08-05
4
+
5
+ - **提案時文脈をexact sessionへ戻した。** 評価記録がproject全体の最新threadを読む
6
+ `observer-read`を誤って使っていた経路を撤去し、既存のThroughline `auditor-context`へ一本化した。
7
+ Claude / CodexのUserPromptSubmit hookから提案元の`session_id`、`host`、`transcript_path`を渡し、
8
+ 提案直前の完了turnを非採用caseへ保存できる。
9
+ - **重複adapterを廃止。** 評価専用readerは、すでにfreshness・本文上限・exact-session照合を持つ
10
+ `loadAuditorContext`を再利用する。Throughline本文をauditor入力へ戻さず、監査と評価証拠の分離は維持する。
11
+ - **検証。** 既存adapterへの引数接続とClaude / Codex hookのfocused testを通過した。
12
+
13
+ ## 1.5.4 — 2026-08-05
14
+
15
+ - **Throughlineを提案監査の実行条件から撤去。** Claude / CodexのUserPromptSubmitは、
16
+ `auditorContext`のmode、Throughlineのfreshness、取得成否に関係なく、現在のuser promptと
17
+ host-local tool catalogで監査する。daemonも旧`audit:false`やcontext payloadで監査を止めず、
18
+ Throughline本文を監査AIへ渡さない。
19
+ - **評価文脈だけを独立取得。** 提案が出た時のThroughline `observer-read`は改善用証拠として
20
+ 一度だけ取得し、失敗時は評価文脈だけをunavailableにする。監査結果、親へのtool提案、
21
+ 成功Hook eventには影響させず、retryやbackground回収も追加しない。
22
+ - **停止を正常passへ偽装しない。** 旧版で`context_disabled` / `context_not_fresh` /
23
+ `auditor_context`となっていた経路を削除し、以後の監査成功turnは提案なしの場合も評価母数へ入る。
24
+ install / doctor / READMEもThroughlineを`evaluation context`として表示する。
25
+ - **検証。** disabled / stale / provider error / legacy payload / observer-read failureを含む
26
+ Claude・Codex回帰テストとfull suite 588件(586 pass / 2 platform skip)を通過した。
27
+
3
28
  ## 1.5.3 — 2026-08-04
4
29
 
5
30
  - **dashboardの難解な集計略号を廃止。** 概要cardとproject/tool別内訳の
package/README.ja.md CHANGED
@@ -156,11 +156,16 @@ flowchart LR
156
156
 
157
157
  両者に共通するのは **「主体に頼らない仕組み」**。併用できます。
158
158
 
159
- ### Throughline auditor context(既定ON)
159
+ ### Throughlineの提案時評価文脈(任意)
160
160
 
161
- `spotter install`はPATH上のThroughlineを絶対パスへ解決できる場合、auditor contextを既定で有効化します。
162
- 旧版の既定`disabled` markerも通常の再installで有効化されます。過去に明示的に無効化したprojectは
163
- `origin: explicit`として保持され、勝手に再有効化されません。無効化するには次を実行します。
161
+ Spotterの提案AIはThroughlineを使わず、Throughlineの導入・設定・freshness・取得成否に関係なく
162
+ UserPromptSubmitごとに監査します。提案が出た時だけ、改善分析用の別文脈としてThroughline
163
+ `auditor-context`を提案元のexact sessionとtranscriptで一度だけ呼びます。返されたfreshな直前完了turnは
164
+ 監査入力と混ぜずに保存します。取得できなくても`context_unavailable`として記録するだけで、監査や親への助言には影響しません。
165
+
166
+ `spotter install`がPATH上のThroughlineを絶対パスへ解決できる場合、この評価証拠の取得経路を既定で設定します。
167
+ 既存互換の`--auditor-context`名はmarker設定に残っていますが、監査のON/OFFは制御しません。
168
+ 評価文脈の取得だけを無効化するには次を実行します。
164
169
 
165
170
  ```bash
166
171
  spotter install -y --auditor-context disabled
@@ -183,24 +188,8 @@ spotter install -y --auditor-context throughline `
183
188
  --throughline-arg 'C:\absolute\path\to\throughline\bin\throughline.mjs'
184
189
  ```
185
190
 
186
- この connector AI 呼出しは Codex CLI のみです。コンテキストを argv には載せず、AI へは stdin 経由で
187
- 渡します。Haiku はこの context path に未対応であり、呼び出しません。Throughline 結果が `fresh` の時だけ
188
- AI 呼出し候補になり、それ以外の status では AI を呼びません。enabled connector の障害は hidden fallback
189
- ではなく固定 warning として出します。
190
-
191
- Throughline から渡すのは fresh な完了済み L2 user/assistant pair だけです。直近 2 pair (N=2)、各 body は
192
- 600 文字、合計は 4,000 文字に制限します。Spotter は Throughline の L2、`reason`、`raw` を親へ反射せず、
193
- 親には安全な catalog tool ID から作る固定・非命令形の助言だけを渡します。`spotter doctor` は command / args
194
- を表示せず、auditor-context mode と固定の availability detail だけを表示します。
195
-
196
- v2 model-matrix では context choice を明示できます。
197
-
198
- ```bash
199
- spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.json \
200
- --recent-turns 2 --body-cap 600
201
- ```
202
-
203
- 評価結論はN=2 / 600です。既定ONは確定済みで、7日・30 fresh resultの実運用測定は精度改善に使います。
191
+ `spotter doctor`はこの経路を`evaluation context`として表示し、command / argsや会話本文は表示しません。
192
+ 評価文脈は端末内の評価SQLiteにだけ保存され、network送信、retry、background回収は行いません。
204
193
 
205
194
  ## よく使うコマンド
206
195
 
@@ -217,12 +206,16 @@ spotter db rebuild # Claude local + Claude global DB を両方消してか
217
206
  spotter status # 稼働中の daemon 一覧
218
207
  spotter doctor # 環境診断 (Node / claude CLI / Codex readiness / tool-db 整合性)
219
208
  spotter diagnostics logs # daemon log から pass=false / backend latency / anomaly signal を集計
209
+ spotter diagnostics factory
210
+ # factory向けread-only診断snapshotをJSONで出力
211
+ spotter diagnostics runtime-errors
212
+ # opt-in端末内runtime error集計をread-only表示(network送信なし)
220
213
  spotter evaluation report
221
214
  # 端末内DBからproject横断の提案率・tool採用率を集計
222
215
  spotter evaluation cases --outcome not-adopted
223
216
  # 提案されたが同じturnで使われなかったtool itemを一覧
224
217
  spotter evaluation case <observation-id>
225
- # request、2種類の文脈、提案、利用、outcomeを確認
218
+ # request、任意のThroughline snapshot、提案、利用、outcomeを確認
226
219
  spotter dashboard device --id mac --name Mac
227
220
  # この端末の評価DBを127.0.0.1:53940で配信
228
221
  spotter dashboard hub --config dashboard-hub.json --host 172.18.0.1
@@ -247,8 +240,9 @@ spotter uninstall # hook 登録を解除 (~/.spotter は残す)
247
240
 
248
241
  dashboardはlocal-firstで動く。各端末が自身の`~/.spotter/evaluation.db`を読み、hubは固定の
249
242
  端末・upstream対応だけを持つ。評価データをcloud DBへ複製しない。端末画面では
250
- `S/P/I/C/A/M`、提案率、採用率、project/tool内訳、非採用case、別々に取得した2種類の文脈を
251
- 確認できる。health確認は端末一覧request時だけなので、端末がofflineでもbackground監視や
243
+ 対象ターン、ツール提案あり、提案ツール数、利用判定済み、実際に使用、判定不能、提案率、採用率、
244
+ project/tool内訳、非採用case、監査対象request、任意の提案時Throughline証拠を確認できる。
245
+ health確認は端末一覧request時だけなので、端末がofflineでもbackground監視や
252
246
  retry queueを作らず、その端末だけを切り離せる。
253
247
 
254
248
  4端末のservice、reverse tunnel、Caddy/Cloudflare構成は
package/README.md CHANGED
@@ -156,12 +156,20 @@ The audited catalog is host-local: Claude uses `<project>/.spotter/tool-db.json`
156
156
 
157
157
  Both share the principle of **"don't rely on the primary agent to do it itself."** They compose well — you can run them together.
158
158
 
159
- ### Throughline auditor context (default-on)
159
+ ### Optional Throughline evidence for proposal evaluation
160
160
 
161
- When `spotter install` can resolve Throughline on PATH to an absolute executable,
162
- auditor context is enabled by default. A normal reinstall migrates legacy markers
163
- whose disabled state came from the old default. An explicit project opt-out is
164
- stored with `origin: explicit` and is never silently re-enabled. Disable it with:
161
+ Spotter's proposal auditor does not use Throughline. Every UserPromptSubmit
162
+ audit runs independently of Throughline installation, configuration, freshness, or read
163
+ failures. When Spotter emits a proposal, the evaluation recorder calls Throughline
164
+ `auditor-context` once with the proposing host's exact session ID and transcript path.
165
+ The bounded, fresh completed turns are saved as separate improvement evidence; they are
166
+ never auditor input. A failed read is recorded as `context_unavailable` and never changes
167
+ auditing or parent advice.
168
+
169
+ When `spotter install` resolves Throughline on PATH to an absolute executable, it
170
+ configures this evaluation-evidence path by default. The legacy option name
171
+ `--auditor-context` remains for marker compatibility but no longer controls whether
172
+ auditing runs. Disable only the evidence capture with:
165
173
 
166
174
  ```bash
167
175
  spotter install -y --auditor-context disabled
@@ -185,28 +193,9 @@ spotter install -y --auditor-context throughline `
185
193
  --throughline-arg 'C:\absolute\path\to\throughline\bin\throughline.mjs'
186
194
  ```
187
195
 
188
- The connector is Codex CLI-only. It sends no context in argv: the bounded
189
- projection is supplied to that AI over stdin. Haiku does not support this
190
- context path and is never called for it. Only a `fresh` Throughline result is
191
- eligible for an AI call; every other status skips AI. An enabled connector
192
- failure becomes a fixed warning, not a hidden fallback.
193
-
194
- Throughline contributes only fresh, completed L2 user/assistant pairs: two
195
- recent pairs (N=2), each body capped at 600 characters and 4,000 characters in
196
- total. Spotter never reflects Throughline L2, `reason`, or `raw` to the parent.
197
- The parent receives only fixed non-imperative advice built from safe catalog tool
198
- IDs. `spotter doctor` displays the auditor-context mode and a fixed availability
199
- detail without printing its command or arguments.
200
-
201
- The v2 model-matrix can make the context choice explicit:
202
-
203
- ```bash
204
- spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.json \
205
- --recent-turns 2 --body-cap 600
206
- ```
207
-
208
- The evaluated setting is N=2 / 600. Default-on is final; the 7-day,
209
- 30-fresh-result sample is used only to identify precision improvements.
196
+ `spotter doctor` reports this as `evaluation context` without printing commands,
197
+ arguments, or conversation text. Observer snapshots stay in the terminal-local
198
+ evaluation SQLite. Spotter adds no network upload, retry, or background recovery.
210
199
 
211
200
  ## Common commands
212
201
 
@@ -231,7 +220,7 @@ spotter evaluation report
231
220
  spotter evaluation cases --outcome not-adopted
232
221
  # list proposed tools that were not used in the same turn
233
222
  spotter evaluation case <observation-id>
234
- # inspect request, Spotter context, Throughline snapshot, proposal, usage, and outcome
223
+ # inspect request, optional Throughline snapshot, proposal, usage, and outcome
235
224
  spotter dashboard device --id mac --name Mac
236
225
  # serve this terminal's local evaluation DB on 127.0.0.1:53940
237
226
  spotter dashboard hub --config dashboard-hub.json --host 172.18.0.1
@@ -257,8 +246,8 @@ spotter uninstall # remove hooks from this project (leaves ~/.spotter int
257
246
  The dashboard is local-first. Every terminal reads its own `~/.spotter/evaluation.db`; the hub
258
247
  keeps only a static device-to-upstream map and does not copy evaluation data into a cloud database.
259
248
  The device view shows Japanese labels for every evaluation metric, proposal and adoption rates
260
- with their numerator and denominator, project/tool breakdowns, non-adopted cases, and the two
261
- separately captured context sources. The hub checks health only
249
+ with their numerator and denominator, project/tool breakdowns, non-adopted cases, the request
250
+ audited by Spotter, and optional proposal-time Throughline evidence. The hub checks health only
262
251
  when the device list is requested, so an offline terminal is isolated without a background monitor
263
252
  or retry queue.
264
253
 
package/bin/spotter.mjs CHANGED
@@ -28,8 +28,8 @@ Usage:
28
28
  [--throughline-command ABS] [--throughline-arg VALUE]
29
29
  register hooks in <cwd>/.claude/settings.json
30
30
  and create <cwd>/.spotter/marker.json
31
- (Throughline on PATH enables context by default;
32
- pass disabled for a persistent project opt-out)
31
+ (Throughline on PATH enables proposal-time
32
+ evaluation evidence by default; it never gates auditing)
33
33
  (run inside each project you want audited)
34
34
  spotter install --user [-y] legacy: register globally in ~/.claude/settings.json
35
35
  (NOT RECOMMENDED — fires for every Claude Code session
@@ -1,5 +1,8 @@
1
1
  # Spotter評価dashboard運用
2
2
 
3
+ 現行配布版: **v1.5.4**(2026-08-05)。Mac、main-server、FOX WSL2、FOX Windows nativeへ
4
+ global install済み。v1.5.4は監査gate修正でありdashboard routing構成はv1.5.3から変更していない。
5
+
3
6
  ## 固定構成
4
7
 
5
8
  各端末のdevice serverはloopbackだけで待ち受ける。main-server、Mac、FOX WSL2は
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,13 +30,9 @@ import {
30
30
  hookEventsPath,
31
31
  summarizeHookEvents,
32
32
  } from '../core/hook-event-log.mjs';
33
- import {
34
- loadAuditorContext,
35
- readProjectAuditorContextConfig,
36
- } from '../core/auditor-context.mjs';
37
33
  import { observeRuntimeErrorIsolatedSafe } from '../core/runtime-error-store.mjs';
38
34
  import { createEvaluationStore } from '../core/evaluation-store.mjs';
39
- import { loadEvaluationObserverContext } from '../core/evaluation-context.mjs';
35
+ import { loadEvaluationContext } from '../core/evaluation-context.mjs';
40
36
  import {
41
37
  canonicalizeCodexNestedMcpToolIds,
42
38
  canonicalizeCodexSkillReadToolIds,
@@ -130,13 +126,11 @@ export async function runCodexUserPromptSubmitHook({
130
126
  createAuditorBackendFn = createAuditorBackend,
131
127
  recordHookEventFn = appendCodexHookEvent,
132
128
  discardLegacyPendingFn = discardLegacyPending,
133
- readAuditorContextConfigFn = readProjectAuditorContextConfig,
134
- loadAuditorContextFn = loadAuditorContext,
135
129
  writeOutput = (text) => process.stdout.write(text),
136
130
  writeError = (text) => process.stderr.write(text),
137
131
  runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
138
132
  createEvaluationStoreFn = createEvaluationStore,
139
- loadEvaluationObserverContextFn = loadEvaluationObserverContext,
133
+ loadEvaluationContextFn = loadEvaluationContext,
140
134
  randomUUIDFn = randomUUID,
141
135
  now = () => Date.now(),
142
136
  } = {}) {
@@ -147,27 +141,25 @@ export async function runCodexUserPromptSubmitHook({
147
141
  const projectRoot = findSpotterMarker(input.cwd);
148
142
  if (!projectRoot) return;
149
143
  const startedAt = Date.now();
150
- let contextDurationMs = null;
151
- let auditorContextConfig;
152
144
 
153
145
  const prompt = requireString(input, 'prompt');
154
146
  const sessionId = codexSessionId(input);
155
- const recordEvaluation = async ({ auditStatus, proposedToolIds = [], backend = null, model = null, auditorSeenContext = null, config = undefined }) => {
147
+ const recordEvaluation = async ({ auditStatus, proposedToolIds = [], backend = null, model = null }) => {
156
148
  if (!sessionId) {
157
149
  reportEvaluationFailure(writeError);
158
150
  return;
159
151
  }
160
152
  const proposals = canonicalizeProposedToolIds(proposedToolIds);
161
- let observerContext = { status: 'not_requested', snapshot: null };
153
+ let evaluationContext = { status: 'not_requested', snapshot: null };
162
154
  let proposedAtMs = recordedAtMs;
163
155
  try {
164
156
  if (auditStatus === 'success' && proposals.resolvedToolIds.length > 0) {
165
157
  proposedAtMs = now();
166
- observerContext = await loadEvaluationObserverContextFn({
158
+ evaluationContext = await loadEvaluationContextFn({
167
159
  projectRoot,
168
160
  host: 'codex',
169
161
  sessionId,
170
- config,
162
+ transcriptPath: input.transcript_path,
171
163
  recordedAtMs: proposedAtMs,
172
164
  });
173
165
  }
@@ -182,9 +174,9 @@ export async function runCodexUserPromptSubmitHook({
182
174
  sessionId,
183
175
  auditStatus,
184
176
  requestText: prompt,
185
- auditorSeenContext,
186
- observerContextStatus: observerContext.status,
187
- observerSnapshot: observerContext.snapshot,
177
+ auditorSeenContext: null,
178
+ observerContextStatus: evaluationContext.status,
179
+ observerSnapshot: evaluationContext.snapshot,
188
180
  proposedToolIds: proposals.resolvedToolIds,
189
181
  backend,
190
182
  model,
@@ -198,97 +190,6 @@ export async function runCodexUserPromptSubmitHook({
198
190
  }
199
191
  };
200
192
  const legacyPending = await discardLegacyPendingFn({ projectRoot, sessionId: codexSessionId(input) });
201
- let context;
202
- const contextStartedAt = Date.now();
203
- try {
204
- const config = await readAuditorContextConfigFn(projectRoot);
205
- auditorContextConfig = config;
206
- contextDurationMs = Date.now() - contextStartedAt;
207
- if (config.mode === 'disabled') {
208
- await recordEvaluation({ auditStatus: 'skipped' });
209
- await recordCodexHookEventSafe(recordHookEventFn, {
210
- projectRoot,
211
- event: {
212
- hook: 'UserPromptSubmit',
213
- status: 'skipped',
214
- reason: 'context_disabled',
215
- contextStatus: 'disabled',
216
- contextDurationMs,
217
- legacyPendingDiagnostic: legacyPending.diagnostic,
218
- durationMs: Date.now() - startedAt,
219
- },
220
- }, writeError);
221
- return;
222
- }
223
- context = await loadAuditorContextFn({
224
- config,
225
- host: 'codex',
226
- sessionId: requireCodexSessionId(input),
227
- projectRoot,
228
- transcriptPath: requireString(input, 'transcript_path'),
229
- });
230
- contextDurationMs = Date.now() - contextStartedAt;
231
- } catch (err) {
232
- await recordEvaluation({ auditStatus: 'error' });
233
- await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
234
- contextDurationMs = Date.now() - contextStartedAt;
235
- const failure = projectBackendFailure(err?.code);
236
- safeWriteError(writeError, failure.stderr);
237
- await recordCodexHookEventSafe(recordHookEventFn, {
238
- projectRoot,
239
- event: {
240
- hook: 'UserPromptSubmit',
241
- status: 'error',
242
- code: failure.code,
243
- reason: 'auditor_context',
244
- contextDurationMs,
245
- legacyPendingDiagnostic: legacyPending.diagnostic,
246
- durationMs: Date.now() - startedAt,
247
- },
248
- }, writeError);
249
- writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
250
- return;
251
- }
252
- if (context.status === 'unavailable' || context.status === 'schema_mismatch') {
253
- await recordEvaluation({ auditStatus: 'error' });
254
- await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
255
- const failure = projectBackendFailure(context.status === 'unavailable'
256
- ? 'E_AUDITOR_CONTEXT_UNAVAILABLE'
257
- : 'E_AUDITOR_CONTEXT_SCHEMA');
258
- safeWriteError(writeError, failure.stderr);
259
- await recordCodexHookEventSafe(recordHookEventFn, {
260
- projectRoot,
261
- event: {
262
- hook: 'UserPromptSubmit',
263
- status: 'error',
264
- code: failure.code,
265
- reason: 'auditor_context_status',
266
- contextDurationMs,
267
- legacyPendingDiagnostic: legacyPending.diagnostic,
268
- durationMs: Date.now() - startedAt,
269
- },
270
- }, writeError);
271
- writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
272
- return;
273
- }
274
- if (context.status !== 'fresh') {
275
- await recordEvaluation({ auditStatus: 'skipped' });
276
- await recordCodexHookEventSafe(recordHookEventFn, {
277
- projectRoot,
278
- event: {
279
- hook: 'UserPromptSubmit',
280
- status: 'skipped',
281
- reason: 'context_not_fresh',
282
- contextStatus: context.status,
283
- contextTurns: 0,
284
- contextChars: 0,
285
- contextDurationMs,
286
- legacyPendingDiagnostic: legacyPending.diagnostic,
287
- durationMs: Date.now() - startedAt,
288
- },
289
- }, writeError);
290
- return;
291
- }
292
193
 
293
194
  let catalog;
294
195
  let backend;
@@ -301,8 +202,6 @@ export async function runCodexUserPromptSubmitHook({
301
202
  judgment = await backend.judge({
302
203
  stage: 'user_input',
303
204
  userInput: prompt,
304
- recentContext: context.turns,
305
- contextStatus: 'fresh',
306
205
  });
307
206
  } catch (err) {
308
207
  await recordEvaluation({ auditStatus: 'error', backend: err?.backend ?? null, model: err?.diagnostics?.modelSelection?.effectiveModel ?? null });
@@ -332,10 +231,6 @@ export async function runCodexUserPromptSubmitHook({
332
231
  backend: judgment.meta?.backend ?? backend.name ?? 'unknown',
333
232
  pass: judgment.pass,
334
233
  missingTools: projectToolIds(judgment.findings.map((finding) => finding.toolName)),
335
- contextStatus: 'fresh',
336
- contextTurns: context.stats.returnedTurns,
337
- contextChars: context.stats.chars,
338
- contextDurationMs,
339
234
  ...compactCodexModelSelectionForEvent(judgment.meta?.modelSelection),
340
235
  legacyPendingDiagnostic: legacyPending.diagnostic,
341
236
  backendDurationMs: judgment.meta?.durationMs ?? null,
@@ -357,8 +252,6 @@ export async function runCodexUserPromptSubmitHook({
357
252
  proposedToolIds: toolIds,
358
253
  backend: judgment.meta?.backend ?? backend.name ?? 'unknown',
359
254
  model: judgment.meta?.modelSelection?.effectiveModel ?? null,
360
- auditorSeenContext: JSON.stringify(context.turns),
361
- config: auditorContextConfig,
362
255
  });
363
256
  const advice = projectParentAdvice(toolIds);
364
257
  if (advice) writeCodexUserPromptContexts({ contexts: [advice], writeOutput });
@@ -848,14 +741,6 @@ function codexSessionId(payload) {
848
741
  return typeof value === 'string' && value.length > 0 ? value : null;
849
742
  }
850
743
 
851
- function requireCodexSessionId(payload) {
852
- const value = codexSessionId(payload);
853
- if (value) return value;
854
- const err = new Error('session_id is required');
855
- err.code = 'E_AUDITOR_CONTEXT_INPUT';
856
- throw err;
857
- }
858
-
859
744
  // v1.4.19: legacy pending migration is handled only by `discardLegacyPending` on
860
745
  // UserPromptSubmit. Stop findings are never persisted for a future parent prompt.
861
746
 
@@ -78,7 +78,7 @@ export async function runDoctor() {
78
78
  if (!sidecar.ok) warnings += 1;
79
79
 
80
80
  const auditorContext = await inspectAuditorContextConfiguration({ projectRoot });
81
- mark(auditorContext.ok, `auditor context: ${auditorContext.mode}`, auditorContext.detail);
81
+ mark(auditorContext.ok, `evaluation context: ${auditorContext.mode}`, auditorContext.detail);
82
82
  if (!auditorContext.ok) warnings += 1;
83
83
  }
84
84
 
@@ -45,7 +45,7 @@ export async function runFactoryDiagnostics({
45
45
  if (contextMode === null) {
46
46
  checks.push(check('throughline_context', 'fail', 'invalid_context_configuration'));
47
47
  } else if (contextMode === 'disabled') {
48
- checks.push(check('throughline_context', 'skipped', 'context_disabled'));
48
+ checks.push(check('throughline_context', 'skipped', 'evaluation_evidence_disabled'));
49
49
  } else {
50
50
  const context = await inspectAuditorContextFn({ projectRoot });
51
51
  checks.push(context?.ok === true && context?.mode === 'throughline'
@@ -102,12 +102,12 @@ export async function runInstall({
102
102
  };
103
103
  await writeFile(markerPath, JSON.stringify(marker, null, 2) + '\n', 'utf8');
104
104
  console.log(` wrote ${markerPath}`);
105
- console.log(` auditor context: ${preservedAuditorContext.mode} (${preservedAuditorContext.origin})`);
105
+ console.log(` evaluation context provider: ${preservedAuditorContext.mode} (${preservedAuditorContext.origin})`);
106
106
  if (preservedAuditorContext.mode === 'throughline') {
107
- console.log(' sends bounded completed user/assistant text to the selected Codex auditor; disable with:');
107
+ console.log(' captures bounded proposal-time observer evidence; this never gates auditing; disable evidence capture with:');
108
108
  console.log(' spotter install -y --auditor-context disabled');
109
109
  } else if (preservedAuditorContext.reason === 'throughline_unavailable') {
110
- console.log(' Throughline was not found; context auditing remains disabled (no current-only fallback)');
110
+ console.log(' Throughline was not found; proposal-time observer evidence is unavailable, but auditing remains active');
111
111
  }
112
112
  }
113
113
 
@@ -1,45 +1,33 @@
1
- import { execFile } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
1
  import { realpath } from 'node:fs/promises';
4
2
  import { isAbsolute } from 'node:path';
5
- import { promisify } from 'node:util';
6
3
 
7
4
  import {
8
5
  DEFAULT_AUDITOR_CONTEXT_MAX_BUFFER,
9
6
  DEFAULT_AUDITOR_CONTEXT_TIMEOUT_MS,
7
+ loadAuditorContext,
10
8
  readProjectAuditorContextConfig,
11
9
  } from './auditor-context.mjs';
12
10
 
13
- export const THROUGHLINE_OBSERVER_READ_SCHEMA = 'throughline.observer_read.v1';
14
- export const EVALUATION_OBSERVER_CONTEXT_AVAILABLE = 'context_available';
15
- export const EVALUATION_OBSERVER_CONTEXT_UNAVAILABLE = 'context_unavailable';
16
- export const DEFAULT_EVALUATION_OBSERVER_LIMIT = 10;
17
-
18
- const execFileAsync = promisify(execFile);
19
- const UNAVAILABLE_STATUSES = new Set([
20
- 'projection_pending',
21
- 'ambiguous_parent',
22
- 'resync_required',
23
- 'error',
24
- ]);
11
+ export const EVALUATION_CONTEXT_AVAILABLE = 'context_available';
12
+ export const EVALUATION_CONTEXT_UNAVAILABLE = 'context_unavailable';
25
13
 
26
14
  /**
27
- * Reads one bounded, proposal-time Throughline observer snapshot.
28
- *
29
- * This is deliberately separate from auditor-context.mjs: the returned snapshot
30
- * is evaluation evidence and must never become auditor input.
15
+ * Reads the exact-session Throughline context used only as proposal-time
16
+ * evaluation evidence. It must never become auditor input.
31
17
  */
32
- export async function loadEvaluationObserverContext({
18
+ export async function loadEvaluationContext({
33
19
  projectRoot,
34
20
  host,
35
21
  sessionId,
22
+ transcriptPath,
36
23
  config,
37
24
  recordedAtMs = Date.now(),
38
25
  timeoutMs = DEFAULT_AUDITOR_CONTEXT_TIMEOUT_MS,
39
26
  maxBuffer = DEFAULT_AUDITOR_CONTEXT_MAX_BUFFER,
40
- execFileFn = execFileAsync,
27
+ execFileFn,
41
28
  realpathFn = realpath,
42
29
  readConfigFn = readProjectAuditorContextConfig,
30
+ loadAuditorContextFn = loadAuditorContext,
43
31
  } = {}) {
44
32
  if (!Number.isSafeInteger(recordedAtMs) || recordedAtMs < 0) {
45
33
  throw new TypeError('recordedAtMs must be a non-negative safe integer');
@@ -56,6 +44,9 @@ export async function loadEvaluationObserverContext({
56
44
  if (host === 'codex' && sessionId === 'codex:') {
57
45
  throw new TypeError('sessionId must identify a thread');
58
46
  }
47
+ if (typeof transcriptPath !== 'string' || transcriptPath.length === 0 || !isAbsoluteProjectPath(transcriptPath)) {
48
+ throw new TypeError('transcriptPath must be an absolute path');
49
+ }
59
50
 
60
51
  let canonicalProjectRoot;
61
52
  try {
@@ -75,110 +66,42 @@ export async function loadEvaluationObserverContext({
75
66
  if (effectiveConfig?.mode !== 'throughline') {
76
67
  return unavailableResult(recordedAtMs, 'provider_disabled');
77
68
  }
78
- if (!isDirectCommandConfig(effectiveConfig)) {
79
- return unavailableResult(recordedAtMs, 'config_unavailable');
80
- }
81
-
82
- let stdout;
83
- try {
84
- ({ stdout } = await execFileFn(effectiveConfig.command, [
85
- ...effectiveConfig.args,
86
- 'observer-read',
87
- '--project', canonicalProjectRoot,
88
- '--limit', String(DEFAULT_EVALUATION_OBSERVER_LIMIT),
89
- '--json',
90
- ], {
91
- encoding: 'utf8',
92
- timeout: timeoutMs,
93
- maxBuffer,
94
- windowsHide: true,
95
- shell: false,
96
- }));
97
- } catch {
98
- return unavailableResult(recordedAtMs, 'observer_read_failed');
99
- }
100
69
 
101
70
  let snapshot;
102
71
  try {
103
- snapshot = JSON.parse(stdout);
72
+ snapshot = await loadAuditorContextFn({
73
+ config: effectiveConfig,
74
+ host,
75
+ sessionId,
76
+ projectRoot: canonicalProjectRoot,
77
+ transcriptPath,
78
+ timeoutMs,
79
+ maxBuffer,
80
+ ...(execFileFn === undefined ? {} : { execFileFn }),
81
+ });
104
82
  } catch {
105
- return unavailableResult(recordedAtMs, 'observer_read_invalid');
83
+ return unavailableResult(recordedAtMs, 'auditor_context_failed');
106
84
  }
107
85
 
108
- if (UNAVAILABLE_STATUSES.has(snapshot?.status)) {
109
- return unavailableResult(recordedAtMs, `observer_${snapshot.status}`);
110
- }
111
- if (!isObserverSnapshot(snapshot)) {
112
- return unavailableResult(recordedAtMs, 'observer_read_invalid');
113
- }
114
- if (snapshot.host !== null && snapshot.host !== host) {
115
- return unavailableResult(recordedAtMs, 'observer_host_mismatch');
116
- }
117
- if (snapshot.thread_sha256 !== null && snapshot.thread_sha256 !== expectedThreadHash(host, sessionId)) {
118
- return unavailableResult(recordedAtMs, 'observer_session_mismatch');
86
+ if (snapshot.status !== 'fresh') {
87
+ return unavailableResult(recordedAtMs, `auditor_context_${snapshot.status}`);
119
88
  }
120
89
  return Object.freeze({
121
- status: EVALUATION_OBSERVER_CONTEXT_AVAILABLE,
90
+ status: EVALUATION_CONTEXT_AVAILABLE,
122
91
  recordedAtMs,
123
- snapshot: freezeSnapshot(snapshot),
92
+ snapshot,
124
93
  });
125
94
  }
126
95
 
127
96
  function unavailableResult(recordedAtMs, reason) {
128
97
  return Object.freeze({
129
- status: EVALUATION_OBSERVER_CONTEXT_UNAVAILABLE,
98
+ status: EVALUATION_CONTEXT_UNAVAILABLE,
130
99
  recordedAtMs,
131
100
  reason,
132
101
  snapshot: null,
133
102
  });
134
103
  }
135
104
 
136
- function isDirectCommandConfig(config) {
137
- return config && config.mode === 'throughline' &&
138
- isAbsoluteProjectPath(config.command) &&
139
- !/\.(?:cmd|bat)$/i.test(config.command) &&
140
- Array.isArray(config.args) &&
141
- config.args.every((arg) => typeof arg === 'string' && arg.length > 0);
142
- }
143
-
144
105
  function isAbsoluteProjectPath(value) {
145
106
  return isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value);
146
107
  }
147
-
148
- function isObserverSnapshot(value) {
149
- if (!isRecord(value) || value.schema !== THROUGHLINE_OBSERVER_READ_SCHEMA || value.status !== 'snapshot') return false;
150
- if (!Array.isArray(value.turns) || value.turns.length > DEFAULT_EVALUATION_OBSERVER_LIMIT) return false;
151
- if (typeof value.historyTruncated !== 'boolean' || !isNullableString(value.afterCursor) || !isNullableString(value.throughCursor)) return false;
152
- if (!isRecord(value.page) || typeof value.page.complete !== 'boolean' || !isNullableString(value.page.nextToken)) return false;
153
- if (value.host === null) return value.thread_sha256 === null && value.turns.length === 0;
154
- if ((value.host !== 'claude' && value.host !== 'codex') || !/^[a-f0-9]{64}$/.test(value.thread_sha256)) return false;
155
- return value.turns.every((turn) => isRecord(turn) &&
156
- turn.host === value.host && turn.thread_sha256 === value.thread_sha256);
157
- }
158
-
159
- // Throughline observer-read hashes the Claude session ID directly. Codex hook
160
- // session IDs may already carry Throughline's `codex:` namespace, while the
161
- // observer feed hashes the underlying Codex thread ID.
162
- function expectedThreadHash(host, sessionId) {
163
- const threadId = host === 'codex' && sessionId.startsWith('codex:')
164
- ? sessionId.slice('codex:'.length)
165
- : sessionId;
166
- if (threadId.length === 0) throw new TypeError('sessionId must identify a thread');
167
- return createHash('sha256').update(threadId, 'utf8').digest('hex');
168
- }
169
-
170
- function freezeSnapshot(snapshot) {
171
- return Object.freeze({
172
- ...snapshot,
173
- turns: Object.freeze(snapshot.turns.map((turn) => Object.freeze({ ...turn }))),
174
- page: Object.freeze({ ...snapshot.page }),
175
- });
176
- }
177
-
178
- function isNullableString(value) {
179
- return value === null || typeof value === 'string';
180
- }
181
-
182
- function isRecord(value) {
183
- return value !== null && typeof value === 'object' && !Array.isArray(value);
184
- }
@@ -277,30 +277,7 @@ export async function startDaemon({
277
277
  ? payload.observation_id
278
278
  : null;
279
279
 
280
- if (payload.audit === false) {
281
- logFn('user_input: audit skipped because fresh context was unavailable');
282
- return { pass: true, missing_tools: [], reason: 'auditor_context_not_fresh' };
283
- }
284
-
285
- const hasContext = payload.context_status !== undefined || payload.recent_context !== undefined;
286
- if (hasContext && (payload.context_status !== 'fresh' || !Array.isArray(payload.recent_context))) {
287
- const err = new Error('context-bearing user_input payload must include fresh recent_context');
288
- err.code = 'E_AUDITOR_CONTEXT_INPUT';
289
- throw err;
290
- }
291
- if (hasContext && auditorBackend.name === 'haiku') {
292
- const err = new Error('recent conversation context is not supported by the haiku auditor backend');
293
- err.code = 'E_AUDITOR_CONTEXT_BACKEND_UNSUPPORTED';
294
- throw err;
295
- }
296
- const judgment = await runAuditorJudgment(hasContext
297
- ? {
298
- stage: 'user_input',
299
- userInput,
300
- recentContext: payload.recent_context,
301
- contextStatus: 'fresh',
302
- }
303
- : { stage: 'user_input', userInput });
280
+ const judgment = await runAuditorJudgment({ stage: 'user_input', userInput });
304
281
  const result = legacyResultFromJudgment(judgment);
305
282
  const meta = judgment.meta ?? {};
306
283
  logFn(
@@ -25,13 +25,9 @@ import { sendRequest, TransportError } from '../daemon/transport.mjs';
25
25
  import { spawnDaemonAndWaitReady } from './spawn-daemon.mjs';
26
26
  import { discardLegacyPending } from './pending-context.mjs';
27
27
  import { projectBackendFailure, projectParentAdvice, projectToolIds } from './parent-output-projector.mjs';
28
- import {
29
- loadAuditorContext,
30
- readProjectAuditorContextConfig,
31
- } from '../core/auditor-context.mjs';
32
28
  import { randomUUID } from 'node:crypto';
33
29
  import { createEvaluationStore } from '../core/evaluation-store.mjs';
34
- import { loadEvaluationObserverContext } from '../core/evaluation-context.mjs';
30
+ import { loadEvaluationContext } from '../core/evaluation-context.mjs';
35
31
  import { version } from '../version.mjs';
36
32
 
37
33
  const TIMEOUT_MS = 50_000;
@@ -41,11 +37,9 @@ export async function runUserPrompt({
41
37
  sendRequestFn = sendRequest,
42
38
  spawnDaemonAndWaitReadyFn = spawnDaemonAndWaitReady,
43
39
  discardLegacyPendingFn = discardLegacyPending,
44
- readAuditorContextConfigFn = readProjectAuditorContextConfig,
45
- loadAuditorContextFn = loadAuditorContext,
46
40
  recordHookEventFn = recordClaudeHookEvent,
47
41
  createEvaluationStoreFn = createEvaluationStore,
48
- loadEvaluationObserverContextFn = loadEvaluationObserverContext,
42
+ loadEvaluationContextFn = loadEvaluationContext,
49
43
  randomUUIDFn = randomUUID,
50
44
  spotterVersion = version,
51
45
  now = Date.now,
@@ -62,22 +56,21 @@ export async function runUserPrompt({
62
56
  const observationId = randomUUIDFn();
63
57
  const projectRoot = findSpotterMarker(input.cwd);
64
58
  const startedAt = Date.now();
65
- let contextDurationMs = null;
66
59
  let evaluationWriteFailed = false;
67
60
 
68
- const safelyRecordEvaluation = async ({ auditStatus, result = null, auditContext = null }) => {
61
+ const safelyRecordEvaluation = async ({ auditStatus, result = null }) => {
69
62
  if (!projectRoot) return;
70
63
  try {
71
64
  const proposedToolIds = auditStatus === 'success' && result?.pass !== true
72
65
  ? projectToolIds(Array.isArray(result?.missing_tools) ? result.missing_tools.map((entry) => entry?.name) : [])
73
66
  : [];
74
67
  const proposalRecordedAtMs = proposedToolIds.length > 0 ? now() : null;
75
- const observer = proposedToolIds.length > 0
76
- ? await loadEvaluationObserverContextFn({
68
+ const evaluationContext = proposedToolIds.length > 0
69
+ ? await loadEvaluationContextFn({
77
70
  projectRoot,
78
71
  host: 'claude',
79
72
  sessionId,
80
- config: auditContext?.config,
73
+ transcriptPath: input.transcript_path,
81
74
  recordedAtMs: proposalRecordedAtMs,
82
75
  })
83
76
  : { status: 'not_requested', snapshot: null };
@@ -94,9 +87,9 @@ export async function runUserPrompt({
94
87
  sessionId,
95
88
  auditStatus,
96
89
  requestText: prompt,
97
- auditorSeenContext: auditContext?.turns ? JSON.stringify(auditContext.turns) : null,
98
- observerContextStatus: observer.status,
99
- observerSnapshot: observer.snapshot,
90
+ auditorSeenContext: null,
91
+ observerContextStatus: evaluationContext.status,
92
+ observerSnapshot: evaluationContext.snapshot,
100
93
  proposedToolIds,
101
94
  backend: result?.evaluation_meta?.backend ?? null,
102
95
  model: result?.evaluation_meta?.model ?? null,
@@ -128,7 +121,6 @@ export async function runUserPrompt({
128
121
  status: 'degraded',
129
122
  code: failure.code,
130
123
  reason,
131
- contextDurationMs,
132
124
  legacyPendingDiagnostic: legacyPending.diagnostic,
133
125
  durationMs: Date.now() - startedAt,
134
126
  },
@@ -151,108 +143,12 @@ export async function runUserPrompt({
151
143
  }
152
144
  };
153
145
 
154
- const syncUserInputWithoutAudit = async (contextStatus) => {
155
- try {
156
- const response = await sendDaemonPayload({
157
- user_input: prompt,
158
- observation_id: observationId,
159
- audit: false,
160
- context_status: contextStatus,
161
- });
162
- if (response.ok !== true) {
163
- await degrade({ code: response.error?.code ?? 'E_INTERNAL', reason: 'daemon_state_sync' });
164
- return false;
165
- }
166
- return true;
167
- } catch (err) {
168
- await degrade({ code: err?.code, reason: 'daemon_state_sync' });
169
- return false;
170
- }
171
- };
172
-
173
- let context;
174
- let auditorContextConfig;
175
- const contextStartedAt = Date.now();
176
- try {
177
- const config = await readAuditorContextConfigFn(projectRoot);
178
- auditorContextConfig = config;
179
- contextDurationMs = Date.now() - contextStartedAt;
180
- if (config.mode === 'disabled') {
181
- if (!await syncUserInputWithoutAudit('disabled')) {
182
- await safelyRecordEvaluation({ auditStatus: 'error' });
183
- return;
184
- }
185
- await safelyRecordEvaluation({ auditStatus: 'skipped' });
186
- await recordHookEventFn({
187
- projectRoot,
188
- event: {
189
- hook: 'UserPromptSubmit',
190
- status: 'skipped',
191
- reason: 'context_disabled',
192
- contextStatus: 'disabled',
193
- contextDurationMs,
194
- legacyPendingDiagnostic: legacyPending.diagnostic,
195
- durationMs: Date.now() - startedAt,
196
- },
197
- });
198
- return;
199
- }
200
- context = await loadAuditorContextFn({
201
- config,
202
- host: 'claude',
203
- sessionId,
204
- projectRoot,
205
- transcriptPath: requireString(input, 'transcript_path'),
206
- });
207
- contextDurationMs = Date.now() - contextStartedAt;
208
- } catch (err) {
209
- contextDurationMs = Date.now() - contextStartedAt;
210
- await degrade({ code: err?.code, reason: 'auditor_context' });
211
- await safelyRecordEvaluation({ auditStatus: 'error' });
212
- return;
213
- }
214
-
215
- if (context.status === 'unavailable' || context.status === 'schema_mismatch') {
216
- await degrade({
217
- code: context.status === 'unavailable'
218
- ? 'E_AUDITOR_CONTEXT_UNAVAILABLE'
219
- : 'E_AUDITOR_CONTEXT_SCHEMA',
220
- reason: 'auditor_context_status',
221
- });
222
- await safelyRecordEvaluation({ auditStatus: 'error' });
223
- return;
224
- }
225
- if (context.status !== 'fresh') {
226
- if (!await syncUserInputWithoutAudit(context.status)) {
227
- await safelyRecordEvaluation({ auditStatus: 'error' });
228
- return;
229
- }
230
- await safelyRecordEvaluation({ auditStatus: 'skipped' });
231
- await recordHookEventFn({
232
- projectRoot,
233
- event: {
234
- hook: 'UserPromptSubmit',
235
- status: 'skipped',
236
- reason: 'context_not_fresh',
237
- contextStatus: context.status,
238
- contextTurns: 0,
239
- contextChars: 0,
240
- contextDurationMs,
241
- legacyPendingDiagnostic: legacyPending.diagnostic,
242
- durationMs: Date.now() - startedAt,
243
- },
244
- });
245
- return;
246
- }
247
-
248
146
  let response;
249
147
  try {
250
148
  response = await sendDaemonPayload({
251
149
  user_input: prompt,
252
150
  observation_id: observationId,
253
151
  audit: true,
254
- context_status: 'fresh',
255
- recent_context: context.turns,
256
152
  });
257
153
  } catch (err) {
258
154
  await degrade({ code: err?.code ?? 'E_RESURRECT_FAILED', reason: 'transport_or_resurrect' });
@@ -278,7 +174,6 @@ export async function runUserPrompt({
278
174
  await safelyRecordEvaluation({
279
175
  auditStatus: 'success',
280
176
  result,
281
- auditContext: { config: auditorContextConfig, turns: context.turns },
282
177
  });
283
178
  if (advice) emitAdditionalContext(writeOutput, advice);
284
179
 
@@ -289,10 +184,6 @@ export async function runUserPrompt({
289
184
  status: 'success',
290
185
  pass: result.pass === true,
291
186
  missingTools: toolIds,
292
- contextStatus: 'fresh',
293
- contextTurns: context.stats.returnedTurns,
294
- contextChars: context.stats.chars,
295
- contextDurationMs,
296
187
  legacyPendingDiagnostic: legacyPending.diagnostic,
297
188
  durationMs: Date.now() - startedAt,
298
189
  },