rulereceipt 0.1.74 → 0.1.76
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 +52 -15
- package/dist/blockHint.d.ts +49 -0
- package/dist/blockHint.js +87 -0
- package/dist/checks/claimEvidence.js +11 -0
- package/dist/checks/classify.js +6 -1
- package/dist/checks/codeContent.js +21 -1
- package/dist/cli.js +95 -6
- package/dist/historyReport.js +2 -0
- package/dist/parsers/claudeMdParser.d.ts +1 -1
- package/dist/parsers/claudeMdParser.js +23 -1
- package/dist/parsers/imports.d.ts +9 -0
- package/dist/parsers/imports.js +107 -0
- package/dist/parsers/transcriptParser.d.ts +10 -1
- package/dist/parsers/transcriptParser.js +130 -9
- package/dist/rules.js +37 -0
- package/dist/shadowedAgents.js +7 -2
- package/dist/why.d.ts +61 -0
- package/dist/why.js +255 -0
- package/dist/wrong.js +16 -2
- package/dist/wrongSubmit.d.ts +24 -0
- package/dist/wrongSubmit.js +50 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,15 +6,21 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/rulereceipt)
|
|
7
7
|
[](https://www.npmjs.com/package/rulereceipt#provenance)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
**Check if your AI coding agent followed the rules in your CLAUDE.md, with the exact line as proof.**
|
|
10
|
+
|
|
11
|
+
[](https://rulereceipt.dev)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx rulereceipt
|
|
15
|
+
```
|
|
14
16
|
|
|
15
17
|
Runs entirely on your machine. Plain `rulereceipt check` makes zero network
|
|
16
|
-
calls — [Trust, privacy and licensing](#trust-privacy-and-licensing) has the
|
|
17
|
-
|
|
18
|
+
calls — [Trust, privacy and licensing](#trust-privacy-and-licensing) has the full
|
|
19
|
+
detail, including the three off-by-default opt-ins. Works with Claude Code today
|
|
20
|
+
(OpenAI Codex CLI in testing); reads rules from CLAUDE.md, AGENTS.md, Cursor
|
|
21
|
+
(`.cursor/rules`), GitHub Copilot, Windsurf, Gemini (`GEMINI.md`), Google's
|
|
22
|
+
`.agents/rules`, and Claude Code memory. [Accuracy](https://rulereceipt.dev/accuracy)
|
|
23
|
+
· [Known gaps](KNOWN-GAPS.md) · Source-available, not OSI — see [LICENSE](LICENSE).
|
|
18
24
|
|
|
19
25
|
## See it in 10 seconds
|
|
20
26
|
|
|
@@ -332,12 +338,25 @@ npx rulereceipt wrong <rule-handle>
|
|
|
332
338
|
|
|
333
339
|
Builds a report of that rule, the verdict, how it was decided and the
|
|
334
340
|
session lines around it, with obvious secrets, your home path and email
|
|
335
|
-
addresses masked
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
+
addresses masked (GitHub/Slack/Stripe tokens, JWTs, passwords in URLs,
|
|
342
|
+
private-key blocks and `.env`-style `KEY=value` lines too — but masking
|
|
343
|
+
catches common formats only, so read it before sending). It is saved to
|
|
344
|
+
`.rulereceipt/wrong-<handle>.md` and printed so you can read and edit it.
|
|
345
|
+
|
|
346
|
+
Nothing is ever sent automatically. After showing the report you get three
|
|
347
|
+
choices:
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
rulereceipt wrong <rule> --submit # open a PUBLIC GitHub issue (asks y/N first; needs gh)
|
|
351
|
+
rulereceipt wrong <rule> --email # a mailto: to hello@rulereceipt.dev, private
|
|
352
|
+
rulereceipt wrong <rule> # just print the report + a pre-filled issue link
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`--submit` shows the full report, then asks before creating anything — the
|
|
356
|
+
default answer is No, and `--yes` does not skip that question. If `gh` isn't
|
|
357
|
+
installed or logged in, or you're not at a terminal, it never sends: it
|
|
358
|
+
prints the pre-filled link for you to open yourself. You can pass a rule by
|
|
359
|
+
the short handle or by the id shown in the report (e.g. `S1.2`).
|
|
341
360
|
|
|
342
361
|
Every accuracy fix in this project has come from a report like this.
|
|
343
362
|
|
|
@@ -498,8 +517,26 @@ so you can verify the published package was built from this repository at
|
|
|
498
517
|
a specific commit. No publishing token exists to be stolen. Check it
|
|
499
518
|
yourself with `npm audit signatures` after installing.
|
|
500
519
|
|
|
501
|
-
**Licence.** Source-available
|
|
502
|
-
|
|
520
|
+
**Licence.** Source-available, not OSI open source: the code is public and
|
|
521
|
+
you can read, run and modify it for yourself, but reuse is limited — see
|
|
522
|
+
[LICENSE](./LICENSE) and [NOTICE.md](./NOTICE.md) before reusing it.
|
|
523
|
+
|
|
524
|
+
**Windows.** Not tested yet. RuleReceipt is developed and tested on macOS
|
|
525
|
+
and Linux. It may work on Windows, but nothing there is verified — treat it
|
|
526
|
+
as unsupported until this note changes.
|
|
527
|
+
|
|
528
|
+
## Uninstalling
|
|
529
|
+
|
|
530
|
+
Easy to remove, no leftovers:
|
|
531
|
+
|
|
532
|
+
```bash
|
|
533
|
+
rulereceipt protect --undo # restores .claude/settings.json byte-for-byte
|
|
534
|
+
npm uninstall -g rulereceipt # or: npm rm rulereceipt in a project
|
|
535
|
+
rm -rf .rulereceipt/ # the local reports/receipts folder, if you want it gone
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
`protect --undo` is only needed if you ran `protect`. Nothing else is
|
|
539
|
+
installed anywhere on your system.
|
|
503
540
|
|
|
504
541
|
## Contact
|
|
505
542
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Classification } from "./checks/classify.js";
|
|
2
|
+
/**
|
|
3
|
+
* `why` answers "why isn't THIS rule working?". For a rule that names a
|
|
4
|
+
* concrete action, the honest follow-up is "then how do I actually stop it?".
|
|
5
|
+
* This computes that answer — and it is deliberately pinned to what the tool
|
|
6
|
+
* really does, because an overstated "add this and you're safe" is exactly the
|
|
7
|
+
* failure RuleReceipt exists to catch.
|
|
8
|
+
*
|
|
9
|
+
* Two facts, kept separate because they are not the same promise:
|
|
10
|
+
*
|
|
11
|
+
* - `guardCovers`: RuleReceipt's own PreToolUse guard refuses this exact rule
|
|
12
|
+
* before the action runs. True only for the kinds guardDecision/structuredBlocks
|
|
13
|
+
* actually handle (a branch, a file, forbidden file content, an AI-authorship
|
|
14
|
+
* trailer) and for an approval gate (which it answers "ask", or "deny" in a
|
|
15
|
+
* no-prompt mode). It is NOT a general command blocker — that was measured and
|
|
16
|
+
* cut (see guard.ts), so nothing here claims it.
|
|
17
|
+
*
|
|
18
|
+
* - `native`: a Claude Code `permissions` entry the user can add by hand with no
|
|
19
|
+
* extra tool. Only emitted where a permission rule can genuinely express the
|
|
20
|
+
* thing, and always with the honest caveat: a permission rule matches the
|
|
21
|
+
* command text or the file path, so it cannot scope to a branch, cannot read a
|
|
22
|
+
* commit message, and cannot see file content. Where it can't express the rule,
|
|
23
|
+
* `nativeImpossibleReason` says so instead of inventing a rule that wouldn't fire.
|
|
24
|
+
*
|
|
25
|
+
* `preventable: false` is the honest answer for the rest: a claim-evidence rule,
|
|
26
|
+
* an emoji rule, an edit-implies-test rule, a plain literal rule — these are judged
|
|
27
|
+
* AFTER the run, not blockable before an action. The caller points at `check` and
|
|
28
|
+
* the Stop hook instead.
|
|
29
|
+
*/
|
|
30
|
+
export interface BlockHint {
|
|
31
|
+
/** Can an action be refused BEFORE it runs (guard or a native deny/ask)? */
|
|
32
|
+
preventable: boolean;
|
|
33
|
+
/** Does RuleReceipt's own guard (`rulereceipt protect`) check this exact rule pre-flight? */
|
|
34
|
+
guardCovers: boolean;
|
|
35
|
+
/** A native Claude Code permissions entry, where one can genuinely express the rule. */
|
|
36
|
+
native?: {
|
|
37
|
+
kind: "deny" | "ask";
|
|
38
|
+
entries: string[];
|
|
39
|
+
note: string;
|
|
40
|
+
};
|
|
41
|
+
/** When preventable but a native permission rule cannot express it, why. */
|
|
42
|
+
nativeImpossibleReason?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The block advice for one classified rule, or undefined when there is nothing
|
|
46
|
+
* useful to say (a judgment rule or a non-rule — the "needs your judgment" line
|
|
47
|
+
* already covers those).
|
|
48
|
+
*/
|
|
49
|
+
export declare function blockHintFor(cls: Classification): BlockHint | undefined;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Map an approval-gate action to the Claude Code permission pattern that matches it. */
|
|
2
|
+
function askEntryFor(action) {
|
|
3
|
+
switch (action) {
|
|
4
|
+
case "push": return "Bash(git push:*)";
|
|
5
|
+
case "commit": return "Bash(git commit:*)";
|
|
6
|
+
case "pr": return "Bash(gh pr:*)";
|
|
7
|
+
case "delete": return "Bash(rm:*)";
|
|
8
|
+
default: return undefined;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The block advice for one classified rule, or undefined when there is nothing
|
|
13
|
+
* useful to say (a judgment rule or a non-rule — the "needs your judgment" line
|
|
14
|
+
* already covers those).
|
|
15
|
+
*/
|
|
16
|
+
export function blockHintFor(cls) {
|
|
17
|
+
switch (cls.kind) {
|
|
18
|
+
case "gitBranchPolicy": {
|
|
19
|
+
if (cls.polarity !== "forbid")
|
|
20
|
+
return { preventable: false, guardCovers: false };
|
|
21
|
+
return {
|
|
22
|
+
preventable: true,
|
|
23
|
+
guardCovers: true,
|
|
24
|
+
native: {
|
|
25
|
+
kind: "deny",
|
|
26
|
+
entries: ["Bash(git push:*)"],
|
|
27
|
+
note: `a permission rule matches the command text, so this denies EVERY push — it can't ` +
|
|
28
|
+
`scope to the \`${cls.branchName}\` branch. The guard below checks the actual target branch.`,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
case "fileLifecycle": {
|
|
33
|
+
if (cls.polarity !== "forbid")
|
|
34
|
+
return { preventable: false, guardCovers: false };
|
|
35
|
+
return {
|
|
36
|
+
preventable: true,
|
|
37
|
+
guardCovers: true,
|
|
38
|
+
native: {
|
|
39
|
+
kind: "deny",
|
|
40
|
+
entries: [`Edit(${cls.filePath})`, `Write(${cls.filePath})`],
|
|
41
|
+
note: `covers Edit/Write of that path; a Bash \`rm\`/\`mv\` targeting it is NOT matched by ` +
|
|
42
|
+
`these — the guard below covers those too.`,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
case "approvalGate": {
|
|
47
|
+
const entries = cls.actions.map(askEntryFor).filter((e) => e !== undefined);
|
|
48
|
+
if (entries.length === 0)
|
|
49
|
+
return { preventable: true, guardCovers: true, nativeImpossibleReason: "the action it gates isn't one a permission rule can match" };
|
|
50
|
+
return {
|
|
51
|
+
preventable: true,
|
|
52
|
+
guardCovers: true,
|
|
53
|
+
native: {
|
|
54
|
+
kind: "ask",
|
|
55
|
+
entries,
|
|
56
|
+
note: `an "ask" is skipped in no-prompt modes (bypassPermissions / auto), so the action would ` +
|
|
57
|
+
`run there without a prompt. The guard below denies it in those modes instead.`,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
case "attribution":
|
|
62
|
+
return {
|
|
63
|
+
preventable: true,
|
|
64
|
+
guardCovers: true,
|
|
65
|
+
nativeImpossibleReason: "a permission rule can't read a commit message, so it can't catch an AI-authorship trailer",
|
|
66
|
+
};
|
|
67
|
+
case "codeContent": {
|
|
68
|
+
if (cls.polarity !== "forbid")
|
|
69
|
+
return { preventable: false, guardCovers: false };
|
|
70
|
+
return {
|
|
71
|
+
preventable: true,
|
|
72
|
+
guardCovers: true,
|
|
73
|
+
nativeImpossibleReason: "a permission rule matches the command or path, not the content written into a file",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
case "claimEvidence":
|
|
77
|
+
case "ifEditThenTest":
|
|
78
|
+
case "emojiOutput":
|
|
79
|
+
case "deterministic":
|
|
80
|
+
// Checkable, but only after the run — there is no single action to refuse
|
|
81
|
+
// beforehand. The caller points at `check` and the Stop hook.
|
|
82
|
+
return { preventable: false, guardCovers: false };
|
|
83
|
+
default:
|
|
84
|
+
// judgment, notARule — nothing mechanical to block or check.
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -200,6 +200,10 @@ function outcomeFromOutput(output) {
|
|
|
200
200
|
return false;
|
|
201
201
|
return null;
|
|
202
202
|
}
|
|
203
|
+
/** A test command carrying a leading env-var assignment is a variant of the default run. */
|
|
204
|
+
function isVariantRun(command) {
|
|
205
|
+
return /(?:^|&&|;|\|\||\bthen\b|\bdo\b|\s)\s*[A-Z][A-Z0-9_]+=\S+\s+\S/.test(command);
|
|
206
|
+
}
|
|
203
207
|
/**
|
|
204
208
|
* A command short enough to read in a report.
|
|
205
209
|
*
|
|
@@ -322,6 +326,7 @@ export function runClaimEvidenceChecks(classifications, events) {
|
|
|
322
326
|
failed: stated !== null ? stated : event.isError,
|
|
323
327
|
outcomeReadable: oneRun && (stated !== null || trustExitCode),
|
|
324
328
|
output: event.content.slice(0, 200),
|
|
329
|
+
variant: isVariantRun(pendingRun),
|
|
325
330
|
};
|
|
326
331
|
pendingRuns.delete(resultId);
|
|
327
332
|
unknownSinceRed = null; // a recognised run supersedes anything before it
|
|
@@ -378,6 +383,12 @@ export function runClaimEvidenceChecks(classifications, events) {
|
|
|
378
383
|
if (uncertain === null)
|
|
379
384
|
uncertain = { claim: sentence.trim(), script: unknownSinceRed };
|
|
380
385
|
}
|
|
386
|
+
else if (lastRun.failed && lastRun.variant) {
|
|
387
|
+
// A deliberately-different variant run (env-var prefix) failing does not
|
|
388
|
+
// contradict a claim about the DEFAULT suite — the honest case where
|
|
389
|
+
// "npm test" passes and "DISABLE_LOCKS=1 npm test" is reported failing in
|
|
390
|
+
// the same breath. Not a contradiction; leave it can't-tell.
|
|
391
|
+
}
|
|
381
392
|
else if (lastRun.failed && contradiction === null) {
|
|
382
393
|
contradiction = { claim: sentence.trim(), run: lastRun };
|
|
383
394
|
}
|
package/dist/checks/classify.js
CHANGED
|
@@ -626,7 +626,12 @@ const GATE_ACTIONS = [
|
|
|
626
626
|
];
|
|
627
627
|
const GATE_NEG = String.raw `\b(?:never|don'?t|do\s+not|must\s+not|mustn'?t|should\s+not|shouldn'?t|no)\b`;
|
|
628
628
|
const GATE_CONSENT = String.raw `\b(?:without\s+(?:(?:the\s+)?(?:user'?s?|my|your|an?)\s+)?(?:explicit(?:ly)?\s+|express\s+|prior\s+)?(?:(?:the\s+)?user'?s?\s+|my\s+)?(?:permission|approval|consent|confirmation|instruction|request|sign[- ]?off|go[- ]?ahead|asking|being\s+(?:asked|told|instructed))|unless\s+(?:(?:the\s+)?user|i|you\s+are|explicitly)\s*(?:explicitly\s+)?(?:asks?|asked|requests?|requested|says?|tells?|told|instructs?|instructed|approves?|approved|confirms?)|until\s+(?:the\s+)?user\s+(?:confirms|approves|says|asks))\b`;
|
|
629
|
-
|
|
629
|
+
// "confirm" alone means VERIFY, not "ask the user" — found on unseen data
|
|
630
|
+
// 2026-09-29: a file note "`extension-report.py` … confirm before committing"
|
|
631
|
+
// became a blanket commit-approval gate. So a bare "confirm"/"check" no longer
|
|
632
|
+
// counts; it must be "confirm/check WITH me/the user". "ask", "get approval" and
|
|
633
|
+
// "wait for approval" before the action still count.
|
|
634
|
+
const GATE_ASK_BEFORE = String.raw `\b(?:ask|(?:check|confirm)\s+with\s+(?:me|the\s+user|us)|get\s+(?:approval|permission|sign[- ]?off)|wait\s+for\s+(?:(?:the\s+)?(?:user|me)|approval|confirmation|explicit|sign[- ]?off))\b(?:\s+\w+){0,4}?\s+(?:before|prior\s+to)\b`;
|
|
630
635
|
/**
|
|
631
636
|
* Which gated actions a rule makes conditional on the user's say-so.
|
|
632
637
|
*
|
|
@@ -45,7 +45,21 @@ function editedContentFromEvent(event) {
|
|
|
45
45
|
* open paren or a call — so requiring a boundary after it would reject the
|
|
46
46
|
* arguments.
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* The token sits inside a natural-language sentence (a lowercase word + space
|
|
50
|
+
* right before it, and a space + lowercase word right after) — a MENTION, not
|
|
51
|
+
* code. Found on unseen data 2026-09-29: "Avoid `try-catch` in hot paths" FAILed
|
|
52
|
+
* (and the guard blocked a Write) because "try-catch" appears in the prose
|
|
53
|
+
* "...use a try-catch block...". A real import (`from "lucide-react"`) is not
|
|
54
|
+
* sandwiched in prose (it is bounded by quotes), so it still matches.
|
|
55
|
+
*/
|
|
56
|
+
function isProseSandwich(content, at, pattern) {
|
|
57
|
+
const before = content.slice(Math.max(0, at - 12), at);
|
|
58
|
+
const after = content.slice(at + pattern.length, at + pattern.length + 12);
|
|
59
|
+
return /[a-z]\s$/.test(before) && /^\s[a-z]/.test(after);
|
|
60
|
+
}
|
|
48
61
|
function containsCall(content, pattern) {
|
|
62
|
+
const isCall = pattern.endsWith("(");
|
|
49
63
|
const leadsWithIdentifier = /^[A-Za-z0-9_$]/.test(pattern);
|
|
50
64
|
if (!leadsWithIdentifier) {
|
|
51
65
|
// A CALL like `.forEach(` legitimately follows an object (`arr.forEach()`),
|
|
@@ -79,8 +93,14 @@ function containsCall(content, pattern) {
|
|
|
79
93
|
// continuation — `analytics.track(` is a real call to `track(`. So `.` is
|
|
80
94
|
// NOT in the disqualifying class (fixed 2026-09-26); `_` still is, so
|
|
81
95
|
// `_metar_fetch(` does not match `fetch(`.
|
|
82
|
-
if (!/[A-Za-z0-9_$]/.test(before))
|
|
96
|
+
if (!/[A-Za-z0-9_$]/.test(before)) {
|
|
97
|
+
// A non-call token embedded in a prose sentence is a mention, not code.
|
|
98
|
+
if (!isCall && isProseSandwich(content, at, pattern)) {
|
|
99
|
+
from = at + 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
83
102
|
return true;
|
|
103
|
+
}
|
|
84
104
|
from = at + 1;
|
|
85
105
|
}
|
|
86
106
|
}
|
package/dist/cli.js
CHANGED
|
@@ -14,9 +14,12 @@ import { shadowedAgentsMd } from "./shadowedAgents.js";
|
|
|
14
14
|
import { auditSessions, renderComplianceReport } from "./report/complianceReport.js";
|
|
15
15
|
import { auditProject, renderProjectAudit } from "./audit.js";
|
|
16
16
|
import { evaluateSession } from "./evaluate.js";
|
|
17
|
-
import { buildWrongReport, findTarget } from "./wrong.js";
|
|
17
|
+
import { buildWrongReport, findTarget, reportedLabel } from "./wrong.js";
|
|
18
|
+
import { ghReady, issueTitle, issueCreateArgs, buildMailto, mailtoSubject } from "./wrongSubmit.js";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
18
20
|
import { detectSelfEditedRuleFiles } from "./checks/selfEditedRules.js";
|
|
19
21
|
import { scanHistory, renderHistory } from "./historyReport.js";
|
|
22
|
+
import { explainRule, renderWhy, explainAll, renderAllWhy, whyList, renderWhyList } from "./why.js";
|
|
20
23
|
import { observeSessions, renderNoRules, draftRulesFromHistory } from "./sessionObserve.js";
|
|
21
24
|
import { listSessionRows, renderSessionList } from "./listSessions.js";
|
|
22
25
|
import { runSelfTestChecks, renderSelfTest } from "./selftest.js";
|
|
@@ -928,7 +931,7 @@ const PERIOD_MS = {
|
|
|
928
931
|
};
|
|
929
932
|
program
|
|
930
933
|
.command("report")
|
|
931
|
-
.description("Compliance report across your recent sessions (not just the latest): which policy rules were broken, where, with evidence. Deterministic, local, no network.
|
|
934
|
+
.description("Compliance report across your recent sessions (not just the latest): which policy rules were broken, where, with evidence. Deterministic, local, no network. An org-wide version (multi-repo, trends, a manager digest) is coming in the team version.")
|
|
932
935
|
.option("--last <n>", "how many recent sessions to audit", "25")
|
|
933
936
|
.option("--markdown", "output as markdown, for a report you can send")
|
|
934
937
|
.action(async (opts) => {
|
|
@@ -949,12 +952,49 @@ program
|
|
|
949
952
|
}
|
|
950
953
|
console.log(renderProjectAudit(a, Boolean(opts.markdown)));
|
|
951
954
|
});
|
|
955
|
+
program
|
|
956
|
+
.command("why [rule...]")
|
|
957
|
+
.description("Everything the tool knows about ONE rule, in one place: where it lives (file:line), whether the agent actually loads it, whether a command or path it names exists, whether it's mechanically checkable (and if not, one suggested rewrite), and how it did over the last 30 days. With no argument, lists every rule with its id so you can pick one; with --all, shows every rule (problems first). Read-only — no verdict is created, nothing is sent.")
|
|
958
|
+
.option("--all", "show every rule, problems first (not loaded, missing command, broken recently), then the rest")
|
|
959
|
+
.option("--json", "output machine-readable JSON (the same fields)")
|
|
960
|
+
.action(async (ruleWords, opts) => {
|
|
961
|
+
const cwd = process.cwd();
|
|
962
|
+
const query = (ruleWords ?? []).join(" ").trim();
|
|
963
|
+
const rules = loadRules(cwd);
|
|
964
|
+
if (rules.length === 0) {
|
|
965
|
+
console.log("No rules file found here, so there is nothing to explain. Run `rulereceipt init` to add one.");
|
|
966
|
+
process.exitCode = 1;
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
// --all: every rule, problems first.
|
|
970
|
+
if (opts.all) {
|
|
971
|
+
const all = await explainAll(cwd);
|
|
972
|
+
console.log(opts.json ? JSON.stringify(all, null, 2) : renderAllWhy(all));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
// No argument: list the rules with ids so the reader can pick one.
|
|
976
|
+
if (query.length === 0) {
|
|
977
|
+
const list = whyList(cwd);
|
|
978
|
+
console.log(opts.json ? JSON.stringify(list, null, 2) : renderWhyList(list));
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const result = await explainRule(cwd, query);
|
|
982
|
+
if (opts.json) {
|
|
983
|
+
console.log(JSON.stringify(result, null, 2));
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
console.log(renderWhy(result));
|
|
987
|
+
if (result.matches === 0 || result.candidates)
|
|
988
|
+
process.exitCode = 1;
|
|
989
|
+
});
|
|
952
990
|
program
|
|
953
991
|
.command("wrong <rule>")
|
|
954
|
-
.description("A verdict looks wrong? Builds a report of that rule, the verdict, how it was decided and the session lines around it, with obvious secrets masked. Written to a local file and shown first
|
|
992
|
+
.description("A verdict looks wrong? Builds a report of that rule, the verdict, how it was decided and the session lines around it, with obvious secrets masked. Written to a local file and shown first. Then --submit opens a public GitHub issue (asks first; needs gh) or --email sends it privately to the maintainer; with no flag it just prints the report and a pre-filled link. Nothing is sent without your say-so.")
|
|
955
993
|
.option("--transcript <path>", "use a specific session file (same as check)")
|
|
956
994
|
.option("--out <path>", "where to write the report (default .rulereceipt/wrong-<handle>.md)")
|
|
957
995
|
.option("--no-context", "leave out the session lines around the evidence")
|
|
996
|
+
.option("--submit", "after showing the report, offer to open a PUBLIC GitHub issue (asks first; needs gh)")
|
|
997
|
+
.option("--email", "print a mailto: to send the report privately to the maintainer")
|
|
958
998
|
.action(async (ruleArg, opts) => {
|
|
959
999
|
const cwd = process.cwd();
|
|
960
1000
|
const rules = loadRules(cwd);
|
|
@@ -974,7 +1014,13 @@ program
|
|
|
974
1014
|
const { results } = await evaluateSession(cwd, rules, events, false, needsLlmResult);
|
|
975
1015
|
const target = findTarget(ruleArg, rules, results);
|
|
976
1016
|
if (!target) {
|
|
977
|
-
console.log(`No checked rule matches "${ruleArg}"
|
|
1017
|
+
console.log(`No checked rule matches "${ruleArg}".`);
|
|
1018
|
+
const valid = results.map((r) => ` ${r.ruleId} ${r.ruleTitle.replace(/\s+/g, " ").slice(0, 70)}`);
|
|
1019
|
+
if (valid.length > 0) {
|
|
1020
|
+
console.log("Valid ids from the latest session (use one of these, or the handle from `rulereceipt check --json`):");
|
|
1021
|
+
for (const line of valid.slice(0, 40))
|
|
1022
|
+
console.log(line);
|
|
1023
|
+
}
|
|
978
1024
|
process.exitCode = 1;
|
|
979
1025
|
return;
|
|
980
1026
|
}
|
|
@@ -991,8 +1037,51 @@ program
|
|
|
991
1037
|
writeFileSync(outPath, report.markdown);
|
|
992
1038
|
console.log(report.markdown);
|
|
993
1039
|
console.log(`\nSaved to ${outPath}. Nothing was sent.`);
|
|
994
|
-
|
|
995
|
-
|
|
1040
|
+
const reported = reportedLabel(target.result);
|
|
1041
|
+
if (opts.submit) {
|
|
1042
|
+
// PUBLIC issue. Preview was just printed; --yes does NOT bypass this ask.
|
|
1043
|
+
if (!ghReady()) {
|
|
1044
|
+
console.log("\ngh (GitHub CLI) is not installed or not logged in — nothing was sent.");
|
|
1045
|
+
console.log("Install/login with `gh auth login`, or open this pre-filled link yourself:");
|
|
1046
|
+
console.log(report.issueUrl);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const ok = await confirmYesNo("\nThis creates a PUBLIC issue on github.com/rulereceipt/rulereceipt from your GitHub account. Anyone can read it. Send it? (y/N) ");
|
|
1050
|
+
if (!ok) {
|
|
1051
|
+
console.log("Not sent. The report is saved locally; you can open the link above anytime.");
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const title = issueTitle(reported, target.rule.title);
|
|
1055
|
+
let res = spawnSync("gh", issueCreateArgs(title, report.markdown, true), { encoding: "utf-8" });
|
|
1056
|
+
if (res.status !== 0) {
|
|
1057
|
+
// The wrong-verdict label may not exist yet: retry without it.
|
|
1058
|
+
res = spawnSync("gh", issueCreateArgs(title, report.markdown, false), { encoding: "utf-8" });
|
|
1059
|
+
}
|
|
1060
|
+
if (res.status === 0) {
|
|
1061
|
+
const url = (res.stdout || "").trim();
|
|
1062
|
+
console.log(`\nOpened: ${url || "issue created"}`);
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
console.log("\nCould not create the issue automatically — nothing was sent. Open this link instead:");
|
|
1066
|
+
console.log(report.issueUrl);
|
|
1067
|
+
process.exitCode = 1;
|
|
1068
|
+
}
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (opts.email) {
|
|
1072
|
+
const { url, trimmed } = buildMailto(mailtoSubject(target.rule.title), report.markdown);
|
|
1073
|
+
console.log("\nSend privately to the maintainer:");
|
|
1074
|
+
console.log(url);
|
|
1075
|
+
console.log(`\nIf your mail app doesn't open, email hello@rulereceipt.dev and attach: ${outPath}`);
|
|
1076
|
+
if (trimmed)
|
|
1077
|
+
console.log("(The report was long, so the email body is trimmed — attach the saved file above.)");
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
// No flag: show the three ways to send, plus the pre-filled link.
|
|
1081
|
+
console.log(`\nRead it first, then:`);
|
|
1082
|
+
console.log(` Send publicly: rulereceipt wrong ${ruleArg} --submit`);
|
|
1083
|
+
console.log(` Send privately: rulereceipt wrong ${ruleArg} --email`);
|
|
1084
|
+
console.log(` Or open: ${report.issueUrl}`);
|
|
996
1085
|
});
|
|
997
1086
|
program
|
|
998
1087
|
.command("digest")
|
package/dist/historyReport.js
CHANGED
|
@@ -150,6 +150,8 @@ export function renderHistory(s, projectName, now = Date.now()) {
|
|
|
150
150
|
out.push(`checked ${s.sessionsScanned} session${s.sessionsScanned === 1 ? "" : "s"} in ${secs}s`);
|
|
151
151
|
out.push("");
|
|
152
152
|
out.push("See one session in full: rulereceipt check");
|
|
153
|
+
out.push("Make Claude ask first: rulereceipt protect");
|
|
154
|
+
out.push("Share the result: rulereceipt card");
|
|
153
155
|
out.push("Think a verdict is wrong? rulereceipt wrong <rule>");
|
|
154
156
|
return out.join("\n");
|
|
155
157
|
}
|
|
@@ -8,4 +8,4 @@ import type { Rule } from "../types.js";
|
|
|
8
8
|
* ("your file never leaves the page") is only true because nothing in
|
|
9
9
|
* this function or in classify.ts touches Node APIs; keep it that way.
|
|
10
10
|
*/
|
|
11
|
-
export declare function parseClaudeMdText(
|
|
11
|
+
export declare function parseClaudeMdText(rawInput: string, source: "global" | "project"): Rule[];
|
|
@@ -83,6 +83,25 @@ function stripHtmlComments(raw) {
|
|
|
83
83
|
const stripped = masked.replace(/<!--[\s\S]*?-->/g, (m) => "\n".repeat((m.match(/\n/g) ?? []).length));
|
|
84
84
|
return stripped.replace(/\u0000CODE(\d+)\u0000/g, (_, i) => spans[Number(i)]);
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* A whole-line `@import` directive (`@AGENTS.md`, `@docs/rules.md`) is how Claude
|
|
88
|
+
* Code pulls in another file — it is NOT itself a rule. Blank those lines (keeping
|
|
89
|
+
* the line, so source lines below stay right) so a CLAUDE.md whose body is just
|
|
90
|
+
* `@AGENTS.md` does not report the import as a phantom one-line rule. Imports
|
|
91
|
+
* inside code spans/fences are left untouched (they are examples, not directives).
|
|
92
|
+
*/
|
|
93
|
+
function stripImportDirectiveLines(raw) {
|
|
94
|
+
const spans = [];
|
|
95
|
+
const masked = raw.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`/g, (m) => {
|
|
96
|
+
spans.push(m);
|
|
97
|
+
return `\u0000CODE${spans.length - 1}\u0000`;
|
|
98
|
+
});
|
|
99
|
+
const blanked = masked
|
|
100
|
+
.split("\n")
|
|
101
|
+
.map((line) => (/^\s*@[^\s]+\s*$/.test(line) ? "" : line))
|
|
102
|
+
.join("\n");
|
|
103
|
+
return blanked.replace(/\u0000CODE(\d+)\u0000/g, (_, i) => spans[Number(i)]);
|
|
104
|
+
}
|
|
86
105
|
function normalizeSetextHeaders(lines) {
|
|
87
106
|
const out = [...lines];
|
|
88
107
|
// Fence-aware for the same reason as the main pass: a row of dashes inside
|
|
@@ -134,7 +153,10 @@ function normalizeSetextHeaders(lines) {
|
|
|
134
153
|
* ("your file never leaves the page") is only true because nothing in
|
|
135
154
|
* this function or in classify.ts touches Node APIs; keep it that way.
|
|
136
155
|
*/
|
|
137
|
-
export function parseClaudeMdText(
|
|
156
|
+
export function parseClaudeMdText(rawInput, source) {
|
|
157
|
+
// Whole-line @import directives are file references, not rules — drop them
|
|
158
|
+
// before anything counts or classifies them.
|
|
159
|
+
const raw = stripImportDirectiveLines(rawInput);
|
|
138
160
|
const lines = normalizeSetextHeaders(stripHtmlComments(raw).split("\n"));
|
|
139
161
|
const rules = [];
|
|
140
162
|
// `current` accumulates a numbered-header rule, a bold-rule-header rule,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Blank out fenced code blocks and inline code spans so @tokens inside them are ignored. */
|
|
2
|
+
export declare function stripCodeForImports(md: string): string;
|
|
3
|
+
/** The @import paths a file names, resolved to absolute paths (existence not yet checked). */
|
|
4
|
+
export declare function importTargets(filePath: string, content: string): string[];
|
|
5
|
+
/**
|
|
6
|
+
* Absolute paths of every file transitively @imported by `filePath`, in a stable
|
|
7
|
+
* order, each confirmed to exist as a real file. `filePath` itself is excluded.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveImports(filePath: string): string[];
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
/**
|
|
5
|
+
* Claude Code's @import feature: a rules file can pull in another file with an
|
|
6
|
+
* `@path` reference, and the agent reads the imported file's rules as if they
|
|
7
|
+
* were inline. The canonical case is a CLAUDE.md whose entire body is
|
|
8
|
+
* `@AGENTS.md` (the pattern Anthropic's own docs suggest) — the AGENTS.md IS
|
|
9
|
+
* loaded, not shadowed. Missing this made the tool state something untrue ("AGENTS.md
|
|
10
|
+
* present but not loaded") and silently skip every imported rule (found by
|
|
11
|
+
* independent test on 0.1.74, 2026-09-29).
|
|
12
|
+
*
|
|
13
|
+
* Faithful to how Claude Code resolves them, per its docs:
|
|
14
|
+
* - relative paths resolve against the importing file's directory; `~` is home;
|
|
15
|
+
* - imports inside code spans (`...`) and fenced code blocks (``` ```) do NOT count;
|
|
16
|
+
* - a bounded hop depth (5) guards against import cycles.
|
|
17
|
+
* An @token that resolves to something that is not a real file is ignored, which
|
|
18
|
+
* is what keeps an email address (`name@host`) or an @mention from being treated
|
|
19
|
+
* as an import.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_DEPTH = 5;
|
|
22
|
+
/** Blank out fenced code blocks and inline code spans so @tokens inside them are ignored. */
|
|
23
|
+
export function stripCodeForImports(md) {
|
|
24
|
+
const lines = md.split(/\r?\n/);
|
|
25
|
+
let inFence = false;
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
29
|
+
inFence = !inFence;
|
|
30
|
+
out.push("");
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (inFence) {
|
|
34
|
+
out.push("");
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
out.push(line.replace(/`[^`]*`/g, " "));
|
|
38
|
+
}
|
|
39
|
+
return out.join("\n");
|
|
40
|
+
}
|
|
41
|
+
function realOr(p) {
|
|
42
|
+
try {
|
|
43
|
+
return realpathSync(p);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function isFile(p) {
|
|
50
|
+
try {
|
|
51
|
+
return statSync(p).isFile();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The @import paths a file names, resolved to absolute paths (existence not yet checked). */
|
|
58
|
+
export function importTargets(filePath, content) {
|
|
59
|
+
const base = dirname(filePath);
|
|
60
|
+
const stripped = stripCodeForImports(content);
|
|
61
|
+
const targets = [];
|
|
62
|
+
// An import is `@` at line start or after whitespace, then a path with no spaces.
|
|
63
|
+
const re = /(?:^|\s)@(\S+)/g;
|
|
64
|
+
let m;
|
|
65
|
+
while ((m = re.exec(stripped)) !== null) {
|
|
66
|
+
let p = m[1].replace(/[).,;:'"]+$/, ""); // trailing prose punctuation is not part of the path
|
|
67
|
+
if (p.length === 0)
|
|
68
|
+
continue;
|
|
69
|
+
if (p === "~" || p.startsWith("~/"))
|
|
70
|
+
p = resolve(homedir(), p.slice(2));
|
|
71
|
+
else if (!isAbsolute(p))
|
|
72
|
+
p = resolve(base, p);
|
|
73
|
+
targets.push(p);
|
|
74
|
+
}
|
|
75
|
+
return targets;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Absolute paths of every file transitively @imported by `filePath`, in a stable
|
|
79
|
+
* order, each confirmed to exist as a real file. `filePath` itself is excluded.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveImports(filePath) {
|
|
82
|
+
const found = [];
|
|
83
|
+
const seen = new Set([realOr(filePath)]);
|
|
84
|
+
const walk = (fp, depth) => {
|
|
85
|
+
if (depth >= MAX_DEPTH)
|
|
86
|
+
return;
|
|
87
|
+
let content;
|
|
88
|
+
try {
|
|
89
|
+
content = readFileSync(fp, "utf-8");
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const target of importTargets(fp, content)) {
|
|
95
|
+
const real = realOr(target);
|
|
96
|
+
if (seen.has(real))
|
|
97
|
+
continue;
|
|
98
|
+
seen.add(real);
|
|
99
|
+
if (!existsSync(target) || !isFile(target))
|
|
100
|
+
continue;
|
|
101
|
+
found.push(target);
|
|
102
|
+
walk(target, depth + 1);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
walk(filePath, 0);
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
@@ -5,7 +5,16 @@ import type { TranscriptEvent } from "../types.js";
|
|
|
5
5
|
* variant could keep its own global rules file under its own home dir.
|
|
6
6
|
*/
|
|
7
7
|
export declare function findClaudeHomeDirNames(): string[];
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Every session file that belongs to this project, newest first.
|
|
10
|
+
*
|
|
11
|
+
* A folder matches when the real cwd stored in its sessions is THIS directory
|
|
12
|
+
* (encoding-independent — handles dots, underscores, spaces, symlinks), or when
|
|
13
|
+
* this directory is a project root and the session's cwd is inside it (a
|
|
14
|
+
* monorepo subfolder like packages/api, so the root check sees that work too).
|
|
15
|
+
* Encoding candidates are only a fallback for folders whose stored cwd can't be
|
|
16
|
+
* read.
|
|
17
|
+
*/
|
|
9
18
|
export declare function listAllSessionFiles(cwd: string): string[];
|
|
10
19
|
export declare function findLatestSessionFile(cwd: string): string | null;
|
|
11
20
|
export { parseLine } from "./transcriptLine.js";
|