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.
- package/dist/checkability.d.ts +31 -0
- package/dist/checkability.js +68 -0
- package/dist/checks/approvalGate.d.ts +3 -0
- package/dist/checks/approvalGate.js +105 -0
- package/dist/checks/attribution.d.ts +3 -0
- package/dist/checks/attribution.js +105 -0
- package/dist/checks/classify.d.ts +14 -1
- package/dist/checks/classify.js +86 -0
- package/dist/cli.js +43 -0
- package/dist/evaluate.js +4 -0
- package/dist/guard.js +6 -0
- package/dist/init.d.ts +2 -0
- package/dist/init.js +10 -0
- package/dist/parsers/transcriptParser.d.ts +22 -0
- package/dist/parsers/transcriptParser.js +32 -2
- package/dist/rules.js +19 -9
- package/dist/shadowedAgents.d.ts +34 -0
- package/dist/shadowedAgents.js +40 -0
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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,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,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[];
|
package/dist/checks/classify.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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;
|