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.
@@ -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 rest;
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: { ...sanitizeHaikuEnv(env), SPOTTER_PARENT_PID: String(process.pid) },
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
- if ([...prompt.trim()].length <= SHORT_PROMPT_MAX_CHARS) {
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: 'short_prompt',
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 sendUserInput();
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
- if (err instanceof TransportError && err.code === 'E_UNREACHABLE') {
99
- // v0.12.0: daemon is gone (heartbeat shutdown, crash, missing). Resurrect and retry once.
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
  },