litmus-cli 1.4.11 → 1.4.13
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.map +1 -1
- package/dist/commands/doctor.js +10 -8
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts +5 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +49 -25
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +15 -3
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/submit.js +2 -1
- package/dist/commands/submit.js.map +1 -1
- package/dist/lib/ai-tracking.d.ts +32 -2
- package/dist/lib/ai-tracking.d.ts.map +1 -1
- package/dist/lib/ai-tracking.js +153 -3
- package/dist/lib/ai-tracking.js.map +1 -1
- package/dist/lib/backfill-tools.d.ts +42 -0
- package/dist/lib/backfill-tools.d.ts.map +1 -1
- package/dist/lib/backfill-tools.js +266 -1
- package/dist/lib/backfill-tools.js.map +1 -1
- package/dist/lib/backfill.d.ts +2 -2
- package/dist/lib/backfill.d.ts.map +1 -1
- package/dist/lib/backfill.js.map +1 -1
- package/dist/lib/capture-nudge.d.ts +118 -0
- package/dist/lib/capture-nudge.d.ts.map +1 -0
- package/dist/lib/capture-nudge.js +342 -0
- package/dist/lib/capture-nudge.js.map +1 -0
- package/dist/lib/hook-logger.cjs +28 -4
- package/dist/lib/notice-ownership.d.ts +13 -0
- package/dist/lib/notice-ownership.d.ts.map +1 -0
- package/dist/lib/notice-ownership.js +123 -0
- package/dist/lib/notice-ownership.js.map +1 -0
- package/dist/lib/notify.d.ts +25 -0
- package/dist/lib/notify.d.ts.map +1 -0
- package/dist/lib/notify.js +68 -0
- package/dist/lib/notify.js.map +1 -0
- package/dist/lib/shadow.d.ts +9 -0
- package/dist/lib/shadow.d.ts.map +1 -1
- package/dist/lib/shadow.js +26 -3
- package/dist/lib/shadow.js.map +1 -1
- package/dist/lib/watcher.js +96 -3
- package/dist/lib/watcher.js.map +1 -1
- package/dist/lib/zip.d.ts.map +1 -1
- package/dist/lib/zip.js +22 -1
- package/dist/lib/zip.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
export declare const NUDGE_TRIGGER_TOOLS: readonly ["codex", "copilot", "claude_code"];
|
|
19
|
+
export type NudgeTriggerTool = (typeof NUDGE_TRIGGER_TOOLS)[number];
|
|
20
|
+
export declare const NUDGE_STATE_FILE_NAME = "nudge-state.json";
|
|
21
|
+
export interface NudgeState {
|
|
22
|
+
/** Consecutive env scans each trigger tool has been present on. */
|
|
23
|
+
consecutive: Partial<Record<NudgeTriggerTool, number>>;
|
|
24
|
+
/** Nudges fired this session (hard cap MAX_NUDGES). */
|
|
25
|
+
nudgeCount: number;
|
|
26
|
+
/** Epoch ms of the most recent nudge, null if none fired yet. */
|
|
27
|
+
lastNudgeAt: number | null;
|
|
28
|
+
/** promptCount > 0 was observed — off permanently for this session. */
|
|
29
|
+
latched: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare function initialNudgeState(): NudgeState;
|
|
32
|
+
export type NudgeAction = {
|
|
33
|
+
action: "none";
|
|
34
|
+
} | {
|
|
35
|
+
action: "nudge";
|
|
36
|
+
tool: NudgeTriggerTool;
|
|
37
|
+
}
|
|
38
|
+
/** Capture just started working — remove the notice file, latch off. */
|
|
39
|
+
| {
|
|
40
|
+
action: "remove_notice";
|
|
41
|
+
};
|
|
42
|
+
export type NudgeDecision = NudgeAction & {
|
|
43
|
+
state: NudgeState;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* One decision per env scan. Pure: all inputs explicit, returns the action
|
|
47
|
+
* plus the next state (the caller persists it).
|
|
48
|
+
*
|
|
49
|
+
* @param scanTools aiTools labels from this scan's env detection
|
|
50
|
+
* @param promptCount ai_prompt + ai_response events captured so far
|
|
51
|
+
* @param now epoch ms
|
|
52
|
+
* @param msRemaining ms to the effective deadline, null when none configured
|
|
53
|
+
*/
|
|
54
|
+
export declare function decideNudge(state: NudgeState, scanTools: string[], promptCount: number, now: number, msRemaining: number | null): NudgeDecision;
|
|
55
|
+
/** Tolerant load: anything unreadable or malformed means a fresh state. */
|
|
56
|
+
export declare function loadNudgeState(projectDir: string): NudgeState;
|
|
57
|
+
export declare function saveNudgeState(projectDir: string, state: NudgeState): void;
|
|
58
|
+
export interface PromptScanCursor {
|
|
59
|
+
/** Byte offset already scanned. */
|
|
60
|
+
offset: number;
|
|
61
|
+
/** Trailing partial line from the previous read (no newline yet). */
|
|
62
|
+
remainder: string;
|
|
63
|
+
/** ai_prompt + ai_response events seen so far. */
|
|
64
|
+
count: number;
|
|
65
|
+
}
|
|
66
|
+
export declare function initialPromptCursor(): PromptScanCursor;
|
|
67
|
+
/**
|
|
68
|
+
* Scan any bytes appended to `logPath` since `cursor` and return the
|
|
69
|
+
* advanced cursor. Never throws; a missing/unreadable log leaves the cursor
|
|
70
|
+
* unchanged. A log SMALLER than the cursor's offset (wiped/rotated) rescans
|
|
71
|
+
* from zero.
|
|
72
|
+
*/
|
|
73
|
+
export declare function scanPromptCount(logPath: string, cursor: PromptScanCursor): PromptScanCursor;
|
|
74
|
+
export declare const AI_NOTICE_FILE_NAME = "LITMUS-AI-NOTICE.md";
|
|
75
|
+
/** Human-readable label on every notice this CLI writes. A LABEL ONLY — it is
|
|
76
|
+
* world-readable and therefore copyable, so it proves nothing. Ownership is
|
|
77
|
+
* decided by the recorded content hash (notice-ownership.ts): only a file
|
|
78
|
+
* byte-identical to what we wrote is ever overwritten, deleted, hidden from
|
|
79
|
+
* change events, or excluded from the zip; an edited or planted file is the
|
|
80
|
+
* candidate's, sentinel or not. */
|
|
81
|
+
export declare const AI_NOTICE_SENTINEL = "<!-- litmus-cli capture-nudge notice. Auto-generated, removed when capture starts. -->";
|
|
82
|
+
export declare function aiNoticeContent(): string;
|
|
83
|
+
/**
|
|
84
|
+
* Write (or refresh) our notice at the assessment root. Returns false —
|
|
85
|
+
* writing nothing — when a candidate-owned file already occupies the path;
|
|
86
|
+
* the caller falls back to the desktop notification alone.
|
|
87
|
+
*/
|
|
88
|
+
export declare function writeAiNoticeFile(projectDir: string): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Remove the notice — but only one byte-identical to what WE wrote. Returns
|
|
91
|
+
* whether a file was removed; a candidate-owned file, an edited copy, or no
|
|
92
|
+
* file is left alone and returns false.
|
|
93
|
+
*/
|
|
94
|
+
export declare function removeAiNoticeFile(projectDir: string): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* True only for a notice file THIS CLI generated, sitting at the assessment
|
|
97
|
+
* root. Root-only (any path separator disqualifies) and gated on the recorded
|
|
98
|
+
* content hash — an edited or planted file, sentinel included, is never ours.
|
|
99
|
+
*
|
|
100
|
+
* `lastKnown`, when supplied, is a caller-owned cache of the most recent
|
|
101
|
+
* ownership verdict per filename. It is refreshed on every call that can
|
|
102
|
+
* read the file, and consulted only when the file is GONE — i.e. the caller
|
|
103
|
+
* is classifying the file's own deletion event, where content can no longer
|
|
104
|
+
* decide. Without it, our removal of our own notice would emit a
|
|
105
|
+
* judge-visible rename event. Callers with no deletion events to classify
|
|
106
|
+
* (zip, status) omit it; a missing file is then simply "not ours".
|
|
107
|
+
*/
|
|
108
|
+
export declare function isCliNoticeArtifact(projectDir: string, relPath: string, lastKnown?: Map<string, boolean>): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* @param withDetailsPointer false when the notice file could not be written
|
|
111
|
+
* (a candidate-owned file occupies the path) — pointing at a file that
|
|
112
|
+
* carries THEIR content would misdirect.
|
|
113
|
+
*/
|
|
114
|
+
export declare function nudgeNotificationCopy(tool: NudgeTriggerTool, withDetailsPointer?: boolean): {
|
|
115
|
+
title: string;
|
|
116
|
+
message: string;
|
|
117
|
+
};
|
|
118
|
+
//# sourceMappingURL=capture-nudge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capture-nudge.d.ts","sourceRoot":"","sources":["../../src/lib/capture-nudge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAiBH,eAAO,MAAM,mBAAmB,8CAA+C,CAAA;AAC/E,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,CAAC,CAAA;AAqBnE,eAAO,MAAM,qBAAqB,qBAAqB,CAAA;AAEvD,MAAM,WAAW,UAAU;IACzB,mEAAmE;IACnE,WAAW,EAAE,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC,CAAA;IACtD,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAA;IAClB,iEAAiE;IACjE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,uEAAuE;IACvE,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,wBAAgB,iBAAiB,IAAI,UAAU,CAE9C;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,gBAAgB,CAAA;CAAE;AAC7C,wEAAwE;GACtE;IAAE,MAAM,EAAE,eAAe,CAAA;CAAE,CAAA;AAE/B,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG;IAAE,KAAK,EAAE,UAAU,CAAA;CAAE,CAAA;AAE/D;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,MAAM,EAAE,EACnB,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,GAAG,IAAI,GACzB,aAAa,CAmCf;AAQD,2EAA2E;AAC3E,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,UAAU,CAkB7D;AAED,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI,CAK1E;AASD,MAAM,WAAW,gBAAgB;IAC/B,mCAAmC;IACnC,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,SAAS,EAAE,MAAM,CAAA;IACjB,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAA;CACd;AAED,wBAAgB,mBAAmB,IAAI,gBAAgB,CAEtD;AAeD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,GAAG,gBAAgB,CA8B3F;AAaD,eAAO,MAAM,mBAAmB,wBAAwB,CAAA;AAExD;;;;;oCAKoC;AACpC,eAAO,MAAM,kBAAkB,2FAC2D,CAAA;AAE1F,wBAAgB,eAAe,IAAI,MAAM,CAyBxC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAW7D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAU9D;AAkBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,EACf,SAAS,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAcT;AASD;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,gBAAgB,EACtB,kBAAkB,UAAO,GACxB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAiBpC"}
|
|
@@ -0,0 +1,342 @@
|
|
|
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. Then run `litmus doctor` here to recover",
|
|
219
|
+
" prompts from before you accepted.",
|
|
220
|
+
"- **Copilot CLI**: accept the folder-trust prompt it shows when it starts",
|
|
221
|
+
" in this folder. It runs no hooks (and says nothing) until you do. Then",
|
|
222
|
+
" run `litmus doctor` here to recover prompts from before you accepted.",
|
|
223
|
+
"- **Claude Code**: run `litmus doctor` here to check and repair tracking.",
|
|
224
|
+
"",
|
|
225
|
+
"Already done this, or not using an AI tool (or using one we can't capture,",
|
|
226
|
+
"like a browser chat)? Then ignore this. Nothing is wrong with your",
|
|
227
|
+
"submission. Run `litmus status` any time to see what's being captured.",
|
|
228
|
+
"This file is not part of your submission and disappears once capture starts.",
|
|
229
|
+
"",
|
|
230
|
+
].join("\n");
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Write (or refresh) our notice at the assessment root. Returns false —
|
|
234
|
+
* writing nothing — when a candidate-owned file already occupies the path;
|
|
235
|
+
* the caller falls back to the desktop notification alone.
|
|
236
|
+
*/
|
|
237
|
+
export function writeAiNoticeFile(projectDir) {
|
|
238
|
+
try {
|
|
239
|
+
const target = path.join(projectDir, AI_NOTICE_FILE_NAME);
|
|
240
|
+
if (fs.existsSync(target) && !isOwnedNoticeFile(projectDir, AI_NOTICE_FILE_NAME))
|
|
241
|
+
return false;
|
|
242
|
+
const content = aiNoticeContent();
|
|
243
|
+
fs.writeFileSync(target, content, "utf8");
|
|
244
|
+
recordOwnedNotice(projectDir, AI_NOTICE_FILE_NAME, content);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Remove the notice — but only one byte-identical to what WE wrote. Returns
|
|
253
|
+
* whether a file was removed; a candidate-owned file, an edited copy, or no
|
|
254
|
+
* file is left alone and returns false.
|
|
255
|
+
*/
|
|
256
|
+
export function removeAiNoticeFile(projectDir) {
|
|
257
|
+
try {
|
|
258
|
+
const target = path.join(projectDir, AI_NOTICE_FILE_NAME);
|
|
259
|
+
if (!isOwnedNoticeFile(projectDir, AI_NOTICE_FILE_NAME))
|
|
260
|
+
return false;
|
|
261
|
+
fs.rmSync(target, { force: true });
|
|
262
|
+
clearOwnedNotice(projectDir, AI_NOTICE_FILE_NAME);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ── CLI-notice classification (shared by watcher + zip + status) ──
|
|
270
|
+
// The two notice names are ONLY special as OUR files at the assessment root.
|
|
271
|
+
// Anything nested is candidate work whatever it is called (a basename match
|
|
272
|
+
// there would let a candidate hide arbitrary files from evidence capture and
|
|
273
|
+
// from their own submission), and a root file that isn't byte-identical to
|
|
274
|
+
// content this CLI recorded writing is the candidate's own (suppressing or
|
|
275
|
+
// dropping it would destroy their work — the same file could be silently
|
|
276
|
+
// excluded from the zip AND deleted by cleanup). Content markers can't decide
|
|
277
|
+
// this: they're world-readable and copyable, so ownership is the recorded
|
|
278
|
+
// hash in notice-ownership.ts.
|
|
279
|
+
const NOTICE_FILE_NAMES = new Set([
|
|
280
|
+
AI_NOTICE_FILE_NAME,
|
|
281
|
+
REPAIR_FILE_NAME,
|
|
282
|
+
]);
|
|
283
|
+
/**
|
|
284
|
+
* True only for a notice file THIS CLI generated, sitting at the assessment
|
|
285
|
+
* root. Root-only (any path separator disqualifies) and gated on the recorded
|
|
286
|
+
* content hash — an edited or planted file, sentinel included, is never ours.
|
|
287
|
+
*
|
|
288
|
+
* `lastKnown`, when supplied, is a caller-owned cache of the most recent
|
|
289
|
+
* ownership verdict per filename. It is refreshed on every call that can
|
|
290
|
+
* read the file, and consulted only when the file is GONE — i.e. the caller
|
|
291
|
+
* is classifying the file's own deletion event, where content can no longer
|
|
292
|
+
* decide. Without it, our removal of our own notice would emit a
|
|
293
|
+
* judge-visible rename event. Callers with no deletion events to classify
|
|
294
|
+
* (zip, status) omit it; a missing file is then simply "not ours".
|
|
295
|
+
*/
|
|
296
|
+
export function isCliNoticeArtifact(projectDir, relPath, lastKnown) {
|
|
297
|
+
if (/[/\\]/.test(relPath))
|
|
298
|
+
return false; // nested = candidate work, always
|
|
299
|
+
if (!NOTICE_FILE_NAMES.has(relPath))
|
|
300
|
+
return false;
|
|
301
|
+
const abs = path.join(projectDir, relPath);
|
|
302
|
+
let exists = false;
|
|
303
|
+
try {
|
|
304
|
+
exists = fs.existsSync(abs);
|
|
305
|
+
}
|
|
306
|
+
catch { /* treat as missing */ }
|
|
307
|
+
if (exists) {
|
|
308
|
+
const ours = isOwnedNoticeFile(projectDir, relPath);
|
|
309
|
+
lastKnown?.set(relPath, ours);
|
|
310
|
+
return ours;
|
|
311
|
+
}
|
|
312
|
+
return lastKnown?.get(relPath) ?? false;
|
|
313
|
+
}
|
|
314
|
+
// ── Notification copy ─────────────────────────────────────────────
|
|
315
|
+
// Tone matches the ENG-951 init notice: helpful, never accusatory — zero
|
|
316
|
+
// captured prompts makes the grader renormalize the Process dimension out,
|
|
317
|
+
// so the honest pitch is "visible AI use counts in your favor", not a threat.
|
|
318
|
+
const NOTICE_POINTER = `Details: ${AI_NOTICE_FILE_NAME} in your assessment folder.`;
|
|
319
|
+
/**
|
|
320
|
+
* @param withDetailsPointer false when the notice file could not be written
|
|
321
|
+
* (a candidate-owned file occupies the path) — pointing at a file that
|
|
322
|
+
* carries THEIR content would misdirect.
|
|
323
|
+
*/
|
|
324
|
+
export function nudgeNotificationCopy(tool, withDetailsPointer = true) {
|
|
325
|
+
let message;
|
|
326
|
+
switch (tool) {
|
|
327
|
+
case "codex":
|
|
328
|
+
message =
|
|
329
|
+
`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, then run "litmus doctor" to recover earlier prompts.`;
|
|
330
|
+
break;
|
|
331
|
+
case "copilot":
|
|
332
|
+
message =
|
|
333
|
+
`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.`;
|
|
334
|
+
break;
|
|
335
|
+
default:
|
|
336
|
+
message =
|
|
337
|
+
'No AI prompts captured yet. Run "litmus doctor" in your assessment folder to check AI tracking.';
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
return { title: "Litmus", message: withDetailsPointer ? `${message} ${NOTICE_POINTER}` : message };
|
|
341
|
+
}
|
|
342
|
+
//# 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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"}
|
package/dist/lib/hook-logger.cjs
CHANGED
|
@@ -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
|
|
623
|
-
// preserving the pre-ENG-1323 behavior for
|
|
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"}
|