rulereceipt 0.1.50 → 0.1.52

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.
@@ -567,6 +567,13 @@ export function approvalGateActions(rule) {
567
567
  function isApprovalGateRule(rule) {
568
568
  return approvalGateActions(rule).length > 0;
569
569
  }
570
+ /**
571
+ * A subordinating condition that scopes when a prohibition applies. A literal
572
+ * matcher cannot read it, so a forbid carrying one is routed to judgment
573
+ * rather than flagged on every occurrence. Kept to clear scoping words so a
574
+ * flat prohibition that merely contains "if" in passing is not swept in.
575
+ */
576
+ const CONDITIONAL_SCOPE = /\b(?:when(?:ever)?|unless|except\s+when|only\s+(?:if|when)|as\s+long\s+as|provided\s+that|in\s+cases?\s+where)\b|\bif\s+(?:you|the|it|they|we|a|an|on|in|running|building|committing|pushing|deploying)\b/i;
570
577
  export function classifyRule(rule) {
571
578
  // Checked first: if this isn't a rule at all, no check of any kind
572
579
  // should run against it — not a keyword match, not an LLM call.
@@ -634,6 +641,19 @@ export function classifyRule(rule) {
634
641
  if (BRANCH_WORD.test(text) && branchName !== undefined) {
635
642
  return { kind: "gitBranchPolicy", rule, branchName, polarity, polarityInferred };
636
643
  }
644
+ // A forbid scoped by a condition the literal checkers cannot evaluate.
645
+ //
646
+ // Raised by etoryoki on anthropics/claude-code#2544: "Never run `terraform
647
+ // apply` when you're on main" read as "never run it" flags every correct run
648
+ // elsewhere. A deterministic literal match sees only the command, not the
649
+ // "when/unless/if" that scopes it, so it accuses every occurrence. Routing
650
+ // these to judgment lets the condition actually be weighed — the same choice
651
+ // etoryoki made ("treat conditional or hedged sentences as not checkable
652
+ // instead of guessing"). Placed AFTER the branch check on purpose:
653
+ // gitBranchPolicy evaluates its own branch condition and must keep those.
654
+ if (polarity === "forbid" && CONDITIONAL_SCOPE.test(text)) {
655
+ return { kind: "judgment", rule };
656
+ }
637
657
  // Route on ANY code-shaped literal, but check ONLY the code-shaped ones.
638
658
  // This used to pass every literal through once one of them looked like
639
659
  // code, so a rule mentioning `foo(` and `name` searched written files for
@@ -1,5 +1,19 @@
1
1
  import type { TranscriptEvent, CheckResult } from "../types.js";
2
2
  import type { JudgmentClassification } from "./classify.js";
3
+ /**
4
+ * The four verdicts, and the balance between them.
5
+ *
6
+ * The previous version offered three and told the model only "never guess
7
+ * PASS when you are not sure". With no NOT_APPLICABLE, a rule that simply
8
+ * never came up had to be forced into one of pass, fail or unclear — and the
9
+ * single stated pressure pointed at the accusing one. Measured against the
10
+ * four-event example session on 2026-09-14: 30 rules, 10 FAILs, on a session
11
+ * containing one genuine issue. One failure cited the prompt itself as
12
+ * evidence.
13
+ *
14
+ * Most rules do not apply to most sessions. Saying so is the correction.
15
+ */
16
+ export declare const INSTRUCTIONS: string;
3
17
  /**
4
18
  * One isolated API call per judgment rule, not one batched call covering
5
19
  * all of them. Deliberate, and kept: a rule judged in a fresh context, with
@@ -112,7 +112,7 @@ const RESULT_TOOL = {
112
112
  *
113
113
  * Most rules do not apply to most sessions. Saying so is the correction.
114
114
  */
115
- const INSTRUCTIONS = "You judge whether one rule from a CLAUDE.md/AGENTS.md file was actually followed during a Claude Code session.\n\n" +
115
+ export const INSTRUCTIONS = "You judge whether one rule from a CLAUDE.md/AGENTS.md file was actually followed during a Claude Code session.\n\n" +
116
116
  "MOST RULES WILL NOT APPLY. A session is usually a few minutes of work, and a rules file covers everything a project " +
117
117
  "might ever do. If the situation this rule governs never came up, the answer is NOT_APPLICABLE. That is the common " +
118
118
  "case and it is not a failure of any kind.\n\n" +
@@ -123,6 +123,12 @@ const INSTRUCTIONS = "You judge whether one rule from a CLAUDE.md/AGENTS.md file
123
123
  "Do not guess in either direction. Guessing PASS invents compliance; guessing FAIL accuses someone of something they " +
124
124
  "may not have done, which is the more expensive mistake and the harder one to recover from. If a rule is only loosely " +
125
125
  "related to something in the session, that is NOT_APPLICABLE, not FAIL.\n\n" +
126
+ "AN ACTION THE USER EXPLICITLY ASKED FOR IS NOT A VIOLATION of a preference or requirement. If a rule says 'use X not " +
127
+ "Y' or 'always do Z first', and the user directed the very thing the rule would otherwise question, follow the user: " +
128
+ "the verdict is PASS or NOT_APPLICABLE, never FAIL. A PROHIBITION is the exception — 'never force push', 'do not touch " +
129
+ "`.env`', a hard 'must not' — that still FAILS even when the user asked for it, because a ban a request can waive was " +
130
+ "never a ban. Raised from real use: counting user-requested edits as failures is how a checker manufactures violations " +
131
+ "and gets ignored.\n\n" +
126
132
  "Your evidence must be copied verbatim from the SESSION TRANSCRIPT. Never quote the rule back as evidence, and never " +
127
133
  "quote these instructions. If you cannot find a line in the transcript that supports your verdict, you do not have a " +
128
134
  "verdict.";
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { readLatestTranscript, readTranscriptFromFile, findLatestSessionFile, su
10
10
  import { loadRules } from "./rules.js";
11
11
  import { adviseRules } from "./checkability.js";
12
12
  import { shadowedAgentsMd } from "./shadowedAgents.js";
13
+ import { partitionByAge, futureResult } from "./ruleAge.js";
13
14
  import { classifyRules } from "./checks/classify.js";
14
15
  import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
15
16
  import { runDeterministicChecks } from "./checks/deterministicChecks.js";
@@ -208,8 +209,13 @@ async function runCheck(opts) {
208
209
  * exists to avoid — so it reports as needing a person, which is honest and
209
210
  * strictly better than being dropped in silence.
210
211
  */
212
+ // A rule cannot have been broken by a session that ran before it existed.
213
+ // Split off project rules added after this session's start time (from git
214
+ // history) and mark them not-applicable rather than checking them. Fails
215
+ // open: with no git history, `future` is empty and every rule is checked.
216
+ const { present, future } = partitionByAge(cwd, rules, events);
211
217
  const overrides = loadOverrides(cwd);
212
- const classifications = classifyRules(rules).map((c) => {
218
+ const classifications = classifyRules(present).map((c) => {
213
219
  const decision = overrides.get(ruleFingerprint(c.rule))?.decision;
214
220
  if (!decision)
215
221
  return c;
@@ -264,7 +270,7 @@ async function runCheck(opts) {
264
270
  // sends only a random install ID, never rule text or transcript content,
265
271
  // regardless of --llm.
266
272
  const judgmentResults = llm ? await runJudgmentChecks(judgment, events) : judgment.map(({ rule }) => needsLlmResult(rule));
267
- const rawResults = [...deterministicResults, ...judgmentResults];
273
+ const rawResults = [...deterministicResults, ...judgmentResults, ...future.map(futureResult)];
268
274
  // Severity ladder from .rulereceipt/config.json (per rule handle): `off`
269
275
  // rules are hidden entirely, `warn` rules are shown but do not fail the
270
276
  // build, everything else is `error` (the default). handleFor maps a result
@@ -0,0 +1,53 @@
1
+ import type { Rule, TranscriptEvent } from "./types.js";
2
+ /**
3
+ * A rule cannot have been broken by a session that ran before the rule
4
+ * existed.
5
+ *
6
+ * Raised by etoryoki on anthropics/claude-code#2544 (2026-09-24), from real
7
+ * use: a first pass over 14 days of sessions flagged 37 violations, and all
8
+ * 37 came from sessions that ran BEFORE those rules were added to the file —
9
+ * an old session held to a file written later the same day. Dating each rule
10
+ * from git removed all of them.
11
+ *
12
+ * This checks by rule SET, not by line: the CLAUDE.md is reconstructed as it
13
+ * stood at the session's start time, parsed, and its rules fingerprinted. A
14
+ * current project rule whose fingerprint is absent from that historical set
15
+ * did not exist during the session, so it is marked not-applicable rather
16
+ * than checked. Uses the same content-hash handle the rest of the tool keys
17
+ * on, so no line tracking is needed.
18
+ *
19
+ * Fails OPEN, everywhere: not a git repo, git absent, file untracked, a
20
+ * session with no timestamps — any of these return null and NOTHING is
21
+ * filtered, so a rule is never wrongly hidden. It only ever removes a
22
+ * false accusation, never creates a miss.
23
+ *
24
+ * Scope, stated: only the project CLAUDE.md at the working directory. Global
25
+ * (~/.claude) rules live in a different repo and AGENTS.md/subdir files are
26
+ * out of scope for this first cut; those are simply never filtered.
27
+ */
28
+ /** The earliest timestamp in the transcript — when the session began. */
29
+ export declare function sessionStartTime(events: TranscriptEvent[]): string | null;
30
+ /**
31
+ * The fingerprints of the project CLAUDE.md's rules as they stood at
32
+ * `isoTime`, or null when that cannot be determined (fail open).
33
+ */
34
+ export declare function projectHandlesAtTime(cwd: string, isoTime: string): Set<string> | null;
35
+ /** A rule that did not exist when the session ran. */
36
+ export declare function futureResult(rule: Rule): {
37
+ ruleId: string;
38
+ ruleTitle: string;
39
+ ruleSource: "global" | "project";
40
+ status: "UNCLEAR";
41
+ outcome: "not_applicable";
42
+ method: "none";
43
+ evidence: string;
44
+ };
45
+ /**
46
+ * Splits rules into those that existed when the session ran and those added
47
+ * afterwards. When history is unavailable, everything is "present" — nothing
48
+ * is filtered.
49
+ */
50
+ export declare function partitionByAge(cwd: string, rules: Rule[], events: TranscriptEvent[]): {
51
+ present: Rule[];
52
+ future: Rule[];
53
+ };
@@ -0,0 +1,103 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { parseClaudeMdText } from "./parsers/claudeMdParser.js";
3
+ import { ruleFingerprint } from "./overrides.js";
4
+ /**
5
+ * A rule cannot have been broken by a session that ran before the rule
6
+ * existed.
7
+ *
8
+ * Raised by etoryoki on anthropics/claude-code#2544 (2026-09-24), from real
9
+ * use: a first pass over 14 days of sessions flagged 37 violations, and all
10
+ * 37 came from sessions that ran BEFORE those rules were added to the file —
11
+ * an old session held to a file written later the same day. Dating each rule
12
+ * from git removed all of them.
13
+ *
14
+ * This checks by rule SET, not by line: the CLAUDE.md is reconstructed as it
15
+ * stood at the session's start time, parsed, and its rules fingerprinted. A
16
+ * current project rule whose fingerprint is absent from that historical set
17
+ * did not exist during the session, so it is marked not-applicable rather
18
+ * than checked. Uses the same content-hash handle the rest of the tool keys
19
+ * on, so no line tracking is needed.
20
+ *
21
+ * Fails OPEN, everywhere: not a git repo, git absent, file untracked, a
22
+ * session with no timestamps — any of these return null and NOTHING is
23
+ * filtered, so a rule is never wrongly hidden. It only ever removes a
24
+ * false accusation, never creates a miss.
25
+ *
26
+ * Scope, stated: only the project CLAUDE.md at the working directory. Global
27
+ * (~/.claude) rules live in a different repo and AGENTS.md/subdir files are
28
+ * out of scope for this first cut; those are simply never filtered.
29
+ */
30
+ /** The earliest timestamp in the transcript — when the session began. */
31
+ export function sessionStartTime(events) {
32
+ let earliest = null;
33
+ for (const e of events) {
34
+ const t = e.timestamp;
35
+ if (!t)
36
+ continue;
37
+ if (earliest === null || t < earliest)
38
+ earliest = t;
39
+ }
40
+ return earliest;
41
+ }
42
+ function git(cwd, args) {
43
+ try {
44
+ return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * The fingerprints of the project CLAUDE.md's rules as they stood at
52
+ * `isoTime`, or null when that cannot be determined (fail open).
53
+ */
54
+ export function projectHandlesAtTime(cwd, isoTime) {
55
+ const prefix = git(cwd, ["rev-parse", "--show-prefix"]);
56
+ if (prefix === null)
57
+ return null; // not a git repo
58
+ const relPath = `${prefix.trim()}CLAUDE.md`;
59
+ const commit = git(cwd, ["log", "-1", `--before=${isoTime}`, "--format=%H", "--", relPath]);
60
+ if (commit === null)
61
+ return null;
62
+ const sha = commit.trim();
63
+ if (!sha)
64
+ return null; // no commit to this file before the session began
65
+ const content = git(cwd, ["show", `${sha}:${relPath}`]);
66
+ if (content === null)
67
+ return null; // untracked at that commit
68
+ const handles = new Set();
69
+ for (const rule of parseClaudeMdText(content, "project"))
70
+ handles.add(ruleFingerprint(rule));
71
+ return handles;
72
+ }
73
+ /** A rule that did not exist when the session ran. */
74
+ export function futureResult(rule) {
75
+ return {
76
+ ruleId: rule.id, ruleTitle: rule.title, ruleSource: rule.source,
77
+ status: "UNCLEAR", outcome: "not_applicable", method: "none",
78
+ evidence: "this rule was added to CLAUDE.md after the session ran, so the session could not have followed or broken it",
79
+ };
80
+ }
81
+ /**
82
+ * Splits rules into those that existed when the session ran and those added
83
+ * afterwards. When history is unavailable, everything is "present" — nothing
84
+ * is filtered.
85
+ */
86
+ export function partitionByAge(cwd, rules, events) {
87
+ const startedAt = sessionStartTime(events);
88
+ if (!startedAt)
89
+ return { present: rules, future: [] };
90
+ const historical = projectHandlesAtTime(cwd, startedAt);
91
+ if (historical === null)
92
+ return { present: rules, future: [] };
93
+ const present = [];
94
+ const future = [];
95
+ for (const rule of rules) {
96
+ // Only project rules are datable here; global rules are never filtered.
97
+ if (rule.source === "project" && !historical.has(ruleFingerprint(rule)))
98
+ future.push(rule);
99
+ else
100
+ present.push(rule);
101
+ }
102
+ return { present, future };
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
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",