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.
- package/README.md +11 -7
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +263 -0
- package/dist/adapters/index.d.ts +82 -0
- package/dist/adapters/index.js +133 -0
- package/dist/checks/attribution.js +29 -8
- package/dist/checks/claimEvidence.js +52 -3
- package/dist/checks/codeContent.js +5 -1
- package/dist/checks/deterministicChecks.js +56 -4
- package/dist/checks/emojiOutput.js +29 -10
- package/dist/checks/fileLifecycle.js +29 -15
- package/dist/checks/gitBranchPolicy.js +86 -14
- package/dist/checks/ifEditThenTest.js +7 -1
- package/dist/checks/proposedAction.js +20 -32
- package/dist/checks/shellCommand.d.ts +22 -0
- package/dist/checks/shellCommand.js +67 -4
- package/dist/checks/testCommands.js +12 -3
- package/dist/cli.js +20 -5
- package/dist/parsers/readClaudeMd.d.ts +0 -17
- package/dist/parsers/readClaudeMd.js +20 -1
- package/dist/parsers/readMemory.d.ts +2 -0
- package/dist/parsers/readMemory.js +99 -0
- package/dist/report/complianceReport.js +7 -4
- package/dist/rules.js +40 -5
- package/package.json +8 -2
|
@@ -31,13 +31,76 @@ export function withoutHeredocs(command) {
|
|
|
31
31
|
closing = null;
|
|
32
32
|
continue;
|
|
33
33
|
}
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 (
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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,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 {
|
|
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
|
-
|
|
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
|
|
19
|
-
const events =
|
|
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
|
|
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.
|
|
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(
|
|
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.
|
|
4
|
-
"description": "Checks whether
|
|
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",
|