@syv-ai/rulecast 0.3.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.
@@ -1,10 +1,11 @@
1
1
  import {
2
- BACKLOG_HINT,
3
2
  DeadlineError,
4
3
  LLM_PROVIDERS,
4
+ SWEPT_BLOCK,
5
5
  VERSION,
6
6
  compileFilter,
7
7
  compileRule,
8
+ deliveryCost,
8
9
  detectorCacheDir,
9
10
  diskCache,
10
11
  emptyDelivery,
@@ -24,7 +25,7 @@ import {
24
25
  sectionRange,
25
26
  sectionText,
26
27
  templateBindings
27
- } from "./chunk-HZUATJ53.js";
28
+ } from "./chunk-4XS34SYW.js";
28
29
 
29
30
  // src/core/config/load.ts
30
31
  import { readFile } from "fs/promises";
@@ -59,6 +60,8 @@ var ruleKeys = {
59
60
  */
60
61
  scope: z.enum(RULE_SCOPES).optional(),
61
62
  refuse_write: z.boolean().optional(),
63
+ /** false: compiled and listed, selected for nothing. The way to switch a catalog rule off without deleting it. */
64
+ enabled: z.boolean().optional(),
62
65
  detect: z.record(z.unknown()).refine((value) => Object.keys(value).length === 1, "must name exactly one detector").optional(),
63
66
  message: z.string().optional(),
64
67
  context: z.array(referenceInputSchema).optional(),
@@ -150,13 +153,15 @@ function readConfigData(root) {
150
153
  }
151
154
  function parseConfig(data) {
152
155
  const result = configSchema.safeParse(data);
153
- if (!result.success) return { ok: false, message: `${CONFIG_FILE}: ${formatZodError(result.error)}` };
156
+ if (!result.success) {
157
+ return { ok: false, message: `${CONFIG_FILE}: ${formatZodError(result.error, { schema: configSchema })}` };
158
+ }
154
159
  return { ok: true, value: result.data };
155
160
  }
156
- async function readManifest(dir) {
157
- const loaded = await readYamlFile(path.join(dir, MANIFEST_FILE));
161
+ async function readManifest(dir, file = path.join(dir, MANIFEST_FILE)) {
162
+ const loaded = await readYamlFile(file);
158
163
  if (!loaded.ok) return loaded;
159
- if (!Array.isArray(loaded.value)) return { ok: false, message: `${MANIFEST_FILE}: must be a list of rules` };
164
+ if (!Array.isArray(loaded.value)) return { ok: false, message: `${path.basename(file)}: must be a list of rules` };
160
165
  return { ok: true, value: loaded.value };
161
166
  }
162
167
 
@@ -197,6 +202,7 @@ function debugLogger(stateDir, now = () => /* @__PURE__ */ new Date()) {
197
202
 
198
203
  // src/core/compile/project.ts
199
204
  import { readFile as readFile2 } from "fs/promises";
205
+ import path3 from "path";
200
206
  function diagnosticText(diagnostic) {
201
207
  const rule = diagnostic.rule ? ` (${diagnostic.rule})` : "";
202
208
  return `${diagnostic.source}${rule}: ${diagnostic.message}`;
@@ -274,7 +280,7 @@ async function compile(options) {
274
280
  }
275
281
  for (const raw of entry.rules) {
276
282
  const rule = ruleSchema.safeParse(raw);
277
- if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error));
283
+ if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error, { schema: ruleSchema }));
278
284
  else await add({ data: rule.data, source: "local", contextRoot: project }, CONFIG_FILE);
279
285
  }
280
286
  continue;
@@ -311,7 +317,7 @@ async function compile(options) {
311
317
  for (const raw of entry.rules) {
312
318
  const override = overrideSchema.safeParse(raw);
313
319
  if (!override.success) {
314
- error(CONFIG_FILE, idOf(raw), formatZodError(override.error));
320
+ error(CONFIG_FILE, idOf(raw), formatZodError(override.error, { schema: overrideSchema }));
315
321
  continue;
316
322
  }
317
323
  const { id } = override.data;
@@ -322,7 +328,7 @@ async function compile(options) {
322
328
  }
323
329
  const base = ruleSchema.safeParse(candidates[0]);
324
330
  if (!base.success) {
325
- error(source, id, `manifest: ${formatZodError(base.error)}`);
331
+ error(source, id, formatZodError(base.error, { prefix: "manifest", schema: ruleSchema }));
326
332
  continue;
327
333
  }
328
334
  const data2 = { ...base.data, ...override.data };
@@ -337,12 +343,13 @@ async function compile(options) {
337
343
  );
338
344
  return { root, config, rules, diagnostics };
339
345
  }
340
- async function compileManifest(dir, registry) {
341
- const manifest = await readManifest(dir);
346
+ async function compileManifest(dir, registry, file = path3.join(dir, MANIFEST_FILE)) {
347
+ const source = path3.basename(file);
348
+ const manifest = await readManifest(dir, file);
342
349
  if (!manifest.ok) {
343
350
  return {
344
351
  rules: [],
345
- diagnostics: [{ source: MANIFEST_FILE, rule: null, message: manifest.message, level: "error" }]
352
+ diagnostics: [{ source, rule: null, message: manifest.message, level: "error" }]
346
353
  };
347
354
  }
348
355
  const diagnostics = [];
@@ -351,18 +358,23 @@ async function compileManifest(dir, registry) {
351
358
  for (const raw of manifest.value) {
352
359
  const rule = ruleSchema.safeParse(raw);
353
360
  if (!rule.success) {
354
- diagnostics.push({ source: MANIFEST_FILE, rule: idOf(raw), message: formatZodError(rule.error), level: "error" });
361
+ diagnostics.push({
362
+ source,
363
+ rule: idOf(raw),
364
+ message: formatZodError(rule.error, { schema: ruleSchema }),
365
+ level: "error"
366
+ });
355
367
  continue;
356
368
  }
357
369
  const result = await compileRule({ data: rule.data, source: "local", contextRoot: { dir, label: null } }, context);
358
370
  if (typeof result === "string") {
359
371
  diagnostics.push({
360
- source: MANIFEST_FILE,
372
+ source,
361
373
  rule: rule.data.alias ?? rule.data.id,
362
374
  message: result,
363
375
  level: "error"
364
376
  });
365
- } else compiled.push({ rule: result, source: MANIFEST_FILE });
377
+ } else compiled.push({ rule: result, source });
366
378
  }
367
379
  const rules = uniqueRules(compiled, diagnostics, (id) => `rule "${id}" is defined more than once`);
368
380
  return { rules, diagnostics };
@@ -371,7 +383,7 @@ async function compileManifest(dir, registry) {
371
383
  // src/core/detection/select.ts
372
384
  function selectDetectorRules(rules, event, files, disabled) {
373
385
  return rules.filter(
374
- (rule) => isDetectorRule(rule) && rule.stages.includes(event) && !disabled.has(rule.id)
386
+ (rule) => isDetectorRule(rule) && rule.enabled && rule.stages.includes(event) && !disabled.has(rule.id)
375
387
  ).map((rule) => ({ rule, files: files.filter((file) => rule.matches(file)) })).filter((selection) => selection.files.length > 0);
376
388
  }
377
389
  function rulesOfKind(rules, kind) {
@@ -379,7 +391,7 @@ function rulesOfKind(rules, kind) {
379
391
  }
380
392
  function selectTouchRules(rules, files, touched, disabled) {
381
393
  return rules.filter(
382
- (rule) => rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
394
+ (rule) => rule.enabled && rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
383
395
  );
384
396
  }
385
397
 
@@ -429,7 +441,7 @@ async function checkDetectors(options) {
429
441
  import { randomUUID } from "crypto";
430
442
  import { existsSync as existsSync2 } from "fs";
431
443
  import { mkdir as mkdir2, rename, rm as rm2 } from "fs/promises";
432
- import path5 from "path";
444
+ import path6 from "path";
433
445
 
434
446
  // src/core/git.ts
435
447
  import { execFile } from "child_process";
@@ -475,10 +487,26 @@ async function fileAtCommit(cwd, commit, file) {
475
487
  const result = await git(cwd, ["show", `${commit}:./${file}`]);
476
488
  return result.ok ? result.stdout : null;
477
489
  }
490
+ async function fileInIndex(cwd, file) {
491
+ const result = await git(cwd, ["show", `:./${file}`]);
492
+ return result.ok ? result.stdout : null;
493
+ }
494
+ async function isShallow(cwd) {
495
+ const result = await git(cwd, ["rev-parse", "--is-shallow-repository"]);
496
+ return result.ok && result.stdout.trim() === "true";
497
+ }
498
+ var FETCH_HISTORY = "fetch full history (actions/checkout: fetch-depth: 0)";
478
499
  async function mergeBase(cwd, ref, other = "HEAD") {
479
500
  const result = await git(cwd, ["merge-base", other, ref]);
480
- if (!result.ok) throw new Error(`cannot find merge base with ${ref}: ${result.stderr.trim()}`);
481
- return result.stdout.trim();
501
+ if (result.ok) return result.stdout.trim();
502
+ const shallow = await isShallow(cwd);
503
+ const resolves = (await git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])).ok;
504
+ if (!resolves) {
505
+ throw new Error(`"${ref}" is not a commit here${shallow ? `; this is a shallow clone: ${FETCH_HISTORY}` : ""}`);
506
+ }
507
+ if (shallow) throw new Error(`no merge base with ${ref} in a shallow clone: ${FETCH_HISTORY}`);
508
+ const detail = result.stderr.trim();
509
+ throw new Error(`no merge base with ${ref}${detail ? `: ${detail}` : ": the histories are unrelated"}`);
482
510
  }
483
511
  async function changedFilesSince(cwd, commit) {
484
512
  const diff = await git(cwd, ["diff", "--name-only", "--relative", "--diff-filter=d", commit]);
@@ -509,7 +537,7 @@ async function changedFilesBetween(cwd, from, to) {
509
537
 
510
538
  // src/core/session/lock.ts
511
539
  import { mkdir, rm, stat } from "fs/promises";
512
- import path3 from "path";
540
+ import path4 from "path";
513
541
  var LockTimeoutError = class extends Error {
514
542
  };
515
543
  var DEFAULTS = { waitMs: 2e3, staleMs: 5e3, retryMs: 20 };
@@ -517,7 +545,7 @@ function isAlreadyExists(error) {
517
545
  return error instanceof Error && error.code === "EEXIST";
518
546
  }
519
547
  async function withLock(dir, fn, options = DEFAULTS) {
520
- const lock = path3.join(dir, ".lock");
548
+ const lock = path4.join(dir, ".lock");
521
549
  await mkdir(dir, { recursive: true });
522
550
  const started = Date.now();
523
551
  for (; ; ) {
@@ -550,7 +578,7 @@ async function withLock(dir, fn, options = DEFAULTS) {
550
578
 
551
579
  // src/core/repos/layout.ts
552
580
  import { createHash as createHash2 } from "crypto";
553
- import path4 from "path";
581
+ import path5 from "path";
554
582
  import { fileURLToPath } from "url";
555
583
  var SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
556
584
  var SCP_LIKE = /^[^@/\s]+@([^:/\s]+):(.+)$/;
@@ -558,7 +586,7 @@ function segmentsOf(repoPath) {
558
586
  return repoPath.replace(/\/+$/, "").replace(/\.git$/, "").split("/").filter(Boolean);
559
587
  }
560
588
  function localName(file) {
561
- return path4.basename(file.replace(/[\\/]+$/, "")).replace(/\.git$/, "");
589
+ return path5.basename(file.replace(/[\\/]+$/, "")).replace(/\.git$/, "");
562
590
  }
563
591
  function parseUrl(url) {
564
592
  if (SCHEME.test(url)) {
@@ -585,7 +613,7 @@ function repoSlug(url) {
585
613
  return safeSegment(`local_${parsed.name}_${hash}`);
586
614
  }
587
615
  function repoDir(home, url, rev) {
588
- return path4.join(home, "repos", repoSlug(url), safeSegment(rev));
616
+ return path5.join(home, "repos", repoSlug(url), safeSegment(rev));
589
617
  }
590
618
  function repoLabel(url, rev) {
591
619
  return `${repoName(url)}@${rev}`;
@@ -605,7 +633,7 @@ async function fetchCheckout(url, rev, into) {
605
633
  await run(into, ["init", "-q"]);
606
634
  await run(into, ["fetch", "-q", "--depth", "1", url, rev]);
607
635
  await run(into, ["-c", "advice.detachedHead=false", "checkout", "-q", "FETCH_HEAD"]);
608
- await rm2(path5.join(into, ".git"), { recursive: true, force: true });
636
+ await rm2(path6.join(into, ".git"), { recursive: true, force: true });
609
637
  }
610
638
  function cachedRepo(home, url, rev) {
611
639
  const dir = repoDir(home, url, rev);
@@ -614,12 +642,12 @@ function cachedRepo(home, url, rev) {
614
642
  async function ensureRepo(home, url, rev) {
615
643
  const dir = repoDir(home, url, rev);
616
644
  if (existsSync2(dir)) return dir;
617
- const parent = path5.dirname(dir);
645
+ const parent = path6.dirname(dir);
618
646
  await withLock(
619
647
  parent,
620
648
  async () => {
621
649
  if (existsSync2(dir)) return;
622
- const temp = path5.join(parent, `.tmp-${randomUUID()}`);
650
+ const temp = path6.join(parent, `.tmp-${randomUUID()}`);
623
651
  try {
624
652
  await fetchCheckout(url, rev, temp);
625
653
  await rename(temp, dir);
@@ -662,7 +690,21 @@ function fixedRepo(dir, label) {
662
690
  }
663
691
 
664
692
  // src/core/pipeline.ts
665
- import { createHash as createHash3 } from "crypto";
693
+ import { createHash as createHash5 } from "crypto";
694
+ import path15 from "path";
695
+
696
+ // src/core/content.ts
697
+ var WORKTREE = { kind: "worktree" };
698
+ function contentReader(root, source) {
699
+ switch (source.kind) {
700
+ case "worktree":
701
+ return (file) => readSourceFile(root, file);
702
+ case "index":
703
+ return (file) => fileInIndex(root, file);
704
+ case "commit":
705
+ return (file) => fileAtCommit(root, source.ref, file);
706
+ }
707
+ }
666
708
 
667
709
  // src/core/baseline/changes.ts
668
710
  import { diffArrays } from "diff";
@@ -734,7 +776,7 @@ function snapshotOf(text) {
734
776
  }
735
777
 
736
778
  // src/core/baseline/baseline.ts
737
- async function computeChanges(root, files, sources) {
779
+ async function computeChanges(root, files, sources, read = contentReader(root, WORKTREE)) {
738
780
  const changes = { sets: /* @__PURE__ */ new Map(), maps: /* @__PURE__ */ new Map(), fromCommit: /* @__PURE__ */ new Map() };
739
781
  for (const file of files) {
740
782
  if (!sources.snapshots.has(file) && !sources.fallbackCommit) continue;
@@ -745,7 +787,7 @@ async function computeChanges(root, files, sources) {
745
787
  before = commitText === null ? null : snapshotOf(commitText);
746
788
  }
747
789
  if (!before) continue;
748
- const current = await readSourceFile(root, file);
790
+ const current = await read(file);
749
791
  if (current === null) continue;
750
792
  if (commitText !== null) changes.fromCommit.set(file, commitText);
751
793
  const after = snapshotOf(current);
@@ -821,10 +863,59 @@ async function applySizeCeiling(selections, maxBytes, bounded, sizeOf) {
821
863
  };
822
864
  }
823
865
 
866
+ // src/core/suppress.ts
867
+ var ID = RULE_ID.source.slice(1, -1);
868
+ var IGNORE = new RegExp(
869
+ String.raw`rulecast-ignore:\s*(?<rule>${ID})(?:\s+(?<reason>\S.*?))?\s*(?:\*\/|-->)?\s*$`
870
+ );
871
+ function parseIgnore(line) {
872
+ const match = IGNORE.exec(line);
873
+ if (match?.groups === void 0) return null;
874
+ return { rule: match.groups.rule, reason: match.groups.reason ?? null };
875
+ }
876
+ async function suppress(findings, read) {
877
+ const result = { findings: [], ignored: [], warnings: [] };
878
+ if (findings.length === 0) return result;
879
+ const lines = /* @__PURE__ */ new Map();
880
+ const linesOf = async (file) => {
881
+ if (!lines.has(file)) lines.set(file, (await read(file))?.split("\n") ?? null);
882
+ return lines.get(file);
883
+ };
884
+ const warned = /* @__PURE__ */ new Set();
885
+ for (const finding of findings) {
886
+ const source = await linesOf(finding.match.file);
887
+ let ignore = null;
888
+ let at = 0;
889
+ for (const line of [finding.match.line, finding.match.line - 1]) {
890
+ const parsed = line >= 1 ? parseIgnore(source?.[line - 1] ?? "") : null;
891
+ if (parsed !== null && parsed.rule === finding.rule.id) {
892
+ ignore = parsed;
893
+ at = line;
894
+ break;
895
+ }
896
+ }
897
+ if (ignore === null) {
898
+ result.findings.push(finding);
899
+ continue;
900
+ }
901
+ if (ignore.reason === null) {
902
+ const where = `${finding.match.file}:${at}`;
903
+ if (!warned.has(where)) {
904
+ warned.add(where);
905
+ result.warnings.push(`${where}: rulecast-ignore needs a reason after the rule id; the finding was kept`);
906
+ }
907
+ result.findings.push(finding);
908
+ continue;
909
+ }
910
+ result.ignored.push({ ...finding, reason: ignore.reason });
911
+ }
912
+ return result;
913
+ }
914
+
824
915
  // src/core/detection/run.ts
825
916
  var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
826
917
  async function runDetection(input) {
827
- const output = { findings: [], errors: [], timedOut: [] };
918
+ const output = { findings: [], errors: [], timedOut: [], ignored: [], warnings: [] };
828
919
  const byKind = /* @__PURE__ */ new Map();
829
920
  for (const selection of input.selections) {
830
921
  const kind = selection.rule.detector.kind;
@@ -841,7 +932,7 @@ async function runDetection(input) {
841
932
  const late = () => Date.now() > deadlineAt;
842
933
  const perKind = await Promise.all(
843
934
  [...byKind].map(async ([kind, selections]) => {
844
- const detector = input.registry.get(kind);
935
+ const detector = input.detection.registry.get(kind);
845
936
  if (!detector) return { status: "error", kind, selections, message: `detector "${kind}" is not registered` };
846
937
  try {
847
938
  const rules = await Promise.all(
@@ -849,7 +940,7 @@ async function runDetection(input) {
849
940
  id: rule.id,
850
941
  config: rule.detector.config,
851
942
  files,
852
- context: await input.contextFor(rule)
943
+ context: await input.detection.contextFor(rule)
853
944
  }))
854
945
  );
855
946
  const result = await Promise.race([
@@ -857,10 +948,11 @@ async function runDetection(input) {
857
948
  event: input.event,
858
949
  rules,
859
950
  read: input.read,
951
+ fromDisk: input.fromDisk ?? true,
860
952
  changes: input.changes,
861
- cache: input.cacheFor(kind),
862
- settings: input.settings,
863
- cwd: input.root,
953
+ cache: input.detection.cacheFor(kind),
954
+ settings: input.detection.settings,
955
+ cwd: input.detection.root,
864
956
  signal: controller.signal,
865
957
  deadlineAt
866
958
  }),
@@ -908,20 +1000,21 @@ async function runDetection(input) {
908
1000
  if (!failed.has(finding.rule)) output.findings.push({ rule: rulesById.get(finding.rule), match: finding.match });
909
1001
  }
910
1002
  }
911
- return output;
1003
+ const suppressed = await suppress(output.findings, input.read);
1004
+ return { ...output, ...suppressed };
912
1005
  }
913
1006
 
914
1007
  // src/core/baseline/store.ts
915
- import path7 from "path";
1008
+ import path8 from "path";
916
1009
 
917
1010
  // src/core/jsonl.ts
918
1011
  import { mkdir as mkdir3, open, readFile as readFile3 } from "fs/promises";
919
- import path6 from "path";
1012
+ import path7 from "path";
920
1013
  var CorruptStoreError = class extends Error {
921
1014
  };
922
1015
  async function appendRecords(file, records) {
923
1016
  if (records.length === 0) return;
924
- await mkdir3(path6.dirname(file), { recursive: true });
1017
+ await mkdir3(path7.dirname(file), { recursive: true });
925
1018
  const handle = await open(file, "a+");
926
1019
  try {
927
1020
  await dropPartialRecord(handle);
@@ -973,7 +1066,7 @@ async function readRecords(file) {
973
1066
 
974
1067
  // src/core/baseline/store.ts
975
1068
  function storeFile(sessionDir2) {
976
- return path7.join(sessionDir2, "baseline.jsonl");
1069
+ return path8.join(sessionDir2, "baseline.jsonl");
977
1070
  }
978
1071
  function encodeLines(lines) {
979
1072
  return Buffer.from(lines.buffer, lines.byteOffset, lines.byteLength).toString("base64");
@@ -984,8 +1077,8 @@ function decodeLines(encoded) {
984
1077
  aligned.set(bytes);
985
1078
  return new Uint32Array(aligned.buffer);
986
1079
  }
987
- function startRecord(commit) {
988
- return { t: "start", commit };
1080
+ function startRecord(commit, configHash2 = null) {
1081
+ return { t: "start", commit, configHash: configHash2 };
989
1082
  }
990
1083
  function snapshotRecord(file, snapshot) {
991
1084
  return { t: "snapshot", file, fileHash: snapshot.fileHash, lines: encodeLines(snapshot.lines) };
@@ -996,29 +1089,37 @@ function fingerprintRecord(file, rule, ranges) {
996
1089
  async function appendBaseline(sessionDir2, records) {
997
1090
  await appendRecords(storeFile(sessionDir2), records);
998
1091
  }
1092
+ function mergeFingerprints(into, records) {
1093
+ for (const record of records) {
1094
+ if (record.t !== "fingerprint") continue;
1095
+ const byRule = into.get(record.file) ?? /* @__PURE__ */ new Map();
1096
+ into.set(record.file, byRule);
1097
+ if (!byRule.has(record.rule)) byRule.set(record.rule, record.ranges);
1098
+ }
1099
+ return into;
1100
+ }
999
1101
  async function readBaseline(sessionDir2) {
1000
1102
  const state = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map(), fingerprints: /* @__PURE__ */ new Map() };
1001
- for (const record of await readRecords(storeFile(sessionDir2))) {
1103
+ const records = await readRecords(storeFile(sessionDir2));
1104
+ for (const record of records) {
1002
1105
  if (record.t === "start" && !state.started) {
1003
1106
  state.started = true;
1004
1107
  state.startCommit = record.commit;
1108
+ state.startConfigHash = record.configHash ?? null;
1005
1109
  } else if (record.t === "snapshot" && !state.snapshots.has(record.file)) {
1006
1110
  state.snapshots.set(record.file, { fileHash: record.fileHash, lines: decodeLines(record.lines) });
1007
- } else if (record.t === "fingerprint") {
1008
- const byRule = state.fingerprints.get(record.file) ?? /* @__PURE__ */ new Map();
1009
- state.fingerprints.set(record.file, byRule);
1010
- if (!byRule.has(record.rule)) byRule.set(record.rule, record.ranges);
1011
1111
  }
1012
1112
  }
1113
+ mergeFingerprints(state.fingerprints, records);
1013
1114
  return state;
1014
1115
  }
1015
1116
 
1016
1117
  // src/core/baseline/record.ts
1017
1118
  async function recordFingerprints(input) {
1018
1119
  if (!input.rules.some((rule) => rule.scope === "container")) return [];
1019
- const guards = (kind) => input.registry.get(kind)?.guards === true;
1120
+ const guards = (kind) => input.detection.registry.get(kind)?.guards === true;
1020
1121
  let selections = selectDetectorRules(input.rules, input.event, input.files, input.disabled).filter(
1021
- (selection) => selection.rule.scope === "container" && selection.rule.detector.kind !== "llm" && (input.source === "disk" || guards(selection.rule.detector.kind))
1122
+ (selection) => selection.rule.scope === "container" && input.detection.registry.get(selection.rule.detector.kind)?.metered !== true && (input.source === "disk" || guards(selection.rule.detector.kind))
1022
1123
  );
1023
1124
  if (input.ceiling) {
1024
1125
  const applied = await applySizeCeiling(selections, input.ceiling.maxFileBytes, guards, input.ceiling.sizeOf);
@@ -1026,7 +1127,7 @@ async function recordFingerprints(input) {
1026
1127
  }
1027
1128
  if (selections.length === 0) return [];
1028
1129
  const output = await runDetection({
1029
- root: input.root,
1130
+ detection: input.detection,
1030
1131
  event: input.event,
1031
1132
  selections,
1032
1133
  // No change set: the baseline content is being measured whole, and there is nothing it is a
@@ -1034,10 +1135,6 @@ async function recordFingerprints(input) {
1034
1135
  // exactly right here.
1035
1136
  changes: /* @__PURE__ */ new Map(),
1036
1137
  read: input.read,
1037
- registry: input.registry,
1038
- cacheFor: input.cacheFor,
1039
- contextFor: input.contextFor,
1040
- settings: input.settings,
1041
1138
  timeoutMs: input.timeoutMs
1042
1139
  });
1043
1140
  const unmeasured = /* @__PURE__ */ new Set();
@@ -1058,9 +1155,101 @@ async function recordFingerprints(input) {
1058
1155
  return records;
1059
1156
  }
1060
1157
 
1158
+ // src/core/baseline/stage.ts
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) {
1170
+ const records = [];
1171
+ const snapshotted = [];
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++;
1177
+ const text = await readSourceFile(input.root, file);
1178
+ if (text === null) continue;
1179
+ records.push(snapshotRecord(file, snapshotOf(text)));
1180
+ snapshotted.push(file);
1181
+ }
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 };
1185
+ records.push(
1186
+ ...await recordFingerprints({
1187
+ detection: input.detection,
1188
+ files: snapshotted,
1189
+ rules: input.rules,
1190
+ disabled: input.disabled,
1191
+ read: (file) => readSourceFile(input.root, file),
1192
+ event: "edit",
1193
+ source: "disk",
1194
+ timeoutMs,
1195
+ ceiling: { maxFileBytes: input.limits.maxFileBytes, sizeOf: (file) => fileBytes(input.root, file) }
1196
+ })
1197
+ );
1198
+ return { records, skipped };
1199
+ }
1200
+ async function changesFor(input, event, state, files, read) {
1201
+ let snapshots = state?.snapshots ?? /* @__PURE__ */ new Map();
1202
+ let fallbackCommit = state?.startCommit ?? null;
1203
+ if (event.kind === "verify" && event.baseCommit) {
1204
+ fallbackCommit = event.baseCommit;
1205
+ snapshots = /* @__PURE__ */ new Map();
1206
+ }
1207
+ const changes = await computeChanges(input.root, files, { snapshots, fallbackCommit }, read);
1208
+ const kept = event.kind === "verify" && state !== null && !event.baseCommit ? files.filter((file) => changes.sets.get(file)?.changedLines.length !== 0) : [...files];
1209
+ const fingerprints = new Map(
1210
+ [...state?.fingerprints ?? []].map(([file, byRule]) => [file, new Map(byRule)])
1211
+ );
1212
+ if (event.kind !== "verify" || changes.fromCommit.size === 0) {
1213
+ return { files: kept, changes, fingerprints, records: [] };
1214
+ }
1215
+ const texts = changes.fromCommit;
1216
+ const missing = [...texts.keys()].filter((file) => !fingerprints.has(file));
1217
+ const records = missing.length === 0 ? [] : await recordFingerprints({
1218
+ detection: input.detection,
1219
+ files: missing,
1220
+ rules: input.rules,
1221
+ disabled: input.disabled,
1222
+ read: async (file) => texts.get(file) ?? null,
1223
+ event: "verify",
1224
+ source: "memory",
1225
+ timeoutMs: input.limits.verifyMs
1226
+ });
1227
+ return { files: kept, changes, fingerprints: mergeFingerprints(fingerprints, records), records };
1228
+ }
1229
+
1061
1230
  // src/core/delivery/persist.ts
1062
- import { mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
1063
- import path8 from "path";
1231
+ import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
1232
+ import path9 from "path";
1233
+ var AGENT_DIR = ".rulecast";
1234
+ function agentDir(root) {
1235
+ const dir = path9.join(root, AGENT_DIR);
1236
+ mkdirSync2(dir, { recursive: true });
1237
+ const ignore = path9.join(dir, ".gitignore");
1238
+ if (!existsSync3(ignore)) writeFileSync2(ignore, "# Written by rulecast: files its hooks point an agent at.\n*\n");
1239
+ return dir;
1240
+ }
1241
+ function copyIntoProject(root, home, file) {
1242
+ const rest = path9.relative(home, file);
1243
+ if (rest.startsWith("..") || path9.isAbsolute(rest)) return null;
1244
+ try {
1245
+ const target = path9.join(agentDir(root), rest);
1246
+ mkdirSync2(path9.dirname(target), { recursive: true });
1247
+ copyFileSync(file, target);
1248
+ return path9.join(AGENT_DIR, rest).split(path9.sep).join("/");
1249
+ } catch {
1250
+ return null;
1251
+ }
1252
+ }
1064
1253
  var KEEP = 3;
1065
1254
  var UNLIMITED = { maxMatchesPerRule: Number.MAX_SAFE_INTEGER };
1066
1255
  var safeSegment2 = (value) => value.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -1076,15 +1265,14 @@ function markdown(delivery, now) {
1076
1265
  ""
1077
1266
  ].join("\n");
1078
1267
  }
1079
- function writeOverflow(stateDir, session, delivery, now = /* @__PURE__ */ new Date()) {
1268
+ function writeOverflow(dir, session, delivery, now = /* @__PURE__ */ new Date()) {
1080
1269
  const prefix = safeSegment2(session ?? "no-session");
1081
- const dir = path8.join(stateDir, "deliveries");
1082
- const file = path8.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
1270
+ const file = path9.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
1083
1271
  try {
1084
1272
  mkdirSync2(dir, { recursive: true });
1085
1273
  writeFileSync2(file, markdown(delivery, now));
1086
1274
  const mine = readdirSync(dir).filter((name) => name.startsWith(`${prefix}-`)).sort();
1087
- for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) rmSync(path8.join(dir, old), { force: true });
1275
+ for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) rmSync(path9.join(dir, old), { force: true });
1088
1276
  return file;
1089
1277
  } catch {
1090
1278
  return null;
@@ -1102,16 +1290,16 @@ async function resolveRuleContext(resolver, context) {
1102
1290
  }
1103
1291
  function createReferenceResolver(root) {
1104
1292
  const texts = /* @__PURE__ */ new Map();
1105
- const read = (path11) => {
1106
- let text = texts.get(path11);
1293
+ const read = (path16) => {
1294
+ let text = texts.get(path16);
1107
1295
  if (!text) {
1108
- text = readSourceFile(root, path11);
1109
- texts.set(path11, text);
1296
+ text = readSourceFile(root, path16);
1297
+ texts.set(path16, text);
1110
1298
  }
1111
1299
  return text;
1112
1300
  };
1113
- const contentOf = async (path11, anchor) => {
1114
- const text = await read(path11);
1301
+ const contentOf = async (path16, anchor) => {
1302
+ const text = await read(path16);
1115
1303
  if (text === null) return null;
1116
1304
  if (anchor === null) return text.trimEnd();
1117
1305
  const section = sectionRange(text, anchor);
@@ -1123,19 +1311,108 @@ function createReferenceResolver(root) {
1123
1311
  if (content === null) return { spec, found: false };
1124
1312
  return { spec, found: true, content, hash: fnv1a(content), bytes: Buffer.byteLength(content) };
1125
1313
  },
1126
- async currentHash(path11, anchor) {
1127
- const content = await contentOf(path11, anchor);
1314
+ async currentHash(path16, anchor) {
1315
+ const content = await contentOf(path16, anchor);
1128
1316
  return content === null ? null : fnv1a(content);
1129
1317
  },
1130
- async contains(path11, parent, child) {
1318
+ async contains(path16, parent, child) {
1131
1319
  if (parent === null) return true;
1132
1320
  if (child === null) return false;
1133
- const text = await read(path11);
1321
+ const text = await read(path16);
1134
1322
  return text !== null && sectionContains(text, parent, child);
1135
1323
  }
1136
1324
  };
1137
1325
  }
1138
1326
 
1327
+ // src/core/detection/context.ts
1328
+ function detectionFor(project, registry, stateDir, resolver) {
1329
+ return {
1330
+ root: project.root,
1331
+ registry,
1332
+ settings: { llm: project.config.llm },
1333
+ cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
1334
+ contextFor: (rule) => resolveRuleContext(resolver, rule.context)
1335
+ };
1336
+ }
1337
+
1338
+ // src/core/detection/scratch.ts
1339
+ import { mkdir as mkdir4, mkdtemp, rm as rm3, writeFile } from "fs/promises";
1340
+ import path10 from "path";
1341
+ async function withScratchTree(root, prefix, entries, fn) {
1342
+ const dir = path10.basename(await mkdtemp(path10.join(root, prefix)));
1343
+ try {
1344
+ for (const [file, content] of entries) {
1345
+ const full = path10.join(root, dir, file);
1346
+ await mkdir4(path10.dirname(full), { recursive: true });
1347
+ await writeFile(full, content);
1348
+ }
1349
+ return await fn(dir);
1350
+ } finally {
1351
+ await rm3(path10.join(root, dir), { recursive: true, force: true });
1352
+ }
1353
+ }
1354
+
1355
+ // src/core/detection/materialise.ts
1356
+ var STAGED_DIR = ".rulecast-staged-";
1357
+ async function runDetectionFrom(input, source) {
1358
+ if (source.kind === "worktree") return runDetection(input);
1359
+ const registry = input.detection.registry;
1360
+ const direct = [];
1361
+ const copied = [];
1362
+ for (const selection of input.selections) {
1363
+ const detector = registry.get(selection.rule.detector.kind);
1364
+ const readsContent = detector?.guards === true || detector?.takesContent?.(selection.rule.detector.config) === true;
1365
+ if (readsContent || detector === void 0) direct.push(selection);
1366
+ else copied.push(selection);
1367
+ }
1368
+ const outputs = await Promise.all([
1369
+ direct.length === 0 ? null : runDetection({ ...input, selections: direct, fromDisk: false }),
1370
+ copied.length === 0 ? null : fromScratch(input, copied)
1371
+ ]);
1372
+ return merge(outputs.filter((output) => output !== null));
1373
+ }
1374
+ async function fromScratch(input, selections) {
1375
+ const entries = /* @__PURE__ */ new Map();
1376
+ for (const file of new Set(selections.flatMap((selection) => selection.files))) {
1377
+ const text = await input.read(file);
1378
+ if (text !== null) entries.set(file, text);
1379
+ }
1380
+ if (entries.size === 0) return { findings: [], errors: [], timedOut: [], ignored: [], warnings: [] };
1381
+ return withScratchTree(input.detection.root, STAGED_DIR, entries, async (dir) => {
1382
+ const prefix = `${dir}/`;
1383
+ const strip = (file) => file.startsWith(prefix) ? file.slice(prefix.length) : file;
1384
+ const output = await runDetection({
1385
+ ...input,
1386
+ selections: selections.map((selection) => ({
1387
+ ...selection,
1388
+ files: selection.files.filter((file) => entries.has(file)).map((file) => prefix + file)
1389
+ })).filter((selection) => selection.files.length > 0),
1390
+ changes: new Map([...input.changes].map(([file, change]) => [prefix + file, change])),
1391
+ // Anything reading through `read` under a scratch path is asking about the real file.
1392
+ read: (file) => input.read(strip(file))
1393
+ });
1394
+ return {
1395
+ findings: output.findings.map(({ rule, match }) => ({ rule, match: { ...match, file: strip(match.file) } })),
1396
+ errors: output.errors.map((error) => ({ ...error, message: error.message.replaceAll(prefix, "") })),
1397
+ timedOut: output.timedOut,
1398
+ ignored: output.ignored.map((ignored) => ({
1399
+ ...ignored,
1400
+ match: { ...ignored.match, file: strip(ignored.match.file) }
1401
+ })),
1402
+ warnings: output.warnings.map((warning) => warning.replaceAll(prefix, ""))
1403
+ };
1404
+ });
1405
+ }
1406
+ function merge(outputs) {
1407
+ return {
1408
+ findings: outputs.flatMap((output) => output.findings),
1409
+ errors: outputs.flatMap((output) => output.errors),
1410
+ timedOut: outputs.flatMap((output) => output.timedOut),
1411
+ ignored: outputs.flatMap((output) => output.ignored),
1412
+ warnings: outputs.flatMap((output) => output.warnings)
1413
+ };
1414
+ }
1415
+
1139
1416
  // src/core/detection/proposal.ts
1140
1417
  function propose(current, intent) {
1141
1418
  if (intent.content !== void 0) {
@@ -1161,8 +1438,8 @@ function propose(current, intent) {
1161
1438
  }
1162
1439
  return { content: content + rest, inserts };
1163
1440
  }
1164
- function isInserted(proposal, match, detector) {
1165
- if (detector === "path") return true;
1441
+ function isInserted(proposal, match, wholeFile) {
1442
+ if (wholeFile) return true;
1166
1443
  const starts = lineStarts(proposal.content);
1167
1444
  const lineStart = starts[match.line - 1];
1168
1445
  if (lineStart === void 0) return false;
@@ -1172,7 +1449,7 @@ function isInserted(proposal, match, detector) {
1172
1449
  }
1173
1450
 
1174
1451
  // src/core/session/decide.ts
1175
- import path9 from "path";
1452
+ import path11 from "path";
1176
1453
 
1177
1454
  // src/core/session/state.ts
1178
1455
  function preexistingKey(rule, file) {
@@ -1180,7 +1457,20 @@ function preexistingKey(rule, file) {
1180
1457
  }
1181
1458
  var refusalKey = preexistingKey;
1182
1459
  function emptyWork() {
1183
- return { edited: [], stopBlocks: /* @__PURE__ */ new Map(), refusals: /* @__PURE__ */ new Map(), disabled: /* @__PURE__ */ new Map(), accessed: /* @__PURE__ */ new Map() };
1460
+ return {
1461
+ edited: [],
1462
+ stopBlocks: /* @__PURE__ */ new Map(),
1463
+ refusals: /* @__PURE__ */ new Map(),
1464
+ disabled: /* @__PURE__ */ new Map(),
1465
+ accessed: /* @__PURE__ */ new Map(),
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
1473
+ };
1184
1474
  }
1185
1475
  function emptyContext() {
1186
1476
  return { delivered: [], touched: /* @__PURE__ */ new Set(), preexisting: /* @__PURE__ */ new Set(), warned: /* @__PURE__ */ new Set() };
@@ -1191,6 +1481,19 @@ function foldWork(records) {
1191
1481
  switch (record.t) {
1192
1482
  case "edited":
1193
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;
1194
1497
  break;
1195
1498
  case "accessed": {
1196
1499
  const files = state.accessed.get(record.agent) ?? [];
@@ -1211,10 +1514,20 @@ function foldWork(records) {
1211
1514
  case "disabled":
1212
1515
  if (!state.disabled.has(record.rule)) state.disabled.set(record.rule, record.reason);
1213
1516
  break;
1517
+ case "noticed":
1518
+ state.noticed.add(record.key);
1519
+ break;
1214
1520
  }
1215
1521
  }
1216
1522
  return state;
1217
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
+ }
1218
1531
  function foldContext(records) {
1219
1532
  let state = emptyContext();
1220
1533
  for (const record of records) {
@@ -1240,15 +1553,10 @@ function foldContext(records) {
1240
1553
  }
1241
1554
 
1242
1555
  // src/core/session/decide.ts
1243
- var ITEM_OVERHEAD = 64;
1244
- var HEADER_OVERHEAD = 128;
1245
- var OVERFLOW_NOTICE = 256;
1246
- var REFERENCE_OVERHEAD = 96;
1247
- var BACKLOG_HEADING = "backlog in files you touched (not from your edit):";
1248
1556
  var WARNING_SHARE = 0.15;
1249
1557
  var moreWarnings = (count) => `\u2026and ${count} more (run rulecast validate)`;
1250
1558
  function locationOf(spec) {
1251
- return path9.isAbsolute(spec.path) ? { location: spec.path } : {};
1559
+ return path11.isAbsolute(spec.path) ? { location: spec.path } : {};
1252
1560
  }
1253
1561
  function capturesFor(rule, match) {
1254
1562
  const names = templateBindings(rule.message ?? "");
@@ -1311,37 +1619,43 @@ async function isCovered(spec, delivered, resolver) {
1311
1619
  }
1312
1620
  return false;
1313
1621
  }
1314
- async function decide(input) {
1622
+ async function assemble(input) {
1315
1623
  const delivery = emptyDelivery();
1316
- const work = [];
1624
+ if (input.via !== void 0) delivery.via = input.via;
1317
1625
  const context = [];
1626
+ const touches = input.touches ?? [];
1627
+ const state = input.context ?? emptyContext();
1318
1628
  const fresh = input.findings.filter((finding) => finding.status === "new");
1319
1629
  delivery.findings = renderFindings(fresh);
1320
- const rulesById = new Map(fresh.map(({ rule }) => [rule.id, rule]));
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 ?? []];
1321
1634
  for (const { rule } of fresh) if (rule.message !== null) delivery.templates[rule.id] = rule.message;
1322
1635
  const summaries = /* @__PURE__ */ new Map();
1323
1636
  for (const { rule, match, status } of input.findings) {
1324
1637
  if (status !== "preexisting") continue;
1325
1638
  const key = preexistingKey(rule.id, match.file);
1326
- if (input.context.preexisting.has(key)) continue;
1639
+ if (state.preexisting.has(key)) continue;
1327
1640
  const summary = summaries.get(key) ?? { rule: rule.id, file: match.file, count: 0 };
1328
1641
  summary.count++;
1329
1642
  summaries.set(key, summary);
1330
1643
  }
1331
1644
  delivery.preexistingSummary = [...summaries.values()];
1332
- const unwarned = input.warnings.filter((warning) => !input.context.warned.has(warning.key));
1645
+ const unwarned = (input.warnings ?? []).filter((warning) => !state.warned.has(warning.key));
1333
1646
  delivery.warnings = unwarned.map((warning) => warning.text);
1334
- delivery.touches = input.touches.map((rule) => rule.id);
1335
- for (const rule of input.touches) context.push({ t: "touched", rule: rule.id });
1336
- const delivered = [...input.context.delivered];
1337
- if (input.agentRead !== null) {
1338
- const hash = await input.resolver.currentHash(input.agentRead, null);
1647
+ delivery.touches = touches.map((rule) => rule.id);
1648
+ for (const rule of touches) context.push({ t: "touched", rule: rule.id });
1649
+ const delivered = [...state.delivered];
1650
+ const agentRead = input.agentRead ?? null;
1651
+ if (agentRead !== null) {
1652
+ const hash = await input.resolver.currentHash(agentRead, null);
1339
1653
  if (hash !== null) {
1340
- delivered.push({ path: input.agentRead, anchor: null, hash });
1341
- context.push({ t: "delivered", path: input.agentRead, anchor: null, hash });
1654
+ delivered.push({ path: agentRead, anchor: null, hash });
1655
+ context.push({ t: "delivered", path: agentRead, anchor: null, hash });
1342
1656
  }
1343
1657
  }
1344
- const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...input.touches];
1658
+ const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...touches];
1345
1659
  const candidates = [];
1346
1660
  for (const spec of referencesInOrder(ruleOrder)) {
1347
1661
  const resolved = await input.resolver.resolve(spec);
@@ -1358,47 +1672,72 @@ async function decide(input) {
1358
1672
  delivery.references.push({ ref: spec.ref, state: "full", content: resolved.content });
1359
1673
  }
1360
1674
  }
1361
- const limit = input.maxContextChars;
1362
- const render = { maxMatchesPerRule: input.maxMatchesPerRule };
1363
- const complete = {
1364
- ...delivery,
1365
- findings: [...delivery.findings],
1366
- preexistingSummary: [...delivery.preexistingSummary],
1367
- references: [...delivery.references],
1368
- warnings: [...delivery.warnings],
1675
+ return { delivery, candidates, fresh, unwarned, touches, context };
1676
+ }
1677
+ function trim(assembled, limits) {
1678
+ const { candidates, unwarned, touches } = assembled;
1679
+ const delivery = {
1680
+ ...assembled.delivery,
1681
+ findings: [...assembled.delivery.findings],
1682
+ preexistingSummary: [...assembled.delivery.preexistingSummary],
1683
+ references: [...assembled.delivery.references],
1684
+ warnings: [...assembled.delivery.warnings],
1369
1685
  omitted: { findings: [], rules: 0, preexisting: 0 }
1370
1686
  };
1687
+ const context = [];
1688
+ const rulesById = new Map(assembled.fresh.map(({ rule }) => [rule.id, rule]));
1689
+ const limit = limits.maxContextChars;
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;
1371
1694
  const byRule = /* @__PURE__ */ new Map();
1372
- for (const finding of delivery.findings) {
1373
- const group = byRule.get(finding.rule);
1374
- 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]);
1375
1702
  else group.push(finding);
1376
1703
  }
1377
- let used = HEADER_OVERHEAD;
1704
+ const conventions = delivery.references.length > 0 || delivery.preexistingSummary.length > 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);
1378
1712
  const fits = (size) => limit === null || used + size <= limit;
1379
1713
  const kept = /* @__PURE__ */ new Map();
1380
- const lineCost = (ref) => ref.length + REFERENCE_OVERHEAD;
1714
+ const specs = /* @__PURE__ */ new Map();
1715
+ for (const rule of [...rulesById.values(), ...touches]) for (const spec of rule.context) specs.set(spec.ref, spec);
1716
+ const lineCost = (ref) => {
1717
+ const spec = specs.get(ref);
1718
+ return deliveryCost.referenceLine(ref, spec === void 0 ? void 0 : locationOf(spec).location);
1719
+ };
1381
1720
  const resolvedRefs = new Set(delivery.references.map((reference) => reference.ref));
1382
1721
  const charged = /* @__PURE__ */ new Set();
1383
1722
  const orphaned = /* @__PURE__ */ new Set();
1384
1723
  const floorWithin = (cap) => {
1385
- 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() };
1386
1725
  const uncharged = (rule) => [
1387
1726
  ...new Set(rule.context.map((spec) => spec.ref).filter((ref) => resolvedRefs.has(ref) && !spent.refs.has(ref)))
1388
1727
  ];
1389
1728
  for (const [id, findings] of byRule) {
1390
- const rule = rulesById.get(id);
1729
+ const rule = rulesById.get(ruleOf(id));
1391
1730
  const refs = rule === void 0 ? [] : uncharged(rule);
1392
- const size = measureRuleBlock(id, [findings[0]], 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);
1393
1732
  if (used + spent.chars + size > cap) {
1394
- spent.dropped++;
1733
+ spent.dropped.add(id);
1395
1734
  continue;
1396
1735
  }
1397
1736
  spent.chars += size;
1398
1737
  for (const ref of refs) spent.refs.add(ref);
1399
1738
  spent.kept.set(id, [findings[0]]);
1400
1739
  }
1401
- for (const touch of input.touches) {
1740
+ for (const touch of touches) {
1402
1741
  for (const ref of uncharged(touch)) {
1403
1742
  spent.chars += lineCost(ref);
1404
1743
  spent.refs.add(ref);
@@ -1406,21 +1745,25 @@ async function decide(input) {
1406
1745
  }
1407
1746
  return spent;
1408
1747
  };
1748
+ let droppedBlocks = 0;
1409
1749
  if (limit !== null) {
1410
1750
  let floor = floorWithin(limit);
1411
- if (floor.dropped > 0) floor = floorWithin(limit - OVERFLOW_NOTICE);
1412
- used += floor.chars + (floor.dropped > 0 ? OVERFLOW_NOTICE : 0);
1413
- delivery.omitted.rules = floor.dropped;
1751
+ const notice = deliveryCost.overflowNotice(byRule.size);
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;
1414
1757
  for (const [id, findings] of floor.kept) kept.set(id, findings);
1415
1758
  for (const ref of floor.refs) charged.add(ref);
1416
1759
  for (const ref of resolvedRefs) if (!charged.has(ref)) orphaned.add(ref);
1417
1760
  }
1418
- const warningCost = (text) => text.length + ITEM_OVERHEAD;
1761
+ const warningCost = (text) => deliveryCost.warning(text);
1419
1762
  if (limit === null) {
1420
1763
  for (const warning of unwarned) context.push({ t: "warned", key: warning.key });
1421
1764
  } else {
1422
1765
  const ceiling = Math.max(0, Math.min(Math.floor(limit * WARNING_SHARE), limit - used));
1423
- let spent = 0;
1766
+ let spent = unwarned.length > 0 ? deliveryCost.warningsFrame : 0;
1424
1767
  let held = 0;
1425
1768
  while (held < unwarned.length && spent + warningCost(unwarned[held].text) <= ceiling) {
1426
1769
  spent += warningCost(unwarned[held].text);
@@ -1439,10 +1782,10 @@ async function decide(input) {
1439
1782
  spent += warningCost(moreWarnings(cut));
1440
1783
  delivery.warnings.push(moreWarnings(cut));
1441
1784
  }
1442
- used += spent;
1785
+ used += delivery.warnings.length > 0 ? spent : 0;
1443
1786
  }
1444
1787
  for (const { index, resolved } of candidates) {
1445
- const size = resolved.content.length;
1788
+ const size = deliveryCost.referenceContent(resolved.spec.ref, resolved.content, locationOf(resolved.spec).location);
1446
1789
  if (orphaned.has(resolved.spec.ref)) continue;
1447
1790
  if (!fits(size)) {
1448
1791
  delivery.references[index] = {
@@ -1456,14 +1799,16 @@ async function decide(input) {
1456
1799
  used += size;
1457
1800
  context.push({ t: "delivered", path: resolved.spec.path, anchor: resolved.spec.anchor, hash: resolved.hash });
1458
1801
  }
1459
- if (delivery.preexistingSummary.length > 0) used += BACKLOG_HEADING.length + BACKLOG_HINT.length + ITEM_OVERHEAD;
1802
+ const frame = delivery.preexistingSummary.length > 0 ? deliveryCost.backlogFrame(delivery.preexistingSummary.length) : 0;
1803
+ if (frame > 0 && !fits(frame)) delivery.preexistingSummary = [];
1804
+ else used += frame;
1460
1805
  const summariesKept = [];
1461
1806
  for (const summary of delivery.preexistingSummary) {
1462
- if (!fits(summary.rule.length + summary.file.length + ITEM_OVERHEAD)) {
1807
+ if (!fits(deliveryCost.backlogSummary(summary))) {
1463
1808
  delivery.omitted.preexisting++;
1464
1809
  continue;
1465
1810
  }
1466
- used += summary.rule.length + summary.file.length + ITEM_OVERHEAD;
1811
+ used += deliveryCost.backlogSummary(summary);
1467
1812
  summariesKept.push(summary);
1468
1813
  context.push({ t: "preexisting", rule: summary.rule, file: summary.file });
1469
1814
  }
@@ -1474,9 +1819,9 @@ async function decide(input) {
1474
1819
  added = false;
1475
1820
  for (const [rule, findings] of byRule) {
1476
1821
  const shown = kept.get(rule);
1477
- if (shown === void 0 || shown.length >= Math.min(findings.length, input.maxMatchesPerRule)) continue;
1478
- const template = delivery.templates[rule];
1479
- const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], template, render) - measureRuleBlock(rule, shown, template, render);
1822
+ if (shown === void 0 || shown.length >= Math.min(findings.length, limits.maxMatchesPerRule)) continue;
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);
1480
1825
  if (!fits(delta)) continue;
1481
1826
  used += delta;
1482
1827
  shown.push(findings[shown.length]);
@@ -1489,27 +1834,38 @@ async function decide(input) {
1489
1834
  const shownFiles = new Set(shown.map((finding) => finding.file));
1490
1835
  const dropped = findings.slice(shown.length);
1491
1836
  delivery.omitted.findings.push({
1492
- rule,
1837
+ rule: ruleOf(rule),
1493
1838
  count: dropped.length,
1494
- 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 }
1495
1841
  });
1496
1842
  }
1497
- 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);
1498
1844
  }
1499
- if (input.stopGate) {
1500
- const newErrors = fresh.some(({ rule }) => rule.severity === "error");
1501
- const blocks = input.work.stopBlocks.get(input.agent) ?? 0;
1502
- if (!newErrors) delivery.stop = "allow";
1503
- else if (blocks < input.maxBlocks) {
1504
- delivery.stop = "block";
1505
- work.push({ t: "stopBlock", agent: input.agent });
1506
- } else delivery.stop = "capReached";
1507
- if (delivery.stop === "allow") return { delivery, overflow: null, work, context: [] };
1508
- if (delivery.stop === "capReached") {
1509
- return { delivery, overflow: delivery.omitted.rules > 0 ? complete : null, work, context: [] };
1510
- }
1845
+ return { delivery, overflow: droppedBlocks > 0 ? assembled.delivery : null, context };
1846
+ }
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: [] };
1511
1852
  }
1512
- return { delivery, overflow: delivery.omitted.rules > 0 ? complete : null, work, context };
1853
+ if ((work.stopBlocks.get(agent) ?? 0) < maxBlocks) return { stop: "block", work: [{ t: "stopBlock", agent }] };
1854
+ return { stop: "capReached", work: [] };
1855
+ }
1856
+ async function decide(input) {
1857
+ const assembled = await assemble(input);
1858
+ const trimmed = trim(assembled, {
1859
+ maxContextChars: input.maxContextChars,
1860
+ maxMatchesPerRule: input.maxMatchesPerRule
1861
+ });
1862
+ const context = [...assembled.context, ...trimmed.context];
1863
+ if (!input.stopGate) return { delivery: trimmed.delivery, overflow: trimmed.overflow, work: [], context };
1864
+ const gated = gate(assembled.fresh, input.work, input.agent, input.maxBlocks, new Set(input.swept ?? []));
1865
+ const delivery = { ...trimmed.delivery, stop: gated.stop };
1866
+ if (gated.stop === "allow") return { delivery, overflow: null, work: gated.work, context: [] };
1867
+ if (gated.stop === "capReached") return { delivery, overflow: trimmed.overflow, work: gated.work, context: [] };
1868
+ return { delivery, overflow: trimmed.overflow, work: gated.work, context };
1513
1869
  }
1514
1870
 
1515
1871
  // src/core/guard.ts
@@ -1519,7 +1875,7 @@ async function guardWrite(input) {
1519
1875
  if (file === void 0 || intent === void 0) return emptyDelivery();
1520
1876
  const rules = input.rules.filter((rule) => rule.refuseWrite && !input.disabled.has(rule.id) && rule.matches(file));
1521
1877
  if (rules.length === 0) return emptyDelivery();
1522
- const proposal = propose(await readSourceFile(input.root, file), intent);
1878
+ const proposal = propose(await readSourceFile(input.detection.root, file), intent);
1523
1879
  if (proposal === null) {
1524
1880
  input.log(`guard: ${file} could not be reconstructed from the tool call; allowing the write`);
1525
1881
  return emptyDelivery();
@@ -1532,19 +1888,18 @@ async function guardWrite(input) {
1532
1888
  const selections = selectDetectorRules(rules, "edit", [file], input.disabled);
1533
1889
  if (selections.length === 0) return emptyDelivery();
1534
1890
  const output = await runDetection({
1535
- root: input.root,
1891
+ detection: input.detection,
1536
1892
  event: "edit",
1537
1893
  selections,
1538
1894
  changes: /* @__PURE__ */ new Map(),
1539
1895
  // The proposed file for the one being written; anything else a detector reaches for is real.
1540
- read: async (name) => name === file ? proposal.content : readSourceFile(input.root, name),
1541
- registry: input.registry,
1542
- cacheFor: (kind) => diskCache(detectorCacheDir(input.stateDir, kind)),
1543
- contextFor: (rule) => resolveRuleContext(input.resolver, rule.context),
1544
- settings: { llm: input.config.llm },
1896
+ read: async (name) => name === file ? proposal.content : readSourceFile(input.detection.root, name),
1545
1897
  timeoutMs: input.config.timeouts.editDeadlineMs
1546
1898
  });
1547
- const evidence = output.findings.filter(({ rule, match }) => isInserted(proposal, match, rule.detector.kind));
1899
+ const wholeFile = (kind) => input.detection.registry.get(kind)?.wholeFile === true;
1900
+ const evidence = output.findings.filter(
1901
+ ({ rule, match }) => isInserted(proposal, match, wholeFile(rule.detector.kind))
1902
+ );
1548
1903
  if (evidence.length === 0) return emptyDelivery();
1549
1904
  const spent = (rule) => (input.work.refusals.get(refusalKey(rule, file)) ?? 0) >= input.config.refuseGate.maxRefusals;
1550
1905
  const refusing = evidence.filter(({ rule }) => !spent(rule.id));
@@ -1552,41 +1907,72 @@ async function guardWrite(input) {
1552
1907
  input.log(`guard: ${file} has spent its refusals; allowing the write`);
1553
1908
  return emptyDelivery();
1554
1909
  }
1555
- const { delivery } = await decide({
1556
- agent: "main",
1910
+ const assembled = await assemble({
1557
1911
  findings: refusing.map(({ rule, match }) => ({ rule, match, status: "new" })),
1558
- touches: [],
1559
- agentRead: null,
1560
- warnings: [],
1561
- work: input.work,
1562
- // An empty context, so the sections come with the refusal even if they were delivered earlier:
1563
- // the message has to stand on its own, and nothing here is recorded as delivered because the
1564
- // write it explains never happened.
1565
- context: emptyContext(),
1566
1912
  resolver: input.resolver,
1567
- maxBytes: input.config.context.maxBytes,
1568
- maxBlocks: input.config.stopGate.maxBlocks,
1913
+ maxBytes: input.config.context.maxBytes
1914
+ });
1915
+ const { delivery, context } = trim(assembled, {
1569
1916
  maxContextChars: input.maxContextChars,
1570
- maxMatchesPerRule: input.config.maxMatchesPerRule,
1571
- stopGate: false
1917
+ maxMatchesPerRule: input.config.maxMatchesPerRule
1572
1918
  });
1919
+ await input.recordContext(context.filter((record) => record.t === "delivered"));
1573
1920
  await input.record(
1574
1921
  [...new Set(refusing.map(({ rule }) => rule.id))].map((rule) => ({ t: "refused", rule, file }))
1575
1922
  );
1576
1923
  return delivery;
1577
1924
  }
1578
1925
 
1926
+ // src/core/session/oversight.ts
1927
+ import { createHash as createHash3 } from "crypto";
1928
+ import { readFile as readFile4 } from "fs/promises";
1929
+ import path12 from "path";
1930
+ function notices(input) {
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
+ }
1937
+ const configKey = `config:${input.currentConfigHash ?? "gone"}`;
1938
+ if (input.startConfigHash !== null && input.startConfigHash !== input.currentConfigHash && !input.told.has(configKey)) {
1939
+ out.push({
1940
+ key: configKey,
1941
+ text: `rulecast: ${CONFIG_FILE} changed during this session; findings may have been silenced. Review: git diff ${CONFIG_FILE}`
1942
+ });
1943
+ }
1944
+ const keyOf = ({ rule, match }) => `ignore:${rule}:${match.file}:${match.line}`;
1945
+ const added = input.ignored.filter((ignored) => ignored.status === "new" && !input.told.has(keyOf(ignored)));
1946
+ if (added.length > 0) {
1947
+ const sites = added.map(({ rule, match, reason }) => `${match.file}:${match.line} (${rule}: "${reason}")`);
1948
+ const comments = added.length === 1 ? "a rulecast-ignore comment" : `${added.length} rulecast-ignore comments`;
1949
+ out.push({
1950
+ key: added.map(keyOf).join("\n"),
1951
+ text: `rulecast: the agent added ${comments} this session: ${sites.join(", ")}`
1952
+ });
1953
+ }
1954
+ return out;
1955
+ }
1956
+ async function configHash(root) {
1957
+ try {
1958
+ return createHash3("sha256").update(await readFile4(path12.join(root, CONFIG_FILE))).digest("hex");
1959
+ } catch (error) {
1960
+ if (isNotFound(error)) return null;
1961
+ throw error;
1962
+ }
1963
+ }
1964
+
1579
1965
  // src/core/session/session.ts
1580
- import path10 from "path";
1966
+ import path13 from "path";
1581
1967
  function safeSegment3(value) {
1582
1968
  const segment = value.replace(/[^A-Za-z0-9._-]/g, "_");
1583
1969
  return /^\.*$/.test(segment) ? `_${segment}` : segment;
1584
1970
  }
1585
1971
  function sessionDir(stateDir, sessionId) {
1586
- return path10.join(stateDir, "sessions", safeSegment3(sessionId));
1972
+ return path13.join(stateDir, "sessions", safeSegment3(sessionId));
1587
1973
  }
1588
- var workFile = (dir) => path10.join(dir, "work.jsonl");
1589
- var contextFile = (dir, agent) => path10.join(dir, `context.${safeSegment3(agent)}.jsonl`);
1974
+ var workFile = (dir) => path13.join(dir, "work.jsonl");
1975
+ var contextFile = (dir, agent) => path13.join(dir, `context.${safeSegment3(agent)}.jsonl`);
1590
1976
  async function appendWork(dir, records) {
1591
1977
  await appendRecords(workFile(dir), records);
1592
1978
  }
@@ -1609,11 +1995,87 @@ async function commitSession(dir, agent, decideWith) {
1609
1995
  });
1610
1996
  }
1611
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
+
1612
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";
1613
2075
  var SUMMARY_THRESHOLD = 3;
1614
2076
  var DEFAULT_HINT = "rulecast validate";
1615
2077
  function summaryKey(prefix, ids) {
1616
- const digest = createHash3("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);
1617
2079
  return `${prefix}:${digest}`;
1618
2080
  }
1619
2081
  function compileWarnings(errors) {
@@ -1636,8 +2098,9 @@ First: ${diagnosticText(errors[0])}`
1636
2098
  }
1637
2099
  ];
1638
2100
  }
1639
- function detectorWarnings(errors) {
1640
- const text = (error) => `${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}. Disabled for this session.`;
2101
+ function detectorWarnings(errors, scope) {
2102
+ const outcome = scope === "session" ? "Disabled for this session." : "Skipped for this run.";
2103
+ const text = (error) => `${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}. ${outcome}`;
1641
2104
  const byKind = /* @__PURE__ */ new Map();
1642
2105
  for (const error of errors) {
1643
2106
  const group = byKind.get(error.kind);
@@ -1658,14 +2121,15 @@ function detectorWarnings(errors) {
1658
2121
  const ids = group.flatMap((error) => error.rules);
1659
2122
  summarised.push({
1660
2123
  key: summaryKey(`detector:${kind}`, ids),
1661
- text: `${ids.length} ${kind} rules were disabled this session \u2014 see debug.log.
2124
+ text: `${ids.length} ${kind} rules ${scope === "session" ? "were disabled this session" : "were skipped in this run"} \u2014 see debug.log.
1662
2125
  First: ${text(group[0])}`
1663
2126
  });
1664
2127
  }
1665
2128
  return summarised;
1666
2129
  }
1667
2130
  async function runPipeline(options) {
1668
- const { project, stateDir, event, registry } = options;
2131
+ const { project, stateDir, registry } = options;
2132
+ let event = options.event;
1669
2133
  const { root, config } = project;
1670
2134
  const log = options.log ?? (() => {
1671
2135
  });
@@ -1691,6 +2155,27 @@ async function runPipeline(options) {
1691
2155
  if (session) await appendWork(session.dir, [{ t: "prompt", agent: session.agent }]);
1692
2156
  return { delivery: emptyDelivery(), failed, deadlineMissed };
1693
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
+ }
1694
2179
  if (event.kind === "reset") {
1695
2180
  if (session) await appendContext(session.dir, session.agent, [{ t: "reset" }]);
1696
2181
  if (!session || restoredFiles === 0) return { delivery: emptyDelivery(), failed, deadlineMissed };
@@ -1705,23 +2190,36 @@ async function runPipeline(options) {
1705
2190
  const dir = session.dir;
1706
2191
  baseline = await readingStore(() => readBaseline(dir), baseline);
1707
2192
  if (session && !baseline.started) {
1708
- await appendBaseline(dir, [startRecord(await headCommit(root))]);
2193
+ await appendBaseline(dir, [startRecord(await headCommit(root), await configHash(root))]);
1709
2194
  baseline = await readingStore(() => readBaseline(dir), baseline);
1710
2195
  }
1711
2196
  }
1712
- const empty = () => ({ work: emptyWork(), context: emptyContext() });
1713
2197
  const view = session ? await readingStore(() => openSession(session.dir, session.agent), empty()) : empty();
1714
2198
  const disabled = new Set(view.work.disabled.keys());
1715
2199
  const resolver = createReferenceResolver(root);
2200
+ const detection = detectionFor(project, registry, stateDir, resolver);
1716
2201
  const workRecords = [];
1717
- 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
+ }
1718
2218
  if (event.kind === "guard") {
1719
2219
  const open2 = session;
1720
2220
  const delivery = await guardWrite({
1721
- root,
2221
+ detection,
1722
2222
  config,
1723
- registry,
1724
- stateDir,
1725
2223
  event,
1726
2224
  rules: project.rules,
1727
2225
  disabled,
@@ -1730,12 +2228,16 @@ async function runPipeline(options) {
1730
2228
  maxContextChars: options.maxContextChars,
1731
2229
  log,
1732
2230
  record: open2 ? (records) => appendWork(open2.dir, records) : async () => {
2231
+ },
2232
+ recordContext: open2 ? (records) => appendContext(open2.dir, open2.agent, records) : async () => {
1733
2233
  }
1734
2234
  });
1735
2235
  return { delivery, failed, deadlineMissed };
1736
2236
  }
1737
2237
  let touches = [];
1738
2238
  let findings = [];
2239
+ let ignored = [];
2240
+ let told = [];
1739
2241
  let agentRead = null;
1740
2242
  if (event.kind === "touch" || event.kind === "edit") {
1741
2243
  touches = selectTouchRules(project.rules, event.files, view.context.touched, disabled);
@@ -1751,96 +2253,73 @@ async function runPipeline(options) {
1751
2253
  const recent = (view.work.accessed.get(session.agent) ?? []).slice(-restoredFiles).reverse();
1752
2254
  touches = selectTouchRules(project.rules, recent, view.context.touched, disabled);
1753
2255
  }
2256
+ const baselineInput = {
2257
+ root,
2258
+ detection,
2259
+ rules: project.rules,
2260
+ disabled,
2261
+ limits: {
2262
+ editDeadlineMs: config.timeouts.editDeadlineMs,
2263
+ verifyMs: config.timeouts.verifyMs,
2264
+ maxFileBytes: config.maxFileBytes
2265
+ }
2266
+ };
1754
2267
  if (event.kind === "touch") {
1755
2268
  if (event.completeRead) agentRead = event.files[0] ?? null;
1756
- if (session) {
1757
- const records = [];
1758
- const snapshotted = [];
1759
- for (const file of relevant(event.files)) {
1760
- if (baseline.snapshots.has(file)) continue;
1761
- const text = await readSourceFile(root, file);
1762
- if (text === null) continue;
1763
- records.push(snapshotRecord(file, snapshotOf(text)));
1764
- snapshotted.push(file);
1765
- }
1766
- if (snapshotted.length > 0) {
1767
- records.push(
1768
- ...await recordFingerprints({
1769
- root,
1770
- files: snapshotted,
1771
- rules: project.rules,
1772
- disabled,
1773
- registry,
1774
- read: (file) => readSourceFile(root, file),
1775
- cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
1776
- contextFor: (rule) => resolveRuleContext(resolver, rule.context),
1777
- settings: { llm: config.llm },
1778
- event: "edit",
1779
- source: "disk",
1780
- timeoutMs: config.timeouts.editDeadlineMs,
1781
- ceiling: { maxFileBytes: config.maxFileBytes, sizeOf: (file) => fileBytes(root, file) }
1782
- })
1783
- );
2269
+ if (session) await appendBaseline(session.dir, await touchRecords(baselineInput, baseline, relevant(event.files)));
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`);
1784
2280
  }
1785
- await appendBaseline(session.dir, records);
1786
2281
  }
1787
2282
  }
1788
2283
  if (event.kind === "edit" || event.kind === "verify") {
1789
- let files = relevant(event.files);
1790
- let snapshots = session ? baseline.snapshots : /* @__PURE__ */ new Map();
1791
- let fallbackCommit = session ? baseline.startCommit : null;
2284
+ let requested = relevant(event.files);
1792
2285
  if (event.kind === "edit" && session) {
1793
2286
  await appendWork(
1794
2287
  session.dir,
1795
- files.map((file) => ({ t: "edited", file }))
2288
+ requested.map((file) => ({ t: "edited", file, ...via === void 0 ? {} : { via } }))
1796
2289
  );
1797
2290
  }
1798
- if (event.kind === "verify") {
1799
- if (event.baseCommit) {
1800
- fallbackCommit = event.baseCommit;
1801
- snapshots = /* @__PURE__ */ new Map();
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];
1802
2296
  }
1803
- if (event.files.length === 0 && session) files = relevant(view.work.edited);
1804
2297
  }
1805
- const baselineChanges = await computeChanges(root, files, { snapshots, fallbackCommit });
2298
+ const read = contentReader(root, event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE);
2299
+ const stage = await changesFor(
2300
+ baselineInput,
2301
+ { kind: event.kind, baseCommit: event.baseCommit },
2302
+ session ? baseline : null,
2303
+ requested,
2304
+ read
2305
+ );
2306
+ const { files, changes: baselineChanges, fingerprints } = stage;
1806
2307
  const changes = baselineChanges.sets;
1807
- if (event.kind === "verify" && session && !event.baseCommit) {
1808
- files = files.filter((file) => changes.get(file)?.changedLines.length !== 0);
1809
- }
1810
- if (event.kind === "verify" && baselineChanges.fromCommit.size > 0) {
1811
- const texts = baselineChanges.fromCommit;
1812
- const missing = [...texts.keys()].filter((file) => !baseline.fingerprints.has(file));
1813
- const records = missing.length === 0 ? [] : await recordFingerprints({
1814
- root,
1815
- files: missing,
1816
- rules: project.rules,
1817
- disabled,
1818
- registry,
1819
- read: async (file) => texts.get(file) ?? null,
1820
- cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
1821
- contextFor: (rule) => resolveRuleContext(resolver, rule.context),
1822
- settings: { llm: config.llm },
1823
- event: "verify",
1824
- source: "memory",
1825
- timeoutMs: config.timeouts.verifyMs
1826
- });
1827
- for (const record of records) {
1828
- if (record.t !== "fingerprint") continue;
1829
- const byRule = baseline.fingerprints.get(record.file) ?? /* @__PURE__ */ new Map();
1830
- baseline.fingerprints.set(record.file, byRule);
1831
- byRule.set(record.rule, record.ranges);
1832
- }
1833
- if (session && records.length > 0) await appendBaseline(session.dir, records);
1834
- }
2308
+ if (session && stage.records.length > 0) await appendBaseline(session.dir, stage.records);
1835
2309
  const skip = options.skipDetectorKinds ?? /* @__PURE__ */ new Set();
1836
2310
  let selections = selectDetectorRules(project.rules, event.kind, files, disabled).filter(
1837
2311
  (selection) => !skip.has(selection.rule.detector.kind) && (options.onlyRules?.has(selection.rule.id) ?? true)
1838
2312
  );
1839
- let overBudget = [];
2313
+ const overBudget = [];
1840
2314
  if (event.kind === "verify") {
1841
- const budgeted = applyFileBudget("llm", selections, config.llm.maxFilesPerVerify, [...view.work.edited].reverse());
1842
- selections = budgeted.selections;
1843
- overBudget = budgeted.skipped;
2315
+ const recent = [...view.work.edited].reverse();
2316
+ for (const kind of new Set(selections.map((selection) => selection.rule.detector.kind))) {
2317
+ const budget = registry.get(kind)?.fileBudget?.(detection.settings);
2318
+ if (budget === void 0) continue;
2319
+ const budgeted = applyFileBudget(kind, selections, budget.max, recent);
2320
+ selections = budgeted.selections;
2321
+ if (budgeted.skipped.length > 0) overBudget.push({ kind, ...budget, skipped: budgeted.skipped });
2322
+ }
1844
2323
  }
1845
2324
  if (event.kind === "edit") {
1846
2325
  const ceiling = await applySizeCeiling(
@@ -1854,35 +2333,64 @@ async function runPipeline(options) {
1854
2333
  log(`over max_file_bytes (${config.maxFileBytes}), not checked on this edit: ${ceiling.skipped.join(", ")}`);
1855
2334
  }
1856
2335
  }
1857
- const output = await runDetection({
1858
- root,
1859
- event: event.kind,
1860
- selections,
1861
- changes,
1862
- read: (file) => readSourceFile(root, file),
1863
- registry,
1864
- cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
1865
- contextFor: (rule) => resolveRuleContext(resolver, rule.context),
1866
- settings: { llm: config.llm },
1867
- timeoutMs: event.kind === "edit" ? config.timeouts.editDeadlineMs : config.timeouts.verifyMs
1868
- });
2336
+ const output = await runDetectionFrom(
2337
+ {
2338
+ detection,
2339
+ event: event.kind,
2340
+ selections,
2341
+ changes,
2342
+ read,
2343
+ timeoutMs: event.kind === "edit" ? config.timeouts.editDeadlineMs : config.timeouts.verifyMs
2344
+ },
2345
+ event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE
2346
+ );
1869
2347
  findings = output.findings.map(({ rule, match }) => ({
1870
2348
  rule,
1871
2349
  match,
1872
- status: classify(match, rule.scope, rule.id, baselineChanges, baseline.fingerprints)
2350
+ status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
1873
2351
  }));
1874
2352
  for (const error of output.errors) {
1875
2353
  failed = true;
1876
2354
  log(`${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}`);
1877
2355
  for (const rule of error.rules) workRecords.push({ t: "disabled", rule, reason: error.message });
1878
2356
  }
1879
- warnings.push(...detectorWarnings(output.errors));
1880
- if (overBudget.length > 0) {
2357
+ warnings.push(...detectorWarnings(output.errors, session === null ? "run" : "session"));
2358
+ ignored = output.ignored;
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)) {
2369
+ told = notices({
2370
+ startConfigHash: atStop ? baseline.startConfigHash ?? null : null,
2371
+ currentConfigHash: atStop ? await configHash(root) : null,
2372
+ ignored: atStop ? ignored.map(({ rule, match, reason }) => ({
2373
+ rule: rule.id,
2374
+ match,
2375
+ reason,
2376
+ status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
2377
+ })) : [],
2378
+ told: view.work.noticed,
2379
+ refused
2380
+ });
2381
+ for (const notice of told) for (const key of notice.key.split("\n")) workRecords.push({ t: "noticed", key });
2382
+ }
2383
+ for (const text of output.warnings) warnings.push({ key: `ignore:${text}`, text });
2384
+ for (const { kind, max, setting, skipped } of overBudget) {
1881
2385
  warnings.push({
1882
- key: `llm-budget:${overBudget.join(",")}`,
1883
- text: `llm rules checked ${config.llm.maxFilesPerVerify} files; ${overBudget.length} were not checked: ${overBudget.join(", ")}. Raise llm.max_files_per_verify, or run rulecast run --files on them.`
2386
+ key: `${kind}-budget:${skipped.join(",")}`,
2387
+ text: `${kind} rules checked ${max} files; ${skipped.length} were not checked: ${skipped.join(", ")}. Raise ${setting}, or run rulecast run --files on them.`
1884
2388
  });
1885
2389
  }
2390
+ const hintFor = (kind) => {
2391
+ const hint = registry.get(kind)?.timeoutHint;
2392
+ return hint === void 0 ? "" : ` (${hint})`;
2393
+ };
1886
2394
  for (const timeout of output.timedOut) {
1887
2395
  if (event.kind === "edit") {
1888
2396
  log(`edit deadline passed for ${timeout.kind}: ${timeout.rules.join(", ")}`);
@@ -1893,11 +2401,26 @@ async function runPipeline(options) {
1893
2401
  key: `timeout:${timeout.kind}:${timeout.rules.join(",")}`,
1894
2402
  // Naming the lever matters: a field trial measured a single haiku call on a 135-line file
1895
2403
  // at 89 s against a 60 s default, and "timed out" alone leaves nobody knowing what to do.
1896
- text: `${timeout.kind} detector timed out after ${config.timeouts.verifyMs} ms for ${timeout.rules.join(", ")}. Raise timeouts.verify_ms${timeout.kind === "llm" ? " (an llm rule on a large file can need 120000 or more)" : ""}.`
2404
+ text: `${timeout.kind} detector timed out after ${config.timeouts.verifyMs} ms for ${timeout.rules.join(", ")}. Raise timeouts.verify_ms${hintFor(timeout.kind)}.`
1897
2405
  });
1898
2406
  }
1899
2407
  }
1900
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
+ }
1901
2424
  const inputFor = (state) => ({
1902
2425
  agent: session?.agent ?? "main",
1903
2426
  findings,
@@ -1911,19 +2434,35 @@ async function runPipeline(options) {
1911
2434
  maxBlocks: config.stopGate.maxBlocks,
1912
2435
  maxContextChars: options.maxContextChars,
1913
2436
  maxMatchesPerRule: config.maxMatchesPerRule,
1914
- 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 }
1915
2441
  });
2442
+ const home = path15.dirname(path15.dirname(stateDir));
1916
2443
  const persisted = (decision) => {
1917
2444
  if (decision.overflow !== null) {
1918
- decision.delivery.overflowPath = writeOverflow(stateDir, event.session?.id ?? null, decision.overflow);
2445
+ let written = null;
2446
+ try {
2447
+ written = writeOverflow(path15.join(agentDir(root), "deliveries"), event.session?.id ?? null, decision.overflow);
2448
+ } catch {
2449
+ written = null;
2450
+ }
2451
+ decision.delivery.overflowPath = written === null ? null : path15.relative(root, written).split(path15.sep).join("/");
2452
+ }
2453
+ for (const reference of decision.delivery.references) {
2454
+ if (reference.location === void 0 || !path15.isAbsolute(reference.location)) continue;
2455
+ const copied = copyIntoProject(root, home, reference.location);
2456
+ if (copied !== null) reference.location = copied;
1919
2457
  }
2458
+ if (told.length > 0) decision.delivery.notices = told.map((notice) => notice.text);
1920
2459
  return decision.delivery;
1921
2460
  };
1922
- if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed };
2461
+ if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed, ignored };
1923
2462
  await appendWork(session.dir, workRecords);
1924
2463
  try {
1925
2464
  const decision = await commitSession(session.dir, session.agent, (state) => decide(inputFor(state)));
1926
- return { delivery: persisted(decision), failed, deadlineMissed };
2465
+ return { delivery: persisted(decision), failed, deadlineMissed, ignored };
1927
2466
  } catch (error) {
1928
2467
  if (error instanceof CorruptStoreError) {
1929
2468
  log(`session store unreadable: ${error.message}`);
@@ -1932,7 +2471,7 @@ async function runPipeline(options) {
1932
2471
  warnings.push({ key: "lock", text: "session state was locked; delivered without session memory" });
1933
2472
  } else throw error;
1934
2473
  const decision = await decide({ ...inputFor({ work: emptyWork(), context: emptyContext() }), stopGate: false });
1935
- return { delivery: persisted(decision), failed, deadlineMissed };
2474
+ return { delivery: persisted(decision), failed, deadlineMissed, ignored };
1936
2475
  }
1937
2476
  }
1938
2477
 
@@ -1940,10 +2479,12 @@ export {
1940
2479
  defaultConfig,
1941
2480
  CONFIG_FILE,
1942
2481
  MANIFEST_FILE,
2482
+ readYamlFile,
1943
2483
  readConfigData,
1944
2484
  parseConfig,
1945
2485
  readManifest,
1946
2486
  git,
2487
+ headCommit,
1947
2488
  mergeBase,
1948
2489
  changedFilesSince,
1949
2490
  allFiles,
@@ -1964,14 +2505,16 @@ export {
1964
2505
  diagnosticText,
1965
2506
  compile,
1966
2507
  compileManifest,
2508
+ createReferenceResolver,
1967
2509
  rulesOfKind,
1968
2510
  checkableKinds,
1969
2511
  checkDetectors,
2512
+ detectionFor,
1970
2513
  runDetection,
1971
2514
  cachedRepos,
1972
2515
  fetchingRepos,
1973
2516
  fixedRepo,
1974
- resolveRuleContext,
1975
- createReferenceResolver,
2517
+ WORKTREE,
2518
+ withScratchTree,
1976
2519
  runPipeline
1977
2520
  };