rulereceipt 0.1.52 → 0.1.54
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/README.md +10 -7
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +263 -0
- package/dist/adapters/index.d.ts +82 -0
- package/dist/adapters/index.js +133 -0
- package/dist/checks/attribution.js +29 -8
- package/dist/checks/claimEvidence.js +52 -3
- package/dist/checks/codeContent.js +5 -1
- package/dist/checks/deterministicChecks.js +56 -4
- package/dist/checks/emojiOutput.js +29 -10
- package/dist/checks/fileLifecycle.js +29 -15
- package/dist/checks/gitBranchPolicy.js +86 -14
- package/dist/checks/ifEditThenTest.js +7 -1
- package/dist/checks/proposedAction.js +20 -32
- package/dist/checks/shellCommand.d.ts +22 -0
- package/dist/checks/shellCommand.js +67 -4
- package/dist/checks/testCommands.js +12 -3
- package/dist/cli.js +31 -5
- package/dist/parsers/readClaudeMd.d.ts +0 -17
- package/dist/parsers/readClaudeMd.js +20 -1
- package/dist/parsers/readMemory.d.ts +2 -0
- package/dist/parsers/readMemory.js +99 -0
- package/dist/parsers/transcriptParser.d.ts +2 -0
- package/dist/parsers/transcriptParser.js +7 -4
- package/dist/report/complianceReport.d.ts +35 -0
- package/dist/report/complianceReport.js +79 -0
- package/dist/rules.js +35 -5
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/rulereceipt)
|
|
7
7
|
[](https://www.npmjs.com/package/rulereceipt#provenance)
|
|
8
8
|
|
|
9
|
-
Checks whether
|
|
10
|
-
|
|
9
|
+
Checks whether your AI coding agent actually followed your rules — with
|
|
10
|
+
evidence, not just a vibe. Works with Claude Code today (OpenAI Codex CLI
|
|
11
|
+
support is built and in testing), and reads rules from CLAUDE.md, AGENTS.md,
|
|
12
|
+
Cursor (`.cursor/rules`), GitHub Copilot, Windsurf, and Claude Code memory.
|
|
11
13
|
|
|
12
14
|
Runs entirely on your machine. Plain `rulereceipt check` makes zero network
|
|
13
15
|
calls — [Trust, privacy and licensing](#trust-privacy-and-licensing) has the
|
|
@@ -28,11 +30,12 @@ Published and live on npm, actively developed.
|
|
|
28
30
|
|
|
29
31
|
## How it works
|
|
30
32
|
|
|
31
|
-
1. Reads your CLAUDE.md / AGENTS.md
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
different directory
|
|
33
|
+
1. Reads your rules and extracts individual ones — from CLAUDE.md / AGENTS.md,
|
|
34
|
+
Cursor / Copilot / Windsurf rule files, and Claude Code memory, across the
|
|
35
|
+
current project directory and your global rules file.
|
|
36
|
+
2. Reads your most recent agent session transcript — Claude Code today
|
|
37
|
+
(including hosted/enterprise variants under a different directory), and
|
|
38
|
+
OpenAI Codex CLI (in testing); newest session across tools wins.
|
|
36
39
|
3. Routes each rule to the narrowest check that can actually answer it:
|
|
37
40
|
- **Structured checks** read what the session really did — an actual
|
|
38
41
|
git command's branch argument, actual file edits, actual file
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TranscriptEvent } from "../types.js";
|
|
2
|
+
export declare function parseCodexLine(line: string): TranscriptEvent[];
|
|
3
|
+
export declare function parseCodexTranscript(filePath: string): TranscriptEvent[];
|
|
4
|
+
/** Every Codex session recorded for this cwd, newest first. */
|
|
5
|
+
export declare function listCodexSessions(cwd: string): string[];
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* OpenAI Codex CLI session adapter.
|
|
6
|
+
*
|
|
7
|
+
* Format VERIFIED 2026-09-26 against the openai/codex repo, a real v0.130.0
|
|
8
|
+
* rollout dump (dev.to/milkoor reverse-engineering write-up), and a
|
|
9
|
+
* third-party Go parser struct — NOT assumed. But OpenAI publishes no schema
|
|
10
|
+
* and the on-disk shape has changed across versions (open request openai/codex
|
|
11
|
+
* #2288 for a stable trajectory format), so parsing here is deliberately
|
|
12
|
+
* TOLERANT: an unknown line type or field is ignored, never a crash and never
|
|
13
|
+
* a fabricated event. Same fail-closed discipline as the Claude parser.
|
|
14
|
+
*
|
|
15
|
+
* Path (GLOBAL, date-partitioned — not per-project):
|
|
16
|
+
* ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
|
|
17
|
+
* Line 1 is a `session_meta` record whose payload.cwd names the project the
|
|
18
|
+
* session ran in — that is how a global store is filtered to one project.
|
|
19
|
+
* Every other line is `{timestamp, type, payload}`; the transcript lives in
|
|
20
|
+
* `type:"response_item"` lines, discriminated by `payload.type`:
|
|
21
|
+
* - message -> text (role user/developer/system/assistant)
|
|
22
|
+
* - function_call / -> tool_use (name, arguments JSON string, call_id)
|
|
23
|
+
* local_shell_call /
|
|
24
|
+
* custom_tool_call
|
|
25
|
+
* - function_call_output-> tool_result (call_id, output)
|
|
26
|
+
* `reasoning`, `event_msg`, `turn_context`, `compacted` etc. are ignored.
|
|
27
|
+
*
|
|
28
|
+
* NOT YET tested against a real session file on THIS machine — until it is,
|
|
29
|
+
* this is not advertised as supported on the site/README (see adapters/index).
|
|
30
|
+
*/
|
|
31
|
+
// Codex function-call names that mean "run a shell command" — normalised to
|
|
32
|
+
// the engine's canonical `Bash` tool so the command-scanning checks fire.
|
|
33
|
+
// `local_shell_call` is handled by its payload type, not this list.
|
|
34
|
+
const SHELL_TOOL_NAMES = new Set([
|
|
35
|
+
"exec_command", "shell", "bash", "sh", "exec", "run_command", "shell_command", "container.exec",
|
|
36
|
+
]);
|
|
37
|
+
/** Turn an argv array into the command string, unwrapping `sh -c "<script>"`. */
|
|
38
|
+
function argvToString(argv) {
|
|
39
|
+
const parts = argv.map((a) => String(a));
|
|
40
|
+
if (parts.length >= 3 && /^(?:\/(?:usr\/)?bin\/)?(?:ba|z)?sh$/.test(parts[0]) && /^-[a-z]*c$/.test(parts[1])) {
|
|
41
|
+
return parts[2]; // the -c/-lc script is the real command
|
|
42
|
+
}
|
|
43
|
+
return parts.join(" ");
|
|
44
|
+
}
|
|
45
|
+
/** The shell command string from a tool input, however Codex shaped it. */
|
|
46
|
+
function shellCommandString(input) {
|
|
47
|
+
if (typeof input === "string")
|
|
48
|
+
return input;
|
|
49
|
+
if (Array.isArray(input))
|
|
50
|
+
return argvToString(input);
|
|
51
|
+
if (input && typeof input === "object") {
|
|
52
|
+
const o = input;
|
|
53
|
+
const action = o.action;
|
|
54
|
+
const cmd = o.command ?? o.cmd ?? action?.command;
|
|
55
|
+
if (typeof cmd === "string")
|
|
56
|
+
return cmd;
|
|
57
|
+
if (Array.isArray(cmd))
|
|
58
|
+
return argvToString(cmd);
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function textFromContent(content) {
|
|
63
|
+
if (typeof content === "string")
|
|
64
|
+
return content;
|
|
65
|
+
if (Array.isArray(content)) {
|
|
66
|
+
// Take `.text` from any part regardless of its inner type
|
|
67
|
+
// (input_text / output_text / text) — the inner-type names are the one
|
|
68
|
+
// thing not confirmed against a real user line, so we do not rely on them.
|
|
69
|
+
return content
|
|
70
|
+
.map((p) => (p && typeof p === "object" && typeof p.text === "string" ? p.text : ""))
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join("\n");
|
|
73
|
+
}
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
function outputText(output) {
|
|
77
|
+
if (typeof output === "string")
|
|
78
|
+
return output;
|
|
79
|
+
if (output && typeof output === "object") {
|
|
80
|
+
const o = output;
|
|
81
|
+
if (typeof o.output === "string")
|
|
82
|
+
return o.output;
|
|
83
|
+
if (typeof o.text === "string")
|
|
84
|
+
return o.text;
|
|
85
|
+
if (typeof o.content === "string")
|
|
86
|
+
return o.content;
|
|
87
|
+
try {
|
|
88
|
+
return JSON.stringify(output);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
/** Best-effort error detection from a tool result, without fabricating one. */
|
|
97
|
+
function outputIsError(output) {
|
|
98
|
+
if (output && typeof output === "object") {
|
|
99
|
+
const o = output;
|
|
100
|
+
const meta = o.metadata;
|
|
101
|
+
const exit = (o.exit_code ?? meta?.exit_code);
|
|
102
|
+
if (typeof exit === "number")
|
|
103
|
+
return exit !== 0;
|
|
104
|
+
if (o.success === false)
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
export function parseCodexLine(line) {
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(line);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
118
|
+
return [];
|
|
119
|
+
const obj = parsed;
|
|
120
|
+
if (obj.type !== "response_item")
|
|
121
|
+
return [];
|
|
122
|
+
const payload = obj.payload;
|
|
123
|
+
if (typeof payload !== "object" || payload === null)
|
|
124
|
+
return [];
|
|
125
|
+
const p = payload;
|
|
126
|
+
const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : "";
|
|
127
|
+
if (p.type === "message") {
|
|
128
|
+
const text = textFromContent(p.content);
|
|
129
|
+
if (!text)
|
|
130
|
+
return [];
|
|
131
|
+
const role = p.role === "assistant" ? "assistant" : "user";
|
|
132
|
+
return [{ role, kind: "text", text, timestamp }];
|
|
133
|
+
}
|
|
134
|
+
if (p.type === "function_call" || p.type === "local_shell_call" || p.type === "custom_tool_call") {
|
|
135
|
+
const rawName = typeof p.name === "string" ? p.name : "";
|
|
136
|
+
// `arguments` is a JSON string in Codex; parse when possible so the checks
|
|
137
|
+
// see structured input, else keep the raw value.
|
|
138
|
+
let input = p.arguments ?? p.input ?? p.action ?? {};
|
|
139
|
+
if (typeof input === "string") {
|
|
140
|
+
try {
|
|
141
|
+
input = JSON.parse(input);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* leave as the raw string */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// NORMALISE shell execution to the engine's canonical shape. Every
|
|
148
|
+
// command-scanning check (git branch, file lifecycle, attribution,
|
|
149
|
+
// approval gate, proposed action, deterministic literals) keys on
|
|
150
|
+
// toolName === "Bash" with input.command as a STRING — Claude's shape.
|
|
151
|
+
// Codex runs shells under `local_shell_call` and `exec_command`-style
|
|
152
|
+
// function calls, so without this none of those checks would fire on a
|
|
153
|
+
// Codex session (found 2026-09-26 when a `git push origin main` in a Codex
|
|
154
|
+
// rollout produced zero violations). Non-shell tools (a real custom/MCP
|
|
155
|
+
// tool) keep their own name and input untouched.
|
|
156
|
+
const isShell = p.type === "local_shell_call" || SHELL_TOOL_NAMES.has(rawName.toLowerCase());
|
|
157
|
+
if (isShell) {
|
|
158
|
+
const command = shellCommandString(input);
|
|
159
|
+
return [
|
|
160
|
+
{
|
|
161
|
+
role: "assistant",
|
|
162
|
+
kind: "tool_use",
|
|
163
|
+
toolName: "Bash",
|
|
164
|
+
input: command !== null ? { command } : input,
|
|
165
|
+
timestamp,
|
|
166
|
+
toolUseId: typeof p.call_id === "string" ? p.call_id : undefined,
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
return [
|
|
171
|
+
{
|
|
172
|
+
role: "assistant",
|
|
173
|
+
kind: "tool_use",
|
|
174
|
+
toolName: rawName || "tool",
|
|
175
|
+
input,
|
|
176
|
+
timestamp,
|
|
177
|
+
toolUseId: typeof p.call_id === "string" ? p.call_id : undefined,
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
if (p.type === "function_call_output" || p.type === "custom_tool_call_output" || p.type === "local_shell_call_output") {
|
|
182
|
+
return [
|
|
183
|
+
{
|
|
184
|
+
role: "user",
|
|
185
|
+
kind: "tool_result",
|
|
186
|
+
content: outputText(p.output),
|
|
187
|
+
isError: outputIsError(p.output),
|
|
188
|
+
timestamp,
|
|
189
|
+
toolUseId: typeof p.call_id === "string" ? p.call_id : undefined,
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
return []; // reasoning, unknown payload types: ignored, not guessed at
|
|
194
|
+
}
|
|
195
|
+
export function parseCodexTranscript(filePath) {
|
|
196
|
+
let raw;
|
|
197
|
+
try {
|
|
198
|
+
raw = readFileSync(filePath, "utf-8");
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
const events = [];
|
|
204
|
+
for (const line of raw.split("\n")) {
|
|
205
|
+
if (!line.trim())
|
|
206
|
+
continue;
|
|
207
|
+
events.push(...parseCodexLine(line));
|
|
208
|
+
}
|
|
209
|
+
return events;
|
|
210
|
+
}
|
|
211
|
+
/** The cwd a rollout file was recorded in, from its first `session_meta` line. */
|
|
212
|
+
function sessionCwd(filePath) {
|
|
213
|
+
let raw;
|
|
214
|
+
try {
|
|
215
|
+
raw = readFileSync(filePath, "utf-8");
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const firstLine = raw.split("\n", 1)[0];
|
|
221
|
+
if (!firstLine)
|
|
222
|
+
return null;
|
|
223
|
+
try {
|
|
224
|
+
const obj = JSON.parse(firstLine);
|
|
225
|
+
if (obj.type !== "session_meta")
|
|
226
|
+
return null;
|
|
227
|
+
const payload = obj.payload;
|
|
228
|
+
const cwd = payload?.cwd;
|
|
229
|
+
return typeof cwd === "string" ? cwd : null;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Resolved at call time, not module load, so the home dir is read live.
|
|
236
|
+
function codexSessionsRoot() {
|
|
237
|
+
return join(homedir(), ".codex", "sessions");
|
|
238
|
+
}
|
|
239
|
+
/** Recursively collect rollout-*.jsonl files under the date-partitioned tree. */
|
|
240
|
+
function collectRolloutFiles(dir, out) {
|
|
241
|
+
let entries;
|
|
242
|
+
try {
|
|
243
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
const full = join(dir, entry.name);
|
|
250
|
+
if (entry.isDirectory())
|
|
251
|
+
collectRolloutFiles(full, out);
|
|
252
|
+
else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl"))
|
|
253
|
+
out.push(full);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Every Codex session recorded for this cwd, newest first. */
|
|
257
|
+
export function listCodexSessions(cwd) {
|
|
258
|
+
const all = [];
|
|
259
|
+
collectRolloutFiles(codexSessionsRoot(), all);
|
|
260
|
+
return all
|
|
261
|
+
.filter((f) => sessionCwd(f) === cwd)
|
|
262
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
263
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { TranscriptEvent } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A session adapter turns one coding agent's on-disk session log into the
|
|
4
|
+
* neutral TranscriptEvent[] the rule engine (classify.ts / the checks) already
|
|
5
|
+
* runs on. The engine is agent-agnostic — it only ever sees text / tool_use /
|
|
6
|
+
* tool_result — so generalising the tool to new agents is entirely a matter of
|
|
7
|
+
* adding adapters here; nothing downstream changes.
|
|
8
|
+
*
|
|
9
|
+
* Only adapters that have been VERIFIED against a real format are `supported`.
|
|
10
|
+
* The rest are listed as honest stubs (see UNSUPPORTED_TOOLS) so the tool can
|
|
11
|
+
* say what it does and does NOT read, rather than silently missing sessions or
|
|
12
|
+
* pretending to support a format it has not parsed.
|
|
13
|
+
*/
|
|
14
|
+
export interface SessionAdapter {
|
|
15
|
+
/** Stable tool id, e.g. "claude-code", "codex". */
|
|
16
|
+
tool: string;
|
|
17
|
+
/** Every session file for this cwd, newest first (empty when the tool is absent). */
|
|
18
|
+
listSessions(cwd: string): string[];
|
|
19
|
+
/** Parse one session file into neutral events. */
|
|
20
|
+
parse(sessionFile: string): TranscriptEvent[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Claude Code — the original and reference adapter. Delegates to the existing
|
|
24
|
+
* transcriptParser so its behaviour (including subagent transcripts) is
|
|
25
|
+
* unchanged: for a Claude-only machine the registry picks exactly the file and
|
|
26
|
+
* events it always did.
|
|
27
|
+
*/
|
|
28
|
+
export declare const claudeCodeAdapter: SessionAdapter;
|
|
29
|
+
/** OpenAI Codex CLI — format verified, parsing tolerant. See adapters/codex.ts. */
|
|
30
|
+
export declare const codexAdapter: SessionAdapter;
|
|
31
|
+
/** Every adapter with a verified, tested-buildable parser. */
|
|
32
|
+
export declare const ADAPTERS: SessionAdapter[];
|
|
33
|
+
/**
|
|
34
|
+
* Tools deliberately NOT read yet, with the honest reason. Kept as data (not
|
|
35
|
+
* silence) so the tool — and its docs — can state exactly where the line is
|
|
36
|
+
* and why, and so adding one later is a visible change here.
|
|
37
|
+
*/
|
|
38
|
+
export declare const UNSUPPORTED_TOOLS: {
|
|
39
|
+
tool: string;
|
|
40
|
+
reason: string;
|
|
41
|
+
}[];
|
|
42
|
+
export interface LatestSession {
|
|
43
|
+
adapter: SessionAdapter;
|
|
44
|
+
file: string;
|
|
45
|
+
mtimeMs: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The single most recently modified session across ALL supported tools for
|
|
49
|
+
* this cwd — the same "newest wins" rule the Claude reader already uses across
|
|
50
|
+
* `.claude` vs `.claude-office`, now extended across tools. Returns null only
|
|
51
|
+
* when no supported tool has a session for this project (the caller then asks
|
|
52
|
+
* or reports "no session found").
|
|
53
|
+
*/
|
|
54
|
+
export declare function findLatestSession(cwd: string): LatestSession | null;
|
|
55
|
+
/** Events from the latest session across all tools (empty when none exists). */
|
|
56
|
+
export declare function readLatestSessionEvents(cwd: string): TranscriptEvent[];
|
|
57
|
+
/**
|
|
58
|
+
* EVERY session across all supported tools for this cwd, newest first, each
|
|
59
|
+
* paired with the adapter that can parse it. Used by the multi-session
|
|
60
|
+
* compliance report so it audits Codex sessions alongside Claude ones, not
|
|
61
|
+
* just Claude Code's.
|
|
62
|
+
*/
|
|
63
|
+
export declare function listAllSessions(cwd: string): {
|
|
64
|
+
adapter: SessionAdapter;
|
|
65
|
+
file: string;
|
|
66
|
+
}[];
|
|
67
|
+
/**
|
|
68
|
+
* Parse a single session file whose tool is not known ahead of time (a
|
|
69
|
+
* `--transcript <file>` the user pointed at directly). A Codex rollout opens
|
|
70
|
+
* with a `session_meta` or `response_item` line; anything else is read as a
|
|
71
|
+
* Claude transcript. Sniffing the first line beats guessing from the path,
|
|
72
|
+
* and it fails closed — an unreadable or unrecognised file yields no events,
|
|
73
|
+
* never a wrong parse presented as right.
|
|
74
|
+
*/
|
|
75
|
+
export declare function parseSessionFile(file: string): TranscriptEvent[];
|
|
76
|
+
/**
|
|
77
|
+
* A one-line note naming the tool a session came from, when it is NOT the
|
|
78
|
+
* default Claude Code — so a user running `check` in a Codex project sees that
|
|
79
|
+
* the report is about their Codex session, not silently. Null for Claude Code
|
|
80
|
+
* (the default) and when there is no session.
|
|
81
|
+
*/
|
|
82
|
+
export declare function sessionSourceNote(cwd: string): string | null;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { statSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { listAllSessionFiles, readTranscriptFromFile, findSubagentFiles } from "../parsers/transcriptParser.js";
|
|
4
|
+
import { listCodexSessions, parseCodexTranscript } from "./codex.js";
|
|
5
|
+
/**
|
|
6
|
+
* Claude Code — the original and reference adapter. Delegates to the existing
|
|
7
|
+
* transcriptParser so its behaviour (including subagent transcripts) is
|
|
8
|
+
* unchanged: for a Claude-only machine the registry picks exactly the file and
|
|
9
|
+
* events it always did.
|
|
10
|
+
*/
|
|
11
|
+
export const claudeCodeAdapter = {
|
|
12
|
+
tool: "claude-code",
|
|
13
|
+
listSessions: (cwd) => listAllSessionFiles(cwd),
|
|
14
|
+
parse: (sessionFile) => {
|
|
15
|
+
const events = readTranscriptFromFile(sessionFile);
|
|
16
|
+
for (const sub of findSubagentFiles(sessionFile))
|
|
17
|
+
events.push(...readTranscriptFromFile(sub));
|
|
18
|
+
return events;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
/** OpenAI Codex CLI — format verified, parsing tolerant. See adapters/codex.ts. */
|
|
22
|
+
export const codexAdapter = {
|
|
23
|
+
tool: "codex",
|
|
24
|
+
listSessions: (cwd) => listCodexSessions(cwd),
|
|
25
|
+
parse: (sessionFile) => parseCodexTranscript(sessionFile),
|
|
26
|
+
};
|
|
27
|
+
/** Every adapter with a verified, tested-buildable parser. */
|
|
28
|
+
export const ADAPTERS = [claudeCodeAdapter, codexAdapter];
|
|
29
|
+
/**
|
|
30
|
+
* Tools deliberately NOT read yet, with the honest reason. Kept as data (not
|
|
31
|
+
* silence) so the tool — and its docs — can state exactly where the line is
|
|
32
|
+
* and why, and so adding one later is a visible change here.
|
|
33
|
+
*/
|
|
34
|
+
export const UNSUPPORTED_TOOLS = [
|
|
35
|
+
{ tool: "gemini-cli", reason: "session-log path is known (~/.gemini/tmp/<hash>/chats/*.json) but the per-line JSON schema is unverified — not parsed, to avoid fabricating events" },
|
|
36
|
+
{ tool: "aider", reason: "history is a Markdown transcript (.aider.chat.history.md), not structured events — needs a prose parser, not a field mapping" },
|
|
37
|
+
{ tool: "opencode", reason: "stores sessions in a SQLite DB (opencode.db) since v1.2.0 (per-record JSON before) — needs a SQLite reader, version-dependent" },
|
|
38
|
+
{ tool: "cursor", reason: "IDE-embedded; chat history lives in undocumented internal state that changes across Cursor versions — real ongoing maintenance, out of scope for this pass" },
|
|
39
|
+
{ tool: "github-copilot", reason: "IDE-embedded; no accessible, stable local session log a third-party CLI can read" },
|
|
40
|
+
{ tool: "windsurf", reason: "IDE-embedded; history in undocumented internal state, same as Cursor" },
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* The single most recently modified session across ALL supported tools for
|
|
44
|
+
* this cwd — the same "newest wins" rule the Claude reader already uses across
|
|
45
|
+
* `.claude` vs `.claude-office`, now extended across tools. Returns null only
|
|
46
|
+
* when no supported tool has a session for this project (the caller then asks
|
|
47
|
+
* or reports "no session found").
|
|
48
|
+
*/
|
|
49
|
+
export function findLatestSession(cwd) {
|
|
50
|
+
let best = null;
|
|
51
|
+
for (const adapter of ADAPTERS) {
|
|
52
|
+
const files = adapter.listSessions(cwd); // newest first
|
|
53
|
+
if (files.length === 0)
|
|
54
|
+
continue;
|
|
55
|
+
const file = files[0];
|
|
56
|
+
let mtimeMs;
|
|
57
|
+
try {
|
|
58
|
+
mtimeMs = statSync(file).mtimeMs;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!best || mtimeMs > best.mtimeMs)
|
|
64
|
+
best = { adapter, file, mtimeMs };
|
|
65
|
+
}
|
|
66
|
+
return best;
|
|
67
|
+
}
|
|
68
|
+
/** Events from the latest session across all tools (empty when none exists). */
|
|
69
|
+
export function readLatestSessionEvents(cwd) {
|
|
70
|
+
const latest = findLatestSession(cwd);
|
|
71
|
+
return latest ? latest.adapter.parse(latest.file) : [];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* EVERY session across all supported tools for this cwd, newest first, each
|
|
75
|
+
* paired with the adapter that can parse it. Used by the multi-session
|
|
76
|
+
* compliance report so it audits Codex sessions alongside Claude ones, not
|
|
77
|
+
* just Claude Code's.
|
|
78
|
+
*/
|
|
79
|
+
export function listAllSessions(cwd) {
|
|
80
|
+
const pairs = [];
|
|
81
|
+
for (const adapter of ADAPTERS) {
|
|
82
|
+
for (const file of adapter.listSessions(cwd)) {
|
|
83
|
+
try {
|
|
84
|
+
pairs.push({ adapter, file, mtimeMs: statSync(file).mtimeMs });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* unreadable file: skip */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
pairs.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
92
|
+
return pairs.map(({ adapter, file }) => ({ adapter, file }));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Parse a single session file whose tool is not known ahead of time (a
|
|
96
|
+
* `--transcript <file>` the user pointed at directly). A Codex rollout opens
|
|
97
|
+
* with a `session_meta` or `response_item` line; anything else is read as a
|
|
98
|
+
* Claude transcript. Sniffing the first line beats guessing from the path,
|
|
99
|
+
* and it fails closed — an unreadable or unrecognised file yields no events,
|
|
100
|
+
* never a wrong parse presented as right.
|
|
101
|
+
*/
|
|
102
|
+
export function parseSessionFile(file) {
|
|
103
|
+
let raw;
|
|
104
|
+
try {
|
|
105
|
+
raw = readFileSync(file, "utf-8");
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
const firstLine = raw.split("\n").find((l) => l.trim()) ?? "";
|
|
111
|
+
try {
|
|
112
|
+
const obj = JSON.parse(firstLine);
|
|
113
|
+
if (obj && typeof obj === "object" && (obj.type === "session_meta" || obj.type === "response_item")) {
|
|
114
|
+
return parseCodexTranscript(file);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* first line is not JSON: treat as a Claude transcript below */
|
|
119
|
+
}
|
|
120
|
+
return readTranscriptFromFile(file);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A one-line note naming the tool a session came from, when it is NOT the
|
|
124
|
+
* default Claude Code — so a user running `check` in a Codex project sees that
|
|
125
|
+
* the report is about their Codex session, not silently. Null for Claude Code
|
|
126
|
+
* (the default) and when there is no session.
|
|
127
|
+
*/
|
|
128
|
+
export function sessionSourceNote(cwd) {
|
|
129
|
+
const latest = findLatestSession(cwd);
|
|
130
|
+
if (!latest || latest.adapter.tool === "claude-code")
|
|
131
|
+
return null;
|
|
132
|
+
return `Read a ${latest.adapter.tool} session (${basename(latest.file)}) — the most recently modified session found for this project.`;
|
|
133
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { violation } from "../types.js";
|
|
2
|
-
import {
|
|
2
|
+
import { segments, leadingCommand } from "./shellCommand.js";
|
|
3
3
|
/**
|
|
4
4
|
* Did the session add an AI-attribution trailer to a commit, PR or comment,
|
|
5
5
|
* against a rule forbidding it?
|
|
@@ -38,12 +38,36 @@ const GIT_WRITE = /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])|\bgit\s+.*--amend\b|\
|
|
|
38
38
|
* mark, not merely the word "Claude" (a commit may legitimately say "fix the
|
|
39
39
|
* Claude Code parser"): the co-author trailer, the generated-with line with
|
|
40
40
|
* or without its robot, and the anthropic noreply address used as an author.
|
|
41
|
+
*
|
|
42
|
+
* The co-authored-by branch requires the `<email>` that a REAL trailer always
|
|
43
|
+
* carries. Without it, a commit message that merely DESCRIBES the trailer —
|
|
44
|
+
* `git commit -m "Add detection for Co-Authored-By: Claude trailer"`, or the
|
|
45
|
+
* same phrase sitting in a `node -e` string — was flagged as adding one
|
|
46
|
+
* (live-confirmed false positive, 2026-09-26, on this very repo whose own rule
|
|
47
|
+
* documents the trailer). A mention has no `<…>`; the injected trailer
|
|
48
|
+
* (`Co-Authored-By: Claude <noreply@anthropic.com>`) does. The claude/anthropic
|
|
49
|
+
* requirement keeps a legitimate HUMAN co-author (`… <jane@example.com>`) out.
|
|
41
50
|
*/
|
|
42
|
-
const ATTRIBUTION_TRAILER = /co-?authored-by
|
|
51
|
+
const ATTRIBUTION_TRAILER = /co-?authored-by:[^\n]*(?:claude|anthropic)[^\n]*<[^>\n]+>|generated with\s*\[?\s*claude code|🤖\s*generated with|<?noreply@anthropic\.com>?/i;
|
|
43
52
|
function commandText(event) {
|
|
44
53
|
const input = event.input;
|
|
45
54
|
return input && typeof input.command === "string" ? input.command : "";
|
|
46
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Is a git/gh write command actually INVOKED here — not merely quoted inside
|
|
58
|
+
* another command (a `node -e '…git commit…'` string, a `cat <<EOF` writing an
|
|
59
|
+
* example)? Requires git/gh to be the LEADING command of a real segment.
|
|
60
|
+
* Heredoc bodies are stripped first (by `segments`), so a heredoc that writes
|
|
61
|
+
* an example does not count, while `git commit -F- <<EOF` still does.
|
|
62
|
+
*/
|
|
63
|
+
function invokesGitWrite(rawCommand) {
|
|
64
|
+
for (const seg of segments(rawCommand)) {
|
|
65
|
+
const exe = leadingCommand(seg);
|
|
66
|
+
if ((exe === "git" || exe === "gh") && GIT_WRITE.test(seg))
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
47
71
|
/** The first git-writing command in the session that carries a trailer. */
|
|
48
72
|
function firstOffendingCommand(events) {
|
|
49
73
|
let sawGitWrite = false;
|
|
@@ -51,14 +75,11 @@ function firstOffendingCommand(events) {
|
|
|
51
75
|
if (event.kind !== "tool_use" || event.toolName !== "Bash")
|
|
52
76
|
continue;
|
|
53
77
|
const command = commandText(event);
|
|
54
|
-
|
|
55
|
-
// commit … Co-Authored-By … EOF` writes a file that contains the example,
|
|
56
|
-
// it does not commit. Strip heredoc bodies before testing the invocation,
|
|
57
|
-
// but match the trailer against the FULL command so a real heredoc that
|
|
58
|
-
// FEEDS the commit message is still caught.
|
|
59
|
-
if (!GIT_WRITE.test(withoutHeredocs(command)))
|
|
78
|
+
if (!invokesGitWrite(command))
|
|
60
79
|
continue;
|
|
61
80
|
sawGitWrite = true;
|
|
81
|
+
// Trailer matched against the FULL command (heredoc body included) so a
|
|
82
|
+
// real heredoc that FEEDS the commit message its trailer is still caught.
|
|
62
83
|
if (ATTRIBUTION_TRAILER.test(command))
|
|
63
84
|
return command;
|
|
64
85
|
}
|