rulereceipt 0.1.25 → 0.1.27

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
@@ -9,29 +9,18 @@
9
9
  Checks whether a Claude Code session actually followed the rules in your
10
10
  CLAUDE.md / AGENTS.md — with evidence, not just a vibe.
11
11
 
12
- Every release from 0.1.19 on is built and published by GitHub Actions and
13
- signed with [npm provenance](https://docs.npmjs.com/generating-provenance-statements),
14
- so you can verify the published package was built from this repository at
15
- a specific commit. No publishing token exists to be stolen. Check it
16
- yourself with `npm audit signatures` after installing.
12
+ Runs entirely on your machine. Plain `rulereceipt check` makes zero network
13
+ calls — [Trust, privacy and licensing](#trust-privacy-and-licensing) has the
14
+ full detail, including the three off-by-default opt-ins.
17
15
 
18
- Licensed source-available software — see [LICENSE](./LICENSE) and
19
- [NOTICE.md](./NOTICE.md) before reusing this code.
16
+ ## See it in 10 seconds
20
17
 
21
- Runs entirely on your machine. Your code, rules, and session content never
22
- leave your computer, ever. Plain `rulereceipt check` makes zero network
23
- calls. `--llm`, `--share`, and `--telemetry` are all separate, off-by-default
24
- opt-ins: `--llm` calls the Claude API using your own Anthropic key for rules
25
- that need judgment; `--share` sends aggregate pass/fail/unclear counts;
26
- `--telemetry` sends one random per-machine ID so real distinct-install
27
- counts are knowable, nothing else. None of them fire unless you explicitly
28
- pass the flag, and `DO_NOT_TRACK=1` / `RULERECEIPT_NO_TELEMETRY=1` forces
29
- telemetry off even if you do.
30
-
31
- **Security note:** RuleReceipt never modifies `.claude/settings.json` and
32
- installs no hooks without explicit action. It only ever reads your
33
- CLAUDE.md/AGENTS.md and session transcripts — read-only, manual invocation
34
- only (`rulereceipt check`). No automatic hooks, ever, in v1.
18
+ ```bash
19
+ npx rulereceipt demo
20
+ ```
21
+
22
+ No install, no config, no API key, no real session needed — prints a sample
23
+ report so you can see the output shape immediately.
35
24
 
36
25
  ## Status
37
26
 
@@ -59,7 +48,12 @@ Published and live on npm, actively developed.
59
48
  Claude key; without it they report UNCLEAR rather than guessing.
60
49
  - Lines containing no instruction at all — directory listings,
61
50
  reference tables, examples — aren't rules, and are reported as such
62
- instead of being checked.
51
+ instead of being checked. This step is a heuristic over English
52
+ instruction words, so it can be wrong in both directions: run
53
+ `rulereceipt check --show-skipped` once on your rules file to see
54
+ exactly what it excluded. A rule phrased unusually, or written in
55
+ another language, can land there — and a rule dropped silently is
56
+ worse than one reported wrongly.
63
57
  4. Prints a report — terminal table by default, `--markdown` for pasting
64
58
  into a PR or Slack message, or `--html` for a shareable single file —
65
59
  showing what passed, what failed, and a quoted line of evidence for
@@ -68,30 +62,31 @@ Published and live on npm, actively developed.
68
62
  that exact file. (It proves the report matches the file, not that the
69
63
  file is an unmodified record — see SECURITY.md.)
70
64
 
71
- ## Exit codes
72
-
73
- `check` exits **1** when a rule was actually broken, and **0** otherwise,
74
- so CI can gate on it. Rules that need human judgment report UNCLEAR and
75
- never affect the exit code — most rules in a real CLAUDE.md need judgment,
76
- and gating on those would make every build red on day one.
77
-
78
- `--exit-zero` prints the report without failing the build. `--require-session`
79
- does the opposite and is the one to use anywhere automated: it fails when
80
- there is no session, or an empty one, instead of reporting a pass for a
81
- check that never actually ran.
82
-
83
- ### A limit worth knowing before you wire this into CI
65
+ ## Usage
84
66
 
85
- Claude Code writes its session transcript to the machine the agent ran on
86
- — your laptop. A CI runner is a fresh machine that has never seen it, so a
87
- CI job cannot check a session that happened on your laptop unless you
88
- deliberately make that transcript available to the job. See
89
- [templates/rulereceipt-ci.yml](./templates/rulereceipt-ci.yml), which
90
- explains the options and, if you use it, fails loudly rather than passing
91
- on a session it never found.
67
+ ```bash
68
+ rulereceipt check # check the latest session in this project
69
+ rulereceipt check --markdown # same, formatted for pasting into a PR/Slack
70
+ rulereceipt check --html # write a shareable single-file HTML report you can send
71
+ rulereceipt check --html report.html # ...to a specific path
72
+ rulereceipt check --show-skipped # list what was treated as documentation and not checked
73
+ rulereceipt check --require-session # fail if there's no session, instead of passing silently
74
+ rulereceipt check --exit-zero # report failures without failing the build
75
+ rulereceipt check --llm # opt-in: grade judgment rules with your own Claude key
76
+ rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
77
+ rulereceipt check --telemetry # opt-in: send one random per-machine ID
78
+ rulereceipt check --transcript <path> # check a specific session file
79
+ rulereceipt doctor # list hooks/auto-run tasks configured on this machine
80
+ rulereceipt lint # find contradictions between CLAUDE.md and AGENTS.md
81
+ rulereceipt digest # summarise recent checks; --email to send it
82
+ rulereceipt config # set up email sending (stays on your machine)
83
+ rulereceipt demo # sample output, no setup needed
84
+ rulereceipt demo --markdown
85
+ rulereceipt --version # print the installed version
86
+ rulereceipt verify <session-file> <hash> # spot-check a report you received against the real session file
87
+ ```
92
88
 
93
- For most people the honest answer is simpler: run `rulereceipt check --html`
94
- locally and attach the report to the PR.
89
+ `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.
95
90
 
96
91
  ## Sharing a report
97
92
 
@@ -110,39 +105,30 @@ independently confirm it describes the session it claims to.
110
105
  Nothing is uploaded. The file is written to your working directory and
111
106
  goes wherever you choose to send it.
112
107
 
113
- ## Try it with zero setup
108
+ ## Exit codes
114
109
 
115
- ```bash
116
- rulereceipt demo
117
- ```
110
+ `check` exits **1** when a rule was actually broken, and **0** otherwise,
111
+ so CI can gate on it. Rules that need human judgment report UNCLEAR and
112
+ never affect the exit code — most rules in a real CLAUDE.md need judgment,
113
+ and gating on those would make every build red on day one.
118
114
 
119
- No install config, no API key, no real session needed — prints a sample
120
- report so you can see the output shape immediately.
115
+ `--exit-zero` prints the report without failing the build. `--require-session`
116
+ does the opposite and is the one to use anywhere automated: it fails when
117
+ there is no session, or an empty one, instead of reporting a pass for a
118
+ check that never actually ran.
121
119
 
122
- ## Usage
120
+ ### A limit worth knowing before you wire this into CI
123
121
 
124
- ```bash
125
- rulereceipt check # check the latest session in this project
126
- rulereceipt check --markdown # same, formatted for pasting into a PR/Slack
127
- rulereceipt check --html # write a shareable single-file HTML report you can send
128
- rulereceipt check --html report.html # ...to a specific path
129
- rulereceipt check --require-session # fail if there's no session, instead of passing silently
130
- rulereceipt check --exit-zero # report failures without failing the build
131
- rulereceipt check --llm # opt-in: grade judgment rules with your own Claude key
132
- rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
133
- rulereceipt check --telemetry # opt-in: send one random per-machine ID
134
- rulereceipt check --transcript <path> # check a specific session file
135
- rulereceipt doctor # list hooks/auto-run tasks configured on this machine
136
- rulereceipt lint # find contradictions between CLAUDE.md and AGENTS.md
137
- rulereceipt digest # summarise recent checks; --email to send it
138
- rulereceipt config # set up email sending (stays on your machine)
139
- rulereceipt demo # sample output, no setup needed
140
- rulereceipt demo --markdown
141
- rulereceipt --version # print the installed version
142
- rulereceipt verify <session-file> <hash> # spot-check a report you received against the real session file
143
- ```
122
+ Claude Code writes its session transcript to the machine the agent ran on
123
+ — your laptop. A CI runner is a fresh machine that has never seen it, so a
124
+ CI job cannot check a session that happened on your laptop unless you
125
+ deliberately make that transcript available to the job. See
126
+ [templates/rulereceipt-ci.yml](./templates/rulereceipt-ci.yml), which
127
+ explains the options and, if you use it, fails loudly rather than passing
128
+ on a session it never found.
144
129
 
145
- `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.
130
+ For most people the honest answer is simpler: run `rulereceipt check --html`
131
+ locally and attach the report to the PR.
146
132
 
147
133
  ## Install
148
134
 
@@ -161,6 +147,34 @@ npm test
161
147
  npx tsx src/cli.ts demo
162
148
  ```
163
149
 
150
+ ## Trust, privacy and licensing
151
+
152
+ **Nothing leaves your machine unless you ask.** Your code, rules, and
153
+ session content never leave your computer, ever. Plain `rulereceipt check`
154
+ makes zero network calls. `--llm`, `--share`, and `--telemetry` are all
155
+ separate, off-by-default opt-ins: `--llm` calls the Claude API using your
156
+ own Anthropic key for rules that need judgment; `--share` sends aggregate
157
+ pass/fail/unclear counts; `--telemetry` sends one random per-machine ID so
158
+ real distinct-install counts are knowable, nothing else. None of them fire
159
+ unless you explicitly pass the flag, and `DO_NOT_TRACK=1` /
160
+ `RULERECEIPT_NO_TELEMETRY=1` forces telemetry off even if you do.
161
+
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.
167
+
168
+ **You can verify the package came from this source.** Every release from
169
+ 0.1.19 on is built and published by GitHub Actions and signed with
170
+ [npm provenance](https://docs.npmjs.com/generating-provenance-statements),
171
+ so you can verify the published package was built from this repository at
172
+ a specific commit. No publishing token exists to be stolen. Check it
173
+ yourself with `npm audit signatures` after installing.
174
+
175
+ **Licence.** Source-available software — see [LICENSE](./LICENSE) and
176
+ [NOTICE.md](./NOTICE.md) before reusing this code.
177
+
164
178
  ## Contact
165
179
 
166
180
  Questions, bugs, or anything else — hello@rulereceipt.dev.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Entry point for the in-browser checker on the site.
3
+ *
4
+ * The privacy claim on that page — "your file never leaves this page" — is
5
+ * only true because everything reachable from here is pure string work.
6
+ * parseClaudeMdText and classifyRules touch no Node API, no network, and
7
+ * no storage. If anything in this dependency chain ever gains an import
8
+ * that does, the page's claim becomes false and must change with it.
9
+ *
10
+ * Deliberately returns counts and a small sample, never the file: nothing
11
+ * here should make it easy to accidentally send the text somewhere.
12
+ */
13
+ export interface Breakdown {
14
+ kind: string;
15
+ count: number;
16
+ }
17
+ export interface AnalysisResult {
18
+ /** Everything the parser pulled out of the file, rules and non-rules alike. */
19
+ parsed: number;
20
+ /** Items carrying no instruction — documentation, tables, listings. */
21
+ notRules: number;
22
+ /** Items that are genuinely directives. */
23
+ realRules: number;
24
+ /** Real rules a check can answer by looking at what ran. */
25
+ checkable: number;
26
+ /** Real rules that need a person. */
27
+ judgment: number;
28
+ /** Share of the whole file that isn't an instruction, 0-100. */
29
+ notRulesPct: number;
30
+ /** Share of REAL rules that are mechanically answerable, 0-100. */
31
+ checkablePct: number;
32
+ byKind: Breakdown[];
33
+ /** A few non-rule titles, so the number is inspectable rather than asserted. */
34
+ notRuleSamples: string[];
35
+ /** A few judgment-call titles, same reason. */
36
+ judgmentSamples: string[];
37
+ }
38
+ /** Measured across 559 real public rules files. Shown for comparison. */
39
+ export declare const CORPUS: {
40
+ files: number;
41
+ parsed: number;
42
+ notRulesPct: number;
43
+ checkablePct: number;
44
+ };
45
+ export declare function analyze(text: string): AnalysisResult;
@@ -0,0 +1,49 @@
1
+ import { parseClaudeMdText } from "../parsers/claudeMdParser.js";
2
+ import { classifyRules } from "../checks/classify.js";
3
+ /** Measured across 559 real public rules files. Shown for comparison. */
4
+ export const CORPUS = {
5
+ files: 559,
6
+ parsed: 23704,
7
+ notRulesPct: 64.5,
8
+ checkablePct: 42.7,
9
+ };
10
+ function pct(part, whole) {
11
+ return whole === 0 ? 0 : Math.round((part / whole) * 1000) / 10;
12
+ }
13
+ export function analyze(text) {
14
+ const rules = parseClaudeMdText(text, "project");
15
+ const classified = classifyRules(rules);
16
+ const counts = new Map();
17
+ const notRuleSamples = [];
18
+ const judgmentSamples = [];
19
+ for (const c of classified) {
20
+ counts.set(c.kind, (counts.get(c.kind) ?? 0) + 1);
21
+ const title = c.rule.title.trim();
22
+ if (!title)
23
+ continue;
24
+ if (c.kind === "notARule" && notRuleSamples.length < 4)
25
+ notRuleSamples.push(title);
26
+ if (c.kind === "judgment" && judgmentSamples.length < 4)
27
+ judgmentSamples.push(title);
28
+ }
29
+ const parsed = classified.length;
30
+ const notRules = counts.get("notARule") ?? 0;
31
+ const judgment = counts.get("judgment") ?? 0;
32
+ const realRules = parsed - notRules;
33
+ const checkable = realRules - judgment;
34
+ const byKind = [...counts.entries()]
35
+ .map(([kind, count]) => ({ kind, count }))
36
+ .sort((a, b) => b.count - a.count);
37
+ return {
38
+ parsed,
39
+ notRules,
40
+ realRules,
41
+ checkable,
42
+ judgment,
43
+ notRulesPct: pct(notRules, parsed),
44
+ checkablePct: pct(checkable, realRules),
45
+ byKind,
46
+ notRuleSamples,
47
+ judgmentSamples,
48
+ };
49
+ }
@@ -68,12 +68,82 @@ function isEventRecord(rule) {
68
68
  return false;
69
69
  return EVENT_RECORD_TITLE.test(rule.title);
70
70
  }
71
+ /**
72
+ * A line whose SUBJECT is a command-line option, i.e. the documentation of
73
+ * a flag rather than an instruction to the agent. "`--file=<path>, -f`: Use
74
+ * alternative tasks.json file" describes what the flag does; the imperative
75
+ * belongs to the flag, not to the reader.
76
+ *
77
+ * This is why the directive test alone cannot separate these: command
78
+ * documentation is written with the same verbs as a command, because it is
79
+ * describing a command. The distinguishing property is grammatical — what
80
+ * the sentence is ABOUT — and it is announced by the line beginning with an
81
+ * option token and a separator.
82
+ *
83
+ * Deliberately narrow. It requires the option token to be the first thing on
84
+ * the line and to be followed by a colon, so a rule that merely mentions a
85
+ * flag ("Always pass `--frozen-lockfile` when installing") is untouched.
86
+ */
87
+ const FLAG_DOCUMENTATION = /^\s*`?\s*-{1,2}[A-Za-z0-9][\w-]*(?:=[^\s,`]*)?(?:\s*,\s*`?\s*-{1,2}[\w-]+`?(?:=[^\s,`]*)?)*\s*`?\s*:/;
88
+ /**
89
+ * English prose almost always contains at least one function word. A shell
90
+ * invocation contains none, and contains at least one token carrying a path
91
+ * separator, a flag prefix, an internal dot, or an assignment.
92
+ *
93
+ * Both conditions are required. "Use modular architecture" has no function
94
+ * word either, but no command-shaped token, so it stays a rule. "Run `npm
95
+ * test` before every push" has command-shaped tokens but also has function
96
+ * words, so it stays a rule too.
97
+ *
98
+ * This is a property of the language, not of the file format — the same
99
+ * reasoning as DIRECTIVE_LANGUAGE above, which is why it is expressed as a
100
+ * test on the text rather than as a list of documentation conventions.
101
+ */
102
+ const FUNCTION_WORD = /\b(a|an|the|is|are|was|were|be|being|been|to|of|in|on|for|with|when|if|that|this|these|those|and|or|but|not|no|do|does|you|your|it|its|as|at|by|from|before|after|until|unless|any|every|all|each|must|should|never|always|only|via|per|than|then|so|such|into|onto|over|under|about)\b/i;
103
+ const COMMAND_SHAPED_TOKEN = /(?:^|\s)(?:\S*[/\\]\S*|-{1,2}[A-Za-z]\S*|\S+\.\S+|\S+=\S+)/;
104
+ function looksLikeBareCommand(text) {
105
+ const withoutCode = text
106
+ .replace(/```[\s\S]*?```/g, " ")
107
+ .replace(/`[^`]*`/g, " ")
108
+ .trim();
109
+ // If stripping code leaves nothing, the body WAS only code.
110
+ const body = withoutCode.length > 0 ? withoutCode : text.replace(/[`\n]+/g, " ").trim();
111
+ if (!body)
112
+ return false;
113
+ // Long enough to be prose, whatever it contains.
114
+ if (body.split(/\s+/).length > 12)
115
+ return false;
116
+ if (FUNCTION_WORD.test(body))
117
+ return false;
118
+ return COMMAND_SHAPED_TOKEN.test(body);
119
+ }
120
+ /**
121
+ * A heading that labels a command, with the command as its whole body:
122
+ * "Build release APK" over `.\gradlew assembleRelease`. The title reads as
123
+ * an imperative, but nothing here constrains the agent — it is a how-to, and
124
+ * there is no compliance to check.
125
+ *
126
+ * Guarded by TITLE_OPENS_WITH_DIRECTIVE so a genuine prohibition whose body
127
+ * is the forbidden command ("Never run: `rm -rf /`") is still a rule.
128
+ */
129
+ function isCommandDocumentation(rule) {
130
+ if (TITLE_OPENS_WITH_DIRECTIVE.test(rule.title))
131
+ return false;
132
+ if (FLAG_DOCUMENTATION.test(rule.text) || FLAG_DOCUMENTATION.test(rule.title))
133
+ return true;
134
+ return looksLikeBareCommand(rule.text);
135
+ }
71
136
  function isNotARule(rule) {
72
137
  // Checked before the directive test on purpose: an incident note that
73
138
  // ends with its lesson contains a real directive, and would otherwise
74
139
  // be enforced as though the history itself were the rule.
75
140
  if (isEventRecord(rule))
76
141
  return true;
142
+ // Same reason as above: command documentation carries real imperatives
143
+ // ("Use alternative tasks.json file"), so the directive test below would
144
+ // otherwise accept it as a rule to check compliance against.
145
+ if (isCommandDocumentation(rule))
146
+ return true;
77
147
  const combined = `${rule.title} ${rule.text}`;
78
148
  if (DIRECTIVE_LANGUAGE.test(combined))
79
149
  return false;
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import { join, dirname, resolve, isAbsolute } from "node:path";
6
6
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
- import { parseClaudeMd } from "./parsers/claudeMdParser.js";
8
+ 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";
@@ -132,7 +132,7 @@ function writeHtmlReport(results, meta, cwd, target) {
132
132
  }
133
133
  }
134
134
  async function runCheck(opts) {
135
- const { markdown, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, transcriptOverride } = opts;
135
+ const { markdown, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, showSkipped, transcriptOverride } = opts;
136
136
  const cwd = process.cwd();
137
137
  const rules = loadRules(cwd);
138
138
  if (rules.length === 0) {
@@ -229,8 +229,35 @@ async function runCheck(opts) {
229
229
  if (html !== false) {
230
230
  writeHtmlReport(results, { sessionFilePath, ruleCount: results.length }, cwd, html);
231
231
  }
232
+ // A count alone is not enough. The classifier is a heuristic over English
233
+ // verbs: measured across 559 public rules files it drops non-English
234
+ // content at 97.5% against a 64.5% baseline, and any imperative verb
235
+ // outside its list is invisible to it. Neither gap closes by extending the
236
+ // list — imperative verbs are not a closed class, and the list is
237
+ // English-only by construction.
238
+ //
239
+ // What does close is the silence. "12 items were documentation" reads as
240
+ // reassurance; it is the one place this tool still guesses without saying
241
+ // so, and a rule dropped here never appears in the report at all. Listing
242
+ // them needs no key, works in any language, and lets the person who wrote
243
+ // the rule be the one who decides.
232
244
  if (notARule.length > 0) {
233
- console.log(`\n(${notARule.length} item${notARule.length === 1 ? "" : "s"} in your rules file ${notARule.length === 1 ? "is" : "are"} documentation, not a rule — directory listings, reference tables, examples. Not checked, because there's nothing to check.)`);
245
+ const n = notARule.length;
246
+ const plural = n === 1 ? "" : "s";
247
+ console.log(`\n(${n} item${plural} in your rules file ${n === 1 ? "was" : "were"} treated as documentation and not checked — directory listings, reference tables, examples.)`);
248
+ if (showSkipped) {
249
+ console.log(`\nSkipped as documentation:\n`);
250
+ for (const { rule } of notARule) {
251
+ const label = rule.title.replace(/\s+/g, " ").trim();
252
+ console.log(` [${rule.id}] ${label.slice(0, 110)}`);
253
+ }
254
+ console.log(`\nIf any of those is actually a rule, the classifier was wrong. It looks for` +
255
+ `\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.`);
257
+ }
258
+ else {
259
+ console.log(`Run with --show-skipped to see them.`);
260
+ }
234
261
  }
235
262
  appendHistory(results, sessionFilePath);
236
263
  if (share) {
@@ -288,6 +315,7 @@ program
288
315
  .option("--html [path]", `write a shareable single-file HTML report you can email, attach to a ticket, or print to PDF. Defaults to ./${DEFAULT_HTML_REPORT_NAME}. Written locally — nothing is uploaded.`)
289
316
  .option("--exit-zero", "always exit 0, even when a rule was broken. Without this, `check` exits 1 on any FAIL so CI can gate on it (rules needing human judgment report UNCLEAR and never affect the exit code).")
290
317
  .option("--require-session", "fail (exit 1) if no session is found, or the session is empty, instead of reporting a pass for a check that never actually ran. Use this anywhere automated.")
318
+ .option("--show-skipped", "list the items that were treated as documentation and not checked. Worth running once on any rules file: the classifier is a heuristic over English verbs, so a rule it does not recognise is otherwise dropped without you seeing it.")
291
319
  .option("--transcript <path>", "manual override: check this exact .jsonl session file instead of auto-detecting one. Useful if your Claude Code session lives somewhere non-standard that auto-detection doesn't cover.")
292
320
  .action((opts) => {
293
321
  runCheck({
@@ -301,6 +329,7 @@ program
301
329
  html: opts.html ?? false,
302
330
  exitZero: Boolean(opts.exitZero),
303
331
  requireSession: Boolean(opts.requireSession),
332
+ showSkipped: Boolean(opts.showSkipped),
304
333
  transcriptOverride: opts.transcript,
305
334
  }).catch((err) => {
306
335
  console.error("Something went wrong:", err instanceof Error ? err.message : err);
@@ -1,2 +1,11 @@
1
1
  import type { Rule } from "../types.js";
2
- export declare function parseClaudeMd(filePath: string, source: "global" | "project"): Rule[];
2
+ /**
3
+ * The actual parser, taking text rather than a path.
4
+ *
5
+ * Split out from parseClaudeMd so the parsing logic carries no filesystem
6
+ * dependency and can run anywhere a string can — including a browser, for
7
+ * the client-side checker on the site. That checker's privacy claim
8
+ * ("your file never leaves the page") is only true because nothing in
9
+ * this function or in classify.ts touches Node APIs; keep it that way.
10
+ */
11
+ export declare function parseClaudeMdText(raw: string, source: "global" | "project"): Rule[];
@@ -1,4 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
1
  /**
3
2
  * Real CLAUDE.md/AGENTS.md files use several different conventions for
4
3
  * organizing rules. Verified against real files and templates: Anthropic's
@@ -67,14 +66,16 @@ function normalizeSetextHeaders(lines) {
67
66
  }
68
67
  return out;
69
68
  }
70
- export function parseClaudeMd(filePath, source) {
71
- let raw;
72
- try {
73
- raw = readFileSync(filePath, "utf-8");
74
- }
75
- catch {
76
- return [];
77
- }
69
+ /**
70
+ * The actual parser, taking text rather than a path.
71
+ *
72
+ * Split out from parseClaudeMd so the parsing logic carries no filesystem
73
+ * dependency and can run anywhere a string can — including a browser, for
74
+ * the client-side checker on the site. That checker's privacy claim
75
+ * ("your file never leaves the page") is only true because nothing in
76
+ * this function or in classify.ts touches Node APIs; keep it that way.
77
+ */
78
+ export function parseClaudeMdText(raw, source) {
78
79
  const lines = normalizeSetextHeaders(raw.split("\n"));
79
80
  const rules = [];
80
81
  // `current` accumulates a numbered-header rule, a bold-rule-header rule,
@@ -0,0 +1,19 @@
1
+ import type { Rule } from "../types.js";
2
+ /**
3
+ * The filesystem half of rules-file parsing, deliberately kept in its own
4
+ * module.
5
+ *
6
+ * claudeMdParser.ts must stay free of Node imports so it can be bundled
7
+ * for the browser — the in-page checker on the site claims the pasted file
8
+ * never leaves the page, and that is only true while nothing reachable
9
+ * from the parser can perform I/O. Keeping the one readFileSync here
10
+ * means a bundler cannot pull `node:fs` in behind it, and a future import
11
+ * that breaks the guarantee has to be added here, visibly, rather than
12
+ * appearing by accident in the parser.
13
+ */
14
+ /**
15
+ * Reads a rules file from disk. Thin wrapper: an unreadable file is an
16
+ * empty rule list, never a throw, because a missing global CLAUDE.md is a
17
+ * normal state rather than an error.
18
+ */
19
+ export declare function parseClaudeMd(filePath: string, source: "global" | "project"): Rule[];
@@ -0,0 +1,29 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseClaudeMdText } from "./claudeMdParser.js";
3
+ /**
4
+ * The filesystem half of rules-file parsing, deliberately kept in its own
5
+ * module.
6
+ *
7
+ * claudeMdParser.ts must stay free of Node imports so it can be bundled
8
+ * for the browser — the in-page checker on the site claims the pasted file
9
+ * never leaves the page, and that is only true while nothing reachable
10
+ * from the parser can perform I/O. Keeping the one readFileSync here
11
+ * means a bundler cannot pull `node:fs` in behind it, and a future import
12
+ * that breaks the guarantee has to be added here, visibly, rather than
13
+ * appearing by accident in the parser.
14
+ */
15
+ /**
16
+ * Reads a rules file from disk. Thin wrapper: an unreadable file is an
17
+ * empty rule list, never a throw, because a missing global CLAUDE.md is a
18
+ * normal state rather than an error.
19
+ */
20
+ export function parseClaudeMd(filePath, source) {
21
+ let raw;
22
+ try {
23
+ raw = readFileSync(filePath, "utf-8");
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ return parseClaudeMdText(raw, source);
29
+ }
package/dist/rules.d.ts CHANGED
@@ -5,5 +5,15 @@ import type { Rule } from "./types.js";
5
5
  * global CLAUDE.md under its own home dir (e.g. ~/.claude-office/CLAUDE.md).
6
6
  * Real gap found 2026-08-30, same root cause as the transcript-lookup fix
7
7
  * in transcriptParser.ts: hardcoding one home-dir name misses any variant.
8
+ *
9
+ * Also reads ~/.claude/rules/*.md, the documented location for personal
10
+ * rules that apply across every project.
11
+ *
12
+ * NOT covered, and stated rather than left silent: machine-wide managed
13
+ * enterprise policy files (/Library/Application Support/ClaudeCode,
14
+ * /etc/claude-code, C:\Program Files\ClaudeCode). Those are deployed by
15
+ * IT, exist on no development machine this can be tested against, and
16
+ * guessing at their location would be the kind of unverified assumption
17
+ * this project has already been bitten by twice.
8
18
  */
9
19
  export declare function loadRules(cwd: string): Rule[];
package/dist/rules.js CHANGED
@@ -1,9 +1,62 @@
1
1
  import { homedir } from "node:os";
2
2
  import { dirname, join, parse } from "node:path";
3
- import { existsSync } from "node:fs";
4
- import { parseClaudeMd } from "./parsers/claudeMdParser.js";
3
+ import { existsSync, readdirSync, statSync } from "node:fs";
4
+ import { parseClaudeMd } from "./parsers/readClaudeMd.js";
5
5
  import { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
6
- const RULE_FILE_NAMES = ["CLAUDE.md", "AGENTS.md"];
6
+ /**
7
+ * Every place Claude Code actually reads a rule from, at one directory
8
+ * level. Order mirrors the documented load order, broadest first.
9
+ *
10
+ * Real gap found 2026-09-02: only the two bare filenames were read. A
11
+ * project with four rules files reported "1 rules checked · all passed" —
12
+ * three files invisible, with nothing saying so. A clean report on rules
13
+ * the tool never opened is the most misleading result this can produce,
14
+ * worse than no report, because it looks like evidence.
15
+ */
16
+ const RULE_FILE_NAMES = ["CLAUDE.md", "AGENTS.md", "CLAUDE.local.md", "AGENTS.local.md"];
17
+ const RULE_SUBDIR_FILES = [join(".claude", "CLAUDE.md"), join(".claude", "AGENTS.md")];
18
+ const RULE_DIRS = [join(".claude", "rules")];
19
+ /**
20
+ * Lists the markdown files in a rules directory, if it exists.
21
+ *
22
+ * Sorted so the same project always produces the same rule order — rule
23
+ * ids are positional, and an unstable order would renumber rules between
24
+ * runs on different machines, making two reports of the same session
25
+ * impossible to compare. Non-markdown files are skipped: a rules
26
+ * directory legitimately holds README fragments and notes.
27
+ */
28
+ function markdownFilesIn(dir) {
29
+ if (!existsSync(dir))
30
+ return [];
31
+ try {
32
+ if (!statSync(dir).isDirectory())
33
+ return [];
34
+ return readdirSync(dir)
35
+ .filter((f) => f.toLowerCase().endsWith(".md"))
36
+ .sort()
37
+ .map((f) => join(dir, f));
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ }
43
+ /** Every rules file at one directory level, in documented load order. */
44
+ function ruleFilesAtLevel(dir) {
45
+ const found = [];
46
+ for (const rel of RULE_SUBDIR_FILES) {
47
+ const p = join(dir, rel);
48
+ if (existsSync(p))
49
+ found.push(p);
50
+ }
51
+ for (const rel of RULE_DIRS)
52
+ found.push(...markdownFilesIn(join(dir, rel)));
53
+ for (const name of RULE_FILE_NAMES) {
54
+ const p = join(dir, name);
55
+ if (existsSync(p))
56
+ found.push(p);
57
+ }
58
+ return found;
59
+ }
7
60
  /**
8
61
  * Walks from the working directory up toward the repository root,
9
62
  * collecting rules files at every level.
@@ -25,11 +78,7 @@ function findProjectRuleFiles(cwd) {
25
78
  const home = homedir();
26
79
  let dir = cwd;
27
80
  for (;;) {
28
- for (const name of RULE_FILE_NAMES) {
29
- const p = join(dir, name);
30
- if (existsSync(p))
31
- found.push(p);
32
- }
81
+ found.push(...ruleFilesAtLevel(dir));
33
82
  // stop AT the repo root (inclusive) — its rules do apply
34
83
  if (existsSync(join(dir, ".git")))
35
84
  break;
@@ -48,9 +97,26 @@ function findProjectRuleFiles(cwd) {
48
97
  * global CLAUDE.md under its own home dir (e.g. ~/.claude-office/CLAUDE.md).
49
98
  * Real gap found 2026-08-30, same root cause as the transcript-lookup fix
50
99
  * in transcriptParser.ts: hardcoding one home-dir name misses any variant.
100
+ *
101
+ * Also reads ~/.claude/rules/*.md, the documented location for personal
102
+ * rules that apply across every project.
103
+ *
104
+ * NOT covered, and stated rather than left silent: machine-wide managed
105
+ * enterprise policy files (/Library/Application Support/ClaudeCode,
106
+ * /etc/claude-code, C:\Program Files\ClaudeCode). Those are deployed by
107
+ * IT, exist on no development machine this can be tested against, and
108
+ * guessing at their location would be the kind of unverified assumption
109
+ * this project has already been bitten by twice.
51
110
  */
52
111
  export function loadRules(cwd) {
53
- const rules = findClaudeHomeDirNames().flatMap((dirName) => parseClaudeMd(join(homedir(), dirName, "CLAUDE.md"), "global"));
112
+ const rules = [];
113
+ for (const dirName of findClaudeHomeDirNames()) {
114
+ const base = join(homedir(), dirName);
115
+ rules.push(...parseClaudeMd(join(base, "CLAUDE.md"), "global"));
116
+ for (const file of markdownFilesIn(join(base, "rules"))) {
117
+ rules.push(...parseClaudeMd(file, "global"));
118
+ }
119
+ }
54
120
  for (const path of findProjectRuleFiles(cwd)) {
55
121
  rules.push(...parseClaudeMd(path, "project"));
56
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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",
@@ -37,7 +37,8 @@
37
37
  "test": "vitest run",
38
38
  "test:watch": "vitest",
39
39
  "lint": "eslint src tests",
40
- "prepublishOnly": "npm run build && npm test"
40
+ "prepublishOnly": "npm run build && npm test",
41
+ "build:checker": "esbuild src/browser/analyze.ts --bundle --format=esm --minify --outfile=landing/checker.js"
41
42
  },
42
43
  "engines": {
43
44
  "node": ">=18"