litmus-cli 1.4.1 → 1.4.3

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 (44) hide show
  1. package/dist/commands/doctor.d.ts +136 -0
  2. package/dist/commands/doctor.d.ts.map +1 -0
  3. package/dist/commands/doctor.js +1083 -0
  4. package/dist/commands/doctor.js.map +1 -0
  5. package/dist/commands/init.d.ts +1 -0
  6. package/dist/commands/init.d.ts.map +1 -1
  7. package/dist/commands/init.js +8 -2
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/status.d.ts.map +1 -1
  10. package/dist/commands/status.js +5 -1
  11. package/dist/commands/status.js.map +1 -1
  12. package/dist/index.js +25 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/lib/ai-tracking.d.ts +35 -0
  15. package/dist/lib/ai-tracking.d.ts.map +1 -1
  16. package/dist/lib/ai-tracking.js +40 -10
  17. package/dist/lib/ai-tracking.js.map +1 -1
  18. package/dist/lib/api.d.ts +14 -0
  19. package/dist/lib/api.d.ts.map +1 -1
  20. package/dist/lib/api.js +40 -0
  21. package/dist/lib/api.js.map +1 -1
  22. package/dist/lib/backfill-tools.d.ts +126 -0
  23. package/dist/lib/backfill-tools.d.ts.map +1 -0
  24. package/dist/lib/backfill-tools.js +787 -0
  25. package/dist/lib/backfill-tools.js.map +1 -0
  26. package/dist/lib/backfill.d.ts +124 -0
  27. package/dist/lib/backfill.d.ts.map +1 -0
  28. package/dist/lib/backfill.js +362 -0
  29. package/dist/lib/backfill.js.map +1 -0
  30. package/dist/lib/chain.d.ts +42 -0
  31. package/dist/lib/chain.d.ts.map +1 -0
  32. package/dist/lib/chain.js +98 -0
  33. package/dist/lib/chain.js.map +1 -0
  34. package/dist/lib/errors.d.ts +19 -0
  35. package/dist/lib/errors.d.ts.map +1 -1
  36. package/dist/lib/errors.js +9 -1
  37. package/dist/lib/errors.js.map +1 -1
  38. package/dist/lib/tracker.d.ts +16 -0
  39. package/dist/lib/tracker.d.ts.map +1 -1
  40. package/dist/lib/tracker.js +23 -0
  41. package/dist/lib/tracker.js.map +1 -1
  42. package/dist/lib/watcher.js +110 -5
  43. package/dist/lib/watcher.js.map +1 -1
  44. package/package.json +1 -1
@@ -0,0 +1,1083 @@
1
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, renameSync, unlinkSync, statSync } from "fs";
2
+ import path from "path";
3
+ import { execSync } from "child_process";
4
+ import * as readline from "readline/promises";
5
+ import chalk from "chalk";
6
+ import { readConfig, writeConfig, findProjectRoot, getEffectiveDeadline } from "../lib/config.js";
7
+ import { fatal, setErrorContext, reportEvent } from "../lib/errors.js";
8
+ import { formatDeadlinePT } from "../lib/format-time.js";
9
+ import { startTracker, isTrackerHealthy, trackerDiagnostics } from "../lib/tracker.js";
10
+ import { detectInstalledHooks, readRegistrySnapshot } from "../lib/ai-tracking.js";
11
+ import { setupAiTracking } from "./init.js";
12
+ import { fetchSubmitStatus, fetchInitMetadata, uploadActivityEvents, ServerError } from "../lib/api.js";
13
+ import { resolveApiBase } from "../lib/api-base.js";
14
+ import { collectBackfillSessions, planUploadBatches, defaultClaudeProjectsDir, } from "../lib/backfill.js";
15
+ import { collectToolBackfillSources } from "../lib/backfill-tools.js";
16
+ import { CLI_VERSION } from "../lib/version.js";
17
+ // Doctor prints in the same grammar as `litmus status` and `litmus submit`:
18
+ // plain 4-space "Key: value" lines where the STATE lives in the wording and
19
+ // color, not in a status gutter. ok/fixed render plain (the words say what
20
+ // happened), warn renders yellow, fail renders red — mirroring status's red
21
+ // "Time remaining: PASSED" and yellow "Warning:" conventions.
22
+ const STATUS_PAINT = {
23
+ ok: (s) => s,
24
+ fixed: (s) => s,
25
+ warn: chalk.yellow,
26
+ fail: chalk.red,
27
+ };
28
+ function plural(n, word) {
29
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
30
+ }
31
+ // ── Tunables ─────────────────────────────────────────────────────
32
+ // Backlog flush window when there is no cursor: the dead watcher's last
33
+ // heartbeat marks roughly when server uploads stopped, and its unflushed
34
+ // in-memory buffer held at most one heartbeat interval of events. Reaching
35
+ // one extra interval further back trades a few duplicate rows (the server
36
+ // stores batches append-only) for never missing the gap.
37
+ const FLUSH_WINDOW_BEFORE_LAST_HEARTBEAT_MS = 10 * 60 * 1000;
38
+ // Per-request caps for the backlog flush. The server accepts up to 5000
39
+ // events per batch and rate-limits /cli/activity per candidate, so a bounded
40
+ // number of modest batches per doctor run stays well inside both while still
41
+ // clearing any realistic gap. Anything left over is remembered in the cursor
42
+ // and picked up by the next run.
43
+ export const MAX_EVENTS_PER_BATCH = 500;
44
+ export const MAX_BATCH_BYTES = 2000000;
45
+ const MAX_BATCHES_PER_RUN = 4;
46
+ /** One pass over raw activity-log text: counts and latest timestamps. */
47
+ export function scanActivityLog(text) {
48
+ const scan = {
49
+ totalBytes: Buffer.byteLength(text, "utf8"),
50
+ eventCount: 0,
51
+ malformedCount: 0,
52
+ aiPromptCount: 0,
53
+ lastEventTs: null,
54
+ lastHeartbeatTs: null,
55
+ lastLiveAiPromptTs: null,
56
+ };
57
+ for (const line of text.split("\n")) {
58
+ if (!line.trim())
59
+ continue;
60
+ let event;
61
+ try {
62
+ event = JSON.parse(line);
63
+ }
64
+ catch {
65
+ scan.malformedCount++;
66
+ continue;
67
+ }
68
+ if (!event || typeof event !== "object") {
69
+ scan.malformedCount++;
70
+ continue;
71
+ }
72
+ scan.eventCount++;
73
+ const e = event;
74
+ if (typeof e.ts === "string")
75
+ scan.lastEventTs = e.ts;
76
+ if (e.type === "ai_prompt") {
77
+ scan.aiPromptCount++;
78
+ if (e.backfilled !== true && typeof e.ts === "string")
79
+ scan.lastLiveAiPromptTs = e.ts;
80
+ }
81
+ if (e.type === "heartbeat" && typeof e.ts === "string")
82
+ scan.lastHeartbeatTs = e.ts;
83
+ }
84
+ return scan;
85
+ }
86
+ /**
87
+ * Byte offset of the first log line whose event timestamp is at or after
88
+ * `cutoffMs`. Lines without a parseable timestamp don't start the window.
89
+ * Returns the total byte length (i.e. "flush nothing") when no line qualifies.
90
+ */
91
+ export function offsetForFirstEventAtOrAfter(text, cutoffMs) {
92
+ let offset = 0;
93
+ for (const line of text.split("\n")) {
94
+ const lineBytes = Buffer.byteLength(line, "utf8") + 1; // + newline
95
+ if (line.trim()) {
96
+ try {
97
+ const event = JSON.parse(line);
98
+ if (typeof event.ts === "string") {
99
+ const ts = Date.parse(event.ts);
100
+ if (!isNaN(ts) && ts >= cutoffMs)
101
+ return offset;
102
+ }
103
+ }
104
+ catch { /* malformed line can't start the window */ }
105
+ }
106
+ offset += lineBytes;
107
+ }
108
+ return Buffer.byteLength(text, "utf8");
109
+ }
110
+ /**
111
+ * Slice the log from `startOffset` into upload batches. Events are passed
112
+ * through VERBATIM (chained watcher events stay verifiable); malformed lines
113
+ * are skipped but still advance the offset so they are never retried forever.
114
+ * `startOffset` must fall on a line boundary (it always does: every stored
115
+ * cursor value is a `FlushBatch.endOffset`); a mid-line offset skips the torn
116
+ * line rather than uploading half an event.
117
+ */
118
+ export function planFlushBatches(text, startOffset) {
119
+ const batches = [];
120
+ let offset = 0;
121
+ let current = [];
122
+ let currentBytes = 0;
123
+ let end = startOffset;
124
+ const finish = () => {
125
+ if (current.length > 0) {
126
+ batches.push({ events: current, endOffset: end });
127
+ current = [];
128
+ currentBytes = 0;
129
+ }
130
+ };
131
+ for (const line of text.split("\n")) {
132
+ const lineBytes = Buffer.byteLength(line, "utf8") + 1;
133
+ const lineStart = offset;
134
+ offset += lineBytes;
135
+ if (lineStart < startOffset)
136
+ continue;
137
+ if (!line.trim()) {
138
+ end = Math.min(offset, Buffer.byteLength(text, "utf8"));
139
+ continue;
140
+ }
141
+ let event;
142
+ try {
143
+ event = JSON.parse(line);
144
+ }
145
+ catch {
146
+ end = offset; // skip malformed, but move past it
147
+ continue;
148
+ }
149
+ const eventBytes = Buffer.byteLength(line, "utf8");
150
+ if (current.length >= MAX_EVENTS_PER_BATCH || currentBytes + eventBytes > MAX_BATCH_BYTES) {
151
+ finish();
152
+ }
153
+ current.push(event);
154
+ currentBytes += eventBytes;
155
+ end = Math.min(offset, Buffer.byteLength(text, "utf8"));
156
+ }
157
+ finish();
158
+ return batches;
159
+ }
160
+ // ── Flush cursor (.litmus/upload-state.json) ─────────────────────
161
+ // Records the byte offset up to which doctor has delivered locally-recorded
162
+ // events to the server. Only exists while a recovery is in flight: a fully
163
+ // flushed backlog removes it (live watcher uploads own everything after
164
+ // that), and it NEVER moves backward.
165
+ const UPLOAD_STATE_FILE = "upload-state.json";
166
+ export function readUploadState(litmusDir) {
167
+ try {
168
+ const raw = JSON.parse(readFileSync(path.join(litmusDir, UPLOAD_STATE_FILE), "utf8"));
169
+ const v = raw?.flushFromBytes;
170
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0)
171
+ return v;
172
+ }
173
+ catch { /* absent or unreadable */ }
174
+ return null;
175
+ }
176
+ export function writeUploadState(litmusDir, flushFromBytes) {
177
+ const existing = readUploadState(litmusDir);
178
+ // Forward-only: a smaller offset would make the next run re-upload events
179
+ // the server already has.
180
+ const value = Math.max(existing ?? 0, flushFromBytes);
181
+ const target = path.join(litmusDir, UPLOAD_STATE_FILE);
182
+ const tmp = `${target}.litmus-tmp-${process.pid}-${Date.now()}`;
183
+ writeFileSync(tmp, JSON.stringify({ flushFromBytes: value, updatedAt: new Date().toISOString() }, null, 2) + "\n", "utf8");
184
+ renameSync(tmp, target);
185
+ }
186
+ function clearUploadState(litmusDir) {
187
+ try {
188
+ unlinkSync(path.join(litmusDir, UPLOAD_STATE_FILE));
189
+ }
190
+ catch { /* already gone */ }
191
+ }
192
+ // ── Small utilities ──────────────────────────────────────────────
193
+ function sleep(ms) {
194
+ return new Promise((r) => setTimeout(r, ms));
195
+ }
196
+ async function waitFor(predicate, timeoutMs) {
197
+ const deadline = Date.now() + timeoutMs;
198
+ while (Date.now() < deadline) {
199
+ if (predicate())
200
+ return true;
201
+ await sleep(100);
202
+ }
203
+ return predicate();
204
+ }
205
+ function agoText(tsMs) {
206
+ const delta = Math.max(0, Date.now() - tsMs);
207
+ const mins = Math.floor(delta / 60000);
208
+ if (mins < 1)
209
+ return "under a minute ago";
210
+ if (mins < 60)
211
+ return `${mins}m ago`;
212
+ const hours = Math.floor(mins / 60);
213
+ if (hours < 48)
214
+ return `${hours}h ${mins % 60}m ago`;
215
+ return `${Math.floor(hours / 24)}d ${hours % 24}h ago`;
216
+ }
217
+ /** Append a doctor line to .litmus/tracker.log (the tracking diagnostics log
218
+ * that already ships with every submission). Never throws. */
219
+ function logToTrackerLog(projectRoot, line) {
220
+ try {
221
+ appendFileSync(path.join(projectRoot, ".litmus", "tracker.log"), `[doctor] ${new Date().toISOString()} ${line}\n`);
222
+ }
223
+ catch { /* diagnostics logging must never break the repair */ }
224
+ }
225
+ // ── Config rebuild (R1) ──────────────────────────────────────────
226
+ /**
227
+ * Sanity-check the times the server sent before writing a rebuilt config.
228
+ * Returns a human-readable problem, or null when they are usable.
229
+ *
230
+ * The failure this guards against is real: the init route used to answer
231
+ * every re-fetch with `startedAt: now`, and a config rebuilt from that
232
+ * mid-session arms the client-side deadline (startedAt + timeLimit) hours
233
+ * late — the tracker then sleeps through the real cutoff. The route is fixed
234
+ * to return the candidate's true start, and this check refuses any regression
235
+ * of that class rather than writing a config that lies about time.
236
+ *
237
+ * A deadline already in the past is deliberately NOT refused: the deadline
238
+ * check reports it, and support flows still need the config on disk.
239
+ */
240
+ export function validateRebuiltTimes(meta, nowMs) {
241
+ const started = Date.parse(meta.startedAt);
242
+ if (isNaN(started))
243
+ return "the start time was unreadable";
244
+ // Small allowance for client clock skew; anything past it means the server
245
+ // stamped a fresh "now" instead of the real start.
246
+ if (started > nowMs + 2 * 60000)
247
+ return "the start time is in the future";
248
+ const effective = getEffectiveDeadline(meta);
249
+ if (effective !== null && effective < started)
250
+ return "the cutoff is earlier than the start time";
251
+ return null;
252
+ }
253
+ async function promptForToken(corrupted) {
254
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
255
+ return null;
256
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
257
+ try {
258
+ // The prompt carries its own context: it is the only place the candidate
259
+ // learns the settings file is gone, and it only appears when no token was
260
+ // passed on the command line.
261
+ const answer = await rl.question(` Your assessment settings file is ${corrupted ? "unreadable" : "missing"}. Paste your assessment token to restore it (it is on your assessment page, under "Something not working?"): `);
262
+ return answer.trim() || null;
263
+ }
264
+ finally {
265
+ rl.close();
266
+ }
267
+ }
268
+ /**
269
+ * Rebuild a missing or corrupted .litmus/config.json in place (R1).
270
+ *
271
+ * This is `litmus init` minus everything destructive: fetch the same
272
+ * metadata, write the same config — but into the CURRENT directory. Never
273
+ * create a folder, never download the starter zip: the candidate's work is
274
+ * already here, possibly days deep, and must not be touched.
275
+ *
276
+ * Either returns the freshly written config or exits via fatal() — there is
277
+ * no degraded mode, because every later repair (deadline, tracker, capture,
278
+ * uploads) needs a config to act on.
279
+ */
280
+ async function rebuildConfig(args) {
281
+ const { projectRoot, corrupted } = args;
282
+ let token = args.tokenFlag?.trim() || null;
283
+ if (!token)
284
+ token = await promptForToken(corrupted);
285
+ if (!token) {
286
+ fatal("No Litmus assessment found in this directory.", 'Doctor needs your assessment token to restore it. The token is on your assessment page in the browser, under "Something not working?". Run "litmus doctor" again and paste it when asked. If you cannot find it, contact support@litmushiring.com.', { severity: "warning" });
287
+ }
288
+ const apiBase = resolveApiBase(process.env.LITMUS_API_URL);
289
+ setErrorContext({ token, apiBase });
290
+ let metadata;
291
+ try {
292
+ metadata = await fetchInitMetadata(apiBase, token);
293
+ }
294
+ catch (err) {
295
+ const msg = err instanceof Error ? err.message : String(err);
296
+ const detail = err instanceof ServerError ? `${err.message}\n${err.detail}` : undefined;
297
+ const severity = err instanceof ServerError && err.statusCode && err.statusCode < 500 ? "warning" : "error";
298
+ fatal(msg, "Check the token and try again, or contact support@litmushiring.com for help.", { internalDetail: detail, severity });
299
+ }
300
+ setErrorContext({ candidateEmail: metadata.candidateEmail, assessmentName: metadata.assessmentName });
301
+ const timeProblem = validateRebuiltTimes(metadata, Date.now());
302
+ if (timeProblem) {
303
+ fatal(`The server sent assessment settings that look wrong (${timeProblem}), so nothing was written.`, "Contact support@litmushiring.com and include this output.", {
304
+ internalDetail: JSON.stringify({ startedAt: metadata.startedAt, deadline: metadata.deadline, timeLimit: metadata.timeLimit }),
305
+ severity: "error",
306
+ });
307
+ }
308
+ const rebuilt = {
309
+ assessmentId: metadata.assessmentId,
310
+ assessmentName: metadata.assessmentName,
311
+ candidateEmail: metadata.candidateEmail,
312
+ candidateName: metadata.candidateName,
313
+ token,
314
+ startedAt: metadata.startedAt,
315
+ deadline: metadata.deadline,
316
+ timeLimit: metadata.timeLimit,
317
+ walkthroughWindowMinutes: metadata.walkthroughWindowMinutes,
318
+ apiBase,
319
+ backendUrl: metadata.backendUrl,
320
+ cliVersion: CLI_VERSION,
321
+ };
322
+ await writeConfig(projectRoot, rebuilt);
323
+ return rebuilt;
324
+ }
325
+ // ── Tracker revival ──────────────────────────────────────────────
326
+ /**
327
+ * Restart the tracker through the SAME spawn/ownership path init uses
328
+ * (startTracker: health check, cross-process spawn lock, watcher-side
329
+ * singleton claim). `graceful` handles the live-but-stalled case: ask the
330
+ * incumbent to flush and exit via the shutdown sentinel it already polls,
331
+ * then clear the stale identity files so startTracker doesn't read the
332
+ * incumbent as healthy and refuse to spawn.
333
+ *
334
+ * Deliberately NO SIGTERM anywhere: a stalled tracker's pid may belong to an
335
+ * unrelated process that recycled it, and doctor must never signal a pid it
336
+ * cannot verify. The sentinel only affects our own watcher, and a genuinely
337
+ * live-but-superseded watcher also exits on its own via the singleton claim.
338
+ */
339
+ async function reviveTracker(projectRoot, opts) {
340
+ const litmusDir = path.join(projectRoot, ".litmus");
341
+ const sentinel = path.join(litmusDir, "shutdown");
342
+ if (opts.graceful) {
343
+ try {
344
+ writeFileSync(sentinel, String(Date.now()), "utf8");
345
+ }
346
+ catch { /* best effort */ }
347
+ // The watcher polls the sentinel every second and deletes it during its
348
+ // graceful shutdown (which also flushes its upload buffer). Give it a
349
+ // few seconds to do so.
350
+ await waitFor(() => !existsSync(sentinel), 3000);
351
+ // Clear the stale identity so startTracker's health check doesn't keep
352
+ // trusting the old pid + nonce. Control files only, never the log.
353
+ try {
354
+ unlinkSync(path.join(litmusDir, "tracker.pid"));
355
+ }
356
+ catch { /* gone */ }
357
+ try {
358
+ unlinkSync(path.join(litmusDir, "tracker.nonce"));
359
+ }
360
+ catch { /* gone */ }
361
+ }
362
+ // A leftover sentinel (e.g. written by a submit path after the watcher had
363
+ // already died) would make the NEW watcher shut itself down within a
364
+ // second of starting. Remove it before spawning.
365
+ try {
366
+ unlinkSync(sentinel);
367
+ }
368
+ catch { /* not present */ }
369
+ startTracker(projectRoot);
370
+ return waitFor(() => isTrackerHealthy(projectRoot), 3000);
371
+ }
372
+ // ── Diagnostic phone-home payload (R5) ───────────────────────────
373
+ function mtimeOrNull(p) {
374
+ try {
375
+ return statSync(p).mtime.toISOString();
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ }
381
+ /**
382
+ * The forensic snapshot we cannot get server-side (R5). The single most
383
+ * important bit is `hooks.claudeUserScope`: submit is what uninstalls the
384
+ * user-scope hook, so on a live assessment it discriminates "hook install
385
+ * worked, something later deleted the config" (true) from "install failed or
386
+ * fell back to project scope at init" (false) — the two surviving causes of
387
+ * a zero-prompts session. The mtimes date the damage; the registry shows what
388
+ * the shared logger could still resolve.
389
+ */
390
+ export function collectDoctorDiagnostics(projectRoot, results) {
391
+ const litmusDir = path.join(projectRoot, ".litmus");
392
+ let claudeVersion = null;
393
+ try {
394
+ claudeVersion = execSync("claude --version", { timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
395
+ }
396
+ catch { /* claude not installed or not on PATH */ }
397
+ return {
398
+ cliVersion: CLI_VERSION,
399
+ claudeVersion,
400
+ checks: Object.fromEntries(results.map((r) => [r.name, r.status])),
401
+ // The full candidate-facing and internal text of every check, INCLUDING
402
+ // the ones doctor keeps quiet on stdout. Discretion applies only to what
403
+ // the candidate's terminal shows — our record always gets everything.
404
+ checkDetails: results.map((r) => ({ name: r.name, status: r.status, detail: r.detail, ...(r.verbose ? { verbose: r.verbose } : {}) })),
405
+ hooks: detectInstalledHooks(projectRoot),
406
+ projectClaudeDirExists: existsSync(path.join(projectRoot, ".claude")),
407
+ litmusDir: {
408
+ exists: existsSync(litmusDir),
409
+ configMtime: mtimeOrNull(path.join(litmusDir, "config.json")),
410
+ activityLogMtime: mtimeOrNull(path.join(litmusDir, "activity.jsonl")),
411
+ trackerPidMtime: mtimeOrNull(path.join(litmusDir, "tracker.pid")),
412
+ trackerNonceMtime: mtimeOrNull(path.join(litmusDir, "tracker.nonce")),
413
+ },
414
+ registry: readRegistrySnapshot(),
415
+ };
416
+ }
417
+ export async function runDoctor(opts) {
418
+ const verbose = opts.verbose === true;
419
+ const results = [];
420
+ const record = (r) => {
421
+ results.push(r);
422
+ if (r.quiet && !verbose)
423
+ return;
424
+ console.log(` ${STATUS_PAINT[r.status](r.detail)}`);
425
+ if (verbose && r.verbose)
426
+ console.log(chalk.dim(` ${r.verbose}`));
427
+ };
428
+ // 1. Local assessment state ---------------------------------------------
429
+ // Missing OR corrupted config both route to the R1 rebuild: the two shapes
430
+ // have one cause in practice (cleanup tooling deleting/mangling gitignored
431
+ // files) and one cure (re-fetch the metadata, rewrite the file in place).
432
+ let config = null;
433
+ let configCorrupted = false;
434
+ try {
435
+ config = await readConfig();
436
+ }
437
+ catch {
438
+ configCorrupted = true;
439
+ }
440
+ if (config) {
441
+ setErrorContext({ token: config.token, apiBase: config.apiBase, candidateEmail: config.candidateEmail, assessmentName: config.assessmentName });
442
+ }
443
+ // With no readable config this is null for a missing file (rebuild lands in
444
+ // cwd — the email tells the candidate to run doctor inside their assessment
445
+ // folder) but still finds the root when the file exists and is corrupted.
446
+ const projectRoot = findProjectRoot() ?? process.cwd();
447
+ const litmusDir = path.join(projectRoot, ".litmus");
448
+ let configRebuilt = false;
449
+ if (!config) {
450
+ config = await rebuildConfig({ projectRoot, corrupted: configCorrupted, tokenFlag: opts.token });
451
+ configRebuilt = true;
452
+ }
453
+ // Title matches litmus status: the assessment name, bold. Printed after
454
+ // the config is resolved, because a rebuild is what tells us the name.
455
+ console.log();
456
+ console.log(chalk.bold(` ${config.assessmentName}`));
457
+ console.log();
458
+ logToTrackerLog(projectRoot, `run started (cli ${CLI_VERSION})${configRebuilt ? " — config rebuilt" : ""}`);
459
+ if (configRebuilt) {
460
+ record({
461
+ name: "assessment",
462
+ status: "fixed",
463
+ detail: `Assessment: settings restored (started ${agoText(Date.parse(config.startedAt))})`,
464
+ verbose: `wrote ${path.join(litmusDir, "config.json")}`,
465
+ });
466
+ }
467
+ else {
468
+ const startedAt = new Date(config.startedAt);
469
+ record({
470
+ name: "assessment",
471
+ status: "ok",
472
+ detail: `Assessment: found (started ${agoText(startedAt.getTime())})`,
473
+ verbose: `root=${projectRoot}`,
474
+ });
475
+ }
476
+ // 2. Deadline vs wall clock ---------------------------------------------
477
+ const effectiveDeadline = getEffectiveDeadline(config);
478
+ let deadlineLocallyPassed = false;
479
+ if (effectiveDeadline === null) {
480
+ record({ name: "deadline", status: "ok", detail: "Time remaining: no submission cutoff configured" });
481
+ }
482
+ else {
483
+ const remaining = effectiveDeadline - Date.now();
484
+ const cutoff = formatDeadlinePT(new Date(effectiveDeadline));
485
+ if (remaining <= 0) {
486
+ deadlineLocallyPassed = true;
487
+ record({
488
+ name: "deadline",
489
+ status: "fail",
490
+ detail: `Time remaining: PASSED (cutoff was ${cutoff}). If you have not submitted, run "litmus submit" now and contact support@litmushiring.com.`,
491
+ });
492
+ }
493
+ else {
494
+ const remHours = Math.floor(remaining / 3600000);
495
+ const remMins = Math.floor((remaining % 3600000) / 60000);
496
+ record({
497
+ name: "deadline",
498
+ status: "ok",
499
+ detail: `Time remaining: ${remHours}h ${remMins}m (cutoff ${cutoff})`,
500
+ verbose: `auto-submit fires at the cutoff while the tracker is running`,
501
+ });
502
+ }
503
+ }
504
+ // 3. Activity log (read-only: doctor NEVER rewrites or truncates it) -----
505
+ const activityPath = path.join(litmusDir, "activity.jsonl");
506
+ let scan = null;
507
+ let logText = "";
508
+ if (existsSync(activityPath)) {
509
+ try {
510
+ logText = readFileSync(activityPath, "utf8");
511
+ scan = scanActivityLog(logText);
512
+ const last = scan.lastEventTs ? `, last event ${agoText(Date.parse(scan.lastEventTs))}` : "";
513
+ const malformed = scan.malformedCount > 0 ? ` (${scan.malformedCount} unreadable lines, left as-is)` : "";
514
+ record({
515
+ name: "activity_log",
516
+ status: scan.malformedCount > 0 ? "warn" : "ok",
517
+ detail: `Activity log: ${scan.eventCount} events${last}${malformed}`,
518
+ verbose: `${scan.totalBytes} bytes, ${scan.aiPromptCount} AI prompts, last heartbeat ${scan.lastHeartbeatTs ?? "never"}`,
519
+ });
520
+ }
521
+ catch (err) {
522
+ record({
523
+ name: "activity_log",
524
+ status: "fail",
525
+ detail: "Activity log: could not be read. Do not delete it; contact support@litmushiring.com.",
526
+ verbose: err instanceof Error ? err.message : String(err),
527
+ });
528
+ }
529
+ }
530
+ else {
531
+ record({
532
+ name: "activity_log",
533
+ status: "warn",
534
+ detail: "Activity log: none yet (it is created as soon as the tracker runs)",
535
+ });
536
+ }
537
+ // 4. Server round-trip ----------------------------------------------------
538
+ // A real authenticated request, not a ping: this is how doctor detects the
539
+ // silent failure where the server has started rejecting uploads (expired or
540
+ // invalid token) while everything looks fine locally.
541
+ console.log(); // stanza break: local state above, connections and repairs below
542
+ const backendUrl = config.backendUrl || config.apiBase;
543
+ let channel = "unreachable";
544
+ try {
545
+ await fetchSubmitStatus(backendUrl, config.token);
546
+ channel = "healthy";
547
+ // Quiet: a healthy connection is implied by the absence of a red line
548
+ // (failures below always print). Saying it out loud just teaches the
549
+ // candidate that there is a token to worry about.
550
+ record({ name: "server", status: "ok", quiet: true, detail: "Server connection: OK, your access token is accepted" });
551
+ }
552
+ catch (err) {
553
+ if (err instanceof ServerError && (err.errorCode === "Token expired" || err.statusCode === 403)) {
554
+ channel = "session_over";
555
+ record({
556
+ name: "server",
557
+ status: "fail",
558
+ detail: "Server connection: the server reports this assessment has ended. If that seems wrong, contact support@litmushiring.com.",
559
+ verbose: `HTTP ${err.statusCode}: ${err.message}`,
560
+ });
561
+ }
562
+ else if (err instanceof ServerError && (err.statusCode === 401 || err.statusCode === 404)) {
563
+ channel = "bad_token";
564
+ record({
565
+ name: "server",
566
+ status: "fail",
567
+ detail: "Server connection: your access token was not accepted, so activity uploads are failing. Contact support@litmushiring.com and include this output.",
568
+ verbose: `HTTP ${err.statusCode}: ${err.message}`,
569
+ });
570
+ }
571
+ else {
572
+ channel = "unreachable";
573
+ record({
574
+ name: "server",
575
+ status: "fail",
576
+ detail: "Server connection: FAILED. Check your internet connection, then run \"litmus doctor\" again.",
577
+ verbose: err instanceof Error ? err.message : String(err),
578
+ });
579
+ }
580
+ }
581
+ logToTrackerLog(projectRoot, `server probe: ${channel}`);
582
+ // Repairs only make sense while the session is (or may still be) live.
583
+ const sessionOver = channel === "session_over" || (channel !== "healthy" && deadlineLocallyPassed);
584
+ // 5. Tracker liveness + revival ------------------------------------------
585
+ const diag = trackerDiagnostics(projectRoot);
586
+ // A live pid that no longer reads healthy stopped heartbeating within the
587
+ // liveness window: a wedged watcher, a recycled pid over a dead one, or a
588
+ // machine just woken from sleep. All deserve a GRACEFUL restart (sentinel
589
+ // ask, then the watcher-side singleton contest settles any survivor).
590
+ const stalled = diag.pidAlive && !diag.healthy;
591
+ const trackerWasUnhealthy = !diag.healthy;
592
+ // R3: a healthy tracker is still the WRONG tracker after a config rebuild.
593
+ // It read the old config at spawn, so its deadline auto-submit is armed from
594
+ // the stale startedAt + timeLimit (and a latch that already fired never
595
+ // retries). Only a restart re-arms it from the restored settings.
596
+ const staleInMemory = configRebuilt && diag.healthy;
597
+ const diagVerbose = `pid=${diag.pid ?? "none"} alive=${diag.pidAlive} lastLivenessSignal=${diag.nonceAgeMs === null ? "never" : Math.round(diag.nonceAgeMs / 1000) + "s ago"}`;
598
+ if (sessionOver) {
599
+ record({
600
+ name: "tracker",
601
+ status: diag.healthy ? "warn" : "ok",
602
+ detail: diag.healthy
603
+ ? "Tracker: still running, but the assessment has ended. It will stop on its own."
604
+ : "Tracker: not running (the assessment has ended, so it was not restarted)",
605
+ verbose: diagVerbose,
606
+ });
607
+ }
608
+ else if (diag.healthy && !staleInMemory) {
609
+ record({ name: "tracker", status: "ok", detail: "Tracker: running", verbose: diagVerbose });
610
+ }
611
+ else {
612
+ const why = !diag.pidAlive
613
+ ? "it was not running"
614
+ : stalled
615
+ ? "it had stopped responding"
616
+ : staleInMemory
617
+ ? "it was still using your old settings"
618
+ : "it was not running";
619
+ logToTrackerLog(projectRoot, `tracker ${why} (${diagVerbose}); restarting`);
620
+ let revived = false;
621
+ try {
622
+ revived = await reviveTracker(projectRoot, { graceful: stalled || staleInMemory });
623
+ }
624
+ catch (err) {
625
+ logToTrackerLog(projectRoot, `restart threw: ${err instanceof Error ? err.message : String(err)}`);
626
+ }
627
+ if (revived) {
628
+ const rearm = effectiveDeadline !== null && effectiveDeadline > Date.now()
629
+ ? ", auto-submit at the cutoff is armed"
630
+ : "";
631
+ record({
632
+ name: "tracker",
633
+ status: "fixed",
634
+ detail: `Tracker: resumed${rearm}`,
635
+ verbose: `restarted because ${why}; after restart: ${JSON.stringify(trackerDiagnostics(projectRoot))}`,
636
+ });
637
+ logToTrackerLog(projectRoot, "tracker restarted successfully");
638
+ }
639
+ else {
640
+ record({
641
+ name: "tracker",
642
+ status: "fail",
643
+ detail: "Tracker: could not be restarted. Try reinstalling the CLI (npm install -g litmus-cli), run \"litmus doctor\" again, and contact support@litmushiring.com if this line repeats.",
644
+ verbose: diagVerbose,
645
+ });
646
+ logToTrackerLog(projectRoot, "tracker restart FAILED");
647
+ }
648
+ }
649
+ // 6. AI-usage capture hooks ----------------------------------------------
650
+ // Editor updates and cleanup tools clobber these. Reinstall goes through
651
+ // setupAiTracking, the exact code init runs: idempotent merges that keep
652
+ // the candidate's own hooks and never overwrite an earlier config backup
653
+ // (see backupAside in ai-tracking.ts).
654
+ //
655
+ // Capture stands on three legs and doctor reports each one every run (R2):
656
+ // the per-editor hook configs, the shared logger they invoke, and the
657
+ // active-assessments registry the logger resolves this folder through.
658
+ // Any one missing means prompts silently stop being recorded.
659
+ let aiCaptureFixed = false;
660
+ {
661
+ const hooksVerboseOf = (h) => `claude=${h.claude ?? "none"} codex=${h.codex} cursor=${h.cursor} copilot=${h.copilot} registered=${h.registered} logger=${h.loggerPresent}`;
662
+ const printLegs = (h) => {
663
+ const leg = (ok, label) => {
664
+ console.log(` ${ok ? chalk.green("+") : chalk.red("x")} ${chalk.dim(label)}`);
665
+ };
666
+ const claudeDesc = h.claude === "user"
667
+ ? "installed"
668
+ : h.claude === "project"
669
+ ? "installed inside this folder only"
670
+ : "NOT installed";
671
+ leg(h.claude !== null && h.codex && h.cursor && h.copilot, `editor hooks: Claude Code ${claudeDesc}; Codex ${h.codex ? "installed" : "NOT installed"}, Cursor ${h.cursor ? "installed" : "NOT installed"}, Copilot ${h.copilot ? "installed" : "NOT installed"}`);
672
+ leg(h.loggerPresent, `shared capture helper: ${h.loggerPresent ? "present" : "MISSING"}`);
673
+ leg(h.registered, `this folder registered for capture: ${h.registered ? "yes" : "NO"}`);
674
+ };
675
+ // Project-scope Claude capture lives inside the assessment folder — the
676
+ // same gitignored blast radius as .litmus/, deletable by the same cleanup
677
+ // that causes these incidents. It works, but it is fragile, so doctor
678
+ // always tries for user scope and never treats the fallback as silent
679
+ // success (R2).
680
+ const warnProjectScope = (why) => {
681
+ console.log(chalk.yellow(" Note: Claude Code capture could only be installed inside this folder"));
682
+ console.log(chalk.yellow(` because your personal Claude settings could not be updated${why ? ` (${why})` : ""}.`));
683
+ console.log(chalk.yellow(" Please keep the .claude folder here; deleting it turns capture off."));
684
+ };
685
+ const hooks = detectInstalledHooks(projectRoot);
686
+ const missing = [];
687
+ if (hooks.claude !== "user")
688
+ missing.push("Claude Code");
689
+ if (!hooks.codex)
690
+ missing.push("Codex");
691
+ if (!hooks.cursor)
692
+ missing.push("Cursor");
693
+ if (!hooks.copilot)
694
+ missing.push("Copilot");
695
+ const healthy = missing.length === 0 && hooks.loggerPresent && hooks.registered;
696
+ if (healthy) {
697
+ record({ name: "ai_capture", status: "ok", quiet: true, detail: "AI usage capture: installed for Claude Code, Codex, Cursor, and Copilot", verbose: hooksVerboseOf(hooks) });
698
+ if (verbose)
699
+ printLegs(hooks);
700
+ }
701
+ else if (sessionOver) {
702
+ record({ name: "ai_capture", status: "warn", quiet: true, detail: "AI usage capture: incomplete, but the assessment has ended so it was left alone", verbose: hooksVerboseOf(hooks) });
703
+ if (verbose)
704
+ printLegs(hooks);
705
+ }
706
+ else {
707
+ logToTrackerLog(projectRoot, `ai capture incomplete (${hooksVerboseOf(hooks)}); reinstalling`);
708
+ try {
709
+ const setup = setupAiTracking(projectRoot);
710
+ const after = detectInstalledHooks(projectRoot);
711
+ // Fixed = every leg the installed mode needs is standing. In project
712
+ // mode the logger is folder-local and the registry is deliberately
713
+ // not used, so those legs read differently.
714
+ const repaired = after.claude === "user"
715
+ ? after.loggerPresent && after.registered
716
+ : after.claude === "project";
717
+ const stillProjectScope = after.claude === "project";
718
+ record({
719
+ name: "ai_capture",
720
+ status: !repaired ? "fail" : stillProjectScope ? "warn" : "fixed",
721
+ // A clean reinstall is our plumbing — quiet. The project-scope
722
+ // fallback stays loud because the candidate must not delete .claude.
723
+ quiet: repaired && !stillProjectScope,
724
+ detail: !repaired
725
+ ? "AI usage capture: could not be reinstalled. Contact support@litmushiring.com and include this output."
726
+ : stillProjectScope
727
+ ? "AI usage capture: reinstalled, but Claude Code capture is folder-local (see the note below)"
728
+ : `AI usage capture: reinstalled (was incomplete: ${missing.length > 0 ? missing.join(", ") : "shared files missing"})`,
729
+ verbose: `after reinstall: ${hooksVerboseOf(after)}`,
730
+ });
731
+ if (verbose)
732
+ printLegs(after);
733
+ if (stillProjectScope)
734
+ warnProjectScope(setup.userScopeError);
735
+ aiCaptureFixed = repaired;
736
+ logToTrackerLog(projectRoot, `ai capture reinstall ${!repaired ? "FAILED" : stillProjectScope ? `fell back to project scope (${setup.userScopeError ?? "unknown"})` : "succeeded"}`);
737
+ }
738
+ catch (err) {
739
+ record({
740
+ name: "ai_capture",
741
+ status: "fail",
742
+ detail: "AI usage capture: could not be reinstalled. Contact support@litmushiring.com and include this output.",
743
+ verbose: err instanceof Error ? err.message : String(err),
744
+ });
745
+ logToTrackerLog(projectRoot, `ai capture reinstall threw: ${err instanceof Error ? err.message : String(err)}`);
746
+ }
747
+ }
748
+ }
749
+ // 7. Backlog flush ---------------------------------------------------------
750
+ // Everything the trackers record lands in activity.jsonl first, so the file
751
+ // is the source of truth. When the watcher died, its last few minutes of
752
+ // events never reached the server; deliver that gap now (verbatim, through
753
+ // the same /cli/activity endpoint) so the team's monitoring sees the
754
+ // recovery. The cursor makes this resumable and strictly forward-moving.
755
+ {
756
+ const cursor = readUploadState(litmusDir);
757
+ let flushFrom = null;
758
+ if (cursor !== null) {
759
+ flushFrom = Math.min(cursor, scan?.totalBytes ?? cursor);
760
+ }
761
+ else if (trackerWasUnhealthy && scan?.lastHeartbeatTs) {
762
+ const cutoff = Date.parse(scan.lastHeartbeatTs) - FLUSH_WINDOW_BEFORE_LAST_HEARTBEAT_MS;
763
+ flushFrom = offsetForFirstEventAtOrAfter(logText, cutoff);
764
+ }
765
+ if (flushFrom === null) {
766
+ record({
767
+ name: "uploads",
768
+ status: "ok",
769
+ quiet: true,
770
+ detail: channel === "healthy"
771
+ ? "Pending uploads: none. Activity is delivered to the server as it happens."
772
+ : "Pending uploads: none recorded so far.",
773
+ });
774
+ }
775
+ else if (channel !== "healthy") {
776
+ // Remember the gap so the next doctor run delivers it once the server
777
+ // is reachable again.
778
+ let remembered = false;
779
+ try {
780
+ writeUploadState(litmusDir, flushFrom);
781
+ remembered = true;
782
+ }
783
+ catch { /* reported below */ }
784
+ const pending = planFlushBatches(logText, flushFrom).reduce((n, b) => n + b.events.length, 0);
785
+ record({
786
+ name: "uploads",
787
+ status: "fail",
788
+ detail: `Pending uploads: ${pending} recorded events have not reached the server yet. Nothing is lost; they are saved on this machine. Run "litmus doctor" again once you are back online.`,
789
+ verbose: `flushFrom=${flushFrom} rememberedCursor=${remembered}`,
790
+ });
791
+ logToTrackerLog(projectRoot, `flush skipped (channel=${channel}); pending=${pending} from byte ${flushFrom}`);
792
+ }
793
+ else {
794
+ const batches = planFlushBatches(logText, flushFrom);
795
+ const totalEvents = batches.reduce((n, b) => n + b.events.length, 0);
796
+ if (totalEvents === 0) {
797
+ clearUploadState(litmusDir);
798
+ record({ name: "uploads", status: "ok", quiet: true, detail: "Pending uploads: none. Activity is delivered to the server as it happens." });
799
+ }
800
+ else {
801
+ let sent = 0;
802
+ let lastGood = flushFrom;
803
+ let failure = null;
804
+ for (const batch of batches.slice(0, MAX_BATCHES_PER_RUN)) {
805
+ try {
806
+ await uploadActivityEvents(backendUrl, config.token, batch.events);
807
+ sent += batch.events.length;
808
+ lastGood = batch.endOffset;
809
+ }
810
+ catch (err) {
811
+ failure = err instanceof Error ? err.message : String(err);
812
+ break;
813
+ }
814
+ }
815
+ const remaining = totalEvents - sent;
816
+ if (remaining === 0 && failure === null) {
817
+ clearUploadState(litmusDir);
818
+ record({
819
+ name: "uploads",
820
+ status: "fixed",
821
+ quiet: true,
822
+ detail: `Pending uploads: delivered ${sent} recorded events the server was missing`,
823
+ verbose: `flushed bytes ${flushFrom}..${lastGood}`,
824
+ });
825
+ logToTrackerLog(projectRoot, `flushed ${sent} events (bytes ${flushFrom}..${lastGood})`);
826
+ }
827
+ else {
828
+ try {
829
+ writeUploadState(litmusDir, lastGood);
830
+ }
831
+ catch { /* next run recomputes */ }
832
+ record({
833
+ name: "uploads",
834
+ status: failure ? "fail" : "warn",
835
+ detail: failure
836
+ ? `Pending uploads: delivered ${sent} of ${totalEvents} before the connection failed. Nothing is lost; run "litmus doctor" again to send the rest.`
837
+ : `Pending uploads: delivered ${sent} of ${totalEvents} (large backlog). Run "litmus doctor" again to send the rest.`,
838
+ verbose: failure ?? `cursor now at byte ${lastGood}`,
839
+ });
840
+ logToTrackerLog(projectRoot, `flush partial: ${sent}/${totalEvents}${failure ? ` (error: ${failure})` : ""}`);
841
+ }
842
+ }
843
+ }
844
+ }
845
+ // 8. Prompt backfill from AI-tool session stores (R4) -----------------------
846
+ // The hook logger records nothing while the config is unresolvable, but the
847
+ // prompts survive in each tool's own session store — Claude Code transcripts
848
+ // (lib/backfill.ts) plus Gemini CLI, Copilot CLI, VS Code Copilot Chat and
849
+ // Cursor (lib/backfill-tools.ts). Re-synthesize and upload them — every
850
+ // event marked `backfilled: true`, counts printed, never silently.
851
+ //
852
+ // Runs AUTOMATICALLY when this run proved capture was broken (config had to
853
+ // be rebuilt, or the capture hooks had to be repaired) — the candidate just
854
+ // runs `litmus doctor` and the gap heals. The automatic window starts at the
855
+ // last LIVE-captured prompt, which makes it self-limiting: on a setup where
856
+ // capture worked all along, doctor never reaches this section, and the
857
+ // window would be empty even if it did. `--backfill-prompts` forces a run
858
+ // over the whole assessment (support-guided); `--no-backfill` opts out.
859
+ const backfillForced = opts.backfillPrompts === true;
860
+ const backfillAuto = !backfillForced && opts.backfill !== false && (configRebuilt || aiCaptureFixed);
861
+ let backfillSummary = null;
862
+ if ((backfillForced || backfillAuto) && channel !== "healthy") {
863
+ record({
864
+ name: "backfill",
865
+ status: backfillForced ? "fail" : "warn",
866
+ detail: sessionOver
867
+ ? "AI activity recovery: skipped, the server reports this assessment has ended"
868
+ : 'AI activity recovery: skipped, the server could not be reached. Run "litmus doctor" again when you are back online.',
869
+ });
870
+ }
871
+ else if (backfillForced || backfillAuto) {
872
+ let sinceMs = null;
873
+ let sinceLabel;
874
+ if (opts.since) {
875
+ sinceMs = Date.parse(opts.since);
876
+ if (isNaN(sinceMs)) {
877
+ fatal(`Could not understand --since "${opts.since}".`, "Use an ISO timestamp like 2026-08-07T19:00:00Z.", { severity: "warning" });
878
+ }
879
+ sinceLabel = new Date(sinceMs).toISOString();
880
+ }
881
+ else if (backfillAuto && scan?.lastLiveAiPromptTs) {
882
+ // Everything before the last live-captured prompt already reached the
883
+ // record through the normal path; only the gap after it can be missing.
884
+ sinceMs = Date.parse(scan.lastLiveAiPromptTs);
885
+ if (isNaN(sinceMs))
886
+ sinceMs = null;
887
+ sinceLabel = sinceMs === null ? "the beginning" : `your last recorded AI prompt (${new Date(sinceMs).toISOString()})`;
888
+ }
889
+ else {
890
+ const started = Date.parse(config.startedAt);
891
+ sinceMs = isNaN(started) ? null : started;
892
+ sinceLabel = sinceMs === null ? "the beginning" : `your assessment start (${new Date(sinceMs).toISOString()})`;
893
+ }
894
+ const scans = [
895
+ {
896
+ source: "claude_transcript",
897
+ tool: "claude",
898
+ notes: [],
899
+ ...collectBackfillSessions({
900
+ claudeProjectsDir: defaultClaudeProjectsDir(),
901
+ assessmentRoot: projectRoot,
902
+ allSessions: opts.allSessions === true,
903
+ sinceMs,
904
+ }),
905
+ },
906
+ ...collectToolBackfillSources({
907
+ assessmentRoot: projectRoot,
908
+ allSessions: opts.allSessions === true,
909
+ sinceMs,
910
+ }),
911
+ ];
912
+ const scopeLabel = opts.allSessions === true
913
+ ? "every AI session on this machine (Claude, Gemini, Copilot, Cursor)"
914
+ : "AI sessions in this assessment folder (Claude, Gemini, Copilot, Cursor)";
915
+ const sessions = scans.flatMap((s) => s.sessions);
916
+ const allEvents = sessions.flatMap((s) => s.events);
917
+ const promptCount = sessions.reduce((n, s) => n + s.prompts, 0);
918
+ const responseCount = sessions.reduce((n, s) => n + s.responses, 0);
919
+ const scannedFiles = scans.reduce((n, s) => n + s.scannedFiles, 0);
920
+ const skippedOversized = scans.reduce((n, s) => n + s.skippedOversized.length, 0);
921
+ const unreadable = scans.reduce((n, s) => n + s.unreadable.length, 0);
922
+ const notes = scans.flatMap((s) => s.notes);
923
+ const scanStats = `scanned ${scannedFiles} session records, ${skippedOversized} oversized, ${unreadable} unreadable${notes.length ? `; ${notes.join("; ")}` : ""}`;
924
+ if (allEvents.length === 0) {
925
+ record({
926
+ name: "backfill",
927
+ status: "ok",
928
+ // Quiet on the automatic path; someone who passed --backfill-prompts
929
+ // asked a question and gets the answer.
930
+ quiet: !backfillForced,
931
+ detail: "AI activity recovery: nothing to recover",
932
+ verbose: `scope: ${scopeLabel}, since ${sinceLabel}; ${scanStats}`,
933
+ });
934
+ }
935
+ else {
936
+ // Local record first, prompts only: local copies survive an upload
937
+ // failure, while full response bodies (30KB each) stay upload-only so
938
+ // they cannot bloat activity.jsonl past the submission-zip cap.
939
+ const localPrompts = allEvents.filter((e) => e.type === "ai_prompt");
940
+ try {
941
+ appendFileSync(activityPath, localPrompts.map((e) => JSON.stringify(e)).join("\n") + "\n");
942
+ }
943
+ catch { /* upload is the real delivery; the summary event below still records the run */ }
944
+ let uploaded = 0;
945
+ let failure = null;
946
+ for (const batch of planUploadBatches(allEvents)) {
947
+ try {
948
+ await uploadActivityEvents(backendUrl, config.token, batch);
949
+ uploaded += batch.length;
950
+ }
951
+ catch (err) {
952
+ failure = err instanceof Error ? err.message : String(err);
953
+ break;
954
+ }
955
+ }
956
+ backfillSummary = {
957
+ trigger: backfillForced ? "flag" : "auto",
958
+ scope: opts.allSessions === true ? "all_sessions" : "assessment_dir",
959
+ since: sinceMs === null ? null : new Date(sinceMs).toISOString(),
960
+ sessions: sessions.length,
961
+ prompts: promptCount,
962
+ responses: responseCount,
963
+ uploaded,
964
+ skippedOversized,
965
+ unreadable,
966
+ bySource: Object.fromEntries(scans
967
+ .filter((s) => s.sessions.length > 0)
968
+ .map((s) => [s.source, {
969
+ sessions: s.sessions.length,
970
+ prompts: s.sessions.reduce((n, x) => n + x.prompts, 0),
971
+ responses: s.sessions.reduce((n, x) => n + x.responses, 0),
972
+ }])),
973
+ };
974
+ // The local summary event is appended ALWAYS (even on upload failure):
975
+ // it is the durable, submission-visible record that a recovery ran and
976
+ // exactly what it took (the never-silent half of the over-capture rule).
977
+ try {
978
+ appendFileSync(activityPath, JSON.stringify({
979
+ ts: new Date().toISOString(),
980
+ type: "backfill_run",
981
+ cliVersion: CLI_VERSION,
982
+ sources: scans.filter((s) => s.sessions.length > 0).map((s) => s.source),
983
+ ...backfillSummary,
984
+ }) + "\n");
985
+ }
986
+ catch { /* nothing else to do locally */ }
987
+ const recovered = `AI activity recovery: recovered ${plural(promptCount, "prompt")} and ${plural(responseCount, "response")} from ${plural(sessions.length, "session")}`;
988
+ record({
989
+ name: "backfill",
990
+ status: failure ? "fail" : "fixed",
991
+ detail: failure
992
+ ? `${recovered}, but only ${uploaded} of ${allEvents.length} events reached the server before the connection failed. Run "litmus doctor${backfillForced ? " --backfill-prompts" : ""}" again to send the rest.`
993
+ : recovered,
994
+ verbose: failure ?? `scope: ${scopeLabel}, since ${sinceLabel}; ${scanStats}`,
995
+ });
996
+ // Show the candidate exactly what was taken, per session and per tool.
997
+ for (const s of sessions) {
998
+ console.log(chalk.dim(` • [${s.tool}] ${s.label}: ${plural(s.prompts, "prompt")}, ${plural(s.responses, "response")}`));
999
+ }
1000
+ for (const note of notes) {
1001
+ console.log(chalk.dim(` • note: ${note}`));
1002
+ }
1003
+ logToTrackerLog(projectRoot, `backfill: ${uploaded}/${allEvents.length} events uploaded from ${sessions.length} sessions (scope=${backfillSummary.scope})${failure ? ` error: ${failure}` : ""}`);
1004
+ }
1005
+ }
1006
+ // 9. Record the run --------------------------------------------------------
1007
+ const fixed = results.filter((r) => r.status === "fixed");
1008
+ const problems = results.filter((r) => r.status === "fail");
1009
+ const summaryLine = `doctor v${CLI_VERSION}: ${results.map((r) => `${r.name}=${r.status}`).join(" ")}`;
1010
+ const doctorEvent = {
1011
+ ts: new Date().toISOString(),
1012
+ type: "doctor_run",
1013
+ cliVersion: CLI_VERSION,
1014
+ checks: results.map((r) => ({ name: r.name, status: r.status })),
1015
+ repaired: fixed.map((r) => r.name),
1016
+ problems: problems.map((r) => r.name),
1017
+ ...(backfillSummary ? { backfill: backfillSummary } : {}),
1018
+ };
1019
+ // Local record first (same append-only pattern as the hook logger; carries
1020
+ // no chain fields, so the watcher chain is untouched). Ships in the zip.
1021
+ try {
1022
+ appendFileSync(activityPath, JSON.stringify(doctorEvent) + "\n");
1023
+ }
1024
+ catch { /* log may be unwritable; the tracker.log lines still record the run */ }
1025
+ logToTrackerLog(projectRoot, summaryLine);
1026
+ // Server-side record: the doctor_run activity event (visible to monitoring
1027
+ // next to the watcher's own events) plus the diagnostic phone-home below.
1028
+ if (channel === "healthy") {
1029
+ try {
1030
+ await uploadActivityEvents(backendUrl, config.token, [doctorEvent]);
1031
+ }
1032
+ catch { /* the reportEvent below still records the run server-side */ }
1033
+ }
1034
+ // R5: diagnostic phone-home. Severity gates on whether anything was wrong:
1035
+ // "error" reaches platform-health monitoring (a candidate whose setup needed
1036
+ // repair is exactly what the team must see); "info" on clean runs stays
1037
+ // log-only so healthy doctor runs never page anyone. The part of the message
1038
+ // before "Internal detail:" is the server's dedupe key — keep it stable.
1039
+ const stableSummary = problems.length > 0
1040
+ ? `litmus doctor: problems remain (${problems.map((r) => r.name).sort().join(", ")})`
1041
+ : fixed.length > 0
1042
+ ? `litmus doctor: repaired (${fixed.map((r) => r.name).sort().join(", ")})`
1043
+ : "litmus doctor: healthy";
1044
+ const diagnostics = collectDoctorDiagnostics(projectRoot, results);
1045
+ await reportEvent(problems.length > 0 || fixed.length > 0 ? "error" : "info", `${stableSummary}\n\nInternal detail: ${JSON.stringify(diagnostics)}`, undefined, "doctor");
1046
+ // 10. Summary + exit code ---------------------------------------------------
1047
+ console.log();
1048
+ // The repaired count only counts VISIBLE fixes, so the sentence matches the
1049
+ // lines the candidate just read. Quiet internal fixes (upload bookkeeping,
1050
+ // hook refreshes) still get "healthy now" rather than the false "no changes
1051
+ // were made", and the machine line + records always carry the full set.
1052
+ const visibleFixed = fixed.filter((r) => !r.quiet);
1053
+ if (problems.length > 0) {
1054
+ console.log(chalk.red(` ${problems.length} problem${problems.length === 1 ? "" : "s"} need${problems.length === 1 ? "s" : ""} attention.`));
1055
+ console.log(chalk.dim(" Copy this whole output into an email to support@litmushiring.com if you need help."));
1056
+ }
1057
+ else if (visibleFixed.length > 0) {
1058
+ console.log(chalk.green(` Repaired ${visibleFixed.length} issue${visibleFixed.length === 1 ? "" : "s"}. Everything is healthy now; you can keep working.`));
1059
+ }
1060
+ else if (fixed.length > 0) {
1061
+ console.log(chalk.green(" Everything is healthy now; you can keep working."));
1062
+ }
1063
+ else {
1064
+ console.log(chalk.green(" Everything is healthy. No changes were made."));
1065
+ }
1066
+ console.log(chalk.dim(` ${summaryLine}`));
1067
+ // After a capture-affecting repair, close with the self-check. Deliberately
1068
+ // NOT a wait: an in-terminal wait blocks the very terminal the candidate
1069
+ // would use to send the test prompt (their typed input piles into doctor's
1070
+ // stdin and executes in the shell after exit).
1071
+ // Quieted for now (verbose-only) — support-guided runs still get it, and
1072
+ // the candidate default ends at the summary.
1073
+ if (verbose && !sessionOver && (configRebuilt || aiCaptureFixed)) {
1074
+ console.log();
1075
+ console.log(chalk.bold(" One more step, to confirm AI capture is working again:"));
1076
+ console.log(" Open your AI assistant (Claude Code, Cursor, Codex, or Copilot) in this");
1077
+ console.log(` folder and send any short prompt. Then run ${chalk.cyan("litmus status")}: the`);
1078
+ console.log(' "AI prompts captured" count should go up within a few seconds.');
1079
+ }
1080
+ console.log();
1081
+ process.exitCode = problems.length > 0 ? 1 : 0;
1082
+ }
1083
+ //# sourceMappingURL=doctor.js.map