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 +18 -0
- package/dist/report/gateOffer.d.ts +29 -0
- package/dist/report/gateOffer.js +58 -0
- package/dist/whatsNew.d.ts +41 -0
- package/dist/whatsNew.js +112 -0
- package/package.json +3 -3
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;
|
package/dist/whatsNew.js
ADDED
|
@@ -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.
|
|
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.
|
|
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": "^
|
|
60
|
+
"vitest": "^5.0.1"
|
|
61
61
|
}
|
|
62
62
|
}
|