rulereceipt 0.1.74 → 0.1.76

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.
@@ -1,6 +1,6 @@
1
- import { readFileSync, readdirSync, statSync } from "node:fs";
1
+ import { readFileSync, readdirSync, statSync, realpathSync, existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, dirname, basename } from "node:path";
3
+ import { join, dirname, basename, sep } from "node:path";
4
4
  import { parseTranscriptText } from "./transcriptLine.js";
5
5
  /**
6
6
  * Claude Code stores each session as a JSONL file at:
@@ -32,8 +32,80 @@ import { parseTranscriptText } from "./transcriptLine.js";
32
32
  * generalizes to variants never seen on this machine, at the cost of one
33
33
  * extra readdir() of the home directory per check — negligible.
34
34
  */
35
- function encodeProjectPath(cwd) {
36
- return cwd.replace(/\//g, "-");
35
+ /**
36
+ * Claude Code names the per-project directory by mangling the cwd, but the exact
37
+ * rule is not stable or documented across versions: at minimum "/" becomes "-",
38
+ * and observed builds also turn "." "_" and space (every non-alphanumeric) into
39
+ * "-". Guessing the encoding is therefore fragile — a project at
40
+ * /Users/john.doe/my.app, my_project or "My Work" would silently find zero
41
+ * sessions (found by independent test on 0.1.74, 2026-09-29; the whole first
42
+ * screen — check, history, list-sessions, card — showed "No sessions found").
43
+ *
44
+ * So encoding is only a FAST-PATH hint. The source of truth is the `cwd` field
45
+ * every Claude session line carries: this reads the real cwd out of each folder
46
+ * and matches on it (realpath-compared, so symlinks and dotted paths just work),
47
+ * which also survives any future encoding change Claude Code makes.
48
+ */
49
+ function encodeCandidates(cwd) {
50
+ const slashOnly = cwd.replace(/\//g, "-");
51
+ const allNonAlnum = cwd.replace(/[^A-Za-z0-9]/g, "-");
52
+ return [...new Set([slashOnly, allNonAlnum])];
53
+ }
54
+ function realpathOr(p) {
55
+ try {
56
+ return realpathSync(p);
57
+ }
58
+ catch {
59
+ return p;
60
+ }
61
+ }
62
+ /** The cwd a session folder belongs to, read from the first line that carries it. */
63
+ function sessionCwdOf(sessionFile) {
64
+ let text;
65
+ try {
66
+ text = readFileSync(sessionFile, "utf-8");
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ // The cwd is on every line; scan only until the first hit (usually line 1).
72
+ let from = 0;
73
+ for (let i = 0; i < 200; i++) {
74
+ const nl = text.indexOf("\n", from);
75
+ const line = text.slice(from, nl === -1 ? undefined : nl);
76
+ if (line.includes('"cwd"')) {
77
+ try {
78
+ const cwd = JSON.parse(line).cwd;
79
+ if (typeof cwd === "string" && cwd.length > 0)
80
+ return cwd;
81
+ }
82
+ catch {
83
+ /* partial/garbled line: keep scanning */
84
+ }
85
+ }
86
+ if (nl === -1)
87
+ break;
88
+ from = nl + 1;
89
+ }
90
+ return null;
91
+ }
92
+ /** A cwd looks like a real project root, so descendant (monorepo) sessions are safe to pull in. */
93
+ function looksLikeProjectRoot(cwd) {
94
+ return [".git", "CLAUDE.md", "AGENTS.md", "GEMINI.md", ".claude", ".cursor", ".github/copilot-instructions.md"].some((marker) => existsSync(join(cwd, marker)));
95
+ }
96
+ /** Absolute paths of every Claude-Code-style home to search, including CLAUDE_CONFIG_DIR. */
97
+ function claudeHomeDirs() {
98
+ const dirs = new Set();
99
+ for (const name of findClaudeHomeDirNames())
100
+ dirs.add(join(homedir(), name));
101
+ const cfg = process.env.CLAUDE_CONFIG_DIR;
102
+ if (cfg)
103
+ for (const part of cfg.split(",")) {
104
+ const p = part.trim();
105
+ if (p)
106
+ dirs.add(p);
107
+ }
108
+ return [...dirs];
37
109
  }
38
110
  function listSessionFiles(projectDir) {
39
111
  let entries;
@@ -70,12 +142,61 @@ export function findClaudeHomeDirNames() {
70
142
  return [];
71
143
  }
72
144
  }
73
- /** Every session file for this project, newest first. */
145
+ /**
146
+ * Every session file that belongs to this project, newest first.
147
+ *
148
+ * A folder matches when the real cwd stored in its sessions is THIS directory
149
+ * (encoding-independent — handles dots, underscores, spaces, symlinks), or when
150
+ * this directory is a project root and the session's cwd is inside it (a
151
+ * monorepo subfolder like packages/api, so the root check sees that work too).
152
+ * Encoding candidates are only a fallback for folders whose stored cwd can't be
153
+ * read.
154
+ */
74
155
  export function listAllSessionFiles(cwd) {
75
- const encoded = encodeProjectPath(cwd);
76
- const sessionFiles = findClaudeHomeDirNames().flatMap((dirName) => listSessionFiles(join(homedir(), dirName, "projects", encoded)));
77
- sessionFiles.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
78
- return sessionFiles;
156
+ const target = realpathOr(cwd);
157
+ const candidates = new Set(encodeCandidates(cwd));
158
+ const allowDescendants = looksLikeProjectRoot(cwd);
159
+ const files = [];
160
+ const seen = new Set();
161
+ for (const home of claudeHomeDirs()) {
162
+ const projectsDir = join(home, "projects");
163
+ let folders;
164
+ try {
165
+ folders = readdirSync(projectsDir);
166
+ }
167
+ catch {
168
+ continue;
169
+ }
170
+ for (const folder of folders) {
171
+ const dir = join(projectsDir, folder);
172
+ const folderFiles = listSessionFiles(dir);
173
+ if (folderFiles.length === 0)
174
+ continue;
175
+ const storedCwd = sessionCwdOf(folderFiles[0]);
176
+ let matches = false;
177
+ if (storedCwd) {
178
+ const real = realpathOr(storedCwd);
179
+ if (real === target)
180
+ matches = true;
181
+ else if (allowDescendants && real.startsWith(target + sep))
182
+ matches = true;
183
+ }
184
+ else {
185
+ // No readable cwd (older/garbled file): fall back to the name encoding.
186
+ matches = candidates.has(folder);
187
+ }
188
+ if (!matches)
189
+ continue;
190
+ for (const f of folderFiles) {
191
+ if (!seen.has(f)) {
192
+ seen.add(f);
193
+ files.push(f);
194
+ }
195
+ }
196
+ }
197
+ }
198
+ files.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
199
+ return files;
79
200
  }
80
201
  export function findLatestSessionFile(cwd) {
81
202
  const all = listAllSessionFiles(cwd);
package/dist/rules.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
4
  import { parseClaudeMd } from "./parsers/readClaudeMd.js";
5
5
  import { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
6
6
  import { loadMemoryRules } from "./parsers/readMemory.js";
7
+ import { resolveImports } from "./parsers/imports.js";
7
8
  /**
8
9
  * Every place Claude Code actually reads a rule from, at one directory
9
10
  * level. Order mirrors the documented load order, broadest first.
@@ -135,6 +136,42 @@ function ruleSourcesAtLevel(dir) {
135
136
  }
136
137
  // Gemini CLI: single rules file (its AGENTS.md equivalent).
137
138
  loaded("GEMINI.md", "Gemini");
139
+ return applyImports(out);
140
+ }
141
+ /**
142
+ * Claude Code follows @imports: a loaded rules file that says `@AGENTS.md` (or
143
+ * `@docs/rules.md`) makes that file part of what the agent reads. So an imported
144
+ * file is NOT shadowed, and its rules ARE checked. This reconciles `out` with
145
+ * that: any file imported by a loaded file is promoted to loaded (a shadowed
146
+ * AGENTS.md a CLAUDE.md imports flips to loaded), and any imported file not
147
+ * already listed is added as a loaded source. Without imports, `out` is
148
+ * unchanged, so existing projects keep their exact rule order and ids.
149
+ */
150
+ function applyImports(out) {
151
+ const imported = new Set();
152
+ for (const src of out) {
153
+ if (src.status !== "loaded")
154
+ continue;
155
+ for (const target of resolveImports(src.path))
156
+ imported.add(resolve(target));
157
+ }
158
+ if (imported.size === 0)
159
+ return out;
160
+ const present = new Set(out.map((s) => resolve(s.path)));
161
+ for (const src of out) {
162
+ if (src.status === "shadowed" && imported.has(resolve(src.path))) {
163
+ src.status = "loaded";
164
+ src.note = "imported by a loaded CLAUDE.md (@import), so the agent does read it";
165
+ }
166
+ }
167
+ // Imported files that were not otherwise candidates at this level (e.g. a
168
+ // @docs/rules.md), in a stable order so rule ids stay deterministic.
169
+ for (const path of [...imported].sort()) {
170
+ if (present.has(path))
171
+ continue;
172
+ present.add(path);
173
+ out.push({ path, status: "loaded", format: "imported (@import)" });
174
+ }
138
175
  return out;
139
176
  }
140
177
  /** Every rules file at one directory level that the agent actually loads. */
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, dirname, parse } from "node:path";
3
+ import { join, dirname, parse, resolve } from "node:path";
4
+ import { resolveImports } from "./parsers/imports.js";
4
5
  /** Directory-level pairs where a CLAUDE.md shadows an AGENTS.md. */
5
6
  const SHADOW_PAIRS = [
6
7
  { claude: "CLAUDE.md", agents: "AGENTS.md" },
@@ -24,7 +25,11 @@ export function shadowedAgentsMd(cwd) {
24
25
  const claudePath = join(dir, claude);
25
26
  const agentsPath = join(dir, agents);
26
27
  if (existsSync(claudePath) && existsSync(agentsPath)) {
27
- found.push({ agents: agentsPath, shadowedBy: claudePath });
28
+ // If the CLAUDE.md @imports the AGENTS.md, the agent DOES read it — it
29
+ // is not shadowed, and warning that it is would itself be untrue.
30
+ const importedByClaude = resolveImports(claudePath).some((p) => resolve(p) === resolve(agentsPath));
31
+ if (!importedByClaude)
32
+ found.push({ agents: agentsPath, shadowedBy: claudePath });
28
33
  }
29
34
  }
30
35
  if (existsSync(join(dir, ".git")))
package/dist/why.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { type BlockHint } from "./blockHint.js";
2
+ import type { Rule } from "./types.js";
3
+ export interface WhyRule {
4
+ id: string;
5
+ title: string;
6
+ source: "global" | "project";
7
+ location: string;
8
+ loaded: boolean;
9
+ loadNote?: string;
10
+ pathScoped?: string;
11
+ checkable: boolean;
12
+ kind: string;
13
+ suggestion?: string;
14
+ named?: {
15
+ kind: "command" | "file";
16
+ name: string;
17
+ exists: boolean;
18
+ };
19
+ /** How (and whether) this rule can actually be blocked/checked — see blockHint.ts. */
20
+ block?: BlockHint;
21
+ brokenCount: number;
22
+ brokenDates: string[];
23
+ sessionsScanned: number;
24
+ }
25
+ export interface WhyResult {
26
+ query: string;
27
+ matches: number;
28
+ rule?: WhyRule;
29
+ candidates?: {
30
+ title: string;
31
+ location: string;
32
+ }[];
33
+ }
34
+ /** Fuzzy-match a rule by the query appearing in its title/body, or the query being the title. */
35
+ export declare function findRules(rules: Rule[], query: string): Rule[];
36
+ export declare function explainRule(cwd: string, query: string): Promise<WhyResult>;
37
+ export interface WhyAll {
38
+ rules: WhyRule[];
39
+ sessionsScanned: number;
40
+ }
41
+ /**
42
+ * `why --all` — the same facts for every loaded rule, problems first (not loaded, then a
43
+ * named command/file missing, then broken recently, then judgment-only, then the rest).
44
+ * Read-only, facts only, no verdicts. Order within a rank is the rule's own order.
45
+ */
46
+ export declare function explainAll(cwd: string): Promise<WhyAll>;
47
+ /** The no-argument help: how to use `why`, plus every rule with its id, so people can pick. */
48
+ export declare function whyList(cwd: string): {
49
+ id: string;
50
+ title: string;
51
+ location: string;
52
+ }[];
53
+ export declare function renderWhy(r: WhyResult): string;
54
+ /** No-argument output: how to use `why`, then every rule with its id so you can pick one. */
55
+ export declare function renderWhyList(list: {
56
+ id: string;
57
+ title: string;
58
+ location: string;
59
+ }[]): string;
60
+ /** One short line per rule for `why --all`, problems first. */
61
+ export declare function renderAllWhy(all: WhyAll): string;
package/dist/why.js ADDED
@@ -0,0 +1,255 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { loadRules, describeRuleSources } from "./rules.js";
5
+ import { classifyRule } from "./checks/classify.js";
6
+ import { adviseRule } from "./checkability.js";
7
+ import { scanHistory } from "./historyReport.js";
8
+ import { blockHintFor } from "./blockHint.js";
9
+ /**
10
+ * `rulereceipt why "<rule text>"` — everything the tool already knows about ONE
11
+ * rule, in one place: where it lives, whether the agent even loads it, whether a
12
+ * command/path it names exists, whether it is mechanically checkable (and if
13
+ * not, the smallest edit that would make it), and how it has done in the last 30
14
+ * days. It answers the exact question people ask — "why isn't THIS rule
15
+ * working?" — and it invents nothing: every line is combined from data the
16
+ * engine already produces. Read-only; no verdict is created here.
17
+ */
18
+ const CHECKABLE_KINDS = new Set([
19
+ "gitBranchPolicy", "fileLifecycle", "codeContent", "approvalGate",
20
+ "claimEvidence", "deterministic", "attribution", "emojiOutput", "ifEditThenTest",
21
+ ]);
22
+ /** Fuzzy-match a rule by the query appearing in its title/body, or the query being the title. */
23
+ export function findRules(rules, query) {
24
+ // Normalize both sides to lowercase words separated by single spaces, so
25
+ // punctuation the user won't type (commas, backticks, dashes) doesn't block a
26
+ // match: "clean elegant maintainable" finds "clean, elegant, maintainable".
27
+ const clean = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
28
+ const q = clean(query);
29
+ if (q.length === 0)
30
+ return [];
31
+ const exact = rules.filter((r) => clean(r.title) === q);
32
+ if (exact.length > 0)
33
+ return exact;
34
+ return rules.filter((r) => {
35
+ const hay = clean(`${r.title} ${r.text}`);
36
+ if (hay.includes(q))
37
+ return true;
38
+ // Reverse direction (query IS roughly the title) only for a title long
39
+ // enough to be distinctive — otherwise a 1-char heading like "A" matches
40
+ // any query that happens to contain that letter.
41
+ const title = clean(r.title);
42
+ return title.length >= 6 && q.includes(title);
43
+ });
44
+ }
45
+ /** A `npm run <script>` or a backtick file path the rule names, and whether it exists. */
46
+ function namedCommandOrPath(rule, cwd) {
47
+ const text = `${rule.title} ${rule.text}`;
48
+ const script = text.match(/\b(?:npm|pnpm|yarn)\s+run\s+([\w:-]+)/);
49
+ if (script) {
50
+ let exists = false;
51
+ try {
52
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
53
+ exists = Boolean(pkg.scripts && script[1] in pkg.scripts);
54
+ }
55
+ catch {
56
+ /* no package.json: report not found */
57
+ }
58
+ return { kind: "command", name: `${script[1]}`, exists };
59
+ }
60
+ const path = text.match(/`([^`\s]+\.[a-z0-9]{1,6})`/i);
61
+ if (path && !path[1].includes("://")) {
62
+ const p = isAbsolute(path[1]) ? path[1] : join(cwd, path[1]);
63
+ return { kind: "file", name: path[1], exists: existsSync(p) };
64
+ }
65
+ return undefined;
66
+ }
67
+ async function historyLookup(cwd, rules) {
68
+ try {
69
+ const hist = await scanHistory(cwd, rules, 30);
70
+ const byRule = new Map();
71
+ for (const b of hist.breaks)
72
+ byRule.set(`${b.ruleId}\u0000${b.ruleTitle}`, { count: b.count, lastMs: b.lastMs });
73
+ return { sessionsScanned: hist.sessionsScanned, byRule };
74
+ }
75
+ catch {
76
+ return { sessionsScanned: 0, byRule: new Map() };
77
+ }
78
+ }
79
+ /** Build the WhyRule facts for one rule, reusing an already-computed load graph and history. */
80
+ function ruleFacts(cwd, rule, graph, hist) {
81
+ const src = rule.sourcePath ? graph.find((e) => e.path === rule.sourcePath) : undefined;
82
+ const cls = classifyRule(rule);
83
+ const checkable = CHECKABLE_KINDS.has(cls.kind);
84
+ const advice = checkable ? null : adviseRule(rule);
85
+ const b = hist.byRule.get(`${rule.id}\u0000${rule.title}`);
86
+ return {
87
+ id: rule.id,
88
+ title: rule.title,
89
+ source: rule.source,
90
+ location: locationOf(rule),
91
+ loaded: src ? src.status === "loaded" : true,
92
+ loadNote: src?.note,
93
+ pathScoped: rule.paths ? rule.paths.join(", ") : undefined,
94
+ checkable,
95
+ kind: cls.kind,
96
+ suggestion: advice?.suggestion,
97
+ named: namedCommandOrPath(rule, cwd),
98
+ block: blockHintFor(cls),
99
+ brokenCount: b?.count ?? 0,
100
+ brokenDates: b ? [new Date(b.lastMs).toISOString().slice(0, 10)] : [],
101
+ sessionsScanned: hist.sessionsScanned,
102
+ };
103
+ }
104
+ export async function explainRule(cwd, query) {
105
+ const rules = loadRules(cwd);
106
+ const matches = findRules(rules, query);
107
+ if (matches.length === 0)
108
+ return { query, matches: 0 };
109
+ if (matches.length > 1) {
110
+ return {
111
+ query,
112
+ matches: matches.length,
113
+ candidates: matches.slice(0, 12).map((r) => ({ title: r.title, location: locationOf(r) })),
114
+ };
115
+ }
116
+ const graph = describeRuleSources(cwd);
117
+ const hist = await historyLookup(cwd, rules);
118
+ return { query, matches: 1, rule: ruleFacts(cwd, matches[0], graph, hist) };
119
+ }
120
+ /** A "problem" rank so `why --all` can put the ones that need attention first. */
121
+ function problemRank(w) {
122
+ if (!w.loaded)
123
+ return 0; // the agent never sees it
124
+ if (w.named && !w.named.exists)
125
+ return 1; // names a command/file that does not exist
126
+ if (w.brokenCount > 0)
127
+ return 2; // broken recently
128
+ if (!w.checkable)
129
+ return 3; // needs judgment
130
+ return 4; // fine
131
+ }
132
+ /**
133
+ * `why --all` — the same facts for every loaded rule, problems first (not loaded, then a
134
+ * named command/file missing, then broken recently, then judgment-only, then the rest).
135
+ * Read-only, facts only, no verdicts. Order within a rank is the rule's own order.
136
+ */
137
+ export async function explainAll(cwd) {
138
+ const rules = loadRules(cwd);
139
+ const graph = describeRuleSources(cwd);
140
+ const hist = await historyLookup(cwd, rules);
141
+ const facts = rules.map((r) => ruleFacts(cwd, r, graph, hist));
142
+ const ranked = facts
143
+ .map((w, i) => ({ w, i }))
144
+ .sort((a, b) => problemRank(a.w) - problemRank(b.w) || a.i - b.i)
145
+ .map((x) => x.w);
146
+ return { rules: ranked, sessionsScanned: hist.sessionsScanned };
147
+ }
148
+ /** The no-argument help: how to use `why`, plus every rule with its id, so people can pick. */
149
+ export function whyList(cwd) {
150
+ return loadRules(cwd).map((r) => ({ id: r.id, title: r.title, location: locationOf(r) }));
151
+ }
152
+ function locationOf(rule) {
153
+ if (!rule.sourcePath)
154
+ return "unknown";
155
+ const home = homedir();
156
+ const p = rule.sourcePath.startsWith(home) ? `~${rule.sourcePath.slice(home.length)}` : rule.sourcePath;
157
+ return rule.sourceLine ? `${p}:${rule.sourceLine}` : p;
158
+ }
159
+ export function renderWhy(r) {
160
+ if (r.matches === 0)
161
+ return `No rule matched "${r.query}". Try a distinctive phrase from the rule, or run \`rulereceipt audit\` to list what loads.`;
162
+ if (r.candidates) {
163
+ const lines = r.candidates.map((c) => ` • ${c.title} (${c.location})`);
164
+ return `"${r.query}" matched ${r.matches} rules — narrow it down:\n${lines.join("\n")}`;
165
+ }
166
+ const w = r.rule;
167
+ const out = [];
168
+ out.push(`Rule ${w.id} — ${w.title}`);
169
+ out.push(` at ${w.location} (${w.source})`);
170
+ out.push("");
171
+ out.push(w.loaded ? ` ✓ loaded — the agent reads this file` : ` ✗ NOT loaded — ${w.loadNote ?? "the agent never sees this file"}`);
172
+ if (w.pathScoped)
173
+ out.push(` • path-scoped: only loads when the session touches ${w.pathScoped}`);
174
+ if (w.named)
175
+ out.push(w.named.exists ? ` ✓ names a ${w.named.kind} that exists: ${w.named.name}` : ` ✗ names a ${w.named.kind} that does NOT exist here: ${w.named.name}`);
176
+ out.push(w.checkable
177
+ ? ` ✓ mechanically checkable (${w.kind}) — a session is judged against it with quoted evidence`
178
+ : ` • needs your judgment${w.suggestion ? ` — ${w.suggestion}` : ` — no command or file to check it by; a human decides`}`);
179
+ renderBlockHint(w.block, out);
180
+ out.push("");
181
+ if (w.brokenCount > 0)
182
+ out.push(` last 30 days: broken ${w.brokenCount}× (last: ${w.brokenDates[0]}) across ${w.sessionsScanned} session${w.sessionsScanned === 1 ? "" : "s"}`);
183
+ else
184
+ out.push(` last 30 days: no proven break across ${w.sessionsScanned} session${w.sessionsScanned === 1 ? "" : "s"}`);
185
+ return out.join("\n");
186
+ }
187
+ /**
188
+ * How to actually enforce this rule, appended to the single-rule view. Prints
189
+ * only what the tool truly does: a native Claude Code permissions rule where one
190
+ * can genuinely express it (with its honest limitation), RuleReceipt's own guard
191
+ * where it covers the rule pre-flight, and — for rules judged only after the run —
192
+ * the after-the-fact path. Nothing here claims a block the guard doesn't make.
193
+ */
194
+ function renderBlockHint(block, out) {
195
+ if (!block)
196
+ return;
197
+ out.push("");
198
+ if (block.preventable) {
199
+ out.push(" To stop this before it runs:");
200
+ if (block.native) {
201
+ const entries = block.native.entries.map((e) => `"${e}"`).join(", ");
202
+ out.push(` • Claude Code settings (.claude/settings.json), no extra tool:`);
203
+ out.push(` { "permissions": { "${block.native.kind}": [${entries}] } }`);
204
+ out.push(` ${block.native.note}`);
205
+ }
206
+ else if (block.nativeImpossibleReason) {
207
+ out.push(` • A Claude Code permission rule can't express this — ${block.nativeImpossibleReason}.`);
208
+ }
209
+ if (block.guardCovers) {
210
+ out.push(` • RuleReceipt's own guard checks this exact rule: run \`rulereceipt protect\``);
211
+ out.push(` (adds a PreToolUse deny hook + a Stop hook, shown before it writes anything).`);
212
+ }
213
+ }
214
+ else {
215
+ out.push(" Can't be blocked before an action — this rule is judged after the run.");
216
+ out.push(" Run `rulereceipt check` after a session; `rulereceipt protect` also adds a Stop");
217
+ out.push(" hook that won't let a session end on a broken rule.");
218
+ }
219
+ }
220
+ /** No-argument output: how to use `why`, then every rule with its id so you can pick one. */
221
+ export function renderWhyList(list) {
222
+ if (list.length === 0)
223
+ return "No rules file found here. Run `rulereceipt init` to add one, then `rulereceipt why \"<rule>\"`.";
224
+ const out = ['Explain one rule: rulereceipt why "<some words from the rule>"', "Or all of them: rulereceipt why --all", "", "Rules found:"];
225
+ for (const r of list)
226
+ out.push(` ${r.id.padEnd(6)} ${r.title.replace(/\s+/g, " ").slice(0, 72)}`);
227
+ return out.join("\n");
228
+ }
229
+ /** One short line per rule for `why --all`, problems first. */
230
+ export function renderAllWhy(all) {
231
+ if (all.rules.length === 0)
232
+ return "No rules file found here. Run `rulereceipt init` to add one.";
233
+ const out = [];
234
+ for (const w of all.rules) {
235
+ let mark = " ✓";
236
+ let note = w.checkable ? `checkable (${w.kind})` : "needs your judgment";
237
+ if (!w.loaded) {
238
+ mark = " ✗";
239
+ note = `NOT loaded — ${w.loadNote ?? "the agent never sees this file"}`;
240
+ }
241
+ else if (w.named && !w.named.exists) {
242
+ mark = " ✗";
243
+ note = `names a ${w.named.kind} that does not exist: ${w.named.name}`;
244
+ }
245
+ else if (w.brokenCount > 0) {
246
+ mark = " ✗";
247
+ note = `broken ${w.brokenCount}× (last: ${w.brokenDates[0]})`;
248
+ }
249
+ out.push(`${mark} ${w.id.padEnd(6)} ${w.title.replace(/\s+/g, " ").slice(0, 60)}`);
250
+ out.push(` ${note}`);
251
+ }
252
+ out.push("");
253
+ out.push(`across ${all.sessionsScanned} session${all.sessionsScanned === 1 ? "" : "s"} · run \`rulereceipt why "<rule>"\` for one rule in full`);
254
+ return out.join("\n");
255
+ }
package/dist/wrong.js CHANGED
@@ -27,12 +27,25 @@ export function reportedLabel(r) {
27
27
  return "Couldn't tell";
28
28
  }
29
29
  const SECRET_PATTERNS = [
30
+ // Private key blocks and passwords inside URLs go FIRST — before the email
31
+ // rule, which would otherwise partially rewrite a user:pass@host authority.
32
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted-private-key>"],
33
+ [/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s:/@]+:)[^\s:/@]+(@)/g, "$1<redacted>$2"],
30
34
  [/\bsk-[A-Za-z0-9_-]{16,}/g, "<redacted-key>"],
31
35
  [/\b(?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{16,}/g, "<redacted-token>"],
32
36
  [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "<redacted-token>"],
37
+ // Stripe secret/publishable/restricted/webhook keys.
38
+ [/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, "<redacted-stripe-key>"],
39
+ [/\bwhsec_[A-Za-z0-9]{16,}/g, "<redacted-stripe-secret>"],
33
40
  [/\bAKIA[0-9A-Z]{16}\b/g, "<redacted-aws-key>"],
34
- [/\b(?:Bearer|token|apikey|api_key|password|passwd|secret)(\s*[:=]\s*|\s+)["']?[^\s"']{6,}/gi, "$1<redacted>"],
35
- [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted-private-key>"],
41
+ // JSON Web Tokens: header.payload.signature, each base64url.
42
+ [/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "<redacted-jwt>"],
43
+ // The value excludes a leading "<" so this never re-clobbers a more specific
44
+ // placeholder an earlier rule already inserted (e.g. "token <redacted-jwt>").
45
+ [/\b(?:Bearer|token|apikey|api_key|password|passwd|secret)(\s*[:=]\s*|\s+)["']?(?!<redacted)[^\s"']{6,}/gi, "$1<redacted>"],
46
+ // .env-style KEY=value: an UPPERCASE_KEY assigned a non-trivial value. A short
47
+ // value (DISABLE_LOCKS=1) is left alone so ordinary flags are not mangled.
48
+ [/\b([A-Z][A-Z0-9_]{2,})=(["']?)[^\s"']{8,}\2/g, "$1=<redacted>"],
36
49
  [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "<email>"],
37
50
  ];
38
51
  /** Masks the things most likely to be private. Not a guarantee — the user is told to read it. */
@@ -91,6 +104,7 @@ export function buildWrongReport(input) {
91
104
  "",
92
105
  "> Read this before sharing. Obvious secrets, your home path and email addresses were masked,",
93
106
  "> but rule text and session lines are quoted as they are. Edit anything private.",
107
+ "> Masking catches common formats only. Read before sending.",
94
108
  "",
95
109
  `**Rule handle:** \`${handle}\` (id ${result.ruleId}, ${result.ruleSource})`,
96
110
  "",
@@ -0,0 +1,24 @@
1
+ import { type SpawnSyncReturns } from "node:child_process";
2
+ /**
3
+ * The two ways to send a wrong-verdict report, kept as pure, testable pieces so
4
+ * the command wiring in cli.ts stays thin. Nothing here sends on its own: it
5
+ * builds the exact `gh issue create` argv and the exact mailto: URL, and the
6
+ * caller only runs them AFTER an explicit yes (see cli.ts). `--yes` never skips
7
+ * that preview+confirm — the report is public, so the default answer is No.
8
+ */
9
+ export declare const SUPPORT_EMAIL = "hello@rulereceipt.dev";
10
+ export declare const REPO = "rulereceipt/rulereceipt";
11
+ export declare const ISSUE_LABEL = "wrong-verdict";
12
+ type Runner = (cmd: string, args: string[]) => SpawnSyncReturns<Buffer>;
13
+ /** True only if `gh` is installed AND logged in. No issue is offered otherwise. */
14
+ export declare function ghReady(run?: Runner): boolean;
15
+ export declare function issueTitle(reported: string, ruleTitle: string): string;
16
+ /** argv for `gh issue create`. The label is included only when withLabel. */
17
+ export declare function issueCreateArgs(title: string, body: string, withLabel: boolean): string[];
18
+ export declare const MAILTO_MAX = 1800;
19
+ export declare function mailtoSubject(ruleTitle: string): string;
20
+ export declare function buildMailto(subject: string, body: string): {
21
+ url: string;
22
+ trimmed: boolean;
23
+ };
24
+ export {};
@@ -0,0 +1,50 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * The two ways to send a wrong-verdict report, kept as pure, testable pieces so
4
+ * the command wiring in cli.ts stays thin. Nothing here sends on its own: it
5
+ * builds the exact `gh issue create` argv and the exact mailto: URL, and the
6
+ * caller only runs them AFTER an explicit yes (see cli.ts). `--yes` never skips
7
+ * that preview+confirm — the report is public, so the default answer is No.
8
+ */
9
+ export const SUPPORT_EMAIL = "hello@rulereceipt.dev";
10
+ export const REPO = "rulereceipt/rulereceipt";
11
+ export const ISSUE_LABEL = "wrong-verdict";
12
+ const defaultRun = (cmd, args) => spawnSync(cmd, args, { stdio: "ignore" });
13
+ /** True only if `gh` is installed AND logged in. No issue is offered otherwise. */
14
+ export function ghReady(run = defaultRun) {
15
+ try {
16
+ if (run("gh", ["--version"]).status !== 0)
17
+ return false;
18
+ return run("gh", ["auth", "status"]).status === 0;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export function issueTitle(reported, ruleTitle) {
25
+ const t = ruleTitle.replace(/\s+/g, " ").trim().slice(0, 60);
26
+ return `Wrong verdict: ${reported} on ${t}`;
27
+ }
28
+ /** argv for `gh issue create`. The label is included only when withLabel. */
29
+ export function issueCreateArgs(title, body, withLabel) {
30
+ const args = ["issue", "create", "--repo", REPO, "--title", title, "--body", body];
31
+ if (withLabel)
32
+ args.push("--label", ISSUE_LABEL);
33
+ return args;
34
+ }
35
+ // A conservative cap so the mailto: fits what mail clients accept; a longer body
36
+ // is trimmed and the user is told to attach the saved .md file instead.
37
+ export const MAILTO_MAX = 1800;
38
+ export function mailtoSubject(ruleTitle) {
39
+ return `RuleReceipt wrong verdict: ${ruleTitle.replace(/\s+/g, " ").trim().slice(0, 80)}`;
40
+ }
41
+ export function buildMailto(subject, body) {
42
+ let b = body;
43
+ let trimmed = false;
44
+ if (b.length > MAILTO_MAX) {
45
+ b = b.slice(0, MAILTO_MAX) + "\n\n[Report trimmed to fit email — attach the saved .md file instead.]";
46
+ trimmed = true;
47
+ }
48
+ const url = `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(b)}`;
49
+ return { url, trimmed };
50
+ }