rulereceipt 0.1.38 → 0.1.40

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 CHANGED
@@ -168,16 +168,37 @@ Read the limit before wiring it in, because it is most of the story. It
168
168
  enforces rules naming a **file** or a **branch** — "never modify `.env`",
169
169
  "never commit to `main`" — and nothing else.
170
170
 
171
- It does **not** block banned commands. That was the point of building it, and
172
- it did not survive measurement: replaying 16,336 real tool calls against every
173
- forbidding rule in a 559-file corpus, blocking on command literals refused
174
- 62.8% of them. Narrowing twice reached 2.5%, and the residue was still wrong
175
- in a way no matcher fixes — one rule refused `npm run build` 112 times,
176
- because it forbids running Playwright unprompted and *recommends*
177
- `npm run build`, which is its only command-shaped literal.
171
+ It does **not** block a banned command unless you have said which command is
172
+ banned. That was the point of building it, and the automatic version did not
173
+ survive measurement: replaying 16,336 real tool calls against every forbidding
174
+ rule in a 559-file corpus, blocking on command literals refused 62.8% of them.
175
+ Narrowing twice reached 2.5%, and the residue was still wrong in a way no
176
+ matcher fixes — one rule refused `npm run build` 112 times, because it forbids
177
+ running Playwright unprompted and *recommends* `npm run build`, which is its
178
+ only command-shaped literal.
178
179
 
179
180
  Nothing in a rules file marks which backtick is the prohibition. A report
180
- survives that by saying UNCLEAR. A gate cannot.
181
+ survives that by saying UNCLEAR. A gate cannot — so you mark it:
182
+
183
+ ```bash
184
+ rulereceipt rules --forbid <handle> --literal "git push --force"
185
+ ```
186
+
187
+ Handles come from `rulereceipt rules --handles`. The mark is stored
188
+ against the rule's content hash, and the guard blocks on that literal and no
189
+ other. Three things it deliberately will not do:
190
+
191
+ - An **unmarked** rule cannot block, at any confidence, ever. There is no
192
+ fallback to "probably the first literal" — that fallback is the bug.
193
+ - **Rewording the rule drops the mark.** It would otherwise carry your
194
+ judgment onto words you never read.
195
+ - A mark naming a literal the rule no longer contains is **ignored**. A gate
196
+ refusing a command for a reason written nowhere is the worst failure a gate
197
+ has.
198
+
199
+ Of 99 forbidding rules in the corpus that name a command-shaped literal, only
200
+ 43 have a prohibition that actually introduces one. The rest could never be
201
+ marked automatically, which is the point.
181
202
 
182
203
 
183
204
  ## Which rules actually have teeth
@@ -107,6 +107,36 @@ export interface ClaimEvidenceClassification {
107
107
  rule: Rule;
108
108
  }
109
109
  export type Classification = ClaimEvidenceClassification | DeterministicClassification | IfEditThenTestClassification | GitBranchPolicyClassification | CodeContentClassification | FileLifecycleClassification | NotARuleClassification | JudgmentClassification;
110
+ export declare const DIRECTIVE_LANGUAGE: RegExp;
111
+ /**
112
+ * Imperative instruction — a bare command verb starting a clause ("Use
113
+ * `gh pr merge`", "Run the tests first", "Keep functions small"). This is
114
+ * the other way a real rule is written when it doesn't use a modal.
115
+ *
116
+ * Anchored to a clause start (line start, or after sentence/bullet
117
+ * punctuation) on purpose: the same verbs appear mid-sentence in pure
118
+ * documentation ("the CLI can run migrations"), where they describe a
119
+ * capability rather than instruct the agent.
120
+ */
121
+ export declare const IMPERATIVE_INSTRUCTION: RegExp;
122
+ /**
123
+ * A title that OPENS with an instruction is a rule, whatever it goes on
124
+ * to mention. "Never repeat the 2026-08-28 incident" is a directive that
125
+ * happens to name an incident; "Real incident (2026-08-28): ..." is a
126
+ * report that happens to contain the word never further along.
127
+ */
128
+ export declare const TITLE_OPENS_WITH_DIRECTIVE: RegExp;
129
+ export declare function isEventRecord(rule: Rule): boolean;
130
+ /**
131
+ * A heading that labels a command, with the command as its whole body:
132
+ * "Build release APK" over `.\gradlew assembleRelease`. The title reads as
133
+ * an imperative, but nothing here constrains the agent — it is a how-to, and
134
+ * there is no compliance to check.
135
+ *
136
+ * Guarded by TITLE_OPENS_WITH_DIRECTIVE so a genuine prohibition whose body
137
+ * is the forbidden command ("Never run: `rm -rf /`") is still a rule.
138
+ */
139
+ export declare function isCommandDocumentation(rule: Rule): boolean;
110
140
  /**
111
141
  * A rule is only treated as deterministic when it names a specific,
112
142
  * literal, checkable token (a CLI flag, a command, an exact string) in
@@ -1,7 +1,7 @@
1
1
  // Normative language — the thing that makes a line a rule rather than a
2
2
  // description. Deliberately broad on modals AND imperative verbs, because
3
3
  // a wrongly-excluded rule is a silent miss.
4
- const DIRECTIVE_LANGUAGE = /\b(never|always|must|should|shall|do not|don't|dont|cannot|can't|required?|requires|ensure|avoid|prefer|forbidden|prohibited|only|make sure|be sure|need|needs|needed|need to|has to|have to|expected to|responsible for)\b/i;
4
+ export const DIRECTIVE_LANGUAGE = /\b(never|always|must|should|shall|do not|don't|dont|cannot|can't|required?|requires|ensure|avoid|prefer|forbidden|prohibited|only|make sure|be sure|need|needs|needed|need to|has to|have to|expected to|responsible for)\b/i;
5
5
  /**
6
6
  * Imperative instruction — a bare command verb starting a clause ("Use
7
7
  * `gh pr merge`", "Run the tests first", "Keep functions small"). This is
@@ -12,7 +12,7 @@ const DIRECTIVE_LANGUAGE = /\b(never|always|must|should|shall|do not|don't|dont|
12
12
  * documentation ("the CLI can run migrations"), where they describe a
13
13
  * capability rather than instruct the agent.
14
14
  */
15
- const IMPERATIVE_INSTRUCTION = /(?:^|[.;:!?]\s+|^\s*[-*+]\s*|\n\s*[-*+]\s*)(use|run|keep|write|add|remove|delete|check|verify|test|commit|document|update|create|follow|apply|include|exclude|handle|validate|escape|sanitize|log|report|raise|throw|return|call|invoke|split|group|sort|name|place|put|store|read|load|save|close|open|start|stop|restart|install|build|deploy|review|refactor|rename|move|copy|merge|rebase|squash|tag|branch|push|pull|fetch|clone|stage|stash|lead|state|explain|describe|list|show|surface|flag|mark|label|note|treat|assume|confirm|ask|wait|stick|limit|cap|batch|cache|mock|stub|assert|expect|measure|quantify|label)\b/i;
15
+ export const IMPERATIVE_INSTRUCTION = /(?:^|[.;:!?]\s+|^\s*[-*+]\s*|\n\s*[-*+]\s*)(use|run|keep|write|add|remove|delete|check|verify|test|commit|document|update|create|follow|apply|include|exclude|handle|validate|escape|sanitize|log|report|raise|throw|return|call|invoke|split|group|sort|name|place|put|store|read|load|save|close|open|start|stop|restart|install|build|deploy|review|refactor|rename|move|copy|merge|rebase|squash|tag|branch|push|pull|fetch|clone|stage|stash|lead|state|explain|describe|list|show|surface|flag|mark|label|note|treat|assume|confirm|ask|wait|stick|limit|cap|batch|cache|mock|stub|assert|expect|measure|quantify|label)\b/i;
16
16
  /**
17
17
  * Deliberately inverted: tests for the presence of a DIRECTIVE, never for
18
18
  * the shape of documentation.
@@ -60,8 +60,8 @@ const EVENT_RECORD_TITLE = /\b(incident|post-?mortem|retro(spective)?|outage|wha
60
60
  * happens to name an incident; "Real incident (2026-08-28): ..." is a
61
61
  * report that happens to contain the word never further along.
62
62
  */
63
- const TITLE_OPENS_WITH_DIRECTIVE = /^\s*[-*+\d.\s]*(never|always|must|do not|don'?t|dont|avoid|ensure|prefer|only|make sure|be sure|no)\b/i;
64
- function isEventRecord(rule) {
63
+ export const TITLE_OPENS_WITH_DIRECTIVE = /^\s*[-*+\d.\s]*(never|always|must|do not|don'?t|dont|avoid|ensure|prefer|only|make sure|be sure|no)\b/i;
64
+ export function isEventRecord(rule) {
65
65
  if (TITLE_OPENS_WITH_DIRECTIVE.test(rule.title))
66
66
  return false;
67
67
  if (IMPERATIVE_INSTRUCTION.test(rule.title))
@@ -126,7 +126,7 @@ function looksLikeBareCommand(text) {
126
126
  * Guarded by TITLE_OPENS_WITH_DIRECTIVE so a genuine prohibition whose body
127
127
  * is the forbidden command ("Never run: `rm -rf /`") is still a rule.
128
128
  */
129
- function isCommandDocumentation(rule) {
129
+ export function isCommandDocumentation(rule) {
130
130
  if (TITLE_OPENS_WITH_DIRECTIVE.test(rule.title))
131
131
  return false;
132
132
  if (FLAG_DOCUMENTATION.test(rule.text) || FLAG_DOCUMENTATION.test(rule.title))
package/dist/cli.js CHANGED
@@ -488,6 +488,80 @@ async function runRules(opts) {
488
488
  }
489
489
  return;
490
490
  }
491
+ /**
492
+ * Marking WHICH clause of a rule is the prohibition.
493
+ *
494
+ * The one thing a rules file never says. Blocking on every backtick in a
495
+ * forbidding rule refused 62.8% of 16,336 real tool calls, and the worst
496
+ * survivor after two narrowings refused `npm run build` 112 times against
497
+ * a rule that recommends it. So the guard blocks on nothing here until a
498
+ * person names the clause, and this is where they name it.
499
+ */
500
+ if (opts.forbid) {
501
+ const rule = findRule(opts.forbid);
502
+ if (!rule) {
503
+ console.error(`No rule in this project has the handle ${opts.forbid}.`);
504
+ console.error(`Handles come from \`rulereceipt check --show-skipped\`, and change if the rule's wording changes.`);
505
+ process.exitCode = 1;
506
+ return;
507
+ }
508
+ const literal = opts.literal?.trim();
509
+ if (!literal) {
510
+ console.error(`--forbid needs --literal "<the exact command this rule bans>".`);
511
+ console.error(`Copy it from the rule itself; it has to appear in the rule's text.`);
512
+ process.exitCode = 1;
513
+ return;
514
+ }
515
+ if (!`${rule.title}\n${rule.text ?? ""}`.includes(literal)) {
516
+ console.error(`That rule does not contain "${literal}".`);
517
+ console.error(`The mark has to name something the rule actually says, or a gate would`);
518
+ console.error(`refuse a command for a reason written nowhere.`);
519
+ process.exitCode = 1;
520
+ return;
521
+ }
522
+ const prior = overrides.get(opts.forbid)?.forbids ?? [];
523
+ const forbids = [...new Set([...prior, literal])];
524
+ saveOverride(cwd, {
525
+ hash: opts.forbid,
526
+ decision: overrides.get(opts.forbid)?.decision ?? "rule",
527
+ title: rule.title.replace(/\s+/g, " ").trim().slice(0, 200),
528
+ forbids,
529
+ });
530
+ console.log(`Saved to ${OVERRIDES_PATH}.`);
531
+ console.log(` "${rule.title.replace(/\s+/g, " ").trim().slice(0, 90)}"`);
532
+ console.log(` now blocks on: ${forbids.map((f) => `\`${f}\``).join(", ")}`);
533
+ console.log(`\nThis only takes effect if you run \`rulereceipt guard\` as a PreToolUse hook.`);
534
+ console.log(`Rewording the rule drops the mark, on purpose — it would otherwise carry`);
535
+ console.log(`your judgment onto words you never read.`);
536
+ return;
537
+ }
538
+ /**
539
+ * Every rule with its handle.
540
+ *
541
+ * --forbid needs a handle, and before this the only place handles were
542
+ * printed was `check --show-skipped`, which lists the items the classifier
543
+ * DISCARDED. A rule that is actually being checked had no handle anywhere,
544
+ * so the marking feature shipped in 0.1.39 could not be reached for any
545
+ * rule a user would want to mark. Found by trying to use it.
546
+ */
547
+ if (opts.handles) {
548
+ if (rules.length === 0) {
549
+ console.log("No CLAUDE.md or AGENTS.md rules found in this project.");
550
+ return;
551
+ }
552
+ console.log(`${rules.length} rule${rules.length === 1 ? "" : "s"} in this project:\n`);
553
+ for (const r of rules) {
554
+ const h = ruleFingerprint(r);
555
+ const marked = overrides.get(h)?.forbids;
556
+ console.log(` ${h} ${r.title.replace(/\s+/g, " ").trim().slice(0, 72)}`);
557
+ if (marked?.length)
558
+ console.log(` blocks on: ${marked.map((f) => `\`${f}\``).join(", ")}`);
559
+ }
560
+ console.log(`\nMark which clause of a rule is the prohibition:`);
561
+ console.log(` rulereceipt rules --forbid <handle> --literal "<the banned command>"`);
562
+ console.log(`Only a marked clause can ever refuse a command, and only via \`rulereceipt guard\`.`);
563
+ return;
564
+ }
491
565
  if (opts.clear) {
492
566
  console.log(clearOverride(cwd, opts.clear) ? `Removed the correction for ${opts.clear}.` : `No correction stored for ${opts.clear}.`);
493
567
  return;
@@ -604,6 +678,9 @@ program
604
678
  .description("correct what the classifier treats as a rule. Handles come from `check --show-skipped`.")
605
679
  .option("--include <handle>", "treat this item as a real rule and check it from now on")
606
680
  .option("--exclude <handle>", "treat this item as documentation and stop reporting it")
681
+ .option("--handles", "list every rule with its handle, for use with --forbid")
682
+ .option("--forbid <handle>", "mark which clause of this rule is the prohibition, so the guard may block on it")
683
+ .option("--literal <text>", "the exact banned command, used with --forbid; must appear in the rule")
607
684
  .option("--clear <handle>", "remove a stored correction")
608
685
  .option("--list", "show stored corrections (the default when no other flag is given)")
609
686
  .option("--coverage", "show which rules a configured hook might actually be enforcing, and which are prose only")
package/dist/guard.js CHANGED
@@ -3,7 +3,8 @@ 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 { loadOverrides, ruleFingerprint } from "./overrides.js";
6
+ import { loadOverrides, ruleFingerprint, ratifiedForbids } from "./overrides.js";
7
+ import { commandRunsLiteral } from "./checks/proposedAction.js";
7
8
  function readStdin() {
8
9
  return new Promise((resolve) => {
9
10
  let data = "";
@@ -56,7 +57,52 @@ function structuredBlocks(cwd, event) {
56
57
  }));
57
58
  }
58
59
  /**
59
- * Literal command bans do NOT block. Measured, then cut.
60
+ * A command ban blocks only where a person marked the clause.
61
+ *
62
+ * The unratified version was measured before shipping and cut: blocking on a
63
+ * rule's command literals refused 62.8% of 16,336 real tool calls. Two
64
+ * narrowings reached 2.49% and the residue had no matcher fix — a rule
65
+ * titled "Feature Validation" refused `npm run build` 112 times, because it
66
+ * forbids running Playwright unprompted and RECOMMENDS the build command,
67
+ * which is its only command-shaped literal. Another refused plain
68
+ * `git status`, its backticks holding both the ban and the alternative.
69
+ *
70
+ * Nothing in a rules file marks which backtick is the prohibition. So this
71
+ * path reads only what someone declared, and yields nothing otherwise. The
72
+ * declaration is keyed on the rule's content hash and re-checked against the
73
+ * rule's current text, so a reworded rule loses its mark rather than
74
+ * carrying a judgement onto words nobody read.
75
+ *
76
+ * The important property is what happens by default: an unmarked rule cannot
77
+ * block, at any confidence, ever. That is the whole difference between this
78
+ * and the version that refused two thirds of everything.
79
+ */
80
+ function ratifiedLiteralBlocks(cwd, command) {
81
+ const overrides = loadOverrides(cwd);
82
+ const blocks = [];
83
+ // Read from the RULES, not from the classification. A human mark
84
+ // supersedes the classifier, including its refusal to classify — and that
85
+ // refusal is the common case here. "Never use `git push --force`; prefer
86
+ // `git push --force-with-lease`" goes to judgment via hasMixedPolarity,
87
+ // for a correct reason: literal matching cannot tell which half owns which
88
+ // token. A rule naming both the ban and the alternative is the canonical
89
+ // reason to have someone say which is which, so gating the mark behind the
90
+ // classifier made the feature unavailable in exactly the case that
91
+ // motivated it. Shipped that way in 0.1.39 and caught by running it.
92
+ for (const rule of loadRules(cwd)) {
93
+ if (overrides.get(ruleFingerprint(rule))?.decision === "notARule")
94
+ continue;
95
+ for (const literal of ratifiedForbids(overrides, rule)) {
96
+ if (!commandRunsLiteral(command, literal))
97
+ continue;
98
+ blocks.push({ rule, why: `the command about to run does \`${literal}\`, which this rule forbids (marked by you, not inferred)` });
99
+ break;
100
+ }
101
+ }
102
+ return blocks;
103
+ }
104
+ /**
105
+ * Literal command bans do NOT block unless ratified. Measured, then cut.
60
106
  *
61
107
  * This was the point of the feature and it does not survive its own
62
108
  * measurement. Replaying 16,336 real tool calls against every forbidding
@@ -149,7 +195,7 @@ export async function runGuard() {
149
195
  role: "assistant", kind: "tool_use", toolName: "Bash",
150
196
  input: { command: toolInput.command }, timestamp: "",
151
197
  };
152
- blocks = structuredBlocks(cwd, event);
198
+ blocks = [...structuredBlocks(cwd, event), ...ratifiedLiteralBlocks(cwd, toolInput.command)];
153
199
  }
154
200
  else if (tool === "Write" || tool === "Edit" || tool === "NotebookEdit") {
155
201
  const event = {
@@ -26,6 +26,21 @@ export interface Override {
26
26
  decision: Decision;
27
27
  /** Stored for humans reading the file, and to explain a stale entry. */
28
28
  title: string;
29
+ /**
30
+ * The literal(s) a person has marked as the PROHIBITION in this rule.
31
+ *
32
+ * A rules file does not say which of its backticks is the thing being
33
+ * banned. Measured: blocking on all of them refused 62.8% of 16,336 real
34
+ * tool calls, and the residue after two narrowings still refused
35
+ * `npm run build` 112 times, because the rule that named it forbids
36
+ * running Playwright and RECOMMENDS the build command. No matcher fixes
37
+ * that — the information is not in the text.
38
+ *
39
+ * So it is declared, once, and only what is declared can block. Absent
40
+ * means absent: there is deliberately no fallback to "probably the first
41
+ * literal", because that fallback is the bug.
42
+ */
43
+ forbids?: string[];
29
44
  }
30
45
  export declare const OVERRIDES_PATH: string;
31
46
  /**
@@ -60,3 +75,21 @@ export declare function clearOverride(cwd: string, hash: string): boolean;
60
75
  * correction is no longer being applied instead of assuming it still is.
61
76
  */
62
77
  export declare function staleOverrides(overrides: Map<string, Override>, rules: Rule[]): Override[];
78
+ /**
79
+ * The prohibition literals a person has declared for this rule, or none.
80
+ *
81
+ * Two conditions, both required, and both are refusals to infer:
82
+ *
83
+ * 1. The mark is keyed on the rule's CONTENT hash, so rewording the rule
84
+ * drops it rather than reattaching a judgement to text nobody read. Same
85
+ * reasoning as ruleFingerprint, one field down.
86
+ *
87
+ * 2. The literal must still appear in the rule. A mark that survives a
88
+ * partial edit and names something the rule no longer mentions would
89
+ * block on a phrase with nothing behind it — a gate refusing a command
90
+ * for a reason that is no longer written anywhere, which is the worst
91
+ * failure available to a gate.
92
+ *
93
+ * Returns [] for anything unratified. There is no fallback on purpose.
94
+ */
95
+ export declare function ratifiedForbids(overrides: Map<string, Override>, rule: Rule): string[];
package/dist/overrides.js CHANGED
@@ -32,7 +32,15 @@ export function loadOverrides(cwd) {
32
32
  return map;
33
33
  for (const o of parsed.overrides) {
34
34
  if (typeof o?.hash === "string" && (o.decision === "rule" || o.decision === "notARule")) {
35
- map.set(o.hash, { hash: o.hash, decision: o.decision, title: String(o.title ?? "") });
35
+ const forbids = Array.isArray(o.forbids)
36
+ ? o.forbids.filter((x) => typeof x === "string" && x.trim().length > 0)
37
+ : undefined;
38
+ map.set(o.hash, {
39
+ hash: o.hash,
40
+ decision: o.decision,
41
+ title: String(o.title ?? ""),
42
+ ...(forbids && forbids.length > 0 ? { forbids } : {}),
43
+ });
36
44
  }
37
45
  }
38
46
  }
@@ -83,3 +91,27 @@ export function staleOverrides(overrides, rules) {
83
91
  const live = new Set(rules.map(ruleFingerprint));
84
92
  return [...overrides.values()].filter((o) => !live.has(o.hash));
85
93
  }
94
+ /**
95
+ * The prohibition literals a person has declared for this rule, or none.
96
+ *
97
+ * Two conditions, both required, and both are refusals to infer:
98
+ *
99
+ * 1. The mark is keyed on the rule's CONTENT hash, so rewording the rule
100
+ * drops it rather than reattaching a judgement to text nobody read. Same
101
+ * reasoning as ruleFingerprint, one field down.
102
+ *
103
+ * 2. The literal must still appear in the rule. A mark that survives a
104
+ * partial edit and names something the rule no longer mentions would
105
+ * block on a phrase with nothing behind it — a gate refusing a command
106
+ * for a reason that is no longer written anywhere, which is the worst
107
+ * failure available to a gate.
108
+ *
109
+ * Returns [] for anything unratified. There is no fallback on purpose.
110
+ */
111
+ export function ratifiedForbids(overrides, rule) {
112
+ const entry = overrides.get(ruleFingerprint(rule));
113
+ if (!entry?.forbids)
114
+ return [];
115
+ const body = `${rule.title}\n${rule.text ?? ""}`;
116
+ return entry.forbids.filter((literal) => body.includes(literal));
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "Checks whether a Claude Code session actually followed your CLAUDE.md / AGENTS.md rules, with evidence.",
5
5
  "repository": {
6
6
  "type": "git",