@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.
@@ -3,18 +3,20 @@ import {
3
3
  DeadlineError,
4
4
  LlmUnavailableError,
5
5
  MODEL_ALIASES,
6
+ emptyDelivery,
6
7
  errorMessage,
7
8
  isNotFound,
8
9
  lineStarts,
9
10
  offsetAt,
10
11
  onPath,
12
+ pastDeadline,
11
13
  perRule,
12
14
  positionAt,
13
15
  providerByName,
14
- readSourceFile,
15
16
  renderAgentText,
16
- resolveModel
17
- } from "./chunk-HZUATJ53.js";
17
+ resolveModel,
18
+ sourceReader
19
+ } from "./chunk-4XS34SYW.js";
18
20
 
19
21
  // src/core/detection/registry.ts
20
22
  function createRegistry(detectors) {
@@ -29,6 +31,61 @@ function createRegistry(detectors) {
29
31
  };
30
32
  }
31
33
 
34
+ // src/core/detection/tool.ts
35
+ import { execFile } from "child_process";
36
+ import { promisify } from "util";
37
+ var exec = promisify(execFile);
38
+ async function runTool(command, args, options) {
39
+ try {
40
+ const running = exec(command, [...args], {
41
+ cwd: options.cwd,
42
+ signal: options.signal,
43
+ // A whole-repository run of a linter on a large project prints megabytes of JSON.
44
+ maxBuffer: 64 * 1024 * 1024
45
+ });
46
+ running.child.stdin?.on("error", () => {
47
+ });
48
+ if (options.stdin === void 0) running.child.stdin?.end();
49
+ else running.child.stdin?.end(options.stdin);
50
+ const result = await running;
51
+ return result.stdout;
52
+ } catch (error) {
53
+ if (options.signal.aborted) throw error;
54
+ if (isNotFound(error)) throw new Error(options.notFound);
55
+ const failure = error;
56
+ if (typeof failure.stdout !== "string") throw error;
57
+ return failure.stdout;
58
+ }
59
+ }
60
+
61
+ // src/core/detection/paths.ts
62
+ import { realpathSync } from "fs";
63
+ import path from "path";
64
+ import { fileURLToPath } from "url";
65
+ function repoRelativeTo(cwd) {
66
+ const roots = rootsOf(cwd);
67
+ return (file) => {
68
+ const withoutScheme = file.startsWith("file://") ? fileURLToPath(file) : file;
69
+ if (!path.isAbsolute(withoutScheme)) return withoutScheme.split(path.sep).join("/");
70
+ for (const root of roots) {
71
+ const relative = path.relative(root, withoutScheme);
72
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) return relative.split(path.sep).join("/");
73
+ }
74
+ return path.relative(cwd, withoutScheme).split(path.sep).join("/");
75
+ };
76
+ }
77
+ function repoRelative(file, cwd) {
78
+ return repoRelativeTo(cwd)(file);
79
+ }
80
+ function rootsOf(root) {
81
+ try {
82
+ const real = realpathSync(root);
83
+ return real === root ? [root] : [root, real];
84
+ } catch {
85
+ return [root];
86
+ }
87
+ }
88
+
32
89
  // src/detectors/ast-grep/languages.ts
33
90
  var LANGUAGES = {
34
91
  css: { builtin: "Css" },
@@ -180,7 +237,7 @@ var astGrepDetector = {
180
237
  if (source === null) continue;
181
238
  root = parser.parse(source).root();
182
239
  } catch (error) {
183
- if (input.signal.aborted) throw error;
240
+ if (pastDeadline(error, input)) throw error;
184
241
  const message = errorMessage(error);
185
242
  for (const rule of rules) {
186
243
  if (rule.files.includes(file) && !result.errors.some((existing) => existing.rule === rule.id)) {
@@ -196,7 +253,7 @@ var astGrepDetector = {
196
253
  result.findings.push({ rule: rule.id, match: matchOf(file, node, variables.get(rule.id)) });
197
254
  }
198
255
  } catch (error) {
199
- if (input.signal.aborted) throw error;
256
+ if (pastDeadline(error, input)) throw error;
200
257
  if (!result.errors.some((existing) => existing.rule === rule.id)) {
201
258
  result.errors.push({ rule: rule.id, message: errorMessage(error) });
202
259
  }
@@ -215,15 +272,10 @@ var astGrepDetector = {
215
272
  };
216
273
 
217
274
  // src/detectors/command/detector.ts
218
- import { execFile } from "child_process";
219
275
  import { access, constants } from "fs/promises";
220
276
  import path2 from "path";
221
- import { promisify } from "util";
222
277
 
223
278
  // src/detectors/command/results.ts
224
- import { realpathSync } from "fs";
225
- import path from "path";
226
- import { fileURLToPath } from "url";
227
279
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
228
280
  function parseJson(output) {
229
281
  try {
@@ -232,23 +284,6 @@ function parseJson(output) {
232
284
  throw new Error("output is not JSON");
233
285
  }
234
286
  }
235
- function repoRelative(file, cwd) {
236
- const withoutScheme = file.startsWith("file://") ? fileURLToPath(file) : file;
237
- if (!path.isAbsolute(withoutScheme)) return withoutScheme.split(path.sep).join("/");
238
- for (const root of rootsOf(cwd)) {
239
- const relative = path.relative(root, withoutScheme);
240
- if (!relative.startsWith("..") && !path.isAbsolute(relative)) return relative.split(path.sep).join("/");
241
- }
242
- return path.relative(cwd, withoutScheme).split(path.sep).join("/");
243
- }
244
- function rootsOf(root) {
245
- try {
246
- const real = realpathSync(root);
247
- return real === root ? [root] : [root, real];
248
- } catch {
249
- return [root];
250
- }
251
- }
252
287
  function capturesOf2(source, names, where) {
253
288
  const captures = {};
254
289
  for (const name of names) {
@@ -259,6 +294,7 @@ function capturesOf2(source, names, where) {
259
294
  return captures;
260
295
  }
261
296
  function matchesFromJson(output, captures, cwd) {
297
+ const relative = repoRelativeTo(cwd);
262
298
  const parsed = parseJson(output);
263
299
  if (!Array.isArray(parsed)) throw new Error("output is not a JSON array of results");
264
300
  return parsed.map((result, index) => {
@@ -267,7 +303,7 @@ function matchesFromJson(output, captures, cwd) {
267
303
  if (typeof result.file !== "string") throw new Error(`${where}: "file" must be a string`);
268
304
  if (typeof result.line !== "number") throw new Error(`${where}: "line" must be a number`);
269
305
  return {
270
- file: repoRelative(result.file, cwd),
306
+ file: relative(result.file),
271
307
  line: result.line,
272
308
  endLine: typeof result.endLine === "number" ? result.endLine : result.line,
273
309
  column: typeof result.column === "number" ? result.column : 1,
@@ -277,6 +313,7 @@ function matchesFromJson(output, captures, cwd) {
277
313
  });
278
314
  }
279
315
  function matchesFromSarif(output, captures, cwd) {
316
+ const relative = repoRelativeTo(cwd);
280
317
  const parsed = parseJson(output);
281
318
  if (!isObject(parsed) || !Array.isArray(parsed.runs)) throw new Error("output is not SARIF 2.1.0");
282
319
  const matches = [];
@@ -296,7 +333,7 @@ function matchesFromSarif(output, captures, cwd) {
296
333
  const message = isObject(result.message) && typeof result.message.text === "string" ? result.message.text : "";
297
334
  const source = isObject(result.properties) ? { ...result, ...result.properties } : result;
298
335
  matches.push({
299
- file: repoRelative(artifact.uri, cwd),
336
+ file: relative(artifact.uri),
300
337
  line,
301
338
  endLine: typeof region.endLine === "number" ? region.endLine : line,
302
339
  column: typeof region.startColumn === "number" ? region.startColumn : 1,
@@ -325,7 +362,6 @@ var commandSchema = z2.object({
325
362
  }).strict();
326
363
 
327
364
  // src/detectors/command/detector.ts
328
- var exec = promisify(execFile);
329
365
  var FILES = "{{files}}";
330
366
  function argvFor(run, files) {
331
367
  if (!run.includes(FILES)) return [...run, ...files];
@@ -356,17 +392,11 @@ var commandDetector = {
356
392
  run: perRule(async (rule, input) => {
357
393
  if (rule.files.length === 0) return [];
358
394
  const [command, ...rest] = argvFor(rule.config.run, rule.files);
359
- let stdout;
360
- try {
361
- const result = await exec(command, rest, { cwd: input.cwd, signal: input.signal, maxBuffer: 64 * 1024 * 1024 });
362
- stdout = result.stdout;
363
- } catch (error) {
364
- if (input.signal.aborted) throw error;
365
- if (isNotFound(error)) throw new Error(`command not found: ${command}`);
366
- const failure = error;
367
- if (typeof failure.stdout !== "string") throw error;
368
- stdout = failure.stdout;
369
- }
395
+ const stdout = await runTool(command, rest, {
396
+ cwd: input.cwd,
397
+ signal: input.signal,
398
+ notFound: `command not found: ${command}`
399
+ });
370
400
  return rule.config.output === "sarif" ? matchesFromSarif(stdout, rule.config.captures, input.cwd) : matchesFromJson(stdout, rule.config.captures, input.cwd);
371
401
  }),
372
402
  async check(input) {
@@ -384,10 +414,6 @@ var commandDetector = {
384
414
  }
385
415
  };
386
416
 
387
- // src/detectors/linter/detector.ts
388
- import { execFile as execFile2 } from "child_process";
389
- import { promisify as promisify2 } from "util";
390
-
391
417
  // src/detectors/linter/resolve.ts
392
418
  import { access as access2, constants as constants2 } from "fs/promises";
393
419
  import path3 from "path";
@@ -432,8 +458,6 @@ var linterSchema = z3.object({
432
458
  }).strict();
433
459
 
434
460
  // src/detectors/linter/tools.ts
435
- import { realpathSync as realpathSync2 } from "fs";
436
- import path4 from "path";
437
461
  var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
438
462
  function json(tool, stdout) {
439
463
  try {
@@ -442,22 +466,6 @@ function json(tool, stdout) {
442
466
  throw new Error(`${tool} output is not JSON`);
443
467
  }
444
468
  }
445
- function relativeTo(roots, file) {
446
- if (!path4.isAbsolute(file)) return file.split(path4.sep).join("/");
447
- for (const root of roots) {
448
- const relative = path4.relative(root, file);
449
- if (!relative.startsWith("..") && !path4.isAbsolute(relative)) return relative.split(path4.sep).join("/");
450
- }
451
- return path4.relative(roots[0], file).split(path4.sep).join("/");
452
- }
453
- function rootsOf2(root) {
454
- try {
455
- const real = realpathSync2(root);
456
- return real === root ? [root] : [root, real];
457
- } catch {
458
- return [root];
459
- }
460
- }
461
469
  var number = (value, fallback) => typeof value === "number" ? value : fallback;
462
470
  var text = (value) => typeof value === "string" ? value : "";
463
471
  function oxlintRuleId(code) {
@@ -468,16 +476,17 @@ var TOOLS = {
468
476
  ruff: {
469
477
  events: ["edit", "verify"],
470
478
  args: (files) => ["check", "--output-format", "json", "--force-exclude", "--", ...files],
479
+ stdinArgs: (file) => ["check", "--output-format", "json", "--force-exclude", "--stdin-filename", file, "-"],
471
480
  parse(stdout, root) {
472
481
  const parsed = json("ruff", stdout);
473
482
  if (!Array.isArray(parsed)) throw new Error("ruff output is not a JSON array of diagnostics");
474
- const roots = rootsOf2(root);
483
+ const relative = repoRelativeTo(root);
475
484
  return parsed.filter(isObject2).map((result) => {
476
485
  const start = isObject2(result.location) ? result.location : {};
477
486
  const end = isObject2(result.end_location) ? result.end_location : {};
478
487
  const line = number(start.row, 1);
479
488
  return {
480
- file: relativeTo(roots, text(result.filename)),
489
+ file: relative(text(result.filename)),
481
490
  line,
482
491
  column: number(start.column, 1),
483
492
  endLine: number(end.row, line),
@@ -496,13 +505,13 @@ var TOOLS = {
496
505
  if (!isObject2(parsed) || !Array.isArray(parsed.diagnostics)) {
497
506
  throw new Error("oxlint output is not JSON with diagnostics");
498
507
  }
499
- const roots = rootsOf2(root);
508
+ const relative = repoRelativeTo(root);
500
509
  return parsed.diagnostics.filter(isObject2).map((diagnostic) => {
501
510
  const label = Array.isArray(diagnostic.labels) ? diagnostic.labels[0] : void 0;
502
511
  const span = isObject2(label) && isObject2(label.span) ? label.span : {};
503
512
  const line = number(span.line, 1);
504
513
  return {
505
- file: relativeTo(roots, text(diagnostic.filename)),
514
+ file: relative(text(diagnostic.filename)),
506
515
  line,
507
516
  column: number(span.column, 1),
508
517
  endLine: line,
@@ -517,17 +526,18 @@ var TOOLS = {
517
526
  // eslint is the slow one: spec §6 keeps it off the edit hook by default.
518
527
  events: ["verify"],
519
528
  args: (files) => ["--format=json", "--no-error-on-unmatched-pattern", "--", ...files],
529
+ stdinArgs: (file) => ["--format=json", "--stdin", "--stdin-filename", file],
520
530
  parse(stdout, root) {
521
531
  const parsed = json("eslint", stdout);
522
532
  if (!Array.isArray(parsed)) throw new Error("eslint output is not a JSON array of file results");
523
- const roots = rootsOf2(root);
533
+ const relative = repoRelativeTo(root);
524
534
  const findings = [];
525
535
  for (const file of parsed.filter(isObject2)) {
526
536
  const messages = Array.isArray(file.messages) ? file.messages : [];
527
537
  for (const message of messages.filter(isObject2)) {
528
538
  const line = number(message.line, 1);
529
539
  findings.push({
530
- file: relativeTo(roots, text(file.filePath)),
540
+ file: relative(text(file.filePath)),
531
541
  line,
532
542
  column: number(message.column, 1),
533
543
  endLine: number(message.endLine, line),
@@ -543,18 +553,6 @@ var TOOLS = {
543
553
  };
544
554
 
545
555
  // src/detectors/linter/detector.ts
546
- var exec2 = promisify2(execFile2);
547
- function sourceReader(cwd) {
548
- const cache = /* @__PURE__ */ new Map();
549
- return (file) => {
550
- let source = cache.get(file);
551
- if (source === void 0) {
552
- source = readSourceFile(cwd, file);
553
- cache.set(file, source);
554
- }
555
- return source;
556
- };
557
- }
558
556
  function excerpt(source, finding) {
559
557
  if (source === null) return "";
560
558
  const starts = lineStarts(source);
@@ -568,41 +566,45 @@ function excerpt(source, finding) {
568
566
  }
569
567
  return source.slice(from, Math.max(from, to)).replace(/\n$/, "");
570
568
  }
571
- async function runTool(tool, files, cwd, signal) {
569
+ async function runLinter(tool, files, cwd, signal) {
572
570
  const resolved = await resolveTool(tool, cwd);
573
571
  const args = [...resolved.prefix, ...TOOLS[tool].args(files)];
574
- let stdout;
575
- try {
576
- const result = await exec2(resolved.command, args, { cwd, signal, maxBuffer: 64 * 1024 * 1024 });
577
- stdout = result.stdout;
578
- } catch (error) {
579
- if (signal.aborted) throw error;
580
- if (isNotFound(error)) throw new Error(`${tool} is not installed`);
581
- const failure = error;
582
- if (typeof failure.stdout !== "string") throw error;
583
- stdout = failure.stdout;
584
- }
572
+ const stdout = await runTool(resolved.command, args, { cwd, signal, notFound: `${tool} is not installed` });
585
573
  return TOOLS[tool].parse(stdout, cwd);
586
574
  }
575
+ async function runLinterOnContent(tool, files, read, cwd, signal) {
576
+ const resolved = await resolveTool(tool, cwd);
577
+ const findings = [];
578
+ for (const file of files) {
579
+ const stdin = await read(file);
580
+ if (stdin === null) continue;
581
+ const args = [...resolved.prefix, ...TOOLS[tool].stdinArgs(file)];
582
+ const stdout = await runTool(resolved.command, args, { cwd, signal, notFound: `${tool} is not installed`, stdin });
583
+ findings.push(...TOOLS[tool].parse(stdout, cwd));
584
+ }
585
+ return findings;
586
+ }
587
587
  var linterDetector = {
588
588
  kind: "linter",
589
589
  schema: linterSchema,
590
590
  captures: () => ["message", "ruleId"],
591
591
  // A copy: compileRule stores this as the rule's stages, and module-level data must not escape into it.
592
592
  events: (config) => [...TOOLS[config.tool].events],
593
+ takesContent: (config) => TOOLS[config.tool].stdinArgs !== void 0,
593
594
  async run(input) {
594
595
  const result = { findings: [], errors: [] };
595
596
  const byTool = /* @__PURE__ */ new Map();
596
597
  for (const rule of input.rules) {
597
598
  byTool.set(rule.config.tool, [...byTool.get(rule.config.tool) ?? [], rule]);
598
599
  }
599
- const read = sourceReader(input.cwd);
600
+ const onDisk = input.fromDisk !== false;
601
+ const read = onDisk ? sourceReader(input.cwd) : input.read;
600
602
  await Promise.all(
601
603
  [...byTool].map(async ([tool, rules]) => {
602
604
  const files = [...new Set(rules.flatMap((rule) => rule.files))].sort();
603
605
  if (files.length === 0) return;
604
606
  try {
605
- const findings = await runTool(tool, files, input.cwd, input.signal);
607
+ const findings = onDisk ? await runLinter(tool, files, input.cwd, input.signal) : await runLinterOnContent(tool, files, read, input.cwd, input.signal);
606
608
  for (const finding of findings) {
607
609
  const match = {
608
610
  file: finding.file,
@@ -619,7 +621,7 @@ var linterDetector = {
619
621
  }
620
622
  }
621
623
  } catch (error) {
622
- if (input.signal.aborted) throw error;
624
+ if (pastDeadline(error, input)) throw error;
623
625
  const message = errorMessage(error);
624
626
  for (const rule of rules) result.errors.push({ rule: rule.id, message });
625
627
  }
@@ -722,17 +724,6 @@ var llmSchema = z4.object({
722
724
  }).strict();
723
725
 
724
726
  // src/detectors/llm/detector.ts
725
- function sourceReader2(cwd) {
726
- const cache = /* @__PURE__ */ new Map();
727
- return (file) => {
728
- let source = cache.get(file);
729
- if (source === void 0) {
730
- source = readSourceFile(cwd, file);
731
- cache.set(file, source);
732
- }
733
- return source;
734
- };
735
- }
736
727
  function changedLinesOf(changes, file) {
737
728
  const change = changes.get(file);
738
729
  if (change === void 0) return null;
@@ -824,6 +815,15 @@ var llmDetector = {
824
815
  // verify only by default (spec §6): a model call is far too slow for an edit hook. A rule opts
825
816
  // in with `stages: [edit, verify]`.
826
817
  events: () => ["verify"],
818
+ // The prompt carries the file's text, so content that is not on disk needs no scratch copy.
819
+ takesContent: () => true,
820
+ // Spec §6, Consent: an llm rule sends file contents to a third party and costs money per file.
821
+ // Everything the core does differently for it follows from these, not from the name "llm".
822
+ metered: true,
823
+ cost: (config) => `llm \xB7 ${config.model} \xB7 sends file contents to your provider`,
824
+ fileBudget: (settings) => ({ max: settings.llm.maxFilesPerVerify, setting: "llm.max_files_per_verify" }),
825
+ // A field trial measured a single haiku call on a 135-line file at 89 s against a 60 s default.
826
+ timeoutHint: "an llm rule on a large file can need 120000 or more",
827
827
  async run(input) {
828
828
  const result = { findings: [], errors: [] };
829
829
  const settings = input.settings.llm;
@@ -833,13 +833,14 @@ var llmDetector = {
833
833
  } catch (error) {
834
834
  return { findings: [], errors: [{ rule: null, message: errorMessage(error) }] };
835
835
  }
836
- const calls = await groupCalls(input.rules, input.changes, sourceReader2(input.cwd));
836
+ const read = input.fromDisk === false ? input.read : sourceReader(input.cwd);
837
+ const calls = await groupCalls(input.rules, input.changes, read);
837
838
  const outcomes = await Promise.all(
838
839
  calls.map(async (call) => {
839
840
  try {
840
841
  return { call, findings: await runCall(call, provider, settings, input) };
841
842
  } catch (error) {
842
- if (input.signal.aborted) throw error;
843
+ if (pastDeadline(error, input)) throw error;
843
844
  return { call, error };
844
845
  }
845
846
  })
@@ -923,6 +924,8 @@ var pathDetector = {
923
924
  captures: () => [],
924
925
  events: () => ["edit", "verify"],
925
926
  guards: true,
927
+ // The match is the file itself, and writing it is what the rule forbids.
928
+ wholeFile: true,
926
929
  run: perRule(async (rule, input) => {
927
930
  const matches = [];
928
931
  for (const file of rule.files) {
@@ -1046,7 +1049,9 @@ var common = z7.object({
1046
1049
  agent_id: z7.string().min(1).optional(),
1047
1050
  agent_type: z7.string().optional(),
1048
1051
  source: z7.string().optional(),
1049
- prompt: z7.string().optional()
1052
+ prompt: z7.string().optional(),
1053
+ tool_name: z7.string().optional(),
1054
+ tool_use_id: z7.string().min(1).optional()
1050
1055
  });
1051
1056
  var fileTool = z7.object({
1052
1057
  tool_name: z7.string(),
@@ -1076,7 +1081,9 @@ function parseClaudeCode(input) {
1076
1081
  agent_id: agentId,
1077
1082
  agent_type: agentType,
1078
1083
  source,
1079
- prompt
1084
+ prompt,
1085
+ tool_name: toolName,
1086
+ tool_use_id: toolUseId
1080
1087
  } = head.data;
1081
1088
  const session = agentId === void 0 ? { id } : { id, agentId };
1082
1089
  const result = (kind, files = [], completeRead) => ({
@@ -1084,6 +1091,23 @@ function parseClaudeCode(input) {
1084
1091
  event: kind === null ? null : { kind, files, cwd, session, ...completeRead === void 0 ? {} : { completeRead } },
1085
1092
  warmup: false
1086
1093
  });
1094
+ const shell = (kind, failed = false) => ({
1095
+ cwd,
1096
+ event: {
1097
+ kind,
1098
+ files: [],
1099
+ cwd,
1100
+ session,
1101
+ ...toolUseId === void 0 ? {} : { toolUseId },
1102
+ ...failed ? { failed } : {}
1103
+ },
1104
+ warmup: false
1105
+ });
1106
+ if (toolName === "Bash") {
1107
+ if (hook === "PreToolUse") return shell("shell-before");
1108
+ if (hook === "PostToolUse") return shell("shell-after");
1109
+ if (hook === "PostToolUseFailure") return shell("shell-after", true);
1110
+ }
1087
1111
  switch (hook) {
1088
1112
  case "PreToolUse": {
1089
1113
  const tool = fileTool.safeParse(input);
@@ -1125,7 +1149,9 @@ function parseClaudeCode(input) {
1125
1149
  return result(prompt?.startsWith("<task-notification>") ? null : "prompt");
1126
1150
  case "SessionStart":
1127
1151
  if (source === "compact") return result("reset");
1128
- return { ...result(null), warmup: source === "startup" || source === "resume" };
1152
+ if (source === "startup" || source === "resume") return { ...result("start"), warmup: true };
1153
+ if (source === "clear" || source === "fork") return result("start");
1154
+ return result(null);
1129
1155
  default:
1130
1156
  return result(null);
1131
1157
  }
@@ -1145,10 +1171,18 @@ function hookGroups(verifyMs) {
1145
1171
  { event: "PreToolUse", matcher: "Edit|Write", timeout: 5 },
1146
1172
  { event: "PostToolUse", matcher: "Read", timeout: 5 },
1147
1173
  { event: "PostToolUse", matcher: "Edit|Write", timeout: 5 },
1174
+ // Around every shell call: the working tree before and after it is how rulecast sees what a
1175
+ // command wrote. After can run an edit event; a command that fails fires the Failure hook instead.
1176
+ { event: "PreToolUse", matcher: "Bash", timeout: 5 },
1177
+ { event: "PostToolUse", matcher: "Bash", timeout: 5 },
1178
+ { event: "PostToolUseFailure", matcher: "Bash", timeout: 5 },
1148
1179
  { event: "Stop", timeout: verifyTimeout },
1149
1180
  { event: "SubagentStop", timeout: verifyTimeout },
1150
1181
  { event: "UserPromptSubmit", timeout: 5 },
1151
- { event: "SessionStart", matcher: "startup|resume|compact", timeout: 5 }
1182
+ { event: "SessionStart", matcher: "startup|resume|compact", timeout: 5 },
1183
+ // A group of its own rather than a wider matcher on the one above: an older install's group
1184
+ // would then no longer match, and upgrading it would leave two hooks firing on every startup.
1185
+ { event: "SessionStart", matcher: "clear|fork", timeout: 5 }
1152
1186
  ];
1153
1187
  }
1154
1188
  function installed(groups, matcher2) {
@@ -1162,18 +1196,38 @@ function hooksOf(settings) {
1162
1196
  if (!isObject3(hooks)) throw new SettingsError('"hooks" must be an object');
1163
1197
  return hooks;
1164
1198
  }
1165
- function mergeHooks(settings, command, verifyMs) {
1199
+ function mergeHooks(settings, command, verifyMs, keep) {
1166
1200
  const merged = { ...hooksOf(settings) };
1167
1201
  const added = [];
1202
+ const updated = [];
1168
1203
  for (const { event, matcher: matcher2, timeout } of hookGroups(verifyMs)) {
1169
1204
  const groups = merged[event] ?? [];
1170
1205
  if (!Array.isArray(groups)) throw new SettingsError(`"hooks.${event}" must be an array`);
1171
- if (installed(groups, matcher2)) continue;
1206
+ if (installed(groups, matcher2)) {
1207
+ let changed = false;
1208
+ const rewritten = groups.map((group) => {
1209
+ if (!isObject3(group) || group.matcher !== matcher2 || !Array.isArray(group.hooks)) return group;
1210
+ return {
1211
+ ...group,
1212
+ hooks: group.hooks.map((hook2) => {
1213
+ const current = hook2.command;
1214
+ if (!isRulecastHook(hook2) || current === command || current === keep) return hook2;
1215
+ changed = true;
1216
+ return { ...hook2, command };
1217
+ })
1218
+ };
1219
+ });
1220
+ if (changed) {
1221
+ merged[event] = rewritten;
1222
+ updated.push(matcher2 === void 0 ? event : `${event} (${matcher2})`);
1223
+ }
1224
+ continue;
1225
+ }
1172
1226
  const hook = { type: "command", command, timeout };
1173
1227
  merged[event] = [...groups, matcher2 === void 0 ? { hooks: [hook] } : { matcher: matcher2, hooks: [hook] }];
1174
1228
  added.push(matcher2 === void 0 ? event : `${event} (${matcher2})`);
1175
1229
  }
1176
- return { settings: { ...settings, hooks: merged }, added };
1230
+ return { settings: { ...settings, hooks: merged }, added, updated };
1177
1231
  }
1178
1232
  function removeHooks(settings) {
1179
1233
  const hooks = hooksOf(settings);
@@ -1206,9 +1260,12 @@ function removeHooks(settings) {
1206
1260
  // src/adapters/claude-code/adapter.ts
1207
1261
  var CONTEXT_LIMIT = 1e4;
1208
1262
  var CONTEXT_BUDGET = 9e3;
1209
- var BLOCK_PREAMBLE = "This project's rulecast rules (.rulecast-config.yaml) found problems in code changed in this session. Fix them before you finish.";
1263
+ var BLOCK_PREAMBLE = `This project's rulecast rules (.rulecast-config.yaml) found problems in code changed in this session. Fix them before you finish. If you believe a finding is wrong, say so to the user rather than changing .rulecast-config.yaml; add a "rulecast-ignore: <rule> <reason>" comment only if they agree.`;
1210
1264
  var CAP_PREAMBLE = "rulecast: the agent stopped with these findings unresolved (stop gate limit reached).";
1265
+ var SWEPT_PREAMBLE = "rulecast: files changed outside the agent's tool calls this session have findings (not blocking the agent).";
1211
1266
  var DENY_PREAMBLE = "This project's rulecast rules (.rulecast-config.yaml) refuse this write. Change what you are writing, or the file you are writing it to, and try again.";
1267
+ var NOT_INSTALLED = "rulecast is configured in this project but not installed. Run npm install (or pnpm install), then rulecast doctor.";
1268
+ var LOCAL_COMMAND = `B="$CLAUDE_PROJECT_DIR"/node_modules/.bin/rulecast; [ -x "$B" ] && exec "$B" hook claude-code; grep -q '"hook_event_name": *"SessionStart"' && printf '%s' '${JSON.stringify({ systemMessage: NOT_INSTALLED })}'; exit 0`;
1212
1269
  function cutNotice(delivery, event) {
1213
1270
  if (delivery.overflowPath !== null) {
1214
1271
  return `
@@ -1238,8 +1295,12 @@ var claudeCodeAdapter = {
1238
1295
  restoredFiles: 5,
1239
1296
  parse: parseClaudeCode,
1240
1297
  format(delivery, event, options) {
1241
- const text2 = renderAgentText(delivery, options);
1242
- if (text2 === "") return NONE;
1298
+ const text2 = renderAgentText(delivery, { ...options, notices: false, warnings: false });
1299
+ const problems = delivery.warnings.length === 0 ? [] : ["rulecast problems:", ...delivery.warnings.map((w) => ` - ${w}`)];
1300
+ const notices = [...delivery.notices ?? [], ...problems].join("\n");
1301
+ const userOnly = event.kind === "guard" ? NONE : json2({ systemMessage: notices });
1302
+ if (text2 === "") return notices === "" ? NONE : userOnly;
1303
+ const withNotices = (value) => notices === "" ? value : { ...value, systemMessage: notices };
1243
1304
  switch (event.kind) {
1244
1305
  case "guard":
1245
1306
  return json2({
@@ -1253,23 +1314,58 @@ ${text2}`, delivery, event)
1253
1314
  });
1254
1315
  case "touch":
1255
1316
  case "edit":
1256
- return json2({
1257
- hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: withinLimit(text2, delivery, event) }
1258
- });
1317
+ case "shell-after":
1318
+ return json2(
1319
+ withNotices({
1320
+ hookSpecificOutput: {
1321
+ // A command that failed fired PostToolUseFailure, which takes context only under its own name.
1322
+ hookEventName: event.failed === true ? "PostToolUseFailure" : "PostToolUse",
1323
+ additionalContext: withinLimit(text2, delivery, event)
1324
+ }
1325
+ })
1326
+ );
1259
1327
  case "reset":
1260
- return json2({
1261
- hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: withinLimit(text2, delivery, event) }
1262
- });
1328
+ return json2(
1329
+ withNotices({
1330
+ hookSpecificOutput: {
1331
+ hookEventName: "SessionStart",
1332
+ additionalContext: withinLimit(text2, delivery, event)
1333
+ }
1334
+ })
1335
+ );
1263
1336
  case "verify":
1264
1337
  if (delivery.stop === "block")
1265
- return json2({ decision: "block", reason: withinLimit(`${BLOCK_PREAMBLE}
1338
+ return json2(
1339
+ withNotices({ decision: "block", reason: withinLimit(`${BLOCK_PREAMBLE}
1266
1340
 
1267
- ${text2}`, delivery, event) });
1268
- if (delivery.stop === "capReached")
1269
- return json2({ systemMessage: withinLimit(`${CAP_PREAMBLE}
1341
+ ${text2}`, delivery, event) })
1342
+ );
1343
+ if (delivery.stop === "capReached") {
1344
+ const cap = withinLimit(`${CAP_PREAMBLE}
1270
1345
 
1271
- ${text2}`, delivery, event) });
1272
- return NONE;
1346
+ ${text2}`, delivery, event);
1347
+ return json2({ systemMessage: notices === "" ? cap : `${notices}
1348
+
1349
+ ${cap}` });
1350
+ }
1351
+ if ((delivery.swept ?? []).length > 0) {
1352
+ const swept = new Set(delivery.swept);
1353
+ const onlySwept = {
1354
+ ...emptyDelivery(),
1355
+ findings: delivery.findings.filter((finding) => swept.has(finding.file)),
1356
+ templates: delivery.templates,
1357
+ omitted: { ...delivery.omitted, findings: delivery.omitted.findings.filter((cut) => cut.swept === true) },
1358
+ swept: delivery.swept
1359
+ };
1360
+ const sweptText = renderAgentText(onlySwept, { ...options, notices: false, warnings: false });
1361
+ const outside = withinLimit(`${SWEPT_PREAMBLE}
1362
+
1363
+ ${sweptText}`, delivery, event);
1364
+ return json2({ systemMessage: notices === "" ? outside : `${notices}
1365
+
1366
+ ${outside}` });
1367
+ }
1368
+ return notices === "" ? NONE : json2({ systemMessage: notices });
1273
1369
  default:
1274
1370
  return NONE;
1275
1371
  }
@@ -1280,8 +1376,11 @@ ${text2}`, delivery, event) });
1280
1376
  { scope: "shared", file: ".claude/settings.json" },
1281
1377
  { scope: "personal", file: ".claude/settings.local.json" }
1282
1378
  ],
1283
- command: (local) => local ? '"$CLAUDE_PROJECT_DIR"/node_modules/.bin/rulecast hook claude-code' : "rulecast hook claude-code",
1284
- merge: mergeHooks,
1379
+ command: (local) => local ? LOCAL_COMMAND : "rulecast hook claude-code",
1380
+ // The guarded command is never rewritten to the bare one. It is right with or without
1381
+ // node_modules, and `install` in a clone that has not installed yet would otherwise swap the
1382
+ // committed command for one that exits 127 on every hook.
1383
+ merge: (settings, command, verifyMs) => mergeHooks(settings, command, verifyMs, LOCAL_COMMAND),
1285
1384
  remove: removeHooks
1286
1385
  }
1287
1386
  };
@@ -1342,6 +1441,7 @@ function renderBacklog(summary, options) {
1342
1441
  const rest = summary.files.length - shown.length;
1343
1442
  if (rest > 0) out.push(` \u2026and ${plural(rest, "more file")}`);
1344
1443
  }
1444
+ if (options.note === false) return out.join("\n");
1345
1445
  out.push(
1346
1446
  "",
1347
1447
  "This is the stock, not a rate. Enforcement on edits does not reduce it.",
@@ -1352,6 +1452,9 @@ function renderBacklog(summary, options) {
1352
1452
 
1353
1453
  export {
1354
1454
  createRegistry,
1455
+ runTool,
1456
+ repoRelativeTo,
1457
+ repoRelative,
1355
1458
  builtinDetectors,
1356
1459
  claudeCodeAdapter,
1357
1460
  ADAPTERS,