rulereceipt 0.1.60 → 0.1.61

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.
@@ -551,7 +551,11 @@ function isEmojiRule(rule) {
551
551
  * rule, so the check is dogfooded on every session here.
552
552
  */
553
553
  const ATTRIBUTION_SUBJECT = /co-?authored-by|generated with\s*\[?\s*claude|\bai\b[^.\n]{0,20}(?:trace|attribution|authorship)|\battribution\b/i;
554
- const ATTRIBUTION_CONTEXT = /\b(?:commit|git|pull request|\bpr\b|github|co-?author)\b/i;
554
+ // Plural and verb forms count: "no Co-Authored-By on commits" / "when
555
+ // committing" / "on PRs" are the same rule as the singular. `\bcommit\b` alone
556
+ // missed "commits"/"committing" and left the rule at judgment (KNOWN-GAPS,
557
+ // fixed 2026-09-28).
558
+ const ATTRIBUTION_CONTEXT = /\b(?:commit(?:s|ted|ting|ment|ments)?|git|pull\s+requests?|prs?|github|co-?authors?)\b/i;
555
559
  const ATTRIBUTION_FORBID = /\b(?:no|never|don't|do not|without|must not|shall not|not add|zero|forbid)\b/i;
556
560
  function isAttributionRule(rule) {
557
561
  const text = `${rule.title} ${rule.text}`;
@@ -0,0 +1,3 @@
1
+ import type { TranscriptEvent } from "../types.js";
2
+ /** Rules/settings files the session wrote to, deduped, in first-seen order. */
3
+ export declare function detectSelfEditedRuleFiles(events: TranscriptEvent[]): string[];
@@ -0,0 +1,69 @@
1
+ import { withoutHeredocs } from "./shellCommand.js";
2
+ /**
3
+ * Did the session EDIT the rules or agent-settings it is being judged by?
4
+ *
5
+ * A verdict is about the rules as they are NOW. If the agent rewrote CLAUDE.md,
6
+ * `.claude/settings.json` or `.rulereceipt/` during the session, the report
7
+ * should say so out loud — "Claude changed CLAUDE.md this session, then passed
8
+ * its own rules" is exactly what a reader needs to know. This is a NOTE, never a
9
+ * FAIL: editing a rules file is not itself a violation, and plenty of legitimate
10
+ * work does it (a session that adds a rule, `init`, a settings tweak). Added
11
+ * 2026-09-28. Reading a rules file (Read / `cat` / `grep`) is not editing and
12
+ * never warns — only a write whose target is a rules/settings file counts.
13
+ */
14
+ /** A rules or agent-settings file, matched by path suffix. */
15
+ const RULE_FILE = /(?:^|[\\/])(?:CLAUDE(?:\.local)?\.md|AGENTS(?:\.local)?\.md|AGENT\.md|GEMINI\.md|\.cursorrules|\.windsurfrules|copilot-instructions\.md|settings(?:\.local)?\.json)$|(?:^|[\\/])\.claude[\\/]rules[\\/][^\\/]+$|(?:^|[\\/])\.cursor[\\/]rules[\\/][^\\/]+$|(?:^|[\\/])\.agents[\\/]rules[\\/][^\\/]+$|(?:^|[\\/])\.rulereceipt[\\/].+$/i;
16
+ function editTargetPath(input) {
17
+ const o = input;
18
+ const p = o?.file_path ?? o?.notebook_path ?? o?.path;
19
+ return typeof p === "string" ? p : "";
20
+ }
21
+ /**
22
+ * Rules files a shell command WRITES to: a `>`/`>>`/`tee` target, a `sed -i`
23
+ * file argument, or a `cp`/`mv`/`install` destination. A rules file that is only
24
+ * read (`cat f`, `grep x f`, `sed -n p f`) is never a write target and does not
25
+ * match — the same read-vs-write distinction the code checks already draw.
26
+ */
27
+ function shellWriteTargets(command) {
28
+ const cmd = withoutHeredocs(command);
29
+ const hits = new Set();
30
+ // redirect / tee target
31
+ for (const m of cmd.matchAll(/(?:>>?|\btee\s+(?:-a\s+)?)\s*("?)([^\s"'|;&<>()]+)\1/g)) {
32
+ if (RULE_FILE.test(m[2]))
33
+ hits.add(m[2]);
34
+ }
35
+ // sed -i edits its file argument(s) in place
36
+ if (/\bsed\s+-i/.test(cmd)) {
37
+ for (const m of cmd.matchAll(/(\S+)/g))
38
+ if (RULE_FILE.test(m[1]))
39
+ hits.add(m[1]);
40
+ }
41
+ // cp / mv / install: the destination is the last non-option argument
42
+ for (const m of cmd.matchAll(/\b(?:cp|mv|install)\b([^|;&]*)/g)) {
43
+ const args = m[1].trim().split(/\s+/).filter((a) => a && !a.startsWith("-"));
44
+ const dest = args[args.length - 1];
45
+ if (dest && RULE_FILE.test(dest))
46
+ hits.add(dest);
47
+ }
48
+ return [...hits];
49
+ }
50
+ /** Rules/settings files the session wrote to, deduped, in first-seen order. */
51
+ export function detectSelfEditedRuleFiles(events) {
52
+ const found = new Set();
53
+ for (const e of events) {
54
+ if (e.kind !== "tool_use")
55
+ continue;
56
+ if (e.toolName === "Write" || e.toolName === "Edit" || e.toolName === "MultiEdit" || e.toolName === "NotebookEdit") {
57
+ const p = editTargetPath(e.input);
58
+ if (p && RULE_FILE.test(p))
59
+ found.add(p);
60
+ }
61
+ else if (e.toolName === "Bash") {
62
+ const c = e.input?.command;
63
+ if (typeof c === "string")
64
+ for (const f of shellWriteTargets(c))
65
+ found.add(f);
66
+ }
67
+ }
68
+ return [...found];
69
+ }
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { auditSessions, renderComplianceReport } from "./report/complianceReport
15
15
  import { auditProject, renderProjectAudit } from "./audit.js";
16
16
  import { evaluateSession } from "./evaluate.js";
17
17
  import { buildWrongReport, findTarget } from "./wrong.js";
18
+ import { detectSelfEditedRuleFiles } from "./checks/selfEditedRules.js";
18
19
  import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
19
20
  import { runHook } from "./hook.js";
20
21
  import { runGuard } from "./guard.js";
@@ -232,13 +233,24 @@ async function runCheck(opts) {
232
233
  const blockingFails = blockingFailures(results, projectConfig, handleFor);
233
234
  const warnedFails = warningFailures(results, projectConfig, handleFor);
234
235
  const meta = { sessionFilePath, ruleCount: results.length };
236
+ // A NOTE, never a verdict: if the session rewrote the rules or settings it is
237
+ // being judged by, say so at the top. "Claude changed CLAUDE.md this session,
238
+ // then passed its own rules" is exactly what a reader needs to know.
239
+ const editedRuleFiles = detectSelfEditedRuleFiles(events);
240
+ const editedNote = editedRuleFiles.length > 0
241
+ ? `Note: the agent changed ${editedRuleFiles.length === 1 ? "a rules/settings file" : `${editedRuleFiles.length} rules/settings files`} during this session (${editedRuleFiles
242
+ .map((f) => f.replace(`${cwd}/`, ""))
243
+ .join(", ")}). The verdicts below are against the rules as they are now.`
244
+ : "";
235
245
  // Kept in human/markdown form for --email and any other reader below, even
236
246
  // when stdout is JSON — a manager gets a readable report, not raw JSON.
237
247
  const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
238
248
  if (json) {
239
- console.log(generateJsonReport(results, meta, pkg.version));
249
+ console.log(generateJsonReport(results, meta, pkg.version, editedRuleFiles));
240
250
  }
241
251
  else {
252
+ if (editedNote)
253
+ console.log(`${editedNote}\n`);
242
254
  console.log(reportText);
243
255
  // Name the tool when it is not the default Claude Code, so a Codex run is
244
256
  // not silently reported as if it were a Claude session.
@@ -24,4 +24,4 @@ export declare function generateMarkdownReport(results: CheckResult[], meta: Rep
24
24
  * way as every other output: a hostile CLAUDE.md does not get to smuggle
25
25
  * control characters through the JSON either.
26
26
  */
27
- export declare function generateJsonReport(results: CheckResult[], meta: ReportMeta, toolVersion: string): string;
27
+ export declare function generateJsonReport(results: CheckResult[], meta: ReportMeta, toolVersion: string, editedRuleFiles?: string[]): string;
@@ -277,7 +277,7 @@ export function generateMarkdownReport(results, meta) {
277
277
  * way as every other output: a hostile CLAUDE.md does not get to smuggle
278
278
  * control characters through the JSON either.
279
279
  */
280
- export function generateJsonReport(results, meta, toolVersion) {
280
+ export function generateJsonReport(results, meta, toolVersion, editedRuleFiles = []) {
281
281
  const clean = results.map(sanitize);
282
282
  const count = (s) => clean.filter((r) => r.status === s).length;
283
283
  const report = {
@@ -289,6 +289,9 @@ export function generateJsonReport(results, meta, toolVersion) {
289
289
  path: meta.sessionFilePath,
290
290
  sha256: computeTranscriptHash(meta.sessionFilePath),
291
291
  },
292
+ // Rules/settings files the session itself edited (a NOTE, not a verdict):
293
+ // the verdicts are against the rules as they are now.
294
+ agentEditedRuleFiles: editedRuleFiles,
292
295
  summary: {
293
296
  total: clean.length,
294
297
  pass: count("PASS"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.60",
3
+ "version": "0.1.61",
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",