rulereceipt 0.1.84 → 0.1.86

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 CHANGED
@@ -538,6 +538,15 @@ rm -rf .rulereceipt/ # the local reports/receipts folder, if you want
538
538
  `protect --undo` is only needed if you ran `protect`. Nothing else is
539
539
  installed anywhere on your system.
540
540
 
541
+ ## Team plan
542
+
543
+ The CLI is free and runs locally, forever. A separate **Team plan** — an
544
+ org-wide, hosted view with trends over time, history, cross-repo dashboards and
545
+ compliance-ready exports — is a genuinely different product from the local
546
+ check. It's in **early access** while we finish setting it up; email
547
+ hello@rulereceipt.dev if you want it early. The local check (and the free
548
+ `rulereceipt team <folder>` snapshot of exports you already have) stays free.
549
+
541
550
  ## Contact
542
551
 
543
552
  Questions, bugs, or anything else — hello@rulereceipt.dev.
@@ -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,8 @@ 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";
35
+ import { teamPlanNote, activateNote } from "./teamPlan.js";
34
36
  import { gateOffer, hookIsInstalled } from "./report/gateOffer.js";
35
37
  import { generateHtmlReport } from "./report/generateHtmlReport.js";
36
38
  import { verifySessionHash } from "./verifyHash.js";
@@ -268,7 +270,21 @@ async function runCheck(opts) {
268
270
  // back to its stable handle so the mark survives edits that renumber ids.
269
271
  const projectConfig = loadProjectConfig(cwd);
270
272
  const handleFor = handleMap(rules);
271
- const results = visibleResults(rawResults, projectConfig, handleFor);
273
+ // The raw session text — for A4 "why it broke" context AND for the
274
+ // visibility pass (#4). Best-effort: if it can't be read, visibility is left
275
+ // undetermined (a would-be break stays Broken) and no A4 context is shown.
276
+ let transcriptText;
277
+ try {
278
+ if (sessionFilePath)
279
+ transcriptText = readFileSync(sessionFilePath, "utf-8");
280
+ }
281
+ catch {
282
+ /* unreadable: never a crash */
283
+ }
284
+ // "Rule not visible" (#4): downgrade a FAIL whose rule was never in the
285
+ // agent's context at the break. Applied HERE, before anything counts a break,
286
+ // so the report, the exit code and the export all agree.
287
+ const results = applyVisibility(visibleResults(rawResults, projectConfig, handleFor), transcriptText);
272
288
  const blockingFails = blockingFailures(results, projectConfig, handleFor);
273
289
  const warnedFails = warningFailures(results, projectConfig, handleFor);
274
290
  const meta = { sessionFilePath, ruleCount: results.length };
@@ -303,16 +319,6 @@ async function runCheck(opts) {
303
319
  : "";
304
320
  // Kept in human/markdown form for --email and any other reader below, even
305
321
  // 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
322
  const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta, transcriptText);
317
323
  if (json) {
318
324
  console.log(generateJsonReport(results, meta, pkg.version, editedRuleFiles));
@@ -1406,6 +1412,13 @@ program
1406
1412
  const outPath = opts.out ? resolve(process.cwd(), opts.out) : join(dir, "team-report.html");
1407
1413
  writeFileSync(outPath, renderTeamHtml(merged));
1408
1414
  console.log(`team preview: merged ${merged.exportsRead} export(s) from ${merged.devs.length} dev(s), ${merged.totalBroken} break(s) — wrote ${outPath}`);
1415
+ console.log(`\n${teamPlanNote()}`);
1416
+ });
1417
+ program
1418
+ .command("activate <key>")
1419
+ .description("activate a Team plan seat. (Early access while the paid tier is being set up.)")
1420
+ .action((key) => {
1421
+ console.log(activateNote(key));
1409
1422
  });
1410
1423
  // Bare `rulereceipt` (no subcommand, no flags) runs history mode — the first-run
1411
1424
  // "wait, what?" screen across the last 30 days of sessions. Anything with a
@@ -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++;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Team plan — the ONE place the checkout link and its feature flag live, read by
3
+ * the CLI (the landing pages mirror these exact values; keep them in sync).
4
+ *
5
+ * OPEN-CORE: this file holds only a LINK, a flag, and copy — never any paid-tier
6
+ * LOGIC. Real licence/key validation and the hosted team service live in the
7
+ * separate PRIVATE repo. `activate` here does not validate or unlock anything;
8
+ * while the flag is off it only points at early access.
9
+ *
10
+ * TEAM_CHECKOUT_LIVE stays `false` until Polar setup is done and a test purchase
11
+ * works. While false, every surface shows "early access, contact …" and no
12
+ * checkout/activate link is shown. Flip the flag AND fill `checkoutUrl`/
13
+ * `portalUrl` together (and mirror them on the landing pages), then rebuild.
14
+ */
15
+ export declare const TEAM_PLAN: {
16
+ readonly live: false;
17
+ /** Polar checkout URL — filled in when going live. Empty while not live. */
18
+ readonly checkoutUrl: "";
19
+ /** Polar customer portal (manage subscription) — shown on /thanks when live. */
20
+ readonly portalUrl: "";
21
+ readonly contact: "hello@rulereceipt.dev";
22
+ readonly trialDays: 14;
23
+ };
24
+ /** The one-line Team-plan note for CLI output, flag-aware. */
25
+ export declare function teamPlanNote(): string;
26
+ /** What `rulereceipt activate <key>` prints. No validation here — that is the private tier. */
27
+ export declare function activateNote(key: string): string;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Team plan — the ONE place the checkout link and its feature flag live, read by
3
+ * the CLI (the landing pages mirror these exact values; keep them in sync).
4
+ *
5
+ * OPEN-CORE: this file holds only a LINK, a flag, and copy — never any paid-tier
6
+ * LOGIC. Real licence/key validation and the hosted team service live in the
7
+ * separate PRIVATE repo. `activate` here does not validate or unlock anything;
8
+ * while the flag is off it only points at early access.
9
+ *
10
+ * TEAM_CHECKOUT_LIVE stays `false` until Polar setup is done and a test purchase
11
+ * works. While false, every surface shows "early access, contact …" and no
12
+ * checkout/activate link is shown. Flip the flag AND fill `checkoutUrl`/
13
+ * `portalUrl` together (and mirror them on the landing pages), then rebuild.
14
+ */
15
+ export const TEAM_PLAN = {
16
+ live: false,
17
+ /** Polar checkout URL — filled in when going live. Empty while not live. */
18
+ checkoutUrl: "",
19
+ /** Polar customer portal (manage subscription) — shown on /thanks when live. */
20
+ portalUrl: "",
21
+ contact: "hello@rulereceipt.dev",
22
+ trialDays: 14,
23
+ };
24
+ /** The one-line Team-plan note for CLI output, flag-aware. */
25
+ export function teamPlanNote() {
26
+ if (TEAM_PLAN.live && TEAM_PLAN.checkoutUrl) {
27
+ return (`Team plan (trends over time, history, cross-repo dashboards): ` +
28
+ `start a ${TEAM_PLAN.trialDays}-day free trial — ${TEAM_PLAN.checkoutUrl}\n` +
29
+ `Already bought a seat? Run \`rulereceipt activate <key>\`.`);
30
+ }
31
+ return `Team plan (trends over time, history, cross-repo dashboards): early access — contact ${TEAM_PLAN.contact}.`;
32
+ }
33
+ /** What `rulereceipt activate <key>` prints. No validation here — that is the private tier. */
34
+ export function activateNote(key) {
35
+ const masked = key.length > 6 ? `${key.slice(0, 3)}…${key.slice(-2)}` : "(key)";
36
+ if (!TEAM_PLAN.live) {
37
+ return `Team plan is in early access — nothing to activate yet. Contact ${TEAM_PLAN.contact} and we'll set you up.`;
38
+ }
39
+ return (`Thanks for subscribing. Key ${masked} noted.\n` +
40
+ `Activation and the hosted team features are handled by the Team service (see rulereceipt.dev/thanks).\n` +
41
+ `Manage your subscription: ${TEAM_PLAN.portalUrl || TEAM_PLAN.contact}`);
42
+ }
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.84",
3
+ "version": "0.1.86",
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",