rulereceipt 0.1.19 → 0.1.20
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 +39 -0
- package/dist/cli.js +47 -1
- package/dist/report/generateReport.js +27 -2
- package/dist/telemetry.d.ts +10 -0
- package/dist/telemetry.js +10 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
# RuleReceipt
|
|
2
2
|
|
|
3
|
+
[](https://github.com/rulereceipt/rulereceipt/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/rulereceipt/rulereceipt/actions/workflows/codeql.yml)
|
|
5
|
+
[](https://scorecard.dev/viewer/?uri=github.com/rulereceipt/rulereceipt)
|
|
6
|
+
[](https://www.npmjs.com/package/rulereceipt)
|
|
7
|
+
[](https://www.npmjs.com/package/rulereceipt#provenance)
|
|
8
|
+
|
|
3
9
|
Checks whether a Claude Code session actually followed the rules in your
|
|
4
10
|
CLAUDE.md / AGENTS.md — with evidence, not just a vibe.
|
|
5
11
|
|
|
12
|
+
Every release from 0.1.19 on is built and published by GitHub Actions and
|
|
13
|
+
signed with [npm provenance](https://docs.npmjs.com/generating-provenance-statements),
|
|
14
|
+
so you can verify the published package was built from this repository at
|
|
15
|
+
a specific commit. No publishing token exists to be stolen. Check it
|
|
16
|
+
yourself with `npm audit signatures` after installing.
|
|
17
|
+
|
|
6
18
|
Licensed source-available software — see [LICENSE](./LICENSE) and
|
|
7
19
|
[NOTICE.md](./NOTICE.md) before reusing this code.
|
|
8
20
|
|
|
@@ -56,6 +68,31 @@ Published and live on npm, actively developed.
|
|
|
56
68
|
that exact file. (It proves the report matches the file, not that the
|
|
57
69
|
file is an unmodified record — see SECURITY.md.)
|
|
58
70
|
|
|
71
|
+
## Exit codes
|
|
72
|
+
|
|
73
|
+
`check` exits **1** when a rule was actually broken, and **0** otherwise,
|
|
74
|
+
so CI can gate on it. Rules that need human judgment report UNCLEAR and
|
|
75
|
+
never affect the exit code — most rules in a real CLAUDE.md need judgment,
|
|
76
|
+
and gating on those would make every build red on day one.
|
|
77
|
+
|
|
78
|
+
`--exit-zero` prints the report without failing the build. `--require-session`
|
|
79
|
+
does the opposite and is the one to use anywhere automated: it fails when
|
|
80
|
+
there is no session, or an empty one, instead of reporting a pass for a
|
|
81
|
+
check that never actually ran.
|
|
82
|
+
|
|
83
|
+
### A limit worth knowing before you wire this into CI
|
|
84
|
+
|
|
85
|
+
Claude Code writes its session transcript to the machine the agent ran on
|
|
86
|
+
— your laptop. A CI runner is a fresh machine that has never seen it, so a
|
|
87
|
+
CI job cannot check a session that happened on your laptop unless you
|
|
88
|
+
deliberately make that transcript available to the job. See
|
|
89
|
+
[templates/rulereceipt-ci.yml](./templates/rulereceipt-ci.yml), which
|
|
90
|
+
explains the options and, if you use it, fails loudly rather than passing
|
|
91
|
+
on a session it never found.
|
|
92
|
+
|
|
93
|
+
For most people the honest answer is simpler: run `rulereceipt check --html`
|
|
94
|
+
locally and attach the report to the PR.
|
|
95
|
+
|
|
59
96
|
## Sharing a report
|
|
60
97
|
|
|
61
98
|
`rulereceipt check --html` writes one self-contained HTML file. No
|
|
@@ -89,6 +126,8 @@ rulereceipt check # check the latest session in this project
|
|
|
89
126
|
rulereceipt check --markdown # same, formatted for pasting into a PR/Slack
|
|
90
127
|
rulereceipt check --html # write a shareable single-file HTML report you can send
|
|
91
128
|
rulereceipt check --html report.html # ...to a specific path
|
|
129
|
+
rulereceipt check --require-session # fail if there's no session, instead of passing silently
|
|
130
|
+
rulereceipt check --exit-zero # report failures without failing the build
|
|
92
131
|
rulereceipt check --llm # opt-in: grade judgment rules with your own Claude key
|
|
93
132
|
rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
|
|
94
133
|
rulereceipt check --telemetry # opt-in: send one random per-machine ID
|
package/dist/cli.js
CHANGED
|
@@ -121,7 +121,7 @@ function writeHtmlReport(results, meta, cwd, target) {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
async function runCheck(opts) {
|
|
124
|
-
const { markdown, share, email, emailAlways, llm, telemetry, html, transcriptOverride } = opts;
|
|
124
|
+
const { markdown, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, transcriptOverride } = opts;
|
|
125
125
|
const cwd = process.cwd();
|
|
126
126
|
const rules = loadRules(cwd);
|
|
127
127
|
if (rules.length === 0) {
|
|
@@ -139,9 +139,34 @@ async function runCheck(opts) {
|
|
|
139
139
|
console.log("No Claude Code session found for this project yet.\n" +
|
|
140
140
|
"Run Claude Code here at least once, then try `rulereceipt check` again — " +
|
|
141
141
|
"or pass --transcript <path-to-.jsonl> directly if your session lives somewhere non-standard.");
|
|
142
|
+
// Exiting 0 here is right for a person running this locally for the
|
|
143
|
+
// first time — nothing is wrong, there is simply nothing yet. It is
|
|
144
|
+
// dangerous anywhere automated, where a silent 0 reads as "checked,
|
|
145
|
+
// all clear" when nothing was checked at all. --require-session makes
|
|
146
|
+
// that case fail loudly. See the note in templates/rulereceipt-ci.yml.
|
|
147
|
+
if (requireSession) {
|
|
148
|
+
console.error("\n--require-session was set and no session was found, so nothing could be checked. " +
|
|
149
|
+
"Failing rather than reporting a pass for a check that never ran.");
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
}
|
|
142
152
|
return;
|
|
143
153
|
}
|
|
144
154
|
const events = transcriptOverride ? readTranscriptFromFile(sessionFilePath) : readLatestTranscript(cwd);
|
|
155
|
+
// A session file with nothing in it produces a report full of PASSes,
|
|
156
|
+
// because no forbidden action appears in an empty session. That is
|
|
157
|
+
// technically true and badly misleading: "we found no proof of
|
|
158
|
+
// wrongdoing" gets printed as "you're fine." Same shape as the rule this
|
|
159
|
+
// tool already enforces on itself — an absence of evidence is not
|
|
160
|
+
// evidence. Say so out loud, and fail where a machine is reading it.
|
|
161
|
+
if (events.length === 0) {
|
|
162
|
+
console.log("\n⚠ This session file contains no recorded activity, so there was nothing to check against.\n" +
|
|
163
|
+
" Every result below reflects an empty session, not a clean one.");
|
|
164
|
+
if (requireSession) {
|
|
165
|
+
console.error("\n--require-session was set and the session was empty. Failing rather than reporting a pass for a check that had no evidence.");
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
145
170
|
const classifications = classifyRules(rules);
|
|
146
171
|
const deterministic = classifications.filter((c) => c.kind === "deterministic");
|
|
147
172
|
const ifEditThenTest = classifications.filter((c) => c.kind === "ifEditThenTest");
|
|
@@ -201,6 +226,23 @@ async function runCheck(opts) {
|
|
|
201
226
|
if (isTelemetryEnabled(telemetry)) {
|
|
202
227
|
await sendTelemetryPing();
|
|
203
228
|
}
|
|
229
|
+
// Exit non-zero when a rule was actually broken, so CI can gate on it.
|
|
230
|
+
//
|
|
231
|
+
// This was a real shipped falsehood (found 2026-08-31):
|
|
232
|
+
// templates/rulereceipt-ci.yml told people to copy a workflow and said
|
|
233
|
+
// "rulereceipt already exits non-zero on FAIL, this just wires that
|
|
234
|
+
// into CI" — while `check` always exited 0. Anyone who used that
|
|
235
|
+
// template had a job that passed even as the agent broke their rules,
|
|
236
|
+
// which is worse than having no check at all, because it reads as
|
|
237
|
+
// evidence that nothing went wrong.
|
|
238
|
+
//
|
|
239
|
+
// Only FAIL counts. UNCLEAR must not, and that isn't a softening: most
|
|
240
|
+
// rules in a real CLAUDE.md need judgment, so without --llm they
|
|
241
|
+
// legitimately report UNCLEAR. Gating on those would make every build
|
|
242
|
+
// red on day one and the check would be deleted within a week.
|
|
243
|
+
if (!exitZero && results.some((r) => r.status === "FAIL")) {
|
|
244
|
+
process.exitCode = 1;
|
|
245
|
+
}
|
|
204
246
|
}
|
|
205
247
|
function runDemo(markdown) {
|
|
206
248
|
const meta = { sessionFilePath: null, ruleCount: DEMO_RESULTS.length };
|
|
@@ -222,6 +264,8 @@ program
|
|
|
222
264
|
.option("--llm", "opt-in: grade rules that need judgment (not just pattern matching) using your own Anthropic key. Without this flag, those rules report UNCLEAR and nothing is sent anywhere — deterministic checks always run with no key regardless.")
|
|
223
265
|
.option("--telemetry", "opt-in: send an anonymous install-count ping (a random per-machine ID, never rule text or results) so real distinct-install counts are knowable. Off by default. DO_NOT_TRACK=1 or RULERECEIPT_NO_TELEMETRY=1 overrides this flag back off.")
|
|
224
266
|
.option("--html [path]", `write a shareable single-file HTML report you can email, attach to a ticket, or print to PDF. Defaults to ./${DEFAULT_HTML_REPORT_NAME}. Written locally — nothing is uploaded.`)
|
|
267
|
+
.option("--exit-zero", "always exit 0, even when a rule was broken. Without this, `check` exits 1 on any FAIL so CI can gate on it (rules needing human judgment report UNCLEAR and never affect the exit code).")
|
|
268
|
+
.option("--require-session", "fail (exit 1) if no session is found, or the session is empty, instead of reporting a pass for a check that never actually ran. Use this anywhere automated.")
|
|
225
269
|
.option("--transcript <path>", "manual override: check this exact .jsonl session file instead of auto-detecting one. Useful if your Claude Code session lives somewhere non-standard that auto-detection doesn't cover.")
|
|
226
270
|
.action((opts) => {
|
|
227
271
|
runCheck({
|
|
@@ -233,6 +277,8 @@ program
|
|
|
233
277
|
telemetry: Boolean(opts.telemetry),
|
|
234
278
|
// commander gives `true` for a bare --html and the string for --html <path>
|
|
235
279
|
html: opts.html ?? false,
|
|
280
|
+
exitZero: Boolean(opts.exitZero),
|
|
281
|
+
requireSession: Boolean(opts.requireSession),
|
|
236
282
|
transcriptOverride: opts.transcript,
|
|
237
283
|
}).catch((err) => {
|
|
238
284
|
console.error("Something went wrong:", err instanceof Error ? err.message : err);
|
|
@@ -67,6 +67,31 @@ export function generateReport(results, meta) {
|
|
|
67
67
|
lines.push(`checked: ${new Date().toISOString()}`);
|
|
68
68
|
return lines.join("\n");
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Makes a value safe to place inside a markdown table cell.
|
|
72
|
+
*
|
|
73
|
+
* The previous version escaped only `|`, which broke a real table three
|
|
74
|
+
* separate ways (first found by CodeQL js/incomplete-sanitization, the
|
|
75
|
+
* other two while fixing it):
|
|
76
|
+
*
|
|
77
|
+
* 1. Backslash was not escaped, so evidence containing a literal `\|`
|
|
78
|
+
* became `\\|` — rendering as a backslash followed by a live column
|
|
79
|
+
* separator, splitting the row. Backslash must be escaped FIRST, or
|
|
80
|
+
* it re-escapes the pipes added afterwards.
|
|
81
|
+
* 2. The rule TITLE was not escaped at all, so any rule whose title
|
|
82
|
+
* contains a pipe broke the table. Titles come from a user's
|
|
83
|
+
* CLAUDE.md, and pipes appear naturally in shell examples.
|
|
84
|
+
* 3. Newlines were not handled. stripControlChars deliberately keeps
|
|
85
|
+
* `\n` so multi-line evidence stays readable in the terminal, but a
|
|
86
|
+
* newline inside a table cell ends the row and destroys everything
|
|
87
|
+
* below it. Rendered as a literal <br> instead.
|
|
88
|
+
*/
|
|
89
|
+
function escapeMarkdownCell(value) {
|
|
90
|
+
return value
|
|
91
|
+
.replace(/\\/g, "\\\\")
|
|
92
|
+
.replace(/\|/g, "\\|")
|
|
93
|
+
.replace(/\r?\n/g, "<br>");
|
|
94
|
+
}
|
|
70
95
|
export function generateMarkdownReport(results, meta) {
|
|
71
96
|
const clean = results.map(sanitize);
|
|
72
97
|
const lines = [];
|
|
@@ -75,8 +100,8 @@ export function generateMarkdownReport(results, meta) {
|
|
|
75
100
|
lines.push("| Status | Rule | Evidence |");
|
|
76
101
|
lines.push("|---|---|---|");
|
|
77
102
|
for (const r of clean) {
|
|
78
|
-
const evidence = (r.evidence || "")
|
|
79
|
-
lines.push(`| ${MARK[r.status]} ${r.status} | ${ruleLabel(r, clean)} | ${evidence} |`);
|
|
103
|
+
const evidence = escapeMarkdownCell(r.evidence || "");
|
|
104
|
+
lines.push(`| ${MARK[r.status]} ${r.status} | ${escapeMarkdownCell(ruleLabel(r, clean))} | ${evidence} |`);
|
|
80
105
|
}
|
|
81
106
|
lines.push("");
|
|
82
107
|
const hash = computeTranscriptHash(meta.sessionFilePath);
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -15,5 +15,15 @@ export declare function isTelemetryEnabled(telemetryFlag: boolean): boolean;
|
|
|
15
15
|
* session content, or even pass/fail counts (that's what opt-in --share is
|
|
16
16
|
* for). A send failure must never affect the `check` command's own exit
|
|
17
17
|
* code or output; this is best-effort and silent on failure.
|
|
18
|
+
*
|
|
19
|
+
* CodeQL flags this as js/file-access-to-http ("outbound network request
|
|
20
|
+
* depends on file data"), which is technically accurate and not a real
|
|
21
|
+
* issue: the file it reads is ~/.rulereceipt/telemetry-id, whose entire
|
|
22
|
+
* contents are a random UUID this tool generated and wrote itself. No
|
|
23
|
+
* user content, no path, and nothing derived from the session ever
|
|
24
|
+
* reaches this request. Reviewed and dismissed deliberately rather than
|
|
25
|
+
* left open — a permanently red alert list is one nobody reads. If the
|
|
26
|
+
* payload here ever grows beyond `{ id }`, that decision is void and
|
|
27
|
+
* this needs re-reviewing.
|
|
18
28
|
*/
|
|
19
29
|
export declare function sendTelemetryPing(): Promise<void>;
|
package/dist/telemetry.js
CHANGED
|
@@ -61,6 +61,16 @@ export function isTelemetryEnabled(telemetryFlag) {
|
|
|
61
61
|
* session content, or even pass/fail counts (that's what opt-in --share is
|
|
62
62
|
* for). A send failure must never affect the `check` command's own exit
|
|
63
63
|
* code or output; this is best-effort and silent on failure.
|
|
64
|
+
*
|
|
65
|
+
* CodeQL flags this as js/file-access-to-http ("outbound network request
|
|
66
|
+
* depends on file data"), which is technically accurate and not a real
|
|
67
|
+
* issue: the file it reads is ~/.rulereceipt/telemetry-id, whose entire
|
|
68
|
+
* contents are a random UUID this tool generated and wrote itself. No
|
|
69
|
+
* user content, no path, and nothing derived from the session ever
|
|
70
|
+
* reaches this request. Reviewed and dismissed deliberately rather than
|
|
71
|
+
* left open — a permanently red alert list is one nobody reads. If the
|
|
72
|
+
* payload here ever grows beyond `{ id }`, that decision is void and
|
|
73
|
+
* this needs re-reviewing.
|
|
64
74
|
*/
|
|
65
75
|
export async function sendTelemetryPing() {
|
|
66
76
|
const id = getOrCreateTelemetryId();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "Checks whether a Claude Code session actually followed your CLAUDE.md / AGENTS.md rules, with evidence.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -45,16 +45,16 @@
|
|
|
45
45
|
"license": "SEE LICENSE IN LICENSE",
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@anthropic-ai/sdk": "^0.32.0",
|
|
48
|
-
"commander": "^
|
|
49
|
-
"nodemailer": "^9.0.
|
|
48
|
+
"commander": "^15.0.0",
|
|
49
|
+
"nodemailer": "^9.0.6"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@types/node": "^
|
|
52
|
+
"@types/node": "^26.4.0",
|
|
53
53
|
"@types/nodemailer": "^8.0.1",
|
|
54
|
-
"eslint": "^9.
|
|
54
|
+
"eslint": "^10.9.1",
|
|
55
55
|
"tsx": "^4.19.0",
|
|
56
56
|
"typescript": "^5.6.0",
|
|
57
|
-
"typescript-eslint": "^8.
|
|
57
|
+
"typescript-eslint": "^8.68.0",
|
|
58
58
|
"vitest": "^4.1.11"
|
|
59
59
|
}
|
|
60
60
|
}
|