rulereceipt 0.1.53 → 0.1.55

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.
@@ -31,13 +31,76 @@ export function withoutHeredocs(command) {
31
31
  closing = null;
32
32
  continue;
33
33
  }
34
- const open = line.match(/<<-?\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/);
34
+ // A heredoc opener: `<<WORD`, `<<-WORD`, `<<'WORD'`, `<<"WORD"`, and the
35
+ // backslash-escaped `<<\WORD` (valid POSIX, same "no expansion" effect as
36
+ // quoting). All spellings must strip the body, or a command that WRITES a
37
+ // command is misread as one that RUNS it.
38
+ const open = line.match(/<<-?\s*(?:'([^']+)'|"([^"]+)"|\\([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*))/);
35
39
  if (open) {
36
- closing = open[1] ?? open[2] ?? open[3];
37
- out.push(line.slice(0, open.index));
38
- continue;
40
+ const delimiter = open[1] ?? open[2] ?? open[3] ?? open[4] ?? null;
41
+ const at = open.index ?? 0;
42
+ const before = line.slice(0, at);
43
+ const afterDelim = line.slice(at + open[0].length);
44
+ // Only a REAL opener starts a body. `echo "see <<EOF"` merely mentions
45
+ // one — treating it as an opener silently deletes every following line,
46
+ // which hid real later commands from every caller. A genuine opener is
47
+ // not inside an already-open quote, and is followed only by an optional
48
+ // redirect/target (`<<EOF > out.txt`), never by more words.
49
+ if (delimiter !== null && isHeredocOpener(before, afterDelim)) {
50
+ closing = delimiter;
51
+ out.push(line.slice(0, at));
52
+ continue;
53
+ }
39
54
  }
40
55
  out.push(line);
41
56
  }
42
57
  return out.join("\n");
43
58
  }
59
+ /**
60
+ * Is a matched `<<WORD` an actual heredoc opener, given the text before it and
61
+ * the text after the delimiter token? Rejects a `<<WORD` sitting inside an
62
+ * already-open quote (it is data), and one followed by more command words
63
+ * (also not a real opener).
64
+ */
65
+ function isHeredocOpener(before, afterDelim) {
66
+ const singles = (before.match(/'/g) ?? []).length;
67
+ const doubles = (before.match(/"/g) ?? []).length;
68
+ if (singles % 2 === 1 || doubles % 2 === 1)
69
+ return false;
70
+ return /^\s*(?:[0-9]*>>?\s*[^\s<>|;&]+\s*)?$/.test(afterDelim);
71
+ }
72
+ /**
73
+ * Splits a shell command into the pieces that run separately.
74
+ *
75
+ * Crude by design — not a shell parser. Heredoc bodies are stripped first, so
76
+ * a command that only WRITES another command is not split into it. Shared by
77
+ * every checker that needs to reason about what a compound command actually
78
+ * runs (proposedAction, attribution).
79
+ */
80
+ export function segments(command) {
81
+ return withoutHeredocs(command)
82
+ .split(/\n|&&|\|\||[;|]/)
83
+ .map((s) => s.trim())
84
+ .filter((s) => s.length > 0);
85
+ }
86
+ /** The executable a segment invokes, with env assignments and `sudo` skipped. */
87
+ export function leadingCommand(segment) {
88
+ const words = segment.split(/\s+/).filter(Boolean);
89
+ let i = 0;
90
+ while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || words[i] === "sudo" || words[i] === "command" || words[i] === "time"))
91
+ i++;
92
+ return (words[i] ?? "").replace(/^.*\//, "");
93
+ }
94
+ /**
95
+ * Blanks out a git/gh commit or PR MESSAGE, leaving the command around it.
96
+ *
97
+ * A `-m "…"` / `--message "…"` value is text the author wrote, not a command
98
+ * being run, and not a path being touched — a message that quotes `rm …` or
99
+ * names a protected file must not be read as doing either. Shared so every
100
+ * checker that scans a Bash command string strips it the same way (this was
101
+ * present only in proposedAction, so fileLifecycle and testCommands each
102
+ * false-matched on commit-message text — findings 2026-09-26).
103
+ */
104
+ export function withoutCommitMessage(command) {
105
+ return command.replace(/(-m|--message)(=|\s+)(['"])(?:\\.|(?!\3)[\s\S])*\3/g, "$1 <message>");
106
+ }
@@ -1,4 +1,13 @@
1
- import { withoutHeredocs } from "./shellCommand.js";
1
+ import { withoutHeredocs, withoutCommitMessage } from "./shellCommand.js";
2
+ /**
3
+ * What a shell command actually RUNS, for test-detection: heredoc bodies
4
+ * stripped (a command that writes `npm test` is not one that runs it) and
5
+ * commit/PR messages blanked (a commit message naming `jest`/`vitest` — e.g. a
6
+ * migration commit — is not a test run; finding #6, 2026-09-26).
7
+ */
8
+ function runnableText(command) {
9
+ return withoutCommitMessage(withoutHeredocs(command));
10
+ }
2
11
  /**
3
12
  * Commands that run a project's test suite.
4
13
  *
@@ -38,7 +47,7 @@ export const TEST_COMMAND = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|verify
38
47
  */
39
48
  export function countTestRuns(command) {
40
49
  const global = new RegExp(TEST_COMMAND.source, "gi");
41
- return (withoutHeredocs(command).match(global) ?? []).length;
50
+ return (runnableText(command).match(global) ?? []).length;
42
51
  }
43
52
  /** The first test command run in this session, or null if none ran. */
44
53
  export function findTestRun(events) {
@@ -47,7 +56,7 @@ export function findTestRun(events) {
47
56
  continue;
48
57
  const input = event.input;
49
58
  const command = input && typeof input.command === "string" ? input.command : "";
50
- if (command && TEST_COMMAND.test(withoutHeredocs(command)))
59
+ if (command && TEST_COMMAND.test(runnableText(command)))
51
60
  return command;
52
61
  }
53
62
  return null;
package/dist/cli.js CHANGED
@@ -6,7 +6,8 @@ import { join, dirname, resolve, isAbsolute } from "node:path";
6
6
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { parseClaudeMd } from "./parsers/readClaudeMd.js";
9
- import { readLatestTranscript, readTranscriptFromFile, findLatestSessionFile, subagentNote } from "./parsers/transcriptParser.js";
9
+ import { subagentNote } from "./parsers/transcriptParser.js";
10
+ import { findLatestSession, sessionSourceNote, parseSessionFile } from "./adapters/index.js";
10
11
  import { loadRules } from "./rules.js";
11
12
  import { adviseRules } from "./checkability.js";
12
13
  import { shadowedAgentsMd } from "./shadowedAgents.js";
@@ -162,10 +163,15 @@ async function runCheck(opts) {
162
163
  // Code variant used ~/.claude-office/ instead of ~/.claude/ — the
163
164
  // multi-root scan in transcriptParser.ts now catches that automatically,
164
165
  // but this flag stays as a fallback for whatever variant shows up next).
165
- const sessionFilePath = transcriptOverride ?? findLatestSessionFile(cwd);
166
+ // Auto-detect the session across every supported tool (Claude Code, Codex),
167
+ // newest-modified wins — the same rule the Claude reader already applies
168
+ // across .claude vs .claude-office, now extended across tools. A
169
+ // Claude-only machine picks exactly the file and events it always did.
170
+ const latestSession = transcriptOverride ? null : findLatestSession(cwd);
171
+ const sessionFilePath = transcriptOverride ?? latestSession?.file ?? null;
166
172
  if (!sessionFilePath) {
167
- console.log("No Claude Code session found for this project yet.\n" +
168
- "Run Claude Code here at least once, then try `rulereceipt check` again — " +
173
+ console.log("No coding-agent session found for this project yet.\n" +
174
+ "Run Claude Code (or Codex) here at least once, then try `rulereceipt check` again — " +
169
175
  "or pass --transcript <path-to-.jsonl> directly if your session lives somewhere non-standard.");
170
176
  // Exiting 0 here is right for a person running this locally for the
171
177
  // first time — nothing is wrong, there is simply nothing yet. It is
@@ -179,7 +185,11 @@ async function runCheck(opts) {
179
185
  }
180
186
  return;
181
187
  }
182
- const events = transcriptOverride ? readTranscriptFromFile(sessionFilePath) : readLatestTranscript(cwd);
188
+ const events = transcriptOverride
189
+ ? parseSessionFile(sessionFilePath) // sniffs Claude vs Codex format
190
+ : latestSession
191
+ ? latestSession.adapter.parse(latestSession.file)
192
+ : [];
183
193
  // A session file with nothing in it produces a report full of PASSes,
184
194
  // because no forbidden action appears in an empty session. That is
185
195
  // technically true and badly misleading: "we found no proof of
@@ -290,6 +300,11 @@ async function runCheck(opts) {
290
300
  }
291
301
  else {
292
302
  console.log(reportText);
303
+ // Name the tool when it is not the default Claude Code, so a Codex run is
304
+ // not silently reported as if it were a Claude session.
305
+ const sourceNote = transcriptOverride ? null : sessionSourceNote(cwd);
306
+ if (sourceNote)
307
+ console.log(`\n${sourceNote}`);
293
308
  const subNote = subagentNote(sessionFilePath);
294
309
  if (subNote)
295
310
  console.log(`\n${subNote}`);
@@ -1,19 +1,2 @@
1
1
  import type { Rule } from "../types.js";
2
- /**
3
- * The filesystem half of rules-file parsing, deliberately kept in its own
4
- * module.
5
- *
6
- * claudeMdParser.ts must stay free of Node imports so it can be bundled
7
- * for the browser — the in-page checker on the site claims the pasted file
8
- * never leaves the page, and that is only true while nothing reachable
9
- * from the parser can perform I/O. Keeping the one readFileSync here
10
- * means a bundler cannot pull `node:fs` in behind it, and a future import
11
- * that breaks the guarantee has to be added here, visibly, rather than
12
- * appearing by accident in the parser.
13
- */
14
- /**
15
- * Reads a rules file from disk. Thin wrapper: an unreadable file is an
16
- * empty rule list, never a throw, because a missing global CLAUDE.md is a
17
- * normal state rather than an error.
18
- */
19
2
  export declare function parseClaudeMd(filePath: string, source: "global" | "project"): Rule[];
@@ -17,6 +17,25 @@ import { parseClaudeMdText } from "./claudeMdParser.js";
17
17
  * empty rule list, never a throw, because a missing global CLAUDE.md is a
18
18
  * normal state rather than an error.
19
19
  */
20
+ /**
21
+ * Strips a leading YAML frontmatter block (`---\n…\n---`) if present.
22
+ *
23
+ * Cursor's `.mdc` rule files open with a frontmatter block (description,
24
+ * globs, alwaysApply) that is metadata, not a rule — without this it parses
25
+ * as prose and surfaces `alwaysApply: true` as a checkable "rule". Kept in
26
+ * the filesystem reader (not the browser parser) so the parser stays
27
+ * Node-free. Only strips a block that starts on the very first line, so a
28
+ * `---` divider mid-document is untouched.
29
+ */
30
+ function stripFrontmatter(raw) {
31
+ if (!/^---\r?\n/.test(raw))
32
+ return raw;
33
+ const end = raw.indexOf("\n---", 3);
34
+ if (end === -1)
35
+ return raw;
36
+ const after = raw.indexOf("\n", end + 1);
37
+ return after === -1 ? "" : raw.slice(after + 1);
38
+ }
20
39
  export function parseClaudeMd(filePath, source) {
21
40
  let raw;
22
41
  try {
@@ -25,5 +44,5 @@ export function parseClaudeMd(filePath, source) {
25
44
  catch {
26
45
  return [];
27
46
  }
28
- return parseClaudeMdText(raw, source);
47
+ return parseClaudeMdText(stripFrontmatter(raw), source);
29
48
  }
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare function loadMemoryRules(cwd: string): Rule[];
@@ -0,0 +1,99 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, basename } from "node:path";
4
+ import { findClaudeHomeDirNames } from "./transcriptParser.js";
5
+ /**
6
+ * Claude Code's memory as a rule source.
7
+ *
8
+ * Memory lives per-project at:
9
+ * <claude-home>/projects/<cwd with every "/" -> "-">/memory/*.md
10
+ * with an index `MEMORY.md` and one file per memory. Each file opens with a
11
+ * frontmatter block:
12
+ * ---
13
+ * name: <slug>
14
+ * description: <one line>
15
+ * metadata:
16
+ * type: user | feedback | project | reference
17
+ * ---
18
+ * <body>
19
+ *
20
+ * Why this matters: teams increasingly move standing corrections into memory
21
+ * ("after any correction, update memory"), so a rule the user actually holds
22
+ * the agent to can live here and nowhere in CLAUDE.md. Without reading it the
23
+ * tool goes stale — it reports a clean session while missing the very rule the
24
+ * user cares most about.
25
+ *
26
+ * What counts as a rule: `feedback` (guidance on how to work — corrections and
27
+ * confirmed approaches) and `project` (ongoing constraints) can carry
28
+ * directives, so they are read. `user` (who the user is) and `reference`
29
+ * (pointers/URLs) never are, and are skipped by type. Anything that slips
30
+ * through and is not actually a directive is dropped downstream by the same
31
+ * not-a-rule filter every rules file goes through — so a plain fact in memory
32
+ * never becomes a checkable rule.
33
+ *
34
+ * Scope: non-office homes only. Reading office memory into this project is the
35
+ * exact office/personal mixing this project forbids, so any `.claude*` home
36
+ * whose name contains "office" is skipped.
37
+ */
38
+ // Types whose memories can be rules. `user`/`reference` are excluded by
39
+ // omission; an untyped memory is kept and left to the not-a-rule filter.
40
+ const RULE_MEMORY_TYPES = new Set(["feedback", "project"]);
41
+ function parseMemoryFile(raw) {
42
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
43
+ if (!m)
44
+ return { type: null, name: null, description: null, body: raw.trim() };
45
+ const front = m[1];
46
+ const body = m[2].trim();
47
+ const type = front.match(/^\s*type:\s*(user|feedback|project|reference)\b/im)?.[1]?.toLowerCase() ?? null;
48
+ const name = front.match(/^\s*name:\s*(.+)$/im)?.[1]?.trim() ?? null;
49
+ const description = front.match(/^\s*description:\s*(.+)$/im)?.[1]?.trim() ?? null;
50
+ return { type, name, description, body };
51
+ }
52
+ export function loadMemoryRules(cwd) {
53
+ const rules = [];
54
+ const seenIds = new Set();
55
+ 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");
60
+ let files;
61
+ try {
62
+ if (!statSync(memoryDir).isDirectory())
63
+ continue;
64
+ files = readdirSync(memoryDir)
65
+ .filter((f) => f.toLowerCase().endsWith(".md") && f !== "MEMORY.md")
66
+ .sort();
67
+ }
68
+ catch {
69
+ continue; // no memory dir for this project under this home: normal
70
+ }
71
+ for (const file of files) {
72
+ let raw;
73
+ try {
74
+ raw = readFileSync(join(memoryDir, file), "utf-8");
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ const { type, name, description, body } = parseMemoryFile(raw);
80
+ // Skip identity/pointer memories by declared type; keep feedback/project
81
+ // and untyped (the not-a-rule filter drops any non-directive body later).
82
+ if (type !== null && !RULE_MEMORY_TYPES.has(type))
83
+ continue;
84
+ if (!body)
85
+ continue;
86
+ const id = `memory:${name ?? basename(file, ".md")}`;
87
+ if (seenIds.has(id))
88
+ continue; // same memory reachable from two homes
89
+ seenIds.add(id);
90
+ rules.push({
91
+ id,
92
+ title: description ?? name ?? basename(file, ".md"),
93
+ text: body,
94
+ source: "project",
95
+ });
96
+ }
97
+ }
98
+ return rules;
99
+ }
@@ -1,7 +1,7 @@
1
1
  import { basename } from "node:path";
2
2
  import { loadRules } from "../rules.js";
3
3
  import { evaluateSession } from "../evaluate.js";
4
- import { readTranscriptFromFile, listAllSessionFiles } from "../parsers/transcriptParser.js";
4
+ import { listAllSessions } from "../adapters/index.js";
5
5
  function needsReview(rule) {
6
6
  return {
7
7
  ruleId: rule.id, ruleTitle: rule.title, ruleSource: rule.source,
@@ -11,12 +11,15 @@ function needsReview(rule) {
11
11
  }
12
12
  export async function auditSessions(cwd, limit) {
13
13
  const rules = loadRules(cwd);
14
- const files = listAllSessionFiles(cwd).slice(0, Math.max(1, limit));
14
+ // Every session across all supported tools (Claude Code, Codex), newest
15
+ // first — the report audits a Codex session the same way it audits a Claude
16
+ // one, since the engine is agent-neutral.
17
+ const found = listAllSessions(cwd).slice(0, Math.max(1, limit));
15
18
  const sessions = [];
16
19
  const byRule = new Map();
17
20
  let totalViolations = 0;
18
- for (const file of files) {
19
- const events = readTranscriptFromFile(file);
21
+ for (const { adapter, file } of found) {
22
+ const events = adapter.parse(file);
20
23
  if (events.length === 0)
21
24
  continue;
22
25
  const { results } = await evaluateSession(cwd, rules, events, false, needsReview);
package/dist/rules.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, join, parse, resolve } from "node:path";
3
3
  import { existsSync, readdirSync, statSync } from "node:fs";
4
4
  import { parseClaudeMd } from "./parsers/readClaudeMd.js";
5
5
  import { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
6
+ import { loadMemoryRules } from "./parsers/readMemory.js";
6
7
  /**
7
8
  * Every place Claude Code actually reads a rule from, at one directory
8
9
  * level. Order mirrors the documented load order, broadest first.
@@ -15,22 +16,24 @@ import { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
15
16
  */
16
17
  const RULE_DIRS = [join(".claude", "rules")];
17
18
  /**
18
- * Lists the markdown files in a rules directory, if it exists.
19
+ * Lists the rule-doc files in a directory, if it exists.
19
20
  *
20
21
  * Sorted so the same project always produces the same rule order — rule
21
22
  * ids are positional, and an unstable order would renumber rules between
22
23
  * runs on different machines, making two reports of the same session
23
- * impossible to compare. Non-markdown files are skipped: a rules
24
- * directory legitimately holds README fragments and notes.
24
+ * impossible to compare. Only the given extensions count: a rules
25
+ * directory legitimately holds README fragments and notes. `.mdc` is
26
+ * Cursor's rule-file extension (Markdown + a YAML frontmatter block, which
27
+ * the reader strips).
25
28
  */
26
- function markdownFilesIn(dir) {
29
+ function markdownFilesIn(dir, exts = [".md"]) {
27
30
  if (!existsSync(dir))
28
31
  return [];
29
32
  try {
30
33
  if (!statSync(dir).isDirectory())
31
34
  return [];
32
35
  return readdirSync(dir)
33
- .filter((f) => f.toLowerCase().endsWith(".md"))
36
+ .filter((f) => exts.some((e) => f.toLowerCase().endsWith(e)))
34
37
  .sort()
35
38
  .map((f) => join(dir, f));
36
39
  }
@@ -65,6 +68,33 @@ function ruleFilesAtLevel(dir) {
65
68
  // documented, so both are kept rather than guessing at a shadow rule.
66
69
  push("CLAUDE.local.md");
67
70
  push("AGENTS.local.md");
71
+ // Non-Claude rule-file conventions (added 2026-09-26 for multi-tool
72
+ // support). Each is a plain text/markdown file needing no special access,
73
+ // read IN ADDITION to Claude's files when present — a rule the project
74
+ // wrote is a rule to check, and silently ignoring one is the "clean report
75
+ // on rules never opened" failure this module already guards against. The
76
+ // engine (classify.ts) is agent-neutral, so it does not matter which tool a
77
+ // rule was authored for. Precedence is FIXED and documented so rule ids stay
78
+ // deterministic: Claude family (above), then Cursor, Copilot, Windsurf.
79
+ //
80
+ // Cursor: the modern `.cursor/rules/*.mdc|.md` directory SHADOWS the legacy
81
+ // single `.cursorrules` file — Cursor itself deprecated `.cursorrules` in
82
+ // favour of the directory, so reading both would double-count. Same
83
+ // shadow shape as CLAUDE.md over AGENTS.md above.
84
+ const cursorRules = markdownFilesIn(join(dir, ".cursor", "rules"), [".mdc", ".md"]);
85
+ if (cursorRules.length > 0)
86
+ found.push(...cursorRules);
87
+ else
88
+ push(".cursorrules");
89
+ // GitHub Copilot: repo-level custom instructions.
90
+ push(join(".github", "copilot-instructions.md"));
91
+ // Windsurf (Codeium): single rules file.
92
+ push(".windsurfrules");
93
+ // Google's newer agent convention (agy) / "agents rules": .agents/rules/*.md,
94
+ // each with a `trigger:` frontmatter block (stripped by the reader).
95
+ found.push(...markdownFilesIn(join(dir, ".agents", "rules"), [".md"]));
96
+ // Gemini CLI: single rules file (its AGENTS.md equivalent).
97
+ push("GEMINI.md");
68
98
  return found;
69
99
  }
70
100
  /**
@@ -148,5 +178,10 @@ export function loadRules(cwd) {
148
178
  }
149
179
  for (const path of findProjectRuleFiles(cwd))
150
180
  read(path, "project");
181
+ // Claude Code memory (feedback/project memories) as a rule source, so a
182
+ // standing correction the user moved into memory is still checked and the
183
+ // tool does not go stale against it. Non-office homes only; ids are
184
+ // "memory:<name>", distinct from file-rule ids, so no dedup collision.
185
+ rules.push(...loadMemoryRules(cwd));
151
186
  return rules;
152
187
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.53",
4
- "description": "Checks whether a Claude Code session actually followed your CLAUDE.md / AGENTS.md rules, with evidence.",
3
+ "version": "0.1.55",
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",
7
7
  "url": "git+https://github.com/rulereceipt/rulereceipt.git"
@@ -13,7 +13,13 @@
13
13
  "keywords": [
14
14
  "claude-code",
15
15
  "claude",
16
+ "codex",
17
+ "cursor",
18
+ "copilot",
19
+ "windsurf",
20
+ "gemini",
16
21
  "agents",
22
+ "agents-md",
17
23
  "ai-agent",
18
24
  "code-review",
19
25
  "audit",