litmus-cli 1.3.9 → 1.3.11
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/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +95 -22
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/submit.js +15 -1
- package/dist/commands/submit.js.map +1 -1
- package/dist/lib/ai-tracking.d.ts +61 -0
- package/dist/lib/ai-tracking.d.ts.map +1 -1
- package/dist/lib/ai-tracking.js +342 -4
- package/dist/lib/ai-tracking.js.map +1 -1
- package/dist/lib/hook-logger.cjs +341 -48
- package/package.json +1 -1
package/dist/lib/hook-logger.cjs
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Litmus AI prompt logger.
|
|
4
|
-
* Universal hook script invoked by Claude Code, GitHub Copilot CLI,
|
|
4
|
+
* Universal hook script invoked by Claude Code, GitHub Copilot CLI, OpenAI Codex
|
|
5
|
+
* CLI, and Cursor.
|
|
5
6
|
*
|
|
6
7
|
* Usage:
|
|
7
8
|
* node hook-logger.cjs <tool>
|
|
8
9
|
*
|
|
9
10
|
* Each tool pipes JSON on stdin when a user submits a prompt:
|
|
10
11
|
* - Claude Code (UserPromptSubmit): stdin JSON with session_id, prompt content
|
|
11
|
-
* - Copilot
|
|
12
|
+
* - GitHub Copilot (UserPromptSubmit): CLI sends { sessionId, timestamp, cwd,
|
|
13
|
+
* prompt }; VS Code Copilot Chat sends { hook_event_name, session_id,
|
|
14
|
+
* transcript_path, timestamp, prompt } and notably NO cwd
|
|
12
15
|
* - Codex CLI: experimental hooks support (format TBD)
|
|
16
|
+
* - Cursor (beforeSubmitPrompt): { conversation_id, generation_id, prompt, ... }
|
|
17
|
+
*
|
|
18
|
+
* Cursor is the only one that expects something back on stdout — see
|
|
19
|
+
* `emitCursorVerdictIfNeeded`. For every other tool stdout MUST stay empty:
|
|
20
|
+
* Claude Code injects it into the candidate's prompt as context.
|
|
13
21
|
*
|
|
14
22
|
* Normalizes to: { ts, type: "ai_prompt", tool, prompt, sessionId? }
|
|
15
23
|
* Appends to .litmus/activity.jsonl — the same file the watcher uses,
|
|
16
24
|
* so prompts flow through the existing analysis pipeline.
|
|
17
|
-
* Also uploads the event to
|
|
25
|
+
* Also uploads the event to /cli/activity so it reaches candidate_activity_logs.
|
|
26
|
+
* That upload is AWAITED (bounded) — see uploadEvent for why an unawaited one
|
|
27
|
+
* silently stopped working.
|
|
18
28
|
*/
|
|
19
29
|
|
|
20
30
|
const fs = require("fs")
|
|
@@ -35,6 +45,21 @@ const STDIN_TIMEOUT_MS = 5000
|
|
|
35
45
|
|
|
36
46
|
const tool = process.argv[2] || "unknown"
|
|
37
47
|
|
|
48
|
+
// Optional explicit assessment directory (argv[3]).
|
|
49
|
+
//
|
|
50
|
+
// Repo-scope hooks are written INTO an assessment, so they know its absolute
|
|
51
|
+
// path at install time and should never have to guess. That matters most for
|
|
52
|
+
// Copilot: VS Code Copilot Chat's payload carries no `cwd` and no workspace
|
|
53
|
+
// roots, so without this the only resolution left is the cwd walk-up (wrong
|
|
54
|
+
// whenever the editor's process starts elsewhere) or the active-assessments
|
|
55
|
+
// registry — and the registry is not always populated. `litmus init` removes
|
|
56
|
+
// the entry in project-scope mode, which silently disabled Copilot Chat capture
|
|
57
|
+
// for exactly the candidates whose HOME could not take the user-scope hook.
|
|
58
|
+
//
|
|
59
|
+
// Passing it explicitly also sidesteps the registry's first-match behaviour,
|
|
60
|
+
// which misattributes prompts when a candidate has more than one assessment.
|
|
61
|
+
const explicitAssessmentDir = process.argv[3] || null
|
|
62
|
+
|
|
38
63
|
// A directory only counts as an assessment if it has a config.json. Without
|
|
39
64
|
// this guard, the walk-up would resolve to `~/.litmus/` (created by the
|
|
40
65
|
// user-scope install) for any Claude Code session whose cwd is in HOME but
|
|
@@ -49,15 +74,9 @@ function isAssessmentLitmusDir(litmusDir) {
|
|
|
49
74
|
}
|
|
50
75
|
}
|
|
51
76
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// assessment dir). The registry fallback handles the case where Claude Code,
|
|
56
|
-
// Codex, or Copilot CLI fires from a cwd outside the assessment dir — e.g.
|
|
57
|
-
// VS Code workspace opened at a parent dir, or the candidate ran `claude`
|
|
58
|
-
// from their home dir.
|
|
59
|
-
function findLitmusDir() {
|
|
60
|
-
let dir = process.cwd()
|
|
77
|
+
// Walk up from `startDir` looking for an assessment .litmus dir.
|
|
78
|
+
function walkUpForLitmusDir(startDir) {
|
|
79
|
+
let dir = startDir
|
|
61
80
|
for (let i = 0; i < 10; i++) {
|
|
62
81
|
const candidate = path.join(dir, ".litmus")
|
|
63
82
|
if (isAssessmentLitmusDir(candidate)) return candidate
|
|
@@ -65,6 +84,27 @@ function findLitmusDir() {
|
|
|
65
84
|
if (parent === dir) break
|
|
66
85
|
dir = parent
|
|
67
86
|
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Find the .litmus directory — walk up from any workspace roots the tool told
|
|
91
|
+
// us about, then from cwd, then fall back to the active-assessments registry
|
|
92
|
+
// that `litmus init` writes to ~/.litmus/.
|
|
93
|
+
//
|
|
94
|
+
// `hintDirs` matters because not every tool runs the hook from the workspace.
|
|
95
|
+
// Verified against a real Cursor 3.13.25 session: Cursor invokes the hook with
|
|
96
|
+
// cwd = ~/.cursor and passes the actual workspace in `workspace_roots`. Without
|
|
97
|
+
// the hint the cwd walk-up can never match, and we'd silently fall through to
|
|
98
|
+
// the registry — which returns the FIRST active assessment, misattributing
|
|
99
|
+
// prompts whenever a candidate has more than one on the go.
|
|
100
|
+
function findLitmusDir(hintDirs = []) {
|
|
101
|
+
for (const hint of hintDirs) {
|
|
102
|
+
if (typeof hint !== "string" || !hint) continue
|
|
103
|
+
const found = walkUpForLitmusDir(hint)
|
|
104
|
+
if (found) return found
|
|
105
|
+
}
|
|
106
|
+
const fromCwd = walkUpForLitmusDir(process.cwd())
|
|
107
|
+
if (fromCwd) return fromCwd
|
|
68
108
|
try {
|
|
69
109
|
const home = os.homedir()
|
|
70
110
|
if (!home) return null
|
|
@@ -90,46 +130,133 @@ function readConfig(litmusDir) {
|
|
|
90
130
|
}
|
|
91
131
|
}
|
|
92
132
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
133
|
+
// Upload budget. The measured round trip to the backend is ~240ms (dns 15ms,
|
|
134
|
+
// tcp 39ms, TLS 154ms, complete 242ms against Railway), so 2s is generous while
|
|
135
|
+
// staying far inside every hook runner's allowance: Claude Code gives its
|
|
136
|
+
// UserPromptSubmit hook 5s, Cursor 10s, Codex 30s. Exceeding it is not worth
|
|
137
|
+
// delaying a candidate's prompt over — activity.jsonl already has the event and
|
|
138
|
+
// the submission zip carries it to the grader.
|
|
139
|
+
const UPLOAD_TIMEOUT_MS = 2000
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* POST the event to /cli/activity and WAIT for it to complete.
|
|
143
|
+
*
|
|
144
|
+
* This used to be fire-and-forget: it called `socket.unref()` so Node wouldn't
|
|
145
|
+
* hold the process open for the response. That only ever worked by accident —
|
|
146
|
+
* `readStdin`'s timeout timer was never cleared, which kept the event loop alive
|
|
147
|
+
* ~5s, comfortably longer than the upload. Clearing that timer (correctly, it was
|
|
148
|
+
* spending the entire hook budget doing nothing) dropped the process lifetime to
|
|
149
|
+
* ~26ms, which is BEFORE tcp connect at 39ms, let alone TLS at 154ms. Every
|
|
150
|
+
* hook-captured prompt silently stopped reaching candidate_activity_logs.
|
|
151
|
+
*
|
|
152
|
+
* That was invisible because grading still worked: the grader merges the in-zip
|
|
153
|
+
* activity.jsonl as well as the DB rows. But anything reading the DB alone went
|
|
154
|
+
* blank — including the report's own AI-prompts drawer, which queries
|
|
155
|
+
* candidateActivityLog and renders nothing when empty. Confirmed in the caroline
|
|
156
|
+
* DB: zero ai_prompt rows for a submission whose zip had three, while the
|
|
157
|
+
* long-lived watcher's uploads (heartbeat, env_detected) all landed.
|
|
158
|
+
*
|
|
159
|
+
* So we await it now, bounded. Resolves rather than rejects on every failure
|
|
160
|
+
* path: a telemetry upload must never fail a candidate's prompt. Both timers are
|
|
161
|
+
* cleared on settle — the bug above is exactly what an uncleared timer costs.
|
|
162
|
+
*/
|
|
96
163
|
function uploadEvent(config, event) {
|
|
97
|
-
if (!config || !config.token || !config.backendUrl) return
|
|
164
|
+
if (!config || !config.token || !config.backendUrl) return Promise.resolve()
|
|
165
|
+
|
|
166
|
+
let url
|
|
167
|
+
try {
|
|
168
|
+
url = new URL(`${config.backendUrl}/cli/activity`)
|
|
169
|
+
} catch {
|
|
170
|
+
return Promise.resolve() // malformed backendUrl — nothing to do
|
|
171
|
+
}
|
|
98
172
|
|
|
99
173
|
const body = JSON.stringify({ events: [event] })
|
|
100
|
-
const url = new URL(`${config.backendUrl}/cli/activity`)
|
|
101
174
|
const reqFn = url.protocol === "https:" ? https.request : http.request
|
|
102
175
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
let settled = false
|
|
178
|
+
let guard = null
|
|
179
|
+
const done = () => {
|
|
180
|
+
if (settled) return
|
|
181
|
+
settled = true
|
|
182
|
+
if (guard) clearTimeout(guard)
|
|
183
|
+
resolve()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const req = reqFn(url, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: {
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
"Content-Length": Buffer.byteLength(body),
|
|
191
|
+
"Authorization": `Bearer ${config.token}`,
|
|
192
|
+
},
|
|
193
|
+
timeout: UPLOAD_TIMEOUT_MS,
|
|
194
|
+
})
|
|
195
|
+
// Drain the response so the socket can close and the event loop empty.
|
|
196
|
+
req.on("response", (res) => { res.resume(); res.on("end", done); res.on("error", done) })
|
|
197
|
+
req.on("error", done) // network down, DNS, refused, destroyed
|
|
198
|
+
req.on("timeout", () => { req.destroy(); done() })
|
|
199
|
+
// Backstop in case none of the above fires (a socket wedged before connect).
|
|
200
|
+
guard = setTimeout(() => { try { req.destroy() } catch { /* already gone */ } done() }, UPLOAD_TIMEOUT_MS)
|
|
201
|
+
req.end(body)
|
|
111
202
|
})
|
|
112
|
-
req.on("error", () => {}) // Swallow — fire-and-forget
|
|
113
|
-
req.on("timeout", () => { req.destroy() })
|
|
114
|
-
req.on("socket", (socket) => { socket.unref() }) // Don't keep process alive for response
|
|
115
|
-
req.end(body)
|
|
116
203
|
}
|
|
117
204
|
|
|
118
205
|
function readStdin() {
|
|
119
206
|
return new Promise((resolve) => {
|
|
120
207
|
let resolved = false
|
|
208
|
+
let timer = null
|
|
121
209
|
const chunks = []
|
|
210
|
+
// clearTimeout is load-bearing, not tidiness. A pending timer keeps the
|
|
211
|
+
// Node event loop alive after main() has finished, so without this the
|
|
212
|
+
// process lingers for the FULL STDIN_TIMEOUT_MS on every single
|
|
213
|
+
// invocation even though stdin closed in milliseconds. That silently ate
|
|
214
|
+
// the entire 5s budget Claude Code allows its UserPromptSubmit hook.
|
|
215
|
+
const done = () => {
|
|
216
|
+
if (resolved) return
|
|
217
|
+
resolved = true
|
|
218
|
+
if (timer) clearTimeout(timer)
|
|
219
|
+
resolve(chunks.join(""))
|
|
220
|
+
}
|
|
122
221
|
process.stdin.setEncoding("utf8")
|
|
123
|
-
process.stdin.on("data", (chunk) =>
|
|
124
|
-
|
|
125
|
-
|
|
222
|
+
process.stdin.on("data", (chunk) => {
|
|
223
|
+
chunks.push(chunk)
|
|
224
|
+
// Resolve as soon as the payload is a COMPLETE JSON document instead of
|
|
225
|
+
// waiting for EOF.
|
|
226
|
+
//
|
|
227
|
+
// Not every hook runner closes stdin after writing. Cursor demonstrably
|
|
228
|
+
// does not: every Cursor prompt burned the full STDIN_TIMEOUT_MS, which
|
|
229
|
+
// killed the 5s-budget Claude entry outright (exit 1). Waiting for EOF
|
|
230
|
+
// therefore spends the whole hook budget doing nothing, and the budget is
|
|
231
|
+
// what the awaited /cli/activity upload has to fit inside — so on any
|
|
232
|
+
// such runner the process is killed before the upload lands and prompts
|
|
233
|
+
// silently stop reaching candidate_activity_logs. That is precisely the
|
|
234
|
+
// regression 2c1aeeb1b fixed for Cursor; resolving on a complete document
|
|
235
|
+
// removes the whole failure mode rather than re-tuning timeouts per tool.
|
|
236
|
+
//
|
|
237
|
+
// Safe against partial reads: every payload is a JSON object, and a
|
|
238
|
+
// truncated object never parses (`{"prompt":` throws). Worst case this
|
|
239
|
+
// never fires and we fall back to EOF or the timeout, i.e. today's
|
|
240
|
+
// behaviour.
|
|
241
|
+
if (isCompleteJson(chunks.join(""))) done()
|
|
126
242
|
})
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
243
|
+
process.stdin.on("end", done)
|
|
244
|
+
process.stdin.on("error", done) // closed/absent stdin must not hang either
|
|
245
|
+
timer = setTimeout(done, STDIN_TIMEOUT_MS)
|
|
130
246
|
})
|
|
131
247
|
}
|
|
132
248
|
|
|
249
|
+
function isCompleteJson(buf) {
|
|
250
|
+
const trimmed = buf.trim()
|
|
251
|
+
if (!trimmed) return false
|
|
252
|
+
try {
|
|
253
|
+
JSON.parse(trimmed)
|
|
254
|
+
return true
|
|
255
|
+
} catch {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
133
260
|
function extractPrompt(input, toolName) {
|
|
134
261
|
try {
|
|
135
262
|
const data = JSON.parse(input)
|
|
@@ -145,10 +272,24 @@ function extractPrompt(input, toolName) {
|
|
|
145
272
|
}
|
|
146
273
|
}
|
|
147
274
|
case "copilot": {
|
|
148
|
-
// Copilot
|
|
275
|
+
// GitHub Copilot, both surfaces. We register the hook under the
|
|
276
|
+
// PascalCase `UserPromptSubmit` key, which Copilot CLI maps onto its
|
|
277
|
+
// internal `userPromptSubmitted` (alias map verified in the first-party
|
|
278
|
+
// @github/copilot SDK) and which VS Code Copilot Chat uses natively —
|
|
279
|
+
// so one config serves the terminal binary, the cloud agent, VS Code
|
|
280
|
+
// and JetBrains.
|
|
281
|
+
//
|
|
282
|
+
// Payloads differ slightly by surface, so read both spellings:
|
|
283
|
+
// Copilot CLI { sessionId | session_id, timestamp, cwd, prompt }
|
|
284
|
+
// VS Code Chat { hook_event_name, session_id?, transcript_path?,
|
|
285
|
+
// timestamp, prompt } <- note: no cwd
|
|
286
|
+
// The missing cwd is why this arm must never rely on the hook's working
|
|
287
|
+
// directory to locate the assessment; resolution comes from the
|
|
288
|
+
// registry fallback in findLitmusDir.
|
|
289
|
+
const prompt = data.prompt || null
|
|
149
290
|
return {
|
|
150
|
-
prompt:
|
|
151
|
-
sessionId: null,
|
|
291
|
+
prompt: typeof prompt === "string" ? prompt : JSON.stringify(prompt),
|
|
292
|
+
sessionId: data.session_id || data.sessionId || null,
|
|
152
293
|
}
|
|
153
294
|
}
|
|
154
295
|
case "codex": {
|
|
@@ -161,6 +302,25 @@ function extractPrompt(input, toolName) {
|
|
|
161
302
|
sessionId: data.session_id || null,
|
|
162
303
|
}
|
|
163
304
|
}
|
|
305
|
+
case "cursor": {
|
|
306
|
+
// Cursor beforeSubmitPrompt. Payload captured verbatim from a real
|
|
307
|
+
// Cursor 3.13.25 agent session:
|
|
308
|
+
// { conversation_id, generation_id, model, model_id, model_params,
|
|
309
|
+
// composer_mode, prompt, attachments, session_id, hook_event_name,
|
|
310
|
+
// cursor_version, workspace_roots, user_email, transcript_path }
|
|
311
|
+
// session_id and conversation_id carry the same value; prefer session_id
|
|
312
|
+
// to match every other tool here. `workspace_roots` is load-bearing —
|
|
313
|
+
// Cursor runs the hook with cwd = ~/.cursor, so it is the only reliable
|
|
314
|
+
// pointer back to the assessment directory.
|
|
315
|
+
// Deliberately NOT captured: user_email (PII we have no use for) and
|
|
316
|
+
// transcript_path (points at Cursor's own on-disk agent transcript).
|
|
317
|
+
const prompt = data.prompt || null
|
|
318
|
+
return {
|
|
319
|
+
prompt: typeof prompt === "string" ? prompt : JSON.stringify(prompt),
|
|
320
|
+
sessionId: data.session_id || data.conversation_id || null,
|
|
321
|
+
workspaceRoots: Array.isArray(data.workspace_roots) ? data.workspace_roots : [],
|
|
322
|
+
}
|
|
323
|
+
}
|
|
164
324
|
default: {
|
|
165
325
|
// Best-effort: look for common field names
|
|
166
326
|
const prompt = data.prompt || data.user_message || data.content || null
|
|
@@ -171,25 +331,156 @@ function extractPrompt(input, toolName) {
|
|
|
171
331
|
}
|
|
172
332
|
}
|
|
173
333
|
} catch {
|
|
174
|
-
return { prompt: null, sessionId: null }
|
|
334
|
+
return { prompt: null, sessionId: null, workspaceRoots: [] }
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Cursor requires a JSON verdict on stdout; non-JSON output trips a parse error
|
|
339
|
+
// in its hook runtime and the integration silently breaks. We are pure
|
|
340
|
+
// observation and never block a prompt, so the verdict is a constant.
|
|
341
|
+
//
|
|
342
|
+
// Emitted FIRST, before any capture work, so a slow filesystem, a missing
|
|
343
|
+
// assessment, or an outright crash can never delay or swallow it — the
|
|
344
|
+
// candidate's agent proceeds regardless of whether we captured anything.
|
|
345
|
+
//
|
|
346
|
+
// Gated on the RAW argv tool, never on `effectiveTool`: Claude Code injects
|
|
347
|
+
// hook stdout into the candidate's prompt as added context, and `effectiveTool`
|
|
348
|
+
// relabels claude -> cursor when Claude Code is driven from inside Cursor.
|
|
349
|
+
// Keying off the relabel would leak `{"continue":true}` into those prompts.
|
|
350
|
+
function emitCursorVerdictIfNeeded() {
|
|
351
|
+
if (tool !== "cursor") return
|
|
352
|
+
try {
|
|
353
|
+
process.stdout.write(JSON.stringify({ continue: true }))
|
|
354
|
+
} catch {
|
|
355
|
+
// Closed/broken stdout — Cursor treats absent output as "proceed" anyway.
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Cursor merges EVERY config source it finds and runs all matching hooks, and it
|
|
360
|
+
// translates Claude Code's `UserPromptSubmit` onto its own `beforeSubmitPrompt`.
|
|
361
|
+
// Since `litmus init` installs into both ~/.cursor/hooks.json and
|
|
362
|
+
// ~/.claude/settings.json, one Cursor prompt fires us TWICE. Confirmed in
|
|
363
|
+
// Cursor's own hooks log:
|
|
364
|
+
//
|
|
365
|
+
// Found 2 hook(s) to execute for step: beforeSubmitPrompt
|
|
366
|
+
// Executing hook 1/2 from user config (~/.cursor/hooks.json)
|
|
367
|
+
// Executing hook 2/2 from claude-user config (~/.claude/settings.json)
|
|
368
|
+
//
|
|
369
|
+
// Both invocations got the identical Cursor payload, so a single prompt produced
|
|
370
|
+
// two activity rows with the same text and timestamp, differing only in `tool`
|
|
371
|
+
// (`cursor` and `claude`). That double-counts prompts and misattributes half of
|
|
372
|
+
// them to Claude Code, which was never involved.
|
|
373
|
+
//
|
|
374
|
+
// Dedupe by identity rather than by inspecting the log: the two hooks run
|
|
375
|
+
// concurrently (observed writing at the same millisecond), so any
|
|
376
|
+
// read-then-write check would race. Instead the claude-registered invocation
|
|
377
|
+
// stands down when the payload is visibly Cursor's — `cursor_version` is present
|
|
378
|
+
// on every beforeSubmitPrompt payload and never on a Claude Code one — leaving
|
|
379
|
+
// the dedicated cursor hook as the single writer.
|
|
380
|
+
//
|
|
381
|
+
// Gated on our cursor hook actually being installed. If that install failed
|
|
382
|
+
// (unwritable ~/.cursor, refused config shape) nothing else would capture the
|
|
383
|
+
// prompt, and dropping it would be worse than a duplicate.
|
|
384
|
+
function isCursorPayload(data) {
|
|
385
|
+
if (!data || typeof data !== "object") return false
|
|
386
|
+
return typeof data.cursor_version === "string"
|
|
387
|
+
|| data.hook_event_name === "beforeSubmitPrompt"
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Dedupe by CLAIMING the prompt, not by predicting whether the sibling hook will
|
|
391
|
+
// succeed. An earlier version inspected ~/.cursor/hooks.json and stood down if a
|
|
392
|
+
// Cursor hook looked installed, but that is guesswork about another process, and
|
|
393
|
+
// the failure is asymmetric: guess wrong and the prompt is lost entirely, where
|
|
394
|
+
// the worst case in the other direction is a duplicate row. A hook can be
|
|
395
|
+
// configured and still not run (logger missing, truncated, unreadable, or broken
|
|
396
|
+
// in a way no amount of statting reveals), so there is no check that makes the
|
|
397
|
+
// prediction sound.
|
|
398
|
+
//
|
|
399
|
+
// Instead, both invocations race to create a marker for this specific prompt.
|
|
400
|
+
// `openSync(..., "wx")` is O_CREAT|O_EXCL, so exactly one wins — no read-then-write
|
|
401
|
+
// window, which matters because the two hooks run concurrently (observed writing
|
|
402
|
+
// in the same millisecond). The winner writes the event; the loser exits. If the
|
|
403
|
+
// Cursor hook never runs at all, the Claude invocation simply wins uncontested.
|
|
404
|
+
//
|
|
405
|
+
// Keyed on `generation_id`, which is per prompt submission. NOT conversation_id,
|
|
406
|
+
// which is stable for a whole thread and would collapse every prompt in a
|
|
407
|
+
// conversation into one. If it is absent we do not dedupe at all — a duplicate is
|
|
408
|
+
// the acceptable failure, a dropped prompt is not.
|
|
409
|
+
const CLAIM_DIR = path.join(os.homedir() || os.tmpdir(), ".litmus", "claims")
|
|
410
|
+
const CLAIM_TTL_MS = 60 * 60 * 1000
|
|
411
|
+
|
|
412
|
+
// Keep the markers from accumulating in the candidate's HOME forever. Cheap: this
|
|
413
|
+
// directory only ever holds an hour of prompts.
|
|
414
|
+
function pruneClaims() {
|
|
415
|
+
try {
|
|
416
|
+
const cutoff = Date.now() - CLAIM_TTL_MS
|
|
417
|
+
for (const name of fs.readdirSync(CLAIM_DIR)) {
|
|
418
|
+
const p = path.join(CLAIM_DIR, name)
|
|
419
|
+
try {
|
|
420
|
+
if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p)
|
|
421
|
+
} catch { /* raced with another invocation — fine */ }
|
|
422
|
+
}
|
|
423
|
+
} catch { /* no dir yet, or unreadable */ }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** True when THIS invocation should write the event. */
|
|
427
|
+
function claimPrompt(generationId) {
|
|
428
|
+
if (typeof generationId !== "string" || !generationId) return true // can't dedupe → capture
|
|
429
|
+
const safe = generationId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128)
|
|
430
|
+
try {
|
|
431
|
+
fs.mkdirSync(CLAIM_DIR, { recursive: true })
|
|
432
|
+
pruneClaims()
|
|
433
|
+
fs.closeSync(fs.openSync(path.join(CLAIM_DIR, safe), "wx"))
|
|
434
|
+
return true
|
|
435
|
+
} catch (e) {
|
|
436
|
+
if (e && e.code === "EEXIST") return false // sibling already has it
|
|
437
|
+
return true // unwritable claim dir → fail toward capture
|
|
175
438
|
}
|
|
176
439
|
}
|
|
177
440
|
|
|
178
441
|
async function main() {
|
|
179
|
-
|
|
180
|
-
if (!litmusDir) return // Not in a Litmus assessment — silently exit
|
|
442
|
+
emitCursorVerdictIfNeeded()
|
|
181
443
|
|
|
444
|
+
// stdin is read BEFORE resolving the assessment dir: the payload is what
|
|
445
|
+
// tells us where the workspace actually is (see findLitmusDir's hintDirs).
|
|
182
446
|
const raw = await readStdin()
|
|
183
447
|
if (!raw.trim()) return
|
|
184
448
|
|
|
185
|
-
// Detect Cursor firing Claude Code hooks
|
|
186
|
-
|
|
449
|
+
// Detect Cursor firing Claude Code hooks. CURSOR_TRACE_ID is checked for the
|
|
450
|
+
// real Claude-Code-inside-Cursor case (a Claude-shaped payload from Cursor's
|
|
451
|
+
// integrated terminal), but it is NOT set when Cursor's own hooks service
|
|
452
|
+
// spawns us — verified against a real session, where the compat-path event
|
|
453
|
+
// stayed labelled `claude`. The payload shape is the reliable signal.
|
|
454
|
+
let parsed = null
|
|
455
|
+
try { parsed = JSON.parse(raw) } catch { /* extractPrompt reports the failure */ }
|
|
456
|
+
const cursorShaped = isCursorPayload(parsed)
|
|
457
|
+
|
|
458
|
+
// The relabel is what makes the claim below label-safe: whichever invocation
|
|
459
|
+
// wins the race, a Cursor-shaped payload is recorded as tool="cursor", so the
|
|
460
|
+
// event never blames Claude Code for a prompt it had no part in.
|
|
461
|
+
const effectiveTool = (tool === "claude" && (cursorShaped || process.env.CURSOR_TRACE_ID))
|
|
187
462
|
? "cursor"
|
|
188
463
|
: tool
|
|
189
464
|
|
|
190
|
-
const { prompt, sessionId } = extractPrompt(raw, effectiveTool)
|
|
465
|
+
const { prompt, sessionId, workspaceRoots } = extractPrompt(raw, effectiveTool)
|
|
191
466
|
if (!prompt) return
|
|
192
467
|
|
|
468
|
+
// Explicit hint first: it is the only source that is correct by construction.
|
|
469
|
+
// Then the tool-supplied workspace roots, then cwd, then the registry.
|
|
470
|
+
const hintDirs = explicitAssessmentDir
|
|
471
|
+
? [explicitAssessmentDir, ...(workspaceRoots || [])]
|
|
472
|
+
: (workspaceRoots || [])
|
|
473
|
+
const litmusDir = findLitmusDir(hintDirs)
|
|
474
|
+
if (!litmusDir) return // Not in a Litmus assessment — silently exit
|
|
475
|
+
|
|
476
|
+
// Claim as LATE as possible — only once we know we have a prompt and somewhere
|
|
477
|
+
// to put it. Claiming earlier would let an invocation that then bails (no
|
|
478
|
+
// prompt, no assessment in scope) consume the claim and silence the sibling
|
|
479
|
+
// that would have written successfully.
|
|
480
|
+
if (cursorShaped && !claimPrompt(parsed && parsed.generation_id)) {
|
|
481
|
+
return // the sibling hook is writing this same prompt
|
|
482
|
+
}
|
|
483
|
+
|
|
193
484
|
const truncated = prompt.length > MAX_PROMPT_LENGTH
|
|
194
485
|
? prompt.slice(0, MAX_PROMPT_LENGTH) + "...[truncated]"
|
|
195
486
|
: prompt
|
|
@@ -210,12 +501,14 @@ async function main() {
|
|
|
210
501
|
// Non-critical
|
|
211
502
|
}
|
|
212
503
|
|
|
213
|
-
// 2. Upload to server
|
|
504
|
+
// 2. Upload to the server so the event reaches candidate_activity_logs. Awaited
|
|
505
|
+
// (bounded by UPLOAD_TIMEOUT_MS) because the process now exits far too fast for
|
|
506
|
+
// an unawaited request to survive — see uploadEvent.
|
|
214
507
|
const config = readConfig(litmusDir)
|
|
215
|
-
uploadEvent(config, event)
|
|
508
|
+
await uploadEvent(config, event)
|
|
216
509
|
|
|
217
|
-
// No explicit process.exit() — the event loop drains naturally
|
|
218
|
-
//
|
|
510
|
+
// No explicit process.exit() — the event loop drains naturally once the upload
|
|
511
|
+
// has settled and its timers are cleared.
|
|
219
512
|
}
|
|
220
513
|
|
|
221
514
|
main().catch(() => process.exit(0))
|