rulereceipt 0.1.63 → 0.1.65
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 +11 -0
- package/dist/browser/analyze.d.ts +1 -0
- package/dist/browser/analyze.js +3 -0
- package/dist/browser/evaluateBrowser.d.ts +23 -0
- package/dist/browser/evaluateBrowser.js +78 -0
- package/dist/card.d.ts +36 -0
- package/dist/card.js +76 -0
- package/dist/cli.js +74 -7
- package/dist/parsers/transcriptLine.d.ts +8 -0
- package/dist/parsers/transcriptLine.js +121 -0
- package/dist/parsers/transcriptParser.d.ts +1 -1
- package/dist/parsers/transcriptParser.js +7 -112
- package/dist/sessionObserve.d.ts +20 -0
- package/dist/sessionObserve.js +129 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,17 @@ showing you exactly what it will add and asking. `protect --undo` restores the
|
|
|
49
49
|
file byte-for-byte. It's the only place RuleReceipt writes settings, and only
|
|
50
50
|
with your yes.
|
|
51
51
|
|
|
52
|
+
To share the result:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx rulereceipt card
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Writes a small SVG summary and prints ready-to-post links for X, LinkedIn,
|
|
59
|
+
Bluesky and Reddit, plus copy-text for Slack or a PR. It carries **counts
|
|
60
|
+
only** — no code, paths, or rule text (add rule names to the copy-text with
|
|
61
|
+
`--show-rules`). Nothing is posted for you and nothing is uploaded.
|
|
62
|
+
|
|
52
63
|
## Status
|
|
53
64
|
|
|
54
65
|
Published and live on npm, actively developed.
|
package/dist/browser/analyze.js
CHANGED
|
@@ -47,3 +47,6 @@ export function analyze(text) {
|
|
|
47
47
|
judgmentSamples,
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
// Client-side SESSION check for the browser demo: drop a session .jsonl +
|
|
51
|
+
// paste rules, get per-rule verdicts, nothing uploaded. Same checkers as the CLI.
|
|
52
|
+
export { evaluateBrowserSession, checkSessionInBrowser } from "./evaluateBrowser.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CheckResult } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Evaluate a session against rules ENTIRELY in the browser — the same checkers
|
|
4
|
+
* the CLI uses, run on a file the user dropped, with nothing leaving the page.
|
|
5
|
+
*
|
|
6
|
+
* This is the CLI's evaluateSession minus the parts that need a machine: no
|
|
7
|
+
* saved overrides, no git rule-age split, no reading the user's settings.json
|
|
8
|
+
* for a permissions.allow list (so the approval check runs with an empty allow
|
|
9
|
+
* list — it can only be MORE cautious, never less). Everything reachable from
|
|
10
|
+
* here is pure string work; keep it that way so the site's "your file never
|
|
11
|
+
* leaves this page" promise stays true. Judgment rules report UNCLEAR (no LLM
|
|
12
|
+
* call), exactly as `check` without `--llm` does.
|
|
13
|
+
*/
|
|
14
|
+
export declare function evaluateBrowserSession(rulesText: string, sessionText: string): CheckResult[];
|
|
15
|
+
export interface BrowserSessionSummary {
|
|
16
|
+
results: CheckResult[];
|
|
17
|
+
pass: number;
|
|
18
|
+
fail: number;
|
|
19
|
+
unclear: number;
|
|
20
|
+
events: number;
|
|
21
|
+
}
|
|
22
|
+
/** The whole client-side session check: parse, evaluate, and count. */
|
|
23
|
+
export declare function checkSessionInBrowser(rulesText: string, sessionText: string): BrowserSessionSummary;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { parseTranscriptText } from "../parsers/transcriptLine.js";
|
|
2
|
+
import { parseClaudeMdText } from "../parsers/claudeMdParser.js";
|
|
3
|
+
import { classifyRules } from "../checks/classify.js";
|
|
4
|
+
import { runDeterministicChecks } from "../checks/deterministicChecks.js";
|
|
5
|
+
import { runIfEditThenTestChecks } from "../checks/ifEditThenTest.js";
|
|
6
|
+
import { runGitBranchPolicyChecks } from "../checks/gitBranchPolicy.js";
|
|
7
|
+
import { runCodeContentChecks } from "../checks/codeContent.js";
|
|
8
|
+
import { runFileLifecycleChecks } from "../checks/fileLifecycle.js";
|
|
9
|
+
import { runClaimEvidenceChecks } from "../checks/claimEvidence.js";
|
|
10
|
+
import { runEmojiChecks } from "../checks/emojiOutput.js";
|
|
11
|
+
import { runAttributionChecks } from "../checks/attribution.js";
|
|
12
|
+
import { runApprovalGateChecks } from "../checks/approvalGate.js";
|
|
13
|
+
import { touchedPaths, ruleWasLoaded } from "../checks/pathScope.js";
|
|
14
|
+
/**
|
|
15
|
+
* Evaluate a session against rules ENTIRELY in the browser — the same checkers
|
|
16
|
+
* the CLI uses, run on a file the user dropped, with nothing leaving the page.
|
|
17
|
+
*
|
|
18
|
+
* This is the CLI's evaluateSession minus the parts that need a machine: no
|
|
19
|
+
* saved overrides, no git rule-age split, no reading the user's settings.json
|
|
20
|
+
* for a permissions.allow list (so the approval check runs with an empty allow
|
|
21
|
+
* list — it can only be MORE cautious, never less). Everything reachable from
|
|
22
|
+
* here is pure string work; keep it that way so the site's "your file never
|
|
23
|
+
* leaves this page" promise stays true. Judgment rules report UNCLEAR (no LLM
|
|
24
|
+
* call), exactly as `check` without `--llm` does.
|
|
25
|
+
*/
|
|
26
|
+
export function evaluateBrowserSession(rulesText, sessionText) {
|
|
27
|
+
const rules = parseClaudeMdText(rulesText, "project");
|
|
28
|
+
const events = parseTranscriptText(sessionText);
|
|
29
|
+
const touched = touchedPaths(events);
|
|
30
|
+
const classified = classifyRules(rules);
|
|
31
|
+
// A path-scoped rule the session never touched a matching file for was never
|
|
32
|
+
// loaded by the agent, so it is reported not-applicable rather than judged.
|
|
33
|
+
const notLoaded = classified.filter((c) => c.kind !== "notARule" && c.rule.paths && !ruleWasLoaded(c.rule.paths, touched));
|
|
34
|
+
const classifications = classified.filter((c) => !notLoaded.includes(c));
|
|
35
|
+
const scopeResults = notLoaded.map(({ rule }) => ({
|
|
36
|
+
ruleId: rule.id,
|
|
37
|
+
ruleTitle: rule.title,
|
|
38
|
+
ruleSource: rule.source,
|
|
39
|
+
status: "UNCLEAR",
|
|
40
|
+
outcome: "not_applicable",
|
|
41
|
+
method: "file_events",
|
|
42
|
+
reason: "path_scope_not_loaded",
|
|
43
|
+
evidence: `path-scoped rule (${rule.paths.join(", ")}): this session touched no matching file, so it was never loaded`,
|
|
44
|
+
}));
|
|
45
|
+
const of = (kind) => classifications.filter((c) => c.kind === kind);
|
|
46
|
+
const deterministicResults = [
|
|
47
|
+
...runDeterministicChecks(of("deterministic"), events),
|
|
48
|
+
...runIfEditThenTestChecks(of("ifEditThenTest"), events),
|
|
49
|
+
...runGitBranchPolicyChecks(of("gitBranchPolicy"), events),
|
|
50
|
+
...runCodeContentChecks(of("codeContent"), events),
|
|
51
|
+
...runFileLifecycleChecks(of("fileLifecycle"), events),
|
|
52
|
+
...runClaimEvidenceChecks(of("claimEvidence"), events),
|
|
53
|
+
...runEmojiChecks(of("emojiOutput"), events),
|
|
54
|
+
...runAttributionChecks(of("attribution"), events),
|
|
55
|
+
...runApprovalGateChecks(of("approvalGate"), events, { allow: [] }),
|
|
56
|
+
];
|
|
57
|
+
const judgmentResults = of("judgment").map(({ rule }) => ({
|
|
58
|
+
ruleId: rule.id,
|
|
59
|
+
ruleTitle: rule.title,
|
|
60
|
+
ruleSource: rule.source,
|
|
61
|
+
status: "UNCLEAR",
|
|
62
|
+
needsHuman: true,
|
|
63
|
+
evidence: "",
|
|
64
|
+
}));
|
|
65
|
+
return [...deterministicResults, ...judgmentResults, ...scopeResults];
|
|
66
|
+
}
|
|
67
|
+
/** The whole client-side session check: parse, evaluate, and count. */
|
|
68
|
+
export function checkSessionInBrowser(rulesText, sessionText) {
|
|
69
|
+
const results = evaluateBrowserSession(rulesText, sessionText);
|
|
70
|
+
const count = (s) => results.filter((r) => r.status === s).length;
|
|
71
|
+
return {
|
|
72
|
+
results,
|
|
73
|
+
pass: count("PASS"),
|
|
74
|
+
fail: count("FAIL"),
|
|
75
|
+
unclear: count("UNCLEAR"),
|
|
76
|
+
events: parseTranscriptText(sessionText).length,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/dist/card.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rulereceipt card` — a shareable image and pre-filled share links, built from
|
|
3
|
+
* history-mode counts. This is the "share it" step: someone runs RuleReceipt,
|
|
4
|
+
* sees their agent's mistakes, fixes them with `protect`, and posts the card.
|
|
5
|
+
*
|
|
6
|
+
* Privacy is the whole point of it being shareable: by default the card and the
|
|
7
|
+
* share text carry ONLY counts — never code, file paths, or rule text. A rule's
|
|
8
|
+
* name appears only when the user passes `--show-rules`, and even then only in
|
|
9
|
+
* the copy-text, never the image. Nothing is posted automatically and nothing
|
|
10
|
+
* is uploaded; the links open a compose window the user chooses to send.
|
|
11
|
+
*/
|
|
12
|
+
export interface CardData {
|
|
13
|
+
broken: number;
|
|
14
|
+
followed: number;
|
|
15
|
+
judgment: number;
|
|
16
|
+
sessions: number;
|
|
17
|
+
days: number;
|
|
18
|
+
/** "Claude" when the sessions are all Claude Code, else "the agent". */
|
|
19
|
+
who: string;
|
|
20
|
+
/** Titles of the broken rules — only used with --show-rules. */
|
|
21
|
+
brokenTitles?: string[];
|
|
22
|
+
}
|
|
23
|
+
/** The share caption. Counts only, unless showRules adds the broken rule names. */
|
|
24
|
+
export declare function shareText(d: CardData, showRules?: boolean): string;
|
|
25
|
+
export interface ShareLinks {
|
|
26
|
+
x: string;
|
|
27
|
+
linkedin: string;
|
|
28
|
+
bluesky: string;
|
|
29
|
+
reddit: string;
|
|
30
|
+
}
|
|
31
|
+
/** Compose-window links for each network. No auth, no posting — the user sends it. */
|
|
32
|
+
export declare function shareLinks(text: string): ShareLinks;
|
|
33
|
+
/** A self-contained SVG card. Counts only — never rule text, paths or code. */
|
|
34
|
+
export declare function cardSvg(d: CardData): string;
|
|
35
|
+
/** The terminal block: where the image went, the share links, and the copy-text. */
|
|
36
|
+
export declare function renderCardShare(d: CardData, outPath: string, showRules?: boolean): string;
|
package/dist/card.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rulereceipt card` — a shareable image and pre-filled share links, built from
|
|
3
|
+
* history-mode counts. This is the "share it" step: someone runs RuleReceipt,
|
|
4
|
+
* sees their agent's mistakes, fixes them with `protect`, and posts the card.
|
|
5
|
+
*
|
|
6
|
+
* Privacy is the whole point of it being shareable: by default the card and the
|
|
7
|
+
* share text carry ONLY counts — never code, file paths, or rule text. A rule's
|
|
8
|
+
* name appears only when the user passes `--show-rules`, and even then only in
|
|
9
|
+
* the copy-text, never the image. Nothing is posted automatically and nothing
|
|
10
|
+
* is uploaded; the links open a compose window the user chooses to send.
|
|
11
|
+
*/
|
|
12
|
+
const SITE = "rulereceipt.dev";
|
|
13
|
+
/** The share caption. Counts only, unless showRules adds the broken rule names. */
|
|
14
|
+
export function shareText(d, showRules = false) {
|
|
15
|
+
const base = d.broken > 0
|
|
16
|
+
? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in ${d.days} days (${d.sessions} session${d.sessions === 1 ? "" : "s"}) — now it can't.`
|
|
17
|
+
: `RuleReceipt checked ${d.sessions} of my agent session${d.sessions === 1 ? "" : "s"} over ${d.days} days against my written rules: ${d.broken} broken.`;
|
|
18
|
+
const tail = `Checked with RuleReceipt — runs locally, nothing uploaded. ${SITE}`;
|
|
19
|
+
if (showRules && d.broken > 0 && d.brokenTitles && d.brokenTitles.length > 0) {
|
|
20
|
+
const list = d.brokenTitles.slice(0, 3).map((t) => `“${t.replace(/\s+/g, " ").trim().slice(0, 50)}”`).join(", ");
|
|
21
|
+
return `${base}\nBroke: ${list}.\n${tail}`;
|
|
22
|
+
}
|
|
23
|
+
return `${base}\n${tail}`;
|
|
24
|
+
}
|
|
25
|
+
/** Compose-window links for each network. No auth, no posting — the user sends it. */
|
|
26
|
+
export function shareLinks(text) {
|
|
27
|
+
const t = encodeURIComponent(text);
|
|
28
|
+
const url = encodeURIComponent(`https://${SITE}`);
|
|
29
|
+
const title = encodeURIComponent(text.split("\n")[0]);
|
|
30
|
+
return {
|
|
31
|
+
x: `https://twitter.com/intent/tweet?text=${t}`,
|
|
32
|
+
// LinkedIn's offsite share only takes a URL; the caption is added by the user.
|
|
33
|
+
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`,
|
|
34
|
+
bluesky: `https://bsky.app/intent/compose?text=${t}`,
|
|
35
|
+
reddit: `https://www.reddit.com/submit?title=${title}&url=${url}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function esc(s) {
|
|
39
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
40
|
+
}
|
|
41
|
+
/** A self-contained SVG card. Counts only — never rule text, paths or code. */
|
|
42
|
+
export function cardSvg(d) {
|
|
43
|
+
const headline = d.broken > 0 ? `${d.who} broke your rules ${d.broken}×` : `0 rules broken`;
|
|
44
|
+
const sub = `${d.sessions} session${d.sessions === 1 ? "" : "s"} · last ${d.days} days`;
|
|
45
|
+
const stats = `${d.followed} followed · ${d.judgment} need judgment`;
|
|
46
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="418" viewBox="0 0 800 418" role="img" aria-label="RuleReceipt summary">
|
|
47
|
+
<rect width="800" height="418" fill="#0b0d10"/>
|
|
48
|
+
<rect x="0" y="0" width="800" height="6" fill="${d.broken > 0 ? "#e5534b" : "#3fb950"}"/>
|
|
49
|
+
<text x="56" y="86" fill="#8b949e" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="20">RuleReceipt</text>
|
|
50
|
+
<text x="56" y="196" fill="#e6edf3" font-family="-apple-system, Segoe UI, Roboto, sans-serif" font-size="52" font-weight="700">${esc(headline)}</text>
|
|
51
|
+
<text x="56" y="248" fill="#8b949e" font-family="-apple-system, Segoe UI, Roboto, sans-serif" font-size="24">${esc(sub)}</text>
|
|
52
|
+
<text x="56" y="300" fill="#8b949e" font-family="-apple-system, Segoe UI, Roboto, sans-serif" font-size="22">${esc(stats)}</text>
|
|
53
|
+
<text x="56" y="372" fill="#6e7681" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="18">proof from real sessions · local · nothing uploaded · ${SITE}</text>
|
|
54
|
+
</svg>`;
|
|
55
|
+
}
|
|
56
|
+
/** The terminal block: where the image went, the share links, and the copy-text. */
|
|
57
|
+
export function renderCardShare(d, outPath, showRules = false) {
|
|
58
|
+
const text = shareText(d, showRules);
|
|
59
|
+
const links = shareLinks(text);
|
|
60
|
+
const out = [];
|
|
61
|
+
out.push(`Saved a shareable card to ${outPath} (counts only — no code, paths or rule text).`);
|
|
62
|
+
out.push("");
|
|
63
|
+
out.push("Share it (opens a compose window — nothing is posted for you):");
|
|
64
|
+
out.push(` X / Twitter ${links.x}`);
|
|
65
|
+
out.push(` LinkedIn ${links.linkedin}`);
|
|
66
|
+
out.push(` Bluesky ${links.bluesky}`);
|
|
67
|
+
out.push(` Reddit ${links.reddit}`);
|
|
68
|
+
out.push("");
|
|
69
|
+
out.push("Copy text (for Slack / Discord / a PR):");
|
|
70
|
+
out.push(text.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
71
|
+
if (!showRules && d.broken > 0) {
|
|
72
|
+
out.push("");
|
|
73
|
+
out.push("(Rule names are left out. Add them with --show-rules if you want them in the copy-text.)");
|
|
74
|
+
}
|
|
75
|
+
return out.join("\n");
|
|
76
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -17,7 +17,9 @@ import { evaluateSession } from "./evaluate.js";
|
|
|
17
17
|
import { buildWrongReport, findTarget } from "./wrong.js";
|
|
18
18
|
import { detectSelfEditedRuleFiles } from "./checks/selfEditedRules.js";
|
|
19
19
|
import { scanHistory, renderHistory } from "./historyReport.js";
|
|
20
|
+
import { observeSessions, renderNoRules, draftRulesFromHistory } from "./sessionObserve.js";
|
|
20
21
|
import { planProtect, applyProtect, undoProtect } from "./protect.js";
|
|
22
|
+
import { cardSvg, renderCardShare } from "./card.js";
|
|
21
23
|
import { createInterface } from "node:readline";
|
|
22
24
|
import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
|
|
23
25
|
import { runHook } from "./hook.js";
|
|
@@ -796,9 +798,31 @@ program
|
|
|
796
798
|
});
|
|
797
799
|
program
|
|
798
800
|
.command("init")
|
|
799
|
-
.description("Guided setup: shows what's configured and the exact next steps. Read-only — writes nothing.")
|
|
800
|
-
.
|
|
801
|
+
.description("Guided setup: shows what's configured and the exact next steps. Read-only — writes nothing (except --from-history, which drafts a starter rules file you approve).")
|
|
802
|
+
.option("--from-history", "draft a starter rules file from what your agent did in recent sessions, written to .rulereceipt/draft-CLAUDE.md (never overwrites an existing rules file)")
|
|
803
|
+
.option("--days <n>", "how many days back to look, with --from-history", "30")
|
|
804
|
+
.action((opts) => {
|
|
801
805
|
const cwd = process.cwd();
|
|
806
|
+
if (opts.fromHistory) {
|
|
807
|
+
const d = Number.parseInt(opts.days ?? "30", 10);
|
|
808
|
+
const obs = observeSessions(cwd, Number.isFinite(d) && d > 0 ? d : 30);
|
|
809
|
+
if (obs.sessions === 0) {
|
|
810
|
+
console.log("No sessions found for this project yet, so there's nothing to draft rules from. Run your agent here first.");
|
|
811
|
+
process.exitCode = 1;
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const draftPath = join(cwd, ".rulereceipt", "draft-CLAUDE.md");
|
|
815
|
+
if (existsSync(draftPath)) {
|
|
816
|
+
console.log(`A draft already exists at ${draftPath} — open it, or delete it and re-run. Not overwriting.`);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
mkdirSync(dirname(draftPath), { recursive: true });
|
|
820
|
+
writeFileSync(draftPath, draftRulesFromHistory(obs));
|
|
821
|
+
console.log(`Drafted a starter rules file from ${obs.sessions} session${obs.sessions === 1 ? "" : "s"} → ${draftPath}`);
|
|
822
|
+
console.log("Read it, keep the rules you want, then move it to CLAUDE.md (or AGENTS.md) in your project root.");
|
|
823
|
+
console.log("It's a draft only — nothing is enforced until you move it into place and run `rulereceipt`.");
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
802
826
|
console.log(buildInitGuidance({
|
|
803
827
|
hasClaudeMd: existsSync(join(cwd, "CLAUDE.md")),
|
|
804
828
|
hasAgentsMd: existsSync(join(cwd, "AGENTS.md")),
|
|
@@ -1087,6 +1111,40 @@ program
|
|
|
1087
1111
|
console.log("The hooks call `rulereceipt` on your PATH (install once with `npm i -g rulereceipt`); they fail open if it's missing.");
|
|
1088
1112
|
console.log("Undo: rulereceipt protect --undo");
|
|
1089
1113
|
});
|
|
1114
|
+
program
|
|
1115
|
+
.command("card")
|
|
1116
|
+
.description("Make a shareable image and pre-filled share links from your last 30 days of sessions — counts only, no code, paths or rule text (add rule names to the copy-text with --show-rules). Saves an SVG locally and prints X/LinkedIn/Bluesky/Reddit compose links. Nothing is posted and nothing is uploaded.")
|
|
1117
|
+
.option("--out <path>", "where to write the SVG card", "rulereceipt-card.svg")
|
|
1118
|
+
.option("--show-rules", "include the broken rule names in the copy-text (never in the image)")
|
|
1119
|
+
.option("--days <n>", "how many days back to summarize", "30")
|
|
1120
|
+
.action(async (opts) => {
|
|
1121
|
+
const cwd = process.cwd();
|
|
1122
|
+
const rules = loadRules(cwd);
|
|
1123
|
+
if (rules.length === 0) {
|
|
1124
|
+
console.log("No rules file found for this project, so there's nothing to summarize. Add a CLAUDE.md or AGENTS.md first.");
|
|
1125
|
+
process.exitCode = 1;
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
const days = Number.parseInt(opts.days ?? "30", 10);
|
|
1129
|
+
const s = await scanHistory(cwd, rules, Number.isFinite(days) && days > 0 ? days : 30);
|
|
1130
|
+
if (s.sessionsScanned === 0) {
|
|
1131
|
+
console.log("No sessions found for this project in the window, so there's nothing to put on a card yet. Run your agent here, then try again.");
|
|
1132
|
+
process.exitCode = 1;
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
const data = {
|
|
1136
|
+
broken: s.totalBrokenCount,
|
|
1137
|
+
followed: s.followedRules,
|
|
1138
|
+
judgment: s.judgmentRules,
|
|
1139
|
+
sessions: s.sessionsScanned,
|
|
1140
|
+
days: s.days,
|
|
1141
|
+
who: s.tools.length === 1 && s.tools[0] === "claude-code" ? "Claude" : "the agent",
|
|
1142
|
+
brokenTitles: s.breaks.map((b) => b.ruleTitle),
|
|
1143
|
+
};
|
|
1144
|
+
const outPath = resolve(cwd, opts.out ?? "rulereceipt-card.svg");
|
|
1145
|
+
writeFileSync(outPath, cardSvg(data));
|
|
1146
|
+
console.log(renderCardShare(data, outPath, Boolean(opts.showRules)));
|
|
1147
|
+
});
|
|
1090
1148
|
program
|
|
1091
1149
|
.command("history")
|
|
1092
1150
|
.description("Check EVERY session for this project in the last 30 days (this is what runs when you type `rulereceipt` with no arguments). Leads with the rules broken most, each with a count, the last date and one quoted line. Counts only proven breaks; judgment rules stay separate. No session to pick, no API key, nothing uploaded.")
|
|
@@ -1096,15 +1154,24 @@ program
|
|
|
1096
1154
|
});
|
|
1097
1155
|
async function runHistory(opts) {
|
|
1098
1156
|
const cwd = process.cwd();
|
|
1157
|
+
const parsedDays = Number.parseInt(opts.days ?? "30", 10);
|
|
1158
|
+
const days = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 30;
|
|
1099
1159
|
const rules = loadRules(cwd);
|
|
1100
1160
|
if (rules.length === 0) {
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1161
|
+
// No rules to judge against — but we can still show what the agent DID, so a
|
|
1162
|
+
// first-time user sees something true about their own work.
|
|
1163
|
+
const obs = observeSessions(cwd, days);
|
|
1164
|
+
if (obs.sessions === 0) {
|
|
1165
|
+
console.log("No rules file and no coding-agent sessions found for this project yet.\n" +
|
|
1166
|
+
"Run Claude Code (or Codex) here, then `rulereceipt` shows what it did — or `rulereceipt demo` for a sample.\n" +
|
|
1167
|
+
"To score a rules file you already have: rulereceipt audit");
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
console.log(renderNoRules(obs, basename(cwd) || "this project"));
|
|
1171
|
+
}
|
|
1104
1172
|
return;
|
|
1105
1173
|
}
|
|
1106
|
-
const
|
|
1107
|
-
const summary = await scanHistory(cwd, rules, Number.isFinite(days) && days > 0 ? days : 30);
|
|
1174
|
+
const summary = await scanHistory(cwd, rules, days);
|
|
1108
1175
|
console.log(renderHistory(summary, basename(cwd) || "this project"));
|
|
1109
1176
|
}
|
|
1110
1177
|
// Bare `rulereceipt` (no subcommand, no flags) runs history mode — the first-run
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TranscriptEvent } from "../types.js";
|
|
2
|
+
export declare function parseLine(line: string): TranscriptEvent[];
|
|
3
|
+
/**
|
|
4
|
+
* Parse a whole session file's TEXT (not a path) into events, tracking the
|
|
5
|
+
* permission mode the same way readTranscriptFromFile does — so the browser
|
|
6
|
+
* demo and the CLI agree. Pure: takes the file contents as a string.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseTranscriptText(raw: string): TranscriptEvent[];
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure, Node-free half of transcript parsing: one JSONL line → events.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from transcriptParser.ts 2026-09-28 so the browser demo can parse a
|
|
5
|
+
* dropped session file client-side, with the SAME logic as the CLI and no
|
|
6
|
+
* filesystem dependency. The site's "your file never leaves this page" claim
|
|
7
|
+
* depends on everything reachable from here being pure string work — keep it
|
|
8
|
+
* that way (no node: imports, no network, no storage).
|
|
9
|
+
*/
|
|
10
|
+
function extractToolResultText(content) {
|
|
11
|
+
if (typeof content === "string")
|
|
12
|
+
return content;
|
|
13
|
+
if (Array.isArray(content)) {
|
|
14
|
+
return content
|
|
15
|
+
.map((part) => (typeof part === "object" && part && "text" in part ? String(part.text) : ""))
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join("\n");
|
|
18
|
+
}
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
export function parseLine(line) {
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(line);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
// JSON.parse accepts any valid JSON value, not just objects — "null", "42",
|
|
30
|
+
// "\"a string\"" all parse without throwing. A transcript line is only ever
|
|
31
|
+
// meaningful as an object; anything else is skipped, not a crash.
|
|
32
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const obj = parsed;
|
|
36
|
+
const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : "";
|
|
37
|
+
const events = [];
|
|
38
|
+
if (obj.type === "assistant" && !obj.isApiErrorMessage) {
|
|
39
|
+
const message = obj.message;
|
|
40
|
+
const content = message?.content;
|
|
41
|
+
if (Array.isArray(content)) {
|
|
42
|
+
for (const block of content) {
|
|
43
|
+
if (typeof block !== "object" || block === null)
|
|
44
|
+
continue;
|
|
45
|
+
const b = block;
|
|
46
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
47
|
+
events.push({ role: "assistant", kind: "text", text: b.text, timestamp });
|
|
48
|
+
}
|
|
49
|
+
else if (b.type === "tool_use" && typeof b.name === "string") {
|
|
50
|
+
events.push({ role: "assistant", kind: "tool_use", toolName: b.name, input: b.input, timestamp, toolUseId: typeof b.id === "string" ? b.id : undefined });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else if (obj.type === "user") {
|
|
56
|
+
const message = obj.message;
|
|
57
|
+
const content = message?.content;
|
|
58
|
+
if (typeof content === "string") {
|
|
59
|
+
events.push({ role: "user", kind: "text", text: content, timestamp });
|
|
60
|
+
}
|
|
61
|
+
else if (Array.isArray(content)) {
|
|
62
|
+
for (const block of content) {
|
|
63
|
+
if (typeof block !== "object" || block === null)
|
|
64
|
+
continue;
|
|
65
|
+
const b = block;
|
|
66
|
+
// User text sent as blocks (VS Code / IDE extension, or any message with
|
|
67
|
+
// an image) was dropped until 2026-09-28: in real sessions whole
|
|
68
|
+
// conversations had no user message at all, so every check that reads
|
|
69
|
+
// what the user said saw nothing. Harness-injected context wrapped in
|
|
70
|
+
// tags (<ide_opened_file>, <system-reminder>, …) is not the user
|
|
71
|
+
// speaking and is stripped; what remains is.
|
|
72
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
73
|
+
const said = b.text.replace(/<([a-z][\w-]*)>[\s\S]*?<\/\1>/gi, "").trim();
|
|
74
|
+
if (said.length > 0)
|
|
75
|
+
events.push({ role: "user", kind: "text", text: said, timestamp });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (b.type === "tool_result") {
|
|
79
|
+
events.push({
|
|
80
|
+
role: "user",
|
|
81
|
+
kind: "tool_result",
|
|
82
|
+
content: extractToolResultText(b.content),
|
|
83
|
+
isError: b.is_error === true,
|
|
84
|
+
timestamp,
|
|
85
|
+
toolUseId: typeof b.tool_use_id === "string" ? b.tool_use_id : undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return events;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Parse a whole session file's TEXT (not a path) into events, tracking the
|
|
95
|
+
* permission mode the same way readTranscriptFromFile does — so the browser
|
|
96
|
+
* demo and the CLI agree. Pure: takes the file contents as a string.
|
|
97
|
+
*/
|
|
98
|
+
export function parseTranscriptText(raw) {
|
|
99
|
+
const events = [];
|
|
100
|
+
let mode;
|
|
101
|
+
for (const line of raw.split("\n")) {
|
|
102
|
+
if (!line.trim())
|
|
103
|
+
continue;
|
|
104
|
+
try {
|
|
105
|
+
const m = line.match(/"(?:permissionMode|permission_mode)":"([A-Za-z]+)"/) ??
|
|
106
|
+
(line.includes('"permission-mode"') ? line.match(/"mode":"([A-Za-z]+)"/) : null);
|
|
107
|
+
if (m)
|
|
108
|
+
mode = m[1];
|
|
109
|
+
const parsed = parseLine(line);
|
|
110
|
+
if (mode)
|
|
111
|
+
for (const e of parsed)
|
|
112
|
+
if (e.kind === "tool_use")
|
|
113
|
+
e.permissionMode = mode;
|
|
114
|
+
events.push(...parsed);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return events;
|
|
121
|
+
}
|
|
@@ -8,7 +8,7 @@ export declare function findClaudeHomeDirNames(): string[];
|
|
|
8
8
|
/** Every session file for this project, newest first. */
|
|
9
9
|
export declare function listAllSessionFiles(cwd: string): string[];
|
|
10
10
|
export declare function findLatestSessionFile(cwd: string): string | null;
|
|
11
|
-
export
|
|
11
|
+
export { parseLine } from "./transcriptLine.js";
|
|
12
12
|
/**
|
|
13
13
|
* Read the most recently modified session transcript for a project
|
|
14
14
|
* directory. Returns an empty array (not an error) if no session exists
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, dirname, basename } from "node:path";
|
|
4
|
+
import { parseTranscriptText } from "./transcriptLine.js";
|
|
4
5
|
/**
|
|
5
6
|
* Claude Code stores each session as a JSONL file at:
|
|
6
7
|
* ~/.claude/projects/<cwd with every "/" replaced by "-">/<sessionId>.jsonl
|
|
@@ -80,89 +81,9 @@ export function findLatestSessionFile(cwd) {
|
|
|
80
81
|
const all = listAllSessionFiles(cwd);
|
|
81
82
|
return all.length > 0 ? all[0] : null;
|
|
82
83
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (Array.isArray(content)) {
|
|
87
|
-
return content
|
|
88
|
-
.map((part) => (typeof part === "object" && part && "text" in part ? String(part.text) : ""))
|
|
89
|
-
.filter(Boolean)
|
|
90
|
-
.join("\n");
|
|
91
|
-
}
|
|
92
|
-
return "";
|
|
93
|
-
}
|
|
94
|
-
export function parseLine(line) {
|
|
95
|
-
let parsed;
|
|
96
|
-
try {
|
|
97
|
-
parsed = JSON.parse(line);
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
return [];
|
|
101
|
-
}
|
|
102
|
-
// JSON.parse accepts any valid JSON value, not just objects — "null",
|
|
103
|
-
// "42", "\"a string\"" all parse without throwing. A transcript line is
|
|
104
|
-
// only ever meaningful as an object; anything else is skipped, not a crash.
|
|
105
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
106
|
-
return [];
|
|
107
|
-
}
|
|
108
|
-
const obj = parsed;
|
|
109
|
-
const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : "";
|
|
110
|
-
const events = [];
|
|
111
|
-
if (obj.type === "assistant" && !obj.isApiErrorMessage) {
|
|
112
|
-
const message = obj.message;
|
|
113
|
-
const content = message?.content;
|
|
114
|
-
if (Array.isArray(content)) {
|
|
115
|
-
for (const block of content) {
|
|
116
|
-
if (typeof block !== "object" || block === null)
|
|
117
|
-
continue;
|
|
118
|
-
const b = block;
|
|
119
|
-
if (b.type === "text" && typeof b.text === "string") {
|
|
120
|
-
events.push({ role: "assistant", kind: "text", text: b.text, timestamp });
|
|
121
|
-
}
|
|
122
|
-
else if (b.type === "tool_use" && typeof b.name === "string") {
|
|
123
|
-
events.push({ role: "assistant", kind: "tool_use", toolName: b.name, input: b.input, timestamp, toolUseId: typeof b.id === "string" ? b.id : undefined });
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
else if (obj.type === "user") {
|
|
129
|
-
const message = obj.message;
|
|
130
|
-
const content = message?.content;
|
|
131
|
-
if (typeof content === "string") {
|
|
132
|
-
events.push({ role: "user", kind: "text", text: content, timestamp });
|
|
133
|
-
}
|
|
134
|
-
else if (Array.isArray(content)) {
|
|
135
|
-
for (const block of content) {
|
|
136
|
-
if (typeof block !== "object" || block === null)
|
|
137
|
-
continue;
|
|
138
|
-
const b = block;
|
|
139
|
-
// User text sent as blocks (VS Code / IDE extension, or any message with
|
|
140
|
-
// an image) was dropped until 2026-09-28: in real sessions whole
|
|
141
|
-
// conversations had no user message at all, so every check that reads
|
|
142
|
-
// what the user said saw nothing. Harness-injected context wrapped in
|
|
143
|
-
// tags (<ide_opened_file>, <system-reminder>, …) is not the user
|
|
144
|
-
// speaking and is stripped; what remains is.
|
|
145
|
-
if (b.type === "text" && typeof b.text === "string") {
|
|
146
|
-
const said = b.text.replace(/<([a-z][\w-]*)>[\s\S]*?<\/\1>/gi, "").trim();
|
|
147
|
-
if (said.length > 0)
|
|
148
|
-
events.push({ role: "user", kind: "text", text: said, timestamp });
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (b.type === "tool_result") {
|
|
152
|
-
events.push({
|
|
153
|
-
role: "user",
|
|
154
|
-
kind: "tool_result",
|
|
155
|
-
content: extractToolResultText(b.content),
|
|
156
|
-
isError: b.is_error === true,
|
|
157
|
-
timestamp,
|
|
158
|
-
toolUseId: typeof b.tool_use_id === "string" ? b.tool_use_id : undefined,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return events;
|
|
165
|
-
}
|
|
84
|
+
// parseLine (and its permission-mode-tracking wrapper parseTranscriptText) live
|
|
85
|
+
// in transcriptLine.ts — a pure, Node-free module the browser demo also imports.
|
|
86
|
+
export { parseLine } from "./transcriptLine.js";
|
|
166
87
|
/**
|
|
167
88
|
* Read the most recently modified session transcript for a project
|
|
168
89
|
* directory. Returns an empty array (not an error) if no session exists
|
|
@@ -170,35 +91,9 @@ export function parseLine(line) {
|
|
|
170
91
|
* not a failure.
|
|
171
92
|
*/
|
|
172
93
|
export function readTranscriptFromFile(filePath) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// in newer versions); it applies to the tool calls that follow. The approval
|
|
177
|
-
// check needs it to tell "a prompt may have been approved" (default/
|
|
178
|
-
// acceptEdits/plan) from "no person was asked" (bypassPermissions/dontAsk/auto).
|
|
179
|
-
let mode;
|
|
180
|
-
for (const line of raw.split("\n")) {
|
|
181
|
-
if (!line.trim())
|
|
182
|
-
continue;
|
|
183
|
-
try {
|
|
184
|
-
const m = line.match(/"(?:permissionMode|permission_mode)":"([A-Za-z]+)"/) ??
|
|
185
|
-
(line.includes('"permission-mode"') ? line.match(/"mode":"([A-Za-z]+)"/) : null);
|
|
186
|
-
if (m)
|
|
187
|
-
mode = m[1];
|
|
188
|
-
const parsed = parseLine(line);
|
|
189
|
-
if (mode)
|
|
190
|
-
for (const e of parsed)
|
|
191
|
-
if (e.kind === "tool_use")
|
|
192
|
-
e.permissionMode = mode;
|
|
193
|
-
events.push(...parsed);
|
|
194
|
-
}
|
|
195
|
-
catch {
|
|
196
|
-
// One malformed/unexpected line must not crash the whole check —
|
|
197
|
-
// skip it and keep going, same fail-closed principle as everywhere else.
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return events;
|
|
94
|
+
// Pure parsing (including permission-mode tracking) lives in transcriptLine.ts
|
|
95
|
+
// so the browser demo can run the exact same logic on a dropped file.
|
|
96
|
+
return parseTranscriptText(readFileSync(filePath, "utf-8"));
|
|
202
97
|
}
|
|
203
98
|
/**
|
|
204
99
|
* Subagent transcripts for a session.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface SessionObservations {
|
|
2
|
+
sessions: number;
|
|
3
|
+
days: number;
|
|
4
|
+
tools: string[];
|
|
5
|
+
pushes: number;
|
|
6
|
+
commits: number;
|
|
7
|
+
envWrites: number;
|
|
8
|
+
/** "the tests pass" claims where no test command ran anywhere in that session. */
|
|
9
|
+
testClaimsNoRun: number;
|
|
10
|
+
testClaims: number;
|
|
11
|
+
elapsedMs: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function observeSessions(cwd: string, days?: number, now?: number, sessions?: {
|
|
14
|
+
adapter: import("./adapters/index.js").SessionAdapter;
|
|
15
|
+
file: string;
|
|
16
|
+
}[]): SessionObservations;
|
|
17
|
+
/** The no-rules screen: what the agent did, and how to turn it into rules. */
|
|
18
|
+
export declare function renderNoRules(o: SessionObservations, projectName: string): string;
|
|
19
|
+
/** A starter rules file drafted from the observed actions. Only rules for things that happened. */
|
|
20
|
+
export declare function draftRulesFromHistory(o: SessionObservations): string;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { listAllSessions } from "./adapters/index.js";
|
|
3
|
+
import { withoutHeredocs } from "./checks/shellCommand.js";
|
|
4
|
+
/**
|
|
5
|
+
* "What did your agent actually do?" — for someone with sessions but NO rules
|
|
6
|
+
* file. History mode needs rules to judge; this needs none. It just reports a
|
|
7
|
+
* few plainly-observable actions across recent sessions (pushes, edits to
|
|
8
|
+
* `.env`, "the tests pass" claims), so a first-time user sees something true
|
|
9
|
+
* about their own work even before they've written a single rule, and then
|
|
10
|
+
* `init --from-history` drafts a starter rules file from what it saw.
|
|
11
|
+
*
|
|
12
|
+
* These are OBSERVATIONS, not verdicts — nothing here says a rule was broken,
|
|
13
|
+
* because there is no rule yet. The wording stays factual ("pushed 9 times")
|
|
14
|
+
* and the checkers' polarity/false-accusation discipline does not apply,
|
|
15
|
+
* precisely because nothing is being accused.
|
|
16
|
+
*/
|
|
17
|
+
const PUSH = /\bgit\s+(?:\S+\s+){0,4}?push(?![\w-])/;
|
|
18
|
+
const COMMIT = /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])/;
|
|
19
|
+
const ENV_FILE = /(?:^|[\\/])\.env(?:\.[\w.-]+)?$/;
|
|
20
|
+
// A "the tests pass" style completion claim — the strongest, least-noisy signal.
|
|
21
|
+
const TESTS_PASS_CLAIM = /\b(?:all\s+)?tests?\s+(?:are\s+)?(?:pass(?:ing|ed|es)?|green)\b|\ball\s+green\b|\beverything\s+(?:passes|works)\b/i;
|
|
22
|
+
const TEST_COMMAND = /\b(?:npm\s+(?:run\s+)?test|npx\s+vitest|vitest|jest|pytest|go\s+test|cargo\s+test|mvn\s+test|rspec|phpunit|\btox\b)\b/;
|
|
23
|
+
function bashCommand(e) {
|
|
24
|
+
if (e.kind !== "tool_use" || e.toolName !== "Bash")
|
|
25
|
+
return "";
|
|
26
|
+
const c = e.input?.command;
|
|
27
|
+
return typeof c === "string" ? withoutHeredocs(c) : "";
|
|
28
|
+
}
|
|
29
|
+
function editedPath(e) {
|
|
30
|
+
if (e.kind !== "tool_use")
|
|
31
|
+
return "";
|
|
32
|
+
if (e.toolName !== "Write" && e.toolName !== "Edit" && e.toolName !== "MultiEdit" && e.toolName !== "NotebookEdit")
|
|
33
|
+
return "";
|
|
34
|
+
const p = e.input;
|
|
35
|
+
const path = p?.file_path ?? p?.notebook_path;
|
|
36
|
+
return typeof path === "string" ? path : "";
|
|
37
|
+
}
|
|
38
|
+
export function observeSessions(cwd, days = 30, now = Date.now(), sessions = listAllSessions(cwd)) {
|
|
39
|
+
const started = Date.now();
|
|
40
|
+
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
|
41
|
+
const tools = new Set();
|
|
42
|
+
let scanned = 0, pushes = 0, commits = 0, envWrites = 0, testClaims = 0, testClaimsNoRun = 0;
|
|
43
|
+
for (const { adapter, file } of sessions) {
|
|
44
|
+
let ms;
|
|
45
|
+
try {
|
|
46
|
+
ms = statSync(file).mtimeMs;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ms < cutoff)
|
|
52
|
+
continue;
|
|
53
|
+
let events;
|
|
54
|
+
try {
|
|
55
|
+
events = adapter.parse(file);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (events.length === 0)
|
|
61
|
+
continue;
|
|
62
|
+
scanned++;
|
|
63
|
+
tools.add(adapter.tool);
|
|
64
|
+
const ranTest = events.some((e) => TEST_COMMAND.test(bashCommand(e)));
|
|
65
|
+
for (const e of events) {
|
|
66
|
+
const cmd = bashCommand(e);
|
|
67
|
+
if (cmd && PUSH.test(cmd))
|
|
68
|
+
pushes++;
|
|
69
|
+
if (cmd && COMMIT.test(cmd))
|
|
70
|
+
commits++;
|
|
71
|
+
if (ENV_FILE.test(editedPath(e)))
|
|
72
|
+
envWrites++;
|
|
73
|
+
if (e.kind === "text" && e.role === "assistant" && TESTS_PASS_CLAIM.test(e.text)) {
|
|
74
|
+
testClaims++;
|
|
75
|
+
if (!ranTest)
|
|
76
|
+
testClaimsNoRun++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { sessions: scanned, days, tools: [...tools], pushes, commits, envWrites, testClaims, testClaimsNoRun, elapsedMs: Date.now() - started };
|
|
81
|
+
}
|
|
82
|
+
const toolLabel = (t) => (t === "claude-code" ? "Claude Code" : t === "codex" ? "Codex" : t);
|
|
83
|
+
/** The no-rules screen: what the agent did, and how to turn it into rules. */
|
|
84
|
+
export function renderNoRules(o, projectName) {
|
|
85
|
+
const who = o.tools.length === 1 && o.tools[0] === "claude-code" ? "Claude" : "your agent";
|
|
86
|
+
const out = [];
|
|
87
|
+
const toolNote = o.tools.length ? `${o.tools.map(toolLabel).join(" + ")} ` : "";
|
|
88
|
+
out.push(`RuleReceipt · ${projectName} · last ${o.days} days · ${o.sessions} ${toolNote}session${o.sessions === 1 ? "" : "s"}`);
|
|
89
|
+
out.push("");
|
|
90
|
+
out.push(`You don't have a rules file yet, so there's nothing to check against. Here's what ${who} actually did:`);
|
|
91
|
+
out.push("");
|
|
92
|
+
out.push(` pushed ${o.pushes} time${o.pushes === 1 ? "" : "s"}`);
|
|
93
|
+
out.push(` committed ${o.commits} time${o.commits === 1 ? "" : "s"}`);
|
|
94
|
+
out.push(` wrote to a .env file ${o.envWrites} time${o.envWrites === 1 ? "" : "s"}`);
|
|
95
|
+
out.push(` claimed the tests pass ${o.testClaims} time${o.testClaims === 1 ? "" : "s"}${o.testClaimsNoRun > 0 ? ` (${o.testClaimsNoRun} with no test command run that session)` : ""}`);
|
|
96
|
+
out.push("");
|
|
97
|
+
out.push("Turn this into rules to check from now on:");
|
|
98
|
+
out.push(" rulereceipt init --from-history (drafts a starter CLAUDE.md from the above — never overwrites)");
|
|
99
|
+
out.push("");
|
|
100
|
+
out.push(`checked ${o.sessions} session${o.sessions === 1 ? "" : "s"} in ${(o.elapsedMs / 1000).toFixed(1)}s · nothing uploaded`);
|
|
101
|
+
return out.join("\n");
|
|
102
|
+
}
|
|
103
|
+
/** A starter rules file drafted from the observed actions. Only rules for things that happened. */
|
|
104
|
+
export function draftRulesFromHistory(o) {
|
|
105
|
+
const lines = [
|
|
106
|
+
"# Project rules (draft)",
|
|
107
|
+
"",
|
|
108
|
+
"# Drafted by `rulereceipt init --from-history` from what your agent did in the",
|
|
109
|
+
"# last " + o.days + " days. Keep the ones you want, delete the rest, then move this",
|
|
110
|
+
"# to CLAUDE.md (or AGENTS.md) in your project root.",
|
|
111
|
+
"",
|
|
112
|
+
];
|
|
113
|
+
let n = 0;
|
|
114
|
+
if (o.pushes > 0)
|
|
115
|
+
lines.push(`## ${++n}. Never push without asking`, "Don't run `git push` unless I've asked for it in this session.", "");
|
|
116
|
+
if (o.commits > 0)
|
|
117
|
+
lines.push(`## ${++n}. Ask before committing`, "Don't `git commit` unless I've asked for it.", "");
|
|
118
|
+
if (o.envWrites > 0)
|
|
119
|
+
lines.push(`## ${++n}. Never edit \`.env\``, "Don't write to `.env` or any `.env.*` file.", "");
|
|
120
|
+
if (o.testClaims > 0)
|
|
121
|
+
lines.push(`## ${++n}. Don't say the tests pass without running them`, "Only say the tests pass after actually running the test command in this session.", "");
|
|
122
|
+
if (n === 0) {
|
|
123
|
+
lines.push("# Nothing risky was observed to base a rule on. Write your own, e.g.:");
|
|
124
|
+
lines.push("## 1. Never push to `main`");
|
|
125
|
+
lines.push("Never push directly to the `main` branch.");
|
|
126
|
+
lines.push("");
|
|
127
|
+
}
|
|
128
|
+
return lines.join("\n") + "\n";
|
|
129
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.65",
|
|
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",
|