rulereceipt 0.1.27 → 0.1.28

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
@@ -54,6 +54,13 @@ Published and live on npm, actively developed.
54
54
  exactly what it excluded. A rule phrased unusually, or written in
55
55
  another language, can land there — and a rule dropped silently is
56
56
  worse than one reported wrongly.
57
+
58
+ When it gets one wrong, `rulereceipt rules --include <handle>` fixes it
59
+ permanently. The handle is a hash of the rule's own text, not its
60
+ position, so the correction survives edits elsewhere in the file. That
61
+ matters more than making the classifier smarter: imperative verbs are
62
+ not a closed class and the word list is English-only, so it will keep
63
+ being wrong — it just needs to be correctable.
57
64
  4. Prints a report — terminal table by default, `--markdown` for pasting
58
65
  into a PR or Slack message, or `--html` for a shareable single file —
59
66
  showing what passed, what failed, and a quoted line of evidence for
@@ -76,6 +83,10 @@ rulereceipt check --llm # opt-in: grade judgment rules with your own Clau
76
83
  rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
77
84
  rulereceipt check --telemetry # opt-in: send one random per-machine ID
78
85
  rulereceipt check --transcript <path> # check a specific session file
86
+ rulereceipt rules # show corrections you've made to what counts as a rule
87
+ rulereceipt rules --include <handle> # "this IS a rule" — check it from now on
88
+ rulereceipt rules --exclude <handle> # "this isn't" — stop reporting it
89
+ rulereceipt rules --coverage # which rules a configured hook might actually enforce
79
90
  rulereceipt doctor # list hooks/auto-run tasks configured on this machine
80
91
  rulereceipt lint # find contradictions between CLAUDE.md and AGENTS.md
81
92
  rulereceipt digest # summarise recent checks; --email to send it
@@ -88,6 +99,30 @@ rulereceipt verify <session-file> <hash> # spot-check a report you received ag
88
99
 
89
100
  `verify` isn't a routine check — trust your team day to day, same as any status update. It's there for the rare case it actually matters (a dispute, an incident review): give it the session file and the hash printed in the report, and it confirms whether they really match.
90
101
 
102
+ ## Which rules actually have teeth
103
+
104
+ A rule in a file and a rule with a `PreToolUse` hook behind it look identical
105
+ when you read them, and behave completely differently when they're ignored.
106
+ One fails loudly; the other doesn't fail at all.
107
+
108
+ ```bash
109
+ rulereceipt rules --coverage
110
+ ```
111
+
112
+ This lists your rules against the hooks configured on this machine and in the
113
+ project, and tells you which rules name something a *blocking* hook also
114
+ names. Hooks on events that can't refuse anything — `SessionStart`,
115
+ `PostToolUse` — are counted separately, because they can log or inject
116
+ context but can't make a rule fail.
117
+
118
+ **It reports a possible backing, never a proof, and says so in its own
119
+ output.** A hook's command is usually a path to a script this tool doesn't
120
+ read, so the only evidence available is the event, the matcher, and literal
121
+ text in the command. Both mistakes are possible: a hook can guard a rule
122
+ while sharing no wording with it, and shared wording doesn't mean the hook
123
+ guards it. Treat the links as somewhere to look, and everything else as prose
124
+ until you've checked.
125
+
91
126
  ## Sharing a report
92
127
 
93
128
  `rulereceipt check --html` writes one self-contained HTML file. No
@@ -159,11 +194,14 @@ real distinct-install counts are knowable, nothing else. None of them fire
159
194
  unless you explicitly pass the flag, and `DO_NOT_TRACK=1` /
160
195
  `RULERECEIPT_NO_TELEMETRY=1` forces telemetry off even if you do.
161
196
 
162
- **Read-only, and no hooks.** RuleReceipt never modifies
163
- `.claude/settings.json` and installs no hooks without explicit action. It
164
- only ever reads your CLAUDE.md/AGENTS.md and session transcripts —
165
- read-only, manual invocation only (`rulereceipt check`). No automatic
166
- hooks, ever, in v1.
197
+ **Never writes anything you didn't ask for.** RuleReceipt never modifies
198
+ `.claude/settings.json` and installs no hooks. No automatic hooks, ever, in
199
+ v1 — it runs only when you type the command.
200
+
201
+ Two commands write, both only when you invoke them: `check --html` writes the
202
+ report to the path you name, and `rules --include/--exclude` records a
203
+ correction in `.rulereceipt/overrides.json`. Plain `rulereceipt check` writes
204
+ nothing and makes no network calls.
167
205
 
168
206
  **You can verify the package came from this source.** Every release from
169
207
  0.1.19 on is built and published by GitHub Actions and signed with
@@ -3,9 +3,9 @@ import { classifyRules } from "../checks/classify.js";
3
3
  /** Measured across 559 real public rules files. Shown for comparison. */
4
4
  export const CORPUS = {
5
5
  files: 559,
6
- parsed: 23704,
7
- notRulesPct: 64.5,
8
- checkablePct: 42.7,
6
+ parsed: 21986,
7
+ notRulesPct: 63.4,
8
+ checkablePct: 44.2,
9
9
  };
10
10
  function pct(part, whole) {
11
11
  return whole === 0 ? 0 : Math.round((part / whole) * 1000) / 10;
@@ -3,6 +3,13 @@ export interface HookEntry {
3
3
  event: string;
4
4
  command: string;
5
5
  flags: string[];
6
+ /**
7
+ * The matcher the hook is registered under — which tool it fires on, for
8
+ * tool-call events. Kept because it is the only machine-readable statement
9
+ * of what a hook watches; the command itself is usually a script path whose
10
+ * contents this tool does not read.
11
+ */
12
+ matcher?: string;
6
13
  }
7
14
  export interface DoctorResult {
8
15
  filesScanned: string[];
@@ -38,10 +38,17 @@ function extractHooksFromSettings(filePath) {
38
38
  const hookList = matcher?.hooks;
39
39
  if (!Array.isArray(hookList))
40
40
  continue;
41
+ const matcherName = matcher?.matcher;
41
42
  for (const h of hookList) {
42
43
  const command = h?.command;
43
44
  if (typeof command === "string") {
44
- entries.push({ sourceFile: filePath, event, command, flags: flagCommand(command) });
45
+ entries.push({
46
+ sourceFile: filePath,
47
+ event,
48
+ command,
49
+ flags: flagCommand(command),
50
+ matcher: typeof matcherName === "string" ? matcherName : undefined,
51
+ });
45
52
  }
46
53
  }
47
54
  }
@@ -0,0 +1,59 @@
1
+ import type { Rule } from "../types.js";
2
+ import type { HookEntry } from "./doctor.js";
3
+ /**
4
+ * Which of your rules has anything behind it?
5
+ *
6
+ * Two people arrived at this question independently within a week. From a
7
+ * dev.to thread: "does the parser separate a rule that's only a sentence from
8
+ * the same rule with a hook behind it? In a file they look identical, and
9
+ * only the hooked one fails loudly when it's ignored." And from
10
+ * anthropics/claude-code#90542, someone with a hook-heavy setup reporting
11
+ * 13 out of 13 held where enforcement existed, and 1 failure out of 1
12
+ * opportunity where the same decision was written as prose.
13
+ *
14
+ * The tool could already list hooks (`doctor`) and list rules (`check`).
15
+ * Nothing joined them, so it could tell you what your hooks were and what
16
+ * your rules were, and not which rules were load-bearing.
17
+ *
18
+ * WHAT THIS CANNOT DO, STATED UP FRONT
19
+ *
20
+ * It cannot prove a hook enforces a rule. A hook's command is usually a path
21
+ * to a script this tool does not read, so the only machine-readable evidence
22
+ * available is the event it fires on, the matcher it is registered under, and
23
+ * whatever literal text sits in the command string.
24
+ *
25
+ * That means both errors are possible and neither is rare:
26
+ * - a hook can enforce a rule while sharing no text with it at all
27
+ * - sharing text proves nothing about whether the hook actually guards it
28
+ *
29
+ * So this reports a POSSIBLE backing and nothing stronger. It is a starting
30
+ * point for a person who knows their own setup, in the same spirit as
31
+ * `--show-skipped`: surface what the tool can see, and let the judgement sit
32
+ * with whoever can actually make it.
33
+ */
34
+ export type Backing = "possiblyGuarded" | "noHookFound";
35
+ export interface RuleCoverage {
36
+ rule: Rule;
37
+ backing: Backing;
38
+ /** Hooks sharing a literal token with this rule. Empty when noHookFound. */
39
+ matchedHooks: HookEntry[];
40
+ /** The tokens that matched, so the user can see WHY it was linked. */
41
+ sharedTokens: string[];
42
+ }
43
+ export declare function isBlockingEvent(event: string): boolean;
44
+ export declare function distinctiveTokens(text: string): string[];
45
+ /**
46
+ * Links rules to hooks by shared literal text.
47
+ *
48
+ * Only hooks on blocking events are considered: a rule "backed" by a hook
49
+ * that cannot refuse anything is not backed in the sense being asked about.
50
+ */
51
+ export declare function correlate(rules: Rule[], hooks: HookEntry[]): RuleCoverage[];
52
+ export interface CoverageSummary {
53
+ totalRules: number;
54
+ possiblyGuarded: number;
55
+ noHookFound: number;
56
+ blockingHooks: number;
57
+ nonBlockingHooks: number;
58
+ }
59
+ export declare function summarise(coverage: RuleCoverage[], hooks: HookEntry[]): CoverageSummary;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Events that can actually refuse something. A hook on an event that cannot
3
+ * block may still be useful — logging, injecting context — but it cannot make
4
+ * a rule fail loudly, which is the question being asked here.
5
+ *
6
+ * Taken from the documented hook reference. PostToolUse is deliberately
7
+ * absent: it runs after the call has already succeeded.
8
+ */
9
+ const BLOCKING_EVENTS = new Set([
10
+ "PreToolUse",
11
+ "UserPromptSubmit",
12
+ "UserPromptExpansion",
13
+ "Stop",
14
+ "SubagentStop",
15
+ "PostToolBatch",
16
+ "TeammateIdle",
17
+ "TaskCreated",
18
+ "TaskCompleted",
19
+ "ConfigChange",
20
+ "WorktreeCreate",
21
+ "PreModelSwitch",
22
+ ]);
23
+ export function isBlockingEvent(event) {
24
+ return BLOCKING_EVENTS.has(event);
25
+ }
26
+ /**
27
+ * Distinctive literals a rule names: backtick-quoted spans, and bare tokens
28
+ * that look like a command, path or flag.
29
+ *
30
+ * Short and common tokens are dropped. Linking a rule to a hook because both
31
+ * contain "the" or "test" would produce confident-looking nonsense, which is
32
+ * worse than reporting nothing — the same reason a text match in this tool
33
+ * can never produce a confident failure.
34
+ */
35
+ const MIN_TOKEN_LENGTH = 4;
36
+ const TOO_COMMON = new Set([
37
+ "test", "tests", "file", "files", "code", "main", "true", "false", "null",
38
+ "type", "types", "name", "path", "line", "lines", "data", "when", "then",
39
+ "with", "this", "that", "from", "into", "must", "should", "never", "always",
40
+ "bash", "node", "json", "yaml", "text", "user", "call", "tool", "hook",
41
+ ]);
42
+ export function distinctiveTokens(text) {
43
+ const found = new Set();
44
+ for (const [, span] of text.matchAll(/`([^`]{2,80})`/g)) {
45
+ const cleaned = span.trim().toLowerCase();
46
+ if (cleaned.length >= MIN_TOKEN_LENGTH && !TOO_COMMON.has(cleaned))
47
+ found.add(cleaned);
48
+ }
49
+ // Bare tokens shaped like a command, path or flag.
50
+ for (const [, tok] of text.matchAll(/(?:^|\s)(--?[a-z][\w-]{2,}|[\w.-]*\/[\w./*-]+|[\w-]+\.[a-z]{2,4})\b/gi)) {
51
+ const cleaned = tok.trim().toLowerCase();
52
+ if (cleaned.length >= MIN_TOKEN_LENGTH && !TOO_COMMON.has(cleaned))
53
+ found.add(cleaned);
54
+ }
55
+ return [...found];
56
+ }
57
+ /**
58
+ * Links rules to hooks by shared literal text.
59
+ *
60
+ * Only hooks on blocking events are considered: a rule "backed" by a hook
61
+ * that cannot refuse anything is not backed in the sense being asked about.
62
+ */
63
+ export function correlate(rules, hooks) {
64
+ const blocking = hooks.filter((h) => isBlockingEvent(h.event));
65
+ const hookText = blocking.map((h) => ({
66
+ hook: h,
67
+ haystack: `${h.command} ${h.matcher ?? ""}`.toLowerCase(),
68
+ }));
69
+ return rules.map((rule) => {
70
+ const tokens = distinctiveTokens(`${rule.title} ${rule.text ?? ""}`);
71
+ const matchedHooks = [];
72
+ const sharedTokens = new Set();
73
+ for (const { hook, haystack } of hookText) {
74
+ const hits = tokens.filter((t) => haystack.includes(t));
75
+ if (hits.length > 0) {
76
+ matchedHooks.push(hook);
77
+ hits.forEach((h) => sharedTokens.add(h));
78
+ }
79
+ }
80
+ return {
81
+ rule,
82
+ backing: matchedHooks.length > 0 ? "possiblyGuarded" : "noHookFound",
83
+ matchedHooks,
84
+ sharedTokens: [...sharedTokens],
85
+ };
86
+ });
87
+ }
88
+ export function summarise(coverage, hooks) {
89
+ return {
90
+ totalRules: coverage.length,
91
+ possiblyGuarded: coverage.filter((c) => c.backing === "possiblyGuarded").length,
92
+ noHookFound: coverage.filter((c) => c.backing === "noHookFound").length,
93
+ blockingHooks: hooks.filter((h) => isBlockingEvent(h.event)).length,
94
+ nonBlockingHooks: hooks.filter((h) => !isBlockingEvent(h.event)).length,
95
+ };
96
+ }
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { parseClaudeMd } from "./parsers/readClaudeMd.js";
9
9
  import { readLatestTranscript, readTranscriptFromFile, findLatestSessionFile } from "./parsers/transcriptParser.js";
10
10
  import { loadRules } from "./rules.js";
11
11
  import { classifyRules } from "./checks/classify.js";
12
+ import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
12
13
  import { runDeterministicChecks } from "./checks/deterministicChecks.js";
13
14
  import { runIfEditThenTestChecks } from "./checks/ifEditThenTest.js";
14
15
  import { runGitBranchPolicyChecks } from "./checks/gitBranchPolicy.js";
@@ -25,6 +26,7 @@ import { generateDigest } from "./digest.js";
25
26
  import { enableSchedule, disableSchedule, scheduleStatus } from "./schedule.js";
26
27
  import { findSplitBrainConflicts } from "./checks/splitBrain.js";
27
28
  import { runDoctor } from "./checks/doctor.js";
29
+ import { correlate, summarise } from "./checks/hookCoverage.js";
28
30
  import { sendTelemetryPing, isTelemetryEnabled } from "./telemetry.js";
29
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
32
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
@@ -178,7 +180,33 @@ async function runCheck(opts) {
178
180
  return;
179
181
  }
180
182
  }
181
- const classifications = classifyRules(rules);
183
+ /**
184
+ * The classifier's guess is corrected here, before anything is checked.
185
+ *
186
+ * It looks for a list of English instruction words and is wrong in both
187
+ * directions: imperative verbs are not a closed class, and the list cannot
188
+ * match a rule written in another language. This project's own Rule 5 was
189
+ * filed as documentation and never checked, because it says "say so
190
+ * explicitly" and `say` is not on the list.
191
+ *
192
+ * A rule the user re-includes becomes a judgment result rather than a
193
+ * confident verdict. Knowing it IS a rule says nothing about which check
194
+ * can settle it, and guessing would be exactly the behaviour this tool
195
+ * exists to avoid — so it reports as needing a person, which is honest and
196
+ * strictly better than being dropped in silence.
197
+ */
198
+ const overrides = loadOverrides(cwd);
199
+ const classifications = classifyRules(rules).map((c) => {
200
+ const decision = overrides.get(ruleFingerprint(c.rule))?.decision;
201
+ if (!decision)
202
+ return c;
203
+ if (decision === "notARule")
204
+ return { kind: "notARule", rule: c.rule };
205
+ return c.kind === "notARule" ? { kind: "judgment", rule: c.rule } : c;
206
+ });
207
+ // An override that stopped matching usually means the rule was reworded.
208
+ // Saying so beats letting someone assume a correction is still in force.
209
+ const stale = staleOverrides(overrides, rules);
182
210
  const deterministic = classifications.filter((c) => c.kind === "deterministic");
183
211
  const ifEditThenTest = classifications.filter((c) => c.kind === "ifEditThenTest");
184
212
  const gitBranchPolicy = classifications.filter((c) => c.kind === "gitBranchPolicy");
@@ -249,16 +277,29 @@ async function runCheck(opts) {
249
277
  console.log(`\nSkipped as documentation:\n`);
250
278
  for (const { rule } of notARule) {
251
279
  const label = rule.title.replace(/\s+/g, " ").trim();
252
- console.log(` [${rule.id}] ${label.slice(0, 110)}`);
280
+ // The handle is a content hash, never the rule id: ids are positional
281
+ // and every edit above a rule renumbers it, so a correction keyed on
282
+ // one would silently reattach itself to a different rule.
283
+ //
284
+ // The source is shown because rules are read from the global file as
285
+ // well as the project's. Without it, someone editing their project
286
+ // CLAUDE.md to fix an item that came from ~/.claude/CLAUDE.md gets no
287
+ // explanation for why nothing changed.
288
+ const where = rule.source === "global" ? " (global)" : "";
289
+ console.log(` [${ruleFingerprint(rule)}]${where} ${label.slice(0, 100)}`);
253
290
  }
254
291
  console.log(`\nIf any of those is actually a rule, the classifier was wrong. It looks for` +
255
292
  `\nEnglish instruction words, so a rule written another way — or in another` +
256
- `\nlanguage — can land here. Worth a look; you know your rules, it doesn't.`);
293
+ `\nlanguage — can land here. Worth a look; you know your rules, it doesn't.` +
294
+ `\n\nTo fix one permanently: rulereceipt rules --include <handle>`);
257
295
  }
258
296
  else {
259
297
  console.log(`Run with --show-skipped to see them.`);
260
298
  }
261
299
  }
300
+ if (stale.length > 0) {
301
+ console.log(`\n(${stale.length} saved correction${stale.length === 1 ? "" : "s"} no longer match any rule in this project — the rule was probably reworded. Run \`rulereceipt rules --list\` to see them.)`);
302
+ }
262
303
  appendHistory(results, sessionFilePath);
263
304
  if (share) {
264
305
  await shareResults(results);
@@ -336,6 +377,131 @@ program
336
377
  process.exitCode = 1;
337
378
  });
338
379
  });
380
+ /**
381
+ * Corrections to what the classifier thinks is a rule.
382
+ *
383
+ * Writes, deliberately and only from here. `check` never writes anything —
384
+ * a tool that puts files in someone's project without being asked is one
385
+ * people stop trusting, and that guarantee is worth more than the
386
+ * convenience of auto-saving a correction.
387
+ */
388
+ /**
389
+ * Which rules have a hook behind them, and — said plainly — which of that is
390
+ * guesswork.
391
+ *
392
+ * A hook's command is normally a path to a script this tool does not read, so
393
+ * the only evidence available is the event, the matcher, and literal text in
394
+ * the command string. Both error directions are live: a hook can guard a rule
395
+ * while sharing no wording with it, and shared wording proves nothing. The
396
+ * output says so every time, because it reads like an audit and people
397
+ * believe audits.
398
+ */
399
+ function runCoverage() {
400
+ const cwd = process.cwd();
401
+ const rules = loadRules(cwd);
402
+ if (rules.length === 0) {
403
+ console.log("No CLAUDE.md or AGENTS.md found, so there are no rules to check hooks against.");
404
+ return;
405
+ }
406
+ const hooks = runDoctor(cwd).hooks;
407
+ const coverage = correlate(rules, hooks);
408
+ const s = summarise(coverage, hooks);
409
+ console.log(`Rule coverage against configured hooks\n`);
410
+ console.log(` ${hooks.length} hook${hooks.length === 1 ? "" : "s"} configured · ${s.blockingHooks} can refuse something · ${s.nonBlockingHooks} cannot`);
411
+ console.log(` ${s.possiblyGuarded} of ${s.totalRules} rules name something a blocking hook also names`);
412
+ console.log(` ${s.noHookFound} of ${s.totalRules} have no hook this can connect them to\n`);
413
+ const guarded = coverage.filter((c) => c.backing === "possiblyGuarded");
414
+ if (guarded.length > 0) {
415
+ console.log(`Possibly guarded:\n`);
416
+ for (const c of guarded) {
417
+ console.log(` ${c.rule.title.replace(/\s+/g, " ").trim().slice(0, 80)}`);
418
+ for (const h of c.matchedHooks) {
419
+ // Relative to the working directory when possible: an absolute path
420
+ // is noise in a report someone may paste elsewhere, and can leak a
421
+ // home directory name.
422
+ const where = h.sourceFile.startsWith(cwd) ? h.sourceFile.slice(cwd.length + 1) : h.sourceFile;
423
+ console.log(` ${h.event} hook in ${where}${h.matcher ? ` (matcher: ${h.matcher})` : ""}`);
424
+ }
425
+ console.log(` linked on: ${c.sharedTokens.map((t) => `"${t}"`).join(", ")}`);
426
+ console.log("");
427
+ }
428
+ }
429
+ // Project rules first. Someone running this inside a project can act on
430
+ // their own rules; the ones from ~/.claude/CLAUDE.md apply everywhere and
431
+ // are usually not what they came here to look at. Listing global rules
432
+ // first buried every project rule behind them.
433
+ const unbacked = coverage
434
+ .filter((c) => c.backing === "noHookFound")
435
+ .sort((a, b) => Number(a.rule.source === "global") - Number(b.rule.source === "global"));
436
+ if (unbacked.length > 0) {
437
+ const projectCount = unbacked.filter((c) => c.rule.source !== "global").length;
438
+ console.log(`No hook found for ${unbacked.length} rule${unbacked.length === 1 ? "" : "s"} (${projectCount} in this project), including:\n`);
439
+ for (const c of unbacked.slice(0, 10)) {
440
+ const where = c.rule.source === "global" ? " (global)" : "";
441
+ console.log(` ${c.rule.title.replace(/\s+/g, " ").trim().slice(0, 76)}${where}`);
442
+ }
443
+ if (unbacked.length > 10)
444
+ console.log(` ...and ${unbacked.length - 10} more`);
445
+ console.log("");
446
+ }
447
+ console.log(`This is text overlap, and it is not proof. A hook can guard a rule without`);
448
+ console.log(`sharing any wording with it, and shared wording does not mean the hook`);
449
+ console.log(`guards it — the command is usually a script this tool does not read. Treat`);
450
+ console.log(`the links above as somewhere to look, and the rest as prose until you have`);
451
+ console.log(`checked otherwise.`);
452
+ if (s.nonBlockingHooks > 0) {
453
+ console.log(`\nHooks on events that cannot refuse anything were not counted. They can`);
454
+ console.log(`log or inject context, but they cannot make a rule fail when it is ignored.`);
455
+ }
456
+ }
457
+ async function runRules(opts) {
458
+ if (opts.coverage) {
459
+ runCoverage();
460
+ return;
461
+ }
462
+ const cwd = process.cwd();
463
+ const rules = loadRules(cwd);
464
+ const overrides = loadOverrides(cwd);
465
+ const findRule = (handle) => rules.find((r) => ruleFingerprint(r) === handle);
466
+ if (opts.include || opts.exclude) {
467
+ const handle = (opts.include ?? opts.exclude);
468
+ const decision = opts.include ? "rule" : "notARule";
469
+ const rule = findRule(handle);
470
+ if (!rule) {
471
+ console.error(`No rule in this project has the handle ${handle}.`);
472
+ console.error(`Handles come from \`rulereceipt check --show-skipped\`, and change if the rule's wording changes.`);
473
+ process.exitCode = 1;
474
+ return;
475
+ }
476
+ saveOverride(cwd, { hash: handle, decision, title: rule.title.replace(/\s+/g, " ").trim().slice(0, 200) });
477
+ const verb = opts.include ? "will now be checked" : "will no longer be checked";
478
+ console.log(`Saved to ${OVERRIDES_PATH}.`);
479
+ console.log(` "${rule.title.replace(/\s+/g, " ").trim().slice(0, 90)}" ${verb}.`);
480
+ if (opts.include) {
481
+ console.log(`\nIt will report as needing your judgment. Knowing it is a rule says nothing`);
482
+ console.log(`about which check can settle it, and guessing is what this tool avoids.`);
483
+ }
484
+ return;
485
+ }
486
+ if (opts.clear) {
487
+ console.log(clearOverride(cwd, opts.clear) ? `Removed the correction for ${opts.clear}.` : `No correction stored for ${opts.clear}.`);
488
+ return;
489
+ }
490
+ if (overrides.size === 0) {
491
+ console.log(`No corrections stored for this project.`);
492
+ console.log(`\nRun \`rulereceipt check --show-skipped\` to see what the classifier excluded,`);
493
+ console.log(`then \`rulereceipt rules --include <handle>\` for anything that is really a rule.`);
494
+ return;
495
+ }
496
+ const stale = new Set(staleOverrides(overrides, rules).map((o) => o.hash));
497
+ console.log(`Corrections in ${OVERRIDES_PATH}:\n`);
498
+ for (const o of overrides.values()) {
499
+ const mark = o.decision === "rule" ? "checked" : "ignored";
500
+ const note = stale.has(o.hash) ? " (no longer matches any rule — reworded?)" : "";
501
+ console.log(` ${o.hash} ${mark.padEnd(8)} ${o.title.slice(0, 70)}${note}`);
502
+ }
503
+ console.log(`\nRemove one with: rulereceipt rules --clear <handle>`);
504
+ }
339
505
  async function runLint(markdown, llm) {
340
506
  const cwd = process.cwd();
341
507
  const claudeMdPath = join(cwd, "CLAUDE.md");
@@ -416,6 +582,20 @@ program
416
582
  process.exitCode = 1;
417
583
  }
418
584
  });
585
+ program
586
+ .command("rules")
587
+ .description("correct what the classifier treats as a rule. Handles come from `check --show-skipped`.")
588
+ .option("--include <handle>", "treat this item as a real rule and check it from now on")
589
+ .option("--exclude <handle>", "treat this item as documentation and stop reporting it")
590
+ .option("--clear <handle>", "remove a stored correction")
591
+ .option("--list", "show stored corrections (the default when no other flag is given)")
592
+ .option("--coverage", "show which rules a configured hook might actually be enforcing, and which are prose only")
593
+ .action((opts) => {
594
+ runRules(opts).catch((err) => {
595
+ console.error("Something went wrong:", err instanceof Error ? err.message : err);
596
+ process.exitCode = 1;
597
+ });
598
+ });
419
599
  program
420
600
  .command("lint")
421
601
  .description("Find contradictions between this project's CLAUDE.md and AGENTS.md")
@@ -0,0 +1,62 @@
1
+ import type { Rule } from "./types.js";
2
+ /**
3
+ * User corrections to the tool's guess about what counts as a rule.
4
+ *
5
+ * The classifier decides which lines in a rules file are genuine instructions
6
+ * and which are documentation, using a list of English instruction words. It
7
+ * is wrong in both directions and cannot stop being wrong: imperative verbs
8
+ * are not a closed class, and an English word list cannot match a rule
9
+ * written in another language — measured across 559 public files, non-English
10
+ * content is dropped at 97.5% against a 63.4% baseline.
11
+ *
12
+ * This project's own Rule 5, "No silent scope changes", was filed as
13
+ * documentation and never checked in any report the tool produced, because
14
+ * the rule says "say so explicitly" and `say` is not on that list.
15
+ *
16
+ * So the classifier does not need to be right. It needs to be CORRECTABLE,
17
+ * and to stay corrected. `--show-skipped` makes a mistake visible; this makes
18
+ * the fix permanent. That combination has no coverage gap — it needs no API
19
+ * key, works in any language and any phrasing, and leaves the judgement with
20
+ * whoever wrote the rules.
21
+ */
22
+ export type Decision = "rule" | "notARule";
23
+ export interface Override {
24
+ /** Content hash of the rule — see ruleFingerprint. */
25
+ hash: string;
26
+ decision: Decision;
27
+ /** Stored for humans reading the file, and to explain a stale entry. */
28
+ title: string;
29
+ }
30
+ export declare const OVERRIDES_PATH: string;
31
+ /**
32
+ * Identifies a rule by its CONTENT, never by its id or position.
33
+ *
34
+ * Rule ids are positional. Editing a rules file renumbers everything after
35
+ * the edit, and the same is true of any upstream change: a parser fix on
36
+ * 2026-09-04 shifted the corpus enough that a seeded sample redrawn at the
37
+ * same seed returned 92 different rules out of 100. An override keyed on
38
+ * position would silently reattach a decision to a rule nobody ever judged,
39
+ * which is the exact failure this feature exists to prevent.
40
+ *
41
+ * Whitespace is normalised so reflowing a paragraph doesn't drop the
42
+ * override, but wording is not: if the rule's words change, it is a different
43
+ * rule and deserves a fresh look.
44
+ */
45
+ export declare function ruleFingerprint(rule: Rule): string;
46
+ /** Reads overrides for a project. Absent or unreadable file means none. */
47
+ export declare function loadOverrides(cwd: string): Map<string, Override>;
48
+ /**
49
+ * Writes an override. Only ever called from an explicit command — `check`
50
+ * never writes anything, which is a stated guarantee of this tool.
51
+ */
52
+ export declare function saveOverride(cwd: string, entry: Override): void;
53
+ /** Removes an override, returning whether one was actually there. */
54
+ export declare function clearOverride(cwd: string, hash: string): boolean;
55
+ /**
56
+ * Overrides recorded against rules that are no longer present.
57
+ *
58
+ * Surfaced rather than silently ignored: an override that stopped matching
59
+ * usually means the rule was reworded, and the user should know their
60
+ * correction is no longer being applied instead of assuming it still is.
61
+ */
62
+ export declare function staleOverrides(overrides: Map<string, Override>, rules: Rule[]): Override[];
@@ -0,0 +1,85 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ export const OVERRIDES_PATH = join(".rulereceipt", "overrides.json");
5
+ /**
6
+ * Identifies a rule by its CONTENT, never by its id or position.
7
+ *
8
+ * Rule ids are positional. Editing a rules file renumbers everything after
9
+ * the edit, and the same is true of any upstream change: a parser fix on
10
+ * 2026-09-04 shifted the corpus enough that a seeded sample redrawn at the
11
+ * same seed returned 92 different rules out of 100. An override keyed on
12
+ * position would silently reattach a decision to a rule nobody ever judged,
13
+ * which is the exact failure this feature exists to prevent.
14
+ *
15
+ * Whitespace is normalised so reflowing a paragraph doesn't drop the
16
+ * override, but wording is not: if the rule's words change, it is a different
17
+ * rule and deserves a fresh look.
18
+ */
19
+ export function ruleFingerprint(rule) {
20
+ const normalised = `${rule.title}\n${rule.text ?? ""}`.replace(/\s+/g, " ").trim();
21
+ return createHash("sha256").update(normalised).digest("hex").slice(0, 12);
22
+ }
23
+ /** Reads overrides for a project. Absent or unreadable file means none. */
24
+ export function loadOverrides(cwd) {
25
+ const path = join(cwd, OVERRIDES_PATH);
26
+ const map = new Map();
27
+ if (!existsSync(path))
28
+ return map;
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
31
+ if (!Array.isArray(parsed?.overrides))
32
+ return map;
33
+ for (const o of parsed.overrides) {
34
+ if (typeof o?.hash === "string" && (o.decision === "rule" || o.decision === "notARule")) {
35
+ map.set(o.hash, { hash: o.hash, decision: o.decision, title: String(o.title ?? "") });
36
+ }
37
+ }
38
+ }
39
+ catch {
40
+ // A corrupt overrides file must not take the whole check down. The user
41
+ // loses their corrections for this run, which is visible in the report,
42
+ // rather than losing the report entirely.
43
+ }
44
+ return map;
45
+ }
46
+ /**
47
+ * Writes an override. Only ever called from an explicit command — `check`
48
+ * never writes anything, which is a stated guarantee of this tool.
49
+ */
50
+ export function saveOverride(cwd, entry) {
51
+ const path = join(cwd, OVERRIDES_PATH);
52
+ const existing = loadOverrides(cwd);
53
+ existing.set(entry.hash, entry);
54
+ const out = {
55
+ version: 1,
56
+ overrides: [...existing.values()].sort((a, b) => a.hash.localeCompare(b.hash)),
57
+ };
58
+ mkdirSync(dirname(path), { recursive: true });
59
+ writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`);
60
+ }
61
+ /** Removes an override, returning whether one was actually there. */
62
+ export function clearOverride(cwd, hash) {
63
+ const existing = loadOverrides(cwd);
64
+ if (!existing.delete(hash))
65
+ return false;
66
+ const out = {
67
+ version: 1,
68
+ overrides: [...existing.values()].sort((a, b) => a.hash.localeCompare(b.hash)),
69
+ };
70
+ const path = join(cwd, OVERRIDES_PATH);
71
+ mkdirSync(dirname(path), { recursive: true });
72
+ writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`);
73
+ return true;
74
+ }
75
+ /**
76
+ * Overrides recorded against rules that are no longer present.
77
+ *
78
+ * Surfaced rather than silently ignored: an override that stopped matching
79
+ * usually means the rule was reworded, and the user should know their
80
+ * correction is no longer being applied instead of assuming it still is.
81
+ */
82
+ export function staleOverrides(overrides, rules) {
83
+ const live = new Set(rules.map(ruleFingerprint));
84
+ return [...overrides.values()].filter((o) => !live.has(o.hash));
85
+ }
@@ -42,9 +42,45 @@ const PLAIN_HEADER = /^#{1,6}\s+(.+)$/;
42
42
  const BULLET_ITEM = /^\s*[-*+]\s+(.+)$/;
43
43
  const SETEXT_H1_UNDERLINE = /^=+\s*$/;
44
44
  const SETEXT_H2_UNDERLINE = /^-{2,}\s*$/;
45
+ /**
46
+ * A fenced code block is content, not structure.
47
+ *
48
+ * Found by auditing 559 real rules files: 588 rules came out holding an
49
+ * unclosed fence, meaning the parser had split them mid-block. Markdown
50
+ * headings and bullets appear inside code samples constantly — a shell
51
+ * comment starts with `#`, a YAML list item starts with `-` — and treating
52
+ * those as rule boundaries does three things at once. It truncates the real
53
+ * rule at the fence, it fabricates rules out of the sample's contents, and
54
+ * it lifts commands out of a "here is what NOT to do" example into a rule
55
+ * body, where the structured checks can then read them as though they were
56
+ * the rule itself.
57
+ *
58
+ * Matches both fence styles CommonMark allows, and requires the closing
59
+ * fence to use the same character as the opening one, so a ``` inside a
60
+ * ~~~ block does not close it.
61
+ */
62
+ const FENCE_LINE = /^\s*(`{3,}|~{3,})/;
45
63
  function normalizeSetextHeaders(lines) {
46
64
  const out = [...lines];
65
+ // Fence-aware for the same reason as the main pass: a row of dashes inside
66
+ // a code sample is not a setext underline.
67
+ let inFence = false;
68
+ let fenceChar = "";
47
69
  for (let i = 0; i < out.length - 1; i++) {
70
+ const fence = out[i].match(FENCE_LINE);
71
+ if (fence) {
72
+ const ch = fence[1][0];
73
+ if (!inFence) {
74
+ inFence = true;
75
+ fenceChar = ch;
76
+ }
77
+ else if (ch === fenceChar) {
78
+ inFence = false;
79
+ }
80
+ continue;
81
+ }
82
+ if (inFence)
83
+ continue;
48
84
  const title = out[i];
49
85
  if (title.trim() === "")
50
86
  continue;
@@ -101,6 +137,8 @@ export function parseClaudeMdText(raw, source) {
101
137
  let sectionCount = 0;
102
138
  let sectionId = "S0"; // "S0" before any header is seen; null while a header is pending its first rule
103
139
  let bulletIndex = 0;
140
+ let inFence = false;
141
+ let fenceChar = "";
104
142
  const flush = () => {
105
143
  if (current) {
106
144
  current.text = bodyLines.join("\n").trim();
@@ -116,7 +154,40 @@ export function parseClaudeMdText(raw, source) {
116
154
  }
117
155
  return sectionId;
118
156
  };
157
+ /** Adds a line to whatever rule is open, promoting a pending section if needed. */
158
+ const appendBody = (line) => {
159
+ if (currentIsMarkedRule) {
160
+ bodyLines.push(line);
161
+ }
162
+ else if (pendingSectionTitle !== null && line.trim() !== "") {
163
+ current = { id: `${assignSectionId()}.0`, title: pendingSectionTitle, text: "", source };
164
+ bodyLines = [line];
165
+ pendingSectionTitle = null;
166
+ }
167
+ else if (current) {
168
+ bodyLines.push(line);
169
+ }
170
+ };
119
171
  for (const line of lines) {
172
+ // Fence state is tracked before any structural match, so nothing inside a
173
+ // code block is ever read as a heading, a bullet, or a rule marker.
174
+ const fence = line.match(FENCE_LINE);
175
+ if (fence) {
176
+ const ch = fence[1][0];
177
+ if (!inFence) {
178
+ inFence = true;
179
+ fenceChar = ch;
180
+ }
181
+ else if (ch === fenceChar) {
182
+ inFence = false;
183
+ }
184
+ appendBody(line);
185
+ continue;
186
+ }
187
+ if (inFence) {
188
+ appendBody(line);
189
+ continue;
190
+ }
120
191
  const numbered = line.match(NUMBERED_HEADER);
121
192
  if (numbered) {
122
193
  flush();
@@ -154,17 +225,7 @@ export function parseClaudeMdText(raw, source) {
154
225
  }
155
226
  continue;
156
227
  }
157
- if (currentIsMarkedRule) {
158
- bodyLines.push(line);
159
- }
160
- else if (pendingSectionTitle !== null && line.trim() !== "") {
161
- current = { id: `${assignSectionId()}.0`, title: pendingSectionTitle, text: "", source };
162
- bodyLines = [line];
163
- pendingSectionTitle = null;
164
- }
165
- else if (current) {
166
- bodyLines.push(line);
167
- }
228
+ appendBody(line);
168
229
  }
169
230
  flush();
170
231
  if (rules.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
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",
@@ -38,7 +38,8 @@
38
38
  "test:watch": "vitest",
39
39
  "lint": "eslint src tests",
40
40
  "prepublishOnly": "npm run build && npm test",
41
- "build:checker": "esbuild src/browser/analyze.ts --bundle --format=esm --minify --outfile=landing/checker.js"
41
+ "build:checker": "esbuild src/browser/analyze.ts --bundle --format=esm --minify --outfile=landing/checker.js",
42
+ "verify": "npm run lint && npm run typecheck && npm test"
42
43
  },
43
44
  "engines": {
44
45
  "node": ">=18"