rulereceipt 0.1.65 → 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 +6 -0
- package/dist/audit.d.ts +2 -1
- package/dist/audit.js +84 -6
- package/dist/checkability.d.ts +2 -0
- package/dist/checkability.js +11 -1
- package/dist/checks/approvalGate.js +5 -2
- package/dist/checks/shellCommand.d.ts +12 -0
- package/dist/checks/shellCommand.js +16 -0
- package/dist/cli.js +17 -0
- package/dist/guard.js +19 -1
- package/dist/listSessions.d.ts +18 -0
- package/dist/listSessions.js +60 -0
- package/dist/selftest.d.ts +11 -0
- package/dist/selftest.js +85 -0
- package/package.json +1 -1
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("");
|
package/dist/checkability.d.ts
CHANGED
|
@@ -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.
|
package/dist/checkability.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
@@ -18,6 +18,8 @@ import { buildWrongReport, findTarget } from "./wrong.js";
|
|
|
18
18
|
import { detectSelfEditedRuleFiles } from "./checks/selfEditedRules.js";
|
|
19
19
|
import { scanHistory, renderHistory } from "./historyReport.js";
|
|
20
20
|
import { observeSessions, renderNoRules, draftRulesFromHistory } from "./sessionObserve.js";
|
|
21
|
+
import { listSessionRows, renderSessionList } from "./listSessions.js";
|
|
22
|
+
import { runSelfTestChecks, renderSelfTest } from "./selftest.js";
|
|
21
23
|
import { planProtect, applyProtect, undoProtect } from "./protect.js";
|
|
22
24
|
import { cardSvg, renderCardShare } from "./card.js";
|
|
23
25
|
import { createInterface } from "node:readline";
|
|
@@ -411,7 +413,13 @@ program
|
|
|
411
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.")
|
|
412
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.")
|
|
413
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.")
|
|
414
417
|
.action((opts) => {
|
|
418
|
+
if (opts.listSessions) {
|
|
419
|
+
const cwd = process.cwd();
|
|
420
|
+
console.log(renderSessionList(listSessionRows(cwd), cwd));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
415
423
|
runCheck({
|
|
416
424
|
markdown: Boolean(opts.markdown),
|
|
417
425
|
json: Boolean(opts.json),
|
|
@@ -831,6 +839,15 @@ program
|
|
|
831
839
|
shadowedAgents: shadowedAgentsMd(cwd).map((s) => s.agents),
|
|
832
840
|
}));
|
|
833
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
|
+
});
|
|
834
851
|
program
|
|
835
852
|
.command("demo")
|
|
836
853
|
.description("See a sample report — no setup, no API key needed")
|
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,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;
|
package/dist/selftest.js
ADDED
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
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",
|