@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 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 edits a file that breaks a rule, it gets told — in the same turn, before it moves on.
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.4.0/agents/SETUP.md
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.4.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 where = files.size === 1 && delivery.omitted.rules === 0 ? ` in ${[...files][0]}` : "";
586
- return `rulecast: ${plural(rules, "rule")} violated${where}`;
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 where = longest === "" ? "" : ` in ${longest}`;
685
- const violated = rules > 0 ? linesCost([`rulecast: ${plural(rules, "rule")} violated${where}`, ""]) : 0;
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 byRule = /* @__PURE__ */ new Map();
729
- for (const finding of delivery.findings) {
730
- const group = byRule.get(finding.rule);
731
- if (group === void 0) byRule.set(finding.rule, [finding]);
732
- else group.push(finding);
733
- }
734
- for (const [rule, findings] of byRule) {
735
- out.push(...ruleBlock(rule, findings, delivery.templates[rule], omittedFor(delivery.omitted, rule), options));
736
- out.push("");
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-DQUCE2IK.js";
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
- return { ...result(null), warmup: source === "startup" || source === "resume" };
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: { hookEventName: "PostToolUse", additionalContext: withinLimit(text2, delivery, event) }
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-DQUCE2IK.js";
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 createHash4 } from "crypto";
693
- import path14 from "path";
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
- for (const file of files) {
1162
- if (state.snapshots.has(file)) continue;
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
- if (snapshotted.length === 0) return records;
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: input.limits.editDeadlineMs,
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 = (path15) => {
1278
- let text = texts.get(path15);
1293
+ const read = (path16) => {
1294
+ let text = texts.get(path16);
1279
1295
  if (!text) {
1280
- text = readSourceFile(root, path15);
1281
- texts.set(path15, text);
1296
+ text = readSourceFile(root, path16);
1297
+ texts.set(path16, text);
1282
1298
  }
1283
1299
  return text;
1284
1300
  };
1285
- const contentOf = async (path15, anchor) => {
1286
- const text = await read(path15);
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(path15, anchor) {
1299
- const content = await contentOf(path15, anchor);
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(path15, parent, child) {
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(path15);
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
- for (const finding of delivery.findings) {
1646
- const group = byRule.get(finding.rule);
1647
- if (group === void 0) byRule.set(finding.rule, [finding]);
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(byRule.size, new Set(delivery.findings.map((finding) => finding.file)), conventions) + (delivery.references.length > 0 ? deliveryCost.referencesEnd : 0);
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: 0 };
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
- delivery.omitted.rules = floor.dropped;
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.rule)?.includes(finding) === true);
1843
+ delivery.findings = delivery.findings.filter((finding) => kept.get(blockOf(finding))?.includes(finding) === true);
1780
1844
  }
1781
- return { delivery, overflow: delivery.omitted.rules > 0 ? assembled.delivery : null, context };
1845
+ return { delivery, overflow: droppedBlocks > 0 ? assembled.delivery : null, context };
1782
1846
  }
1783
- function gate(fresh, work, agent, maxBlocks) {
1784
- if (!fresh.some(({ rule }) => rule.severity === "error")) return { stop: "allow", work: [] };
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 = createHash4("sha256").update([...ids].sort().join("\n")).digest("hex").slice(0, 16);
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, event, registry } = options;
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
- const relevant = (files) => files.filter((file) => project.rules.some((rule) => rule.matches(file)));
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) requested = relevant(view.work.edited);
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
- if (options.stopGate === true && event.kind === "verify" && session !== null) {
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 = path14.dirname(path14.dirname(stateDir));
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(path14.join(agentDir(root), "deliveries"), event.session?.id ?? null, decision.overflow);
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 : path14.relative(root, written).split(path14.sep).join("/");
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 || !path14.isAbsolute(reference.location)) continue;
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-IKONOTME.js";
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-WUFU4LW2.js";
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-DQUCE2IK.js";
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 null;
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) return { file: already.file, added: [] };
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: [], updated: merged2.updated ?? [] };
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
- io.stdout(
278
- updated.length > 0 ? `updated ${adapter.label} hooks in ${result.file} to the current command: ${updated.join(", ")}
279
- ` : result.added.length === 0 ? `${adapter.label} hooks already installed in ${result.file}
280
- ` : `installed ${adapter.label} hooks in ${result.file}: ${result.added.join(", ")}
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 level = file === null || !resolves || stale ? "warning" : "ok";
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-Dcra5LXR.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-Dcra5LXR.js';
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.4.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-IKONOTME.js";
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-DQUCE2IK.js";
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 (event.kind === "prompt" || event.kind === "reset") {
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
  }
@@ -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-Dcra5LXR.js';
2
- export { R as RuleEntry, S as Stage, r as renderAgentText } from './types-Dcra5LXR.js';
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-WUFU4LW2.js";
14
+ } from "./chunk-Y3FXJRZB.js";
15
15
  import {
16
16
  MODEL_ALIASES,
17
17
  providerByName,
18
18
  renderAgentText,
19
19
  resolveModel
20
- } from "./chunk-DQUCE2IK.js";
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
- type EventKind = "touch" | "edit" | "verify" | "prompt" | "reset" | "guard";
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 reset. */
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syv-ai/rulecast",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Deliver project conventions to coding agents at the moment they matter.",
5
5
  "keywords": [
6
6
  "claude-code",