rulereceipt 0.1.75 → 0.1.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/dist/blockHint.d.ts +49 -0
- package/dist/blockHint.js +87 -0
- package/dist/breakContext.d.ts +49 -0
- package/dist/breakContext.js +129 -0
- package/dist/cli.js +31 -8
- package/dist/historyReport.js +2 -0
- package/dist/report/generateReport.d.ts +6 -1
- package/dist/report/generateReport.js +14 -1
- package/dist/rules.js +19 -8
- package/dist/why.d.ts +27 -0
- package/dist/why.js +142 -40
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,15 +6,21 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/rulereceipt)
|
|
7
7
|
[](https://www.npmjs.com/package/rulereceipt#provenance)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
**Check if your AI coding agent followed the rules in your CLAUDE.md, with the exact line as proof.**
|
|
10
|
+
|
|
11
|
+
[](https://rulereceipt.dev)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx rulereceipt
|
|
15
|
+
```
|
|
14
16
|
|
|
15
17
|
Runs entirely on your machine. Plain `rulereceipt check` makes zero network
|
|
16
|
-
calls — [Trust, privacy and licensing](#trust-privacy-and-licensing) has the
|
|
17
|
-
|
|
18
|
+
calls — [Trust, privacy and licensing](#trust-privacy-and-licensing) has the full
|
|
19
|
+
detail, including the three off-by-default opt-ins. Works with Claude Code today
|
|
20
|
+
(OpenAI Codex CLI in testing); reads rules from CLAUDE.md, AGENTS.md, Cursor
|
|
21
|
+
(`.cursor/rules`), GitHub Copilot, Windsurf, Gemini (`GEMINI.md`), Google's
|
|
22
|
+
`.agents/rules`, and Claude Code memory. [Accuracy](https://rulereceipt.dev/accuracy)
|
|
23
|
+
· [Known gaps](KNOWN-GAPS.md) · Source-available, not OSI — see [LICENSE](LICENSE).
|
|
18
24
|
|
|
19
25
|
## See it in 10 seconds
|
|
20
26
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Classification } from "./checks/classify.js";
|
|
2
|
+
/**
|
|
3
|
+
* `why` answers "why isn't THIS rule working?". For a rule that names a
|
|
4
|
+
* concrete action, the honest follow-up is "then how do I actually stop it?".
|
|
5
|
+
* This computes that answer — and it is deliberately pinned to what the tool
|
|
6
|
+
* really does, because an overstated "add this and you're safe" is exactly the
|
|
7
|
+
* failure RuleReceipt exists to catch.
|
|
8
|
+
*
|
|
9
|
+
* Two facts, kept separate because they are not the same promise:
|
|
10
|
+
*
|
|
11
|
+
* - `guardCovers`: RuleReceipt's own PreToolUse guard refuses this exact rule
|
|
12
|
+
* before the action runs. True only for the kinds guardDecision/structuredBlocks
|
|
13
|
+
* actually handle (a branch, a file, forbidden file content, an AI-authorship
|
|
14
|
+
* trailer) and for an approval gate (which it answers "ask", or "deny" in a
|
|
15
|
+
* no-prompt mode). It is NOT a general command blocker — that was measured and
|
|
16
|
+
* cut (see guard.ts), so nothing here claims it.
|
|
17
|
+
*
|
|
18
|
+
* - `native`: a Claude Code `permissions` entry the user can add by hand with no
|
|
19
|
+
* extra tool. Only emitted where a permission rule can genuinely express the
|
|
20
|
+
* thing, and always with the honest caveat: a permission rule matches the
|
|
21
|
+
* command text or the file path, so it cannot scope to a branch, cannot read a
|
|
22
|
+
* commit message, and cannot see file content. Where it can't express the rule,
|
|
23
|
+
* `nativeImpossibleReason` says so instead of inventing a rule that wouldn't fire.
|
|
24
|
+
*
|
|
25
|
+
* `preventable: false` is the honest answer for the rest: a claim-evidence rule,
|
|
26
|
+
* an emoji rule, an edit-implies-test rule, a plain literal rule — these are judged
|
|
27
|
+
* AFTER the run, not blockable before an action. The caller points at `check` and
|
|
28
|
+
* the Stop hook instead.
|
|
29
|
+
*/
|
|
30
|
+
export interface BlockHint {
|
|
31
|
+
/** Can an action be refused BEFORE it runs (guard or a native deny/ask)? */
|
|
32
|
+
preventable: boolean;
|
|
33
|
+
/** Does RuleReceipt's own guard (`rulereceipt protect`) check this exact rule pre-flight? */
|
|
34
|
+
guardCovers: boolean;
|
|
35
|
+
/** A native Claude Code permissions entry, where one can genuinely express the rule. */
|
|
36
|
+
native?: {
|
|
37
|
+
kind: "deny" | "ask";
|
|
38
|
+
entries: string[];
|
|
39
|
+
note: string;
|
|
40
|
+
};
|
|
41
|
+
/** When preventable but a native permission rule cannot express it, why. */
|
|
42
|
+
nativeImpossibleReason?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The block advice for one classified rule, or undefined when there is nothing
|
|
46
|
+
* useful to say (a judgment rule or a non-rule — the "needs your judgment" line
|
|
47
|
+
* already covers those).
|
|
48
|
+
*/
|
|
49
|
+
export declare function blockHintFor(cls: Classification): BlockHint | undefined;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Map an approval-gate action to the Claude Code permission pattern that matches it. */
|
|
2
|
+
function askEntryFor(action) {
|
|
3
|
+
switch (action) {
|
|
4
|
+
case "push": return "Bash(git push:*)";
|
|
5
|
+
case "commit": return "Bash(git commit:*)";
|
|
6
|
+
case "pr": return "Bash(gh pr:*)";
|
|
7
|
+
case "delete": return "Bash(rm:*)";
|
|
8
|
+
default: return undefined;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The block advice for one classified rule, or undefined when there is nothing
|
|
13
|
+
* useful to say (a judgment rule or a non-rule — the "needs your judgment" line
|
|
14
|
+
* already covers those).
|
|
15
|
+
*/
|
|
16
|
+
export function blockHintFor(cls) {
|
|
17
|
+
switch (cls.kind) {
|
|
18
|
+
case "gitBranchPolicy": {
|
|
19
|
+
if (cls.polarity !== "forbid")
|
|
20
|
+
return { preventable: false, guardCovers: false };
|
|
21
|
+
return {
|
|
22
|
+
preventable: true,
|
|
23
|
+
guardCovers: true,
|
|
24
|
+
native: {
|
|
25
|
+
kind: "deny",
|
|
26
|
+
entries: ["Bash(git push:*)"],
|
|
27
|
+
note: `a permission rule matches the command text, so this denies EVERY push — it can't ` +
|
|
28
|
+
`scope to the \`${cls.branchName}\` branch. The guard below checks the actual target branch.`,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
case "fileLifecycle": {
|
|
33
|
+
if (cls.polarity !== "forbid")
|
|
34
|
+
return { preventable: false, guardCovers: false };
|
|
35
|
+
return {
|
|
36
|
+
preventable: true,
|
|
37
|
+
guardCovers: true,
|
|
38
|
+
native: {
|
|
39
|
+
kind: "deny",
|
|
40
|
+
entries: [`Edit(${cls.filePath})`, `Write(${cls.filePath})`],
|
|
41
|
+
note: `covers Edit/Write of that path; a Bash \`rm\`/\`mv\` targeting it is NOT matched by ` +
|
|
42
|
+
`these — the guard below covers those too.`,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
case "approvalGate": {
|
|
47
|
+
const entries = cls.actions.map(askEntryFor).filter((e) => e !== undefined);
|
|
48
|
+
if (entries.length === 0)
|
|
49
|
+
return { preventable: true, guardCovers: true, nativeImpossibleReason: "the action it gates isn't one a permission rule can match" };
|
|
50
|
+
return {
|
|
51
|
+
preventable: true,
|
|
52
|
+
guardCovers: true,
|
|
53
|
+
native: {
|
|
54
|
+
kind: "ask",
|
|
55
|
+
entries,
|
|
56
|
+
note: `an "ask" is skipped in no-prompt modes (bypassPermissions / auto), so the action would ` +
|
|
57
|
+
`run there without a prompt. The guard below denies it in those modes instead.`,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
case "attribution":
|
|
62
|
+
return {
|
|
63
|
+
preventable: true,
|
|
64
|
+
guardCovers: true,
|
|
65
|
+
nativeImpossibleReason: "a permission rule can't read a commit message, so it can't catch an AI-authorship trailer",
|
|
66
|
+
};
|
|
67
|
+
case "codeContent": {
|
|
68
|
+
if (cls.polarity !== "forbid")
|
|
69
|
+
return { preventable: false, guardCovers: false };
|
|
70
|
+
return {
|
|
71
|
+
preventable: true,
|
|
72
|
+
guardCovers: true,
|
|
73
|
+
nativeImpossibleReason: "a permission rule matches the command or path, not the content written into a file",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
case "claimEvidence":
|
|
77
|
+
case "ifEditThenTest":
|
|
78
|
+
case "emojiOutput":
|
|
79
|
+
case "deterministic":
|
|
80
|
+
// Checkable, but only after the run — there is no single action to refuse
|
|
81
|
+
// beforehand. The caller points at `check` and the Stop hook.
|
|
82
|
+
return { preventable: false, guardCovers: false };
|
|
83
|
+
default:
|
|
84
|
+
// judgment, notARule — nothing mechanical to block or check.
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A4 — "why it broke" context for a single proven break.
|
|
3
|
+
*
|
|
4
|
+
* The report says WHICH rule broke and quotes the line. This adds the three
|
|
5
|
+
* things a person asks next, read straight from the raw transcript (the parsed
|
|
6
|
+
* event stream drops attachment/system/compaction lines, so this works on the
|
|
7
|
+
* file text `check` already has):
|
|
8
|
+
*
|
|
9
|
+
* 1. the user's own message just before the break,
|
|
10
|
+
* 2. whether the rules file (CLAUDE.md/AGENTS.md/GEMINI.md) was in context
|
|
11
|
+
* BEFORE the break at all,
|
|
12
|
+
* 3. whether a compaction happened earlier in the session.
|
|
13
|
+
*
|
|
14
|
+
* The point of (2) is honesty, not accusation. Claude Code can load CLAUDE.md
|
|
15
|
+
* only when a Read touches its directory, so a shell-heavy session may never
|
|
16
|
+
* have the rule in context. When that is the case the report must say "the rules
|
|
17
|
+
* file was not in context here", NOT imply the agent ignored a rule it never saw
|
|
18
|
+
* — and it points at the fix (a SessionStart / post-compaction hook that injects
|
|
19
|
+
* the rules). Nothing here changes a verdict; it only explains one.
|
|
20
|
+
*
|
|
21
|
+
* If the break line cannot be located in the transcript (evidence with no
|
|
22
|
+
* quotable fragment), `located` is false and the caller shows nothing rather
|
|
23
|
+
* than guessing.
|
|
24
|
+
*/
|
|
25
|
+
export interface BreakContext {
|
|
26
|
+
located: boolean;
|
|
27
|
+
/** The user's own typed message just before the break, clipped; undefined if none. */
|
|
28
|
+
precedingUser?: string;
|
|
29
|
+
/** Did a rules file appear in the transcript before the break? */
|
|
30
|
+
rulesInContext: boolean;
|
|
31
|
+
/** Did a compaction occur before the break? */
|
|
32
|
+
compactionBefore: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The rules file was in context earlier, but its last appearance was BEFORE
|
|
35
|
+
* the last compaction and it was not re-injected after — so the summary may
|
|
36
|
+
* have dropped it. (The real session ef53e676: CLAUDE.md present before a
|
|
37
|
+
* compaction, never came back.)
|
|
38
|
+
*/
|
|
39
|
+
rulesStaleAfterCompaction: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Context for the break whose evidence is `evidence`, read from the raw JSONL
|
|
43
|
+
* `transcriptText`. Line-based: the break line is the LAST line carrying a
|
|
44
|
+
* distinctive fragment of the evidence (so a later, unrelated mention does not
|
|
45
|
+
* win), and the three facts are computed over the lines before it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function breakContext(transcriptText: string, evidence: string): BreakContext;
|
|
48
|
+
/** The lines the report prints under a break, or [] when nothing is worth adding. */
|
|
49
|
+
export declare function renderBreakContext(ctx: BreakContext): string[];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A4 — "why it broke" context for a single proven break.
|
|
3
|
+
*
|
|
4
|
+
* The report says WHICH rule broke and quotes the line. This adds the three
|
|
5
|
+
* things a person asks next, read straight from the raw transcript (the parsed
|
|
6
|
+
* event stream drops attachment/system/compaction lines, so this works on the
|
|
7
|
+
* file text `check` already has):
|
|
8
|
+
*
|
|
9
|
+
* 1. the user's own message just before the break,
|
|
10
|
+
* 2. whether the rules file (CLAUDE.md/AGENTS.md/GEMINI.md) was in context
|
|
11
|
+
* BEFORE the break at all,
|
|
12
|
+
* 3. whether a compaction happened earlier in the session.
|
|
13
|
+
*
|
|
14
|
+
* The point of (2) is honesty, not accusation. Claude Code can load CLAUDE.md
|
|
15
|
+
* only when a Read touches its directory, so a shell-heavy session may never
|
|
16
|
+
* have the rule in context. When that is the case the report must say "the rules
|
|
17
|
+
* file was not in context here", NOT imply the agent ignored a rule it never saw
|
|
18
|
+
* — and it points at the fix (a SessionStart / post-compaction hook that injects
|
|
19
|
+
* the rules). Nothing here changes a verdict; it only explains one.
|
|
20
|
+
*
|
|
21
|
+
* If the break line cannot be located in the transcript (evidence with no
|
|
22
|
+
* quotable fragment), `located` is false and the caller shows nothing rather
|
|
23
|
+
* than guessing.
|
|
24
|
+
*/
|
|
25
|
+
// A rules file being injected into context. Covers the literal injected header
|
|
26
|
+
// ("Contents of .../CLAUDE.md (project instructions"), the "checked into" variant,
|
|
27
|
+
// and the structural claudeMd attachment (escaped or not inside a JSONL line).
|
|
28
|
+
const RULES_INJECTION = /Contents of [^\n"]*(?:CLAUDE|AGENTS|GEMINI|AGENT)[^\n"]*\.md \(project instructions|project instructions, checked into|\\?"(?:claudeMd|type\\?":\\?"claudeMd)\\?"|\\?"type\\?":\s*\\?"claudeMd/;
|
|
29
|
+
const COMPACTION = /"isCompactSummary"\s*:\s*true/;
|
|
30
|
+
/** Longest-first distinctive fragments of the evidence to find the break line by. */
|
|
31
|
+
function needles(evidence) {
|
|
32
|
+
const quoted = [...evidence.matchAll(/"([^"]{6,})"/g)].map((m) => m[1]);
|
|
33
|
+
const after = evidence.split(/:\s/).slice(1).join(": ");
|
|
34
|
+
return [...quoted, after]
|
|
35
|
+
.map((s) => s.replace(/\s+/g, " ").trim().slice(0, 80))
|
|
36
|
+
.filter((s) => s.length >= 6)
|
|
37
|
+
.sort((a, b) => b.length - a.length);
|
|
38
|
+
}
|
|
39
|
+
/** The human's own message on a user line (string content), or null. */
|
|
40
|
+
function userTyped(line) {
|
|
41
|
+
try {
|
|
42
|
+
const o = JSON.parse(line);
|
|
43
|
+
if (o.type !== "user")
|
|
44
|
+
return null;
|
|
45
|
+
const c = o.message?.content;
|
|
46
|
+
if (typeof c === "string" && c.trim().length > 0)
|
|
47
|
+
return c.trim();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* partial line */
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function clip(s, n = 140) {
|
|
55
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
56
|
+
if (one.length <= n)
|
|
57
|
+
return one;
|
|
58
|
+
const cut = one.slice(0, n);
|
|
59
|
+
const sp = cut.lastIndexOf(" ");
|
|
60
|
+
return `${sp > n * 0.6 ? cut.slice(0, sp) : cut}…`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Context for the break whose evidence is `evidence`, read from the raw JSONL
|
|
64
|
+
* `transcriptText`. Line-based: the break line is the LAST line carrying a
|
|
65
|
+
* distinctive fragment of the evidence (so a later, unrelated mention does not
|
|
66
|
+
* win), and the three facts are computed over the lines before it.
|
|
67
|
+
*/
|
|
68
|
+
export function breakContext(transcriptText, evidence) {
|
|
69
|
+
const lines = transcriptText.split(/\r?\n/);
|
|
70
|
+
const ns = needles(evidence);
|
|
71
|
+
let breakIdx = -1;
|
|
72
|
+
if (ns.length > 0) {
|
|
73
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
74
|
+
const flat = lines[i].replace(/\s+/g, " ");
|
|
75
|
+
if (ns.some((n) => flat.includes(n))) {
|
|
76
|
+
breakIdx = i;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (breakIdx === -1)
|
|
82
|
+
return { located: false, rulesInContext: false, compactionBefore: false, rulesStaleAfterCompaction: false };
|
|
83
|
+
let lastRulesIdx = -1;
|
|
84
|
+
let lastCompactionIdx = -1;
|
|
85
|
+
let precedingUser;
|
|
86
|
+
for (let i = 0; i < breakIdx; i++) {
|
|
87
|
+
const line = lines[i];
|
|
88
|
+
if (RULES_INJECTION.test(line))
|
|
89
|
+
lastRulesIdx = i;
|
|
90
|
+
if (COMPACTION.test(line))
|
|
91
|
+
lastCompactionIdx = i;
|
|
92
|
+
const u = userTyped(line);
|
|
93
|
+
if (u)
|
|
94
|
+
precedingUser = clip(u);
|
|
95
|
+
}
|
|
96
|
+
const rulesInContext = lastRulesIdx !== -1;
|
|
97
|
+
const compactionBefore = lastCompactionIdx !== -1;
|
|
98
|
+
return {
|
|
99
|
+
located: true,
|
|
100
|
+
precedingUser,
|
|
101
|
+
rulesInContext,
|
|
102
|
+
compactionBefore,
|
|
103
|
+
rulesStaleAfterCompaction: rulesInContext && compactionBefore && lastRulesIdx < lastCompactionIdx,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** The lines the report prints under a break, or [] when nothing is worth adding. */
|
|
107
|
+
export function renderBreakContext(ctx) {
|
|
108
|
+
if (!ctx.located)
|
|
109
|
+
return [];
|
|
110
|
+
const out = [" why it broke:"];
|
|
111
|
+
if (ctx.precedingUser)
|
|
112
|
+
out.push(` just before, you said: "${ctx.precedingUser}"`);
|
|
113
|
+
if (!ctx.rulesInContext) {
|
|
114
|
+
out.push(" your rules file was NOT in context at this point — not the agent ignoring a");
|
|
115
|
+
out.push(" rule it never saw. Claude Code can load CLAUDE.md only when a Read touches its");
|
|
116
|
+
out.push(" directory, so a shell-heavy session can miss it. Fix: a SessionStart (and");
|
|
117
|
+
out.push(" post-compaction) hook that injects your rules every session.");
|
|
118
|
+
}
|
|
119
|
+
else if (ctx.rulesStaleAfterCompaction) {
|
|
120
|
+
out.push(" your rules file was in context earlier but NOT after the last compaction — the");
|
|
121
|
+
out.push(" summary may have dropped it. Fix: a post-compaction hook that re-injects your rules.");
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
out.push(" your rules file was in context before this.");
|
|
125
|
+
if (ctx.compactionBefore)
|
|
126
|
+
out.push(" (a compaction happened earlier in this session; context before it was summarised.)");
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -19,7 +19,7 @@ import { ghReady, issueTitle, issueCreateArgs, buildMailto, mailtoSubject } from
|
|
|
19
19
|
import { spawnSync } from "node:child_process";
|
|
20
20
|
import { detectSelfEditedRuleFiles } from "./checks/selfEditedRules.js";
|
|
21
21
|
import { scanHistory, renderHistory } from "./historyReport.js";
|
|
22
|
-
import { explainRule, renderWhy } from "./why.js";
|
|
22
|
+
import { explainRule, renderWhy, explainAll, renderAllWhy, whyList, renderWhyList } from "./why.js";
|
|
23
23
|
import { observeSessions, renderNoRules, draftRulesFromHistory } from "./sessionObserve.js";
|
|
24
24
|
import { listSessionRows, renderSessionList } from "./listSessions.js";
|
|
25
25
|
import { runSelfTestChecks, renderSelfTest } from "./selftest.js";
|
|
@@ -282,7 +282,17 @@ async function runCheck(opts) {
|
|
|
282
282
|
: "";
|
|
283
283
|
// Kept in human/markdown form for --email and any other reader below, even
|
|
284
284
|
// when stdout is JSON — a manager gets a readable report, not raw JSON.
|
|
285
|
-
|
|
285
|
+
// The raw session text, for A4 "why it broke" context under each Broken verdict.
|
|
286
|
+
// Best-effort: if it can't be read, the report simply omits the context.
|
|
287
|
+
let transcriptText;
|
|
288
|
+
try {
|
|
289
|
+
if (sessionFilePath)
|
|
290
|
+
transcriptText = readFileSync(sessionFilePath, "utf-8");
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* unreadable: no A4 context, never a crash */
|
|
294
|
+
}
|
|
295
|
+
const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta, transcriptText);
|
|
286
296
|
if (json) {
|
|
287
297
|
console.log(generateJsonReport(results, meta, pkg.version, editedRuleFiles));
|
|
288
298
|
}
|
|
@@ -572,7 +582,7 @@ function runAdvise() {
|
|
|
572
582
|
console.log(` ${advice.length} cannot yet — here is what each one needs:\n`);
|
|
573
583
|
// Project rules first: those are the ones the reader can act on today.
|
|
574
584
|
const ordered = advice
|
|
575
|
-
.map((a
|
|
585
|
+
.map((a) => ({ a, source: rules.find((r) => r.title === a.ruleTitle)?.source }))
|
|
576
586
|
.sort((x, y) => Number(x.source === "global") - Number(y.source === "global"))
|
|
577
587
|
.map((x) => x.a);
|
|
578
588
|
for (const a of ordered) {
|
|
@@ -931,7 +941,7 @@ const PERIOD_MS = {
|
|
|
931
941
|
};
|
|
932
942
|
program
|
|
933
943
|
.command("report")
|
|
934
|
-
.description("Compliance report across your recent sessions (not just the latest): which policy rules were broken, where, with evidence. Deterministic, local, no network.
|
|
944
|
+
.description("Compliance report across your recent sessions (not just the latest): which policy rules were broken, where, with evidence. Deterministic, local, no network. An org-wide version (multi-repo, trends, a manager digest) is coming in the team version.")
|
|
935
945
|
.option("--last <n>", "how many recent sessions to audit", "25")
|
|
936
946
|
.option("--markdown", "output as markdown, for a report you can send")
|
|
937
947
|
.action(async (opts) => {
|
|
@@ -953,18 +963,31 @@ program
|
|
|
953
963
|
console.log(renderProjectAudit(a, Boolean(opts.markdown)));
|
|
954
964
|
});
|
|
955
965
|
program
|
|
956
|
-
.command("why
|
|
957
|
-
.description("Everything the tool knows about ONE rule, in one place: where it lives (file:line), whether the agent actually loads it, whether a command or path it names exists, whether it's mechanically checkable (and if not, one suggested rewrite), and how it did over the last 30 days.
|
|
966
|
+
.command("why [rule...]")
|
|
967
|
+
.description("Everything the tool knows about ONE rule, in one place: where it lives (file:line), whether the agent actually loads it, whether a command or path it names exists, whether it's mechanically checkable (and if not, one suggested rewrite), and how it did over the last 30 days. With no argument, lists every rule with its id so you can pick one; with --all, shows every rule (problems first). Read-only — no verdict is created, nothing is sent.")
|
|
968
|
+
.option("--all", "show every rule, problems first (not loaded, missing command, broken recently), then the rest")
|
|
958
969
|
.option("--json", "output machine-readable JSON (the same fields)")
|
|
959
970
|
.action(async (ruleWords, opts) => {
|
|
960
971
|
const cwd = process.cwd();
|
|
961
|
-
const query = ruleWords.join(" ").trim();
|
|
972
|
+
const query = (ruleWords ?? []).join(" ").trim();
|
|
962
973
|
const rules = loadRules(cwd);
|
|
963
974
|
if (rules.length === 0) {
|
|
964
975
|
console.log("No rules file found here, so there is nothing to explain. Run `rulereceipt init` to add one.");
|
|
965
976
|
process.exitCode = 1;
|
|
966
977
|
return;
|
|
967
978
|
}
|
|
979
|
+
// --all: every rule, problems first.
|
|
980
|
+
if (opts.all) {
|
|
981
|
+
const all = await explainAll(cwd);
|
|
982
|
+
console.log(opts.json ? JSON.stringify(all, null, 2) : renderAllWhy(all));
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
// No argument: list the rules with ids so the reader can pick one.
|
|
986
|
+
if (query.length === 0) {
|
|
987
|
+
const list = whyList(cwd);
|
|
988
|
+
console.log(opts.json ? JSON.stringify(list, null, 2) : renderWhyList(list));
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
968
991
|
const result = await explainRule(cwd, query);
|
|
969
992
|
if (opts.json) {
|
|
970
993
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -976,7 +999,7 @@ program
|
|
|
976
999
|
});
|
|
977
1000
|
program
|
|
978
1001
|
.command("wrong <rule>")
|
|
979
|
-
.description("A verdict looks wrong? Builds a report of that rule, the verdict, how it was decided and the session lines around it, with obvious secrets masked. Written to a local file and shown first
|
|
1002
|
+
.description("A verdict looks wrong? Builds a report of that rule, the verdict, how it was decided and the session lines around it, with obvious secrets masked. Written to a local file and shown first. Then --submit opens a public GitHub issue (asks first; needs gh) or --email sends it privately to the maintainer; with no flag it just prints the report and a pre-filled link. Nothing is sent without your say-so.")
|
|
980
1003
|
.option("--transcript <path>", "use a specific session file (same as check)")
|
|
981
1004
|
.option("--out <path>", "where to write the report (default .rulereceipt/wrong-<handle>.md)")
|
|
982
1005
|
.option("--no-context", "leave out the session lines around the evidence")
|
package/dist/historyReport.js
CHANGED
|
@@ -150,6 +150,8 @@ export function renderHistory(s, projectName, now = Date.now()) {
|
|
|
150
150
|
out.push(`checked ${s.sessionsScanned} session${s.sessionsScanned === 1 ? "" : "s"} in ${secs}s`);
|
|
151
151
|
out.push("");
|
|
152
152
|
out.push("See one session in full: rulereceipt check");
|
|
153
|
+
out.push("Make Claude ask first: rulereceipt protect");
|
|
154
|
+
out.push("Share the result: rulereceipt card");
|
|
153
155
|
out.push("Think a verdict is wrong? rulereceipt wrong <rule>");
|
|
154
156
|
return out.join("\n");
|
|
155
157
|
}
|
|
@@ -11,7 +11,12 @@ export interface ReportMeta {
|
|
|
11
11
|
* is a valid state (e.g. running against demo data).
|
|
12
12
|
*/
|
|
13
13
|
export declare function computeTranscriptHash(sessionFilePath: string | null): string | null;
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* `transcriptText` is the raw session JSONL. When present, each Broken verdict
|
|
16
|
+
* gets A4 "why it broke" context (the user message before it, whether the rules
|
|
17
|
+
* file was in context, whether a compaction preceded it) read straight from it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function generateReport(results: CheckResult[], meta: ReportMeta, transcriptText?: string): string;
|
|
15
20
|
export declare function generateMarkdownReport(results: CheckResult[], meta: ReportMeta): string;
|
|
16
21
|
/**
|
|
17
22
|
* Machine-readable output for CI, a GitHub Action, or any other consumer.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { breakContext, renderBreakContext } from "../breakContext.js";
|
|
4
5
|
/**
|
|
5
6
|
* Where a rule lives, for the report — "~/proj/CLAUDE.md:42", or just the path
|
|
6
7
|
* when the parser could not place a line, or "" when the rule's location was
|
|
@@ -191,7 +192,12 @@ function sharedEvidence(rs) {
|
|
|
191
192
|
}
|
|
192
193
|
return best;
|
|
193
194
|
}
|
|
194
|
-
|
|
195
|
+
/**
|
|
196
|
+
* `transcriptText` is the raw session JSONL. When present, each Broken verdict
|
|
197
|
+
* gets A4 "why it broke" context (the user message before it, whether the rules
|
|
198
|
+
* file was in context, whether a compaction preceded it) read straight from it.
|
|
199
|
+
*/
|
|
200
|
+
export function generateReport(results, meta, transcriptText) {
|
|
195
201
|
const clean = results.map(sanitize);
|
|
196
202
|
const lines = [];
|
|
197
203
|
lines.push(`RuleReceipt · ${meta.ruleCount} rules checked`);
|
|
@@ -236,6 +242,13 @@ export function generateReport(results, meta) {
|
|
|
236
242
|
// versions as a PASS on a session that ran `git push -f`.
|
|
237
243
|
if (r.ceiling)
|
|
238
244
|
lines.push(` this means: ${r.ceiling}`);
|
|
245
|
+
// A4: why it broke — the context around a proven break, read from the raw
|
|
246
|
+
// transcript. Honest by construction: if the rules file was never in
|
|
247
|
+
// context before the break, it says so rather than implying it was ignored.
|
|
248
|
+
if (r.status === "FAIL" && transcriptText && r.evidence) {
|
|
249
|
+
for (const l of renderBreakContext(breakContext(transcriptText, r.evidence)))
|
|
250
|
+
lines.push(l);
|
|
251
|
+
}
|
|
239
252
|
}
|
|
240
253
|
}
|
|
241
254
|
lines.push("");
|
package/dist/rules.js
CHANGED
|
@@ -75,20 +75,31 @@ function ruleSourcesAtLevel(dir) {
|
|
|
75
75
|
for (const f of markdownFilesIn(join(dir, rel)))
|
|
76
76
|
out.push({ path: f, status: "loaded", format: ".claude/rules" });
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
// AGENTS.md / AGENT.md load ONLY when this level has no Claude file at all.
|
|
79
|
+
// As of Claude Code 2.1.277 (default "Project instructions" = claude-md-or-
|
|
80
|
+
// agents-md), AGENTS.md is read only when none of CLAUDE.md / CLAUDE.local.md
|
|
81
|
+
// (nor .claude/CLAUDE.md, handled above) exists — so CLAUDE.local.md ALSO
|
|
82
|
+
// shadows AGENTS.md, not just CLAUDE.md. Checking a shadowed AGENTS.md would be
|
|
83
|
+
// a false accusation (it never reaches the agent).
|
|
84
|
+
// KNOWN LIMIT: the 2.1.277 rule is "no Claude file in cwd OR ABOVE"; this
|
|
85
|
+
// shadows at the SAME level only. A parent CLAUDE.md shadowing a child
|
|
86
|
+
// AGENTS.md across levels is not yet modelled (see KNOWN-GAPS).
|
|
87
|
+
const hasClaudeMd = has("CLAUDE.md");
|
|
88
|
+
const hasClaudeLocal = has("CLAUDE.local.md");
|
|
89
|
+
if (hasClaudeMd)
|
|
79
90
|
loaded("CLAUDE.md", "Claude (CLAUDE.md)");
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
// .local always loads alongside the base CLAUDE.md when present.
|
|
92
|
+
if (hasClaudeLocal)
|
|
93
|
+
loaded("CLAUDE.local.md", "Claude (CLAUDE.local.md)");
|
|
94
|
+
if (hasClaudeMd || hasClaudeLocal) {
|
|
95
|
+
const winner = hasClaudeMd ? "CLAUDE.md" : "CLAUDE.local.md";
|
|
96
|
+
shadowed("AGENTS.md", "AGENTS.md", `a ${winner} at the same level wins`);
|
|
97
|
+
shadowed("AGENT.md", "AGENT.md", `a ${winner} at the same level wins`);
|
|
84
98
|
}
|
|
85
99
|
else {
|
|
86
100
|
loaded("AGENTS.md", "AGENTS.md");
|
|
87
101
|
loaded("AGENT.md", "AGENT.md");
|
|
88
102
|
}
|
|
89
|
-
// .local variants: precedence relative to the base files is not documented,
|
|
90
|
-
// so both are kept rather than guessing at a shadow rule.
|
|
91
|
-
loaded("CLAUDE.local.md", "Claude (CLAUDE.local.md)");
|
|
92
103
|
loaded("AGENTS.local.md", "AGENTS (AGENTS.local.md)");
|
|
93
104
|
// Non-Claude rule-file conventions (added 2026-09-26 for multi-tool
|
|
94
105
|
// support), read IN ADDITION to Claude's files when present. The engine
|
package/dist/why.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type BlockHint } from "./blockHint.js";
|
|
1
2
|
import type { Rule } from "./types.js";
|
|
2
3
|
export interface WhyRule {
|
|
3
4
|
id: string;
|
|
@@ -15,6 +16,8 @@ export interface WhyRule {
|
|
|
15
16
|
name: string;
|
|
16
17
|
exists: boolean;
|
|
17
18
|
};
|
|
19
|
+
/** How (and whether) this rule can actually be blocked/checked — see blockHint.ts. */
|
|
20
|
+
block?: BlockHint;
|
|
18
21
|
brokenCount: number;
|
|
19
22
|
brokenDates: string[];
|
|
20
23
|
sessionsScanned: number;
|
|
@@ -31,4 +34,28 @@ export interface WhyResult {
|
|
|
31
34
|
/** Fuzzy-match a rule by the query appearing in its title/body, or the query being the title. */
|
|
32
35
|
export declare function findRules(rules: Rule[], query: string): Rule[];
|
|
33
36
|
export declare function explainRule(cwd: string, query: string): Promise<WhyResult>;
|
|
37
|
+
export interface WhyAll {
|
|
38
|
+
rules: WhyRule[];
|
|
39
|
+
sessionsScanned: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `why --all` — the same facts for every loaded rule, problems first (not loaded, then a
|
|
43
|
+
* named command/file missing, then broken recently, then judgment-only, then the rest).
|
|
44
|
+
* Read-only, facts only, no verdicts. Order within a rank is the rule's own order.
|
|
45
|
+
*/
|
|
46
|
+
export declare function explainAll(cwd: string): Promise<WhyAll>;
|
|
47
|
+
/** The no-argument help: how to use `why`, plus every rule with its id, so people can pick. */
|
|
48
|
+
export declare function whyList(cwd: string): {
|
|
49
|
+
id: string;
|
|
50
|
+
title: string;
|
|
51
|
+
location: string;
|
|
52
|
+
}[];
|
|
34
53
|
export declare function renderWhy(r: WhyResult): string;
|
|
54
|
+
/** No-argument output: how to use `why`, then every rule with its id so you can pick one. */
|
|
55
|
+
export declare function renderWhyList(list: {
|
|
56
|
+
id: string;
|
|
57
|
+
title: string;
|
|
58
|
+
location: string;
|
|
59
|
+
}[]): string;
|
|
60
|
+
/** One short line per rule for `why --all`, problems first. */
|
|
61
|
+
export declare function renderAllWhy(all: WhyAll): string;
|
package/dist/why.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadRules, describeRuleSources } from "./rules.js";
|
|
|
5
5
|
import { classifyRule } from "./checks/classify.js";
|
|
6
6
|
import { adviseRule } from "./checkability.js";
|
|
7
7
|
import { scanHistory } from "./historyReport.js";
|
|
8
|
+
import { blockHintFor } from "./blockHint.js";
|
|
8
9
|
/**
|
|
9
10
|
* `rulereceipt why "<rule text>"` — everything the tool already knows about ONE
|
|
10
11
|
* rule, in one place: where it lives, whether the agent even loads it, whether a
|
|
@@ -63,6 +64,43 @@ function namedCommandOrPath(rule, cwd) {
|
|
|
63
64
|
}
|
|
64
65
|
return undefined;
|
|
65
66
|
}
|
|
67
|
+
async function historyLookup(cwd, rules) {
|
|
68
|
+
try {
|
|
69
|
+
const hist = await scanHistory(cwd, rules, 30);
|
|
70
|
+
const byRule = new Map();
|
|
71
|
+
for (const b of hist.breaks)
|
|
72
|
+
byRule.set(`${b.ruleId}\u0000${b.ruleTitle}`, { count: b.count, lastMs: b.lastMs });
|
|
73
|
+
return { sessionsScanned: hist.sessionsScanned, byRule };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return { sessionsScanned: 0, byRule: new Map() };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Build the WhyRule facts for one rule, reusing an already-computed load graph and history. */
|
|
80
|
+
function ruleFacts(cwd, rule, graph, hist) {
|
|
81
|
+
const src = rule.sourcePath ? graph.find((e) => e.path === rule.sourcePath) : undefined;
|
|
82
|
+
const cls = classifyRule(rule);
|
|
83
|
+
const checkable = CHECKABLE_KINDS.has(cls.kind);
|
|
84
|
+
const advice = checkable ? null : adviseRule(rule);
|
|
85
|
+
const b = hist.byRule.get(`${rule.id}\u0000${rule.title}`);
|
|
86
|
+
return {
|
|
87
|
+
id: rule.id,
|
|
88
|
+
title: rule.title,
|
|
89
|
+
source: rule.source,
|
|
90
|
+
location: locationOf(rule),
|
|
91
|
+
loaded: src ? src.status === "loaded" : true,
|
|
92
|
+
loadNote: src?.note,
|
|
93
|
+
pathScoped: rule.paths ? rule.paths.join(", ") : undefined,
|
|
94
|
+
checkable,
|
|
95
|
+
kind: cls.kind,
|
|
96
|
+
suggestion: advice?.suggestion,
|
|
97
|
+
named: namedCommandOrPath(rule, cwd),
|
|
98
|
+
block: blockHintFor(cls),
|
|
99
|
+
brokenCount: b?.count ?? 0,
|
|
100
|
+
brokenDates: b ? [new Date(b.lastMs).toISOString().slice(0, 10)] : [],
|
|
101
|
+
sessionsScanned: hist.sessionsScanned,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
66
104
|
export async function explainRule(cwd, query) {
|
|
67
105
|
const rules = loadRules(cwd);
|
|
68
106
|
const matches = findRules(rules, query);
|
|
@@ -75,47 +113,41 @@ export async function explainRule(cwd, query) {
|
|
|
75
113
|
candidates: matches.slice(0, 12).map((r) => ({ title: r.title, location: locationOf(r) })),
|
|
76
114
|
};
|
|
77
115
|
}
|
|
78
|
-
const rule = matches[0];
|
|
79
116
|
const graph = describeRuleSources(cwd);
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
brokenCount,
|
|
115
|
-
brokenDates,
|
|
116
|
-
sessionsScanned,
|
|
117
|
-
},
|
|
118
|
-
};
|
|
117
|
+
const hist = await historyLookup(cwd, rules);
|
|
118
|
+
return { query, matches: 1, rule: ruleFacts(cwd, matches[0], graph, hist) };
|
|
119
|
+
}
|
|
120
|
+
/** A "problem" rank so `why --all` can put the ones that need attention first. */
|
|
121
|
+
function problemRank(w) {
|
|
122
|
+
if (!w.loaded)
|
|
123
|
+
return 0; // the agent never sees it
|
|
124
|
+
if (w.named && !w.named.exists)
|
|
125
|
+
return 1; // names a command/file that does not exist
|
|
126
|
+
if (w.brokenCount > 0)
|
|
127
|
+
return 2; // broken recently
|
|
128
|
+
if (!w.checkable)
|
|
129
|
+
return 3; // needs judgment
|
|
130
|
+
return 4; // fine
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* `why --all` — the same facts for every loaded rule, problems first (not loaded, then a
|
|
134
|
+
* named command/file missing, then broken recently, then judgment-only, then the rest).
|
|
135
|
+
* Read-only, facts only, no verdicts. Order within a rank is the rule's own order.
|
|
136
|
+
*/
|
|
137
|
+
export async function explainAll(cwd) {
|
|
138
|
+
const rules = loadRules(cwd);
|
|
139
|
+
const graph = describeRuleSources(cwd);
|
|
140
|
+
const hist = await historyLookup(cwd, rules);
|
|
141
|
+
const facts = rules.map((r) => ruleFacts(cwd, r, graph, hist));
|
|
142
|
+
const ranked = facts
|
|
143
|
+
.map((w, i) => ({ w, i }))
|
|
144
|
+
.sort((a, b) => problemRank(a.w) - problemRank(b.w) || a.i - b.i)
|
|
145
|
+
.map((x) => x.w);
|
|
146
|
+
return { rules: ranked, sessionsScanned: hist.sessionsScanned };
|
|
147
|
+
}
|
|
148
|
+
/** The no-argument help: how to use `why`, plus every rule with its id, so people can pick. */
|
|
149
|
+
export function whyList(cwd) {
|
|
150
|
+
return loadRules(cwd).map((r) => ({ id: r.id, title: r.title, location: locationOf(r) }));
|
|
119
151
|
}
|
|
120
152
|
function locationOf(rule) {
|
|
121
153
|
if (!rule.sourcePath)
|
|
@@ -144,6 +176,7 @@ export function renderWhy(r) {
|
|
|
144
176
|
out.push(w.checkable
|
|
145
177
|
? ` ✓ mechanically checkable (${w.kind}) — a session is judged against it with quoted evidence`
|
|
146
178
|
: ` • needs your judgment${w.suggestion ? ` — ${w.suggestion}` : ` — no command or file to check it by; a human decides`}`);
|
|
179
|
+
renderBlockHint(w.block, out);
|
|
147
180
|
out.push("");
|
|
148
181
|
if (w.brokenCount > 0)
|
|
149
182
|
out.push(` last 30 days: broken ${w.brokenCount}× (last: ${w.brokenDates[0]}) across ${w.sessionsScanned} session${w.sessionsScanned === 1 ? "" : "s"}`);
|
|
@@ -151,3 +184,72 @@ export function renderWhy(r) {
|
|
|
151
184
|
out.push(` last 30 days: no proven break across ${w.sessionsScanned} session${w.sessionsScanned === 1 ? "" : "s"}`);
|
|
152
185
|
return out.join("\n");
|
|
153
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* How to actually enforce this rule, appended to the single-rule view. Prints
|
|
189
|
+
* only what the tool truly does: a native Claude Code permissions rule where one
|
|
190
|
+
* can genuinely express it (with its honest limitation), RuleReceipt's own guard
|
|
191
|
+
* where it covers the rule pre-flight, and — for rules judged only after the run —
|
|
192
|
+
* the after-the-fact path. Nothing here claims a block the guard doesn't make.
|
|
193
|
+
*/
|
|
194
|
+
function renderBlockHint(block, out) {
|
|
195
|
+
if (!block)
|
|
196
|
+
return;
|
|
197
|
+
out.push("");
|
|
198
|
+
if (block.preventable) {
|
|
199
|
+
out.push(" To stop this before it runs:");
|
|
200
|
+
if (block.native) {
|
|
201
|
+
const entries = block.native.entries.map((e) => `"${e}"`).join(", ");
|
|
202
|
+
out.push(` • Claude Code settings (.claude/settings.json), no extra tool:`);
|
|
203
|
+
out.push(` { "permissions": { "${block.native.kind}": [${entries}] } }`);
|
|
204
|
+
out.push(` ${block.native.note}`);
|
|
205
|
+
}
|
|
206
|
+
else if (block.nativeImpossibleReason) {
|
|
207
|
+
out.push(` • A Claude Code permission rule can't express this — ${block.nativeImpossibleReason}.`);
|
|
208
|
+
}
|
|
209
|
+
if (block.guardCovers) {
|
|
210
|
+
out.push(` • RuleReceipt's own guard checks this exact rule: run \`rulereceipt protect\``);
|
|
211
|
+
out.push(` (adds a PreToolUse deny hook + a Stop hook, shown before it writes anything).`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
out.push(" Can't be blocked before an action — this rule is judged after the run.");
|
|
216
|
+
out.push(" Run `rulereceipt check` after a session; `rulereceipt protect` also adds a Stop");
|
|
217
|
+
out.push(" hook that won't let a session end on a broken rule.");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** No-argument output: how to use `why`, then every rule with its id so you can pick one. */
|
|
221
|
+
export function renderWhyList(list) {
|
|
222
|
+
if (list.length === 0)
|
|
223
|
+
return "No rules file found here. Run `rulereceipt init` to add one, then `rulereceipt why \"<rule>\"`.";
|
|
224
|
+
const out = ['Explain one rule: rulereceipt why "<some words from the rule>"', "Or all of them: rulereceipt why --all", "", "Rules found:"];
|
|
225
|
+
for (const r of list)
|
|
226
|
+
out.push(` ${r.id.padEnd(6)} ${r.title.replace(/\s+/g, " ").slice(0, 72)}`);
|
|
227
|
+
return out.join("\n");
|
|
228
|
+
}
|
|
229
|
+
/** One short line per rule for `why --all`, problems first. */
|
|
230
|
+
export function renderAllWhy(all) {
|
|
231
|
+
if (all.rules.length === 0)
|
|
232
|
+
return "No rules file found here. Run `rulereceipt init` to add one.";
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const w of all.rules) {
|
|
235
|
+
let mark = " ✓";
|
|
236
|
+
let note = w.checkable ? `checkable (${w.kind})` : "needs your judgment";
|
|
237
|
+
if (!w.loaded) {
|
|
238
|
+
mark = " ✗";
|
|
239
|
+
note = `NOT loaded — ${w.loadNote ?? "the agent never sees this file"}`;
|
|
240
|
+
}
|
|
241
|
+
else if (w.named && !w.named.exists) {
|
|
242
|
+
mark = " ✗";
|
|
243
|
+
note = `names a ${w.named.kind} that does not exist: ${w.named.name}`;
|
|
244
|
+
}
|
|
245
|
+
else if (w.brokenCount > 0) {
|
|
246
|
+
mark = " ✗";
|
|
247
|
+
note = `broken ${w.brokenCount}× (last: ${w.brokenDates[0]})`;
|
|
248
|
+
}
|
|
249
|
+
out.push(`${mark} ${w.id.padEnd(6)} ${w.title.replace(/\s+/g, " ").slice(0, 60)}`);
|
|
250
|
+
out.push(` ${note}`);
|
|
251
|
+
}
|
|
252
|
+
out.push("");
|
|
253
|
+
out.push(`across ${all.sessionsScanned} session${all.sessionsScanned === 1 ? "" : "s"} · run \`rulereceipt why "<rule>"\` for one rule in full`);
|
|
254
|
+
return out.join("\n");
|
|
255
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.77",
|
|
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",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"test": "vitest run",
|
|
44
44
|
"test:watch": "vitest",
|
|
45
45
|
"lint": "eslint src tests",
|
|
46
|
+
"license:check": "node scripts/license-gate.mjs",
|
|
46
47
|
"prepublishOnly": "npm run build && npm test",
|
|
47
48
|
"build:checker": "esbuild src/browser/analyze.ts --bundle --format=esm --minify --outfile=landing/checker.js",
|
|
48
49
|
"verify": "npm run lint && npm run typecheck && npm test"
|