claude-spotter 1.4.28 → 1.4.29

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,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.29 — 2026-08-04
4
+
5
+ - **提案率とtool採用率の端末内評価を追加。** Claude / Codexの成功UserPromptSubmitを母数に、
6
+ safe projector後に実際に提示したtool itemと、同じturnでのcanonical tool利用を
7
+ `~/.spotter/evaluation.db`へ記録する。採用率の母数はoutcome確定itemだけとし、利用記録不全は
8
+ `outcome_missing`へ分離する。組込みtoolは評価対象外で、既存turn-end auditorのraw usedToolsは変更しない。
9
+ - **提案時の別文脈を保存。** proposal確定時刻を記録し、既存Throughline `observer-read`を一度だけ呼ぶ。
10
+ Spotterがauditorへ渡したcontextとは別fieldに保存し、失敗時はcontextだけunavailableとしてretryしない。
11
+ 返却されたhost / thread hashを提案元sessionと照合し、同じprojectの並行sessionを取り違えた場合も
12
+ snapshotを保存しない。
13
+ - **欠測とtool identityを率から分離。** Claudeの利用記録失敗、Codex transcript不全、Stopが30分以上
14
+ 欠落したopen proposalは`outcome_missing`として投影し、非採用へ混ぜない。Codex Skillは提案済みの
15
+ 正規`SKILL.md`を実際にreadしたcallだけを採用として認識する。現行Codexのouter `exec`内で実行される
16
+ nested MCP callと、同じturn内の複数`exec`も個別の利用入力として保持する。
17
+ - **初回同時openを直列化。** 2 projectが未作成の評価DBを同時に開いてもWAL設定前に
18
+ `database is locked`とならないよう、bounded busy timeoutをschema初期化より先に有効化する。
19
+ retry、lockfile、background回収は追加しない。
20
+ - **評価CLIを追加。** `spotter evaluation report`で全project / project / tool / host別の
21
+ `S/P/I/C/A/M`、`P/S`、`A/C`を表示し、`cases` / `case`で非採用caseのrequest、2種類の文脈、
22
+ 提案ID、利用ID、outcomeを確認できる。reportは保存済みSQLiteだけを読み、JSONはWindows
23
+ PowerShell 5.1でも壊れないASCII-safe形式で出力する。
24
+ - **検証・公開。** 固定fixture、Claude / Codex hook、Throughline束縛、2 process同時writeを含む
25
+ 568 tests(macOS / Linuxは566 pass / 2 skip、Windowsは556 pass / 12 platform skip)、
26
+ macOS / Linux / Windows × Node 22.13 / 22.xのCI run `30913375991`、75-file packを通過した。
27
+ 別projectの実Codex turnではThroughline文脈付きproposalから
28
+ `S=1 P=1 I=1 C=1 A=1 M=0`を記録し、Claude実turnのpass観測と両hostのproposal fixtureも確認した。
29
+ Windowsではouter `exec`内の絶対pathを壊さずSkill採用へ変換する回帰も固定した。
30
+
3
31
  ## 1.4.28 — 2026-07-21
4
32
 
5
33
  - **Windows Codex SessionStartの誤timeoutを修正。** detached refreshを起動するSpotter所有hookの
package/README.ja.md CHANGED
@@ -13,6 +13,16 @@
13
13
 
14
14
  > **気づく役と実行する役を分離する。** Claude Code の横で並走し、主役の Claude が**ツールを呼び忘れたとき**だけ静かに指摘する監査役。
15
15
 
16
+ [kitepon.dev](https://kitepon.dev/)を運営する[クオ(@QLyun35332)](https://x.com/QLyun35332)が
17
+ 開発・メンテナンスしています。
18
+
19
+ ## 所有境界
20
+
21
+ 本repositoryは監査挙動、project marker、installer、release、diagnosticsを所有します。
22
+ 製品横断のcatalogとhost統合は、kitepon.devの製品開発を支える内部基盤
23
+ [dotagents](https://github.com/kitepon-rgb/dotagents)が担当します。
24
+ MarkItDownは別区分の第三者CLIです。
25
+
16
26
  Claude には「使えるツールがあるのに、使うべきタイミングで使わない」という構造的な弱点があります。記録すべき決定を memory / caveat MCP に残さない、docs lookup MCP を呼ばずに古い知識で応答する、ブラウザ自動化 MCP で確認せず UI 状態を推測する — **「分からないと自覚できない」から、ツールを取りに行けない**。
17
27
 
18
28
  Spotter はツールカタログを完全に把握した別の監査エージェントで、ユーザー入力と主役 AI の応答を並走監査します。自動選択では Claude host は Codex CLI があればそれを、なければ session-scoped Haiku を選び、Codex host は Codex CLI を既定にします。明示 backend override は host より優先しますが、runtime failure で別 backend へ黙って切り替えません。応答前は検証済みtool IDだけを固定・非命令形の助言へ変換でき、応答後のfindingは構造eventに留めて後続turnへ注入しません。監査用AIの自由文が親セッションへ入ることはありません。**主役 AI が自覚して自己監査する**設計は本プロダクトの存在意義を破壊するため、hook 経由でその意思と独立に検出します。
@@ -207,6 +217,12 @@ spotter db rebuild # Claude local + Claude global DB を両方消してか
207
217
  spotter status # 稼働中の daemon 一覧
208
218
  spotter doctor # 環境診断 (Node / claude CLI / Codex readiness / tool-db 整合性)
209
219
  spotter diagnostics logs # daemon log から pass=false / backend latency / anomaly signal を集計
220
+ spotter evaluation report
221
+ # 端末内DBからproject横断の提案率・tool採用率を集計
222
+ spotter evaluation cases --outcome not-adopted
223
+ # 提案されたが同じturnで使われなかったtool itemを一覧
224
+ spotter evaluation case <observation-id>
225
+ # request、2種類の文脈、提案、利用、outcomeを確認
210
226
  spotter codex risk-check --findings findings.json --host-agent claude
211
227
  # Spotter finding を codex-sidecar に渡して read-only risk analysis
212
228
  spotter codex review|explore|opinion --findings findings.json --host-agent claude
package/README.md CHANGED
@@ -13,6 +13,16 @@
13
13
 
14
14
  > **Separate the spotter from the doer.** Spotter runs alongside Claude Code and quietly flags the moments when your primary Claude **forgets to use a tool it has access to**.
15
15
 
16
+ Built and maintained by [Quo](https://x.com/QLyun35332) at [kitepon.dev](https://kitepon.dev/en).
17
+
18
+ ## Ownership boundary
19
+
20
+ This repository owns auditor behavior, project markers, installers, release,
21
+ and diagnostics. The cross-product catalog and host integration are handled by
22
+ [dotagents](https://github.com/kitepon-rgb/dotagents), the internal development
23
+ toolchain behind kitepon.dev's products.
24
+ MarkItDown is managed separately as a third-party CLI.
25
+
16
26
  Claude has a structural blind spot: **it can't reach for a tool it doesn't realize it needs**. It may skip a project memory MCP when a decision should be recorded, answer from stale memory instead of a docs-lookup MCP, or reason about UI state without a browser-automation MCP. The model can't always tell when it doesn't know — so the tool stays unused.
17
27
 
18
28
  Spotter runs a separate auditor with the full tool catalog and checks both the user's prompt and the primary agent's reply. Automatic selection uses Codex CLI on a Claude host when available, otherwise the session-scoped Haiku path; on a Codex host it defaults to Codex CLI. An explicit backend override takes precedence, but a runtime failure never silently switches backend. Before the primary reply, validated tool IDs may become fixed, non-directive advice; after the reply, findings remain structured events and are not injected into a later turn. Auditor prose never enters the parent session. **The primary agent is never asked to self-audit** — that would defeat the premise. Detection happens through hooks, independent of the primary agent's intent.
@@ -216,6 +226,12 @@ spotter doctor # environment check (Node / claude CLI / Codex readines
216
226
  spotter diagnostics logs # summarize daemon logs for pass=false / backend latency / anomaly signals
217
227
  spotter diagnostics runtime-errors
218
228
  # print the local allow-listed runtime-error aggregate snapshot (no network)
229
+ spotter evaluation report
230
+ # show cross-project proposal/adoption counts and rates from the local DB
231
+ spotter evaluation cases --outcome not-adopted
232
+ # list proposed tools that were not used in the same turn
233
+ spotter evaluation case <observation-id>
234
+ # inspect request, Spotter context, Throughline snapshot, proposal, usage, and outcome
219
235
  spotter codex risk-check --findings findings.json --host-agent claude
220
236
  # run read-only codex-sidecar risk analysis for Spotter findings
221
237
  spotter codex review|explore|opinion --findings findings.json --host-agent claude
package/bin/spotter.mjs CHANGED
@@ -11,6 +11,7 @@ import { runCodexCommand } from '../src/cli/codex-cmd.mjs';
11
11
  import { runCodexHookCommand } from '../src/cli/codex-hook-cmd.mjs';
12
12
  import { runAuditorCommand } from '../src/cli/auditor-cmd.mjs';
13
13
  import { runDiagnosticsCommand } from '../src/cli/diagnostics-cmd.mjs';
14
+ import { runEvaluationCommand } from '../src/cli/evaluation-cmd.mjs';
14
15
  import { runFactoryDiagnostics } from '../src/cli/factory-diagnostics.mjs';
15
16
  import { runDaemonStart } from '../src/cli/daemon-cmd.mjs';
16
17
  import { runSessionStart } from '../src/hooks/session-start.mjs';
@@ -45,6 +46,10 @@ Usage:
45
46
  spotter diagnostics logs [--json] summarize daemon logs for precision diagnostics
46
47
  spotter diagnostics runtime-errors [snapshot|ack|resolve|reopen|compact]
47
48
  consume the local allow-listed aggregate store
49
+ spotter evaluation report [filters] [--json]
50
+ spotter evaluation cases --outcome OUTCOME [filters] [--json]
51
+ spotter evaluation case OBSERVATION_ID [--json]
52
+ read saved proposal-adoption observations
48
53
  spotter codex risk-check --findings FILE
49
54
  run read-only codex-sidecar risk analysis
50
55
  spotter codex review|explore|opinion --findings FILE
@@ -124,6 +129,9 @@ async function main() {
124
129
  }
125
130
  await runDiagnosticsCommand({ argv: rest });
126
131
  return;
132
+ case 'evaluation':
133
+ await runEvaluationCommand({ argv: rest });
134
+ return;
127
135
  case 'daemon': {
128
136
  const sub = rest[0];
129
137
  if (sub === 'start') { await runDaemonStart({ argv: rest.slice(1) }); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.4.28",
3
+ "version": "1.4.29",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,8 @@
11
11
  },
12
12
  "scripts": {
13
13
  "test": "node --test",
14
+ "verify:release-commit": "node scripts/verify-release-commit.mjs",
15
+ "prepublishOnly": "npm run verify:release-commit && npm test",
14
16
  "postinstall": "node scripts/postinstall.mjs",
15
17
  "preuninstall": "node scripts/preuninstall.mjs"
16
18
  },
@@ -23,7 +25,10 @@
23
25
  "audit",
24
26
  "spotter"
25
27
  ],
26
- "author": "kitepon",
28
+ "author": {
29
+ "name": "Quo / クオ at kitepon.dev",
30
+ "url": "https://kitepon.dev/"
31
+ },
27
32
  "license": "MIT",
28
33
  "repository": {
29
34
  "type": "git",
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+
3
+ // publishする成果が、既定ブランチへ着地済みのcommitから作られていることを保証する。
4
+ // 着地していないbranchからpublishすると、そのbranchが取り残された時点で公開物が
5
+ // 後続releaseから消え、統合契約だけが存在しない面を指し続ける(0.3.0の孤児releaseが実例)。
6
+
7
+ import assert from "node:assert/strict";
8
+ import { spawnSync } from "node:child_process";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const scriptPath = fileURLToPath(import.meta.url);
13
+ const defaultProjectDirectory = path.dirname(path.dirname(scriptPath));
14
+
15
+ export function verifyReleaseCommit({
16
+ projectDirectory = defaultProjectDirectory,
17
+ spawn = spawnSync,
18
+ } = {}) {
19
+ const git = (...args) => {
20
+ const result = spawn("git", args, { cwd: projectDirectory, encoding: "utf8" });
21
+ return { ok: result.status === 0, stdout: (result.stdout ?? "").trim() };
22
+ };
23
+
24
+ const head = git("rev-parse", "HEAD");
25
+ assert.ok(head.ok, "git HEAD を解決できません");
26
+ const originHead = git("symbolic-ref", "--quiet", "refs/remotes/origin/HEAD");
27
+ const defaultRef = originHead.ok && originHead.stdout
28
+ ? originHead.stdout.replace("refs/remotes/", "")
29
+ : "origin/main";
30
+ const defaultResolved = git("rev-parse", "--verify", `${defaultRef}^{commit}`);
31
+ assert.ok(defaultResolved.ok, `既定ブランチ ${defaultRef} を解決できません。git fetch origin を先に実行してください。`);
32
+ const isAncestor = git("merge-base", "--is-ancestor", head.stdout, defaultRef);
33
+ assert.ok(
34
+ isAncestor.ok,
35
+ `publish対象 ${head.stdout.slice(0, 12)} が ${defaultRef} の祖先ではありません。`
36
+ + " 先に既定ブランチへ着地させてpushしてから publish してください。"
37
+ );
38
+ const dirty = git("status", "--porcelain", "--untracked-files=normal");
39
+ assert.ok(dirty.ok, "working treeの状態を確認できません");
40
+ assert.equal(
41
+ dirty.stdout,
42
+ "",
43
+ `working treeに未commitの変更があります。publish対象commitとpayloadが一致しません:\n${dirty.stdout}`
44
+ );
45
+ return `release commit ${head.stdout.slice(0, 12)} is landed on ${defaultRef}.`;
46
+ }
47
+
48
+ if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
49
+ console.log(verifyReleaseCommit());
50
+ }
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+
8
+ import { verifyReleaseCommit } from "./verify-release-commit.mjs";
9
+
10
+ function git(cwd, ...args) {
11
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
12
+ }
13
+
14
+ async function createReleaseFixture(t) {
15
+ const root = await mkdtemp(path.join(tmpdir(), "spotter-release-gate-"));
16
+ const remote = path.join(root, "origin.git");
17
+ const work = path.join(root, "work");
18
+ t.after(async () => rm(root, { recursive: true, force: true }));
19
+ await mkdir(work);
20
+ git(root, "init", "--bare", remote);
21
+ git(work, "init", "--initial-branch=main");
22
+ git(work, "config", "user.name", "Spotter release gate test");
23
+ git(work, "config", "user.email", "spotter-release-gate@example.invalid");
24
+ await writeFile(path.join(work, ".gitignore"), "ignored.tmp\n", "utf8");
25
+ await writeFile(path.join(work, "tracked.txt"), "tracked\n", "utf8");
26
+ git(work, "add", ".gitignore", "tracked.txt");
27
+ git(work, "commit", "-m", "fixture");
28
+ git(work, "remote", "add", "origin", remote);
29
+ git(work, "push", "--set-upstream", "origin", "main");
30
+ return work;
31
+ }
32
+
33
+ test("release gate passes for a commit landed on main", async (t) => {
34
+ const work = await createReleaseFixture(t);
35
+ assert.match(verifyReleaseCommit({ projectDirectory: work }), /landed on origin\/main/);
36
+ });
37
+
38
+ test("release gate rejects an untracked publish payload", async (t) => {
39
+ const work = await createReleaseFixture(t);
40
+ await writeFile(path.join(work, "untracked.txt"), "must block publish\n", "utf8");
41
+ assert.throws(() => verifyReleaseCommit({ projectDirectory: work }), /working treeに未commitの変更があります/);
42
+ });
43
+
44
+ test("release gate permits ignored generated output", async (t) => {
45
+ const work = await createReleaseFixture(t);
46
+ await writeFile(path.join(work, "ignored.tmp"), "generated\n", "utf8");
47
+ assert.match(verifyReleaseCommit({ projectDirectory: work }), /landed on origin\/main/);
48
+ });
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { existsSync, realpathSync } from 'node:fs';
3
4
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
5
  import { homedir } from 'node:os';
@@ -34,6 +35,15 @@ import {
34
35
  readProjectAuditorContextConfig,
35
36
  } from '../core/auditor-context.mjs';
36
37
  import { observeRuntimeErrorIsolatedSafe } from '../core/runtime-error-store.mjs';
38
+ import { createEvaluationStore } from '../core/evaluation-store.mjs';
39
+ import { loadEvaluationObserverContext } from '../core/evaluation-context.mjs';
40
+ import {
41
+ canonicalizeCodexNestedMcpToolIds,
42
+ canonicalizeCodexSkillReadToolIds,
43
+ canonicalizeProposedToolIds,
44
+ canonicalizeUsedToolIds,
45
+ } from '../core/evaluation-tool-id.mjs';
46
+ import { version as SPOTTER_VERSION } from '../version.mjs';
37
47
 
38
48
  const HERE = dirname(fileURLToPath(import.meta.url));
39
49
  const PACKAGE_ROOT = resolve(HERE, '..', '..');
@@ -125,22 +135,77 @@ export async function runCodexUserPromptSubmitHook({
125
135
  writeOutput = (text) => process.stdout.write(text),
126
136
  writeError = (text) => process.stderr.write(text),
127
137
  runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
138
+ createEvaluationStoreFn = createEvaluationStore,
139
+ loadEvaluationObserverContextFn = loadEvaluationObserverContext,
140
+ randomUUIDFn = randomUUID,
141
+ now = () => Date.now(),
128
142
  } = {}) {
129
143
  if (isChildCall()) return;
130
144
  const input = await readInput();
145
+ const observationId = randomUUIDFn();
146
+ const recordedAtMs = now();
131
147
  const projectRoot = findSpotterMarker(input.cwd);
132
148
  if (!projectRoot) return;
133
149
  const startedAt = Date.now();
134
150
  let contextDurationMs = null;
151
+ let auditorContextConfig;
135
152
 
136
153
  const prompt = requireString(input, 'prompt');
154
+ const sessionId = codexSessionId(input);
155
+ const recordEvaluation = async ({ auditStatus, proposedToolIds = [], backend = null, model = null, auditorSeenContext = null, config = undefined }) => {
156
+ if (!sessionId) {
157
+ reportEvaluationFailure(writeError);
158
+ return;
159
+ }
160
+ const proposals = canonicalizeProposedToolIds(proposedToolIds);
161
+ let observerContext = { status: 'not_requested', snapshot: null };
162
+ let proposedAtMs = recordedAtMs;
163
+ try {
164
+ if (auditStatus === 'success' && proposals.resolvedToolIds.length > 0) {
165
+ proposedAtMs = now();
166
+ observerContext = await loadEvaluationObserverContextFn({
167
+ projectRoot,
168
+ host: 'codex',
169
+ sessionId,
170
+ config,
171
+ recordedAtMs: proposedAtMs,
172
+ });
173
+ }
174
+ const store = createEvaluationStoreFn();
175
+ try {
176
+ store.recordTurn({
177
+ observationId,
178
+ recordedAtMs: proposals.resolvedToolIds.length > 0 ? proposedAtMs : recordedAtMs,
179
+ proposedAtMs,
180
+ projectPath: projectRoot,
181
+ host: 'codex',
182
+ sessionId,
183
+ auditStatus,
184
+ requestText: prompt,
185
+ auditorSeenContext,
186
+ observerContextStatus: observerContext.status,
187
+ observerSnapshot: observerContext.snapshot,
188
+ proposedToolIds: proposals.resolvedToolIds,
189
+ backend,
190
+ model,
191
+ spotterVersion: SPOTTER_VERSION,
192
+ });
193
+ } finally {
194
+ store.close();
195
+ }
196
+ } catch {
197
+ reportEvaluationFailure(writeError);
198
+ }
199
+ };
137
200
  const legacyPending = await discardLegacyPendingFn({ projectRoot, sessionId: codexSessionId(input) });
138
201
  let context;
139
202
  const contextStartedAt = Date.now();
140
203
  try {
141
204
  const config = await readAuditorContextConfigFn(projectRoot);
205
+ auditorContextConfig = config;
142
206
  contextDurationMs = Date.now() - contextStartedAt;
143
207
  if (config.mode === 'disabled') {
208
+ await recordEvaluation({ auditStatus: 'skipped' });
144
209
  await recordCodexHookEventSafe(recordHookEventFn, {
145
210
  projectRoot,
146
211
  event: {
@@ -164,6 +229,7 @@ export async function runCodexUserPromptSubmitHook({
164
229
  });
165
230
  contextDurationMs = Date.now() - contextStartedAt;
166
231
  } catch (err) {
232
+ await recordEvaluation({ auditStatus: 'error' });
167
233
  await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
168
234
  contextDurationMs = Date.now() - contextStartedAt;
169
235
  const failure = projectBackendFailure(err?.code);
@@ -184,6 +250,7 @@ export async function runCodexUserPromptSubmitHook({
184
250
  return;
185
251
  }
186
252
  if (context.status === 'unavailable' || context.status === 'schema_mismatch') {
253
+ await recordEvaluation({ auditStatus: 'error' });
187
254
  await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
188
255
  const failure = projectBackendFailure(context.status === 'unavailable'
189
256
  ? 'E_AUDITOR_CONTEXT_UNAVAILABLE'
@@ -205,6 +272,7 @@ export async function runCodexUserPromptSubmitHook({
205
272
  return;
206
273
  }
207
274
  if (context.status !== 'fresh') {
275
+ await recordEvaluation({ auditStatus: 'skipped' });
208
276
  await recordCodexHookEventSafe(recordHookEventFn, {
209
277
  projectRoot,
210
278
  event: {
@@ -237,6 +305,7 @@ export async function runCodexUserPromptSubmitHook({
237
305
  contextStatus: 'fresh',
238
306
  });
239
307
  } catch (err) {
308
+ await recordEvaluation({ auditStatus: 'error', backend: err?.backend ?? null, model: err?.diagnostics?.modelSelection?.effectiveModel ?? null });
240
309
  if (enteredAuditorBoundary) await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
241
310
  const failure = projectBackendFailure(err?.code);
242
311
  safeWriteError(writeError, failure.stderr);
@@ -274,10 +343,23 @@ export async function runCodexUserPromptSubmitHook({
274
343
  },
275
344
  }, writeError);
276
345
  if (judgment.pass === true) {
346
+ await recordEvaluation({
347
+ auditStatus: 'success',
348
+ backend: judgment.meta?.backend ?? backend.name ?? 'unknown',
349
+ model: judgment.meta?.modelSelection?.effectiveModel ?? null,
350
+ });
277
351
  return;
278
352
  }
279
353
 
280
354
  const toolIds = projectToolIds(judgment.findings.map((finding) => finding.toolName));
355
+ await recordEvaluation({
356
+ auditStatus: 'success',
357
+ proposedToolIds: toolIds,
358
+ backend: judgment.meta?.backend ?? backend.name ?? 'unknown',
359
+ model: judgment.meta?.modelSelection?.effectiveModel ?? null,
360
+ auditorSeenContext: JSON.stringify(context.turns),
361
+ config: auditorContextConfig,
362
+ });
281
363
  const advice = projectParentAdvice(toolIds);
282
364
  if (advice) writeCodexUserPromptContexts({ contexts: [advice], writeOutput });
283
365
  }
@@ -291,20 +373,35 @@ export async function runCodexStopHook({
291
373
  writeOutput = (text) => process.stdout.write(text),
292
374
  writeError = (text) => process.stderr.write(text),
293
375
  runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
376
+ createEvaluationStoreFn = createEvaluationStore,
377
+ codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'),
378
+ now = () => Date.now(),
294
379
  } = {}) {
295
380
  if (isChildCall()) return;
296
381
  const input = await readInput();
297
- if (input.stop_hook_active === true) return;
298
382
  const projectRoot = findSpotterMarker(input.cwd);
299
383
  if (!projectRoot) return;
300
384
  const startedAt = Date.now();
301
385
  const reportError = (text) => safeWriteError(writeError, text);
386
+ if (input.stop_hook_active === true) {
387
+ await closeCodexEvaluationTurn({
388
+ createEvaluationStoreFn,
389
+ sessionId: codexSessionId(input),
390
+ usageStatus: 'incomplete',
391
+ completedAtMs: now(),
392
+ writeError: reportError,
393
+ });
394
+ return;
395
+ }
302
396
  const transcriptPath = requireString(input, 'transcript_path');
303
397
  const finalResponse = codexLastAssistantMessage(input) ?? '(no final response available)';
304
398
  let toolUsage;
305
399
  try {
306
400
  toolUsage = await readCodexToolUsageFn(transcriptPath);
307
401
  } catch (err) {
402
+ await closeCodexEvaluationTurn({
403
+ createEvaluationStoreFn, sessionId: codexSessionId(input), usageStatus: 'incomplete', completedAtMs: now(), writeError: reportError,
404
+ });
308
405
  const failure = projectBackendFailure(err?.code);
309
406
  reportError(failure.stderr);
310
407
  writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
@@ -322,6 +419,15 @@ export async function runCodexStopHook({
322
419
  return;
323
420
  }
324
421
  const usedTools = Array.isArray(toolUsage?.usedTools) ? toolUsage.usedTools : [];
422
+ await closeCodexEvaluationTurn({
423
+ createEvaluationStoreFn,
424
+ sessionId: codexSessionId(input),
425
+ toolUsage,
426
+ projectRoot,
427
+ codexHome,
428
+ completedAtMs: now(),
429
+ writeError: reportError,
430
+ });
325
431
  const toolUsageEvent = compactCodexToolUsageForEvent(toolUsage);
326
432
  if (toolUsageEvent.toolUsageAnomalyCount === 0
327
433
  && shouldSkipShortCodexStop({ finalResponse, usedTools, env: process.env })) {
@@ -777,6 +883,79 @@ function safeWriteError(writeError, text) {
777
883
  }
778
884
  }
779
885
 
886
+ function reportEvaluationFailure(writeError) {
887
+ safeWriteError(writeError, 'Spotter の評価記録に失敗しました。\n');
888
+ }
889
+
890
+ function isCompleteCodexUsage(toolUsage, canonicalUsage) {
891
+ if (!toolUsage || !Array.isArray(toolUsage.usedTools) || !Array.isArray(toolUsage.toolCalls)) return false;
892
+ if (toolUsage.scope !== 'current-turn') return false;
893
+ if (Array.isArray(toolUsage.anomalies) && toolUsage.anomalies.length > 0) return false;
894
+ return canonicalUsage.missingCount === 0;
895
+ }
896
+
897
+ function isCodexEvaluationUsage(toolName) {
898
+ return typeof toolName === 'string' && (
899
+ /^mcp__[A-Za-z0-9_-]+__/u.test(toolName)
900
+ || toolName === 'Skill'
901
+ || toolName === 'skill'
902
+ || /^(?:skills?|Skill)(?:__|\.)/u.test(toolName)
903
+ );
904
+ }
905
+
906
+ async function closeCodexEvaluationTurn({
907
+ createEvaluationStoreFn,
908
+ sessionId,
909
+ usedToolIds = [],
910
+ usageStatus,
911
+ toolUsage = null,
912
+ projectRoot = null,
913
+ codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'),
914
+ completedAtMs,
915
+ writeError,
916
+ }) {
917
+ if (!sessionId) {
918
+ reportEvaluationFailure(writeError);
919
+ return;
920
+ }
921
+ try {
922
+ const store = createEvaluationStoreFn();
923
+ try {
924
+ const row = store.database.prepare(`
925
+ SELECT observation_id FROM evaluation_turns
926
+ WHERE session_id = ? AND completed_at_ms IS NULL
927
+ ORDER BY recorded_at_ms DESC LIMIT 1
928
+ `).get(sessionId);
929
+ if (!row) return;
930
+ if (toolUsage) {
931
+ const proposals = store.database.prepare(`
932
+ SELECT tool_id FROM evaluation_items WHERE observation_id = ? ORDER BY tool_id
933
+ `).all(row.observation_id).map((item) => item.tool_id);
934
+ const allUsages = toolUsage.toolCalls ?? toolUsage.usedTools.map((toolName) => ({ toolName }));
935
+ const evaluationUsages = allUsages.filter((usage) => isCodexEvaluationUsage(usage?.toolName));
936
+ const canonicalUsage = canonicalizeUsedToolIds(evaluationUsages, { host: 'codex' });
937
+ const skillReads = await canonicalizeCodexSkillReadToolIds(allUsages, {
938
+ proposedToolIds: proposals,
939
+ projectRoot,
940
+ codexHome,
941
+ });
942
+ const nestedMcpCalls = canonicalizeCodexNestedMcpToolIds(allUsages);
943
+ usedToolIds = [...new Set([
944
+ ...canonicalUsage.resolvedToolIds,
945
+ ...nestedMcpCalls,
946
+ ...skillReads,
947
+ ])];
948
+ usageStatus = isCompleteCodexUsage(toolUsage, canonicalUsage) ? 'complete' : 'incomplete';
949
+ }
950
+ store.closeTurn({ observationId: row.observation_id, usedToolIds, usageStatus, completedAtMs });
951
+ } finally {
952
+ store.close();
953
+ }
954
+ } catch {
955
+ reportEvaluationFailure(writeError);
956
+ }
957
+ }
958
+
780
959
  // Phase D (hook parity, 2026-05-08): Codex `--project` diagnostics now read the host-neutral
781
960
  // `<projectRoot>/.spotter/hook-events.jsonl` and filter to `host:"codex"` so the existing
782
961
  // `codex-hook diagnostics` shape (counts of just Codex events) stays intact.
@@ -59,7 +59,7 @@ export async function runDiagnosticsLogsCommand({
59
59
  writeOutput(formatDaemonLogSummary(merged));
60
60
  }
61
61
 
62
- function stringifyAsciiJson(value) {
62
+ export function stringifyAsciiJson(value) {
63
63
  return JSON.stringify(value, null, 2).replace(/[\u007f-\uffff]/g, (character) =>
64
64
  `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
65
65
  );