rulereceipt 0.1.73 → 0.1.75
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 +39 -8
- package/dist/checks/approvalGate.d.ts +2 -0
- package/dist/checks/approvalGate.js +9 -3
- package/dist/checks/claimEvidence.js +11 -0
- package/dist/checks/classify.js +6 -1
- package/dist/checks/codeContent.js +21 -1
- package/dist/cli.js +80 -4
- package/dist/guard.js +18 -1
- package/dist/historyReport.js +14 -2
- package/dist/parsers/claudeMdParser.d.ts +1 -1
- package/dist/parsers/claudeMdParser.js +23 -1
- package/dist/parsers/imports.d.ts +9 -0
- package/dist/parsers/imports.js +107 -0
- package/dist/parsers/transcriptParser.d.ts +10 -1
- package/dist/parsers/transcriptParser.js +130 -9
- package/dist/rules.js +37 -0
- package/dist/shadowedAgents.js +7 -2
- package/dist/why.d.ts +34 -0
- package/dist/why.js +153 -0
- package/dist/wrong.js +16 -2
- package/dist/wrongSubmit.d.ts +24 -0
- package/dist/wrongSubmit.js +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -332,12 +332,25 @@ npx rulereceipt wrong <rule-handle>
|
|
|
332
332
|
|
|
333
333
|
Builds a report of that rule, the verdict, how it was decided and the
|
|
334
334
|
session lines around it, with obvious secrets, your home path and email
|
|
335
|
-
addresses masked
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
335
|
+
addresses masked (GitHub/Slack/Stripe tokens, JWTs, passwords in URLs,
|
|
336
|
+
private-key blocks and `.env`-style `KEY=value` lines too — but masking
|
|
337
|
+
catches common formats only, so read it before sending). It is saved to
|
|
338
|
+
`.rulereceipt/wrong-<handle>.md` and printed so you can read and edit it.
|
|
339
|
+
|
|
340
|
+
Nothing is ever sent automatically. After showing the report you get three
|
|
341
|
+
choices:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
rulereceipt wrong <rule> --submit # open a PUBLIC GitHub issue (asks y/N first; needs gh)
|
|
345
|
+
rulereceipt wrong <rule> --email # a mailto: to hello@rulereceipt.dev, private
|
|
346
|
+
rulereceipt wrong <rule> # just print the report + a pre-filled issue link
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`--submit` shows the full report, then asks before creating anything — the
|
|
350
|
+
default answer is No, and `--yes` does not skip that question. If `gh` isn't
|
|
351
|
+
installed or logged in, or you're not at a terminal, it never sends: it
|
|
352
|
+
prints the pre-filled link for you to open yourself. You can pass a rule by
|
|
353
|
+
the short handle or by the id shown in the report (e.g. `S1.2`).
|
|
341
354
|
|
|
342
355
|
Every accuracy fix in this project has come from a report like this.
|
|
343
356
|
|
|
@@ -498,8 +511,26 @@ so you can verify the published package was built from this repository at
|
|
|
498
511
|
a specific commit. No publishing token exists to be stolen. Check it
|
|
499
512
|
yourself with `npm audit signatures` after installing.
|
|
500
513
|
|
|
501
|
-
**Licence.** Source-available
|
|
502
|
-
|
|
514
|
+
**Licence.** Source-available, not OSI open source: the code is public and
|
|
515
|
+
you can read, run and modify it for yourself, but reuse is limited — see
|
|
516
|
+
[LICENSE](./LICENSE) and [NOTICE.md](./NOTICE.md) before reusing it.
|
|
517
|
+
|
|
518
|
+
**Windows.** Not tested yet. RuleReceipt is developed and tested on macOS
|
|
519
|
+
and Linux. It may work on Windows, but nothing there is verified — treat it
|
|
520
|
+
as unsupported until this note changes.
|
|
521
|
+
|
|
522
|
+
## Uninstalling
|
|
523
|
+
|
|
524
|
+
Easy to remove, no leftovers:
|
|
525
|
+
|
|
526
|
+
```bash
|
|
527
|
+
rulereceipt protect --undo # restores .claude/settings.json byte-for-byte
|
|
528
|
+
npm uninstall -g rulereceipt # or: npm rm rulereceipt in a project
|
|
529
|
+
rm -rf .rulereceipt/ # the local reports/receipts folder, if you want it gone
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
`protect --undo` is only needed if you ran `protect`. Nothing else is
|
|
533
|
+
installed anywhere on your system.
|
|
503
534
|
|
|
504
535
|
## Contact
|
|
505
536
|
|
|
@@ -46,6 +46,8 @@ export interface ApprovalOptions {
|
|
|
46
46
|
allow?: string[];
|
|
47
47
|
/** When set, a `push` action is only gated if it targets this branch (or its target is unknown). */
|
|
48
48
|
scopedBranch?: string;
|
|
49
|
+
/** The current git branch (guard only), so a bare `git push` from a feature branch is not gated by a "push to main" rule. */
|
|
50
|
+
currentBranch?: string;
|
|
49
51
|
}
|
|
50
52
|
interface Occurrence {
|
|
51
53
|
action: Action;
|
|
@@ -75,7 +75,7 @@ export function approvalScopedBranch(rule) {
|
|
|
75
75
|
* push to a DIFFERENT branch returns false, so a feature-branch push is not
|
|
76
76
|
* gated by a rule that names main.
|
|
77
77
|
*/
|
|
78
|
-
function pushTargetsBranch(command, branch) {
|
|
78
|
+
function pushTargetsBranch(command, branch, currentBranch) {
|
|
79
79
|
const m = command.match(/\bgit\s+(?:\S+\s+){0,4}?push\b(.*)/i);
|
|
80
80
|
if (!m)
|
|
81
81
|
return true;
|
|
@@ -88,7 +88,13 @@ function pushTargetsBranch(command, branch) {
|
|
|
88
88
|
const b = tokens[tokens.length - 1];
|
|
89
89
|
return b === branch || b.endsWith(`/${branch}`);
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
// Bare `git push` / `git push origin` — the target is the CURRENT branch. When
|
|
92
|
+
// the guard can tell us that branch, gate only if it is the scoped branch (a
|
|
93
|
+
// bare push from a feature branch is not a push to main). When it is unknown
|
|
94
|
+
// (the check path, or git unavailable), gate to be safe.
|
|
95
|
+
if (currentBranch)
|
|
96
|
+
return currentBranch === branch || currentBranch.endsWith(`/${branch}`);
|
|
97
|
+
return true;
|
|
92
98
|
}
|
|
93
99
|
/** The result for the call at `i`: matched by id when present, else the next result. */
|
|
94
100
|
function resultOf(events, i) {
|
|
@@ -128,7 +134,7 @@ export function approvalOccurrences(events, actions, opts = {}) {
|
|
|
128
134
|
continue;
|
|
129
135
|
// A branch-scoped push rule ("push to main") does not gate a push to a
|
|
130
136
|
// different branch — only main (or a bare push whose target is unknown).
|
|
131
|
-
if (action === "push" && opts.scopedBranch && !pushTargetsBranch(command, opts.scopedBranch))
|
|
137
|
+
if (action === "push" && opts.scopedBranch && !pushTargetsBranch(command, opts.scopedBranch, opts.currentBranch))
|
|
132
138
|
continue;
|
|
133
139
|
const res = resultOf(events, i);
|
|
134
140
|
if (res && res.kind === "tool_result" && res.isError)
|
|
@@ -200,6 +200,10 @@ function outcomeFromOutput(output) {
|
|
|
200
200
|
return false;
|
|
201
201
|
return null;
|
|
202
202
|
}
|
|
203
|
+
/** A test command carrying a leading env-var assignment is a variant of the default run. */
|
|
204
|
+
function isVariantRun(command) {
|
|
205
|
+
return /(?:^|&&|;|\|\||\bthen\b|\bdo\b|\s)\s*[A-Z][A-Z0-9_]+=\S+\s+\S/.test(command);
|
|
206
|
+
}
|
|
203
207
|
/**
|
|
204
208
|
* A command short enough to read in a report.
|
|
205
209
|
*
|
|
@@ -322,6 +326,7 @@ export function runClaimEvidenceChecks(classifications, events) {
|
|
|
322
326
|
failed: stated !== null ? stated : event.isError,
|
|
323
327
|
outcomeReadable: oneRun && (stated !== null || trustExitCode),
|
|
324
328
|
output: event.content.slice(0, 200),
|
|
329
|
+
variant: isVariantRun(pendingRun),
|
|
325
330
|
};
|
|
326
331
|
pendingRuns.delete(resultId);
|
|
327
332
|
unknownSinceRed = null; // a recognised run supersedes anything before it
|
|
@@ -378,6 +383,12 @@ export function runClaimEvidenceChecks(classifications, events) {
|
|
|
378
383
|
if (uncertain === null)
|
|
379
384
|
uncertain = { claim: sentence.trim(), script: unknownSinceRed };
|
|
380
385
|
}
|
|
386
|
+
else if (lastRun.failed && lastRun.variant) {
|
|
387
|
+
// A deliberately-different variant run (env-var prefix) failing does not
|
|
388
|
+
// contradict a claim about the DEFAULT suite — the honest case where
|
|
389
|
+
// "npm test" passes and "DISABLE_LOCKS=1 npm test" is reported failing in
|
|
390
|
+
// the same breath. Not a contradiction; leave it can't-tell.
|
|
391
|
+
}
|
|
381
392
|
else if (lastRun.failed && contradiction === null) {
|
|
382
393
|
contradiction = { claim: sentence.trim(), run: lastRun };
|
|
383
394
|
}
|
package/dist/checks/classify.js
CHANGED
|
@@ -626,7 +626,12 @@ const GATE_ACTIONS = [
|
|
|
626
626
|
];
|
|
627
627
|
const GATE_NEG = String.raw `\b(?:never|don'?t|do\s+not|must\s+not|mustn'?t|should\s+not|shouldn'?t|no)\b`;
|
|
628
628
|
const GATE_CONSENT = String.raw `\b(?:without\s+(?:(?:the\s+)?(?:user'?s?|my|your|an?)\s+)?(?:explicit(?:ly)?\s+|express\s+|prior\s+)?(?:(?:the\s+)?user'?s?\s+|my\s+)?(?:permission|approval|consent|confirmation|instruction|request|sign[- ]?off|go[- ]?ahead|asking|being\s+(?:asked|told|instructed))|unless\s+(?:(?:the\s+)?user|i|you\s+are|explicitly)\s*(?:explicitly\s+)?(?:asks?|asked|requests?|requested|says?|tells?|told|instructs?|instructed|approves?|approved|confirms?)|until\s+(?:the\s+)?user\s+(?:confirms|approves|says|asks))\b`;
|
|
629
|
-
|
|
629
|
+
// "confirm" alone means VERIFY, not "ask the user" — found on unseen data
|
|
630
|
+
// 2026-09-29: a file note "`extension-report.py` … confirm before committing"
|
|
631
|
+
// became a blanket commit-approval gate. So a bare "confirm"/"check" no longer
|
|
632
|
+
// counts; it must be "confirm/check WITH me/the user". "ask", "get approval" and
|
|
633
|
+
// "wait for approval" before the action still count.
|
|
634
|
+
const GATE_ASK_BEFORE = String.raw `\b(?:ask|(?:check|confirm)\s+with\s+(?:me|the\s+user|us)|get\s+(?:approval|permission|sign[- ]?off)|wait\s+for\s+(?:(?:the\s+)?(?:user|me)|approval|confirmation|explicit|sign[- ]?off))\b(?:\s+\w+){0,4}?\s+(?:before|prior\s+to)\b`;
|
|
630
635
|
/**
|
|
631
636
|
* Which gated actions a rule makes conditional on the user's say-so.
|
|
632
637
|
*
|
|
@@ -45,7 +45,21 @@ function editedContentFromEvent(event) {
|
|
|
45
45
|
* open paren or a call — so requiring a boundary after it would reject the
|
|
46
46
|
* arguments.
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* The token sits inside a natural-language sentence (a lowercase word + space
|
|
50
|
+
* right before it, and a space + lowercase word right after) — a MENTION, not
|
|
51
|
+
* code. Found on unseen data 2026-09-29: "Avoid `try-catch` in hot paths" FAILed
|
|
52
|
+
* (and the guard blocked a Write) because "try-catch" appears in the prose
|
|
53
|
+
* "...use a try-catch block...". A real import (`from "lucide-react"`) is not
|
|
54
|
+
* sandwiched in prose (it is bounded by quotes), so it still matches.
|
|
55
|
+
*/
|
|
56
|
+
function isProseSandwich(content, at, pattern) {
|
|
57
|
+
const before = content.slice(Math.max(0, at - 12), at);
|
|
58
|
+
const after = content.slice(at + pattern.length, at + pattern.length + 12);
|
|
59
|
+
return /[a-z]\s$/.test(before) && /^\s[a-z]/.test(after);
|
|
60
|
+
}
|
|
48
61
|
function containsCall(content, pattern) {
|
|
62
|
+
const isCall = pattern.endsWith("(");
|
|
49
63
|
const leadsWithIdentifier = /^[A-Za-z0-9_$]/.test(pattern);
|
|
50
64
|
if (!leadsWithIdentifier) {
|
|
51
65
|
// A CALL like `.forEach(` legitimately follows an object (`arr.forEach()`),
|
|
@@ -79,8 +93,14 @@ function containsCall(content, pattern) {
|
|
|
79
93
|
// continuation — `analytics.track(` is a real call to `track(`. So `.` is
|
|
80
94
|
// NOT in the disqualifying class (fixed 2026-09-26); `_` still is, so
|
|
81
95
|
// `_metar_fetch(` does not match `fetch(`.
|
|
82
|
-
if (!/[A-Za-z0-9_$]/.test(before))
|
|
96
|
+
if (!/[A-Za-z0-9_$]/.test(before)) {
|
|
97
|
+
// A non-call token embedded in a prose sentence is a mention, not code.
|
|
98
|
+
if (!isCall && isProseSandwich(content, at, pattern)) {
|
|
99
|
+
from = at + 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
83
102
|
return true;
|
|
103
|
+
}
|
|
84
104
|
from = at + 1;
|
|
85
105
|
}
|
|
86
106
|
}
|
package/dist/cli.js
CHANGED
|
@@ -14,9 +14,12 @@ import { shadowedAgentsMd } from "./shadowedAgents.js";
|
|
|
14
14
|
import { auditSessions, renderComplianceReport } from "./report/complianceReport.js";
|
|
15
15
|
import { auditProject, renderProjectAudit } from "./audit.js";
|
|
16
16
|
import { evaluateSession } from "./evaluate.js";
|
|
17
|
-
import { buildWrongReport, findTarget } from "./wrong.js";
|
|
17
|
+
import { buildWrongReport, findTarget, reportedLabel } from "./wrong.js";
|
|
18
|
+
import { ghReady, issueTitle, issueCreateArgs, buildMailto, mailtoSubject } from "./wrongSubmit.js";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
18
20
|
import { detectSelfEditedRuleFiles } from "./checks/selfEditedRules.js";
|
|
19
21
|
import { scanHistory, renderHistory } from "./historyReport.js";
|
|
22
|
+
import { explainRule, renderWhy } from "./why.js";
|
|
20
23
|
import { observeSessions, renderNoRules, draftRulesFromHistory } from "./sessionObserve.js";
|
|
21
24
|
import { listSessionRows, renderSessionList } from "./listSessions.js";
|
|
22
25
|
import { runSelfTestChecks, renderSelfTest } from "./selftest.js";
|
|
@@ -949,12 +952,36 @@ program
|
|
|
949
952
|
}
|
|
950
953
|
console.log(renderProjectAudit(a, Boolean(opts.markdown)));
|
|
951
954
|
});
|
|
955
|
+
program
|
|
956
|
+
.command("why <rule...>")
|
|
957
|
+
.description("Everything the tool knows about ONE rule, in one place: where it lives (file:line), whether the agent actually loads it, whether a command or path it names exists, whether it's mechanically checkable (and if not, one suggested rewrite), and how it did over the last 30 days. Fuzzy-matches the rule text; if several match, lists them. Read-only — no verdict is created, nothing is sent.")
|
|
958
|
+
.option("--json", "output machine-readable JSON (the same fields)")
|
|
959
|
+
.action(async (ruleWords, opts) => {
|
|
960
|
+
const cwd = process.cwd();
|
|
961
|
+
const query = ruleWords.join(" ").trim();
|
|
962
|
+
const rules = loadRules(cwd);
|
|
963
|
+
if (rules.length === 0) {
|
|
964
|
+
console.log("No rules file found here, so there is nothing to explain. Run `rulereceipt init` to add one.");
|
|
965
|
+
process.exitCode = 1;
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const result = await explainRule(cwd, query);
|
|
969
|
+
if (opts.json) {
|
|
970
|
+
console.log(JSON.stringify(result, null, 2));
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
console.log(renderWhy(result));
|
|
974
|
+
if (result.matches === 0 || result.candidates)
|
|
975
|
+
process.exitCode = 1;
|
|
976
|
+
});
|
|
952
977
|
program
|
|
953
978
|
.command("wrong <rule>")
|
|
954
979
|
.description("A verdict looks wrong? Builds a report of that rule, the verdict, how it was decided and the session lines around it, with obvious secrets masked. Written to a local file and shown first; prints a GitHub issue link for you to open. Nothing is sent.")
|
|
955
980
|
.option("--transcript <path>", "use a specific session file (same as check)")
|
|
956
981
|
.option("--out <path>", "where to write the report (default .rulereceipt/wrong-<handle>.md)")
|
|
957
982
|
.option("--no-context", "leave out the session lines around the evidence")
|
|
983
|
+
.option("--submit", "after showing the report, offer to open a PUBLIC GitHub issue (asks first; needs gh)")
|
|
984
|
+
.option("--email", "print a mailto: to send the report privately to the maintainer")
|
|
958
985
|
.action(async (ruleArg, opts) => {
|
|
959
986
|
const cwd = process.cwd();
|
|
960
987
|
const rules = loadRules(cwd);
|
|
@@ -974,7 +1001,13 @@ program
|
|
|
974
1001
|
const { results } = await evaluateSession(cwd, rules, events, false, needsLlmResult);
|
|
975
1002
|
const target = findTarget(ruleArg, rules, results);
|
|
976
1003
|
if (!target) {
|
|
977
|
-
console.log(`No checked rule matches "${ruleArg}"
|
|
1004
|
+
console.log(`No checked rule matches "${ruleArg}".`);
|
|
1005
|
+
const valid = results.map((r) => ` ${r.ruleId} ${r.ruleTitle.replace(/\s+/g, " ").slice(0, 70)}`);
|
|
1006
|
+
if (valid.length > 0) {
|
|
1007
|
+
console.log("Valid ids from the latest session (use one of these, or the handle from `rulereceipt check --json`):");
|
|
1008
|
+
for (const line of valid.slice(0, 40))
|
|
1009
|
+
console.log(line);
|
|
1010
|
+
}
|
|
978
1011
|
process.exitCode = 1;
|
|
979
1012
|
return;
|
|
980
1013
|
}
|
|
@@ -991,8 +1024,51 @@ program
|
|
|
991
1024
|
writeFileSync(outPath, report.markdown);
|
|
992
1025
|
console.log(report.markdown);
|
|
993
1026
|
console.log(`\nSaved to ${outPath}. Nothing was sent.`);
|
|
994
|
-
|
|
995
|
-
|
|
1027
|
+
const reported = reportedLabel(target.result);
|
|
1028
|
+
if (opts.submit) {
|
|
1029
|
+
// PUBLIC issue. Preview was just printed; --yes does NOT bypass this ask.
|
|
1030
|
+
if (!ghReady()) {
|
|
1031
|
+
console.log("\ngh (GitHub CLI) is not installed or not logged in — nothing was sent.");
|
|
1032
|
+
console.log("Install/login with `gh auth login`, or open this pre-filled link yourself:");
|
|
1033
|
+
console.log(report.issueUrl);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const ok = await confirmYesNo("\nThis creates a PUBLIC issue on github.com/rulereceipt/rulereceipt from your GitHub account. Anyone can read it. Send it? (y/N) ");
|
|
1037
|
+
if (!ok) {
|
|
1038
|
+
console.log("Not sent. The report is saved locally; you can open the link above anytime.");
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const title = issueTitle(reported, target.rule.title);
|
|
1042
|
+
let res = spawnSync("gh", issueCreateArgs(title, report.markdown, true), { encoding: "utf-8" });
|
|
1043
|
+
if (res.status !== 0) {
|
|
1044
|
+
// The wrong-verdict label may not exist yet: retry without it.
|
|
1045
|
+
res = spawnSync("gh", issueCreateArgs(title, report.markdown, false), { encoding: "utf-8" });
|
|
1046
|
+
}
|
|
1047
|
+
if (res.status === 0) {
|
|
1048
|
+
const url = (res.stdout || "").trim();
|
|
1049
|
+
console.log(`\nOpened: ${url || "issue created"}`);
|
|
1050
|
+
}
|
|
1051
|
+
else {
|
|
1052
|
+
console.log("\nCould not create the issue automatically — nothing was sent. Open this link instead:");
|
|
1053
|
+
console.log(report.issueUrl);
|
|
1054
|
+
process.exitCode = 1;
|
|
1055
|
+
}
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (opts.email) {
|
|
1059
|
+
const { url, trimmed } = buildMailto(mailtoSubject(target.rule.title), report.markdown);
|
|
1060
|
+
console.log("\nSend privately to the maintainer:");
|
|
1061
|
+
console.log(url);
|
|
1062
|
+
console.log(`\nIf your mail app doesn't open, email hello@rulereceipt.dev and attach: ${outPath}`);
|
|
1063
|
+
if (trimmed)
|
|
1064
|
+
console.log("(The report was long, so the email body is trimmed — attach the saved file above.)");
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
// No flag: show the three ways to send, plus the pre-filled link.
|
|
1068
|
+
console.log(`\nRead it first, then:`);
|
|
1069
|
+
console.log(` Send publicly: rulereceipt wrong ${ruleArg} --submit`);
|
|
1070
|
+
console.log(` Send privately: rulereceipt wrong ${ruleArg} --email`);
|
|
1071
|
+
console.log(` Or open: ${report.issueUrl}`);
|
|
996
1072
|
});
|
|
997
1073
|
program
|
|
998
1074
|
.command("digest")
|
package/dist/guard.js
CHANGED
|
@@ -8,6 +8,23 @@ import { loadOverrides, ruleFingerprint, ratifiedForbids } from "./overrides.js"
|
|
|
8
8
|
import { commandRunsLiteral } from "./checks/proposedAction.js";
|
|
9
9
|
import { approvalOccurrences, allowListed, approvalCommandShort, approvalScopedBranch } from "./checks/approvalGate.js";
|
|
10
10
|
import { readTranscriptFromFile } from "./parsers/transcriptParser.js";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
/**
|
|
13
|
+
* The current git branch in `cwd`, or undefined if it can't be determined —
|
|
14
|
+
* so a bare `git push` from a feature branch is not gated by a "push to main"
|
|
15
|
+
* rule. Fails open (never throws, never blocks) if git is unavailable.
|
|
16
|
+
*/
|
|
17
|
+
function gitCurrentBranch(cwd) {
|
|
18
|
+
try {
|
|
19
|
+
// `symbolic-ref --short HEAD` gives the branch name even on an unborn branch
|
|
20
|
+
// (no commits yet); `rev-parse --abbrev-ref` returns "HEAD" there.
|
|
21
|
+
const b = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
|
|
22
|
+
return b && b !== "HEAD" ? b : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
11
28
|
import { readFileSync } from "node:fs";
|
|
12
29
|
import { homedir } from "node:os";
|
|
13
30
|
import { join } from "node:path";
|
|
@@ -204,7 +221,7 @@ function unapprovedGate(cwd, command, events) {
|
|
|
204
221
|
const gates = classifyRules(loadRules(cwd)).filter((c) => c.kind === "approvalGate");
|
|
205
222
|
for (const { rule, actions } of gates) {
|
|
206
223
|
const proposed = { role: "assistant", kind: "tool_use", toolName: "Bash", input: { command }, timestamp: "", permissionMode: "dontAsk" };
|
|
207
|
-
const occ = approvalOccurrences([...events, proposed], actions, { scopedBranch: approvalScopedBranch(rule) });
|
|
224
|
+
const occ = approvalOccurrences([...events, proposed], actions, { scopedBranch: approvalScopedBranch(rule), currentBranch: gitCurrentBranch(cwd) });
|
|
208
225
|
const last = occ[occ.length - 1];
|
|
209
226
|
// Compare against the SAME canonical short the occurrence uses (mention
|
|
210
227
|
// segments dropped, `sh -c` unwrapped) — a raw-string compare missed a
|
package/dist/historyReport.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { listAllSessions } from "./adapters/index.js";
|
|
3
3
|
import { evaluateSession } from "./evaluate.js";
|
|
4
|
+
/**
|
|
5
|
+
* One-line clip that ends on a whole word with an ellipsis, never mid-sentence.
|
|
6
|
+
* Found by a real test 2026-09-29: a break quote was cut as "...so no prompt was".
|
|
7
|
+
*/
|
|
8
|
+
function clip(s, n) {
|
|
9
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
10
|
+
if (one.length <= n)
|
|
11
|
+
return one;
|
|
12
|
+
const cut = one.slice(0, n);
|
|
13
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
14
|
+
return `${(lastSpace > n * 0.6 ? cut.slice(0, lastSpace) : cut).replace(/[\s,;:.—-]+$/, "")}…`;
|
|
15
|
+
}
|
|
4
16
|
function needsLlmResult(rule) {
|
|
5
17
|
return { ruleId: rule.id, ruleTitle: rule.title, ruleSource: rule.source, status: "UNCLEAR", needsHuman: true, evidence: "" };
|
|
6
18
|
}
|
|
@@ -125,11 +137,11 @@ export function renderHistory(s, projectName, now = Date.now()) {
|
|
|
125
137
|
out.push(`${who} broke your rules ${s.totalBrokenCount} time${s.totalBrokenCount === 1 ? "" : "s"}.`);
|
|
126
138
|
out.push("");
|
|
127
139
|
for (const b of s.breaks.slice(0, 10)) {
|
|
128
|
-
const title = b.ruleTitle
|
|
140
|
+
const title = clip(b.ruleTitle, 60);
|
|
129
141
|
const when = relDate(b.lastMs, now);
|
|
130
142
|
out.push(` x ${title} ${b.count} time${b.count === 1 ? "" : "s"} last: ${when}`);
|
|
131
143
|
if (b.quote)
|
|
132
|
-
out.push(` ${b.quote
|
|
144
|
+
out.push(` ${clip(b.quote, 100)}`);
|
|
133
145
|
}
|
|
134
146
|
}
|
|
135
147
|
out.push("");
|
|
@@ -8,4 +8,4 @@ import type { Rule } from "../types.js";
|
|
|
8
8
|
* ("your file never leaves the page") is only true because nothing in
|
|
9
9
|
* this function or in classify.ts touches Node APIs; keep it that way.
|
|
10
10
|
*/
|
|
11
|
-
export declare function parseClaudeMdText(
|
|
11
|
+
export declare function parseClaudeMdText(rawInput: string, source: "global" | "project"): Rule[];
|
|
@@ -83,6 +83,25 @@ function stripHtmlComments(raw) {
|
|
|
83
83
|
const stripped = masked.replace(/<!--[\s\S]*?-->/g, (m) => "\n".repeat((m.match(/\n/g) ?? []).length));
|
|
84
84
|
return stripped.replace(/\u0000CODE(\d+)\u0000/g, (_, i) => spans[Number(i)]);
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* A whole-line `@import` directive (`@AGENTS.md`, `@docs/rules.md`) is how Claude
|
|
88
|
+
* Code pulls in another file — it is NOT itself a rule. Blank those lines (keeping
|
|
89
|
+
* the line, so source lines below stay right) so a CLAUDE.md whose body is just
|
|
90
|
+
* `@AGENTS.md` does not report the import as a phantom one-line rule. Imports
|
|
91
|
+
* inside code spans/fences are left untouched (they are examples, not directives).
|
|
92
|
+
*/
|
|
93
|
+
function stripImportDirectiveLines(raw) {
|
|
94
|
+
const spans = [];
|
|
95
|
+
const masked = raw.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`/g, (m) => {
|
|
96
|
+
spans.push(m);
|
|
97
|
+
return `\u0000CODE${spans.length - 1}\u0000`;
|
|
98
|
+
});
|
|
99
|
+
const blanked = masked
|
|
100
|
+
.split("\n")
|
|
101
|
+
.map((line) => (/^\s*@[^\s]+\s*$/.test(line) ? "" : line))
|
|
102
|
+
.join("\n");
|
|
103
|
+
return blanked.replace(/\u0000CODE(\d+)\u0000/g, (_, i) => spans[Number(i)]);
|
|
104
|
+
}
|
|
86
105
|
function normalizeSetextHeaders(lines) {
|
|
87
106
|
const out = [...lines];
|
|
88
107
|
// Fence-aware for the same reason as the main pass: a row of dashes inside
|
|
@@ -134,7 +153,10 @@ function normalizeSetextHeaders(lines) {
|
|
|
134
153
|
* ("your file never leaves the page") is only true because nothing in
|
|
135
154
|
* this function or in classify.ts touches Node APIs; keep it that way.
|
|
136
155
|
*/
|
|
137
|
-
export function parseClaudeMdText(
|
|
156
|
+
export function parseClaudeMdText(rawInput, source) {
|
|
157
|
+
// Whole-line @import directives are file references, not rules — drop them
|
|
158
|
+
// before anything counts or classifies them.
|
|
159
|
+
const raw = stripImportDirectiveLines(rawInput);
|
|
138
160
|
const lines = normalizeSetextHeaders(stripHtmlComments(raw).split("\n"));
|
|
139
161
|
const rules = [];
|
|
140
162
|
// `current` accumulates a numbered-header rule, a bold-rule-header rule,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Blank out fenced code blocks and inline code spans so @tokens inside them are ignored. */
|
|
2
|
+
export declare function stripCodeForImports(md: string): string;
|
|
3
|
+
/** The @import paths a file names, resolved to absolute paths (existence not yet checked). */
|
|
4
|
+
export declare function importTargets(filePath: string, content: string): string[];
|
|
5
|
+
/**
|
|
6
|
+
* Absolute paths of every file transitively @imported by `filePath`, in a stable
|
|
7
|
+
* order, each confirmed to exist as a real file. `filePath` itself is excluded.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveImports(filePath: string): string[];
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
/**
|
|
5
|
+
* Claude Code's @import feature: a rules file can pull in another file with an
|
|
6
|
+
* `@path` reference, and the agent reads the imported file's rules as if they
|
|
7
|
+
* were inline. The canonical case is a CLAUDE.md whose entire body is
|
|
8
|
+
* `@AGENTS.md` (the pattern Anthropic's own docs suggest) — the AGENTS.md IS
|
|
9
|
+
* loaded, not shadowed. Missing this made the tool state something untrue ("AGENTS.md
|
|
10
|
+
* present but not loaded") and silently skip every imported rule (found by
|
|
11
|
+
* independent test on 0.1.74, 2026-09-29).
|
|
12
|
+
*
|
|
13
|
+
* Faithful to how Claude Code resolves them, per its docs:
|
|
14
|
+
* - relative paths resolve against the importing file's directory; `~` is home;
|
|
15
|
+
* - imports inside code spans (`...`) and fenced code blocks (``` ```) do NOT count;
|
|
16
|
+
* - a bounded hop depth (5) guards against import cycles.
|
|
17
|
+
* An @token that resolves to something that is not a real file is ignored, which
|
|
18
|
+
* is what keeps an email address (`name@host`) or an @mention from being treated
|
|
19
|
+
* as an import.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_DEPTH = 5;
|
|
22
|
+
/** Blank out fenced code blocks and inline code spans so @tokens inside them are ignored. */
|
|
23
|
+
export function stripCodeForImports(md) {
|
|
24
|
+
const lines = md.split(/\r?\n/);
|
|
25
|
+
let inFence = false;
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
29
|
+
inFence = !inFence;
|
|
30
|
+
out.push("");
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (inFence) {
|
|
34
|
+
out.push("");
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
out.push(line.replace(/`[^`]*`/g, " "));
|
|
38
|
+
}
|
|
39
|
+
return out.join("\n");
|
|
40
|
+
}
|
|
41
|
+
function realOr(p) {
|
|
42
|
+
try {
|
|
43
|
+
return realpathSync(p);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function isFile(p) {
|
|
50
|
+
try {
|
|
51
|
+
return statSync(p).isFile();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The @import paths a file names, resolved to absolute paths (existence not yet checked). */
|
|
58
|
+
export function importTargets(filePath, content) {
|
|
59
|
+
const base = dirname(filePath);
|
|
60
|
+
const stripped = stripCodeForImports(content);
|
|
61
|
+
const targets = [];
|
|
62
|
+
// An import is `@` at line start or after whitespace, then a path with no spaces.
|
|
63
|
+
const re = /(?:^|\s)@(\S+)/g;
|
|
64
|
+
let m;
|
|
65
|
+
while ((m = re.exec(stripped)) !== null) {
|
|
66
|
+
let p = m[1].replace(/[).,;:'"]+$/, ""); // trailing prose punctuation is not part of the path
|
|
67
|
+
if (p.length === 0)
|
|
68
|
+
continue;
|
|
69
|
+
if (p === "~" || p.startsWith("~/"))
|
|
70
|
+
p = resolve(homedir(), p.slice(2));
|
|
71
|
+
else if (!isAbsolute(p))
|
|
72
|
+
p = resolve(base, p);
|
|
73
|
+
targets.push(p);
|
|
74
|
+
}
|
|
75
|
+
return targets;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Absolute paths of every file transitively @imported by `filePath`, in a stable
|
|
79
|
+
* order, each confirmed to exist as a real file. `filePath` itself is excluded.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveImports(filePath) {
|
|
82
|
+
const found = [];
|
|
83
|
+
const seen = new Set([realOr(filePath)]);
|
|
84
|
+
const walk = (fp, depth) => {
|
|
85
|
+
if (depth >= MAX_DEPTH)
|
|
86
|
+
return;
|
|
87
|
+
let content;
|
|
88
|
+
try {
|
|
89
|
+
content = readFileSync(fp, "utf-8");
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const target of importTargets(fp, content)) {
|
|
95
|
+
const real = realOr(target);
|
|
96
|
+
if (seen.has(real))
|
|
97
|
+
continue;
|
|
98
|
+
seen.add(real);
|
|
99
|
+
if (!existsSync(target) || !isFile(target))
|
|
100
|
+
continue;
|
|
101
|
+
found.push(target);
|
|
102
|
+
walk(target, depth + 1);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
walk(filePath, 0);
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
@@ -5,7 +5,16 @@ import type { TranscriptEvent } from "../types.js";
|
|
|
5
5
|
* variant could keep its own global rules file under its own home dir.
|
|
6
6
|
*/
|
|
7
7
|
export declare function findClaudeHomeDirNames(): string[];
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Every session file that belongs to this project, newest first.
|
|
10
|
+
*
|
|
11
|
+
* A folder matches when the real cwd stored in its sessions is THIS directory
|
|
12
|
+
* (encoding-independent — handles dots, underscores, spaces, symlinks), or when
|
|
13
|
+
* this directory is a project root and the session's cwd is inside it (a
|
|
14
|
+
* monorepo subfolder like packages/api, so the root check sees that work too).
|
|
15
|
+
* Encoding candidates are only a fallback for folders whose stored cwd can't be
|
|
16
|
+
* read.
|
|
17
|
+
*/
|
|
9
18
|
export declare function listAllSessionFiles(cwd: string): string[];
|
|
10
19
|
export declare function findLatestSessionFile(cwd: string): string | null;
|
|
11
20
|
export { parseLine } from "./transcriptLine.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
1
|
+
import { readFileSync, readdirSync, statSync, realpathSync, existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, dirname, basename } from "node:path";
|
|
3
|
+
import { join, dirname, basename, sep } from "node:path";
|
|
4
4
|
import { parseTranscriptText } from "./transcriptLine.js";
|
|
5
5
|
/**
|
|
6
6
|
* Claude Code stores each session as a JSONL file at:
|
|
@@ -32,8 +32,80 @@ import { parseTranscriptText } from "./transcriptLine.js";
|
|
|
32
32
|
* generalizes to variants never seen on this machine, at the cost of one
|
|
33
33
|
* extra readdir() of the home directory per check — negligible.
|
|
34
34
|
*/
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Claude Code names the per-project directory by mangling the cwd, but the exact
|
|
37
|
+
* rule is not stable or documented across versions: at minimum "/" becomes "-",
|
|
38
|
+
* and observed builds also turn "." "_" and space (every non-alphanumeric) into
|
|
39
|
+
* "-". Guessing the encoding is therefore fragile — a project at
|
|
40
|
+
* /Users/john.doe/my.app, my_project or "My Work" would silently find zero
|
|
41
|
+
* sessions (found by independent test on 0.1.74, 2026-09-29; the whole first
|
|
42
|
+
* screen — check, history, list-sessions, card — showed "No sessions found").
|
|
43
|
+
*
|
|
44
|
+
* So encoding is only a FAST-PATH hint. The source of truth is the `cwd` field
|
|
45
|
+
* every Claude session line carries: this reads the real cwd out of each folder
|
|
46
|
+
* and matches on it (realpath-compared, so symlinks and dotted paths just work),
|
|
47
|
+
* which also survives any future encoding change Claude Code makes.
|
|
48
|
+
*/
|
|
49
|
+
function encodeCandidates(cwd) {
|
|
50
|
+
const slashOnly = cwd.replace(/\//g, "-");
|
|
51
|
+
const allNonAlnum = cwd.replace(/[^A-Za-z0-9]/g, "-");
|
|
52
|
+
return [...new Set([slashOnly, allNonAlnum])];
|
|
53
|
+
}
|
|
54
|
+
function realpathOr(p) {
|
|
55
|
+
try {
|
|
56
|
+
return realpathSync(p);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return p;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The cwd a session folder belongs to, read from the first line that carries it. */
|
|
63
|
+
function sessionCwdOf(sessionFile) {
|
|
64
|
+
let text;
|
|
65
|
+
try {
|
|
66
|
+
text = readFileSync(sessionFile, "utf-8");
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
// The cwd is on every line; scan only until the first hit (usually line 1).
|
|
72
|
+
let from = 0;
|
|
73
|
+
for (let i = 0; i < 200; i++) {
|
|
74
|
+
const nl = text.indexOf("\n", from);
|
|
75
|
+
const line = text.slice(from, nl === -1 ? undefined : nl);
|
|
76
|
+
if (line.includes('"cwd"')) {
|
|
77
|
+
try {
|
|
78
|
+
const cwd = JSON.parse(line).cwd;
|
|
79
|
+
if (typeof cwd === "string" && cwd.length > 0)
|
|
80
|
+
return cwd;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* partial/garbled line: keep scanning */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (nl === -1)
|
|
87
|
+
break;
|
|
88
|
+
from = nl + 1;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** A cwd looks like a real project root, so descendant (monorepo) sessions are safe to pull in. */
|
|
93
|
+
function looksLikeProjectRoot(cwd) {
|
|
94
|
+
return [".git", "CLAUDE.md", "AGENTS.md", "GEMINI.md", ".claude", ".cursor", ".github/copilot-instructions.md"].some((marker) => existsSync(join(cwd, marker)));
|
|
95
|
+
}
|
|
96
|
+
/** Absolute paths of every Claude-Code-style home to search, including CLAUDE_CONFIG_DIR. */
|
|
97
|
+
function claudeHomeDirs() {
|
|
98
|
+
const dirs = new Set();
|
|
99
|
+
for (const name of findClaudeHomeDirNames())
|
|
100
|
+
dirs.add(join(homedir(), name));
|
|
101
|
+
const cfg = process.env.CLAUDE_CONFIG_DIR;
|
|
102
|
+
if (cfg)
|
|
103
|
+
for (const part of cfg.split(",")) {
|
|
104
|
+
const p = part.trim();
|
|
105
|
+
if (p)
|
|
106
|
+
dirs.add(p);
|
|
107
|
+
}
|
|
108
|
+
return [...dirs];
|
|
37
109
|
}
|
|
38
110
|
function listSessionFiles(projectDir) {
|
|
39
111
|
let entries;
|
|
@@ -70,12 +142,61 @@ export function findClaudeHomeDirNames() {
|
|
|
70
142
|
return [];
|
|
71
143
|
}
|
|
72
144
|
}
|
|
73
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Every session file that belongs to this project, newest first.
|
|
147
|
+
*
|
|
148
|
+
* A folder matches when the real cwd stored in its sessions is THIS directory
|
|
149
|
+
* (encoding-independent — handles dots, underscores, spaces, symlinks), or when
|
|
150
|
+
* this directory is a project root and the session's cwd is inside it (a
|
|
151
|
+
* monorepo subfolder like packages/api, so the root check sees that work too).
|
|
152
|
+
* Encoding candidates are only a fallback for folders whose stored cwd can't be
|
|
153
|
+
* read.
|
|
154
|
+
*/
|
|
74
155
|
export function listAllSessionFiles(cwd) {
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
156
|
+
const target = realpathOr(cwd);
|
|
157
|
+
const candidates = new Set(encodeCandidates(cwd));
|
|
158
|
+
const allowDescendants = looksLikeProjectRoot(cwd);
|
|
159
|
+
const files = [];
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
for (const home of claudeHomeDirs()) {
|
|
162
|
+
const projectsDir = join(home, "projects");
|
|
163
|
+
let folders;
|
|
164
|
+
try {
|
|
165
|
+
folders = readdirSync(projectsDir);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
for (const folder of folders) {
|
|
171
|
+
const dir = join(projectsDir, folder);
|
|
172
|
+
const folderFiles = listSessionFiles(dir);
|
|
173
|
+
if (folderFiles.length === 0)
|
|
174
|
+
continue;
|
|
175
|
+
const storedCwd = sessionCwdOf(folderFiles[0]);
|
|
176
|
+
let matches = false;
|
|
177
|
+
if (storedCwd) {
|
|
178
|
+
const real = realpathOr(storedCwd);
|
|
179
|
+
if (real === target)
|
|
180
|
+
matches = true;
|
|
181
|
+
else if (allowDescendants && real.startsWith(target + sep))
|
|
182
|
+
matches = true;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// No readable cwd (older/garbled file): fall back to the name encoding.
|
|
186
|
+
matches = candidates.has(folder);
|
|
187
|
+
}
|
|
188
|
+
if (!matches)
|
|
189
|
+
continue;
|
|
190
|
+
for (const f of folderFiles) {
|
|
191
|
+
if (!seen.has(f)) {
|
|
192
|
+
seen.add(f);
|
|
193
|
+
files.push(f);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
files.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
199
|
+
return files;
|
|
79
200
|
}
|
|
80
201
|
export function findLatestSessionFile(cwd) {
|
|
81
202
|
const all = listAllSessionFiles(cwd);
|
package/dist/rules.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
4
4
|
import { parseClaudeMd } from "./parsers/readClaudeMd.js";
|
|
5
5
|
import { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
|
|
6
6
|
import { loadMemoryRules } from "./parsers/readMemory.js";
|
|
7
|
+
import { resolveImports } from "./parsers/imports.js";
|
|
7
8
|
/**
|
|
8
9
|
* Every place Claude Code actually reads a rule from, at one directory
|
|
9
10
|
* level. Order mirrors the documented load order, broadest first.
|
|
@@ -135,6 +136,42 @@ function ruleSourcesAtLevel(dir) {
|
|
|
135
136
|
}
|
|
136
137
|
// Gemini CLI: single rules file (its AGENTS.md equivalent).
|
|
137
138
|
loaded("GEMINI.md", "Gemini");
|
|
139
|
+
return applyImports(out);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Claude Code follows @imports: a loaded rules file that says `@AGENTS.md` (or
|
|
143
|
+
* `@docs/rules.md`) makes that file part of what the agent reads. So an imported
|
|
144
|
+
* file is NOT shadowed, and its rules ARE checked. This reconciles `out` with
|
|
145
|
+
* that: any file imported by a loaded file is promoted to loaded (a shadowed
|
|
146
|
+
* AGENTS.md a CLAUDE.md imports flips to loaded), and any imported file not
|
|
147
|
+
* already listed is added as a loaded source. Without imports, `out` is
|
|
148
|
+
* unchanged, so existing projects keep their exact rule order and ids.
|
|
149
|
+
*/
|
|
150
|
+
function applyImports(out) {
|
|
151
|
+
const imported = new Set();
|
|
152
|
+
for (const src of out) {
|
|
153
|
+
if (src.status !== "loaded")
|
|
154
|
+
continue;
|
|
155
|
+
for (const target of resolveImports(src.path))
|
|
156
|
+
imported.add(resolve(target));
|
|
157
|
+
}
|
|
158
|
+
if (imported.size === 0)
|
|
159
|
+
return out;
|
|
160
|
+
const present = new Set(out.map((s) => resolve(s.path)));
|
|
161
|
+
for (const src of out) {
|
|
162
|
+
if (src.status === "shadowed" && imported.has(resolve(src.path))) {
|
|
163
|
+
src.status = "loaded";
|
|
164
|
+
src.note = "imported by a loaded CLAUDE.md (@import), so the agent does read it";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Imported files that were not otherwise candidates at this level (e.g. a
|
|
168
|
+
// @docs/rules.md), in a stable order so rule ids stay deterministic.
|
|
169
|
+
for (const path of [...imported].sort()) {
|
|
170
|
+
if (present.has(path))
|
|
171
|
+
continue;
|
|
172
|
+
present.add(path);
|
|
173
|
+
out.push({ path, status: "loaded", format: "imported (@import)" });
|
|
174
|
+
}
|
|
138
175
|
return out;
|
|
139
176
|
}
|
|
140
177
|
/** Every rules file at one directory level that the agent actually loads. */
|
package/dist/shadowedAgents.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, dirname, parse } from "node:path";
|
|
3
|
+
import { join, dirname, parse, resolve } from "node:path";
|
|
4
|
+
import { resolveImports } from "./parsers/imports.js";
|
|
4
5
|
/** Directory-level pairs where a CLAUDE.md shadows an AGENTS.md. */
|
|
5
6
|
const SHADOW_PAIRS = [
|
|
6
7
|
{ claude: "CLAUDE.md", agents: "AGENTS.md" },
|
|
@@ -24,7 +25,11 @@ export function shadowedAgentsMd(cwd) {
|
|
|
24
25
|
const claudePath = join(dir, claude);
|
|
25
26
|
const agentsPath = join(dir, agents);
|
|
26
27
|
if (existsSync(claudePath) && existsSync(agentsPath)) {
|
|
27
|
-
|
|
28
|
+
// If the CLAUDE.md @imports the AGENTS.md, the agent DOES read it — it
|
|
29
|
+
// is not shadowed, and warning that it is would itself be untrue.
|
|
30
|
+
const importedByClaude = resolveImports(claudePath).some((p) => resolve(p) === resolve(agentsPath));
|
|
31
|
+
if (!importedByClaude)
|
|
32
|
+
found.push({ agents: agentsPath, shadowedBy: claudePath });
|
|
28
33
|
}
|
|
29
34
|
}
|
|
30
35
|
if (existsSync(join(dir, ".git")))
|
package/dist/why.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Rule } from "./types.js";
|
|
2
|
+
export interface WhyRule {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
source: "global" | "project";
|
|
6
|
+
location: string;
|
|
7
|
+
loaded: boolean;
|
|
8
|
+
loadNote?: string;
|
|
9
|
+
pathScoped?: string;
|
|
10
|
+
checkable: boolean;
|
|
11
|
+
kind: string;
|
|
12
|
+
suggestion?: string;
|
|
13
|
+
named?: {
|
|
14
|
+
kind: "command" | "file";
|
|
15
|
+
name: string;
|
|
16
|
+
exists: boolean;
|
|
17
|
+
};
|
|
18
|
+
brokenCount: number;
|
|
19
|
+
brokenDates: string[];
|
|
20
|
+
sessionsScanned: number;
|
|
21
|
+
}
|
|
22
|
+
export interface WhyResult {
|
|
23
|
+
query: string;
|
|
24
|
+
matches: number;
|
|
25
|
+
rule?: WhyRule;
|
|
26
|
+
candidates?: {
|
|
27
|
+
title: string;
|
|
28
|
+
location: string;
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
/** Fuzzy-match a rule by the query appearing in its title/body, or the query being the title. */
|
|
32
|
+
export declare function findRules(rules: Rule[], query: string): Rule[];
|
|
33
|
+
export declare function explainRule(cwd: string, query: string): Promise<WhyResult>;
|
|
34
|
+
export declare function renderWhy(r: WhyResult): string;
|
package/dist/why.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { loadRules, describeRuleSources } from "./rules.js";
|
|
5
|
+
import { classifyRule } from "./checks/classify.js";
|
|
6
|
+
import { adviseRule } from "./checkability.js";
|
|
7
|
+
import { scanHistory } from "./historyReport.js";
|
|
8
|
+
/**
|
|
9
|
+
* `rulereceipt why "<rule text>"` — everything the tool already knows about ONE
|
|
10
|
+
* rule, in one place: where it lives, whether the agent even loads it, whether a
|
|
11
|
+
* command/path it names exists, whether it is mechanically checkable (and if
|
|
12
|
+
* not, the smallest edit that would make it), and how it has done in the last 30
|
|
13
|
+
* days. It answers the exact question people ask — "why isn't THIS rule
|
|
14
|
+
* working?" — and it invents nothing: every line is combined from data the
|
|
15
|
+
* engine already produces. Read-only; no verdict is created here.
|
|
16
|
+
*/
|
|
17
|
+
const CHECKABLE_KINDS = new Set([
|
|
18
|
+
"gitBranchPolicy", "fileLifecycle", "codeContent", "approvalGate",
|
|
19
|
+
"claimEvidence", "deterministic", "attribution", "emojiOutput", "ifEditThenTest",
|
|
20
|
+
]);
|
|
21
|
+
/** Fuzzy-match a rule by the query appearing in its title/body, or the query being the title. */
|
|
22
|
+
export function findRules(rules, query) {
|
|
23
|
+
// Normalize both sides to lowercase words separated by single spaces, so
|
|
24
|
+
// punctuation the user won't type (commas, backticks, dashes) doesn't block a
|
|
25
|
+
// match: "clean elegant maintainable" finds "clean, elegant, maintainable".
|
|
26
|
+
const clean = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
27
|
+
const q = clean(query);
|
|
28
|
+
if (q.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
const exact = rules.filter((r) => clean(r.title) === q);
|
|
31
|
+
if (exact.length > 0)
|
|
32
|
+
return exact;
|
|
33
|
+
return rules.filter((r) => {
|
|
34
|
+
const hay = clean(`${r.title} ${r.text}`);
|
|
35
|
+
if (hay.includes(q))
|
|
36
|
+
return true;
|
|
37
|
+
// Reverse direction (query IS roughly the title) only for a title long
|
|
38
|
+
// enough to be distinctive — otherwise a 1-char heading like "A" matches
|
|
39
|
+
// any query that happens to contain that letter.
|
|
40
|
+
const title = clean(r.title);
|
|
41
|
+
return title.length >= 6 && q.includes(title);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** A `npm run <script>` or a backtick file path the rule names, and whether it exists. */
|
|
45
|
+
function namedCommandOrPath(rule, cwd) {
|
|
46
|
+
const text = `${rule.title} ${rule.text}`;
|
|
47
|
+
const script = text.match(/\b(?:npm|pnpm|yarn)\s+run\s+([\w:-]+)/);
|
|
48
|
+
if (script) {
|
|
49
|
+
let exists = false;
|
|
50
|
+
try {
|
|
51
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
|
|
52
|
+
exists = Boolean(pkg.scripts && script[1] in pkg.scripts);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* no package.json: report not found */
|
|
56
|
+
}
|
|
57
|
+
return { kind: "command", name: `${script[1]}`, exists };
|
|
58
|
+
}
|
|
59
|
+
const path = text.match(/`([^`\s]+\.[a-z0-9]{1,6})`/i);
|
|
60
|
+
if (path && !path[1].includes("://")) {
|
|
61
|
+
const p = isAbsolute(path[1]) ? path[1] : join(cwd, path[1]);
|
|
62
|
+
return { kind: "file", name: path[1], exists: existsSync(p) };
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
export async function explainRule(cwd, query) {
|
|
67
|
+
const rules = loadRules(cwd);
|
|
68
|
+
const matches = findRules(rules, query);
|
|
69
|
+
if (matches.length === 0)
|
|
70
|
+
return { query, matches: 0 };
|
|
71
|
+
if (matches.length > 1) {
|
|
72
|
+
return {
|
|
73
|
+
query,
|
|
74
|
+
matches: matches.length,
|
|
75
|
+
candidates: matches.slice(0, 12).map((r) => ({ title: r.title, location: locationOf(r) })),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const rule = matches[0];
|
|
79
|
+
const graph = describeRuleSources(cwd);
|
|
80
|
+
const src = rule.sourcePath ? graph.find((e) => e.path === rule.sourcePath) : undefined;
|
|
81
|
+
const cls = classifyRule(rule);
|
|
82
|
+
const checkable = CHECKABLE_KINDS.has(cls.kind);
|
|
83
|
+
const advice = checkable ? null : adviseRule(rule);
|
|
84
|
+
let brokenCount = 0;
|
|
85
|
+
let brokenDates = [];
|
|
86
|
+
let sessionsScanned = 0;
|
|
87
|
+
try {
|
|
88
|
+
const hist = await scanHistory(cwd, rules, 30);
|
|
89
|
+
sessionsScanned = hist.sessionsScanned;
|
|
90
|
+
const b = hist.breaks.find((x) => x.ruleId === rule.id && x.ruleTitle === rule.title);
|
|
91
|
+
if (b) {
|
|
92
|
+
brokenCount = b.count;
|
|
93
|
+
brokenDates = [new Date(b.lastMs).toISOString().slice(0, 10)];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* history is best-effort; a rule can still be explained without it */
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
query,
|
|
101
|
+
matches: 1,
|
|
102
|
+
rule: {
|
|
103
|
+
id: rule.id,
|
|
104
|
+
title: rule.title,
|
|
105
|
+
source: rule.source,
|
|
106
|
+
location: locationOf(rule),
|
|
107
|
+
loaded: src ? src.status === "loaded" : true,
|
|
108
|
+
loadNote: src?.note,
|
|
109
|
+
pathScoped: rule.paths ? rule.paths.join(", ") : undefined,
|
|
110
|
+
checkable,
|
|
111
|
+
kind: cls.kind,
|
|
112
|
+
suggestion: advice?.suggestion,
|
|
113
|
+
named: namedCommandOrPath(rule, cwd),
|
|
114
|
+
brokenCount,
|
|
115
|
+
brokenDates,
|
|
116
|
+
sessionsScanned,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function locationOf(rule) {
|
|
121
|
+
if (!rule.sourcePath)
|
|
122
|
+
return "unknown";
|
|
123
|
+
const home = homedir();
|
|
124
|
+
const p = rule.sourcePath.startsWith(home) ? `~${rule.sourcePath.slice(home.length)}` : rule.sourcePath;
|
|
125
|
+
return rule.sourceLine ? `${p}:${rule.sourceLine}` : p;
|
|
126
|
+
}
|
|
127
|
+
export function renderWhy(r) {
|
|
128
|
+
if (r.matches === 0)
|
|
129
|
+
return `No rule matched "${r.query}". Try a distinctive phrase from the rule, or run \`rulereceipt audit\` to list what loads.`;
|
|
130
|
+
if (r.candidates) {
|
|
131
|
+
const lines = r.candidates.map((c) => ` • ${c.title} (${c.location})`);
|
|
132
|
+
return `"${r.query}" matched ${r.matches} rules — narrow it down:\n${lines.join("\n")}`;
|
|
133
|
+
}
|
|
134
|
+
const w = r.rule;
|
|
135
|
+
const out = [];
|
|
136
|
+
out.push(`Rule ${w.id} — ${w.title}`);
|
|
137
|
+
out.push(` at ${w.location} (${w.source})`);
|
|
138
|
+
out.push("");
|
|
139
|
+
out.push(w.loaded ? ` ✓ loaded — the agent reads this file` : ` ✗ NOT loaded — ${w.loadNote ?? "the agent never sees this file"}`);
|
|
140
|
+
if (w.pathScoped)
|
|
141
|
+
out.push(` • path-scoped: only loads when the session touches ${w.pathScoped}`);
|
|
142
|
+
if (w.named)
|
|
143
|
+
out.push(w.named.exists ? ` ✓ names a ${w.named.kind} that exists: ${w.named.name}` : ` ✗ names a ${w.named.kind} that does NOT exist here: ${w.named.name}`);
|
|
144
|
+
out.push(w.checkable
|
|
145
|
+
? ` ✓ mechanically checkable (${w.kind}) — a session is judged against it with quoted evidence`
|
|
146
|
+
: ` • needs your judgment${w.suggestion ? ` — ${w.suggestion}` : ` — no command or file to check it by; a human decides`}`);
|
|
147
|
+
out.push("");
|
|
148
|
+
if (w.brokenCount > 0)
|
|
149
|
+
out.push(` last 30 days: broken ${w.brokenCount}× (last: ${w.brokenDates[0]}) across ${w.sessionsScanned} session${w.sessionsScanned === 1 ? "" : "s"}`);
|
|
150
|
+
else
|
|
151
|
+
out.push(` last 30 days: no proven break across ${w.sessionsScanned} session${w.sessionsScanned === 1 ? "" : "s"}`);
|
|
152
|
+
return out.join("\n");
|
|
153
|
+
}
|
package/dist/wrong.js
CHANGED
|
@@ -27,12 +27,25 @@ export function reportedLabel(r) {
|
|
|
27
27
|
return "Couldn't tell";
|
|
28
28
|
}
|
|
29
29
|
const SECRET_PATTERNS = [
|
|
30
|
+
// Private key blocks and passwords inside URLs go FIRST — before the email
|
|
31
|
+
// rule, which would otherwise partially rewrite a user:pass@host authority.
|
|
32
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted-private-key>"],
|
|
33
|
+
[/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s:/@]+:)[^\s:/@]+(@)/g, "$1<redacted>$2"],
|
|
30
34
|
[/\bsk-[A-Za-z0-9_-]{16,}/g, "<redacted-key>"],
|
|
31
35
|
[/\b(?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{16,}/g, "<redacted-token>"],
|
|
32
36
|
[/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "<redacted-token>"],
|
|
37
|
+
// Stripe secret/publishable/restricted/webhook keys.
|
|
38
|
+
[/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, "<redacted-stripe-key>"],
|
|
39
|
+
[/\bwhsec_[A-Za-z0-9]{16,}/g, "<redacted-stripe-secret>"],
|
|
33
40
|
[/\bAKIA[0-9A-Z]{16}\b/g, "<redacted-aws-key>"],
|
|
34
|
-
|
|
35
|
-
[
|
|
41
|
+
// JSON Web Tokens: header.payload.signature, each base64url.
|
|
42
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "<redacted-jwt>"],
|
|
43
|
+
// The value excludes a leading "<" so this never re-clobbers a more specific
|
|
44
|
+
// placeholder an earlier rule already inserted (e.g. "token <redacted-jwt>").
|
|
45
|
+
[/\b(?:Bearer|token|apikey|api_key|password|passwd|secret)(\s*[:=]\s*|\s+)["']?(?!<redacted)[^\s"']{6,}/gi, "$1<redacted>"],
|
|
46
|
+
// .env-style KEY=value: an UPPERCASE_KEY assigned a non-trivial value. A short
|
|
47
|
+
// value (DISABLE_LOCKS=1) is left alone so ordinary flags are not mangled.
|
|
48
|
+
[/\b([A-Z][A-Z0-9_]{2,})=(["']?)[^\s"']{8,}\2/g, "$1=<redacted>"],
|
|
36
49
|
[/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "<email>"],
|
|
37
50
|
];
|
|
38
51
|
/** Masks the things most likely to be private. Not a guarantee — the user is told to read it. */
|
|
@@ -91,6 +104,7 @@ export function buildWrongReport(input) {
|
|
|
91
104
|
"",
|
|
92
105
|
"> Read this before sharing. Obvious secrets, your home path and email addresses were masked,",
|
|
93
106
|
"> but rule text and session lines are quoted as they are. Edit anything private.",
|
|
107
|
+
"> Masking catches common formats only. Read before sending.",
|
|
94
108
|
"",
|
|
95
109
|
`**Rule handle:** \`${handle}\` (id ${result.ruleId}, ${result.ruleSource})`,
|
|
96
110
|
"",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type SpawnSyncReturns } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* The two ways to send a wrong-verdict report, kept as pure, testable pieces so
|
|
4
|
+
* the command wiring in cli.ts stays thin. Nothing here sends on its own: it
|
|
5
|
+
* builds the exact `gh issue create` argv and the exact mailto: URL, and the
|
|
6
|
+
* caller only runs them AFTER an explicit yes (see cli.ts). `--yes` never skips
|
|
7
|
+
* that preview+confirm — the report is public, so the default answer is No.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SUPPORT_EMAIL = "hello@rulereceipt.dev";
|
|
10
|
+
export declare const REPO = "rulereceipt/rulereceipt";
|
|
11
|
+
export declare const ISSUE_LABEL = "wrong-verdict";
|
|
12
|
+
type Runner = (cmd: string, args: string[]) => SpawnSyncReturns<Buffer>;
|
|
13
|
+
/** True only if `gh` is installed AND logged in. No issue is offered otherwise. */
|
|
14
|
+
export declare function ghReady(run?: Runner): boolean;
|
|
15
|
+
export declare function issueTitle(reported: string, ruleTitle: string): string;
|
|
16
|
+
/** argv for `gh issue create`. The label is included only when withLabel. */
|
|
17
|
+
export declare function issueCreateArgs(title: string, body: string, withLabel: boolean): string[];
|
|
18
|
+
export declare const MAILTO_MAX = 1800;
|
|
19
|
+
export declare function mailtoSubject(ruleTitle: string): string;
|
|
20
|
+
export declare function buildMailto(subject: string, body: string): {
|
|
21
|
+
url: string;
|
|
22
|
+
trimmed: boolean;
|
|
23
|
+
};
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* The two ways to send a wrong-verdict report, kept as pure, testable pieces so
|
|
4
|
+
* the command wiring in cli.ts stays thin. Nothing here sends on its own: it
|
|
5
|
+
* builds the exact `gh issue create` argv and the exact mailto: URL, and the
|
|
6
|
+
* caller only runs them AFTER an explicit yes (see cli.ts). `--yes` never skips
|
|
7
|
+
* that preview+confirm — the report is public, so the default answer is No.
|
|
8
|
+
*/
|
|
9
|
+
export const SUPPORT_EMAIL = "hello@rulereceipt.dev";
|
|
10
|
+
export const REPO = "rulereceipt/rulereceipt";
|
|
11
|
+
export const ISSUE_LABEL = "wrong-verdict";
|
|
12
|
+
const defaultRun = (cmd, args) => spawnSync(cmd, args, { stdio: "ignore" });
|
|
13
|
+
/** True only if `gh` is installed AND logged in. No issue is offered otherwise. */
|
|
14
|
+
export function ghReady(run = defaultRun) {
|
|
15
|
+
try {
|
|
16
|
+
if (run("gh", ["--version"]).status !== 0)
|
|
17
|
+
return false;
|
|
18
|
+
return run("gh", ["auth", "status"]).status === 0;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function issueTitle(reported, ruleTitle) {
|
|
25
|
+
const t = ruleTitle.replace(/\s+/g, " ").trim().slice(0, 60);
|
|
26
|
+
return `Wrong verdict: ${reported} on ${t}`;
|
|
27
|
+
}
|
|
28
|
+
/** argv for `gh issue create`. The label is included only when withLabel. */
|
|
29
|
+
export function issueCreateArgs(title, body, withLabel) {
|
|
30
|
+
const args = ["issue", "create", "--repo", REPO, "--title", title, "--body", body];
|
|
31
|
+
if (withLabel)
|
|
32
|
+
args.push("--label", ISSUE_LABEL);
|
|
33
|
+
return args;
|
|
34
|
+
}
|
|
35
|
+
// A conservative cap so the mailto: fits what mail clients accept; a longer body
|
|
36
|
+
// is trimmed and the user is told to attach the saved .md file instead.
|
|
37
|
+
export const MAILTO_MAX = 1800;
|
|
38
|
+
export function mailtoSubject(ruleTitle) {
|
|
39
|
+
return `RuleReceipt wrong verdict: ${ruleTitle.replace(/\s+/g, " ").trim().slice(0, 80)}`;
|
|
40
|
+
}
|
|
41
|
+
export function buildMailto(subject, body) {
|
|
42
|
+
let b = body;
|
|
43
|
+
let trimmed = false;
|
|
44
|
+
if (b.length > MAILTO_MAX) {
|
|
45
|
+
b = b.slice(0, MAILTO_MAX) + "\n\n[Report trimmed to fit email — attach the saved .md file instead.]";
|
|
46
|
+
trimmed = true;
|
|
47
|
+
}
|
|
48
|
+
const url = `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(b)}`;
|
|
49
|
+
return { url, trimmed };
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.75",
|
|
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",
|