rulereceipt 0.1.66 → 0.1.67
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/dist/browser/analyze.d.ts +1 -0
- package/dist/browser/analyze.js +5 -0
- package/dist/card.js +13 -4
- package/dist/checks/doctor.d.ts +14 -0
- package/dist/checks/doctor.js +18 -1
- package/dist/cli.js +8 -0
- package/dist/evaluate.d.ts +1 -0
- package/dist/evaluate.js +27 -1
- package/dist/parsers/claudeMdParser.js +35 -12
- package/dist/parsers/readClaudeMd.js +25 -1
- package/dist/report/generateReport.js +28 -1
- package/dist/types.d.ts +17 -0
- package/package.json +1 -1
|
@@ -44,3 +44,4 @@ export declare const CORPUS: {
|
|
|
44
44
|
};
|
|
45
45
|
export declare function analyze(text: string): AnalysisResult;
|
|
46
46
|
export { evaluateBrowserSession, checkSessionInBrowser, type BrowserSessionSummary } from "./evaluateBrowser.js";
|
|
47
|
+
export { shareText, shareLinks, cardSvg, type CardData } from "../card.js";
|
package/dist/browser/analyze.js
CHANGED
|
@@ -50,3 +50,8 @@ export function analyze(text) {
|
|
|
50
50
|
// Client-side SESSION check for the browser demo: drop a session .jsonl +
|
|
51
51
|
// paste rules, get per-rule verdicts, nothing uploaded. Same checkers as the CLI.
|
|
52
52
|
export { evaluateBrowserSession, checkSessionInBrowser } from "./evaluateBrowser.js";
|
|
53
|
+
// Share the browser result: caption (counts only), compose links, and a
|
|
54
|
+
// self-contained SVG card. card.ts is pure string work — no Node, no network —
|
|
55
|
+
// so it can run in the page without breaking the "nothing leaves this page"
|
|
56
|
+
// promise. Counts only, never rule text or the session.
|
|
57
|
+
export { shareText, shareLinks, cardSvg } from "../card.js";
|
package/dist/card.js
CHANGED
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
const SITE = "rulereceipt.dev";
|
|
13
13
|
/** The share caption. Counts only, unless showRules adds the broken rule names. */
|
|
14
14
|
export function shareText(d, showRules = false) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
// A single dropped session (the browser demo) has no "over N days" span, so
|
|
16
|
+
// it gets a "this session" caption rather than the history-mode one.
|
|
17
|
+
const single = d.sessions === 1;
|
|
18
|
+
const base = single
|
|
19
|
+
? d.broken > 0
|
|
20
|
+
? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in this session — now it can't.`
|
|
21
|
+
: `RuleReceipt checked one of my agent's sessions against my written rules: ${d.broken} broken.`
|
|
22
|
+
: d.broken > 0
|
|
23
|
+
? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in ${d.days} days (${d.sessions} sessions) — now it can't.`
|
|
24
|
+
: `RuleReceipt checked ${d.sessions} of my agent sessions over ${d.days} days against my written rules: ${d.broken} broken.`;
|
|
18
25
|
const tail = `Checked with RuleReceipt — runs locally, nothing uploaded. ${SITE}`;
|
|
19
26
|
if (showRules && d.broken > 0 && d.brokenTitles && d.brokenTitles.length > 0) {
|
|
20
27
|
const list = d.brokenTitles.slice(0, 3).map((t) => `“${t.replace(/\s+/g, " ").trim().slice(0, 50)}”`).join(", ");
|
|
@@ -41,7 +48,9 @@ function esc(s) {
|
|
|
41
48
|
/** A self-contained SVG card. Counts only — never rule text, paths or code. */
|
|
42
49
|
export function cardSvg(d) {
|
|
43
50
|
const headline = d.broken > 0 ? `${d.who} broke your rules ${d.broken}×` : `0 rules broken`;
|
|
44
|
-
const sub =
|
|
51
|
+
const sub = d.days > 0
|
|
52
|
+
? `${d.sessions} session${d.sessions === 1 ? "" : "s"} · last ${d.days} days`
|
|
53
|
+
: `${d.sessions} session${d.sessions === 1 ? "" : "s"}`;
|
|
45
54
|
const stats = `${d.followed} followed · ${d.judgment} need judgment`;
|
|
46
55
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="418" viewBox="0 0 800 418" role="img" aria-label="RuleReceipt summary">
|
|
47
56
|
<rect width="800" height="418" fill="#0b0d10"/>
|
package/dist/checks/doctor.d.ts
CHANGED
|
@@ -11,11 +11,25 @@ export interface HookEntry {
|
|
|
11
11
|
*/
|
|
12
12
|
matcher?: string;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* A hook command registered more than once on the same event within a single
|
|
16
|
+
* settings file — so it fires that many times per event. Cross-file repetition
|
|
17
|
+
* (a global and a project settings file both registering it) is legitimate
|
|
18
|
+
* layering and is NOT reported: only a genuinely redundant in-file duplicate is,
|
|
19
|
+
* to avoid a false "you have a problem" on a normal setup.
|
|
20
|
+
*/
|
|
21
|
+
export interface DuplicateHook {
|
|
22
|
+
sourceFile: string;
|
|
23
|
+
event: string;
|
|
24
|
+
command: string;
|
|
25
|
+
count: number;
|
|
26
|
+
}
|
|
14
27
|
export interface DoctorResult {
|
|
15
28
|
filesScanned: string[];
|
|
16
29
|
filesFound: string[];
|
|
17
30
|
hooks: HookEntry[];
|
|
18
31
|
newSinceLastRun: HookEntry[];
|
|
32
|
+
duplicates: DuplicateHook[];
|
|
19
33
|
}
|
|
20
34
|
/**
|
|
21
35
|
* Lists every Claude Code hook and VS Code folderOpen task this machine
|
package/dist/checks/doctor.js
CHANGED
|
@@ -140,5 +140,22 @@ export function runDoctor(cwd) {
|
|
|
140
140
|
const previousKeys = new Set(previous.map((h) => `${h.sourceFile}|${h.event}|${h.command}`));
|
|
141
141
|
const newSinceLastRun = hooks.filter((h) => !previousKeys.has(`${h.sourceFile}|${h.event}|${h.command}`));
|
|
142
142
|
saveSnapshot(cwd, hooks);
|
|
143
|
-
return { filesScanned, filesFound, hooks, newSinceLastRun };
|
|
143
|
+
return { filesScanned, filesFound, hooks, newSinceLastRun, duplicates: findDuplicates(hooks) };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Same command on the same event within ONE file, counted. Keyed by
|
|
147
|
+
* file+event+command so the global-plus-project case never registers as a
|
|
148
|
+
* duplicate — that is intended layering, not a misconfiguration.
|
|
149
|
+
*/
|
|
150
|
+
function findDuplicates(hooks) {
|
|
151
|
+
const counts = new Map();
|
|
152
|
+
for (const h of hooks) {
|
|
153
|
+
const key = `${h.sourceFile}|${h.event}|${h.command}`;
|
|
154
|
+
const existing = counts.get(key);
|
|
155
|
+
if (existing)
|
|
156
|
+
existing.count += 1;
|
|
157
|
+
else
|
|
158
|
+
counts.set(key, { sourceFile: h.sourceFile, event: h.event, command: h.command, count: 1 });
|
|
159
|
+
}
|
|
160
|
+
return [...counts.values()].filter((d) => d.count > 1);
|
|
144
161
|
}
|
package/dist/cli.js
CHANGED
|
@@ -750,6 +750,14 @@ function runDoctorCommand() {
|
|
|
750
750
|
if (result.newSinceLastRun.length > 0) {
|
|
751
751
|
console.log(`${result.newSinceLastRun.length} of these are new since the last time doctor ran here.`);
|
|
752
752
|
}
|
|
753
|
+
if (result.duplicates.length > 0) {
|
|
754
|
+
console.log("");
|
|
755
|
+
console.log(`⚠ ${result.duplicates.length} duplicate hook${result.duplicates.length === 1 ? "" : "s"} — the same command is registered more than once on one event, so it runs that many times:`);
|
|
756
|
+
for (const d of result.duplicates) {
|
|
757
|
+
console.log(` ${d.event} — ${d.command} (×${d.count})`);
|
|
758
|
+
console.log(` in ${d.sourceFile} — remove the extra copy so it fires once.`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
753
761
|
}
|
|
754
762
|
program
|
|
755
763
|
.command("hook")
|
package/dist/evaluate.d.ts
CHANGED
|
@@ -20,3 +20,4 @@ export interface Evaluation {
|
|
|
20
20
|
* a transcript comes from, and a hook is handed one it must not second-guess.
|
|
21
21
|
*/
|
|
22
22
|
export declare function evaluateSession(cwd: string, rules: Rule[], events: TranscriptEvent[], llm: boolean, needsLlmResult: (rule: Rule) => CheckResult): Promise<Evaluation>;
|
|
23
|
+
export declare function attachSourceLocation(results: CheckResult[], rules: Rule[]): CheckResult[];
|
package/dist/evaluate.js
CHANGED
|
@@ -98,9 +98,35 @@ export async function evaluateSession(cwd, rules, events, llm, needsLlmResult) {
|
|
|
98
98
|
const judgmentResults = llm
|
|
99
99
|
? await runJudgmentChecks(judgment, events)
|
|
100
100
|
: judgment.map(({ rule }) => needsLlmResult(rule));
|
|
101
|
+
const results = [...deterministicResults, ...judgmentResults, ...scopeResults, ...future.map(futureResult)];
|
|
101
102
|
return {
|
|
102
|
-
results:
|
|
103
|
+
results: attachSourceLocation(results, rules),
|
|
103
104
|
notARule: of("notARule"),
|
|
104
105
|
stale: staleOverrides(overrides, rules),
|
|
105
106
|
};
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Copies each rule's source file/line onto its verdict — but ONLY when the
|
|
110
|
+
* (source, id, title) triple maps to exactly one loaded rule. Rule ids are
|
|
111
|
+
* positional and two files can legitimately reuse "1" or "S1.1", so a blind
|
|
112
|
+
* id-match could point a report at the wrong line. A wrong "CLAUDE.md:42" is
|
|
113
|
+
* worse than none, so an ambiguous or unlocated rule simply carries no line.
|
|
114
|
+
*/
|
|
115
|
+
function locationKey(source, id, title) {
|
|
116
|
+
return `${source}\u0000${id}\u0000${title}`;
|
|
117
|
+
}
|
|
118
|
+
export function attachSourceLocation(results, rules) {
|
|
119
|
+
const byKey = new Map();
|
|
120
|
+
for (const rule of rules) {
|
|
121
|
+
if (rule.sourcePath === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
const key = locationKey(rule.source, rule.id, rule.title);
|
|
124
|
+
byKey.set(key, byKey.has(key) ? null : rule); // second hit => ambiguous => null
|
|
125
|
+
}
|
|
126
|
+
return results.map((r) => {
|
|
127
|
+
const rule = byKey.get(locationKey(r.ruleSource, r.ruleId, r.ruleTitle));
|
|
128
|
+
if (!rule)
|
|
129
|
+
return r;
|
|
130
|
+
return { ...r, sourcePath: rule.sourcePath, sourceLine: rule.sourceLine };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -75,7 +75,12 @@ function stripHtmlComments(raw) {
|
|
|
75
75
|
spans.push(m);
|
|
76
76
|
return `\u0000CODE${spans.length - 1}\u0000`;
|
|
77
77
|
});
|
|
78
|
-
|
|
78
|
+
// A removed comment is replaced by the SAME number of newlines it spanned,
|
|
79
|
+
// not by nothing. Rule source lines are tracked by line index (2026-09-29),
|
|
80
|
+
// so a multi-line comment that collapsed to nothing would shift every rule
|
|
81
|
+
// below it and make the reported "CLAUDE.md:42" wrong. Blank lines left in a
|
|
82
|
+
// rule body are trimmed at its edges and harmless within it.
|
|
83
|
+
const stripped = masked.replace(/<!--[\s\S]*?-->/g, (m) => "\n".repeat((m.match(/\n/g) ?? []).length));
|
|
79
84
|
return stripped.replace(/\u0000CODE(\d+)\u0000/g, (_, i) => spans[Number(i)]);
|
|
80
85
|
}
|
|
81
86
|
function normalizeSetextHeaders(lines) {
|
|
@@ -152,6 +157,8 @@ export function parseClaudeMdText(raw, source) {
|
|
|
152
157
|
let currentIsMarkedRule = false; // true for numbered/bold rules: bullets in their body stay as body text
|
|
153
158
|
let bodyLines = [];
|
|
154
159
|
let pendingSectionTitle = null;
|
|
160
|
+
let pendingSectionLine = 0; // 1-based line of the plain header awaiting its prose rule
|
|
161
|
+
let lineNo = 0; // 1-based index of the line currently being read
|
|
155
162
|
let sectionCount = 0;
|
|
156
163
|
let sectionId = "S0"; // "S0" before any header is seen; null while a header is pending its first rule
|
|
157
164
|
let bulletIndex = 0;
|
|
@@ -178,7 +185,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
178
185
|
bodyLines.push(line);
|
|
179
186
|
}
|
|
180
187
|
else if (pendingSectionTitle !== null && line.trim() !== "") {
|
|
181
|
-
current = { id: `${assignSectionId()}.0`, title: pendingSectionTitle, text: "", source };
|
|
188
|
+
current = { id: `${assignSectionId()}.0`, title: pendingSectionTitle, text: "", source, sourceLine: pendingSectionLine };
|
|
182
189
|
bodyLines = [line];
|
|
183
190
|
pendingSectionTitle = null;
|
|
184
191
|
}
|
|
@@ -187,6 +194,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
187
194
|
}
|
|
188
195
|
};
|
|
189
196
|
for (const line of lines) {
|
|
197
|
+
lineNo += 1;
|
|
190
198
|
// Fence state is tracked before any structural match, so nothing inside a
|
|
191
199
|
// code block is ever read as a heading, a bullet, or a rule marker.
|
|
192
200
|
const fence = line.match(FENCE_LINE);
|
|
@@ -210,7 +218,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
210
218
|
if (numbered) {
|
|
211
219
|
flush();
|
|
212
220
|
pendingSectionTitle = null;
|
|
213
|
-
current = { id: numbered[1], title: numbered[2].trim(), text: "", source };
|
|
221
|
+
current = { id: numbered[1], title: numbered[2].trim(), text: "", source, sourceLine: lineNo };
|
|
214
222
|
currentIsMarkedRule = true;
|
|
215
223
|
continue;
|
|
216
224
|
}
|
|
@@ -218,7 +226,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
218
226
|
if (bold) {
|
|
219
227
|
flush();
|
|
220
228
|
pendingSectionTitle = null;
|
|
221
|
-
current = { id: bold[1], title: bold[2].trim(), text: "", source };
|
|
229
|
+
current = { id: bold[1], title: bold[2].trim(), text: "", source, sourceLine: lineNo };
|
|
222
230
|
currentIsMarkedRule = true;
|
|
223
231
|
continue;
|
|
224
232
|
}
|
|
@@ -228,6 +236,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
228
236
|
sectionId = null;
|
|
229
237
|
bulletIndex = 0;
|
|
230
238
|
pendingSectionTitle = plain[1].trim();
|
|
239
|
+
pendingSectionLine = lineNo;
|
|
231
240
|
currentIsMarkedRule = false;
|
|
232
241
|
continue;
|
|
233
242
|
}
|
|
@@ -239,7 +248,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
239
248
|
else {
|
|
240
249
|
bulletIndex += 1;
|
|
241
250
|
const text = bullet[1].trim();
|
|
242
|
-
rules.push({ id: `${assignSectionId()}.${bulletIndex}`, title: text, text, source });
|
|
251
|
+
rules.push({ id: `${assignSectionId()}.${bulletIndex}`, title: text, text, source, sourceLine: lineNo });
|
|
243
252
|
}
|
|
244
253
|
continue;
|
|
245
254
|
}
|
|
@@ -253,13 +262,27 @@ export function parseClaudeMdText(raw, source) {
|
|
|
253
262
|
// headers, no bullets, no bold-rule markers) is split one rule per
|
|
254
263
|
// blank-line-separated paragraph, so a genuinely unstructured file
|
|
255
264
|
// still yields checkable rules instead of silently returning nothing.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
265
|
+
// Track the line each paragraph starts on so the fallback rules carry a
|
|
266
|
+
// source line too. Split on the raw text and walk cumulative line counts.
|
|
267
|
+
const chunks = raw.split(/\n\s*\n/);
|
|
268
|
+
const out = [];
|
|
269
|
+
let lineCursor = 1;
|
|
270
|
+
let n = 0;
|
|
271
|
+
for (const chunk of chunks) {
|
|
272
|
+
const startLine = lineCursor;
|
|
273
|
+
lineCursor += (chunk.match(/\n/g) ?? []).length; // lines consumed by this chunk
|
|
274
|
+
// account for the blank separator the split removed (one newline minimum)
|
|
275
|
+
lineCursor += 1;
|
|
276
|
+
const p = chunk.trim();
|
|
277
|
+
if (p.length === 0)
|
|
278
|
+
continue;
|
|
279
|
+
n += 1;
|
|
261
280
|
const firstLine = p.split("\n")[0].trim();
|
|
262
281
|
const title = firstLine.length > 100 ? `${firstLine.slice(0, 100).trim()}…` : firstLine;
|
|
263
|
-
|
|
264
|
-
|
|
282
|
+
// Best-effort line for the freeform fallback: any leading blank lines in the
|
|
283
|
+
// chunk push the real first line down.
|
|
284
|
+
const leadBlank = (chunk.match(/^(?:[ \t]*\n)*/)?.[0].match(/\n/g) ?? []).length;
|
|
285
|
+
out.push({ id: String(n), title, text: p, source, sourceLine: startLine + leadBlank });
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
265
288
|
}
|
|
@@ -81,6 +81,24 @@ export function readPathScope(raw) {
|
|
|
81
81
|
}
|
|
82
82
|
return undefined;
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* How many leading lines `stripFrontmatter` removes, so a source line computed
|
|
86
|
+
* on the stripped text can be mapped back to the real file line. Zero when
|
|
87
|
+
* there is no frontmatter block.
|
|
88
|
+
*/
|
|
89
|
+
function frontmatterLineOffset(raw) {
|
|
90
|
+
if (!/^---\r?\n/.test(raw))
|
|
91
|
+
return 0;
|
|
92
|
+
const end = raw.indexOf("\n---", 3);
|
|
93
|
+
if (end === -1)
|
|
94
|
+
return 0;
|
|
95
|
+
const after = raw.indexOf("\n", end + 1);
|
|
96
|
+
if (after === -1)
|
|
97
|
+
return 0;
|
|
98
|
+
// stripFrontmatter returns raw.slice(after + 1): everything up to and
|
|
99
|
+
// including that newline is gone. Count the newlines removed.
|
|
100
|
+
return (raw.slice(0, after + 1).match(/\n/g) ?? []).length;
|
|
101
|
+
}
|
|
84
102
|
export function parseClaudeMd(filePath, source) {
|
|
85
103
|
let raw;
|
|
86
104
|
try {
|
|
@@ -89,7 +107,13 @@ export function parseClaudeMd(filePath, source) {
|
|
|
89
107
|
catch {
|
|
90
108
|
return [];
|
|
91
109
|
}
|
|
110
|
+
const offset = frontmatterLineOffset(raw);
|
|
92
111
|
const rules = parseClaudeMdText(stripFrontmatter(raw), source);
|
|
93
112
|
const paths = readPathScope(raw);
|
|
94
|
-
return
|
|
113
|
+
return rules.map((r) => ({
|
|
114
|
+
...r,
|
|
115
|
+
sourcePath: filePath,
|
|
116
|
+
sourceLine: r.sourceLine === undefined ? undefined : r.sourceLine + offset,
|
|
117
|
+
...(paths ? { paths } : {}),
|
|
118
|
+
}));
|
|
95
119
|
}
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
/**
|
|
5
|
+
* Where a rule lives, for the report — "~/proj/CLAUDE.md:42", or just the path
|
|
6
|
+
* when the parser could not place a line, or "" when the rule's location was
|
|
7
|
+
* ambiguous and deliberately omitted (see attachSourceLocation). The home dir
|
|
8
|
+
* is compressed to ~ so the line stays readable; the JSON report keeps the full
|
|
9
|
+
* absolute path.
|
|
10
|
+
*/
|
|
11
|
+
function locationOf(r) {
|
|
12
|
+
if (!r.sourcePath)
|
|
13
|
+
return "";
|
|
14
|
+
const home = homedir();
|
|
15
|
+
const path = r.sourcePath.startsWith(home) ? `~${r.sourcePath.slice(home.length)}` : r.sourcePath;
|
|
16
|
+
return r.sourceLine ? `${path}:${r.sourceLine}` : path;
|
|
17
|
+
}
|
|
3
18
|
const MARK = { PASS: "✓", FAIL: "✕", UNCLEAR: "?" };
|
|
4
19
|
/**
|
|
5
20
|
* A CLAUDE.md rule title, or evidence text pulled from session content, is
|
|
@@ -196,6 +211,9 @@ export function generateReport(results, meta) {
|
|
|
196
211
|
lines.push("");
|
|
197
212
|
for (const r of inBucket) {
|
|
198
213
|
lines.push(` ${ruleLabel(r, clean)}`);
|
|
214
|
+
const loc = locationOf(r);
|
|
215
|
+
if (loc)
|
|
216
|
+
lines.push(` ↳ ${loc}`);
|
|
199
217
|
// An entry that does not share the hoisted text still says its own
|
|
200
218
|
// piece — that difference is the only per-rule information there is.
|
|
201
219
|
if (r.evidence && r.evidence !== shared)
|
|
@@ -207,6 +225,9 @@ export function generateReport(results, meta) {
|
|
|
207
225
|
}
|
|
208
226
|
for (const r of inBucket) {
|
|
209
227
|
lines.push(`${MARK[r.status]} ${r.status.padEnd(7)} ${ruleLabel(r, clean)}`);
|
|
228
|
+
const loc = locationOf(r);
|
|
229
|
+
if (loc)
|
|
230
|
+
lines.push(` ↳ ${loc}`);
|
|
210
231
|
if (r.evidence)
|
|
211
232
|
lines.push(` evidence: ${r.evidence}`);
|
|
212
233
|
// What this method was ALLOWED to conclude, travelling with the
|
|
@@ -259,7 +280,9 @@ export function generateMarkdownReport(results, meta) {
|
|
|
259
280
|
lines.push("|---|---|---|");
|
|
260
281
|
for (const r of clean) {
|
|
261
282
|
const evidence = escapeMarkdownCell(r.evidence || "");
|
|
262
|
-
|
|
283
|
+
const loc = locationOf(r);
|
|
284
|
+
const label = loc ? `${ruleLabel(r, clean)}<br>\`${loc}\`` : ruleLabel(r, clean);
|
|
285
|
+
lines.push(`| ${MARK[r.status]} ${r.status} | ${escapeMarkdownCell(label)} | ${evidence} |`);
|
|
263
286
|
}
|
|
264
287
|
lines.push("");
|
|
265
288
|
const hash = computeTranscriptHash(meta.sessionFilePath);
|
|
@@ -302,6 +325,10 @@ export function generateJsonReport(results, meta, toolVersion, editedRuleFiles =
|
|
|
302
325
|
ruleId: r.ruleId,
|
|
303
326
|
ruleTitle: r.ruleTitle,
|
|
304
327
|
ruleSource: r.ruleSource,
|
|
328
|
+
// Absolute path + 1-based line of the rule's heading, when unambiguous
|
|
329
|
+
// (see attachSourceLocation). A consumer can jump straight to the rule.
|
|
330
|
+
sourcePath: r.sourcePath ?? null,
|
|
331
|
+
sourceLine: r.sourceLine ?? null,
|
|
305
332
|
status: r.status,
|
|
306
333
|
outcome: r.outcome ?? null,
|
|
307
334
|
method: r.method ?? null,
|
package/dist/types.d.ts
CHANGED
|
@@ -11,6 +11,15 @@ export interface Rule {
|
|
|
11
11
|
* against a rule it was never shown. Absent = always loaded.
|
|
12
12
|
*/
|
|
13
13
|
paths?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Absolute path of the rules file this rule was read from, and the 1-based
|
|
16
|
+
* line where its heading/marker sits. Added 2026-09-29 so a report can say
|
|
17
|
+
* exactly where a rule lives ("CLAUDE.md:42 — Never push to main"): a verdict
|
|
18
|
+
* you can walk to the source of is a verdict you can trust. Optional — memory
|
|
19
|
+
* rules and the freeform-paragraph fallback carry a path but may omit a line.
|
|
20
|
+
*/
|
|
21
|
+
sourcePath?: string;
|
|
22
|
+
sourceLine?: number;
|
|
14
23
|
}
|
|
15
24
|
export interface TranscriptTextEvent {
|
|
16
25
|
role: "user" | "assistant";
|
|
@@ -149,6 +158,14 @@ export interface CheckResult {
|
|
|
149
158
|
* anthropics/claude-code#90542.
|
|
150
159
|
*/
|
|
151
160
|
polarityInferred?: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Where the checked rule lives — the rules file's absolute path and the
|
|
163
|
+
* 1-based line of its heading. Copied from the rule in `evaluateSession`,
|
|
164
|
+
* and ONLY when the (source, id, title) triple maps to exactly one loaded
|
|
165
|
+
* rule, so a report never points at the wrong line. Absent otherwise.
|
|
166
|
+
*/
|
|
167
|
+
sourcePath?: string;
|
|
168
|
+
sourceLine?: number;
|
|
152
169
|
}
|
|
153
170
|
/**
|
|
154
171
|
* A FAIL may only be constructed from a forbidding rule.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.67",
|
|
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",
|