@syv-ai/rulecast 0.4.0 → 0.5.0
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 +8 -2
- package/dist/{chunk-DQUCE2IK.js → chunk-4XS34SYW.js} +52 -18
- package/dist/{chunk-IKONOTME.js → chunk-MB4F7ZXC.js} +61 -6
- package/dist/{chunk-WUFU4LW2.js → chunk-Y3FXJRZB.js} +287 -56
- package/dist/cli.js +31 -15
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +2 -2
- package/dist/{types-Dcra5LXR.d.ts → types-D5y_IokF.d.ts} +28 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Your project's conventions are written down in `AGENTS.md` or `CLAUDE.md`, and your coding agent read them once, forty tool calls ago. rulecast delivers them again at the moment they matter: when the agent is about to break one.
|
|
6
6
|
|
|
7
|
-
A rule pairs a check with a message and a pointer to the doc section it enforces. rulecast runs as an agent hook, so when the agent
|
|
7
|
+
A rule pairs a check with a message and a pointer to the doc section it enforces. rulecast runs as an agent hook, so when the agent changes a file that breaks a rule — with its edit tools or with Bash — it gets told, in the same turn, before it moves on.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
rulecast: 1 rule violated in app/services/users.py
|
|
@@ -20,6 +20,8 @@ Services raise domain exceptions. `app/api/errors.py` maps each one to a status
|
|
|
20
20
|
|
|
21
21
|
That is real output. The agent sees the finding *and* the paragraph of your documentation that explains it, without having to go looking.
|
|
22
22
|
|
|
23
|
+
A file the agent rewrites with `sed -i` or a script is checked like one it changes with `Edit`: rulecast compares the working tree before and after each Bash call. A change no call explains — your own editor, a formatter, a job the agent left running — is reported when the agent stops, and never blocks it.
|
|
24
|
+
|
|
23
25
|
## Getting started
|
|
24
26
|
|
|
25
27
|
```sh
|
|
@@ -85,6 +87,8 @@ Some rules are not advice. `refuse_write: true` refuses the edit before it happe
|
|
|
85
87
|
|
|
86
88
|
A refusal only ever rests on the text the agent is writing, never on a reconstruction rulecast is unsure of, and it happens once per file per session — so a rule can stop a mistake without trapping the agent. Generated code, vendored directories, files that must not change. Everything else is better reported after the write, which is what the other rules do.
|
|
87
89
|
|
|
90
|
+
That covers the edit tools. A write through Bash cannot be refused before it runs, because nothing knows what a command will write, so rulecast catches it right after: the agent is told the file is protected and how to revert it, it cannot stop until it has, and you are told which file it changed.
|
|
91
|
+
|
|
88
92
|
## Commands
|
|
89
93
|
|
|
90
94
|
| Command | Does |
|
|
@@ -220,7 +224,7 @@ rulecast up itself:
|
|
|
220
224
|
|
|
221
225
|
```text
|
|
222
226
|
Set up rulecast in this project. Read
|
|
223
|
-
https://raw.githubusercontent.com/syv-ai/rulecast/v0.
|
|
227
|
+
https://raw.githubusercontent.com/syv-ai/rulecast/v0.5.0/agents/SETUP.md
|
|
224
228
|
and follow it. Show me every file it creates or changes before I commit anything.
|
|
225
229
|
```
|
|
226
230
|
|
|
@@ -256,6 +260,8 @@ Node ≥ 20.12. Linux and macOS; Windows is not supported.
|
|
|
256
260
|
|
|
257
261
|
The edit hook is budgeted to finish in under 500 ms at p95 — measured from process start to exit, on a 30-rule project with warm caches, excluding `llm` rules. `pnpm perf` replays 50 edit events and checks it. The last measurement, on an Apple M3 Pro with all six detectors configured, was p50 170 ms and p95 187 ms.
|
|
258
262
|
|
|
263
|
+
The hook before every Bash call only records the working tree: it is held to 150 ms over a hook that does nothing, and adds 135–150 ms at p95 on a 20,000-file repository (`microsoft/vscode`), nearly all of it git finding untracked files. The hook after a Bash call that changed a file is an edit event, under the same 500 ms budget.
|
|
264
|
+
|
|
259
265
|
The budget is what shapes the design: one detector run per kind per event, kinds in parallel, slow tools defaulted to `verify`, content-addressed caches on disk, no daemon. When an edit runs long, rulecast delivers what finished and builds the rest in the background rather than making the agent wait.
|
|
260
266
|
|
|
261
267
|
## Status
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/core/version.ts
|
|
2
|
-
var VERSION = "0.
|
|
2
|
+
var VERSION = "0.5.0";
|
|
3
3
|
function parseVersion(text) {
|
|
4
4
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
|
|
5
5
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
@@ -568,6 +568,15 @@ var BACKLOG_HEADING = "backlog in files you touched (not from your edit; leave i
|
|
|
568
568
|
var WARNINGS_HEADING = "rulecast warnings:";
|
|
569
569
|
var CONVENTIONS_TITLE = "rulecast: conventions for the files you are working on";
|
|
570
570
|
var NOTICES_HEADING = "rulecast notices (for the user):";
|
|
571
|
+
var SWEPT_HEADING = "changed outside your tool calls (fix them if a process you started made them; they do not block):";
|
|
572
|
+
var SWEPT_BLOCK = "\0swept:";
|
|
573
|
+
var PROTECTED = "This file is protected; revert your change.";
|
|
574
|
+
var DIRTY_ADVICE = "It had uncommitted changes before this session: undo only your change, not the whole file.";
|
|
575
|
+
var refusedLines = (refused) => [
|
|
576
|
+
`${refused.file} (${refused.rule}): ${PROTECTED}`,
|
|
577
|
+
` ${refused.dirtyAtStart ? DIRTY_ADVICE : `git checkout -- ${refused.file}`}`
|
|
578
|
+
];
|
|
579
|
+
var SHELL_TRIGGER = "changed by your Bash command";
|
|
571
580
|
var backlogLine = (summary) => ` ${summary.rule} \xD7${summary.count} in ${summary.file}`;
|
|
572
581
|
var backlogMoreLine = (count) => ` \u2026and ${count} more`;
|
|
573
582
|
var warningLine = (warning) => ` - ${warning}`;
|
|
@@ -578,12 +587,16 @@ var overflowLines = (rules, path7) => [
|
|
|
578
587
|
];
|
|
579
588
|
var WRAP = 96;
|
|
580
589
|
var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
590
|
+
function violatedTitle(rules, file, via) {
|
|
591
|
+
const where = via === "shell" ? file === null ? ` in files ${SHELL_TRIGGER}` : ` in ${file}, ${SHELL_TRIGGER}` : file === null ? "" : ` in ${file}`;
|
|
592
|
+
return `rulecast: ${plural(rules, "rule")} violated${where}`;
|
|
593
|
+
}
|
|
581
594
|
function header(delivery) {
|
|
582
595
|
if (delivery.findings.length > 0) {
|
|
583
596
|
const rules = new Set(delivery.findings.map((finding) => finding.rule)).size + delivery.omitted.rules;
|
|
584
597
|
const files = new Set(delivery.findings.map((finding) => finding.file));
|
|
585
|
-
const
|
|
586
|
-
return
|
|
598
|
+
const file = files.size === 1 && delivery.omitted.rules === 0 ? [...files][0] : null;
|
|
599
|
+
return violatedTitle(rules, file, delivery.via);
|
|
587
600
|
}
|
|
588
601
|
if (delivery.references.length > 0 || delivery.preexistingSummary.length > 0) {
|
|
589
602
|
return CONVENTIONS_TITLE;
|
|
@@ -642,8 +655,8 @@ function referenceLines(reference) {
|
|
|
642
655
|
}
|
|
643
656
|
}
|
|
644
657
|
}
|
|
645
|
-
function omittedFor(omitted, rule) {
|
|
646
|
-
const entry = omitted.findings.find((finding) => finding.rule === rule);
|
|
658
|
+
function omittedFor(omitted, rule, swept) {
|
|
659
|
+
const entry = omitted.findings.find((finding) => finding.rule === rule && finding.swept === true === swept);
|
|
647
660
|
return { count: entry?.count ?? 0, files: entry?.files ?? 0 };
|
|
648
661
|
}
|
|
649
662
|
function ruleBlock(rule, findings, template, dropped, options) {
|
|
@@ -677,12 +690,13 @@ var deliveryCost = {
|
|
|
677
690
|
* It names a file when every *delivered* finding is in one, and the budget may cut a rule down to
|
|
678
691
|
* findings in a single file however many files it fired in; so the longest file name is charged.
|
|
679
692
|
* And if the budget drops every rule that fired, no findings are left and the conventions title
|
|
680
|
-
* prints instead; so with `conventions` the longer of the two is charged.
|
|
693
|
+
* prints instead; so with `conventions` the longer of the two is charged. A shell edit's title
|
|
694
|
+
* names the file or says "files", so both forms are measured and the longer charged.
|
|
681
695
|
*/
|
|
682
|
-
header(rules, files, conventions) {
|
|
696
|
+
header(rules, files, conventions, via) {
|
|
683
697
|
const longest = [...files].reduce((most, file) => file.length > most.length ? file : most, "");
|
|
684
|
-
const
|
|
685
|
-
const violated = rules > 0 ? linesCost([
|
|
698
|
+
const titles = [violatedTitle(rules, longest === "" ? null : longest, via), violatedTitle(rules, null, via)];
|
|
699
|
+
const violated = rules > 0 ? Math.max(...titles.map((title) => linesCost([title, ""]))) : 0;
|
|
686
700
|
return Math.max(violated, conventions ? linesCost([CONVENTIONS_TITLE, ""]) : 0);
|
|
687
701
|
},
|
|
688
702
|
/**
|
|
@@ -696,6 +710,12 @@ var deliveryCost = {
|
|
|
696
710
|
referenceContent(ref, content, location) {
|
|
697
711
|
return linesCost(referenceLines({ ref, state: "full", content })) - deliveryCost.referenceLine(ref, location);
|
|
698
712
|
},
|
|
713
|
+
/** What to undo for one protected file changed with the shell, and the blank line after it. */
|
|
714
|
+
refused(refused) {
|
|
715
|
+
return linesCost([...refusedLines(refused), ""]);
|
|
716
|
+
},
|
|
717
|
+
/** The heading over findings in swept files, once. */
|
|
718
|
+
sweptFrame: linesCost([SWEPT_HEADING]),
|
|
699
719
|
/** The blank line that closes the references, once. */
|
|
700
720
|
referencesEnd: 1,
|
|
701
721
|
/** The warnings heading, once. */
|
|
@@ -725,15 +745,28 @@ function renderAgentText(delivery, options) {
|
|
|
725
745
|
if (title === null && warnings.length === 0 && notices.length === 0) return "";
|
|
726
746
|
const out = [];
|
|
727
747
|
if (title !== null) out.push(title, "");
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
748
|
+
const swept = new Set(delivery.swept ?? []);
|
|
749
|
+
const blocks = (findings, isSwept) => {
|
|
750
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
751
|
+
for (const finding of findings) {
|
|
752
|
+
const group = byRule.get(finding.rule);
|
|
753
|
+
if (group === void 0) byRule.set(finding.rule, [finding]);
|
|
754
|
+
else group.push(finding);
|
|
755
|
+
}
|
|
756
|
+
for (const [rule, ruleFindings] of byRule) {
|
|
757
|
+
const dropped = omittedFor(delivery.omitted, rule, isSwept);
|
|
758
|
+
out.push(...ruleBlock(rule, ruleFindings, delivery.templates[rule], dropped, options), "");
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
blocks(
|
|
762
|
+
delivery.findings.filter((finding) => !swept.has(finding.file)),
|
|
763
|
+
false
|
|
764
|
+
);
|
|
765
|
+
for (const refused of delivery.refused ?? []) out.push(...refusedLines(refused), "");
|
|
766
|
+
const outside = delivery.findings.filter((finding) => swept.has(finding.file));
|
|
767
|
+
if (outside.length > 0) {
|
|
768
|
+
out.push(SWEPT_HEADING);
|
|
769
|
+
blocks(outside, true);
|
|
737
770
|
}
|
|
738
771
|
if (delivery.preexistingSummary.length > 0 || delivery.omitted.preexisting > 0) {
|
|
739
772
|
out.push(BACKLOG_HEADING);
|
|
@@ -1081,6 +1114,7 @@ export {
|
|
|
1081
1114
|
LLM_PROVIDERS,
|
|
1082
1115
|
defaultDetectorSettings,
|
|
1083
1116
|
emptyDelivery,
|
|
1117
|
+
SWEPT_BLOCK,
|
|
1084
1118
|
measureRuleBlock,
|
|
1085
1119
|
deliveryCost,
|
|
1086
1120
|
renderAgentText,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
DeadlineError,
|
|
4
4
|
LlmUnavailableError,
|
|
5
5
|
MODEL_ALIASES,
|
|
6
|
+
emptyDelivery,
|
|
6
7
|
errorMessage,
|
|
7
8
|
isNotFound,
|
|
8
9
|
lineStarts,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
renderAgentText,
|
|
16
17
|
resolveModel,
|
|
17
18
|
sourceReader
|
|
18
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-4XS34SYW.js";
|
|
19
20
|
|
|
20
21
|
// src/core/detection/registry.ts
|
|
21
22
|
function createRegistry(detectors) {
|
|
@@ -1048,7 +1049,9 @@ var common = z7.object({
|
|
|
1048
1049
|
agent_id: z7.string().min(1).optional(),
|
|
1049
1050
|
agent_type: z7.string().optional(),
|
|
1050
1051
|
source: z7.string().optional(),
|
|
1051
|
-
prompt: z7.string().optional()
|
|
1052
|
+
prompt: z7.string().optional(),
|
|
1053
|
+
tool_name: z7.string().optional(),
|
|
1054
|
+
tool_use_id: z7.string().min(1).optional()
|
|
1052
1055
|
});
|
|
1053
1056
|
var fileTool = z7.object({
|
|
1054
1057
|
tool_name: z7.string(),
|
|
@@ -1078,7 +1081,9 @@ function parseClaudeCode(input) {
|
|
|
1078
1081
|
agent_id: agentId,
|
|
1079
1082
|
agent_type: agentType,
|
|
1080
1083
|
source,
|
|
1081
|
-
prompt
|
|
1084
|
+
prompt,
|
|
1085
|
+
tool_name: toolName,
|
|
1086
|
+
tool_use_id: toolUseId
|
|
1082
1087
|
} = head.data;
|
|
1083
1088
|
const session = agentId === void 0 ? { id } : { id, agentId };
|
|
1084
1089
|
const result = (kind, files = [], completeRead) => ({
|
|
@@ -1086,6 +1091,23 @@ function parseClaudeCode(input) {
|
|
|
1086
1091
|
event: kind === null ? null : { kind, files, cwd, session, ...completeRead === void 0 ? {} : { completeRead } },
|
|
1087
1092
|
warmup: false
|
|
1088
1093
|
});
|
|
1094
|
+
const shell = (kind, failed = false) => ({
|
|
1095
|
+
cwd,
|
|
1096
|
+
event: {
|
|
1097
|
+
kind,
|
|
1098
|
+
files: [],
|
|
1099
|
+
cwd,
|
|
1100
|
+
session,
|
|
1101
|
+
...toolUseId === void 0 ? {} : { toolUseId },
|
|
1102
|
+
...failed ? { failed } : {}
|
|
1103
|
+
},
|
|
1104
|
+
warmup: false
|
|
1105
|
+
});
|
|
1106
|
+
if (toolName === "Bash") {
|
|
1107
|
+
if (hook === "PreToolUse") return shell("shell-before");
|
|
1108
|
+
if (hook === "PostToolUse") return shell("shell-after");
|
|
1109
|
+
if (hook === "PostToolUseFailure") return shell("shell-after", true);
|
|
1110
|
+
}
|
|
1089
1111
|
switch (hook) {
|
|
1090
1112
|
case "PreToolUse": {
|
|
1091
1113
|
const tool = fileTool.safeParse(input);
|
|
@@ -1127,7 +1149,9 @@ function parseClaudeCode(input) {
|
|
|
1127
1149
|
return result(prompt?.startsWith("<task-notification>") ? null : "prompt");
|
|
1128
1150
|
case "SessionStart":
|
|
1129
1151
|
if (source === "compact") return result("reset");
|
|
1130
|
-
|
|
1152
|
+
if (source === "startup" || source === "resume") return { ...result("start"), warmup: true };
|
|
1153
|
+
if (source === "clear" || source === "fork") return result("start");
|
|
1154
|
+
return result(null);
|
|
1131
1155
|
default:
|
|
1132
1156
|
return result(null);
|
|
1133
1157
|
}
|
|
@@ -1147,10 +1171,18 @@ function hookGroups(verifyMs) {
|
|
|
1147
1171
|
{ event: "PreToolUse", matcher: "Edit|Write", timeout: 5 },
|
|
1148
1172
|
{ event: "PostToolUse", matcher: "Read", timeout: 5 },
|
|
1149
1173
|
{ event: "PostToolUse", matcher: "Edit|Write", timeout: 5 },
|
|
1174
|
+
// Around every shell call: the working tree before and after it is how rulecast sees what a
|
|
1175
|
+
// command wrote. After can run an edit event; a command that fails fires the Failure hook instead.
|
|
1176
|
+
{ event: "PreToolUse", matcher: "Bash", timeout: 5 },
|
|
1177
|
+
{ event: "PostToolUse", matcher: "Bash", timeout: 5 },
|
|
1178
|
+
{ event: "PostToolUseFailure", matcher: "Bash", timeout: 5 },
|
|
1150
1179
|
{ event: "Stop", timeout: verifyTimeout },
|
|
1151
1180
|
{ event: "SubagentStop", timeout: verifyTimeout },
|
|
1152
1181
|
{ event: "UserPromptSubmit", timeout: 5 },
|
|
1153
|
-
{ event: "SessionStart", matcher: "startup|resume|compact", timeout: 5 }
|
|
1182
|
+
{ event: "SessionStart", matcher: "startup|resume|compact", timeout: 5 },
|
|
1183
|
+
// A group of its own rather than a wider matcher on the one above: an older install's group
|
|
1184
|
+
// would then no longer match, and upgrading it would leave two hooks firing on every startup.
|
|
1185
|
+
{ event: "SessionStart", matcher: "clear|fork", timeout: 5 }
|
|
1154
1186
|
];
|
|
1155
1187
|
}
|
|
1156
1188
|
function installed(groups, matcher2) {
|
|
@@ -1230,6 +1262,7 @@ var CONTEXT_LIMIT = 1e4;
|
|
|
1230
1262
|
var CONTEXT_BUDGET = 9e3;
|
|
1231
1263
|
var BLOCK_PREAMBLE = `This project's rulecast rules (.rulecast-config.yaml) found problems in code changed in this session. Fix them before you finish. If you believe a finding is wrong, say so to the user rather than changing .rulecast-config.yaml; add a "rulecast-ignore: <rule> <reason>" comment only if they agree.`;
|
|
1232
1264
|
var CAP_PREAMBLE = "rulecast: the agent stopped with these findings unresolved (stop gate limit reached).";
|
|
1265
|
+
var SWEPT_PREAMBLE = "rulecast: files changed outside the agent's tool calls this session have findings (not blocking the agent).";
|
|
1233
1266
|
var DENY_PREAMBLE = "This project's rulecast rules (.rulecast-config.yaml) refuse this write. Change what you are writing, or the file you are writing it to, and try again.";
|
|
1234
1267
|
var NOT_INSTALLED = "rulecast is configured in this project but not installed. Run npm install (or pnpm install), then rulecast doctor.";
|
|
1235
1268
|
var LOCAL_COMMAND = `B="$CLAUDE_PROJECT_DIR"/node_modules/.bin/rulecast; [ -x "$B" ] && exec "$B" hook claude-code; grep -q '"hook_event_name": *"SessionStart"' && printf '%s' '${JSON.stringify({ systemMessage: NOT_INSTALLED })}'; exit 0`;
|
|
@@ -1281,9 +1314,14 @@ ${text2}`, delivery, event)
|
|
|
1281
1314
|
});
|
|
1282
1315
|
case "touch":
|
|
1283
1316
|
case "edit":
|
|
1317
|
+
case "shell-after":
|
|
1284
1318
|
return json2(
|
|
1285
1319
|
withNotices({
|
|
1286
|
-
hookSpecificOutput: {
|
|
1320
|
+
hookSpecificOutput: {
|
|
1321
|
+
// A command that failed fired PostToolUseFailure, which takes context only under its own name.
|
|
1322
|
+
hookEventName: event.failed === true ? "PostToolUseFailure" : "PostToolUse",
|
|
1323
|
+
additionalContext: withinLimit(text2, delivery, event)
|
|
1324
|
+
}
|
|
1287
1325
|
})
|
|
1288
1326
|
);
|
|
1289
1327
|
case "reset":
|
|
@@ -1309,6 +1347,23 @@ ${text2}`, delivery, event);
|
|
|
1309
1347
|
return json2({ systemMessage: notices === "" ? cap : `${notices}
|
|
1310
1348
|
|
|
1311
1349
|
${cap}` });
|
|
1350
|
+
}
|
|
1351
|
+
if ((delivery.swept ?? []).length > 0) {
|
|
1352
|
+
const swept = new Set(delivery.swept);
|
|
1353
|
+
const onlySwept = {
|
|
1354
|
+
...emptyDelivery(),
|
|
1355
|
+
findings: delivery.findings.filter((finding) => swept.has(finding.file)),
|
|
1356
|
+
templates: delivery.templates,
|
|
1357
|
+
omitted: { ...delivery.omitted, findings: delivery.omitted.findings.filter((cut) => cut.swept === true) },
|
|
1358
|
+
swept: delivery.swept
|
|
1359
|
+
};
|
|
1360
|
+
const sweptText = renderAgentText(onlySwept, { ...options, notices: false, warnings: false });
|
|
1361
|
+
const outside = withinLimit(`${SWEPT_PREAMBLE}
|
|
1362
|
+
|
|
1363
|
+
${sweptText}`, delivery, event);
|
|
1364
|
+
return json2({ systemMessage: notices === "" ? outside : `${notices}
|
|
1365
|
+
|
|
1366
|
+
${outside}` });
|
|
1312
1367
|
}
|
|
1313
1368
|
return notices === "" ? NONE : json2({ systemMessage: notices });
|
|
1314
1369
|
default:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DeadlineError,
|
|
3
3
|
LLM_PROVIDERS,
|
|
4
|
+
SWEPT_BLOCK,
|
|
4
5
|
VERSION,
|
|
5
6
|
compileFilter,
|
|
6
7
|
compileRule,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
sectionRange,
|
|
25
26
|
sectionText,
|
|
26
27
|
templateBindings
|
|
27
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-4XS34SYW.js";
|
|
28
29
|
|
|
29
30
|
// src/core/config/load.ts
|
|
30
31
|
import { readFile } from "fs/promises";
|
|
@@ -689,8 +690,8 @@ function fixedRepo(dir, label) {
|
|
|
689
690
|
}
|
|
690
691
|
|
|
691
692
|
// src/core/pipeline.ts
|
|
692
|
-
import { createHash as
|
|
693
|
-
import
|
|
693
|
+
import { createHash as createHash5 } from "crypto";
|
|
694
|
+
import path15 from "path";
|
|
694
695
|
|
|
695
696
|
// src/core/content.ts
|
|
696
697
|
var WORKTREE = { kind: "worktree" };
|
|
@@ -1156,16 +1157,31 @@ async function recordFingerprints(input) {
|
|
|
1156
1157
|
|
|
1157
1158
|
// src/core/baseline/stage.ts
|
|
1158
1159
|
async function touchRecords(input, state, files) {
|
|
1160
|
+
return (await snapshotRecords(input, state, files, null)).records;
|
|
1161
|
+
}
|
|
1162
|
+
var START_SNAPSHOT_MAX = 200;
|
|
1163
|
+
async function startRecords(input, state, files) {
|
|
1164
|
+
return snapshotRecords(input, state, files, {
|
|
1165
|
+
max: START_SNAPSHOT_MAX,
|
|
1166
|
+
deadlineAt: Date.now() + input.limits.editDeadlineMs
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
async function snapshotRecords(input, state, files, bounds) {
|
|
1159
1170
|
const records = [];
|
|
1160
1171
|
const snapshotted = [];
|
|
1161
|
-
|
|
1162
|
-
|
|
1172
|
+
const pending = files.filter((file) => !state.snapshots.has(file));
|
|
1173
|
+
let taken = 0;
|
|
1174
|
+
for (const file of pending) {
|
|
1175
|
+
if (bounds !== null && (taken >= bounds.max || Date.now() >= bounds.deadlineAt)) break;
|
|
1176
|
+
taken++;
|
|
1163
1177
|
const text = await readSourceFile(input.root, file);
|
|
1164
1178
|
if (text === null) continue;
|
|
1165
1179
|
records.push(snapshotRecord(file, snapshotOf(text)));
|
|
1166
1180
|
snapshotted.push(file);
|
|
1167
1181
|
}
|
|
1168
|
-
|
|
1182
|
+
const skipped = pending.length - taken;
|
|
1183
|
+
const timeoutMs = bounds === null ? input.limits.editDeadlineMs : bounds.deadlineAt - Date.now();
|
|
1184
|
+
if (snapshotted.length === 0 || timeoutMs <= 0) return { records, skipped };
|
|
1169
1185
|
records.push(
|
|
1170
1186
|
...await recordFingerprints({
|
|
1171
1187
|
detection: input.detection,
|
|
@@ -1175,11 +1191,11 @@ async function touchRecords(input, state, files) {
|
|
|
1175
1191
|
read: (file) => readSourceFile(input.root, file),
|
|
1176
1192
|
event: "edit",
|
|
1177
1193
|
source: "disk",
|
|
1178
|
-
timeoutMs
|
|
1194
|
+
timeoutMs,
|
|
1179
1195
|
ceiling: { maxFileBytes: input.limits.maxFileBytes, sizeOf: (file) => fileBytes(input.root, file) }
|
|
1180
1196
|
})
|
|
1181
1197
|
);
|
|
1182
|
-
return records;
|
|
1198
|
+
return { records, skipped };
|
|
1183
1199
|
}
|
|
1184
1200
|
async function changesFor(input, event, state, files, read) {
|
|
1185
1201
|
let snapshots = state?.snapshots ?? /* @__PURE__ */ new Map();
|
|
@@ -1274,16 +1290,16 @@ async function resolveRuleContext(resolver, context) {
|
|
|
1274
1290
|
}
|
|
1275
1291
|
function createReferenceResolver(root) {
|
|
1276
1292
|
const texts = /* @__PURE__ */ new Map();
|
|
1277
|
-
const read = (
|
|
1278
|
-
let text = texts.get(
|
|
1293
|
+
const read = (path16) => {
|
|
1294
|
+
let text = texts.get(path16);
|
|
1279
1295
|
if (!text) {
|
|
1280
|
-
text = readSourceFile(root,
|
|
1281
|
-
texts.set(
|
|
1296
|
+
text = readSourceFile(root, path16);
|
|
1297
|
+
texts.set(path16, text);
|
|
1282
1298
|
}
|
|
1283
1299
|
return text;
|
|
1284
1300
|
};
|
|
1285
|
-
const contentOf = async (
|
|
1286
|
-
const text = await read(
|
|
1301
|
+
const contentOf = async (path16, anchor) => {
|
|
1302
|
+
const text = await read(path16);
|
|
1287
1303
|
if (text === null) return null;
|
|
1288
1304
|
if (anchor === null) return text.trimEnd();
|
|
1289
1305
|
const section = sectionRange(text, anchor);
|
|
@@ -1295,14 +1311,14 @@ function createReferenceResolver(root) {
|
|
|
1295
1311
|
if (content === null) return { spec, found: false };
|
|
1296
1312
|
return { spec, found: true, content, hash: fnv1a(content), bytes: Buffer.byteLength(content) };
|
|
1297
1313
|
},
|
|
1298
|
-
async currentHash(
|
|
1299
|
-
const content = await contentOf(
|
|
1314
|
+
async currentHash(path16, anchor) {
|
|
1315
|
+
const content = await contentOf(path16, anchor);
|
|
1300
1316
|
return content === null ? null : fnv1a(content);
|
|
1301
1317
|
},
|
|
1302
|
-
async contains(
|
|
1318
|
+
async contains(path16, parent, child) {
|
|
1303
1319
|
if (parent === null) return true;
|
|
1304
1320
|
if (child === null) return false;
|
|
1305
|
-
const text = await read(
|
|
1321
|
+
const text = await read(path16);
|
|
1306
1322
|
return text !== null && sectionContains(text, parent, child);
|
|
1307
1323
|
}
|
|
1308
1324
|
};
|
|
@@ -1447,7 +1463,13 @@ function emptyWork() {
|
|
|
1447
1463
|
refusals: /* @__PURE__ */ new Map(),
|
|
1448
1464
|
disabled: /* @__PURE__ */ new Map(),
|
|
1449
1465
|
accessed: /* @__PURE__ */ new Map(),
|
|
1450
|
-
noticed: /* @__PURE__ */ new Set()
|
|
1466
|
+
noticed: /* @__PURE__ */ new Set(),
|
|
1467
|
+
editedVia: /* @__PURE__ */ new Map(),
|
|
1468
|
+
swept: [],
|
|
1469
|
+
latestTree: /* @__PURE__ */ new Map(),
|
|
1470
|
+
sessionLatestTree: null,
|
|
1471
|
+
beforeTrees: /* @__PURE__ */ new Map(),
|
|
1472
|
+
startTree: null
|
|
1451
1473
|
};
|
|
1452
1474
|
}
|
|
1453
1475
|
function emptyContext() {
|
|
@@ -1459,6 +1481,19 @@ function foldWork(records) {
|
|
|
1459
1481
|
switch (record.t) {
|
|
1460
1482
|
case "edited":
|
|
1461
1483
|
state.edited = [...state.edited.filter((file) => file !== record.file), record.file];
|
|
1484
|
+
state.editedVia.set(record.file, record.via ?? "tool");
|
|
1485
|
+
state.swept = state.swept.filter((file) => file !== record.file);
|
|
1486
|
+
break;
|
|
1487
|
+
case "swept":
|
|
1488
|
+
state.swept = [...state.swept.filter((file) => file !== record.file), record.file];
|
|
1489
|
+
break;
|
|
1490
|
+
case "tree":
|
|
1491
|
+
state.latestTree.set(record.agent, record.state);
|
|
1492
|
+
state.sessionLatestTree = record.state;
|
|
1493
|
+
if (record.phase === "before" && record.toolUseId !== void 0) {
|
|
1494
|
+
state.beforeTrees.set(record.toolUseId, record.state);
|
|
1495
|
+
}
|
|
1496
|
+
if (record.phase === "start" && state.startTree === null) state.startTree = record.state;
|
|
1462
1497
|
break;
|
|
1463
1498
|
case "accessed": {
|
|
1464
1499
|
const files = state.accessed.get(record.agent) ?? [];
|
|
@@ -1486,6 +1521,13 @@ function foldWork(records) {
|
|
|
1486
1521
|
}
|
|
1487
1522
|
return state;
|
|
1488
1523
|
}
|
|
1524
|
+
function referenceState(work, agent, toolUseId) {
|
|
1525
|
+
const before = toolUseId === void 0 ? void 0 : work.beforeTrees.get(toolUseId);
|
|
1526
|
+
return before ?? work.latestTree.get(agent) ?? work.sessionLatestTree;
|
|
1527
|
+
}
|
|
1528
|
+
function dirtyAtStart(work, file) {
|
|
1529
|
+
return work.startTree !== null && file in work.startTree.entries;
|
|
1530
|
+
}
|
|
1489
1531
|
function foldContext(records) {
|
|
1490
1532
|
let state = emptyContext();
|
|
1491
1533
|
for (const record of records) {
|
|
@@ -1579,11 +1621,16 @@ async function isCovered(spec, delivered, resolver) {
|
|
|
1579
1621
|
}
|
|
1580
1622
|
async function assemble(input) {
|
|
1581
1623
|
const delivery = emptyDelivery();
|
|
1624
|
+
if (input.via !== void 0) delivery.via = input.via;
|
|
1582
1625
|
const context = [];
|
|
1583
1626
|
const touches = input.touches ?? [];
|
|
1584
1627
|
const state = input.context ?? emptyContext();
|
|
1585
1628
|
const fresh = input.findings.filter((finding) => finding.status === "new");
|
|
1586
1629
|
delivery.findings = renderFindings(fresh);
|
|
1630
|
+
const swept = new Set(input.swept ?? []);
|
|
1631
|
+
const sweptWithFindings = [...new Set(fresh.map(({ match }) => match.file))].filter((file) => swept.has(file));
|
|
1632
|
+
if (sweptWithFindings.length > 0) delivery.swept = sweptWithFindings.sort();
|
|
1633
|
+
if ((input.refused ?? []).length > 0) delivery.refused = [...input.refused ?? []];
|
|
1587
1634
|
for (const { rule } of fresh) if (rule.message !== null) delivery.templates[rule.id] = rule.message;
|
|
1588
1635
|
const summaries = /* @__PURE__ */ new Map();
|
|
1589
1636
|
for (const { rule, match, status } of input.findings) {
|
|
@@ -1641,14 +1688,27 @@ function trim(assembled, limits) {
|
|
|
1641
1688
|
const rulesById = new Map(assembled.fresh.map(({ rule }) => [rule.id, rule]));
|
|
1642
1689
|
const limit = limits.maxContextChars;
|
|
1643
1690
|
const render = { maxMatchesPerRule: limits.maxMatchesPerRule };
|
|
1691
|
+
const swept = new Set(delivery.swept ?? []);
|
|
1692
|
+
const blockOf = (finding) => swept.has(finding.file) ? `${SWEPT_BLOCK}${finding.rule}` : finding.rule;
|
|
1693
|
+
const ruleOf = (block) => block.startsWith(SWEPT_BLOCK) ? block.slice(SWEPT_BLOCK.length) : block;
|
|
1644
1694
|
const byRule = /* @__PURE__ */ new Map();
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1695
|
+
const ordered = [
|
|
1696
|
+
...delivery.findings.filter((finding) => !swept.has(finding.file)),
|
|
1697
|
+
...delivery.findings.filter((finding) => swept.has(finding.file))
|
|
1698
|
+
];
|
|
1699
|
+
for (const finding of ordered) {
|
|
1700
|
+
const group = byRule.get(blockOf(finding));
|
|
1701
|
+
if (group === void 0) byRule.set(blockOf(finding), [finding]);
|
|
1648
1702
|
else group.push(finding);
|
|
1649
1703
|
}
|
|
1650
1704
|
const conventions = delivery.references.length > 0 || delivery.preexistingSummary.length > 0;
|
|
1651
|
-
let used = deliveryCost.header(
|
|
1705
|
+
let used = deliveryCost.header(
|
|
1706
|
+
byRule.size,
|
|
1707
|
+
new Set(delivery.findings.map((finding) => finding.file)),
|
|
1708
|
+
conventions,
|
|
1709
|
+
delivery.via
|
|
1710
|
+
) + (delivery.references.length > 0 ? deliveryCost.referencesEnd : 0) + (swept.size > 0 ? deliveryCost.sweptFrame : 0) + // Never cut: what to undo is the one thing the agent must not miss.
|
|
1711
|
+
(delivery.refused ?? []).reduce((sum, refused) => sum + deliveryCost.refused(refused), 0);
|
|
1652
1712
|
const fits = (size) => limit === null || used + size <= limit;
|
|
1653
1713
|
const kept = /* @__PURE__ */ new Map();
|
|
1654
1714
|
const specs = /* @__PURE__ */ new Map();
|
|
@@ -1661,16 +1721,16 @@ function trim(assembled, limits) {
|
|
|
1661
1721
|
const charged = /* @__PURE__ */ new Set();
|
|
1662
1722
|
const orphaned = /* @__PURE__ */ new Set();
|
|
1663
1723
|
const floorWithin = (cap) => {
|
|
1664
|
-
const spent = { chars: 0, kept: /* @__PURE__ */ new Map(), refs: /* @__PURE__ */ new Set(), dropped:
|
|
1724
|
+
const spent = { chars: 0, kept: /* @__PURE__ */ new Map(), refs: /* @__PURE__ */ new Set(), dropped: /* @__PURE__ */ new Set() };
|
|
1665
1725
|
const uncharged = (rule) => [
|
|
1666
1726
|
...new Set(rule.context.map((spec) => spec.ref).filter((ref) => resolvedRefs.has(ref) && !spent.refs.has(ref)))
|
|
1667
1727
|
];
|
|
1668
1728
|
for (const [id, findings] of byRule) {
|
|
1669
|
-
const rule = rulesById.get(id);
|
|
1729
|
+
const rule = rulesById.get(ruleOf(id));
|
|
1670
1730
|
const refs = rule === void 0 ? [] : uncharged(rule);
|
|
1671
|
-
const size = measureRuleBlock(id, [findings[0]], findings, delivery.templates[id], render) + refs.reduce((sum, ref) => sum + lineCost(ref), 0);
|
|
1731
|
+
const size = measureRuleBlock(ruleOf(id), [findings[0]], findings, delivery.templates[ruleOf(id)], render) + refs.reduce((sum, ref) => sum + lineCost(ref), 0);
|
|
1672
1732
|
if (used + spent.chars + size > cap) {
|
|
1673
|
-
spent.dropped
|
|
1733
|
+
spent.dropped.add(id);
|
|
1674
1734
|
continue;
|
|
1675
1735
|
}
|
|
1676
1736
|
spent.chars += size;
|
|
@@ -1685,12 +1745,15 @@ function trim(assembled, limits) {
|
|
|
1685
1745
|
}
|
|
1686
1746
|
return spent;
|
|
1687
1747
|
};
|
|
1748
|
+
let droppedBlocks = 0;
|
|
1688
1749
|
if (limit !== null) {
|
|
1689
1750
|
let floor = floorWithin(limit);
|
|
1690
1751
|
const notice = deliveryCost.overflowNotice(byRule.size);
|
|
1691
|
-
if (floor.dropped > 0) floor = floorWithin(limit - notice);
|
|
1692
|
-
used += floor.chars + (floor.dropped > 0 ? notice : 0);
|
|
1693
|
-
|
|
1752
|
+
if (floor.dropped.size > 0) floor = floorWithin(limit - notice);
|
|
1753
|
+
used += floor.chars + (floor.dropped.size > 0 ? notice : 0);
|
|
1754
|
+
const keptRules = new Set([...floor.kept.keys()].map(ruleOf));
|
|
1755
|
+
droppedBlocks = floor.dropped.size;
|
|
1756
|
+
delivery.omitted.rules = new Set([...floor.dropped].map(ruleOf).filter((rule) => !keptRules.has(rule))).size;
|
|
1694
1757
|
for (const [id, findings] of floor.kept) kept.set(id, findings);
|
|
1695
1758
|
for (const ref of floor.refs) charged.add(ref);
|
|
1696
1759
|
for (const ref of resolvedRefs) if (!charged.has(ref)) orphaned.add(ref);
|
|
@@ -1757,8 +1820,8 @@ function trim(assembled, limits) {
|
|
|
1757
1820
|
for (const [rule, findings] of byRule) {
|
|
1758
1821
|
const shown = kept.get(rule);
|
|
1759
1822
|
if (shown === void 0 || shown.length >= Math.min(findings.length, limits.maxMatchesPerRule)) continue;
|
|
1760
|
-
const template = delivery.templates[rule];
|
|
1761
|
-
const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], findings, template, render) - measureRuleBlock(rule, shown, findings, template, render);
|
|
1823
|
+
const template = delivery.templates[ruleOf(rule)];
|
|
1824
|
+
const delta = measureRuleBlock(ruleOf(rule), [...shown, findings[shown.length]], findings, template, render) - measureRuleBlock(ruleOf(rule), shown, findings, template, render);
|
|
1762
1825
|
if (!fits(delta)) continue;
|
|
1763
1826
|
used += delta;
|
|
1764
1827
|
shown.push(findings[shown.length]);
|
|
@@ -1771,17 +1834,22 @@ function trim(assembled, limits) {
|
|
|
1771
1834
|
const shownFiles = new Set(shown.map((finding) => finding.file));
|
|
1772
1835
|
const dropped = findings.slice(shown.length);
|
|
1773
1836
|
delivery.omitted.findings.push({
|
|
1774
|
-
rule,
|
|
1837
|
+
rule: ruleOf(rule),
|
|
1775
1838
|
count: dropped.length,
|
|
1776
|
-
files: new Set(dropped.map((finding) => finding.file).filter((file) => !shownFiles.has(file))).size
|
|
1839
|
+
files: new Set(dropped.map((finding) => finding.file).filter((file) => !shownFiles.has(file))).size,
|
|
1840
|
+
...rule === ruleOf(rule) ? {} : { swept: true }
|
|
1777
1841
|
});
|
|
1778
1842
|
}
|
|
1779
|
-
delivery.findings = delivery.findings.filter((finding) => kept.get(finding
|
|
1843
|
+
delivery.findings = delivery.findings.filter((finding) => kept.get(blockOf(finding))?.includes(finding) === true);
|
|
1780
1844
|
}
|
|
1781
|
-
return { delivery, overflow:
|
|
1845
|
+
return { delivery, overflow: droppedBlocks > 0 ? assembled.delivery : null, context };
|
|
1782
1846
|
}
|
|
1783
|
-
function gate(fresh, work, agent, maxBlocks) {
|
|
1784
|
-
|
|
1847
|
+
function gate(fresh, work, agent, maxBlocks, swept = /* @__PURE__ */ new Set()) {
|
|
1848
|
+
const blocking = fresh.filter(({ match }) => !swept.has(match.file));
|
|
1849
|
+
const refused = ({ rule, match }) => rule.refuseWrite && work.editedVia.get(match.file) === "shell";
|
|
1850
|
+
if (!blocking.some((finding) => finding.rule.severity === "error" || refused(finding))) {
|
|
1851
|
+
return { stop: "allow", work: [] };
|
|
1852
|
+
}
|
|
1785
1853
|
if ((work.stopBlocks.get(agent) ?? 0) < maxBlocks) return { stop: "block", work: [{ t: "stopBlock", agent }] };
|
|
1786
1854
|
return { stop: "capReached", work: [] };
|
|
1787
1855
|
}
|
|
@@ -1793,7 +1861,7 @@ async function decide(input) {
|
|
|
1793
1861
|
});
|
|
1794
1862
|
const context = [...assembled.context, ...trimmed.context];
|
|
1795
1863
|
if (!input.stopGate) return { delivery: trimmed.delivery, overflow: trimmed.overflow, work: [], context };
|
|
1796
|
-
const gated = gate(assembled.fresh, input.work, input.agent, input.maxBlocks);
|
|
1864
|
+
const gated = gate(assembled.fresh, input.work, input.agent, input.maxBlocks, new Set(input.swept ?? []));
|
|
1797
1865
|
const delivery = { ...trimmed.delivery, stop: gated.stop };
|
|
1798
1866
|
if (gated.stop === "allow") return { delivery, overflow: null, work: gated.work, context: [] };
|
|
1799
1867
|
if (gated.stop === "capReached") return { delivery, overflow: trimmed.overflow, work: gated.work, context: [] };
|
|
@@ -1861,6 +1929,11 @@ import { readFile as readFile4 } from "fs/promises";
|
|
|
1861
1929
|
import path12 from "path";
|
|
1862
1930
|
function notices(input) {
|
|
1863
1931
|
const out = [];
|
|
1932
|
+
for (const { file, rule } of input.refused ?? []) {
|
|
1933
|
+
const key = `refused:${rule}:${file}`;
|
|
1934
|
+
if (input.told.has(key)) continue;
|
|
1935
|
+
out.push({ key, text: `rulecast: the agent changed a protected file with Bash: ${file} (${rule})` });
|
|
1936
|
+
}
|
|
1864
1937
|
const configKey = `config:${input.currentConfigHash ?? "gone"}`;
|
|
1865
1938
|
if (input.startConfigHash !== null && input.startConfigHash !== input.currentConfigHash && !input.told.has(configKey)) {
|
|
1866
1939
|
out.push({
|
|
@@ -1922,11 +1995,87 @@ async function commitSession(dir, agent, decideWith) {
|
|
|
1922
1995
|
});
|
|
1923
1996
|
}
|
|
1924
1997
|
|
|
1998
|
+
// src/core/session/tree.ts
|
|
1999
|
+
import { createHash as createHash4 } from "crypto";
|
|
2000
|
+
import { lstat, readFile as readFile5 } from "fs/promises";
|
|
2001
|
+
import path14 from "path";
|
|
2002
|
+
var HASHED_MAX_BYTES = 1024 * 1024;
|
|
2003
|
+
var GONE = -1;
|
|
2004
|
+
function isGone(state, file) {
|
|
2005
|
+
return state.entries[file]?.[1] === GONE;
|
|
2006
|
+
}
|
|
2007
|
+
function parseStatus(stdout) {
|
|
2008
|
+
const fields = stdout.split("\0");
|
|
2009
|
+
let head = null;
|
|
2010
|
+
const paths = [];
|
|
2011
|
+
for (let index = 0; index < fields.length; index++) {
|
|
2012
|
+
const field = fields[index];
|
|
2013
|
+
if (field.startsWith("# branch.oid ")) {
|
|
2014
|
+
const oid = field.slice("# branch.oid ".length);
|
|
2015
|
+
head = oid === "(initial)" ? null : oid;
|
|
2016
|
+
} else if (field.startsWith("1 ")) {
|
|
2017
|
+
paths.push(field.split(" ").slice(8).join(" "));
|
|
2018
|
+
} else if (field.startsWith("2 ")) {
|
|
2019
|
+
paths.push(field.split(" ").slice(9).join(" "));
|
|
2020
|
+
const original = fields[++index];
|
|
2021
|
+
if (original) paths.push(original);
|
|
2022
|
+
} else if (field.startsWith("u ")) {
|
|
2023
|
+
paths.push(field.split(" ").slice(10).join(" "));
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return { head, paths };
|
|
2027
|
+
}
|
|
2028
|
+
async function treeState(root) {
|
|
2029
|
+
const [status, untracked, prefix] = await Promise.all([
|
|
2030
|
+
git(root, ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=no", "--", "."]),
|
|
2031
|
+
git(root, ["ls-files", "-z", "--others", "--exclude-standard"]),
|
|
2032
|
+
git(root, ["rev-parse", "--show-prefix"])
|
|
2033
|
+
]);
|
|
2034
|
+
if (!status.ok || !untracked.ok || !prefix.ok) return null;
|
|
2035
|
+
const top = prefix.stdout.trim();
|
|
2036
|
+
const { head, paths } = parseStatus(status.stdout);
|
|
2037
|
+
const files = [
|
|
2038
|
+
...paths.filter((listed) => listed.startsWith(top)).map((listed) => listed.slice(top.length)),
|
|
2039
|
+
...untracked.stdout.split("\0").filter(Boolean)
|
|
2040
|
+
];
|
|
2041
|
+
const entries = {};
|
|
2042
|
+
await Promise.all(
|
|
2043
|
+
files.map(async (file) => {
|
|
2044
|
+
try {
|
|
2045
|
+
const stats = await lstat(path14.join(root, file));
|
|
2046
|
+
entries[file] = stats.isFile() && stats.size <= HASHED_MAX_BYTES ? [stats.mtimeMs, stats.size, await contentHash(path14.join(root, file))] : [stats.mtimeMs, stats.size];
|
|
2047
|
+
} catch (error) {
|
|
2048
|
+
if (!isNotFound(error)) throw error;
|
|
2049
|
+
entries[file] = [0, GONE];
|
|
2050
|
+
}
|
|
2051
|
+
})
|
|
2052
|
+
);
|
|
2053
|
+
return { head, entries };
|
|
2054
|
+
}
|
|
2055
|
+
async function contentHash(file) {
|
|
2056
|
+
return createHash4("sha1").update(await readFile5(file)).digest("hex").slice(0, 16);
|
|
2057
|
+
}
|
|
2058
|
+
function same([mtime, size, hash], [wasMtime, wasSize, wasHash]) {
|
|
2059
|
+
if (hash !== void 0 && wasHash !== void 0) return hash === wasHash && size === wasSize;
|
|
2060
|
+
return mtime === wasMtime && size === wasSize;
|
|
2061
|
+
}
|
|
2062
|
+
function treeChanges(before, after) {
|
|
2063
|
+
if (before.head !== after.head) return { gitOperation: true };
|
|
2064
|
+
const files = /* @__PURE__ */ new Set();
|
|
2065
|
+
for (const [file, entry] of Object.entries(after.entries)) {
|
|
2066
|
+
const was = before.entries[file];
|
|
2067
|
+
if (was === void 0 || !same(entry, was)) files.add(file);
|
|
2068
|
+
}
|
|
2069
|
+
for (const file of Object.keys(before.entries)) if (!(file in after.entries)) files.add(file);
|
|
2070
|
+
return { gitOperation: false, files: [...files].sort() };
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1925
2073
|
// src/core/pipeline.ts
|
|
2074
|
+
var TREE_UNAVAILABLE = "rulecast cannot see the working tree here; edits made with Bash are checked only by git hooks and CI";
|
|
1926
2075
|
var SUMMARY_THRESHOLD = 3;
|
|
1927
2076
|
var DEFAULT_HINT = "rulecast validate";
|
|
1928
2077
|
function summaryKey(prefix, ids) {
|
|
1929
|
-
const digest =
|
|
2078
|
+
const digest = createHash5("sha256").update([...ids].sort().join("\n")).digest("hex").slice(0, 16);
|
|
1930
2079
|
return `${prefix}:${digest}`;
|
|
1931
2080
|
}
|
|
1932
2081
|
function compileWarnings(errors) {
|
|
@@ -1979,7 +2128,8 @@ First: ${text(group[0])}`
|
|
|
1979
2128
|
return summarised;
|
|
1980
2129
|
}
|
|
1981
2130
|
async function runPipeline(options) {
|
|
1982
|
-
const { project, stateDir,
|
|
2131
|
+
const { project, stateDir, registry } = options;
|
|
2132
|
+
let event = options.event;
|
|
1983
2133
|
const { root, config } = project;
|
|
1984
2134
|
const log = options.log ?? (() => {
|
|
1985
2135
|
});
|
|
@@ -2005,6 +2155,27 @@ async function runPipeline(options) {
|
|
|
2005
2155
|
if (session) await appendWork(session.dir, [{ t: "prompt", agent: session.agent }]);
|
|
2006
2156
|
return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
2007
2157
|
}
|
|
2158
|
+
const empty = () => ({ work: emptyWork(), context: emptyContext() });
|
|
2159
|
+
const relevant = (files) => files.filter((file) => project.rules.some((rule) => rule.matches(file)));
|
|
2160
|
+
if (event.kind === "shell-before") {
|
|
2161
|
+
const open2 = session;
|
|
2162
|
+
const now = open2 ? await treeState(root) : null;
|
|
2163
|
+
if (open2 && now !== null) {
|
|
2164
|
+
const { work } = await readingStore(() => openSession(open2.dir, open2.agent), empty());
|
|
2165
|
+
const reference = referenceState(work, open2.agent);
|
|
2166
|
+
const gap = reference === null ? null : treeChanges(reference, now);
|
|
2167
|
+
const edited = new Set(work.edited);
|
|
2168
|
+
const swept2 = gap === null || gap.gitOperation ? [] : relevant(gap.files.filter((file) => !isGone(now, file) && !edited.has(file)));
|
|
2169
|
+
const id = event.toolUseId === void 0 ? {} : { toolUseId: event.toolUseId };
|
|
2170
|
+
if (session !== null) {
|
|
2171
|
+
await appendWork(open2.dir, [
|
|
2172
|
+
...swept2.map((file) => ({ t: "swept", file })),
|
|
2173
|
+
{ t: "tree", phase: "before", agent: open2.agent, ...id, state: now }
|
|
2174
|
+
]);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
2178
|
+
}
|
|
2008
2179
|
if (event.kind === "reset") {
|
|
2009
2180
|
if (session) await appendContext(session.dir, session.agent, [{ t: "reset" }]);
|
|
2010
2181
|
if (!session || restoredFiles === 0) return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
@@ -2023,13 +2194,27 @@ async function runPipeline(options) {
|
|
|
2023
2194
|
baseline = await readingStore(() => readBaseline(dir), baseline);
|
|
2024
2195
|
}
|
|
2025
2196
|
}
|
|
2026
|
-
const empty = () => ({ work: emptyWork(), context: emptyContext() });
|
|
2027
2197
|
const view = session ? await readingStore(() => openSession(session.dir, session.agent), empty()) : empty();
|
|
2028
2198
|
const disabled = new Set(view.work.disabled.keys());
|
|
2029
2199
|
const resolver = createReferenceResolver(root);
|
|
2030
2200
|
const detection = detectionFor(project, registry, stateDir, resolver);
|
|
2031
2201
|
const workRecords = [];
|
|
2032
|
-
|
|
2202
|
+
let via;
|
|
2203
|
+
let swept = [];
|
|
2204
|
+
const refused = [];
|
|
2205
|
+
if (event.kind === "shell-after") {
|
|
2206
|
+
const open2 = session;
|
|
2207
|
+
const now = open2 ? await treeState(root) : null;
|
|
2208
|
+
if (!open2 || now === null) return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
2209
|
+
const reference = referenceState(view.work, open2.agent, event.toolUseId);
|
|
2210
|
+
const id = event.toolUseId === void 0 ? {} : { toolUseId: event.toolUseId };
|
|
2211
|
+
await appendWork(open2.dir, [{ t: "tree", phase: "after", agent: open2.agent, ...id, state: now }]);
|
|
2212
|
+
const call = reference === null ? null : treeChanges(reference, now);
|
|
2213
|
+
const files = call === null || call.gitOperation ? [] : relevant(call.files.filter((file) => !isGone(now, file)));
|
|
2214
|
+
if (files.length === 0) return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
2215
|
+
via = "shell";
|
|
2216
|
+
event = { ...event, kind: "edit", files };
|
|
2217
|
+
}
|
|
2033
2218
|
if (event.kind === "guard") {
|
|
2034
2219
|
const open2 = session;
|
|
2035
2220
|
const delivery = await guardWrite({
|
|
@@ -2083,15 +2268,33 @@ async function runPipeline(options) {
|
|
|
2083
2268
|
if (event.completeRead) agentRead = event.files[0] ?? null;
|
|
2084
2269
|
if (session) await appendBaseline(session.dir, await touchRecords(baselineInput, baseline, relevant(event.files)));
|
|
2085
2270
|
}
|
|
2271
|
+
if (event.kind === "start" && session) {
|
|
2272
|
+
const tree = await treeState(root);
|
|
2273
|
+
if (tree === null) warnings.push({ key: "tree-unavailable", text: TREE_UNAVAILABLE });
|
|
2274
|
+
else {
|
|
2275
|
+
await appendWork(session.dir, [{ t: "tree", phase: "start", agent: session.agent, state: tree }]);
|
|
2276
|
+
const start = await startRecords(baselineInput, baseline, relevant(Object.keys(tree.entries).sort()));
|
|
2277
|
+
if (start.records.length > 0) await appendBaseline(session.dir, start.records);
|
|
2278
|
+
if (start.skipped > 0) {
|
|
2279
|
+
log(`session start: ${start.skipped} dirty files past the snapshot limits; their baseline is the start commit`);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2086
2283
|
if (event.kind === "edit" || event.kind === "verify") {
|
|
2087
2284
|
let requested = relevant(event.files);
|
|
2088
2285
|
if (event.kind === "edit" && session) {
|
|
2089
2286
|
await appendWork(
|
|
2090
2287
|
session.dir,
|
|
2091
|
-
requested.map((file) => ({ t: "edited", file }))
|
|
2288
|
+
requested.map((file) => ({ t: "edited", file, ...via === void 0 ? {} : { via } }))
|
|
2092
2289
|
);
|
|
2093
2290
|
}
|
|
2094
|
-
if (event.kind === "verify" && event.files.length === 0 && session)
|
|
2291
|
+
if (event.kind === "verify" && event.files.length === 0 && session) {
|
|
2292
|
+
requested = relevant(view.work.edited);
|
|
2293
|
+
if (options.stopGate === true) {
|
|
2294
|
+
swept = await sweep(session, new Set(requested));
|
|
2295
|
+
requested = [...requested, ...swept];
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2095
2298
|
const read = contentReader(root, event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE);
|
|
2096
2299
|
const stage = await changesFor(
|
|
2097
2300
|
baselineInput,
|
|
@@ -2153,17 +2356,27 @@ async function runPipeline(options) {
|
|
|
2153
2356
|
}
|
|
2154
2357
|
warnings.push(...detectorWarnings(output.errors, session === null ? "run" : "session"));
|
|
2155
2358
|
ignored = output.ignored;
|
|
2156
|
-
|
|
2359
|
+
const shellEdited = (file) => via === "shell" ? requested.includes(file) : view.work.editedVia.get(file) === "shell";
|
|
2360
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2361
|
+
for (const { rule, match, status } of findings) {
|
|
2362
|
+
if (status !== "new" || !rule.refuseWrite || !shellEdited(match.file)) continue;
|
|
2363
|
+
if (seen.has(`${rule.id} ${match.file}`)) continue;
|
|
2364
|
+
seen.add(`${rule.id} ${match.file}`);
|
|
2365
|
+
refused.push({ file: match.file, rule: rule.id, dirtyAtStart: dirtyAtStart(view.work, match.file) });
|
|
2366
|
+
}
|
|
2367
|
+
const atStop = options.stopGate === true && event.kind === "verify" && session !== null;
|
|
2368
|
+
if (session !== null && (atStop || refused.length > 0)) {
|
|
2157
2369
|
told = notices({
|
|
2158
|
-
startConfigHash: baseline.startConfigHash ?? null,
|
|
2159
|
-
currentConfigHash: await configHash(root),
|
|
2160
|
-
ignored: ignored.map(({ rule, match, reason }) => ({
|
|
2370
|
+
startConfigHash: atStop ? baseline.startConfigHash ?? null : null,
|
|
2371
|
+
currentConfigHash: atStop ? await configHash(root) : null,
|
|
2372
|
+
ignored: atStop ? ignored.map(({ rule, match, reason }) => ({
|
|
2161
2373
|
rule: rule.id,
|
|
2162
2374
|
match,
|
|
2163
2375
|
reason,
|
|
2164
2376
|
status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
|
|
2165
|
-
})),
|
|
2166
|
-
told: view.work.noticed
|
|
2377
|
+
})) : [],
|
|
2378
|
+
told: view.work.noticed,
|
|
2379
|
+
refused
|
|
2167
2380
|
});
|
|
2168
2381
|
for (const notice of told) for (const key of notice.key.split("\n")) workRecords.push({ t: "noticed", key });
|
|
2169
2382
|
}
|
|
@@ -2193,6 +2406,21 @@ async function runPipeline(options) {
|
|
|
2193
2406
|
}
|
|
2194
2407
|
}
|
|
2195
2408
|
}
|
|
2409
|
+
async function sweep(open2, edited) {
|
|
2410
|
+
const now = await treeState(root);
|
|
2411
|
+
let found = [];
|
|
2412
|
+
if (now !== null) {
|
|
2413
|
+
const reference = referenceState(view.work, open2.agent);
|
|
2414
|
+
const gap = reference === null ? null : treeChanges(reference, now);
|
|
2415
|
+
if (gap !== null && !gap.gitOperation) found = relevant(gap.files.filter((file) => !edited.has(file)));
|
|
2416
|
+
await appendWork(open2.dir, [
|
|
2417
|
+
...found.map((file) => ({ t: "swept", file })),
|
|
2418
|
+
{ t: "tree", phase: "stop", agent: open2.agent, state: now }
|
|
2419
|
+
]);
|
|
2420
|
+
}
|
|
2421
|
+
const all = [.../* @__PURE__ */ new Set([...relevant(view.work.swept), ...found])].filter((file) => !edited.has(file));
|
|
2422
|
+
return now === null ? all : all.filter((file) => !isGone(now, file));
|
|
2423
|
+
}
|
|
2196
2424
|
const inputFor = (state) => ({
|
|
2197
2425
|
agent: session?.agent ?? "main",
|
|
2198
2426
|
findings,
|
|
@@ -2206,21 +2434,24 @@ async function runPipeline(options) {
|
|
|
2206
2434
|
maxBlocks: config.stopGate.maxBlocks,
|
|
2207
2435
|
maxContextChars: options.maxContextChars,
|
|
2208
2436
|
maxMatchesPerRule: config.maxMatchesPerRule,
|
|
2209
|
-
stopGate: options.stopGate === true && event.kind === "verify" && session !== null
|
|
2437
|
+
stopGate: options.stopGate === true && event.kind === "verify" && session !== null,
|
|
2438
|
+
...via === void 0 ? {} : { via },
|
|
2439
|
+
...swept.length === 0 ? {} : { swept },
|
|
2440
|
+
...refused.length === 0 ? {} : { refused }
|
|
2210
2441
|
});
|
|
2211
|
-
const home =
|
|
2442
|
+
const home = path15.dirname(path15.dirname(stateDir));
|
|
2212
2443
|
const persisted = (decision) => {
|
|
2213
2444
|
if (decision.overflow !== null) {
|
|
2214
2445
|
let written = null;
|
|
2215
2446
|
try {
|
|
2216
|
-
written = writeOverflow(
|
|
2447
|
+
written = writeOverflow(path15.join(agentDir(root), "deliveries"), event.session?.id ?? null, decision.overflow);
|
|
2217
2448
|
} catch {
|
|
2218
2449
|
written = null;
|
|
2219
2450
|
}
|
|
2220
|
-
decision.delivery.overflowPath = written === null ? null :
|
|
2451
|
+
decision.delivery.overflowPath = written === null ? null : path15.relative(root, written).split(path15.sep).join("/");
|
|
2221
2452
|
}
|
|
2222
2453
|
for (const reference of decision.delivery.references) {
|
|
2223
|
-
if (reference.location === void 0 || !
|
|
2454
|
+
if (reference.location === void 0 || !path15.isAbsolute(reference.location)) continue;
|
|
2224
2455
|
const copied = copyIntoProject(root, home, reference.location);
|
|
2225
2456
|
if (copied !== null) reference.location = copied;
|
|
2226
2457
|
}
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
createRegistry,
|
|
7
7
|
renderBacklog,
|
|
8
8
|
summarise
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-MB4F7ZXC.js";
|
|
10
10
|
import {
|
|
11
11
|
CONFIG_FILE,
|
|
12
12
|
LockTimeoutError,
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
stagedFiles,
|
|
49
49
|
withLock,
|
|
50
50
|
withScratchTree
|
|
51
|
-
} from "./chunk-
|
|
51
|
+
} from "./chunk-Y3FXJRZB.js";
|
|
52
52
|
import {
|
|
53
53
|
VERSION,
|
|
54
54
|
detectorCacheDir,
|
|
@@ -64,7 +64,7 @@ import {
|
|
|
64
64
|
renderTemplate,
|
|
65
65
|
scanHeadings,
|
|
66
66
|
tagsOf
|
|
67
|
-
} from "./chunk-
|
|
67
|
+
} from "./chunk-4XS34SYW.js";
|
|
68
68
|
import {
|
|
69
69
|
Cancelled
|
|
70
70
|
} from "./chunk-K37BHCEK.js";
|
|
@@ -218,24 +218,29 @@ async function hookCommandResolves(root, signal) {
|
|
|
218
218
|
async function hookState(root, adapter, verifyMs) {
|
|
219
219
|
const install = installOf(adapter);
|
|
220
220
|
const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
|
|
221
|
+
let partial = null;
|
|
221
222
|
for (const { file } of install.scopes) {
|
|
222
223
|
const current = await readSettings(root, file);
|
|
223
224
|
if (current === null) continue;
|
|
224
225
|
const merged = inFile(file, () => install.merge(current.value, command, verifyMs));
|
|
225
|
-
if (merged.added.length === 0) return { file, stale: merged.updated ?? [] };
|
|
226
|
+
if (merged.added.length === 0) return { file, stale: merged.updated ?? [], missing: [] };
|
|
227
|
+
const ours = inFile(file, () => install.remove(current.value)).removed.length > 0;
|
|
228
|
+
if (ours && partial === null) partial = { file, stale: merged.updated ?? [], missing: merged.added };
|
|
226
229
|
}
|
|
227
|
-
return
|
|
230
|
+
return partial;
|
|
228
231
|
}
|
|
229
232
|
async function installHooks(root, adapter, scope, verifyMs) {
|
|
230
233
|
const install = installOf(adapter);
|
|
231
234
|
const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
|
|
232
235
|
const already = await hookState(root, adapter, verifyMs);
|
|
233
|
-
if (already !== null && already.stale.length === 0
|
|
236
|
+
if (already !== null && already.stale.length === 0 && already.missing.length === 0) {
|
|
237
|
+
return { file: already.file, added: [] };
|
|
238
|
+
}
|
|
234
239
|
if (already !== null) {
|
|
235
240
|
const current2 = (await readSettings(root, already.file))?.value ?? {};
|
|
236
241
|
const merged2 = inFile(already.file, () => install.merge(current2, command, verifyMs));
|
|
237
242
|
await writeSettings(root, already.file, merged2.settings);
|
|
238
|
-
return { file: already.file, added:
|
|
243
|
+
return { file: already.file, added: merged2.added, updated: merged2.updated ?? [] };
|
|
239
244
|
}
|
|
240
245
|
const target = install.scopes.find((candidate) => candidate.scope === scope);
|
|
241
246
|
if (!target) throw new UsageError(`${adapter.name} has no ${scope} settings`);
|
|
@@ -274,12 +279,18 @@ async function loadProjectConfig(root, command) {
|
|
|
274
279
|
}
|
|
275
280
|
function printInstall(io, adapter, result) {
|
|
276
281
|
const updated = result.updated ?? [];
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
`
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
282
|
+
if (updated.length > 0) {
|
|
283
|
+
io.stdout(`updated ${adapter.label} hooks in ${result.file} to the current command: ${updated.join(", ")}
|
|
284
|
+
`);
|
|
285
|
+
}
|
|
286
|
+
if (result.added.length > 0) {
|
|
287
|
+
io.stdout(`installed ${adapter.label} hooks in ${result.file}: ${result.added.join(", ")}
|
|
288
|
+
`);
|
|
289
|
+
}
|
|
290
|
+
if (updated.length === 0 && result.added.length === 0) {
|
|
291
|
+
io.stdout(`${adapter.label} hooks already installed in ${result.file}
|
|
292
|
+
`);
|
|
293
|
+
}
|
|
283
294
|
}
|
|
284
295
|
async function fetchMissingRepos(config, io) {
|
|
285
296
|
const home = cacheHome(io.env);
|
|
@@ -412,6 +423,10 @@ function naming(rules) {
|
|
|
412
423
|
if (rules.length <= 3) return ` (${rules.join(", ")})`;
|
|
413
424
|
return ` (${rules.slice(0, 3).join(", ")} and ${rules.length - 3} more)`;
|
|
414
425
|
}
|
|
426
|
+
function missingNames(groups) {
|
|
427
|
+
const names = groups.map((group) => /ToolUse/.test(group) ? /\((.*)\)$/.exec(group)?.[1] ?? group : group);
|
|
428
|
+
return [...new Set(names)].join(", ");
|
|
429
|
+
}
|
|
415
430
|
function plural(count, noun) {
|
|
416
431
|
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
417
432
|
}
|
|
@@ -468,9 +483,10 @@ async function doctorCommand(root, args, registry, io) {
|
|
|
468
483
|
const state = await hookState(root, adapter, project.config.timeouts.verifyMs);
|
|
469
484
|
const file = state?.file ?? null;
|
|
470
485
|
const stale = (state?.stale ?? []).length > 0;
|
|
471
|
-
const
|
|
486
|
+
const missing = state?.missing ?? [];
|
|
487
|
+
const level = file === null || !resolves || stale || missing.length > 0 ? "warning" : "ok";
|
|
472
488
|
count(level);
|
|
473
|
-
const detail = file === null ? "not installed (run rulecast install)" : stale ? `${file} \u2014 but the hooks use an old command; run rulecast install` : resolves ? file : `${file} \u2014 but "rulecast" is on neither the PATH nor node_modules/.bin, so the hooks do nothing. Add it to the project (pnpm add -D @syv-ai/rulecast) and run rulecast install again.`;
|
|
489
|
+
const detail = file === null ? "not installed (run rulecast install)" : missing.length > 0 ? `${file} \u2014 but hooks for ${missingNames(missing)} missing; run rulecast install` : stale ? `${file} \u2014 but the hooks use an old command; run rulecast install` : resolves ? file : `${file} \u2014 but "rulecast" is on neither the PATH nor node_modules/.bin, so the hooks do nothing. Add it to the project (pnpm add -D @syv-ai/rulecast) and run rulecast install again.`;
|
|
474
490
|
io.stdout(line(level, adapter.label, detail));
|
|
475
491
|
}
|
|
476
492
|
io.stdout("\ncache\n");
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as Adapter, e as Delivery, g as DetectorRun, h as DetectorRuleInput, M as Match, i as Detector, j as AnyDetector, k as AdapterInput, E as Event } from './types-
|
|
2
|
-
export { l as AdapterInstall, m as Cache, n as ChangeSet, b as CheckResult, o as CompiledDetector, a as CompiledRule, p as ContentSource, q as DeliveredReference, s as DetectorCheck, t as DetectorEvent, D as DetectorRegistry, u as DetectorResult, d as DetectorRule, c as DetectorSettings, v as DetectorWarm, w as EventKind, F as Finding, I as InstallScope, x as LLM_PROVIDERS, y as LlmFinding, f as LlmProvider, L as LlmProviderName, z as LlmRequest, B as LlmSettings, G as LlmUnavailableError, O as Omitted, H as ReferenceMode, J as ReferenceSpec, K as RenderOptions, N as ResolvedReference, P as RuleExample, Q as RuleExamples, T as RuleScope, U as Severity, S as Stage, V as TouchRule, W as WriteIntent, X as createRegistry, Y as defaultDetectorSettings, Z as emptyDelivery, _ as isDetectorRule, r as renderAgentText } from './types-
|
|
1
|
+
import { A as Adapter, e as Delivery, g as DetectorRun, h as DetectorRuleInput, M as Match, i as Detector, j as AnyDetector, k as AdapterInput, E as Event } from './types-D5y_IokF.js';
|
|
2
|
+
export { l as AdapterInstall, m as Cache, n as ChangeSet, b as CheckResult, o as CompiledDetector, a as CompiledRule, p as ContentSource, q as DeliveredReference, s as DetectorCheck, t as DetectorEvent, D as DetectorRegistry, u as DetectorResult, d as DetectorRule, c as DetectorSettings, v as DetectorWarm, w as EventKind, F as Finding, I as InstallScope, x as LLM_PROVIDERS, y as LlmFinding, f as LlmProvider, L as LlmProviderName, z as LlmRequest, B as LlmSettings, G as LlmUnavailableError, O as Omitted, H as ReferenceMode, J as ReferenceSpec, K as RenderOptions, N as ResolvedReference, P as RuleExample, Q as RuleExamples, T as RuleScope, U as Severity, S as Stage, V as TouchRule, W as WriteIntent, X as createRegistry, Y as defaultDetectorSettings, Z as emptyDelivery, _ as isDetectorRule, r as renderAgentText } from './types-D5y_IokF.js';
|
|
3
3
|
import 'zod';
|
|
4
4
|
|
|
5
5
|
declare const claudeCodeAdapter: Adapter;
|
|
@@ -119,7 +119,7 @@ declare function runTool(command: string, args: readonly string[], options: {
|
|
|
119
119
|
}): Promise<string>;
|
|
120
120
|
|
|
121
121
|
/** The running rulecast version. test/core/version.test.ts keeps it equal to package.json. */
|
|
122
|
-
declare const VERSION = "0.
|
|
122
|
+
declare const VERSION = "0.5.0";
|
|
123
123
|
|
|
124
124
|
declare const builtinDetectors: readonly AnyDetector[];
|
|
125
125
|
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
repoRelativeTo,
|
|
10
10
|
runTool,
|
|
11
11
|
summarise
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-MB4F7ZXC.js";
|
|
13
13
|
import {
|
|
14
14
|
LlmUnavailableError,
|
|
15
15
|
VERSION,
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
readSourceFile,
|
|
26
26
|
renderAgentText,
|
|
27
27
|
sourceReader
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-4XS34SYW.js";
|
|
29
29
|
|
|
30
30
|
// src/testing/adapter-contract.ts
|
|
31
31
|
import assert from "assert/strict";
|
|
@@ -79,7 +79,7 @@ function adapterContract(adapter, fixture) {
|
|
|
79
79
|
if (!event) continue;
|
|
80
80
|
assert.ok(event.cwd.length > 0, `${name}: event.cwd must not be empty`);
|
|
81
81
|
for (const file of event.files) assert.equal(typeof file, "string", `${name}: files must be strings`);
|
|
82
|
-
if (
|
|
82
|
+
if (["prompt", "reset", "start", "shell-before", "shell-after"].includes(event.kind)) {
|
|
83
83
|
assert.deepEqual(event.files, [], `${name}: ${event.kind} carries no files`);
|
|
84
84
|
}
|
|
85
85
|
}
|
package/dist/internal.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { D as DetectorRegistry, C as Config, a as CompiledRule, b as CheckResult, c as DetectorSettings, d as DetectorRule, M as Match, E as Event, e as Delivery, L as LlmProviderName, f as LlmProvider } from './types-
|
|
2
|
-
export { R as RuleEntry, S as Stage, r as renderAgentText } from './types-
|
|
1
|
+
import { D as DetectorRegistry, C as Config, a as CompiledRule, b as CheckResult, c as DetectorSettings, d as DetectorRule, M as Match, E as Event, e as Delivery, L as LlmProviderName, f as LlmProvider } from './types-D5y_IokF.js';
|
|
2
|
+
export { R as RuleEntry, S as Stage, r as renderAgentText } from './types-D5y_IokF.js';
|
|
3
3
|
import 'zod';
|
|
4
4
|
|
|
5
5
|
type Checkout = {
|
package/dist/internal.js
CHANGED
|
@@ -11,13 +11,13 @@ import {
|
|
|
11
11
|
fixedRepo,
|
|
12
12
|
projectStateDir,
|
|
13
13
|
runPipeline
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-Y3FXJRZB.js";
|
|
15
15
|
import {
|
|
16
16
|
MODEL_ALIASES,
|
|
17
17
|
providerByName,
|
|
18
18
|
renderAgentText,
|
|
19
19
|
resolveModel
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-4XS34SYW.js";
|
|
21
21
|
export {
|
|
22
22
|
CONFIG_FILE,
|
|
23
23
|
MANIFEST_FILE,
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { ZodType, ZodTypeDef, z } from 'zod';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `start`: the session began or resumed. `shell-before` / `shell-after`: around one call of the
|
|
5
|
+
* agent's shell tool, whose writes rulecast finds by comparing the working tree (session/tree.ts).
|
|
6
|
+
*/
|
|
7
|
+
type EventKind = "touch" | "edit" | "verify" | "prompt" | "reset" | "guard" | "start" | "shell-before" | "shell-after";
|
|
4
8
|
type DetectorEvent = "edit" | "verify";
|
|
5
9
|
type Severity = "error" | "warning";
|
|
6
10
|
type ReferenceMode = "inject" | "read";
|
|
@@ -34,8 +38,12 @@ type ContentSource = {
|
|
|
34
38
|
};
|
|
35
39
|
interface Event {
|
|
36
40
|
kind: EventKind;
|
|
37
|
-
/** Repo-relative paths (adapters may give absolute ones; the hook command converts them). Empty for prompt and
|
|
41
|
+
/** Repo-relative paths (adapters may give absolute ones; the hook command converts them). Empty for prompt, reset, start and shell events. */
|
|
38
42
|
files: string[];
|
|
43
|
+
/** shell events: pairs a call's before and after states when the agent's payloads carry an id. */
|
|
44
|
+
toolUseId?: string;
|
|
45
|
+
/** shell-after: the command exited non-zero. It may still have written files; an adapter may answer it differently. */
|
|
46
|
+
failed?: boolean;
|
|
39
47
|
/** guard only: what the agent is about to write to `files[0]`. */
|
|
40
48
|
intent?: WriteIntent;
|
|
41
49
|
/** touch from a read: the whole file was read. */
|
|
@@ -246,6 +254,7 @@ interface Omitted {
|
|
|
246
254
|
rule: string;
|
|
247
255
|
count: number;
|
|
248
256
|
files: number;
|
|
257
|
+
swept?: true;
|
|
249
258
|
}[];
|
|
250
259
|
/** Rules dropped whole, with their findings: only when the floor itself did not fit. */
|
|
251
260
|
rules: number;
|
|
@@ -267,6 +276,23 @@ interface Delivery {
|
|
|
267
276
|
omitted: Omitted;
|
|
268
277
|
/** Absolute path of the untrimmed delivery, written when even the floor did not fit; null when it did. */
|
|
269
278
|
overflowPath: string | null;
|
|
279
|
+
/** The findings are in files the agent's shell command changed: the title says so. Absent: an edit tool. */
|
|
280
|
+
via?: "shell";
|
|
281
|
+
/**
|
|
282
|
+
* Files with findings that changed outside the agent's tool calls (spec §9, Shell edits): printed
|
|
283
|
+
* under their own heading, after the agent's own, and never blocking. Set at a stop.
|
|
284
|
+
*/
|
|
285
|
+
swept?: string[];
|
|
286
|
+
/**
|
|
287
|
+
* Findings of `refuse_write` rules in files the agent changed with its shell (spec §9, Shell
|
|
288
|
+
* edits): the write could not be refused before it happened, so the agent is told to revert it.
|
|
289
|
+
* `dirtyAtStart`: the file had the user's uncommitted changes, so reverting the whole file is wrong.
|
|
290
|
+
*/
|
|
291
|
+
refused?: {
|
|
292
|
+
file: string;
|
|
293
|
+
rule: string;
|
|
294
|
+
dirtyAtStart: boolean;
|
|
295
|
+
}[];
|
|
270
296
|
/**
|
|
271
297
|
* For the user, not the agent (spec §9, Oversight): the config changed this session, ignores were
|
|
272
298
|
* added. Attached after the budget is spent and never part of agent context under a hook adapter.
|