rulereceipt 0.1.75 → 0.1.76
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/cli.js +19 -6
- package/dist/historyReport.js +2 -0
- 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
|
+
}
|
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";
|
|
@@ -931,7 +931,7 @@ const PERIOD_MS = {
|
|
|
931
931
|
};
|
|
932
932
|
program
|
|
933
933
|
.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.
|
|
934
|
+
.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
935
|
.option("--last <n>", "how many recent sessions to audit", "25")
|
|
936
936
|
.option("--markdown", "output as markdown, for a report you can send")
|
|
937
937
|
.action(async (opts) => {
|
|
@@ -953,18 +953,31 @@ program
|
|
|
953
953
|
console.log(renderProjectAudit(a, Boolean(opts.markdown)));
|
|
954
954
|
});
|
|
955
955
|
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.
|
|
956
|
+
.command("why [rule...]")
|
|
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. 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.")
|
|
958
|
+
.option("--all", "show every rule, problems first (not loaded, missing command, broken recently), then the rest")
|
|
958
959
|
.option("--json", "output machine-readable JSON (the same fields)")
|
|
959
960
|
.action(async (ruleWords, opts) => {
|
|
960
961
|
const cwd = process.cwd();
|
|
961
|
-
const query = ruleWords.join(" ").trim();
|
|
962
|
+
const query = (ruleWords ?? []).join(" ").trim();
|
|
962
963
|
const rules = loadRules(cwd);
|
|
963
964
|
if (rules.length === 0) {
|
|
964
965
|
console.log("No rules file found here, so there is nothing to explain. Run `rulereceipt init` to add one.");
|
|
965
966
|
process.exitCode = 1;
|
|
966
967
|
return;
|
|
967
968
|
}
|
|
969
|
+
// --all: every rule, problems first.
|
|
970
|
+
if (opts.all) {
|
|
971
|
+
const all = await explainAll(cwd);
|
|
972
|
+
console.log(opts.json ? JSON.stringify(all, null, 2) : renderAllWhy(all));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
// No argument: list the rules with ids so the reader can pick one.
|
|
976
|
+
if (query.length === 0) {
|
|
977
|
+
const list = whyList(cwd);
|
|
978
|
+
console.log(opts.json ? JSON.stringify(list, null, 2) : renderWhyList(list));
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
968
981
|
const result = await explainRule(cwd, query);
|
|
969
982
|
if (opts.json) {
|
|
970
983
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -976,7 +989,7 @@ program
|
|
|
976
989
|
});
|
|
977
990
|
program
|
|
978
991
|
.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
|
|
992
|
+
.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
993
|
.option("--transcript <path>", "use a specific session file (same as check)")
|
|
981
994
|
.option("--out <path>", "where to write the report (default .rulereceipt/wrong-<handle>.md)")
|
|
982
995
|
.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
|
}
|
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.76",
|
|
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"
|