rulereceipt 0.1.46 → 0.1.48

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.
@@ -0,0 +1,31 @@
1
+ import type { Rule } from "./types.js";
2
+ /**
3
+ * Why a rule can't be checked mechanically, and what to change so it can.
4
+ *
5
+ * The other half of hookCoverage. `hookCoverage` answers "which rules have a
6
+ * hook behind them"; this answers "which rules can't be checked at all, and
7
+ * how to fix that." Both come from the same complaint — a CLAUDE.md full of
8
+ * rules that read fine to a human and mean nothing to a checker
9
+ * (anthropics/claude-code#2544, and the whole "wish list, not a contract"
10
+ * genre).
11
+ *
12
+ * It never rewrites the rule. It says, in one line, what is missing and the
13
+ * smallest edit that would make the rule land in a real check — usually
14
+ * naming the concrete command, file or branch the rule is about, in
15
+ * backticks. Some rules are genuine judgment calls ("surface bad news
16
+ * first") and the honest advice is that no edit makes them mechanical; those
17
+ * are for the LLM judge, and this says so rather than pretending otherwise.
18
+ */
19
+ export interface RuleAdvice {
20
+ ruleTitle: string;
21
+ /** The classifier's verdict: "judgment" or "notARule". */
22
+ kind: "judgment" | "notARule";
23
+ /** One line: what is missing and the smallest edit that fixes it. */
24
+ suggestion: string;
25
+ }
26
+ /**
27
+ * Advice for one rule, or null when the rule is already mechanically checked.
28
+ */
29
+ export declare function adviseRule(rule: Rule): RuleAdvice | null;
30
+ /** Advice for every rule that isn't already mechanically checked. */
31
+ export declare function adviseRules(rules: Rule[]): RuleAdvice[];
@@ -0,0 +1,68 @@
1
+ import { classifyRule } from "./checks/classify.js";
2
+ /** A concrete action the rule is plausibly about, so we can name what to quote. */
3
+ const CONCRETE_SUBJECT = /\b(?:push(?:es|ed|ing)?|commit(?:s|ted|ting)?|merge[ds]?|rebase|delet\w*|remov\w*|\brm\b|drop|truncate|deploy\w*|migrat\w*|branch|tag|force[- ]?push|test|lint|build|install|env|secret|token|password|key|\.env|database|table|file|path|directory|endpoint|api)\b/i;
4
+ /** A rule that is qualitative by nature — no literal makes it mechanical. */
5
+ const QUALITATIVE = /\b(?:concise|verbose|clear|clean|readable|tone|polite|honest|thorough|surface\s+bad\s+news|be\s+kind|professional|idiomatic|maintainable|simple|elegant|good\s+judg\w*|reasonable|appropriate|well[- ]?(?:written|structured|named))\b/i;
6
+ function hasBacktickLiteral(rule) {
7
+ return /`[^`]{2,}`/.test(`${rule.title} ${rule.text}`);
8
+ }
9
+ /** A believable backtick example for the kind of subject the rule named. */
10
+ function exampleFor(subject) {
11
+ if (/push|commit|merge|rebase|branch|tag|force/.test(subject))
12
+ return "`git push`, `main`";
13
+ if (/deploy|migrat/.test(subject))
14
+ return "`vercel --prod`, `npm run deploy`";
15
+ if (/test|lint|build|install/.test(subject))
16
+ return "`npm test`, `npm run build`";
17
+ if (/\.env|secret|token|password|key/.test(subject))
18
+ return "`.env`, `.env.production`";
19
+ if (/database|table/.test(subject))
20
+ return "`data/app.db`, `DROP TABLE`";
21
+ if (/file|path|directory|endpoint|api/.test(subject))
22
+ return "`data/x.db`, `src/config.ts`";
23
+ if (/rm|delet|remov|drop|truncate/.test(subject))
24
+ return "`rm`, `data/x.db`";
25
+ return "`git push`, `data/x.db`";
26
+ }
27
+ /**
28
+ * Advice for one rule, or null when the rule is already mechanically checked.
29
+ */
30
+ export function adviseRule(rule) {
31
+ const kind = classifyRule(rule).kind;
32
+ if (kind !== "judgment" && kind !== "notARule")
33
+ return null;
34
+ const text = `${rule.title} ${rule.text}`;
35
+ if (kind === "notARule") {
36
+ return {
37
+ ruleTitle: rule.title,
38
+ kind,
39
+ suggestion: "reads as documentation or an incident note, not a rule to check. If it IS a rule, phrase it as a direct imperative (\"Never …\", \"Always …\") so a check can bind to it.",
40
+ };
41
+ }
42
+ // kind === "judgment"
43
+ if (QUALITATIVE.test(text) && !CONCRETE_SUBJECT.test(text)) {
44
+ return {
45
+ ruleTitle: rule.title,
46
+ kind,
47
+ suggestion: "a genuine judgment call — no edit makes it mechanical. It can only be graded with the LLM judge (`check --llm`); that is expected, not a defect.",
48
+ };
49
+ }
50
+ if (CONCRETE_SUBJECT.test(text) && !hasBacktickLiteral(rule)) {
51
+ const m = text.match(CONCRETE_SUBJECT);
52
+ const subject = m ? m[0].toLowerCase() : "the action";
53
+ return {
54
+ ruleTitle: rule.title,
55
+ kind,
56
+ suggestion: `mentions ${subject} but names no exact term to match. Put the concrete command, file or branch in backticks (e.g. ${exampleFor(subject)}) and it becomes a mechanical check.`,
57
+ };
58
+ }
59
+ return {
60
+ ruleTitle: rule.title,
61
+ kind,
62
+ suggestion: "has no concrete term a check can bind to. Name the exact command, file, branch or flag it is about in backticks, or leave it for the LLM judge (`check --llm`).",
63
+ };
64
+ }
65
+ /** Advice for every rule that isn't already mechanically checked. */
66
+ export function adviseRules(rules) {
67
+ return rules.map(adviseRule).filter((a) => a !== null);
68
+ }
@@ -0,0 +1,3 @@
1
+ import type { ApprovalGateClassification } from "./classify.js";
2
+ import type { CheckResult, TranscriptEvent } from "../types.js";
3
+ export declare function runApprovalGateChecks(classifications: ApprovalGateClassification[], events: TranscriptEvent[]): CheckResult[];
@@ -0,0 +1,105 @@
1
+ import { violation } from "../types.js";
2
+ import { withoutHeredocs } from "./shellCommand.js";
3
+ /**
4
+ * Did the assistant ASK before doing a thing a rule says needs approval?
5
+ *
6
+ * From anthropics/claude-code#95494 ("committed without permission", scope
7
+ * rewritten and fake results presented) and #92505. A whole cluster of rules
8
+ * reads "ask/repeat-back/wait for approval BEFORE you delete | push | commit".
9
+ *
10
+ * The honest, mechanical half is narrow and stated as such: the rule is
11
+ * satisfied by ASKING, so this checks whether the assistant sought approval
12
+ * in its recorded text before the action. It does NOT judge whether a reply
13
+ * actually granted approval — the "it read my frustration as a yes" case
14
+ * (#92505) is a judgment call and stays with the model, not this check.
15
+ *
16
+ * Bias is deliberately toward NOT accusing: any approval-seeking assistant
17
+ * turn before the action clears the rule. A FAIL means the action ran and
18
+ * nothing before it asked — the clean case, e.g. a bare `git commit` with no
19
+ * "shall I / ok to / before I" anywhere ahead of it.
20
+ */
21
+ /** Command shapes for each gated action. Kept conservative to avoid accusing. */
22
+ // `git\s+…\s+push/commit` allows config flags between the two (`git -c x=y
23
+ // push`), which a literal `git push` missed — found by an evasion probe
24
+ // 2026-09-22. Up to four intervening tokens; the verb must not be followed by
25
+ // a word char so `commit-graph` / `push-option` are not matched.
26
+ const ACTION_IN_COMMAND = {
27
+ push: /\bgit\s+(?:\S+\s+){0,4}?push(?![\w-])/,
28
+ commit: /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])/,
29
+ delete: /\brm\s+-?\w|\bgit\b[^\n]*\s-D\b|\bdrop\s+table\b|\bdelete\s+from\b|\btruncate\b/i,
30
+ };
31
+ /**
32
+ * The assistant seeking sign-off. Read generously on purpose: a broad match
33
+ * here means fewer false accusations, at the cost of occasionally missing a
34
+ * real one — the safe direction for a tool whose whole point is not crying
35
+ * wolf.
36
+ */
37
+ const APPROVAL_SEEK = /\b(?:shall i|should i|may i|can i|do you want|would you like|let me know|before i (?:proceed|do|run|delete|push|commit|go|continue)|your (?:approval|go[- ]?ahead|sign[- ]?off|confirmation)|please confirm|is (?:it|this) ok|ok(?:ay)? to|go ahead\?)\b/i;
38
+ function commandOf(event) {
39
+ if (event.kind !== "tool_use" || event.toolName !== "Bash")
40
+ return "";
41
+ const input = event.input;
42
+ const raw = input && typeof input.command === "string" ? input.command : "";
43
+ // A heredoc that WRITES "git push" into a file is not a push. Strip
44
+ // heredoc bodies so only the commands actually invoked are inspected.
45
+ return withoutHeredocs(raw);
46
+ }
47
+ /**
48
+ * The first event index at which one of the gated actions runs, and the
49
+ * command that ran it — or null if none did.
50
+ */
51
+ function firstAction(events, actions) {
52
+ const regexes = actions.map((a) => ACTION_IN_COMMAND[a]).filter(Boolean);
53
+ for (let i = 0; i < events.length; i++) {
54
+ const command = commandOf(events[i]);
55
+ if (!command)
56
+ continue;
57
+ if (regexes.some((re) => re.test(command)))
58
+ return { index: i, command };
59
+ }
60
+ return null;
61
+ }
62
+ /** Did any assistant text before `index` seek approval? */
63
+ function askedBefore(events, index) {
64
+ for (let i = 0; i < index; i++) {
65
+ const e = events[i];
66
+ if (e.kind !== "text" || e.role !== "assistant")
67
+ continue;
68
+ if (APPROVAL_SEEK.test(e.text))
69
+ return true;
70
+ }
71
+ return false;
72
+ }
73
+ export function runApprovalGateChecks(classifications, events) {
74
+ return classifications.map(({ rule, actions, polarity }) => {
75
+ const action = firstAction(events, actions);
76
+ if (!action) {
77
+ return {
78
+ ruleId: rule.id,
79
+ ruleTitle: rule.title,
80
+ ruleSource: rule.source,
81
+ status: "UNCLEAR",
82
+ outcome: "not_applicable",
83
+ method: "approval_gate",
84
+ evidence: `no ${actions.join("/")} action ran this session, so the gate never applied`,
85
+ };
86
+ }
87
+ if (askedBefore(events, action.index)) {
88
+ return {
89
+ ruleId: rule.id,
90
+ ruleTitle: rule.title,
91
+ ruleSource: rule.source,
92
+ status: "PASS",
93
+ outcome: "pass",
94
+ method: "approval_gate",
95
+ evidence: "the assistant sought approval before the action",
96
+ ceiling: "confirms the assistant ASKED; it does not judge whether the reply granted approval, and cannot see an approval given through the permission UI",
97
+ };
98
+ }
99
+ const excerpt = action.command.replace(/\s+/g, " ").trim().slice(0, 70);
100
+ return violation(rule, polarity, `the assistant ran "${excerpt}" with no approval sought beforehand`, {
101
+ method: "approval_gate",
102
+ ceiling: "flags an action taken with no approval-seeking text before it; it cannot see an approval given through the permission UI, so this is the clean no-ask case only",
103
+ });
104
+ });
105
+ }
@@ -0,0 +1,3 @@
1
+ import type { AttributionClassification } from "./classify.js";
2
+ import type { CheckResult, TranscriptEvent } from "../types.js";
3
+ export declare function runAttributionChecks(classifications: AttributionClassification[], events: TranscriptEvent[]): CheckResult[];
@@ -0,0 +1,105 @@
1
+ import { violation } from "../types.js";
2
+ import { withoutHeredocs } from "./shellCommand.js";
3
+ /**
4
+ * Did the session add an AI-attribution trailer to a commit, PR or comment,
5
+ * against a rule forbidding it?
6
+ *
7
+ * Raised by anthropics/claude-code#83813, #92169, #82690 and #4287: a user
8
+ * writes "no Co-Authored-By, no 'Generated with Claude Code'" into their
9
+ * rules file, and the trailer lands on the commit anyway. It is one of the
10
+ * most-filed rule-following complaints, and it is mechanically checkable —
11
+ * the trailer is literal text that sits in the git command the assistant
12
+ * ran.
13
+ *
14
+ * Scope is deliberately narrow, for the same reason emojiOutput reads only
15
+ * assistant text: a rule that FORBIDS attribution necessarily quotes the
16
+ * exact trailer it forbids ("never add `Co-Authored-By: Claude`"), and this
17
+ * repo's own CLAUDE.md does exactly that. Scanning rule text, user text, or
18
+ * a plain `cat` of a file would fire on the prohibition itself. So this
19
+ * looks at one thing only: the text of a git-writing command the ASSISTANT
20
+ * issued — `git commit`, `gh pr create`, a PR or issue comment — and asks
21
+ * whether the forbidden trailer is inside it.
22
+ *
23
+ * The ceiling that travels with the verdict is the honest limit: a trailer
24
+ * the harness appends OUTSIDE the recorded command text (the #83813
25
+ * mechanism, where the platform adds it) is not visible in the transcript,
26
+ * so a PASS means "not in any command recorded here", never "no attribution
27
+ * reached the commit".
28
+ */
29
+ /** Commands that write to git history or to GitHub on the author's behalf. */
30
+ // `git\s+…\s+commit` allows config flags between the two — `git -c
31
+ // user.name=x commit`, `git --no-pager commit` — which a literal `git commit`
32
+ // missed (found by an evasion probe, 2026-09-22). Up to four intervening
33
+ // tokens, and `commit` not followed by a word char so `commit-graph` is not
34
+ // a commit. Same shape for the push detector in approvalGate.
35
+ const GIT_WRITE = /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])|\bgit\s+.*--amend\b|\bgh\s+pr\s+(?:create|edit|review|comment)\b|\bgh\s+issue\s+(?:create|comment)\b|\bgh\s+api\b[^\n]*\bcomments?\b/i;
36
+ /**
37
+ * The forbidden trailers, as literal spellings. Each is an AI-authorship
38
+ * mark, not merely the word "Claude" (a commit may legitimately say "fix the
39
+ * Claude Code parser"): the co-author trailer, the generated-with line with
40
+ * or without its robot, and the anthropic noreply address used as an author.
41
+ */
42
+ const ATTRIBUTION_TRAILER = /co-?authored-by:\s*[^\n]*(?:claude|anthropic)|generated with\s*\[?\s*claude code|🤖\s*generated with|<?noreply@anthropic\.com>?/i;
43
+ function commandText(event) {
44
+ const input = event.input;
45
+ return input && typeof input.command === "string" ? input.command : "";
46
+ }
47
+ /** The first git-writing command in the session that carries a trailer. */
48
+ function firstOffendingCommand(events) {
49
+ let sawGitWrite = false;
50
+ for (const event of events) {
51
+ if (event.kind !== "tool_use" || event.toolName !== "Bash")
52
+ continue;
53
+ const command = commandText(event);
54
+ // Prove git is actually INVOKED, not merely quoted: a `cat <<EOF … git
55
+ // commit … Co-Authored-By … EOF` writes a file that contains the example,
56
+ // it does not commit. Strip heredoc bodies before testing the invocation,
57
+ // but match the trailer against the FULL command so a real heredoc that
58
+ // FEEDS the commit message is still caught.
59
+ if (!GIT_WRITE.test(withoutHeredocs(command)))
60
+ continue;
61
+ sawGitWrite = true;
62
+ if (ATTRIBUTION_TRAILER.test(command))
63
+ return command;
64
+ }
65
+ return sawGitWrite ? "" : null;
66
+ }
67
+ export function runAttributionChecks(classifications, events) {
68
+ const offending = firstOffendingCommand(events);
69
+ return classifications.map(({ rule, polarity }) => {
70
+ if (typeof offending === "string" && offending.length > 0) {
71
+ const m = offending.match(ATTRIBUTION_TRAILER);
72
+ const at = m?.index ?? 0;
73
+ const excerpt = offending
74
+ .slice(Math.max(0, at - 30), at + 50)
75
+ .replace(/\s+/g, " ")
76
+ .trim();
77
+ return violation(rule, polarity, `a git/PR command carried an AI-attribution trailer — "…${excerpt}…"`, {
78
+ method: "attribution_scan",
79
+ ceiling: "reads the text of git/PR commands recorded in the transcript; a trailer the harness appends outside the recorded command is not visible here",
80
+ });
81
+ }
82
+ if (offending === null) {
83
+ return {
84
+ ruleId: rule.id,
85
+ ruleTitle: rule.title,
86
+ ruleSource: rule.source,
87
+ status: "PASS",
88
+ outcome: "not_applicable",
89
+ method: "attribution_scan",
90
+ evidence: "no commit, PR or comment was created this session, so there was nothing to attribute",
91
+ ceiling: "a scan of the git/PR commands recorded in this transcript",
92
+ };
93
+ }
94
+ return {
95
+ ruleId: rule.id,
96
+ ruleTitle: rule.title,
97
+ ruleSource: rule.source,
98
+ status: "PASS",
99
+ outcome: "pass",
100
+ method: "attribution_scan",
101
+ evidence: "the git/PR commands this session ran carried no AI-attribution trailer",
102
+ ceiling: "reads the text of git/PR commands recorded in the transcript; a trailer the harness appends outside the recorded command is not visible here",
103
+ };
104
+ });
105
+ }
@@ -22,6 +22,18 @@ export interface EmojiClassification {
22
22
  rule: Rule;
23
23
  polarity: "forbid";
24
24
  }
25
+ export interface AttributionClassification {
26
+ kind: "attribution";
27
+ rule: Rule;
28
+ polarity: "forbid";
29
+ }
30
+ export interface ApprovalGateClassification {
31
+ kind: "approvalGate";
32
+ rule: Rule;
33
+ /** The gated actions this rule names, e.g. ["push", "delete"]. */
34
+ actions: string[];
35
+ polarity: "forbid";
36
+ }
25
37
  export interface JudgmentClassification {
26
38
  kind: "judgment";
27
39
  rule: Rule;
@@ -111,7 +123,7 @@ export interface ClaimEvidenceClassification {
111
123
  kind: "claimEvidence";
112
124
  rule: Rule;
113
125
  }
114
- export type Classification = ClaimEvidenceClassification | DeterministicClassification | IfEditThenTestClassification | GitBranchPolicyClassification | CodeContentClassification | FileLifecycleClassification | NotARuleClassification | EmojiClassification | JudgmentClassification;
126
+ export type Classification = ClaimEvidenceClassification | DeterministicClassification | IfEditThenTestClassification | GitBranchPolicyClassification | CodeContentClassification | FileLifecycleClassification | NotARuleClassification | EmojiClassification | AttributionClassification | ApprovalGateClassification | JudgmentClassification;
115
127
  export declare const DIRECTIVE_LANGUAGE: RegExp;
116
128
  /**
117
129
  * Imperative instruction — a bare command verb starting a clause ("Use
@@ -142,5 +154,6 @@ export declare function isEventRecord(rule: Rule): boolean;
142
154
  * is the forbidden command ("Never run: `rm -rf /`") is still a rule.
143
155
  */
144
156
  export declare function isCommandDocumentation(rule: Rule): boolean;
157
+ export declare function approvalGateActions(rule: Rule): string[];
145
158
  export declare function classifyRule(rule: Rule): Classification;
146
159
  export declare function classifyRules(rules: Rule[]): Classification[];
@@ -152,6 +152,12 @@ function isNotARule(rule) {
152
152
  const combined = `${rule.title} ${rule.text}`;
153
153
  if (DIRECTIVE_LANGUAGE.test(combined))
154
154
  return false;
155
+ // A gate over a concrete action ("before you delete data, wait for
156
+ // confirmation") is a rule, even when its imperative sits after a comma
157
+ // where IMPERATIVE_INSTRUCTION's clause-start anchor cannot see it. Placed
158
+ // after the event-record and command-doc filters so those still win.
159
+ if (isApprovalGateRule(rule))
160
+ return false;
155
161
  // Title and text are tested SEPARATELY: the imperative pattern is
156
162
  // anchored to a clause start, and concatenating them pushes the text's
157
163
  // opening verb into mid-string where the anchor can never match. That
@@ -493,6 +499,74 @@ function isEmojiRule(rule) {
493
499
  const window = text.slice(Math.max(0, m.index - 60), m.index + 40);
494
500
  return EMOJI_FORBID.test(window);
495
501
  }
502
+ /**
503
+ * A rule that forbids an AI-authorship mark in git commits, PRs or comments.
504
+ *
505
+ * Same shape as the emoji test: a subject (an attribution mark) and a
506
+ * prohibition next to it, plus a git/GitHub context so a general rule about
507
+ * crediting sources does not route here. Placed before the backtick pass
508
+ * because the rule quotes the exact trailer it forbids (`Co-Authored-By:
509
+ * Claude`), which would otherwise be extracted as a literal and searched for
510
+ * across written files — flagging the prohibition itself.
511
+ *
512
+ * From anthropics/claude-code#83813, #92169, #82690, #4287, one of the most-
513
+ * filed rule-following complaints. This repo's own CLAUDE.md carries this
514
+ * rule, so the check is dogfooded on every session here.
515
+ */
516
+ const ATTRIBUTION_SUBJECT = /co-?authored-by|generated with\s*\[?\s*claude|\bai\b[^.\n]{0,20}(?:trace|attribution|authorship)|\battribution\b/i;
517
+ const ATTRIBUTION_CONTEXT = /\b(?:commit|git|pull request|\bpr\b|github|co-?author)\b/i;
518
+ const ATTRIBUTION_FORBID = /\b(?:no|never|don't|do not|without|must not|shall not|not add|zero|forbid)\b/i;
519
+ function isAttributionRule(rule) {
520
+ const text = `${rule.title} ${rule.text}`;
521
+ if (!ATTRIBUTION_SUBJECT.test(text))
522
+ return false;
523
+ if (!ATTRIBUTION_CONTEXT.test(text))
524
+ return false;
525
+ if (!ATTRIBUTION_FORBID.test(text))
526
+ return false;
527
+ const m = text.match(ATTRIBUTION_SUBJECT);
528
+ if (!m || m.index === undefined)
529
+ return false;
530
+ const window = text.slice(Math.max(0, m.index - 60), m.index + 60);
531
+ return ATTRIBUTION_FORBID.test(window);
532
+ }
533
+ /**
534
+ * A rule that says the agent must ASK before a concrete, detectable action.
535
+ *
536
+ * "Wait for approval before you delete", "repeat-back before destructive
537
+ * actions", "ask first before you push". These already trip PRE_ACTION_GATE
538
+ * (which deflects them away from claimEvidence); routing them here lets the
539
+ * ONE mechanically-honest half be answered without the LLM: did the assistant
540
+ * seek approval before it did the thing.
541
+ *
542
+ * Only routes when the rule names an action this can actually find in a
543
+ * transcript — push, commit, delete/rm/drop/truncate. A gate over something
544
+ * vague ("ask before big changes") has no detectable action and stays a
545
+ * judgment call. The subtle half — whether a reply actually GRANTED approval,
546
+ * the #92505 "read my frustration as a yes" case — is not claimed here.
547
+ */
548
+ const APPROVAL_GATE_ACTIONS = [
549
+ { key: "push", inRule: /\bpush(?:es|ed|ing)?\b/i },
550
+ { key: "commit", inRule: /\bcommit(?:s|ted|ting)?\b/i },
551
+ { key: "delete", inRule: /\b(?:delet\w*|remov\w*|wip(?:e|ed|ing)?|truncat\w*|drop)\b|\brm\b/i },
552
+ ];
553
+ /**
554
+ * The rule must actually ask for sign-off, not merely order two things in
555
+ * time. "Run `npm test` before committing" gates a commit temporally but
556
+ * seeks no approval — it is a require-an-action rule, not this. Requiring an
557
+ * approval-seeking phrase (not the bare "before <action>" clause) is what
558
+ * keeps those out.
559
+ */
560
+ const APPROVAL_SIGNAL = /\b(?:repeat[- ]back|restate\s+what|wait\s+for\s+(?:confirmation|approval|explicit|sign[- ]?off)|ask\s+(?:first|before|for\s+(?:permission|approval|confirmation|sign[- ]?off))|get\s+(?:approval|sign[- ]?off|permission)|(?:explicit\s+)?(?:approval|confirmation|sign[- ]?off|permission)\s+(?:is\s+)?(?:required|needed|first)|confirm\s+(?:first|before))\b/i;
561
+ export function approvalGateActions(rule) {
562
+ const text = `${rule.title} ${rule.text}`;
563
+ if (!APPROVAL_SIGNAL.test(text))
564
+ return [];
565
+ return APPROVAL_GATE_ACTIONS.filter((a) => a.inRule.test(text)).map((a) => a.key);
566
+ }
567
+ function isApprovalGateRule(rule) {
568
+ return approvalGateActions(rule).length > 0;
569
+ }
496
570
  export function classifyRule(rule) {
497
571
  // Checked first: if this isn't a rule at all, no check of any kind
498
572
  // should run against it — not a keyword match, not an LLM call.
@@ -511,6 +585,18 @@ export function classifyRule(rule) {
511
585
  if (isEmojiRule(rule)) {
512
586
  return { kind: "emojiOutput", rule, polarity: "forbid" };
513
587
  }
588
+ // Checked before the backtick test: the rule quotes the exact trailer it
589
+ // forbids, which would otherwise be extracted as a literal and searched
590
+ // for across written files, flagging the prohibition itself.
591
+ if (isAttributionRule(rule)) {
592
+ return { kind: "attribution", rule, polarity: "forbid" };
593
+ }
594
+ // Checked before the backtick pass: these carry action verbs, not literals,
595
+ // and would otherwise fall through to judgment. Only the ones naming a
596
+ // detectable action route here; the rest stay judgment.
597
+ if (isApprovalGateRule(rule)) {
598
+ return { kind: "approvalGate", rule, actions: approvalGateActions(rule), polarity: "forbid" };
599
+ }
514
600
  const patterns = new Set();
515
601
  for (const match of rule.text.matchAll(BACKTICK_TOKEN)) {
516
602
  const token = match[1].trim();
package/dist/cli.js CHANGED
@@ -8,6 +8,8 @@ import { fileURLToPath } from "node:url";
8
8
  import { parseClaudeMd } from "./parsers/readClaudeMd.js";
9
9
  import { readLatestTranscript, readTranscriptFromFile, findLatestSessionFile } from "./parsers/transcriptParser.js";
10
10
  import { loadRules } from "./rules.js";
11
+ import { adviseRules } from "./checkability.js";
12
+ import { shadowedAgentsMd } from "./shadowedAgents.js";
11
13
  import { classifyRules } from "./checks/classify.js";
12
14
  import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
13
15
  import { runDeterministicChecks } from "./checks/deterministicChecks.js";
@@ -510,11 +512,50 @@ function runCoverage() {
510
512
  console.log(`log or inject context, but they cannot make a rule fail when it is ignored.`);
511
513
  }
512
514
  }
515
+ /**
516
+ * `rules --advise`: for every rule the classifier can't check mechanically,
517
+ * one line saying why and the smallest edit that would fix it. The other
518
+ * half of `--coverage` — that says which rules a hook might guard; this says
519
+ * which rules can't be checked at all, and how to change that.
520
+ */
521
+ function runAdvise() {
522
+ const cwd = process.cwd();
523
+ const rules = loadRules(cwd);
524
+ if (rules.length === 0) {
525
+ console.log("No CLAUDE.md or AGENTS.md found, so there are no rules to advise on.");
526
+ return;
527
+ }
528
+ const advice = adviseRules(rules);
529
+ const checkable = rules.length - advice.length;
530
+ console.log(`Rule checkability\n`);
531
+ console.log(` ${checkable} of ${rules.length} rule${rules.length === 1 ? "" : "s"} can be checked mechanically as written.`);
532
+ if (advice.length === 0) {
533
+ console.log(`\n Every rule names something a check can bind to. Nothing to fix.`);
534
+ return;
535
+ }
536
+ console.log(` ${advice.length} cannot yet — here is what each one needs:\n`);
537
+ // Project rules first: those are the ones the reader can act on today.
538
+ const ordered = advice
539
+ .map((a, i) => ({ a, source: rules.find((r) => r.title === a.ruleTitle)?.source }))
540
+ .sort((x, y) => Number(x.source === "global") - Number(y.source === "global"))
541
+ .map((x) => x.a);
542
+ for (const a of ordered) {
543
+ const tag = a.kind === "notARule" ? "not a rule?" : "judgment";
544
+ console.log(` [${tag}] ${a.ruleTitle.replace(/\s+/g, " ").trim().slice(0, 76)}`);
545
+ console.log(` -> ${a.suggestion}\n`);
546
+ }
547
+ console.log(`Naming the exact command, file or branch a rule is about — in backticks —`);
548
+ console.log(`is what turns a "wish list" line into one this tool can hold to account.`);
549
+ }
513
550
  async function runRules(opts) {
514
551
  if (opts.coverage) {
515
552
  runCoverage();
516
553
  return;
517
554
  }
555
+ if (opts.advise) {
556
+ runAdvise();
557
+ return;
558
+ }
518
559
  const cwd = process.cwd();
519
560
  const rules = loadRules(cwd);
520
561
  const overrides = loadOverrides(cwd);
@@ -740,6 +781,7 @@ program
740
781
  .option("--clear <handle>", "remove a stored correction")
741
782
  .option("--list", "show stored corrections (the default when no other flag is given)")
742
783
  .option("--coverage", "show which rules a configured hook might actually be enforcing, and which are prose only")
784
+ .option("--advise", "for each rule that can't be checked mechanically, show why and the smallest edit that would fix it")
743
785
  .action((opts) => {
744
786
  runRules(opts).catch((err) => {
745
787
  console.error("Something went wrong:", err instanceof Error ? err.message : err);
@@ -767,6 +809,7 @@ program
767
809
  hasAgentsMd: existsSync(join(cwd, "AGENTS.md")),
768
810
  hookInstalled: hookIsInstalled(cwd),
769
811
  hasApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
812
+ shadowedAgents: shadowedAgentsMd(cwd).map((s) => s.agents),
770
813
  }));
771
814
  });
772
815
  program
package/dist/evaluate.js CHANGED
@@ -6,6 +6,8 @@ import { runCodeContentChecks } from "./checks/codeContent.js";
6
6
  import { runFileLifecycleChecks } from "./checks/fileLifecycle.js";
7
7
  import { runClaimEvidenceChecks } from "./checks/claimEvidence.js";
8
8
  import { runEmojiChecks } from "./checks/emojiOutput.js";
9
+ import { runAttributionChecks } from "./checks/attribution.js";
10
+ import { runApprovalGateChecks } from "./checks/approvalGate.js";
9
11
  import { runJudgmentChecks } from "./checks/judgmentChecks.js";
10
12
  import { loadOverrides, ruleFingerprint, staleOverrides } from "./overrides.js";
11
13
  /**
@@ -40,6 +42,8 @@ export async function evaluateSession(cwd, rules, events, llm, needsLlmResult) {
40
42
  ...runFileLifecycleChecks(of("fileLifecycle"), events),
41
43
  ...runClaimEvidenceChecks(of("claimEvidence"), events),
42
44
  ...runEmojiChecks(of("emojiOutput"), events),
45
+ ...runAttributionChecks(of("attribution"), events),
46
+ ...runApprovalGateChecks(of("approvalGate"), events),
43
47
  ];
44
48
  const judgment = of("judgment");
45
49
  const judgmentResults = llm
package/dist/guard.js CHANGED
@@ -3,6 +3,7 @@ import { classifyRules } from "./checks/classify.js";
3
3
  import { runCodeContentChecks } from "./checks/codeContent.js";
4
4
  import { runFileLifecycleChecks } from "./checks/fileLifecycle.js";
5
5
  import { runGitBranchPolicyChecks } from "./checks/gitBranchPolicy.js";
6
+ import { runAttributionChecks } from "./checks/attribution.js";
6
7
  import { loadOverrides, ruleFingerprint, ratifiedForbids } from "./overrides.js";
7
8
  import { commandRunsLiteral } from "./checks/proposedAction.js";
8
9
  function readStdin() {
@@ -48,6 +49,11 @@ function structuredBlocks(cwd, event) {
48
49
  ...runCodeContentChecks(of("codeContent"), [event]),
49
50
  ...runFileLifecycleChecks(of("fileLifecycle"), [event]),
50
51
  ...runGitBranchPolicyChecks(of("gitBranchPolicy"), [event]),
52
+ // Prevention for the attribution rule: a commit/PR carrying a
53
+ // `Co-Authored-By: Claude` / "Generated with Claude Code" trailer is
54
+ // refused before it is made, not just reported after. Reuses the exact
55
+ // detection the report uses, so the two cannot disagree.
56
+ ...runAttributionChecks(of("attribution"), [event]),
51
57
  ];
52
58
  return results
53
59
  .filter((r) => r.status === "FAIL")
package/dist/init.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface InitState {
9
9
  hasAgentsMd: boolean;
10
10
  hookInstalled: boolean;
11
11
  hasApiKey: boolean;
12
+ /** AGENTS.md files a CLAUDE.md shadows, so Claude Code never loads them. */
13
+ shadowedAgents?: string[];
12
14
  }
13
15
  /** The PreToolUse guard hook, as it goes into .claude/settings.json. */
14
16
  export declare const GUARD_HOOK_SNIPPET = "{\n \"hooks\": {\n \"PreToolUse\": [\n { \"hooks\": [ { \"type\": \"command\", \"command\": \"rulereceipt guard\" } ] }\n ]\n }\n}";
package/dist/init.js CHANGED
@@ -41,6 +41,16 @@ export function buildInitGuidance(state) {
41
41
  " judgment graded. Without it those report UNCLEAR — deterministic checks run regardless,\n" +
42
42
  " and nothing is ever sent without the --llm flag.");
43
43
  }
44
+ const shadowed = state.shadowedAgents ?? [];
45
+ if (shadowed.length > 0) {
46
+ out.push("Warning — rules Claude Code never reads:");
47
+ for (const path of shadowed) {
48
+ out.push(` ${path} sits next to a CLAUDE.md, so Claude Code ignores it.`);
49
+ }
50
+ out.push(" Since 2026-09, AGENTS.md is only read when there is NO CLAUDE.md at that");
51
+ out.push(" level. Move these rules into the CLAUDE.md, or they govern nothing.");
52
+ out.push("");
53
+ }
44
54
  if (steps.length === 0) {
45
55
  out.push("You're set up. Run: rulereceipt check");
46
56
  }
@@ -14,4 +14,26 @@ export declare function parseLine(line: string): TranscriptEvent[];
14
14
  * not a failure.
15
15
  */
16
16
  export declare function readTranscriptFromFile(filePath: string): TranscriptEvent[];
17
+ /**
18
+ * Subagent transcripts for a session.
19
+ *
20
+ * Claude Code writes each subagent (a Task/background agent, up to 20 at once
21
+ * and 3 deep as of mid-2026) to its own JSONL under a directory named after
22
+ * the PARENT session id — verified against real files 2026-09-23:
23
+ * projects/<enc>/<sessionId>/subagents/agent-*.jsonl
24
+ * and each subagent line's own `sessionId` equals that parent id. So for a
25
+ * picked session file `<sessionId>.jsonl`, the subagents sit in a sibling
26
+ * directory named by its basename.
27
+ *
28
+ * These were invisible before: the reader took only the newest top-level
29
+ * file, so a rule broken by a subagent — the exact shape of the risk as
30
+ * Claude Code pushes toward fleets of unattended agents — was never checked.
31
+ *
32
+ * The events are appended to the main stream. Scan checks (git/file/
33
+ * attribution/emoji) simply gain more to inspect; claim-vs-evidence pairs on
34
+ * globally-unique tool ids so it cannot cross-match; the approval gate can at
35
+ * worst treat a main-session "ask" as covering a subagent action, which is a
36
+ * false negative — the safe direction for a tool that must not over-accuse.
37
+ */
38
+ export declare function findSubagentFiles(sessionFile: string): string[];
17
39
  export declare function readLatestTranscript(cwd: string): TranscriptEvent[];
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { join, dirname, basename } from "node:path";
4
4
  /**
5
5
  * Claude Code stores each session as a JSONL file at:
6
6
  * ~/.claude/projects/<cwd with every "/" replaced by "-">/<sessionId>.jsonl
@@ -171,9 +171,39 @@ export function readTranscriptFromFile(filePath) {
171
171
  }
172
172
  return events;
173
173
  }
174
+ /**
175
+ * Subagent transcripts for a session.
176
+ *
177
+ * Claude Code writes each subagent (a Task/background agent, up to 20 at once
178
+ * and 3 deep as of mid-2026) to its own JSONL under a directory named after
179
+ * the PARENT session id — verified against real files 2026-09-23:
180
+ * projects/<enc>/<sessionId>/subagents/agent-*.jsonl
181
+ * and each subagent line's own `sessionId` equals that parent id. So for a
182
+ * picked session file `<sessionId>.jsonl`, the subagents sit in a sibling
183
+ * directory named by its basename.
184
+ *
185
+ * These were invisible before: the reader took only the newest top-level
186
+ * file, so a rule broken by a subagent — the exact shape of the risk as
187
+ * Claude Code pushes toward fleets of unattended agents — was never checked.
188
+ *
189
+ * The events are appended to the main stream. Scan checks (git/file/
190
+ * attribution/emoji) simply gain more to inspect; claim-vs-evidence pairs on
191
+ * globally-unique tool ids so it cannot cross-match; the approval gate can at
192
+ * worst treat a main-session "ask" as covering a subagent action, which is a
193
+ * false negative — the safe direction for a tool that must not over-accuse.
194
+ */
195
+ export function findSubagentFiles(sessionFile) {
196
+ const sessionId = basename(sessionFile).replace(/\.jsonl$/, "");
197
+ const subagentDir = join(dirname(sessionFile), sessionId, "subagents");
198
+ return listSessionFiles(subagentDir);
199
+ }
174
200
  export function readLatestTranscript(cwd) {
175
201
  const filePath = findLatestSessionFile(cwd);
176
202
  if (!filePath)
177
203
  return [];
178
- return readTranscriptFromFile(filePath);
204
+ const events = readTranscriptFromFile(filePath);
205
+ for (const sub of findSubagentFiles(filePath)) {
206
+ events.push(...readTranscriptFromFile(sub));
207
+ }
208
+ return events;
179
209
  }
package/dist/rules.js CHANGED
@@ -13,8 +13,6 @@ import { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
13
13
  * the tool never opened is the most misleading result this can produce,
14
14
  * worse than no report, because it looks like evidence.
15
15
  */
16
- const RULE_FILE_NAMES = ["CLAUDE.md", "AGENTS.md", "CLAUDE.local.md", "AGENTS.local.md"];
17
- const RULE_SUBDIR_FILES = [join(".claude", "CLAUDE.md"), join(".claude", "AGENTS.md")];
18
16
  const RULE_DIRS = [join(".claude", "rules")];
19
17
  /**
20
18
  * Lists the markdown files in a rules directory, if it exists.
@@ -43,18 +41,30 @@ function markdownFilesIn(dir) {
43
41
  /** Every rules file at one directory level, in documented load order. */
44
42
  function ruleFilesAtLevel(dir) {
45
43
  const found = [];
46
- for (const rel of RULE_SUBDIR_FILES) {
44
+ const push = (rel) => {
47
45
  const p = join(dir, rel);
48
46
  if (existsSync(p))
49
47
  found.push(p);
50
- }
48
+ };
49
+ const has = (rel) => existsSync(join(dir, rel));
50
+ // CLAUDE.md shadows AGENTS.md at the same level: as of 2026-09-19 Claude
51
+ // Code loads AGENTS.md ONLY when that level has no CLAUDE.md, and silently
52
+ // ignores it otherwise. Reading a shadowed AGENTS.md here would check the
53
+ // session against rules Claude never loaded — a false accusation. `init`
54
+ // separately WARNS about the shadowed file (see shadowedAgents.ts) so the
55
+ // rules are not lost silently. Mirrored for the `.claude/` subdir pair.
56
+ push(join(".claude", "CLAUDE.md"));
57
+ if (!has(join(".claude", "CLAUDE.md")))
58
+ push(join(".claude", "AGENTS.md"));
51
59
  for (const rel of RULE_DIRS)
52
60
  found.push(...markdownFilesIn(join(dir, rel)));
53
- for (const name of RULE_FILE_NAMES) {
54
- const p = join(dir, name);
55
- if (existsSync(p))
56
- found.push(p);
57
- }
61
+ push("CLAUDE.md");
62
+ if (!has("CLAUDE.md"))
63
+ push("AGENTS.md");
64
+ // .local variants: their precedence relative to the base files is not
65
+ // documented, so both are kept rather than guessing at a shadow rule.
66
+ push("CLAUDE.local.md");
67
+ push("AGENTS.local.md");
58
68
  return found;
59
69
  }
60
70
  /**
@@ -0,0 +1,34 @@
1
+ /**
2
+ * An AGENTS.md that Claude Code never loads because a CLAUDE.md sits beside it.
3
+ *
4
+ * As of 2026-09-19, Claude Code reads AGENTS.md at a directory level ONLY when
5
+ * that level has no CLAUDE.md; if both exist, the AGENTS.md is silently
6
+ * ignored (InfoWorld / Enterprise DNA, 2026-09). So a rule a user carefully
7
+ * wrote into AGENTS.md next to a CLAUDE.md governs nothing — Claude never saw
8
+ * it.
9
+ *
10
+ * This matters to RuleReceipt in TWO ways:
11
+ * 1. A warning the user needs: "these rules are dead, move them into
12
+ * CLAUDE.md." That is what this surfaces.
13
+ * 2. A false-accusation risk in the tool itself: loadRules currently reads
14
+ * BOTH files, so it could report the session for breaking a shadowed
15
+ * AGENTS.md rule Claude never loaded. That deeper loading fix is tracked
16
+ * separately; this detector is the first, safe, additive step.
17
+ *
18
+ * Detection mirrors Claude Code's own precedence per directory level: a
19
+ * CLAUDE.md shadows an AGENTS.md at the same level, and the same for the
20
+ * `.claude/` subdirectory pair.
21
+ */
22
+ export interface ShadowedAgents {
23
+ /** The AGENTS.md that is being ignored. */
24
+ agents: string;
25
+ /** The CLAUDE.md at the same level that shadows it. */
26
+ shadowedBy: string;
27
+ }
28
+ /**
29
+ * Walks from cwd up to the repository root (inclusive), the same span
30
+ * loadRules reads project rules over, and returns every AGENTS.md shadowed by
31
+ * a CLAUDE.md. Global (home-dir) files are out of scope: that is a different
32
+ * precedence and a different fix.
33
+ */
34
+ export declare function shadowedAgentsMd(cwd: string): ShadowedAgents[];
@@ -0,0 +1,40 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, dirname, parse } from "node:path";
4
+ /** Directory-level pairs where a CLAUDE.md shadows an AGENTS.md. */
5
+ const SHADOW_PAIRS = [
6
+ { claude: "CLAUDE.md", agents: "AGENTS.md" },
7
+ { claude: join(".claude", "CLAUDE.md"), agents: join(".claude", "AGENTS.md") },
8
+ ];
9
+ /**
10
+ * Walks from cwd up to the repository root (inclusive), the same span
11
+ * loadRules reads project rules over, and returns every AGENTS.md shadowed by
12
+ * a CLAUDE.md. Global (home-dir) files are out of scope: that is a different
13
+ * precedence and a different fix.
14
+ */
15
+ export function shadowedAgentsMd(cwd) {
16
+ const found = [];
17
+ const { root } = parse(cwd);
18
+ const home = homedir();
19
+ let dir = cwd;
20
+ for (;;) {
21
+ if (dir === home && dir !== cwd)
22
+ break;
23
+ for (const { claude, agents } of SHADOW_PAIRS) {
24
+ const claudePath = join(dir, claude);
25
+ const agentsPath = join(dir, agents);
26
+ if (existsSync(claudePath) && existsSync(agentsPath)) {
27
+ found.push({ agents: agentsPath, shadowedBy: claudePath });
28
+ }
29
+ }
30
+ if (existsSync(join(dir, ".git")))
31
+ break;
32
+ if (dir === root)
33
+ break;
34
+ const parent = dirname(dir);
35
+ if (parent === dir)
36
+ break;
37
+ dir = parent;
38
+ }
39
+ return found;
40
+ }
package/dist/types.d.ts CHANGED
@@ -64,7 +64,7 @@ export type CheckOutcome = "pass" | "fail"
64
64
  /** How a verdict was reached. A verdict with no method is a verdict with no standing. */
65
65
  export type CheckMethod = "text_scan" | "file_events" | "git_events" | "code_content" | "edit_test_pairing" | "claim_vs_evidence" | "model_judgment"
66
66
  /** Nothing ran. */
67
- | "none" | "emoji_output";
67
+ | "none" | "emoji_output" | "attribution_scan" | "approval_gate";
68
68
  export interface CheckResult {
69
69
  ruleId: string;
70
70
  ruleTitle: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "Checks whether a Claude Code session actually followed your CLAUDE.md / AGENTS.md rules, with evidence.",
5
5
  "repository": {
6
6
  "type": "git",