rulereceipt 0.1.65 → 0.1.67

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
@@ -431,6 +431,12 @@ npx tsx src/cli.ts demo
431
431
 
432
432
  ## Trust, privacy and licensing
433
433
 
434
+ **Verify it yourself.** `npx rulereceipt selftest` runs a set of bundled
435
+ golden fixtures on your machine and reports how many verdicts are correct, with
436
+ **zero network calls** — watch it with `lsof` or Little Snitch if you like. The
437
+ same fixtures are the project's regression suite, so "all correct" is a promise
438
+ the build enforces, not a claim.
439
+
434
440
  **What it can't see, it says so.** [KNOWN-GAPS.md](KNOWN-GAPS.md) lists
435
441
  exactly where the evidence runs out — commands in another terminal, clicks on
436
442
  the permission prompt, `rm`/delete not bound to a rule's subject, edited
package/dist/audit.d.ts CHANGED
@@ -27,6 +27,7 @@ export interface RulesAudit {
27
27
  topFixes: {
28
28
  title: string;
29
29
  suggestion: string;
30
+ handle?: string;
30
31
  }[];
31
32
  }
32
33
  export declare function auditRules(rules: Rule[]): RulesAudit;
@@ -39,7 +40,7 @@ export declare function renderAudit(a: RulesAudit, md?: boolean): string;
39
40
  * cannot know without a transcript.
40
41
  */
41
42
  export interface Diagnostic {
42
- id: "no-rules-file" | "empty-or-pointer" | "zero-rules" | "docs-heavy" | "shadowed-file" | "size-warn" | "template-text" | "broken-import";
43
+ id: "no-rules-file" | "empty-or-pointer" | "zero-rules" | "docs-heavy" | "shadowed-file" | "size-warn" | "template-text" | "broken-import" | "hook-config" | "dead-globs";
43
44
  severity: "info" | "warn";
44
45
  message: string;
45
46
  }
package/dist/audit.js CHANGED
@@ -1,8 +1,39 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, isAbsolute, relative, resolve } from "node:path";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { classifyRules } from "./checks/classify.js";
4
4
  import { adviseRules } from "./checkability.js";
5
+ import { ruleWasLoaded } from "./checks/pathScope.js";
5
6
  import { describeRuleSources, loadRules } from "./rules.js";
7
+ /** Files in the repo, for checking whether a path-scoped rule matches anything. */
8
+ function repoFiles(cwd, cap = 4000) {
9
+ const out = [];
10
+ const skip = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "coverage", ".rulereceipt", ".vercel", ".turbo", "vendor"]);
11
+ const walk = (dir) => {
12
+ if (out.length >= cap)
13
+ return;
14
+ let entries;
15
+ try {
16
+ entries = readdirSync(dir, { withFileTypes: true });
17
+ }
18
+ catch {
19
+ return;
20
+ }
21
+ for (const e of entries) {
22
+ if (out.length >= cap)
23
+ return;
24
+ const full = join(dir, e.name);
25
+ if (e.isDirectory()) {
26
+ if (!skip.has(e.name))
27
+ walk(full);
28
+ }
29
+ else {
30
+ out.push(full);
31
+ }
32
+ }
33
+ };
34
+ walk(cwd);
35
+ return out;
36
+ }
6
37
  export function auditRules(rules) {
7
38
  let checkable = 0;
8
39
  let judgment = 0;
@@ -19,7 +50,7 @@ export function auditRules(rules) {
19
50
  const topFixes = adviseRules(rules)
20
51
  .filter((a) => a.actionable)
21
52
  .slice(0, 5)
22
- .map((a) => ({ title: a.ruleTitle, suggestion: a.suggestion }));
53
+ .map((a) => ({ title: a.ruleTitle, suggestion: a.suggestion, handle: a.handle }));
23
54
  return {
24
55
  total: checkable + judgment + skipped,
25
56
  checkable,
@@ -60,6 +91,30 @@ export function renderAudit(a, md = false) {
60
91
  out.push("Full advice, rule by rule: rulereceipt rules --advise");
61
92
  return out.join("\n");
62
93
  }
94
+ /** Hook events Claude Code recognises. A hook under any other name never fires. */
95
+ const KNOWN_HOOK_EVENTS = new Set([
96
+ "PreToolUse", "PostToolUse", "Stop", "SubagentStop", "UserPromptSubmit",
97
+ "SessionStart", "SessionEnd", "Notification", "PreCompact", "PostCompact",
98
+ "PermissionRequest", "PermissionDenied", "InstructionsLoaded",
99
+ ]);
100
+ /** Hook event names in the project/user settings that Claude Code won't recognise. */
101
+ function unknownHookEvents(cwd) {
102
+ const bad = new Set();
103
+ for (const p of [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json")]) {
104
+ try {
105
+ const hooks = JSON.parse(readFileSync(p, "utf-8")).hooks;
106
+ if (hooks && typeof hooks === "object") {
107
+ for (const name of Object.keys(hooks))
108
+ if (!KNOWN_HOOK_EVENTS.has(name))
109
+ bad.add(name);
110
+ }
111
+ }
112
+ catch {
113
+ /* absent or unreadable */
114
+ }
115
+ }
116
+ return [...bad];
117
+ }
63
118
  /**
64
119
  * Claude Code reads a bounded prefix of a rules file; past it the rest is
65
120
  * silently dropped, so a rule below the cut never loads. The figure is a
@@ -104,7 +159,7 @@ function isPointerFile(path) {
104
159
  return false;
105
160
  }
106
161
  }
107
- function buildDiagnostics(cwd, graph, a) {
162
+ function buildDiagnostics(cwd, graph, a, rules) {
108
163
  const diags = [];
109
164
  const loaded = graph.filter((g) => g.status === "loaded");
110
165
  const shadowed = graph.filter((g) => g.status === "shadowed");
@@ -187,6 +242,29 @@ function buildDiagnostics(cwd, graph, a) {
187
242
  });
188
243
  }
189
244
  }
245
+ // A path-scoped rule whose globs match no file in the repo never loads.
246
+ const scoped = rules.filter((r) => r.paths && r.paths.length > 0);
247
+ if (scoped.length > 0) {
248
+ const files = repoFiles(cwd);
249
+ for (const r of scoped) {
250
+ if (!ruleWasLoaded(r.paths, files)) {
251
+ diags.push({
252
+ id: "dead-globs",
253
+ severity: "warn",
254
+ message: `"${r.title.replace(/\s+/g, " ").trim().slice(0, 50)}" is scoped to ${r.paths.join(", ")}, which matches no file in this repo — so it never loads and governs nothing. Fix the glob.`,
255
+ });
256
+ }
257
+ }
258
+ }
259
+ // A hook wired under a misspelled/unknown event name never fires — silently.
260
+ const badHooks = unknownHookEvents(cwd);
261
+ if (badHooks.length > 0) {
262
+ diags.push({
263
+ id: "hook-config",
264
+ severity: "warn",
265
+ message: `.claude/settings.json has hook${badHooks.length === 1 ? "" : "s"} under ${badHooks.map((h) => `"${h}"`).join(", ")}, which ${badHooks.length === 1 ? "is not a" : "are not"} Claude Code hook event${badHooks.length === 1 ? "" : "s"} — ${badHooks.length === 1 ? "it never fires" : "they never fire"}. Check the spelling (e.g. PreToolUse, PostToolUse, Stop).`,
266
+ });
267
+ }
190
268
  // A handbook, not a policy: mostly documentation, little to enforce.
191
269
  if (a.total >= 8 && a.skipped / a.total > 0.7) {
192
270
  diags.push({
@@ -206,7 +284,7 @@ export function auditProject(cwd) {
206
284
  const rules = loadRules(cwd);
207
285
  const base = auditRules(rules);
208
286
  const loadGraph = describeRuleSources(cwd);
209
- const diagnostics = buildDiagnostics(cwd, loadGraph, base);
287
+ const diagnostics = buildDiagnostics(cwd, loadGraph, base, rules);
210
288
  const memoryRules = rules.filter((r) => r.id.startsWith("memory:")).length;
211
289
  return { ...base, loadGraph, diagnostics, memoryRules };
212
290
  }
@@ -251,7 +329,7 @@ export function renderProjectAudit(pa, md = false) {
251
329
  if (pa.topFixes.length > 0) {
252
330
  out.push(H("Top fixes to unlock more checks"));
253
331
  for (const f of pa.topFixes) {
254
- out.push(` • ${f.title.replace(/\s+/g, " ").trim().slice(0, 60)}`);
332
+ out.push(` • ${f.title.replace(/\s+/g, " ").trim().slice(0, 60)}${f.handle ? ` [${f.handle}]` : ""}`);
255
333
  out.push(` ${f.suggestion}`);
256
334
  }
257
335
  out.push("");
@@ -44,3 +44,4 @@ export declare const CORPUS: {
44
44
  };
45
45
  export declare function analyze(text: string): AnalysisResult;
46
46
  export { evaluateBrowserSession, checkSessionInBrowser, type BrowserSessionSummary } from "./evaluateBrowser.js";
47
+ export { shareText, shareLinks, cardSvg, type CardData } from "../card.js";
@@ -50,3 +50,8 @@ export function analyze(text) {
50
50
  // Client-side SESSION check for the browser demo: drop a session .jsonl +
51
51
  // paste rules, get per-rule verdicts, nothing uploaded. Same checkers as the CLI.
52
52
  export { evaluateBrowserSession, checkSessionInBrowser } from "./evaluateBrowser.js";
53
+ // Share the browser result: caption (counts only), compose links, and a
54
+ // self-contained SVG card. card.ts is pure string work — no Node, no network —
55
+ // so it can run in the page without breaking the "nothing leaves this page"
56
+ // promise. Counts only, never rule text or the session.
57
+ export { shareText, shareLinks, cardSvg } from "../card.js";
package/dist/card.js CHANGED
@@ -12,9 +12,16 @@
12
12
  const SITE = "rulereceipt.dev";
13
13
  /** The share caption. Counts only, unless showRules adds the broken rule names. */
14
14
  export function shareText(d, showRules = false) {
15
- const base = d.broken > 0
16
- ? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in ${d.days} days (${d.sessions} session${d.sessions === 1 ? "" : "s"}) — now it can't.`
17
- : `RuleReceipt checked ${d.sessions} of my agent session${d.sessions === 1 ? "" : "s"} over ${d.days} days against my written rules: ${d.broken} broken.`;
15
+ // A single dropped session (the browser demo) has no "over N days" span, so
16
+ // it gets a "this session" caption rather than the history-mode one.
17
+ const single = d.sessions === 1;
18
+ const base = single
19
+ ? d.broken > 0
20
+ ? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in this session — now it can't.`
21
+ : `RuleReceipt checked one of my agent's sessions against my written rules: ${d.broken} broken.`
22
+ : d.broken > 0
23
+ ? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in ${d.days} days (${d.sessions} sessions) — now it can't.`
24
+ : `RuleReceipt checked ${d.sessions} of my agent sessions over ${d.days} days against my written rules: ${d.broken} broken.`;
18
25
  const tail = `Checked with RuleReceipt — runs locally, nothing uploaded. ${SITE}`;
19
26
  if (showRules && d.broken > 0 && d.brokenTitles && d.brokenTitles.length > 0) {
20
27
  const list = d.brokenTitles.slice(0, 3).map((t) => `“${t.replace(/\s+/g, " ").trim().slice(0, 50)}”`).join(", ");
@@ -41,7 +48,9 @@ function esc(s) {
41
48
  /** A self-contained SVG card. Counts only — never rule text, paths or code. */
42
49
  export function cardSvg(d) {
43
50
  const headline = d.broken > 0 ? `${d.who} broke your rules ${d.broken}×` : `0 rules broken`;
44
- const sub = `${d.sessions} session${d.sessions === 1 ? "" : "s"} · last ${d.days} days`;
51
+ const sub = d.days > 0
52
+ ? `${d.sessions} session${d.sessions === 1 ? "" : "s"} · last ${d.days} days`
53
+ : `${d.sessions} session${d.sessions === 1 ? "" : "s"}`;
45
54
  const stats = `${d.followed} followed · ${d.judgment} need judgment`;
46
55
  return `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="418" viewBox="0 0 800 418" role="img" aria-label="RuleReceipt summary">
47
56
  <rect width="800" height="418" fill="#0b0d10"/>
@@ -30,6 +30,8 @@ export interface RuleAdvice {
30
30
  * actionable ones as the top fixes.
31
31
  */
32
32
  actionable?: boolean;
33
+ /** Stable content-hash handle for `rules --include/--exclude`. */
34
+ handle?: string;
33
35
  }
34
36
  /**
35
37
  * Advice for one rule, or null when the rule is already mechanically checked.
@@ -1,4 +1,5 @@
1
1
  import { classifyRule } from "./checks/classify.js";
2
+ import { ruleFingerprint } from "./overrides.js";
2
3
  /** A concrete action the rule is plausibly about, so we can name what to quote. */
3
4
  const CONCRETE_SUBJECT = /\b(?:push(?:es|ed|ing)?|commit(?:s|ted|ting)?|merge[ds]?|rebase|delet\w*|remov\w*|\brm\b|drop|truncate|deploy\w*|migrat\w*|branch|tag|force[- ]?push|test|lint|build|install|env|secret|token|password|key|\.env|database|table|file|path|directory|endpoint|api)\b/i;
4
5
  /** A rule that is qualitative by nature — no literal makes it mechanical. */
@@ -65,5 +66,14 @@ export function adviseRule(rule) {
65
66
  }
66
67
  /** Advice for every rule that isn't already mechanically checked. */
67
68
  export function adviseRules(rules) {
68
- return rules.map(adviseRule).filter((a) => a !== null);
69
+ const out = [];
70
+ for (const rule of rules) {
71
+ const a = adviseRule(rule);
72
+ // Attach the stable content-hash handle so `audit`'s top fixes can be acted
73
+ // on with `rules --include/--exclude <handle>` (ids are positional and
74
+ // renumber; the handle survives edits above the rule).
75
+ if (a)
76
+ out.push({ ...a, handle: ruleFingerprint(rule) });
77
+ }
78
+ return out;
69
79
  }
@@ -1,5 +1,5 @@
1
1
  import { violation } from "../types.js";
2
- import { withoutHeredocs } from "./shellCommand.js";
2
+ import { withoutHeredocs, withoutQuotedMentions } from "./shellCommand.js";
3
3
  const IN_COMMAND = {
4
4
  push: /\bgit\s+(?:\S+\s+){0,4}?push(?![\w-])/,
5
5
  commit: /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])/,
@@ -29,7 +29,10 @@ function commandOf(e) {
29
29
  const c = e.input?.command;
30
30
  // A heredoc that WRITES "git push" into a file is not a push. Strip heredoc
31
31
  // bodies so only the commands actually invoked are inspected.
32
- return typeof c === "string" ? withoutHeredocs(c) : "";
32
+ // Strip heredoc bodies (a heredoc that WRITES "git push" is not a push) and
33
+ // blank quoted/commented mentions (`echo "git push"`, `# git push`), so only
34
+ // a command actually being run is matched.
35
+ return typeof c === "string" ? withoutQuotedMentions(withoutHeredocs(c)) : "";
33
36
  }
34
37
  /** The result for the call at `i`: matched by id when present, else the next result. */
35
38
  function resultOf(events, i) {
@@ -11,11 +11,25 @@ export interface HookEntry {
11
11
  */
12
12
  matcher?: string;
13
13
  }
14
+ /**
15
+ * A hook command registered more than once on the same event within a single
16
+ * settings file — so it fires that many times per event. Cross-file repetition
17
+ * (a global and a project settings file both registering it) is legitimate
18
+ * layering and is NOT reported: only a genuinely redundant in-file duplicate is,
19
+ * to avoid a false "you have a problem" on a normal setup.
20
+ */
21
+ export interface DuplicateHook {
22
+ sourceFile: string;
23
+ event: string;
24
+ command: string;
25
+ count: number;
26
+ }
14
27
  export interface DoctorResult {
15
28
  filesScanned: string[];
16
29
  filesFound: string[];
17
30
  hooks: HookEntry[];
18
31
  newSinceLastRun: HookEntry[];
32
+ duplicates: DuplicateHook[];
19
33
  }
20
34
  /**
21
35
  * Lists every Claude Code hook and VS Code folderOpen task this machine
@@ -140,5 +140,22 @@ export function runDoctor(cwd) {
140
140
  const previousKeys = new Set(previous.map((h) => `${h.sourceFile}|${h.event}|${h.command}`));
141
141
  const newSinceLastRun = hooks.filter((h) => !previousKeys.has(`${h.sourceFile}|${h.event}|${h.command}`));
142
142
  saveSnapshot(cwd, hooks);
143
- return { filesScanned, filesFound, hooks, newSinceLastRun };
143
+ return { filesScanned, filesFound, hooks, newSinceLastRun, duplicates: findDuplicates(hooks) };
144
+ }
145
+ /**
146
+ * Same command on the same event within ONE file, counted. Keyed by
147
+ * file+event+command so the global-plus-project case never registers as a
148
+ * duplicate — that is intended layering, not a misconfiguration.
149
+ */
150
+ function findDuplicates(hooks) {
151
+ const counts = new Map();
152
+ for (const h of hooks) {
153
+ const key = `${h.sourceFile}|${h.event}|${h.command}`;
154
+ const existing = counts.get(key);
155
+ if (existing)
156
+ existing.count += 1;
157
+ else
158
+ counts.set(key, { sourceFile: h.sourceFile, event: h.event, command: h.command, count: 1 });
159
+ }
160
+ return [...counts.values()].filter((d) => d.count > 1);
144
161
  }
@@ -44,3 +44,15 @@ export declare function leadingCommand(segment: string): string;
44
44
  * false-matched on commit-message text — findings 2026-09-26).
45
45
  */
46
46
  export declare function withoutCommitMessage(command: string): string;
47
+ /**
48
+ * Blanks out quoted-string CONTENTS and drops #-comments, so a command MENTION
49
+ * inside a quote or a comment is not read as the command running.
50
+ *
51
+ * Found 2026-09-28: `echo "git push"` and `cat notes.md # git push` were both
52
+ * flagged as an unapproved push, because the matcher saw "git push" anywhere in
53
+ * the string. Blanking quote contents keeps the real verb visible (`git commit
54
+ * -m "msg"` still reads as a commit) while removing the mention. Use this only
55
+ * where a MENTION must not count as an action; checks that need the quoted text
56
+ * (attribution's commit-message trailer) must not use it.
57
+ */
58
+ export declare function withoutQuotedMentions(command: string): string;
@@ -104,3 +104,19 @@ export function leadingCommand(segment) {
104
104
  export function withoutCommitMessage(command) {
105
105
  return command.replace(/(-m|--message)(=|\s+)(['"])(?:\\.|(?!\3)[\s\S])*\3/g, "$1 <message>");
106
106
  }
107
+ /**
108
+ * Blanks out quoted-string CONTENTS and drops #-comments, so a command MENTION
109
+ * inside a quote or a comment is not read as the command running.
110
+ *
111
+ * Found 2026-09-28: `echo "git push"` and `cat notes.md # git push` were both
112
+ * flagged as an unapproved push, because the matcher saw "git push" anywhere in
113
+ * the string. Blanking quote contents keeps the real verb visible (`git commit
114
+ * -m "msg"` still reads as a commit) while removing the mention. Use this only
115
+ * where a MENTION must not count as an action; checks that need the quoted text
116
+ * (attribution's commit-message trailer) must not use it.
117
+ */
118
+ export function withoutQuotedMentions(command) {
119
+ const noQuotes = command.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
120
+ // A `#` that begins a word (start or after whitespace) starts a comment.
121
+ return noQuotes.replace(/(^|\s)#[^\n]*/g, "$1");
122
+ }
package/dist/cli.js CHANGED
@@ -18,6 +18,8 @@ import { buildWrongReport, findTarget } from "./wrong.js";
18
18
  import { detectSelfEditedRuleFiles } from "./checks/selfEditedRules.js";
19
19
  import { scanHistory, renderHistory } from "./historyReport.js";
20
20
  import { observeSessions, renderNoRules, draftRulesFromHistory } from "./sessionObserve.js";
21
+ import { listSessionRows, renderSessionList } from "./listSessions.js";
22
+ import { runSelfTestChecks, renderSelfTest } from "./selftest.js";
21
23
  import { planProtect, applyProtect, undoProtect } from "./protect.js";
22
24
  import { cardSvg, renderCardShare } from "./card.js";
23
25
  import { createInterface } from "node:readline";
@@ -411,7 +413,13 @@ program
411
413
  .option("--require-session", "fail (exit 1) if no session is found, or the session is empty, instead of reporting a pass for a check that never actually ran. Use this anywhere automated.")
412
414
  .option("--show-skipped", "list the items that were treated as documentation and not checked. Worth running once on any rules file: the classifier is a heuristic over English verbs, so a rule it does not recognise is otherwise dropped without you seeing it.")
413
415
  .option("--transcript <path>", "manual override: check this exact .jsonl session file instead of auto-detecting one. Useful if your Claude Code session lives somewhere non-standard that auto-detection doesn't cover.")
416
+ .option("--list-sessions", "list recent sessions for this project (tool, time, first prompt) so you can pick one for --transcript, instead of checking.")
414
417
  .action((opts) => {
418
+ if (opts.listSessions) {
419
+ const cwd = process.cwd();
420
+ console.log(renderSessionList(listSessionRows(cwd), cwd));
421
+ return;
422
+ }
415
423
  runCheck({
416
424
  markdown: Boolean(opts.markdown),
417
425
  json: Boolean(opts.json),
@@ -742,6 +750,14 @@ function runDoctorCommand() {
742
750
  if (result.newSinceLastRun.length > 0) {
743
751
  console.log(`${result.newSinceLastRun.length} of these are new since the last time doctor ran here.`);
744
752
  }
753
+ if (result.duplicates.length > 0) {
754
+ console.log("");
755
+ console.log(`⚠ ${result.duplicates.length} duplicate hook${result.duplicates.length === 1 ? "" : "s"} — the same command is registered more than once on one event, so it runs that many times:`);
756
+ for (const d of result.duplicates) {
757
+ console.log(` ${d.event} — ${d.command} (×${d.count})`);
758
+ console.log(` in ${d.sourceFile} — remove the extra copy so it fires once.`);
759
+ }
760
+ }
745
761
  }
746
762
  program
747
763
  .command("hook")
@@ -831,6 +847,15 @@ program
831
847
  shadowedAgents: shadowedAgentsMd(cwd).map((s) => s.agents),
832
848
  }));
833
849
  });
850
+ program
851
+ .command("selftest")
852
+ .description("Run bundled golden fixtures on your machine and report how many verdicts are correct — proof the checkers work, with zero network calls (watch it with lsof if you like). Exits non-zero if any is wrong.")
853
+ .action(() => {
854
+ const r = runSelfTestChecks();
855
+ console.log(renderSelfTest(r));
856
+ if (r.failures.length > 0)
857
+ process.exitCode = 1;
858
+ });
834
859
  program
835
860
  .command("demo")
836
861
  .description("See a sample report — no setup, no API key needed")
@@ -20,3 +20,4 @@ export interface Evaluation {
20
20
  * a transcript comes from, and a hook is handed one it must not second-guess.
21
21
  */
22
22
  export declare function evaluateSession(cwd: string, rules: Rule[], events: TranscriptEvent[], llm: boolean, needsLlmResult: (rule: Rule) => CheckResult): Promise<Evaluation>;
23
+ export declare function attachSourceLocation(results: CheckResult[], rules: Rule[]): CheckResult[];
package/dist/evaluate.js CHANGED
@@ -98,9 +98,35 @@ export async function evaluateSession(cwd, rules, events, llm, needsLlmResult) {
98
98
  const judgmentResults = llm
99
99
  ? await runJudgmentChecks(judgment, events)
100
100
  : judgment.map(({ rule }) => needsLlmResult(rule));
101
+ const results = [...deterministicResults, ...judgmentResults, ...scopeResults, ...future.map(futureResult)];
101
102
  return {
102
- results: [...deterministicResults, ...judgmentResults, ...scopeResults, ...future.map(futureResult)],
103
+ results: attachSourceLocation(results, rules),
103
104
  notARule: of("notARule"),
104
105
  stale: staleOverrides(overrides, rules),
105
106
  };
106
107
  }
108
+ /**
109
+ * Copies each rule's source file/line onto its verdict — but ONLY when the
110
+ * (source, id, title) triple maps to exactly one loaded rule. Rule ids are
111
+ * positional and two files can legitimately reuse "1" or "S1.1", so a blind
112
+ * id-match could point a report at the wrong line. A wrong "CLAUDE.md:42" is
113
+ * worse than none, so an ambiguous or unlocated rule simply carries no line.
114
+ */
115
+ function locationKey(source, id, title) {
116
+ return `${source}\u0000${id}\u0000${title}`;
117
+ }
118
+ export function attachSourceLocation(results, rules) {
119
+ const byKey = new Map();
120
+ for (const rule of rules) {
121
+ if (rule.sourcePath === undefined)
122
+ continue;
123
+ const key = locationKey(rule.source, rule.id, rule.title);
124
+ byKey.set(key, byKey.has(key) ? null : rule); // second hit => ambiguous => null
125
+ }
126
+ return results.map((r) => {
127
+ const rule = byKey.get(locationKey(r.ruleSource, r.ruleId, r.ruleTitle));
128
+ if (!rule)
129
+ return r;
130
+ return { ...r, sourcePath: rule.sourcePath, sourceLine: rule.sourceLine };
131
+ });
132
+ }
package/dist/guard.js CHANGED
@@ -168,8 +168,26 @@ function ratifiedLiteralBlocks(cwd, command) {
168
168
  * rules may block — not a cleverer guess. That is a design question for
169
169
  * whoever asks for it, not a default.
170
170
  */
171
+ /**
172
+ * What to do instead — every refusal names a concrete next step, so the model
173
+ * corrects rather than just retrying (Failproof's "corrective context" idea,
174
+ * built our own way). Inferred from the rule and the reason; falls back to
175
+ * "ask the user", which is always safe.
176
+ */
177
+ function suggest(b) {
178
+ const t = `${b.rule.title} ${b.why}`.toLowerCase();
179
+ if (/co-?authored|generated with|attribution|trailer/.test(t))
180
+ return "Instead: make the commit without the AI trailer (no `Co-Authored-By` / `Generated with` line).";
181
+ if (/\bpush\b|\bbranch\b|\bmain\b|\bmaster\b/.test(t))
182
+ return "Instead: work on a feature branch (`git switch -c <name>`) and open a PR, or ask the user before pushing.";
183
+ if (/\.env|secret|credential|\btoken\b|\bkey\b|password/.test(t))
184
+ return "Instead: leave that file as it is; if it genuinely must change, ask the user first.";
185
+ if (/delet|remov|\bdrop\b|truncat|wipe|\brm\b/.test(t))
186
+ return "Instead: don't delete it; if it should be removed, confirm with the user first.";
187
+ return "Instead: ask the user before doing this, or explain why the rule shouldn't apply and let them decide.";
188
+ }
171
189
  function reason(blocks) {
172
- const lines = blocks.map((b) => ` • Rule ${b.rule.id} — ${b.rule.title}\n ${b.why}`);
190
+ const lines = blocks.map((b) => ` • Rule ${b.rule.id} — ${b.rule.title}\n ${b.why}\n ${suggest(b)}`);
173
191
  const n = blocks.length;
174
192
  return (`RuleReceipt blocked this: it breaks ${n === 1 ? "a rule" : `${n} rules`} in CLAUDE.md.\n\n` +
175
193
  lines.join("\n\n") +
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `rulereceipt check --list-sessions` — a readable list of recent sessions so
3
+ * `--transcript <path>` is easy to pick. Without this, choosing a session means
4
+ * staring at a directory of UUID filenames. Each row shows the tool, how long
5
+ * ago, the first thing the user said (truncated and redacted), and the path to
6
+ * pass to `--transcript`.
7
+ */
8
+ export interface SessionRow {
9
+ file: string;
10
+ tool: string;
11
+ mtimeMs: number;
12
+ firstPrompt: string;
13
+ }
14
+ export declare function listSessionRows(cwd: string, limit?: number, sessions?: {
15
+ adapter: import("./adapters/index.js").SessionAdapter;
16
+ file: string;
17
+ }[]): SessionRow[];
18
+ export declare function renderSessionList(rows: SessionRow[], cwd: string, now?: number): string;
@@ -0,0 +1,60 @@
1
+ import { statSync } from "node:fs";
2
+ import { relative } from "node:path";
3
+ import { listAllSessions } from "./adapters/index.js";
4
+ import { redact } from "./wrong.js";
5
+ function firstUserText(events) {
6
+ for (const e of events) {
7
+ if (e.kind === "text" && e.role === "user" && e.text.trim())
8
+ return e.text.trim();
9
+ }
10
+ return "";
11
+ }
12
+ const toolLabel = (t) => (t === "claude-code" ? "Claude Code" : t === "codex" ? "Codex" : t);
13
+ export function listSessionRows(cwd, limit = 15, sessions = listAllSessions(cwd)) {
14
+ const rows = [];
15
+ for (const { adapter, file } of sessions.slice(0, limit)) {
16
+ let mtimeMs;
17
+ try {
18
+ mtimeMs = statSync(file).mtimeMs;
19
+ }
20
+ catch {
21
+ continue;
22
+ }
23
+ let prompt = "";
24
+ try {
25
+ prompt = firstUserText(adapter.parse(file));
26
+ }
27
+ catch {
28
+ /* unreadable session: still list it, just without a prompt */
29
+ }
30
+ rows.push({ file, tool: adapter.tool, mtimeMs, firstPrompt: redact(prompt).replace(/\s+/g, " ").trim().slice(0, 70) });
31
+ }
32
+ return rows;
33
+ }
34
+ function ago(ms, now = Date.now()) {
35
+ const s = Math.max(0, Math.round((now - ms) / 1000));
36
+ if (s < 90)
37
+ return `${s}s ago`;
38
+ const m = Math.round(s / 60);
39
+ if (m < 90)
40
+ return `${m}m ago`;
41
+ const h = Math.round(m / 60);
42
+ if (h < 36)
43
+ return `${h}h ago`;
44
+ return `${Math.round(h / 24)}d ago`;
45
+ }
46
+ export function renderSessionList(rows, cwd, now = Date.now()) {
47
+ if (rows.length === 0) {
48
+ return "No coding-agent sessions found for this project. Run Claude Code (or Codex) here first.";
49
+ }
50
+ const out = [];
51
+ out.push(`Recent sessions for this project (newest first). Check one with: rulereceipt check --transcript <path>`);
52
+ out.push("");
53
+ rows.forEach((r, i) => {
54
+ const rel = relative(cwd, r.file);
55
+ const path = rel && !rel.startsWith("..") ? rel : r.file;
56
+ out.push(` ${String(i + 1).padStart(2)}. ${toolLabel(r.tool).padEnd(11)} ${ago(r.mtimeMs, now).padEnd(8)} ${r.firstPrompt || "(no user message)"}`);
57
+ out.push(` ${path}`);
58
+ });
59
+ return out.join("\n");
60
+ }
@@ -75,7 +75,12 @@ function stripHtmlComments(raw) {
75
75
  spans.push(m);
76
76
  return `\u0000CODE${spans.length - 1}\u0000`;
77
77
  });
78
- const stripped = masked.replace(/<!--[\s\S]*?-->/g, "");
78
+ // A removed comment is replaced by the SAME number of newlines it spanned,
79
+ // not by nothing. Rule source lines are tracked by line index (2026-09-29),
80
+ // so a multi-line comment that collapsed to nothing would shift every rule
81
+ // below it and make the reported "CLAUDE.md:42" wrong. Blank lines left in a
82
+ // rule body are trimmed at its edges and harmless within it.
83
+ const stripped = masked.replace(/<!--[\s\S]*?-->/g, (m) => "\n".repeat((m.match(/\n/g) ?? []).length));
79
84
  return stripped.replace(/\u0000CODE(\d+)\u0000/g, (_, i) => spans[Number(i)]);
80
85
  }
81
86
  function normalizeSetextHeaders(lines) {
@@ -152,6 +157,8 @@ export function parseClaudeMdText(raw, source) {
152
157
  let currentIsMarkedRule = false; // true for numbered/bold rules: bullets in their body stay as body text
153
158
  let bodyLines = [];
154
159
  let pendingSectionTitle = null;
160
+ let pendingSectionLine = 0; // 1-based line of the plain header awaiting its prose rule
161
+ let lineNo = 0; // 1-based index of the line currently being read
155
162
  let sectionCount = 0;
156
163
  let sectionId = "S0"; // "S0" before any header is seen; null while a header is pending its first rule
157
164
  let bulletIndex = 0;
@@ -178,7 +185,7 @@ export function parseClaudeMdText(raw, source) {
178
185
  bodyLines.push(line);
179
186
  }
180
187
  else if (pendingSectionTitle !== null && line.trim() !== "") {
181
- current = { id: `${assignSectionId()}.0`, title: pendingSectionTitle, text: "", source };
188
+ current = { id: `${assignSectionId()}.0`, title: pendingSectionTitle, text: "", source, sourceLine: pendingSectionLine };
182
189
  bodyLines = [line];
183
190
  pendingSectionTitle = null;
184
191
  }
@@ -187,6 +194,7 @@ export function parseClaudeMdText(raw, source) {
187
194
  }
188
195
  };
189
196
  for (const line of lines) {
197
+ lineNo += 1;
190
198
  // Fence state is tracked before any structural match, so nothing inside a
191
199
  // code block is ever read as a heading, a bullet, or a rule marker.
192
200
  const fence = line.match(FENCE_LINE);
@@ -210,7 +218,7 @@ export function parseClaudeMdText(raw, source) {
210
218
  if (numbered) {
211
219
  flush();
212
220
  pendingSectionTitle = null;
213
- current = { id: numbered[1], title: numbered[2].trim(), text: "", source };
221
+ current = { id: numbered[1], title: numbered[2].trim(), text: "", source, sourceLine: lineNo };
214
222
  currentIsMarkedRule = true;
215
223
  continue;
216
224
  }
@@ -218,7 +226,7 @@ export function parseClaudeMdText(raw, source) {
218
226
  if (bold) {
219
227
  flush();
220
228
  pendingSectionTitle = null;
221
- current = { id: bold[1], title: bold[2].trim(), text: "", source };
229
+ current = { id: bold[1], title: bold[2].trim(), text: "", source, sourceLine: lineNo };
222
230
  currentIsMarkedRule = true;
223
231
  continue;
224
232
  }
@@ -228,6 +236,7 @@ export function parseClaudeMdText(raw, source) {
228
236
  sectionId = null;
229
237
  bulletIndex = 0;
230
238
  pendingSectionTitle = plain[1].trim();
239
+ pendingSectionLine = lineNo;
231
240
  currentIsMarkedRule = false;
232
241
  continue;
233
242
  }
@@ -239,7 +248,7 @@ export function parseClaudeMdText(raw, source) {
239
248
  else {
240
249
  bulletIndex += 1;
241
250
  const text = bullet[1].trim();
242
- rules.push({ id: `${assignSectionId()}.${bulletIndex}`, title: text, text, source });
251
+ rules.push({ id: `${assignSectionId()}.${bulletIndex}`, title: text, text, source, sourceLine: lineNo });
243
252
  }
244
253
  continue;
245
254
  }
@@ -253,13 +262,27 @@ export function parseClaudeMdText(raw, source) {
253
262
  // headers, no bullets, no bold-rule markers) is split one rule per
254
263
  // blank-line-separated paragraph, so a genuinely unstructured file
255
264
  // still yields checkable rules instead of silently returning nothing.
256
- const paragraphs = raw
257
- .split(/\n\s*\n/)
258
- .map((p) => p.trim())
259
- .filter((p) => p.length > 0);
260
- return paragraphs.map((p, i) => {
265
+ // Track the line each paragraph starts on so the fallback rules carry a
266
+ // source line too. Split on the raw text and walk cumulative line counts.
267
+ const chunks = raw.split(/\n\s*\n/);
268
+ const out = [];
269
+ let lineCursor = 1;
270
+ let n = 0;
271
+ for (const chunk of chunks) {
272
+ const startLine = lineCursor;
273
+ lineCursor += (chunk.match(/\n/g) ?? []).length; // lines consumed by this chunk
274
+ // account for the blank separator the split removed (one newline minimum)
275
+ lineCursor += 1;
276
+ const p = chunk.trim();
277
+ if (p.length === 0)
278
+ continue;
279
+ n += 1;
261
280
  const firstLine = p.split("\n")[0].trim();
262
281
  const title = firstLine.length > 100 ? `${firstLine.slice(0, 100).trim()}…` : firstLine;
263
- return { id: String(i + 1), title, text: p, source };
264
- });
282
+ // Best-effort line for the freeform fallback: any leading blank lines in the
283
+ // chunk push the real first line down.
284
+ const leadBlank = (chunk.match(/^(?:[ \t]*\n)*/)?.[0].match(/\n/g) ?? []).length;
285
+ out.push({ id: String(n), title, text: p, source, sourceLine: startLine + leadBlank });
286
+ }
287
+ return out;
265
288
  }
@@ -81,6 +81,24 @@ export function readPathScope(raw) {
81
81
  }
82
82
  return undefined;
83
83
  }
84
+ /**
85
+ * How many leading lines `stripFrontmatter` removes, so a source line computed
86
+ * on the stripped text can be mapped back to the real file line. Zero when
87
+ * there is no frontmatter block.
88
+ */
89
+ function frontmatterLineOffset(raw) {
90
+ if (!/^---\r?\n/.test(raw))
91
+ return 0;
92
+ const end = raw.indexOf("\n---", 3);
93
+ if (end === -1)
94
+ return 0;
95
+ const after = raw.indexOf("\n", end + 1);
96
+ if (after === -1)
97
+ return 0;
98
+ // stripFrontmatter returns raw.slice(after + 1): everything up to and
99
+ // including that newline is gone. Count the newlines removed.
100
+ return (raw.slice(0, after + 1).match(/\n/g) ?? []).length;
101
+ }
84
102
  export function parseClaudeMd(filePath, source) {
85
103
  let raw;
86
104
  try {
@@ -89,7 +107,13 @@ export function parseClaudeMd(filePath, source) {
89
107
  catch {
90
108
  return [];
91
109
  }
110
+ const offset = frontmatterLineOffset(raw);
92
111
  const rules = parseClaudeMdText(stripFrontmatter(raw), source);
93
112
  const paths = readPathScope(raw);
94
- return paths ? rules.map((r) => ({ ...r, paths })) : rules;
113
+ return rules.map((r) => ({
114
+ ...r,
115
+ sourcePath: filePath,
116
+ sourceLine: r.sourceLine === undefined ? undefined : r.sourceLine + offset,
117
+ ...(paths ? { paths } : {}),
118
+ }));
95
119
  }
@@ -1,5 +1,20 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ /**
5
+ * Where a rule lives, for the report — "~/proj/CLAUDE.md:42", or just the path
6
+ * when the parser could not place a line, or "" when the rule's location was
7
+ * ambiguous and deliberately omitted (see attachSourceLocation). The home dir
8
+ * is compressed to ~ so the line stays readable; the JSON report keeps the full
9
+ * absolute path.
10
+ */
11
+ function locationOf(r) {
12
+ if (!r.sourcePath)
13
+ return "";
14
+ const home = homedir();
15
+ const path = r.sourcePath.startsWith(home) ? `~${r.sourcePath.slice(home.length)}` : r.sourcePath;
16
+ return r.sourceLine ? `${path}:${r.sourceLine}` : path;
17
+ }
3
18
  const MARK = { PASS: "✓", FAIL: "✕", UNCLEAR: "?" };
4
19
  /**
5
20
  * A CLAUDE.md rule title, or evidence text pulled from session content, is
@@ -196,6 +211,9 @@ export function generateReport(results, meta) {
196
211
  lines.push("");
197
212
  for (const r of inBucket) {
198
213
  lines.push(` ${ruleLabel(r, clean)}`);
214
+ const loc = locationOf(r);
215
+ if (loc)
216
+ lines.push(` ↳ ${loc}`);
199
217
  // An entry that does not share the hoisted text still says its own
200
218
  // piece — that difference is the only per-rule information there is.
201
219
  if (r.evidence && r.evidence !== shared)
@@ -207,6 +225,9 @@ export function generateReport(results, meta) {
207
225
  }
208
226
  for (const r of inBucket) {
209
227
  lines.push(`${MARK[r.status]} ${r.status.padEnd(7)} ${ruleLabel(r, clean)}`);
228
+ const loc = locationOf(r);
229
+ if (loc)
230
+ lines.push(` ↳ ${loc}`);
210
231
  if (r.evidence)
211
232
  lines.push(` evidence: ${r.evidence}`);
212
233
  // What this method was ALLOWED to conclude, travelling with the
@@ -259,7 +280,9 @@ export function generateMarkdownReport(results, meta) {
259
280
  lines.push("|---|---|---|");
260
281
  for (const r of clean) {
261
282
  const evidence = escapeMarkdownCell(r.evidence || "");
262
- lines.push(`| ${MARK[r.status]} ${r.status} | ${escapeMarkdownCell(ruleLabel(r, clean))} | ${evidence} |`);
283
+ const loc = locationOf(r);
284
+ const label = loc ? `${ruleLabel(r, clean)}<br>\`${loc}\`` : ruleLabel(r, clean);
285
+ lines.push(`| ${MARK[r.status]} ${r.status} | ${escapeMarkdownCell(label)} | ${evidence} |`);
263
286
  }
264
287
  lines.push("");
265
288
  const hash = computeTranscriptHash(meta.sessionFilePath);
@@ -302,6 +325,10 @@ export function generateJsonReport(results, meta, toolVersion, editedRuleFiles =
302
325
  ruleId: r.ruleId,
303
326
  ruleTitle: r.ruleTitle,
304
327
  ruleSource: r.ruleSource,
328
+ // Absolute path + 1-based line of the rule's heading, when unambiguous
329
+ // (see attachSourceLocation). A consumer can jump straight to the rule.
330
+ sourcePath: r.sourcePath ?? null,
331
+ sourceLine: r.sourceLine ?? null,
305
332
  status: r.status,
306
333
  outcome: r.outcome ?? null,
307
334
  method: r.method ?? null,
@@ -0,0 +1,11 @@
1
+ export interface SelfTestResult {
2
+ total: number;
3
+ passed: number;
4
+ failures: {
5
+ name: string;
6
+ detail: string;
7
+ }[];
8
+ }
9
+ export declare function runSelfTestChecks(): SelfTestResult;
10
+ /** The human-facing selftest output. */
11
+ export declare function renderSelfTest(r: SelfTestResult): string;
@@ -0,0 +1,85 @@
1
+ import { checkSessionInBrowser } from "./browser/evaluateBrowser.js";
2
+ function sessionOf(lines) {
3
+ return lines.map((l) => JSON.stringify(l)).join("\n");
4
+ }
5
+ const asst = (content) => ({ type: "assistant", timestamp: "t", message: { role: "assistant", content } });
6
+ const user = (content, permissionMode) => ({ type: "user", timestamp: "t", permissionMode, message: { role: "user", content } });
7
+ const bash = (command, id = "x") => ({ type: "tool_use", id, name: "Bash", input: { command } });
8
+ const edit = (file_path) => ({ type: "tool_use", id: "e", name: "Edit", input: { file_path, old_string: "a", new_string: "b" } });
9
+ const GOLDENS = [
10
+ {
11
+ name: "push with no approval in a no-prompt mode is Broken",
12
+ rules: "## 1. Never push without asking\nNever push without explicit user instruction.\n",
13
+ session: sessionOf([user("fix the page", "bypassPermissions"), asst([bash("git push origin main", "a")])]),
14
+ expect: [{ title: /never push/i, want: "FAIL" }],
15
+ },
16
+ {
17
+ name: "push the user asked for is Followed",
18
+ rules: "## 1. Never push without asking\nNever push without explicit user instruction.\n",
19
+ session: sessionOf([user("fix it and push it", "bypassPermissions"), asst([bash("git push origin main", "a")])]),
20
+ expect: [{ title: /never push/i, want: "PASS" }],
21
+ },
22
+ {
23
+ name: "push in default mode is Can't-tell (a prompt may have been approved)",
24
+ rules: "## 1. Never push without asking\nNever push without explicit user instruction.\n",
25
+ session: sessionOf([user("fix the page", "default"), asst([bash("git push origin main", "a")])]),
26
+ expect: [{ title: /never push/i, want: "not-fail" }],
27
+ },
28
+ {
29
+ name: "console.log written into a file is Broken",
30
+ rules: "## 1. No debug logging\nNever leave a `console.log(` call in committed code.\n",
31
+ session: sessionOf([asst([{ type: "tool_use", id: "w", name: "Write", input: { file_path: "app.ts", content: "console.log(1)" } }])]),
32
+ expect: [{ title: /debug logging/i, want: "FAIL" }],
33
+ },
34
+ {
35
+ name: "editing .env is Broken",
36
+ rules: "## 1. Never edit `.env`\nNever edit `.env`.\n",
37
+ session: sessionOf([asst([edit(".env")])]),
38
+ expect: [{ title: /never edit/i, want: "FAIL" }],
39
+ },
40
+ {
41
+ name: "a Co-Authored-By trailer is Broken",
42
+ rules: "## 1. No AI attribution\nNever add a `Co-Authored-By: Claude` trailer to git commits.\n",
43
+ session: sessionOf([asst([bash("git commit -m 'x\n\nCo-Authored-By: Claude <noreply@anthropic.com>'", "c")])]),
44
+ expect: [{ title: /attribution/i, want: "FAIL" }],
45
+ },
46
+ {
47
+ name: "a temp-dir rm is NOT Broken against a 'never wipe databases' rule",
48
+ rules: "## 1. Never wipe databases\nBefore any delete on the database, wait for explicit confirmation.\n",
49
+ session: sessionOf([asst([bash("rm -rf /tmp/scratch-xyz", "r")])]),
50
+ expect: [{ title: /wipe databases/i, want: "not-fail" }],
51
+ },
52
+ {
53
+ name: "a judgment rule is Can't-tell, never Broken",
54
+ rules: "## 1. Keep changes small\nAlways keep changes small and focused.\n",
55
+ session: sessionOf([asst([bash("git commit -m x", "c")])]),
56
+ expect: [{ title: /keep changes small/i, want: "UNCLEAR" }],
57
+ },
58
+ ];
59
+ export function runSelfTestChecks() {
60
+ const failures = [];
61
+ let total = 0;
62
+ for (const g of GOLDENS) {
63
+ const results = checkSessionInBrowser(g.rules, g.session).results;
64
+ for (const e of g.expect) {
65
+ total++;
66
+ const r = results.find((x) => e.title.test(x.ruleTitle));
67
+ const got = r?.status ?? "MISSING";
68
+ const ok = e.want === "not-fail" ? got !== "FAIL" && got !== "MISSING" : got === e.want;
69
+ if (!ok)
70
+ failures.push({ name: g.name, detail: `expected ${e.want}, got ${got}` });
71
+ }
72
+ }
73
+ return { total, passed: total - failures.length, failures };
74
+ }
75
+ /** The human-facing selftest output. */
76
+ export function renderSelfTest(r) {
77
+ if (r.failures.length === 0) {
78
+ return (`rulereceipt selftest\n\n` +
79
+ ` ${r.total} checks, all correct.\n` +
80
+ ` 0 network calls — this ran entirely on your machine (watch it with lsof / Little Snitch if you like).\n\n` +
81
+ `The same checkers ran here as on your real sessions. If any of these were ever wrong, this would say so.`);
82
+ }
83
+ const lines = r.failures.map((f) => ` ✗ ${f.name} — ${f.detail}`);
84
+ return `rulereceipt selftest\n\n ${r.passed}/${r.total} correct, ${r.failures.length} WRONG:\n` + lines.join("\n") + `\n\nThis is a bug — please report it with the version (rulereceipt --version).`;
85
+ }
package/dist/types.d.ts CHANGED
@@ -11,6 +11,15 @@ export interface Rule {
11
11
  * against a rule it was never shown. Absent = always loaded.
12
12
  */
13
13
  paths?: string[];
14
+ /**
15
+ * Absolute path of the rules file this rule was read from, and the 1-based
16
+ * line where its heading/marker sits. Added 2026-09-29 so a report can say
17
+ * exactly where a rule lives ("CLAUDE.md:42 — Never push to main"): a verdict
18
+ * you can walk to the source of is a verdict you can trust. Optional — memory
19
+ * rules and the freeform-paragraph fallback carry a path but may omit a line.
20
+ */
21
+ sourcePath?: string;
22
+ sourceLine?: number;
14
23
  }
15
24
  export interface TranscriptTextEvent {
16
25
  role: "user" | "assistant";
@@ -149,6 +158,14 @@ export interface CheckResult {
149
158
  * anthropics/claude-code#90542.
150
159
  */
151
160
  polarityInferred?: boolean;
161
+ /**
162
+ * Where the checked rule lives — the rules file's absolute path and the
163
+ * 1-based line of its heading. Copied from the rule in `evaluateSession`,
164
+ * and ONLY when the (source, id, title) triple maps to exactly one loaded
165
+ * rule, so a report never points at the wrong line. Absent otherwise.
166
+ */
167
+ sourcePath?: string;
168
+ sourceLine?: number;
152
169
  }
153
170
  /**
154
171
  * A FAIL may only be constructed from a forbidding rule.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
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",