rulereceipt 0.1.64 → 0.1.66

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
@@ -431,6 +431,12 @@ npx tsx src/cli.ts demo
431
431
 
432
432
  ## Trust, privacy and licensing
433
433
 
434
+ **Verify it yourself.** `npx rulereceipt selftest` runs a set of bundled
435
+ golden fixtures on your machine and reports how many verdicts are correct, with
436
+ **zero network calls** — watch it with `lsof` or Little Snitch if you like. The
437
+ same fixtures are the project's regression suite, so "all correct" is a promise
438
+ the build enforces, not a claim.
439
+
434
440
  **What it can't see, it says so.** [KNOWN-GAPS.md](KNOWN-GAPS.md) lists
435
441
  exactly where the evidence runs out — commands in another terminal, clicks on
436
442
  the permission prompt, `rm`/delete not bound to a rule's subject, edited
package/dist/audit.d.ts CHANGED
@@ -27,6 +27,7 @@ export interface RulesAudit {
27
27
  topFixes: {
28
28
  title: string;
29
29
  suggestion: string;
30
+ handle?: string;
30
31
  }[];
31
32
  }
32
33
  export declare function auditRules(rules: Rule[]): RulesAudit;
@@ -39,7 +40,7 @@ export declare function renderAudit(a: RulesAudit, md?: boolean): string;
39
40
  * cannot know without a transcript.
40
41
  */
41
42
  export interface Diagnostic {
42
- id: "no-rules-file" | "empty-or-pointer" | "zero-rules" | "docs-heavy" | "shadowed-file" | "size-warn" | "template-text" | "broken-import";
43
+ id: "no-rules-file" | "empty-or-pointer" | "zero-rules" | "docs-heavy" | "shadowed-file" | "size-warn" | "template-text" | "broken-import" | "hook-config" | "dead-globs";
43
44
  severity: "info" | "warn";
44
45
  message: string;
45
46
  }
package/dist/audit.js CHANGED
@@ -1,8 +1,39 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, isAbsolute, relative, resolve } from "node:path";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { classifyRules } from "./checks/classify.js";
4
4
  import { adviseRules } from "./checkability.js";
5
+ import { ruleWasLoaded } from "./checks/pathScope.js";
5
6
  import { describeRuleSources, loadRules } from "./rules.js";
7
+ /** Files in the repo, for checking whether a path-scoped rule matches anything. */
8
+ function repoFiles(cwd, cap = 4000) {
9
+ const out = [];
10
+ const skip = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "coverage", ".rulereceipt", ".vercel", ".turbo", "vendor"]);
11
+ const walk = (dir) => {
12
+ if (out.length >= cap)
13
+ return;
14
+ let entries;
15
+ try {
16
+ entries = readdirSync(dir, { withFileTypes: true });
17
+ }
18
+ catch {
19
+ return;
20
+ }
21
+ for (const e of entries) {
22
+ if (out.length >= cap)
23
+ return;
24
+ const full = join(dir, e.name);
25
+ if (e.isDirectory()) {
26
+ if (!skip.has(e.name))
27
+ walk(full);
28
+ }
29
+ else {
30
+ out.push(full);
31
+ }
32
+ }
33
+ };
34
+ walk(cwd);
35
+ return out;
36
+ }
6
37
  export function auditRules(rules) {
7
38
  let checkable = 0;
8
39
  let judgment = 0;
@@ -19,7 +50,7 @@ export function auditRules(rules) {
19
50
  const topFixes = adviseRules(rules)
20
51
  .filter((a) => a.actionable)
21
52
  .slice(0, 5)
22
- .map((a) => ({ title: a.ruleTitle, suggestion: a.suggestion }));
53
+ .map((a) => ({ title: a.ruleTitle, suggestion: a.suggestion, handle: a.handle }));
23
54
  return {
24
55
  total: checkable + judgment + skipped,
25
56
  checkable,
@@ -60,6 +91,30 @@ export function renderAudit(a, md = false) {
60
91
  out.push("Full advice, rule by rule: rulereceipt rules --advise");
61
92
  return out.join("\n");
62
93
  }
94
+ /** Hook events Claude Code recognises. A hook under any other name never fires. */
95
+ const KNOWN_HOOK_EVENTS = new Set([
96
+ "PreToolUse", "PostToolUse", "Stop", "SubagentStop", "UserPromptSubmit",
97
+ "SessionStart", "SessionEnd", "Notification", "PreCompact", "PostCompact",
98
+ "PermissionRequest", "PermissionDenied", "InstructionsLoaded",
99
+ ]);
100
+ /** Hook event names in the project/user settings that Claude Code won't recognise. */
101
+ function unknownHookEvents(cwd) {
102
+ const bad = new Set();
103
+ for (const p of [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json")]) {
104
+ try {
105
+ const hooks = JSON.parse(readFileSync(p, "utf-8")).hooks;
106
+ if (hooks && typeof hooks === "object") {
107
+ for (const name of Object.keys(hooks))
108
+ if (!KNOWN_HOOK_EVENTS.has(name))
109
+ bad.add(name);
110
+ }
111
+ }
112
+ catch {
113
+ /* absent or unreadable */
114
+ }
115
+ }
116
+ return [...bad];
117
+ }
63
118
  /**
64
119
  * Claude Code reads a bounded prefix of a rules file; past it the rest is
65
120
  * silently dropped, so a rule below the cut never loads. The figure is a
@@ -104,7 +159,7 @@ function isPointerFile(path) {
104
159
  return false;
105
160
  }
106
161
  }
107
- function buildDiagnostics(cwd, graph, a) {
162
+ function buildDiagnostics(cwd, graph, a, rules) {
108
163
  const diags = [];
109
164
  const loaded = graph.filter((g) => g.status === "loaded");
110
165
  const shadowed = graph.filter((g) => g.status === "shadowed");
@@ -187,6 +242,29 @@ function buildDiagnostics(cwd, graph, a) {
187
242
  });
188
243
  }
189
244
  }
245
+ // A path-scoped rule whose globs match no file in the repo never loads.
246
+ const scoped = rules.filter((r) => r.paths && r.paths.length > 0);
247
+ if (scoped.length > 0) {
248
+ const files = repoFiles(cwd);
249
+ for (const r of scoped) {
250
+ if (!ruleWasLoaded(r.paths, files)) {
251
+ diags.push({
252
+ id: "dead-globs",
253
+ severity: "warn",
254
+ message: `"${r.title.replace(/\s+/g, " ").trim().slice(0, 50)}" is scoped to ${r.paths.join(", ")}, which matches no file in this repo — so it never loads and governs nothing. Fix the glob.`,
255
+ });
256
+ }
257
+ }
258
+ }
259
+ // A hook wired under a misspelled/unknown event name never fires — silently.
260
+ const badHooks = unknownHookEvents(cwd);
261
+ if (badHooks.length > 0) {
262
+ diags.push({
263
+ id: "hook-config",
264
+ severity: "warn",
265
+ message: `.claude/settings.json has hook${badHooks.length === 1 ? "" : "s"} under ${badHooks.map((h) => `"${h}"`).join(", ")}, which ${badHooks.length === 1 ? "is not a" : "are not"} Claude Code hook event${badHooks.length === 1 ? "" : "s"} — ${badHooks.length === 1 ? "it never fires" : "they never fire"}. Check the spelling (e.g. PreToolUse, PostToolUse, Stop).`,
266
+ });
267
+ }
190
268
  // A handbook, not a policy: mostly documentation, little to enforce.
191
269
  if (a.total >= 8 && a.skipped / a.total > 0.7) {
192
270
  diags.push({
@@ -206,7 +284,7 @@ export function auditProject(cwd) {
206
284
  const rules = loadRules(cwd);
207
285
  const base = auditRules(rules);
208
286
  const loadGraph = describeRuleSources(cwd);
209
- const diagnostics = buildDiagnostics(cwd, loadGraph, base);
287
+ const diagnostics = buildDiagnostics(cwd, loadGraph, base, rules);
210
288
  const memoryRules = rules.filter((r) => r.id.startsWith("memory:")).length;
211
289
  return { ...base, loadGraph, diagnostics, memoryRules };
212
290
  }
@@ -251,7 +329,7 @@ export function renderProjectAudit(pa, md = false) {
251
329
  if (pa.topFixes.length > 0) {
252
330
  out.push(H("Top fixes to unlock more checks"));
253
331
  for (const f of pa.topFixes) {
254
- out.push(` • ${f.title.replace(/\s+/g, " ").trim().slice(0, 60)}`);
332
+ out.push(` • ${f.title.replace(/\s+/g, " ").trim().slice(0, 60)}${f.handle ? ` [${f.handle}]` : ""}`);
255
333
  out.push(` ${f.suggestion}`);
256
334
  }
257
335
  out.push("");
@@ -43,3 +43,4 @@ export declare const CORPUS: {
43
43
  checkablePct: number;
44
44
  };
45
45
  export declare function analyze(text: string): AnalysisResult;
46
+ export { evaluateBrowserSession, checkSessionInBrowser, type BrowserSessionSummary } from "./evaluateBrowser.js";
@@ -47,3 +47,6 @@ export function analyze(text) {
47
47
  judgmentSamples,
48
48
  };
49
49
  }
50
+ // Client-side SESSION check for the browser demo: drop a session .jsonl +
51
+ // paste rules, get per-rule verdicts, nothing uploaded. Same checkers as the CLI.
52
+ export { evaluateBrowserSession, checkSessionInBrowser } from "./evaluateBrowser.js";
@@ -0,0 +1,23 @@
1
+ import type { CheckResult } from "../types.js";
2
+ /**
3
+ * Evaluate a session against rules ENTIRELY in the browser — the same checkers
4
+ * the CLI uses, run on a file the user dropped, with nothing leaving the page.
5
+ *
6
+ * This is the CLI's evaluateSession minus the parts that need a machine: no
7
+ * saved overrides, no git rule-age split, no reading the user's settings.json
8
+ * for a permissions.allow list (so the approval check runs with an empty allow
9
+ * list — it can only be MORE cautious, never less). Everything reachable from
10
+ * here is pure string work; keep it that way so the site's "your file never
11
+ * leaves this page" promise stays true. Judgment rules report UNCLEAR (no LLM
12
+ * call), exactly as `check` without `--llm` does.
13
+ */
14
+ export declare function evaluateBrowserSession(rulesText: string, sessionText: string): CheckResult[];
15
+ export interface BrowserSessionSummary {
16
+ results: CheckResult[];
17
+ pass: number;
18
+ fail: number;
19
+ unclear: number;
20
+ events: number;
21
+ }
22
+ /** The whole client-side session check: parse, evaluate, and count. */
23
+ export declare function checkSessionInBrowser(rulesText: string, sessionText: string): BrowserSessionSummary;
@@ -0,0 +1,78 @@
1
+ import { parseTranscriptText } from "../parsers/transcriptLine.js";
2
+ import { parseClaudeMdText } from "../parsers/claudeMdParser.js";
3
+ import { classifyRules } from "../checks/classify.js";
4
+ import { runDeterministicChecks } from "../checks/deterministicChecks.js";
5
+ import { runIfEditThenTestChecks } from "../checks/ifEditThenTest.js";
6
+ import { runGitBranchPolicyChecks } from "../checks/gitBranchPolicy.js";
7
+ import { runCodeContentChecks } from "../checks/codeContent.js";
8
+ import { runFileLifecycleChecks } from "../checks/fileLifecycle.js";
9
+ import { runClaimEvidenceChecks } from "../checks/claimEvidence.js";
10
+ import { runEmojiChecks } from "../checks/emojiOutput.js";
11
+ import { runAttributionChecks } from "../checks/attribution.js";
12
+ import { runApprovalGateChecks } from "../checks/approvalGate.js";
13
+ import { touchedPaths, ruleWasLoaded } from "../checks/pathScope.js";
14
+ /**
15
+ * Evaluate a session against rules ENTIRELY in the browser — the same checkers
16
+ * the CLI uses, run on a file the user dropped, with nothing leaving the page.
17
+ *
18
+ * This is the CLI's evaluateSession minus the parts that need a machine: no
19
+ * saved overrides, no git rule-age split, no reading the user's settings.json
20
+ * for a permissions.allow list (so the approval check runs with an empty allow
21
+ * list — it can only be MORE cautious, never less). Everything reachable from
22
+ * here is pure string work; keep it that way so the site's "your file never
23
+ * leaves this page" promise stays true. Judgment rules report UNCLEAR (no LLM
24
+ * call), exactly as `check` without `--llm` does.
25
+ */
26
+ export function evaluateBrowserSession(rulesText, sessionText) {
27
+ const rules = parseClaudeMdText(rulesText, "project");
28
+ const events = parseTranscriptText(sessionText);
29
+ const touched = touchedPaths(events);
30
+ const classified = classifyRules(rules);
31
+ // A path-scoped rule the session never touched a matching file for was never
32
+ // loaded by the agent, so it is reported not-applicable rather than judged.
33
+ const notLoaded = classified.filter((c) => c.kind !== "notARule" && c.rule.paths && !ruleWasLoaded(c.rule.paths, touched));
34
+ const classifications = classified.filter((c) => !notLoaded.includes(c));
35
+ const scopeResults = notLoaded.map(({ rule }) => ({
36
+ ruleId: rule.id,
37
+ ruleTitle: rule.title,
38
+ ruleSource: rule.source,
39
+ status: "UNCLEAR",
40
+ outcome: "not_applicable",
41
+ method: "file_events",
42
+ reason: "path_scope_not_loaded",
43
+ evidence: `path-scoped rule (${rule.paths.join(", ")}): this session touched no matching file, so it was never loaded`,
44
+ }));
45
+ const of = (kind) => classifications.filter((c) => c.kind === kind);
46
+ const deterministicResults = [
47
+ ...runDeterministicChecks(of("deterministic"), events),
48
+ ...runIfEditThenTestChecks(of("ifEditThenTest"), events),
49
+ ...runGitBranchPolicyChecks(of("gitBranchPolicy"), events),
50
+ ...runCodeContentChecks(of("codeContent"), events),
51
+ ...runFileLifecycleChecks(of("fileLifecycle"), events),
52
+ ...runClaimEvidenceChecks(of("claimEvidence"), events),
53
+ ...runEmojiChecks(of("emojiOutput"), events),
54
+ ...runAttributionChecks(of("attribution"), events),
55
+ ...runApprovalGateChecks(of("approvalGate"), events, { allow: [] }),
56
+ ];
57
+ const judgmentResults = of("judgment").map(({ rule }) => ({
58
+ ruleId: rule.id,
59
+ ruleTitle: rule.title,
60
+ ruleSource: rule.source,
61
+ status: "UNCLEAR",
62
+ needsHuman: true,
63
+ evidence: "",
64
+ }));
65
+ return [...deterministicResults, ...judgmentResults, ...scopeResults];
66
+ }
67
+ /** The whole client-side session check: parse, evaluate, and count. */
68
+ export function checkSessionInBrowser(rulesText, sessionText) {
69
+ const results = evaluateBrowserSession(rulesText, sessionText);
70
+ const count = (s) => results.filter((r) => r.status === s).length;
71
+ return {
72
+ results,
73
+ pass: count("PASS"),
74
+ fail: count("FAIL"),
75
+ unclear: count("UNCLEAR"),
76
+ events: parseTranscriptText(sessionText).length,
77
+ };
78
+ }
@@ -30,6 +30,8 @@ export interface RuleAdvice {
30
30
  * actionable ones as the top fixes.
31
31
  */
32
32
  actionable?: boolean;
33
+ /** Stable content-hash handle for `rules --include/--exclude`. */
34
+ handle?: string;
33
35
  }
34
36
  /**
35
37
  * Advice for one rule, or null when the rule is already mechanically checked.
@@ -1,4 +1,5 @@
1
1
  import { classifyRule } from "./checks/classify.js";
2
+ import { ruleFingerprint } from "./overrides.js";
2
3
  /** A concrete action the rule is plausibly about, so we can name what to quote. */
3
4
  const CONCRETE_SUBJECT = /\b(?:push(?:es|ed|ing)?|commit(?:s|ted|ting)?|merge[ds]?|rebase|delet\w*|remov\w*|\brm\b|drop|truncate|deploy\w*|migrat\w*|branch|tag|force[- ]?push|test|lint|build|install|env|secret|token|password|key|\.env|database|table|file|path|directory|endpoint|api)\b/i;
4
5
  /** A rule that is qualitative by nature — no literal makes it mechanical. */
@@ -65,5 +66,14 @@ export function adviseRule(rule) {
65
66
  }
66
67
  /** Advice for every rule that isn't already mechanically checked. */
67
68
  export function adviseRules(rules) {
68
- return rules.map(adviseRule).filter((a) => a !== null);
69
+ const out = [];
70
+ for (const rule of rules) {
71
+ const a = adviseRule(rule);
72
+ // Attach the stable content-hash handle so `audit`'s top fixes can be acted
73
+ // on with `rules --include/--exclude <handle>` (ids are positional and
74
+ // renumber; the handle survives edits above the rule).
75
+ if (a)
76
+ out.push({ ...a, handle: ruleFingerprint(rule) });
77
+ }
78
+ return out;
69
79
  }
@@ -1,5 +1,5 @@
1
1
  import { violation } from "../types.js";
2
- import { withoutHeredocs } from "./shellCommand.js";
2
+ import { withoutHeredocs, withoutQuotedMentions } from "./shellCommand.js";
3
3
  const IN_COMMAND = {
4
4
  push: /\bgit\s+(?:\S+\s+){0,4}?push(?![\w-])/,
5
5
  commit: /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])/,
@@ -29,7 +29,10 @@ function commandOf(e) {
29
29
  const c = e.input?.command;
30
30
  // A heredoc that WRITES "git push" into a file is not a push. Strip heredoc
31
31
  // bodies so only the commands actually invoked are inspected.
32
- return typeof c === "string" ? withoutHeredocs(c) : "";
32
+ // Strip heredoc bodies (a heredoc that WRITES "git push" is not a push) and
33
+ // blank quoted/commented mentions (`echo "git push"`, `# git push`), so only
34
+ // a command actually being run is matched.
35
+ return typeof c === "string" ? withoutQuotedMentions(withoutHeredocs(c)) : "";
33
36
  }
34
37
  /** The result for the call at `i`: matched by id when present, else the next result. */
35
38
  function resultOf(events, i) {
@@ -44,3 +44,15 @@ export declare function leadingCommand(segment: string): string;
44
44
  * false-matched on commit-message text — findings 2026-09-26).
45
45
  */
46
46
  export declare function withoutCommitMessage(command: string): string;
47
+ /**
48
+ * Blanks out quoted-string CONTENTS and drops #-comments, so a command MENTION
49
+ * inside a quote or a comment is not read as the command running.
50
+ *
51
+ * Found 2026-09-28: `echo "git push"` and `cat notes.md # git push` were both
52
+ * flagged as an unapproved push, because the matcher saw "git push" anywhere in
53
+ * the string. Blanking quote contents keeps the real verb visible (`git commit
54
+ * -m "msg"` still reads as a commit) while removing the mention. Use this only
55
+ * where a MENTION must not count as an action; checks that need the quoted text
56
+ * (attribution's commit-message trailer) must not use it.
57
+ */
58
+ export declare function withoutQuotedMentions(command: string): string;
@@ -104,3 +104,19 @@ export function leadingCommand(segment) {
104
104
  export function withoutCommitMessage(command) {
105
105
  return command.replace(/(-m|--message)(=|\s+)(['"])(?:\\.|(?!\3)[\s\S])*\3/g, "$1 <message>");
106
106
  }
107
+ /**
108
+ * Blanks out quoted-string CONTENTS and drops #-comments, so a command MENTION
109
+ * inside a quote or a comment is not read as the command running.
110
+ *
111
+ * Found 2026-09-28: `echo "git push"` and `cat notes.md # git push` were both
112
+ * flagged as an unapproved push, because the matcher saw "git push" anywhere in
113
+ * the string. Blanking quote contents keeps the real verb visible (`git commit
114
+ * -m "msg"` still reads as a commit) while removing the mention. Use this only
115
+ * where a MENTION must not count as an action; checks that need the quoted text
116
+ * (attribution's commit-message trailer) must not use it.
117
+ */
118
+ export function withoutQuotedMentions(command) {
119
+ const noQuotes = command.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
120
+ // A `#` that begins a word (start or after whitespace) starts a comment.
121
+ return noQuotes.replace(/(^|\s)#[^\n]*/g, "$1");
122
+ }
package/dist/cli.js CHANGED
@@ -17,6 +17,9 @@ import { evaluateSession } from "./evaluate.js";
17
17
  import { buildWrongReport, findTarget } from "./wrong.js";
18
18
  import { detectSelfEditedRuleFiles } from "./checks/selfEditedRules.js";
19
19
  import { scanHistory, renderHistory } from "./historyReport.js";
20
+ import { observeSessions, renderNoRules, draftRulesFromHistory } from "./sessionObserve.js";
21
+ import { listSessionRows, renderSessionList } from "./listSessions.js";
22
+ import { runSelfTestChecks, renderSelfTest } from "./selftest.js";
20
23
  import { planProtect, applyProtect, undoProtect } from "./protect.js";
21
24
  import { cardSvg, renderCardShare } from "./card.js";
22
25
  import { createInterface } from "node:readline";
@@ -410,7 +413,13 @@ program
410
413
  .option("--require-session", "fail (exit 1) if no session is found, or the session is empty, instead of reporting a pass for a check that never actually ran. Use this anywhere automated.")
411
414
  .option("--show-skipped", "list the items that were treated as documentation and not checked. Worth running once on any rules file: the classifier is a heuristic over English verbs, so a rule it does not recognise is otherwise dropped without you seeing it.")
412
415
  .option("--transcript <path>", "manual override: check this exact .jsonl session file instead of auto-detecting one. Useful if your Claude Code session lives somewhere non-standard that auto-detection doesn't cover.")
416
+ .option("--list-sessions", "list recent sessions for this project (tool, time, first prompt) so you can pick one for --transcript, instead of checking.")
413
417
  .action((opts) => {
418
+ if (opts.listSessions) {
419
+ const cwd = process.cwd();
420
+ console.log(renderSessionList(listSessionRows(cwd), cwd));
421
+ return;
422
+ }
414
423
  runCheck({
415
424
  markdown: Boolean(opts.markdown),
416
425
  json: Boolean(opts.json),
@@ -797,9 +806,31 @@ program
797
806
  });
798
807
  program
799
808
  .command("init")
800
- .description("Guided setup: shows what's configured and the exact next steps. Read-only — writes nothing.")
801
- .action(() => {
809
+ .description("Guided setup: shows what's configured and the exact next steps. Read-only — writes nothing (except --from-history, which drafts a starter rules file you approve).")
810
+ .option("--from-history", "draft a starter rules file from what your agent did in recent sessions, written to .rulereceipt/draft-CLAUDE.md (never overwrites an existing rules file)")
811
+ .option("--days <n>", "how many days back to look, with --from-history", "30")
812
+ .action((opts) => {
802
813
  const cwd = process.cwd();
814
+ if (opts.fromHistory) {
815
+ const d = Number.parseInt(opts.days ?? "30", 10);
816
+ const obs = observeSessions(cwd, Number.isFinite(d) && d > 0 ? d : 30);
817
+ if (obs.sessions === 0) {
818
+ console.log("No sessions found for this project yet, so there's nothing to draft rules from. Run your agent here first.");
819
+ process.exitCode = 1;
820
+ return;
821
+ }
822
+ const draftPath = join(cwd, ".rulereceipt", "draft-CLAUDE.md");
823
+ if (existsSync(draftPath)) {
824
+ console.log(`A draft already exists at ${draftPath} — open it, or delete it and re-run. Not overwriting.`);
825
+ return;
826
+ }
827
+ mkdirSync(dirname(draftPath), { recursive: true });
828
+ writeFileSync(draftPath, draftRulesFromHistory(obs));
829
+ console.log(`Drafted a starter rules file from ${obs.sessions} session${obs.sessions === 1 ? "" : "s"} → ${draftPath}`);
830
+ console.log("Read it, keep the rules you want, then move it to CLAUDE.md (or AGENTS.md) in your project root.");
831
+ console.log("It's a draft only — nothing is enforced until you move it into place and run `rulereceipt`.");
832
+ return;
833
+ }
803
834
  console.log(buildInitGuidance({
804
835
  hasClaudeMd: existsSync(join(cwd, "CLAUDE.md")),
805
836
  hasAgentsMd: existsSync(join(cwd, "AGENTS.md")),
@@ -808,6 +839,15 @@ program
808
839
  shadowedAgents: shadowedAgentsMd(cwd).map((s) => s.agents),
809
840
  }));
810
841
  });
842
+ program
843
+ .command("selftest")
844
+ .description("Run bundled golden fixtures on your machine and report how many verdicts are correct — proof the checkers work, with zero network calls (watch it with lsof if you like). Exits non-zero if any is wrong.")
845
+ .action(() => {
846
+ const r = runSelfTestChecks();
847
+ console.log(renderSelfTest(r));
848
+ if (r.failures.length > 0)
849
+ process.exitCode = 1;
850
+ });
811
851
  program
812
852
  .command("demo")
813
853
  .description("See a sample report — no setup, no API key needed")
@@ -1131,15 +1171,24 @@ program
1131
1171
  });
1132
1172
  async function runHistory(opts) {
1133
1173
  const cwd = process.cwd();
1174
+ const parsedDays = Number.parseInt(opts.days ?? "30", 10);
1175
+ const days = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 30;
1134
1176
  const rules = loadRules(cwd);
1135
1177
  if (rules.length === 0) {
1136
- console.log("No rules file found for this project (checked CLAUDE.md / AGENTS.md and every ~/.claude*/CLAUDE.md).\n" +
1137
- "Add a CLAUDE.md or AGENTS.md with the rules you want checked, then run `rulereceipt` again.\n" +
1138
- "To score a rules file you already have: rulereceipt audit");
1178
+ // No rules to judge against — but we can still show what the agent DID, so a
1179
+ // first-time user sees something true about their own work.
1180
+ const obs = observeSessions(cwd, days);
1181
+ if (obs.sessions === 0) {
1182
+ console.log("No rules file and no coding-agent sessions found for this project yet.\n" +
1183
+ "Run Claude Code (or Codex) here, then `rulereceipt` shows what it did — or `rulereceipt demo` for a sample.\n" +
1184
+ "To score a rules file you already have: rulereceipt audit");
1185
+ }
1186
+ else {
1187
+ console.log(renderNoRules(obs, basename(cwd) || "this project"));
1188
+ }
1139
1189
  return;
1140
1190
  }
1141
- const days = Number.parseInt(opts.days ?? "30", 10);
1142
- const summary = await scanHistory(cwd, rules, Number.isFinite(days) && days > 0 ? days : 30);
1191
+ const summary = await scanHistory(cwd, rules, days);
1143
1192
  console.log(renderHistory(summary, basename(cwd) || "this project"));
1144
1193
  }
1145
1194
  // Bare `rulereceipt` (no subcommand, no flags) runs history mode — the first-run
package/dist/guard.js CHANGED
@@ -168,8 +168,26 @@ function ratifiedLiteralBlocks(cwd, command) {
168
168
  * rules may block — not a cleverer guess. That is a design question for
169
169
  * whoever asks for it, not a default.
170
170
  */
171
+ /**
172
+ * What to do instead — every refusal names a concrete next step, so the model
173
+ * corrects rather than just retrying (Failproof's "corrective context" idea,
174
+ * built our own way). Inferred from the rule and the reason; falls back to
175
+ * "ask the user", which is always safe.
176
+ */
177
+ function suggest(b) {
178
+ const t = `${b.rule.title} ${b.why}`.toLowerCase();
179
+ if (/co-?authored|generated with|attribution|trailer/.test(t))
180
+ return "Instead: make the commit without the AI trailer (no `Co-Authored-By` / `Generated with` line).";
181
+ if (/\bpush\b|\bbranch\b|\bmain\b|\bmaster\b/.test(t))
182
+ return "Instead: work on a feature branch (`git switch -c <name>`) and open a PR, or ask the user before pushing.";
183
+ if (/\.env|secret|credential|\btoken\b|\bkey\b|password/.test(t))
184
+ return "Instead: leave that file as it is; if it genuinely must change, ask the user first.";
185
+ if (/delet|remov|\bdrop\b|truncat|wipe|\brm\b/.test(t))
186
+ return "Instead: don't delete it; if it should be removed, confirm with the user first.";
187
+ return "Instead: ask the user before doing this, or explain why the rule shouldn't apply and let them decide.";
188
+ }
171
189
  function reason(blocks) {
172
- const lines = blocks.map((b) => ` • Rule ${b.rule.id} — ${b.rule.title}\n ${b.why}`);
190
+ const lines = blocks.map((b) => ` • Rule ${b.rule.id} — ${b.rule.title}\n ${b.why}\n ${suggest(b)}`);
173
191
  const n = blocks.length;
174
192
  return (`RuleReceipt blocked this: it breaks ${n === 1 ? "a rule" : `${n} rules`} in CLAUDE.md.\n\n` +
175
193
  lines.join("\n\n") +
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `rulereceipt check --list-sessions` — a readable list of recent sessions so
3
+ * `--transcript <path>` is easy to pick. Without this, choosing a session means
4
+ * staring at a directory of UUID filenames. Each row shows the tool, how long
5
+ * ago, the first thing the user said (truncated and redacted), and the path to
6
+ * pass to `--transcript`.
7
+ */
8
+ export interface SessionRow {
9
+ file: string;
10
+ tool: string;
11
+ mtimeMs: number;
12
+ firstPrompt: string;
13
+ }
14
+ export declare function listSessionRows(cwd: string, limit?: number, sessions?: {
15
+ adapter: import("./adapters/index.js").SessionAdapter;
16
+ file: string;
17
+ }[]): SessionRow[];
18
+ export declare function renderSessionList(rows: SessionRow[], cwd: string, now?: number): string;
@@ -0,0 +1,60 @@
1
+ import { statSync } from "node:fs";
2
+ import { relative } from "node:path";
3
+ import { listAllSessions } from "./adapters/index.js";
4
+ import { redact } from "./wrong.js";
5
+ function firstUserText(events) {
6
+ for (const e of events) {
7
+ if (e.kind === "text" && e.role === "user" && e.text.trim())
8
+ return e.text.trim();
9
+ }
10
+ return "";
11
+ }
12
+ const toolLabel = (t) => (t === "claude-code" ? "Claude Code" : t === "codex" ? "Codex" : t);
13
+ export function listSessionRows(cwd, limit = 15, sessions = listAllSessions(cwd)) {
14
+ const rows = [];
15
+ for (const { adapter, file } of sessions.slice(0, limit)) {
16
+ let mtimeMs;
17
+ try {
18
+ mtimeMs = statSync(file).mtimeMs;
19
+ }
20
+ catch {
21
+ continue;
22
+ }
23
+ let prompt = "";
24
+ try {
25
+ prompt = firstUserText(adapter.parse(file));
26
+ }
27
+ catch {
28
+ /* unreadable session: still list it, just without a prompt */
29
+ }
30
+ rows.push({ file, tool: adapter.tool, mtimeMs, firstPrompt: redact(prompt).replace(/\s+/g, " ").trim().slice(0, 70) });
31
+ }
32
+ return rows;
33
+ }
34
+ function ago(ms, now = Date.now()) {
35
+ const s = Math.max(0, Math.round((now - ms) / 1000));
36
+ if (s < 90)
37
+ return `${s}s ago`;
38
+ const m = Math.round(s / 60);
39
+ if (m < 90)
40
+ return `${m}m ago`;
41
+ const h = Math.round(m / 60);
42
+ if (h < 36)
43
+ return `${h}h ago`;
44
+ return `${Math.round(h / 24)}d ago`;
45
+ }
46
+ export function renderSessionList(rows, cwd, now = Date.now()) {
47
+ if (rows.length === 0) {
48
+ return "No coding-agent sessions found for this project. Run Claude Code (or Codex) here first.";
49
+ }
50
+ const out = [];
51
+ out.push(`Recent sessions for this project (newest first). Check one with: rulereceipt check --transcript <path>`);
52
+ out.push("");
53
+ rows.forEach((r, i) => {
54
+ const rel = relative(cwd, r.file);
55
+ const path = rel && !rel.startsWith("..") ? rel : r.file;
56
+ out.push(` ${String(i + 1).padStart(2)}. ${toolLabel(r.tool).padEnd(11)} ${ago(r.mtimeMs, now).padEnd(8)} ${r.firstPrompt || "(no user message)"}`);
57
+ out.push(` ${path}`);
58
+ });
59
+ return out.join("\n");
60
+ }
@@ -0,0 +1,8 @@
1
+ import type { TranscriptEvent } from "../types.js";
2
+ export declare function parseLine(line: string): TranscriptEvent[];
3
+ /**
4
+ * Parse a whole session file's TEXT (not a path) into events, tracking the
5
+ * permission mode the same way readTranscriptFromFile does — so the browser
6
+ * demo and the CLI agree. Pure: takes the file contents as a string.
7
+ */
8
+ export declare function parseTranscriptText(raw: string): TranscriptEvent[];
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The pure, Node-free half of transcript parsing: one JSONL line → events.
3
+ *
4
+ * Extracted from transcriptParser.ts 2026-09-28 so the browser demo can parse a
5
+ * dropped session file client-side, with the SAME logic as the CLI and no
6
+ * filesystem dependency. The site's "your file never leaves this page" claim
7
+ * depends on everything reachable from here being pure string work — keep it
8
+ * that way (no node: imports, no network, no storage).
9
+ */
10
+ function extractToolResultText(content) {
11
+ if (typeof content === "string")
12
+ return content;
13
+ if (Array.isArray(content)) {
14
+ return content
15
+ .map((part) => (typeof part === "object" && part && "text" in part ? String(part.text) : ""))
16
+ .filter(Boolean)
17
+ .join("\n");
18
+ }
19
+ return "";
20
+ }
21
+ export function parseLine(line) {
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(line);
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ // JSON.parse accepts any valid JSON value, not just objects — "null", "42",
30
+ // "\"a string\"" all parse without throwing. A transcript line is only ever
31
+ // meaningful as an object; anything else is skipped, not a crash.
32
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
33
+ return [];
34
+ }
35
+ const obj = parsed;
36
+ const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : "";
37
+ const events = [];
38
+ if (obj.type === "assistant" && !obj.isApiErrorMessage) {
39
+ const message = obj.message;
40
+ const content = message?.content;
41
+ if (Array.isArray(content)) {
42
+ for (const block of content) {
43
+ if (typeof block !== "object" || block === null)
44
+ continue;
45
+ const b = block;
46
+ if (b.type === "text" && typeof b.text === "string") {
47
+ events.push({ role: "assistant", kind: "text", text: b.text, timestamp });
48
+ }
49
+ else if (b.type === "tool_use" && typeof b.name === "string") {
50
+ events.push({ role: "assistant", kind: "tool_use", toolName: b.name, input: b.input, timestamp, toolUseId: typeof b.id === "string" ? b.id : undefined });
51
+ }
52
+ }
53
+ }
54
+ }
55
+ else if (obj.type === "user") {
56
+ const message = obj.message;
57
+ const content = message?.content;
58
+ if (typeof content === "string") {
59
+ events.push({ role: "user", kind: "text", text: content, timestamp });
60
+ }
61
+ else if (Array.isArray(content)) {
62
+ for (const block of content) {
63
+ if (typeof block !== "object" || block === null)
64
+ continue;
65
+ const b = block;
66
+ // User text sent as blocks (VS Code / IDE extension, or any message with
67
+ // an image) was dropped until 2026-09-28: in real sessions whole
68
+ // conversations had no user message at all, so every check that reads
69
+ // what the user said saw nothing. Harness-injected context wrapped in
70
+ // tags (<ide_opened_file>, <system-reminder>, …) is not the user
71
+ // speaking and is stripped; what remains is.
72
+ if (b.type === "text" && typeof b.text === "string") {
73
+ const said = b.text.replace(/<([a-z][\w-]*)>[\s\S]*?<\/\1>/gi, "").trim();
74
+ if (said.length > 0)
75
+ events.push({ role: "user", kind: "text", text: said, timestamp });
76
+ continue;
77
+ }
78
+ if (b.type === "tool_result") {
79
+ events.push({
80
+ role: "user",
81
+ kind: "tool_result",
82
+ content: extractToolResultText(b.content),
83
+ isError: b.is_error === true,
84
+ timestamp,
85
+ toolUseId: typeof b.tool_use_id === "string" ? b.tool_use_id : undefined,
86
+ });
87
+ }
88
+ }
89
+ }
90
+ }
91
+ return events;
92
+ }
93
+ /**
94
+ * Parse a whole session file's TEXT (not a path) into events, tracking the
95
+ * permission mode the same way readTranscriptFromFile does — so the browser
96
+ * demo and the CLI agree. Pure: takes the file contents as a string.
97
+ */
98
+ export function parseTranscriptText(raw) {
99
+ const events = [];
100
+ let mode;
101
+ for (const line of raw.split("\n")) {
102
+ if (!line.trim())
103
+ continue;
104
+ try {
105
+ const m = line.match(/"(?:permissionMode|permission_mode)":"([A-Za-z]+)"/) ??
106
+ (line.includes('"permission-mode"') ? line.match(/"mode":"([A-Za-z]+)"/) : null);
107
+ if (m)
108
+ mode = m[1];
109
+ const parsed = parseLine(line);
110
+ if (mode)
111
+ for (const e of parsed)
112
+ if (e.kind === "tool_use")
113
+ e.permissionMode = mode;
114
+ events.push(...parsed);
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ }
120
+ return events;
121
+ }
@@ -8,7 +8,7 @@ export declare function findClaudeHomeDirNames(): string[];
8
8
  /** Every session file for this project, newest first. */
9
9
  export declare function listAllSessionFiles(cwd: string): string[];
10
10
  export declare function findLatestSessionFile(cwd: string): string | null;
11
- export declare function parseLine(line: string): TranscriptEvent[];
11
+ export { parseLine } from "./transcriptLine.js";
12
12
  /**
13
13
  * Read the most recently modified session transcript for a project
14
14
  * directory. Returns an empty array (not an error) if no session exists
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, dirname, basename } from "node:path";
4
+ import { parseTranscriptText } from "./transcriptLine.js";
4
5
  /**
5
6
  * Claude Code stores each session as a JSONL file at:
6
7
  * ~/.claude/projects/<cwd with every "/" replaced by "-">/<sessionId>.jsonl
@@ -80,89 +81,9 @@ export function findLatestSessionFile(cwd) {
80
81
  const all = listAllSessionFiles(cwd);
81
82
  return all.length > 0 ? all[0] : null;
82
83
  }
83
- function extractToolResultText(content) {
84
- if (typeof content === "string")
85
- return content;
86
- if (Array.isArray(content)) {
87
- return content
88
- .map((part) => (typeof part === "object" && part && "text" in part ? String(part.text) : ""))
89
- .filter(Boolean)
90
- .join("\n");
91
- }
92
- return "";
93
- }
94
- export function parseLine(line) {
95
- let parsed;
96
- try {
97
- parsed = JSON.parse(line);
98
- }
99
- catch {
100
- return [];
101
- }
102
- // JSON.parse accepts any valid JSON value, not just objects — "null",
103
- // "42", "\"a string\"" all parse without throwing. A transcript line is
104
- // only ever meaningful as an object; anything else is skipped, not a crash.
105
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
106
- return [];
107
- }
108
- const obj = parsed;
109
- const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : "";
110
- const events = [];
111
- if (obj.type === "assistant" && !obj.isApiErrorMessage) {
112
- const message = obj.message;
113
- const content = message?.content;
114
- if (Array.isArray(content)) {
115
- for (const block of content) {
116
- if (typeof block !== "object" || block === null)
117
- continue;
118
- const b = block;
119
- if (b.type === "text" && typeof b.text === "string") {
120
- events.push({ role: "assistant", kind: "text", text: b.text, timestamp });
121
- }
122
- else if (b.type === "tool_use" && typeof b.name === "string") {
123
- events.push({ role: "assistant", kind: "tool_use", toolName: b.name, input: b.input, timestamp, toolUseId: typeof b.id === "string" ? b.id : undefined });
124
- }
125
- }
126
- }
127
- }
128
- else if (obj.type === "user") {
129
- const message = obj.message;
130
- const content = message?.content;
131
- if (typeof content === "string") {
132
- events.push({ role: "user", kind: "text", text: content, timestamp });
133
- }
134
- else if (Array.isArray(content)) {
135
- for (const block of content) {
136
- if (typeof block !== "object" || block === null)
137
- continue;
138
- const b = block;
139
- // User text sent as blocks (VS Code / IDE extension, or any message with
140
- // an image) was dropped until 2026-09-28: in real sessions whole
141
- // conversations had no user message at all, so every check that reads
142
- // what the user said saw nothing. Harness-injected context wrapped in
143
- // tags (<ide_opened_file>, <system-reminder>, …) is not the user
144
- // speaking and is stripped; what remains is.
145
- if (b.type === "text" && typeof b.text === "string") {
146
- const said = b.text.replace(/<([a-z][\w-]*)>[\s\S]*?<\/\1>/gi, "").trim();
147
- if (said.length > 0)
148
- events.push({ role: "user", kind: "text", text: said, timestamp });
149
- continue;
150
- }
151
- if (b.type === "tool_result") {
152
- events.push({
153
- role: "user",
154
- kind: "tool_result",
155
- content: extractToolResultText(b.content),
156
- isError: b.is_error === true,
157
- timestamp,
158
- toolUseId: typeof b.tool_use_id === "string" ? b.tool_use_id : undefined,
159
- });
160
- }
161
- }
162
- }
163
- }
164
- return events;
165
- }
84
+ // parseLine (and its permission-mode-tracking wrapper parseTranscriptText) live
85
+ // in transcriptLine.ts — a pure, Node-free module the browser demo also imports.
86
+ export { parseLine } from "./transcriptLine.js";
166
87
  /**
167
88
  * Read the most recently modified session transcript for a project
168
89
  * directory. Returns an empty array (not an error) if no session exists
@@ -170,35 +91,9 @@ export function parseLine(line) {
170
91
  * not a failure.
171
92
  */
172
93
  export function readTranscriptFromFile(filePath) {
173
- const raw = readFileSync(filePath, "utf-8");
174
- const events = [];
175
- // Permission mode is recorded on user turns (and on `permission-mode` entries
176
- // in newer versions); it applies to the tool calls that follow. The approval
177
- // check needs it to tell "a prompt may have been approved" (default/
178
- // acceptEdits/plan) from "no person was asked" (bypassPermissions/dontAsk/auto).
179
- let mode;
180
- for (const line of raw.split("\n")) {
181
- if (!line.trim())
182
- continue;
183
- try {
184
- const m = line.match(/"(?:permissionMode|permission_mode)":"([A-Za-z]+)"/) ??
185
- (line.includes('"permission-mode"') ? line.match(/"mode":"([A-Za-z]+)"/) : null);
186
- if (m)
187
- mode = m[1];
188
- const parsed = parseLine(line);
189
- if (mode)
190
- for (const e of parsed)
191
- if (e.kind === "tool_use")
192
- e.permissionMode = mode;
193
- events.push(...parsed);
194
- }
195
- catch {
196
- // One malformed/unexpected line must not crash the whole check —
197
- // skip it and keep going, same fail-closed principle as everywhere else.
198
- continue;
199
- }
200
- }
201
- return events;
94
+ // Pure parsing (including permission-mode tracking) lives in transcriptLine.ts
95
+ // so the browser demo can run the exact same logic on a dropped file.
96
+ return parseTranscriptText(readFileSync(filePath, "utf-8"));
202
97
  }
203
98
  /**
204
99
  * Subagent transcripts for a session.
@@ -0,0 +1,11 @@
1
+ export interface SelfTestResult {
2
+ total: number;
3
+ passed: number;
4
+ failures: {
5
+ name: string;
6
+ detail: string;
7
+ }[];
8
+ }
9
+ export declare function runSelfTestChecks(): SelfTestResult;
10
+ /** The human-facing selftest output. */
11
+ export declare function renderSelfTest(r: SelfTestResult): string;
@@ -0,0 +1,85 @@
1
+ import { checkSessionInBrowser } from "./browser/evaluateBrowser.js";
2
+ function sessionOf(lines) {
3
+ return lines.map((l) => JSON.stringify(l)).join("\n");
4
+ }
5
+ const asst = (content) => ({ type: "assistant", timestamp: "t", message: { role: "assistant", content } });
6
+ const user = (content, permissionMode) => ({ type: "user", timestamp: "t", permissionMode, message: { role: "user", content } });
7
+ const bash = (command, id = "x") => ({ type: "tool_use", id, name: "Bash", input: { command } });
8
+ const edit = (file_path) => ({ type: "tool_use", id: "e", name: "Edit", input: { file_path, old_string: "a", new_string: "b" } });
9
+ const GOLDENS = [
10
+ {
11
+ name: "push with no approval in a no-prompt mode is Broken",
12
+ rules: "## 1. Never push without asking\nNever push without explicit user instruction.\n",
13
+ session: sessionOf([user("fix the page", "bypassPermissions"), asst([bash("git push origin main", "a")])]),
14
+ expect: [{ title: /never push/i, want: "FAIL" }],
15
+ },
16
+ {
17
+ name: "push the user asked for is Followed",
18
+ rules: "## 1. Never push without asking\nNever push without explicit user instruction.\n",
19
+ session: sessionOf([user("fix it and push it", "bypassPermissions"), asst([bash("git push origin main", "a")])]),
20
+ expect: [{ title: /never push/i, want: "PASS" }],
21
+ },
22
+ {
23
+ name: "push in default mode is Can't-tell (a prompt may have been approved)",
24
+ rules: "## 1. Never push without asking\nNever push without explicit user instruction.\n",
25
+ session: sessionOf([user("fix the page", "default"), asst([bash("git push origin main", "a")])]),
26
+ expect: [{ title: /never push/i, want: "not-fail" }],
27
+ },
28
+ {
29
+ name: "console.log written into a file is Broken",
30
+ rules: "## 1. No debug logging\nNever leave a `console.log(` call in committed code.\n",
31
+ session: sessionOf([asst([{ type: "tool_use", id: "w", name: "Write", input: { file_path: "app.ts", content: "console.log(1)" } }])]),
32
+ expect: [{ title: /debug logging/i, want: "FAIL" }],
33
+ },
34
+ {
35
+ name: "editing .env is Broken",
36
+ rules: "## 1. Never edit `.env`\nNever edit `.env`.\n",
37
+ session: sessionOf([asst([edit(".env")])]),
38
+ expect: [{ title: /never edit/i, want: "FAIL" }],
39
+ },
40
+ {
41
+ name: "a Co-Authored-By trailer is Broken",
42
+ rules: "## 1. No AI attribution\nNever add a `Co-Authored-By: Claude` trailer to git commits.\n",
43
+ session: sessionOf([asst([bash("git commit -m 'x\n\nCo-Authored-By: Claude <noreply@anthropic.com>'", "c")])]),
44
+ expect: [{ title: /attribution/i, want: "FAIL" }],
45
+ },
46
+ {
47
+ name: "a temp-dir rm is NOT Broken against a 'never wipe databases' rule",
48
+ rules: "## 1. Never wipe databases\nBefore any delete on the database, wait for explicit confirmation.\n",
49
+ session: sessionOf([asst([bash("rm -rf /tmp/scratch-xyz", "r")])]),
50
+ expect: [{ title: /wipe databases/i, want: "not-fail" }],
51
+ },
52
+ {
53
+ name: "a judgment rule is Can't-tell, never Broken",
54
+ rules: "## 1. Keep changes small\nAlways keep changes small and focused.\n",
55
+ session: sessionOf([asst([bash("git commit -m x", "c")])]),
56
+ expect: [{ title: /keep changes small/i, want: "UNCLEAR" }],
57
+ },
58
+ ];
59
+ export function runSelfTestChecks() {
60
+ const failures = [];
61
+ let total = 0;
62
+ for (const g of GOLDENS) {
63
+ const results = checkSessionInBrowser(g.rules, g.session).results;
64
+ for (const e of g.expect) {
65
+ total++;
66
+ const r = results.find((x) => e.title.test(x.ruleTitle));
67
+ const got = r?.status ?? "MISSING";
68
+ const ok = e.want === "not-fail" ? got !== "FAIL" && got !== "MISSING" : got === e.want;
69
+ if (!ok)
70
+ failures.push({ name: g.name, detail: `expected ${e.want}, got ${got}` });
71
+ }
72
+ }
73
+ return { total, passed: total - failures.length, failures };
74
+ }
75
+ /** The human-facing selftest output. */
76
+ export function renderSelfTest(r) {
77
+ if (r.failures.length === 0) {
78
+ return (`rulereceipt selftest\n\n` +
79
+ ` ${r.total} checks, all correct.\n` +
80
+ ` 0 network calls — this ran entirely on your machine (watch it with lsof / Little Snitch if you like).\n\n` +
81
+ `The same checkers ran here as on your real sessions. If any of these were ever wrong, this would say so.`);
82
+ }
83
+ const lines = r.failures.map((f) => ` ✗ ${f.name} — ${f.detail}`);
84
+ return `rulereceipt selftest\n\n ${r.passed}/${r.total} correct, ${r.failures.length} WRONG:\n` + lines.join("\n") + `\n\nThis is a bug — please report it with the version (rulereceipt --version).`;
85
+ }
@@ -0,0 +1,20 @@
1
+ export interface SessionObservations {
2
+ sessions: number;
3
+ days: number;
4
+ tools: string[];
5
+ pushes: number;
6
+ commits: number;
7
+ envWrites: number;
8
+ /** "the tests pass" claims where no test command ran anywhere in that session. */
9
+ testClaimsNoRun: number;
10
+ testClaims: number;
11
+ elapsedMs: number;
12
+ }
13
+ export declare function observeSessions(cwd: string, days?: number, now?: number, sessions?: {
14
+ adapter: import("./adapters/index.js").SessionAdapter;
15
+ file: string;
16
+ }[]): SessionObservations;
17
+ /** The no-rules screen: what the agent did, and how to turn it into rules. */
18
+ export declare function renderNoRules(o: SessionObservations, projectName: string): string;
19
+ /** A starter rules file drafted from the observed actions. Only rules for things that happened. */
20
+ export declare function draftRulesFromHistory(o: SessionObservations): string;
@@ -0,0 +1,129 @@
1
+ import { statSync } from "node:fs";
2
+ import { listAllSessions } from "./adapters/index.js";
3
+ import { withoutHeredocs } from "./checks/shellCommand.js";
4
+ /**
5
+ * "What did your agent actually do?" — for someone with sessions but NO rules
6
+ * file. History mode needs rules to judge; this needs none. It just reports a
7
+ * few plainly-observable actions across recent sessions (pushes, edits to
8
+ * `.env`, "the tests pass" claims), so a first-time user sees something true
9
+ * about their own work even before they've written a single rule, and then
10
+ * `init --from-history` drafts a starter rules file from what it saw.
11
+ *
12
+ * These are OBSERVATIONS, not verdicts — nothing here says a rule was broken,
13
+ * because there is no rule yet. The wording stays factual ("pushed 9 times")
14
+ * and the checkers' polarity/false-accusation discipline does not apply,
15
+ * precisely because nothing is being accused.
16
+ */
17
+ const PUSH = /\bgit\s+(?:\S+\s+){0,4}?push(?![\w-])/;
18
+ const COMMIT = /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])/;
19
+ const ENV_FILE = /(?:^|[\\/])\.env(?:\.[\w.-]+)?$/;
20
+ // A "the tests pass" style completion claim — the strongest, least-noisy signal.
21
+ const TESTS_PASS_CLAIM = /\b(?:all\s+)?tests?\s+(?:are\s+)?(?:pass(?:ing|ed|es)?|green)\b|\ball\s+green\b|\beverything\s+(?:passes|works)\b/i;
22
+ const TEST_COMMAND = /\b(?:npm\s+(?:run\s+)?test|npx\s+vitest|vitest|jest|pytest|go\s+test|cargo\s+test|mvn\s+test|rspec|phpunit|\btox\b)\b/;
23
+ function bashCommand(e) {
24
+ if (e.kind !== "tool_use" || e.toolName !== "Bash")
25
+ return "";
26
+ const c = e.input?.command;
27
+ return typeof c === "string" ? withoutHeredocs(c) : "";
28
+ }
29
+ function editedPath(e) {
30
+ if (e.kind !== "tool_use")
31
+ return "";
32
+ if (e.toolName !== "Write" && e.toolName !== "Edit" && e.toolName !== "MultiEdit" && e.toolName !== "NotebookEdit")
33
+ return "";
34
+ const p = e.input;
35
+ const path = p?.file_path ?? p?.notebook_path;
36
+ return typeof path === "string" ? path : "";
37
+ }
38
+ export function observeSessions(cwd, days = 30, now = Date.now(), sessions = listAllSessions(cwd)) {
39
+ const started = Date.now();
40
+ const cutoff = now - days * 24 * 60 * 60 * 1000;
41
+ const tools = new Set();
42
+ let scanned = 0, pushes = 0, commits = 0, envWrites = 0, testClaims = 0, testClaimsNoRun = 0;
43
+ for (const { adapter, file } of sessions) {
44
+ let ms;
45
+ try {
46
+ ms = statSync(file).mtimeMs;
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ if (ms < cutoff)
52
+ continue;
53
+ let events;
54
+ try {
55
+ events = adapter.parse(file);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ if (events.length === 0)
61
+ continue;
62
+ scanned++;
63
+ tools.add(adapter.tool);
64
+ const ranTest = events.some((e) => TEST_COMMAND.test(bashCommand(e)));
65
+ for (const e of events) {
66
+ const cmd = bashCommand(e);
67
+ if (cmd && PUSH.test(cmd))
68
+ pushes++;
69
+ if (cmd && COMMIT.test(cmd))
70
+ commits++;
71
+ if (ENV_FILE.test(editedPath(e)))
72
+ envWrites++;
73
+ if (e.kind === "text" && e.role === "assistant" && TESTS_PASS_CLAIM.test(e.text)) {
74
+ testClaims++;
75
+ if (!ranTest)
76
+ testClaimsNoRun++;
77
+ }
78
+ }
79
+ }
80
+ return { sessions: scanned, days, tools: [...tools], pushes, commits, envWrites, testClaims, testClaimsNoRun, elapsedMs: Date.now() - started };
81
+ }
82
+ const toolLabel = (t) => (t === "claude-code" ? "Claude Code" : t === "codex" ? "Codex" : t);
83
+ /** The no-rules screen: what the agent did, and how to turn it into rules. */
84
+ export function renderNoRules(o, projectName) {
85
+ const who = o.tools.length === 1 && o.tools[0] === "claude-code" ? "Claude" : "your agent";
86
+ const out = [];
87
+ const toolNote = o.tools.length ? `${o.tools.map(toolLabel).join(" + ")} ` : "";
88
+ out.push(`RuleReceipt · ${projectName} · last ${o.days} days · ${o.sessions} ${toolNote}session${o.sessions === 1 ? "" : "s"}`);
89
+ out.push("");
90
+ out.push(`You don't have a rules file yet, so there's nothing to check against. Here's what ${who} actually did:`);
91
+ out.push("");
92
+ out.push(` pushed ${o.pushes} time${o.pushes === 1 ? "" : "s"}`);
93
+ out.push(` committed ${o.commits} time${o.commits === 1 ? "" : "s"}`);
94
+ out.push(` wrote to a .env file ${o.envWrites} time${o.envWrites === 1 ? "" : "s"}`);
95
+ out.push(` claimed the tests pass ${o.testClaims} time${o.testClaims === 1 ? "" : "s"}${o.testClaimsNoRun > 0 ? ` (${o.testClaimsNoRun} with no test command run that session)` : ""}`);
96
+ out.push("");
97
+ out.push("Turn this into rules to check from now on:");
98
+ out.push(" rulereceipt init --from-history (drafts a starter CLAUDE.md from the above — never overwrites)");
99
+ out.push("");
100
+ out.push(`checked ${o.sessions} session${o.sessions === 1 ? "" : "s"} in ${(o.elapsedMs / 1000).toFixed(1)}s · nothing uploaded`);
101
+ return out.join("\n");
102
+ }
103
+ /** A starter rules file drafted from the observed actions. Only rules for things that happened. */
104
+ export function draftRulesFromHistory(o) {
105
+ const lines = [
106
+ "# Project rules (draft)",
107
+ "",
108
+ "# Drafted by `rulereceipt init --from-history` from what your agent did in the",
109
+ "# last " + o.days + " days. Keep the ones you want, delete the rest, then move this",
110
+ "# to CLAUDE.md (or AGENTS.md) in your project root.",
111
+ "",
112
+ ];
113
+ let n = 0;
114
+ if (o.pushes > 0)
115
+ lines.push(`## ${++n}. Never push without asking`, "Don't run `git push` unless I've asked for it in this session.", "");
116
+ if (o.commits > 0)
117
+ lines.push(`## ${++n}. Ask before committing`, "Don't `git commit` unless I've asked for it.", "");
118
+ if (o.envWrites > 0)
119
+ lines.push(`## ${++n}. Never edit \`.env\``, "Don't write to `.env` or any `.env.*` file.", "");
120
+ if (o.testClaims > 0)
121
+ lines.push(`## ${++n}. Don't say the tests pass without running them`, "Only say the tests pass after actually running the test command in this session.", "");
122
+ if (n === 0) {
123
+ lines.push("# Nothing risky was observed to base a rule on. Write your own, e.g.:");
124
+ lines.push("## 1. Never push to `main`");
125
+ lines.push("Never push directly to the `main` branch.");
126
+ lines.push("");
127
+ }
128
+ return lines.join("\n") + "\n";
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "description": "Checks whether your AI coding agent followed your rules, with evidence. Works with Claude Code (Codex in testing); reads CLAUDE.md, AGENTS.md, Cursor, Copilot and Windsurf rules.",
5
5
  "repository": {
6
6
  "type": "git",