litmus-cli 1.4.11 → 1.4.12

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 (39) hide show
  1. package/dist/commands/doctor.d.ts.map +1 -1
  2. package/dist/commands/doctor.js +5 -3
  3. package/dist/commands/doctor.js.map +1 -1
  4. package/dist/commands/init.d.ts +5 -2
  5. package/dist/commands/init.d.ts.map +1 -1
  6. package/dist/commands/init.js +44 -6
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/status.d.ts.map +1 -1
  9. package/dist/commands/status.js +15 -3
  10. package/dist/commands/status.js.map +1 -1
  11. package/dist/commands/submit.js +2 -1
  12. package/dist/commands/submit.js.map +1 -1
  13. package/dist/lib/ai-tracking.d.ts +32 -2
  14. package/dist/lib/ai-tracking.d.ts.map +1 -1
  15. package/dist/lib/ai-tracking.js +153 -3
  16. package/dist/lib/ai-tracking.js.map +1 -1
  17. package/dist/lib/capture-nudge.d.ts +118 -0
  18. package/dist/lib/capture-nudge.d.ts.map +1 -0
  19. package/dist/lib/capture-nudge.js +341 -0
  20. package/dist/lib/capture-nudge.js.map +1 -0
  21. package/dist/lib/hook-logger.cjs +28 -4
  22. package/dist/lib/notice-ownership.d.ts +13 -0
  23. package/dist/lib/notice-ownership.d.ts.map +1 -0
  24. package/dist/lib/notice-ownership.js +123 -0
  25. package/dist/lib/notice-ownership.js.map +1 -0
  26. package/dist/lib/notify.d.ts +25 -0
  27. package/dist/lib/notify.d.ts.map +1 -0
  28. package/dist/lib/notify.js +68 -0
  29. package/dist/lib/notify.js.map +1 -0
  30. package/dist/lib/shadow.d.ts +9 -0
  31. package/dist/lib/shadow.d.ts.map +1 -1
  32. package/dist/lib/shadow.js +26 -3
  33. package/dist/lib/shadow.js.map +1 -1
  34. package/dist/lib/watcher.js +96 -3
  35. package/dist/lib/watcher.js.map +1 -1
  36. package/dist/lib/zip.d.ts.map +1 -1
  37. package/dist/lib/zip.js +22 -1
  38. package/dist/lib/zip.js.map +1 -1
  39. package/package.json +1 -1
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Capture-nudge decision logic (ENG-1857) — pure and unit-testable
3
+ * (watcher.ts is a spawned script, so logic living there can't be imported
4
+ * by tests; same split as detection.ts).
5
+ *
6
+ * The signature this exists to catch: a hook-capturable AI tool is running
7
+ * but zero prompts have been captured — almost always Codex or the Copilot
8
+ * CLI sitting behind their silent trust gates (ENG-951/ENG-1323), i.e. a
9
+ * candidate whose AI work is invisibly not being recorded. The nudge is
10
+ * CANDIDATE-FACING ONLY: a desktop notification plus a notice file at the
11
+ * assessment root. It emits NO activity event — the grader renders every
12
+ * non-response activity event verbatim in the judge-visible timeline
13
+ * (substrate_sections.py) and merges the in-zip activity.jsonl, so anything
14
+ * we logged there would leak to reports. Watcher firings go to stderr only
15
+ * (.litmus/tracker.log, excluded from all grader signals via
16
+ * substrate.py's _is_capture_artifact).
17
+ */
18
+ import fs from "fs";
19
+ import path from "path";
20
+ import { REPAIR_FILE_NAME } from "./shadow.js";
21
+ import { clearOwnedNotice, isOwnedNoticeFile, recordOwnedNotice } from "./notice-ownership.js";
22
+ // ── Trigger tools ─────────────────────────────────────────────────
23
+ // In copy-priority order. Sourced from env_detected aiTools labels ONLY
24
+ // (process-list detection) — never network signals, which can't separate a
25
+ // capturable surface from a browser tab. Deliberately NOT included:
26
+ // cursor — the watcher labels the Cursor EDITOR as an AI tool
27
+ // whenever it is open, so this would nag plain-editor users
28
+ // claude — legacy/unattributable label; could be Desktop or a
29
+ // bystander string, neither hook-capturable
30
+ // claude_desktop, claude_web, gemini — no hook exists; nothing to nudge
31
+ export const NUDGE_TRIGGER_TOOLS = ["codex", "copilot", "claude_code"];
32
+ // Fire only after the tool is present on this many CONSECUTIVE scans, so a
33
+ // one-scan blip (a stray process, a tool opened and closed) never nags.
34
+ const CONSECUTIVE_SCANS_TO_FIRE = 2;
35
+ // Re-nudge once if still zero prompts and the tool is still present this
36
+ // long after the first nudge; hard cap below.
37
+ const RENUDGE_AFTER_MS = 30 * 60 * 1000;
38
+ const MAX_NUDGES = 2;
39
+ // Don't nag someone in their final minutes — nothing they do now changes
40
+ // what got captured, and status.ts already warns about the deadline itself.
41
+ const DEADLINE_SUPPRESS_MS = 10 * 60 * 1000;
42
+ // ── State ─────────────────────────────────────────────────────────
43
+ // Serialized to .litmus/nudge-state.json so watcher respawns don't re-nag
44
+ // (litmus status/doctor revive watchers routinely). The file lives in
45
+ // .litmus/, which never reaches the submission zip: zip.ts excludes the
46
+ // directory (via the .gitignore entry init guarantees) and re-includes only
47
+ // its whitelisted diagnostics — plus a ROOT-ANCHORED nudge-state.json
48
+ // exclude (EXCLUDE_ROOT_PATHS) for the case where a candidate stripped
49
+ // their .gitignore.
50
+ export const NUDGE_STATE_FILE_NAME = "nudge-state.json";
51
+ export function initialNudgeState() {
52
+ return { consecutive: {}, nudgeCount: 0, lastNudgeAt: null, latched: false };
53
+ }
54
+ /**
55
+ * One decision per env scan. Pure: all inputs explicit, returns the action
56
+ * plus the next state (the caller persists it).
57
+ *
58
+ * @param scanTools aiTools labels from this scan's env detection
59
+ * @param promptCount ai_prompt + ai_response events captured so far
60
+ * @param now epoch ms
61
+ * @param msRemaining ms to the effective deadline, null when none configured
62
+ */
63
+ export function decideNudge(state, scanTools, promptCount, now, msRemaining) {
64
+ if (state.latched)
65
+ return { action: "none", state };
66
+ // Capture confirmed: latch off permanently and signal notice removal.
67
+ // Counters are cleared — they can never matter again this session.
68
+ if (promptCount > 0) {
69
+ return { action: "remove_notice", state: { ...state, consecutive: {}, latched: true } };
70
+ }
71
+ // Advance the per-tool consecutive counters; absence resets to zero.
72
+ const present = new Set(scanTools);
73
+ const consecutive = {};
74
+ for (const tool of NUDGE_TRIGGER_TOOLS) {
75
+ consecutive[tool] = present.has(tool) ? (state.consecutive[tool] ?? 0) + 1 : 0;
76
+ }
77
+ const next = { ...state, consecutive };
78
+ // First trigger tool (in copy-priority order) that has been present long
79
+ // enough to be a real session rather than a blip.
80
+ const tool = NUDGE_TRIGGER_TOOLS.find((t) => (consecutive[t] ?? 0) >= CONSECUTIVE_SCANS_TO_FIRE);
81
+ if (!tool)
82
+ return { action: "none", state: next };
83
+ if (msRemaining !== null && msRemaining < DEADLINE_SUPPRESS_MS) {
84
+ return { action: "none", state: next };
85
+ }
86
+ if (next.nudgeCount >= MAX_NUDGES)
87
+ return { action: "none", state: next };
88
+ if (next.nudgeCount > 0 && (next.lastNudgeAt === null || now - next.lastNudgeAt < RENUDGE_AFTER_MS)) {
89
+ return { action: "none", state: next };
90
+ }
91
+ return {
92
+ action: "nudge",
93
+ tool,
94
+ state: { ...next, nudgeCount: next.nudgeCount + 1, lastNudgeAt: now },
95
+ };
96
+ }
97
+ // ── State persistence ─────────────────────────────────────────────
98
+ function nudgeStatePath(projectDir) {
99
+ return path.join(projectDir, ".litmus", NUDGE_STATE_FILE_NAME);
100
+ }
101
+ /** Tolerant load: anything unreadable or malformed means a fresh state. */
102
+ export function loadNudgeState(projectDir) {
103
+ try {
104
+ const raw = JSON.parse(fs.readFileSync(nudgeStatePath(projectDir), "utf8"));
105
+ if (typeof raw !== "object" || raw === null)
106
+ return initialNudgeState();
107
+ const consecutive = {};
108
+ for (const tool of NUDGE_TRIGGER_TOOLS) {
109
+ const n = raw.consecutive?.[tool];
110
+ if (typeof n === "number" && Number.isFinite(n) && n >= 0)
111
+ consecutive[tool] = n;
112
+ }
113
+ return {
114
+ consecutive,
115
+ nudgeCount: typeof raw.nudgeCount === "number" && raw.nudgeCount >= 0 ? raw.nudgeCount : 0,
116
+ lastNudgeAt: typeof raw.lastNudgeAt === "number" ? raw.lastNudgeAt : null,
117
+ latched: raw.latched === true,
118
+ };
119
+ }
120
+ catch {
121
+ return initialNudgeState();
122
+ }
123
+ }
124
+ export function saveNudgeState(projectDir, state) {
125
+ try {
126
+ fs.mkdirSync(path.dirname(nudgeStatePath(projectDir)), { recursive: true });
127
+ fs.writeFileSync(nudgeStatePath(projectDir), JSON.stringify(state), "utf8");
128
+ }
129
+ catch { /* best effort — worst case a respawn re-nags once */ }
130
+ }
131
+ export function initialPromptCursor() {
132
+ return { offset: 0, remainder: "", count: 0 };
133
+ }
134
+ function countCaptureEvents(text) {
135
+ let count = 0;
136
+ for (const line of text.split("\n")) {
137
+ const trimmed = line.trim();
138
+ if (!trimmed)
139
+ continue;
140
+ try {
141
+ const event = JSON.parse(trimmed);
142
+ if (event && (event.type === "ai_prompt" || event.type === "ai_response"))
143
+ count++;
144
+ }
145
+ catch { /* malformed line — skip, same tolerance as status.ts */ }
146
+ }
147
+ return count;
148
+ }
149
+ /**
150
+ * Scan any bytes appended to `logPath` since `cursor` and return the
151
+ * advanced cursor. Never throws; a missing/unreadable log leaves the cursor
152
+ * unchanged. A log SMALLER than the cursor's offset (wiped/rotated) rescans
153
+ * from zero.
154
+ */
155
+ export function scanPromptCount(logPath, cursor) {
156
+ try {
157
+ let { offset, remainder } = cursor;
158
+ const size = fs.statSync(logPath).size;
159
+ if (size < offset) {
160
+ offset = 0;
161
+ remainder = "";
162
+ }
163
+ if (size === offset)
164
+ return { ...cursor, offset, remainder };
165
+ const length = size - offset;
166
+ const buf = Buffer.alloc(length);
167
+ const fd = fs.openSync(logPath, "r");
168
+ let read = 0;
169
+ try {
170
+ read = fs.readSync(fd, buf, 0, length, offset);
171
+ }
172
+ finally {
173
+ fs.closeSync(fd);
174
+ }
175
+ const text = remainder + buf.subarray(0, read).toString("utf8");
176
+ const lastNewline = text.lastIndexOf("\n");
177
+ const complete = lastNewline === -1 ? "" : text.slice(0, lastNewline + 1);
178
+ return {
179
+ offset: offset + read,
180
+ remainder: lastNewline === -1 ? text : text.slice(lastNewline + 1),
181
+ count: cursor.count + countCaptureEvents(complete),
182
+ };
183
+ }
184
+ catch {
185
+ return cursor;
186
+ }
187
+ }
188
+ // ── Candidate-facing notice file ──────────────────────────────────
189
+ // Modeled on writeRepairFile (shadow.ts): rewritten whenever the condition
190
+ // fires, removed on resolution. Lives at the assessment ROOT so the
191
+ // candidate actually sees it — which is why it must be excluded everywhere:
192
+ // the submission zip (never ships), the watcher's change events (its own
193
+ // write never emits a judge-visible event), and the interview-agent
194
+ // snapshot. All three exclusions go through isCliNoticeArtifact below:
195
+ // root-only AND content-gated on the sentinel, so a candidate's own file
196
+ // that merely shares the name — at the root or nested — is never touched,
197
+ // suppressed, or dropped from their submission.
198
+ export const AI_NOTICE_FILE_NAME = "LITMUS-AI-NOTICE.md";
199
+ /** Human-readable label on every notice this CLI writes. A LABEL ONLY — it is
200
+ * world-readable and therefore copyable, so it proves nothing. Ownership is
201
+ * decided by the recorded content hash (notice-ownership.ts): only a file
202
+ * byte-identical to what we wrote is ever overwritten, deleted, hidden from
203
+ * change events, or excluded from the zip; an edited or planted file is the
204
+ * candidate's, sentinel or not. */
205
+ export const AI_NOTICE_SENTINEL = "<!-- litmus-cli capture-nudge notice. Auto-generated, removed when capture starts. -->";
206
+ export function aiNoticeContent() {
207
+ return [
208
+ AI_NOTICE_SENTINEL,
209
+ "",
210
+ "# Your AI usage isn't being recorded yet",
211
+ "",
212
+ "An AI coding tool looks like it's running, but no prompts have been captured",
213
+ "so far. The hiring company reviews your AI use as part of your submission,",
214
+ "and visible AI usage counts in your favor. A session we can't see just",
215
+ "looks like you didn't use AI at all.",
216
+ "",
217
+ "- **Codex**: accept the one-time trust prompt it shows the first time it",
218
+ " runs a hook in this folder. Until then, nothing is recorded.",
219
+ "- **Copilot CLI**: accept the folder-trust prompt it shows when it starts",
220
+ " in this folder. It runs no hooks (and says nothing) until you do. Then",
221
+ " run `litmus doctor` here to recover prompts from before you accepted.",
222
+ "- **Claude Code**: run `litmus doctor` here to check and repair tracking.",
223
+ "",
224
+ "Already done this, or not using an AI tool (or using one we can't capture,",
225
+ "like a browser chat)? Then ignore this. Nothing is wrong with your",
226
+ "submission. Run `litmus status` any time to see what's being captured.",
227
+ "This file is not part of your submission and disappears once capture starts.",
228
+ "",
229
+ ].join("\n");
230
+ }
231
+ /**
232
+ * Write (or refresh) our notice at the assessment root. Returns false —
233
+ * writing nothing — when a candidate-owned file already occupies the path;
234
+ * the caller falls back to the desktop notification alone.
235
+ */
236
+ export function writeAiNoticeFile(projectDir) {
237
+ try {
238
+ const target = path.join(projectDir, AI_NOTICE_FILE_NAME);
239
+ if (fs.existsSync(target) && !isOwnedNoticeFile(projectDir, AI_NOTICE_FILE_NAME))
240
+ return false;
241
+ const content = aiNoticeContent();
242
+ fs.writeFileSync(target, content, "utf8");
243
+ recordOwnedNotice(projectDir, AI_NOTICE_FILE_NAME, content);
244
+ return true;
245
+ }
246
+ catch {
247
+ return false;
248
+ }
249
+ }
250
+ /**
251
+ * Remove the notice — but only one byte-identical to what WE wrote. Returns
252
+ * whether a file was removed; a candidate-owned file, an edited copy, or no
253
+ * file is left alone and returns false.
254
+ */
255
+ export function removeAiNoticeFile(projectDir) {
256
+ try {
257
+ const target = path.join(projectDir, AI_NOTICE_FILE_NAME);
258
+ if (!isOwnedNoticeFile(projectDir, AI_NOTICE_FILE_NAME))
259
+ return false;
260
+ fs.rmSync(target, { force: true });
261
+ clearOwnedNotice(projectDir, AI_NOTICE_FILE_NAME);
262
+ return true;
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ }
268
+ // ── CLI-notice classification (shared by watcher + zip + status) ──
269
+ // The two notice names are ONLY special as OUR files at the assessment root.
270
+ // Anything nested is candidate work whatever it is called (a basename match
271
+ // there would let a candidate hide arbitrary files from evidence capture and
272
+ // from their own submission), and a root file that isn't byte-identical to
273
+ // content this CLI recorded writing is the candidate's own (suppressing or
274
+ // dropping it would destroy their work — the same file could be silently
275
+ // excluded from the zip AND deleted by cleanup). Content markers can't decide
276
+ // this: they're world-readable and copyable, so ownership is the recorded
277
+ // hash in notice-ownership.ts.
278
+ const NOTICE_FILE_NAMES = new Set([
279
+ AI_NOTICE_FILE_NAME,
280
+ REPAIR_FILE_NAME,
281
+ ]);
282
+ /**
283
+ * True only for a notice file THIS CLI generated, sitting at the assessment
284
+ * root. Root-only (any path separator disqualifies) and gated on the recorded
285
+ * content hash — an edited or planted file, sentinel included, is never ours.
286
+ *
287
+ * `lastKnown`, when supplied, is a caller-owned cache of the most recent
288
+ * ownership verdict per filename. It is refreshed on every call that can
289
+ * read the file, and consulted only when the file is GONE — i.e. the caller
290
+ * is classifying the file's own deletion event, where content can no longer
291
+ * decide. Without it, our removal of our own notice would emit a
292
+ * judge-visible rename event. Callers with no deletion events to classify
293
+ * (zip, status) omit it; a missing file is then simply "not ours".
294
+ */
295
+ export function isCliNoticeArtifact(projectDir, relPath, lastKnown) {
296
+ if (/[/\\]/.test(relPath))
297
+ return false; // nested = candidate work, always
298
+ if (!NOTICE_FILE_NAMES.has(relPath))
299
+ return false;
300
+ const abs = path.join(projectDir, relPath);
301
+ let exists = false;
302
+ try {
303
+ exists = fs.existsSync(abs);
304
+ }
305
+ catch { /* treat as missing */ }
306
+ if (exists) {
307
+ const ours = isOwnedNoticeFile(projectDir, relPath);
308
+ lastKnown?.set(relPath, ours);
309
+ return ours;
310
+ }
311
+ return lastKnown?.get(relPath) ?? false;
312
+ }
313
+ // ── Notification copy ─────────────────────────────────────────────
314
+ // Tone matches the ENG-951 init notice: helpful, never accusatory — zero
315
+ // captured prompts makes the grader renormalize the Process dimension out,
316
+ // so the honest pitch is "visible AI use counts in your favor", not a threat.
317
+ const NOTICE_POINTER = `Details: ${AI_NOTICE_FILE_NAME} in your assessment folder.`;
318
+ /**
319
+ * @param withDetailsPointer false when the notice file could not be written
320
+ * (a candidate-owned file occupies the path) — pointing at a file that
321
+ * carries THEIR content would misdirect.
322
+ */
323
+ export function nudgeNotificationCopy(tool, withDetailsPointer = true) {
324
+ let message;
325
+ switch (tool) {
326
+ case "codex":
327
+ message =
328
+ "No AI prompts captured yet. If you're using Codex, accept its one-time trust prompt so your AI work is recorded for review by the hiring company.";
329
+ break;
330
+ case "copilot":
331
+ message =
332
+ `No AI prompts captured yet. If you're using the Copilot CLI, accept its folder-trust prompt so your AI work is recorded for review by the hiring company, then run "litmus doctor" to recover earlier prompts.`;
333
+ break;
334
+ default:
335
+ message =
336
+ 'No AI prompts captured yet. Run "litmus doctor" in your assessment folder to check AI tracking.';
337
+ break;
338
+ }
339
+ return { title: "Litmus", message: withDetailsPointer ? `${message} ${NOTICE_POINTER}` : message };
340
+ }
341
+ //# sourceMappingURL=capture-nudge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capture-nudge.js","sourceRoot":"","sources":["../../src/lib/capture-nudge.ts"],"names":[],"mappings":"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"}
@@ -20,10 +20,13 @@
20
20
  * cursor-agent CLI never fires it)
21
21
  * copilot Stop payload -> read transcript_path (JSONL), assistant.message
22
22
  * entries of the final turn (VS Code shape; CLI best-effort)
23
+ * gemini AfterAgent payload -> `prompt_response` directly (arrives inline;
24
+ * no transcript read)
23
25
  *
24
26
  * stdout contract: Cursor is the only tool that expects output back — see
25
27
  * emitCursorVerdictIfNeeded. For every other tool stdout MUST stay empty:
26
- * Claude Code injects hook stdout into the candidate's prompt as context.
28
+ * Claude Code injects hook stdout into the candidate's prompt as context, and
29
+ * Gemini surfaces non-empty hook stdout to the candidate as a systemMessage.
27
30
  */
28
31
 
29
32
  const fs = require("fs")
@@ -587,6 +590,17 @@ function extractPrompt(input, toolName) {
587
590
  sessionId: data.session_id || null,
588
591
  }
589
592
  }
593
+ case "gemini": {
594
+ // Gemini CLI BeforeAgent: { session_id, transcript_path, cwd,
595
+ // hook_event_name, prompt } per geminicli.com/docs/hooks. `cwd` is
596
+ // always sent and rides into sessionDirs via main()'s generic
597
+ // `parsed.cwd` plumbing, the same way the other CLIs' payloads do.
598
+ const prompt = data.prompt || null
599
+ return {
600
+ prompt: typeof prompt === "string" ? prompt : JSON.stringify(prompt),
601
+ sessionId: data.session_id || null,
602
+ }
603
+ }
590
604
  case "cursor": {
591
605
  // Cursor beforeSubmitPrompt. session_id and conversation_id carry the
592
606
  // same value; prefer session_id to match every other tool here.
@@ -619,11 +633,12 @@ function extractPrompt(input, toolName) {
619
633
 
620
634
  // The payload event names that mean "the model finished responding".
621
635
  // Claude Code and Codex both name theirs `Stop`; Cursor's is
622
- // `afterAgentResponse`. Anything else is treated as a prompt-submit event,
623
- // preserving the pre-ENG-1323 behavior for tools/payloads we don't recognize.
636
+ // `afterAgentResponse`; Gemini's is `AfterAgent`. Anything else is treated as
637
+ // a prompt-submit event, preserving the pre-ENG-1323 behavior for
638
+ // tools/payloads we don't recognize.
624
639
  // Note the case difference is real, not a typo: Claude Code, Codex and Copilot
625
640
  // all name theirs `Stop`; Cursor's is lowercase `stop` (and `afterAgentResponse`).
626
- const RESPONSE_HOOK_EVENTS = new Set(["Stop", "stop", "afterAgentResponse"])
641
+ const RESPONSE_HOOK_EVENTS = new Set(["Stop", "stop", "afterAgentResponse", "AfterAgent"])
627
642
 
628
643
  function isResponseEvent(data) {
629
644
  if (!data || typeof data !== "object") return false
@@ -927,6 +942,15 @@ function extractResponse(data, toolName) {
927
942
  sessionId: data.session_id || null,
928
943
  }
929
944
  }
945
+ case "gemini": {
946
+ // Gemini AfterAgent carries the response INLINE: { session_id, cwd,
947
+ // hook_event_name: "AfterAgent", prompt_response }. No transcript read.
948
+ const msg = data.prompt_response
949
+ return {
950
+ text: typeof msg === "string" && msg ? msg : null,
951
+ sessionId: data.session_id || null,
952
+ }
953
+ }
930
954
  case "cursor": {
931
955
  const common = {
932
956
  sessionId: data.session_id || data.conversation_id || null,
@@ -0,0 +1,13 @@
1
+ /** Record that this CLI just wrote `content` to the root file `fileName`.
2
+ * Call immediately after every write of a notice file. */
3
+ export declare function recordOwnedNotice(projectDir: string, fileName: string, content: string): void;
4
+ /** Forget a notice we removed (or gave up on). */
5
+ export declare function clearOwnedNotice(projectDir: string, fileName: string): void;
6
+ /**
7
+ * True only when the root file `fileName` exists and is byte-identical to
8
+ * content this CLI recorded writing for this project. Never throws; a missing
9
+ * file, missing record, unreadable content, oversize file, or hash mismatch
10
+ * are all "not ours".
11
+ */
12
+ export declare function isOwnedNoticeFile(projectDir: string, fileName: string): boolean;
13
+ //# sourceMappingURL=notice-ownership.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notice-ownership.d.ts","sourceRoot":"","sources":["../../src/lib/notice-ownership.ts"],"names":[],"mappings":"AAqFA;2DAC2D;AAC3D,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAK7F;AAED,kDAAkD;AAClD,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAQ3E;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAW/E"}
@@ -0,0 +1,123 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { createHash } from "crypto";
5
+ // ── Ownership registry for candidate-facing notice files (ENG-1857) ──
6
+ // The notice files (LITMUS-AI-NOTICE.md, LITMUS-REPAIR.md) sit world-readable
7
+ // at the assessment root, so nothing IN a file can prove this CLI wrote it —
8
+ // any sentinel or marker line can be copied into a look-alike, and a
9
+ // candidate's edits to a generated notice would still carry it. Ownership
10
+ // therefore lives OUTSIDE the file: a registry records the sha256 of exactly
11
+ // the content we wrote, and only a byte-identical file is ever treated as
12
+ // ours. The moment a candidate edits our notice — or plants their own — the
13
+ // hash stops matching and the file is theirs: its change events flow, it
14
+ // ships in the submission zip, and no cleanup path may overwrite or remove it.
15
+ //
16
+ // The registry lives in HOME scope (`~/.litmus/notice-owners.json`, keyed by
17
+ // project realpath), not in the project's own `.litmus/`, so it is outside
18
+ // everything that operates on the assessment tree — the candidate's editor
19
+ // and agents, `git clean`, the zip, the watcher. To be explicit about the
20
+ // boundary this does NOT move: everything on a candidate-owned machine is
21
+ // ultimately candidate-writable (activity.jsonl included), so no client-side
22
+ // state can be adversarially trustworthy. This registry's job is preventing
23
+ // ACCIDENTAL destruction or suppression of candidate work; a candidate who
24
+ // deliberately forges an entry can only self-suppress a root file with one of
25
+ // these two fixed names — which gains them nothing over simply keeping that
26
+ // file outside the assessment directory, where it was never evidence at all.
27
+ const OWNERS_FILE_NAME = "notice-owners.json";
28
+ // Same ceiling as the notices themselves (~1KB each): anything larger at one
29
+ // of these paths is candidate work regardless of what the registry says.
30
+ const MAX_NOTICE_BYTES = 64 * 1024;
31
+ // Resolved per call (not at module load): tests fake HOME, and vitest caches
32
+ // modules — a load-time constant would lock the first test's home in place.
33
+ function ownersPath() {
34
+ return path.join(os.homedir(), ".litmus", OWNERS_FILE_NAME);
35
+ }
36
+ /** Registry keys are project realpaths: `process.cwd()` is realpath-resolved
37
+ * but callers may hold raw paths, and on macOS `/var` is a symlink to
38
+ * `/private/var` — raw-string keys would silently never match. */
39
+ function projectKey(projectDir) {
40
+ try {
41
+ return fs.realpathSync(projectDir);
42
+ }
43
+ catch {
44
+ return projectDir;
45
+ }
46
+ }
47
+ function contentHash(content) {
48
+ return createHash("sha256").update(content, "utf8").digest("hex");
49
+ }
50
+ /** Tolerant load: unreadable or malformed means an empty registry — doubt is
51
+ * always resolved toward "the candidate's file". */
52
+ function loadOwners() {
53
+ try {
54
+ const raw = JSON.parse(fs.readFileSync(ownersPath(), "utf8"));
55
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
56
+ return {};
57
+ const registry = {};
58
+ for (const [project, entries] of Object.entries(raw)) {
59
+ if (typeof entries !== "object" || entries === null || Array.isArray(entries))
60
+ continue;
61
+ const owned = {};
62
+ for (const [name, hash] of Object.entries(entries)) {
63
+ if (typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash))
64
+ owned[name] = hash;
65
+ }
66
+ if (Object.keys(owned).length)
67
+ registry[project] = owned;
68
+ }
69
+ return registry;
70
+ }
71
+ catch {
72
+ return {};
73
+ }
74
+ }
75
+ function saveOwners(registry) {
76
+ try {
77
+ fs.mkdirSync(path.dirname(ownersPath()), { recursive: true });
78
+ fs.writeFileSync(ownersPath(), JSON.stringify(registry), "utf8");
79
+ }
80
+ catch { /* best effort — worst case a notice we wrote is treated as the candidate's */ }
81
+ }
82
+ /** Record that this CLI just wrote `content` to the root file `fileName`.
83
+ * Call immediately after every write of a notice file. */
84
+ export function recordOwnedNotice(projectDir, fileName, content) {
85
+ const registry = loadOwners();
86
+ const key = projectKey(projectDir);
87
+ registry[key] = { ...registry[key], [fileName]: contentHash(content) };
88
+ saveOwners(registry);
89
+ }
90
+ /** Forget a notice we removed (or gave up on). */
91
+ export function clearOwnedNotice(projectDir, fileName) {
92
+ const registry = loadOwners();
93
+ const key = projectKey(projectDir);
94
+ const owned = registry[key];
95
+ if (!owned || !(fileName in owned))
96
+ return;
97
+ delete owned[fileName];
98
+ if (!Object.keys(owned).length)
99
+ delete registry[key];
100
+ saveOwners(registry);
101
+ }
102
+ /**
103
+ * True only when the root file `fileName` exists and is byte-identical to
104
+ * content this CLI recorded writing for this project. Never throws; a missing
105
+ * file, missing record, unreadable content, oversize file, or hash mismatch
106
+ * are all "not ours".
107
+ */
108
+ export function isOwnedNoticeFile(projectDir, fileName) {
109
+ const recorded = loadOwners()[projectKey(projectDir)]?.[fileName];
110
+ if (!recorded)
111
+ return false;
112
+ try {
113
+ const abs = path.join(projectDir, fileName);
114
+ const stat = fs.statSync(abs);
115
+ if (!stat.isFile() || stat.size > MAX_NOTICE_BYTES)
116
+ return false;
117
+ return contentHash(fs.readFileSync(abs, "utf8")) === recorded;
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
123
+ //# sourceMappingURL=notice-ownership.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notice-ownership.js","sourceRoot":"","sources":["../../src/lib/notice-ownership.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAA;AACnB,OAAO,EAAE,MAAM,IAAI,CAAA;AACnB,OAAO,IAAI,MAAM,MAAM,CAAA;AACvB,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAA;AAEnC,wEAAwE;AACxE,8EAA8E;AAC9E,6EAA6E;AAC7E,qEAAqE;AACrE,0EAA0E;AAC1E,6EAA6E;AAC7E,0EAA0E;AAC1E,4EAA4E;AAC5E,yEAAyE;AACzE,+EAA+E;AAC/E,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,2EAA2E;AAC3E,0EAA0E;AAC1E,0EAA0E;AAC1E,6EAA6E;AAC7E,4EAA4E;AAC5E,2EAA2E;AAC3E,8EAA8E;AAC9E,4EAA4E;AAC5E,6EAA6E;AAE7E,MAAM,gBAAgB,GAAG,oBAAoB,CAAA;AAE7C,6EAA6E;AAC7E,yEAAyE;AACzE,MAAM,gBAAgB,GAAG,EAAE,GAAG,IAAI,CAAA;AAElC,6EAA6E;AAC7E,4EAA4E;AAC5E,SAAS,UAAU;IACjB,OAAO,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,gBAAgB,CAAC,CAAA;AAC7D,CAAC;AAED;;mEAEmE;AACnE,SAAS,UAAU,CAAC,UAAkB;IACpC,IAAI,CAAC;QACH,OAAO,EAAE,CAAC,YAAY,CAAC,UAAU,CAAC,CAAA;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,UAAU,CAAA;IACnB,CAAC;AACH,CAAC;AAED,SAAS,WAAW,CAAC,OAAe;IAClC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACnE,CAAC;AAID;qDACqD;AACrD,SAAS,UAAU;IACjB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,UAAU,EAAE,EAAE,MAAM,CAAC,CAAC,CAAA;QAC7D,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;YAAE,OAAO,EAAE,CAAA;QAC5E,MAAM,QAAQ,GAAmB,EAAE,CAAA;QACnC,KAAK,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;YACrD,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;gBAAE,SAAQ;YACvF,MAAM,KAAK,GAA2B,EAAE,CAAA;YACxC,KAAK,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;gBACnD,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC;oBAAE,KAAK,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;YACjF,CAAC;YACD,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM;gBAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,KAAK,CAAA;QAC1D,CAAC;QACD,OAAO,QAAQ,CAAA;IACjB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAA;IACX,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,QAAwB;IAC1C,IAAI,CAAC;QACH,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QAC7D,EAAE,CAAC,aAAa,CAAC,UAAU,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,CAAA;IAClE,CAAC;IAAC,MAAM,CAAC,CAAC,8EAA8E,CAAC,CAAC;AAC5F,CAAC;AAED;2DAC2D;AAC3D,MAAM,UAAU,iBAAiB,CAAC,UAAkB,EAAE,QAAgB,EAAE,OAAe;IACrF,MAAM,QAAQ,GAAG,UAAU,EAAE,CAAA;IAC7B,MAAM,GAAG,GAAG,UAAU,CAAC,UAAU,CAAC,CAAA;IAClC,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,WAAW,CAAC,OAAO,CAAC,EAAE,CAAA;IACtE,UAAU,CAAC,QAAQ,CAAC,CAAA;AACtB,CAAC;AAED,kDAAkD;AAClD,MAAM,UAAU,gBAAgB,CAAC,UAAkB,EAAE,QAAgB;IACnE,MAAM,QAAQ,GAAG,UAAU,EAAE,CAAA;IAC7B,MAAM,GAAG,GAAG,UAAU,CAAC,UAAU,CAAC,CAAA;IAClC,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAA;IAC3B,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,QAAQ,IAAI,KAAK,CAAC;QAAE,OAAM;IAC1C,OAAO,KAAK,CAAC,QAAQ,CAAC,CAAA;IACtB,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM;QAAE,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAA;IACpD,UAAU,CAAC,QAAQ,CAAC,CAAA;AACtB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,UAAkB,EAAE,QAAgB;IACpE,MAAM,QAAQ,GAAG,UAAU,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAA;IACjE,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAA;IAC3B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAA;QAC3C,MAAM,IAAI,GAAG,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAA;QAC7B,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,IAAI,CAAC,IAAI,GAAG,gBAAgB;YAAE,OAAO,KAAK,CAAA;QAChE,OAAO,WAAW,CAAC,EAAE,CAAC,YAAY,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,KAAK,QAAQ,CAAA;IAC/D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Best-effort desktop notification (ENG-1857) — zero dependencies, silent
3
+ * failure. Used by the watcher to reach a candidate whose terminal (and
4
+ * tracker.log) they are not looking at. It must never throw and never block:
5
+ * every path is a fire-and-forget execFile with a 5s timeout, and a missing
6
+ * binary (no notify-send, locked-down PowerShell) is simply a no-op.
7
+ */
8
+ import { execFile } from "child_process";
9
+ /**
10
+ * The command for this platform, or null when there is nothing sensible to
11
+ * run. Pure — split out so tests can check construction (quote escaping)
12
+ * without spawning anything.
13
+ */
14
+ export declare function buildNotifyCommand(platform: NodeJS.Platform, title: string, message: string): {
15
+ cmd: string;
16
+ args: string[];
17
+ } | null;
18
+ /**
19
+ * Show a desktop notification. Never throws, never blocks the caller;
20
+ * failure of any kind (missing binary, DE without a notification daemon,
21
+ * headless session) is silently ignored — the notice file is the durable
22
+ * fallback surface.
23
+ */
24
+ export declare function desktopNotify(title: string, message: string, exec?: typeof execFile): void;
25
+ //# sourceMappingURL=notify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../src/lib/notify.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAYxC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,CAAC,QAAQ,EACzB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,GACd;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CA4BxC;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,IAAI,GAAE,OAAO,QAAmB,GAC/B,IAAI,CAaN"}