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.
- package/README.md +52 -15
- package/dist/blockHint.d.ts +49 -0
- package/dist/blockHint.js +87 -0
- package/dist/checks/claimEvidence.js +11 -0
- package/dist/checks/classify.js +6 -1
- package/dist/checks/codeContent.js +21 -1
- package/dist/cli.js +95 -6
- package/dist/historyReport.js +2 -0
- package/dist/parsers/claudeMdParser.d.ts +1 -1
- package/dist/parsers/claudeMdParser.js +23 -1
- package/dist/parsers/imports.d.ts +9 -0
- package/dist/parsers/imports.js +107 -0
- package/dist/parsers/transcriptParser.d.ts +10 -1
- package/dist/parsers/transcriptParser.js +130 -9
- package/dist/rules.js +37 -0
- package/dist/shadowedAgents.js +7 -2
- package/dist/why.d.ts +61 -0
- package/dist/why.js +255 -0
- package/dist/wrong.js +16 -2
- package/dist/wrongSubmit.d.ts +24 -0
- package/dist/wrongSubmit.js +50 -0
- package/package.json +2 -1
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
-
/**
|
|
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
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
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. */
|
package/dist/shadowedAgents.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
35
|
-
[
|
|
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
|
+
}
|