rulereceipt 0.1.22 → 0.1.24

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
@@ -93,6 +93,38 @@ on a session it never found.
93
93
  For most people the honest answer is simpler: run `rulereceipt check --html`
94
94
  locally and attach the report to the PR.
95
95
 
96
+ ## Correcting a misclassification
97
+
98
+ If the tool treats something in your rules file as a rule when it isn't,
99
+ create `.rulereceipt.json` in your project:
100
+
101
+ ```json
102
+ {
103
+ "overrides": [
104
+ { "rule": "12", "reason": "changelog entry, not a rule", "date": "2026-08-31" }
105
+ ]
106
+ }
107
+ ```
108
+
109
+ Commit it. An override should be visible in code review, not just in the
110
+ report.
111
+
112
+ **An override cannot hide a violation, by design.** The check still runs,
113
+ keeps its real result, and the override only adds a label. So the report
114
+ shows the rule, its true result, your reason, and a line saying what the
115
+ result would have been without the override. The headline count includes
116
+ overridden failures, and the exit code still fails on them — otherwise CI
117
+ would be the loophole.
118
+
119
+ The worst an override can do is draw a labelled box around a real
120
+ violation and sign it with your reason. That leaves a reader better
121
+ informed than a plain failure would, not worse.
122
+
123
+ A reason is required; an override without one is refused rather than
124
+ applied quietly. If a rule id exists in both your global and project
125
+ rules files, write `"project:12"` or `"global:12"` — a bare id that
126
+ matches both is refused rather than silently disabling both.
127
+
96
128
  ## Sharing a report
97
129
 
98
130
  `rulereceipt check --html` writes one self-contained HTML file. No
@@ -128,6 +160,7 @@ rulereceipt check --html # write a shareable single-file HTML report you c
128
160
  rulereceipt check --html report.html # ...to a specific path
129
161
  rulereceipt check --require-session # fail if there's no session, instead of passing silently
130
162
  rulereceipt check --exit-zero # report failures without failing the build
163
+ # .rulereceipt.json # mark a misclassified item as not-a-rule (see below)
131
164
  rulereceipt check --llm # opt-in: grade judgment rules with your own Claude key
132
165
  rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
133
166
  rulereceipt check --telemetry # opt-in: send one random per-machine ID
@@ -31,7 +31,49 @@ const IMPERATIVE_INSTRUCTION = /(?:^|[.;:!?]\s+|^\s*[-*+]\s*|\n\s*[-*+]\s*)(use|
31
31
  * with no instruction in it has nothing to check compliance against,
32
32
  * whatever its punctuation.
33
33
  */
34
+ /**
35
+ * A section whose TITLE announces a record of something that happened —
36
+ * an incident, a postmortem, a retrospective. These are written to
37
+ * explain history, not to instruct the agent.
38
+ *
39
+ * Found by running this tool on a real session (2026-08-31): a section
40
+ * titled "Real incident (2026-08-28): Vercel had the same office/personal
41
+ * mixup" was enforced as a REQUIRE rule whose pattern was an employer
42
+ * name pulled out of the narrative, so the report announced FOLLOWED
43
+ * because that name appeared somewhere in the session. The rule being
44
+ * "satisfied" was a sentence describing a past mistake.
45
+ *
46
+ * The existing directive test cannot catch these, and correctly so: a
47
+ * good incident note ends with the lesson ("Verify with `vercel whoami`
48
+ * before every deploy"), so it genuinely does contain a directive. What
49
+ * the section IS gets announced by its title, which is where this looks.
50
+ *
51
+ * Kept to a small closed class of words that name a record of an event,
52
+ * in the same spirit as DIRECTIVE_LANGUAGE above — a bounded property of
53
+ * language, not an enumeration of document formats. Enumerating formats
54
+ * is the mistake this project already made once and wrote up publicly.
55
+ */
56
+ const EVENT_RECORD_TITLE = /\b(incident|post-?mortem|retro(spective)?|outage|what went wrong)\b/i;
57
+ /**
58
+ * A title that OPENS with an instruction is a rule, whatever it goes on
59
+ * to mention. "Never repeat the 2026-08-28 incident" is a directive that
60
+ * happens to name an incident; "Real incident (2026-08-28): ..." is a
61
+ * report that happens to contain the word never further along.
62
+ */
63
+ const TITLE_OPENS_WITH_DIRECTIVE = /^\s*[-*+\d.\s]*(never|always|must|do not|don't|dont|avoid|ensure|prefer|only|make sure|be sure)\b/i;
64
+ function isEventRecord(rule) {
65
+ if (TITLE_OPENS_WITH_DIRECTIVE.test(rule.title))
66
+ return false;
67
+ if (IMPERATIVE_INSTRUCTION.test(rule.title))
68
+ return false;
69
+ return EVENT_RECORD_TITLE.test(rule.title);
70
+ }
34
71
  function isNotARule(rule) {
72
+ // Checked before the directive test on purpose: an incident note that
73
+ // ends with its lesson contains a real directive, and would otherwise
74
+ // be enforced as though the history itself were the rule.
75
+ if (isEventRecord(rule))
76
+ return true;
35
77
  const combined = `${rule.title} ${rule.text}`;
36
78
  if (DIRECTIVE_LANGUAGE.test(combined))
37
79
  return false;
package/dist/cli.js CHANGED
@@ -26,6 +26,7 @@ import { enableSchedule, disableSchedule, scheduleStatus } from "./schedule.js";
26
26
  import { findSplitBrainConflicts } from "./checks/splitBrain.js";
27
27
  import { runDoctor } from "./checks/doctor.js";
28
28
  import { sendTelemetryPing, isTelemetryEnabled } from "./telemetry.js";
29
+ import { loadOverrides, resolveOverrides } from "./overrides.js";
29
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
31
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
31
32
  const SHARE_ENDPOINT = "https://rulereceipt.dev/api/share";
@@ -123,7 +124,9 @@ function writeHtmlReport(results, meta, cwd, target) {
123
124
  // name, an office email, and absolute paths. Home paths are redacted
124
125
  // automatically; nothing else can be, so say so plainly at the moment
125
126
  // the file is created rather than burying it in a policy page.
126
- console.log("Read it before you send it: it quotes your rule text and session evidence verbatim, so anything sensitive in your CLAUDE.md is in there too. (Home paths are shortened to ~.)");
127
+ console.log("\n⚠ This report includes your rule text and session evidence VERBATIM.\n" +
128
+ " Review it before sharing outside your team — only you know what's in your rules file.\n" +
129
+ " Nothing is auto-redacted: this tool cannot tell which of your own rules are sensitive.");
127
130
  }
128
131
  catch (err) {
129
132
  console.log(`\n(--html: couldn't write ${outPath} — ${err instanceof Error ? err.message : String(err)})`);
@@ -218,7 +221,22 @@ async function runCheck(opts) {
218
221
  // sends only a random install ID, never rule text or transcript content,
219
222
  // regardless of --llm.
220
223
  const judgmentResults = llm ? await runJudgmentChecks(judgment, events) : judgment.map(({ rule }) => needsLlmResult(rule));
221
- const results = [...deterministicResults, ...judgmentResults];
224
+ const computed = [...deterministicResults, ...judgmentResults];
225
+ // Applied AFTER every check has run, and it only attaches a label.
226
+ // Nothing is skipped and no status is changed: an override that could
227
+ // suppress a check would let anyone delete their own violations, which
228
+ // is precisely what this design refuses to allow. See src/overrides.ts.
229
+ // Resolved against the rules actually present, and scoped by source: a
230
+ // global and a project rules file can both define "Rule 1", and a bare
231
+ // id would otherwise silently disable both.
232
+ const { bySourceAndId: overrides, problems: overrideProblems } = resolveOverrides(loadOverrides(cwd), computed.map((r) => ({ ruleId: r.ruleId, ruleSource: r.ruleSource })));
233
+ const results = computed.map((r) => {
234
+ const o = overrides.get(`${r.ruleSource}:${r.ruleId}`);
235
+ return o ? { ...r, overriddenReason: o.reason, overriddenDate: o.date } : r;
236
+ });
237
+ for (const problem of overrideProblems) {
238
+ console.log(`\n(${problem})`);
239
+ }
222
240
  const meta = { sessionFilePath, ruleCount: results.length };
223
241
  const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
224
242
  console.log(reportText);
@@ -260,6 +278,9 @@ async function runCheck(opts) {
260
278
  // rules in a real CLAUDE.md need judgment, so without --llm they
261
279
  // legitimately report UNCLEAR. Gating on those would make every build
262
280
  // red on day one and the check would be deleted within a week.
281
+ // Deliberately reads .status, which an override never changes. If an
282
+ // overridden failure exited 0, CI would become the loophole this whole
283
+ // design exists to close: mark the rule, get a green build, done.
263
284
  if (!exitZero && results.some((r) => r.status === "FAIL")) {
264
285
  process.exitCode = 1;
265
286
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Per-project overrides: a way to say "this item in my rules file isn't
3
+ * actually a rule for my project" without waiting for an upstream release.
4
+ *
5
+ * THE SECURITY PROPERTY THAT MAKES THIS SAFE
6
+ *
7
+ * An override never changes whether a check runs, and never changes its
8
+ * result. The check executes exactly as it would have, keeps its real
9
+ * status, and the override only changes how the result is PRESENTED.
10
+ *
11
+ * That distinction is the whole design. The obvious version of this
12
+ * feature — "let the user mark a rule as not-a-rule, and skip it" — is
13
+ * not safe, and restricting the direction of the override does not make
14
+ * it safe:
15
+ *
16
+ * Rule: "Never commit directly to main"
17
+ * Agent: commits directly to main -> FAIL
18
+ * User: marks it "not a rule" -> not checked
19
+ * Report: the violation is gone
20
+ *
21
+ * Nobody had to claim a pass. They deleted the question instead. So this
22
+ * implementation refuses to delete questions. The worst an override can
23
+ * do is draw a labelled box around a real violation and sign it with a
24
+ * reason and a date — which leaves a reader BETTER informed than a plain
25
+ * failure would, not worse.
26
+ *
27
+ * Consequences enforced elsewhere, and deliberately not weakened:
28
+ * - the exit code still fails on an overridden violation (cli.ts), or
29
+ * CI becomes the loophole this whole design exists to close;
30
+ * - the report's headline verdict counts overridden failures, or the
31
+ * one line everyone reads would be the one line that lies.
32
+ *
33
+ * A reason is mandatory. An override without one is refused, not applied
34
+ * silently: it costs a sentence to write, and it is the part a reviewer
35
+ * actually reads.
36
+ *
37
+ * AMBIGUOUS IDS FAIL CLOSED. A global CLAUDE.md and a project one can
38
+ * legitimately both contain a "Rule 1" — the report already disambiguates
39
+ * those on collision. An override written as `"rule": "1"` when two rules
40
+ * share that id would silently disable BOTH, including one the user never
41
+ * meant to touch. Found while testing this feature against a real machine
42
+ * that has a global rules file. So a bare id is applied only when it is
43
+ * unambiguous; when it is not, the override is refused and the user is
44
+ * told to write `"project:1"` or `"global:1"` instead.
45
+ */
46
+ export declare const OVERRIDES_FILE = ".rulereceipt.json";
47
+ export interface RuleOverride {
48
+ /** Rule id as it appears in the report, e.g. "12" or "S7.0". */
49
+ rule: string;
50
+ /** Why this isn't a rule for this project. Required — never optional. */
51
+ reason: string;
52
+ /** Optional ISO date, shown in the report so a reader can judge staleness. */
53
+ date?: string;
54
+ }
55
+ export interface LoadedOverrides {
56
+ /** Keyed by the raw id as written by the user ("1" or "project:1"). */
57
+ byRuleId: Map<string, RuleOverride>;
58
+ /** Problems worth telling the user about — malformed entries, missing reasons. */
59
+ problems: string[];
60
+ }
61
+ /** A rule as the report identifies it, used to resolve an override target. */
62
+ export interface OverrideTarget {
63
+ ruleId: string;
64
+ ruleSource: "global" | "project";
65
+ }
66
+ /**
67
+ * Resolves override entries against the rules actually present, and
68
+ * refuses anything ambiguous rather than guessing which rule was meant.
69
+ * Returns a lookup keyed by `${source}:${id}`, plus any new problems.
70
+ */
71
+ export declare function resolveOverrides(loaded: LoadedOverrides, targets: OverrideTarget[]): {
72
+ bySourceAndId: Map<string, RuleOverride>;
73
+ problems: string[];
74
+ };
75
+ /**
76
+ * Reads and validates the override file. Never throws: a broken override
77
+ * file must not take down a check that would otherwise have worked, and
78
+ * an unreadable file means "no overrides", never "override everything".
79
+ * Every rejection is reported rather than swallowed, so a user whose
80
+ * override isn't working finds out why.
81
+ */
82
+ export declare function loadOverrides(cwd: string): LoadedOverrides;
@@ -0,0 +1,134 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Per-project overrides: a way to say "this item in my rules file isn't
5
+ * actually a rule for my project" without waiting for an upstream release.
6
+ *
7
+ * THE SECURITY PROPERTY THAT MAKES THIS SAFE
8
+ *
9
+ * An override never changes whether a check runs, and never changes its
10
+ * result. The check executes exactly as it would have, keeps its real
11
+ * status, and the override only changes how the result is PRESENTED.
12
+ *
13
+ * That distinction is the whole design. The obvious version of this
14
+ * feature — "let the user mark a rule as not-a-rule, and skip it" — is
15
+ * not safe, and restricting the direction of the override does not make
16
+ * it safe:
17
+ *
18
+ * Rule: "Never commit directly to main"
19
+ * Agent: commits directly to main -> FAIL
20
+ * User: marks it "not a rule" -> not checked
21
+ * Report: the violation is gone
22
+ *
23
+ * Nobody had to claim a pass. They deleted the question instead. So this
24
+ * implementation refuses to delete questions. The worst an override can
25
+ * do is draw a labelled box around a real violation and sign it with a
26
+ * reason and a date — which leaves a reader BETTER informed than a plain
27
+ * failure would, not worse.
28
+ *
29
+ * Consequences enforced elsewhere, and deliberately not weakened:
30
+ * - the exit code still fails on an overridden violation (cli.ts), or
31
+ * CI becomes the loophole this whole design exists to close;
32
+ * - the report's headline verdict counts overridden failures, or the
33
+ * one line everyone reads would be the one line that lies.
34
+ *
35
+ * A reason is mandatory. An override without one is refused, not applied
36
+ * silently: it costs a sentence to write, and it is the part a reviewer
37
+ * actually reads.
38
+ *
39
+ * AMBIGUOUS IDS FAIL CLOSED. A global CLAUDE.md and a project one can
40
+ * legitimately both contain a "Rule 1" — the report already disambiguates
41
+ * those on collision. An override written as `"rule": "1"` when two rules
42
+ * share that id would silently disable BOTH, including one the user never
43
+ * meant to touch. Found while testing this feature against a real machine
44
+ * that has a global rules file. So a bare id is applied only when it is
45
+ * unambiguous; when it is not, the override is refused and the user is
46
+ * told to write `"project:1"` or `"global:1"` instead.
47
+ */
48
+ export const OVERRIDES_FILE = ".rulereceipt.json";
49
+ /**
50
+ * Resolves override entries against the rules actually present, and
51
+ * refuses anything ambiguous rather than guessing which rule was meant.
52
+ * Returns a lookup keyed by `${source}:${id}`, plus any new problems.
53
+ */
54
+ export function resolveOverrides(loaded, targets) {
55
+ const bySourceAndId = new Map();
56
+ const problems = [...loaded.problems];
57
+ for (const [written, entry] of loaded.byRuleId) {
58
+ const scoped = /^(global|project):(.+)$/i.exec(written);
59
+ if (scoped) {
60
+ const source = scoped[1].toLowerCase();
61
+ const id = scoped[2].trim();
62
+ const hit = targets.find((t) => t.ruleId === id && t.ruleSource === source);
63
+ if (!hit) {
64
+ problems.push(`${OVERRIDES_FILE}: no ${source} rule with id "${id}" was found, so that override did nothing.`);
65
+ continue;
66
+ }
67
+ bySourceAndId.set(`${source}:${id}`, entry);
68
+ continue;
69
+ }
70
+ const matches = targets.filter((t) => t.ruleId === written);
71
+ if (matches.length === 0) {
72
+ problems.push(`${OVERRIDES_FILE}: no rule with id "${written}" was found, so that override did nothing.`);
73
+ continue;
74
+ }
75
+ const sources = new Set(matches.map((m) => m.ruleSource));
76
+ if (sources.size > 1) {
77
+ problems.push(`${OVERRIDES_FILE}: rule id "${written}" exists in BOTH your global and project rules, so the override was NOT applied — it would have silently disabled both. Write "project:${written}" or "global:${written}" instead.`);
78
+ continue;
79
+ }
80
+ bySourceAndId.set(`${[...sources][0]}:${written}`, entry);
81
+ }
82
+ return { bySourceAndId, problems };
83
+ }
84
+ function isNonEmptyString(v) {
85
+ return typeof v === "string" && v.trim().length > 0;
86
+ }
87
+ /**
88
+ * Reads and validates the override file. Never throws: a broken override
89
+ * file must not take down a check that would otherwise have worked, and
90
+ * an unreadable file means "no overrides", never "override everything".
91
+ * Every rejection is reported rather than swallowed, so a user whose
92
+ * override isn't working finds out why.
93
+ */
94
+ export function loadOverrides(cwd) {
95
+ const byRuleId = new Map();
96
+ const problems = [];
97
+ const path = join(cwd, OVERRIDES_FILE);
98
+ if (!existsSync(path))
99
+ return { byRuleId, problems };
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
103
+ }
104
+ catch (err) {
105
+ problems.push(`${OVERRIDES_FILE} isn't valid JSON, so no overrides were applied: ${err instanceof Error ? err.message : String(err)}`);
106
+ return { byRuleId, problems };
107
+ }
108
+ const raw = parsed?.overrides;
109
+ if (raw === undefined)
110
+ return { byRuleId, problems };
111
+ if (!Array.isArray(raw)) {
112
+ problems.push(`${OVERRIDES_FILE}: "overrides" must be an array, so no overrides were applied.`);
113
+ return { byRuleId, problems };
114
+ }
115
+ for (const [i, entry] of raw.entries()) {
116
+ const e = entry;
117
+ if (!isNonEmptyString(e?.rule)) {
118
+ problems.push(`${OVERRIDES_FILE} entry ${i + 1}: missing a "rule" id, so it was ignored.`);
119
+ continue;
120
+ }
121
+ // Fails closed, on purpose. An override with no stated reason is the
122
+ // exact shape of one added to make a number go away.
123
+ if (!isNonEmptyString(e?.reason)) {
124
+ problems.push(`${OVERRIDES_FILE} entry for rule ${e.rule}: no "reason" given, so it was NOT applied. Every override needs a reason a reviewer can read.`);
125
+ continue;
126
+ }
127
+ byRuleId.set(e.rule.trim(), {
128
+ rule: e.rule.trim(),
129
+ reason: e.reason.trim(),
130
+ date: isNonEmptyString(e.date) ? e.date.trim() : undefined,
131
+ });
132
+ }
133
+ return { byRuleId, problems };
134
+ }
@@ -34,11 +34,18 @@ function stripControlChars(value) {
34
34
  * directory layout — that is a leak in the one artifact most likely to
35
35
  * leave the machine.
36
36
  *
37
- * This is a mechanical, judgment-free redaction: it removes the home
38
- * prefix and nothing else. It is NOT a general secret scrubber, and must
39
- * not be described as one. Rule text and evidence are still reproduced
40
- * verbatim, because that is what makes the report useful — which is why
41
- * the CLI warns the user to read the file before sending it.
37
+ * This is DISPLAY FORMATTING, not a safety mechanism, and the difference
38
+ * matters. It is a fixed, deterministic substitution of one known string
39
+ * — always correct, never guessing — and it makes paths easier to read as
40
+ * a side benefit. It protects nothing.
41
+ *
42
+ * Auto-detecting "sensitive" content and scrubbing it would be a losing
43
+ * game: it can never catch everything, and a partial scrub is worse than
44
+ * none because it invites the user to trust the output. Rule text and
45
+ * evidence are reproduced verbatim on purpose, because that is what makes
46
+ * the report worth sending. The real safeguard is the warning the CLI
47
+ * prints at write time, which puts the responsibility where it belongs —
48
+ * with the person who knows what is in their own rules file.
42
49
  */
43
50
  function redactHome(value) {
44
51
  const home = homedir();
@@ -96,6 +103,13 @@ function renderResultRow(result, all) {
96
103
  </div>
97
104
  <h3 class="result__title">${clean(result.ruleTitle)}</h3>
98
105
  ${result.evidence ? `<p class="result__evidence">${clean(result.evidence)}</p>` : ""}
106
+ ${result.overriddenReason
107
+ ? `<div class="override">
108
+ <strong>Marked by the developer as &ldquo;not a rule for this project&rdquo;${result.overriddenDate ? ` on ${clean(result.overriddenDate)}` : ""}.</strong>
109
+ Reason given: ${clean(result.overriddenReason)}
110
+ <span class="override__truth">Without this override, the result is: <strong>${clean(BUCKET_LABEL[bucketOf({ ...result, overriddenReason: undefined })])}</strong></span>
111
+ </div>`
112
+ : ""}
99
113
  </article>`;
100
114
  }
101
115
  function renderSection(bucket, results, all) {
@@ -195,6 +209,9 @@ export function generateHtmlReport(results, meta) {
195
209
  .result--judgment { border-left-color: #6b6f76; }
196
210
  .badge--judgment { background: #f2f3f5; color: #4a4e55; }
197
211
  .section__note { font-size: 13px; color: var(--muted); margin: -4px 0 12px; }
212
+ .override { margin-top: 10px; padding: 10px 12px; border-radius: 6px; background: var(--unclear-bg); border: 1px solid var(--unclear-line); font-size: 13.5px; color: var(--ink-soft); }
213
+ .override strong { color: var(--ink); }
214
+ .override__truth { display: block; margin-top: 6px; }
198
215
  .result__id { font-size: 12px; color: var(--muted); }
199
216
  .result__title { font-size: 15px; margin: 0 0 6px; font-weight: 600; }
200
217
  .result__evidence { margin: 0; font-size: 14px; color: var(--muted); white-space: pre-wrap; }
@@ -225,7 +242,7 @@ export function generateHtmlReport(results, meta) {
225
242
 
226
243
  <div class="verdict verdict--${v.cls}">
227
244
  <strong>${clean(v.text)}</strong>
228
- <span>${countBy(results, "PASS")} followed · ${countBy(results, "FAIL")} not followed · ${results.filter((r) => bucketOf(r) === "UNCLEAR_EVIDENCE").length} couldn&#39;t tell · ${results.filter((r) => bucketOf(r) === "UNCLEAR_JUDGMENT").length} need your judgment</span>
245
+ <span>${countBy(results, "PASS")} followed · ${countBy(results, "FAIL")} not followed · ${results.filter((r) => bucketOf(r) === "UNCLEAR_EVIDENCE").length} couldn&#39;t tell · ${results.filter((r) => bucketOf(r) === "UNCLEAR_JUDGMENT").length} need your judgment${results.filter((r) => r.overriddenReason).length > 0 ? ` · ${results.filter((r) => r.overriddenReason).length} developer-overridden` : ""}</span>
229
246
  </div>
230
247
 
231
248
  <table class="facts">
@@ -64,11 +64,21 @@ function summaryLine(results) {
64
64
  const fail = results.filter((r) => r.status === "FAIL").length;
65
65
  const needsHuman = results.filter((r) => r.status === "UNCLEAR" && r.needsHuman).length;
66
66
  const couldntTell = results.filter((r) => r.status === "UNCLEAR" && !r.needsHuman).length;
67
+ const overriddenFails = results.filter((r) => r.status === "FAIL" && r.overriddenReason).length;
68
+ const overridden = results.filter((r) => r.overriddenReason).length;
67
69
  const parts = [`${pass} followed`, `${fail} not followed`];
68
70
  if (couldntTell > 0)
69
71
  parts.push(`${couldntTell} couldn't tell`);
70
72
  if (needsHuman > 0)
71
73
  parts.push(`${needsHuman} need your judgment`);
74
+ // Named in the one line everyone reads. A headline that quietly folded
75
+ // overridden failures into a clean total would be the single most
76
+ // misleading thing this tool could print.
77
+ if (overridden > 0) {
78
+ parts.push(overriddenFails > 0
79
+ ? `${overridden} user-overridden (${overriddenFails} still not followed)`
80
+ : `${overridden} user-overridden`);
81
+ }
72
82
  return parts.join(" · ");
73
83
  }
74
84
  export function generateReport(results, meta) {
@@ -80,6 +90,15 @@ export function generateReport(results, meta) {
80
90
  lines.push(`${MARK[r.status]} ${r.status.padEnd(7)} ${ruleLabel(r, clean)}`);
81
91
  if (r.evidence)
82
92
  lines.push(` evidence: ${r.evidence}`);
93
+ // The true status is printed above, unchanged. This line adds the
94
+ // override on top of it rather than replacing it — a reader must be
95
+ // able to see what the result would have been without the override.
96
+ if (r.overriddenReason) {
97
+ lines.push(` USER-OVERRIDDEN as "not a rule for this project"${r.overriddenDate ? ` on ${r.overriddenDate}` : ""} — reason: ${r.overriddenReason}`);
98
+ if (r.status === "FAIL") {
99
+ lines.push(" NOTE: this rule was NOT FOLLOWED. The override does not change that.");
100
+ }
101
+ }
83
102
  }
84
103
  lines.push("─".repeat(40));
85
104
  lines.push(summaryLine(clean));
package/dist/types.d.ts CHANGED
@@ -32,6 +32,15 @@ export interface CheckResult {
32
32
  ruleSource: "global" | "project";
33
33
  status: CheckStatus;
34
34
  evidence: string;
35
+ /**
36
+ * Set when the user marked this rule as "not a rule for my project" in
37
+ * .rulereceipt.json. The status above is STILL the real, computed
38
+ * result — an override changes presentation only, never the answer.
39
+ * See src/overrides.ts for why that distinction is the whole design.
40
+ */
41
+ overriddenReason?: string;
42
+ /** Optional date from the override entry, so a reader can judge staleness. */
43
+ overriddenDate?: string;
35
44
  /**
36
45
  * True when this rule was never mechanically answerable — a judgment
37
46
  * call like "surface bad news first", which has no command to inspect.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
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",