rulereceipt 0.1.76 → 0.1.78
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 +2 -2
- package/dist/breakContext.d.ts +49 -0
- package/dist/breakContext.js +129 -0
- package/dist/checks/gitBranchPolicy.js +8 -1
- package/dist/cli.js +12 -2
- package/dist/report/generateReport.d.ts +6 -1
- package/dist/report/generateReport.js +14 -1
- package/dist/rules.js +19 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ npx rulereceipt
|
|
|
17
17
|
Runs entirely on your machine. Plain `rulereceipt check` makes zero network
|
|
18
18
|
calls — [Trust, privacy and licensing](#trust-privacy-and-licensing) has the full
|
|
19
19
|
detail, including the three off-by-default opt-ins. Works with Claude Code today
|
|
20
|
-
(OpenAI Codex CLI
|
|
20
|
+
(OpenAI Codex CLI supported); reads rules from CLAUDE.md, AGENTS.md, Cursor
|
|
21
21
|
(`.cursor/rules`), GitHub Copilot, Windsurf, Gemini (`GEMINI.md`), Google's
|
|
22
22
|
`.agents/rules`, and Claude Code memory. [Accuracy](https://rulereceipt.dev/accuracy)
|
|
23
23
|
· [Known gaps](KNOWN-GAPS.md) · Source-available, not OSI — see [LICENSE](LICENSE).
|
|
@@ -77,7 +77,7 @@ Published and live on npm, actively developed.
|
|
|
77
77
|
current project directory and your global rules file.
|
|
78
78
|
2. Reads your most recent agent session transcript — Claude Code today
|
|
79
79
|
(including hosted/enterprise variants under a different directory), and
|
|
80
|
-
OpenAI Codex CLI (
|
|
80
|
+
OpenAI Codex CLI (supported); newest session across tools wins.
|
|
81
81
|
3. Routes each rule to the narrowest check that can actually answer it:
|
|
82
82
|
- **Structured checks** read what the session really did — an actual
|
|
83
83
|
git command's branch argument, actual file edits, actual file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A4 — "why it broke" context for a single proven break.
|
|
3
|
+
*
|
|
4
|
+
* The report says WHICH rule broke and quotes the line. This adds the three
|
|
5
|
+
* things a person asks next, read straight from the raw transcript (the parsed
|
|
6
|
+
* event stream drops attachment/system/compaction lines, so this works on the
|
|
7
|
+
* file text `check` already has):
|
|
8
|
+
*
|
|
9
|
+
* 1. the user's own message just before the break,
|
|
10
|
+
* 2. whether the rules file (CLAUDE.md/AGENTS.md/GEMINI.md) was in context
|
|
11
|
+
* BEFORE the break at all,
|
|
12
|
+
* 3. whether a compaction happened earlier in the session.
|
|
13
|
+
*
|
|
14
|
+
* The point of (2) is honesty, not accusation. Claude Code can load CLAUDE.md
|
|
15
|
+
* only when a Read touches its directory, so a shell-heavy session may never
|
|
16
|
+
* have the rule in context. When that is the case the report must say "the rules
|
|
17
|
+
* file was not in context here", NOT imply the agent ignored a rule it never saw
|
|
18
|
+
* — and it points at the fix (a SessionStart / post-compaction hook that injects
|
|
19
|
+
* the rules). Nothing here changes a verdict; it only explains one.
|
|
20
|
+
*
|
|
21
|
+
* If the break line cannot be located in the transcript (evidence with no
|
|
22
|
+
* quotable fragment), `located` is false and the caller shows nothing rather
|
|
23
|
+
* than guessing.
|
|
24
|
+
*/
|
|
25
|
+
export interface BreakContext {
|
|
26
|
+
located: boolean;
|
|
27
|
+
/** The user's own typed message just before the break, clipped; undefined if none. */
|
|
28
|
+
precedingUser?: string;
|
|
29
|
+
/** Did a rules file appear in the transcript before the break? */
|
|
30
|
+
rulesInContext: boolean;
|
|
31
|
+
/** Did a compaction occur before the break? */
|
|
32
|
+
compactionBefore: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The rules file was in context earlier, but its last appearance was BEFORE
|
|
35
|
+
* the last compaction and it was not re-injected after — so the summary may
|
|
36
|
+
* have dropped it. (The real session ef53e676: CLAUDE.md present before a
|
|
37
|
+
* compaction, never came back.)
|
|
38
|
+
*/
|
|
39
|
+
rulesStaleAfterCompaction: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Context for the break whose evidence is `evidence`, read from the raw JSONL
|
|
43
|
+
* `transcriptText`. Line-based: the break line is the LAST line carrying a
|
|
44
|
+
* distinctive fragment of the evidence (so a later, unrelated mention does not
|
|
45
|
+
* win), and the three facts are computed over the lines before it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function breakContext(transcriptText: string, evidence: string): BreakContext;
|
|
48
|
+
/** The lines the report prints under a break, or [] when nothing is worth adding. */
|
|
49
|
+
export declare function renderBreakContext(ctx: BreakContext): string[];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A4 — "why it broke" context for a single proven break.
|
|
3
|
+
*
|
|
4
|
+
* The report says WHICH rule broke and quotes the line. This adds the three
|
|
5
|
+
* things a person asks next, read straight from the raw transcript (the parsed
|
|
6
|
+
* event stream drops attachment/system/compaction lines, so this works on the
|
|
7
|
+
* file text `check` already has):
|
|
8
|
+
*
|
|
9
|
+
* 1. the user's own message just before the break,
|
|
10
|
+
* 2. whether the rules file (CLAUDE.md/AGENTS.md/GEMINI.md) was in context
|
|
11
|
+
* BEFORE the break at all,
|
|
12
|
+
* 3. whether a compaction happened earlier in the session.
|
|
13
|
+
*
|
|
14
|
+
* The point of (2) is honesty, not accusation. Claude Code can load CLAUDE.md
|
|
15
|
+
* only when a Read touches its directory, so a shell-heavy session may never
|
|
16
|
+
* have the rule in context. When that is the case the report must say "the rules
|
|
17
|
+
* file was not in context here", NOT imply the agent ignored a rule it never saw
|
|
18
|
+
* — and it points at the fix (a SessionStart / post-compaction hook that injects
|
|
19
|
+
* the rules). Nothing here changes a verdict; it only explains one.
|
|
20
|
+
*
|
|
21
|
+
* If the break line cannot be located in the transcript (evidence with no
|
|
22
|
+
* quotable fragment), `located` is false and the caller shows nothing rather
|
|
23
|
+
* than guessing.
|
|
24
|
+
*/
|
|
25
|
+
// A rules file being injected into context. Covers the literal injected header
|
|
26
|
+
// ("Contents of .../CLAUDE.md (project instructions"), the "checked into" variant,
|
|
27
|
+
// and the structural claudeMd attachment (escaped or not inside a JSONL line).
|
|
28
|
+
const RULES_INJECTION = /Contents of [^\n"]*(?:CLAUDE|AGENTS|GEMINI|AGENT)[^\n"]*\.md \(project instructions|project instructions, checked into|\\?"(?:claudeMd|type\\?":\\?"claudeMd)\\?"|\\?"type\\?":\s*\\?"claudeMd/;
|
|
29
|
+
const COMPACTION = /"isCompactSummary"\s*:\s*true/;
|
|
30
|
+
/** Longest-first distinctive fragments of the evidence to find the break line by. */
|
|
31
|
+
function needles(evidence) {
|
|
32
|
+
const quoted = [...evidence.matchAll(/"([^"]{6,})"/g)].map((m) => m[1]);
|
|
33
|
+
const after = evidence.split(/:\s/).slice(1).join(": ");
|
|
34
|
+
return [...quoted, after]
|
|
35
|
+
.map((s) => s.replace(/\s+/g, " ").trim().slice(0, 80))
|
|
36
|
+
.filter((s) => s.length >= 6)
|
|
37
|
+
.sort((a, b) => b.length - a.length);
|
|
38
|
+
}
|
|
39
|
+
/** The human's own message on a user line (string content), or null. */
|
|
40
|
+
function userTyped(line) {
|
|
41
|
+
try {
|
|
42
|
+
const o = JSON.parse(line);
|
|
43
|
+
if (o.type !== "user")
|
|
44
|
+
return null;
|
|
45
|
+
const c = o.message?.content;
|
|
46
|
+
if (typeof c === "string" && c.trim().length > 0)
|
|
47
|
+
return c.trim();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* partial line */
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function clip(s, n = 140) {
|
|
55
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
56
|
+
if (one.length <= n)
|
|
57
|
+
return one;
|
|
58
|
+
const cut = one.slice(0, n);
|
|
59
|
+
const sp = cut.lastIndexOf(" ");
|
|
60
|
+
return `${sp > n * 0.6 ? cut.slice(0, sp) : cut}…`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Context for the break whose evidence is `evidence`, read from the raw JSONL
|
|
64
|
+
* `transcriptText`. Line-based: the break line is the LAST line carrying a
|
|
65
|
+
* distinctive fragment of the evidence (so a later, unrelated mention does not
|
|
66
|
+
* win), and the three facts are computed over the lines before it.
|
|
67
|
+
*/
|
|
68
|
+
export function breakContext(transcriptText, evidence) {
|
|
69
|
+
const lines = transcriptText.split(/\r?\n/);
|
|
70
|
+
const ns = needles(evidence);
|
|
71
|
+
let breakIdx = -1;
|
|
72
|
+
if (ns.length > 0) {
|
|
73
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
74
|
+
const flat = lines[i].replace(/\s+/g, " ");
|
|
75
|
+
if (ns.some((n) => flat.includes(n))) {
|
|
76
|
+
breakIdx = i;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (breakIdx === -1)
|
|
82
|
+
return { located: false, rulesInContext: false, compactionBefore: false, rulesStaleAfterCompaction: false };
|
|
83
|
+
let lastRulesIdx = -1;
|
|
84
|
+
let lastCompactionIdx = -1;
|
|
85
|
+
let precedingUser;
|
|
86
|
+
for (let i = 0; i < breakIdx; i++) {
|
|
87
|
+
const line = lines[i];
|
|
88
|
+
if (RULES_INJECTION.test(line))
|
|
89
|
+
lastRulesIdx = i;
|
|
90
|
+
if (COMPACTION.test(line))
|
|
91
|
+
lastCompactionIdx = i;
|
|
92
|
+
const u = userTyped(line);
|
|
93
|
+
if (u)
|
|
94
|
+
precedingUser = clip(u);
|
|
95
|
+
}
|
|
96
|
+
const rulesInContext = lastRulesIdx !== -1;
|
|
97
|
+
const compactionBefore = lastCompactionIdx !== -1;
|
|
98
|
+
return {
|
|
99
|
+
located: true,
|
|
100
|
+
precedingUser,
|
|
101
|
+
rulesInContext,
|
|
102
|
+
compactionBefore,
|
|
103
|
+
rulesStaleAfterCompaction: rulesInContext && compactionBefore && lastRulesIdx < lastCompactionIdx,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** The lines the report prints under a break, or [] when nothing is worth adding. */
|
|
107
|
+
export function renderBreakContext(ctx) {
|
|
108
|
+
if (!ctx.located)
|
|
109
|
+
return [];
|
|
110
|
+
const out = [" why it broke:"];
|
|
111
|
+
if (ctx.precedingUser)
|
|
112
|
+
out.push(` just before, you said: "${ctx.precedingUser}"`);
|
|
113
|
+
if (!ctx.rulesInContext) {
|
|
114
|
+
out.push(" your rules file was NOT in context at this point — not the agent ignoring a");
|
|
115
|
+
out.push(" rule it never saw. Claude Code can load CLAUDE.md only when a Read touches its");
|
|
116
|
+
out.push(" directory, so a shell-heavy session can miss it. Fix: a SessionStart (and");
|
|
117
|
+
out.push(" post-compaction) hook that injects your rules every session.");
|
|
118
|
+
}
|
|
119
|
+
else if (ctx.rulesStaleAfterCompaction) {
|
|
120
|
+
out.push(" your rules file was in context earlier but NOT after the last compaction — the");
|
|
121
|
+
out.push(" summary may have dropped it. Fix: a post-compaction hook that re-injects your rules.");
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
out.push(" your rules file was in context before this.");
|
|
125
|
+
if (ctx.compactionBefore)
|
|
126
|
+
out.push(" (a compaction happened earlier in this session; context before it was summarised.)");
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
@@ -158,7 +158,14 @@ export function runGitBranchPolicyChecks(classifications, events) {
|
|
|
158
158
|
allTargets.push({ branch: hit.branch, command, kind: hit.kind });
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
-
|
|
161
|
+
// "Did a git command actually run?" — tested on the real command segments, not
|
|
162
|
+
// JSON.stringify(input). Two bugs that caused: (1) a newline before `git`
|
|
163
|
+
// becomes the two chars `\n` once stringified, so `\bgit` lost its word
|
|
164
|
+
// boundary and a `git push` on the line after a heredoc was missed entirely
|
|
165
|
+
// (check AND guard reported "didn't apply"); (2) a mere mention in a quote or
|
|
166
|
+
// heredoc body counted as a git command. leadingCommand over heredoc-stripped
|
|
167
|
+
// segments answers the real question: a git invocation in an executable segment.
|
|
168
|
+
const anyGitCommand = commands.some((c) => segments(c).some((seg) => leadingCommand(seg) === "git"));
|
|
162
169
|
return classifications.map(({ rule, branchName, polarity, polarityInferred }) => {
|
|
163
170
|
// No git command ran, so a git rule never had a situation to govern.
|
|
164
171
|
// Calling that "followed" is how an empty session produced 2,770 green
|
package/dist/cli.js
CHANGED
|
@@ -282,7 +282,17 @@ async function runCheck(opts) {
|
|
|
282
282
|
: "";
|
|
283
283
|
// Kept in human/markdown form for --email and any other reader below, even
|
|
284
284
|
// when stdout is JSON — a manager gets a readable report, not raw JSON.
|
|
285
|
-
|
|
285
|
+
// The raw session text, for A4 "why it broke" context under each Broken verdict.
|
|
286
|
+
// Best-effort: if it can't be read, the report simply omits the context.
|
|
287
|
+
let transcriptText;
|
|
288
|
+
try {
|
|
289
|
+
if (sessionFilePath)
|
|
290
|
+
transcriptText = readFileSync(sessionFilePath, "utf-8");
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* unreadable: no A4 context, never a crash */
|
|
294
|
+
}
|
|
295
|
+
const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta, transcriptText);
|
|
286
296
|
if (json) {
|
|
287
297
|
console.log(generateJsonReport(results, meta, pkg.version, editedRuleFiles));
|
|
288
298
|
}
|
|
@@ -572,7 +582,7 @@ function runAdvise() {
|
|
|
572
582
|
console.log(` ${advice.length} cannot yet — here is what each one needs:\n`);
|
|
573
583
|
// Project rules first: those are the ones the reader can act on today.
|
|
574
584
|
const ordered = advice
|
|
575
|
-
.map((a
|
|
585
|
+
.map((a) => ({ a, source: rules.find((r) => r.title === a.ruleTitle)?.source }))
|
|
576
586
|
.sort((x, y) => Number(x.source === "global") - Number(y.source === "global"))
|
|
577
587
|
.map((x) => x.a);
|
|
578
588
|
for (const a of ordered) {
|
|
@@ -11,7 +11,12 @@ export interface ReportMeta {
|
|
|
11
11
|
* is a valid state (e.g. running against demo data).
|
|
12
12
|
*/
|
|
13
13
|
export declare function computeTranscriptHash(sessionFilePath: string | null): string | null;
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* `transcriptText` is the raw session JSONL. When present, each Broken verdict
|
|
16
|
+
* gets A4 "why it broke" context (the user message before it, whether the rules
|
|
17
|
+
* file was in context, whether a compaction preceded it) read straight from it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function generateReport(results: CheckResult[], meta: ReportMeta, transcriptText?: string): string;
|
|
15
20
|
export declare function generateMarkdownReport(results: CheckResult[], meta: ReportMeta): string;
|
|
16
21
|
/**
|
|
17
22
|
* Machine-readable output for CI, a GitHub Action, or any other consumer.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { breakContext, renderBreakContext } from "../breakContext.js";
|
|
4
5
|
/**
|
|
5
6
|
* Where a rule lives, for the report — "~/proj/CLAUDE.md:42", or just the path
|
|
6
7
|
* when the parser could not place a line, or "" when the rule's location was
|
|
@@ -191,7 +192,12 @@ function sharedEvidence(rs) {
|
|
|
191
192
|
}
|
|
192
193
|
return best;
|
|
193
194
|
}
|
|
194
|
-
|
|
195
|
+
/**
|
|
196
|
+
* `transcriptText` is the raw session JSONL. When present, each Broken verdict
|
|
197
|
+
* gets A4 "why it broke" context (the user message before it, whether the rules
|
|
198
|
+
* file was in context, whether a compaction preceded it) read straight from it.
|
|
199
|
+
*/
|
|
200
|
+
export function generateReport(results, meta, transcriptText) {
|
|
195
201
|
const clean = results.map(sanitize);
|
|
196
202
|
const lines = [];
|
|
197
203
|
lines.push(`RuleReceipt · ${meta.ruleCount} rules checked`);
|
|
@@ -236,6 +242,13 @@ export function generateReport(results, meta) {
|
|
|
236
242
|
// versions as a PASS on a session that ran `git push -f`.
|
|
237
243
|
if (r.ceiling)
|
|
238
244
|
lines.push(` this means: ${r.ceiling}`);
|
|
245
|
+
// A4: why it broke — the context around a proven break, read from the raw
|
|
246
|
+
// transcript. Honest by construction: if the rules file was never in
|
|
247
|
+
// context before the break, it says so rather than implying it was ignored.
|
|
248
|
+
if (r.status === "FAIL" && transcriptText && r.evidence) {
|
|
249
|
+
for (const l of renderBreakContext(breakContext(transcriptText, r.evidence)))
|
|
250
|
+
lines.push(l);
|
|
251
|
+
}
|
|
239
252
|
}
|
|
240
253
|
}
|
|
241
254
|
lines.push("");
|
package/dist/rules.js
CHANGED
|
@@ -75,20 +75,31 @@ function ruleSourcesAtLevel(dir) {
|
|
|
75
75
|
for (const f of markdownFilesIn(join(dir, rel)))
|
|
76
76
|
out.push({ path: f, status: "loaded", format: ".claude/rules" });
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
// AGENTS.md / AGENT.md load ONLY when this level has no Claude file at all.
|
|
79
|
+
// As of Claude Code 2.1.277 (default "Project instructions" = claude-md-or-
|
|
80
|
+
// agents-md), AGENTS.md is read only when none of CLAUDE.md / CLAUDE.local.md
|
|
81
|
+
// (nor .claude/CLAUDE.md, handled above) exists — so CLAUDE.local.md ALSO
|
|
82
|
+
// shadows AGENTS.md, not just CLAUDE.md. Checking a shadowed AGENTS.md would be
|
|
83
|
+
// a false accusation (it never reaches the agent).
|
|
84
|
+
// KNOWN LIMIT: the 2.1.277 rule is "no Claude file in cwd OR ABOVE"; this
|
|
85
|
+
// shadows at the SAME level only. A parent CLAUDE.md shadowing a child
|
|
86
|
+
// AGENTS.md across levels is not yet modelled (see KNOWN-GAPS).
|
|
87
|
+
const hasClaudeMd = has("CLAUDE.md");
|
|
88
|
+
const hasClaudeLocal = has("CLAUDE.local.md");
|
|
89
|
+
if (hasClaudeMd)
|
|
79
90
|
loaded("CLAUDE.md", "Claude (CLAUDE.md)");
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
// .local always loads alongside the base CLAUDE.md when present.
|
|
92
|
+
if (hasClaudeLocal)
|
|
93
|
+
loaded("CLAUDE.local.md", "Claude (CLAUDE.local.md)");
|
|
94
|
+
if (hasClaudeMd || hasClaudeLocal) {
|
|
95
|
+
const winner = hasClaudeMd ? "CLAUDE.md" : "CLAUDE.local.md";
|
|
96
|
+
shadowed("AGENTS.md", "AGENTS.md", `a ${winner} at the same level wins`);
|
|
97
|
+
shadowed("AGENT.md", "AGENT.md", `a ${winner} at the same level wins`);
|
|
84
98
|
}
|
|
85
99
|
else {
|
|
86
100
|
loaded("AGENTS.md", "AGENTS.md");
|
|
87
101
|
loaded("AGENT.md", "AGENT.md");
|
|
88
102
|
}
|
|
89
|
-
// .local variants: precedence relative to the base files is not documented,
|
|
90
|
-
// so both are kept rather than guessing at a shadow rule.
|
|
91
|
-
loaded("CLAUDE.local.md", "Claude (CLAUDE.local.md)");
|
|
92
103
|
loaded("AGENTS.local.md", "AGENTS (AGENTS.local.md)");
|
|
93
104
|
// Non-Claude rule-file conventions (added 2026-09-26 for multi-tool
|
|
94
105
|
// support), read IN ADDITION to Claude's files when present. The engine
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.78",
|
|
4
4
|
"description": "Checks whether your AI coding agent followed your rules, with evidence. Works with Claude Code (Codex in testing); reads CLAUDE.md, AGENTS.md, Cursor, Copilot and Windsurf rules.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|