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 +86 -72
- package/dist/browser/analyze.d.ts +45 -0
- package/dist/browser/analyze.js +49 -0
- package/dist/checks/classify.js +70 -0
- package/dist/cli.js +32 -3
- package/dist/parsers/claudeMdParser.d.ts +10 -1
- package/dist/parsers/claudeMdParser.js +10 -9
- package/dist/parsers/readClaudeMd.d.ts +19 -0
- package/dist/parsers/readClaudeMd.js +29 -0
- package/dist/rules.d.ts +10 -0
- package/dist/rules.js +75 -9
- package/package.json +3 -2
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
[NOTICE.md](./NOTICE.md) before reusing this code.
|
|
16
|
+
## See it in 10 seconds
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
108
|
+
## Exit codes
|
|
114
109
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
120
|
+
### A limit worth knowing before you wire this into CI
|
|
123
121
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
rulereceipt
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/checks/classify.js
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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/
|
|
3
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { parseClaudeMd } from "./parsers/readClaudeMd.js";
|
|
5
5
|
import { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
|
|
6
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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"
|