rulereceipt 0.1.87 → 0.1.88
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/adapters/codex.js +39 -13
- package/dist/adapters/index.d.ts +1 -1
- package/dist/adapters/index.js +1 -1
- package/dist/audit.js +4 -3
- package/dist/checks/claimEvidence.js +7 -1
- package/dist/cli.js +5 -7
- package/dist/parsers/readMemory.d.ts +11 -0
- package/dist/parsers/readMemory.js +27 -6
- package/dist/parsers/transcriptParser.d.ts +14 -4
- package/dist/parsers/transcriptParser.js +36 -41
- package/dist/rules.d.ts +5 -5
- package/dist/rules.js +131 -14
- package/package.json +1 -1
package/dist/adapters/codex.js
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import * as zlib from "node:zlib";
|
|
5
|
+
/**
|
|
6
|
+
* Codex stores rollouts as plain `rollout-*.jsonl` and, once a session is
|
|
7
|
+
* compacted/paginated, Zstandard-compressed `rollout-*.jsonl.zst`. Reading the
|
|
8
|
+
* compressed form needs `node:zlib`'s zstd support, added in Node 22.15 / 23.8.
|
|
9
|
+
* On an older Node it is simply absent — we skip those files with one note
|
|
10
|
+
* rather than crash. (The package's floor is Node 20.)
|
|
11
|
+
*/
|
|
12
|
+
const zstdDecompressSync = zlib.zstdDecompressSync;
|
|
13
|
+
let warnedNoZstd = false;
|
|
14
|
+
/**
|
|
15
|
+
* Read a rollout file as text, decompressing a `.jsonl.zst` with zstd. Returns
|
|
16
|
+
* null when the file can't be read — unreadable on disk, or compressed on a Node
|
|
17
|
+
* without zstd (noted once). Callers treat null as "no events / no cwd", never a
|
|
18
|
+
* crash.
|
|
19
|
+
*/
|
|
20
|
+
function readRolloutText(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
if (filePath.endsWith(".zst")) {
|
|
23
|
+
if (!zstdDecompressSync) {
|
|
24
|
+
if (!warnedNoZstd) {
|
|
25
|
+
warnedNoZstd = true;
|
|
26
|
+
process.stderr.write("rulereceipt: compressed Codex rollouts (.jsonl.zst) need Node >= 22.15 for zstd; skipping them. Upgrade Node to include them.\n");
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return zstdDecompressSync(readFileSync(filePath)).toString("utf-8");
|
|
31
|
+
}
|
|
32
|
+
return readFileSync(filePath, "utf-8");
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
4
38
|
/**
|
|
5
39
|
* OpenAI Codex CLI session adapter.
|
|
6
40
|
*
|
|
@@ -193,13 +227,9 @@ export function parseCodexLine(line) {
|
|
|
193
227
|
return []; // reasoning, unknown payload types: ignored, not guessed at
|
|
194
228
|
}
|
|
195
229
|
export function parseCodexTranscript(filePath) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
raw = readFileSync(filePath, "utf-8");
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
230
|
+
const raw = readRolloutText(filePath);
|
|
231
|
+
if (raw === null)
|
|
201
232
|
return [];
|
|
202
|
-
}
|
|
203
233
|
const events = [];
|
|
204
234
|
for (const line of raw.split("\n")) {
|
|
205
235
|
if (!line.trim())
|
|
@@ -210,13 +240,9 @@ export function parseCodexTranscript(filePath) {
|
|
|
210
240
|
}
|
|
211
241
|
/** The cwd a rollout file was recorded in, from its first `session_meta` line. */
|
|
212
242
|
function sessionCwd(filePath) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
raw = readFileSync(filePath, "utf-8");
|
|
216
|
-
}
|
|
217
|
-
catch {
|
|
243
|
+
const raw = readRolloutText(filePath);
|
|
244
|
+
if (raw === null)
|
|
218
245
|
return null;
|
|
219
|
-
}
|
|
220
246
|
const firstLine = raw.split("\n", 1)[0];
|
|
221
247
|
if (!firstLine)
|
|
222
248
|
return null;
|
|
@@ -249,7 +275,7 @@ function collectRolloutFiles(dir, out) {
|
|
|
249
275
|
const full = join(dir, entry.name);
|
|
250
276
|
if (entry.isDirectory())
|
|
251
277
|
collectRolloutFiles(full, out);
|
|
252
|
-
else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl"))
|
|
278
|
+
else if (entry.isFile() && entry.name.startsWith("rollout-") && (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.zst")))
|
|
253
279
|
out.push(full);
|
|
254
280
|
}
|
|
255
281
|
}
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -67,7 +67,7 @@ export interface LatestSession {
|
|
|
67
67
|
/**
|
|
68
68
|
* The single most recently modified session across ALL supported tools for
|
|
69
69
|
* this cwd — the same "newest wins" rule the Claude reader already uses across
|
|
70
|
-
*
|
|
70
|
+
* every configured Claude home, now extended across tools. Returns null only
|
|
71
71
|
* when no supported tool has a session for this project (the caller then asks
|
|
72
72
|
* or reports "no session found").
|
|
73
73
|
*/
|
package/dist/adapters/index.js
CHANGED
|
@@ -76,7 +76,7 @@ export const UNSUPPORTED_TOOLS = [
|
|
|
76
76
|
/**
|
|
77
77
|
* The single most recently modified session across ALL supported tools for
|
|
78
78
|
* this cwd — the same "newest wins" rule the Claude reader already uses across
|
|
79
|
-
*
|
|
79
|
+
* every configured Claude home, now extended across tools. Returns null only
|
|
80
80
|
* when no supported tool has a session for this project (the caller then asks
|
|
81
81
|
* or reports "no session found").
|
|
82
82
|
*/
|
package/dist/audit.js
CHANGED
|
@@ -302,13 +302,14 @@ export function renderProjectAudit(pa, md = false) {
|
|
|
302
302
|
}
|
|
303
303
|
else {
|
|
304
304
|
for (const g of loaded) {
|
|
305
|
-
|
|
305
|
+
// Memory and subfolder rows now come through the load graph itself, so
|
|
306
|
+
// they are listed here like any other source (no separate memory line).
|
|
307
|
+
const scoped = g.note ? ` · ${g.note}` : "";
|
|
308
|
+
out.push(` loaded ${g.format} · ${g.ruleCount} rule${g.ruleCount === 1 ? "" : "s"}${scoped} (${g.path})`);
|
|
306
309
|
}
|
|
307
310
|
for (const g of shadowed) {
|
|
308
311
|
out.push(` ignored ${g.format} · ${g.note} (${g.path})`);
|
|
309
312
|
}
|
|
310
|
-
if (pa.memoryRules > 0)
|
|
311
|
-
out.push(` loaded Claude memory · ${pa.memoryRules} rule${pa.memoryRules === 1 ? "" : "s"}`);
|
|
312
313
|
}
|
|
313
314
|
out.push("");
|
|
314
315
|
if (pa.checkable + pa.judgment > 0) {
|
|
@@ -124,7 +124,13 @@ const ACTION_CLAIMS = [
|
|
|
124
124
|
// having read a source (finding #7, 2026-09-26). "PAGES READ: <n>" and
|
|
125
125
|
// "STATUS: READ IN FULL" are the real provenance forms and still count.
|
|
126
126
|
claim: /\b(?:i|we)(?:'ve|’ve| have| had)?\s+(?:\w+ly\s+|just\s+|already\s+|then\s+|also\s+|now\s+)*read\b|^\s*pages?\s+read\s*:\s*(?:[\d\s,-]+|read\s+in\s+full)|^\s*status\s*:\s*read\s+in\s+full|\bread\s+in\s+full\b|\bconfirmed\s+at\s+source\b/im,
|
|
127
|
-
|
|
127
|
+
// The future tense makes a read a plan, not a claim. Beyond the explicit
|
|
128
|
+
// modals, a near-future TIME expression ("in a couple minutes", "shortly")
|
|
129
|
+
// is the same signal written in present tense: "download it and I read it
|
|
130
|
+
// in a couple minutes and tell you" is a plan. Found dogfooding 2026-10-03
|
|
131
|
+
// — it fired on a casual planning message and, via the shared fabricated
|
|
132
|
+
// state, FAILed three unrelated rules at once (claimEvidenceFutureRead.test).
|
|
133
|
+
exclude: /\b(?:will|going\s+to|need\s+to|should|next|plan\s+to|about\s+to|let\s+me|i'?ll|we'?ll)\s+(?:\w+\s+){0,3}read\b|\bin\s+(?:a\s+)?(?:couple|few|several)?\s*(?:of\s+)?(?:minutes?|mins?|moments?|seconds?|secs?|hours?|a\s+(?:minute|moment|bit|sec|second|while))\b|\b(?:shortly|momentarily|in\s+a\s+bit)\b/i,
|
|
128
134
|
command: /\b(?:cat|head|tail|less|more|bat|nl|strings|pdftotext|xxd|od)\b/i,
|
|
129
135
|
},
|
|
130
136
|
];
|
package/dist/cli.js
CHANGED
|
@@ -194,13 +194,11 @@ async function runCheck(opts) {
|
|
|
194
194
|
return;
|
|
195
195
|
}
|
|
196
196
|
// --transcript is a manual escape hatch for any layout auto-detection
|
|
197
|
-
// doesn't cover (a
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
// newest-modified wins — the same rule the Claude reader already applies
|
|
203
|
-
// across .claude vs .claude-office, now extended across tools. A
|
|
197
|
+
// doesn't cover (e.g. a hosted/enterprise Claude Code variant writing to a
|
|
198
|
+
// non-standard home; configure it via RULERECEIPT_CLAUDE_HOMES, or point this
|
|
199
|
+
// flag straight at the file). Auto-detect the session across every supported
|
|
200
|
+
// tool (Claude Code, Codex), newest-modified wins — the same rule the Claude
|
|
201
|
+
// reader applies across every configured home, now extended across tools. A
|
|
204
202
|
// Claude-only machine picks exactly the file and events it always did.
|
|
205
203
|
const latestSession = transcriptOverride ? null : findLatestSession(cwd);
|
|
206
204
|
const sessionFilePath = transcriptOverride ?? latestSession?.file ?? null;
|
|
@@ -1,2 +1,13 @@
|
|
|
1
1
|
import type { Rule } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The memory source for the load graph: the first existing non-office memory
|
|
4
|
+
* dir for this project, and how many rules load from memory in total. Returns
|
|
5
|
+
* null when memory contributes no rules. Lets `describeRuleSources` list memory
|
|
6
|
+
* so the load graph matches what `loadRules` actually checks (memory was
|
|
7
|
+
* omitted before — a reporting gap found 2026-10-03, not a checking gap).
|
|
8
|
+
*/
|
|
9
|
+
export declare function memoryGraphEntry(cwd: string): {
|
|
10
|
+
path: string;
|
|
11
|
+
ruleCount: number;
|
|
12
|
+
} | null;
|
|
2
13
|
export declare function loadMemoryRules(cwd: string): Rule[];
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
2
|
import { join, basename } from "node:path";
|
|
4
|
-
import {
|
|
3
|
+
import { claudeHomes } from "./transcriptParser.js";
|
|
5
4
|
/**
|
|
6
5
|
* Claude Code's memory as a rule source.
|
|
7
6
|
*
|
|
@@ -49,14 +48,36 @@ function parseMemoryFile(raw) {
|
|
|
49
48
|
const description = front.match(/^\s*description:\s*(.+)$/im)?.[1]?.trim() ?? null;
|
|
50
49
|
return { type, name, description, body };
|
|
51
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* The memory source for the load graph: the first existing non-office memory
|
|
53
|
+
* dir for this project, and how many rules load from memory in total. Returns
|
|
54
|
+
* null when memory contributes no rules. Lets `describeRuleSources` list memory
|
|
55
|
+
* so the load graph matches what `loadRules` actually checks (memory was
|
|
56
|
+
* omitted before — a reporting gap found 2026-10-03, not a checking gap).
|
|
57
|
+
*/
|
|
58
|
+
export function memoryGraphEntry(cwd) {
|
|
59
|
+
const ruleCount = loadMemoryRules(cwd).length;
|
|
60
|
+
if (ruleCount === 0)
|
|
61
|
+
return null;
|
|
62
|
+
const encoded = cwd.replace(/\//g, "-");
|
|
63
|
+
for (const base of claudeHomes()) {
|
|
64
|
+
const memoryDir = join(base, "projects", encoded, "memory");
|
|
65
|
+
try {
|
|
66
|
+
if (statSync(memoryDir).isDirectory())
|
|
67
|
+
return { path: memoryDir, ruleCount };
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* no memory dir under this home: try the next */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
52
75
|
export function loadMemoryRules(cwd) {
|
|
53
76
|
const rules = [];
|
|
54
77
|
const seenIds = new Set();
|
|
55
78
|
const encoded = cwd.replace(/\//g, "-");
|
|
56
|
-
for (const
|
|
57
|
-
|
|
58
|
-
continue; // never office (project rule)
|
|
59
|
-
const memoryDir = join(homedir(), dirName, "projects", encoded, "memory");
|
|
79
|
+
for (const base of claudeHomes()) {
|
|
80
|
+
const memoryDir = join(base, "projects", encoded, "memory");
|
|
60
81
|
let files;
|
|
61
82
|
try {
|
|
62
83
|
if (!statSync(memoryDir).isDirectory())
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import type { TranscriptEvent } from "../types.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Absolute paths of every Claude-Code home to search.
|
|
4
|
+
*
|
|
5
|
+
* The standard home `~/.claude`, plus `CLAUDE_CONFIG_DIR` (Claude Code's own
|
|
6
|
+
* override), plus `RULERECEIPT_CLAUDE_HOMES` for anyone running a non-standard
|
|
7
|
+
* layout — both comma-separated, absolute or relative-to-home.
|
|
8
|
+
*
|
|
9
|
+
* It used to glob every `~/.claude*` directory, which swept in whatever extra
|
|
10
|
+
* homes happened to exist on the machine — including a separate or employer home
|
|
11
|
+
* the user never meant the tool to read. That also baked one machine's folder
|
|
12
|
+
* names into the shipped tool. Discovery is now opt-in:
|
|
13
|
+
* nothing beyond `~/.claude` is read unless the user names it. A hosted or
|
|
14
|
+
* enterprise variant on a different home is supported by setting
|
|
15
|
+
* `RULERECEIPT_CLAUDE_HOMES` (or `CLAUDE_CONFIG_DIR`), rather than by guessing.
|
|
6
16
|
*/
|
|
7
|
-
export declare function
|
|
17
|
+
export declare function claudeHomes(): string[];
|
|
8
18
|
/**
|
|
9
19
|
* Every session file that belongs to this project, newest first.
|
|
10
20
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync, realpathSync, existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, dirname, basename, sep } from "node:path";
|
|
3
|
+
import { join, dirname, basename, sep, isAbsolute } from "node:path";
|
|
4
4
|
import { parseTranscriptText } from "./transcriptLine.js";
|
|
5
5
|
/**
|
|
6
6
|
* Claude Code stores each session as a JSONL file at:
|
|
@@ -17,20 +17,13 @@ import { parseTranscriptText } from "./transcriptLine.js";
|
|
|
17
17
|
* - some assistant entries are API error stubs (isApiErrorMessage: true)
|
|
18
18
|
* with no real content — skip these.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Hardcoding ".claude-office" specifically would only fix THIS machine's
|
|
28
|
-
* naming — a different org's hosted variant could use any name. Instead,
|
|
29
|
-
* every directory directly under the home dir that starts with ".claude"
|
|
30
|
-
* and has a matching projects/<encoded-cwd> tree is treated as a
|
|
31
|
-
* candidate, and the overall latest file across all of them wins. This
|
|
32
|
-
* generalizes to variants never seen on this machine, at the cost of one
|
|
33
|
-
* extra readdir() of the home directory per check — negligible.
|
|
20
|
+
* A hosted/enterprise Claude Code variant can write its sessions under a
|
|
21
|
+
* non-standard home instead of ~/.claude/projects/... — same directory-encoding
|
|
22
|
+
* convention, same file format, different root. Those homes are searched when
|
|
23
|
+
* the user names them (CLAUDE_CONFIG_DIR or RULERECEIPT_CLAUDE_HOMES; see
|
|
24
|
+
* claudeHomes), and the overall latest file across every configured home wins.
|
|
25
|
+
* Discovery is opt-in rather than guessed from whatever dirs exist on the
|
|
26
|
+
* machine (see claudeHomes for why).
|
|
34
27
|
*/
|
|
35
28
|
/**
|
|
36
29
|
* Claude Code names the per-project directory by mangling the cwd, but the exact
|
|
@@ -93,19 +86,36 @@ function sessionCwdOf(sessionFile) {
|
|
|
93
86
|
function looksLikeProjectRoot(cwd) {
|
|
94
87
|
return [".git", "CLAUDE.md", "AGENTS.md", "GEMINI.md", ".claude", ".cursor", ".github/copilot-instructions.md"].some((marker) => existsSync(join(cwd, marker)));
|
|
95
88
|
}
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Absolute paths of every Claude-Code home to search.
|
|
91
|
+
*
|
|
92
|
+
* The standard home `~/.claude`, plus `CLAUDE_CONFIG_DIR` (Claude Code's own
|
|
93
|
+
* override), plus `RULERECEIPT_CLAUDE_HOMES` for anyone running a non-standard
|
|
94
|
+
* layout — both comma-separated, absolute or relative-to-home.
|
|
95
|
+
*
|
|
96
|
+
* It used to glob every `~/.claude*` directory, which swept in whatever extra
|
|
97
|
+
* homes happened to exist on the machine — including a separate or employer home
|
|
98
|
+
* the user never meant the tool to read. That also baked one machine's folder
|
|
99
|
+
* names into the shipped tool. Discovery is now opt-in:
|
|
100
|
+
* nothing beyond `~/.claude` is read unless the user names it. A hosted or
|
|
101
|
+
* enterprise variant on a different home is supported by setting
|
|
102
|
+
* `RULERECEIPT_CLAUDE_HOMES` (or `CLAUDE_CONFIG_DIR`), rather than by guessing.
|
|
103
|
+
*/
|
|
104
|
+
export function claudeHomes() {
|
|
105
|
+
const out = new Set();
|
|
106
|
+
out.add(join(homedir(), ".claude"));
|
|
107
|
+
const add = (list) => {
|
|
108
|
+
if (!list)
|
|
109
|
+
return;
|
|
110
|
+
for (const part of list.split(",")) {
|
|
104
111
|
const p = part.trim();
|
|
105
112
|
if (p)
|
|
106
|
-
|
|
113
|
+
out.add(isAbsolute(p) ? p : join(homedir(), p));
|
|
107
114
|
}
|
|
108
|
-
|
|
115
|
+
};
|
|
116
|
+
add(process.env.CLAUDE_CONFIG_DIR);
|
|
117
|
+
add(process.env.RULERECEIPT_CLAUDE_HOMES);
|
|
118
|
+
return [...out];
|
|
109
119
|
}
|
|
110
120
|
function listSessionFiles(projectDir) {
|
|
111
121
|
let entries;
|
|
@@ -127,21 +137,6 @@ function listSessionFiles(projectDir) {
|
|
|
127
137
|
}
|
|
128
138
|
});
|
|
129
139
|
}
|
|
130
|
-
/**
|
|
131
|
-
* Also used for the global CLAUDE.md lookup (src/cli.ts) — the same
|
|
132
|
-
* ".claude vs .claude-office" gap applies there too: a hosted/enterprise
|
|
133
|
-
* variant could keep its own global rules file under its own home dir.
|
|
134
|
-
*/
|
|
135
|
-
export function findClaudeHomeDirNames() {
|
|
136
|
-
try {
|
|
137
|
-
return readdirSync(homedir(), { withFileTypes: true })
|
|
138
|
-
.filter((entry) => entry.isDirectory() && entry.name.startsWith(".claude"))
|
|
139
|
-
.map((entry) => entry.name);
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
return [];
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
140
|
/**
|
|
146
141
|
* Every session file that belongs to this project, newest first.
|
|
147
142
|
*
|
|
@@ -158,7 +153,7 @@ export function listAllSessionFiles(cwd) {
|
|
|
158
153
|
const allowDescendants = looksLikeProjectRoot(cwd);
|
|
159
154
|
const files = [];
|
|
160
155
|
const seen = new Set();
|
|
161
|
-
for (const home of
|
|
156
|
+
for (const home of claudeHomes()) {
|
|
162
157
|
const projectsDir = join(home, "projects");
|
|
163
158
|
let folders;
|
|
164
159
|
try {
|
package/dist/rules.d.ts
CHANGED
|
@@ -21,11 +21,11 @@ export interface RuleSource {
|
|
|
21
21
|
note?: string;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* Global rules come from every
|
|
25
|
-
* ~/.claude
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* Global rules come from every configured Claude home (see claudeHomes): the
|
|
25
|
+
* standard ~/.claude, plus any the user names via CLAUDE_CONFIG_DIR or
|
|
26
|
+
* RULERECEIPT_CLAUDE_HOMES — so a hosted/enterprise variant with its own global
|
|
27
|
+
* CLAUDE.md is supported when the user points at it, rather than by scanning
|
|
28
|
+
* whatever ~/.claude* dirs happen to exist on the machine.
|
|
29
29
|
*
|
|
30
30
|
* Also reads ~/.claude/rules/*.md, the documented location for personal
|
|
31
31
|
* rules that apply across every project.
|
package/dist/rules.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { dirname, join, parse, resolve } from "node:path";
|
|
2
|
+
import { dirname, join, parse, relative, resolve } from "node:path";
|
|
3
3
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { parseClaudeMd } from "./parsers/readClaudeMd.js";
|
|
5
|
-
import {
|
|
6
|
-
import { loadMemoryRules } from "./parsers/readMemory.js";
|
|
5
|
+
import { claudeHomes } from "./parsers/transcriptParser.js";
|
|
6
|
+
import { loadMemoryRules, memoryGraphEntry } from "./parsers/readMemory.js";
|
|
7
7
|
import { resolveImports } from "./parsers/imports.js";
|
|
8
8
|
/**
|
|
9
9
|
* Every place Claude Code actually reads a rule from, at one directory
|
|
@@ -236,11 +236,98 @@ function findProjectRuleFiles(cwd) {
|
|
|
236
236
|
return projectLevels(cwd).flatMap((dir) => ruleFilesAtLevel(dir));
|
|
237
237
|
}
|
|
238
238
|
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
239
|
+
* Directories we never descend into when looking for subfolder rules files:
|
|
240
|
+
* build output, dependencies, VCS internals, RuleReceipt's own state.
|
|
241
|
+
*/
|
|
242
|
+
const SKIP_DESCEND = new Set([
|
|
243
|
+
"node_modules", ".git", "dist", "build", ".next", "out", "coverage",
|
|
244
|
+
".rulereceipt", ".vercel", ".turbo", "vendor", ".cache", "tmp", ".venv",
|
|
245
|
+
"__pycache__", "target",
|
|
246
|
+
]);
|
|
247
|
+
// Bounded so scanning a large workspace root can never run away.
|
|
248
|
+
const MAX_DESCEND_DEPTH = 8;
|
|
249
|
+
const MAX_DESCEND_DIRS = 3000;
|
|
250
|
+
/**
|
|
251
|
+
* Every directory strictly BELOW cwd, bounded. The up-walk (`projectLevels`)
|
|
252
|
+
* covers cwd and its ancestors; this covers its descendants.
|
|
253
|
+
*
|
|
254
|
+
* Why descend at all: Claude Code loads a subfolder CLAUDE.md/AGENTS.md on
|
|
255
|
+
* demand the moment the session touches a file in that subtree (surfaced in the
|
|
256
|
+
* transcript as a `nested_memory` attachment). The up-only walk never saw
|
|
257
|
+
* these, so running `check` from a parent dir silently missed every subfolder
|
|
258
|
+
* rules file — proven 2026-10-03 against real `nested_memory` ground truth
|
|
259
|
+
* (e.g. `costrr/CLAUDE.md`, `Daily _crypto/CLAUDE.md` loaded while cwd was the
|
|
260
|
+
* parent workspace). Nested git repos are NOT a stop condition here: Claude's
|
|
261
|
+
* nested_memory loads a nested-repo CLAUDE.md too, so we must find it.
|
|
262
|
+
*/
|
|
263
|
+
function descendantLevels(cwd) {
|
|
264
|
+
const out = [];
|
|
265
|
+
// Breadth-first on purpose: a shallow subfolder rules file is the common case
|
|
266
|
+
// and the one most likely to have been loaded, so when the dir budget runs
|
|
267
|
+
// out on a large workspace root it is the DEEP dirs that are dropped, never
|
|
268
|
+
// the shallow siblings. (A depth-first walk with the same budget could dive
|
|
269
|
+
// into one big subtree and starve a sibling's depth-1 CLAUDE.md — the bug this
|
|
270
|
+
// replaces, caught 2026-10-03 when costrr/ and rulereceipt/ were missed from a
|
|
271
|
+
// workspace root.)
|
|
272
|
+
let queue = [{ dir: cwd, depth: 0 }];
|
|
273
|
+
let budget = MAX_DESCEND_DIRS;
|
|
274
|
+
while (queue.length > 0 && budget > 0) {
|
|
275
|
+
const next = [];
|
|
276
|
+
for (const { dir, depth } of queue) {
|
|
277
|
+
if (budget <= 0)
|
|
278
|
+
break;
|
|
279
|
+
let entries;
|
|
280
|
+
try {
|
|
281
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
for (const e of entries) {
|
|
287
|
+
if (budget <= 0)
|
|
288
|
+
break;
|
|
289
|
+
if (!e.isDirectory())
|
|
290
|
+
continue;
|
|
291
|
+
if (SKIP_DESCEND.has(e.name) || e.name.startsWith("."))
|
|
292
|
+
continue; // dotdirs hold tooling, not project subtrees
|
|
293
|
+
const full = join(dir, e.name);
|
|
294
|
+
budget--;
|
|
295
|
+
out.push(full);
|
|
296
|
+
if (depth + 1 < MAX_DESCEND_DEPTH)
|
|
297
|
+
next.push({ dir: full, depth: depth + 1 });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
queue = next;
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
/** The glob that scopes a subfolder rules file to its own subtree, relative to cwd. */
|
|
305
|
+
function subtreeGlob(cwd, dir) {
|
|
306
|
+
const rel = relative(cwd, dir).replace(/\\/g, "/");
|
|
307
|
+
return `${rel}/**`;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Subfolder rules files below cwd, each paired with the subtree glob that
|
|
311
|
+
* scopes it. A subfolder rule is only applied to a session that actually
|
|
312
|
+
* touched its subtree (the same path-scope machinery as `paths:` frontmatter),
|
|
313
|
+
* so discovering them can never manufacture a false accusation against a
|
|
314
|
+
* session that never worked there.
|
|
315
|
+
*/
|
|
316
|
+
function scopedRuleFilesBelow(cwd) {
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const dir of descendantLevels(cwd)) {
|
|
319
|
+
const glob = subtreeGlob(cwd, dir);
|
|
320
|
+
for (const path of ruleFilesAtLevel(dir))
|
|
321
|
+
out.push({ path, scopeGlob: glob });
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Global rules come from every configured Claude home (see claudeHomes): the
|
|
327
|
+
* standard ~/.claude, plus any the user names via CLAUDE_CONFIG_DIR or
|
|
328
|
+
* RULERECEIPT_CLAUDE_HOMES — so a hosted/enterprise variant with its own global
|
|
329
|
+
* CLAUDE.md is supported when the user points at it, rather than by scanning
|
|
330
|
+
* whatever ~/.claude* dirs happen to exist on the machine.
|
|
244
331
|
*
|
|
245
332
|
* Also reads ~/.claude/rules/*.md, the documented location for personal
|
|
246
333
|
* rules that apply across every project.
|
|
@@ -258,21 +345,30 @@ export function loadRules(cwd) {
|
|
|
258
345
|
// both ways keeps its "global" label. Without this, running the check from
|
|
259
346
|
// inside the home directory reported every global rule twice.
|
|
260
347
|
const seen = new Set();
|
|
261
|
-
const read = (path, source) => {
|
|
348
|
+
const read = (path, source, scopeGlob) => {
|
|
262
349
|
const key = resolve(path);
|
|
263
350
|
if (seen.has(key))
|
|
264
351
|
return;
|
|
265
352
|
seen.add(key);
|
|
266
|
-
|
|
353
|
+
let parsed = parseClaudeMd(path, source);
|
|
354
|
+
// A subfolder rules file is loaded by the agent only when the session works
|
|
355
|
+
// in its subtree, so it is scoped to that subtree unless the file's own
|
|
356
|
+
// frontmatter already carries a (narrower) `paths:`.
|
|
357
|
+
if (scopeGlob) {
|
|
358
|
+
parsed = parsed.map((r) => (r.paths && r.paths.length > 0 ? r : { ...r, paths: [scopeGlob] }));
|
|
359
|
+
}
|
|
360
|
+
rules.push(...parsed);
|
|
267
361
|
};
|
|
268
|
-
for (const
|
|
269
|
-
const base = join(homedir(), dirName);
|
|
362
|
+
for (const base of claudeHomes()) {
|
|
270
363
|
read(join(base, "CLAUDE.md"), "global");
|
|
271
364
|
for (const file of markdownFilesIn(join(base, "rules")))
|
|
272
365
|
read(file, "global");
|
|
273
366
|
}
|
|
274
367
|
for (const path of findProjectRuleFiles(cwd))
|
|
275
368
|
read(path, "project");
|
|
369
|
+
// Subfolder rules files (below cwd), each scoped to its own subtree.
|
|
370
|
+
for (const { path, scopeGlob } of scopedRuleFilesBelow(cwd))
|
|
371
|
+
read(path, "project", scopeGlob);
|
|
276
372
|
// Claude Code memory (feedback/project memories) as a rule source, so a
|
|
277
373
|
// standing correction the user moved into memory is still checked and the
|
|
278
374
|
// tool does not go stale against it. Non-office homes only; ids are
|
|
@@ -310,8 +406,7 @@ export function describeRuleSources(cwd) {
|
|
|
310
406
|
};
|
|
311
407
|
// Globals first, so a file reachable both ways keeps its "global" label —
|
|
312
408
|
// mirrors loadRules' dedup order exactly.
|
|
313
|
-
for (const
|
|
314
|
-
const base = join(homedir(), dirName);
|
|
409
|
+
for (const base of claudeHomes()) {
|
|
315
410
|
if (existsSync(join(base, "CLAUDE.md")))
|
|
316
411
|
add({ path: join(base, "CLAUDE.md"), status: "loaded", format: "Claude (global CLAUDE.md)" }, "global");
|
|
317
412
|
for (const file of markdownFilesIn(join(base, "rules")))
|
|
@@ -321,5 +416,27 @@ export function describeRuleSources(cwd) {
|
|
|
321
416
|
for (const src of ruleSourcesAtLevel(dir))
|
|
322
417
|
add(src, "project");
|
|
323
418
|
}
|
|
419
|
+
// Subfolder rules files below cwd: loaded on demand when the session works in
|
|
420
|
+
// their subtree. Tagged so the graph says WHY they are conditional, matching
|
|
421
|
+
// the subtree scope `loadRules` applies.
|
|
422
|
+
for (const dir of descendantLevels(cwd)) {
|
|
423
|
+
const rel = relative(cwd, dir).replace(/\\/g, "/");
|
|
424
|
+
for (const src of ruleSourcesAtLevel(dir)) {
|
|
425
|
+
if (src.status !== "loaded") {
|
|
426
|
+
add(src, "project");
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
add({ ...src, note: `subfolder rules — loaded when the agent works in ${rel}/` }, "project");
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// Claude Code memory, as its own load-graph row. loadRules already CHECKS
|
|
433
|
+
// memory rules; listing them here closes the reporting gap where the graph
|
|
434
|
+
// undercounted what the checker uses (found 2026-10-03). Office homes are
|
|
435
|
+
// excluded inside the memory loader.
|
|
436
|
+
const mem = memoryGraphEntry(cwd);
|
|
437
|
+
if (mem && !seen.has(resolve(mem.path))) {
|
|
438
|
+
seen.add(resolve(mem.path));
|
|
439
|
+
entries.push({ path: mem.path, scope: "project", status: "loaded", format: "Claude memory", ruleCount: mem.ruleCount });
|
|
440
|
+
}
|
|
324
441
|
return entries;
|
|
325
442
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.88",
|
|
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",
|