rulereceipt 0.1.66 → 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.
@@ -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"/>
@@ -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
  }
package/dist/cli.js CHANGED
@@ -750,6 +750,14 @@ function runDoctorCommand() {
750
750
  if (result.newSinceLastRun.length > 0) {
751
751
  console.log(`${result.newSinceLastRun.length} of these are new since the last time doctor ran here.`);
752
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
+ }
753
761
  }
754
762
  program
755
763
  .command("hook")
@@ -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
+ }
@@ -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,
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.66",
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",