litmus-cli 1.4.39 → 1.4.41

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.
Files changed (45) hide show
  1. package/README.md +7 -0
  2. package/dist/commands/connect.d.ts +2 -0
  3. package/dist/commands/connect.d.ts.map +1 -1
  4. package/dist/commands/connect.js +21 -0
  5. package/dist/commands/connect.js.map +1 -1
  6. package/dist/commands/doctor.d.ts +20 -2
  7. package/dist/commands/doctor.d.ts.map +1 -1
  8. package/dist/commands/doctor.js +51 -8
  9. package/dist/commands/doctor.js.map +1 -1
  10. package/dist/commands/submit.d.ts.map +1 -1
  11. package/dist/commands/submit.js +1 -42
  12. package/dist/commands/submit.js.map +1 -1
  13. package/dist/index.js +3 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/lib/ai-tracking.d.ts +13 -0
  16. package/dist/lib/ai-tracking.d.ts.map +1 -1
  17. package/dist/lib/ai-tracking.js +71 -0
  18. package/dist/lib/ai-tracking.js.map +1 -1
  19. package/dist/lib/api.d.ts +23 -0
  20. package/dist/lib/api.d.ts.map +1 -1
  21. package/dist/lib/api.js +68 -0
  22. package/dist/lib/api.js.map +1 -1
  23. package/dist/lib/backfill.d.ts +46 -3
  24. package/dist/lib/backfill.d.ts.map +1 -1
  25. package/dist/lib/backfill.js +203 -3
  26. package/dist/lib/backfill.js.map +1 -1
  27. package/dist/lib/connect-skills.d.ts +39 -0
  28. package/dist/lib/connect-skills.d.ts.map +1 -0
  29. package/dist/lib/connect-skills.js +138 -0
  30. package/dist/lib/connect-skills.js.map +1 -0
  31. package/dist/lib/hook-logger.cjs +216 -15
  32. package/dist/lib/session-over.d.ts +32 -0
  33. package/dist/lib/session-over.d.ts.map +1 -0
  34. package/dist/lib/session-over.js +99 -0
  35. package/dist/lib/session-over.js.map +1 -0
  36. package/dist/lib/skill-import.d.ts +82 -0
  37. package/dist/lib/skill-import.d.ts.map +1 -0
  38. package/dist/lib/skill-import.js +228 -0
  39. package/dist/lib/skill-import.js.map +1 -0
  40. package/dist/lib/tracker.d.ts.map +1 -1
  41. package/dist/lib/tracker.js +10 -0
  42. package/dist/lib/tracker.js.map +1 -1
  43. package/dist/lib/watcher.js +8 -0
  44. package/dist/lib/watcher.js.map +1 -1
  45. package/package.json +1 -1
@@ -465,6 +465,96 @@ function versionHeader(config) {
465
465
  : {}
466
466
  }
467
467
 
468
+ // ── Session over (ENG-2768) ──────────────────────────────────────
469
+ // Only `litmus submit` releases the hooks and the registry. A session that
470
+ // ends any other way (web submit, deadline, the company ending it) leaves the
471
+ // hooks live, and the walk-up above finds the folder's config whatever the
472
+ // registry says, so every AI turn in an ended assessment folder kept sending
473
+ // the candidate's prompt and reply to a server that answers `400 Session over`
474
+ // every time: 12,229 of them over three days in production.
475
+ //
476
+ // So the refusal is remembered in the folder. The first refusal writes
477
+ // `.litmus/session-over`, and every later invocation that resolves to that
478
+ // folder stops before it records, appends, uploads or revives anything. The
479
+ // watcher writes the same marker when IT is told the session is over (see
480
+ // watcher.ts), which usually comes first.
481
+ //
482
+ // Only a refusal that can never turn into an acceptance counts: "Session over"
483
+ // (which `/cli/activity` also returns for a token that matches no candidate,
484
+ // ENG-2749) and "Invalid token" (its older spelling there, and other routes').
485
+ // A 5xx, a timeout or a 429 is transient and must never silence capture of a
486
+ // live session.
487
+ //
488
+ // The marker names the token it was written for, as a hash. A folder re-used
489
+ // by a later `litmus init` carries a new token, and the old marker does not
490
+ // silence it. A marker that names no token (unreadable, or hand-made) still
491
+ // silences: it can only ever stop capture, never start it.
492
+ //
493
+ // The same hash is also written as an empty file under
494
+ // ~/.litmus/sessions-over/, because the folder may not be writable, and a
495
+ // folder we could not mark would otherwise keep uploading for as long as
496
+ // another assessment keeps the user-scope hooks installed. Either copy is
497
+ // enough.
498
+ const SESSION_OVER_MARKER = "session-over"
499
+ const SESSIONS_OVER_DIR = path.join(os.homedir() || os.tmpdir(), ".litmus", "sessions-over")
500
+ const MAX_REFUSAL_BODY_BYTES = 16 * 1024
501
+ const FINAL_REFUSAL_CODES = new Set(["session over", "invalid token"])
502
+
503
+ function isFinalRefusal(status, rawBody) {
504
+ if (status < 400 || status >= 500 || status === 429) return false
505
+ let body
506
+ try { body = JSON.parse(rawBody) } catch { return false }
507
+ const detail = body && body.detail
508
+ const code = typeof detail === "string"
509
+ ? detail
510
+ : detail && typeof detail === "object" && typeof detail.error === "string" ? detail.error : null
511
+ return typeof code === "string" && FINAL_REFUSAL_CODES.has(code.trim().toLowerCase())
512
+ }
513
+
514
+ function tokenDigest(token) {
515
+ return typeof token === "string" && token
516
+ ? crypto.createHash("sha256").update(token).digest("hex")
517
+ : null
518
+ }
519
+
520
+ function writeSessionOverMarker(litmusDir, config, reason) {
521
+ const digest = tokenDigest(config && config.token)
522
+ try {
523
+ fs.writeFileSync(path.join(litmusDir, SESSION_OVER_MARKER), JSON.stringify({
524
+ reason,
525
+ at: new Date().toISOString(),
526
+ tokenSha256: digest,
527
+ }) + "\n")
528
+ } catch { /* unwritable folder; the home copy below still counts */ }
529
+ if (!digest) return
530
+ try {
531
+ fs.mkdirSync(SESSIONS_OVER_DIR, { recursive: true })
532
+ fs.writeFileSync(path.join(SESSIONS_OVER_DIR, digest), "")
533
+ } catch { /* best effort; the next refusal tries again */ }
534
+ }
535
+
536
+ function sessionOverMarked(litmusDir) {
537
+ const current = tokenDigest((readConfig(litmusDir) || {}).token)
538
+ if (current) {
539
+ try {
540
+ if (fs.statSync(path.join(SESSIONS_OVER_DIR, current)).isFile()) return true
541
+ } catch { /* not recorded at home */ }
542
+ }
543
+ let raw
544
+ try {
545
+ raw = fs.readFileSync(path.join(litmusDir, SESSION_OVER_MARKER), "utf8")
546
+ } catch {
547
+ return false
548
+ }
549
+ let marked = null
550
+ try {
551
+ const parsed = JSON.parse(raw)
552
+ if (parsed && typeof parsed.tokenSha256 === "string" && parsed.tokenSha256) marked = parsed.tokenSha256
553
+ } catch { /* names no token */ }
554
+ if (!marked) return true
555
+ return !current || current === marked
556
+ }
557
+
468
558
  /**
469
559
  * POST the event to /cli/activity and WAIT for it to complete, bounded.
470
560
  *
@@ -474,16 +564,17 @@ function versionHeader(config) {
474
564
  * the in-zip activity.jsonl) kept working, so nothing surfaced it.
475
565
  *
476
566
  * Resolves rather than rejects on every failure path: a telemetry upload must
477
- * never fail a candidate's prompt. Both timers are cleared on settle.
567
+ * never fail a candidate's prompt. Both timers are cleared on settle. Resolves
568
+ * `{ sessionOver }`, true only when the server refused the token for good.
478
569
  */
479
570
  function uploadEvent(config, event) {
480
- if (!config || !config.token || !config.backendUrl) return Promise.resolve()
571
+ if (!config || !config.token || !config.backendUrl) return Promise.resolve({ sessionOver: false })
481
572
 
482
573
  let url
483
574
  try {
484
575
  url = new URL(`${config.backendUrl}/cli/activity`)
485
576
  } catch {
486
- return Promise.resolve() // malformed backendUrl — nothing to do
577
+ return Promise.resolve({ sessionOver: false }) // malformed backendUrl — nothing to do
487
578
  }
488
579
 
489
580
  const body = JSON.stringify({ events: [event] })
@@ -492,11 +583,12 @@ function uploadEvent(config, event) {
492
583
  return new Promise((resolve) => {
493
584
  let settled = false
494
585
  let guard = null
586
+ let sessionOver = false
495
587
  const done = () => {
496
588
  if (settled) return
497
589
  settled = true
498
590
  if (guard) clearTimeout(guard)
499
- resolve()
591
+ resolve({ sessionOver })
500
592
  }
501
593
 
502
594
  const req = reqFn(url, {
@@ -518,7 +610,24 @@ function uploadEvent(config, event) {
518
610
  timeout: UPLOAD_TIMEOUT_MS,
519
611
  })
520
612
  // Drain the response so the socket can close and the event loop empty.
521
- req.on("response", (res) => { res.resume(); res.on("end", done); res.on("error", done) })
613
+ // A 4xx body is kept (capped) because it is the only place the server says
614
+ // the assessment is over for good (ENG-2768, see isFinalRefusal).
615
+ req.on("response", (res) => {
616
+ const status = res.statusCode || 0
617
+ const chunks = []
618
+ let kept = 0
619
+ res.on("data", (chunk) => {
620
+ if (status >= 400 && status < 500 && kept < MAX_REFUSAL_BODY_BYTES) {
621
+ chunks.push(chunk)
622
+ kept += chunk.length
623
+ }
624
+ })
625
+ res.on("end", () => {
626
+ if (chunks.length) sessionOver = isFinalRefusal(status, Buffer.concat(chunks).toString("utf8"))
627
+ done()
628
+ })
629
+ res.on("error", done)
630
+ })
522
631
  req.on("error", done) // network down, DNS, refused, destroyed
523
632
  req.on("timeout", () => { req.destroy(); done() })
524
633
  // Backstop in case none of the above fires (a socket wedged before connect).
@@ -919,7 +1028,7 @@ function parseClaudeTranscript(raw) {
919
1028
  if (entry.type !== "assistant" || !Array.isArray(content)) continue
920
1029
  for (const block of content) {
921
1030
  if (block && block.type === "text" && typeof block.text === "string" && block.text) {
922
- collected.push(block.text)
1031
+ collected.push({ id: typeof entry.uuid === "string" ? entry.uuid : null, text: block.text })
923
1032
  }
924
1033
  }
925
1034
  // An assistant entry whose last block is tool_use is mid-turn; prose means
@@ -928,7 +1037,8 @@ function parseClaudeTranscript(raw) {
928
1037
  tail = lastBlock && lastBlock.type === "tool_use" ? "tool" : "text"
929
1038
  }
930
1039
  return {
931
- text: collected.length ? collected.join("\n\n") : null,
1040
+ text: collected.length ? collected.map((b) => b.text).join("\n\n") : null,
1041
+ blocks: collected,
932
1042
  tailComplete: tail === "text",
933
1043
  }
934
1044
  }
@@ -986,8 +1096,13 @@ function readSettledTranscript(transcriptPath, parse) {
986
1096
  }
987
1097
  }
988
1098
 
1099
+ // The whole parse, not just its text: `blocks` carries each block's entry id,
1100
+ // which is what lets a later Stop send only what this one did not (ENG-2793).
989
1101
  function readClaudeTranscriptResponse(transcriptPath) {
990
- return readSettledTranscript(transcriptPath, raw => parseClaudeTranscript(raw).text)
1102
+ return readSettledTranscript(transcriptPath, (raw) => {
1103
+ const parsed = parseClaudeTranscript(raw)
1104
+ return parsed.text ? parsed : null
1105
+ })
991
1106
  }
992
1107
 
993
1108
  /**
@@ -1037,12 +1152,13 @@ function parseCopilotTranscript(raw) {
1037
1152
  }
1038
1153
  if (entry.type === "assistant.message" && entry.data
1039
1154
  && typeof entry.data.content === "string" && entry.data.content) {
1040
- collected.push(entry.data.content)
1155
+ collected.push({ id: typeof entry.id === "string" ? entry.id : null, text: entry.data.content })
1041
1156
  tail = "text"
1042
1157
  }
1043
1158
  }
1044
1159
  return {
1045
- text: collected.length ? collected.join("\n\n") : null,
1160
+ text: collected.length ? collected.map((b) => b.text).join("\n\n") : null,
1161
+ blocks: collected,
1046
1162
  tailComplete: tail === "text",
1047
1163
  }
1048
1164
  }
@@ -1051,7 +1167,10 @@ function readCopilotTranscriptResponse(transcriptPath) {
1051
1167
  // VS Code flushes before running the hook but bounds that flush at 500ms,
1052
1168
  // and the Copilot CLI's flush behavior is undocumented — so wait for
1053
1169
  // quiescence like the others.
1054
- return readSettledTranscript(transcriptPath, raw => parseCopilotTranscript(raw).text)
1170
+ return readSettledTranscript(transcriptPath, (raw) => {
1171
+ const parsed = parseCopilotTranscript(raw)
1172
+ return parsed.text ? parsed : null
1173
+ })
1055
1174
  }
1056
1175
 
1057
1176
  /**
@@ -1175,8 +1294,10 @@ function extractResponse(data, toolName) {
1175
1294
  // Claude Code Stop payload: { session_id, transcript_path, cwd,
1176
1295
  // hook_event_name, stop_hook_active }. The response text is not in the
1177
1296
  // payload — read it from the transcript.
1297
+ const parsed = readClaudeTranscriptResponse(data.transcript_path)
1178
1298
  return {
1179
- text: readClaudeTranscriptResponse(data.transcript_path),
1299
+ text: parsed ? parsed.text : null,
1300
+ blocks: parsed ? parsed.blocks : null,
1180
1301
  sessionId: data.session_id || null,
1181
1302
  }
1182
1303
  }
@@ -1220,8 +1341,10 @@ function extractResponse(data, toolName) {
1220
1341
  // Copilot Stop (both surfaces, via the PascalCase config spelling):
1221
1342
  // { hook_event_name: "Stop", session_id, transcript_path, ... }. The
1222
1343
  // response is in the transcript, not the payload.
1344
+ const parsed = readCopilotTranscriptResponse(data.transcript_path || data.transcriptPath)
1223
1345
  return {
1224
- text: readCopilotTranscriptResponse(data.transcript_path || data.transcriptPath),
1346
+ text: parsed ? parsed.text : null,
1347
+ blocks: parsed ? parsed.blocks : null,
1225
1348
  sessionId: data.session_id || data.sessionId || null,
1226
1349
  }
1227
1350
  }
@@ -1377,6 +1500,51 @@ function currentTurnRecord(sessionId) {
1377
1500
  }
1378
1501
  }
1379
1502
 
1503
+ // ── What a Stop has already sent (ENG-2793) ──────────────────────────
1504
+ // The Claude and Copilot readers collect every assistant message since the last
1505
+ // genuine user entry in the transcript. A Stop that fires again before one is
1506
+ // written collects the previous reply as well, and the report showed it twice:
1507
+ // measured on production, 258 hook replies began with the previous reply and a
1508
+ // blank line. So each Stop records the transcript entry its reply ended on, and
1509
+ // the next one sends only the blocks written after it. Kept beside the turn file
1510
+ // rather than in it, because `startTurn` rewrites that one on every prompt and a
1511
+ // re-send crosses prompts as often as not. A transcript without entry ids
1512
+ // records nothing and sends what it collected, as before.
1513
+
1514
+ function repliedFile(sessionId) {
1515
+ return turnFile(sessionId).replace(/\.json$/, ".replied.json")
1516
+ }
1517
+
1518
+ /** The entry id the session's last captured reply ended on, if any. */
1519
+ function lastRepliedEntry(sessionId) {
1520
+ if (!sessionId) return null
1521
+ try {
1522
+ const data = JSON.parse(fs.readFileSync(repliedFile(sessionId), "utf8"))
1523
+ return typeof data.entryId === "string" ? data.entryId : null
1524
+ } catch {
1525
+ return null
1526
+ }
1527
+ }
1528
+
1529
+ function recordRepliedEntry(sessionId, entryId) {
1530
+ if (!sessionId || !entryId) return
1531
+ try {
1532
+ fs.mkdirSync(TURNS_DIR, { recursive: true })
1533
+ fs.writeFileSync(repliedFile(sessionId), JSON.stringify({ entryId, ts: Date.now() }))
1534
+ } catch {
1535
+ // Unwritable: the next reply may repeat this one, which the report folds.
1536
+ }
1537
+ }
1538
+
1539
+ /** The blocks written after `entryId`. All of them when it is not among them,
1540
+ * because then a genuine prompt has started collection again since. */
1541
+ function unsentBlocks(blocks, entryId) {
1542
+ if (!entryId) return blocks
1543
+ let at = -1
1544
+ blocks.forEach((b, i) => { if (b.id === entryId) at = i })
1545
+ return at < 0 ? blocks : blocks.slice(at + 1)
1546
+ }
1547
+
1380
1548
  // ── Cursor's agent edits (ENG-2344) ──────────────────────────────────
1381
1549
  // Composer routes its model traffic to Cursor's own backend, so neither the
1382
1550
  // gateway nor the in-container MITM ever sees the tool call that writes a file
@@ -1716,6 +1884,15 @@ async function main() {
1716
1884
  try { parsed = JSON.parse(raw) } catch { /* extractPrompt reports the failure */ }
1717
1885
  const cursorShaped = isCursorPayload(parsed)
1718
1886
 
1887
+ // ENG-2768: an assessment whose session is over records nothing more. Asked
1888
+ // here, before any transcript read, against the same placement the event
1889
+ // would get (the payload's workspace roots are the only roots any tool's
1890
+ // extractor reports), and asked again below once the event's own placement
1891
+ // is known.
1892
+ const earlyRoots = parsed && Array.isArray(parsed.workspace_roots) ? parsed.workspace_roots : []
1893
+ const earlyLitmusDir = resolveAssessmentDir(parsed, earlyRoots)
1894
+ if (earlyLitmusDir && sessionOverMarked(earlyLitmusDir)) return
1895
+
1719
1896
  // ENG-2477: a capture SELF-TEST probe. `litmus init` (and `litmus doctor`)
1720
1897
  // run this script the way the tool would — the tool's own payload shape, on
1721
1898
  // stdin, through the command string in the tool's own config file — plus
@@ -1767,7 +1944,7 @@ async function main() {
1767
1944
  if (effectiveTool === "cursor" && parsed && parsed.hook_event_name === CURSOR_EDIT_EVENT) {
1768
1945
  const roots = Array.isArray(parsed.workspace_roots) ? parsed.workspace_roots : []
1769
1946
  const editLitmusDir = resolveAssessmentDir(parsed, roots)
1770
- if (!editLitmusDir) return
1947
+ if (!editLitmusDir || sessionOverMarked(editLitmusDir)) return
1771
1948
  recordCursorFileEdit(parsed, parsed.session_id || parsed.conversation_id || null,
1772
1949
  path.dirname(editLitmusDir))
1773
1950
  return
@@ -1777,12 +1954,20 @@ async function main() {
1777
1954
  let sessionId = null
1778
1955
  let workspaceRoots = []
1779
1956
  let responseMeta = null
1957
+ // ENG-2793: the entry this reply ends on, recorded once the reply is written.
1958
+ let repliedEntry = null
1780
1959
  if (responseEvent) {
1781
1960
  const extracted = extractResponse(parsed, effectiveTool)
1782
1961
  content = extracted.text
1783
1962
  sessionId = extracted.sessionId
1784
1963
  workspaceRoots = extracted.workspaceRoots || []
1785
1964
  responseMeta = extracted.meta || null
1965
+ const blocks = extracted.blocks
1966
+ if (blocks && blocks.length && sessionId && !selftestNonce) {
1967
+ const unsent = unsentBlocks(blocks, lastRepliedEntry(sessionId))
1968
+ content = unsent.length ? unsent.map((b) => b.text).join("\n\n") : null
1969
+ repliedEntry = blocks[blocks.length - 1].id
1970
+ }
1786
1971
  } else {
1787
1972
  const extracted = extractPrompt(raw, effectiveTool)
1788
1973
  content = extracted.prompt
@@ -1797,6 +1982,7 @@ async function main() {
1797
1982
 
1798
1983
  const litmusDir = resolveAssessmentDir(parsed, workspaceRoots)
1799
1984
  if (!litmusDir) return
1985
+ if (litmusDir !== earlyLitmusDir && sessionOverMarked(litmusDir)) return
1800
1986
 
1801
1987
  // Claim as LATE as possible — only once we know we have content and somewhere
1802
1988
  // to put it. Claiming earlier would let an invocation that then bails (no
@@ -1907,6 +2093,10 @@ async function main() {
1907
2093
  } catch {
1908
2094
  // Non-critical
1909
2095
  }
2096
+ // ENG-2793: the next Stop starts after this reply only once a copy of it is
2097
+ // on disk. The drain and the upload report no outcome, so a reply whose append
2098
+ // failed is sent again by the next Stop, and the report folds the repeat.
2099
+ if (repliedEntry && logged) recordRepliedEntry(sessionId, repliedEntry)
1910
2100
  // ENG-2344: the edits this reply carries leave the ledger only now that the
1911
2101
  // reply is on disk. An append that failed hands them back for the next reply.
1912
2102
  if (editLedger) {
@@ -1922,7 +2112,15 @@ async function main() {
1922
2112
 
1923
2113
  // 2. Upload to the server. Awaited, bounded — see uploadEvent.
1924
2114
  const config = readConfig(litmusDir)
1925
- await uploadEvent(config, event)
2115
+ const upload = await uploadEvent(config, event)
2116
+
2117
+ // ENG-2768: the server says this token will never be accepted again. Mark
2118
+ // the folder so the next turn stops at the top of main(), and skip the
2119
+ // revival below: there is no session left for a watcher to serve.
2120
+ if (upload.sessionOver) {
2121
+ writeSessionOverMarker(litmusDir, config, "server refused the token")
2122
+ return
2123
+ }
1926
2124
 
1927
2125
  // 3. Revive a dead watcher (ENG-1491). Strictly after the capture/upload
1928
2126
  // work — this is a courtesy, and it must never cost the prompt anything.
@@ -1937,6 +2135,9 @@ async function main() {
1937
2135
  // Exported for unit tests only; every hook invocation runs this file directly.
1938
2136
  module.exports = {
1939
2137
  watcherPresumedDead,
2138
+ isFinalRefusal,
2139
+ sessionOverMarked,
2140
+ SESSION_OVER_MARKER,
1940
2141
  reviveThrottleAllows,
1941
2142
  buildDrainFrame,
1942
2143
  trimEventToBytes,
@@ -0,0 +1,32 @@
1
+ /**
2
+ * ENG-2768: the "this session is over" marker the hook logger reads at the top
3
+ * of every invocation. Kept in step with `writeSessionOverMarker` and
4
+ * `sessionOverMarked` in hook-logger.cjs (that file is standalone CommonJS and
5
+ * cannot import this one): same names, same JSON shape.
6
+ *
7
+ * Two copies, either one enough. `.litmus/session-over` names the token as a
8
+ * sha256 hash, so a later `litmus init` into the same folder, which carries a
9
+ * new token, is not silenced by it. `~/.litmus/sessions-over/<hash>` is the
10
+ * same fact recorded outside the folder, for a folder that cannot be written.
11
+ */
12
+ export declare const SESSION_OVER_MARKER = "session-over";
13
+ export declare function sessionsOverDir(): string;
14
+ export declare function writeSessionOverMarker(projectDir: string, token: string | undefined, reason: string): boolean;
15
+ /** Has the session this folder's config belongs to been marked over? */
16
+ export declare function sessionOverMarked(projectDir: string): boolean;
17
+ /**
18
+ * What the watcher does with its own AI capture when the server says the
19
+ * session is over. The marker silences the hook logger in this folder even
20
+ * while another assessment keeps the user-scope hooks installed; the release
21
+ * drops the registry entry, the folder's own hooks and trust entries, and the
22
+ * user-scope hooks once no assessment is left. Only `litmus submit` used to do
23
+ * the release, so a session that ended any other way kept a live hook.
24
+ *
25
+ * Run by the watcher because it is alive exactly when the session ends, so no
26
+ * hook writer races it (the ENG-1289 concurrency problem). Marker first: it
27
+ * alone already stops capture, so a release that throws halfway loses nothing.
28
+ * The marker also keeps startTracker from bringing a watcher back here, whose
29
+ * hook self-heal would otherwise reinstall everything this just released.
30
+ */
31
+ export declare function retireAiCaptureForEndedSession(projectDir: string, token: string | undefined): void;
32
+ //# sourceMappingURL=session-over.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-over.d.ts","sourceRoot":"","sources":["../../src/lib/session-over.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,iBAAiB,CAAA;AAEjD,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAMD,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAmB7G;AAED,wEAAwE;AACxE,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAuB7D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,8BAA8B,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAGlG"}
@@ -0,0 +1,99 @@
1
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { createHash } from "crypto";
5
+ import { releaseAiTrackingForAssessment } from "./ai-tracking.js";
6
+ /**
7
+ * ENG-2768: the "this session is over" marker the hook logger reads at the top
8
+ * of every invocation. Kept in step with `writeSessionOverMarker` and
9
+ * `sessionOverMarked` in hook-logger.cjs (that file is standalone CommonJS and
10
+ * cannot import this one): same names, same JSON shape.
11
+ *
12
+ * Two copies, either one enough. `.litmus/session-over` names the token as a
13
+ * sha256 hash, so a later `litmus init` into the same folder, which carries a
14
+ * new token, is not silenced by it. `~/.litmus/sessions-over/<hash>` is the
15
+ * same fact recorded outside the folder, for a folder that cannot be written.
16
+ */
17
+ export const SESSION_OVER_MARKER = "session-over";
18
+ export function sessionsOverDir() {
19
+ return path.join(os.homedir() || os.tmpdir(), ".litmus", "sessions-over");
20
+ }
21
+ function tokenDigest(token) {
22
+ return typeof token === "string" && token ? createHash("sha256").update(token).digest("hex") : null;
23
+ }
24
+ export function writeSessionOverMarker(projectDir, token, reason) {
25
+ const digest = tokenDigest(token);
26
+ let written = false;
27
+ try {
28
+ writeFileSync(path.join(projectDir, ".litmus", SESSION_OVER_MARKER), JSON.stringify({
29
+ reason,
30
+ at: new Date().toISOString(),
31
+ tokenSha256: digest,
32
+ }) + "\n");
33
+ written = true;
34
+ }
35
+ catch { /* unwritable folder; the home copy below still counts */ }
36
+ if (digest) {
37
+ try {
38
+ mkdirSync(sessionsOverDir(), { recursive: true });
39
+ writeFileSync(path.join(sessionsOverDir(), digest), "");
40
+ written = true;
41
+ }
42
+ catch { /* best effort */ }
43
+ }
44
+ return written;
45
+ }
46
+ /** Has the session this folder's config belongs to been marked over? */
47
+ export function sessionOverMarked(projectDir) {
48
+ let current = null;
49
+ try {
50
+ current = tokenDigest(JSON.parse(readFileSync(path.join(projectDir, ".litmus", "config.json"), "utf8")).token);
51
+ }
52
+ catch { /* no readable config */ }
53
+ if (current) {
54
+ try {
55
+ if (statSync(path.join(sessionsOverDir(), current)).isFile())
56
+ return true;
57
+ }
58
+ catch { /* not recorded at home */ }
59
+ }
60
+ let raw;
61
+ try {
62
+ raw = readFileSync(path.join(projectDir, ".litmus", SESSION_OVER_MARKER), "utf8");
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ let marked = null;
68
+ try {
69
+ const parsed = JSON.parse(raw);
70
+ if (typeof parsed?.tokenSha256 === "string" && parsed.tokenSha256)
71
+ marked = parsed.tokenSha256;
72
+ }
73
+ catch { /* names no token */ }
74
+ if (!marked)
75
+ return true;
76
+ return !current || current === marked;
77
+ }
78
+ /**
79
+ * What the watcher does with its own AI capture when the server says the
80
+ * session is over. The marker silences the hook logger in this folder even
81
+ * while another assessment keeps the user-scope hooks installed; the release
82
+ * drops the registry entry, the folder's own hooks and trust entries, and the
83
+ * user-scope hooks once no assessment is left. Only `litmus submit` used to do
84
+ * the release, so a session that ended any other way kept a live hook.
85
+ *
86
+ * Run by the watcher because it is alive exactly when the session ends, so no
87
+ * hook writer races it (the ENG-1289 concurrency problem). Marker first: it
88
+ * alone already stops capture, so a release that throws halfway loses nothing.
89
+ * The marker also keeps startTracker from bringing a watcher back here, whose
90
+ * hook self-heal would otherwise reinstall everything this just released.
91
+ */
92
+ export function retireAiCaptureForEndedSession(projectDir, token) {
93
+ writeSessionOverMarker(projectDir, token, "watcher: server signaled session over");
94
+ try {
95
+ releaseAiTrackingForAssessment(projectDir);
96
+ }
97
+ catch { /* best effort */ }
98
+ }
99
+ //# sourceMappingURL=session-over.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-over.js","sourceRoot":"","sources":["../../src/lib/session-over.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ENG-2814 — find the candidate's own AI tool skills on THIS machine, for `litmus
3
+ * connect` to offer to import into their workspace.
4
+ *
5
+ * Runs on the laptop because that is the only place the skills are: the workspace
6
+ * cannot see the candidate's home. The upload goes to the same `/workspace/skills`
7
+ * routes the assessment page's Import skills dialog uses, and NOTHING about which
8
+ * files are skills is decided here: `POST /workspace/skills/plan` runs the upload's
9
+ * own parser over the paths and SKILL.md heads and answers with the files to send
10
+ * (`backend/services/candidate_skills.py`). One parser cannot disagree with itself.
11
+ *
12
+ * Every folder an AI tool reads Agent Skills (`<name>/SKILL.md`) from is looked in.
13
+ * A folder that does not exist costs nothing; a skill found in two of them (a
14
+ * `.claude/skills` copy of an `.agents/skills` one, say) is imported once, and the
15
+ * server reports the other as a duplicate.
16
+ */
17
+ export declare const SKILL_SOURCES: ReadonlyArray<{
18
+ dir: string;
19
+ tools: string;
20
+ }>;
21
+ export declare const PLAN_HEAD_CHARS: number;
22
+ export interface LocalSkillFile {
23
+ /** Sent to the server. The source folder without its dot (`claude/skills/x/SKILL.md`):
24
+ * the server skips hidden folders other than the ones it knows hold skills, and
25
+ * `.cursor` or `.gemini` are not among them. */
26
+ path: string;
27
+ abs: string;
28
+ size: number;
29
+ mtimeMs: number;
30
+ }
31
+ /**
32
+ * Every file under each skills folder that exists.
33
+ *
34
+ * A link is followed only where it stays inside what the candidate agreed to share.
35
+ * An entry directly under a skills folder may be a link (a skill kept in dotfiles and
36
+ * linked in is common), and it sets the boundary: below it, a file or folder whose
37
+ * real path leaves that entry is not collected, so a link in a skill cannot carry an
38
+ * unrelated file off the laptop under the skill's name.
39
+ */
40
+ export declare function collectSkillFiles(home?: string): LocalSkillFile[];
41
+ export declare const MAX_UPLOAD_BYTES: number;
42
+ export declare const MAX_UPLOAD_FILES = 1000;
43
+ /** Why these files cannot be uploaded in one import, or null when they fit. */
44
+ export declare function overUploadCaps(files: LocalSkillFile[]): string | null;
45
+ export interface SkillPlanRequest {
46
+ /** Leave out a skill the server cannot read and name it in `refused`, rather than
47
+ * refusing every skill over one malformed SKILL.md. */
48
+ skipInvalid: true;
49
+ paths: string[];
50
+ skillFiles: Record<string, string>;
51
+ unreadable: string[];
52
+ truncated: string[];
53
+ }
54
+ /** The plan route's body: every path, and the head of each SKILL.md. */
55
+ export declare function planRequest(files: LocalSkillFile[]): SkillPlanRequest;
56
+ /** The skill folder names among the paths the server said to send, for the prompt. */
57
+ export declare function skillNames(kept: string[]): string[];
58
+ /**
59
+ * What the candidate was last asked about, per assessment, so a candidate who
60
+ * reconnects every day of a work trial is asked again only when their skills changed.
61
+ * A digest of the kept files' paths, sizes and modification times: cheap, and any
62
+ * edit to a skill changes it.
63
+ */
64
+ export declare function skillSetDigest(files: LocalSkillFile[]): string;
65
+ export declare function lastAnswered(assessmentId: string, home?: string): string | null;
66
+ /** Best effort: failing to remember only means being asked again next time. */
67
+ export declare function rememberAnswered(assessmentId: string, digest: string, home?: string): void;
68
+ export interface SkillImportResult {
69
+ installed: string[];
70
+ skipped: {
71
+ name: string;
72
+ dest: string;
73
+ reason: string;
74
+ }[];
75
+ ignored: string[];
76
+ duplicates: string[];
77
+ aider: string | null;
78
+ }
79
+ /** What the import did, in the same words the assessment page's dialog uses. */
80
+ export declare function importSummaryLines(result: SkillImportResult): string[];
81
+ export declare const SKILLS_PICKUP = "Start a new Claude Code or Codex session to use them. In Copilot, run /skills reload.";
82
+ //# sourceMappingURL=skill-import.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill-import.d.ts","sourceRoot":"","sources":["../../src/lib/skill-import.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,aAAa,EAAE,aAAa,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAOvE,CAAA;AAUD,eAAO,MAAM,eAAe,QAAY,CAAA;AAExC,MAAM,WAAW,cAAc;IAC7B;;qDAEiD;IACjD,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;CAChB;AAQD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,GAAE,MAAqB,GAAG,cAAc,EAAE,CAiD/E;AAID,eAAO,MAAM,gBAAgB,QAAmB,CAAA;AAChD,eAAO,MAAM,gBAAgB,OAAO,CAAA;AAEpC,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,KAAK,EAAE,cAAc,EAAE,GAAG,MAAM,GAAG,IAAI,CAKrE;AAED,MAAM,WAAW,gBAAgB;IAC/B;4DACwD;IACxD,WAAW,EAAE,IAAI,CAAA;IACjB,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAClC,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,SAAS,EAAE,MAAM,EAAE,CAAA;CACpB;AAED,wEAAwE;AACxE,wBAAgB,WAAW,CAAC,KAAK,EAAE,cAAc,EAAE,GAAG,gBAAgB,CAkBrE;AAED,sFAAsF;AACtF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAOnD;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,cAAc,EAAE,GAAG,MAAM,CAM9D;AAMD,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,EAAE,IAAI,GAAE,MAAqB,GAAG,MAAM,GAAG,IAAI,CAQ7F;AAED,+EAA+E;AAC/E,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,MAAqB,GAAG,IAAI,CAexG;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACzD,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB;AAQD,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,EAAE,CAgBtE;AAED,eAAO,MAAM,aAAa,0FAA0F,CAAA"}