rulereceipt 0.1.59 → 0.1.60

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/dist/rules.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { dirname, join, parse, resolve } from "node:path";
3
- import { existsSync, readdirSync, statSync } from "node:fs";
3
+ 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";
@@ -41,65 +41,105 @@ function markdownFilesIn(dir, exts = [".md"]) {
41
41
  return [];
42
42
  }
43
43
  }
44
- /** Every rules file at one directory level, in documented load order. */
45
- function ruleFilesAtLevel(dir) {
46
- const found = [];
47
- const push = (rel) => {
48
- const p = join(dir, rel);
49
- if (existsSync(p))
50
- found.push(p);
51
- };
44
+ /**
45
+ * Every candidate rules file at one directory level, in documented load order,
46
+ * each tagged loaded or shadowed. This is the single source of truth for both
47
+ * what gets checked (`ruleFilesAtLevel`, the loaded subset) and the load graph
48
+ * (`describeRuleSources`) — so the report can never claim a file was loaded
49
+ * that the checker skipped, or vice versa.
50
+ */
51
+ function ruleSourcesAtLevel(dir) {
52
+ const out = [];
52
53
  const has = (rel) => existsSync(join(dir, rel));
54
+ const loaded = (rel, format) => {
55
+ if (has(rel))
56
+ out.push({ path: join(dir, rel), status: "loaded", format });
57
+ };
58
+ const shadowed = (rel, format, note) => {
59
+ if (has(rel))
60
+ out.push({ path: join(dir, rel), status: "shadowed", format, note });
61
+ };
53
62
  // CLAUDE.md shadows AGENTS.md at the same level: as of 2026-09-19 Claude
54
63
  // Code loads AGENTS.md ONLY when that level has no CLAUDE.md, and silently
55
- // ignores it otherwise. Reading a shadowed AGENTS.md here would check the
56
- // session against rules Claude never loaded — a false accusation. `init`
57
- // separately WARNS about the shadowed file (see shadowedAgents.ts) so the
58
- // rules are not lost silently. Mirrored for the `.claude/` subdir pair.
59
- push(join(".claude", "CLAUDE.md"));
60
- if (!has(join(".claude", "CLAUDE.md")))
61
- push(join(".claude", "AGENTS.md"));
62
- for (const rel of RULE_DIRS)
63
- found.push(...markdownFilesIn(join(dir, rel)));
64
- push("CLAUDE.md");
65
- // AGENTS.md (and the singular AGENT.md some tools use) are read only when
66
- // there's no CLAUDE.md at this level, mirroring Claude Code's shadow rule.
67
- if (!has("CLAUDE.md")) {
68
- push("AGENTS.md");
69
- push("AGENT.md");
64
+ // ignores it otherwise. `init` separately WARNS about the shadowed file (see
65
+ // shadowedAgents.ts). Mirrored for the `.claude/` subdir pair.
66
+ if (has(join(".claude", "CLAUDE.md"))) {
67
+ loaded(join(".claude", "CLAUDE.md"), "Claude (.claude/CLAUDE.md)");
68
+ shadowed(join(".claude", "AGENTS.md"), "AGENTS (.claude/AGENTS.md)", "a CLAUDE.md at the same level wins");
69
+ }
70
+ else {
71
+ loaded(join(".claude", "AGENTS.md"), "AGENTS (.claude/AGENTS.md)");
72
+ }
73
+ for (const rel of RULE_DIRS) {
74
+ for (const f of markdownFilesIn(join(dir, rel)))
75
+ out.push({ path: f, status: "loaded", format: ".claude/rules" });
70
76
  }
71
- // .local variants: their precedence relative to the base files is not
72
- // documented, so both are kept rather than guessing at a shadow rule.
73
- push("CLAUDE.local.md");
74
- push("AGENTS.local.md");
77
+ if (has("CLAUDE.md")) {
78
+ loaded("CLAUDE.md", "Claude (CLAUDE.md)");
79
+ // AGENTS.md (and the singular AGENT.md some tools use) are ignored when
80
+ // there's a CLAUDE.md at this level, mirroring Claude Code's shadow rule.
81
+ shadowed("AGENTS.md", "AGENTS.md", "a CLAUDE.md at the same level wins");
82
+ shadowed("AGENT.md", "AGENT.md", "a CLAUDE.md at the same level wins");
83
+ }
84
+ else {
85
+ loaded("AGENTS.md", "AGENTS.md");
86
+ loaded("AGENT.md", "AGENT.md");
87
+ }
88
+ // .local variants: precedence relative to the base files is not documented,
89
+ // so both are kept rather than guessing at a shadow rule.
90
+ loaded("CLAUDE.local.md", "Claude (CLAUDE.local.md)");
91
+ loaded("AGENTS.local.md", "AGENTS (AGENTS.local.md)");
75
92
  // Non-Claude rule-file conventions (added 2026-09-26 for multi-tool
76
- // support). Each is a plain text/markdown file needing no special access,
77
- // read IN ADDITION to Claude's files when present — a rule the project
78
- // wrote is a rule to check, and silently ignoring one is the "clean report
79
- // on rules never opened" failure this module already guards against. The
80
- // engine (classify.ts) is agent-neutral, so it does not matter which tool a
81
- // rule was authored for. Precedence is FIXED and documented so rule ids stay
93
+ // support), read IN ADDITION to Claude's files when present. The engine
94
+ // (classify.ts) is agent-neutral. Precedence is FIXED so rule ids stay
82
95
  // deterministic: Claude family (above), then Cursor, Copilot, Windsurf.
83
96
  //
84
97
  // Cursor: the modern `.cursor/rules/*.mdc|.md` directory SHADOWS the legacy
85
- // single `.cursorrules` file — Cursor itself deprecated `.cursorrules` in
86
- // favour of the directory, so reading both would double-count. Same
87
- // shadow shape as CLAUDE.md over AGENTS.md above.
98
+ // single `.cursorrules` file — Cursor deprecated `.cursorrules` in favour of
99
+ // the directory, so reading both would double-count.
88
100
  const cursorRules = markdownFilesIn(join(dir, ".cursor", "rules"), [".mdc", ".md"]);
89
- if (cursorRules.length > 0)
90
- found.push(...cursorRules);
91
- else
92
- push(".cursorrules");
101
+ if (cursorRules.length > 0) {
102
+ for (const f of cursorRules)
103
+ out.push({ path: f, status: "loaded", format: "Cursor (.cursor/rules)" });
104
+ shadowed(".cursorrules", "Cursor (.cursorrules)", "the .cursor/rules/ directory supersedes the legacy file");
105
+ }
106
+ else {
107
+ loaded(".cursorrules", "Cursor (.cursorrules)");
108
+ }
93
109
  // GitHub Copilot: repo-level custom instructions.
94
- push(join(".github", "copilot-instructions.md"));
110
+ loaded(join(".github", "copilot-instructions.md"), "Copilot");
95
111
  // Windsurf (Codeium): single rules file.
96
- push(".windsurfrules");
97
- // Google's newer agent convention (agy) / "agents rules": .agents/rules/*.md,
98
- // each with a `trigger:` frontmatter block (stripped by the reader).
99
- found.push(...markdownFilesIn(join(dir, ".agents", "rules"), [".md"]));
112
+ loaded(".windsurfrules", "Windsurf");
113
+ // Google's newer agent convention / "agents rules": .agents/rules/*.md, each
114
+ // with a `trigger:` frontmatter block. The trigger decides whether the agent
115
+ // auto-loads the file, so it decides whether we may check against it:
116
+ // - always_on / glob (or no trigger) → loaded, checked
117
+ // - manual / model_decision → not auto-loaded → shadowed
118
+ // README fragments in the directory are notes, not rules, so they are skipped
119
+ // entirely (not even reported — they were never a candidate rule).
120
+ for (const file of markdownFilesIn(join(dir, ".agents", "rules"), [".md"])) {
121
+ if (/(^|[/\\])readme\.md$/i.test(file))
122
+ continue;
123
+ let head = "";
124
+ try {
125
+ head = readFileSync(file, "utf-8").slice(0, 600);
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ if (/^---[\s\S]*?^\s*trigger:\s*(?:manual|model_decision)\b/m.test(head)) {
131
+ out.push({ path: file, status: "shadowed", format: ".agents/rules", note: "trigger is manual/model_decision — the agent does not auto-load it" });
132
+ continue;
133
+ }
134
+ out.push({ path: file, status: "loaded", format: ".agents/rules" });
135
+ }
100
136
  // Gemini CLI: single rules file (its AGENTS.md equivalent).
101
- push("GEMINI.md");
102
- return found;
137
+ loaded("GEMINI.md", "Gemini");
138
+ return out;
139
+ }
140
+ /** Every rules file at one directory level that the agent actually loads. */
141
+ function ruleFilesAtLevel(dir) {
142
+ return ruleSourcesAtLevel(dir).filter((s) => s.status === "loaded").map((s) => s.path);
103
143
  }
104
144
  /**
105
145
  * Walks from the working directory up toward the repository root,
@@ -116,8 +156,8 @@ function ruleFilesAtLevel(dir) {
116
156
  * or the home directory — is never pulled into an unrelated project.
117
157
  * Global rules are handled separately, deliberately, below.
118
158
  */
119
- function findProjectRuleFiles(cwd) {
120
- const found = [];
159
+ function projectLevels(cwd) {
160
+ const levels = [];
121
161
  const { root } = parse(cwd);
122
162
  const home = homedir();
123
163
  let dir = cwd;
@@ -131,7 +171,7 @@ function findProjectRuleFiles(cwd) {
131
171
  // it; loadRules dedupes that against the global pass.
132
172
  if (dir === home && dir !== cwd)
133
173
  break;
134
- found.push(...ruleFilesAtLevel(dir));
174
+ levels.push(dir);
135
175
  // stop AT the repo root (inclusive) — its rules do apply
136
176
  if (existsSync(join(dir, ".git")))
137
177
  break;
@@ -142,7 +182,10 @@ function findProjectRuleFiles(cwd) {
142
182
  break;
143
183
  dir = parent;
144
184
  }
145
- return found;
185
+ return levels;
186
+ }
187
+ function findProjectRuleFiles(cwd) {
188
+ return projectLevels(cwd).flatMap((dir) => ruleFilesAtLevel(dir));
146
189
  }
147
190
  /**
148
191
  * Global rules come from every .claude*-prefixed home dir found, not just
@@ -189,3 +232,46 @@ export function loadRules(cwd) {
189
232
  rules.push(...loadMemoryRules(cwd));
190
233
  return rules;
191
234
  }
235
+ /**
236
+ * The load graph: every candidate rules file the discovery walk sees, in the
237
+ * same order and with the same dedup as `loadRules`, tagged loaded or shadowed
238
+ * and counted. This is what `audit` prints so "why isn't my rule firing?" has
239
+ * an honest, file-level answer — built on the SAME `ruleSourcesAtLevel` the
240
+ * checker's own discovery uses, so the graph can never claim a file was loaded
241
+ * that the checker skipped.
242
+ *
243
+ * Memory rules are deliberately NOT listed here: this graph is about files on
244
+ * disk a user can point at, and memory is summarised separately by the caller.
245
+ */
246
+ export function describeRuleSources(cwd) {
247
+ const entries = [];
248
+ const seen = new Set();
249
+ const add = (src, scope) => {
250
+ const key = resolve(src.path);
251
+ if (seen.has(key))
252
+ return;
253
+ seen.add(key);
254
+ let ruleCount = 0;
255
+ try {
256
+ ruleCount = parseClaudeMd(src.path, scope).length;
257
+ }
258
+ catch {
259
+ /* unreadable: reported with count 0 rather than dropped */
260
+ }
261
+ entries.push({ path: src.path, scope, status: src.status, format: src.format, note: src.note, ruleCount });
262
+ };
263
+ // Globals first, so a file reachable both ways keeps its "global" label —
264
+ // mirrors loadRules' dedup order exactly.
265
+ for (const dirName of findClaudeHomeDirNames()) {
266
+ const base = join(homedir(), dirName);
267
+ if (existsSync(join(base, "CLAUDE.md")))
268
+ add({ path: join(base, "CLAUDE.md"), status: "loaded", format: "Claude (global CLAUDE.md)" }, "global");
269
+ for (const file of markdownFilesIn(join(base, "rules")))
270
+ add({ path: file, status: "loaded", format: "Claude (global rules)" }, "global");
271
+ }
272
+ for (const dir of projectLevels(cwd)) {
273
+ for (const src of ruleSourcesAtLevel(dir))
274
+ add(src, "project");
275
+ }
276
+ return entries;
277
+ }
package/dist/types.d.ts CHANGED
@@ -35,6 +35,14 @@ export interface TranscriptToolUseEvent {
35
35
  * the test run that a completion claim depended on.
36
36
  */
37
37
  toolUseId?: string;
38
+ /**
39
+ * The Claude Code permission mode in force when this call was made, when the
40
+ * transcript records one (`permissionMode` on user turns). Added 2026-09-28
41
+ * for the approval check: in `default`/`acceptEdits`/`plan` a shell command
42
+ * may have been approved in the permission prompt, which leaves no trace in
43
+ * the transcript; in `bypassPermissions`/`dontAsk`/`auto` no person was asked.
44
+ */
45
+ permissionMode?: string;
38
46
  }
39
47
  export interface TranscriptToolResultEvent {
40
48
  role: "user";
@@ -0,0 +1,57 @@
1
+ import type { CheckResult, Rule, TranscriptEvent } from "./types.js";
2
+ /**
3
+ * `rulereceipt wrong <rule>` — turn "this verdict is wrong" into a report
4
+ * someone can actually file, without sending anything anywhere.
5
+ *
6
+ * Added 2026-09-28. The "A result looks wrong" issue template already asked the
7
+ * right questions, but nothing in the tool pointed to it, and filling it meant
8
+ * copying the rule, the verdict and the evidence by hand. A user who sees a
9
+ * wrong verdict and has to do that mostly uninstalls instead, and the project
10
+ * loses the one kind of report every accuracy fix has come from.
11
+ *
12
+ * Privacy: nothing is sent. The report is written to a local file and shown
13
+ * first; the issue link is printed for the user to open themselves. Obvious
14
+ * secrets, the home directory and email addresses are masked before either is
15
+ * produced, and the user is told to read it before sharing.
16
+ */
17
+ export declare const ISSUE_BASE = "https://github.com/rulereceipt/rulereceipt/issues/new";
18
+ /** The dropdown options of .github/ISSUE_TEMPLATE/wrong-result.yml, verbatim. */
19
+ export declare function reportedLabel(r: CheckResult): string;
20
+ /** Masks the things most likely to be private. Not a guarantee — the user is told to read it. */
21
+ export declare function redact(text: string, home?: string): string;
22
+ /**
23
+ * A few events around the one the evidence quotes, so the reader can see what
24
+ * happened just before and after. Found by the longest quoted fragment of the
25
+ * evidence; if nothing matches, no excerpt (never a guessed one).
26
+ */
27
+ export declare function excerptAround(events: TranscriptEvent[], evidence: string, radius?: number): string[];
28
+ export interface WrongReportInput {
29
+ version: string;
30
+ rule: Rule;
31
+ result: CheckResult;
32
+ events: TranscriptEvent[];
33
+ withContext?: boolean;
34
+ home?: string;
35
+ }
36
+ export interface WrongReport {
37
+ handle: string;
38
+ markdown: string;
39
+ issueUrl: string;
40
+ }
41
+ export declare function buildWrongReport(input: WrongReportInput): WrongReport;
42
+ /**
43
+ * The link for a shared report (HTML). No rule text and no evidence in it: that
44
+ * report is often sent to someone else, and a link should not leak what the
45
+ * page deliberately shows only to its reader.
46
+ */
47
+ export declare function minimalIssueUrl(result: CheckResult, version: string): string;
48
+ /** Finds the result a user means: by 12-char handle, or by id when that id is unique. */
49
+ export declare function findTarget(query: string, rules: Rule[], results: CheckResult[]): {
50
+ rule: Rule;
51
+ result: CheckResult;
52
+ } | {
53
+ ambiguous: Array<{
54
+ handle: string;
55
+ title: string;
56
+ }>;
57
+ } | null;
package/dist/wrong.js ADDED
@@ -0,0 +1,156 @@
1
+ import { homedir } from "node:os";
2
+ import { ruleFingerprint } from "./overrides.js";
3
+ /**
4
+ * `rulereceipt wrong <rule>` — turn "this verdict is wrong" into a report
5
+ * someone can actually file, without sending anything anywhere.
6
+ *
7
+ * Added 2026-09-28. The "A result looks wrong" issue template already asked the
8
+ * right questions, but nothing in the tool pointed to it, and filling it meant
9
+ * copying the rule, the verdict and the evidence by hand. A user who sees a
10
+ * wrong verdict and has to do that mostly uninstalls instead, and the project
11
+ * loses the one kind of report every accuracy fix has come from.
12
+ *
13
+ * Privacy: nothing is sent. The report is written to a local file and shown
14
+ * first; the issue link is printed for the user to open themselves. Obvious
15
+ * secrets, the home directory and email addresses are masked before either is
16
+ * produced, and the user is told to read it before sharing.
17
+ */
18
+ export const ISSUE_BASE = "https://github.com/rulereceipt/rulereceipt/issues/new";
19
+ /** The dropdown options of .github/ISSUE_TEMPLATE/wrong-result.yml, verbatim. */
20
+ export function reportedLabel(r) {
21
+ if (r.status === "FAIL")
22
+ return "Not followed";
23
+ if (r.status === "PASS")
24
+ return "Followed";
25
+ if (r.needsHuman || r.outcome === "not_run")
26
+ return "Needs your judgment";
27
+ return "Couldn't tell";
28
+ }
29
+ const SECRET_PATTERNS = [
30
+ [/\bsk-[A-Za-z0-9_-]{16,}/g, "<redacted-key>"],
31
+ [/\b(?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{16,}/g, "<redacted-token>"],
32
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "<redacted-token>"],
33
+ [/\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>"],
36
+ [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "<email>"],
37
+ ];
38
+ /** Masks the things most likely to be private. Not a guarantee — the user is told to read it. */
39
+ export function redact(text, home = homedir()) {
40
+ let out = text;
41
+ if (home && home.length > 1)
42
+ out = out.split(home).join("~");
43
+ for (const [re, rep] of SECRET_PATTERNS)
44
+ out = out.replace(re, rep);
45
+ return out;
46
+ }
47
+ function eventLine(e) {
48
+ if (e.kind === "text")
49
+ return `${e.role}: ${e.text}`;
50
+ if (e.kind === "tool_use") {
51
+ const input = e.input;
52
+ const main = input?.command ?? input?.file_path ?? input?.notebook_path ?? JSON.stringify(input ?? {});
53
+ return `assistant ran ${e.toolName}: ${String(main)}`;
54
+ }
55
+ return `tool result${e.isError ? " (error)" : ""}: ${e.content}`;
56
+ }
57
+ /**
58
+ * A few events around the one the evidence quotes, so the reader can see what
59
+ * happened just before and after. Found by the longest quoted fragment of the
60
+ * evidence; if nothing matches, no excerpt (never a guessed one).
61
+ */
62
+ export function excerptAround(events, evidence, radius = 2) {
63
+ const quoted = [...evidence.matchAll(/"([^"]{6,})"/g)].map((m) => m[1]);
64
+ const after = evidence.split(/:\s/).slice(1).join(": ");
65
+ const needles = [...quoted, after].map((s) => s.trim().slice(0, 60)).filter((s) => s.length >= 6).sort((a, b) => b.length - a.length);
66
+ if (needles.length === 0)
67
+ return [];
68
+ // Evidence strings collapse whitespace; the session keeps newlines. Compare both the same way.
69
+ const flat = (t) => t.replace(/\s+/g, " ");
70
+ const idx = events.findIndex((e) => needles.some((n) => flat(eventLine(e)).includes(flat(n))));
71
+ if (idx === -1)
72
+ return [];
73
+ return events.slice(Math.max(0, idx - radius), idx + radius + 1).map((e) => eventLine(e).replace(/\s+/g, " ").slice(0, 240));
74
+ }
75
+ const MAX_URL = 7500;
76
+ export function buildWrongReport(input) {
77
+ const { version, rule, result, events } = input;
78
+ const r = (s) => redact(s, input.home);
79
+ const handle = ruleFingerprint(rule);
80
+ const ruleText = r(rule.text && rule.text !== rule.title ? `${rule.title}\n${rule.text}` : rule.title);
81
+ const reported = reportedLabel(result);
82
+ const excerpt = input.withContext === false ? [] : excerptAround(events, result.evidence).map(r);
83
+ const how = [
84
+ result.method ? `method: ${result.method}` : "",
85
+ result.outcome ? `outcome: ${result.outcome}` : "",
86
+ result.reason ? `reason: ${result.reason}` : "",
87
+ result.ceiling ? `limits: ${r(result.ceiling)}` : "",
88
+ ].filter(Boolean);
89
+ const markdown = [
90
+ `# Wrong verdict report (rulereceipt ${version})`,
91
+ "",
92
+ "> Read this before sharing. Obvious secrets, your home path and email addresses were masked,",
93
+ "> but rule text and session lines are quoted as they are. Edit anything private.",
94
+ "",
95
+ `**Rule handle:** \`${handle}\` (id ${result.ruleId}, ${result.ruleSource})`,
96
+ "",
97
+ "## The rule",
98
+ "```",
99
+ ruleText,
100
+ "```",
101
+ "",
102
+ `## What RuleReceipt reported: ${reported}`,
103
+ "",
104
+ "```",
105
+ r(result.evidence),
106
+ "```",
107
+ ...(how.length ? ["", ...how.map((h) => `- ${h}`)] : []),
108
+ ...(excerpt.length ? ["", "## Session lines around it", "```", ...excerpt, "```"] : []),
109
+ "",
110
+ "## What you expected instead",
111
+ "",
112
+ "_(write it here)_",
113
+ "",
114
+ ].join("\n");
115
+ const params = new URLSearchParams({
116
+ template: "wrong-result.yml",
117
+ title: `Wrong verdict: ${reported} on "${r(rule.title).slice(0, 60)}"`,
118
+ rule: ruleText,
119
+ reported,
120
+ evidence: r(result.evidence),
121
+ version,
122
+ });
123
+ let issueUrl = `${ISSUE_BASE}?${params.toString()}`;
124
+ if (issueUrl.length > MAX_URL) {
125
+ params.set("rule", ruleText.slice(0, 1500));
126
+ params.set("evidence", r(result.evidence).slice(0, 1500));
127
+ issueUrl = `${ISSUE_BASE}?${params.toString()}`;
128
+ }
129
+ return { handle, markdown, issueUrl };
130
+ }
131
+ /**
132
+ * The link for a shared report (HTML). No rule text and no evidence in it: that
133
+ * report is often sent to someone else, and a link should not leak what the
134
+ * page deliberately shows only to its reader.
135
+ */
136
+ export function minimalIssueUrl(result, version) {
137
+ const params = new URLSearchParams({ template: "wrong-result.yml", reported: reportedLabel(result), version });
138
+ return `${ISSUE_BASE}?${params.toString()}`;
139
+ }
140
+ /** Finds the result a user means: by 12-char handle, or by id when that id is unique. */
141
+ export function findTarget(query, rules, results) {
142
+ const pairs = results
143
+ .map((result) => ({ result, rule: rules.find((ru) => ru.source === result.ruleSource && ru.id === result.ruleId && ru.title === result.ruleTitle) }))
144
+ .filter((p) => Boolean(p.rule));
145
+ const q = query.trim();
146
+ const byHandle = pairs.filter((p) => ruleFingerprint(p.rule) === q || (q.length >= 6 && ruleFingerprint(p.rule).startsWith(q)));
147
+ if (byHandle.length === 1)
148
+ return byHandle[0];
149
+ const byId = pairs.filter((p) => p.result.ruleId === q);
150
+ if (byId.length === 1)
151
+ return byId[0];
152
+ const many = byHandle.length > 1 ? byHandle : byId;
153
+ if (many.length > 1)
154
+ return { ambiguous: many.map((p) => ({ handle: ruleFingerprint(p.rule), title: p.rule.title })) };
155
+ return null;
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.59",
3
+ "version": "0.1.60",
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",