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 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 in testing); reads rules from CLAUDE.md, AGENTS.md, Cursor
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 (in testing); newest session across tools wins.
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
- const anyGitCommand = events.some((e) => e.kind === "tool_use" && /\bgit\s/.test(JSON.stringify(e.input ?? "")));
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
- const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
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, i) => ({ a, source: rules.find((r) => r.title === a.ruleTitle)?.source }))
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
- export declare function generateReport(results: CheckResult[], meta: ReportMeta): string;
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
- export function generateReport(results, meta) {
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
- if (has("CLAUDE.md")) {
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
- // AGENTS.md (and the singular AGENT.md some tools use) are ignored when
81
- // there's a CLAUDE.md at this level, mirroring Claude Code's shadow rule.
82
- shadowed("AGENTS.md", "AGENTS.md", "a CLAUDE.md at the same level wins");
83
- shadowed("AGENT.md", "AGENT.md", "a CLAUDE.md at the same level wins");
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.76",
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",