rulereceipt 0.1.51 → 0.1.53

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
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { loadRules } from "./rules.js";
11
11
  import { adviseRules } from "./checkability.js";
12
12
  import { shadowedAgentsMd } from "./shadowedAgents.js";
13
13
  import { partitionByAge, futureResult } from "./ruleAge.js";
14
+ import { auditSessions, renderComplianceReport } from "./report/complianceReport.js";
14
15
  import { classifyRules } from "./checks/classify.js";
15
16
  import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
16
17
  import { runDeterministicChecks } from "./checks/deterministicChecks.js";
@@ -865,6 +866,16 @@ const PERIOD_MS = {
865
866
  weekly: 7 * 24 * 60 * 60 * 1000,
866
867
  monthly: 30 * 24 * 60 * 60 * 1000,
867
868
  };
869
+ program
870
+ .command("report")
871
+ .description("Compliance report across your recent sessions (not just the latest): which policy rules were broken, where, with evidence. Deterministic, local, no network. The org-wide version runs via the Claude Compliance API for Enterprise orgs.")
872
+ .option("--last <n>", "how many recent sessions to audit", "25")
873
+ .option("--markdown", "output as markdown, for a report you can send")
874
+ .action(async (opts) => {
875
+ const n = Number.parseInt(String(opts.last), 10);
876
+ const r = await auditSessions(process.cwd(), Number.isFinite(n) ? n : 25);
877
+ console.log(renderComplianceReport(r, Boolean(opts.markdown)));
878
+ });
868
879
  program
869
880
  .command("digest")
870
881
  .description("A non-technical summary of recent check runs (counts only, no rule text) — for a manager who doesn't have time to read 30 individual reports.")
@@ -5,6 +5,8 @@ import type { TranscriptEvent } from "../types.js";
5
5
  * variant could keep its own global rules file under its own home dir.
6
6
  */
7
7
  export declare function findClaudeHomeDirNames(): string[];
8
+ /** Every session file for this project, newest first. */
9
+ export declare function listAllSessionFiles(cwd: string): string[];
8
10
  export declare function findLatestSessionFile(cwd: string): string | null;
9
11
  export declare function parseLine(line: string): TranscriptEvent[];
10
12
  /**
@@ -69,13 +69,16 @@ export function findClaudeHomeDirNames() {
69
69
  return [];
70
70
  }
71
71
  }
72
- export function findLatestSessionFile(cwd) {
72
+ /** Every session file for this project, newest first. */
73
+ export function listAllSessionFiles(cwd) {
73
74
  const encoded = encodeProjectPath(cwd);
74
75
  const sessionFiles = findClaudeHomeDirNames().flatMap((dirName) => listSessionFiles(join(homedir(), dirName, "projects", encoded)));
75
- if (sessionFiles.length === 0)
76
- return null;
77
76
  sessionFiles.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
78
- return sessionFiles[0];
77
+ return sessionFiles;
78
+ }
79
+ export function findLatestSessionFile(cwd) {
80
+ const all = listAllSessionFiles(cwd);
81
+ return all.length > 0 ? all[0] : null;
79
82
  }
80
83
  function extractToolResultText(content) {
81
84
  if (typeof content === "string")
@@ -0,0 +1,35 @@
1
+ import type { CheckResult } from "../types.js";
2
+ /**
3
+ * A compliance report over MANY sessions, not one.
4
+ *
5
+ * The free `check` reads a single latest session — one dev, one run. The
6
+ * enterprise question is different: "across every agent session in this org
7
+ * this month, which policy rules were broken, and where's the evidence?"
8
+ * This aggregates per-session results into that org-style summary. It runs on
9
+ * whatever sessions are reachable locally today; the same aggregation feeds
10
+ * the Compliance API (org-wide sessions) once an enterprise grants a key.
11
+ *
12
+ * Deterministic by default — no LLM, no network — so it can run over hundreds
13
+ * of sessions fast. Judgment rules are surfaced as "needs review", never
14
+ * guessed at, same honesty as everywhere else.
15
+ */
16
+ export interface SessionAudit {
17
+ session: string;
18
+ when: string;
19
+ events: number;
20
+ violations: CheckResult[];
21
+ }
22
+ export interface ComplianceReport {
23
+ sessionsChecked: number;
24
+ sessionsWithViolations: number;
25
+ totalViolations: number;
26
+ ruleCount: number;
27
+ byRule: {
28
+ title: string;
29
+ count: number;
30
+ }[];
31
+ sessions: SessionAudit[];
32
+ }
33
+ export declare function auditSessions(cwd: string, limit: number): Promise<ComplianceReport>;
34
+ /** A readable / pasteable compliance report. Markdown when `md` is set. */
35
+ export declare function renderComplianceReport(r: ComplianceReport, md?: boolean): string;
@@ -0,0 +1,76 @@
1
+ import { basename } from "node:path";
2
+ import { loadRules } from "../rules.js";
3
+ import { evaluateSession } from "../evaluate.js";
4
+ import { readTranscriptFromFile, listAllSessionFiles } from "../parsers/transcriptParser.js";
5
+ function needsReview(rule) {
6
+ return {
7
+ ruleId: rule.id, ruleTitle: rule.title, ruleSource: rule.source,
8
+ status: "UNCLEAR", outcome: "not_run", method: "none", needsHuman: true,
9
+ evidence: "judgment rule — not graded without --llm",
10
+ };
11
+ }
12
+ export async function auditSessions(cwd, limit) {
13
+ const rules = loadRules(cwd);
14
+ const files = listAllSessionFiles(cwd).slice(0, Math.max(1, limit));
15
+ const sessions = [];
16
+ const byRule = new Map();
17
+ let totalViolations = 0;
18
+ for (const file of files) {
19
+ const events = readTranscriptFromFile(file);
20
+ if (events.length === 0)
21
+ continue;
22
+ const { results } = await evaluateSession(cwd, rules, events, false, needsReview);
23
+ const violations = results.filter((r) => r.status === "FAIL");
24
+ for (const v of violations)
25
+ byRule.set(v.ruleTitle, (byRule.get(v.ruleTitle) ?? 0) + 1);
26
+ totalViolations += violations.length;
27
+ const first = events.find((e) => e.timestamp)?.timestamp ?? "";
28
+ sessions.push({ session: basename(file).replace(/\.jsonl$/, ""), when: first.slice(0, 10), events: events.length, violations });
29
+ }
30
+ return {
31
+ sessionsChecked: sessions.length,
32
+ sessionsWithViolations: sessions.filter((s) => s.violations.length > 0).length,
33
+ totalViolations,
34
+ ruleCount: rules.length,
35
+ byRule: [...byRule.entries()].map(([title, count]) => ({ title, count })).sort((a, b) => b.count - a.count),
36
+ sessions,
37
+ };
38
+ }
39
+ /** A readable / pasteable compliance report. Markdown when `md` is set. */
40
+ export function renderComplianceReport(r, md = false) {
41
+ const H = (s) => (md ? `## ${s}` : s);
42
+ const out = [];
43
+ out.push(md ? "# RuleReceipt — Compliance Report" : "RuleReceipt · Compliance Report");
44
+ out.push("");
45
+ out.push(`Checked ${r.sessionsChecked} session${r.sessionsChecked === 1 ? "" : "s"} against ${r.ruleCount} rules (project + global).`);
46
+ out.push("");
47
+ out.push(`${r.sessionsWithViolations} of ${r.sessionsChecked} sessions had at least one policy violation.`);
48
+ out.push(`${r.totalViolations} total violation${r.totalViolations === 1 ? "" : "s"} across ${r.byRule.length} distinct rule${r.byRule.length === 1 ? "" : "s"}.`);
49
+ out.push("");
50
+ if (r.byRule.length > 0) {
51
+ out.push(H("Most-violated rules"));
52
+ for (const { title, count } of r.byRule.slice(0, 10)) {
53
+ out.push(` ${String(count).padStart(3)}× ${title.replace(/\s+/g, " ").trim().slice(0, 70)}`);
54
+ }
55
+ out.push("");
56
+ }
57
+ const flagged = r.sessions.filter((s) => s.violations.length > 0);
58
+ if (flagged.length > 0) {
59
+ out.push(H("Sessions with violations"));
60
+ for (const s of flagged) {
61
+ out.push(` ${s.session.slice(0, 12)} ${s.when} — ${s.violations.length} violation${s.violations.length === 1 ? "" : "s"}`);
62
+ for (const v of s.violations.slice(0, 5)) {
63
+ out.push(` ✕ ${v.ruleTitle.replace(/\s+/g, " ").trim().slice(0, 66)}`);
64
+ out.push(` ${v.evidence.replace(/\s+/g, " ").trim().slice(0, 90)}`);
65
+ }
66
+ }
67
+ out.push("");
68
+ }
69
+ else {
70
+ out.push("No mechanical policy violations found in the sessions checked.");
71
+ out.push("");
72
+ }
73
+ out.push("Deterministic checks only. Judgment rules are not graded here (run per-session with --llm).");
74
+ out.push("Local sessions today; the same report runs org-wide via the Claude Compliance API for Enterprise orgs.");
75
+ return out.join("\n");
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
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",