litmus-cli 1.4.0 → 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.
- package/dist/commands/doctor.d.ts +136 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +1055 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +24 -10
- package/dist/commands/status.js.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/ai-tracking.d.ts +35 -0
- package/dist/lib/ai-tracking.d.ts.map +1 -1
- package/dist/lib/ai-tracking.js +40 -10
- package/dist/lib/ai-tracking.js.map +1 -1
- package/dist/lib/api.d.ts +14 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +40 -0
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/backfill.d.ts +106 -0
- package/dist/lib/backfill.d.ts.map +1 -0
- package/dist/lib/backfill.js +356 -0
- package/dist/lib/backfill.js.map +1 -0
- package/dist/lib/chain.d.ts +42 -0
- package/dist/lib/chain.d.ts.map +1 -0
- package/dist/lib/chain.js +98 -0
- package/dist/lib/chain.js.map +1 -0
- package/dist/lib/detect-project.d.ts +35 -0
- package/dist/lib/detect-project.d.ts.map +1 -1
- package/dist/lib/detect-project.js +111 -3
- package/dist/lib/detect-project.js.map +1 -1
- package/dist/lib/errors.d.ts +19 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +9 -1
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/tracker.d.ts +26 -0
- package/dist/lib/tracker.d.ts.map +1 -1
- package/dist/lib/tracker.js +137 -20
- package/dist/lib/tracker.js.map +1 -1
- package/dist/lib/watcher.js +270 -5
- package/dist/lib/watcher.js.map +1 -1
- package/dist/lib/zip.js +2 -2
- package/dist/lib/zip.js.map +1 -1
- 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
|