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 CHANGED
@@ -6,15 +6,21 @@
6
6
  [![npm](https://img.shields.io/npm/v/rulereceipt)](https://www.npmjs.com/package/rulereceipt)
7
7
  [![provenance](https://img.shields.io/badge/npm-provenance%20signed-blue)](https://www.npmjs.com/package/rulereceipt#provenance)
8
8
 
9
- Checks whether your AI coding agent actually followed your rules — with
10
- evidence, not just a vibe. Works with Claude Code today (OpenAI Codex CLI
11
- support is built and in testing), and reads rules from CLAUDE.md, AGENTS.md,
12
- Cursor (`.cursor/rules`), GitHub Copilot, Windsurf, Gemini (`GEMINI.md`),
13
- Google's `.agents/rules`, and Claude Code memory.
9
+ **Check if your AI coding agent followed the rules in your CLAUDE.md, with the exact line as proof.**
10
+
11
+ [![RuleReceipt checking an agent session against your rules — three rules broken, each with the quoted line](https://raw.githubusercontent.com/rulereceipt/rulereceipt/main/docs/screenshot.png)](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
- full detail, including the three off-by-default opt-ins.
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. It is saved to `.rulereceipt/wrong-<handle>.md` and
336
- printed so you can read and edit it, along with a link to a pre-filled
337
- GitHub issue that you open yourself. Nothing is sent. `check` prints the
338
- command after every report that has a decided verdict, and the HTML report
339
- has a "Verdict wrong?" link on each one that carries only the version and
340
- the verdict, never the rule or the evidence.
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 software — see [LICENSE](./LICENSE) and
502
- [NOTICE.md](./NOTICE.md) before reusing this code.
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
  }
@@ -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
- const GATE_ASK_BEFORE = String.raw `\b(?:ask|check\s+with\s+(?:me|the\s+user)|confirm|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`;
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. The org-wide version runs via the Claude Compliance API for Enterprise orgs.")
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; prints a GitHub issue link for you to open. Nothing is sent.")
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}". Use the handle from \`rulereceipt check --json\` or the rule id shown in the report.`);
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
- console.log("Read it, edit anything private, then open this link to file it (the form is pre-filled; add what you expected):");
995
- console.log(report.issueUrl);
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")
@@ -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(raw: string, source: "global" | "project"): Rule[];
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(raw, source) {
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
- /** Every session file for this project, newest first. */
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";