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.
- package/README.md +7 -0
- package/dist/commands/connect.d.ts +2 -0
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +21 -0
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/doctor.d.ts +20 -2
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +51 -8
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/submit.d.ts.map +1 -1
- package/dist/commands/submit.js +1 -42
- package/dist/commands/submit.js.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/ai-tracking.d.ts +13 -0
- package/dist/lib/ai-tracking.d.ts.map +1 -1
- package/dist/lib/ai-tracking.js +71 -0
- package/dist/lib/ai-tracking.js.map +1 -1
- package/dist/lib/api.d.ts +23 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +68 -0
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/backfill.d.ts +46 -3
- package/dist/lib/backfill.d.ts.map +1 -1
- package/dist/lib/backfill.js +203 -3
- package/dist/lib/backfill.js.map +1 -1
- package/dist/lib/connect-skills.d.ts +39 -0
- package/dist/lib/connect-skills.d.ts.map +1 -0
- package/dist/lib/connect-skills.js +138 -0
- package/dist/lib/connect-skills.js.map +1 -0
- package/dist/lib/hook-logger.cjs +216 -15
- package/dist/lib/session-over.d.ts +32 -0
- package/dist/lib/session-over.d.ts.map +1 -0
- package/dist/lib/session-over.js +99 -0
- package/dist/lib/session-over.js.map +1 -0
- package/dist/lib/skill-import.d.ts +82 -0
- package/dist/lib/skill-import.d.ts.map +1 -0
- package/dist/lib/skill-import.js +228 -0
- package/dist/lib/skill-import.js.map +1 -0
- package/dist/lib/tracker.d.ts.map +1 -1
- package/dist/lib/tracker.js +10 -0
- package/dist/lib/tracker.js.map +1 -1
- package/dist/lib/watcher.js +8 -0
- package/dist/lib/watcher.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/hook-logger.cjs
CHANGED
|
@@ -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
|
-
|
|
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 =>
|
|
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 =>
|
|
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:
|
|
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:
|
|
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"}
|