rulereceipt 0.1.83 → 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/NOTICE.md CHANGED
@@ -32,3 +32,4 @@ we did not copy the code):
32
32
  from **Pilan-AI/mnemo** (MIT). Our reader is `src/adapters/copilot.ts`.
33
33
  - **Gemini CLI** session format (`~/.gemini/tmp/<hash>/chats/*.{jsonl,json}`, legacy `~/.gemini/sessions/*.json`): documented by **yigitkonur/cli-continues** (MIT, pinned `e486cd2`; from mnemo MIT). Our reader is `src/adapters/gemini.ts`.
34
34
  - **Cursor** agent-transcripts (`~/.cursor/projects/<slug>/agent-transcripts/**/*.jsonl`, Anthropic-API shaped): documented by **yigitkonur/cli-continues** (MIT, pinned `e486cd2`). Our reader `src/adapters/cursor.ts`. (Old `state.vscdb` SQLite path deferred.)
35
+ - **OpenCode** JSON file store (`$XDG_DATA_HOME/opencode/storage/{session,message,part}/*.json`): documented by **yigitkonur/cli-continues** (MIT, pinned `e486cd2`). Our reader `src/adapters/opencode.ts`. (opencode.db SQLite path deferred.)
@@ -46,6 +46,8 @@ export declare const ADAPTERS: SessionAdapter[];
46
46
  export declare const geminiCliAdapter: SessionAdapter;
47
47
  /** Cursor — EXPERIMENTAL. Current agent-transcripts (JSONL, Anthropic-shaped); old SQLite deferred. */
48
48
  export declare const cursorAdapter: SessionAdapter;
49
+ /** OpenCode — EXPERIMENTAL. JSON file store (3-dir join); opencode.db SQLite deferred. */
50
+ export declare const openCodeAdapter: SessionAdapter;
49
51
  /** Experimental adapters: reader exists, awaiting real+planted+clean fixtures. */
50
52
  export declare const EXPERIMENTAL_ADAPTERS: SessionAdapter[];
51
53
  /**
@@ -5,6 +5,7 @@ import { listCodexSessions, parseCodexTranscript } from "./codex.js";
5
5
  import { listCopilotSessions, parseCopilotTranscript, copilotFormatIsKnown } from "./copilot.js";
6
6
  import { listGeminiSessions, parseGeminiTranscript, geminiFormatIsKnown } from "./gemini.js";
7
7
  import { listCursorSessions, parseCursorTranscript, cursorFormatIsKnown } from "./cursor.js";
8
+ import { listOpenCodeSessions, parseOpenCodeTranscript, openCodeFormatIsKnown } from "./opencode.js";
8
9
  /**
9
10
  * Claude Code — the original and reference adapter. Delegates to the existing
10
11
  * transcriptParser so its behaviour (including subagent transcripts) is
@@ -54,8 +55,15 @@ export const cursorAdapter = {
54
55
  parse: (sessionFile) => parseCursorTranscript(sessionFile),
55
56
  experimental: true,
56
57
  };
58
+ /** OpenCode — EXPERIMENTAL. JSON file store (3-dir join); opencode.db SQLite deferred. */
59
+ export const openCodeAdapter = {
60
+ tool: "opencode",
61
+ listSessions: (cwd) => listOpenCodeSessions(cwd),
62
+ parse: (sessionFile) => parseOpenCodeTranscript(sessionFile),
63
+ experimental: true,
64
+ };
57
65
  /** Experimental adapters: reader exists, awaiting real+planted+clean fixtures. */
58
- export const EXPERIMENTAL_ADAPTERS = [copilotCliAdapter, geminiCliAdapter, cursorAdapter];
66
+ export const EXPERIMENTAL_ADAPTERS = [copilotCliAdapter, geminiCliAdapter, cursorAdapter, openCodeAdapter];
59
67
  /**
60
68
  * Tools deliberately NOT read yet, with the honest reason. Kept as data (not
61
69
  * silence) so the tool — and its docs — can state exactly where the line is
@@ -63,7 +71,6 @@ export const EXPERIMENTAL_ADAPTERS = [copilotCliAdapter, geminiCliAdapter, curso
63
71
  */
64
72
  export const UNSUPPORTED_TOOLS = [
65
73
  { tool: "aider", reason: "history is a Markdown transcript (.aider.chat.history.md), not structured events — needs a prose parser, not a field mapping" },
66
- { tool: "opencode", reason: "stores sessions in a SQLite DB (opencode.db) — needs a WASM sqlite reader; planned next" },
67
74
  { tool: "windsurf", reason: "IDE-embedded; history in undocumented internal state, same as old Cursor" },
68
75
  ];
69
76
  /**
@@ -160,6 +167,10 @@ export function parseSessionFile(file) {
160
167
  // `message` wrapper and no `type`. Gated the same way.
161
168
  if (cursorFormatIsKnown(file))
162
169
  return parseCursorTranscript(file);
170
+ // OpenCode: a single session JSON (`id` starting `ses_`); the message/part
171
+ // dirs are resolved relative to this file.
172
+ if (openCodeFormatIsKnown(file))
173
+ return parseOpenCodeTranscript(file);
163
174
  return readTranscriptFromFile(file);
164
175
  }
165
176
  /**
@@ -0,0 +1,7 @@
1
+ import type { TranscriptEvent } from "../types.js";
2
+ /** OpenCode session files for this cwd, newest first. */
3
+ export declare function listOpenCodeSessions(cwd: string): string[];
4
+ /** True when the file is an OpenCode session JSON (id starting `ses_`). */
5
+ export declare function openCodeFormatIsKnown(sessionFile: string): boolean;
6
+ /** Parse one OpenCode session (by its ses_*.json) into neutral events. Tolerant. */
7
+ export declare function parseOpenCodeTranscript(sessionFile: string): TranscriptEvent[];
@@ -0,0 +1,179 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, dirname } from "node:path";
4
+ /**
5
+ * OpenCode adapter — EXPERIMENTAL (no real-session fixtures yet).
6
+ *
7
+ * Reads the JSON file store (NOT the newer `opencode.db` SQLite, which is
8
+ * deferred — see REUSE.md). Format documented by yigitkonur/cli-continues (MIT,
9
+ * src/parsers/opencode.ts, pinned e486cd22a592d89d890cff056624647fbe9cbe80).
10
+ * Credit: NOTICE.md / REUSE.md.
11
+ *
12
+ * Store (under `$XDG_DATA_HOME/opencode/storage` or `~/.local/share/opencode/
13
+ * storage`): a 3-directory join
14
+ * session/<proj>/ses_*.json → { id, projectID, directory, time:{updated} }
15
+ * message/<sessionId>/msg_*.json → { id, role }
16
+ * part/<messageId>/prt_*.json → { type:'text'|'tool'|…, text?, tool?, callID?,
17
+ * state:{ input, status, output, error } }
18
+ * cwd = session.directory, else project/<projectID>.json `.worktree`.
19
+ * text part -> text (role of its message)
20
+ * tool part -> tool_use (shell -> canonical Bash) + a tool_result from its state.
21
+ *
22
+ * The message/part dirs are resolved RELATIVE to the session file, so
23
+ * `--transcript <…/session/<proj>/ses_X.json>` works wherever the store lives.
24
+ *
25
+ * RESILIENCE: only the fields above; unknown part types / bad files skipped,
26
+ * never guessed; `openCodeFormatIsKnown` gates use.
27
+ */
28
+ function realpathOr(p) { try {
29
+ return realpathSync(p);
30
+ }
31
+ catch {
32
+ return p;
33
+ } }
34
+ function str(v) { return typeof v === "string" && v.length > 0 ? v : undefined; }
35
+ function rec(v) { return v && typeof v === "object" && !Array.isArray(v) ? v : {}; }
36
+ function storageDir() {
37
+ const xdg = process.env.XDG_DATA_HOME?.trim();
38
+ const base = xdg && xdg.length > 0 ? join(xdg, "opencode") : join(homedir(), ".local", "share", "opencode");
39
+ return join(base, "storage");
40
+ }
41
+ function readJson(file) {
42
+ try {
43
+ const o = JSON.parse(readFileSync(file, "utf-8"));
44
+ return rec(o);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** storage root for a given session file (…/storage/session/<proj>/ses_X.json). */
51
+ function storageRootOf(sessionFile) {
52
+ return join(dirname(sessionFile), "..", "..");
53
+ }
54
+ /** cwd of a session JSON: its `directory`, else project/<projectID>.json .worktree. */
55
+ function sessionCwd(sessionFile, s) {
56
+ const dir = str(s.directory);
57
+ if (dir)
58
+ return dir;
59
+ const pid = str(s.projectID);
60
+ if (pid) {
61
+ const proj = readJson(join(storageRootOf(sessionFile), "project", `${pid}.json`));
62
+ if (proj)
63
+ return str(proj.worktree) ?? null;
64
+ }
65
+ return null;
66
+ }
67
+ function listSessionJsonFiles(storage) {
68
+ const sessionBase = join(storage, "session");
69
+ const out = [];
70
+ let projs = [];
71
+ try {
72
+ projs = readdirSync(sessionBase);
73
+ }
74
+ catch {
75
+ return out;
76
+ }
77
+ for (const p of projs) {
78
+ const d = join(sessionBase, p);
79
+ let names = [];
80
+ try {
81
+ names = readdirSync(d);
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ for (const n of names)
87
+ if (n.startsWith("ses_") && n.endsWith(".json"))
88
+ out.push(join(d, n));
89
+ }
90
+ return out;
91
+ }
92
+ /** OpenCode session files for this cwd, newest first. */
93
+ export function listOpenCodeSessions(cwd) {
94
+ const storage = storageDir();
95
+ if (!existsSync(storage))
96
+ return [];
97
+ const target = realpathOr(cwd);
98
+ const hits = [];
99
+ for (const file of listSessionJsonFiles(storage)) {
100
+ const s = readJson(file);
101
+ if (!s)
102
+ continue;
103
+ const c = sessionCwd(file, s);
104
+ if (c && realpathOr(c) !== target)
105
+ continue;
106
+ try {
107
+ hits.push({ file, mtimeMs: statSync(file).mtimeMs });
108
+ }
109
+ catch { /* skip */ }
110
+ }
111
+ return hits.sort((a, b) => b.mtimeMs - a.mtimeMs).map((h) => h.file);
112
+ }
113
+ function sortedJson(dir) {
114
+ try {
115
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ }
121
+ function toToolUse(name, input, id, ts) {
122
+ const command = str(input.command) ?? str(input.cmd);
123
+ const filePath = str(input.file_path) ?? str(input.path) ?? str(input.filePath);
124
+ const lower = name.toLowerCase();
125
+ if (command && (/(shell|bash|terminal|exec|run|command)/.test(lower) || !filePath)) {
126
+ return { role: "assistant", kind: "tool_use", toolName: "Bash", toolUseId: id, input: { command }, timestamp: ts };
127
+ }
128
+ if (filePath && /(write|create|edit|replace|patch|insert|modif|apply)/.test(lower)) {
129
+ return { role: "assistant", kind: "tool_use", toolName: /(edit|replace|patch|apply)/.test(lower) ? "Edit" : "Write", toolUseId: id, input: { file_path: filePath, ...input }, timestamp: ts };
130
+ }
131
+ if (filePath && /(read|view|open|cat|show)/.test(lower)) {
132
+ return { role: "assistant", kind: "tool_use", toolName: "Read", toolUseId: id, input: { file_path: filePath }, timestamp: ts };
133
+ }
134
+ return { role: "assistant", kind: "tool_use", toolName: name, toolUseId: id, input, timestamp: ts };
135
+ }
136
+ /** True when the file is an OpenCode session JSON (id starting `ses_`). */
137
+ export function openCodeFormatIsKnown(sessionFile) {
138
+ const s = readJson(sessionFile);
139
+ return Boolean(s && str(s.id)?.startsWith("ses_"));
140
+ }
141
+ /** Parse one OpenCode session (by its ses_*.json) into neutral events. Tolerant. */
142
+ export function parseOpenCodeTranscript(sessionFile) {
143
+ const s = readJson(sessionFile);
144
+ const sessionId = s ? str(s.id) : undefined;
145
+ if (!s || !sessionId)
146
+ return [];
147
+ const root = storageRootOf(sessionFile);
148
+ const messageDir = join(root, "message", sessionId);
149
+ const out = [];
150
+ for (const msgFile of sortedJson(messageDir)) {
151
+ const m = readJson(join(messageDir, msgFile));
152
+ if (!m)
153
+ continue;
154
+ const role = m.role === "assistant" ? "assistant" : m.role === "user" ? "user" : undefined;
155
+ const msgId = str(m.id);
156
+ if (!role || !msgId)
157
+ continue;
158
+ const partDir = join(root, "part", msgId);
159
+ for (const prtFile of sortedJson(partDir)) {
160
+ const part = readJson(join(partDir, prtFile));
161
+ if (!part)
162
+ continue;
163
+ if (part.type === "text" && str(part.text)) {
164
+ out.push({ role, kind: "text", text: part.text, timestamp: "" });
165
+ }
166
+ else if (part.type === "tool" && role === "assistant") {
167
+ const name = str(part.tool);
168
+ if (!name)
169
+ continue;
170
+ const state = rec(part.state);
171
+ const id = str(part.callID);
172
+ out.push(toToolUse(name, rec(state.input), id, ""));
173
+ const content = str(state.output) ?? str(state.error) ?? "";
174
+ out.push({ role: "user", kind: "tool_result", toolUseId: id, content: content.slice(0, 2000), isError: state.status === "error", timestamp: "" });
175
+ }
176
+ }
177
+ }
178
+ return out;
179
+ }
@@ -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
@@ -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
- if (!ctx.rulesInContext) {
114
- out.push(" your rules file was NOT in context at this point — not the agent ignoring a");
115
- out.push(" rule it never saw. Claude Code can load CLAUDE.md only when a Read touches its");
116
- out.push(" directory, so a shell-heavy session can miss it. Fix: a SessionStart (and");
117
- out.push(" post-compaction) hook that injects your rules every session.");
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; context before it was summarised.)");
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
- const results = visibleResults(rawResults, projectConfig, handleFor);
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));
@@ -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
  /**
@@ -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("");
@@ -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
- return results.filter((r) => r.status === "FAIL" && modeForResult(r, config, handleFor) === "error");
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
- // A4: why it broke — the context around a proven break, read from the raw
246
- // transcript. Honest by construction: if the rules file was never in
247
- // context before the break, it says so rather than implying it was ignored.
248
- if (r.status === "FAIL" && transcriptText && r.evidence) {
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
- fail: count("FAIL"),
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,
@@ -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, nothing else. */
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;
@@ -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: count("FAIL"), unclear: count("UNCLEAR") },
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.83",
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",