rulereceipt 0.1.73 → 0.1.75

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
@@ -332,12 +332,25 @@ npx rulereceipt wrong <rule-handle>
332
332
 
333
333
  Builds a report of that rule, the verdict, how it was decided and the
334
334
  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.
335
+ addresses masked (GitHub/Slack/Stripe tokens, JWTs, passwords in URLs,
336
+ private-key blocks and `.env`-style `KEY=value` lines too — but masking
337
+ catches common formats only, so read it before sending). It is saved to
338
+ `.rulereceipt/wrong-<handle>.md` and printed so you can read and edit it.
339
+
340
+ Nothing is ever sent automatically. After showing the report you get three
341
+ choices:
342
+
343
+ ```bash
344
+ rulereceipt wrong <rule> --submit # open a PUBLIC GitHub issue (asks y/N first; needs gh)
345
+ rulereceipt wrong <rule> --email # a mailto: to hello@rulereceipt.dev, private
346
+ rulereceipt wrong <rule> # just print the report + a pre-filled issue link
347
+ ```
348
+
349
+ `--submit` shows the full report, then asks before creating anything — the
350
+ default answer is No, and `--yes` does not skip that question. If `gh` isn't
351
+ installed or logged in, or you're not at a terminal, it never sends: it
352
+ prints the pre-filled link for you to open yourself. You can pass a rule by
353
+ the short handle or by the id shown in the report (e.g. `S1.2`).
341
354
 
342
355
  Every accuracy fix in this project has come from a report like this.
343
356
 
@@ -498,8 +511,26 @@ so you can verify the published package was built from this repository at
498
511
  a specific commit. No publishing token exists to be stolen. Check it
499
512
  yourself with `npm audit signatures` after installing.
500
513
 
501
- **Licence.** Source-available software — see [LICENSE](./LICENSE) and
502
- [NOTICE.md](./NOTICE.md) before reusing this code.
514
+ **Licence.** Source-available, not OSI open source: the code is public and
515
+ you can read, run and modify it for yourself, but reuse is limited — see
516
+ [LICENSE](./LICENSE) and [NOTICE.md](./NOTICE.md) before reusing it.
517
+
518
+ **Windows.** Not tested yet. RuleReceipt is developed and tested on macOS
519
+ and Linux. It may work on Windows, but nothing there is verified — treat it
520
+ as unsupported until this note changes.
521
+
522
+ ## Uninstalling
523
+
524
+ Easy to remove, no leftovers:
525
+
526
+ ```bash
527
+ rulereceipt protect --undo # restores .claude/settings.json byte-for-byte
528
+ npm uninstall -g rulereceipt # or: npm rm rulereceipt in a project
529
+ rm -rf .rulereceipt/ # the local reports/receipts folder, if you want it gone
530
+ ```
531
+
532
+ `protect --undo` is only needed if you ran `protect`. Nothing else is
533
+ installed anywhere on your system.
503
534
 
504
535
  ## Contact
505
536
 
@@ -46,6 +46,8 @@ export interface ApprovalOptions {
46
46
  allow?: string[];
47
47
  /** When set, a `push` action is only gated if it targets this branch (or its target is unknown). */
48
48
  scopedBranch?: string;
49
+ /** The current git branch (guard only), so a bare `git push` from a feature branch is not gated by a "push to main" rule. */
50
+ currentBranch?: string;
49
51
  }
50
52
  interface Occurrence {
51
53
  action: Action;
@@ -75,7 +75,7 @@ export function approvalScopedBranch(rule) {
75
75
  * push to a DIFFERENT branch returns false, so a feature-branch push is not
76
76
  * gated by a rule that names main.
77
77
  */
78
- function pushTargetsBranch(command, branch) {
78
+ function pushTargetsBranch(command, branch, currentBranch) {
79
79
  const m = command.match(/\bgit\s+(?:\S+\s+){0,4}?push\b(.*)/i);
80
80
  if (!m)
81
81
  return true;
@@ -88,7 +88,13 @@ function pushTargetsBranch(command, branch) {
88
88
  const b = tokens[tokens.length - 1];
89
89
  return b === branch || b.endsWith(`/${branch}`);
90
90
  }
91
- return true; // bare `git push` / `git push origin` — unknown target, gate to be safe
91
+ // Bare `git push` / `git push origin` — the target is the CURRENT branch. When
92
+ // the guard can tell us that branch, gate only if it is the scoped branch (a
93
+ // bare push from a feature branch is not a push to main). When it is unknown
94
+ // (the check path, or git unavailable), gate to be safe.
95
+ if (currentBranch)
96
+ return currentBranch === branch || currentBranch.endsWith(`/${branch}`);
97
+ return true;
92
98
  }
93
99
  /** The result for the call at `i`: matched by id when present, else the next result. */
94
100
  function resultOf(events, i) {
@@ -128,7 +134,7 @@ export function approvalOccurrences(events, actions, opts = {}) {
128
134
  continue;
129
135
  // A branch-scoped push rule ("push to main") does not gate a push to a
130
136
  // different branch — only main (or a bare push whose target is unknown).
131
- if (action === "push" && opts.scopedBranch && !pushTargetsBranch(command, opts.scopedBranch))
137
+ if (action === "push" && opts.scopedBranch && !pushTargetsBranch(command, opts.scopedBranch, opts.currentBranch))
132
138
  continue;
133
139
  const res = resultOf(events, i);
134
140
  if (res && res.kind === "tool_result" && res.isError)
@@ -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 } 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";
@@ -949,12 +952,36 @@ 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. Fuzzy-matches the rule text; if several match, lists them. Read-only — no verdict is created, nothing is sent.")
958
+ .option("--json", "output machine-readable JSON (the same fields)")
959
+ .action(async (ruleWords, opts) => {
960
+ const cwd = process.cwd();
961
+ const query = ruleWords.join(" ").trim();
962
+ const rules = loadRules(cwd);
963
+ if (rules.length === 0) {
964
+ console.log("No rules file found here, so there is nothing to explain. Run `rulereceipt init` to add one.");
965
+ process.exitCode = 1;
966
+ return;
967
+ }
968
+ const result = await explainRule(cwd, query);
969
+ if (opts.json) {
970
+ console.log(JSON.stringify(result, null, 2));
971
+ return;
972
+ }
973
+ console.log(renderWhy(result));
974
+ if (result.matches === 0 || result.candidates)
975
+ process.exitCode = 1;
976
+ });
952
977
  program
953
978
  .command("wrong <rule>")
954
979
  .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.")
955
980
  .option("--transcript <path>", "use a specific session file (same as check)")
956
981
  .option("--out <path>", "where to write the report (default .rulereceipt/wrong-<handle>.md)")
957
982
  .option("--no-context", "leave out the session lines around the evidence")
983
+ .option("--submit", "after showing the report, offer to open a PUBLIC GitHub issue (asks first; needs gh)")
984
+ .option("--email", "print a mailto: to send the report privately to the maintainer")
958
985
  .action(async (ruleArg, opts) => {
959
986
  const cwd = process.cwd();
960
987
  const rules = loadRules(cwd);
@@ -974,7 +1001,13 @@ program
974
1001
  const { results } = await evaluateSession(cwd, rules, events, false, needsLlmResult);
975
1002
  const target = findTarget(ruleArg, rules, results);
976
1003
  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.`);
1004
+ console.log(`No checked rule matches "${ruleArg}".`);
1005
+ const valid = results.map((r) => ` ${r.ruleId} ${r.ruleTitle.replace(/\s+/g, " ").slice(0, 70)}`);
1006
+ if (valid.length > 0) {
1007
+ console.log("Valid ids from the latest session (use one of these, or the handle from `rulereceipt check --json`):");
1008
+ for (const line of valid.slice(0, 40))
1009
+ console.log(line);
1010
+ }
978
1011
  process.exitCode = 1;
979
1012
  return;
980
1013
  }
@@ -991,8 +1024,51 @@ program
991
1024
  writeFileSync(outPath, report.markdown);
992
1025
  console.log(report.markdown);
993
1026
  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);
1027
+ const reported = reportedLabel(target.result);
1028
+ if (opts.submit) {
1029
+ // PUBLIC issue. Preview was just printed; --yes does NOT bypass this ask.
1030
+ if (!ghReady()) {
1031
+ console.log("\ngh (GitHub CLI) is not installed or not logged in — nothing was sent.");
1032
+ console.log("Install/login with `gh auth login`, or open this pre-filled link yourself:");
1033
+ console.log(report.issueUrl);
1034
+ return;
1035
+ }
1036
+ 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) ");
1037
+ if (!ok) {
1038
+ console.log("Not sent. The report is saved locally; you can open the link above anytime.");
1039
+ return;
1040
+ }
1041
+ const title = issueTitle(reported, target.rule.title);
1042
+ let res = spawnSync("gh", issueCreateArgs(title, report.markdown, true), { encoding: "utf-8" });
1043
+ if (res.status !== 0) {
1044
+ // The wrong-verdict label may not exist yet: retry without it.
1045
+ res = spawnSync("gh", issueCreateArgs(title, report.markdown, false), { encoding: "utf-8" });
1046
+ }
1047
+ if (res.status === 0) {
1048
+ const url = (res.stdout || "").trim();
1049
+ console.log(`\nOpened: ${url || "issue created"}`);
1050
+ }
1051
+ else {
1052
+ console.log("\nCould not create the issue automatically — nothing was sent. Open this link instead:");
1053
+ console.log(report.issueUrl);
1054
+ process.exitCode = 1;
1055
+ }
1056
+ return;
1057
+ }
1058
+ if (opts.email) {
1059
+ const { url, trimmed } = buildMailto(mailtoSubject(target.rule.title), report.markdown);
1060
+ console.log("\nSend privately to the maintainer:");
1061
+ console.log(url);
1062
+ console.log(`\nIf your mail app doesn't open, email hello@rulereceipt.dev and attach: ${outPath}`);
1063
+ if (trimmed)
1064
+ console.log("(The report was long, so the email body is trimmed — attach the saved file above.)");
1065
+ return;
1066
+ }
1067
+ // No flag: show the three ways to send, plus the pre-filled link.
1068
+ console.log(`\nRead it first, then:`);
1069
+ console.log(` Send publicly: rulereceipt wrong ${ruleArg} --submit`);
1070
+ console.log(` Send privately: rulereceipt wrong ${ruleArg} --email`);
1071
+ console.log(` Or open: ${report.issueUrl}`);
996
1072
  });
997
1073
  program
998
1074
  .command("digest")
package/dist/guard.js CHANGED
@@ -8,6 +8,23 @@ import { loadOverrides, ruleFingerprint, ratifiedForbids } from "./overrides.js"
8
8
  import { commandRunsLiteral } from "./checks/proposedAction.js";
9
9
  import { approvalOccurrences, allowListed, approvalCommandShort, approvalScopedBranch } from "./checks/approvalGate.js";
10
10
  import { readTranscriptFromFile } from "./parsers/transcriptParser.js";
11
+ import { execFileSync } from "node:child_process";
12
+ /**
13
+ * The current git branch in `cwd`, or undefined if it can't be determined —
14
+ * so a bare `git push` from a feature branch is not gated by a "push to main"
15
+ * rule. Fails open (never throws, never blocks) if git is unavailable.
16
+ */
17
+ function gitCurrentBranch(cwd) {
18
+ try {
19
+ // `symbolic-ref --short HEAD` gives the branch name even on an unborn branch
20
+ // (no commits yet); `rev-parse --abbrev-ref` returns "HEAD" there.
21
+ const b = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
22
+ return b && b !== "HEAD" ? b : undefined;
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
11
28
  import { readFileSync } from "node:fs";
12
29
  import { homedir } from "node:os";
13
30
  import { join } from "node:path";
@@ -204,7 +221,7 @@ function unapprovedGate(cwd, command, events) {
204
221
  const gates = classifyRules(loadRules(cwd)).filter((c) => c.kind === "approvalGate");
205
222
  for (const { rule, actions } of gates) {
206
223
  const proposed = { role: "assistant", kind: "tool_use", toolName: "Bash", input: { command }, timestamp: "", permissionMode: "dontAsk" };
207
- const occ = approvalOccurrences([...events, proposed], actions, { scopedBranch: approvalScopedBranch(rule) });
224
+ const occ = approvalOccurrences([...events, proposed], actions, { scopedBranch: approvalScopedBranch(rule), currentBranch: gitCurrentBranch(cwd) });
208
225
  const last = occ[occ.length - 1];
209
226
  // Compare against the SAME canonical short the occurrence uses (mention
210
227
  // segments dropped, `sh -c` unwrapped) — a raw-string compare missed a
@@ -1,6 +1,18 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { listAllSessions } from "./adapters/index.js";
3
3
  import { evaluateSession } from "./evaluate.js";
4
+ /**
5
+ * One-line clip that ends on a whole word with an ellipsis, never mid-sentence.
6
+ * Found by a real test 2026-09-29: a break quote was cut as "...so no prompt was".
7
+ */
8
+ function clip(s, n) {
9
+ const one = s.replace(/\s+/g, " ").trim();
10
+ if (one.length <= n)
11
+ return one;
12
+ const cut = one.slice(0, n);
13
+ const lastSpace = cut.lastIndexOf(" ");
14
+ return `${(lastSpace > n * 0.6 ? cut.slice(0, lastSpace) : cut).replace(/[\s,;:.—-]+$/, "")}…`;
15
+ }
4
16
  function needsLlmResult(rule) {
5
17
  return { ruleId: rule.id, ruleTitle: rule.title, ruleSource: rule.source, status: "UNCLEAR", needsHuman: true, evidence: "" };
6
18
  }
@@ -125,11 +137,11 @@ export function renderHistory(s, projectName, now = Date.now()) {
125
137
  out.push(`${who} broke your rules ${s.totalBrokenCount} time${s.totalBrokenCount === 1 ? "" : "s"}.`);
126
138
  out.push("");
127
139
  for (const b of s.breaks.slice(0, 10)) {
128
- const title = b.ruleTitle.replace(/\s+/g, " ").trim().slice(0, 60);
140
+ const title = clip(b.ruleTitle, 60);
129
141
  const when = relDate(b.lastMs, now);
130
142
  out.push(` x ${title} ${b.count} time${b.count === 1 ? "" : "s"} last: ${when}`);
131
143
  if (b.quote)
132
- out.push(` ${b.quote.replace(/\s+/g, " ").trim().slice(0, 100)}`);
144
+ out.push(` ${clip(b.quote, 100)}`);
133
145
  }
134
146
  }
135
147
  out.push("");
@@ -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";
@@ -1,6 +1,6 @@
1
- import { readFileSync, readdirSync, statSync } from "node:fs";
1
+ import { readFileSync, readdirSync, statSync, realpathSync, existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, dirname, basename } from "node:path";
3
+ import { join, dirname, basename, sep } from "node:path";
4
4
  import { parseTranscriptText } from "./transcriptLine.js";
5
5
  /**
6
6
  * Claude Code stores each session as a JSONL file at:
@@ -32,8 +32,80 @@ import { parseTranscriptText } from "./transcriptLine.js";
32
32
  * generalizes to variants never seen on this machine, at the cost of one
33
33
  * extra readdir() of the home directory per check — negligible.
34
34
  */
35
- function encodeProjectPath(cwd) {
36
- return cwd.replace(/\//g, "-");
35
+ /**
36
+ * Claude Code names the per-project directory by mangling the cwd, but the exact
37
+ * rule is not stable or documented across versions: at minimum "/" becomes "-",
38
+ * and observed builds also turn "." "_" and space (every non-alphanumeric) into
39
+ * "-". Guessing the encoding is therefore fragile — a project at
40
+ * /Users/john.doe/my.app, my_project or "My Work" would silently find zero
41
+ * sessions (found by independent test on 0.1.74, 2026-09-29; the whole first
42
+ * screen — check, history, list-sessions, card — showed "No sessions found").
43
+ *
44
+ * So encoding is only a FAST-PATH hint. The source of truth is the `cwd` field
45
+ * every Claude session line carries: this reads the real cwd out of each folder
46
+ * and matches on it (realpath-compared, so symlinks and dotted paths just work),
47
+ * which also survives any future encoding change Claude Code makes.
48
+ */
49
+ function encodeCandidates(cwd) {
50
+ const slashOnly = cwd.replace(/\//g, "-");
51
+ const allNonAlnum = cwd.replace(/[^A-Za-z0-9]/g, "-");
52
+ return [...new Set([slashOnly, allNonAlnum])];
53
+ }
54
+ function realpathOr(p) {
55
+ try {
56
+ return realpathSync(p);
57
+ }
58
+ catch {
59
+ return p;
60
+ }
61
+ }
62
+ /** The cwd a session folder belongs to, read from the first line that carries it. */
63
+ function sessionCwdOf(sessionFile) {
64
+ let text;
65
+ try {
66
+ text = readFileSync(sessionFile, "utf-8");
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ // The cwd is on every line; scan only until the first hit (usually line 1).
72
+ let from = 0;
73
+ for (let i = 0; i < 200; i++) {
74
+ const nl = text.indexOf("\n", from);
75
+ const line = text.slice(from, nl === -1 ? undefined : nl);
76
+ if (line.includes('"cwd"')) {
77
+ try {
78
+ const cwd = JSON.parse(line).cwd;
79
+ if (typeof cwd === "string" && cwd.length > 0)
80
+ return cwd;
81
+ }
82
+ catch {
83
+ /* partial/garbled line: keep scanning */
84
+ }
85
+ }
86
+ if (nl === -1)
87
+ break;
88
+ from = nl + 1;
89
+ }
90
+ return null;
91
+ }
92
+ /** A cwd looks like a real project root, so descendant (monorepo) sessions are safe to pull in. */
93
+ function looksLikeProjectRoot(cwd) {
94
+ return [".git", "CLAUDE.md", "AGENTS.md", "GEMINI.md", ".claude", ".cursor", ".github/copilot-instructions.md"].some((marker) => existsSync(join(cwd, marker)));
95
+ }
96
+ /** Absolute paths of every Claude-Code-style home to search, including CLAUDE_CONFIG_DIR. */
97
+ function claudeHomeDirs() {
98
+ const dirs = new Set();
99
+ for (const name of findClaudeHomeDirNames())
100
+ dirs.add(join(homedir(), name));
101
+ const cfg = process.env.CLAUDE_CONFIG_DIR;
102
+ if (cfg)
103
+ for (const part of cfg.split(",")) {
104
+ const p = part.trim();
105
+ if (p)
106
+ dirs.add(p);
107
+ }
108
+ return [...dirs];
37
109
  }
38
110
  function listSessionFiles(projectDir) {
39
111
  let entries;
@@ -70,12 +142,61 @@ export function findClaudeHomeDirNames() {
70
142
  return [];
71
143
  }
72
144
  }
73
- /** Every session file for this project, newest first. */
145
+ /**
146
+ * Every session file that belongs to this project, newest first.
147
+ *
148
+ * A folder matches when the real cwd stored in its sessions is THIS directory
149
+ * (encoding-independent — handles dots, underscores, spaces, symlinks), or when
150
+ * this directory is a project root and the session's cwd is inside it (a
151
+ * monorepo subfolder like packages/api, so the root check sees that work too).
152
+ * Encoding candidates are only a fallback for folders whose stored cwd can't be
153
+ * read.
154
+ */
74
155
  export function listAllSessionFiles(cwd) {
75
- const encoded = encodeProjectPath(cwd);
76
- const sessionFiles = findClaudeHomeDirNames().flatMap((dirName) => listSessionFiles(join(homedir(), dirName, "projects", encoded)));
77
- sessionFiles.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
78
- return sessionFiles;
156
+ const target = realpathOr(cwd);
157
+ const candidates = new Set(encodeCandidates(cwd));
158
+ const allowDescendants = looksLikeProjectRoot(cwd);
159
+ const files = [];
160
+ const seen = new Set();
161
+ for (const home of claudeHomeDirs()) {
162
+ const projectsDir = join(home, "projects");
163
+ let folders;
164
+ try {
165
+ folders = readdirSync(projectsDir);
166
+ }
167
+ catch {
168
+ continue;
169
+ }
170
+ for (const folder of folders) {
171
+ const dir = join(projectsDir, folder);
172
+ const folderFiles = listSessionFiles(dir);
173
+ if (folderFiles.length === 0)
174
+ continue;
175
+ const storedCwd = sessionCwdOf(folderFiles[0]);
176
+ let matches = false;
177
+ if (storedCwd) {
178
+ const real = realpathOr(storedCwd);
179
+ if (real === target)
180
+ matches = true;
181
+ else if (allowDescendants && real.startsWith(target + sep))
182
+ matches = true;
183
+ }
184
+ else {
185
+ // No readable cwd (older/garbled file): fall back to the name encoding.
186
+ matches = candidates.has(folder);
187
+ }
188
+ if (!matches)
189
+ continue;
190
+ for (const f of folderFiles) {
191
+ if (!seen.has(f)) {
192
+ seen.add(f);
193
+ files.push(f);
194
+ }
195
+ }
196
+ }
197
+ }
198
+ files.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
199
+ return files;
79
200
  }
80
201
  export function findLatestSessionFile(cwd) {
81
202
  const all = listAllSessionFiles(cwd);
package/dist/rules.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
4
  import { parseClaudeMd } from "./parsers/readClaudeMd.js";
5
5
  import { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
6
6
  import { loadMemoryRules } from "./parsers/readMemory.js";
7
+ import { resolveImports } from "./parsers/imports.js";
7
8
  /**
8
9
  * Every place Claude Code actually reads a rule from, at one directory
9
10
  * level. Order mirrors the documented load order, broadest first.
@@ -135,6 +136,42 @@ function ruleSourcesAtLevel(dir) {
135
136
  }
136
137
  // Gemini CLI: single rules file (its AGENTS.md equivalent).
137
138
  loaded("GEMINI.md", "Gemini");
139
+ return applyImports(out);
140
+ }
141
+ /**
142
+ * Claude Code follows @imports: a loaded rules file that says `@AGENTS.md` (or
143
+ * `@docs/rules.md`) makes that file part of what the agent reads. So an imported
144
+ * file is NOT shadowed, and its rules ARE checked. This reconciles `out` with
145
+ * that: any file imported by a loaded file is promoted to loaded (a shadowed
146
+ * AGENTS.md a CLAUDE.md imports flips to loaded), and any imported file not
147
+ * already listed is added as a loaded source. Without imports, `out` is
148
+ * unchanged, so existing projects keep their exact rule order and ids.
149
+ */
150
+ function applyImports(out) {
151
+ const imported = new Set();
152
+ for (const src of out) {
153
+ if (src.status !== "loaded")
154
+ continue;
155
+ for (const target of resolveImports(src.path))
156
+ imported.add(resolve(target));
157
+ }
158
+ if (imported.size === 0)
159
+ return out;
160
+ const present = new Set(out.map((s) => resolve(s.path)));
161
+ for (const src of out) {
162
+ if (src.status === "shadowed" && imported.has(resolve(src.path))) {
163
+ src.status = "loaded";
164
+ src.note = "imported by a loaded CLAUDE.md (@import), so the agent does read it";
165
+ }
166
+ }
167
+ // Imported files that were not otherwise candidates at this level (e.g. a
168
+ // @docs/rules.md), in a stable order so rule ids stay deterministic.
169
+ for (const path of [...imported].sort()) {
170
+ if (present.has(path))
171
+ continue;
172
+ present.add(path);
173
+ out.push({ path, status: "loaded", format: "imported (@import)" });
174
+ }
138
175
  return out;
139
176
  }
140
177
  /** Every rules file at one directory level that the agent actually loads. */
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, dirname, parse } from "node:path";
3
+ import { join, dirname, parse, resolve } from "node:path";
4
+ import { resolveImports } from "./parsers/imports.js";
4
5
  /** Directory-level pairs where a CLAUDE.md shadows an AGENTS.md. */
5
6
  const SHADOW_PAIRS = [
6
7
  { claude: "CLAUDE.md", agents: "AGENTS.md" },
@@ -24,7 +25,11 @@ export function shadowedAgentsMd(cwd) {
24
25
  const claudePath = join(dir, claude);
25
26
  const agentsPath = join(dir, agents);
26
27
  if (existsSync(claudePath) && existsSync(agentsPath)) {
27
- found.push({ agents: agentsPath, shadowedBy: claudePath });
28
+ // If the CLAUDE.md @imports the AGENTS.md, the agent DOES read it — it
29
+ // is not shadowed, and warning that it is would itself be untrue.
30
+ const importedByClaude = resolveImports(claudePath).some((p) => resolve(p) === resolve(agentsPath));
31
+ if (!importedByClaude)
32
+ found.push({ agents: agentsPath, shadowedBy: claudePath });
28
33
  }
29
34
  }
30
35
  if (existsSync(join(dir, ".git")))
package/dist/why.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { Rule } from "./types.js";
2
+ export interface WhyRule {
3
+ id: string;
4
+ title: string;
5
+ source: "global" | "project";
6
+ location: string;
7
+ loaded: boolean;
8
+ loadNote?: string;
9
+ pathScoped?: string;
10
+ checkable: boolean;
11
+ kind: string;
12
+ suggestion?: string;
13
+ named?: {
14
+ kind: "command" | "file";
15
+ name: string;
16
+ exists: boolean;
17
+ };
18
+ brokenCount: number;
19
+ brokenDates: string[];
20
+ sessionsScanned: number;
21
+ }
22
+ export interface WhyResult {
23
+ query: string;
24
+ matches: number;
25
+ rule?: WhyRule;
26
+ candidates?: {
27
+ title: string;
28
+ location: string;
29
+ }[];
30
+ }
31
+ /** Fuzzy-match a rule by the query appearing in its title/body, or the query being the title. */
32
+ export declare function findRules(rules: Rule[], query: string): Rule[];
33
+ export declare function explainRule(cwd: string, query: string): Promise<WhyResult>;
34
+ export declare function renderWhy(r: WhyResult): string;
package/dist/why.js ADDED
@@ -0,0 +1,153 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { loadRules, describeRuleSources } from "./rules.js";
5
+ import { classifyRule } from "./checks/classify.js";
6
+ import { adviseRule } from "./checkability.js";
7
+ import { scanHistory } from "./historyReport.js";
8
+ /**
9
+ * `rulereceipt why "<rule text>"` — everything the tool already knows about ONE
10
+ * rule, in one place: where it lives, whether the agent even loads it, whether a
11
+ * command/path it names exists, whether it is mechanically checkable (and if
12
+ * not, the smallest edit that would make it), and how it has done in the last 30
13
+ * days. It answers the exact question people ask — "why isn't THIS rule
14
+ * working?" — and it invents nothing: every line is combined from data the
15
+ * engine already produces. Read-only; no verdict is created here.
16
+ */
17
+ const CHECKABLE_KINDS = new Set([
18
+ "gitBranchPolicy", "fileLifecycle", "codeContent", "approvalGate",
19
+ "claimEvidence", "deterministic", "attribution", "emojiOutput", "ifEditThenTest",
20
+ ]);
21
+ /** Fuzzy-match a rule by the query appearing in its title/body, or the query being the title. */
22
+ export function findRules(rules, query) {
23
+ // Normalize both sides to lowercase words separated by single spaces, so
24
+ // punctuation the user won't type (commas, backticks, dashes) doesn't block a
25
+ // match: "clean elegant maintainable" finds "clean, elegant, maintainable".
26
+ const clean = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
27
+ const q = clean(query);
28
+ if (q.length === 0)
29
+ return [];
30
+ const exact = rules.filter((r) => clean(r.title) === q);
31
+ if (exact.length > 0)
32
+ return exact;
33
+ return rules.filter((r) => {
34
+ const hay = clean(`${r.title} ${r.text}`);
35
+ if (hay.includes(q))
36
+ return true;
37
+ // Reverse direction (query IS roughly the title) only for a title long
38
+ // enough to be distinctive — otherwise a 1-char heading like "A" matches
39
+ // any query that happens to contain that letter.
40
+ const title = clean(r.title);
41
+ return title.length >= 6 && q.includes(title);
42
+ });
43
+ }
44
+ /** A `npm run <script>` or a backtick file path the rule names, and whether it exists. */
45
+ function namedCommandOrPath(rule, cwd) {
46
+ const text = `${rule.title} ${rule.text}`;
47
+ const script = text.match(/\b(?:npm|pnpm|yarn)\s+run\s+([\w:-]+)/);
48
+ if (script) {
49
+ let exists = false;
50
+ try {
51
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
52
+ exists = Boolean(pkg.scripts && script[1] in pkg.scripts);
53
+ }
54
+ catch {
55
+ /* no package.json: report not found */
56
+ }
57
+ return { kind: "command", name: `${script[1]}`, exists };
58
+ }
59
+ const path = text.match(/`([^`\s]+\.[a-z0-9]{1,6})`/i);
60
+ if (path && !path[1].includes("://")) {
61
+ const p = isAbsolute(path[1]) ? path[1] : join(cwd, path[1]);
62
+ return { kind: "file", name: path[1], exists: existsSync(p) };
63
+ }
64
+ return undefined;
65
+ }
66
+ export async function explainRule(cwd, query) {
67
+ const rules = loadRules(cwd);
68
+ const matches = findRules(rules, query);
69
+ if (matches.length === 0)
70
+ return { query, matches: 0 };
71
+ if (matches.length > 1) {
72
+ return {
73
+ query,
74
+ matches: matches.length,
75
+ candidates: matches.slice(0, 12).map((r) => ({ title: r.title, location: locationOf(r) })),
76
+ };
77
+ }
78
+ const rule = matches[0];
79
+ const graph = describeRuleSources(cwd);
80
+ const src = rule.sourcePath ? graph.find((e) => e.path === rule.sourcePath) : undefined;
81
+ const cls = classifyRule(rule);
82
+ const checkable = CHECKABLE_KINDS.has(cls.kind);
83
+ const advice = checkable ? null : adviseRule(rule);
84
+ let brokenCount = 0;
85
+ let brokenDates = [];
86
+ let sessionsScanned = 0;
87
+ try {
88
+ const hist = await scanHistory(cwd, rules, 30);
89
+ sessionsScanned = hist.sessionsScanned;
90
+ const b = hist.breaks.find((x) => x.ruleId === rule.id && x.ruleTitle === rule.title);
91
+ if (b) {
92
+ brokenCount = b.count;
93
+ brokenDates = [new Date(b.lastMs).toISOString().slice(0, 10)];
94
+ }
95
+ }
96
+ catch {
97
+ /* history is best-effort; a rule can still be explained without it */
98
+ }
99
+ return {
100
+ query,
101
+ matches: 1,
102
+ rule: {
103
+ id: rule.id,
104
+ title: rule.title,
105
+ source: rule.source,
106
+ location: locationOf(rule),
107
+ loaded: src ? src.status === "loaded" : true,
108
+ loadNote: src?.note,
109
+ pathScoped: rule.paths ? rule.paths.join(", ") : undefined,
110
+ checkable,
111
+ kind: cls.kind,
112
+ suggestion: advice?.suggestion,
113
+ named: namedCommandOrPath(rule, cwd),
114
+ brokenCount,
115
+ brokenDates,
116
+ sessionsScanned,
117
+ },
118
+ };
119
+ }
120
+ function locationOf(rule) {
121
+ if (!rule.sourcePath)
122
+ return "unknown";
123
+ const home = homedir();
124
+ const p = rule.sourcePath.startsWith(home) ? `~${rule.sourcePath.slice(home.length)}` : rule.sourcePath;
125
+ return rule.sourceLine ? `${p}:${rule.sourceLine}` : p;
126
+ }
127
+ export function renderWhy(r) {
128
+ if (r.matches === 0)
129
+ return `No rule matched "${r.query}". Try a distinctive phrase from the rule, or run \`rulereceipt audit\` to list what loads.`;
130
+ if (r.candidates) {
131
+ const lines = r.candidates.map((c) => ` • ${c.title} (${c.location})`);
132
+ return `"${r.query}" matched ${r.matches} rules — narrow it down:\n${lines.join("\n")}`;
133
+ }
134
+ const w = r.rule;
135
+ const out = [];
136
+ out.push(`Rule ${w.id} — ${w.title}`);
137
+ out.push(` at ${w.location} (${w.source})`);
138
+ out.push("");
139
+ out.push(w.loaded ? ` ✓ loaded — the agent reads this file` : ` ✗ NOT loaded — ${w.loadNote ?? "the agent never sees this file"}`);
140
+ if (w.pathScoped)
141
+ out.push(` • path-scoped: only loads when the session touches ${w.pathScoped}`);
142
+ if (w.named)
143
+ out.push(w.named.exists ? ` ✓ names a ${w.named.kind} that exists: ${w.named.name}` : ` ✗ names a ${w.named.kind} that does NOT exist here: ${w.named.name}`);
144
+ out.push(w.checkable
145
+ ? ` ✓ mechanically checkable (${w.kind}) — a session is judged against it with quoted evidence`
146
+ : ` • needs your judgment${w.suggestion ? ` — ${w.suggestion}` : ` — no command or file to check it by; a human decides`}`);
147
+ out.push("");
148
+ if (w.brokenCount > 0)
149
+ out.push(` last 30 days: broken ${w.brokenCount}× (last: ${w.brokenDates[0]}) across ${w.sessionsScanned} session${w.sessionsScanned === 1 ? "" : "s"}`);
150
+ else
151
+ out.push(` last 30 days: no proven break across ${w.sessionsScanned} session${w.sessionsScanned === 1 ? "" : "s"}`);
152
+ return out.join("\n");
153
+ }
package/dist/wrong.js CHANGED
@@ -27,12 +27,25 @@ export function reportedLabel(r) {
27
27
  return "Couldn't tell";
28
28
  }
29
29
  const SECRET_PATTERNS = [
30
+ // Private key blocks and passwords inside URLs go FIRST — before the email
31
+ // rule, which would otherwise partially rewrite a user:pass@host authority.
32
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted-private-key>"],
33
+ [/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s:/@]+:)[^\s:/@]+(@)/g, "$1<redacted>$2"],
30
34
  [/\bsk-[A-Za-z0-9_-]{16,}/g, "<redacted-key>"],
31
35
  [/\b(?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{16,}/g, "<redacted-token>"],
32
36
  [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "<redacted-token>"],
37
+ // Stripe secret/publishable/restricted/webhook keys.
38
+ [/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, "<redacted-stripe-key>"],
39
+ [/\bwhsec_[A-Za-z0-9]{16,}/g, "<redacted-stripe-secret>"],
33
40
  [/\bAKIA[0-9A-Z]{16}\b/g, "<redacted-aws-key>"],
34
- [/\b(?:Bearer|token|apikey|api_key|password|passwd|secret)(\s*[:=]\s*|\s+)["']?[^\s"']{6,}/gi, "$1<redacted>"],
35
- [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted-private-key>"],
41
+ // JSON Web Tokens: header.payload.signature, each base64url.
42
+ [/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "<redacted-jwt>"],
43
+ // The value excludes a leading "<" so this never re-clobbers a more specific
44
+ // placeholder an earlier rule already inserted (e.g. "token <redacted-jwt>").
45
+ [/\b(?:Bearer|token|apikey|api_key|password|passwd|secret)(\s*[:=]\s*|\s+)["']?(?!<redacted)[^\s"']{6,}/gi, "$1<redacted>"],
46
+ // .env-style KEY=value: an UPPERCASE_KEY assigned a non-trivial value. A short
47
+ // value (DISABLE_LOCKS=1) is left alone so ordinary flags are not mangled.
48
+ [/\b([A-Z][A-Z0-9_]{2,})=(["']?)[^\s"']{8,}\2/g, "$1=<redacted>"],
36
49
  [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "<email>"],
37
50
  ];
38
51
  /** Masks the things most likely to be private. Not a guarantee — the user is told to read it. */
@@ -91,6 +104,7 @@ export function buildWrongReport(input) {
91
104
  "",
92
105
  "> Read this before sharing. Obvious secrets, your home path and email addresses were masked,",
93
106
  "> but rule text and session lines are quoted as they are. Edit anything private.",
107
+ "> Masking catches common formats only. Read before sending.",
94
108
  "",
95
109
  `**Rule handle:** \`${handle}\` (id ${result.ruleId}, ${result.ruleSource})`,
96
110
  "",
@@ -0,0 +1,24 @@
1
+ import { type SpawnSyncReturns } from "node:child_process";
2
+ /**
3
+ * The two ways to send a wrong-verdict report, kept as pure, testable pieces so
4
+ * the command wiring in cli.ts stays thin. Nothing here sends on its own: it
5
+ * builds the exact `gh issue create` argv and the exact mailto: URL, and the
6
+ * caller only runs them AFTER an explicit yes (see cli.ts). `--yes` never skips
7
+ * that preview+confirm — the report is public, so the default answer is No.
8
+ */
9
+ export declare const SUPPORT_EMAIL = "hello@rulereceipt.dev";
10
+ export declare const REPO = "rulereceipt/rulereceipt";
11
+ export declare const ISSUE_LABEL = "wrong-verdict";
12
+ type Runner = (cmd: string, args: string[]) => SpawnSyncReturns<Buffer>;
13
+ /** True only if `gh` is installed AND logged in. No issue is offered otherwise. */
14
+ export declare function ghReady(run?: Runner): boolean;
15
+ export declare function issueTitle(reported: string, ruleTitle: string): string;
16
+ /** argv for `gh issue create`. The label is included only when withLabel. */
17
+ export declare function issueCreateArgs(title: string, body: string, withLabel: boolean): string[];
18
+ export declare const MAILTO_MAX = 1800;
19
+ export declare function mailtoSubject(ruleTitle: string): string;
20
+ export declare function buildMailto(subject: string, body: string): {
21
+ url: string;
22
+ trimmed: boolean;
23
+ };
24
+ export {};
@@ -0,0 +1,50 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * The two ways to send a wrong-verdict report, kept as pure, testable pieces so
4
+ * the command wiring in cli.ts stays thin. Nothing here sends on its own: it
5
+ * builds the exact `gh issue create` argv and the exact mailto: URL, and the
6
+ * caller only runs them AFTER an explicit yes (see cli.ts). `--yes` never skips
7
+ * that preview+confirm — the report is public, so the default answer is No.
8
+ */
9
+ export const SUPPORT_EMAIL = "hello@rulereceipt.dev";
10
+ export const REPO = "rulereceipt/rulereceipt";
11
+ export const ISSUE_LABEL = "wrong-verdict";
12
+ const defaultRun = (cmd, args) => spawnSync(cmd, args, { stdio: "ignore" });
13
+ /** True only if `gh` is installed AND logged in. No issue is offered otherwise. */
14
+ export function ghReady(run = defaultRun) {
15
+ try {
16
+ if (run("gh", ["--version"]).status !== 0)
17
+ return false;
18
+ return run("gh", ["auth", "status"]).status === 0;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export function issueTitle(reported, ruleTitle) {
25
+ const t = ruleTitle.replace(/\s+/g, " ").trim().slice(0, 60);
26
+ return `Wrong verdict: ${reported} on ${t}`;
27
+ }
28
+ /** argv for `gh issue create`. The label is included only when withLabel. */
29
+ export function issueCreateArgs(title, body, withLabel) {
30
+ const args = ["issue", "create", "--repo", REPO, "--title", title, "--body", body];
31
+ if (withLabel)
32
+ args.push("--label", ISSUE_LABEL);
33
+ return args;
34
+ }
35
+ // A conservative cap so the mailto: fits what mail clients accept; a longer body
36
+ // is trimmed and the user is told to attach the saved .md file instead.
37
+ export const MAILTO_MAX = 1800;
38
+ export function mailtoSubject(ruleTitle) {
39
+ return `RuleReceipt wrong verdict: ${ruleTitle.replace(/\s+/g, " ").trim().slice(0, 80)}`;
40
+ }
41
+ export function buildMailto(subject, body) {
42
+ let b = body;
43
+ let trimmed = false;
44
+ if (b.length > MAILTO_MAX) {
45
+ b = b.slice(0, MAILTO_MAX) + "\n\n[Report trimmed to fit email — attach the saved .md file instead.]";
46
+ trimmed = true;
47
+ }
48
+ const url = `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(b)}`;
49
+ return { url, trimmed };
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
4
4
  "description": "Checks whether your AI coding agent followed your rules, with evidence. Works with Claude Code (Codex in testing); reads CLAUDE.md, AGENTS.md, Cursor, Copilot and Windsurf rules.",
5
5
  "repository": {
6
6
  "type": "git",