agent-polygraph 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +177 -2
- package/bin/polygraph.js +261 -0
- package/package.json +38 -4
- package/src/analyze.js +611 -0
- package/src/ci.js +204 -0
- package/src/classify.js +506 -0
- package/src/demo.js +120 -0
- package/src/guard.js +216 -0
- package/src/jsonl.js +23 -0
- package/src/record.js +238 -0
- package/src/report/format.js +87 -0
- package/src/report/html.js +101 -0
- package/src/report/terminal.js +160 -0
- package/src/scan.js +60 -0
- package/src/shell/record.sh +61 -0
- package/src/sources/claude.js +300 -0
- package/src/sources/codex.js +476 -0
package/src/guard.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// `agent-polygraph guard`: a Stop hook for Claude Code and Codex.
|
|
2
|
+
// When the agent's final message says tests pass but the session shows no passing run
|
|
3
|
+
// after its last code edit, the hook sends it back once to rerun the tests or correct itself.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import * as claude from "./sources/claude.js";
|
|
10
|
+
import * as codex from "./sources/codex.js";
|
|
11
|
+
import { analyzeSession } from "./analyze.js";
|
|
12
|
+
import { findTestClaim, findTestClaims } from "./classify.js";
|
|
13
|
+
import { applyRecords } from "./record.js";
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
/** Hook entry point: read the Stop payload on stdin, maybe print a block decision. */
|
|
17
|
+
export async function runHook(stdinText) {
|
|
18
|
+
let payload;
|
|
19
|
+
try {
|
|
20
|
+
payload = JSON.parse(stdinText || "{}");
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (payload.stop_hook_active) return null; // we already sent the agent back once
|
|
25
|
+
const transcript = payload.transcript_path;
|
|
26
|
+
if (!transcript || !fs.existsSync(transcript)) return null;
|
|
27
|
+
|
|
28
|
+
const isCodex = sniffCodex(transcript);
|
|
29
|
+
const src = isCodex ? codex : claude;
|
|
30
|
+
const files = [transcript];
|
|
31
|
+
if (!isCodex) {
|
|
32
|
+
const subDir = path.join(transcript.replace(/\.jsonl$/, ""), "subagents");
|
|
33
|
+
try {
|
|
34
|
+
for (const f of fs.readdirSync(subDir)) if (f.endsWith(".jsonl")) files.push(path.join(subDir, f));
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
const session = await src.parse({ agent: src.name, id: "live", files });
|
|
38
|
+
if (payload.cwd && !session.cwd) session.cwd = payload.cwd;
|
|
39
|
+
// snapshots and terminal runs from `agent-polygraph record`, if installed
|
|
40
|
+
try {
|
|
41
|
+
session.file = transcript;
|
|
42
|
+
applyRecords(session);
|
|
43
|
+
} catch {}
|
|
44
|
+
|
|
45
|
+
// Judge only what the agent is actually ending with. Agents send that text with the Stop
|
|
46
|
+
// payload (it may not be in the transcript yet); an earlier claim it has since corrected
|
|
47
|
+
// ("Correction: I did not run the tests") must not trigger a block.
|
|
48
|
+
const events = session.events;
|
|
49
|
+
const lastUser = events.findLastIndex((e) => e.kind === "user");
|
|
50
|
+
let final;
|
|
51
|
+
const last = payload.last_assistant_message;
|
|
52
|
+
if (typeof last === "string" && last.trim()) {
|
|
53
|
+
// a loop, not Math.max(...events): long sessions exceed the argument limit
|
|
54
|
+
let latest = session.end ?? 0;
|
|
55
|
+
for (const e of events) if ((e.ts ?? 0) > latest) latest = e.ts;
|
|
56
|
+
final = { kind: "say", ts: latest + 1, text: last };
|
|
57
|
+
events.push(final);
|
|
58
|
+
} else {
|
|
59
|
+
const i = events.findLastIndex((e) => e.kind === "say");
|
|
60
|
+
final = i > lastUser ? events[i] : null;
|
|
61
|
+
}
|
|
62
|
+
if (!final || !findTestClaim(final.text)) return null;
|
|
63
|
+
|
|
64
|
+
const result = analyzeSession(session);
|
|
65
|
+
const detail = result.findings.find((f) => f.type === "claim" && f.ts === final.ts && final.verdict === f.detail.verdict)?.detail;
|
|
66
|
+
if (!detail) return null; // backed, or nothing we can judge
|
|
67
|
+
// quote the message's conclusion ("All tests pass."), not a step along the way
|
|
68
|
+
const quote = findTestClaims(final.text).at(-1) ?? detail.claim;
|
|
69
|
+
return { decision: "block", reason: reasonFor({ ...detail, claim: quote }) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reasonFor(d) {
|
|
73
|
+
const why = {
|
|
74
|
+
"no-run": "no test run appears in this session",
|
|
75
|
+
stale: `you edited ${d.editedAfter} after the last test run`,
|
|
76
|
+
failed: "the most recent test run in this session failed",
|
|
77
|
+
}[d.verdict];
|
|
78
|
+
return [
|
|
79
|
+
`agent-polygraph: your final message says "${d.claim}", but ${why}.`,
|
|
80
|
+
"Run the relevant tests now and report the actual result. If they can't be run, say so instead of claiming they pass.",
|
|
81
|
+
].join(" ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sniffCodex(file) {
|
|
85
|
+
try {
|
|
86
|
+
const fd = fs.openSync(file, "r");
|
|
87
|
+
const buf = Buffer.alloc(4096);
|
|
88
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
89
|
+
fs.closeSync(fd);
|
|
90
|
+
return /"type"\s*:\s*"session_meta"/.test(buf.subarray(0, n).toString("utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- install / uninstall ------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
const IN_NPX_CACHE = /[\\/]_npx[\\/]/;
|
|
99
|
+
|
|
100
|
+
export function hookCommand(sub = "guard") {
|
|
101
|
+
// npx puts its temporary copy on PATH while it runs, so a bare `agent-polygraph`
|
|
102
|
+
// only counts when it's a real install that will still exist later.
|
|
103
|
+
try {
|
|
104
|
+
const found = execFileSync(process.platform === "win32" ? "where" : "which", ["agent-polygraph"], { encoding: "utf8" }).split(/\r?\n/)[0].trim();
|
|
105
|
+
if (found && !IN_NPX_CACHE.test(fs.realpathSync(found))) return `agent-polygraph ${sub}`;
|
|
106
|
+
} catch {}
|
|
107
|
+
const self = fs.realpathSync(process.argv[1]);
|
|
108
|
+
if (!IN_NPX_CACHE.test(self)) return `node ${JSON.stringify(self)} ${sub}`;
|
|
109
|
+
const { version } = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
110
|
+
return `npx -y agent-polygraph@${version} ${sub}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Hook config files to touch: project-level by default, user-level with `global`. */
|
|
114
|
+
export function targets({ agents = ["claude", "codex"], global = false, cwd = process.cwd() } = {}) {
|
|
115
|
+
const out = [];
|
|
116
|
+
if (agents.includes("claude")) {
|
|
117
|
+
out.push({
|
|
118
|
+
agent: "claude",
|
|
119
|
+
file: global
|
|
120
|
+
? path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "settings.json")
|
|
121
|
+
: path.join(cwd, ".claude", "settings.json"),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (agents.includes("codex")) {
|
|
125
|
+
out.push({
|
|
126
|
+
agent: "codex",
|
|
127
|
+
file: global
|
|
128
|
+
? path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "hooks.json")
|
|
129
|
+
: path.join(cwd, ".codex", "hooks.json"),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One definition of "our hook", covering every command form we write: a global install
|
|
137
|
+
* (`agent-polygraph guard`), a version-pinned npx (`npx -y agent-polygraph@0.1.0 guard`)
|
|
138
|
+
* and a local checkout (`node "/path/bin/polygraph.js" guard`).
|
|
139
|
+
*/
|
|
140
|
+
export function isOurHook(h, sub = "guard") {
|
|
141
|
+
const cmd = String(h?.command || "");
|
|
142
|
+
return new RegExp(String.raw`\bagent-polygraph(?:@\S+)?\s+${sub}\b`).test(cmd) || new RegExp(String.raw`polygraph\.js["']?\s+${sub}\b`).test(cmd);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// `guard` is a Stop hook; `record` runs after each shell command (PostToolUse). Codex gets
|
|
146
|
+
// no matcher: the recorder itself ignores tools that aren't shell commands.
|
|
147
|
+
const HOOKS = {
|
|
148
|
+
guard: { event: "Stop", timeout: 30, matcher: {} },
|
|
149
|
+
record: { event: "PostToolUse", timeout: 20, matcher: { claude: "Bash|PowerShell" } },
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export function install(opts) {
|
|
153
|
+
const sub = opts.sub || "guard";
|
|
154
|
+
const spec = HOOKS[sub];
|
|
155
|
+
const command = opts.command || hookCommand(sub);
|
|
156
|
+
const isOurs = (h) => isOurHook(h, sub);
|
|
157
|
+
return targets(opts).map(({ agent, file }) => {
|
|
158
|
+
const config = readJson(file);
|
|
159
|
+
config.hooks ||= {};
|
|
160
|
+
const stop = (config.hooks[spec.event] ||= []);
|
|
161
|
+
const ours = stop.flatMap((g) => (g.hooks || []).filter(isOurs));
|
|
162
|
+
let changed = false;
|
|
163
|
+
if (!ours.length) {
|
|
164
|
+
const group = { hooks: [{ type: "command", command, timeout: spec.timeout }] };
|
|
165
|
+
if (spec.matcher[agent]) group.matcher = spec.matcher[agent];
|
|
166
|
+
stop.push(group);
|
|
167
|
+
changed = true;
|
|
168
|
+
} else {
|
|
169
|
+
// upgrade an older form (another version, npx vs global) in place; drop duplicates
|
|
170
|
+
if (ours[0].command !== command) {
|
|
171
|
+
ours[0].command = command;
|
|
172
|
+
changed = true;
|
|
173
|
+
}
|
|
174
|
+
for (const g of stop) {
|
|
175
|
+
const before = (g.hooks || []).length;
|
|
176
|
+
g.hooks = (g.hooks || []).filter((h) => !isOurs(h) || h === ours[0]);
|
|
177
|
+
if (g.hooks.length !== before) changed = true;
|
|
178
|
+
}
|
|
179
|
+
config.hooks[spec.event] = stop.filter((g) => g.hooks.length);
|
|
180
|
+
}
|
|
181
|
+
if (changed) {
|
|
182
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
183
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
|
|
184
|
+
}
|
|
185
|
+
return { agent, file, changed, command };
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function uninstall(opts) {
|
|
190
|
+
const sub = opts.sub || "guard";
|
|
191
|
+
const { event } = HOOKS[sub];
|
|
192
|
+
const isOurs = (h) => isOurHook(h, sub);
|
|
193
|
+
return targets(opts).map(({ agent, file }) => {
|
|
194
|
+
if (!fs.existsSync(file)) return { agent, file, changed: false };
|
|
195
|
+
const config = readJson(file);
|
|
196
|
+
const stop = config.hooks?.[event];
|
|
197
|
+
if (!Array.isArray(stop)) return { agent, file, changed: false };
|
|
198
|
+
const removed = stop.reduce((n, g) => n + (g.hooks || []).filter(isOurs).length, 0);
|
|
199
|
+
if (!removed) return { agent, file, changed: false };
|
|
200
|
+
const kept = stop.map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => !isOurs(h)) })).filter((g) => g.hooks.length);
|
|
201
|
+
if (kept.length) config.hooks[event] = kept;
|
|
202
|
+
else delete config.hooks[event];
|
|
203
|
+
if (!Object.keys(config.hooks).length) delete config.hooks;
|
|
204
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
|
|
205
|
+
return { agent, file, changed: true };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readJson(file) {
|
|
210
|
+
try {
|
|
211
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
212
|
+
} catch (e) {
|
|
213
|
+
if (e.code === "ENOENT") return {};
|
|
214
|
+
throw new Error(`${file} is not valid JSON; fix or remove it first (${e.message})`);
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/jsonl.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stream parsed records from a JSONL file. `keep(line)` is a cheap substring
|
|
6
|
+
* prefilter so we don't JSON.parse the many large records we never look at.
|
|
7
|
+
*/
|
|
8
|
+
export async function* readJsonl(file, keep = () => true, stats = {}) {
|
|
9
|
+
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
10
|
+
stats.lines ??= 0;
|
|
11
|
+
stats.kept ??= 0;
|
|
12
|
+
for await (const line of rl) {
|
|
13
|
+
if (!line) continue;
|
|
14
|
+
stats.lines++;
|
|
15
|
+
if (!keep(line)) continue;
|
|
16
|
+
stats.kept++;
|
|
17
|
+
try {
|
|
18
|
+
yield JSON.parse(line);
|
|
19
|
+
} catch {
|
|
20
|
+
// partial line from a session that is still being written
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/record.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// `agent-polygraph record`: optional evidence the session logs don't carry.
|
|
2
|
+
//
|
|
3
|
+
// - A PostToolUse hook (Claude Code and Codex). After a test command or a command that may
|
|
4
|
+
// write files, it saves a snapshot of the working tree (a git tree id, stored in
|
|
5
|
+
// agent-polygraph's own object store so your repository is never written to) and, for
|
|
6
|
+
// test commands, the outcome the runner printed in full. With snapshots, a change the
|
|
7
|
+
// logs can't follow ("a script that computes its file names") is settled by comparing the
|
|
8
|
+
// tree after the test run with the tree before the claim.
|
|
9
|
+
// - Shell integration (bash, zsh): test commands you run in your own terminals are logged
|
|
10
|
+
// with their folder, start and end time and exit code. No output is kept.
|
|
11
|
+
//
|
|
12
|
+
// Everything stays in ~/.agent-polygraph (or $AGENT_POLYGRAPH_HOME). Nothing is uploaded.
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { isTestCommand, mayWriteFiles, shellWrites, outcomeFromOutput, passedCount } from "./classify.js";
|
|
20
|
+
|
|
21
|
+
export const home = () => process.env.AGENT_POLYGRAPH_HOME || path.join(os.homedir(), ".agent-polygraph");
|
|
22
|
+
const hash = (s) => crypto.createHash("sha1").update(String(s)).digest("hex").slice(0, 16);
|
|
23
|
+
export const recordsFile = (transcript) => path.join(home(), "records", hash(path.resolve(transcript)) + ".jsonl");
|
|
24
|
+
export const terminalFile = () => path.join(home(), "records", "terminal.jsonl");
|
|
25
|
+
|
|
26
|
+
// --- the hook --------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** PostToolUse entry point. Never throws, never prints: a recorder must not get in the way. */
|
|
29
|
+
export function runRecordHook(stdinText) {
|
|
30
|
+
try {
|
|
31
|
+
const p = JSON.parse(stdinText || "{}");
|
|
32
|
+
const cmd = commandOf(p.tool_input);
|
|
33
|
+
if (!cmd || !p.transcript_path) return;
|
|
34
|
+
const test = isTestCommand(cmd);
|
|
35
|
+
if (!test && !mayWriteFiles(cmd) && !shellWrites(cmd).unresolved) return;
|
|
36
|
+
const cwd = p.cwd || process.cwd();
|
|
37
|
+
const rec = { v: 1, ts: Date.now(), id: p.tool_use_id || null, tool: p.tool_name || null, cmd, cwd };
|
|
38
|
+
try {
|
|
39
|
+
rec.tree = snapshot(cwd);
|
|
40
|
+
} catch {}
|
|
41
|
+
if (test) {
|
|
42
|
+
const out = outputOf(p.tool_response);
|
|
43
|
+
const outcome = outcomeFromOutput(out);
|
|
44
|
+
if (outcome) rec.outcome = outcome;
|
|
45
|
+
const passed = passedCount(out);
|
|
46
|
+
if (passed != null) rec.passed = passed;
|
|
47
|
+
}
|
|
48
|
+
if (!rec.tree && !rec.outcome) return;
|
|
49
|
+
const file = recordsFile(p.transcript_path);
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
fs.appendFileSync(file, JSON.stringify(rec) + "\n");
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function commandOf(input) {
|
|
56
|
+
if (!input) return null;
|
|
57
|
+
if (typeof input === "string") return input;
|
|
58
|
+
const c = input.command ?? input.cmd;
|
|
59
|
+
if (typeof c === "string") return c;
|
|
60
|
+
// ["bash", "-lc", "npm test"]
|
|
61
|
+
if (Array.isArray(c)) return c.length >= 3 && /^-\w*c$/.test(c[c.length - 2]) ? String(c[c.length - 1]) : c.join(" ");
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function outputOf(r) {
|
|
66
|
+
if (r == null) return "";
|
|
67
|
+
if (typeof r === "string") return r;
|
|
68
|
+
const parts = [r.stdout, r.stderr, r.output, r.aggregated_output, r.formatted_output, typeof r.content === "string" ? r.content : null];
|
|
69
|
+
return parts.filter((x) => typeof x === "string").join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- working-tree snapshots ---------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The working tree as a git tree id, untracked files included, ignored files left out.
|
|
76
|
+
* Built with a temporary index and a private object store (the repository's own objects
|
|
77
|
+
* are read through git's alternates), so nothing in the repository changes.
|
|
78
|
+
*/
|
|
79
|
+
export function snapshot(cwd) {
|
|
80
|
+
const git = (args, env = {}, dir = cwd) =>
|
|
81
|
+
execFileSync("git", args, { cwd: dir, env: { ...process.env, ...env }, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 8000, maxBuffer: 8 << 20 }).trim();
|
|
82
|
+
const root = git(["rev-parse", "--show-toplevel"]);
|
|
83
|
+
const [index, objects] = git(["rev-parse", "--git-path", "index", "--git-path", "objects"])
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((p) => path.resolve(cwd, p));
|
|
86
|
+
const store = path.join(home(), "objects", hash(objects));
|
|
87
|
+
fs.mkdirSync(store, { recursive: true });
|
|
88
|
+
const tmp = path.join(os.tmpdir(), `agent-polygraph-${process.pid}-${Date.now()}.index`);
|
|
89
|
+
try {
|
|
90
|
+
// starting from the real index keeps git's stat cache: only changed files are hashed
|
|
91
|
+
if (fs.existsSync(index)) fs.copyFileSync(index, tmp);
|
|
92
|
+
const env = { GIT_INDEX_FILE: tmp, GIT_OBJECT_DIRECTORY: store, GIT_ALTERNATE_OBJECT_DIRECTORIES: objects };
|
|
93
|
+
git(["add", "-A", "--ignore-errors", "--", "."], env, root);
|
|
94
|
+
const tree = git(["write-tree"], env, root);
|
|
95
|
+
return { root, tree, objects, store };
|
|
96
|
+
} finally {
|
|
97
|
+
fs.rmSync(tmp, { force: true });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Paths (relative to the checkout) that differ between two snapshots of it. */
|
|
102
|
+
export function treeDiff(a, b) {
|
|
103
|
+
const gitDir = path.dirname(a.objects);
|
|
104
|
+
const out = execFileSync("git", ["diff", "--name-only", "-z", "--no-renames", a.tree, b.tree], {
|
|
105
|
+
env: { ...process.env, GIT_DIR: gitDir, GIT_OBJECT_DIRECTORY: a.store, GIT_ALTERNATE_OBJECT_DIRECTORIES: [a.objects, b.store].join(path.delimiter) },
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
108
|
+
timeout: 10000,
|
|
109
|
+
maxBuffer: 16 << 20,
|
|
110
|
+
});
|
|
111
|
+
return out.split("\0").filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- using the records -----------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const squash = (c) => String(c).replace(/\s+/g, " ").trim();
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Attach recorded snapshots and outcomes to the session's commands, and add test runs from
|
|
120
|
+
* your terminals that happened during the session in its checkout. Mutates `session`.
|
|
121
|
+
*/
|
|
122
|
+
export function applyRecords(session, { terminal = loadTerminal() } = {}) {
|
|
123
|
+
let recs = [];
|
|
124
|
+
try {
|
|
125
|
+
recs = readLines(recordsFile(session.file));
|
|
126
|
+
} catch {}
|
|
127
|
+
if (recs.length) {
|
|
128
|
+
session.treeDiff = treeDiff;
|
|
129
|
+
// a record belongs to the command with the same text, recorded after it started
|
|
130
|
+
const free = recs.map((r) => ({ ...r, key: squash(r.cmd) }));
|
|
131
|
+
for (const ev of session.events) {
|
|
132
|
+
if (ev.kind !== "cmd" || ev.external) continue;
|
|
133
|
+
const key = squash(ev.cmd);
|
|
134
|
+
const k = free.findIndex((r) => r && (ev.id && r.id ? r.id === ev.id : r.key === key && r.ts >= (ev.ts ?? 0) - 2000));
|
|
135
|
+
if (k < 0) continue;
|
|
136
|
+
const r = free[k];
|
|
137
|
+
free[k] = null;
|
|
138
|
+
if (r.tree) ev.tree = r.tree;
|
|
139
|
+
if (r.outcome) ev.recorded = { outcome: r.outcome, passed: r.passed ?? null };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
addTerminalRuns(session, terminal);
|
|
143
|
+
return session;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let terminalCache = null;
|
|
147
|
+
export function loadTerminal() {
|
|
148
|
+
if (terminalCache) return terminalCache;
|
|
149
|
+
try {
|
|
150
|
+
terminalCache = readLines(terminalFile());
|
|
151
|
+
} catch {
|
|
152
|
+
terminalCache = [];
|
|
153
|
+
}
|
|
154
|
+
return terminalCache;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function addTerminalRuns(session, runs) {
|
|
158
|
+
if (!runs.length || !session.cwd || session.start == null || session.end == null) return;
|
|
159
|
+
const cwd = slash(session.cwd);
|
|
160
|
+
const related = (d) => {
|
|
161
|
+
d = slash(d);
|
|
162
|
+
return d === cwd || d.startsWith(cwd + "/") || cwd.startsWith(d + "/");
|
|
163
|
+
};
|
|
164
|
+
let added = 0;
|
|
165
|
+
for (const r of runs) {
|
|
166
|
+
const start = Math.round(r.start * 1000);
|
|
167
|
+
const end = Math.round((r.end ?? r.start) * 1000);
|
|
168
|
+
if (!(start >= session.start && start <= session.end) || !r.cwd || !related(r.cwd) || !isTestCommand(r.cmd)) continue;
|
|
169
|
+
const ev = { kind: "cmd", ts: start, seq: Number.MAX_SAFE_INTEGER, cmd: r.cmd, workdir: r.cwd, external: "terminal", sub: false };
|
|
170
|
+
session.events.push(ev, { kind: "result", of: ev, ts: end, seq: Number.MAX_SAFE_INTEGER, exitCode: typeof r.exit === "number" ? r.exit : null, isError: null, output: "" });
|
|
171
|
+
added++;
|
|
172
|
+
}
|
|
173
|
+
if (added) session.events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0) || (a.seq ?? 0) - (b.seq ?? 0));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const slash = (p) => String(p).replace(/\\/g, "/").replace(/\/$/, "");
|
|
177
|
+
|
|
178
|
+
function readLines(file) {
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
|
181
|
+
if (!line.trim()) continue;
|
|
182
|
+
try {
|
|
183
|
+
out.push(JSON.parse(line));
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- shell integration -----------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
const MARK = "# agent-polygraph record";
|
|
192
|
+
export const shellScript = () => path.join(home(), "shell.sh");
|
|
193
|
+
|
|
194
|
+
const SHELL_SNIPPET = () => fs.readFileSync(new URL("./shell/record.sh", import.meta.url), "utf8");
|
|
195
|
+
|
|
196
|
+
const RC_FILES = () => [".bashrc", ".zshrc"].map((f) => path.join(os.homedir(), f));
|
|
197
|
+
|
|
198
|
+
export function installShell() {
|
|
199
|
+
const script = shellScript();
|
|
200
|
+
fs.mkdirSync(path.join(home(), "records"), { recursive: true });
|
|
201
|
+
fs.writeFileSync(script, SHELL_SNIPPET());
|
|
202
|
+
const line = `[ -f "${script}" ] && . "${script}" ${MARK}`;
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const rc of RC_FILES()) {
|
|
205
|
+
const shell = rc.endsWith("zshrc") ? "zsh" : "bash";
|
|
206
|
+
if (!fs.existsSync(rc) && !onPath(shell)) continue;
|
|
207
|
+
const text = fs.existsSync(rc) ? fs.readFileSync(rc, "utf8") : "";
|
|
208
|
+
if (text.includes(MARK)) {
|
|
209
|
+
out.push({ file: rc, changed: false });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
fs.appendFileSync(rc, (text && !text.endsWith("\n") ? "\n" : "") + line + "\n");
|
|
213
|
+
out.push({ file: rc, changed: true });
|
|
214
|
+
}
|
|
215
|
+
return { script, rcs: out };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function uninstallShell() {
|
|
219
|
+
const out = [];
|
|
220
|
+
for (const rc of RC_FILES()) {
|
|
221
|
+
if (!fs.existsSync(rc)) continue;
|
|
222
|
+
const text = fs.readFileSync(rc, "utf8");
|
|
223
|
+
if (!text.includes(MARK)) continue;
|
|
224
|
+
fs.writeFileSync(rc, text.split("\n").filter((l) => !l.includes(MARK)).join("\n"));
|
|
225
|
+
out.push({ file: rc, changed: true });
|
|
226
|
+
}
|
|
227
|
+
fs.rmSync(shellScript(), { force: true });
|
|
228
|
+
return { rcs: out };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function onPath(cmd) {
|
|
232
|
+
try {
|
|
233
|
+
execFileSync("which", [cmd], { stdio: "ignore" });
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const AGENT_NAMES = { claude: "Claude Code", codex: "Codex" };
|
|
2
|
+
|
|
3
|
+
export function duration(ms) {
|
|
4
|
+
if (!ms) return "0m";
|
|
5
|
+
const m = ms / 60000;
|
|
6
|
+
if (m < 1) return `${Math.round(ms / 1000)}s`;
|
|
7
|
+
if (m < 60) return `${Math.round(m)}m`;
|
|
8
|
+
const h = m / 60;
|
|
9
|
+
return h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function pct(x) {
|
|
13
|
+
return x == null ? "–" : `${Math.round(x * 100)}%`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function day(ts, opts = { month: "short", day: "numeric" }) {
|
|
17
|
+
return ts ? new Date(ts).toLocaleDateString("en-US", opts) : "?";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function range(g) {
|
|
21
|
+
if (!g.start || !isFinite(g.start)) return "";
|
|
22
|
+
const y = { month: "short", day: "numeric", year: "numeric" };
|
|
23
|
+
return `${day(g.start)} – ${day(g.end, y)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Columns to show: one per agent (or model) that has sessions, plus "All" when there are several. */
|
|
27
|
+
export function columns(groups, by = "agent") {
|
|
28
|
+
const cols = Object.entries(groups)
|
|
29
|
+
.filter(([k, g]) => k.startsWith(by + ":") && g.activeSessions > 0)
|
|
30
|
+
.sort((a, b) => b[1].activeSessions - a[1].activeSessions)
|
|
31
|
+
.map(([k, g]) => ({ key: k, label: by === "agent" ? AGENT_NAMES[k.slice(6)] || k.slice(6) : k.slice(6), g }));
|
|
32
|
+
if (cols.length !== 1) cols.push({ key: "all", label: "All", g: groups.all });
|
|
33
|
+
return cols;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const ROWS = {
|
|
37
|
+
claims: [
|
|
38
|
+
["Backed by a fresh passing run", (g) => g.claims.backed],
|
|
39
|
+
["No test run in the session", (g) => g.claims["no-run"]],
|
|
40
|
+
["Code edited after the last run", (g) => g.claims.stale],
|
|
41
|
+
["Last run had failed", (g) => g.claims.failed],
|
|
42
|
+
["Couldn't tell", (g) => g.claims.unknown],
|
|
43
|
+
],
|
|
44
|
+
unclear: [
|
|
45
|
+
["…test result unreadable", (g) => g.unclear["test result unreadable"] || 0],
|
|
46
|
+
["…unresolved file change after the run", (g) => g.unclear["unresolved file change after the run"] || 0],
|
|
47
|
+
["…test still running", (g) => g.unclear["test still running"] || 0],
|
|
48
|
+
["…other", (g) => Object.entries(g.unclear).filter(([k]) => !/unreadable|unresolved|still running/.test(k)).reduce((a, [, v]) => a + v, 0)],
|
|
49
|
+
],
|
|
50
|
+
integrity: [
|
|
51
|
+
['Failing test "fixed" by editing only tests', (g) => g.findingCounts["test-only-fix"] || 0],
|
|
52
|
+
["Edits that weakened an existing test", (g) => g.findingCounts["weakened-test"] || 0],
|
|
53
|
+
],
|
|
54
|
+
time: [
|
|
55
|
+
["Test runs", (g) => g.testRuns],
|
|
56
|
+
["…that failed", (g) => g.failingRuns],
|
|
57
|
+
["Time spent in tests", (g) => duration(g.testMs)],
|
|
58
|
+
["Full-suite runs", (g) => g.fullSuiteRuns],
|
|
59
|
+
["Time in full-suite runs", (g) => duration(g.fullSuiteMs)],
|
|
60
|
+
["Same failing command retried 3+ times", (g) => g.findingCounts["retry-loop"] || 0],
|
|
61
|
+
],
|
|
62
|
+
you: [
|
|
63
|
+
["Your messages", (g) => g.userMessages],
|
|
64
|
+
['Pushback ("no", "I told you", "revert"…)', (g) => g.corrections],
|
|
65
|
+
["Times you interrupted the agent", (g) => g.interrupts],
|
|
66
|
+
["Risky git commands", (g) => Object.values(g.risky).reduce((a, b) => a + b, 0)],
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Newest agent versions this release was checked against. Logs from newer versions are
|
|
71
|
+
// still read, but formats change: the report says so instead of failing quietly.
|
|
72
|
+
export const TESTED = { claude: "2.1.314", codex: "0.166.0" };
|
|
73
|
+
|
|
74
|
+
const parts = (v) => String(v).split(/[^\d]+/).filter(Boolean).map(Number);
|
|
75
|
+
export function compareVersions(a, b) {
|
|
76
|
+
const x = parts(a), y = parts(b);
|
|
77
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** "2.1.220–2.1.314" for the versions seen, and whether any is newer than tested. */
|
|
82
|
+
export function versionRange(agent, versions) {
|
|
83
|
+
if (!versions?.length) return null;
|
|
84
|
+
const sorted = [...versions].sort(compareVersions);
|
|
85
|
+
const lo = sorted[0], hi = sorted[sorted.length - 1];
|
|
86
|
+
return { text: lo === hi ? lo : `${lo}–${hi}`, newer: compareVersions(hi, TESTED[agent] || "0") > 0 };
|
|
87
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A self-contained, numbers-only report meant to be screenshotted or shared.
|
|
2
|
+
// Never put prompts, file paths, commands or claim text in here.
|
|
3
|
+
|
|
4
|
+
import { ROWS, columns, pct, range } from "./format.js";
|
|
5
|
+
|
|
6
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
7
|
+
|
|
8
|
+
export function renderHtml({ groups, by = "agent", version, demo = false, skipped = 0, unrecognized = 0 }) {
|
|
9
|
+
const cols = columns(groups, by);
|
|
10
|
+
const all = groups.all;
|
|
11
|
+
const head = cols.map((c) => `<th>${esc(c.label)}</th>`).join("");
|
|
12
|
+
// "…" rows break down the row above them; like the terminal, hide them when all zero
|
|
13
|
+
const table = (rows) =>
|
|
14
|
+
rows
|
|
15
|
+
.filter(([label, fn]) => !label.startsWith("…") || cols.some((c) => Number(fn(c.g)) > 0))
|
|
16
|
+
.map(([label, fn]) => {
|
|
17
|
+
const cls = label.startsWith("…") ? ' class="sub"' : "";
|
|
18
|
+
return `<tr${cls}><td>${esc(label)}</td>${cols.map((c) => `<td>${esc(fn(c.g))}</td>`).join("")}</tr>`;
|
|
19
|
+
})
|
|
20
|
+
.join("");
|
|
21
|
+
const rate = `<tr class="total"><td>Receipt rate</td>${cols.map((c) => `<td>${esc(pct(c.g.receiptRate))}</td>`).join("")}</tr>`;
|
|
22
|
+
|
|
23
|
+
const tiles = cols
|
|
24
|
+
.filter((c) => c.key !== "all" || cols.length === 1)
|
|
25
|
+
.map((c) => {
|
|
26
|
+
const r = c.g.receiptRate;
|
|
27
|
+
const judged = c.g.claimsTotal - c.g.claims.unknown;
|
|
28
|
+
const unclear = c.g.claims.unknown;
|
|
29
|
+
const tone = r == null ? "" : r >= 0.95 ? "good" : r >= 0.8 ? "warn" : "bad";
|
|
30
|
+
return `<div class="tile ${tone}">
|
|
31
|
+
<div class="tile-label">${esc(c.label)}</div>
|
|
32
|
+
<div class="tile-value">${esc(pct(r))}</div>
|
|
33
|
+
<div class="tile-sub">${c.g.claims.backed} of ${judged} “tests pass” claims had a fresh passing run${unclear ? `; ${unclear} more couldn't be judged` : ""}</div>
|
|
34
|
+
<div class="bar"><span style="width:${r == null ? 0 : Math.round(r * 100)}%"></span></div>
|
|
35
|
+
</div>`;
|
|
36
|
+
})
|
|
37
|
+
.join("\n");
|
|
38
|
+
|
|
39
|
+
return `<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
44
|
+
<title>Agent Polygraph Report</title>
|
|
45
|
+
<style>
|
|
46
|
+
:root{--bg:#f6f5f1;--card:#fff;--ink:#16181d;--muted:#6b6f78;--line:#e4e2dc;--good:#1f8a4c;--warn:#b7791f;--bad:#c0392b;--accent:#3949ab}
|
|
47
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#111317;--card:#1a1d23;--ink:#eceef2;--muted:#9aa0ab;--line:#2a2e36;--good:#4cc38a;--warn:#e0a84a;--bad:#ef6b5e;--accent:#8c9eff}}
|
|
48
|
+
:root[data-theme=dark]{--bg:#111317;--card:#1a1d23;--ink:#eceef2;--muted:#9aa0ab;--line:#2a2e36;--good:#4cc38a;--warn:#e0a84a;--bad:#ef6b5e;--accent:#8c9eff}
|
|
49
|
+
*{box-sizing:border-box}
|
|
50
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
51
|
+
main{max-width:860px;margin:0 auto;padding:40px 16px 56px}
|
|
52
|
+
h1{font-size:28px;letter-spacing:-.02em;margin:0}
|
|
53
|
+
.meta{color:var(--muted);margin:4px 0 28px}
|
|
54
|
+
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin-bottom:28px}
|
|
55
|
+
.tile{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px 18px 16px}
|
|
56
|
+
.tile-label{color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.06em}
|
|
57
|
+
.tile-value{font-size:52px;font-weight:700;letter-spacing:-.03em;line-height:1.1;margin:6px 0 2px;font-variant-numeric:tabular-nums}
|
|
58
|
+
.tile.good .tile-value{color:var(--good)}.tile.warn .tile-value{color:var(--warn)}.tile.bad .tile-value{color:var(--bad)}
|
|
59
|
+
.tile-sub{color:var(--muted);font-size:13px}
|
|
60
|
+
.bar{height:6px;background:var(--line);border-radius:3px;margin-top:12px;overflow:hidden}
|
|
61
|
+
.bar span{display:block;height:100%;background:currentColor}
|
|
62
|
+
.tile.good .bar{color:var(--good)}.tile.warn .bar{color:var(--warn)}.tile.bad .bar{color:var(--bad)}
|
|
63
|
+
section{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:6px 18px 10px;margin-bottom:14px;overflow-x:auto}
|
|
64
|
+
h2{font-size:15px;margin:14px 0 4px}
|
|
65
|
+
h2 small{color:var(--muted);font-weight:400}
|
|
66
|
+
table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
|
|
67
|
+
th,td{padding:7px 0;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}
|
|
68
|
+
th{color:var(--muted);font-weight:500;font-size:13px}
|
|
69
|
+
th:first-child,td:first-child{text-align:left;white-space:normal;padding-right:12px}
|
|
70
|
+
td+td,th+th{padding-left:16px}
|
|
71
|
+
tr:last-child td{border-bottom:0}
|
|
72
|
+
tr.sub td{color:var(--muted);font-size:13px;padding-top:4px;padding-bottom:4px}
|
|
73
|
+
tr.sub td:first-child{padding-left:14px}
|
|
74
|
+
tr.total td{font-weight:600}
|
|
75
|
+
.note{color:var(--warn);font-size:13px;margin:-16px 0 24px}
|
|
76
|
+
.demo{display:inline-block;margin:10px 0 0;padding:4px 10px;border-radius:999px;background:var(--warn);color:var(--card);font-size:13px;font-weight:600}
|
|
77
|
+
footer{color:var(--muted);font-size:13px;margin-top:22px}
|
|
78
|
+
footer a{color:var(--accent)}
|
|
79
|
+
</style>
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<main>
|
|
83
|
+
<h1>Did my coding agents show their work?</h1>${demo ? '\n<p class="demo">Demo data: generated sessions with invented numbers, not a benchmark of any agent.</p>' : ""}
|
|
84
|
+
<p class="meta">${all.activeSessions} working sessions · ${esc(range(all))} · ${all.testRuns.toLocaleString("en-US")} test runs checked</p>${skipped ? `\n<p class="note">${skipped} session file${skipped === 1 ? "" : "s"} couldn't be read and ${skipped === 1 ? "is" : "are"} not included.</p>` : ""}${unrecognized ? `\n<p class="note">${unrecognized} session file${unrecognized === 1 ? " is" : "s are"} in a log format this version doesn't recognize and not included.</p>` : ""}
|
|
85
|
+
<div class="tiles">
|
|
86
|
+
${tiles}
|
|
87
|
+
</div>
|
|
88
|
+
<section><h2>“Tests pass” claims <small>was there a passing run after the last code edit?</small></h2>
|
|
89
|
+
<table><tr><th></th>${head}</tr>${table(ROWS.claims)}${table(ROWS.unclear)}${rate}</table></section>
|
|
90
|
+
<section><h2>Test integrity <small>worth a second look, not proof of cheating</small></h2>
|
|
91
|
+
<table><tr><th></th>${head}</tr>${table(ROWS.integrity)}</table></section>
|
|
92
|
+
<section><h2>Where the time went</h2>
|
|
93
|
+
<table><tr><th></th>${head}</tr>${table(ROWS.time)}</table></section>
|
|
94
|
+
<section><h2>You and the agent</h2>
|
|
95
|
+
<table><tr><th></th>${head}</tr>${table(ROWS.you)}</table></section>
|
|
96
|
+
<footer>Generated locally by <a href="https://github.com/EleChon-pixel/agent-polygraph">agent-polygraph</a>${version ? " " + esc(version) : ""} from Claude Code and Codex session logs. Numbers only: no prompts, code or file paths are included. Run your own: <code>npx agent-polygraph</code></footer>
|
|
97
|
+
</main>
|
|
98
|
+
</body>
|
|
99
|
+
</html>
|
|
100
|
+
`;
|
|
101
|
+
}
|