litmus-cli 1.4.32 → 1.4.34
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/README.md +11 -0
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +59 -6
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +89 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts +56 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +198 -37
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +72 -5
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/submit.d.ts.map +1 -1
- package/dist/commands/submit.js +12 -17
- package/dist/commands/submit.js.map +1 -1
- package/dist/lib/ai-tracking.d.ts +21 -0
- package/dist/lib/ai-tracking.d.ts.map +1 -1
- package/dist/lib/ai-tracking.js +67 -4
- package/dist/lib/ai-tracking.js.map +1 -1
- package/dist/lib/api.d.ts +24 -1
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +52 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/capture-nudge.d.ts.map +1 -1
- package/dist/lib/capture-nudge.js +5 -3
- package/dist/lib/capture-nudge.js.map +1 -1
- package/dist/lib/capture-selftest.d.ts +202 -0
- package/dist/lib/capture-selftest.d.ts.map +1 -0
- package/dist/lib/capture-selftest.js +492 -0
- package/dist/lib/capture-selftest.js.map +1 -0
- package/dist/lib/cursor-restart.d.ts +260 -0
- package/dist/lib/cursor-restart.d.ts.map +1 -0
- package/dist/lib/cursor-restart.js +308 -0
- package/dist/lib/cursor-restart.js.map +1 -0
- package/dist/lib/hook-logger.cjs +373 -42
- package/dist/lib/schemas.d.ts +5 -0
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +10 -0
- package/dist/lib/schemas.js.map +1 -1
- package/dist/lib/submit-route.d.ts +32 -41
- package/dist/lib/submit-route.d.ts.map +1 -1
- package/dist/lib/submit-route.js +33 -42
- package/dist/lib/submit-route.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/hook-logger.cjs
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
* Reads the tool's JSON payload on stdin and normalizes it to one of:
|
|
8
8
|
* { ts, type: "ai_prompt", tool, prompt, sessionId? } — prompt-submit hooks
|
|
9
9
|
* { ts, type: "ai_response", tool, text, sessionId? } — post-response hooks (ENG-1323)
|
|
10
|
+
* { ts, type: "capture_selftest_probe", tool, nonce } — the CLI's own capture
|
|
11
|
+
* self-test (ENG-2477)
|
|
10
12
|
* appends it to the assessment's activity.jsonl, and uploads it (awaited,
|
|
11
13
|
* bounded — see uploadEvent). Which shape applies is decided by the payload's
|
|
12
14
|
* `hook_event_name`, not argv — the same logger command is registered for both
|
|
13
|
-
* event kinds per tool
|
|
15
|
+
* event kinds per tool — except the probe, which is marked by a
|
|
16
|
+
* `litmus_selftest` field no tool sends (see main()).
|
|
14
17
|
*
|
|
15
18
|
* An `ai_response` may additionally carry the turn's metering, when the tool's
|
|
16
19
|
* payload has it: `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`,
|
|
@@ -27,6 +30,9 @@
|
|
|
27
30
|
* `stop` and this branch is unreachable there)
|
|
28
31
|
* stop payload -> read transcript_path (JSONL), the final turn's
|
|
29
32
|
* answer, plus the payload's own model + token counts
|
|
33
|
+
* afterFileEdit payload -> no event of its own: the file the agent
|
|
34
|
+
* edited is ledgered and rides the turn's `ai_response` as
|
|
35
|
+
* ENG-2191 `file_writes` (ENG-2344, see the Cursor edits block)
|
|
30
36
|
* copilot Stop payload -> read transcript_path (JSONL), assistant.message
|
|
31
37
|
* entries of the final turn (VS Code shape; CLI best-effort)
|
|
32
38
|
* gemini AfterAgent payload -> `prompt_response` directly (arrives inline;
|
|
@@ -665,11 +671,27 @@ function buildDrainFrame(event, hookEventName) {
|
|
|
665
671
|
for (const k of ["model", "input_tokens", "output_tokens", "cache_read_tokens", "status"]) {
|
|
666
672
|
if (event[k] !== undefined) frame[k] = event[k]
|
|
667
673
|
}
|
|
674
|
+
// ENG-2344: the files Cursor's own `afterFileEdit` hook reported the agent
|
|
675
|
+
// editing on this turn, in ENG-2191's `file_writes` shape. Carried whole
|
|
676
|
+
// ahead of the body: the attribution is the part of this record a reader
|
|
677
|
+
// cannot infer from anything else, where a trimmed reply is still a reply.
|
|
678
|
+
if (event.file_writes && typeof event.file_writes === "object") {
|
|
679
|
+
frame.file_writes = event.file_writes
|
|
680
|
+
}
|
|
668
681
|
frame.text = body
|
|
669
682
|
// Trim the BODY only, so the metering and the pairing ids always survive —
|
|
670
683
|
// they are what makes a truncated record still worth having, and they cost a
|
|
671
684
|
// fixed ~200 bytes.
|
|
672
|
-
if (!trimEventToBytes(frame, "text", MAX_DRAIN_FRAME_BYTES))
|
|
685
|
+
if (!trimEventToBytes(frame, "text", MAX_DRAIN_FRAME_BYTES)) {
|
|
686
|
+
// The writes alone outgrew the datagram (forty long paths can). Losing the
|
|
687
|
+
// whole reply frame to them would be the wrong trade — the file copy still
|
|
688
|
+
// holds them — so drop them from THIS copy, say so, and trim the body again.
|
|
689
|
+
if (frame.file_writes === undefined) return null
|
|
690
|
+
delete frame.file_writes
|
|
691
|
+
frame.fileWritesDropped = true
|
|
692
|
+
frame.text = body
|
|
693
|
+
if (!trimEventToBytes(frame, "text", MAX_DRAIN_FRAME_BYTES)) return null
|
|
694
|
+
}
|
|
673
695
|
if (!frame.text) return null // trimmed to nothing: a husk with a turn id and
|
|
674
696
|
// no content reads downstream as a captured
|
|
675
697
|
// prompt that said nothing. Send no frame.
|
|
@@ -1338,13 +1360,250 @@ function startTurn(sessionId) {
|
|
|
1338
1360
|
|
|
1339
1361
|
/** The turn id of this session's most recent prompt, if any. */
|
|
1340
1362
|
function currentTurn(sessionId) {
|
|
1341
|
-
|
|
1363
|
+
return currentTurnRecord(sessionId).turnId
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** The most recent prompt's turn id AND the instant it was minted, so a
|
|
1367
|
+
* ledger keyed on the session can be cut at the turn's start. */
|
|
1368
|
+
function currentTurnRecord(sessionId) {
|
|
1369
|
+
if (!sessionId) return { turnId: null, ts: null }
|
|
1342
1370
|
try {
|
|
1343
1371
|
const data = JSON.parse(fs.readFileSync(turnFile(sessionId), "utf8"))
|
|
1344
|
-
return
|
|
1372
|
+
return {
|
|
1373
|
+
turnId: typeof data.turnId === "string" ? data.turnId : null,
|
|
1374
|
+
ts: typeof data.ts === "number" && Number.isFinite(data.ts) ? data.ts : null,
|
|
1375
|
+
}
|
|
1376
|
+
} catch {
|
|
1377
|
+
return { turnId: null, ts: null }
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// ── Cursor's agent edits (ENG-2344) ──────────────────────────────────
|
|
1382
|
+
// Composer routes its model traffic to Cursor's own backend, so neither the
|
|
1383
|
+
// gateway nor the in-container MITM ever sees the tool call that writes a file
|
|
1384
|
+
// — ENG-2191's extractor names Cursor's agent as its one blind spot. And the
|
|
1385
|
+
// editor-capture extension (ENG-2211) sees an agent write to a file that is not
|
|
1386
|
+
// open in a tab only as a bare watcher item: a path and a count, with none of
|
|
1387
|
+
// the shape the reader turns into "AI wrote". Measured on production (candidate
|
|
1388
|
+
// retest, 2026-09-16): the whole record of a Composer rewrite was one `fs` item.
|
|
1389
|
+
// So the report could show what was asked and what the model answered, and
|
|
1390
|
+
// nothing in it said the model wrote the file.
|
|
1391
|
+
//
|
|
1392
|
+
// Cursor's hooks API carries that fact itself. `afterFileEdit` fires AFTER the
|
|
1393
|
+
// agent modifies a file, with `file_path` and the `edits` it applied
|
|
1394
|
+
// (cursor.com/docs/agent/hooks; one of the six events measured on 3.17.19,
|
|
1395
|
+
// litmus-firstboot §5b). It is Cursor's own statement that its agent edited
|
|
1396
|
+
// the file — the direct mechanism, where correlating a prompt with a nearby
|
|
1397
|
+
// `fs_write` would be a guess dressed as a record.
|
|
1398
|
+
//
|
|
1399
|
+
// WHAT IS RECORDED, AND WHERE. Metadata only, in ENG-2191's `file_writes`
|
|
1400
|
+
// shape so every reader of that field renders it with no Cursor branch: the
|
|
1401
|
+
// path, the byte count of what was placed, and the number of edits. Never the
|
|
1402
|
+
// edit text — `old_string`/`new_string` are file contents, and storing them is
|
|
1403
|
+
// the cap decision ENG-2191 deferred. Each hook invocation is its own process,
|
|
1404
|
+
// so an edit is written as ONE FILE OF ITS OWN into a per-session ledger
|
|
1405
|
+
// directory beside the turn file, and the invocation that writes the turn's
|
|
1406
|
+
// `ai_response` folds the ledger into it as intents each paired with an
|
|
1407
|
+
// `ok: true` outcome: the tool reported the edit applied, which is exactly what
|
|
1408
|
+
// ENG-2191's "confirmed" state means. Not more — a candidate can revert every
|
|
1409
|
+
// line afterwards, and nothing here sees that.
|
|
1410
|
+
//
|
|
1411
|
+
// THE LEDGER NEVER LOSES AN EDIT TO A RACE, AND NEVER REPORTS ONE TWICE, BY
|
|
1412
|
+
// CONSTRUCTION. Three shapes were tried and rejected on review of #2606: a
|
|
1413
|
+
// shared append-only file consumed by read-then-unlink deletes an append that
|
|
1414
|
+
// lands between the two; claiming it by rename only narrows that window; and a
|
|
1415
|
+
// directory of one file per edit that the consumer lists, reads and later
|
|
1416
|
+
// unlinks lets TWO consumers (the IDE fires `afterAgentResponse` and `stop` for
|
|
1417
|
+
// one turn, as separate processes) fold the same edit into two replies, and
|
|
1418
|
+
// deleted edits past the cap unread. So: each edit is written to a temp name
|
|
1419
|
+
// and renamed into the directory (a consumer never sees a partial file), and a
|
|
1420
|
+
// consumer CLAIMS each file by renaming it to a name only it knows, which is
|
|
1421
|
+
// atomic and exclusive — of two consumers racing for one file, exactly one
|
|
1422
|
+
// rename succeeds. It claims at most the cap and leaves the rest untouched,
|
|
1423
|
+
// unlinks its claims ONLY AFTER the reply carrying them is in `activity.jsonl`,
|
|
1424
|
+
// and hands them back under their original names if that append fails. A
|
|
1425
|
+
// claim a crashed consumer left behind is handed back by the next consumer
|
|
1426
|
+
// once it is older than any hook could still be running. Whatever is left for
|
|
1427
|
+
// a later reply is carried flagged `late` (it belongs to an earlier turn)
|
|
1428
|
+
// rather than dropped: a lost edit is unobservable, so a reader can never
|
|
1429
|
+
// report it, and a flagged one is a visible fact. The only remaining loss is a
|
|
1430
|
+
// `stop` that never fires at all, which is the dependency ENG-2191 also has on
|
|
1431
|
+
// the next request for its outcomes.
|
|
1432
|
+
//
|
|
1433
|
+
// FOLDED ONTO THE REPLY, NOT EMITTED PER EDIT. One turn routinely edits several
|
|
1434
|
+
// files; one event per edit would be one drain frame, one row and one dedupe
|
|
1435
|
+
// key each, and would bury the reply under its own edits.
|
|
1436
|
+
//
|
|
1437
|
+
// NOT FORGE-RESISTANT, like everything on this lane: the hook is in the
|
|
1438
|
+
// candidate's own config and the event is `selfReported`. A missing record is
|
|
1439
|
+
// "not captured", never a finding.
|
|
1440
|
+
const CURSOR_EDIT_EVENT = "afterFileEdit"
|
|
1441
|
+
// The `tool` an intent carries: Cursor's own name for the event, since the wire
|
|
1442
|
+
// names no tool. It is what the reader keys its "reported editing" heading on.
|
|
1443
|
+
const CURSOR_EDIT_TOOL = "afterFileEdit"
|
|
1444
|
+
// ENG-2191's caps, restated so the two lanes' records are the same size.
|
|
1445
|
+
const MAX_EDIT_INTENTS = 40
|
|
1446
|
+
const MAX_EDIT_PATH_CHARS = 512
|
|
1447
|
+
// A claim older than this belongs to a consumer that died between claiming
|
|
1448
|
+
// and committing (no hook runs for ten minutes), and is handed back.
|
|
1449
|
+
const EDIT_CLAIM_STALE_MS = 10 * 60 * 1000
|
|
1450
|
+
const EDIT_CLAIM_PREFIX = ".claimed."
|
|
1451
|
+
|
|
1452
|
+
function editLedgerDir(sessionId) {
|
|
1453
|
+
const safe = String(sessionId).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128)
|
|
1454
|
+
return path.join(TURNS_DIR, `${safe}.edits`)
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** Bytes of UTF-8 an `afterFileEdit` payload's edits placed in the file. */
|
|
1458
|
+
function editBytes(edits) {
|
|
1459
|
+
let bytes = 0
|
|
1460
|
+
for (const e of edits) {
|
|
1461
|
+
if (e && typeof e === "object" && typeof e.new_string === "string") {
|
|
1462
|
+
bytes += Buffer.byteLength(e.new_string, "utf8")
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
return bytes
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* One `afterFileEdit` payload -> one ledger line, or nothing. Returns the line
|
|
1470
|
+
* written (for tests), null when the payload carried no usable path.
|
|
1471
|
+
*
|
|
1472
|
+
* Claimed like a prompt is, keyed on the generation and the edit itself: Cursor
|
|
1473
|
+
* merges every config source it finds, so a version that starts honouring the
|
|
1474
|
+
* baked enterprise file would fire this twice per edit with identical payloads.
|
|
1475
|
+
*/
|
|
1476
|
+
function recordCursorFileEdit(data, sessionId, assessmentRoot) {
|
|
1477
|
+
if (!data || typeof data !== "object" || !sessionId) return null
|
|
1478
|
+
const filePath = typeof data.file_path === "string" ? data.file_path.trim() : ""
|
|
1479
|
+
if (!filePath) return null
|
|
1480
|
+
// The edited FILE must sit inside the assessment that resolved, not merely
|
|
1481
|
+
// beside one of the session's roots: a multi-root Cursor window can hold the
|
|
1482
|
+
// assessment and an unrelated project, and `workspace_roots` resolving is a
|
|
1483
|
+
// statement about the window, not about this path (Greptile on #2606).
|
|
1484
|
+
// realpath'd on both sides for the macOS /var and symlinked-home reason
|
|
1485
|
+
// `realpathOrNull` gives; a path that does not exist yet (a `Write` of a new
|
|
1486
|
+
// file the hook reports before it is visible) is compared as given.
|
|
1487
|
+
if (!assessmentRoot) return null
|
|
1488
|
+
const root = realpathOrNull(assessmentRoot) || assessmentRoot
|
|
1489
|
+
const real = realpathOrNull(filePath) || path.resolve(filePath)
|
|
1490
|
+
if (!isAtOrUnder(real, root) || real === root) return null
|
|
1491
|
+
const edits = Array.isArray(data.edits) ? data.edits : []
|
|
1492
|
+
const line = {
|
|
1493
|
+
id: crypto.randomUUID(),
|
|
1494
|
+
tool: CURSOR_EDIT_TOOL,
|
|
1495
|
+
path: filePath.length > MAX_EDIT_PATH_CHARS ? filePath.slice(0, MAX_EDIT_PATH_CHARS - 1) + "…" : filePath,
|
|
1496
|
+
bytes: editBytes(edits),
|
|
1497
|
+
edits: edits.length,
|
|
1498
|
+
ts: Date.now(),
|
|
1499
|
+
}
|
|
1500
|
+
const digest = crypto.createHash("sha256").update(JSON.stringify([filePath, edits])).digest("hex").slice(0, 16)
|
|
1501
|
+
const gen = typeof data.generation_id === "string" && data.generation_id ? data.generation_id : null
|
|
1502
|
+
if (gen && !claimPrompt(`${gen}.edit.${digest}`)) return null
|
|
1503
|
+
try {
|
|
1504
|
+
const dir = editLedgerDir(sessionId)
|
|
1505
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
1506
|
+
// Written whole under a temp name and renamed into place, so a consumer
|
|
1507
|
+
// listing the directory never reads a half-written file. The name sorts by
|
|
1508
|
+
// arrival, and the id keeps two edits in one millisecond apart.
|
|
1509
|
+
const name = `${String(line.ts).padStart(15, "0")}-${line.id}.json`
|
|
1510
|
+
const tmp = path.join(dir, `.${name}.tmp`)
|
|
1511
|
+
fs.writeFileSync(tmp, JSON.stringify(line))
|
|
1512
|
+
fs.renameSync(tmp, path.join(dir, name))
|
|
1513
|
+
} catch {
|
|
1514
|
+
return null // unwritable HOME: the edit is not recorded, and nothing else is affected
|
|
1515
|
+
}
|
|
1516
|
+
return line
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Claim up to the cap of this session's ledger files for the reply being
|
|
1521
|
+
* written now, and fold them into a `file_writes` value. Returns null when
|
|
1522
|
+
* there is nothing to say; otherwise `{ writes, commit, abort }`, where
|
|
1523
|
+
* `commit` deletes exactly the files claimed and `abort` hands them back
|
|
1524
|
+
* under their original names. Nothing is deleted here: the caller commits
|
|
1525
|
+
* only once the reply carrying these writes is in `activity.jsonl`.
|
|
1526
|
+
*
|
|
1527
|
+
* Each file is claimed by an exclusive rename before it is read, so two
|
|
1528
|
+
* consumers running at once (the IDE's `afterAgentResponse` and `stop`) can
|
|
1529
|
+
* never fold the same edit twice, and files past the cap are not touched at
|
|
1530
|
+
* all — they ride the next reply. An entry older than this turn's start
|
|
1531
|
+
* belongs to an earlier turn and is carried flagged `late` rather than
|
|
1532
|
+
* dropped, for the reason in the ledger block above. A claimed file that does
|
|
1533
|
+
* not parse is handed straight back: it is either damaged or not ours, and
|
|
1534
|
+
* deleting it is the one way to make it unobservable.
|
|
1535
|
+
*/
|
|
1536
|
+
function takeCursorFileEdits(sessionId, turnStartMs) {
|
|
1537
|
+
if (!sessionId) return null
|
|
1538
|
+
const dir = editLedgerDir(sessionId)
|
|
1539
|
+
let entries = []
|
|
1540
|
+
try {
|
|
1541
|
+
entries = fs.readdirSync(dir)
|
|
1345
1542
|
} catch {
|
|
1346
1543
|
return null
|
|
1347
1544
|
}
|
|
1545
|
+
// Hand back what a dead consumer left claimed, so it is not lost with it.
|
|
1546
|
+
const now = Date.now()
|
|
1547
|
+
for (const name of entries) {
|
|
1548
|
+
if (!name.startsWith(EDIT_CLAIM_PREFIX)) continue
|
|
1549
|
+
const original = name.slice(name.indexOf(".", EDIT_CLAIM_PREFIX.length) + 1)
|
|
1550
|
+
try {
|
|
1551
|
+
if (now - fs.statSync(path.join(dir, name)).mtimeMs > EDIT_CLAIM_STALE_MS && original) {
|
|
1552
|
+
fs.renameSync(path.join(dir, name), path.join(dir, original))
|
|
1553
|
+
}
|
|
1554
|
+
} catch { /* raced with its owner, or already handed back */ }
|
|
1555
|
+
}
|
|
1556
|
+
let names = []
|
|
1557
|
+
try {
|
|
1558
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith(".json") && !n.startsWith(".")).sort()
|
|
1559
|
+
} catch {
|
|
1560
|
+
return null
|
|
1561
|
+
}
|
|
1562
|
+
const intents = []
|
|
1563
|
+
const outcomes = []
|
|
1564
|
+
const claimed = [] // [claimedName, originalName]
|
|
1565
|
+
for (const name of names) {
|
|
1566
|
+
if (intents.length >= MAX_EDIT_INTENTS) break
|
|
1567
|
+
const mine = `${EDIT_CLAIM_PREFIX}${process.pid}.${name}`
|
|
1568
|
+
try {
|
|
1569
|
+
fs.renameSync(path.join(dir, name), path.join(dir, mine))
|
|
1570
|
+
// A rename keeps the file's mtime, which is when the EDIT was recorded.
|
|
1571
|
+
// The stale check above measures when it was CLAIMED, so stamp that now
|
|
1572
|
+
// (Greptile on #2606): without it a live claim on an edit that waited
|
|
1573
|
+
// ten minutes for its reply reads as abandoned and is claimed twice.
|
|
1574
|
+
const t = new Date()
|
|
1575
|
+
try { fs.utimesSync(path.join(dir, mine), t, t) } catch { /* age reads as the edit's; see below */ }
|
|
1576
|
+
} catch {
|
|
1577
|
+
continue // another consumer has it, or it is already gone
|
|
1578
|
+
}
|
|
1579
|
+
let line
|
|
1580
|
+
try { line = JSON.parse(fs.readFileSync(path.join(dir, mine), "utf8")) } catch { line = null }
|
|
1581
|
+
if (!line || typeof line !== "object" || typeof line.path !== "string" || typeof line.id !== "string") {
|
|
1582
|
+
try { fs.renameSync(path.join(dir, mine), path.join(dir, name)) } catch { /* leave it */ }
|
|
1583
|
+
continue
|
|
1584
|
+
}
|
|
1585
|
+
claimed.push([mine, name])
|
|
1586
|
+
const intent = { id: line.id, tool: CURSOR_EDIT_TOOL, path: line.path }
|
|
1587
|
+
if (typeof line.bytes === "number" && Number.isFinite(line.bytes)) intent.bytes = Math.max(0, Math.trunc(line.bytes))
|
|
1588
|
+
if (typeof line.edits === "number" && Number.isFinite(line.edits)) intent.edits = Math.max(0, Math.trunc(line.edits))
|
|
1589
|
+
if (typeof turnStartMs === "number" && typeof line.ts === "number" && line.ts < turnStartMs) intent.late = true
|
|
1590
|
+
intents.push(intent)
|
|
1591
|
+
outcomes.push({ id: line.id, ok: true })
|
|
1592
|
+
}
|
|
1593
|
+
if (intents.length === 0) return null
|
|
1594
|
+
return {
|
|
1595
|
+
writes: { intents, outcomes },
|
|
1596
|
+
commit() {
|
|
1597
|
+
for (const [mine] of claimed) {
|
|
1598
|
+
try { fs.unlinkSync(path.join(dir, mine)) } catch { /* already gone */ }
|
|
1599
|
+
}
|
|
1600
|
+
},
|
|
1601
|
+
abort() {
|
|
1602
|
+
for (const [mine, name] of claimed) {
|
|
1603
|
+
try { fs.renameSync(path.join(dir, mine), path.join(dir, name)) } catch { /* already gone */ }
|
|
1604
|
+
}
|
|
1605
|
+
},
|
|
1606
|
+
}
|
|
1348
1607
|
}
|
|
1349
1608
|
|
|
1350
1609
|
// Keep the markers from accumulating in the candidate's HOME forever. Cheap: this
|
|
@@ -1411,6 +1670,40 @@ function claimPrompt(generationId) {
|
|
|
1411
1670
|
}
|
|
1412
1671
|
}
|
|
1413
1672
|
|
|
1673
|
+
/**
|
|
1674
|
+
* The assessment's `.litmus` dir for this payload, or null.
|
|
1675
|
+
*
|
|
1676
|
+
* Where the TOOL says this session is: workspace roots (Cursor) or the
|
|
1677
|
+
* payload's own cwd (Claude Code, Codex, Copilot CLI). VS Code Copilot Chat
|
|
1678
|
+
* reports neither. Two uses: checking the explicit path (ENG-1311), and
|
|
1679
|
+
* picking the RIGHT registry entry when walk-up finds nothing (ENG-1323 — see
|
|
1680
|
+
* findLitmusDir).
|
|
1681
|
+
*
|
|
1682
|
+
* Explicit hint first: it is correct by construction — but only once it
|
|
1683
|
+
* agrees with the session's own reported location, since the hook config
|
|
1684
|
+
* naming it can be copied into another project (ENG-1311). On disagreement
|
|
1685
|
+
* it is dropped and the session resolves on its own merits.
|
|
1686
|
+
*
|
|
1687
|
+
* Those merits include every location the tool reported, cwd included. The
|
|
1688
|
+
* reported cwd beats this process's cwd (a hook runner's need not be the
|
|
1689
|
+
* session's) and is the only pointer left once a copied explicit path has
|
|
1690
|
+
* been rejected — without it a rejected Copilot prompt, which carries no
|
|
1691
|
+
* workspace roots, would fall through to the registry and be filed against
|
|
1692
|
+
* an unrelated assessment.
|
|
1693
|
+
*/
|
|
1694
|
+
function resolveAssessmentDir(parsed, workspaceRoots) {
|
|
1695
|
+
const sessionDirs = [
|
|
1696
|
+
...(workspaceRoots || []),
|
|
1697
|
+
...(parsed && typeof parsed.cwd === "string" && parsed.cwd ? [parsed.cwd] : []),
|
|
1698
|
+
]
|
|
1699
|
+
const trustExplicit = explicitAssessmentDir
|
|
1700
|
+
&& explicitAgreesWithSession(explicitAssessmentDir, sessionDirs)
|
|
1701
|
+
const hintDirs = trustExplicit
|
|
1702
|
+
? [explicitAssessmentDir, ...sessionDirs]
|
|
1703
|
+
: sessionDirs
|
|
1704
|
+
return findLitmusDir(hintDirs, sessionDirs)
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1414
1707
|
async function main() {
|
|
1415
1708
|
emitCursorVerdictIfNeeded()
|
|
1416
1709
|
|
|
@@ -1426,6 +1719,26 @@ async function main() {
|
|
|
1426
1719
|
try { parsed = JSON.parse(raw) } catch { /* extractPrompt reports the failure */ }
|
|
1427
1720
|
const cursorShaped = isCursorPayload(parsed)
|
|
1428
1721
|
|
|
1722
|
+
// ENG-2477: a capture SELF-TEST probe. `litmus init` (and `litmus doctor`)
|
|
1723
|
+
// run this script the way the tool would — the tool's own payload shape, on
|
|
1724
|
+
// stdin, through the command string in the tool's own config file — plus
|
|
1725
|
+
// this one field no tool sets, and then ask the backend whether the event
|
|
1726
|
+
// arrived. Everything below runs unchanged on purpose: the tool's real
|
|
1727
|
+
// extractPrompt branch, the real assessment resolution, the real file
|
|
1728
|
+
// append and the real awaited upload, because the thing being proved is the
|
|
1729
|
+
// path a candidate's prompt will take. Only the RECORD differs. It is typed
|
|
1730
|
+
// `capture_selftest_probe`, never `ai_prompt`, so no reader counts it as a
|
|
1731
|
+
// prompt (activity_events.PROMPT_EVENT_TYPES and cli-tracking.ts's channel
|
|
1732
|
+
// map both leave it out), it opens no turn, and it revives no watcher. The
|
|
1733
|
+
// nonce is what the CLI looks for on the other end. A logger from before
|
|
1734
|
+
// this shipped would record the same payload as an `ai_prompt` whose text
|
|
1735
|
+
// carries the nonce — the read-back matches on the nonce, not the type, so
|
|
1736
|
+
// that still reads as "arrived".
|
|
1737
|
+
const selftestNonce = parsed && typeof parsed.litmus_selftest === "string"
|
|
1738
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(parsed.litmus_selftest)
|
|
1739
|
+
? parsed.litmus_selftest.toLowerCase()
|
|
1740
|
+
: null
|
|
1741
|
+
|
|
1429
1742
|
// The relabel is what makes the claim below label-safe: whichever invocation
|
|
1430
1743
|
// wins the race, a Cursor-shaped payload is recorded as tool="cursor", so the
|
|
1431
1744
|
// event never blames Claude Code for a prompt it had no part in.
|
|
@@ -1450,6 +1763,19 @@ async function main() {
|
|
|
1450
1763
|
return
|
|
1451
1764
|
}
|
|
1452
1765
|
|
|
1766
|
+
// ENG-2344: Cursor's own report that its agent edited a file. Nothing to
|
|
1767
|
+
// upload yet — it is ledgered against the session and rides the turn's reply.
|
|
1768
|
+
// Gated on the same assessment resolution a prompt gets, so an edit in a
|
|
1769
|
+
// project that is not the assessment is not written anywhere.
|
|
1770
|
+
if (effectiveTool === "cursor" && parsed && parsed.hook_event_name === CURSOR_EDIT_EVENT) {
|
|
1771
|
+
const roots = Array.isArray(parsed.workspace_roots) ? parsed.workspace_roots : []
|
|
1772
|
+
const editLitmusDir = resolveAssessmentDir(parsed, roots)
|
|
1773
|
+
if (!editLitmusDir) return
|
|
1774
|
+
recordCursorFileEdit(parsed, parsed.session_id || parsed.conversation_id || null,
|
|
1775
|
+
path.dirname(editLitmusDir))
|
|
1776
|
+
return
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1453
1779
|
let content = null
|
|
1454
1780
|
let sessionId = null
|
|
1455
1781
|
let workspaceRoots = []
|
|
@@ -1472,33 +1798,7 @@ async function main() {
|
|
|
1472
1798
|
}
|
|
1473
1799
|
if (!content) return
|
|
1474
1800
|
|
|
1475
|
-
|
|
1476
|
-
// payload's own cwd (Claude Code, Codex, Copilot CLI). VS Code Copilot Chat
|
|
1477
|
-
// reports neither. Two uses: checking the explicit path below (ENG-1311), and
|
|
1478
|
-
// picking the RIGHT registry entry when walk-up finds nothing (ENG-1323 — see
|
|
1479
|
-
// findLitmusDir).
|
|
1480
|
-
const sessionDirs = [
|
|
1481
|
-
...(workspaceRoots || []),
|
|
1482
|
-
...(parsed && typeof parsed.cwd === "string" && parsed.cwd ? [parsed.cwd] : []),
|
|
1483
|
-
]
|
|
1484
|
-
|
|
1485
|
-
// Explicit hint first: it is correct by construction — but only once it
|
|
1486
|
-
// agrees with the session's own reported location, since the hook config
|
|
1487
|
-
// naming it can be copied into another project (ENG-1311). On disagreement
|
|
1488
|
-
// it is dropped and the session resolves on its own merits.
|
|
1489
|
-
//
|
|
1490
|
-
// Those merits include every location the tool reported, cwd included. The
|
|
1491
|
-
// reported cwd beats this process's cwd (a hook runner's need not be the
|
|
1492
|
-
// session's) and is the only pointer left once a copied explicit path has
|
|
1493
|
-
// been rejected — without it a rejected Copilot prompt, which carries no
|
|
1494
|
-
// workspace roots, would fall through to the registry and be filed against
|
|
1495
|
-
// an unrelated assessment.
|
|
1496
|
-
const trustExplicit = explicitAssessmentDir
|
|
1497
|
-
&& explicitAgreesWithSession(explicitAssessmentDir, sessionDirs)
|
|
1498
|
-
const hintDirs = trustExplicit
|
|
1499
|
-
? [explicitAssessmentDir, ...sessionDirs]
|
|
1500
|
-
: sessionDirs
|
|
1501
|
-
const litmusDir = findLitmusDir(hintDirs, sessionDirs)
|
|
1801
|
+
const litmusDir = resolveAssessmentDir(parsed, workspaceRoots)
|
|
1502
1802
|
if (!litmusDir) return
|
|
1503
1803
|
|
|
1504
1804
|
// Claim as LATE as possible — only once we know we have content and somewhere
|
|
@@ -1521,21 +1821,29 @@ async function main() {
|
|
|
1521
1821
|
|
|
1522
1822
|
// Pair the two invocations: a prompt opens a turn, its response inherits the
|
|
1523
1823
|
// same id. See the turn-pairing block above.
|
|
1524
|
-
const
|
|
1824
|
+
const turnRecord = responseEvent && !selftestNonce ? currentTurnRecord(sessionId) : null
|
|
1825
|
+
const turnId = selftestNonce ? null : responseEvent ? turnRecord.turnId : startTurn(sessionId)
|
|
1525
1826
|
|
|
1526
|
-
const event =
|
|
1827
|
+
const event = selftestNonce
|
|
1527
1828
|
? {
|
|
1528
1829
|
ts: new Date().toISOString(),
|
|
1529
|
-
type: "
|
|
1830
|
+
type: "capture_selftest_probe",
|
|
1530
1831
|
tool: effectiveTool,
|
|
1531
|
-
|
|
1532
|
-
}
|
|
1533
|
-
: {
|
|
1534
|
-
ts: new Date().toISOString(),
|
|
1535
|
-
type: "ai_prompt",
|
|
1536
|
-
tool: effectiveTool,
|
|
1537
|
-
prompt: truncated,
|
|
1832
|
+
nonce: selftestNonce,
|
|
1538
1833
|
}
|
|
1834
|
+
: responseEvent
|
|
1835
|
+
? {
|
|
1836
|
+
ts: new Date().toISOString(),
|
|
1837
|
+
type: "ai_response",
|
|
1838
|
+
tool: effectiveTool,
|
|
1839
|
+
text: truncated,
|
|
1840
|
+
}
|
|
1841
|
+
: {
|
|
1842
|
+
ts: new Date().toISOString(),
|
|
1843
|
+
type: "ai_prompt",
|
|
1844
|
+
tool: effectiveTool,
|
|
1845
|
+
prompt: truncated,
|
|
1846
|
+
}
|
|
1539
1847
|
// ENG-1962: ONE id per captured event, minted here — before either copy is
|
|
1540
1848
|
// written — so the copy in `activity.jsonl` and the copy that goes down the
|
|
1541
1849
|
// drain carry the same one and collapse into a single stored event.
|
|
@@ -1556,6 +1864,14 @@ async function main() {
|
|
|
1556
1864
|
event.id = crypto.randomUUID()
|
|
1557
1865
|
if (sessionId) event.sessionId = sessionId
|
|
1558
1866
|
if (turnId) event.turnId = turnId
|
|
1867
|
+
// ENG-2344: the files Cursor's `afterFileEdit` hook reported the agent
|
|
1868
|
+
// editing since this turn's prompt, folded onto its reply. Attached BEFORE
|
|
1869
|
+
// the byte-cap loop, like the metering, so the body is trimmed around it.
|
|
1870
|
+
let editLedger = null
|
|
1871
|
+
if (responseEvent && effectiveTool === "cursor" && !selftestNonce) {
|
|
1872
|
+
editLedger = takeCursorFileEdits(sessionId, turnRecord ? turnRecord.ts : null)
|
|
1873
|
+
if (editLedger) event.file_writes = editLedger.writes
|
|
1874
|
+
}
|
|
1559
1875
|
// The turn's model and token counts (ENG-1956). Attached BEFORE the byte-cap
|
|
1560
1876
|
// loop below so their bytes are counted against the same budget the body is
|
|
1561
1877
|
// trimmed to fit — otherwise a reply trimmed to exactly the cap could be
|
|
@@ -1587,11 +1903,19 @@ async function main() {
|
|
|
1587
1903
|
|
|
1588
1904
|
// 1. Append to activity.jsonl (for submission ZIP)
|
|
1589
1905
|
const logPath = path.join(litmusDir, "activity.jsonl")
|
|
1906
|
+
let logged = false
|
|
1590
1907
|
try {
|
|
1591
1908
|
fs.appendFileSync(logPath, JSON.stringify(event) + "\n")
|
|
1909
|
+
logged = true
|
|
1592
1910
|
} catch {
|
|
1593
1911
|
// Non-critical
|
|
1594
1912
|
}
|
|
1913
|
+
// ENG-2344: the edits this reply carries leave the ledger only now that the
|
|
1914
|
+
// reply is on disk. An append that failed hands them back for the next reply.
|
|
1915
|
+
if (editLedger) {
|
|
1916
|
+
if (logged) editLedger.commit()
|
|
1917
|
+
else editLedger.abort()
|
|
1918
|
+
}
|
|
1595
1919
|
|
|
1596
1920
|
// 1b. Same moment, second copy: onto the root drain, where the candidate
|
|
1597
1921
|
// cannot edit or delete it afterwards (ENG-1962). Additive — step 1 above
|
|
@@ -1605,7 +1929,9 @@ async function main() {
|
|
|
1605
1929
|
|
|
1606
1930
|
// 3. Revive a dead watcher (ENG-1491). Strictly after the capture/upload
|
|
1607
1931
|
// work — this is a courtesy, and it must never cost the prompt anything.
|
|
1608
|
-
|
|
1932
|
+
// Not on a self-test probe: init is about to start the tracker itself, and
|
|
1933
|
+
// doctor has already dealt with it.
|
|
1934
|
+
if (!selftestNonce) maybeReviveWatcher(litmusDir, config)
|
|
1609
1935
|
|
|
1610
1936
|
// No explicit process.exit() — the event loop drains naturally once the upload
|
|
1611
1937
|
// has settled and its timers are cleared (the revival spawn is unref'd).
|
|
@@ -1617,6 +1943,11 @@ module.exports = {
|
|
|
1617
1943
|
reviveThrottleAllows,
|
|
1618
1944
|
buildDrainFrame,
|
|
1619
1945
|
trimEventToBytes,
|
|
1946
|
+
recordCursorFileEdit,
|
|
1947
|
+
takeCursorFileEdits,
|
|
1948
|
+
CURSOR_EDIT_EVENT,
|
|
1949
|
+
CURSOR_EDIT_TOOL,
|
|
1950
|
+
MAX_EDIT_INTENTS,
|
|
1620
1951
|
WATCHER_NONCE_MAX_AGE_MS,
|
|
1621
1952
|
REVIVE_THROTTLE_MS,
|
|
1622
1953
|
MAX_DRAIN_FRAME_BYTES,
|
package/dist/lib/schemas.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare const InitMetadataSchema: z.ZodObject<{
|
|
|
10
10
|
timeLimit: z.ZodNullable<z.ZodNumber>;
|
|
11
11
|
walkthroughWindowMinutes: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
12
12
|
startedAt: z.ZodISODateTime;
|
|
13
|
+
workTrial: z.ZodOptional<z.ZodBoolean>;
|
|
13
14
|
}, z.core.$strip>;
|
|
14
15
|
export type InitMetadata = z.infer<typeof InitMetadataSchema>;
|
|
15
16
|
export declare const SubmitResultSchema: z.ZodObject<{
|
|
@@ -29,6 +30,10 @@ export declare const SubmitStatusSchema: z.ZodObject<{
|
|
|
29
30
|
videoUploaded: z.ZodBoolean;
|
|
30
31
|
}, z.core.$strip>;
|
|
31
32
|
export type SubmitStatus = z.infer<typeof SubmitStatusSchema>;
|
|
33
|
+
export declare const CaptureSelftestSchema: z.ZodObject<{
|
|
34
|
+
arrived: z.ZodArray<z.ZodString>;
|
|
35
|
+
}, z.core.$strip>;
|
|
36
|
+
export type CaptureSelftest = z.infer<typeof CaptureSelftestSchema>;
|
|
32
37
|
export declare const WorkspaceConnectionSchema: z.ZodObject<{
|
|
33
38
|
workspace_status: z.ZodString;
|
|
34
39
|
ssh_hostname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../../src/lib/schemas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,eAAO,MAAM,kBAAkB
|
|
1
|
+
{"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../../src/lib/schemas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,eAAO,MAAM,kBAAkB;;;;;;;;;;;;iBAqB7B,CAAA;AAEF,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAE7D,eAAO,MAAM,kBAAkB;;;;;iBAQ7B,CAAA;AAEF,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAK7D,eAAO,MAAM,iBAAiB;;;;iBAI5B,CAAA;AAEF,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAA;AAE3D,eAAO,MAAM,kBAAkB;;iBAE7B,CAAA;AAEF,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAI7D,eAAO,MAAM,qBAAqB;;iBAEhC,CAAA;AAEF,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAA;AAMnE,eAAO,MAAM,yBAAyB;;;;;;iBAQpC,CAAA;AAEF,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAA;AAU3E,eAAO,MAAM,sBAAsB;;;;;iBAKjC,CAAA;AAEF,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAA"}
|
package/dist/lib/schemas.js
CHANGED
|
@@ -12,6 +12,11 @@ export const InitMetadataSchema = z.object({
|
|
|
12
12
|
// services/walkthrough_shared.py) — the fallback when the server omits it.
|
|
13
13
|
walkthroughWindowMinutes: z.number().positive().optional().default(10),
|
|
14
14
|
startedAt: z.iso.datetime(),
|
|
15
|
+
// ENG-2523: true for a work trial, whose submit is a checkpoint (ENG-2378).
|
|
16
|
+
// THREE-VALUED on purpose: `undefined` means an older frontend did not say,
|
|
17
|
+
// which is not the same as "a take-home". Wording that is only true of a
|
|
18
|
+
// take-home is gated on `=== false`; the checkpoint copy on `=== true`.
|
|
19
|
+
workTrial: z.boolean().optional(),
|
|
15
20
|
});
|
|
16
21
|
export const SubmitResultSchema = z.object({
|
|
17
22
|
submissionId: z.uuid(),
|
|
@@ -33,6 +38,11 @@ export const ServerTimesSchema = z.object({
|
|
|
33
38
|
export const SubmitStatusSchema = z.object({
|
|
34
39
|
videoUploaded: z.boolean(),
|
|
35
40
|
});
|
|
41
|
+
// ENG-2477: GET /cli/activity/selftest — which of the probe nonces the CLI just
|
|
42
|
+
// fired through the installed hooks have landed in candidate_activity_logs.
|
|
43
|
+
export const CaptureSelftestSchema = z.object({
|
|
44
|
+
arrived: z.array(z.string()),
|
|
45
|
+
});
|
|
36
46
|
// The shape `litmus connect` reads from POST /workspace/start and
|
|
37
47
|
// GET /workspace/connection. Both share ssh_hostname/ssh_command; only
|
|
38
48
|
// /connection carries `healthy`, so it's optional-default-false so a start
|
package/dist/lib/schemas.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../../src/lib/schemas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IACzC,YAAY,EAAE,CAAC,CAAC,IAAI,EAAE;IACtB,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE;IAE1B,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;IACtB,UAAU,EAAE,CAAC,CAAC,GAAG,EAAE;IAEnB,cAAc,EAAE,CAAC,CAAC,KAAK,EAAE;IACzB,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE;IAEzB,QAAQ,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE;IACrC,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAChC,4DAA4D;IAC5D,2EAA2E;IAC3E,wBAAwB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC;IACtE,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE;
|
|
1
|
+
{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../../src/lib/schemas.ts"],"names":[],"mappings":"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"}
|