rulereceipt 0.1.43 → 0.1.45

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/dist/cli.js CHANGED
@@ -21,11 +21,13 @@ import { runHook } from "./hook.js";
21
21
  import { runGuard } from "./guard.js";
22
22
  import { runJudgmentChecks } from "./checks/judgmentChecks.js";
23
23
  import { generateReport, generateMarkdownReport } from "./report/generateReport.js";
24
+ import { gateOffer, hookIsInstalled } from "./report/gateOffer.js";
24
25
  import { generateHtmlReport } from "./report/generateHtmlReport.js";
25
26
  import { verifySessionHash } from "./verifyHash.js";
26
27
  import { saveEmailConfig, loadEmailConfig, detectSmtpHost, isValidEmail } from "./emailConfig.js";
27
28
  import { sendReportEmail } from "./sendReport.js";
28
29
  import { appendHistory, readHistorySince } from "./history.js";
30
+ import { maybeShowWhatsNew } from "./whatsNew.js";
29
31
  import { generateDigest } from "./digest.js";
30
32
  import { enableSchedule, disableSchedule, scheduleStatus } from "./schedule.js";
31
33
  import { findSplitBrainConflicts } from "./checks/splitBrain.js";
@@ -259,6 +261,16 @@ async function runCheck(opts) {
259
261
  const meta = { sessionFilePath, ruleCount: results.length };
260
262
  const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
261
263
  console.log(reportText);
264
+ // Shown only to someone who has just read their own broken rules, and only
265
+ // if they have not already wired it up. See report/gateOffer.ts.
266
+ if (!markdown) {
267
+ const offer = gateOffer({
268
+ failures: results.filter((r) => r.status === "FAIL").length,
269
+ hookInstalled: hookIsInstalled(cwd),
270
+ });
271
+ if (offer)
272
+ console.log(`\n${offer}`);
273
+ }
262
274
  // Written before --share/--email so that a failure to send something
263
275
  // never costs the user the local artifact they explicitly asked for.
264
276
  if (html !== false) {
@@ -308,6 +320,12 @@ async function runCheck(opts) {
308
320
  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.)`);
309
321
  }
310
322
  appendHistory(results, sessionFilePath);
323
+ // A once-per-update footer so a returning user sees the tool improved and
324
+ // comes back. Offline (notes ship in the package), fails open, and never
325
+ // on --markdown (that output is meant to be pasted into a PR/Slack).
326
+ if (!markdown) {
327
+ maybeShowWhatsNew(pkg.version);
328
+ }
311
329
  if (share) {
312
330
  await shareResults(results);
313
331
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Is a RuleReceipt hook already wired into this project or this machine?
3
+ *
4
+ * Read-only, and deliberately forgiving: an unreadable or malformed settings
5
+ * file means "assume not installed" rather than an error. A missing settings
6
+ * file is the normal case, not a problem.
7
+ */
8
+ export declare function hookIsInstalled(cwd: string): boolean;
9
+ /**
10
+ * The line offering enforcement, or null when it should not be shown.
11
+ *
12
+ * The site describes the Stop hook to someone deciding whether to adopt the
13
+ * tool at all. This speaks to someone who has already run it and is looking
14
+ * at rules that were broken — which is the moment the offer is a consequence
15
+ * of what they just read rather than an advertisement.
16
+ *
17
+ * Three conditions, and each is a way of not nagging:
18
+ *
19
+ * - Only when something actually failed. On a clean report there is
20
+ * nothing to enforce and the line would be a pitch.
21
+ * - Never when the hook is already installed. Telling someone to do what
22
+ * they have already done is how a tool gets muted.
23
+ * - One line, no config block. A terminal report is not documentation, and
24
+ * pasting JSON into it would bury the findings it sits under.
25
+ */
26
+ export declare function gateOffer(state: {
27
+ failures: number;
28
+ hookInstalled: boolean;
29
+ }): string | null;
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /**
5
+ * Is a RuleReceipt hook already wired into this project or this machine?
6
+ *
7
+ * Read-only, and deliberately forgiving: an unreadable or malformed settings
8
+ * file means "assume not installed" rather than an error. A missing settings
9
+ * file is the normal case, not a problem.
10
+ */
11
+ export function hookIsInstalled(cwd) {
12
+ const candidates = [
13
+ join(cwd, ".claude", "settings.json"),
14
+ join(cwd, ".claude", "settings.local.json"),
15
+ join(homedir(), ".claude", "settings.json"),
16
+ ];
17
+ for (const path of candidates) {
18
+ if (!existsSync(path))
19
+ continue;
20
+ try {
21
+ // Matched as text rather than by walking the hook schema: the shape of
22
+ // that config has changed before, and a rename of one field should not
23
+ // make this start nagging someone who already installed it.
24
+ if (/rulereceipt\s+(hook|guard)/.test(readFileSync(path, "utf-8")))
25
+ return true;
26
+ }
27
+ catch {
28
+ // unreadable settings file — treat as not installed
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ /**
34
+ * The line offering enforcement, or null when it should not be shown.
35
+ *
36
+ * The site describes the Stop hook to someone deciding whether to adopt the
37
+ * tool at all. This speaks to someone who has already run it and is looking
38
+ * at rules that were broken — which is the moment the offer is a consequence
39
+ * of what they just read rather than an advertisement.
40
+ *
41
+ * Three conditions, and each is a way of not nagging:
42
+ *
43
+ * - Only when something actually failed. On a clean report there is
44
+ * nothing to enforce and the line would be a pitch.
45
+ * - Never when the hook is already installed. Telling someone to do what
46
+ * they have already done is how a tool gets muted.
47
+ * - One line, no config block. A terminal report is not documentation, and
48
+ * pasting JSON into it would bury the findings it sits under.
49
+ */
50
+ export function gateOffer(state) {
51
+ if (state.failures === 0)
52
+ return null;
53
+ if (state.hookInstalled)
54
+ return null;
55
+ const n = state.failures;
56
+ return (`This report is after the fact. \`rulereceipt hook\` runs as a Claude Code Stop hook and\n` +
57
+ `refuses to let a session end on ${n === 1 ? "a broken rule" : "a broken rule"} — see the README for the four lines to add.`);
58
+ }
@@ -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,112 @@
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
+ { version: "0.1.45", highlights: ["The tool now shows what's improved since you last ran it, like this note."] },
18
+ { version: "0.1.44", highlights: ["The report now offers to install enforcement, but only when a rule was actually broken."] },
19
+ { version: "0.1.43", highlights: ["New check: a claim to have read or verified something, with nothing in the session behind it."] },
20
+ { version: "0.1.41", highlights: ["Emoji rules are checked properly now (Unicode properties, not a hand-written list)."] },
21
+ { version: "0.1.39", highlights: ["You can mark which clause in a rule is the actual prohibition, so only that blocks."] },
22
+ { version: "0.1.36", highlights: ["Enforcement arrives: the tool can act on a broken rule with a hook, not just report it."] },
23
+ ];
24
+ function stateDir() {
25
+ return join(homedir(), ".rulereceipt");
26
+ }
27
+ function lastSeenPath() {
28
+ return join(stateDir(), "last-seen-version");
29
+ }
30
+ export function readLastSeen() {
31
+ try {
32
+ const v = readFileSync(lastSeenPath(), "utf-8").trim();
33
+ return v.length > 0 ? v : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export function writeLastSeen(version) {
40
+ try {
41
+ const dir = stateDir();
42
+ if (!existsSync(dir))
43
+ mkdirSync(dir, { recursive: true });
44
+ writeFileSync(lastSeenPath(), version, "utf-8");
45
+ }
46
+ catch {
47
+ // best-effort; a run that cannot persist this just shows the note again
48
+ }
49
+ }
50
+ /**
51
+ * Numeric dotted-version compare: <0 if a<b, 0 if equal, >0 if a>b.
52
+ * Numeric per segment, so 0.1.9 < 0.1.10 (not lexical). Junk gives 0, which
53
+ * makes the caller show nothing rather than guess.
54
+ */
55
+ export function compareVersions(a, b) {
56
+ const pa = a.split(".").map((n) => parseInt(n, 10));
57
+ const pb = b.split(".").map((n) => parseInt(n, 10));
58
+ const len = Math.max(pa.length, pb.length);
59
+ for (let i = 0; i < len; i++) {
60
+ const x = pa[i] ?? 0;
61
+ const y = pb[i] ?? 0;
62
+ if (Number.isNaN(x) || Number.isNaN(y))
63
+ return 0;
64
+ if (x !== y)
65
+ return x - y;
66
+ }
67
+ return 0;
68
+ }
69
+ /**
70
+ * Releases strictly newer than lastSeen and no newer than current, newest
71
+ * first. A null lastSeen (first run ever) yields nothing on purpose: a
72
+ * first-timer should see their report, not a changelog.
73
+ */
74
+ export function highlightsBetween(lastSeen, current, releases = RELEASES) {
75
+ if (!lastSeen)
76
+ return [];
77
+ return releases.filter((r) => compareVersions(r.version, lastSeen) > 0 && compareVersions(r.version, current) <= 0);
78
+ }
79
+ export function renderWhatsNew(releases, current) {
80
+ const lines = [];
81
+ lines.push(`\n✨ What's new since you last ran rulereceipt (you're on v${current}):`);
82
+ for (const r of releases) {
83
+ for (const h of r.highlights) {
84
+ lines.push(` • v${r.version} ${h}`);
85
+ }
86
+ }
87
+ lines.push(`\nThis note shows once per update. To stay current: npx rulereceipt@latest`);
88
+ return lines.join("\n");
89
+ }
90
+ /**
91
+ * Prints the "what's new" note once per new version, then records the
92
+ * current version so it never repeats for that version.
93
+ *
94
+ * Fails open, always: any error here must never affect the report the user
95
+ * actually ran for, and there is no network call — the notes ship inside
96
+ * the package, so the tool stays true to "nothing leaves your machine".
97
+ */
98
+ export function maybeShowWhatsNew(current, log = console.log) {
99
+ try {
100
+ const lastSeen = readLastSeen();
101
+ const news = highlightsBetween(lastSeen, current, RELEASES);
102
+ if (news.length > 0)
103
+ log(renderWhatsNew(news, current));
104
+ // Record current even on the first run and even when nothing showed, so
105
+ // the next update is measured from here.
106
+ if (lastSeen !== current)
107
+ writeLastSeen(current);
108
+ }
109
+ catch {
110
+ // never let a footer break the run
111
+ }
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
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",
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "license": "SEE LICENSE IN LICENSE",
48
48
  "dependencies": {
49
- "@anthropic-ai/sdk": "~0.32.1",
49
+ "@anthropic-ai/sdk": "~0.126.0",
50
50
  "commander": "~15.0.0",
51
51
  "nodemailer": "~10.0.1"
52
52
  },
@@ -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
  }