litmus-cli 1.4.32 → 1.4.33

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.
@@ -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)) return null
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
- if (!sessionId) return null
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 typeof data.turnId === "string" ? data.turnId : null
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
- // Where the TOOL says this session is: workspace roots (Cursor) or the
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 turnId = responseEvent ? currentTurn(sessionId) : startTurn(sessionId)
1824
+ const turnRecord = responseEvent && !selftestNonce ? currentTurnRecord(sessionId) : null
1825
+ const turnId = selftestNonce ? null : responseEvent ? turnRecord.turnId : startTurn(sessionId)
1525
1826
 
1526
- const event = responseEvent
1827
+ const event = selftestNonce
1527
1828
  ? {
1528
1829
  ts: new Date().toISOString(),
1529
- type: "ai_response",
1830
+ type: "capture_selftest_probe",
1530
1831
  tool: effectiveTool,
1531
- text: truncated,
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
- maybeReviveWatcher(litmusDir, config)
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,
@@ -29,6 +29,10 @@ export declare const SubmitStatusSchema: z.ZodObject<{
29
29
  videoUploaded: z.ZodBoolean;
30
30
  }, z.core.$strip>;
31
31
  export type SubmitStatus = z.infer<typeof SubmitStatusSchema>;
32
+ export declare const CaptureSelftestSchema: z.ZodObject<{
33
+ arrived: z.ZodArray<z.ZodString>;
34
+ }, z.core.$strip>;
35
+ export type CaptureSelftest = z.infer<typeof CaptureSelftestSchema>;
32
36
  export declare const WorkspaceConnectionSchema: z.ZodObject<{
33
37
  workspace_status: z.ZodString;
34
38
  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;;;;;;;;;;;iBAgB7B,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;AAM7D,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"}
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;;;;;;;;;;;iBAgB7B,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"}
@@ -33,6 +33,11 @@ export const ServerTimesSchema = z.object({
33
33
  export const SubmitStatusSchema = z.object({
34
34
  videoUploaded: z.boolean(),
35
35
  });
36
+ // ENG-2477: GET /cli/activity/selftest — which of the probe nonces the CLI just
37
+ // fired through the installed hooks have landed in candidate_activity_logs.
38
+ export const CaptureSelftestSchema = z.object({
39
+ arrived: z.array(z.string()),
40
+ });
36
41
  // The shape `litmus connect` reads from POST /workspace/start and
37
42
  // GET /workspace/connection. Both share ssh_hostname/ssh_command; only
38
43
  // /connection carries `healthy`, so it's optional-default-false so a start
@@ -1 +1 @@
1
- {"version":3,"file":"schemas.js","sourceRoot":"","sources":["../../src/lib/schemas.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"schemas.js","sourceRoot":"","sources":["../../src/lib/schemas.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "litmus-cli",
3
- "version": "1.4.32",
3
+ "version": "1.4.33",
4
4
  "description": "CLI tool for Litmus engineering assessments",
5
5
  "license": "MIT",
6
6
  "author": "elenazhao",