rulereceipt 0.1.45 → 0.1.47
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 +46 -0
- package/dist/badge.d.ts +22 -0
- package/dist/badge.js +22 -0
- package/dist/checkability.d.ts +31 -0
- package/dist/checkability.js +68 -0
- package/dist/checks/approvalGate.d.ts +3 -0
- package/dist/checks/approvalGate.js +105 -0
- package/dist/checks/attribution.d.ts +3 -0
- package/dist/checks/attribution.js +105 -0
- package/dist/checks/classify.d.ts +14 -1
- package/dist/checks/classify.js +86 -0
- package/dist/cli.js +158 -9
- package/dist/evaluate.js +4 -0
- package/dist/guard.js +6 -0
- package/dist/init.d.ts +15 -0
- package/dist/init.js +55 -0
- package/dist/projectConfig.d.ts +25 -0
- package/dist/projectConfig.js +31 -0
- package/dist/receipt.d.ts +68 -0
- package/dist/receipt.js +91 -0
- package/dist/report/generateReport.d.ts +12 -0
- package/dist/report/generateReport.js +42 -0
- package/dist/types.d.ts +1 -1
- package/dist/updateCheck.d.ts +11 -0
- package/dist/updateCheck.js +92 -0
- package/dist/whatsNew.js +8 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { parseClaudeMd } from "./parsers/readClaudeMd.js";
|
|
9
9
|
import { readLatestTranscript, readTranscriptFromFile, findLatestSessionFile } from "./parsers/transcriptParser.js";
|
|
10
10
|
import { loadRules } from "./rules.js";
|
|
11
|
+
import { adviseRules } from "./checkability.js";
|
|
11
12
|
import { classifyRules } from "./checks/classify.js";
|
|
12
13
|
import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
|
|
13
14
|
import { runDeterministicChecks } from "./checks/deterministicChecks.js";
|
|
@@ -20,7 +21,7 @@ import { runEmojiChecks } from "./checks/emojiOutput.js";
|
|
|
20
21
|
import { runHook } from "./hook.js";
|
|
21
22
|
import { runGuard } from "./guard.js";
|
|
22
23
|
import { runJudgmentChecks } from "./checks/judgmentChecks.js";
|
|
23
|
-
import { generateReport, generateMarkdownReport } from "./report/generateReport.js";
|
|
24
|
+
import { generateReport, generateMarkdownReport, generateJsonReport, computeTranscriptHash } from "./report/generateReport.js";
|
|
24
25
|
import { gateOffer, hookIsInstalled } from "./report/gateOffer.js";
|
|
25
26
|
import { generateHtmlReport } from "./report/generateHtmlReport.js";
|
|
26
27
|
import { verifySessionHash } from "./verifyHash.js";
|
|
@@ -28,6 +29,11 @@ import { saveEmailConfig, loadEmailConfig, detectSmtpHost, isValidEmail } from "
|
|
|
28
29
|
import { sendReportEmail } from "./sendReport.js";
|
|
29
30
|
import { appendHistory, readHistorySince } from "./history.js";
|
|
30
31
|
import { maybeShowWhatsNew } from "./whatsNew.js";
|
|
32
|
+
import { verifyReceipt, parseReceipt } from "./receipt.js";
|
|
33
|
+
import { buildBadge } from "./badge.js";
|
|
34
|
+
import { buildInitGuidance } from "./init.js";
|
|
35
|
+
import { loadProjectConfig, handleMap, blockingFailures, warningFailures, PROJECT_CONFIG_PATH } from "./projectConfig.js";
|
|
36
|
+
import { maybeCheckUpdates, isUpdateCheckEnabled } from "./updateCheck.js";
|
|
31
37
|
import { generateDigest } from "./digest.js";
|
|
32
38
|
import { enableSchedule, disableSchedule, scheduleStatus } from "./schedule.js";
|
|
33
39
|
import { findSplitBrainConflicts } from "./checks/splitBrain.js";
|
|
@@ -140,7 +146,7 @@ function writeHtmlReport(results, meta, cwd, target) {
|
|
|
140
146
|
}
|
|
141
147
|
}
|
|
142
148
|
async function runCheck(opts) {
|
|
143
|
-
const { markdown, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, showSkipped, transcriptOverride } = opts;
|
|
149
|
+
const { markdown, json, checkUpdates, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, showSkipped, transcriptOverride } = opts;
|
|
144
150
|
const cwd = process.cwd();
|
|
145
151
|
const rules = loadRules(cwd);
|
|
146
152
|
if (rules.length === 0) {
|
|
@@ -258,12 +264,26 @@ async function runCheck(opts) {
|
|
|
258
264
|
// regardless of --llm.
|
|
259
265
|
const judgmentResults = llm ? await runJudgmentChecks(judgment, events) : judgment.map(({ rule }) => needsLlmResult(rule));
|
|
260
266
|
const results = [...deterministicResults, ...judgmentResults];
|
|
267
|
+
// Severity: rules a team marked as warnings in .rulereceipt/config.json are
|
|
268
|
+
// still reported but do not fail the build. handleFor maps a result back to
|
|
269
|
+
// its stable handle so the mark survives edits that renumber rule ids.
|
|
270
|
+
const projectConfig = loadProjectConfig(cwd);
|
|
271
|
+
const handleFor = handleMap(rules);
|
|
272
|
+
const blockingFails = blockingFailures(results, projectConfig, handleFor);
|
|
273
|
+
const warnedFails = warningFailures(results, projectConfig, handleFor);
|
|
261
274
|
const meta = { sessionFilePath, ruleCount: results.length };
|
|
275
|
+
// Kept in human/markdown form for --email and any other reader below, even
|
|
276
|
+
// when stdout is JSON — a manager gets a readable report, not raw JSON.
|
|
262
277
|
const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
|
|
263
|
-
|
|
278
|
+
if (json) {
|
|
279
|
+
console.log(generateJsonReport(results, meta, pkg.version));
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
console.log(reportText);
|
|
283
|
+
}
|
|
264
284
|
// Shown only to someone who has just read their own broken rules, and only
|
|
265
285
|
// if they have not already wired it up. See report/gateOffer.ts.
|
|
266
|
-
if (!markdown) {
|
|
286
|
+
if (!markdown && !json) {
|
|
267
287
|
const offer = gateOffer({
|
|
268
288
|
failures: results.filter((r) => r.status === "FAIL").length,
|
|
269
289
|
hookInstalled: hookIsInstalled(cwd),
|
|
@@ -288,7 +308,7 @@ async function runCheck(opts) {
|
|
|
288
308
|
// so, and a rule dropped here never appears in the report at all. Listing
|
|
289
309
|
// them needs no key, works in any language, and lets the person who wrote
|
|
290
310
|
// the rule be the one who decides.
|
|
291
|
-
if (notARule.length > 0) {
|
|
311
|
+
if (!json && notARule.length > 0) {
|
|
292
312
|
const n = notARule.length;
|
|
293
313
|
const plural = n === 1 ? "" : "s";
|
|
294
314
|
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.)`);
|
|
@@ -316,15 +336,21 @@ async function runCheck(opts) {
|
|
|
316
336
|
console.log(`Run with --show-skipped to see them.`);
|
|
317
337
|
}
|
|
318
338
|
}
|
|
319
|
-
if (stale.length > 0) {
|
|
339
|
+
if (!json && stale.length > 0) {
|
|
320
340
|
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.)`);
|
|
321
341
|
}
|
|
342
|
+
if (!json && !markdown && warnedFails.length > 0) {
|
|
343
|
+
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.)`);
|
|
344
|
+
}
|
|
322
345
|
appendHistory(results, sessionFilePath);
|
|
323
346
|
// A once-per-update footer so a returning user sees the tool improved and
|
|
324
347
|
// 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
|
-
|
|
348
|
+
// on --markdown (that output is meant to be pasted into a PR/Slack) or
|
|
349
|
+
// --json (that output must be a single parseable object, nothing else).
|
|
350
|
+
if (!markdown && !json) {
|
|
327
351
|
maybeShowWhatsNew(pkg.version);
|
|
352
|
+
// Opt-in only; makes no network call unless enabled. Fails open.
|
|
353
|
+
await maybeCheckUpdates(pkg.version, isUpdateCheckEnabled(checkUpdates));
|
|
328
354
|
}
|
|
329
355
|
if (share) {
|
|
330
356
|
await shareResults(results);
|
|
@@ -355,7 +381,9 @@ async function runCheck(opts) {
|
|
|
355
381
|
// rules in a real CLAUDE.md need judgment, so without --llm they
|
|
356
382
|
// legitimately report UNCLEAR. Gating on those would make every build
|
|
357
383
|
// red on day one and the check would be deleted within a week.
|
|
358
|
-
|
|
384
|
+
// Gate on BLOCKING failures only — a rule marked warning in the project
|
|
385
|
+
// config is reported but does not fail the build.
|
|
386
|
+
if (!exitZero && blockingFails.length > 0) {
|
|
359
387
|
process.exitCode = 1;
|
|
360
388
|
}
|
|
361
389
|
}
|
|
@@ -373,6 +401,8 @@ program
|
|
|
373
401
|
.command("check", { isDefault: true })
|
|
374
402
|
.description("Check the current project's latest Claude Code session against CLAUDE.md/AGENTS.md")
|
|
375
403
|
.option("--markdown", "output as markdown, for pasting into a PR or Slack")
|
|
404
|
+
.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.")
|
|
405
|
+
.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.")
|
|
376
406
|
.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.")
|
|
377
407
|
.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.")
|
|
378
408
|
.option("--email-always", "used with --email: send every time, even when nothing failed")
|
|
@@ -386,6 +416,8 @@ program
|
|
|
386
416
|
.action((opts) => {
|
|
387
417
|
runCheck({
|
|
388
418
|
markdown: Boolean(opts.markdown),
|
|
419
|
+
json: Boolean(opts.json),
|
|
420
|
+
checkUpdates: Boolean(opts.checkUpdates),
|
|
389
421
|
share: Boolean(opts.share),
|
|
390
422
|
email: Boolean(opts.email),
|
|
391
423
|
emailAlways: Boolean(opts.emailAlways),
|
|
@@ -479,11 +511,50 @@ function runCoverage() {
|
|
|
479
511
|
console.log(`log or inject context, but they cannot make a rule fail when it is ignored.`);
|
|
480
512
|
}
|
|
481
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* `rules --advise`: for every rule the classifier can't check mechanically,
|
|
516
|
+
* one line saying why and the smallest edit that would fix it. The other
|
|
517
|
+
* half of `--coverage` — that says which rules a hook might guard; this says
|
|
518
|
+
* which rules can't be checked at all, and how to change that.
|
|
519
|
+
*/
|
|
520
|
+
function runAdvise() {
|
|
521
|
+
const cwd = process.cwd();
|
|
522
|
+
const rules = loadRules(cwd);
|
|
523
|
+
if (rules.length === 0) {
|
|
524
|
+
console.log("No CLAUDE.md or AGENTS.md found, so there are no rules to advise on.");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const advice = adviseRules(rules);
|
|
528
|
+
const checkable = rules.length - advice.length;
|
|
529
|
+
console.log(`Rule checkability\n`);
|
|
530
|
+
console.log(` ${checkable} of ${rules.length} rule${rules.length === 1 ? "" : "s"} can be checked mechanically as written.`);
|
|
531
|
+
if (advice.length === 0) {
|
|
532
|
+
console.log(`\n Every rule names something a check can bind to. Nothing to fix.`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
console.log(` ${advice.length} cannot yet — here is what each one needs:\n`);
|
|
536
|
+
// Project rules first: those are the ones the reader can act on today.
|
|
537
|
+
const ordered = advice
|
|
538
|
+
.map((a, i) => ({ a, source: rules.find((r) => r.title === a.ruleTitle)?.source }))
|
|
539
|
+
.sort((x, y) => Number(x.source === "global") - Number(y.source === "global"))
|
|
540
|
+
.map((x) => x.a);
|
|
541
|
+
for (const a of ordered) {
|
|
542
|
+
const tag = a.kind === "notARule" ? "not a rule?" : "judgment";
|
|
543
|
+
console.log(` [${tag}] ${a.ruleTitle.replace(/\s+/g, " ").trim().slice(0, 76)}`);
|
|
544
|
+
console.log(` -> ${a.suggestion}\n`);
|
|
545
|
+
}
|
|
546
|
+
console.log(`Naming the exact command, file or branch a rule is about — in backticks —`);
|
|
547
|
+
console.log(`is what turns a "wish list" line into one this tool can hold to account.`);
|
|
548
|
+
}
|
|
482
549
|
async function runRules(opts) {
|
|
483
550
|
if (opts.coverage) {
|
|
484
551
|
runCoverage();
|
|
485
552
|
return;
|
|
486
553
|
}
|
|
554
|
+
if (opts.advise) {
|
|
555
|
+
runAdvise();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
487
558
|
const cwd = process.cwd();
|
|
488
559
|
const rules = loadRules(cwd);
|
|
489
560
|
const overrides = loadOverrides(cwd);
|
|
@@ -629,6 +700,11 @@ async function runLint(markdown, llm) {
|
|
|
629
700
|
console.log("No contradictions found between CLAUDE.md and AGENTS.md.");
|
|
630
701
|
return;
|
|
631
702
|
}
|
|
703
|
+
// A contradiction between the two rule files is a real defect, not just
|
|
704
|
+
// information: it means the agent is being given conflicting instructions.
|
|
705
|
+
// Exit non-zero so CI (and the GitHub Action) can gate on it, the same way
|
|
706
|
+
// `check` exits 1 on a FAIL.
|
|
707
|
+
process.exitCode = 1;
|
|
632
708
|
if (markdown) {
|
|
633
709
|
const lines = ["## CLAUDE.md vs AGENTS.md — contradictions found", ""];
|
|
634
710
|
for (const c of result.conflicts) {
|
|
@@ -704,6 +780,7 @@ program
|
|
|
704
780
|
.option("--clear <handle>", "remove a stored correction")
|
|
705
781
|
.option("--list", "show stored corrections (the default when no other flag is given)")
|
|
706
782
|
.option("--coverage", "show which rules a configured hook might actually be enforcing, and which are prose only")
|
|
783
|
+
.option("--advise", "for each rule that can't be checked mechanically, show why and the smallest edit that would fix it")
|
|
707
784
|
.action((opts) => {
|
|
708
785
|
runRules(opts).catch((err) => {
|
|
709
786
|
console.error("Something went wrong:", err instanceof Error ? err.message : err);
|
|
@@ -721,6 +798,18 @@ program
|
|
|
721
798
|
process.exitCode = 1;
|
|
722
799
|
});
|
|
723
800
|
});
|
|
801
|
+
program
|
|
802
|
+
.command("init")
|
|
803
|
+
.description("Guided setup: shows what's configured and the exact next steps. Read-only — writes nothing.")
|
|
804
|
+
.action(() => {
|
|
805
|
+
const cwd = process.cwd();
|
|
806
|
+
console.log(buildInitGuidance({
|
|
807
|
+
hasClaudeMd: existsSync(join(cwd, "CLAUDE.md")),
|
|
808
|
+
hasAgentsMd: existsSync(join(cwd, "AGENTS.md")),
|
|
809
|
+
hookInstalled: hookIsInstalled(cwd),
|
|
810
|
+
hasApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
|
|
811
|
+
}));
|
|
812
|
+
});
|
|
724
813
|
program
|
|
725
814
|
.command("demo")
|
|
726
815
|
.description("See a sample report — no setup, no API key needed")
|
|
@@ -821,4 +910,64 @@ program
|
|
|
821
910
|
process.exitCode = 1;
|
|
822
911
|
}
|
|
823
912
|
});
|
|
913
|
+
program
|
|
914
|
+
.command("verify-receipt <path>")
|
|
915
|
+
.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.")
|
|
916
|
+
.option("--max-age-days <n>", "reject a receipt older than N days (freshness gate)")
|
|
917
|
+
.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.")
|
|
918
|
+
.action((path, opts) => {
|
|
919
|
+
let text;
|
|
920
|
+
try {
|
|
921
|
+
text = readFileSync(path, "utf-8");
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
console.error(`Could not read receipt file: ${path}`);
|
|
925
|
+
process.exitCode = 1;
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const maxAgeDays = opts.maxAgeDays !== undefined ? Number(opts.maxAgeDays) : undefined;
|
|
929
|
+
if (maxAgeDays !== undefined && !Number.isFinite(maxAgeDays)) {
|
|
930
|
+
console.error(`--max-age-days must be a number, got: ${opts.maxAgeDays}`);
|
|
931
|
+
process.exitCode = 1;
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
// Only pass sessionHash when a session was actually requested; null (path
|
|
935
|
+
// given but unreadable) is a rejection inside verifyReceipt.
|
|
936
|
+
const sessionHash = opts.session !== undefined ? computeTranscriptHash(opts.session) : undefined;
|
|
937
|
+
const res = verifyReceipt(text, { maxAgeDays, sessionHash });
|
|
938
|
+
if (res.ok && res.receipt) {
|
|
939
|
+
const r = res.receipt;
|
|
940
|
+
const trust = res.sessionVerified
|
|
941
|
+
? "re-verified against the session (no trust needed)"
|
|
942
|
+
: "trusted (no session provided to re-verify against)";
|
|
943
|
+
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}`);
|
|
944
|
+
}
|
|
945
|
+
else {
|
|
946
|
+
console.error("✕ receipt rejected:");
|
|
947
|
+
for (const p of res.problems)
|
|
948
|
+
console.error(` - ${p}`);
|
|
949
|
+
process.exitCode = 1;
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
program
|
|
953
|
+
.command("badge <receiptPath>")
|
|
954
|
+
.description("Emit a shields.io endpoint JSON from a receipt (from `check --json`), for a README badge. Commit the output and reference it: ")
|
|
955
|
+
.action((receiptPath) => {
|
|
956
|
+
let text;
|
|
957
|
+
try {
|
|
958
|
+
text = readFileSync(receiptPath, "utf-8");
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
console.error(`Could not read receipt file: ${receiptPath}`);
|
|
962
|
+
process.exitCode = 1;
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const parsed = parseReceipt(text);
|
|
966
|
+
if (parsed.error || !parsed.receipt) {
|
|
967
|
+
console.error(`Not a valid receipt: ${parsed.error}`);
|
|
968
|
+
process.exitCode = 1;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
console.log(JSON.stringify(buildBadge(parsed.receipt.summary), null, 2));
|
|
972
|
+
});
|
|
824
973
|
program.parse();
|
package/dist/evaluate.js
CHANGED
|
@@ -6,6 +6,8 @@ import { runCodeContentChecks } from "./checks/codeContent.js";
|
|
|
6
6
|
import { runFileLifecycleChecks } from "./checks/fileLifecycle.js";
|
|
7
7
|
import { runClaimEvidenceChecks } from "./checks/claimEvidence.js";
|
|
8
8
|
import { runEmojiChecks } from "./checks/emojiOutput.js";
|
|
9
|
+
import { runAttributionChecks } from "./checks/attribution.js";
|
|
10
|
+
import { runApprovalGateChecks } from "./checks/approvalGate.js";
|
|
9
11
|
import { runJudgmentChecks } from "./checks/judgmentChecks.js";
|
|
10
12
|
import { loadOverrides, ruleFingerprint, staleOverrides } from "./overrides.js";
|
|
11
13
|
/**
|
|
@@ -40,6 +42,8 @@ export async function evaluateSession(cwd, rules, events, llm, needsLlmResult) {
|
|
|
40
42
|
...runFileLifecycleChecks(of("fileLifecycle"), events),
|
|
41
43
|
...runClaimEvidenceChecks(of("claimEvidence"), events),
|
|
42
44
|
...runEmojiChecks(of("emojiOutput"), events),
|
|
45
|
+
...runAttributionChecks(of("attribution"), events),
|
|
46
|
+
...runApprovalGateChecks(of("approvalGate"), events),
|
|
43
47
|
];
|
|
44
48
|
const judgment = of("judgment");
|
|
45
49
|
const judgmentResults = llm
|
package/dist/guard.js
CHANGED
|
@@ -3,6 +3,7 @@ import { classifyRules } from "./checks/classify.js";
|
|
|
3
3
|
import { runCodeContentChecks } from "./checks/codeContent.js";
|
|
4
4
|
import { runFileLifecycleChecks } from "./checks/fileLifecycle.js";
|
|
5
5
|
import { runGitBranchPolicyChecks } from "./checks/gitBranchPolicy.js";
|
|
6
|
+
import { runAttributionChecks } from "./checks/attribution.js";
|
|
6
7
|
import { loadOverrides, ruleFingerprint, ratifiedForbids } from "./overrides.js";
|
|
7
8
|
import { commandRunsLiteral } from "./checks/proposedAction.js";
|
|
8
9
|
function readStdin() {
|
|
@@ -48,6 +49,11 @@ function structuredBlocks(cwd, event) {
|
|
|
48
49
|
...runCodeContentChecks(of("codeContent"), [event]),
|
|
49
50
|
...runFileLifecycleChecks(of("fileLifecycle"), [event]),
|
|
50
51
|
...runGitBranchPolicyChecks(of("gitBranchPolicy"), [event]),
|
|
52
|
+
// Prevention for the attribution rule: a commit/PR carrying a
|
|
53
|
+
// `Co-Authored-By: Claude` / "Generated with Claude Code" trailer is
|
|
54
|
+
// refused before it is made, not just reported after. Reuses the exact
|
|
55
|
+
// detection the report uses, so the two cannot disagree.
|
|
56
|
+
...runAttributionChecks(of("attribution"), [event]),
|
|
51
57
|
];
|
|
52
58
|
return results
|
|
53
59
|
.filter((r) => r.status === "FAIL")
|
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;
|
package/dist/receipt.js
ADDED
|
@@ -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;
|