rulereceipt 0.1.58 → 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/README.md +24 -0
- package/dist/audit.d.ts +29 -0
- package/dist/audit.js +203 -0
- package/dist/checks/approvalGate.d.ts +42 -1
- package/dist/checks/approvalGate.js +128 -84
- package/dist/checks/classify.d.ts +13 -0
- package/dist/checks/classify.js +43 -14
- package/dist/checks/pathScope.d.ts +5 -0
- package/dist/checks/pathScope.js +93 -0
- package/dist/cli.js +74 -77
- package/dist/evaluate.js +52 -3
- package/dist/guard.d.ts +10 -2
- package/dist/guard.js +45 -5
- package/dist/parsers/claudeMdParser.js +19 -1
- package/dist/parsers/readClaudeMd.d.ts +11 -0
- package/dist/parsers/readClaudeMd.js +48 -1
- package/dist/parsers/transcriptParser.js +27 -1
- package/dist/report/generateHtmlReport.js +9 -4
- package/dist/rules.d.ts +49 -0
- package/dist/rules.js +138 -52
- package/dist/types.d.ts +16 -0
- package/dist/wrong.d.ts +57 -0
- package/dist/wrong.js +156 -0
- package/package.json +1 -1
|
@@ -36,6 +36,51 @@ function stripFrontmatter(raw) {
|
|
|
36
36
|
const after = raw.indexOf("\n", end + 1);
|
|
37
37
|
return after === -1 ? "" : raw.slice(after + 1);
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Reads the path scope out of a leading frontmatter block, if it has one.
|
|
41
|
+
*
|
|
42
|
+
* Shapes seen in 343 real rule files (2026-09-28): `paths:` as a YAML list
|
|
43
|
+
* or inline array (Claude Code), `globs:` as inline array, comma list, bare
|
|
44
|
+
* scalar or YAML list (Cursor, agy). `alwaysApply: true` (Cursor) and
|
|
45
|
+
* `trigger: always_on` (agy) mean the file is always loaded whatever its
|
|
46
|
+
* globs say, so they clear the scope. Anything unreadable returns undefined,
|
|
47
|
+
* which means "always loaded": the pre-existing behaviour.
|
|
48
|
+
*/
|
|
49
|
+
export function readPathScope(raw) {
|
|
50
|
+
if (!/^---\r?\n/.test(raw))
|
|
51
|
+
return undefined;
|
|
52
|
+
const end = raw.indexOf("\n---", 3);
|
|
53
|
+
if (end === -1)
|
|
54
|
+
return undefined;
|
|
55
|
+
const lines = raw.slice(raw.indexOf("\n") + 1, end).split(/\r?\n/);
|
|
56
|
+
if (lines.some((l) => /^alwaysApply:\s*true\b/i.test(l) || /^trigger:\s*always_on\b/i.test(l)))
|
|
57
|
+
return undefined;
|
|
58
|
+
const unquote = (v) => v.trim().replace(/^["']|["']$/g, "").trim();
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const m = lines[i].match(/^(paths|globs):\s*(.*)$/);
|
|
61
|
+
if (!m)
|
|
62
|
+
continue;
|
|
63
|
+
const value = m[2].trim();
|
|
64
|
+
let items = [];
|
|
65
|
+
if (value.startsWith("[")) {
|
|
66
|
+
items = value.replace(/^\[|\]$/g, "").split(",").map(unquote);
|
|
67
|
+
}
|
|
68
|
+
else if (value.length > 0) {
|
|
69
|
+
items = value.split(",").map(unquote);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
for (let j = i + 1; j < lines.length && /^\s*-\s+/.test(lines[j]); j++) {
|
|
73
|
+
items.push(unquote(lines[j].replace(/^\s*-\s+/, "")));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
items = items.filter((x) => x.length > 0);
|
|
77
|
+
// `**/*` or `*` scopes to everything: same as no scope.
|
|
78
|
+
if (items.length === 0 || items.some((x) => x === "**/*" || x === "**" || x === "*"))
|
|
79
|
+
return undefined;
|
|
80
|
+
return items;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
39
84
|
export function parseClaudeMd(filePath, source) {
|
|
40
85
|
let raw;
|
|
41
86
|
try {
|
|
@@ -44,5 +89,7 @@ export function parseClaudeMd(filePath, source) {
|
|
|
44
89
|
catch {
|
|
45
90
|
return [];
|
|
46
91
|
}
|
|
47
|
-
|
|
92
|
+
const rules = parseClaudeMdText(stripFrontmatter(raw), source);
|
|
93
|
+
const paths = readPathScope(raw);
|
|
94
|
+
return paths ? rules.map((r) => ({ ...r, paths })) : rules;
|
|
48
95
|
}
|
|
@@ -136,6 +136,18 @@ export function parseLine(line) {
|
|
|
136
136
|
if (typeof block !== "object" || block === null)
|
|
137
137
|
continue;
|
|
138
138
|
const b = block;
|
|
139
|
+
// User text sent as blocks (VS Code / IDE extension, or any message with
|
|
140
|
+
// an image) was dropped until 2026-09-28: in real sessions whole
|
|
141
|
+
// conversations had no user message at all, so every check that reads
|
|
142
|
+
// what the user said saw nothing. Harness-injected context wrapped in
|
|
143
|
+
// tags (<ide_opened_file>, <system-reminder>, …) is not the user
|
|
144
|
+
// speaking and is stripped; what remains is.
|
|
145
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
146
|
+
const said = b.text.replace(/<([a-z][\w-]*)>[\s\S]*?<\/\1>/gi, "").trim();
|
|
147
|
+
if (said.length > 0)
|
|
148
|
+
events.push({ role: "user", kind: "text", text: said, timestamp });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
139
151
|
if (b.type === "tool_result") {
|
|
140
152
|
events.push({
|
|
141
153
|
role: "user",
|
|
@@ -160,11 +172,25 @@ export function parseLine(line) {
|
|
|
160
172
|
export function readTranscriptFromFile(filePath) {
|
|
161
173
|
const raw = readFileSync(filePath, "utf-8");
|
|
162
174
|
const events = [];
|
|
175
|
+
// Permission mode is recorded on user turns (and on `permission-mode` entries
|
|
176
|
+
// in newer versions); it applies to the tool calls that follow. The approval
|
|
177
|
+
// check needs it to tell "a prompt may have been approved" (default/
|
|
178
|
+
// acceptEdits/plan) from "no person was asked" (bypassPermissions/dontAsk/auto).
|
|
179
|
+
let mode;
|
|
163
180
|
for (const line of raw.split("\n")) {
|
|
164
181
|
if (!line.trim())
|
|
165
182
|
continue;
|
|
166
183
|
try {
|
|
167
|
-
|
|
184
|
+
const m = line.match(/"(?:permissionMode|permission_mode)":"([A-Za-z]+)"/) ??
|
|
185
|
+
(line.includes('"permission-mode"') ? line.match(/"mode":"([A-Za-z]+)"/) : null);
|
|
186
|
+
if (m)
|
|
187
|
+
mode = m[1];
|
|
188
|
+
const parsed = parseLine(line);
|
|
189
|
+
if (mode)
|
|
190
|
+
for (const e of parsed)
|
|
191
|
+
if (e.kind === "tool_use")
|
|
192
|
+
e.permissionMode = mode;
|
|
193
|
+
events.push(...parsed);
|
|
168
194
|
}
|
|
169
195
|
catch {
|
|
170
196
|
// One malformed/unexpected line must not crash the whole check —
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { minimalIssueUrl } from "../wrong.js";
|
|
1
2
|
import { basename } from "node:path";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { computeTranscriptHash } from "./generateReport.js";
|
|
@@ -92,7 +93,7 @@ function ruleLabel(result, all) {
|
|
|
92
93
|
function countBy(results, status) {
|
|
93
94
|
return results.filter((r) => r.status === status).length;
|
|
94
95
|
}
|
|
95
|
-
function renderResultRow(result, all, hideEvidence) {
|
|
96
|
+
function renderResultRow(result, all, hideEvidence, version = "") {
|
|
96
97
|
const bucket = bucketOf(result);
|
|
97
98
|
const cls = BUCKET_CLASS[bucket];
|
|
98
99
|
const evidence = !hideEvidence && result.evidence
|
|
@@ -106,6 +107,9 @@ function renderResultRow(result, all, hideEvidence) {
|
|
|
106
107
|
</div>
|
|
107
108
|
<h3 class="result__title">${clean(result.ruleTitle)}</h3>
|
|
108
109
|
${evidence}
|
|
110
|
+
${version && (result.status === "FAIL" || result.status === "PASS")
|
|
111
|
+
? `<p class="result__wrong"><a href="${clean(minimalIssueUrl(result, version))}" rel="noopener noreferrer">Verdict wrong? Report it</a> (for the full report, run <code>rulereceipt wrong</code> on the machine that ran the check)</p>`
|
|
112
|
+
: ""}
|
|
109
113
|
</article>`;
|
|
110
114
|
}
|
|
111
115
|
/**
|
|
@@ -129,7 +133,7 @@ function sharedEvidence(rs) {
|
|
|
129
133
|
return null;
|
|
130
134
|
return rs.every((r) => r.evidence === first) ? first : null;
|
|
131
135
|
}
|
|
132
|
-
function renderSection(bucket, results, all) {
|
|
136
|
+
function renderSection(bucket, results, all, version = "") {
|
|
133
137
|
const inSection = results.filter((r) => bucketOf(r) === bucket);
|
|
134
138
|
if (inSection.length === 0)
|
|
135
139
|
return "";
|
|
@@ -143,7 +147,7 @@ function renderSection(bucket, results, all) {
|
|
|
143
147
|
<h2 class="section__title">${clean(BUCKET_LABEL[bucket])} <span class="section__count">${inSection.length}</span></h2>
|
|
144
148
|
${note}
|
|
145
149
|
${sharedBlock}
|
|
146
|
-
${inSection.map((r) => renderResultRow(r, all, shared !== null)).join("")}
|
|
150
|
+
${inSection.map((r) => renderResultRow(r, all, shared !== null, version)).join("")}
|
|
147
151
|
</section>`;
|
|
148
152
|
}
|
|
149
153
|
/**
|
|
@@ -232,6 +236,7 @@ export function generateHtmlReport(results, meta) {
|
|
|
232
236
|
.section__shared { font-size: 13px; color: var(--muted); margin: 0 0 14px; padding: 10px 12px; border-left: 2px solid var(--line); background: var(--panel); border-radius: 0 6px 6px 0; white-space: pre-wrap; }
|
|
233
237
|
.result__id { font-size: 12px; color: var(--muted); }
|
|
234
238
|
.result__title { font-size: 15px; margin: 0 0 6px; font-weight: 600; }
|
|
239
|
+
.result__wrong { margin: 6px 0 0; font-size: 12px; color: var(--muted); }
|
|
235
240
|
.result__evidence { margin: 0; font-size: 14px; color: var(--muted); white-space: pre-wrap; }
|
|
236
241
|
.note { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px 18px; font-size: 13.5px; color: var(--muted); }
|
|
237
242
|
.note h2 { font-size: 13px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--ink); margin: 0 0 10px; }
|
|
@@ -271,7 +276,7 @@ export function generateHtmlReport(results, meta) {
|
|
|
271
276
|
<tr><th>Tool version</th><td><code>rulereceipt ${clean(meta.toolVersion)}</code></td></tr>
|
|
272
277
|
</table>
|
|
273
278
|
|
|
274
|
-
${BUCKET_ORDER.map((b) => renderSection(b, results, results)).join("")}
|
|
279
|
+
${BUCKET_ORDER.map((b) => renderSection(b, results, results, meta.toolVersion)).join("")}
|
|
275
280
|
|
|
276
281
|
<div class="note">
|
|
277
282
|
<h2>How to read this report</h2>
|
package/dist/rules.d.ts
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import type { Rule } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* One candidate rules file, and whether the agent would actually load it.
|
|
4
|
+
*
|
|
5
|
+
* `loaded` files are what the tool checks a session against. `shadowed` files
|
|
6
|
+
* exist on disk but the agent ignores them (a CLAUDE.md at the same level wins
|
|
7
|
+
* over AGENTS.md; the modern .cursor/rules directory supersedes .cursorrules; a
|
|
8
|
+
* manual/model_decision .agents/rules file is not auto-loaded). Checking a
|
|
9
|
+
* session against a shadowed file would be a false accusation, so they are
|
|
10
|
+
* discovered but never fed to the checker — the load graph reports them so the
|
|
11
|
+
* "why isn't my rule firing?" question has an honest answer.
|
|
12
|
+
*/
|
|
13
|
+
export type RuleSourceStatus = "loaded" | "shadowed";
|
|
14
|
+
export interface RuleSource {
|
|
15
|
+
/** Absolute path on disk. */
|
|
16
|
+
path: string;
|
|
17
|
+
status: RuleSourceStatus;
|
|
18
|
+
/** Human label for the format/convention, e.g. "Claude (CLAUDE.md)". */
|
|
19
|
+
format: string;
|
|
20
|
+
/** Why a shadowed file is ignored; undefined for loaded files. */
|
|
21
|
+
note?: string;
|
|
22
|
+
}
|
|
2
23
|
/**
|
|
3
24
|
* Global rules come from every .claude*-prefixed home dir found, not just
|
|
4
25
|
* ~/.claude — a hosted/enterprise Claude Code variant can keep its own
|
|
@@ -17,3 +38,31 @@ import type { Rule } from "./types.js";
|
|
|
17
38
|
* this project has already been bitten by twice.
|
|
18
39
|
*/
|
|
19
40
|
export declare function loadRules(cwd: string): Rule[];
|
|
41
|
+
/** One row of the load graph: a rules file and whether the agent loads it. */
|
|
42
|
+
export interface LoadGraphEntry {
|
|
43
|
+
/** Absolute path on disk. */
|
|
44
|
+
path: string;
|
|
45
|
+
scope: "global" | "project";
|
|
46
|
+
status: RuleSourceStatus;
|
|
47
|
+
format: string;
|
|
48
|
+
/** Why a shadowed file is ignored; undefined for loaded files. */
|
|
49
|
+
note?: string;
|
|
50
|
+
/**
|
|
51
|
+
* How many rules the file parses to. For a loaded file this is what the
|
|
52
|
+
* checker uses; for a shadowed file it is how many rules are being IGNORED,
|
|
53
|
+
* which is the number worth showing ("AGENTS.md — 5 rules not applied").
|
|
54
|
+
*/
|
|
55
|
+
ruleCount: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The load graph: every candidate rules file the discovery walk sees, in the
|
|
59
|
+
* same order and with the same dedup as `loadRules`, tagged loaded or shadowed
|
|
60
|
+
* and counted. This is what `audit` prints so "why isn't my rule firing?" has
|
|
61
|
+
* an honest, file-level answer — built on the SAME `ruleSourcesAtLevel` the
|
|
62
|
+
* checker's own discovery uses, so the graph can never claim a file was loaded
|
|
63
|
+
* that the checker skipped.
|
|
64
|
+
*
|
|
65
|
+
* Memory rules are deliberately NOT listed here: this graph is about files on
|
|
66
|
+
* disk a user can point at, and memory is summarised separately by the caller.
|
|
67
|
+
*/
|
|
68
|
+
export declare function describeRuleSources(cwd: string): LoadGraphEntry[];
|
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
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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)
|
|
77
|
-
//
|
|
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
|
|
86
|
-
//
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
110
|
+
loaded(join(".github", "copilot-instructions.md"), "Copilot");
|
|
95
111
|
// Windsurf (Codeium): single rules file.
|
|
96
|
-
|
|
97
|
-
// Google's newer agent convention
|
|
98
|
-
//
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
return
|
|
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
|
|
120
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
@@ -3,6 +3,14 @@ export interface Rule {
|
|
|
3
3
|
title: string;
|
|
4
4
|
text: string;
|
|
5
5
|
source: "global" | "project";
|
|
6
|
+
/**
|
|
7
|
+
* Path scope from the rules file's frontmatter (`paths:` in Claude Code
|
|
8
|
+
* `.claude/rules/*.md`, `globs:` in Cursor `.mdc` / agy `.agents/rules`).
|
|
9
|
+
* Claude Code only loads a path-scoped rule once the session touches a
|
|
10
|
+
* matching file, so judging it in a session that never did checks Claude
|
|
11
|
+
* against a rule it was never shown. Absent = always loaded.
|
|
12
|
+
*/
|
|
13
|
+
paths?: string[];
|
|
6
14
|
}
|
|
7
15
|
export interface TranscriptTextEvent {
|
|
8
16
|
role: "user" | "assistant";
|
|
@@ -27,6 +35,14 @@ export interface TranscriptToolUseEvent {
|
|
|
27
35
|
* the test run that a completion claim depended on.
|
|
28
36
|
*/
|
|
29
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;
|
|
30
46
|
}
|
|
31
47
|
export interface TranscriptToolResultEvent {
|
|
32
48
|
role: "user";
|
package/dist/wrong.d.ts
ADDED
|
@@ -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;
|