claude-spotter 1.4.21 → 1.4.23
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 +26 -1
- package/README.ja.md +1 -1
- package/README.md +37 -3
- package/bin/spotter.mjs +15 -0
- package/package.json +2 -2
- package/src/cli/codex-hook-cmd.mjs +21 -2
- package/src/cli/daemon-cmd.mjs +7 -1
- package/src/cli/diagnostics-cmd.mjs +98 -1
- package/src/cli/factory-diagnostics.mjs +178 -0
- package/src/core/runtime-error-store-worker.mjs +51 -0
- package/src/core/runtime-error-store.mjs +936 -0
- package/src/daemon/daemon.mjs +67 -19
- package/src/index.mjs +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.23 — release candidate (pending)
|
|
4
|
+
|
|
5
|
+
- **Factory diagnostics と local runtime error store。** factory diagnostics と
|
|
6
|
+
`spotter diagnostics runtime-errors` の公開候補。収集は canonical dotagents config の
|
|
7
|
+
JSON boolean `collection.enabled: true` が明示された時だけ有効で既定OFF、保存は
|
|
8
|
+
local-only、network送信は行わない。npm公開、tag、CI、registry由来installの確認は未実施。
|
|
9
|
+
- **隔離observerのdeadlineを実測へ整合。** 並列full-suite負荷でcold workerとreceipt
|
|
10
|
+
reconcilerが500msに収まらず、commit済みreceiptを`store_unavailable`へ誤分類するraceを
|
|
11
|
+
再現した。絶対deadlineを1.5秒へ広げたうえでreceipt reconcilerを観測workerと
|
|
12
|
+
並行prewarmし、後段cold-startの競合を除去。worker treeの強制終了と本体処理を
|
|
13
|
+
止めない契約は維持する。
|
|
14
|
+
|
|
15
|
+
## 1.4.22 — 2026-07-13
|
|
16
|
+
|
|
17
|
+
- **default-on裁定の文書修正。** v1.4.21の実装どおりdefault-onは確定事項であり、7日・30 fresh resultの測定は精度改善にだけ使う。ON/OFFの再審査やdefault-off rollbackを選択肢としていた誤記をREADME、正典、SLO、計画、課題台帳から削除した。
|
|
18
|
+
- **公開。** 478 tests(476 pass / 2 skip)、CI 6/6 green。公開commit`3c7c820`をnpm `claude-spotter@1.4.22`、tag / GitHub Release、registry由来global installへ同期した。
|
|
19
|
+
|
|
3
20
|
## 1.4.21 — 2026-07-13
|
|
4
21
|
|
|
5
22
|
### Changed
|
|
6
23
|
|
|
7
24
|
- **Throughline監査文脈をdefault-onへ変更。** `spotter install`がPATH上のThroughlineをabsolute pathへ解決できる場合、project markerへ既定設定する。marker v2の`origin:default|explicit`で旧既定disabledだけを移行し、明示OFFは再install後も維持する。POSIXはrealpath、Windows npm shimはabsolute `node.exe + throughline.mjs`を保存する。
|
|
8
25
|
- **不在時は明示disabled。** Throughlineが見つからない場合は固定理由付きdisabledとし、current-only監査や別backendへfallbackしない。installerとdoctorが状態・OFF手順を表示する。
|
|
9
|
-
- **実運用効果測定。** default-on
|
|
26
|
+
- **実運用効果測定。** default-onはowner確定済み。7日以上・fresh 30件以上の過検出・見逃し・stale率・latencyは精度改善にだけ使い、ON/OFFを再審査しない。L2本文は評価ログへ保存しない。
|
|
27
|
+
|
|
28
|
+
### 検証・公開
|
|
29
|
+
|
|
30
|
+
478 tests(476 pass / 2 skip)、macOS/Linux/Windows × Node 22.5/22.xのCI 6/6、packの秘密・
|
|
31
|
+
開発者固有path scan、隔離tarball installを通過。公開commitは`5026ace`。npm `claude-spotter@1.4.21`、
|
|
32
|
+
tag / GitHub Release `v1.4.21`、registry由来global installを同期した。registry版の空projectへ
|
|
33
|
+
`spotter install -y`を実行し、marker v2がThroughlineのglobal実体を`origin:default`で設定し、
|
|
34
|
+
doctorがconnector availableを返すことを確認した。
|
|
10
35
|
|
|
11
36
|
## 1.4.20 — 2026-07-13
|
|
12
37
|
|
package/README.ja.md
CHANGED
|
@@ -190,7 +190,7 @@ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.js
|
|
|
190
190
|
--recent-turns 2 --body-cap 600
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
-
評価結論はN=2 / 600です。既定ON
|
|
193
|
+
評価結論はN=2 / 600です。既定ONは確定済みで、7日・30 fresh resultの実運用測定は精度改善に使います。
|
|
194
194
|
|
|
195
195
|
## よく使うコマンド
|
|
196
196
|
|
package/README.md
CHANGED
|
@@ -80,7 +80,7 @@ spotter codex-hook install
|
|
|
80
80
|
|
|
81
81
|
## Requirements
|
|
82
82
|
|
|
83
|
-
- **Node.js 22.
|
|
83
|
+
- **Node.js 22.13+**
|
|
84
84
|
- **Claude Code 2.0+**
|
|
85
85
|
- **Codex CLI** for the default Codex-native backend and the preferred Claude-host auditor path. The auto-selected Codex backend does not fall back to Haiku after a runtime failure
|
|
86
86
|
- **Claude Max plan** only when a Claude host selects the Haiku path (Codex CLI is absent or `SPOTTER_AUDITOR_BACKEND=haiku` is explicit)
|
|
@@ -195,8 +195,8 @@ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.js
|
|
|
195
195
|
--recent-turns 2 --body-cap 600
|
|
196
196
|
```
|
|
197
197
|
|
|
198
|
-
The evaluated setting is N=2 / 600. Default-on
|
|
199
|
-
30-fresh-result sample used
|
|
198
|
+
The evaluated setting is N=2 / 600. Default-on is final; the 7-day,
|
|
199
|
+
30-fresh-result sample is used only to identify precision improvements.
|
|
200
200
|
|
|
201
201
|
## Common commands
|
|
202
202
|
|
|
@@ -214,6 +214,8 @@ spotter db rebuild # wipe Claude local + Claude global DBs and refresh fro
|
|
|
214
214
|
spotter status # list running daemons
|
|
215
215
|
spotter doctor # environment check (Node / claude CLI / Codex readiness / tool-db integrity)
|
|
216
216
|
spotter diagnostics logs # summarize daemon logs for pass=false / backend latency / anomaly signals
|
|
217
|
+
spotter diagnostics runtime-errors
|
|
218
|
+
# print the local allow-listed runtime-error aggregate snapshot (no network)
|
|
217
219
|
spotter codex risk-check --findings findings.json --host-agent claude
|
|
218
220
|
# run read-only codex-sidecar risk analysis for Spotter findings
|
|
219
221
|
spotter codex review|explore|opinion --findings findings.json --host-agent claude
|
|
@@ -243,6 +245,38 @@ in a detached process. Hook responses do not wait for Codex. Add
|
|
|
243
245
|
Primary auditor backend policy: Claude hooks automatically select Codex CLI when it is available on PATH,
|
|
244
246
|
otherwise the Haiku-compatible path. Codex native hooks automatically select Codex CLI. An explicit
|
|
245
247
|
`SPOTTER_AUDITOR_BACKEND` override wins on either host; runtime failure never triggers a hidden fallback.
|
|
248
|
+
|
|
249
|
+
## Local runtime error aggregates
|
|
250
|
+
|
|
251
|
+
**v1.4.23 は release candidate(公開待ち)です。** 以下の factory diagnostics と
|
|
252
|
+
local runtime error aggregate は collection が既定OFFで、公開・tag・CI・registry由来
|
|
253
|
+
installの確認はいずれも未完了です。
|
|
254
|
+
|
|
255
|
+
Spotter collects fixed-code runtime failures only when the canonical dotagents factory reporter
|
|
256
|
+
configuration contains the JSON boolean `collection.enabled: true`. Missing, malformed, and disabled
|
|
257
|
+
configuration all fail closed. Collection is local-only: Spotter has no reporting credential or network
|
|
258
|
+
transport code. The owner-private atomic store contains only fixed templates and allow-listed aggregates;
|
|
259
|
+
raw exceptions, stdout/stderr, stacks, prompts, hook payloads, findings, file contents, and absolute paths
|
|
260
|
+
are not accepted by its API.
|
|
261
|
+
|
|
262
|
+
Daemon and direct Codex-hook owner boundaries perform collection in a killable child-process group with
|
|
263
|
+
a bounded timeout. A blocked FIFO or descendant therefore cannot stall the hook or daemon. HTTP reporting
|
|
264
|
+
metadata, when present in the shared config, is accepted only when `new URL(value).href === value` and the
|
|
265
|
+
scheme is HTTP(S); Spotter still neither reads credentials nor sends the aggregate anywhere.
|
|
266
|
+
|
|
267
|
+
On POSIX, every config/store read revalidates the current uid and exact `0600` file / `0700` directory
|
|
268
|
+
modes. Store mutations use a private SQLite `BEGIN IMMEDIATE` mutex that the OS releases on process crash;
|
|
269
|
+
there is no PID/mtime stale-owner reclaim path. On Windows, every
|
|
270
|
+
store access rebuilds the DACL to one FullControl ACE for the current process SID and verifies the ACL
|
|
271
|
+
readback before use.
|
|
272
|
+
|
|
273
|
+
`spotter diagnostics runtime-errors` emits the read-only cursor snapshot. Its
|
|
274
|
+
`ack`, `resolve`, `reopen`, and `compact` actions are the machine lifecycle
|
|
275
|
+
surface used after report acceptance. `spotter diagnostics logs`
|
|
276
|
+
and `spotter diagnostics factory` include only bounded store counts/status, never the store/config path
|
|
277
|
+
or record payload. Programmatic consumers can import `readRuntimeErrorSnapshot`,
|
|
278
|
+
`acknowledgeRuntimeErrors`, `resolveRuntimeError`, `reopenRuntimeError`, and `compactRuntimeErrors`.
|
|
279
|
+
Acknowledgement is monotonic, and compaction never removes an unacknowledged record.
|
|
246
280
|
The Codex SessionStart hook refreshes `.spotter/tool-db.codex.json` in the background
|
|
247
281
|
without touching the Claude DB.
|
|
248
282
|
Codex CLI auditor child processes use a versioned product policy. The production selection is
|
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 { runFactoryDiagnostics } from '../src/cli/factory-diagnostics.mjs';
|
|
14
15
|
import { runDaemonStart } from '../src/cli/daemon-cmd.mjs';
|
|
15
16
|
import { runSessionStart } from '../src/hooks/session-start.mjs';
|
|
16
17
|
import { runUserPrompt } from '../src/hooks/user-prompt.mjs';
|
|
@@ -42,6 +43,8 @@ Usage:
|
|
|
42
43
|
spotter status show running daemons
|
|
43
44
|
spotter doctor environment diagnostic
|
|
44
45
|
spotter diagnostics logs [--json] summarize daemon logs for precision diagnostics
|
|
46
|
+
spotter diagnostics runtime-errors [snapshot|ack|resolve|reopen|compact]
|
|
47
|
+
consume the local allow-listed aggregate store
|
|
45
48
|
spotter codex risk-check --findings FILE
|
|
46
49
|
run read-only codex-sidecar risk analysis
|
|
47
50
|
spotter codex review|explore|opinion --findings FILE
|
|
@@ -114,6 +117,11 @@ async function main() {
|
|
|
114
117
|
await runAuditorCommand({ argv: rest });
|
|
115
118
|
return;
|
|
116
119
|
case 'diagnostics':
|
|
120
|
+
if (rest[0] === 'factory') {
|
|
121
|
+
if (rest.length !== 1) throw invalidFactoryDiagnosticsArgs();
|
|
122
|
+
process.stdout.write(`${JSON.stringify(await runFactoryDiagnostics())}\n`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
117
125
|
await runDiagnosticsCommand({ argv: rest });
|
|
118
126
|
return;
|
|
119
127
|
case 'daemon': {
|
|
@@ -197,6 +205,13 @@ function invalidInstallArgs() {
|
|
|
197
205
|
return err;
|
|
198
206
|
}
|
|
199
207
|
|
|
208
|
+
function invalidFactoryDiagnosticsArgs() {
|
|
209
|
+
const err = new Error('usage: spotter diagnostics factory');
|
|
210
|
+
err.stack = '';
|
|
211
|
+
err.exitCode = 2;
|
|
212
|
+
return err;
|
|
213
|
+
}
|
|
214
|
+
|
|
200
215
|
main().catch((err) => {
|
|
201
216
|
process.stderr.write(`spotter: ${err.stack || err.message || err}\n`);
|
|
202
217
|
process.exit(err.exitCode ?? 2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-spotter",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.23",
|
|
4
4
|
"description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://github.com/kitepon-rgb/Spotter#readme",
|
|
36
36
|
"engines": {
|
|
37
|
-
"node": ">=22.
|
|
37
|
+
"node": ">=22.13.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"js-yaml": "^4.1.0"
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
loadAuditorContext,
|
|
33
33
|
readProjectAuditorContextConfig,
|
|
34
34
|
} from '../core/auditor-context.mjs';
|
|
35
|
+
import { observeRuntimeErrorIsolatedSafe } from '../core/runtime-error-store.mjs';
|
|
35
36
|
|
|
36
37
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
37
38
|
const PACKAGE_ROOT = resolve(HERE, '..', '..');
|
|
@@ -77,11 +78,11 @@ export async function runCodexHookCommand({ argv = process.argv.slice(2) } = {})
|
|
|
77
78
|
return;
|
|
78
79
|
}
|
|
79
80
|
if (sub === 'user-prompt-submit') {
|
|
80
|
-
await runCodexUserPromptSubmitHook();
|
|
81
|
+
await runCodexUserPromptSubmitHook({ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe });
|
|
81
82
|
return;
|
|
82
83
|
}
|
|
83
84
|
if (sub === 'stop') {
|
|
84
|
-
await runCodexStopHook();
|
|
85
|
+
await runCodexStopHook({ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe });
|
|
85
86
|
return;
|
|
86
87
|
}
|
|
87
88
|
process.stderr.write(`unknown codex-hook subcommand: ${sub}\n${CODEX_HOOK_USAGE}`);
|
|
@@ -121,6 +122,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
121
122
|
loadAuditorContextFn = loadAuditorContext,
|
|
122
123
|
writeOutput = (text) => process.stdout.write(text),
|
|
123
124
|
writeError = (text) => process.stderr.write(text),
|
|
125
|
+
runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
|
|
124
126
|
} = {}) {
|
|
125
127
|
if (isChildCall()) return;
|
|
126
128
|
const input = await readInput();
|
|
@@ -160,6 +162,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
160
162
|
});
|
|
161
163
|
contextDurationMs = Date.now() - contextStartedAt;
|
|
162
164
|
} catch (err) {
|
|
165
|
+
await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
|
|
163
166
|
contextDurationMs = Date.now() - contextStartedAt;
|
|
164
167
|
const failure = projectBackendFailure(err?.code);
|
|
165
168
|
safeWriteError(writeError, failure.stderr);
|
|
@@ -179,6 +182,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
179
182
|
return;
|
|
180
183
|
}
|
|
181
184
|
if (context.status === 'unavailable' || context.status === 'schema_mismatch') {
|
|
185
|
+
await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
|
|
182
186
|
const failure = projectBackendFailure(context.status === 'unavailable'
|
|
183
187
|
? 'E_AUDITOR_CONTEXT_UNAVAILABLE'
|
|
184
188
|
: 'E_AUDITOR_CONTEXT_SCHEMA');
|
|
@@ -219,8 +223,10 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
219
223
|
let catalog;
|
|
220
224
|
let backend;
|
|
221
225
|
let judgment;
|
|
226
|
+
let enteredAuditorBoundary = false;
|
|
222
227
|
try {
|
|
223
228
|
catalog = await readLocalFn({ projectRoot, hostAgent: 'codex' });
|
|
229
|
+
enteredAuditorBoundary = true;
|
|
224
230
|
backend = createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBackendFn });
|
|
225
231
|
judgment = await backend.judge({
|
|
226
232
|
stage: 'user_input',
|
|
@@ -229,6 +235,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
229
235
|
contextStatus: 'fresh',
|
|
230
236
|
});
|
|
231
237
|
} catch (err) {
|
|
238
|
+
if (enteredAuditorBoundary) await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
|
|
232
239
|
const failure = projectBackendFailure(err?.code);
|
|
233
240
|
safeWriteError(writeError, failure.stderr);
|
|
234
241
|
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
@@ -281,6 +288,7 @@ export async function runCodexStopHook({
|
|
|
281
288
|
recordHookEventFn = appendCodexHookEvent,
|
|
282
289
|
writeOutput = (text) => process.stdout.write(text),
|
|
283
290
|
writeError = (text) => process.stderr.write(text),
|
|
291
|
+
runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
|
|
284
292
|
} = {}) {
|
|
285
293
|
if (isChildCall()) return;
|
|
286
294
|
const input = await readInput();
|
|
@@ -331,11 +339,14 @@ export async function runCodexStopHook({
|
|
|
331
339
|
let catalog;
|
|
332
340
|
let backend;
|
|
333
341
|
let judgment;
|
|
342
|
+
let enteredAuditorBoundary = false;
|
|
334
343
|
try {
|
|
335
344
|
catalog = await readLocalFn({ projectRoot, hostAgent: 'codex' });
|
|
345
|
+
enteredAuditorBoundary = true;
|
|
336
346
|
backend = createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBackendFn });
|
|
337
347
|
judgment = await backend.judge({ stage: 'turn_end', finalResponse, usedTools });
|
|
338
348
|
} catch (err) {
|
|
349
|
+
if (enteredAuditorBoundary) await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
|
|
339
350
|
const failure = projectBackendFailure(err?.code);
|
|
340
351
|
reportError(failure.stderr);
|
|
341
352
|
writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
|
|
@@ -653,6 +664,14 @@ function createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBack
|
|
|
653
664
|
});
|
|
654
665
|
}
|
|
655
666
|
|
|
667
|
+
async function observeRuntimeFailure(observer, kind) {
|
|
668
|
+
try {
|
|
669
|
+
await observer(kind);
|
|
670
|
+
} catch {
|
|
671
|
+
// Runtime error telemetry must not alter hook output or exit behavior.
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
656
675
|
function resolveCodexHookAuditorBackend({ env }) {
|
|
657
676
|
if (!env?.SPOTTER_AUDITOR_BACKEND) return 'codex-cli';
|
|
658
677
|
return selectAuditorBackend({ hostAgent: 'codex', env }).backend;
|
package/src/cli/daemon-cmd.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { open } from 'node:fs/promises';
|
|
7
7
|
import { writeFileSync } from 'node:fs';
|
|
8
|
+
import { observeRuntimeErrorIsolatedSafe } from '../core/runtime-error-store.mjs';
|
|
8
9
|
|
|
9
10
|
function parseArgs(argv) {
|
|
10
11
|
const out = { sessionId: null, projectRoot: null };
|
|
@@ -69,7 +70,12 @@ export async function runDaemonStart({ argv }) {
|
|
|
69
70
|
// removed along with the stateless regime that required it.
|
|
70
71
|
// v0.7.0: projectRoot drives tool-db loading (replaces the old tools.yaml catalog).
|
|
71
72
|
// v0.12.0: orphan-cleanup is heartbeat-based inside startDaemon (no parent-PID arg).
|
|
72
|
-
running = await startDaemon({
|
|
73
|
+
running = await startDaemon({
|
|
74
|
+
sessionId,
|
|
75
|
+
projectRoot,
|
|
76
|
+
logFn: log,
|
|
77
|
+
runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
|
|
78
|
+
});
|
|
73
79
|
} catch (err) {
|
|
74
80
|
if (err instanceof DaemonAlreadyRunningError) {
|
|
75
81
|
// v0.2 PID-preexist layer: a sibling daemon already serves this session.
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { defaultDaemonLogDir, summarizeDaemonLogs } from '../core/daemon-log-diagnostics.mjs';
|
|
3
3
|
import { summarizeHookEvents } from '../core/hook-event-log.mjs';
|
|
4
|
+
import {
|
|
5
|
+
acknowledgeRuntimeErrors,
|
|
6
|
+
compactRuntimeErrors,
|
|
7
|
+
readRuntimeErrorSnapshot,
|
|
8
|
+
readRuntimeErrorStoreStatus,
|
|
9
|
+
reopenRuntimeError,
|
|
10
|
+
resolveRuntimeError,
|
|
11
|
+
} from '../core/runtime-error-store.mjs';
|
|
4
12
|
|
|
5
13
|
const DIAGNOSTICS_USAGE = `spotter diagnostics — read-only operational diagnostics
|
|
6
14
|
|
|
7
15
|
Usage:
|
|
8
16
|
spotter diagnostics logs [--log-dir DIR] [--project DIR] [--json]
|
|
17
|
+
spotter diagnostics runtime-errors [snapshot] [--after-cursor N] [--limit N]
|
|
18
|
+
spotter diagnostics runtime-errors ack CURSOR
|
|
19
|
+
spotter diagnostics runtime-errors resolve|reopen FINGERPRINT
|
|
20
|
+
spotter diagnostics runtime-errors compact
|
|
9
21
|
|
|
10
22
|
--log-dir daemon log directory (default: ~/.spotter/runtime)
|
|
11
23
|
--project project root for hook-events.jsonl (default: cwd)
|
|
@@ -17,6 +29,10 @@ export async function runDiagnosticsCommand({ argv = process.argv.slice(2) } = {
|
|
|
17
29
|
await runDiagnosticsLogsCommand({ argv: argv.slice(1) });
|
|
18
30
|
return;
|
|
19
31
|
}
|
|
32
|
+
if (sub === 'runtime-errors') {
|
|
33
|
+
await runRuntimeErrorDiagnosticsCommand({ argv: argv.slice(1) });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
20
36
|
process.stderr.write(`unknown diagnostics subcommand: ${sub}\n${DIAGNOSTICS_USAGE}`);
|
|
21
37
|
process.exit(2);
|
|
22
38
|
}
|
|
@@ -25,6 +41,7 @@ export async function runDiagnosticsLogsCommand({
|
|
|
25
41
|
argv = [],
|
|
26
42
|
summarizeDaemonLogsFn = summarizeDaemonLogs,
|
|
27
43
|
summarizeHookEventsFn = summarizeHookEvents,
|
|
44
|
+
readRuntimeErrorStoreStatusFn = readRuntimeErrorStoreStatus,
|
|
28
45
|
writeOutput = (text) => process.stdout.write(text),
|
|
29
46
|
} = {}) {
|
|
30
47
|
const opts = parseLogsArgs(argv);
|
|
@@ -33,7 +50,8 @@ export async function runDiagnosticsLogsCommand({
|
|
|
33
50
|
// the hook-side observations (skip reasons, drained pending counts, transport errors
|
|
34
51
|
// that never reach the daemon) surface in the same diagnostics output.
|
|
35
52
|
const hookEvents = await summarizeHookEventsFn({ projectRoot: opts.projectRoot });
|
|
36
|
-
const
|
|
53
|
+
const runtimeErrors = await readRuntimeErrorStoreStatusFn();
|
|
54
|
+
const merged = { ...summary, hookEvents, runtimeErrors };
|
|
37
55
|
if (opts.json) {
|
|
38
56
|
writeOutput(JSON.stringify(merged, null, 2) + '\n');
|
|
39
57
|
return;
|
|
@@ -110,9 +128,78 @@ export function formatDaemonLogSummary(summary) {
|
|
|
110
128
|
}
|
|
111
129
|
}
|
|
112
130
|
|
|
131
|
+
const runtimeErrors = summary.runtimeErrors;
|
|
132
|
+
if (runtimeErrors) {
|
|
133
|
+
lines.push(
|
|
134
|
+
` runtime-errors: collection=${runtimeErrors.collection}, store=${runtimeErrors.store}, records=${runtimeErrors.records}, open=${runtimeErrors.open}, resolved=${runtimeErrors.resolved}, unacknowledged=${runtimeErrors.unacknowledged}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
return lines.join('\n') + '\n';
|
|
114
139
|
}
|
|
115
140
|
|
|
141
|
+
export async function runRuntimeErrorDiagnosticsCommand({
|
|
142
|
+
argv = [],
|
|
143
|
+
readSnapshotFn = readRuntimeErrorSnapshot,
|
|
144
|
+
acknowledgeFn = acknowledgeRuntimeErrors,
|
|
145
|
+
resolveFn = resolveRuntimeError,
|
|
146
|
+
reopenFn = reopenRuntimeError,
|
|
147
|
+
compactFn = compactRuntimeErrors,
|
|
148
|
+
writeOutput = (text) => process.stdout.write(text),
|
|
149
|
+
} = {}) {
|
|
150
|
+
const action = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'snapshot';
|
|
151
|
+
const actionArgs = action === 'snapshot' && argv[0] !== 'snapshot' ? argv : argv.slice(1);
|
|
152
|
+
if (!['snapshot', 'ack', 'resolve', 'reopen', 'compact'].includes(action)) throw runtimeErrorUsage();
|
|
153
|
+
try {
|
|
154
|
+
if (action === 'ack') {
|
|
155
|
+
if (actionArgs.length !== 1) throw runtimeErrorUsage();
|
|
156
|
+
const result = await acknowledgeFn({ cursor: parseNonNegativeInteger(actionArgs[0], 'cursor') });
|
|
157
|
+
writeOutput(`${JSON.stringify(result)}\n`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (action === 'resolve' || action === 'reopen') {
|
|
161
|
+
if (actionArgs.length !== 1 || !/^[a-f0-9]{64}$/.test(actionArgs[0])) throw runtimeErrorUsage();
|
|
162
|
+
const result = await (action === 'resolve' ? resolveFn : reopenFn)({ fingerprint: actionArgs[0] });
|
|
163
|
+
writeOutput(`${JSON.stringify(result)}\n`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (action === 'compact') {
|
|
167
|
+
if (actionArgs.length !== 0) throw runtimeErrorUsage();
|
|
168
|
+
writeOutput(`${JSON.stringify(await compactFn())}\n`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let afterCursor = 0;
|
|
172
|
+
let limit = 100;
|
|
173
|
+
for (let index = 0; index < actionArgs.length; index += 1) {
|
|
174
|
+
const arg = actionArgs[index];
|
|
175
|
+
if (arg === '--after-cursor') {
|
|
176
|
+
afterCursor = parseNonNegativeInteger(requireValue(actionArgs, (index += 1), '--after-cursor'), '--after-cursor');
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (arg === '--limit') {
|
|
180
|
+
limit = parsePositiveInteger(requireValue(actionArgs, (index += 1), '--limit'), '--limit');
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
throw Object.assign(new Error(`unknown diagnostics runtime-errors option: ${arg}`), { exitCode: 2 });
|
|
184
|
+
}
|
|
185
|
+
const snapshot = await readSnapshotFn({ afterCursor, limit });
|
|
186
|
+
writeOutput(`${JSON.stringify(snapshot)}\n`);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error?.exitCode === 2) throw error;
|
|
189
|
+
const fixed = new Error('runtime error store unavailable');
|
|
190
|
+
fixed.stack = '';
|
|
191
|
+
fixed.exitCode = 1;
|
|
192
|
+
throw fixed;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function runtimeErrorUsage() {
|
|
197
|
+
const error = new Error('usage: spotter diagnostics runtime-errors snapshot|ack|resolve|reopen|compact');
|
|
198
|
+
error.stack = '';
|
|
199
|
+
error.exitCode = 2;
|
|
200
|
+
return error;
|
|
201
|
+
}
|
|
202
|
+
|
|
116
203
|
function formatCounter(counter) {
|
|
117
204
|
if (!counter || typeof counter !== 'object') return '';
|
|
118
205
|
const entries = Object.entries(counter).sort(([a], [b]) => a.localeCompare(b));
|
|
@@ -155,6 +242,16 @@ function requireValue(argv, index, option) {
|
|
|
155
242
|
return value;
|
|
156
243
|
}
|
|
157
244
|
|
|
245
|
+
function parseNonNegativeInteger(value, option) {
|
|
246
|
+
if (!/^(?:0|[1-9][0-9]*)$/.test(value)) throw Object.assign(new Error(`${option} requires a non-negative integer`), { exitCode: 2 });
|
|
247
|
+
return Number(value);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parsePositiveInteger(value, option) {
|
|
251
|
+
if (!/^[1-9][0-9]*$/.test(value)) throw Object.assign(new Error(`${option} requires a positive integer`), { exitCode: 2 });
|
|
252
|
+
return Number(value);
|
|
253
|
+
}
|
|
254
|
+
|
|
158
255
|
function topCounter(counter, limit) {
|
|
159
256
|
return Object.entries(counter)
|
|
160
257
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { codexHookDiagnostics } from './codex-hook-cmd.mjs';
|
|
4
|
+
import { inspectAuditorContextConfiguration } from './doctor.mjs';
|
|
5
|
+
import { loadDb, localDbPath } from '../tool-db/loader.mjs';
|
|
6
|
+
import { version } from '../version.mjs';
|
|
7
|
+
import { readRuntimeErrorStoreStatus } from '../core/runtime-error-store.mjs';
|
|
8
|
+
|
|
9
|
+
const KNOWN_MARKER_VERSIONS = new Set(['1', '2']);
|
|
10
|
+
const SAFE_CONTEXT_MODES = new Set(['disabled', 'throughline']);
|
|
11
|
+
const SAFE_CODEX_READINESS = new Set([
|
|
12
|
+
'configured-unverified',
|
|
13
|
+
'misconfigured',
|
|
14
|
+
'not-installed',
|
|
15
|
+
'unavailable',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export async function runFactoryDiagnostics({
|
|
19
|
+
projectRoot = process.cwd(),
|
|
20
|
+
codexHookDiagnosticsFn = codexHookDiagnostics,
|
|
21
|
+
inspectAuditorContextFn = inspectAuditorContextConfiguration,
|
|
22
|
+
readRuntimeErrorStoreStatusFn = readRuntimeErrorStoreStatus,
|
|
23
|
+
} = {}) {
|
|
24
|
+
const runtimeErrorStore = await readRuntimeErrorStoreStatusFn();
|
|
25
|
+
const markerResult = await readMarker(join(projectRoot, '.spotter', 'marker.json'));
|
|
26
|
+
if (markerResult.status === 'missing') return inactiveSnapshot(runtimeErrorStore);
|
|
27
|
+
|
|
28
|
+
const checks = [];
|
|
29
|
+
if (markerResult.status !== 'valid') {
|
|
30
|
+
checks.push(check('project_activation', 'unverified', markerResult.reasonCode));
|
|
31
|
+
return snapshot({ overallStatus: 'unverified', checks, runtimeErrorStore });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const marker = markerResult.value;
|
|
35
|
+
checks.push(check('project_activation', 'pass'));
|
|
36
|
+
|
|
37
|
+
const markerVersion = typeof marker.markerVersion === 'string' && KNOWN_MARKER_VERSIONS.has(marker.markerVersion)
|
|
38
|
+
? marker.markerVersion
|
|
39
|
+
: null;
|
|
40
|
+
checks.push(markerVersion
|
|
41
|
+
? check('marker_schema', 'pass')
|
|
42
|
+
: check('marker_schema', 'fail', 'unsupported_marker_schema'));
|
|
43
|
+
|
|
44
|
+
const contextMode = safeContextMode(marker.auditorContext);
|
|
45
|
+
if (contextMode === null) {
|
|
46
|
+
checks.push(check('throughline_context', 'fail', 'invalid_context_configuration'));
|
|
47
|
+
} else if (contextMode === 'disabled') {
|
|
48
|
+
checks.push(check('throughline_context', 'skipped', 'context_disabled'));
|
|
49
|
+
} else {
|
|
50
|
+
const context = await inspectAuditorContextFn({ projectRoot });
|
|
51
|
+
checks.push(context?.ok === true && context?.mode === 'throughline'
|
|
52
|
+
? check('throughline_context', 'pass')
|
|
53
|
+
: check('throughline_context', 'unverified', 'context_provider_unavailable'));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const catalogs = {};
|
|
57
|
+
for (const host of ['claude', 'codex']) {
|
|
58
|
+
const result = await inspectCatalog(localDbPath(projectRoot, host));
|
|
59
|
+
catalogs[host] = result.publicStatus;
|
|
60
|
+
checks.push(check(`${host}_catalog`, result.checkStatus, result.reasonCode));
|
|
61
|
+
}
|
|
62
|
+
checks.push(Object.values(catalogs).includes('available')
|
|
63
|
+
? check('audit_catalog_readiness', 'pass')
|
|
64
|
+
: Object.values(catalogs).includes('invalid')
|
|
65
|
+
? check('audit_catalog_readiness', 'fail', 'catalog_invalid_schema')
|
|
66
|
+
: check('audit_catalog_readiness', 'unverified', 'no_host_catalog'));
|
|
67
|
+
|
|
68
|
+
let codexReadiness = 'unverified';
|
|
69
|
+
try {
|
|
70
|
+
const diagnostics = await codexHookDiagnosticsFn({ projectRoot });
|
|
71
|
+
if (SAFE_CODEX_READINESS.has(diagnostics?.readiness)) codexReadiness = diagnostics.readiness;
|
|
72
|
+
} catch {
|
|
73
|
+
// A safe snapshot still reports that the inspector could not establish readiness.
|
|
74
|
+
}
|
|
75
|
+
checks.push(codexReadiness === 'configured-unverified'
|
|
76
|
+
? check('codex_hooks', 'unverified', 'trust_not_machine_verifiable')
|
|
77
|
+
: codexReadiness === 'not-installed'
|
|
78
|
+
? check('codex_hooks', 'skipped', 'not_installed')
|
|
79
|
+
: codexReadiness === 'misconfigured'
|
|
80
|
+
? check('codex_hooks', 'fail', 'misconfigured')
|
|
81
|
+
: check('codex_hooks', 'unverified', 'diagnostics_unavailable'));
|
|
82
|
+
|
|
83
|
+
return snapshot({
|
|
84
|
+
overallStatus: overallStatus(checks),
|
|
85
|
+
markerSchemaVersion: markerVersion,
|
|
86
|
+
throughlineContext: contextMode ?? 'unverified',
|
|
87
|
+
catalogs,
|
|
88
|
+
codexHookReadiness: codexReadiness,
|
|
89
|
+
runtimeErrorStore,
|
|
90
|
+
checks,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function inactiveSnapshot(runtimeErrorStore) {
|
|
95
|
+
return snapshot({
|
|
96
|
+
overallStatus: 'not_applicable',
|
|
97
|
+
runtimeErrorStore,
|
|
98
|
+
checks: [check('project_activation', 'skipped', 'project_not_activated')],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function snapshot({
|
|
103
|
+
overallStatus,
|
|
104
|
+
markerSchemaVersion = null,
|
|
105
|
+
throughlineContext = 'unverified',
|
|
106
|
+
catalogs = { claude: 'not_applicable', codex: 'not_applicable' },
|
|
107
|
+
codexHookReadiness = 'not_applicable',
|
|
108
|
+
runtimeErrorStore,
|
|
109
|
+
checks,
|
|
110
|
+
}) {
|
|
111
|
+
return {
|
|
112
|
+
schema_version: '1.0',
|
|
113
|
+
product: 'spotter',
|
|
114
|
+
version,
|
|
115
|
+
overall_status: overallStatus,
|
|
116
|
+
marker_schema_version: markerSchemaVersion,
|
|
117
|
+
throughline_context: throughlineContext,
|
|
118
|
+
catalogs,
|
|
119
|
+
codex_hook_readiness: codexHookReadiness,
|
|
120
|
+
runtime_error_store: runtimeErrorStore,
|
|
121
|
+
checks,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readMarker(path) {
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = await readFile(path, 'utf8');
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return error?.code === 'ENOENT'
|
|
131
|
+
? { status: 'missing' }
|
|
132
|
+
: { status: 'unverified', reasonCode: 'marker_unreadable' };
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const value = JSON.parse(raw);
|
|
136
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
137
|
+
return { status: 'unverified', reasonCode: 'marker_invalid_shape' };
|
|
138
|
+
}
|
|
139
|
+
return { status: 'valid', value };
|
|
140
|
+
} catch {
|
|
141
|
+
return { status: 'unverified', reasonCode: 'marker_invalid_json' };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function inspectCatalog(path) {
|
|
146
|
+
try {
|
|
147
|
+
await access(path);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return error?.code === 'ENOENT'
|
|
150
|
+
? { publicStatus: 'missing', checkStatus: 'skipped', reasonCode: 'catalog_missing' }
|
|
151
|
+
: { publicStatus: 'unverified', checkStatus: 'unverified', reasonCode: 'catalog_unreadable' };
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
await loadDb(path);
|
|
155
|
+
return { publicStatus: 'available', checkStatus: 'pass' };
|
|
156
|
+
} catch {
|
|
157
|
+
return { publicStatus: 'invalid', checkStatus: 'fail', reasonCode: 'catalog_invalid_schema' };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeContextMode(config) {
|
|
162
|
+
if (config === undefined) return 'disabled';
|
|
163
|
+
return config && typeof config === 'object' && SAFE_CONTEXT_MODES.has(config.mode)
|
|
164
|
+
? config.mode
|
|
165
|
+
: null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function check(checkId, status, reasonCode) {
|
|
169
|
+
return reasonCode
|
|
170
|
+
? { check_id: checkId, status, reason_code: reasonCode }
|
|
171
|
+
: { check_id: checkId, status };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function overallStatus(checks) {
|
|
175
|
+
if (checks.some((entry) => entry.status === 'fail')) return 'fail';
|
|
176
|
+
if (checks.some((entry) => entry.status === 'unverified')) return 'unverified';
|
|
177
|
+
return 'pass';
|
|
178
|
+
}
|