@syv-ai/rulecast 0.3.0 → 0.4.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,10 @@
1
1
  import {
2
- BACKLOG_HINT,
3
2
  DeadlineError,
4
3
  LLM_PROVIDERS,
5
4
  VERSION,
6
5
  compileFilter,
7
6
  compileRule,
7
+ deliveryCost,
8
8
  detectorCacheDir,
9
9
  diskCache,
10
10
  emptyDelivery,
@@ -24,7 +24,7 @@ import {
24
24
  sectionRange,
25
25
  sectionText,
26
26
  templateBindings
27
- } from "./chunk-HZUATJ53.js";
27
+ } from "./chunk-DQUCE2IK.js";
28
28
 
29
29
  // src/core/config/load.ts
30
30
  import { readFile } from "fs/promises";
@@ -59,6 +59,8 @@ var ruleKeys = {
59
59
  */
60
60
  scope: z.enum(RULE_SCOPES).optional(),
61
61
  refuse_write: z.boolean().optional(),
62
+ /** false: compiled and listed, selected for nothing. The way to switch a catalog rule off without deleting it. */
63
+ enabled: z.boolean().optional(),
62
64
  detect: z.record(z.unknown()).refine((value) => Object.keys(value).length === 1, "must name exactly one detector").optional(),
63
65
  message: z.string().optional(),
64
66
  context: z.array(referenceInputSchema).optional(),
@@ -150,13 +152,15 @@ function readConfigData(root) {
150
152
  }
151
153
  function parseConfig(data) {
152
154
  const result = configSchema.safeParse(data);
153
- if (!result.success) return { ok: false, message: `${CONFIG_FILE}: ${formatZodError(result.error)}` };
155
+ if (!result.success) {
156
+ return { ok: false, message: `${CONFIG_FILE}: ${formatZodError(result.error, { schema: configSchema })}` };
157
+ }
154
158
  return { ok: true, value: result.data };
155
159
  }
156
- async function readManifest(dir) {
157
- const loaded = await readYamlFile(path.join(dir, MANIFEST_FILE));
160
+ async function readManifest(dir, file = path.join(dir, MANIFEST_FILE)) {
161
+ const loaded = await readYamlFile(file);
158
162
  if (!loaded.ok) return loaded;
159
- if (!Array.isArray(loaded.value)) return { ok: false, message: `${MANIFEST_FILE}: must be a list of rules` };
163
+ if (!Array.isArray(loaded.value)) return { ok: false, message: `${path.basename(file)}: must be a list of rules` };
160
164
  return { ok: true, value: loaded.value };
161
165
  }
162
166
 
@@ -197,6 +201,7 @@ function debugLogger(stateDir, now = () => /* @__PURE__ */ new Date()) {
197
201
 
198
202
  // src/core/compile/project.ts
199
203
  import { readFile as readFile2 } from "fs/promises";
204
+ import path3 from "path";
200
205
  function diagnosticText(diagnostic) {
201
206
  const rule = diagnostic.rule ? ` (${diagnostic.rule})` : "";
202
207
  return `${diagnostic.source}${rule}: ${diagnostic.message}`;
@@ -274,7 +279,7 @@ async function compile(options) {
274
279
  }
275
280
  for (const raw of entry.rules) {
276
281
  const rule = ruleSchema.safeParse(raw);
277
- if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error));
282
+ if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error, { schema: ruleSchema }));
278
283
  else await add({ data: rule.data, source: "local", contextRoot: project }, CONFIG_FILE);
279
284
  }
280
285
  continue;
@@ -311,7 +316,7 @@ async function compile(options) {
311
316
  for (const raw of entry.rules) {
312
317
  const override = overrideSchema.safeParse(raw);
313
318
  if (!override.success) {
314
- error(CONFIG_FILE, idOf(raw), formatZodError(override.error));
319
+ error(CONFIG_FILE, idOf(raw), formatZodError(override.error, { schema: overrideSchema }));
315
320
  continue;
316
321
  }
317
322
  const { id } = override.data;
@@ -322,7 +327,7 @@ async function compile(options) {
322
327
  }
323
328
  const base = ruleSchema.safeParse(candidates[0]);
324
329
  if (!base.success) {
325
- error(source, id, `manifest: ${formatZodError(base.error)}`);
330
+ error(source, id, formatZodError(base.error, { prefix: "manifest", schema: ruleSchema }));
326
331
  continue;
327
332
  }
328
333
  const data2 = { ...base.data, ...override.data };
@@ -337,12 +342,13 @@ async function compile(options) {
337
342
  );
338
343
  return { root, config, rules, diagnostics };
339
344
  }
340
- async function compileManifest(dir, registry) {
341
- const manifest = await readManifest(dir);
345
+ async function compileManifest(dir, registry, file = path3.join(dir, MANIFEST_FILE)) {
346
+ const source = path3.basename(file);
347
+ const manifest = await readManifest(dir, file);
342
348
  if (!manifest.ok) {
343
349
  return {
344
350
  rules: [],
345
- diagnostics: [{ source: MANIFEST_FILE, rule: null, message: manifest.message, level: "error" }]
351
+ diagnostics: [{ source, rule: null, message: manifest.message, level: "error" }]
346
352
  };
347
353
  }
348
354
  const diagnostics = [];
@@ -351,18 +357,23 @@ async function compileManifest(dir, registry) {
351
357
  for (const raw of manifest.value) {
352
358
  const rule = ruleSchema.safeParse(raw);
353
359
  if (!rule.success) {
354
- diagnostics.push({ source: MANIFEST_FILE, rule: idOf(raw), message: formatZodError(rule.error), level: "error" });
360
+ diagnostics.push({
361
+ source,
362
+ rule: idOf(raw),
363
+ message: formatZodError(rule.error, { schema: ruleSchema }),
364
+ level: "error"
365
+ });
355
366
  continue;
356
367
  }
357
368
  const result = await compileRule({ data: rule.data, source: "local", contextRoot: { dir, label: null } }, context);
358
369
  if (typeof result === "string") {
359
370
  diagnostics.push({
360
- source: MANIFEST_FILE,
371
+ source,
361
372
  rule: rule.data.alias ?? rule.data.id,
362
373
  message: result,
363
374
  level: "error"
364
375
  });
365
- } else compiled.push({ rule: result, source: MANIFEST_FILE });
376
+ } else compiled.push({ rule: result, source });
366
377
  }
367
378
  const rules = uniqueRules(compiled, diagnostics, (id) => `rule "${id}" is defined more than once`);
368
379
  return { rules, diagnostics };
@@ -371,7 +382,7 @@ async function compileManifest(dir, registry) {
371
382
  // src/core/detection/select.ts
372
383
  function selectDetectorRules(rules, event, files, disabled) {
373
384
  return rules.filter(
374
- (rule) => isDetectorRule(rule) && rule.stages.includes(event) && !disabled.has(rule.id)
385
+ (rule) => isDetectorRule(rule) && rule.enabled && rule.stages.includes(event) && !disabled.has(rule.id)
375
386
  ).map((rule) => ({ rule, files: files.filter((file) => rule.matches(file)) })).filter((selection) => selection.files.length > 0);
376
387
  }
377
388
  function rulesOfKind(rules, kind) {
@@ -379,7 +390,7 @@ function rulesOfKind(rules, kind) {
379
390
  }
380
391
  function selectTouchRules(rules, files, touched, disabled) {
381
392
  return rules.filter(
382
- (rule) => rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
393
+ (rule) => rule.enabled && rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
383
394
  );
384
395
  }
385
396
 
@@ -429,7 +440,7 @@ async function checkDetectors(options) {
429
440
  import { randomUUID } from "crypto";
430
441
  import { existsSync as existsSync2 } from "fs";
431
442
  import { mkdir as mkdir2, rename, rm as rm2 } from "fs/promises";
432
- import path5 from "path";
443
+ import path6 from "path";
433
444
 
434
445
  // src/core/git.ts
435
446
  import { execFile } from "child_process";
@@ -475,10 +486,26 @@ async function fileAtCommit(cwd, commit, file) {
475
486
  const result = await git(cwd, ["show", `${commit}:./${file}`]);
476
487
  return result.ok ? result.stdout : null;
477
488
  }
489
+ async function fileInIndex(cwd, file) {
490
+ const result = await git(cwd, ["show", `:./${file}`]);
491
+ return result.ok ? result.stdout : null;
492
+ }
493
+ async function isShallow(cwd) {
494
+ const result = await git(cwd, ["rev-parse", "--is-shallow-repository"]);
495
+ return result.ok && result.stdout.trim() === "true";
496
+ }
497
+ var FETCH_HISTORY = "fetch full history (actions/checkout: fetch-depth: 0)";
478
498
  async function mergeBase(cwd, ref, other = "HEAD") {
479
499
  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();
500
+ if (result.ok) return result.stdout.trim();
501
+ const shallow = await isShallow(cwd);
502
+ const resolves = (await git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])).ok;
503
+ if (!resolves) {
504
+ throw new Error(`"${ref}" is not a commit here${shallow ? `; this is a shallow clone: ${FETCH_HISTORY}` : ""}`);
505
+ }
506
+ if (shallow) throw new Error(`no merge base with ${ref} in a shallow clone: ${FETCH_HISTORY}`);
507
+ const detail = result.stderr.trim();
508
+ throw new Error(`no merge base with ${ref}${detail ? `: ${detail}` : ": the histories are unrelated"}`);
482
509
  }
483
510
  async function changedFilesSince(cwd, commit) {
484
511
  const diff = await git(cwd, ["diff", "--name-only", "--relative", "--diff-filter=d", commit]);
@@ -509,7 +536,7 @@ async function changedFilesBetween(cwd, from, to) {
509
536
 
510
537
  // src/core/session/lock.ts
511
538
  import { mkdir, rm, stat } from "fs/promises";
512
- import path3 from "path";
539
+ import path4 from "path";
513
540
  var LockTimeoutError = class extends Error {
514
541
  };
515
542
  var DEFAULTS = { waitMs: 2e3, staleMs: 5e3, retryMs: 20 };
@@ -517,7 +544,7 @@ function isAlreadyExists(error) {
517
544
  return error instanceof Error && error.code === "EEXIST";
518
545
  }
519
546
  async function withLock(dir, fn, options = DEFAULTS) {
520
- const lock = path3.join(dir, ".lock");
547
+ const lock = path4.join(dir, ".lock");
521
548
  await mkdir(dir, { recursive: true });
522
549
  const started = Date.now();
523
550
  for (; ; ) {
@@ -550,7 +577,7 @@ async function withLock(dir, fn, options = DEFAULTS) {
550
577
 
551
578
  // src/core/repos/layout.ts
552
579
  import { createHash as createHash2 } from "crypto";
553
- import path4 from "path";
580
+ import path5 from "path";
554
581
  import { fileURLToPath } from "url";
555
582
  var SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
556
583
  var SCP_LIKE = /^[^@/\s]+@([^:/\s]+):(.+)$/;
@@ -558,7 +585,7 @@ function segmentsOf(repoPath) {
558
585
  return repoPath.replace(/\/+$/, "").replace(/\.git$/, "").split("/").filter(Boolean);
559
586
  }
560
587
  function localName(file) {
561
- return path4.basename(file.replace(/[\\/]+$/, "")).replace(/\.git$/, "");
588
+ return path5.basename(file.replace(/[\\/]+$/, "")).replace(/\.git$/, "");
562
589
  }
563
590
  function parseUrl(url) {
564
591
  if (SCHEME.test(url)) {
@@ -585,7 +612,7 @@ function repoSlug(url) {
585
612
  return safeSegment(`local_${parsed.name}_${hash}`);
586
613
  }
587
614
  function repoDir(home, url, rev) {
588
- return path4.join(home, "repos", repoSlug(url), safeSegment(rev));
615
+ return path5.join(home, "repos", repoSlug(url), safeSegment(rev));
589
616
  }
590
617
  function repoLabel(url, rev) {
591
618
  return `${repoName(url)}@${rev}`;
@@ -605,7 +632,7 @@ async function fetchCheckout(url, rev, into) {
605
632
  await run(into, ["init", "-q"]);
606
633
  await run(into, ["fetch", "-q", "--depth", "1", url, rev]);
607
634
  await run(into, ["-c", "advice.detachedHead=false", "checkout", "-q", "FETCH_HEAD"]);
608
- await rm2(path5.join(into, ".git"), { recursive: true, force: true });
635
+ await rm2(path6.join(into, ".git"), { recursive: true, force: true });
609
636
  }
610
637
  function cachedRepo(home, url, rev) {
611
638
  const dir = repoDir(home, url, rev);
@@ -614,12 +641,12 @@ function cachedRepo(home, url, rev) {
614
641
  async function ensureRepo(home, url, rev) {
615
642
  const dir = repoDir(home, url, rev);
616
643
  if (existsSync2(dir)) return dir;
617
- const parent = path5.dirname(dir);
644
+ const parent = path6.dirname(dir);
618
645
  await withLock(
619
646
  parent,
620
647
  async () => {
621
648
  if (existsSync2(dir)) return;
622
- const temp = path5.join(parent, `.tmp-${randomUUID()}`);
649
+ const temp = path6.join(parent, `.tmp-${randomUUID()}`);
623
650
  try {
624
651
  await fetchCheckout(url, rev, temp);
625
652
  await rename(temp, dir);
@@ -662,7 +689,21 @@ function fixedRepo(dir, label) {
662
689
  }
663
690
 
664
691
  // src/core/pipeline.ts
665
- import { createHash as createHash3 } from "crypto";
692
+ import { createHash as createHash4 } from "crypto";
693
+ import path14 from "path";
694
+
695
+ // src/core/content.ts
696
+ var WORKTREE = { kind: "worktree" };
697
+ function contentReader(root, source) {
698
+ switch (source.kind) {
699
+ case "worktree":
700
+ return (file) => readSourceFile(root, file);
701
+ case "index":
702
+ return (file) => fileInIndex(root, file);
703
+ case "commit":
704
+ return (file) => fileAtCommit(root, source.ref, file);
705
+ }
706
+ }
666
707
 
667
708
  // src/core/baseline/changes.ts
668
709
  import { diffArrays } from "diff";
@@ -734,7 +775,7 @@ function snapshotOf(text) {
734
775
  }
735
776
 
736
777
  // src/core/baseline/baseline.ts
737
- async function computeChanges(root, files, sources) {
778
+ async function computeChanges(root, files, sources, read = contentReader(root, WORKTREE)) {
738
779
  const changes = { sets: /* @__PURE__ */ new Map(), maps: /* @__PURE__ */ new Map(), fromCommit: /* @__PURE__ */ new Map() };
739
780
  for (const file of files) {
740
781
  if (!sources.snapshots.has(file) && !sources.fallbackCommit) continue;
@@ -745,7 +786,7 @@ async function computeChanges(root, files, sources) {
745
786
  before = commitText === null ? null : snapshotOf(commitText);
746
787
  }
747
788
  if (!before) continue;
748
- const current = await readSourceFile(root, file);
789
+ const current = await read(file);
749
790
  if (current === null) continue;
750
791
  if (commitText !== null) changes.fromCommit.set(file, commitText);
751
792
  const after = snapshotOf(current);
@@ -821,10 +862,59 @@ async function applySizeCeiling(selections, maxBytes, bounded, sizeOf) {
821
862
  };
822
863
  }
823
864
 
865
+ // src/core/suppress.ts
866
+ var ID = RULE_ID.source.slice(1, -1);
867
+ var IGNORE = new RegExp(
868
+ String.raw`rulecast-ignore:\s*(?<rule>${ID})(?:\s+(?<reason>\S.*?))?\s*(?:\*\/|-->)?\s*$`
869
+ );
870
+ function parseIgnore(line) {
871
+ const match = IGNORE.exec(line);
872
+ if (match?.groups === void 0) return null;
873
+ return { rule: match.groups.rule, reason: match.groups.reason ?? null };
874
+ }
875
+ async function suppress(findings, read) {
876
+ const result = { findings: [], ignored: [], warnings: [] };
877
+ if (findings.length === 0) return result;
878
+ const lines = /* @__PURE__ */ new Map();
879
+ const linesOf = async (file) => {
880
+ if (!lines.has(file)) lines.set(file, (await read(file))?.split("\n") ?? null);
881
+ return lines.get(file);
882
+ };
883
+ const warned = /* @__PURE__ */ new Set();
884
+ for (const finding of findings) {
885
+ const source = await linesOf(finding.match.file);
886
+ let ignore = null;
887
+ let at = 0;
888
+ for (const line of [finding.match.line, finding.match.line - 1]) {
889
+ const parsed = line >= 1 ? parseIgnore(source?.[line - 1] ?? "") : null;
890
+ if (parsed !== null && parsed.rule === finding.rule.id) {
891
+ ignore = parsed;
892
+ at = line;
893
+ break;
894
+ }
895
+ }
896
+ if (ignore === null) {
897
+ result.findings.push(finding);
898
+ continue;
899
+ }
900
+ if (ignore.reason === null) {
901
+ const where = `${finding.match.file}:${at}`;
902
+ if (!warned.has(where)) {
903
+ warned.add(where);
904
+ result.warnings.push(`${where}: rulecast-ignore needs a reason after the rule id; the finding was kept`);
905
+ }
906
+ result.findings.push(finding);
907
+ continue;
908
+ }
909
+ result.ignored.push({ ...finding, reason: ignore.reason });
910
+ }
911
+ return result;
912
+ }
913
+
824
914
  // src/core/detection/run.ts
825
915
  var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
826
916
  async function runDetection(input) {
827
- const output = { findings: [], errors: [], timedOut: [] };
917
+ const output = { findings: [], errors: [], timedOut: [], ignored: [], warnings: [] };
828
918
  const byKind = /* @__PURE__ */ new Map();
829
919
  for (const selection of input.selections) {
830
920
  const kind = selection.rule.detector.kind;
@@ -841,7 +931,7 @@ async function runDetection(input) {
841
931
  const late = () => Date.now() > deadlineAt;
842
932
  const perKind = await Promise.all(
843
933
  [...byKind].map(async ([kind, selections]) => {
844
- const detector = input.registry.get(kind);
934
+ const detector = input.detection.registry.get(kind);
845
935
  if (!detector) return { status: "error", kind, selections, message: `detector "${kind}" is not registered` };
846
936
  try {
847
937
  const rules = await Promise.all(
@@ -849,7 +939,7 @@ async function runDetection(input) {
849
939
  id: rule.id,
850
940
  config: rule.detector.config,
851
941
  files,
852
- context: await input.contextFor(rule)
942
+ context: await input.detection.contextFor(rule)
853
943
  }))
854
944
  );
855
945
  const result = await Promise.race([
@@ -857,10 +947,11 @@ async function runDetection(input) {
857
947
  event: input.event,
858
948
  rules,
859
949
  read: input.read,
950
+ fromDisk: input.fromDisk ?? true,
860
951
  changes: input.changes,
861
- cache: input.cacheFor(kind),
862
- settings: input.settings,
863
- cwd: input.root,
952
+ cache: input.detection.cacheFor(kind),
953
+ settings: input.detection.settings,
954
+ cwd: input.detection.root,
864
955
  signal: controller.signal,
865
956
  deadlineAt
866
957
  }),
@@ -908,20 +999,21 @@ async function runDetection(input) {
908
999
  if (!failed.has(finding.rule)) output.findings.push({ rule: rulesById.get(finding.rule), match: finding.match });
909
1000
  }
910
1001
  }
911
- return output;
1002
+ const suppressed = await suppress(output.findings, input.read);
1003
+ return { ...output, ...suppressed };
912
1004
  }
913
1005
 
914
1006
  // src/core/baseline/store.ts
915
- import path7 from "path";
1007
+ import path8 from "path";
916
1008
 
917
1009
  // src/core/jsonl.ts
918
1010
  import { mkdir as mkdir3, open, readFile as readFile3 } from "fs/promises";
919
- import path6 from "path";
1011
+ import path7 from "path";
920
1012
  var CorruptStoreError = class extends Error {
921
1013
  };
922
1014
  async function appendRecords(file, records) {
923
1015
  if (records.length === 0) return;
924
- await mkdir3(path6.dirname(file), { recursive: true });
1016
+ await mkdir3(path7.dirname(file), { recursive: true });
925
1017
  const handle = await open(file, "a+");
926
1018
  try {
927
1019
  await dropPartialRecord(handle);
@@ -973,7 +1065,7 @@ async function readRecords(file) {
973
1065
 
974
1066
  // src/core/baseline/store.ts
975
1067
  function storeFile(sessionDir2) {
976
- return path7.join(sessionDir2, "baseline.jsonl");
1068
+ return path8.join(sessionDir2, "baseline.jsonl");
977
1069
  }
978
1070
  function encodeLines(lines) {
979
1071
  return Buffer.from(lines.buffer, lines.byteOffset, lines.byteLength).toString("base64");
@@ -984,8 +1076,8 @@ function decodeLines(encoded) {
984
1076
  aligned.set(bytes);
985
1077
  return new Uint32Array(aligned.buffer);
986
1078
  }
987
- function startRecord(commit) {
988
- return { t: "start", commit };
1079
+ function startRecord(commit, configHash2 = null) {
1080
+ return { t: "start", commit, configHash: configHash2 };
989
1081
  }
990
1082
  function snapshotRecord(file, snapshot) {
991
1083
  return { t: "snapshot", file, fileHash: snapshot.fileHash, lines: encodeLines(snapshot.lines) };
@@ -996,29 +1088,37 @@ function fingerprintRecord(file, rule, ranges) {
996
1088
  async function appendBaseline(sessionDir2, records) {
997
1089
  await appendRecords(storeFile(sessionDir2), records);
998
1090
  }
1091
+ function mergeFingerprints(into, records) {
1092
+ for (const record of records) {
1093
+ if (record.t !== "fingerprint") continue;
1094
+ const byRule = into.get(record.file) ?? /* @__PURE__ */ new Map();
1095
+ into.set(record.file, byRule);
1096
+ if (!byRule.has(record.rule)) byRule.set(record.rule, record.ranges);
1097
+ }
1098
+ return into;
1099
+ }
999
1100
  async function readBaseline(sessionDir2) {
1000
1101
  const state = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map(), fingerprints: /* @__PURE__ */ new Map() };
1001
- for (const record of await readRecords(storeFile(sessionDir2))) {
1102
+ const records = await readRecords(storeFile(sessionDir2));
1103
+ for (const record of records) {
1002
1104
  if (record.t === "start" && !state.started) {
1003
1105
  state.started = true;
1004
1106
  state.startCommit = record.commit;
1107
+ state.startConfigHash = record.configHash ?? null;
1005
1108
  } else if (record.t === "snapshot" && !state.snapshots.has(record.file)) {
1006
1109
  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
1110
  }
1012
1111
  }
1112
+ mergeFingerprints(state.fingerprints, records);
1013
1113
  return state;
1014
1114
  }
1015
1115
 
1016
1116
  // src/core/baseline/record.ts
1017
1117
  async function recordFingerprints(input) {
1018
1118
  if (!input.rules.some((rule) => rule.scope === "container")) return [];
1019
- const guards = (kind) => input.registry.get(kind)?.guards === true;
1119
+ const guards = (kind) => input.detection.registry.get(kind)?.guards === true;
1020
1120
  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))
1121
+ (selection) => selection.rule.scope === "container" && input.detection.registry.get(selection.rule.detector.kind)?.metered !== true && (input.source === "disk" || guards(selection.rule.detector.kind))
1022
1122
  );
1023
1123
  if (input.ceiling) {
1024
1124
  const applied = await applySizeCeiling(selections, input.ceiling.maxFileBytes, guards, input.ceiling.sizeOf);
@@ -1026,7 +1126,7 @@ async function recordFingerprints(input) {
1026
1126
  }
1027
1127
  if (selections.length === 0) return [];
1028
1128
  const output = await runDetection({
1029
- root: input.root,
1129
+ detection: input.detection,
1030
1130
  event: input.event,
1031
1131
  selections,
1032
1132
  // No change set: the baseline content is being measured whole, and there is nothing it is a
@@ -1034,10 +1134,6 @@ async function recordFingerprints(input) {
1034
1134
  // exactly right here.
1035
1135
  changes: /* @__PURE__ */ new Map(),
1036
1136
  read: input.read,
1037
- registry: input.registry,
1038
- cacheFor: input.cacheFor,
1039
- contextFor: input.contextFor,
1040
- settings: input.settings,
1041
1137
  timeoutMs: input.timeoutMs
1042
1138
  });
1043
1139
  const unmeasured = /* @__PURE__ */ new Set();
@@ -1058,9 +1154,86 @@ async function recordFingerprints(input) {
1058
1154
  return records;
1059
1155
  }
1060
1156
 
1157
+ // src/core/baseline/stage.ts
1158
+ async function touchRecords(input, state, files) {
1159
+ const records = [];
1160
+ const snapshotted = [];
1161
+ for (const file of files) {
1162
+ if (state.snapshots.has(file)) continue;
1163
+ const text = await readSourceFile(input.root, file);
1164
+ if (text === null) continue;
1165
+ records.push(snapshotRecord(file, snapshotOf(text)));
1166
+ snapshotted.push(file);
1167
+ }
1168
+ if (snapshotted.length === 0) return records;
1169
+ records.push(
1170
+ ...await recordFingerprints({
1171
+ detection: input.detection,
1172
+ files: snapshotted,
1173
+ rules: input.rules,
1174
+ disabled: input.disabled,
1175
+ read: (file) => readSourceFile(input.root, file),
1176
+ event: "edit",
1177
+ source: "disk",
1178
+ timeoutMs: input.limits.editDeadlineMs,
1179
+ ceiling: { maxFileBytes: input.limits.maxFileBytes, sizeOf: (file) => fileBytes(input.root, file) }
1180
+ })
1181
+ );
1182
+ return records;
1183
+ }
1184
+ async function changesFor(input, event, state, files, read) {
1185
+ let snapshots = state?.snapshots ?? /* @__PURE__ */ new Map();
1186
+ let fallbackCommit = state?.startCommit ?? null;
1187
+ if (event.kind === "verify" && event.baseCommit) {
1188
+ fallbackCommit = event.baseCommit;
1189
+ snapshots = /* @__PURE__ */ new Map();
1190
+ }
1191
+ const changes = await computeChanges(input.root, files, { snapshots, fallbackCommit }, read);
1192
+ const kept = event.kind === "verify" && state !== null && !event.baseCommit ? files.filter((file) => changes.sets.get(file)?.changedLines.length !== 0) : [...files];
1193
+ const fingerprints = new Map(
1194
+ [...state?.fingerprints ?? []].map(([file, byRule]) => [file, new Map(byRule)])
1195
+ );
1196
+ if (event.kind !== "verify" || changes.fromCommit.size === 0) {
1197
+ return { files: kept, changes, fingerprints, records: [] };
1198
+ }
1199
+ const texts = changes.fromCommit;
1200
+ const missing = [...texts.keys()].filter((file) => !fingerprints.has(file));
1201
+ const records = missing.length === 0 ? [] : await recordFingerprints({
1202
+ detection: input.detection,
1203
+ files: missing,
1204
+ rules: input.rules,
1205
+ disabled: input.disabled,
1206
+ read: async (file) => texts.get(file) ?? null,
1207
+ event: "verify",
1208
+ source: "memory",
1209
+ timeoutMs: input.limits.verifyMs
1210
+ });
1211
+ return { files: kept, changes, fingerprints: mergeFingerprints(fingerprints, records), records };
1212
+ }
1213
+
1061
1214
  // src/core/delivery/persist.ts
1062
- import { mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
1063
- import path8 from "path";
1215
+ import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
1216
+ import path9 from "path";
1217
+ var AGENT_DIR = ".rulecast";
1218
+ function agentDir(root) {
1219
+ const dir = path9.join(root, AGENT_DIR);
1220
+ mkdirSync2(dir, { recursive: true });
1221
+ const ignore = path9.join(dir, ".gitignore");
1222
+ if (!existsSync3(ignore)) writeFileSync2(ignore, "# Written by rulecast: files its hooks point an agent at.\n*\n");
1223
+ return dir;
1224
+ }
1225
+ function copyIntoProject(root, home, file) {
1226
+ const rest = path9.relative(home, file);
1227
+ if (rest.startsWith("..") || path9.isAbsolute(rest)) return null;
1228
+ try {
1229
+ const target = path9.join(agentDir(root), rest);
1230
+ mkdirSync2(path9.dirname(target), { recursive: true });
1231
+ copyFileSync(file, target);
1232
+ return path9.join(AGENT_DIR, rest).split(path9.sep).join("/");
1233
+ } catch {
1234
+ return null;
1235
+ }
1236
+ }
1064
1237
  var KEEP = 3;
1065
1238
  var UNLIMITED = { maxMatchesPerRule: Number.MAX_SAFE_INTEGER };
1066
1239
  var safeSegment2 = (value) => value.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -1076,15 +1249,14 @@ function markdown(delivery, now) {
1076
1249
  ""
1077
1250
  ].join("\n");
1078
1251
  }
1079
- function writeOverflow(stateDir, session, delivery, now = /* @__PURE__ */ new Date()) {
1252
+ function writeOverflow(dir, session, delivery, now = /* @__PURE__ */ new Date()) {
1080
1253
  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`);
1254
+ const file = path9.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
1083
1255
  try {
1084
1256
  mkdirSync2(dir, { recursive: true });
1085
1257
  writeFileSync2(file, markdown(delivery, now));
1086
1258
  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 });
1259
+ for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) rmSync(path9.join(dir, old), { force: true });
1088
1260
  return file;
1089
1261
  } catch {
1090
1262
  return null;
@@ -1102,16 +1274,16 @@ async function resolveRuleContext(resolver, context) {
1102
1274
  }
1103
1275
  function createReferenceResolver(root) {
1104
1276
  const texts = /* @__PURE__ */ new Map();
1105
- const read = (path11) => {
1106
- let text = texts.get(path11);
1277
+ const read = (path15) => {
1278
+ let text = texts.get(path15);
1107
1279
  if (!text) {
1108
- text = readSourceFile(root, path11);
1109
- texts.set(path11, text);
1280
+ text = readSourceFile(root, path15);
1281
+ texts.set(path15, text);
1110
1282
  }
1111
1283
  return text;
1112
1284
  };
1113
- const contentOf = async (path11, anchor) => {
1114
- const text = await read(path11);
1285
+ const contentOf = async (path15, anchor) => {
1286
+ const text = await read(path15);
1115
1287
  if (text === null) return null;
1116
1288
  if (anchor === null) return text.trimEnd();
1117
1289
  const section = sectionRange(text, anchor);
@@ -1123,19 +1295,108 @@ function createReferenceResolver(root) {
1123
1295
  if (content === null) return { spec, found: false };
1124
1296
  return { spec, found: true, content, hash: fnv1a(content), bytes: Buffer.byteLength(content) };
1125
1297
  },
1126
- async currentHash(path11, anchor) {
1127
- const content = await contentOf(path11, anchor);
1298
+ async currentHash(path15, anchor) {
1299
+ const content = await contentOf(path15, anchor);
1128
1300
  return content === null ? null : fnv1a(content);
1129
1301
  },
1130
- async contains(path11, parent, child) {
1302
+ async contains(path15, parent, child) {
1131
1303
  if (parent === null) return true;
1132
1304
  if (child === null) return false;
1133
- const text = await read(path11);
1305
+ const text = await read(path15);
1134
1306
  return text !== null && sectionContains(text, parent, child);
1135
1307
  }
1136
1308
  };
1137
1309
  }
1138
1310
 
1311
+ // src/core/detection/context.ts
1312
+ function detectionFor(project, registry, stateDir, resolver) {
1313
+ return {
1314
+ root: project.root,
1315
+ registry,
1316
+ settings: { llm: project.config.llm },
1317
+ cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
1318
+ contextFor: (rule) => resolveRuleContext(resolver, rule.context)
1319
+ };
1320
+ }
1321
+
1322
+ // src/core/detection/scratch.ts
1323
+ import { mkdir as mkdir4, mkdtemp, rm as rm3, writeFile } from "fs/promises";
1324
+ import path10 from "path";
1325
+ async function withScratchTree(root, prefix, entries, fn) {
1326
+ const dir = path10.basename(await mkdtemp(path10.join(root, prefix)));
1327
+ try {
1328
+ for (const [file, content] of entries) {
1329
+ const full = path10.join(root, dir, file);
1330
+ await mkdir4(path10.dirname(full), { recursive: true });
1331
+ await writeFile(full, content);
1332
+ }
1333
+ return await fn(dir);
1334
+ } finally {
1335
+ await rm3(path10.join(root, dir), { recursive: true, force: true });
1336
+ }
1337
+ }
1338
+
1339
+ // src/core/detection/materialise.ts
1340
+ var STAGED_DIR = ".rulecast-staged-";
1341
+ async function runDetectionFrom(input, source) {
1342
+ if (source.kind === "worktree") return runDetection(input);
1343
+ const registry = input.detection.registry;
1344
+ const direct = [];
1345
+ const copied = [];
1346
+ for (const selection of input.selections) {
1347
+ const detector = registry.get(selection.rule.detector.kind);
1348
+ const readsContent = detector?.guards === true || detector?.takesContent?.(selection.rule.detector.config) === true;
1349
+ if (readsContent || detector === void 0) direct.push(selection);
1350
+ else copied.push(selection);
1351
+ }
1352
+ const outputs = await Promise.all([
1353
+ direct.length === 0 ? null : runDetection({ ...input, selections: direct, fromDisk: false }),
1354
+ copied.length === 0 ? null : fromScratch(input, copied)
1355
+ ]);
1356
+ return merge(outputs.filter((output) => output !== null));
1357
+ }
1358
+ async function fromScratch(input, selections) {
1359
+ const entries = /* @__PURE__ */ new Map();
1360
+ for (const file of new Set(selections.flatMap((selection) => selection.files))) {
1361
+ const text = await input.read(file);
1362
+ if (text !== null) entries.set(file, text);
1363
+ }
1364
+ if (entries.size === 0) return { findings: [], errors: [], timedOut: [], ignored: [], warnings: [] };
1365
+ return withScratchTree(input.detection.root, STAGED_DIR, entries, async (dir) => {
1366
+ const prefix = `${dir}/`;
1367
+ const strip = (file) => file.startsWith(prefix) ? file.slice(prefix.length) : file;
1368
+ const output = await runDetection({
1369
+ ...input,
1370
+ selections: selections.map((selection) => ({
1371
+ ...selection,
1372
+ files: selection.files.filter((file) => entries.has(file)).map((file) => prefix + file)
1373
+ })).filter((selection) => selection.files.length > 0),
1374
+ changes: new Map([...input.changes].map(([file, change]) => [prefix + file, change])),
1375
+ // Anything reading through `read` under a scratch path is asking about the real file.
1376
+ read: (file) => input.read(strip(file))
1377
+ });
1378
+ return {
1379
+ findings: output.findings.map(({ rule, match }) => ({ rule, match: { ...match, file: strip(match.file) } })),
1380
+ errors: output.errors.map((error) => ({ ...error, message: error.message.replaceAll(prefix, "") })),
1381
+ timedOut: output.timedOut,
1382
+ ignored: output.ignored.map((ignored) => ({
1383
+ ...ignored,
1384
+ match: { ...ignored.match, file: strip(ignored.match.file) }
1385
+ })),
1386
+ warnings: output.warnings.map((warning) => warning.replaceAll(prefix, ""))
1387
+ };
1388
+ });
1389
+ }
1390
+ function merge(outputs) {
1391
+ return {
1392
+ findings: outputs.flatMap((output) => output.findings),
1393
+ errors: outputs.flatMap((output) => output.errors),
1394
+ timedOut: outputs.flatMap((output) => output.timedOut),
1395
+ ignored: outputs.flatMap((output) => output.ignored),
1396
+ warnings: outputs.flatMap((output) => output.warnings)
1397
+ };
1398
+ }
1399
+
1139
1400
  // src/core/detection/proposal.ts
1140
1401
  function propose(current, intent) {
1141
1402
  if (intent.content !== void 0) {
@@ -1161,8 +1422,8 @@ function propose(current, intent) {
1161
1422
  }
1162
1423
  return { content: content + rest, inserts };
1163
1424
  }
1164
- function isInserted(proposal, match, detector) {
1165
- if (detector === "path") return true;
1425
+ function isInserted(proposal, match, wholeFile) {
1426
+ if (wholeFile) return true;
1166
1427
  const starts = lineStarts(proposal.content);
1167
1428
  const lineStart = starts[match.line - 1];
1168
1429
  if (lineStart === void 0) return false;
@@ -1172,7 +1433,7 @@ function isInserted(proposal, match, detector) {
1172
1433
  }
1173
1434
 
1174
1435
  // src/core/session/decide.ts
1175
- import path9 from "path";
1436
+ import path11 from "path";
1176
1437
 
1177
1438
  // src/core/session/state.ts
1178
1439
  function preexistingKey(rule, file) {
@@ -1180,7 +1441,14 @@ function preexistingKey(rule, file) {
1180
1441
  }
1181
1442
  var refusalKey = preexistingKey;
1182
1443
  function emptyWork() {
1183
- return { edited: [], stopBlocks: /* @__PURE__ */ new Map(), refusals: /* @__PURE__ */ new Map(), disabled: /* @__PURE__ */ new Map(), accessed: /* @__PURE__ */ new Map() };
1444
+ return {
1445
+ edited: [],
1446
+ stopBlocks: /* @__PURE__ */ new Map(),
1447
+ refusals: /* @__PURE__ */ new Map(),
1448
+ disabled: /* @__PURE__ */ new Map(),
1449
+ accessed: /* @__PURE__ */ new Map(),
1450
+ noticed: /* @__PURE__ */ new Set()
1451
+ };
1184
1452
  }
1185
1453
  function emptyContext() {
1186
1454
  return { delivered: [], touched: /* @__PURE__ */ new Set(), preexisting: /* @__PURE__ */ new Set(), warned: /* @__PURE__ */ new Set() };
@@ -1211,6 +1479,9 @@ function foldWork(records) {
1211
1479
  case "disabled":
1212
1480
  if (!state.disabled.has(record.rule)) state.disabled.set(record.rule, record.reason);
1213
1481
  break;
1482
+ case "noticed":
1483
+ state.noticed.add(record.key);
1484
+ break;
1214
1485
  }
1215
1486
  }
1216
1487
  return state;
@@ -1240,15 +1511,10 @@ function foldContext(records) {
1240
1511
  }
1241
1512
 
1242
1513
  // 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
1514
  var WARNING_SHARE = 0.15;
1249
1515
  var moreWarnings = (count) => `\u2026and ${count} more (run rulecast validate)`;
1250
1516
  function locationOf(spec) {
1251
- return path9.isAbsolute(spec.path) ? { location: spec.path } : {};
1517
+ return path11.isAbsolute(spec.path) ? { location: spec.path } : {};
1252
1518
  }
1253
1519
  function capturesFor(rule, match) {
1254
1520
  const names = templateBindings(rule.message ?? "");
@@ -1311,37 +1577,38 @@ async function isCovered(spec, delivered, resolver) {
1311
1577
  }
1312
1578
  return false;
1313
1579
  }
1314
- async function decide(input) {
1580
+ async function assemble(input) {
1315
1581
  const delivery = emptyDelivery();
1316
- const work = [];
1317
1582
  const context = [];
1583
+ const touches = input.touches ?? [];
1584
+ const state = input.context ?? emptyContext();
1318
1585
  const fresh = input.findings.filter((finding) => finding.status === "new");
1319
1586
  delivery.findings = renderFindings(fresh);
1320
- const rulesById = new Map(fresh.map(({ rule }) => [rule.id, rule]));
1321
1587
  for (const { rule } of fresh) if (rule.message !== null) delivery.templates[rule.id] = rule.message;
1322
1588
  const summaries = /* @__PURE__ */ new Map();
1323
1589
  for (const { rule, match, status } of input.findings) {
1324
1590
  if (status !== "preexisting") continue;
1325
1591
  const key = preexistingKey(rule.id, match.file);
1326
- if (input.context.preexisting.has(key)) continue;
1592
+ if (state.preexisting.has(key)) continue;
1327
1593
  const summary = summaries.get(key) ?? { rule: rule.id, file: match.file, count: 0 };
1328
1594
  summary.count++;
1329
1595
  summaries.set(key, summary);
1330
1596
  }
1331
1597
  delivery.preexistingSummary = [...summaries.values()];
1332
- const unwarned = input.warnings.filter((warning) => !input.context.warned.has(warning.key));
1598
+ const unwarned = (input.warnings ?? []).filter((warning) => !state.warned.has(warning.key));
1333
1599
  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);
1600
+ delivery.touches = touches.map((rule) => rule.id);
1601
+ for (const rule of touches) context.push({ t: "touched", rule: rule.id });
1602
+ const delivered = [...state.delivered];
1603
+ const agentRead = input.agentRead ?? null;
1604
+ if (agentRead !== null) {
1605
+ const hash = await input.resolver.currentHash(agentRead, null);
1339
1606
  if (hash !== null) {
1340
- delivered.push({ path: input.agentRead, anchor: null, hash });
1341
- context.push({ t: "delivered", path: input.agentRead, anchor: null, hash });
1607
+ delivered.push({ path: agentRead, anchor: null, hash });
1608
+ context.push({ t: "delivered", path: agentRead, anchor: null, hash });
1342
1609
  }
1343
1610
  }
1344
- const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...input.touches];
1611
+ const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...touches];
1345
1612
  const candidates = [];
1346
1613
  for (const spec of referencesInOrder(ruleOrder)) {
1347
1614
  const resolved = await input.resolver.resolve(spec);
@@ -1358,26 +1625,38 @@ async function decide(input) {
1358
1625
  delivery.references.push({ ref: spec.ref, state: "full", content: resolved.content });
1359
1626
  }
1360
1627
  }
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],
1628
+ return { delivery, candidates, fresh, unwarned, touches, context };
1629
+ }
1630
+ function trim(assembled, limits) {
1631
+ const { candidates, unwarned, touches } = assembled;
1632
+ const delivery = {
1633
+ ...assembled.delivery,
1634
+ findings: [...assembled.delivery.findings],
1635
+ preexistingSummary: [...assembled.delivery.preexistingSummary],
1636
+ references: [...assembled.delivery.references],
1637
+ warnings: [...assembled.delivery.warnings],
1369
1638
  omitted: { findings: [], rules: 0, preexisting: 0 }
1370
1639
  };
1640
+ const context = [];
1641
+ const rulesById = new Map(assembled.fresh.map(({ rule }) => [rule.id, rule]));
1642
+ const limit = limits.maxContextChars;
1643
+ const render = { maxMatchesPerRule: limits.maxMatchesPerRule };
1371
1644
  const byRule = /* @__PURE__ */ new Map();
1372
1645
  for (const finding of delivery.findings) {
1373
1646
  const group = byRule.get(finding.rule);
1374
1647
  if (group === void 0) byRule.set(finding.rule, [finding]);
1375
1648
  else group.push(finding);
1376
1649
  }
1377
- let used = HEADER_OVERHEAD;
1650
+ 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);
1378
1652
  const fits = (size) => limit === null || used + size <= limit;
1379
1653
  const kept = /* @__PURE__ */ new Map();
1380
- const lineCost = (ref) => ref.length + REFERENCE_OVERHEAD;
1654
+ const specs = /* @__PURE__ */ new Map();
1655
+ for (const rule of [...rulesById.values(), ...touches]) for (const spec of rule.context) specs.set(spec.ref, spec);
1656
+ const lineCost = (ref) => {
1657
+ const spec = specs.get(ref);
1658
+ return deliveryCost.referenceLine(ref, spec === void 0 ? void 0 : locationOf(spec).location);
1659
+ };
1381
1660
  const resolvedRefs = new Set(delivery.references.map((reference) => reference.ref));
1382
1661
  const charged = /* @__PURE__ */ new Set();
1383
1662
  const orphaned = /* @__PURE__ */ new Set();
@@ -1389,7 +1668,7 @@ async function decide(input) {
1389
1668
  for (const [id, findings] of byRule) {
1390
1669
  const rule = rulesById.get(id);
1391
1670
  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);
1671
+ const size = measureRuleBlock(id, [findings[0]], findings, delivery.templates[id], render) + refs.reduce((sum, ref) => sum + lineCost(ref), 0);
1393
1672
  if (used + spent.chars + size > cap) {
1394
1673
  spent.dropped++;
1395
1674
  continue;
@@ -1398,7 +1677,7 @@ async function decide(input) {
1398
1677
  for (const ref of refs) spent.refs.add(ref);
1399
1678
  spent.kept.set(id, [findings[0]]);
1400
1679
  }
1401
- for (const touch of input.touches) {
1680
+ for (const touch of touches) {
1402
1681
  for (const ref of uncharged(touch)) {
1403
1682
  spent.chars += lineCost(ref);
1404
1683
  spent.refs.add(ref);
@@ -1408,19 +1687,20 @@ async function decide(input) {
1408
1687
  };
1409
1688
  if (limit !== null) {
1410
1689
  let floor = floorWithin(limit);
1411
- if (floor.dropped > 0) floor = floorWithin(limit - OVERFLOW_NOTICE);
1412
- used += floor.chars + (floor.dropped > 0 ? OVERFLOW_NOTICE : 0);
1690
+ const notice = deliveryCost.overflowNotice(byRule.size);
1691
+ if (floor.dropped > 0) floor = floorWithin(limit - notice);
1692
+ used += floor.chars + (floor.dropped > 0 ? notice : 0);
1413
1693
  delivery.omitted.rules = floor.dropped;
1414
1694
  for (const [id, findings] of floor.kept) kept.set(id, findings);
1415
1695
  for (const ref of floor.refs) charged.add(ref);
1416
1696
  for (const ref of resolvedRefs) if (!charged.has(ref)) orphaned.add(ref);
1417
1697
  }
1418
- const warningCost = (text) => text.length + ITEM_OVERHEAD;
1698
+ const warningCost = (text) => deliveryCost.warning(text);
1419
1699
  if (limit === null) {
1420
1700
  for (const warning of unwarned) context.push({ t: "warned", key: warning.key });
1421
1701
  } else {
1422
1702
  const ceiling = Math.max(0, Math.min(Math.floor(limit * WARNING_SHARE), limit - used));
1423
- let spent = 0;
1703
+ let spent = unwarned.length > 0 ? deliveryCost.warningsFrame : 0;
1424
1704
  let held = 0;
1425
1705
  while (held < unwarned.length && spent + warningCost(unwarned[held].text) <= ceiling) {
1426
1706
  spent += warningCost(unwarned[held].text);
@@ -1439,10 +1719,10 @@ async function decide(input) {
1439
1719
  spent += warningCost(moreWarnings(cut));
1440
1720
  delivery.warnings.push(moreWarnings(cut));
1441
1721
  }
1442
- used += spent;
1722
+ used += delivery.warnings.length > 0 ? spent : 0;
1443
1723
  }
1444
1724
  for (const { index, resolved } of candidates) {
1445
- const size = resolved.content.length;
1725
+ const size = deliveryCost.referenceContent(resolved.spec.ref, resolved.content, locationOf(resolved.spec).location);
1446
1726
  if (orphaned.has(resolved.spec.ref)) continue;
1447
1727
  if (!fits(size)) {
1448
1728
  delivery.references[index] = {
@@ -1456,14 +1736,16 @@ async function decide(input) {
1456
1736
  used += size;
1457
1737
  context.push({ t: "delivered", path: resolved.spec.path, anchor: resolved.spec.anchor, hash: resolved.hash });
1458
1738
  }
1459
- if (delivery.preexistingSummary.length > 0) used += BACKLOG_HEADING.length + BACKLOG_HINT.length + ITEM_OVERHEAD;
1739
+ const frame = delivery.preexistingSummary.length > 0 ? deliveryCost.backlogFrame(delivery.preexistingSummary.length) : 0;
1740
+ if (frame > 0 && !fits(frame)) delivery.preexistingSummary = [];
1741
+ else used += frame;
1460
1742
  const summariesKept = [];
1461
1743
  for (const summary of delivery.preexistingSummary) {
1462
- if (!fits(summary.rule.length + summary.file.length + ITEM_OVERHEAD)) {
1744
+ if (!fits(deliveryCost.backlogSummary(summary))) {
1463
1745
  delivery.omitted.preexisting++;
1464
1746
  continue;
1465
1747
  }
1466
- used += summary.rule.length + summary.file.length + ITEM_OVERHEAD;
1748
+ used += deliveryCost.backlogSummary(summary);
1467
1749
  summariesKept.push(summary);
1468
1750
  context.push({ t: "preexisting", rule: summary.rule, file: summary.file });
1469
1751
  }
@@ -1474,9 +1756,9 @@ async function decide(input) {
1474
1756
  added = false;
1475
1757
  for (const [rule, findings] of byRule) {
1476
1758
  const shown = kept.get(rule);
1477
- if (shown === void 0 || shown.length >= Math.min(findings.length, input.maxMatchesPerRule)) continue;
1759
+ if (shown === void 0 || shown.length >= Math.min(findings.length, limits.maxMatchesPerRule)) continue;
1478
1760
  const template = delivery.templates[rule];
1479
- const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], template, render) - measureRuleBlock(rule, shown, template, render);
1761
+ const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], findings, template, render) - measureRuleBlock(rule, shown, findings, template, render);
1480
1762
  if (!fits(delta)) continue;
1481
1763
  used += delta;
1482
1764
  shown.push(findings[shown.length]);
@@ -1496,20 +1778,26 @@ async function decide(input) {
1496
1778
  }
1497
1779
  delivery.findings = delivery.findings.filter((finding) => kept.get(finding.rule)?.includes(finding) === true);
1498
1780
  }
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
- }
1511
- }
1512
- return { delivery, overflow: delivery.omitted.rules > 0 ? complete : null, work, context };
1781
+ return { delivery, overflow: delivery.omitted.rules > 0 ? assembled.delivery : null, context };
1782
+ }
1783
+ function gate(fresh, work, agent, maxBlocks) {
1784
+ if (!fresh.some(({ rule }) => rule.severity === "error")) return { stop: "allow", work: [] };
1785
+ if ((work.stopBlocks.get(agent) ?? 0) < maxBlocks) return { stop: "block", work: [{ t: "stopBlock", agent }] };
1786
+ return { stop: "capReached", work: [] };
1787
+ }
1788
+ async function decide(input) {
1789
+ const assembled = await assemble(input);
1790
+ const trimmed = trim(assembled, {
1791
+ maxContextChars: input.maxContextChars,
1792
+ maxMatchesPerRule: input.maxMatchesPerRule
1793
+ });
1794
+ const context = [...assembled.context, ...trimmed.context];
1795
+ if (!input.stopGate) return { delivery: trimmed.delivery, overflow: trimmed.overflow, work: [], context };
1796
+ const gated = gate(assembled.fresh, input.work, input.agent, input.maxBlocks);
1797
+ const delivery = { ...trimmed.delivery, stop: gated.stop };
1798
+ if (gated.stop === "allow") return { delivery, overflow: null, work: gated.work, context: [] };
1799
+ if (gated.stop === "capReached") return { delivery, overflow: trimmed.overflow, work: gated.work, context: [] };
1800
+ return { delivery, overflow: trimmed.overflow, work: gated.work, context };
1513
1801
  }
1514
1802
 
1515
1803
  // src/core/guard.ts
@@ -1519,7 +1807,7 @@ async function guardWrite(input) {
1519
1807
  if (file === void 0 || intent === void 0) return emptyDelivery();
1520
1808
  const rules = input.rules.filter((rule) => rule.refuseWrite && !input.disabled.has(rule.id) && rule.matches(file));
1521
1809
  if (rules.length === 0) return emptyDelivery();
1522
- const proposal = propose(await readSourceFile(input.root, file), intent);
1810
+ const proposal = propose(await readSourceFile(input.detection.root, file), intent);
1523
1811
  if (proposal === null) {
1524
1812
  input.log(`guard: ${file} could not be reconstructed from the tool call; allowing the write`);
1525
1813
  return emptyDelivery();
@@ -1532,19 +1820,18 @@ async function guardWrite(input) {
1532
1820
  const selections = selectDetectorRules(rules, "edit", [file], input.disabled);
1533
1821
  if (selections.length === 0) return emptyDelivery();
1534
1822
  const output = await runDetection({
1535
- root: input.root,
1823
+ detection: input.detection,
1536
1824
  event: "edit",
1537
1825
  selections,
1538
1826
  changes: /* @__PURE__ */ new Map(),
1539
1827
  // 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 },
1828
+ read: async (name) => name === file ? proposal.content : readSourceFile(input.detection.root, name),
1545
1829
  timeoutMs: input.config.timeouts.editDeadlineMs
1546
1830
  });
1547
- const evidence = output.findings.filter(({ rule, match }) => isInserted(proposal, match, rule.detector.kind));
1831
+ const wholeFile = (kind) => input.detection.registry.get(kind)?.wholeFile === true;
1832
+ const evidence = output.findings.filter(
1833
+ ({ rule, match }) => isInserted(proposal, match, wholeFile(rule.detector.kind))
1834
+ );
1548
1835
  if (evidence.length === 0) return emptyDelivery();
1549
1836
  const spent = (rule) => (input.work.refusals.get(refusalKey(rule, file)) ?? 0) >= input.config.refuseGate.maxRefusals;
1550
1837
  const refusing = evidence.filter(({ rule }) => !spent(rule.id));
@@ -1552,41 +1839,67 @@ async function guardWrite(input) {
1552
1839
  input.log(`guard: ${file} has spent its refusals; allowing the write`);
1553
1840
  return emptyDelivery();
1554
1841
  }
1555
- const { delivery } = await decide({
1556
- agent: "main",
1842
+ const assembled = await assemble({
1557
1843
  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
1844
  resolver: input.resolver,
1567
- maxBytes: input.config.context.maxBytes,
1568
- maxBlocks: input.config.stopGate.maxBlocks,
1845
+ maxBytes: input.config.context.maxBytes
1846
+ });
1847
+ const { delivery, context } = trim(assembled, {
1569
1848
  maxContextChars: input.maxContextChars,
1570
- maxMatchesPerRule: input.config.maxMatchesPerRule,
1571
- stopGate: false
1849
+ maxMatchesPerRule: input.config.maxMatchesPerRule
1572
1850
  });
1851
+ await input.recordContext(context.filter((record) => record.t === "delivered"));
1573
1852
  await input.record(
1574
1853
  [...new Set(refusing.map(({ rule }) => rule.id))].map((rule) => ({ t: "refused", rule, file }))
1575
1854
  );
1576
1855
  return delivery;
1577
1856
  }
1578
1857
 
1858
+ // src/core/session/oversight.ts
1859
+ import { createHash as createHash3 } from "crypto";
1860
+ import { readFile as readFile4 } from "fs/promises";
1861
+ import path12 from "path";
1862
+ function notices(input) {
1863
+ const out = [];
1864
+ const configKey = `config:${input.currentConfigHash ?? "gone"}`;
1865
+ if (input.startConfigHash !== null && input.startConfigHash !== input.currentConfigHash && !input.told.has(configKey)) {
1866
+ out.push({
1867
+ key: configKey,
1868
+ text: `rulecast: ${CONFIG_FILE} changed during this session; findings may have been silenced. Review: git diff ${CONFIG_FILE}`
1869
+ });
1870
+ }
1871
+ const keyOf = ({ rule, match }) => `ignore:${rule}:${match.file}:${match.line}`;
1872
+ const added = input.ignored.filter((ignored) => ignored.status === "new" && !input.told.has(keyOf(ignored)));
1873
+ if (added.length > 0) {
1874
+ const sites = added.map(({ rule, match, reason }) => `${match.file}:${match.line} (${rule}: "${reason}")`);
1875
+ const comments = added.length === 1 ? "a rulecast-ignore comment" : `${added.length} rulecast-ignore comments`;
1876
+ out.push({
1877
+ key: added.map(keyOf).join("\n"),
1878
+ text: `rulecast: the agent added ${comments} this session: ${sites.join(", ")}`
1879
+ });
1880
+ }
1881
+ return out;
1882
+ }
1883
+ async function configHash(root) {
1884
+ try {
1885
+ return createHash3("sha256").update(await readFile4(path12.join(root, CONFIG_FILE))).digest("hex");
1886
+ } catch (error) {
1887
+ if (isNotFound(error)) return null;
1888
+ throw error;
1889
+ }
1890
+ }
1891
+
1579
1892
  // src/core/session/session.ts
1580
- import path10 from "path";
1893
+ import path13 from "path";
1581
1894
  function safeSegment3(value) {
1582
1895
  const segment = value.replace(/[^A-Za-z0-9._-]/g, "_");
1583
1896
  return /^\.*$/.test(segment) ? `_${segment}` : segment;
1584
1897
  }
1585
1898
  function sessionDir(stateDir, sessionId) {
1586
- return path10.join(stateDir, "sessions", safeSegment3(sessionId));
1899
+ return path13.join(stateDir, "sessions", safeSegment3(sessionId));
1587
1900
  }
1588
- var workFile = (dir) => path10.join(dir, "work.jsonl");
1589
- var contextFile = (dir, agent) => path10.join(dir, `context.${safeSegment3(agent)}.jsonl`);
1901
+ var workFile = (dir) => path13.join(dir, "work.jsonl");
1902
+ var contextFile = (dir, agent) => path13.join(dir, `context.${safeSegment3(agent)}.jsonl`);
1590
1903
  async function appendWork(dir, records) {
1591
1904
  await appendRecords(workFile(dir), records);
1592
1905
  }
@@ -1613,7 +1926,7 @@ async function commitSession(dir, agent, decideWith) {
1613
1926
  var SUMMARY_THRESHOLD = 3;
1614
1927
  var DEFAULT_HINT = "rulecast validate";
1615
1928
  function summaryKey(prefix, ids) {
1616
- const digest = createHash3("sha256").update([...ids].sort().join("\n")).digest("hex").slice(0, 16);
1929
+ const digest = createHash4("sha256").update([...ids].sort().join("\n")).digest("hex").slice(0, 16);
1617
1930
  return `${prefix}:${digest}`;
1618
1931
  }
1619
1932
  function compileWarnings(errors) {
@@ -1636,8 +1949,9 @@ First: ${diagnosticText(errors[0])}`
1636
1949
  }
1637
1950
  ];
1638
1951
  }
1639
- function detectorWarnings(errors) {
1640
- const text = (error) => `${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}. Disabled for this session.`;
1952
+ function detectorWarnings(errors, scope) {
1953
+ const outcome = scope === "session" ? "Disabled for this session." : "Skipped for this run.";
1954
+ const text = (error) => `${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}. ${outcome}`;
1641
1955
  const byKind = /* @__PURE__ */ new Map();
1642
1956
  for (const error of errors) {
1643
1957
  const group = byKind.get(error.kind);
@@ -1658,7 +1972,7 @@ function detectorWarnings(errors) {
1658
1972
  const ids = group.flatMap((error) => error.rules);
1659
1973
  summarised.push({
1660
1974
  key: summaryKey(`detector:${kind}`, ids),
1661
- text: `${ids.length} ${kind} rules were disabled this session \u2014 see debug.log.
1975
+ text: `${ids.length} ${kind} rules ${scope === "session" ? "were disabled this session" : "were skipped in this run"} \u2014 see debug.log.
1662
1976
  First: ${text(group[0])}`
1663
1977
  });
1664
1978
  }
@@ -1705,7 +2019,7 @@ async function runPipeline(options) {
1705
2019
  const dir = session.dir;
1706
2020
  baseline = await readingStore(() => readBaseline(dir), baseline);
1707
2021
  if (session && !baseline.started) {
1708
- await appendBaseline(dir, [startRecord(await headCommit(root))]);
2022
+ await appendBaseline(dir, [startRecord(await headCommit(root), await configHash(root))]);
1709
2023
  baseline = await readingStore(() => readBaseline(dir), baseline);
1710
2024
  }
1711
2025
  }
@@ -1713,15 +2027,14 @@ async function runPipeline(options) {
1713
2027
  const view = session ? await readingStore(() => openSession(session.dir, session.agent), empty()) : empty();
1714
2028
  const disabled = new Set(view.work.disabled.keys());
1715
2029
  const resolver = createReferenceResolver(root);
2030
+ const detection = detectionFor(project, registry, stateDir, resolver);
1716
2031
  const workRecords = [];
1717
2032
  const relevant = (files) => files.filter((file) => project.rules.some((rule) => rule.matches(file)));
1718
2033
  if (event.kind === "guard") {
1719
2034
  const open2 = session;
1720
2035
  const delivery = await guardWrite({
1721
- root,
2036
+ detection,
1722
2037
  config,
1723
- registry,
1724
- stateDir,
1725
2038
  event,
1726
2039
  rules: project.rules,
1727
2040
  disabled,
@@ -1730,12 +2043,16 @@ async function runPipeline(options) {
1730
2043
  maxContextChars: options.maxContextChars,
1731
2044
  log,
1732
2045
  record: open2 ? (records) => appendWork(open2.dir, records) : async () => {
2046
+ },
2047
+ recordContext: open2 ? (records) => appendContext(open2.dir, open2.agent, records) : async () => {
1733
2048
  }
1734
2049
  });
1735
2050
  return { delivery, failed, deadlineMissed };
1736
2051
  }
1737
2052
  let touches = [];
1738
2053
  let findings = [];
2054
+ let ignored = [];
2055
+ let told = [];
1739
2056
  let agentRead = null;
1740
2057
  if (event.kind === "touch" || event.kind === "edit") {
1741
2058
  touches = selectTouchRules(project.rules, event.files, view.context.touched, disabled);
@@ -1751,96 +2068,55 @@ async function runPipeline(options) {
1751
2068
  const recent = (view.work.accessed.get(session.agent) ?? []).slice(-restoredFiles).reverse();
1752
2069
  touches = selectTouchRules(project.rules, recent, view.context.touched, disabled);
1753
2070
  }
2071
+ const baselineInput = {
2072
+ root,
2073
+ detection,
2074
+ rules: project.rules,
2075
+ disabled,
2076
+ limits: {
2077
+ editDeadlineMs: config.timeouts.editDeadlineMs,
2078
+ verifyMs: config.timeouts.verifyMs,
2079
+ maxFileBytes: config.maxFileBytes
2080
+ }
2081
+ };
1754
2082
  if (event.kind === "touch") {
1755
2083
  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
- );
1784
- }
1785
- await appendBaseline(session.dir, records);
1786
- }
2084
+ if (session) await appendBaseline(session.dir, await touchRecords(baselineInput, baseline, relevant(event.files)));
1787
2085
  }
1788
2086
  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;
2087
+ let requested = relevant(event.files);
1792
2088
  if (event.kind === "edit" && session) {
1793
2089
  await appendWork(
1794
2090
  session.dir,
1795
- files.map((file) => ({ t: "edited", file }))
2091
+ requested.map((file) => ({ t: "edited", file }))
1796
2092
  );
1797
2093
  }
1798
- if (event.kind === "verify") {
1799
- if (event.baseCommit) {
1800
- fallbackCommit = event.baseCommit;
1801
- snapshots = /* @__PURE__ */ new Map();
1802
- }
1803
- if (event.files.length === 0 && session) files = relevant(view.work.edited);
1804
- }
1805
- const baselineChanges = await computeChanges(root, files, { snapshots, fallbackCommit });
2094
+ if (event.kind === "verify" && event.files.length === 0 && session) requested = relevant(view.work.edited);
2095
+ const read = contentReader(root, event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE);
2096
+ const stage = await changesFor(
2097
+ baselineInput,
2098
+ { kind: event.kind, baseCommit: event.baseCommit },
2099
+ session ? baseline : null,
2100
+ requested,
2101
+ read
2102
+ );
2103
+ const { files, changes: baselineChanges, fingerprints } = stage;
1806
2104
  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
- }
2105
+ if (session && stage.records.length > 0) await appendBaseline(session.dir, stage.records);
1835
2106
  const skip = options.skipDetectorKinds ?? /* @__PURE__ */ new Set();
1836
2107
  let selections = selectDetectorRules(project.rules, event.kind, files, disabled).filter(
1837
2108
  (selection) => !skip.has(selection.rule.detector.kind) && (options.onlyRules?.has(selection.rule.id) ?? true)
1838
2109
  );
1839
- let overBudget = [];
2110
+ const overBudget = [];
1840
2111
  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;
2112
+ const recent = [...view.work.edited].reverse();
2113
+ for (const kind of new Set(selections.map((selection) => selection.rule.detector.kind))) {
2114
+ const budget = registry.get(kind)?.fileBudget?.(detection.settings);
2115
+ if (budget === void 0) continue;
2116
+ const budgeted = applyFileBudget(kind, selections, budget.max, recent);
2117
+ selections = budgeted.selections;
2118
+ if (budgeted.skipped.length > 0) overBudget.push({ kind, ...budget, skipped: budgeted.skipped });
2119
+ }
1844
2120
  }
1845
2121
  if (event.kind === "edit") {
1846
2122
  const ceiling = await applySizeCeiling(
@@ -1854,35 +2130,54 @@ async function runPipeline(options) {
1854
2130
  log(`over max_file_bytes (${config.maxFileBytes}), not checked on this edit: ${ceiling.skipped.join(", ")}`);
1855
2131
  }
1856
2132
  }
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
- });
2133
+ const output = await runDetectionFrom(
2134
+ {
2135
+ detection,
2136
+ event: event.kind,
2137
+ selections,
2138
+ changes,
2139
+ read,
2140
+ timeoutMs: event.kind === "edit" ? config.timeouts.editDeadlineMs : config.timeouts.verifyMs
2141
+ },
2142
+ event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE
2143
+ );
1869
2144
  findings = output.findings.map(({ rule, match }) => ({
1870
2145
  rule,
1871
2146
  match,
1872
- status: classify(match, rule.scope, rule.id, baselineChanges, baseline.fingerprints)
2147
+ status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
1873
2148
  }));
1874
2149
  for (const error of output.errors) {
1875
2150
  failed = true;
1876
2151
  log(`${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}`);
1877
2152
  for (const rule of error.rules) workRecords.push({ t: "disabled", rule, reason: error.message });
1878
2153
  }
1879
- warnings.push(...detectorWarnings(output.errors));
1880
- if (overBudget.length > 0) {
2154
+ warnings.push(...detectorWarnings(output.errors, session === null ? "run" : "session"));
2155
+ ignored = output.ignored;
2156
+ if (options.stopGate === true && event.kind === "verify" && session !== null) {
2157
+ told = notices({
2158
+ startConfigHash: baseline.startConfigHash ?? null,
2159
+ currentConfigHash: await configHash(root),
2160
+ ignored: ignored.map(({ rule, match, reason }) => ({
2161
+ rule: rule.id,
2162
+ match,
2163
+ reason,
2164
+ status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
2165
+ })),
2166
+ told: view.work.noticed
2167
+ });
2168
+ for (const notice of told) for (const key of notice.key.split("\n")) workRecords.push({ t: "noticed", key });
2169
+ }
2170
+ for (const text of output.warnings) warnings.push({ key: `ignore:${text}`, text });
2171
+ for (const { kind, max, setting, skipped } of overBudget) {
1881
2172
  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.`
2173
+ key: `${kind}-budget:${skipped.join(",")}`,
2174
+ text: `${kind} rules checked ${max} files; ${skipped.length} were not checked: ${skipped.join(", ")}. Raise ${setting}, or run rulecast run --files on them.`
1884
2175
  });
1885
2176
  }
2177
+ const hintFor = (kind) => {
2178
+ const hint = registry.get(kind)?.timeoutHint;
2179
+ return hint === void 0 ? "" : ` (${hint})`;
2180
+ };
1886
2181
  for (const timeout of output.timedOut) {
1887
2182
  if (event.kind === "edit") {
1888
2183
  log(`edit deadline passed for ${timeout.kind}: ${timeout.rules.join(", ")}`);
@@ -1893,7 +2188,7 @@ async function runPipeline(options) {
1893
2188
  key: `timeout:${timeout.kind}:${timeout.rules.join(",")}`,
1894
2189
  // Naming the lever matters: a field trial measured a single haiku call on a 135-line file
1895
2190
  // 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)" : ""}.`
2191
+ text: `${timeout.kind} detector timed out after ${config.timeouts.verifyMs} ms for ${timeout.rules.join(", ")}. Raise timeouts.verify_ms${hintFor(timeout.kind)}.`
1897
2192
  });
1898
2193
  }
1899
2194
  }
@@ -1913,17 +2208,30 @@ async function runPipeline(options) {
1913
2208
  maxMatchesPerRule: config.maxMatchesPerRule,
1914
2209
  stopGate: options.stopGate === true && event.kind === "verify" && session !== null
1915
2210
  });
2211
+ const home = path14.dirname(path14.dirname(stateDir));
1916
2212
  const persisted = (decision) => {
1917
2213
  if (decision.overflow !== null) {
1918
- decision.delivery.overflowPath = writeOverflow(stateDir, event.session?.id ?? null, decision.overflow);
2214
+ let written = null;
2215
+ try {
2216
+ written = writeOverflow(path14.join(agentDir(root), "deliveries"), event.session?.id ?? null, decision.overflow);
2217
+ } catch {
2218
+ written = null;
2219
+ }
2220
+ decision.delivery.overflowPath = written === null ? null : path14.relative(root, written).split(path14.sep).join("/");
2221
+ }
2222
+ for (const reference of decision.delivery.references) {
2223
+ if (reference.location === void 0 || !path14.isAbsolute(reference.location)) continue;
2224
+ const copied = copyIntoProject(root, home, reference.location);
2225
+ if (copied !== null) reference.location = copied;
1919
2226
  }
2227
+ if (told.length > 0) decision.delivery.notices = told.map((notice) => notice.text);
1920
2228
  return decision.delivery;
1921
2229
  };
1922
- if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed };
2230
+ if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed, ignored };
1923
2231
  await appendWork(session.dir, workRecords);
1924
2232
  try {
1925
2233
  const decision = await commitSession(session.dir, session.agent, (state) => decide(inputFor(state)));
1926
- return { delivery: persisted(decision), failed, deadlineMissed };
2234
+ return { delivery: persisted(decision), failed, deadlineMissed, ignored };
1927
2235
  } catch (error) {
1928
2236
  if (error instanceof CorruptStoreError) {
1929
2237
  log(`session store unreadable: ${error.message}`);
@@ -1932,7 +2240,7 @@ async function runPipeline(options) {
1932
2240
  warnings.push({ key: "lock", text: "session state was locked; delivered without session memory" });
1933
2241
  } else throw error;
1934
2242
  const decision = await decide({ ...inputFor({ work: emptyWork(), context: emptyContext() }), stopGate: false });
1935
- return { delivery: persisted(decision), failed, deadlineMissed };
2243
+ return { delivery: persisted(decision), failed, deadlineMissed, ignored };
1936
2244
  }
1937
2245
  }
1938
2246
 
@@ -1940,10 +2248,12 @@ export {
1940
2248
  defaultConfig,
1941
2249
  CONFIG_FILE,
1942
2250
  MANIFEST_FILE,
2251
+ readYamlFile,
1943
2252
  readConfigData,
1944
2253
  parseConfig,
1945
2254
  readManifest,
1946
2255
  git,
2256
+ headCommit,
1947
2257
  mergeBase,
1948
2258
  changedFilesSince,
1949
2259
  allFiles,
@@ -1964,14 +2274,16 @@ export {
1964
2274
  diagnosticText,
1965
2275
  compile,
1966
2276
  compileManifest,
2277
+ createReferenceResolver,
1967
2278
  rulesOfKind,
1968
2279
  checkableKinds,
1969
2280
  checkDetectors,
2281
+ detectionFor,
1970
2282
  runDetection,
1971
2283
  cachedRepos,
1972
2284
  fetchingRepos,
1973
2285
  fixedRepo,
1974
- resolveRuleContext,
1975
- createReferenceResolver,
2286
+ WORKTREE,
2287
+ withScratchTree,
1976
2288
  runPipeline
1977
2289
  };