litmus-cli 1.4.1 → 1.4.2

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