claude-spotter 1.4.19 → 1.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -2
- package/README.ja.md +47 -1
- package/README.md +53 -1
- package/bin/spotter.mjs +61 -4
- package/package.json +1 -1
- package/src/cli/auditor-cmd.mjs +1 -1
- package/src/cli/auditor-model-matrix-cmd.mjs +15 -8
- package/src/cli/codex-hook-cmd.mjs +98 -4
- package/src/cli/doctor.mjs +49 -1
- package/src/cli/install.mjs +86 -3
- package/src/core/auditor-backend.mjs +7 -0
- package/src/core/auditor-context.mjs +198 -0
- package/src/core/auditor-prompt-data.mjs +38 -0
- package/src/core/auditor-response.mjs +7 -14
- package/src/core/codex-cli-backend.mjs +35 -17
- package/src/core/codex-risk-dispatch.mjs +9 -1
- package/src/core/codex-sidecar-runner.mjs +14 -1
- package/src/daemon/daemon.mjs +26 -2
- package/src/daemon/haiku-caller.mjs +11 -3
- package/src/hooks/user-prompt.mjs +105 -29
|
@@ -195,7 +195,12 @@ function truncate(s, n = 300) {
|
|
|
195
195
|
// のみで行う。
|
|
196
196
|
export function sanitizeHaikuEnv(baseEnv) {
|
|
197
197
|
const { CLAUDE_CONFIG_DIR: _strip, ...rest } = baseEnv;
|
|
198
|
-
return
|
|
198
|
+
return {
|
|
199
|
+
...rest,
|
|
200
|
+
// Throughline installs a global Claude Stop hook. Every Spotter Haiku child
|
|
201
|
+
// must be non-capturable so auditor prompts cannot re-enter its memory DB.
|
|
202
|
+
THROUGHLINE_IN_HAIKU_SUBPROCESS: '1',
|
|
203
|
+
};
|
|
199
204
|
}
|
|
200
205
|
|
|
201
206
|
// v1.3.0: write the wire prompt to a tempfile and return its read-only fd.
|
|
@@ -218,7 +223,7 @@ export async function preparePromptFile(wirePrompt) {
|
|
|
218
223
|
throw new TypeError('preparePromptFile: wirePrompt must be a string');
|
|
219
224
|
}
|
|
220
225
|
const tmpPath = join(tmpdir(), `spotter-prompt-${process.pid}-${randomUUID()}.txt`);
|
|
221
|
-
await writeFile(tmpPath, wirePrompt, { encoding: 'utf8' });
|
|
226
|
+
await writeFile(tmpPath, wirePrompt, { encoding: 'utf8', mode: 0o600 });
|
|
222
227
|
const handle = await open(tmpPath, 'r');
|
|
223
228
|
return {
|
|
224
229
|
tmpPath,
|
|
@@ -285,7 +290,10 @@ export function createHaikuCaller({ preamble, timeoutMs, claudeBin = 'claude', m
|
|
|
285
290
|
});
|
|
286
291
|
const child = spawn(cmd, cmdArgs, {
|
|
287
292
|
cwd: WORKDIR,
|
|
288
|
-
env: {
|
|
293
|
+
env: {
|
|
294
|
+
...sanitizeHaikuEnv(env),
|
|
295
|
+
SPOTTER_PARENT_PID: String(process.pid),
|
|
296
|
+
},
|
|
289
297
|
stdio: [promptFile.fd, 'pipe', 'pipe'],
|
|
290
298
|
windowsHide: true,
|
|
291
299
|
});
|
|
@@ -25,15 +25,20 @@ 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';
|
|
28
32
|
|
|
29
33
|
const TIMEOUT_MS = 50_000;
|
|
30
|
-
const SHORT_PROMPT_MAX_CHARS = 10;
|
|
31
34
|
|
|
32
35
|
export async function runUserPrompt({
|
|
33
36
|
readInput = readStdinJson,
|
|
34
37
|
sendRequestFn = sendRequest,
|
|
35
38
|
spawnDaemonAndWaitReadyFn = spawnDaemonAndWaitReady,
|
|
36
39
|
discardLegacyPendingFn = discardLegacyPending,
|
|
40
|
+
readAuditorContextConfigFn = readProjectAuditorContextConfig,
|
|
41
|
+
loadAuditorContextFn = loadAuditorContext,
|
|
37
42
|
recordHookEventFn = recordClaudeHookEvent,
|
|
38
43
|
writeOutput = (text) => process.stdout.write(text),
|
|
39
44
|
writeError = (text) => process.stderr.write(text),
|
|
@@ -47,6 +52,7 @@ export async function runUserPrompt({
|
|
|
47
52
|
const prompt = requireString(input, 'prompt');
|
|
48
53
|
const projectRoot = findSpotterMarker(input.cwd);
|
|
49
54
|
const startedAt = Date.now();
|
|
55
|
+
let contextDurationMs = null;
|
|
50
56
|
|
|
51
57
|
const legacyPending = projectRoot
|
|
52
58
|
? await discardLegacyPendingFn({ projectRoot, sessionId })
|
|
@@ -63,19 +69,103 @@ export async function runUserPrompt({
|
|
|
63
69
|
status: 'degraded',
|
|
64
70
|
code: failure.code,
|
|
65
71
|
reason,
|
|
72
|
+
contextDurationMs,
|
|
66
73
|
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
67
74
|
durationMs: Date.now() - startedAt,
|
|
68
75
|
},
|
|
69
76
|
});
|
|
70
77
|
};
|
|
71
78
|
|
|
72
|
-
|
|
79
|
+
const sendDaemonPayload = async (payload) => {
|
|
80
|
+
const send = () => sendRequestFn({
|
|
81
|
+
sessionId,
|
|
82
|
+
event: 'user_input',
|
|
83
|
+
payload,
|
|
84
|
+
timeoutMs: TIMEOUT_MS,
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
return await send();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (!(err instanceof TransportError) || err.code !== 'E_UNREACHABLE') throw err;
|
|
90
|
+
await spawnDaemonAndWaitReadyFn({ sessionId, projectRoot });
|
|
91
|
+
return send();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const syncUserInputWithoutAudit = async (contextStatus) => {
|
|
96
|
+
try {
|
|
97
|
+
const response = await sendDaemonPayload({
|
|
98
|
+
user_input: prompt,
|
|
99
|
+
audit: false,
|
|
100
|
+
context_status: contextStatus,
|
|
101
|
+
});
|
|
102
|
+
if (response.ok !== true) {
|
|
103
|
+
await degrade({ code: response.error?.code ?? 'E_INTERNAL', reason: 'daemon_state_sync' });
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
await degrade({ code: err?.code, reason: 'daemon_state_sync' });
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let context;
|
|
114
|
+
const contextStartedAt = Date.now();
|
|
115
|
+
try {
|
|
116
|
+
const config = await readAuditorContextConfigFn(projectRoot);
|
|
117
|
+
contextDurationMs = Date.now() - contextStartedAt;
|
|
118
|
+
if (config.mode === 'disabled') {
|
|
119
|
+
if (!await syncUserInputWithoutAudit('disabled')) return;
|
|
120
|
+
await recordHookEventFn({
|
|
121
|
+
projectRoot,
|
|
122
|
+
event: {
|
|
123
|
+
hook: 'UserPromptSubmit',
|
|
124
|
+
status: 'skipped',
|
|
125
|
+
reason: 'context_disabled',
|
|
126
|
+
contextStatus: 'disabled',
|
|
127
|
+
contextDurationMs,
|
|
128
|
+
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
129
|
+
durationMs: Date.now() - startedAt,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
context = await loadAuditorContextFn({
|
|
135
|
+
config,
|
|
136
|
+
host: 'claude',
|
|
137
|
+
sessionId,
|
|
138
|
+
projectRoot,
|
|
139
|
+
transcriptPath: requireString(input, 'transcript_path'),
|
|
140
|
+
});
|
|
141
|
+
contextDurationMs = Date.now() - contextStartedAt;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
contextDurationMs = Date.now() - contextStartedAt;
|
|
144
|
+
await degrade({ code: err?.code, reason: 'auditor_context' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (context.status === 'unavailable' || context.status === 'schema_mismatch') {
|
|
149
|
+
await degrade({
|
|
150
|
+
code: context.status === 'unavailable'
|
|
151
|
+
? 'E_AUDITOR_CONTEXT_UNAVAILABLE'
|
|
152
|
+
: 'E_AUDITOR_CONTEXT_SCHEMA',
|
|
153
|
+
reason: 'auditor_context_status',
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (context.status !== 'fresh') {
|
|
158
|
+
if (!await syncUserInputWithoutAudit(context.status)) return;
|
|
73
159
|
await recordHookEventFn({
|
|
74
160
|
projectRoot,
|
|
75
161
|
event: {
|
|
76
162
|
hook: 'UserPromptSubmit',
|
|
77
163
|
status: 'skipped',
|
|
78
|
-
reason: '
|
|
164
|
+
reason: 'context_not_fresh',
|
|
165
|
+
contextStatus: context.status,
|
|
166
|
+
contextTurns: 0,
|
|
167
|
+
contextChars: 0,
|
|
168
|
+
contextDurationMs,
|
|
79
169
|
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
80
170
|
durationMs: Date.now() - startedAt,
|
|
81
171
|
},
|
|
@@ -83,35 +173,17 @@ export async function runUserPrompt({
|
|
|
83
173
|
return;
|
|
84
174
|
}
|
|
85
175
|
|
|
86
|
-
const sendUserInput = () =>
|
|
87
|
-
sendRequestFn({
|
|
88
|
-
sessionId,
|
|
89
|
-
event: 'user_input',
|
|
90
|
-
payload: { user_input: prompt },
|
|
91
|
-
timeoutMs: TIMEOUT_MS,
|
|
92
|
-
});
|
|
93
|
-
|
|
94
176
|
let response;
|
|
95
177
|
try {
|
|
96
|
-
response = await
|
|
178
|
+
response = await sendDaemonPayload({
|
|
179
|
+
user_input: prompt,
|
|
180
|
+
audit: true,
|
|
181
|
+
context_status: 'fresh',
|
|
182
|
+
recent_context: context.turns,
|
|
183
|
+
});
|
|
97
184
|
} catch (err) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// projectRoot is guaranteed non-null here (isOutsideSpotterProject early-returned otherwise).
|
|
101
|
-
try {
|
|
102
|
-
await spawnDaemonAndWaitReadyFn({ sessionId, projectRoot });
|
|
103
|
-
response = await sendUserInput();
|
|
104
|
-
} catch (recoverErr) {
|
|
105
|
-
await degrade({
|
|
106
|
-
code: recoverErr?.code ?? 'E_RESURRECT_FAILED',
|
|
107
|
-
reason: 'resurrect_failed',
|
|
108
|
-
});
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
} else {
|
|
112
|
-
await degrade({ code: err?.code, reason: 'transport' });
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
185
|
+
await degrade({ code: err?.code ?? 'E_RESURRECT_FAILED', reason: 'transport_or_resurrect' });
|
|
186
|
+
return;
|
|
115
187
|
}
|
|
116
188
|
|
|
117
189
|
if (response.ok !== true) {
|
|
@@ -136,6 +208,10 @@ export async function runUserPrompt({
|
|
|
136
208
|
status: 'success',
|
|
137
209
|
pass: result.pass === true,
|
|
138
210
|
missingTools: toolIds,
|
|
211
|
+
contextStatus: 'fresh',
|
|
212
|
+
contextTurns: context.stats.returnedTurns,
|
|
213
|
+
contextChars: context.stats.chars,
|
|
214
|
+
contextDurationMs,
|
|
139
215
|
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
140
216
|
durationMs: Date.now() - startedAt,
|
|
141
217
|
},
|