rulereceipt 0.1.84 → 0.1.85
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/breakContext.d.ts +7 -0
- package/dist/breakContext.js +18 -13
- package/dist/cli.js +16 -11
- package/dist/historyReport.d.ts +2 -0
- package/dist/historyReport.js +24 -4
- package/dist/projectConfig.js +3 -2
- package/dist/report/generateReport.js +20 -5
- package/dist/teamExport.d.ts +3 -1
- package/dist/teamExport.js +6 -4
- package/dist/types.d.ts +13 -0
- package/dist/visibility.d.ts +13 -0
- package/dist/visibility.js +51 -0
- package/package.json +1 -1
package/dist/breakContext.d.ts
CHANGED
|
@@ -30,6 +30,13 @@ export interface BreakContext {
|
|
|
30
30
|
rulesInContext: boolean;
|
|
31
31
|
/** Did a compaction occur before the break? */
|
|
32
32
|
compactionBefore: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Did the transcript show ANY context-injection machinery (a system-reminder,
|
|
35
|
+
* a claudeMd/instructions attachment, a compaction)? When false, the log is
|
|
36
|
+
* too thin to tell whether the rules file was loaded — so "rules not in
|
|
37
|
+
* context" would be a guess, and the caller must treat it as can't-tell.
|
|
38
|
+
*/
|
|
39
|
+
contextObserved: boolean;
|
|
33
40
|
/**
|
|
34
41
|
* The rules file was in context earlier, but its last appearance was BEFORE
|
|
35
42
|
* the last compaction and it was not re-injected after — so the summary may
|
package/dist/breakContext.js
CHANGED
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
// and the structural claudeMd attachment (escaped or not inside a JSONL line).
|
|
28
28
|
const RULES_INJECTION = /Contents of [^\n"]*(?:CLAUDE|AGENTS|GEMINI|AGENT)[^\n"]*\.md \(project instructions|project instructions, checked into|\\?"(?:claudeMd|type\\?":\\?"claudeMd)\\?"|\\?"type\\?":\s*\\?"claudeMd/;
|
|
29
29
|
const COMPACTION = /"isCompactSummary"\s*:\s*true/;
|
|
30
|
+
// Evidence that THIS transcript records context-injection at all: a
|
|
31
|
+
// system-reminder block, a claudeMd / instructions / attachment line, a
|
|
32
|
+
// rules-file block, or a compaction. If none of this appears, the log is too
|
|
33
|
+
// thin to conclude the rules file was absent (vs simply not recorded).
|
|
34
|
+
const CONTEXT_MACHINERY = /<system-reminder>|\\?"claudeMd\\?"|"type"\s*:\s*"(?:instructions|attachment|system)"|project instructions|Contents of [^\n"]*\.md|"isCompactSummary"\s*:\s*true/i;
|
|
30
35
|
/** Longest-first distinctive fragments of the evidence to find the break line by. */
|
|
31
36
|
function needles(evidence) {
|
|
32
37
|
const quoted = [...evidence.matchAll(/"([^"]{6,})"/g)].map((m) => m[1]);
|
|
@@ -78,8 +83,9 @@ export function breakContext(transcriptText, evidence) {
|
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
}
|
|
86
|
+
const contextObserved = lines.some((l) => CONTEXT_MACHINERY.test(l));
|
|
81
87
|
if (breakIdx === -1)
|
|
82
|
-
return { located: false, rulesInContext: false, compactionBefore: false, rulesStaleAfterCompaction: false };
|
|
88
|
+
return { located: false, rulesInContext: false, compactionBefore: false, rulesStaleAfterCompaction: false, contextObserved };
|
|
83
89
|
let lastRulesIdx = -1;
|
|
84
90
|
let lastCompactionIdx = -1;
|
|
85
91
|
let precedingUser;
|
|
@@ -101,6 +107,7 @@ export function breakContext(transcriptText, evidence) {
|
|
|
101
107
|
rulesInContext,
|
|
102
108
|
compactionBefore,
|
|
103
109
|
rulesStaleAfterCompaction: rulesInContext && compactionBefore && lastRulesIdx < lastCompactionIdx,
|
|
110
|
+
contextObserved,
|
|
104
111
|
};
|
|
105
112
|
}
|
|
106
113
|
/** The lines the report prints under a break, or [] when nothing is worth adding. */
|
|
@@ -110,20 +117,18 @@ export function renderBreakContext(ctx) {
|
|
|
110
117
|
const out = [" why it broke:"];
|
|
111
118
|
if (ctx.precedingUser)
|
|
112
119
|
out.push(` just before, you said: "${ctx.precedingUser}"`);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
else if (ctx.rulesStaleAfterCompaction) {
|
|
120
|
-
out.push(" your rules file was in context earlier but NOT after the last compaction — the");
|
|
121
|
-
out.push(" summary may have dropped it. Fix: a post-compaction hook that re-injects your rules.");
|
|
122
|
-
}
|
|
123
|
-
else {
|
|
120
|
+
// This renders only for a break that was NOT downgraded to "Rule not visible"
|
|
121
|
+
// (see visibility.ts). So either the rules file WAS in context, or the log is
|
|
122
|
+
// too thin to tell — never the confident not-visible case, which shows its own
|
|
123
|
+
// fix in the "Rule not visible" section.
|
|
124
|
+
if (ctx.rulesInContext) {
|
|
124
125
|
out.push(" your rules file was in context before this.");
|
|
125
126
|
if (ctx.compactionBefore)
|
|
126
|
-
out.push(" (a compaction happened earlier in this session;
|
|
127
|
+
out.push(" (a compaction happened earlier in this session; it was re-injected after.)");
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
out.push(" couldn't tell whether your rules file was in context here — the session log");
|
|
131
|
+
out.push(" doesn't record it. If it wasn't, this isn't the agent ignoring a rule it never saw.");
|
|
127
132
|
}
|
|
128
133
|
return out;
|
|
129
134
|
}
|
package/dist/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ import { runHook } from "./hook.js";
|
|
|
31
31
|
import { runGuard } from "./guard.js";
|
|
32
32
|
import { generateReport, generateMarkdownReport, generateJsonReport, computeTranscriptHash } from "./report/generateReport.js";
|
|
33
33
|
import { buildTeamExport, parseExport, mergeTeamExports, renderTeamHtml } from "./teamExport.js";
|
|
34
|
+
import { applyVisibility } from "./visibility.js";
|
|
34
35
|
import { gateOffer, hookIsInstalled } from "./report/gateOffer.js";
|
|
35
36
|
import { generateHtmlReport } from "./report/generateHtmlReport.js";
|
|
36
37
|
import { verifySessionHash } from "./verifyHash.js";
|
|
@@ -268,7 +269,21 @@ async function runCheck(opts) {
|
|
|
268
269
|
// back to its stable handle so the mark survives edits that renumber ids.
|
|
269
270
|
const projectConfig = loadProjectConfig(cwd);
|
|
270
271
|
const handleFor = handleMap(rules);
|
|
271
|
-
|
|
272
|
+
// The raw session text — for A4 "why it broke" context AND for the
|
|
273
|
+
// visibility pass (#4). Best-effort: if it can't be read, visibility is left
|
|
274
|
+
// undetermined (a would-be break stays Broken) and no A4 context is shown.
|
|
275
|
+
let transcriptText;
|
|
276
|
+
try {
|
|
277
|
+
if (sessionFilePath)
|
|
278
|
+
transcriptText = readFileSync(sessionFilePath, "utf-8");
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
/* unreadable: never a crash */
|
|
282
|
+
}
|
|
283
|
+
// "Rule not visible" (#4): downgrade a FAIL whose rule was never in the
|
|
284
|
+
// agent's context at the break. Applied HERE, before anything counts a break,
|
|
285
|
+
// so the report, the exit code and the export all agree.
|
|
286
|
+
const results = applyVisibility(visibleResults(rawResults, projectConfig, handleFor), transcriptText);
|
|
272
287
|
const blockingFails = blockingFailures(results, projectConfig, handleFor);
|
|
273
288
|
const warnedFails = warningFailures(results, projectConfig, handleFor);
|
|
274
289
|
const meta = { sessionFilePath, ruleCount: results.length };
|
|
@@ -303,16 +318,6 @@ async function runCheck(opts) {
|
|
|
303
318
|
: "";
|
|
304
319
|
// Kept in human/markdown form for --email and any other reader below, even
|
|
305
320
|
// when stdout is JSON — a manager gets a readable report, not raw JSON.
|
|
306
|
-
// The raw session text, for A4 "why it broke" context under each Broken verdict.
|
|
307
|
-
// Best-effort: if it can't be read, the report simply omits the context.
|
|
308
|
-
let transcriptText;
|
|
309
|
-
try {
|
|
310
|
-
if (sessionFilePath)
|
|
311
|
-
transcriptText = readFileSync(sessionFilePath, "utf-8");
|
|
312
|
-
}
|
|
313
|
-
catch {
|
|
314
|
-
/* unreadable: no A4 context, never a crash */
|
|
315
|
-
}
|
|
316
321
|
const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta, transcriptText);
|
|
317
322
|
if (json) {
|
|
318
323
|
console.log(generateJsonReport(results, meta, pkg.version, editedRuleFiles));
|
package/dist/historyReport.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface HistorySummary {
|
|
|
37
37
|
followedRules: number;
|
|
38
38
|
/** Rules that need a human's judgment (never mechanically decided). */
|
|
39
39
|
judgmentRules: number;
|
|
40
|
+
/** Rules whose only would-be breaks happened while the rule wasn't in context. */
|
|
41
|
+
notVisibleRules: number;
|
|
40
42
|
elapsedMs: number;
|
|
41
43
|
}
|
|
42
44
|
/**
|
package/dist/historyReport.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
1
|
+
import { statSync, readFileSync } from "node:fs";
|
|
2
2
|
import { listAllSessions } from "./adapters/index.js";
|
|
3
3
|
import { evaluateSession } from "./evaluate.js";
|
|
4
|
+
import { applyVisibility } from "./visibility.js";
|
|
4
5
|
/**
|
|
5
6
|
* One-line clip that ends on a whole word with an ellipsis, never mid-sentence.
|
|
6
7
|
* Found by a real test 2026-09-29: a break quote was cut as "...so no prompt was".
|
|
@@ -64,15 +65,25 @@ export async function scanHistory(cwd, rules, days = 30, now = Date.now(), sessi
|
|
|
64
65
|
// today" for it is wrong (found by a real test, 2026-09-29). Falls back to
|
|
65
66
|
// the mtime only when the transcript carries no usable timestamp.
|
|
66
67
|
const sessionMs = lastEventMs(events) ?? ms;
|
|
67
|
-
const { results } = await evaluateSession(cwd, rules, events, false, needsLlmResult);
|
|
68
|
+
const { results: rawResults } = await evaluateSession(cwd, rules, events, false, needsLlmResult);
|
|
69
|
+
// "Rule not visible" (#4): a FAIL whose rule wasn't in context is not a break
|
|
70
|
+
// here either, so the headline count stays honest across sessions.
|
|
71
|
+
let rawText;
|
|
72
|
+
try {
|
|
73
|
+
rawText = readFileSync(file, "utf-8");
|
|
74
|
+
}
|
|
75
|
+
catch { /* keep undefined */ }
|
|
76
|
+
const results = applyVisibility(rawResults, rawText);
|
|
68
77
|
for (const r of results) {
|
|
69
78
|
const k = key(r);
|
|
70
79
|
let a = rules_.get(k);
|
|
71
80
|
if (!a) {
|
|
72
|
-
a = { title: r.ruleTitle, source: r.ruleSource, id: r.ruleId, breaks: [], passed: false, judgment: false };
|
|
81
|
+
a = { title: r.ruleTitle, source: r.ruleSource, id: r.ruleId, breaks: [], passed: false, judgment: false, notVisible: false };
|
|
73
82
|
rules_.set(k, a);
|
|
74
83
|
}
|
|
75
|
-
if (r.status === "FAIL")
|
|
84
|
+
if (r.status === "FAIL" && r.notVisible)
|
|
85
|
+
a.notVisible = true;
|
|
86
|
+
else if (r.status === "FAIL")
|
|
76
87
|
a.breaks.push({ ms: sessionMs, quote: r.evidence });
|
|
77
88
|
else if (r.status === "PASS")
|
|
78
89
|
a.passed = true;
|
|
@@ -83,11 +94,16 @@ export async function scanHistory(cwd, rules, days = 30, now = Date.now(), sessi
|
|
|
83
94
|
const breaks = [];
|
|
84
95
|
let followedRules = 0;
|
|
85
96
|
let judgmentRules = 0;
|
|
97
|
+
let notVisibleRules = 0;
|
|
86
98
|
for (const a of rules_.values()) {
|
|
87
99
|
if (a.breaks.length > 0) {
|
|
88
100
|
const last = a.breaks.reduce((m, b) => (b.ms > m.ms ? b : m), a.breaks[0]);
|
|
89
101
|
breaks.push({ ruleId: a.id, ruleTitle: a.title, ruleSource: a.source, count: a.breaks.length, lastMs: last.ms, quote: a.breaks[0].quote });
|
|
90
102
|
}
|
|
103
|
+
else if (a.notVisible) {
|
|
104
|
+
// Would-be break(s), but the rule was never in context — not a violation.
|
|
105
|
+
notVisibleRules++;
|
|
106
|
+
}
|
|
91
107
|
else if (a.passed) {
|
|
92
108
|
followedRules++;
|
|
93
109
|
}
|
|
@@ -104,6 +120,7 @@ export async function scanHistory(cwd, rules, days = 30, now = Date.now(), sessi
|
|
|
104
120
|
totalBrokenCount: breaks.reduce((n, b) => n + b.count, 0),
|
|
105
121
|
followedRules,
|
|
106
122
|
judgmentRules,
|
|
123
|
+
notVisibleRules,
|
|
107
124
|
elapsedMs: Date.now() - started,
|
|
108
125
|
};
|
|
109
126
|
}
|
|
@@ -146,6 +163,9 @@ export function renderHistory(s, projectName, now = Date.now()) {
|
|
|
146
163
|
}
|
|
147
164
|
out.push("");
|
|
148
165
|
out.push(` ${s.followedRules} rule${s.followedRules === 1 ? "" : "s"} followed every time · ${s.judgmentRules} need${s.judgmentRules === 1 ? "s" : ""} your judgment`);
|
|
166
|
+
if (s.notVisibleRules > 0) {
|
|
167
|
+
out.push(` ${s.notVisibleRules} rule${s.notVisibleRules === 1 ? " was" : "s were"} not in context when the agent acted — NOT counted as broken. Start sessions from the project root, or add a SessionStart hook that injects your rules.`);
|
|
168
|
+
}
|
|
149
169
|
out.push("");
|
|
150
170
|
out.push(`checked ${s.sessionsScanned} session${s.sessionsScanned === 1 ? "" : "s"} in ${secs}s`);
|
|
151
171
|
out.push("");
|
package/dist/projectConfig.js
CHANGED
|
@@ -85,9 +85,10 @@ export function handleMap(rules) {
|
|
|
85
85
|
}
|
|
86
86
|
/** FAILs at `error` mode — these fail the build. (`off` never reaches here.) */
|
|
87
87
|
export function blockingFailures(results, config, handleFor) {
|
|
88
|
-
|
|
88
|
+
// `notVisible` FAILs are "Rule not visible", not Broken — they never fail the build.
|
|
89
|
+
return results.filter((r) => r.status === "FAIL" && !r.notVisible && modeForResult(r, config, handleFor) === "error");
|
|
89
90
|
}
|
|
90
91
|
/** FAILs at `warn` mode — shown, but they do not fail the build. */
|
|
91
92
|
export function warningFailures(results, config, handleFor) {
|
|
92
|
-
return results.filter((r) => r.status === "FAIL" && modeForResult(r, config, handleFor) === "warn");
|
|
93
|
+
return results.filter((r) => r.status === "FAIL" && !r.notVisible && modeForResult(r, config, handleFor) === "warn");
|
|
93
94
|
}
|
|
@@ -78,6 +78,8 @@ function ruleLabel(r, results) {
|
|
|
78
78
|
function summaryLine(results) {
|
|
79
79
|
const n = (b) => results.filter((r) => bucketOf(r) === b).length;
|
|
80
80
|
const parts = [`${n("PASS")} followed`, `${n("FAIL")} not followed`];
|
|
81
|
+
if (n("RULE_NOT_VISIBLE") > 0)
|
|
82
|
+
parts.push(`${n("RULE_NOT_VISIBLE")} rule not visible`);
|
|
81
83
|
if (n("UNCLEAR_EVIDENCE") > 0)
|
|
82
84
|
parts.push(`${n("UNCLEAR_EVIDENCE")} couldn't tell`);
|
|
83
85
|
if (n("NOT_RUN") > 0)
|
|
@@ -104,6 +106,10 @@ function summaryLine(results) {
|
|
|
104
106
|
* fallback while the rest are migrated.
|
|
105
107
|
*/
|
|
106
108
|
function bucketOf(result) {
|
|
109
|
+
// A would-be Broken whose rule wasn't in context is "Rule not visible", never
|
|
110
|
+
// Broken — decided before anything else so it can't be counted as a violation.
|
|
111
|
+
if (result.notVisible)
|
|
112
|
+
return "RULE_NOT_VISIBLE";
|
|
107
113
|
switch (result.outcome) {
|
|
108
114
|
case "fail":
|
|
109
115
|
return "FAIL";
|
|
@@ -133,6 +139,7 @@ function bucketOf(result) {
|
|
|
133
139
|
*/
|
|
134
140
|
const BUCKET_LABEL = {
|
|
135
141
|
FAIL: "Not followed",
|
|
142
|
+
RULE_NOT_VISIBLE: "Rule not visible (not a violation)",
|
|
136
143
|
UNCLEAR_EVIDENCE: "Couldn't tell",
|
|
137
144
|
NOT_RUN: "Not run",
|
|
138
145
|
PASS: "Followed",
|
|
@@ -147,6 +154,7 @@ const BUCKET_LABEL = {
|
|
|
147
154
|
*/
|
|
148
155
|
const BUCKET_ORDER = [
|
|
149
156
|
"FAIL",
|
|
157
|
+
"RULE_NOT_VISIBLE",
|
|
150
158
|
"UNCLEAR_EVIDENCE",
|
|
151
159
|
"NOT_RUN",
|
|
152
160
|
"PASS",
|
|
@@ -242,10 +250,14 @@ export function generateReport(results, meta, transcriptText) {
|
|
|
242
250
|
// versions as a PASS on a session that ran `git push -f`.
|
|
243
251
|
if (r.ceiling)
|
|
244
252
|
lines.push(` this means: ${r.ceiling}`);
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
// "Rule not visible": not a violation. Say plainly why, and the fix.
|
|
254
|
+
if (r.notVisible) {
|
|
255
|
+
lines.push(` not a violation: the agent never had this rule in context at that moment.`);
|
|
256
|
+
lines.push(` fix: ${r.notVisible.fix}`);
|
|
257
|
+
}
|
|
258
|
+
// A4: why it broke — the context around a PROVEN break (one that WAS
|
|
259
|
+
// visible). Skipped for a not-visible result, which shows its own fix above.
|
|
260
|
+
if (r.status === "FAIL" && !r.notVisible && transcriptText && r.evidence) {
|
|
249
261
|
for (const l of renderBreakContext(breakContext(transcriptText, r.evidence)))
|
|
250
262
|
lines.push(l);
|
|
251
263
|
}
|
|
@@ -331,13 +343,16 @@ export function generateJsonReport(results, meta, toolVersion, editedRuleFiles =
|
|
|
331
343
|
summary: {
|
|
332
344
|
total: clean.length,
|
|
333
345
|
pass: count("PASS"),
|
|
334
|
-
|
|
346
|
+
// A "rule not visible" FAIL is never counted as broken — same everywhere.
|
|
347
|
+
fail: clean.filter((r) => r.status === "FAIL" && !r.notVisible).length,
|
|
335
348
|
unclear: count("UNCLEAR"),
|
|
349
|
+
ruleNotVisible: clean.filter((r) => Boolean(r.notVisible)).length,
|
|
336
350
|
},
|
|
337
351
|
results: clean.map((r) => ({
|
|
338
352
|
ruleId: r.ruleId,
|
|
339
353
|
ruleTitle: r.ruleTitle,
|
|
340
354
|
ruleSource: r.ruleSource,
|
|
355
|
+
...(r.notVisible ? { notVisible: r.notVisible } : {}),
|
|
341
356
|
// Absolute path + 1-based line of the rule's heading, when unambiguous
|
|
342
357
|
// (see attachSourceLocation). A consumer can jump straight to the rule.
|
|
343
358
|
sourcePath: r.sourcePath ?? null,
|
package/dist/teamExport.d.ts
CHANGED
|
@@ -35,12 +35,14 @@ export interface TeamExport {
|
|
|
35
35
|
fail: number;
|
|
36
36
|
unclear: number;
|
|
37
37
|
};
|
|
38
|
-
/** One entry per rule: the verdict and the quoted evidence
|
|
38
|
+
/** One entry per rule: the verdict and the quoted evidence. `notVisible` marks
|
|
39
|
+
* a would-be break the rule wasn't in context for — never counted as broken. */
|
|
39
40
|
rules: {
|
|
40
41
|
title: string;
|
|
41
42
|
source: string;
|
|
42
43
|
status: CheckResult["status"];
|
|
43
44
|
evidence: string;
|
|
45
|
+
notVisible?: boolean;
|
|
44
46
|
}[];
|
|
45
47
|
}
|
|
46
48
|
export declare function buildTeamExport(results: CheckResult[], project: string, dev: string, version: string, now?: Date): TeamExport;
|
package/dist/teamExport.js
CHANGED
|
@@ -20,6 +20,8 @@ import { redact } from "./wrong.js";
|
|
|
20
20
|
export const EXPORT_SCHEMA = 1;
|
|
21
21
|
export function buildTeamExport(results, project, dev, version, now = new Date()) {
|
|
22
22
|
const count = (s) => results.filter((r) => r.status === s).length;
|
|
23
|
+
// A "rule not visible" FAIL is never counted as broken — same rule as the report.
|
|
24
|
+
const fail = results.filter((r) => r.status === "FAIL" && !r.notVisible).length;
|
|
23
25
|
return {
|
|
24
26
|
tool: "rulereceipt",
|
|
25
27
|
kind: "export",
|
|
@@ -28,10 +30,10 @@ export function buildTeamExport(results, project, dev, version, now = new Date()
|
|
|
28
30
|
dev: dev.trim() || "unknown",
|
|
29
31
|
project,
|
|
30
32
|
date: now.toISOString().slice(0, 10),
|
|
31
|
-
summary: { total: results.length, pass: count("PASS"), fail
|
|
33
|
+
summary: { total: results.length, pass: count("PASS"), fail, unclear: count("UNCLEAR") },
|
|
32
34
|
// Evidence is masked before it leaves: an export is shared, so obvious
|
|
33
35
|
// secrets, the home path and emails are redacted (same patterns as `wrong`).
|
|
34
|
-
rules: results.map((r) => ({ title: redact(r.ruleTitle), source: r.ruleSource, status: r.status, evidence: redact(r.evidence) })),
|
|
36
|
+
rules: results.map((r) => ({ title: redact(r.ruleTitle), source: r.ruleSource, status: r.status, evidence: redact(r.evidence), ...(r.notVisible ? { notVisible: true } : {}) })),
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
39
|
/** A tolerant parse of one export file's text; null if it is not a valid export. */
|
|
@@ -59,8 +61,8 @@ export function mergeTeamExports(exports) {
|
|
|
59
61
|
let totalBroken = 0;
|
|
60
62
|
for (const e of exports) {
|
|
61
63
|
for (const r of e.rules) {
|
|
62
|
-
if (r.status !== "FAIL")
|
|
63
|
-
continue;
|
|
64
|
+
if (r.status !== "FAIL" || r.notVisible)
|
|
65
|
+
continue; // a not-visible break is not broken
|
|
64
66
|
totalBroken++;
|
|
65
67
|
const cur = byRule.get(r.title) ?? { count: 0, devs: new Set() };
|
|
66
68
|
cur.count++;
|
package/dist/types.d.ts
CHANGED
|
@@ -129,6 +129,19 @@ export interface CheckResult {
|
|
|
129
129
|
* when a recorded run CONTRADICTED a claim.
|
|
130
130
|
*/
|
|
131
131
|
unverifiedClaim?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Set when a would-be Broken verdict is downgraded to "Rule not visible":
|
|
134
|
+
* the session's working directory and history show the rule was never in the
|
|
135
|
+
* agent's context at the moment of the break (not loaded from this cwd, or
|
|
136
|
+
* dropped by a compaction and not re-injected). This is NOT a violation — the
|
|
137
|
+
* agent can't follow a rule it never saw — so it is never counted as Broken,
|
|
138
|
+
* never fails the build, and carries the fix. See visibility.ts. `reason`
|
|
139
|
+
* distinguishes "never in context" from "lost after a compaction".
|
|
140
|
+
*/
|
|
141
|
+
notVisible?: {
|
|
142
|
+
reason: "not-in-context" | "stale-after-compaction";
|
|
143
|
+
fix: string;
|
|
144
|
+
};
|
|
132
145
|
/**
|
|
133
146
|
* The outcome in the five-value vocabulary. Optional while the checkers
|
|
134
147
|
* are migrated one at a time; `status` remains the fallback.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CheckResult } from "./types.js";
|
|
2
|
+
/** How a would-be break relates to the rule's visibility, from the raw transcript. */
|
|
3
|
+
export declare function classifyVisibility(transcriptText: string, evidence: string): {
|
|
4
|
+
reason: "not-in-context" | "stale-after-compaction";
|
|
5
|
+
fix: string;
|
|
6
|
+
} | null;
|
|
7
|
+
/**
|
|
8
|
+
* Attach `notVisible` to any FAIL whose rule wasn't in context at the break.
|
|
9
|
+
* Returns a new array (inputs untouched). A no-op without transcript text, and
|
|
10
|
+
* for non-FAIL results. Called everywhere a verdict is counted so Broken means
|
|
11
|
+
* the same thing in the report, the exit code, history and the team export.
|
|
12
|
+
*/
|
|
13
|
+
export declare function applyVisibility(results: CheckResult[], transcriptText: string | undefined): CheckResult[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { breakContext } from "./breakContext.js";
|
|
2
|
+
/**
|
|
3
|
+
* "Rule not visible" (dogfood/#4). Before a Broken verdict stands, ask whether
|
|
4
|
+
* the rule was even IN THE AGENT'S CONTEXT at the moment of the break. A rule
|
|
5
|
+
* the agent never saw cannot have been "broken" by it — Claude Code loads
|
|
6
|
+
* CLAUDE.md only when the session starts from (or a Read touches) its directory,
|
|
7
|
+
* and a compaction can drop it. Counting those as Broken is a false accusation.
|
|
8
|
+
*
|
|
9
|
+
* So a FAIL is downgraded to `notVisible` when, read from the raw transcript:
|
|
10
|
+
* - the rules file never entered context before the break -> "not-in-context"
|
|
11
|
+
* - it was present but not re-injected after the last compaction -> "stale-after-compaction"
|
|
12
|
+
* and left as Broken when it WAS visible. When the break can't be located in the
|
|
13
|
+
* transcript we do NOT guess — it stays Broken (the forbidden action is real and
|
|
14
|
+
* quoted); the caller may add a "can't tell if the rule was visible" caveat.
|
|
15
|
+
*
|
|
16
|
+
* This runs wherever a verdict is finalized (check, history, export) so every
|
|
17
|
+
* surface agrees. It changes nothing without the raw transcript text.
|
|
18
|
+
*/
|
|
19
|
+
const FIX_NOT_IN_CONTEXT = "The rules file was never in context here. Start the session from the project root so CLAUDE.md loads at the start, or add a SessionStart hook that injects your rules every session.";
|
|
20
|
+
const FIX_STALE = "The rules file was in context earlier but not after the last compaction. Add a post-compaction hook (SessionStart:compact) that re-injects your rules.";
|
|
21
|
+
/** How a would-be break relates to the rule's visibility, from the raw transcript. */
|
|
22
|
+
export function classifyVisibility(transcriptText, evidence) {
|
|
23
|
+
const ctx = breakContext(transcriptText, evidence);
|
|
24
|
+
if (!ctx.located)
|
|
25
|
+
return null; // can't find the break -> don't guess; stays Broken
|
|
26
|
+
// Only downgrade when we can POSITIVELY tell the rule wasn't there: the
|
|
27
|
+
// transcript shows context-injection machinery (system-reminders, attachments,
|
|
28
|
+
// a compaction) yet no rules file before the break. A thin log with no such
|
|
29
|
+
// machinery is can't-tell, NOT "not visible" — stays Broken.
|
|
30
|
+
if (!ctx.rulesInContext)
|
|
31
|
+
return ctx.contextObserved ? { reason: "not-in-context", fix: FIX_NOT_IN_CONTEXT } : null;
|
|
32
|
+
if (ctx.rulesStaleAfterCompaction)
|
|
33
|
+
return { reason: "stale-after-compaction", fix: FIX_STALE };
|
|
34
|
+
return null; // visible before the break -> a real Broken
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Attach `notVisible` to any FAIL whose rule wasn't in context at the break.
|
|
38
|
+
* Returns a new array (inputs untouched). A no-op without transcript text, and
|
|
39
|
+
* for non-FAIL results. Called everywhere a verdict is counted so Broken means
|
|
40
|
+
* the same thing in the report, the exit code, history and the team export.
|
|
41
|
+
*/
|
|
42
|
+
export function applyVisibility(results, transcriptText) {
|
|
43
|
+
if (!transcriptText)
|
|
44
|
+
return results;
|
|
45
|
+
return results.map((r) => {
|
|
46
|
+
if (r.status !== "FAIL" || r.notVisible || !r.evidence)
|
|
47
|
+
return r;
|
|
48
|
+
const v = classifyVisibility(transcriptText, r.evidence);
|
|
49
|
+
return v ? { ...r, notVisible: v } : r;
|
|
50
|
+
});
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.85",
|
|
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",
|