rulereceipt 0.1.87 → 0.1.89

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.
@@ -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
- let raw;
197
- try {
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
- let raw;
214
- try {
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
  }
@@ -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
- * `.claude` vs `.claude-office`, now extended across tools. Returns null only
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
  */
@@ -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
- * `.claude` vs `.claude-office`, now extended across tools. Returns null only
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
- out.push(` loaded ${g.format} · ${g.ruleCount} rule${g.ruleCount === 1 ? "" : "s"} (${g.path})`);
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
- 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/i,
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 real gap found 2026-08-30: a hosted/enterprise Claude
198
- // Code variant used ~/.claude-office/ instead of ~/.claude/ — the
199
- // multi-root scan in transcriptParser.ts now catches that automatically,
200
- // but this flag stays as a fallback for whatever variant shows up next).
201
- // Auto-detect the session across every supported tool (Claude Code, Codex),
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;
package/dist/guard.js CHANGED
@@ -8,6 +8,7 @@ import { loadOverrides, ruleFingerprint, ratifiedForbids } from "./overrides.js"
8
8
  import { commandRunsLiteral } from "./checks/proposedAction.js";
9
9
  import { approvalOccurrences, allowListed, approvalCommandShort, approvalScopedBranch } from "./checks/approvalGate.js";
10
10
  import { readTranscriptFromFile } from "./parsers/transcriptParser.js";
11
+ import { touchedPaths, ruleWasLoaded } from "./checks/pathScope.js";
11
12
  import { execFileSync } from "node:child_process";
12
13
  /**
13
14
  * The current git branch in `cwd`, or undefined if it can't be determined —
@@ -92,8 +93,28 @@ function forbidRules(cwd) {
92
93
  * is why the guard cannot drift from the report: same checkers, same
93
94
  * verdicts, different tense.
94
95
  */
96
+ /**
97
+ * A path-scoped rule (a subfolder CLAUDE.md, or `paths:`/`globs:` frontmatter)
98
+ * only governs its own subtree. The guard must honour that: enforcing it on an
99
+ * action OUTSIDE the subtree is a false-block — the live-blocking equivalent of
100
+ * a false accusation. Found 2026-10-03: subfolder-rule discovery (0.1.88) made
101
+ * loadRules return those scoped rules, and the guard applied every forbid
102
+ * globally, so a demo rule scoped to one subtree blocked an edit elsewhere.
103
+ *
104
+ * Direction of error, deliberately toward NOT blocking: an unscoped rule always
105
+ * applies; a scoped rule applies only when the proposed action's touched path is
106
+ * inside the scope. A Bash command exposes no file path to match, so a scoped
107
+ * rule never fires on one here — under-enforcing a subtree is safe, a false-block
108
+ * is not. (`check` already path-scopes via this same machinery.)
109
+ */
110
+ function inScope(rule, touched) {
111
+ if (!rule.paths || rule.paths.length === 0)
112
+ return true;
113
+ return ruleWasLoaded(rule.paths, touched);
114
+ }
95
115
  function structuredBlocks(cwd, event) {
96
- const cls = forbidRules(cwd);
116
+ const touched = touchedPaths([event]);
117
+ const cls = forbidRules(cwd).filter((c) => inScope(c.rule, touched));
97
118
  const of = (k) => cls.filter((c) => c.kind === k);
98
119
  const results = [
99
120
  ...runCodeContentChecks(of("codeContent"), [event]),
@@ -218,7 +239,11 @@ function reason(blocks) {
218
239
  * decide — by the current permission mode — whether to ask or to deny.
219
240
  */
220
241
  function unapprovedGate(cwd, command, events) {
221
- const gates = classifyRules(loadRules(cwd)).filter((c) => c.kind === "approvalGate");
242
+ // A Bash command exposes no file path, so a path-scoped gate (a subfolder rule)
243
+ // can't be confirmed in-scope and is not enforced here — same no-false-block
244
+ // reasoning as structuredBlocks' inScope.
245
+ const gates = classifyRules(loadRules(cwd)).filter((c) => c.kind === "approvalGate")
246
+ .filter((c) => inScope(c.rule, touchedPaths([{ role: "assistant", kind: "tool_use", toolName: "Bash", input: { command }, timestamp: "" }])));
222
247
  for (const { rule, actions } of gates) {
223
248
  const proposed = { role: "assistant", kind: "tool_use", toolName: "Bash", input: { command }, timestamp: "", permissionMode: "dontAsk" };
224
249
  const occ = approvalOccurrences([...events, proposed], actions, { scopedBranch: approvalScopedBranch(rule), currentBranch: gitCurrentBranch(cwd) });
@@ -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 { findClaudeHomeDirNames } from "./transcriptParser.js";
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 dirName of findClaudeHomeDirNames()) {
57
- if (/office/i.test(dirName))
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
- * Also used for the global CLAUDE.md lookup (src/cli.ts) — the same
4
- * ".claude vs .claude-office" gap applies there too: a hosted/enterprise
5
- * variant could keep its own global rules file under its own home dir.
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 findClaudeHomeDirNames(): string[];
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
- * Real gap found 2026-08-30: a hosted/enterprise Claude Code variant on
21
- * one real machine writes to ~/.claude-office/projects/... instead of
22
- * ~/.claude/projects/... — same directory-encoding convention, same file
23
- * format, different root. `rulereceipt check` reported "no session found"
24
- * on 4 real projects that had extensive real work done, purely because it
25
- * only ever looked in one root.
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
- /** Absolute paths of every Claude-Code-style home to search, including CLAUDE_CONFIG_DIR. */
97
- function claudeHomeDirs() {
98
- const dirs = new Set();
99
- for (const name of findClaudeHomeDirNames())
100
- dirs.add(join(homedir(), name));
101
- const cfg = process.env.CLAUDE_CONFIG_DIR;
102
- if (cfg)
103
- for (const part of cfg.split(",")) {
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
- dirs.add(p);
113
+ out.add(isAbsolute(p) ? p : join(homedir(), p));
107
114
  }
108
- return [...dirs];
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 claudeHomeDirs()) {
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 .claude*-prefixed home dir found, not just
25
- * ~/.claude — a hosted/enterprise Claude Code variant can keep its own
26
- * global CLAUDE.md under its own home dir (e.g. ~/.claude-office/CLAUDE.md).
27
- * Real gap found 2026-08-30, same root cause as the transcript-lookup fix
28
- * in transcriptParser.ts: hardcoding one home-dir name misses any variant.
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 { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
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
- * Global rules come from every .claude*-prefixed home dir found, not just
240
- * ~/.claude — a hosted/enterprise Claude Code variant can keep its own
241
- * global CLAUDE.md under its own home dir (e.g. ~/.claude-office/CLAUDE.md).
242
- * Real gap found 2026-08-30, same root cause as the transcript-lookup fix
243
- * in transcriptParser.ts: hardcoding one home-dir name misses any variant.
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
- rules.push(...parseClaudeMd(path, source));
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 dirName of findClaudeHomeDirNames()) {
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 dirName of findClaudeHomeDirNames()) {
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.87",
3
+ "version": "0.1.89",
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",