rulereceipt 0.1.44 → 0.1.46

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
@@ -309,6 +309,52 @@ on a session it never found.
309
309
  For most people the honest answer is simpler: run `rulereceipt check --html`
310
310
  locally and attach the report to the PR.
311
311
 
312
+ ### The GitHub Action and the receipt flow
313
+
314
+ The concrete way to gate in CI: produce a **receipt** where the session
315
+ lives, verify it where it doesn't.
316
+
317
+ Locally (the session is on your machine), produce and commit a receipt:
318
+
319
+ ```bash
320
+ rulereceipt check --json > .rulereceipt/receipt.json # commit this file
321
+ ```
322
+
323
+ In CI (no session), verify the committed receipt with the Action:
324
+
325
+ ```yaml
326
+ - uses: rulereceipt/rulereceipt@main # pin to a release tag once one is cut
327
+ with:
328
+ receipt: .rulereceipt/receipt.json
329
+ max-age-days: "7" # optional: reject a stale receipt
330
+ # anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # optional: also fail on CLAUDE.md↔AGENTS.md contradictions
331
+ ```
332
+
333
+ The build **fails** unless the receipt is a real, current, passing
334
+ RuleReceipt receipt. The Action also prints a session-independent audit of
335
+ your CLAUDE.md (`rules --coverage`), and — only if you pass an API key —
336
+ fails on a CLAUDE.md-vs-AGENTS.md contradiction.
337
+
338
+ Or run the pieces directly:
339
+
340
+ ```bash
341
+ rulereceipt verify-receipt .rulereceipt/receipt.json --max-age-days 7
342
+ ```
343
+
344
+ **Honest trust boundary:** with no session, CI trusts the receipt you
345
+ committed. But if the session *is* available — agentic CI, or you upload the
346
+ transcript — pass it and CI re-derives instead of trusting:
347
+
348
+ ```bash
349
+ rulereceipt verify-receipt .rulereceipt/receipt.json --session path/to/session.jsonl
350
+ ```
351
+
352
+ That re-hashes the session and **rejects a receipt that doesn't match it**
353
+ (forged, tampered, or the wrong session) — no trust required. For the
354
+ no-session case, trust remains until signed/attested receipts land; a
355
+ self-signed receipt would not help (the author holds the key), so the honest
356
+ closure is session re-verification where the session exists.
357
+
312
358
  ## Install
313
359
 
314
360
  ```bash
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A shields.io "endpoint" badge, derived from a receipt.
3
+ *
4
+ * The user commits the receipt (from `check --json`), runs `rulereceipt badge
5
+ * <receipt>` to emit this JSON, commits that too, and references it:
6
+ * ![rules](https://img.shields.io/endpoint?url=<raw-url-to-the-json>)
7
+ *
8
+ * Honest by construction: the badge only ever says what the receipt says.
9
+ * "passing" means no rule FAILED — UNCLEAR (needs judgment / no key) is not a
10
+ * failure, same as everywhere else in the tool.
11
+ */
12
+ export interface Badge {
13
+ schemaVersion: 1;
14
+ label: string;
15
+ message: string;
16
+ color: string;
17
+ }
18
+ export declare function buildBadge(summary: {
19
+ pass: number;
20
+ fail: number;
21
+ unclear: number;
22
+ }): Badge;
package/dist/badge.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A shields.io "endpoint" badge, derived from a receipt.
3
+ *
4
+ * The user commits the receipt (from `check --json`), runs `rulereceipt badge
5
+ * <receipt>` to emit this JSON, commits that too, and references it:
6
+ * ![rules](https://img.shields.io/endpoint?url=<raw-url-to-the-json>)
7
+ *
8
+ * Honest by construction: the badge only ever says what the receipt says.
9
+ * "passing" means no rule FAILED — UNCLEAR (needs judgment / no key) is not a
10
+ * failure, same as everywhere else in the tool.
11
+ */
12
+ export function buildBadge(summary) {
13
+ if (summary.fail > 0) {
14
+ return { schemaVersion: 1, label: "rules", message: `${summary.fail} failing`, color: "red" };
15
+ }
16
+ if (summary.pass > 0) {
17
+ return { schemaVersion: 1, label: "rules", message: "passing", color: "brightgreen" };
18
+ }
19
+ // Nothing failed and nothing deterministically passed — only judgment rules
20
+ // with nothing to grade. Not green (that would overstate), not red.
21
+ return { schemaVersion: 1, label: "rules", message: "unclear", color: "lightgrey" };
22
+ }
package/dist/cli.js CHANGED
@@ -20,13 +20,19 @@ import { runEmojiChecks } from "./checks/emojiOutput.js";
20
20
  import { runHook } from "./hook.js";
21
21
  import { runGuard } from "./guard.js";
22
22
  import { runJudgmentChecks } from "./checks/judgmentChecks.js";
23
- import { generateReport, generateMarkdownReport } from "./report/generateReport.js";
23
+ import { generateReport, generateMarkdownReport, generateJsonReport, computeTranscriptHash } from "./report/generateReport.js";
24
24
  import { gateOffer, hookIsInstalled } from "./report/gateOffer.js";
25
25
  import { generateHtmlReport } from "./report/generateHtmlReport.js";
26
26
  import { verifySessionHash } from "./verifyHash.js";
27
27
  import { saveEmailConfig, loadEmailConfig, detectSmtpHost, isValidEmail } from "./emailConfig.js";
28
28
  import { sendReportEmail } from "./sendReport.js";
29
29
  import { appendHistory, readHistorySince } from "./history.js";
30
+ import { maybeShowWhatsNew } from "./whatsNew.js";
31
+ import { verifyReceipt, parseReceipt } from "./receipt.js";
32
+ import { buildBadge } from "./badge.js";
33
+ import { buildInitGuidance } from "./init.js";
34
+ import { loadProjectConfig, handleMap, blockingFailures, warningFailures, PROJECT_CONFIG_PATH } from "./projectConfig.js";
35
+ import { maybeCheckUpdates, isUpdateCheckEnabled } from "./updateCheck.js";
30
36
  import { generateDigest } from "./digest.js";
31
37
  import { enableSchedule, disableSchedule, scheduleStatus } from "./schedule.js";
32
38
  import { findSplitBrainConflicts } from "./checks/splitBrain.js";
@@ -139,7 +145,7 @@ function writeHtmlReport(results, meta, cwd, target) {
139
145
  }
140
146
  }
141
147
  async function runCheck(opts) {
142
- const { markdown, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, showSkipped, transcriptOverride } = opts;
148
+ const { markdown, json, checkUpdates, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, showSkipped, transcriptOverride } = opts;
143
149
  const cwd = process.cwd();
144
150
  const rules = loadRules(cwd);
145
151
  if (rules.length === 0) {
@@ -257,12 +263,26 @@ async function runCheck(opts) {
257
263
  // regardless of --llm.
258
264
  const judgmentResults = llm ? await runJudgmentChecks(judgment, events) : judgment.map(({ rule }) => needsLlmResult(rule));
259
265
  const results = [...deterministicResults, ...judgmentResults];
266
+ // Severity: rules a team marked as warnings in .rulereceipt/config.json are
267
+ // still reported but do not fail the build. handleFor maps a result back to
268
+ // its stable handle so the mark survives edits that renumber rule ids.
269
+ const projectConfig = loadProjectConfig(cwd);
270
+ const handleFor = handleMap(rules);
271
+ const blockingFails = blockingFailures(results, projectConfig, handleFor);
272
+ const warnedFails = warningFailures(results, projectConfig, handleFor);
260
273
  const meta = { sessionFilePath, ruleCount: results.length };
274
+ // Kept in human/markdown form for --email and any other reader below, even
275
+ // when stdout is JSON — a manager gets a readable report, not raw JSON.
261
276
  const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
262
- console.log(reportText);
277
+ if (json) {
278
+ console.log(generateJsonReport(results, meta, pkg.version));
279
+ }
280
+ else {
281
+ console.log(reportText);
282
+ }
263
283
  // Shown only to someone who has just read their own broken rules, and only
264
284
  // if they have not already wired it up. See report/gateOffer.ts.
265
- if (!markdown) {
285
+ if (!markdown && !json) {
266
286
  const offer = gateOffer({
267
287
  failures: results.filter((r) => r.status === "FAIL").length,
268
288
  hookInstalled: hookIsInstalled(cwd),
@@ -287,7 +307,7 @@ async function runCheck(opts) {
287
307
  // so, and a rule dropped here never appears in the report at all. Listing
288
308
  // them needs no key, works in any language, and lets the person who wrote
289
309
  // the rule be the one who decides.
290
- if (notARule.length > 0) {
310
+ if (!json && notARule.length > 0) {
291
311
  const n = notARule.length;
292
312
  const plural = n === 1 ? "" : "s";
293
313
  console.log(`\n(${n} item${plural} in your rules file ${n === 1 ? "was" : "were"} treated as documentation and not checked — directory listings, reference tables, examples.)`);
@@ -315,10 +335,22 @@ async function runCheck(opts) {
315
335
  console.log(`Run with --show-skipped to see them.`);
316
336
  }
317
337
  }
318
- if (stale.length > 0) {
338
+ if (!json && stale.length > 0) {
319
339
  console.log(`\n(${stale.length} saved correction${stale.length === 1 ? "" : "s"} no longer match any rule in this project — the rule was probably reworded. Run \`rulereceipt rules --list\` to see them.)`);
320
340
  }
341
+ if (!json && !markdown && warnedFails.length > 0) {
342
+ console.log(`\n(${warnedFails.length} failing rule${warnedFails.length === 1 ? "" : "s"} ${warnedFails.length === 1 ? "is" : "are"} set to warning in ${PROJECT_CONFIG_PATH} and did not fail the build.)`);
343
+ }
321
344
  appendHistory(results, sessionFilePath);
345
+ // A once-per-update footer so a returning user sees the tool improved and
346
+ // comes back. Offline (notes ship in the package), fails open, and never
347
+ // on --markdown (that output is meant to be pasted into a PR/Slack) or
348
+ // --json (that output must be a single parseable object, nothing else).
349
+ if (!markdown && !json) {
350
+ maybeShowWhatsNew(pkg.version);
351
+ // Opt-in only; makes no network call unless enabled. Fails open.
352
+ await maybeCheckUpdates(pkg.version, isUpdateCheckEnabled(checkUpdates));
353
+ }
322
354
  if (share) {
323
355
  await shareResults(results);
324
356
  }
@@ -348,7 +380,9 @@ async function runCheck(opts) {
348
380
  // rules in a real CLAUDE.md need judgment, so without --llm they
349
381
  // legitimately report UNCLEAR. Gating on those would make every build
350
382
  // red on day one and the check would be deleted within a week.
351
- if (!exitZero && results.some((r) => r.status === "FAIL")) {
383
+ // Gate on BLOCKING failures only — a rule marked warning in the project
384
+ // config is reported but does not fail the build.
385
+ if (!exitZero && blockingFails.length > 0) {
352
386
  process.exitCode = 1;
353
387
  }
354
388
  }
@@ -366,6 +400,8 @@ program
366
400
  .command("check", { isDefault: true })
367
401
  .description("Check the current project's latest Claude Code session against CLAUDE.md/AGENTS.md")
368
402
  .option("--markdown", "output as markdown, for pasting into a PR or Slack")
403
+ .option("--json", "output a machine-readable JSON report instead of text — for CI, a GitHub Action, or any other consumer. Suppresses all human-only output; exit code is unchanged.")
404
+ .option("--check-updates", "opt-in: check npm for a newer rulereceipt and print a one-line nudge if there is one (at most once a day). Off by default; RULERECEIPT_CHECK_UPDATES=1 also enables it.")
369
405
  .option("--share", "opt-in: send anonymous pass/fail/unclear counts only (no rule text, no file paths, no session content). Off by default — no network call happens without this flag.")
370
406
  .option("--email", "opt-in: send this report directly from your own email (configured via `rulereceipt config`) to your configured manager email — but only when something actually failed. A manager doesn't need an email for every clean run. RuleReceipt's servers are never involved — sends straight from your machine via your own SMTP credentials.")
371
407
  .option("--email-always", "used with --email: send every time, even when nothing failed")
@@ -379,6 +415,8 @@ program
379
415
  .action((opts) => {
380
416
  runCheck({
381
417
  markdown: Boolean(opts.markdown),
418
+ json: Boolean(opts.json),
419
+ checkUpdates: Boolean(opts.checkUpdates),
382
420
  share: Boolean(opts.share),
383
421
  email: Boolean(opts.email),
384
422
  emailAlways: Boolean(opts.emailAlways),
@@ -622,6 +660,11 @@ async function runLint(markdown, llm) {
622
660
  console.log("No contradictions found between CLAUDE.md and AGENTS.md.");
623
661
  return;
624
662
  }
663
+ // A contradiction between the two rule files is a real defect, not just
664
+ // information: it means the agent is being given conflicting instructions.
665
+ // Exit non-zero so CI (and the GitHub Action) can gate on it, the same way
666
+ // `check` exits 1 on a FAIL.
667
+ process.exitCode = 1;
625
668
  if (markdown) {
626
669
  const lines = ["## CLAUDE.md vs AGENTS.md — contradictions found", ""];
627
670
  for (const c of result.conflicts) {
@@ -714,6 +757,18 @@ program
714
757
  process.exitCode = 1;
715
758
  });
716
759
  });
760
+ program
761
+ .command("init")
762
+ .description("Guided setup: shows what's configured and the exact next steps. Read-only — writes nothing.")
763
+ .action(() => {
764
+ const cwd = process.cwd();
765
+ console.log(buildInitGuidance({
766
+ hasClaudeMd: existsSync(join(cwd, "CLAUDE.md")),
767
+ hasAgentsMd: existsSync(join(cwd, "AGENTS.md")),
768
+ hookInstalled: hookIsInstalled(cwd),
769
+ hasApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
770
+ }));
771
+ });
717
772
  program
718
773
  .command("demo")
719
774
  .description("See a sample report — no setup, no API key needed")
@@ -814,4 +869,64 @@ program
814
869
  process.exitCode = 1;
815
870
  }
816
871
  });
872
+ program
873
+ .command("verify-receipt <path>")
874
+ .description("CI gate: verify a receipt (produced locally with `check --json` and committed) — that it is a real, current, passing RuleReceipt receipt. No session needed. Exits non-zero if invalid, stale, or anything FAILED.")
875
+ .option("--max-age-days <n>", "reject a receipt older than N days (freshness gate)")
876
+ .option("--session <path>", "if the session transcript is available (agentic CI, or you uploaded it), re-hash it and confirm the receipt was produced from THAT session. This is the only check that needs no trust — a mismatch is rejected.")
877
+ .action((path, opts) => {
878
+ let text;
879
+ try {
880
+ text = readFileSync(path, "utf-8");
881
+ }
882
+ catch {
883
+ console.error(`Could not read receipt file: ${path}`);
884
+ process.exitCode = 1;
885
+ return;
886
+ }
887
+ const maxAgeDays = opts.maxAgeDays !== undefined ? Number(opts.maxAgeDays) : undefined;
888
+ if (maxAgeDays !== undefined && !Number.isFinite(maxAgeDays)) {
889
+ console.error(`--max-age-days must be a number, got: ${opts.maxAgeDays}`);
890
+ process.exitCode = 1;
891
+ return;
892
+ }
893
+ // Only pass sessionHash when a session was actually requested; null (path
894
+ // given but unreadable) is a rejection inside verifyReceipt.
895
+ const sessionHash = opts.session !== undefined ? computeTranscriptHash(opts.session) : undefined;
896
+ const res = verifyReceipt(text, { maxAgeDays, sessionHash });
897
+ if (res.ok && res.receipt) {
898
+ const r = res.receipt;
899
+ const trust = res.sessionVerified
900
+ ? "re-verified against the session (no trust needed)"
901
+ : "trusted (no session provided to re-verify against)";
902
+ console.log(`✓ receipt OK — rulereceipt v${r.version}, ${r.summary.pass} passed / ${r.summary.fail} failed / ${r.summary.unclear} unclear, generated ${r.generatedAt}\n ${trust}`);
903
+ }
904
+ else {
905
+ console.error("✕ receipt rejected:");
906
+ for (const p of res.problems)
907
+ console.error(` - ${p}`);
908
+ process.exitCode = 1;
909
+ }
910
+ });
911
+ program
912
+ .command("badge <receiptPath>")
913
+ .description("Emit a shields.io endpoint JSON from a receipt (from `check --json`), for a README badge. Commit the output and reference it: ![rules](https://img.shields.io/endpoint?url=<raw-url>)")
914
+ .action((receiptPath) => {
915
+ let text;
916
+ try {
917
+ text = readFileSync(receiptPath, "utf-8");
918
+ }
919
+ catch {
920
+ console.error(`Could not read receipt file: ${receiptPath}`);
921
+ process.exitCode = 1;
922
+ return;
923
+ }
924
+ const parsed = parseReceipt(text);
925
+ if (parsed.error || !parsed.receipt) {
926
+ console.error(`Not a valid receipt: ${parsed.error}`);
927
+ process.exitCode = 1;
928
+ return;
929
+ }
930
+ console.log(JSON.stringify(buildBadge(parsed.receipt.summary), null, 2));
931
+ });
817
932
  program.parse();
package/dist/init.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `rulereceipt init` — a guided setup that tells you exactly where you are and
3
+ * what to do next. Read-only on purpose: RuleReceipt audits OTHER tools for
4
+ * silently writing to .claude/settings.json, so it will not do that itself.
5
+ * It shows you the snippet to paste; you paste it.
6
+ */
7
+ export interface InitState {
8
+ hasClaudeMd: boolean;
9
+ hasAgentsMd: boolean;
10
+ hookInstalled: boolean;
11
+ hasApiKey: boolean;
12
+ }
13
+ /** The PreToolUse guard hook, as it goes into .claude/settings.json. */
14
+ export declare const GUARD_HOOK_SNIPPET = "{\n \"hooks\": {\n \"PreToolUse\": [\n { \"hooks\": [ { \"type\": \"command\", \"command\": \"rulereceipt guard\" } ] }\n ]\n }\n}";
15
+ export declare function buildInitGuidance(state: InitState): string;
package/dist/init.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `rulereceipt init` — a guided setup that tells you exactly where you are and
3
+ * what to do next. Read-only on purpose: RuleReceipt audits OTHER tools for
4
+ * silently writing to .claude/settings.json, so it will not do that itself.
5
+ * It shows you the snippet to paste; you paste it.
6
+ */
7
+ /** The PreToolUse guard hook, as it goes into .claude/settings.json. */
8
+ export const GUARD_HOOK_SNIPPET = `{
9
+ "hooks": {
10
+ "PreToolUse": [
11
+ { "hooks": [ { "type": "command", "command": "rulereceipt guard" } ] }
12
+ ]
13
+ }
14
+ }`;
15
+ function line(done, label) {
16
+ return ` ${done ? "✓" : "✗"} ${label}`;
17
+ }
18
+ export function buildInitGuidance(state) {
19
+ const out = [];
20
+ out.push("RuleReceipt setup");
21
+ out.push("");
22
+ out.push("Where you are:");
23
+ out.push(line(state.hasClaudeMd || state.hasAgentsMd, "a rules file (CLAUDE.md or AGENTS.md) in this directory"));
24
+ out.push(line(state.hookInstalled, "a RuleReceipt hook wired into Claude Code (enforcement)"));
25
+ out.push(line(state.hasApiKey, "ANTHROPIC_API_KEY set (for rules that need judgment)"));
26
+ out.push("");
27
+ const steps = [];
28
+ if (!state.hasClaudeMd && !state.hasAgentsMd) {
29
+ steps.push("Write a CLAUDE.md in this directory with your rules, one per line or per heading.\n" +
30
+ ' Even a few lines work — e.g. "Never commit to main" and "Run the tests before committing".');
31
+ }
32
+ if (!state.hookInstalled) {
33
+ steps.push("Turn on enforcement (optional but recommended). Add this to .claude/settings.json,\n" +
34
+ " then start a NEW Claude Code session so the hook loads:\n\n" +
35
+ GUARD_HOOK_SNIPPET.split("\n").map((l) => ` ${l}`).join("\n") +
36
+ "\n\n It refuses a command that breaks a file/branch rule before it runs, and fails\n" +
37
+ " open on any error. Needs `npm i -g rulereceipt` (or use `npx rulereceipt guard`).");
38
+ }
39
+ if (!state.hasApiKey) {
40
+ steps.push("Set ANTHROPIC_API_KEY (the same key Claude Code uses) if you want rules that need\n" +
41
+ " judgment graded. Without it those report UNCLEAR — deterministic checks run regardless,\n" +
42
+ " and nothing is ever sent without the --llm flag.");
43
+ }
44
+ if (steps.length === 0) {
45
+ out.push("You're set up. Run: rulereceipt check");
46
+ }
47
+ else {
48
+ out.push("Next steps:");
49
+ steps.forEach((s, i) => out.push(`${i + 1}. ${s}`));
50
+ }
51
+ out.push("");
52
+ out.push("See it right now with no setup: rulereceipt demo");
53
+ out.push("Check your last real session: rulereceipt check");
54
+ return out.join("\n");
55
+ }
@@ -0,0 +1,25 @@
1
+ import type { CheckResult, Rule } from "./types.js";
2
+ /**
3
+ * A committed, team-shared config at .rulereceipt/config.json.
4
+ *
5
+ * Today it holds one thing: rule handles to treat as WARNINGS — a broken
6
+ * "warning" rule is still reported, but it does not fail the build. This is
7
+ * the honest version of "severity": a team marks the must-not-break rules as
8
+ * errors (the default) and the nice-to-have ones as warnings, so CI gates on
9
+ * what actually matters instead of going red on day one.
10
+ *
11
+ * Handles, not rule ids: an id is positional and renumbers when the file is
12
+ * edited above it; a handle is a content hash, so it survives edits. Get one
13
+ * from `rulereceipt rules --list`.
14
+ */
15
+ export interface ProjectConfig {
16
+ warn: string[];
17
+ }
18
+ export declare const PROJECT_CONFIG_PATH: string;
19
+ export declare function loadProjectConfig(cwd: string): ProjectConfig;
20
+ /** A lookup from a result back to its stable rule handle, built from the loaded rules. */
21
+ export declare function handleMap(rules: Rule[]): (r: CheckResult) => string;
22
+ /** FAILs that are NOT configured as warnings — these fail the build. */
23
+ export declare function blockingFailures(results: CheckResult[], config: ProjectConfig, handleFor: (r: CheckResult) => string): CheckResult[];
24
+ /** FAILs that ARE configured as warnings — shown, but they do not fail the build. */
25
+ export declare function warningFailures(results: CheckResult[], config: ProjectConfig, handleFor: (r: CheckResult) => string): CheckResult[];
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ruleFingerprint } from "./overrides.js";
4
+ export const PROJECT_CONFIG_PATH = join(".rulereceipt", "config.json");
5
+ export function loadProjectConfig(cwd) {
6
+ try {
7
+ const parsed = JSON.parse(readFileSync(join(cwd, PROJECT_CONFIG_PATH), "utf-8"));
8
+ const warn = parsed?.warn;
9
+ return { warn: Array.isArray(warn) ? warn.filter((x) => typeof x === "string") : [] };
10
+ }
11
+ catch {
12
+ // Missing or malformed config means no severities configured, never an
13
+ // error — same fail-open discipline as the rest of the tool.
14
+ return { warn: [] };
15
+ }
16
+ }
17
+ /** A lookup from a result back to its stable rule handle, built from the loaded rules. */
18
+ export function handleMap(rules) {
19
+ const m = new Map();
20
+ for (const rule of rules)
21
+ m.set(`${rule.source}:${rule.id}`, ruleFingerprint(rule));
22
+ return (r) => m.get(`${r.ruleSource}:${r.ruleId}`) ?? "";
23
+ }
24
+ /** FAILs that are NOT configured as warnings — these fail the build. */
25
+ export function blockingFailures(results, config, handleFor) {
26
+ return results.filter((r) => r.status === "FAIL" && !config.warn.includes(handleFor(r)));
27
+ }
28
+ /** FAILs that ARE configured as warnings — shown, but they do not fail the build. */
29
+ export function warningFailures(results, config, handleFor) {
30
+ return results.filter((r) => r.status === "FAIL" && config.warn.includes(handleFor(r)));
31
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The CI side of RuleReceipt.
3
+ *
4
+ * The check itself needs the local Claude Code session transcript, which a CI
5
+ * runner does not have. So CI does not re-run the check — it verifies a
6
+ * RECEIPT the developer produced locally with `check --json` and committed:
7
+ * that it is a real RuleReceipt receipt, a schema this tool understands, not
8
+ * stale, and that nothing FAILED.
9
+ *
10
+ * Honest trust boundary: the receipt commits to the session via its sha256,
11
+ * but CI has no session to re-hash, so CI is trusting the committed receipt.
12
+ * A signed/attested receipt closes that gap and is the documented next step;
13
+ * until then, "verify-receipt" means "this receipt is well-formed, current,
14
+ * and passing", not "CI independently re-derived it from the session".
15
+ */
16
+ export interface Receipt {
17
+ tool: string;
18
+ schema: number;
19
+ version: string;
20
+ generatedAt: string;
21
+ session: {
22
+ path: string | null;
23
+ sha256: string | null;
24
+ };
25
+ summary: {
26
+ total: number;
27
+ pass: number;
28
+ fail: number;
29
+ unclear: number;
30
+ };
31
+ results: unknown[];
32
+ }
33
+ /** The receipt schema this tool understands. Bumped in generateJsonReport on any breaking shape change. */
34
+ export declare const KNOWN_SCHEMA = 1;
35
+ export interface VerifyReceiptOptions {
36
+ /** Reject a receipt whose generatedAt is older than this many days. */
37
+ maxAgeDays?: number;
38
+ /**
39
+ * The sha256 of the actual session file, if it is available to the verifier
40
+ * (agentic CI, or a developer who uploaded the transcript). When set, the
41
+ * receipt is re-verified against it: a mismatch is rejected. This is the
42
+ * only path that closes the trust boundary — CI re-derives instead of
43
+ * trusting. `null` means "a session was named but could not be read".
44
+ * `undefined` means "no session provided" (the normal, trust-the-receipt case).
45
+ */
46
+ sessionHash?: string | null;
47
+ }
48
+ export interface VerifyResult {
49
+ ok: boolean;
50
+ problems: string[];
51
+ receipt?: Receipt;
52
+ /** True only when a session was provided AND its hash matched the receipt — i.e. CI re-derived, not trusted. */
53
+ sessionVerified?: boolean;
54
+ }
55
+ /**
56
+ * Structural validation only — is this a RuleReceipt receipt at all, with the
57
+ * fields the verifier relies on. Returns a typed receipt or a reason.
58
+ */
59
+ export declare function parseReceipt(text: string): {
60
+ receipt?: Receipt;
61
+ error?: string;
62
+ };
63
+ /**
64
+ * The CI gate. ok=false with a reason list means fail the build. UNCLEAR
65
+ * never fails on its own — same rule as `check`'s exit code: a rule that
66
+ * needs human judgment is not a violation.
67
+ */
68
+ export declare function verifyReceipt(text: string, opts?: VerifyReceiptOptions): VerifyResult;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The CI side of RuleReceipt.
3
+ *
4
+ * The check itself needs the local Claude Code session transcript, which a CI
5
+ * runner does not have. So CI does not re-run the check — it verifies a
6
+ * RECEIPT the developer produced locally with `check --json` and committed:
7
+ * that it is a real RuleReceipt receipt, a schema this tool understands, not
8
+ * stale, and that nothing FAILED.
9
+ *
10
+ * Honest trust boundary: the receipt commits to the session via its sha256,
11
+ * but CI has no session to re-hash, so CI is trusting the committed receipt.
12
+ * A signed/attested receipt closes that gap and is the documented next step;
13
+ * until then, "verify-receipt" means "this receipt is well-formed, current,
14
+ * and passing", not "CI independently re-derived it from the session".
15
+ */
16
+ /** The receipt schema this tool understands. Bumped in generateJsonReport on any breaking shape change. */
17
+ export const KNOWN_SCHEMA = 1;
18
+ /**
19
+ * Structural validation only — is this a RuleReceipt receipt at all, with the
20
+ * fields the verifier relies on. Returns a typed receipt or a reason.
21
+ */
22
+ export function parseReceipt(text) {
23
+ let obj;
24
+ try {
25
+ obj = JSON.parse(text);
26
+ }
27
+ catch (e) {
28
+ return { error: `not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
29
+ }
30
+ if (obj === null || typeof obj !== "object")
31
+ return { error: "receipt is not a JSON object" };
32
+ const o = obj;
33
+ if (o.tool !== "rulereceipt")
34
+ return { error: `not a rulereceipt receipt (tool=${JSON.stringify(o.tool)})` };
35
+ if (typeof o.schema !== "number")
36
+ return { error: "receipt has no numeric 'schema'" };
37
+ if (typeof o.generatedAt !== "string")
38
+ return { error: "receipt has no 'generatedAt' timestamp" };
39
+ const s = o.summary;
40
+ if (!s || typeof s.fail !== "number" || typeof s.pass !== "number" || typeof s.unclear !== "number") {
41
+ return { error: "receipt has no valid 'summary' counts" };
42
+ }
43
+ return { receipt: o };
44
+ }
45
+ /**
46
+ * The CI gate. ok=false with a reason list means fail the build. UNCLEAR
47
+ * never fails on its own — same rule as `check`'s exit code: a rule that
48
+ * needs human judgment is not a violation.
49
+ */
50
+ export function verifyReceipt(text, opts = {}) {
51
+ const parsed = parseReceipt(text);
52
+ if (parsed.error)
53
+ return { ok: false, problems: [parsed.error] };
54
+ const r = parsed.receipt;
55
+ const problems = [];
56
+ if (r.schema > KNOWN_SCHEMA) {
57
+ problems.push(`receipt schema ${r.schema} is newer than this tool understands (${KNOWN_SCHEMA}) — upgrade rulereceipt`);
58
+ }
59
+ if (r.summary.fail > 0) {
60
+ problems.push(`${r.summary.fail} rule${r.summary.fail === 1 ? "" : "s"} FAILED in this receipt`);
61
+ }
62
+ if (opts.maxAgeDays !== undefined) {
63
+ const ms = Date.now() - new Date(r.generatedAt).getTime();
64
+ const days = ms / 86_400_000;
65
+ if (!Number.isFinite(days)) {
66
+ problems.push(`receipt generatedAt is not a valid date: ${r.generatedAt}`);
67
+ }
68
+ else if (days > opts.maxAgeDays) {
69
+ problems.push(`receipt is ${Math.floor(days)} days old, older than the ${opts.maxAgeDays}-day limit`);
70
+ }
71
+ }
72
+ // Session re-verification: the only check that does not require trust. When
73
+ // the actual session is available, re-derive its hash and confirm the
74
+ // receipt was produced from THAT session — a mismatch means forged or wrong.
75
+ let sessionVerified;
76
+ if (opts.sessionHash !== undefined) {
77
+ if (opts.sessionHash === null) {
78
+ problems.push("a session file was named but could not be read");
79
+ }
80
+ else if (r.session.sha256 === null) {
81
+ problems.push("receipt has no session hash (demo data?), so it cannot be re-verified against a session");
82
+ }
83
+ else if (opts.sessionHash !== r.session.sha256) {
84
+ problems.push("receipt does NOT match the provided session (sha256 mismatch) — forged, tampered, or the wrong session");
85
+ }
86
+ else {
87
+ sessionVerified = true;
88
+ }
89
+ }
90
+ return { ok: problems.length === 0, problems, receipt: r, sessionVerified };
91
+ }
@@ -13,3 +13,15 @@ export interface ReportMeta {
13
13
  export declare function computeTranscriptHash(sessionFilePath: string | null): string | null;
14
14
  export declare function generateReport(results: CheckResult[], meta: ReportMeta): string;
15
15
  export declare function generateMarkdownReport(results: CheckResult[], meta: ReportMeta): string;
16
+ /**
17
+ * Machine-readable output for CI, a GitHub Action, or any other consumer.
18
+ *
19
+ * `schema` is versioned deliberately: this is a contract other tools will
20
+ * parse, so a breaking shape change must bump it rather than silently move
21
+ * fields under callers. The full (untruncated) sha256 is included so a
22
+ * consumer can `rulereceipt verify` the session independently — the human
23
+ * reports only show a prefix. Rule text and evidence are sanitized the same
24
+ * way as every other output: a hostile CLAUDE.md does not get to smuggle
25
+ * control characters through the JSON either.
26
+ */
27
+ export declare function generateJsonReport(results: CheckResult[], meta: ReportMeta, toolVersion: string): string;
@@ -266,3 +266,45 @@ export function generateMarkdownReport(results, meta) {
266
266
  lines.push(hash ? `\`verify: sha256:${hash.slice(0, 16)}...\` · checked ${new Date().toISOString()}` : `demo data · checked ${new Date().toISOString()}`);
267
267
  return lines.join("\n");
268
268
  }
269
+ /**
270
+ * Machine-readable output for CI, a GitHub Action, or any other consumer.
271
+ *
272
+ * `schema` is versioned deliberately: this is a contract other tools will
273
+ * parse, so a breaking shape change must bump it rather than silently move
274
+ * fields under callers. The full (untruncated) sha256 is included so a
275
+ * consumer can `rulereceipt verify` the session independently — the human
276
+ * reports only show a prefix. Rule text and evidence are sanitized the same
277
+ * way as every other output: a hostile CLAUDE.md does not get to smuggle
278
+ * control characters through the JSON either.
279
+ */
280
+ export function generateJsonReport(results, meta, toolVersion) {
281
+ const clean = results.map(sanitize);
282
+ const count = (s) => clean.filter((r) => r.status === s).length;
283
+ const report = {
284
+ tool: "rulereceipt",
285
+ schema: 1,
286
+ version: toolVersion,
287
+ generatedAt: new Date().toISOString(),
288
+ session: {
289
+ path: meta.sessionFilePath,
290
+ sha256: computeTranscriptHash(meta.sessionFilePath),
291
+ },
292
+ summary: {
293
+ total: clean.length,
294
+ pass: count("PASS"),
295
+ fail: count("FAIL"),
296
+ unclear: count("UNCLEAR"),
297
+ },
298
+ results: clean.map((r) => ({
299
+ ruleId: r.ruleId,
300
+ ruleTitle: r.ruleTitle,
301
+ ruleSource: r.ruleSource,
302
+ status: r.status,
303
+ outcome: r.outcome ?? null,
304
+ method: r.method ?? null,
305
+ needsHuman: r.needsHuman ?? false,
306
+ evidence: r.evidence,
307
+ })),
308
+ };
309
+ return JSON.stringify(report, null, 2);
310
+ }
@@ -0,0 +1,11 @@
1
+ export declare function isUpdateCheckEnabled(flag: boolean): boolean;
2
+ /** True if enough time has passed since the last check (or there was none). */
3
+ export declare function shouldCheckNow(now: number, lastCheck: number | null, intervalMs?: number): boolean;
4
+ /** The nudge, or null if the current version is already latest (or newer). */
5
+ export declare function renderUpdateNudge(current: string, latest: string): string | null;
6
+ /**
7
+ * Orchestration: only when enabled, only when due, fetch the latest version
8
+ * and nudge if newer. Records the check time even on failure so a flaky
9
+ * network does not turn into a ping on every single run.
10
+ */
11
+ export declare function maybeCheckUpdates(current: string, enabled: boolean, log?: (s: string) => void): Promise<void>;
@@ -0,0 +1,92 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { compareVersions } from "./whatsNew.js";
5
+ /**
6
+ * The online complement to the offline "what's new" footer: tell someone on
7
+ * an old global install that a newer version exists.
8
+ *
9
+ * OPT-IN, never by default. RuleReceipt's whole promise is that it makes no
10
+ * network call you did not ask for — telemetry is opt-in for the same reason,
11
+ * and an auditing tool that quietly phones a registry is the hypocrisy it
12
+ * exists to catch. Enabled only by --check-updates or RULERECEIPT_CHECK_UPDATES=1,
13
+ * cached so it pings at most once a day, and fails open on any error.
14
+ */
15
+ const REGISTRY = "https://registry.npmjs.org/rulereceipt/latest";
16
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
17
+ function stampPath() {
18
+ return join(homedir(), ".rulereceipt", "last-update-check");
19
+ }
20
+ export function isUpdateCheckEnabled(flag) {
21
+ if (flag)
22
+ return true;
23
+ const env = process.env.RULERECEIPT_CHECK_UPDATES;
24
+ return env === "1" || env === "true";
25
+ }
26
+ /** True if enough time has passed since the last check (or there was none). */
27
+ export function shouldCheckNow(now, lastCheck, intervalMs = CHECK_INTERVAL_MS) {
28
+ if (lastCheck === null)
29
+ return true;
30
+ return now - lastCheck >= intervalMs;
31
+ }
32
+ function readLastCheck() {
33
+ try {
34
+ const n = parseInt(readFileSync(stampPath(), "utf-8").trim(), 10);
35
+ return Number.isFinite(n) ? n : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function writeLastCheck(now) {
42
+ try {
43
+ const dir = join(homedir(), ".rulereceipt");
44
+ if (!existsSync(dir))
45
+ mkdirSync(dir, { recursive: true });
46
+ writeFileSync(stampPath(), String(now), "utf-8");
47
+ }
48
+ catch {
49
+ // best-effort
50
+ }
51
+ }
52
+ /** The nudge, or null if the current version is already latest (or newer). */
53
+ export function renderUpdateNudge(current, latest) {
54
+ if (compareVersions(latest, current) <= 0)
55
+ return null;
56
+ return `\nA newer rulereceipt is available: v${latest} (you have v${current}). Update with: npx rulereceipt@latest`;
57
+ }
58
+ async function fetchLatestVersion() {
59
+ try {
60
+ const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(2000) });
61
+ if (!res.ok)
62
+ return null;
63
+ const body = (await res.json());
64
+ return typeof body.version === "string" ? body.version : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /**
71
+ * Orchestration: only when enabled, only when due, fetch the latest version
72
+ * and nudge if newer. Records the check time even on failure so a flaky
73
+ * network does not turn into a ping on every single run.
74
+ */
75
+ export async function maybeCheckUpdates(current, enabled, log = console.log) {
76
+ try {
77
+ if (!enabled)
78
+ return;
79
+ if (!shouldCheckNow(Date.now(), readLastCheck()))
80
+ return;
81
+ writeLastCheck(Date.now());
82
+ const latest = await fetchLatestVersion();
83
+ if (!latest)
84
+ return;
85
+ const nudge = renderUpdateNudge(current, latest);
86
+ if (nudge)
87
+ log(nudge);
88
+ }
89
+ catch {
90
+ // never let an update check affect the run
91
+ }
92
+ }
@@ -0,0 +1,41 @@
1
+ export interface Release {
2
+ version: string;
3
+ highlights: string[];
4
+ }
5
+ /**
6
+ * User-facing release highlights, newest first.
7
+ *
8
+ * This is NOT the internal CHANGELOG (developer/business-facing, never
9
+ * shipped). It is the short, plain "here is what got better" note a user
10
+ * sees once, the first time they run a new version. Add ONE entry at the
11
+ * top on each release: what changed, in a sentence, honest, no marketing.
12
+ *
13
+ * The whole point: someone who bounced off an early version sees the tool
14
+ * is improving and comes back. So keep it truthful — a highlight that
15
+ * overstates is the exact failure this tool exists to catch.
16
+ */
17
+ export declare const RELEASES: Release[];
18
+ export declare function readLastSeen(): string | null;
19
+ export declare function writeLastSeen(version: string): void;
20
+ /**
21
+ * Numeric dotted-version compare: <0 if a<b, 0 if equal, >0 if a>b.
22
+ * Numeric per segment, so 0.1.9 < 0.1.10 (not lexical). Junk gives 0, which
23
+ * makes the caller show nothing rather than guess.
24
+ */
25
+ export declare function compareVersions(a: string, b: string): number;
26
+ /**
27
+ * Releases strictly newer than lastSeen and no newer than current, newest
28
+ * first. A null lastSeen (first run ever) yields nothing on purpose: a
29
+ * first-timer should see their report, not a changelog.
30
+ */
31
+ export declare function highlightsBetween(lastSeen: string | null, current: string, releases?: Release[]): Release[];
32
+ export declare function renderWhatsNew(releases: Release[], current: string): string;
33
+ /**
34
+ * Prints the "what's new" note once per new version, then records the
35
+ * current version so it never repeats for that version.
36
+ *
37
+ * Fails open, always: any error here must never affect the report the user
38
+ * actually ran for, and there is no network call — the notes ship inside
39
+ * the package, so the tool stays true to "nothing leaves your machine".
40
+ */
41
+ export declare function maybeShowWhatsNew(current: string, log?: (s: string) => void): void;
@@ -0,0 +1,120 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /**
5
+ * User-facing release highlights, newest first.
6
+ *
7
+ * This is NOT the internal CHANGELOG (developer/business-facing, never
8
+ * shipped). It is the short, plain "here is what got better" note a user
9
+ * sees once, the first time they run a new version. Add ONE entry at the
10
+ * top on each release: what changed, in a sentence, honest, no marketing.
11
+ *
12
+ * The whole point: someone who bounced off an early version sees the tool
13
+ * is improving and comes back. So keep it truthful — a highlight that
14
+ * overstates is the exact failure this tool exists to catch.
15
+ */
16
+ export const RELEASES = [
17
+ {
18
+ version: "0.1.46",
19
+ highlights: [
20
+ "Run RuleReceipt in CI: `--json` output, `verify-receipt`, and a GitHub Action (uses: rulereceipt/rulereceipt).",
21
+ "`rulereceipt init` for guided setup, and per-rule warnings via .rulereceipt/config.json so CI gates on what matters.",
22
+ "A README status badge (`rulereceipt badge`), and opt-in `--check-updates`.",
23
+ ],
24
+ },
25
+ { version: "0.1.45", highlights: ["The tool now shows what's improved since you last ran it, like this note."] },
26
+ { version: "0.1.44", highlights: ["The report now offers to install enforcement, but only when a rule was actually broken."] },
27
+ { version: "0.1.43", highlights: ["New check: a claim to have read or verified something, with nothing in the session behind it."] },
28
+ { version: "0.1.41", highlights: ["Emoji rules are checked properly now (Unicode properties, not a hand-written list)."] },
29
+ { version: "0.1.39", highlights: ["You can mark which clause in a rule is the actual prohibition, so only that blocks."] },
30
+ { version: "0.1.36", highlights: ["Enforcement arrives: the tool can act on a broken rule with a hook, not just report it."] },
31
+ ];
32
+ function stateDir() {
33
+ return join(homedir(), ".rulereceipt");
34
+ }
35
+ function lastSeenPath() {
36
+ return join(stateDir(), "last-seen-version");
37
+ }
38
+ export function readLastSeen() {
39
+ try {
40
+ const v = readFileSync(lastSeenPath(), "utf-8").trim();
41
+ return v.length > 0 ? v : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ export function writeLastSeen(version) {
48
+ try {
49
+ const dir = stateDir();
50
+ if (!existsSync(dir))
51
+ mkdirSync(dir, { recursive: true });
52
+ writeFileSync(lastSeenPath(), version, "utf-8");
53
+ }
54
+ catch {
55
+ // best-effort; a run that cannot persist this just shows the note again
56
+ }
57
+ }
58
+ /**
59
+ * Numeric dotted-version compare: <0 if a<b, 0 if equal, >0 if a>b.
60
+ * Numeric per segment, so 0.1.9 < 0.1.10 (not lexical). Junk gives 0, which
61
+ * makes the caller show nothing rather than guess.
62
+ */
63
+ export function compareVersions(a, b) {
64
+ const pa = a.split(".").map((n) => parseInt(n, 10));
65
+ const pb = b.split(".").map((n) => parseInt(n, 10));
66
+ const len = Math.max(pa.length, pb.length);
67
+ for (let i = 0; i < len; i++) {
68
+ const x = pa[i] ?? 0;
69
+ const y = pb[i] ?? 0;
70
+ if (Number.isNaN(x) || Number.isNaN(y))
71
+ return 0;
72
+ if (x !== y)
73
+ return x - y;
74
+ }
75
+ return 0;
76
+ }
77
+ /**
78
+ * Releases strictly newer than lastSeen and no newer than current, newest
79
+ * first. A null lastSeen (first run ever) yields nothing on purpose: a
80
+ * first-timer should see their report, not a changelog.
81
+ */
82
+ export function highlightsBetween(lastSeen, current, releases = RELEASES) {
83
+ if (!lastSeen)
84
+ return [];
85
+ return releases.filter((r) => compareVersions(r.version, lastSeen) > 0 && compareVersions(r.version, current) <= 0);
86
+ }
87
+ export function renderWhatsNew(releases, current) {
88
+ const lines = [];
89
+ lines.push(`\n✨ What's new since you last ran rulereceipt (you're on v${current}):`);
90
+ for (const r of releases) {
91
+ for (const h of r.highlights) {
92
+ lines.push(` • v${r.version} ${h}`);
93
+ }
94
+ }
95
+ lines.push(`\nThis note shows once per update. To stay current: npx rulereceipt@latest`);
96
+ return lines.join("\n");
97
+ }
98
+ /**
99
+ * Prints the "what's new" note once per new version, then records the
100
+ * current version so it never repeats for that version.
101
+ *
102
+ * Fails open, always: any error here must never affect the report the user
103
+ * actually ran for, and there is no network call — the notes ship inside
104
+ * the package, so the tool stays true to "nothing leaves your machine".
105
+ */
106
+ export function maybeShowWhatsNew(current, log = console.log) {
107
+ try {
108
+ const lastSeen = readLastSeen();
109
+ const news = highlightsBetween(lastSeen, current, RELEASES);
110
+ if (news.length > 0)
111
+ log(renderWhatsNew(news, current));
112
+ // Record current even on the first run and even when nothing showed, so
113
+ // the next update is measured from here.
114
+ if (lastSeen !== current)
115
+ writeLastSeen(current);
116
+ }
117
+ catch {
118
+ // never let a footer break the run
119
+ }
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
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",
@@ -57,6 +57,6 @@
57
57
  "tsx": "^4.19.0",
58
58
  "typescript": "^5.6.0",
59
59
  "typescript-eslint": "^8.68.0",
60
- "vitest": "^4.1.11"
60
+ "vitest": "^5.0.1"
61
61
  }
62
62
  }