@cad0p/pi-steering 0.1.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.
Files changed (246) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +29 -0
  3. package/README.md +920 -0
  4. package/dist/__test-helpers__.d.ts +95 -0
  5. package/dist/__test-helpers__.d.ts.map +1 -0
  6. package/dist/__test-helpers__.js +174 -0
  7. package/dist/__test-helpers__.js.map +1 -0
  8. package/dist/bin/pi-steering.d.ts +9 -0
  9. package/dist/bin/pi-steering.d.ts.map +1 -0
  10. package/dist/bin/pi-steering.js +506 -0
  11. package/dist/bin/pi-steering.js.map +1 -0
  12. package/dist/compat.d.ts +50 -0
  13. package/dist/compat.d.ts.map +1 -0
  14. package/dist/compat.js +180 -0
  15. package/dist/compat.js.map +1 -0
  16. package/dist/defaults.d.ts +61 -0
  17. package/dist/defaults.d.ts.map +1 -0
  18. package/dist/defaults.js +106 -0
  19. package/dist/defaults.js.map +1 -0
  20. package/dist/define-config.d.ts +235 -0
  21. package/dist/define-config.d.ts.map +1 -0
  22. package/dist/define-config.js +118 -0
  23. package/dist/define-config.js.map +1 -0
  24. package/dist/define-predicate.d.ts +49 -0
  25. package/dist/define-predicate.d.ts.map +1 -0
  26. package/dist/define-predicate.js +35 -0
  27. package/dist/define-predicate.js.map +1 -0
  28. package/dist/evaluator-internals/context.d.ts +186 -0
  29. package/dist/evaluator-internals/context.d.ts.map +1 -0
  30. package/dist/evaluator-internals/context.js +219 -0
  31. package/dist/evaluator-internals/context.js.map +1 -0
  32. package/dist/evaluator-internals/override.d.ts +19 -0
  33. package/dist/evaluator-internals/override.d.ts.map +1 -0
  34. package/dist/evaluator-internals/override.js +79 -0
  35. package/dist/evaluator-internals/override.js.map +1 -0
  36. package/dist/evaluator-internals/predicates.d.ts +152 -0
  37. package/dist/evaluator-internals/predicates.d.ts.map +1 -0
  38. package/dist/evaluator-internals/predicates.js +849 -0
  39. package/dist/evaluator-internals/predicates.js.map +1 -0
  40. package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
  41. package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
  42. package/dist/evaluator-internals/speculative-synthesis.js +101 -0
  43. package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
  44. package/dist/evaluator.d.ts +101 -0
  45. package/dist/evaluator.d.ts.map +1 -0
  46. package/dist/evaluator.js +714 -0
  47. package/dist/evaluator.js.map +1 -0
  48. package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
  49. package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
  50. package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
  51. package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
  52. package/dist/index.d.ts +42 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +93 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/internal/drop-unused-observers.d.ts +43 -0
  57. package/dist/internal/drop-unused-observers.d.ts.map +1 -0
  58. package/dist/internal/drop-unused-observers.js +50 -0
  59. package/dist/internal/drop-unused-observers.js.map +1 -0
  60. package/dist/internal/finalize-plugin-state.d.ts +22 -0
  61. package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
  62. package/dist/internal/finalize-plugin-state.js +14 -0
  63. package/dist/internal/finalize-plugin-state.js.map +1 -0
  64. package/dist/internal/merge-observers.d.ts +30 -0
  65. package/dist/internal/merge-observers.d.ts.map +1 -0
  66. package/dist/internal/merge-observers.js +27 -0
  67. package/dist/internal/merge-observers.js.map +1 -0
  68. package/dist/internal/pattern-utils.d.ts +33 -0
  69. package/dist/internal/pattern-utils.d.ts.map +1 -0
  70. package/dist/internal/pattern-utils.js +20 -0
  71. package/dist/internal/pattern-utils.js.map +1 -0
  72. package/dist/internal/ref-text.d.ts +11 -0
  73. package/dist/internal/ref-text.d.ts.map +1 -0
  74. package/dist/internal/ref-text.js +15 -0
  75. package/dist/internal/ref-text.js.map +1 -0
  76. package/dist/internal/session-runtime.d.ts +44 -0
  77. package/dist/internal/session-runtime.d.ts.map +1 -0
  78. package/dist/internal/session-runtime.js +143 -0
  79. package/dist/internal/session-runtime.js.map +1 -0
  80. package/dist/internal/watch-matcher.d.ts +56 -0
  81. package/dist/internal/watch-matcher.d.ts.map +1 -0
  82. package/dist/internal/watch-matcher.js +179 -0
  83. package/dist/internal/watch-matcher.js.map +1 -0
  84. package/dist/loader.d.ts +97 -0
  85. package/dist/loader.d.ts.map +1 -0
  86. package/dist/loader.js +527 -0
  87. package/dist/loader.js.map +1 -0
  88. package/dist/observer-dispatcher.d.ts +69 -0
  89. package/dist/observer-dispatcher.d.ts.map +1 -0
  90. package/dist/observer-dispatcher.js +184 -0
  91. package/dist/observer-dispatcher.js.map +1 -0
  92. package/dist/plugin-merger.d.ts +175 -0
  93. package/dist/plugin-merger.d.ts.map +1 -0
  94. package/dist/plugin-merger.js +450 -0
  95. package/dist/plugin-merger.js.map +1 -0
  96. package/dist/plugins/git/branch-tracker.d.ts +85 -0
  97. package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
  98. package/dist/plugins/git/branch-tracker.js +181 -0
  99. package/dist/plugins/git/branch-tracker.js.map +1 -0
  100. package/dist/plugins/git/cwd-extensions.d.ts +10 -0
  101. package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
  102. package/dist/plugins/git/cwd-extensions.js +155 -0
  103. package/dist/plugins/git/cwd-extensions.js.map +1 -0
  104. package/dist/plugins/git/git-ops.d.ts +89 -0
  105. package/dist/plugins/git/git-ops.d.ts.map +1 -0
  106. package/dist/plugins/git/git-ops.js +107 -0
  107. package/dist/plugins/git/git-ops.js.map +1 -0
  108. package/dist/plugins/git/index.d.ts +225 -0
  109. package/dist/plugins/git/index.d.ts.map +1 -0
  110. package/dist/plugins/git/index.js +52 -0
  111. package/dist/plugins/git/index.js.map +1 -0
  112. package/dist/plugins/git/predicates.d.ts +312 -0
  113. package/dist/plugins/git/predicates.d.ts.map +1 -0
  114. package/dist/plugins/git/predicates.js +580 -0
  115. package/dist/plugins/git/predicates.js.map +1 -0
  116. package/dist/plugins/git/rules.d.ts +209 -0
  117. package/dist/plugins/git/rules.d.ts.map +1 -0
  118. package/dist/plugins/git/rules.js +267 -0
  119. package/dist/plugins/git/rules.js.map +1 -0
  120. package/dist/schema.d.ts +1799 -0
  121. package/dist/schema.d.ts.map +1 -0
  122. package/dist/schema.js +4 -0
  123. package/dist/schema.js.map +1 -0
  124. package/dist/testing/index.d.ts +621 -0
  125. package/dist/testing/index.d.ts.map +1 -0
  126. package/dist/testing/index.js +747 -0
  127. package/dist/testing/index.js.map +1 -0
  128. package/examples/README.md +83 -0
  129. package/examples/combined-git-discipline/README.md +52 -0
  130. package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
  131. package/examples/combined-git-discipline/package.json +18 -0
  132. package/examples/combined-git-discipline/steering.json +27 -0
  133. package/examples/combined-git-discipline/steering.test.ts +42 -0
  134. package/examples/combined-git-discipline/steering.ts +64 -0
  135. package/examples/combined-git-discipline/tsconfig.json +7 -0
  136. package/examples/draft-prs-only/README.md +42 -0
  137. package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
  138. package/examples/draft-prs-only/package.json +18 -0
  139. package/examples/draft-prs-only/steering.json +12 -0
  140. package/examples/draft-prs-only/steering.test.ts +48 -0
  141. package/examples/draft-prs-only/steering.ts +36 -0
  142. package/examples/draft-prs-only/tsconfig.json +7 -0
  143. package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
  144. package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
  145. package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
  146. package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
  147. package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
  148. package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
  149. package/examples/force-push-strict/README.md +75 -0
  150. package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
  151. package/examples/force-push-strict/package.json +18 -0
  152. package/examples/force-push-strict/steering.json +12 -0
  153. package/examples/force-push-strict/steering.test.ts +51 -0
  154. package/examples/force-push-strict/steering.ts +54 -0
  155. package/examples/force-push-strict/tsconfig.json +7 -0
  156. package/examples/no-amend/README.md +52 -0
  157. package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
  158. package/examples/no-amend/package.json +18 -0
  159. package/examples/no-amend/steering.cwd-scoped.json +12 -0
  160. package/examples/no-amend/steering.json +11 -0
  161. package/examples/no-amend/steering.test.ts +49 -0
  162. package/examples/no-amend/steering.ts +38 -0
  163. package/examples/no-amend/tsconfig.json +7 -0
  164. package/examples/work-item-plugin/README.md +103 -0
  165. package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
  166. package/examples/work-item-plugin/package.json +19 -0
  167. package/examples/work-item-plugin/src/index.test.ts +168 -0
  168. package/examples/work-item-plugin/src/index.ts +122 -0
  169. package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
  170. package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
  171. package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
  172. package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
  173. package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
  174. package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
  175. package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
  176. package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
  177. package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
  178. package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
  179. package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
  180. package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
  181. package/examples/work-item-plugin/tsconfig.json +8 -0
  182. package/package.json +88 -0
  183. package/skills/steering-authoring/SKILL.md +102 -0
  184. package/src/INVARIANTS.md +116 -0
  185. package/src/__test-helpers__.ts +243 -0
  186. package/src/bin/pi-steering.test.ts +920 -0
  187. package/src/bin/pi-steering.ts +584 -0
  188. package/src/compat.test.ts +431 -0
  189. package/src/compat.ts +282 -0
  190. package/src/defaults.test.ts +585 -0
  191. package/src/defaults.ts +148 -0
  192. package/src/define-config.test.ts +888 -0
  193. package/src/define-config.ts +375 -0
  194. package/src/define-predicate.test.ts +70 -0
  195. package/src/define-predicate.ts +57 -0
  196. package/src/evaluator-internals/context.ts +290 -0
  197. package/src/evaluator-internals/override.test.ts +99 -0
  198. package/src/evaluator-internals/override.ts +83 -0
  199. package/src/evaluator-internals/predicates.ts +1037 -0
  200. package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
  201. package/src/evaluator-internals/speculative-synthesis.ts +199 -0
  202. package/src/evaluator.test.ts +6296 -0
  203. package/src/evaluator.ts +1106 -0
  204. package/src/factory-time-load.test.ts +630 -0
  205. package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
  206. package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
  207. package/src/index.test.ts +836 -0
  208. package/src/index.ts +229 -0
  209. package/src/internal/drop-unused-observers.test.ts +141 -0
  210. package/src/internal/drop-unused-observers.ts +81 -0
  211. package/src/internal/finalize-plugin-state.ts +43 -0
  212. package/src/internal/merge-observers.ts +51 -0
  213. package/src/internal/pattern-utils.test.ts +52 -0
  214. package/src/internal/pattern-utils.ts +38 -0
  215. package/src/internal/ref-text.test.ts +54 -0
  216. package/src/internal/ref-text.ts +17 -0
  217. package/src/internal/session-runtime.test.ts +629 -0
  218. package/src/internal/session-runtime.ts +201 -0
  219. package/src/internal/watch-matcher.test.ts +326 -0
  220. package/src/internal/watch-matcher.ts +200 -0
  221. package/src/jsdoc-propagation.test.ts +277 -0
  222. package/src/loader.test.ts +1098 -0
  223. package/src/loader.ts +581 -0
  224. package/src/not-block-onunknown.test.ts +554 -0
  225. package/src/observer-dispatcher.test.ts +939 -0
  226. package/src/observer-dispatcher.ts +290 -0
  227. package/src/per-predicate-typing.test.ts +361 -0
  228. package/src/plugin-merger.test.ts +790 -0
  229. package/src/plugin-merger.ts +598 -0
  230. package/src/plugins/git/README.md +485 -0
  231. package/src/plugins/git/branch-tracker.test.ts +281 -0
  232. package/src/plugins/git/branch-tracker.ts +187 -0
  233. package/src/plugins/git/cwd-extensions.test.ts +230 -0
  234. package/src/plugins/git/cwd-extensions.ts +160 -0
  235. package/src/plugins/git/git-ops.test.ts +397 -0
  236. package/src/plugins/git/git-ops.ts +173 -0
  237. package/src/plugins/git/index.ts +253 -0
  238. package/src/plugins/git/integration.test.ts +705 -0
  239. package/src/plugins/git/predicates.test.ts +1401 -0
  240. package/src/plugins/git/predicates.ts +693 -0
  241. package/src/plugins/git/rules.test.ts +1001 -0
  242. package/src/plugins/git/rules.ts +307 -0
  243. package/src/schema.test.ts +259 -0
  244. package/src/schema.ts +1963 -0
  245. package/src/testing/index.test.ts +1696 -0
  246. package/src/testing/index.ts +1472 -0
@@ -0,0 +1,714 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
4
+ import { cwdTracker, envTracker, expandWrapperCommands, extractAllCommandsFromAST, getBasename, parse as parseBash, walk, } from "@cad0p/unbash-walker";
5
+ import { createAppendEntry, createExecCache, createFindEntries, createSessionEntryCache, } from "./evaluator-internals/context.js";
6
+ import { extractOverride } from "./evaluator-internals/override.js";
7
+ import { evaluateWhen, matchesPattern, matchesPatternOrFn, validateWhenClauseShape, } from "./evaluator-internals/predicates.js";
8
+ import { synthesizeSpeculativeEntries, } from "./evaluator-internals/speculative-synthesis.js";
9
+ import { mergeObserversUserFirst } from "./internal/merge-observers.js";
10
+ import { refToText } from "./internal/ref-text.js";
11
+ import { validateName } from "./plugin-merger.js";
12
+ // ---------------------------------------------------------------------------
13
+ // Built-in trackers
14
+ // ---------------------------------------------------------------------------
15
+ /**
16
+ * Names of trackers the evaluator wires in directly (not via a plugin).
17
+ * `resolvePlugins` accepts this list as `knownBuiltinTrackers`: plugin
18
+ * `trackerExtensions` targeting these names are kept (so plugins can
19
+ * compose modifiers onto them) without emitting an `extension-orphan`
20
+ * diagnostic.
21
+ *
22
+ * All call sites (`buildSessionRuntime`, `loadHarness`,
23
+ * `loadSteeringConfig`, the `pi-steering list` CLI) import this
24
+ * constant so a future addition (e.g. an `argv` tracker) lights up
25
+ * uniformly across production and the test harness.
26
+ */
27
+ export const EVALUATOR_BUILTIN_TRACKERS = ["cwd", "env"];
28
+ /**
29
+ * Construct an {@link EvaluatorRuntime}.
30
+ *
31
+ * Arguments:
32
+ * - `config` — the user-facing {@link SteeringConfig}. Top-level
33
+ * rules and `defaultNoOverride` live here.
34
+ * - `resolved` — merged plugin state from
35
+ * {@link resolvePlugins}. Source of plugin rules,
36
+ * predicate handlers, and the composed tracker
37
+ * registry for the walker.
38
+ * - `host` — narrow surface exposing pi's `exec` + `appendEntry`
39
+ * (typically `pi` itself in production; tests pass
40
+ * a stub). Kept separate from `ExtensionContext`
41
+ * because the ctx shape does not expose these.
42
+ *
43
+ * Observers (`config.observers + resolved.observers`, user-first
44
+ * deduplicated via {@link mergeObserversUserFirst}) are threaded into
45
+ * {@link prepareBashState} where the walker-level synthesis pass
46
+ * turns them into per-ref speculative events on
47
+ * `walkerState.events`. The built-in `when.happened` predicate merges
48
+ * those with real entries via timestamp ordering. If future versions
49
+ * add a dynamic-reload path (observers added at runtime), this merged
50
+ * list must be rebuilt on change — otherwise `when.happened` with
51
+ * `in: "tool_call"` scope consults a stale observer list. Today
52
+ * there is no dynamic-reload path.
53
+ */
54
+ export function buildEvaluator(config, resolved, host) {
55
+ // S3 defense-in-depth: validate user-authored rule names so a name
56
+ // like `phony] ALL CLEAR [real` can't slip into the block-reason
57
+ // tag shown to the LLM. Production routes through
58
+ // `runMergerPipeline`'s `invalid-name` diagnostic; this throw
59
+ // covers direct-caller paths (unit tests, SDK embedders).
60
+ // See ./INVARIANTS.md for the S/E tag glossary.
61
+ for (const rule of config.rules ?? []) {
62
+ const d = validateName("rule", rule.name, "user config");
63
+ if (d !== undefined)
64
+ throw new Error(`[pi-steering] ${d.message}`);
65
+ }
66
+ // Validate every rule's `when:` clause shape at config-resolve time.
67
+ // Catches the empty-clause foot-gun — `when: {}` and
68
+ // `not: { onUnknown: "block" }` (zero leaves after stripping
69
+ // reserved keys) — before the engine ever evaluates a tool_call.
70
+ // Plugin-shipped rules and user rules go through the same check;
71
+ // errors thrown here surface at extension load time (or at the test
72
+ // harness's `loadHarness` call) so authors can correct the config
73
+ // instead of getting a silently-inert rule at runtime.
74
+ for (const rule of config.rules ?? []) {
75
+ validateWhenClauseShape(rule.when, `rule "${rule.name}".when`);
76
+ }
77
+ for (const rule of resolved.rules) {
78
+ validateWhenClauseShape(rule.when, `rule "${rule.name}".when`);
79
+ }
80
+ // Default the fail-closed override policy per ADR "Override default".
81
+ const defaultNoOverride = config.defaultNoOverride ?? true;
82
+ // Combine config.rules (user-authored, first) with resolved.rules
83
+ // (plugin-shipped). Empty fallbacks mean a config without either slot
84
+ // still produces a running evaluator — just never fires.
85
+ const userRules = config.rules ?? [];
86
+ const pluginRules = resolved.rules;
87
+ const allRules = [...userRules, ...pluginRules];
88
+ // Source tags per ADR §11: user-authored rules get `@user`, plugin-
89
+ // shipped rules get the originating plugin's name. The merger
90
+ // already tracks `rule-name → plugin-name` during resolution — we
91
+ // reuse that instead of threading the map through the evaluator.
92
+ const ruleSources = new Map();
93
+ for (const rule of userRules) {
94
+ ruleSources.set(rule, "user");
95
+ }
96
+ for (const rule of pluginRules) {
97
+ ruleSources.set(rule, resolved.rulePluginOwners[rule.name] ?? "user");
98
+ }
99
+ // Compose the walker's tracker registry. Must always include `cwd`
100
+ // and `env` so the built-in `when.cwd` predicate + cd's env-aware
101
+ // resolution work — even if no plugin ships them. Plugins extending
102
+ // these with their own modifiers are honored via
103
+ // `resolved.composedTrackers.{cwd,env}` (the plugin merger already
104
+ // layered extensions on top of the plugin-declared trackers, if any).
105
+ //
106
+ // When no plugin registers a `cwd` tracker, we fall back to the
107
+ // built-in `cwdTracker` AND layer any `trackerModifiers.cwd`
108
+ // extensions onto it (the plugin merger preserves extensions
109
+ // targeting `"cwd"` on the caller's behalf via the
110
+ // `knownBuiltinTrackers` hint passed to `resolvePlugins`). Same
111
+ // pattern for `env` — lets a future plugin add e.g. `.envrc`-style
112
+ // env loading as a new modifier on the shared tracker without
113
+ // replacing it.
114
+ //
115
+ // Env goes in first so cd's modifier sees the current ref's env via
116
+ // the `allState` read. Walker iteration is registration-order
117
+ // stable (Object.keys on an object literal); the ordering is a soft
118
+ // guarantee good for the built-in composition.
119
+ const trackers = {
120
+ ...resolved.composedTrackers,
121
+ };
122
+ if (!("env" in trackers)) {
123
+ const extraEnvModifiers = resolved.trackerModifiers["env"];
124
+ trackers["env"] = composeBuiltinEnv(extraEnvModifiers);
125
+ }
126
+ if (!("cwd" in trackers)) {
127
+ const extraCwdModifiers = resolved.trackerModifiers["cwd"];
128
+ trackers["cwd"] = composeBuiltinCwd(extraCwdModifiers);
129
+ }
130
+ // Merge user + plugin observers (user-first dedup via the shared
131
+ // helper, same convention as the observer-dispatcher). The merged
132
+ // list feeds the walker-level synthesis pass in
133
+ // {@link prepareBashState}, where eligible observers contribute
134
+ // speculative `walkerState.events` entries the built-in
135
+ // `when.happened` predicate consults alongside real entries. Without
136
+ // the dedup, a shadowed plugin observer's `writes` could produce
137
+ // synthetic entries that never match a real dispatch, re-creating
138
+ // the infinite-loop risk the speculative pass was designed to avoid.
139
+ const allObservers = mergeObserversUserFirst(config.observers ?? [], resolved.observers);
140
+ return {
141
+ evaluate: (event, ctx, agentLoopIndex) => evaluateEvent(event, ctx, agentLoopIndex, allRules, trackers, resolved.predicates, host, defaultNoOverride, ruleSources, allObservers),
142
+ };
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Per-event evaluation
146
+ // ---------------------------------------------------------------------------
147
+ /**
148
+ * Layer a bucket of plugin-provided `{ basename -> Modifier[] }`
149
+ * extensions on top of the built-in {@link cwdTracker}, returning a
150
+ * fresh tracker so the built-in's `modifiers` map is never mutated.
151
+ *
152
+ * Used when no plugin registers a `cwd` tracker but plugins still
153
+ * want to add basename modifiers to the built-in one (e.g. the git
154
+ * plugin's `--git-dir=` handler). Mirrors the plugin-merger's
155
+ * `composeTracker` shape — kept local here because the merger's
156
+ * helper is private to that module and exposing it would force the
157
+ * merger to know about the built-in cwd tracker. Keeping the merger
158
+ * built-in-agnostic is worth the small duplication.
159
+ */
160
+ function composeBuiltinCwd(extras) {
161
+ return composeBuiltin(cwdTracker, extras);
162
+ }
163
+ /**
164
+ * Layer a bucket of plugin-provided `{ basename -> Modifier[] }`
165
+ * extensions on top of the built-in {@link envTracker}, returning a
166
+ * fresh tracker so the built-in's `modifiers` map is never mutated.
167
+ *
168
+ * Parallels {@link composeBuiltinCwd}. Env extensions are a future
169
+ * surface — no plugin ships one today — but the composition is
170
+ * symmetric with cwd and costs one helper to keep both paths
171
+ * consistent when a plugin eventually wants to add e.g. `.envrc`-
172
+ * style env-loading under the same tracker.
173
+ */
174
+ function composeBuiltinEnv(extras) {
175
+ return composeBuiltin(envTracker, extras);
176
+ }
177
+ /**
178
+ * Generic tracker-extension compositor. Given a base tracker and a
179
+ * bucket of plugin-provided `{ basename -> Modifier[] }` extensions,
180
+ * returns a fresh tracker whose `modifiers` map fuses the two
181
+ * without mutating the base.
182
+ *
183
+ * Resolution rule per basename:
184
+ * - Base has none, extras has 1+: extras become the entry
185
+ * (unwrapped to a single Modifier when length is 1).
186
+ * - Base has one or many, extras has 1+: concatenated into an
187
+ * array ordered base-first, extras-after, so per-command
188
+ * overrides layer in the expected sequence.
189
+ *
190
+ * Used by {@link composeBuiltinCwd} and {@link composeBuiltinEnv}
191
+ * to fold `trackerExtensions.cwd` / `trackerExtensions.env` from
192
+ * plugin registrations onto the built-ins. Keeping this helper
193
+ * internal (not exported) lets the plugin-merger stay agnostic of
194
+ * which built-in trackers exist.
195
+ */
196
+ function composeBuiltin(baseTracker, extras) {
197
+ if (!extras || Object.keys(extras).length === 0)
198
+ return baseTracker;
199
+ const merged = {};
200
+ for (const [basename, mod] of Object.entries(baseTracker.modifiers)) {
201
+ merged[basename] = Array.isArray(mod) ? [...mod] : mod;
202
+ }
203
+ for (const [basename, mods] of Object.entries(extras)) {
204
+ const existing = merged[basename];
205
+ const extrasTyped = mods;
206
+ if (existing === undefined) {
207
+ merged[basename] =
208
+ extrasTyped.length === 1 ? extrasTyped[0] : [...extrasTyped];
209
+ continue;
210
+ }
211
+ const existingList = Array.isArray(existing)
212
+ ? existing
213
+ : [existing];
214
+ merged[basename] = [...existingList, ...extrasTyped];
215
+ }
216
+ return { ...baseTracker, modifiers: merged };
217
+ }
218
+ /**
219
+ * Prepare bash state for every rule to share: parse once, extract +
220
+ * expand wrappers once, walk trackers once, stringify each ref once.
221
+ *
222
+ * Also runs the walker-level speculative-entry synthesis pass and
223
+ * merges its output into each ref's walkerState under the reserved
224
+ * `events` key. The built-in `when.happened` predicate consults
225
+ * `ctx.walkerState.events[customType]` to unify real + speculative
226
+ * entries via timestamp ordering (see {@link evaluateHappened}).
227
+ */
228
+ function prepareBashState(command, sessionCwd, trackers, observers) {
229
+ const script = parseBash(command);
230
+ const extracted = extractAllCommandsFromAST(script, command);
231
+ const { commands: refs } = expandWrapperCommands(extracted);
232
+ const walkResult = walk(script, { cwd: sessionCwd }, trackers, refs);
233
+ const speculativeEvents = synthesizeSpeculativeEntries(refs, observers);
234
+ return refs.map((ref) => {
235
+ const trackerState = walkResult.get(ref) ?? {
236
+ cwd: sessionCwd,
237
+ env: new Map(),
238
+ };
239
+ const events = speculativeEvents.get(ref) ?? {};
240
+ return {
241
+ ref,
242
+ text: refToText(ref),
243
+ basename: getBasename(ref),
244
+ // `node.suffix` is the quote-aware Word[] for the ref. Exposed
245
+ // to predicates via PredicateToolInput.args; the walker already
246
+ // parsed it so we just pass it through.
247
+ args: ref.node.suffix,
248
+ // `node.prefix` is unbash's AssignmentPrefix[] (shape:
249
+ // `{ text, name, value, ... }`). Project into Word[] so
250
+ // PredicateToolInput.envAssignments lines up with `.args` for
251
+ // plugin consumers — `.text` preserves the full "KEY=VALUE"
252
+ // source token (with quoting), and dynamic values like `A=$VAR`
253
+ // come through visibly in `.text` so callers can detect them.
254
+ envAssignments: ref.node.prefix.map((p) => ({
255
+ text: p.text,
256
+ value: p.text,
257
+ pos: p.pos,
258
+ end: p.end,
259
+ })),
260
+ // Merge tracker state with synthesized events so the built-in
261
+ // `happened` predicate can read `walkerState.events` without
262
+ // threading a separate context field. Trackers cannot name a
263
+ // dimension `"events"` — the plugin merger rejects that (see
264
+ // plugin-merger.ts). The merge is a shallow copy so the walker's
265
+ // state object stays untouched for future evaluations.
266
+ //
267
+ // The cast via `unknown` to `Readonly<WhenWalkerState>` is safe:
268
+ // buildEvaluator always registers `cwd` + `env` trackers, so every
269
+ // ref the walker yields carries both fields; the fallback literal
270
+ // above also supplies them. The schema interface's `readonly
271
+ // [key: string]: unknown` index signature tolerates the `events`
272
+ // key and any plugin-registered tracker slot. TypeScript's
273
+ // spread inference over `Record<string, unknown> | { cwd: string;
274
+ // env: Map<...> }` doesn't preserve the cwd/env shape through
275
+ // the spread, so the double cast is the minimum TS needs to
276
+ // accept a structure its inference widens away.
277
+ walkerState: {
278
+ ...trackerState,
279
+ events,
280
+ },
281
+ };
282
+ });
283
+ }
284
+ /**
285
+ * Compute the effective `noOverride` for a rule — rule-level explicit
286
+ * value wins, falling back to the config-level default (itself defaulted
287
+ * to fail-closed `true` per ADR).
288
+ */
289
+ function effectiveNoOverride(rule, defaultNoOverride) {
290
+ return rule.noOverride ?? defaultNoOverride;
291
+ }
292
+ /**
293
+ * Format the block reason shown to the agent. Appends an override hint
294
+ * ONLY when the rule is overridable — rules with
295
+ * `noOverride: true` (or the fail-closed default) omit it to avoid
296
+ * advertising a nonexistent escape hatch.
297
+ *
298
+ * Source-tagged (per ADR §11): `[steering:<rule-name>@<source>] …`
299
+ * where `<source>` is the originating plugin name for plugin-shipped
300
+ * rules, or `user` for rules declared directly in the user's
301
+ * SteeringConfig.rules.
302
+ *
303
+ * Rule.reason accepts both a static string and a {@link ReasonFn}
304
+ * (D3 in pr5-tier-b-shell-var-tracker-spec.md). Function reasons
305
+ * receive the same {@link PredicateContext} the predicates saw;
306
+ * async returns are awaited before prefixing. A reason function
307
+ * that throws or rejects is logged via `console.warn` and replaced
308
+ * with a fail-safe fallback string — the block verdict still fires.
309
+ * The exact fallback text is a stable contract rule authors can
310
+ * detect in tests.
311
+ *
312
+ * Tag→body separator is paragraph-aware: when the resolved body
313
+ * contains a `\n\n` paragraph break, the tag is rendered on its own
314
+ * line (`${tag}\n\n${body}`) so subsequent paragraphs don't orphan
315
+ * visually from the source-tag prefix. Single-paragraph bodies keep
316
+ * the legacy single-space layout (`${tag} ${body}`) — backward-
317
+ * compatible for every reason that was single-paragraph before the
318
+ * paragraph-aware rendering shipped. Trigger is `\n\n` or its CRLF
319
+ * equivalent `\r\n\r\n` (defensive against bodies imported from
320
+ * Windows line-ending sources — CRLF templating layers, hand-typed
321
+ * Windows-IDE strings); a single `\n` inside an otherwise single-
322
+ * paragraph body keeps the single-space layout. The emitted
323
+ * separator is always normalized to `\n\n` regardless of which form
324
+ * triggered it.
325
+ *
326
+ * Body→override-hint separator mirrors the same paragraph-aware
327
+ * separator. Single-paragraph bodies keep the single-space prefix
328
+ * on the override hint (byte-identical to the pre-paragraph-aware
329
+ * rendering); multi-paragraph bodies promote the override hint to
330
+ * its own paragraph (`${body}\n\n${hint}`) so the safety paragraph
331
+ * stays visually standalone rather than running on into an inline
332
+ * "To override" sentence. Mirror docs on {@link Rule.reason}.
333
+ */
334
+ async function formatReason(rule, tool, noOverride, source, ctx) {
335
+ const tag = `[steering:${rule.name}@${source}]`;
336
+ const body = await resolveReasonBody(rule, source, ctx);
337
+ // Paragraph-aware tag separator — see function-level JSDoc for the
338
+ // contract; this line implements the trigger detection.
339
+ const multiPara = body.includes("\n\n") || body.includes("\r\n\r\n");
340
+ const separator = multiPara ? "\n\n" : " ";
341
+ if (noOverride)
342
+ return `${tag}${separator}${body}`;
343
+ const leader = tool === "bash" ? "#" : "//";
344
+ const hint = `To override, include a comment: ` +
345
+ `\`${leader} steering-override: ${rule.name} — <reason>\`.`;
346
+ return `${tag}${separator}${body}${separator}${hint}`;
347
+ }
348
+ /**
349
+ * Resolve the string body of a rule's reason field. Handles both
350
+ * variants of the discriminated union on {@link Rule.reason}:
351
+ *
352
+ * - `string` — returned as-is.
353
+ * - `ReasonFn` — invoked with `ctx`, awaited, returned. A
354
+ * synchronous throw or rejected promise is
355
+ * caught, logged to `console.warn` with the
356
+ * rule name + source prefix + error message
357
+ * + stack, and replaced with the fail-safe
358
+ * fallback body `(reason failed to format;
359
+ * see log)`. The wrapping in
360
+ * {@link formatReason} still adds the source
361
+ * tag, so the agent sees
362
+ * `[steering:<rule>@<source>] (reason failed
363
+ * to format; see log)` — an unambiguous
364
+ * signal of a broken reason fn that still
365
+ * doesn't leak the error message.
366
+ *
367
+ * The fallback behavior is part of the public contract per spec
368
+ * D3: a rule author CAN assert the exact text (e.g. in a test
369
+ * asserting the engine keeps the block verdict alive when the
370
+ * reason function intentionally throws as a smoke-test).
371
+ */
372
+ async function resolveReasonBody(rule, source, ctx) {
373
+ if (typeof rule.reason === "string")
374
+ return rule.reason;
375
+ try {
376
+ return await rule.reason(ctx);
377
+ }
378
+ catch (err) {
379
+ const msg = err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err);
380
+ console.warn(`[pi-steering] Rule "${rule.name}"@${source}: reason function threw: ${msg}`);
381
+ return "(reason failed to format; see log)";
382
+ }
383
+ }
384
+ /**
385
+ * Run a rule's predicate chain (pattern → requires → unless → when).
386
+ * Returns the built {@link PredicateContext} when every predicate
387
+ * passes (rule fires), or `null` when the chain short-circuits to
388
+ * "no-fire" — **either** because a predicate legitimately rejected
389
+ * the candidate, **or** because a predicate threw.
390
+ *
391
+ * Throws are the S1 hardening: a predicate function (built-in or
392
+ * plugin-supplied) that throws synchronously or rejects asynchronously
393
+ * gets its error logged with the rule name + source and the rule is
394
+ * treated as NOT firing. Evaluation continues with the next rule.
395
+ *
396
+ * Why "does not fire" (vs "block" / "abort the whole evaluate"):
397
+ * - Mirrors the observer-dispatcher's per-observer isolation —
398
+ * one broken predicate must not poison the rest of the rule list.
399
+ * - A buggy predicate blocking everything would be worse UX than
400
+ * a buggy predicate silently failing — the block reason would
401
+ * leak the raw error message to the LLM (the pre-hardening
402
+ * behaviour). Top-level engine-throws still fail CLOSED; see
403
+ * {@link evaluateEvent}.
404
+ */
405
+ async function runPredicateChain(rule, cand, shared) {
406
+ const source = shared.ruleSources.get(rule) ?? "user";
407
+ try {
408
+ // Pattern-miss is the common case; exit before allocating ctx.
409
+ if (!matchesPattern(rule.pattern, cand.target))
410
+ return null;
411
+ const ctx = {
412
+ cwd: cand.cwd,
413
+ tool: cand.tool,
414
+ input: cand.input,
415
+ agentLoopIndex: shared.agentLoopIndex,
416
+ exec: shared.exec,
417
+ appendEntry: shared.appendEntry,
418
+ findEntries: shared.findEntries,
419
+ ...(cand.walkerState !== undefined
420
+ ? { walkerState: cand.walkerState }
421
+ : {}),
422
+ };
423
+ if (rule.requires !== undefined) {
424
+ const ok = await matchesPatternOrFn(rule.requires, cand.target, ctx);
425
+ if (!ok)
426
+ return null;
427
+ }
428
+ if (rule.unless !== undefined) {
429
+ const ok = await matchesPatternOrFn(rule.unless, cand.target, ctx);
430
+ if (ok)
431
+ return null;
432
+ }
433
+ const whenOk = await evaluateWhen(rule.when, { cwd: cand.cwd }, ctx, shared.predicates, rule.name, source);
434
+ if (!whenOk)
435
+ return null;
436
+ return ctx;
437
+ }
438
+ catch (err) {
439
+ console.warn(`[pi-steering] predicate threw for rule "${rule.name}"@${source}: ${formatError(err)}`);
440
+ return null;
441
+ }
442
+ }
443
+ /**
444
+ * Evaluate one candidate against one rule. This is the single pipeline
445
+ * every tool funnels through — differences between bash, write, and
446
+ * edit live entirely in the {@link Candidate} input.
447
+ *
448
+ * Evaluation order (short-circuits on first failure):
449
+ *
450
+ * 1. `pattern` — required; if no match we exit before allocating
451
+ * the predicate context.
452
+ * 2. `requires` — optional AND.
453
+ * 3. `unless` — optional exemption.
454
+ * 4. `when` — clause tree (`cwd`, `not`, `condition`, plugin
455
+ * predicates).
456
+ *
457
+ * All four steps are wrapped in a try/catch via
458
+ * {@link runPredicateChain} — a throw is logged and treated as "rule
459
+ * did not fire". That way a buggy predicate neither short-circuits the
460
+ * whole rule list (a broken guardrail rule silently poisoning the
461
+ * rest) nor leaks its raw `error.message` back to the agent via a
462
+ * pi-level error tool_result.
463
+ *
464
+ * On rule fire, check for an override comment addressing the rule by
465
+ * name (unless the rule opts out of overrides). An accepted override
466
+ * logs a `steering-override` audit entry and returns `"overridden"`.
467
+ */
468
+ async function evaluateCandidate(rule, cand, shared) {
469
+ const ctx = await runPredicateChain(rule, cand, shared);
470
+ if (ctx === null)
471
+ return "no-fire";
472
+ // Rule fires. Check for override (if allowed) before committing to
473
+ // blocking.
474
+ const noOverride = effectiveNoOverride(rule, shared.defaultNoOverride);
475
+ if (!noOverride) {
476
+ const reason = extractOverride(cand.overrideCarrier, rule.name);
477
+ if (reason !== null) {
478
+ // Go through the wrapped `shared.appendEntry` so the
479
+ // `_agentLoopIndex` auto-tag lands on the audit entry. Rules
480
+ // using `when.happened: { event: "steering-override", in:
481
+ // "agent_loop" }` rely on the tag to filter overrides by the
482
+ // current loop; a direct `host.appendEntry` here would bypass
483
+ // the wrapper and leave the entry invisible to that predicate.
484
+ shared.appendEntry("steering-override", {
485
+ rule: rule.name,
486
+ reason,
487
+ ...cand.overrideEntryExtras,
488
+ timestamp: new Date().toISOString(),
489
+ });
490
+ return "overridden";
491
+ }
492
+ }
493
+ // Block is going to fire. Run the optional side-effect hook before
494
+ // returning the verdict — rules using `onFire` to self-mark (e.g.
495
+ // "write a session entry so my next attempt this agent loop passes")
496
+ // need the write to land before the agent sees the block. Override
497
+ // paths above already returned, so onFire is skipped when the rule
498
+ // was overridden; fail-closed defaults with no override comment fall
499
+ // through here normally.
500
+ //
501
+ // Fail-closed semantics on onFire errors: a sync throw or rejected
502
+ // promise is logged and SWALLOWED — the block still returns. The
503
+ // block decision already passed every predicate; a broken
504
+ // best-effort side effect must not silently invalidate it. Mirrors
505
+ // the observer-dispatcher's per-observer try/catch (observers are
506
+ // isolated for the same reason).
507
+ if (rule.onFire) {
508
+ try {
509
+ await rule.onFire(ctx);
510
+ }
511
+ catch (err) {
512
+ console.warn(`[pi-steering] onFire for rule "${rule.name}" threw: ${formatError(err)}`);
513
+ }
514
+ }
515
+ return {
516
+ block: true,
517
+ reason: await formatReason(rule, cand.tool, noOverride, shared.ruleSources.get(rule) ?? "user", ctx),
518
+ };
519
+ }
520
+ async function evaluateEvent(event, ctx, agentLoopIndex, rules, trackers, predicates, host, defaultNoOverride, ruleSources, allObservers) {
521
+ // Top-level fail-closed wrap (S1). If the engine's own scaffolding
522
+ // throws — parse errors, walker bugs, corrupted session JSONL, etc.
523
+ // — we block the tool AS A SAFETY MEASURE and tag the reason so the
524
+ // agent sees it came from the engine, not from a rule or plugin.
525
+ // Per-predicate throws are handled one level down in
526
+ // {@link runPredicateChain} (treated as "rule does not fire"); this
527
+ // outer wrap only catches throws OUTSIDE the per-rule try/catch.
528
+ try {
529
+ return await evaluateEventInner(event, ctx, agentLoopIndex, rules, trackers, predicates, host, defaultNoOverride, ruleSources, allObservers);
530
+ }
531
+ catch (err) {
532
+ console.error(`[pi-steering] steering engine threw: ${formatError(err)}`);
533
+ return {
534
+ block: true,
535
+ reason: "[steering:engine@internal] steering engine error; " +
536
+ "tool blocked as a safety measure",
537
+ };
538
+ }
539
+ }
540
+ async function evaluateEventInner(event, ctx, agentLoopIndex, rules, trackers, predicates, host, defaultNoOverride, ruleSources, allObservers) {
541
+ // Shared per-call closures: exec memoized by (cmd, args, cwd);
542
+ // findEntries reads the current session JSONL on demand; appendEntry
543
+ // auto-tags writes with `_agentLoopIndex` so rules using
544
+ // `when.happened` can filter by agent-loop scope.
545
+ //
546
+ // findEntries + appendEntry share a session-entry cache so a write
547
+ // performed by an earlier rule's onFire (or by the override-audit
548
+ // path) invalidates the cached read — later rules' when.happened
549
+ // predicates see the fresh write instead of a stale snapshot
550
+ // (S2/E1). The evaluator itself doesn't interleave writes with reads,
551
+ // but onFire + override-audit do.
552
+ const exec = createExecCache(host, ctx.cwd);
553
+ const entryCache = createSessionEntryCache();
554
+ const findEntries = createFindEntries(ctx, entryCache);
555
+ const appendEntry = createAppendEntry(host, agentLoopIndex, entryCache);
556
+ const shared = {
557
+ agentLoopIndex,
558
+ predicates,
559
+ exec,
560
+ appendEntry,
561
+ findEntries,
562
+ host,
563
+ defaultNoOverride,
564
+ ruleSources,
565
+ };
566
+ // Bash state is lazy: non-bash rules don't pay for parse / walk.
567
+ let bashState = null;
568
+ const bashEvent = isToolCallEventType("bash", event) ? event : null;
569
+ // Edit events share `allNewText` across every field="content" rule.
570
+ // Computed lazily on the first edit rule so a config with only bash /
571
+ // write rules doesn't pay the join cost. `null` sentinel is safe
572
+ // because `edits` is always a non-null array on edit events.
573
+ const editEvent = isToolCallEventType("edit", event) ? event : null;
574
+ let editAllNewText = null;
575
+ for (const rule of rules) {
576
+ if (rule.tool !== event.toolName)
577
+ continue;
578
+ if (rule.tool === "bash") {
579
+ if (!bashEvent)
580
+ continue;
581
+ if (bashState === null) {
582
+ bashState = prepareBashState(bashEvent.input.command, ctx.cwd, trackers, allObservers);
583
+ }
584
+ const result = await evaluateBashRule(rule, bashEvent.input.command, bashState, shared);
585
+ if (result !== undefined)
586
+ return result;
587
+ continue;
588
+ }
589
+ if (rule.tool === "write" && isToolCallEventType("write", event)) {
590
+ const target = rule.field === "path" ? event.input.path : event.input.content;
591
+ const result = await evaluateWriteEditRule(rule, {
592
+ tool: "write",
593
+ path: event.input.path,
594
+ content: event.input.content,
595
+ // Shell env assignments don't apply to file-surface tools;
596
+ // shape as `[]` rather than `undefined` so plugin authors
597
+ // can treat the field uniformly across tools.
598
+ envAssignments: [],
599
+ }, target,
600
+ // override-comment scanned against content (the natural
601
+ // carrier for write override comments — v1 parity).
602
+ event.input.content, event.input.path, ctx.cwd, shared);
603
+ if (result !== undefined)
604
+ return result;
605
+ continue;
606
+ }
607
+ if (rule.tool === "edit" && editEvent) {
608
+ // Joined newText is needed as override carrier for EVERY edit
609
+ // rule plus as `target` for field="content" rules. Compute once
610
+ // per tool_call on the first edit rule, reuse for the rest.
611
+ if (editAllNewText === null) {
612
+ editAllNewText = editEvent.input.edits.map((e) => e.newText).join("\n");
613
+ }
614
+ const target = rule.field === "path" ? editEvent.input.path : editAllNewText;
615
+ const result = await evaluateWriteEditRule(rule, {
616
+ tool: "edit",
617
+ path: editEvent.input.path,
618
+ edits: editEvent.input.edits,
619
+ // See the write branch above: `[]` for uniform shape.
620
+ envAssignments: [],
621
+ }, target, editAllNewText, editEvent.input.path, ctx.cwd, shared);
622
+ if (result !== undefined)
623
+ return result;
624
+ }
625
+ }
626
+ return undefined;
627
+ }
628
+ /**
629
+ * Per-rule bash evaluation. Iterates every extracted command ref as
630
+ * a {@link Candidate}. The first ref that fires the rule (pattern +
631
+ * requires + unless + when) decides the verdict. Per v1 semantics, an
632
+ * accepted override covers the whole tool_call — we stop scanning
633
+ * further refs and hand control back to the caller.
634
+ */
635
+ async function evaluateBashRule(rule, rawCommand, state, shared) {
636
+ for (const refState of state) {
637
+ const cand = {
638
+ target: refState.text,
639
+ cwd: typeof refState.walkerState["cwd"] === "string"
640
+ ? refState.walkerState["cwd"]
641
+ : "unknown",
642
+ input: {
643
+ tool: "bash",
644
+ command: refState.text,
645
+ basename: refState.basename,
646
+ args: refState.args,
647
+ envAssignments: refState.envAssignments,
648
+ },
649
+ overrideCarrier: rawCommand,
650
+ tool: "bash",
651
+ overrideEntryExtras: { command: rawCommand },
652
+ walkerState: refState.walkerState,
653
+ };
654
+ const r = await evaluateCandidate(rule, cand, shared);
655
+ if (r === "no-fire")
656
+ continue;
657
+ if (r === "overridden")
658
+ return undefined; // v1: override covers whole tool_call
659
+ return r;
660
+ }
661
+ return undefined;
662
+ }
663
+ /**
664
+ * Per-rule write / edit evaluation. Produces a single {@link Candidate}
665
+ * and defers to {@link evaluateCandidate}.
666
+ *
667
+ * `target` is the pre-resolved string the rule's pattern tests against
668
+ * — the caller computes it once per rule (reading `path` or the joined
669
+ * `newText`), which lets edit tool_calls share the join across every
670
+ * field="content" rule. `overrideCarrier` is the text scanned for
671
+ * override comments (per v1 parity, content / joined newText even for
672
+ * field="path" rules).
673
+ */
674
+ async function evaluateWriteEditRule(rule, input, target, overrideCarrier, path, sessionCwd, shared) {
675
+ const cand = {
676
+ target,
677
+ cwd: sessionCwd,
678
+ input,
679
+ overrideCarrier,
680
+ tool: rule.tool,
681
+ overrideEntryExtras: { path },
682
+ };
683
+ const r = await evaluateCandidate(rule, cand, shared);
684
+ if (r === "no-fire" || r === "overridden")
685
+ return undefined;
686
+ return r;
687
+ }
688
+ // ---------------------------------------------------------------------------
689
+ // Error formatting
690
+ // ---------------------------------------------------------------------------
691
+ /**
692
+ * Format an unknown thrown value for a warning log. Shared across the
693
+ * three places the evaluator catches throws:
694
+ *
695
+ * - per-predicate try/catch in {@link runPredicateChain} (S1).
696
+ * - per-rule `onFire` try/catch in {@link evaluateCandidate}.
697
+ * - top-level engine try/catch in {@link evaluateEvent}.
698
+ *
699
+ * Mirrors the observer-dispatcher's `formatError` so the log shape
700
+ * stays consistent across the two hook surfaces: `message\nstack` for
701
+ * proper Errors, best-effort JSON otherwise, falling through to
702
+ * `String(err)`.
703
+ */
704
+ function formatError(err) {
705
+ if (err instanceof Error)
706
+ return `${err.message}\n${err.stack ?? ""}`;
707
+ try {
708
+ return JSON.stringify(err);
709
+ }
710
+ catch {
711
+ return String(err);
712
+ }
713
+ }
714
+ //# sourceMappingURL=evaluator.js.map