@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,1106 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * v2 steering evaluator.
6
+ *
7
+ * Assembles the per-tool_call pipeline on top of:
8
+ *
9
+ * - `unbash-walker` — AST parse + command extraction +
10
+ * wrapper expansion + per-ref walker
11
+ * state (cwd today; branch/others once
12
+ * plugins register them).
13
+ * - {@link matchesPatternOrFn} / {@link evaluateWhen} — shared
14
+ * predicate resolution (see
15
+ * `./evaluator-internals/predicates.ts`).
16
+ * - {@link extractOverride} — inline override-comment detection
17
+ * ported from v1 (see
18
+ * `./evaluator-internals/override.ts`).
19
+ * - {@link createExecCache} / {@link createFindEntries} — per-call
20
+ * exec memoization + session-entry
21
+ * filtering (see
22
+ * `./evaluator-internals/context.ts`).
23
+ *
24
+ * Public surface is deliberately small: {@link buildEvaluator} returns
25
+ * an {@link EvaluatorRuntime} whose sole method, {@link
26
+ * EvaluatorRuntime.evaluate}, drives one `tool_call` event through
27
+ * every applicable rule. Phase 3c wires it into the pi extension's
28
+ * `tool_call` listener.
29
+ *
30
+ * Rule ordering (per ADR "Precedence: first-wins everywhere"):
31
+ *
32
+ * 1. `config.rules` — user's top-level rules, first-match-wins.
33
+ * 2. `resolved.rules` — plugin-shipped rules (already deduped /
34
+ * disabled-filtered by the plugin merger).
35
+ *
36
+ * First rule that fires AND isn't overridden wins and returns a block.
37
+ *
38
+ * Internal shape: each applicable rule is fed to {@link
39
+ * evaluateCandidate}, the single predicate-chain used for every tool.
40
+ * Bash rules loop over extracted command refs (one candidate per ref);
41
+ * write / edit produce exactly one candidate. The per-tool axes of
42
+ * variation live in the {@link Candidate} input — the body of
43
+ * `evaluateCandidate` stays tool-agnostic.
44
+ */
45
+
46
+ import type {
47
+ ExtensionContext,
48
+ ToolCallEvent,
49
+ ToolCallEventResult,
50
+ } from "@earendil-works/pi-coding-agent";
51
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
52
+ import {
53
+ type CommandRef,
54
+ cwdTracker,
55
+ type EnvState,
56
+ envTracker,
57
+ expandWrapperCommands,
58
+ extractAllCommandsFromAST,
59
+ getBasename,
60
+ type Modifier,
61
+ parse as parseBash,
62
+ type Tracker,
63
+ type Word,
64
+ walk,
65
+ } from "@cad0p/unbash-walker";
66
+ import {
67
+ createAppendEntry,
68
+ createExecCache,
69
+ createFindEntries,
70
+ createSessionEntryCache,
71
+ type EvaluatorHost,
72
+ } from "./evaluator-internals/context.ts";
73
+ import { extractOverride } from "./evaluator-internals/override.ts";
74
+ import {
75
+ evaluateWhen,
76
+ matchesPattern,
77
+ matchesPatternOrFn,
78
+ validateWhenClauseShape,
79
+ } from "./evaluator-internals/predicates.ts";
80
+ import {
81
+ type SpeculativeEventsByRef,
82
+ synthesizeSpeculativeEntries,
83
+ } from "./evaluator-internals/speculative-synthesis.ts";
84
+ import { mergeObserversUserFirst } from "./internal/merge-observers.ts";
85
+ import { refToText } from "./internal/ref-text.ts";
86
+ import type { ResolvedPluginState } from "./plugin-merger.ts";
87
+ import { validateName } from "./plugin-merger.ts";
88
+ import type {
89
+ Observer,
90
+ PredicateContext,
91
+ PredicateToolInput,
92
+ Rule,
93
+ SteeringConfig,
94
+ WhenWalkerState,
95
+ } from "./schema.ts";
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Built-in trackers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Names of trackers the evaluator wires in directly (not via a plugin).
103
+ * `resolvePlugins` accepts this list as `knownBuiltinTrackers`: plugin
104
+ * `trackerExtensions` targeting these names are kept (so plugins can
105
+ * compose modifiers onto them) without emitting an `extension-orphan`
106
+ * diagnostic.
107
+ *
108
+ * All call sites (`buildSessionRuntime`, `loadHarness`,
109
+ * `loadSteeringConfig`, the `pi-steering list` CLI) import this
110
+ * constant so a future addition (e.g. an `argv` tracker) lights up
111
+ * uniformly across production and the test harness.
112
+ */
113
+ export const EVALUATOR_BUILTIN_TRACKERS = ["cwd", "env"] as const;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Public surface
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Runtime-facing evaluator handle. Phase 3c holds an instance per
121
+ * session and calls {@link evaluate} from the pi `tool_call`
122
+ * listener.
123
+ */
124
+ export interface EvaluatorRuntime {
125
+ /**
126
+ * Evaluate a single `tool_call` event against every rule in
127
+ * `config.rules` + `resolved.rules`. Returns:
128
+ * - `{ block: true, reason }` — a rule matched + wasn't overridden.
129
+ * - `undefined` — no rule fires; tool call proceeds.
130
+ */
131
+ evaluate(
132
+ event: ToolCallEvent,
133
+ ctx: ExtensionContext,
134
+ agentLoopIndex: number,
135
+ ): Promise<ToolCallEventResult | void>;
136
+ }
137
+
138
+ /**
139
+ * Construct an {@link EvaluatorRuntime}.
140
+ *
141
+ * Arguments:
142
+ * - `config` — the user-facing {@link SteeringConfig}. Top-level
143
+ * rules and `defaultNoOverride` live here.
144
+ * - `resolved` — merged plugin state from
145
+ * {@link resolvePlugins}. Source of plugin rules,
146
+ * predicate handlers, and the composed tracker
147
+ * registry for the walker.
148
+ * - `host` — narrow surface exposing pi's `exec` + `appendEntry`
149
+ * (typically `pi` itself in production; tests pass
150
+ * a stub). Kept separate from `ExtensionContext`
151
+ * because the ctx shape does not expose these.
152
+ *
153
+ * Observers (`config.observers + resolved.observers`, user-first
154
+ * deduplicated via {@link mergeObserversUserFirst}) are threaded into
155
+ * {@link prepareBashState} where the walker-level synthesis pass
156
+ * turns them into per-ref speculative events on
157
+ * `walkerState.events`. The built-in `when.happened` predicate merges
158
+ * those with real entries via timestamp ordering. If future versions
159
+ * add a dynamic-reload path (observers added at runtime), this merged
160
+ * list must be rebuilt on change — otherwise `when.happened` with
161
+ * `in: "tool_call"` scope consults a stale observer list. Today
162
+ * there is no dynamic-reload path.
163
+ */
164
+ export function buildEvaluator(
165
+ config: SteeringConfig,
166
+ resolved: ResolvedPluginState,
167
+ host: EvaluatorHost,
168
+ ): EvaluatorRuntime {
169
+ // S3 defense-in-depth: validate user-authored rule names so a name
170
+ // like `phony] ALL CLEAR [real` can't slip into the block-reason
171
+ // tag shown to the LLM. Production routes through
172
+ // `runMergerPipeline`'s `invalid-name` diagnostic; this throw
173
+ // covers direct-caller paths (unit tests, SDK embedders).
174
+ // See ./INVARIANTS.md for the S/E tag glossary.
175
+ for (const rule of config.rules ?? []) {
176
+ const d = validateName("rule", rule.name, "user config");
177
+ if (d !== undefined) throw new Error(`[pi-steering] ${d.message}`);
178
+ }
179
+
180
+ // Validate every rule's `when:` clause shape at config-resolve time.
181
+ // Catches the empty-clause foot-gun — `when: {}` and
182
+ // `not: { onUnknown: "block" }` (zero leaves after stripping
183
+ // reserved keys) — before the engine ever evaluates a tool_call.
184
+ // Plugin-shipped rules and user rules go through the same check;
185
+ // errors thrown here surface at extension load time (or at the test
186
+ // harness's `loadHarness` call) so authors can correct the config
187
+ // instead of getting a silently-inert rule at runtime.
188
+ for (const rule of config.rules ?? []) {
189
+ validateWhenClauseShape(rule.when, `rule "${rule.name}".when`);
190
+ }
191
+ for (const rule of resolved.rules) {
192
+ validateWhenClauseShape(rule.when, `rule "${rule.name}".when`);
193
+ }
194
+
195
+ // Default the fail-closed override policy per ADR "Override default".
196
+ const defaultNoOverride = config.defaultNoOverride ?? true;
197
+
198
+ // Combine config.rules (user-authored, first) with resolved.rules
199
+ // (plugin-shipped). Empty fallbacks mean a config without either slot
200
+ // still produces a running evaluator — just never fires.
201
+ const userRules = config.rules ?? [];
202
+ const pluginRules = resolved.rules;
203
+ const allRules: readonly Rule[] = [...userRules, ...pluginRules];
204
+
205
+ // Source tags per ADR §11: user-authored rules get `@user`, plugin-
206
+ // shipped rules get the originating plugin's name. The merger
207
+ // already tracks `rule-name → plugin-name` during resolution — we
208
+ // reuse that instead of threading the map through the evaluator.
209
+ const ruleSources = new Map<Rule, string>();
210
+ for (const rule of userRules) {
211
+ ruleSources.set(rule, "user");
212
+ }
213
+ for (const rule of pluginRules) {
214
+ ruleSources.set(rule, resolved.rulePluginOwners[rule.name] ?? "user");
215
+ }
216
+
217
+ // Compose the walker's tracker registry. Must always include `cwd`
218
+ // and `env` so the built-in `when.cwd` predicate + cd's env-aware
219
+ // resolution work — even if no plugin ships them. Plugins extending
220
+ // these with their own modifiers are honored via
221
+ // `resolved.composedTrackers.{cwd,env}` (the plugin merger already
222
+ // layered extensions on top of the plugin-declared trackers, if any).
223
+ //
224
+ // When no plugin registers a `cwd` tracker, we fall back to the
225
+ // built-in `cwdTracker` AND layer any `trackerModifiers.cwd`
226
+ // extensions onto it (the plugin merger preserves extensions
227
+ // targeting `"cwd"` on the caller's behalf via the
228
+ // `knownBuiltinTrackers` hint passed to `resolvePlugins`). Same
229
+ // pattern for `env` — lets a future plugin add e.g. `.envrc`-style
230
+ // env loading as a new modifier on the shared tracker without
231
+ // replacing it.
232
+ //
233
+ // Env goes in first so cd's modifier sees the current ref's env via
234
+ // the `allState` read. Walker iteration is registration-order
235
+ // stable (Object.keys on an object literal); the ordering is a soft
236
+ // guarantee good for the built-in composition.
237
+ const trackers: Record<string, Tracker<unknown>> = {
238
+ ...resolved.composedTrackers,
239
+ };
240
+ if (!("env" in trackers)) {
241
+ const extraEnvModifiers = resolved.trackerModifiers["env"];
242
+ trackers["env"] = composeBuiltinEnv(extraEnvModifiers) as Tracker<unknown>;
243
+ }
244
+ if (!("cwd" in trackers)) {
245
+ const extraCwdModifiers = resolved.trackerModifiers["cwd"];
246
+ trackers["cwd"] = composeBuiltinCwd(extraCwdModifiers) as Tracker<unknown>;
247
+ }
248
+
249
+ // Merge user + plugin observers (user-first dedup via the shared
250
+ // helper, same convention as the observer-dispatcher). The merged
251
+ // list feeds the walker-level synthesis pass in
252
+ // {@link prepareBashState}, where eligible observers contribute
253
+ // speculative `walkerState.events` entries the built-in
254
+ // `when.happened` predicate consults alongside real entries. Without
255
+ // the dedup, a shadowed plugin observer's `writes` could produce
256
+ // synthetic entries that never match a real dispatch, re-creating
257
+ // the infinite-loop risk the speculative pass was designed to avoid.
258
+ const allObservers = mergeObserversUserFirst(
259
+ config.observers ?? [],
260
+ resolved.observers,
261
+ );
262
+
263
+ return {
264
+ evaluate: (event, ctx, agentLoopIndex) =>
265
+ evaluateEvent(
266
+ event,
267
+ ctx,
268
+ agentLoopIndex,
269
+ allRules,
270
+ trackers,
271
+ resolved.predicates,
272
+ host,
273
+ defaultNoOverride,
274
+ ruleSources,
275
+ allObservers,
276
+ ),
277
+ };
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Per-event evaluation
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /**
285
+ * Layer a bucket of plugin-provided `{ basename -> Modifier[] }`
286
+ * extensions on top of the built-in {@link cwdTracker}, returning a
287
+ * fresh tracker so the built-in's `modifiers` map is never mutated.
288
+ *
289
+ * Used when no plugin registers a `cwd` tracker but plugins still
290
+ * want to add basename modifiers to the built-in one (e.g. the git
291
+ * plugin's `--git-dir=` handler). Mirrors the plugin-merger's
292
+ * `composeTracker` shape — kept local here because the merger's
293
+ * helper is private to that module and exposing it would force the
294
+ * merger to know about the built-in cwd tracker. Keeping the merger
295
+ * built-in-agnostic is worth the small duplication.
296
+ */
297
+ function composeBuiltinCwd(
298
+ extras: Record<string, Modifier<unknown>[]> | undefined,
299
+ ): Tracker<string> {
300
+ return composeBuiltin(cwdTracker, extras);
301
+ }
302
+
303
+ /**
304
+ * Layer a bucket of plugin-provided `{ basename -> Modifier[] }`
305
+ * extensions on top of the built-in {@link envTracker}, returning a
306
+ * fresh tracker so the built-in's `modifiers` map is never mutated.
307
+ *
308
+ * Parallels {@link composeBuiltinCwd}. Env extensions are a future
309
+ * surface — no plugin ships one today — but the composition is
310
+ * symmetric with cwd and costs one helper to keep both paths
311
+ * consistent when a plugin eventually wants to add e.g. `.envrc`-
312
+ * style env-loading under the same tracker.
313
+ */
314
+ function composeBuiltinEnv(
315
+ extras: Record<string, Modifier<unknown>[]> | undefined,
316
+ ): Tracker<EnvState> {
317
+ return composeBuiltin(envTracker, extras);
318
+ }
319
+
320
+ /**
321
+ * Generic tracker-extension compositor. Given a base tracker and a
322
+ * bucket of plugin-provided `{ basename -> Modifier[] }` extensions,
323
+ * returns a fresh tracker whose `modifiers` map fuses the two
324
+ * without mutating the base.
325
+ *
326
+ * Resolution rule per basename:
327
+ * - Base has none, extras has 1+: extras become the entry
328
+ * (unwrapped to a single Modifier when length is 1).
329
+ * - Base has one or many, extras has 1+: concatenated into an
330
+ * array ordered base-first, extras-after, so per-command
331
+ * overrides layer in the expected sequence.
332
+ *
333
+ * Used by {@link composeBuiltinCwd} and {@link composeBuiltinEnv}
334
+ * to fold `trackerExtensions.cwd` / `trackerExtensions.env` from
335
+ * plugin registrations onto the built-ins. Keeping this helper
336
+ * internal (not exported) lets the plugin-merger stay agnostic of
337
+ * which built-in trackers exist.
338
+ */
339
+ function composeBuiltin<T>(
340
+ baseTracker: Tracker<T>,
341
+ extras: Record<string, Modifier<unknown>[]> | undefined,
342
+ ): Tracker<T> {
343
+ if (!extras || Object.keys(extras).length === 0) return baseTracker;
344
+ const merged: Record<string, Modifier<T> | Modifier<T>[]> = {};
345
+ for (const [basename, mod] of Object.entries(baseTracker.modifiers)) {
346
+ merged[basename] = Array.isArray(mod) ? [...(mod as Modifier<T>[])] : mod;
347
+ }
348
+ for (const [basename, mods] of Object.entries(extras)) {
349
+ const existing = merged[basename];
350
+ const extrasTyped = mods as unknown as Modifier<T>[];
351
+ if (existing === undefined) {
352
+ merged[basename] =
353
+ extrasTyped.length === 1 ? extrasTyped[0]! : [...extrasTyped];
354
+ continue;
355
+ }
356
+ const existingList = Array.isArray(existing)
357
+ ? (existing as Modifier<T>[])
358
+ : [existing as Modifier<T>];
359
+ merged[basename] = [...existingList, ...extrasTyped];
360
+ }
361
+ return { ...baseTracker, modifiers: merged };
362
+ }
363
+
364
+ /**
365
+ * Walker-state snapshot per extracted bash command ref plus the
366
+ * stringified `basename + args` text for regex testing, the basename
367
+ * sugar, and the suffix `Word[]` for quote-aware structured access.
368
+ *
369
+ * Built once per tool_call (in {@link prepareBashState}) so N rules
370
+ * against M refs cost N×M regex tests — no N parses or N walks, and
371
+ * `basename` / `args` are computed once per ref rather than per rule.
372
+ */
373
+ interface BashRefState {
374
+ readonly ref: CommandRef;
375
+ readonly text: string;
376
+ readonly basename: string;
377
+ readonly args: readonly Word[];
378
+ readonly envAssignments: readonly Word[];
379
+ readonly walkerState: Readonly<WhenWalkerState>;
380
+ }
381
+
382
+ /**
383
+ * Prepare bash state for every rule to share: parse once, extract +
384
+ * expand wrappers once, walk trackers once, stringify each ref once.
385
+ *
386
+ * Also runs the walker-level speculative-entry synthesis pass and
387
+ * merges its output into each ref's walkerState under the reserved
388
+ * `events` key. The built-in `when.happened` predicate consults
389
+ * `ctx.walkerState.events[customType]` to unify real + speculative
390
+ * entries via timestamp ordering (see {@link evaluateHappened}).
391
+ */
392
+ function prepareBashState(
393
+ command: string,
394
+ sessionCwd: string,
395
+ trackers: Record<string, Tracker<unknown>>,
396
+ observers: readonly Observer[],
397
+ ): BashRefState[] {
398
+ const script = parseBash(command);
399
+ const extracted = extractAllCommandsFromAST(script, command);
400
+ const { commands: refs } = expandWrapperCommands(extracted);
401
+ const walkResult = walk(
402
+ script,
403
+ { cwd: sessionCwd } as Record<string, unknown>,
404
+ trackers,
405
+ refs,
406
+ );
407
+ const speculativeEvents: SpeculativeEventsByRef =
408
+ synthesizeSpeculativeEntries(refs, observers);
409
+ return refs.map((ref) => {
410
+ const trackerState = walkResult.get(ref) ?? {
411
+ cwd: sessionCwd,
412
+ env: new Map<string, string>(),
413
+ };
414
+ const events = speculativeEvents.get(ref) ?? {};
415
+ return {
416
+ ref,
417
+ text: refToText(ref),
418
+ basename: getBasename(ref),
419
+ // `node.suffix` is the quote-aware Word[] for the ref. Exposed
420
+ // to predicates via PredicateToolInput.args; the walker already
421
+ // parsed it so we just pass it through.
422
+ args: ref.node.suffix,
423
+ // `node.prefix` is unbash's AssignmentPrefix[] (shape:
424
+ // `{ text, name, value, ... }`). Project into Word[] so
425
+ // PredicateToolInput.envAssignments lines up with `.args` for
426
+ // plugin consumers — `.text` preserves the full "KEY=VALUE"
427
+ // source token (with quoting), and dynamic values like `A=$VAR`
428
+ // come through visibly in `.text` so callers can detect them.
429
+ envAssignments: ref.node.prefix.map<Word>((p) => ({
430
+ text: p.text,
431
+ value: p.text,
432
+ pos: p.pos,
433
+ end: p.end,
434
+ })),
435
+ // Merge tracker state with synthesized events so the built-in
436
+ // `happened` predicate can read `walkerState.events` without
437
+ // threading a separate context field. Trackers cannot name a
438
+ // dimension `"events"` — the plugin merger rejects that (see
439
+ // plugin-merger.ts). The merge is a shallow copy so the walker's
440
+ // state object stays untouched for future evaluations.
441
+ //
442
+ // The cast via `unknown` to `Readonly<WhenWalkerState>` is safe:
443
+ // buildEvaluator always registers `cwd` + `env` trackers, so every
444
+ // ref the walker yields carries both fields; the fallback literal
445
+ // above also supplies them. The schema interface's `readonly
446
+ // [key: string]: unknown` index signature tolerates the `events`
447
+ // key and any plugin-registered tracker slot. TypeScript's
448
+ // spread inference over `Record<string, unknown> | { cwd: string;
449
+ // env: Map<...> }` doesn't preserve the cwd/env shape through
450
+ // the spread, so the double cast is the minimum TS needs to
451
+ // accept a structure its inference widens away.
452
+ walkerState: {
453
+ ...trackerState,
454
+ events,
455
+ } as unknown as Readonly<WhenWalkerState>,
456
+ };
457
+ });
458
+ }
459
+
460
+ /**
461
+ * Compute the effective `noOverride` for a rule — rule-level explicit
462
+ * value wins, falling back to the config-level default (itself defaulted
463
+ * to fail-closed `true` per ADR).
464
+ */
465
+ function effectiveNoOverride(rule: Rule, defaultNoOverride: boolean): boolean {
466
+ return rule.noOverride ?? defaultNoOverride;
467
+ }
468
+
469
+ /**
470
+ * Format the block reason shown to the agent. Appends an override hint
471
+ * ONLY when the rule is overridable — rules with
472
+ * `noOverride: true` (or the fail-closed default) omit it to avoid
473
+ * advertising a nonexistent escape hatch.
474
+ *
475
+ * Source-tagged (per ADR §11): `[steering:<rule-name>@<source>] …`
476
+ * where `<source>` is the originating plugin name for plugin-shipped
477
+ * rules, or `user` for rules declared directly in the user's
478
+ * SteeringConfig.rules.
479
+ *
480
+ * Rule.reason accepts both a static string and a {@link ReasonFn}
481
+ * (D3 in pr5-tier-b-shell-var-tracker-spec.md). Function reasons
482
+ * receive the same {@link PredicateContext} the predicates saw;
483
+ * async returns are awaited before prefixing. A reason function
484
+ * that throws or rejects is logged via `console.warn` and replaced
485
+ * with a fail-safe fallback string — the block verdict still fires.
486
+ * The exact fallback text is a stable contract rule authors can
487
+ * detect in tests.
488
+ *
489
+ * Tag→body separator is paragraph-aware: when the resolved body
490
+ * contains a `\n\n` paragraph break, the tag is rendered on its own
491
+ * line (`${tag}\n\n${body}`) so subsequent paragraphs don't orphan
492
+ * visually from the source-tag prefix. Single-paragraph bodies keep
493
+ * the legacy single-space layout (`${tag} ${body}`) — backward-
494
+ * compatible for every reason that was single-paragraph before the
495
+ * paragraph-aware rendering shipped. Trigger is `\n\n` or its CRLF
496
+ * equivalent `\r\n\r\n` (defensive against bodies imported from
497
+ * Windows line-ending sources — CRLF templating layers, hand-typed
498
+ * Windows-IDE strings); a single `\n` inside an otherwise single-
499
+ * paragraph body keeps the single-space layout. The emitted
500
+ * separator is always normalized to `\n\n` regardless of which form
501
+ * triggered it.
502
+ *
503
+ * Body→override-hint separator mirrors the same paragraph-aware
504
+ * separator. Single-paragraph bodies keep the single-space prefix
505
+ * on the override hint (byte-identical to the pre-paragraph-aware
506
+ * rendering); multi-paragraph bodies promote the override hint to
507
+ * its own paragraph (`${body}\n\n${hint}`) so the safety paragraph
508
+ * stays visually standalone rather than running on into an inline
509
+ * "To override" sentence. Mirror docs on {@link Rule.reason}.
510
+ */
511
+ async function formatReason(
512
+ rule: Rule,
513
+ tool: "bash" | "write" | "edit",
514
+ noOverride: boolean,
515
+ source: string,
516
+ ctx: PredicateContext,
517
+ ): Promise<string> {
518
+ const tag = `[steering:${rule.name}@${source}]`;
519
+ const body = await resolveReasonBody(rule, source, ctx);
520
+ // Paragraph-aware tag separator — see function-level JSDoc for the
521
+ // contract; this line implements the trigger detection.
522
+ const multiPara = body.includes("\n\n") || body.includes("\r\n\r\n");
523
+ const separator = multiPara ? "\n\n" : " ";
524
+ if (noOverride) return `${tag}${separator}${body}`;
525
+ const leader = tool === "bash" ? "#" : "//";
526
+ const hint =
527
+ `To override, include a comment: ` +
528
+ `\`${leader} steering-override: ${rule.name} — <reason>\`.`;
529
+ return `${tag}${separator}${body}${separator}${hint}`;
530
+ }
531
+
532
+ /**
533
+ * Resolve the string body of a rule's reason field. Handles both
534
+ * variants of the discriminated union on {@link Rule.reason}:
535
+ *
536
+ * - `string` — returned as-is.
537
+ * - `ReasonFn` — invoked with `ctx`, awaited, returned. A
538
+ * synchronous throw or rejected promise is
539
+ * caught, logged to `console.warn` with the
540
+ * rule name + source prefix + error message
541
+ * + stack, and replaced with the fail-safe
542
+ * fallback body `(reason failed to format;
543
+ * see log)`. The wrapping in
544
+ * {@link formatReason} still adds the source
545
+ * tag, so the agent sees
546
+ * `[steering:<rule>@<source>] (reason failed
547
+ * to format; see log)` — an unambiguous
548
+ * signal of a broken reason fn that still
549
+ * doesn't leak the error message.
550
+ *
551
+ * The fallback behavior is part of the public contract per spec
552
+ * D3: a rule author CAN assert the exact text (e.g. in a test
553
+ * asserting the engine keeps the block verdict alive when the
554
+ * reason function intentionally throws as a smoke-test).
555
+ */
556
+ async function resolveReasonBody(
557
+ rule: Rule,
558
+ source: string,
559
+ ctx: PredicateContext,
560
+ ): Promise<string> {
561
+ if (typeof rule.reason === "string") return rule.reason;
562
+ try {
563
+ return await rule.reason(ctx);
564
+ } catch (err) {
565
+ const msg =
566
+ err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err);
567
+ console.warn(
568
+ `[pi-steering] Rule "${rule.name}"@${source}: reason function threw: ${msg}`,
569
+ );
570
+ return "(reason failed to format; see log)";
571
+ }
572
+ }
573
+
574
+ // ---------------------------------------------------------------------------
575
+ // Unified per-candidate evaluation
576
+ // ---------------------------------------------------------------------------
577
+
578
+ /**
579
+ * Per-tool_call state shared across every candidate and rule. One
580
+ * struct in place of the 6-argument bundle the prior shape threaded
581
+ * through both bash and write/edit call-sites.
582
+ *
583
+ * `exec` / `appendEntry` / `findEntries` are the closures the evaluator
584
+ * builds once per tool_call (see `./evaluator-internals/context.ts`).
585
+ * `appendEntry` auto-tags every write with the current
586
+ * `_agentLoopIndex`, including the `steering-override` audit entries
587
+ * written from the override-accepted path — so rules using
588
+ * `when.happened: { event: "steering-override", in: "agent_loop" }`
589
+ * can correctly filter override activity to the current agent loop.
590
+ *
591
+ * `host` is retained on the shared context for non-entry operations
592
+ * (currently only `exec` indirectly) and for tests that stub pi’s
593
+ * surface without having to re-shape every call-site.
594
+ */
595
+ interface SharedEvalContext {
596
+ readonly agentLoopIndex: number;
597
+ readonly predicates: ResolvedPluginState["predicates"];
598
+ readonly exec: PredicateContext["exec"];
599
+ readonly appendEntry: PredicateContext["appendEntry"];
600
+ readonly findEntries: PredicateContext["findEntries"];
601
+ readonly host: EvaluatorHost;
602
+ readonly defaultNoOverride: boolean;
603
+ /**
604
+ * Rule → source-name lookup for source-tagged block reasons
605
+ * (`[steering:<rule>@<source>]`). Keyed by Rule object identity so
606
+ * the same rule name appearing in multiple plugins still resolves
607
+ * unambiguously.
608
+ */
609
+ readonly ruleSources: ReadonlyMap<Rule, string>;
610
+ }
611
+
612
+ /**
613
+ * Single-candidate input for {@link evaluateCandidate}. The fields here
614
+ * are the sole per-tool axes of variation — the body of
615
+ * `evaluateCandidate` stays tool-agnostic.
616
+ *
617
+ * - `target` — string the rule's `pattern` / `requires` /
618
+ * `unless` test against (bash: basename + args
619
+ * for the current ref; write: content or path;
620
+ * edit: joined newText or path).
621
+ * - `cwd` — effective cwd seen by predicates via
622
+ * `ctx.cwd`. Per-ref for bash (walker-resolved);
623
+ * session cwd for write / edit.
624
+ * - `input` — the `PredicateToolInput` predicates see via
625
+ * `ctx.input`.
626
+ * - `overrideCarrier` — text scanned for `# steering-override: …`
627
+ * comments. Bash: the raw tool_call command;
628
+ * write: content; edit: joined newText.
629
+ * - `tool` — plain-string tool, drives the override-comment
630
+ * leader (`#` vs `//`) and the block reason.
631
+ * - `overrideEntryExtras` — extra fields merged into the
632
+ * `steering-override` audit entry
633
+ * (`command` for bash, `path` for
634
+ * write / edit).
635
+ */
636
+ interface Candidate {
637
+ readonly target: string;
638
+ readonly cwd: string;
639
+ readonly input: PredicateToolInput;
640
+ readonly overrideCarrier: string;
641
+ readonly tool: "bash" | "write" | "edit";
642
+ readonly overrideEntryExtras: Record<string, string>;
643
+ /**
644
+ * Walker state snapshot for this candidate. Bash candidates carry
645
+ * the per-ref walk result (including synthesized
646
+ * `events: Record<customType, SyntheticEntry[]>` under the reserved
647
+ * `events` key, populated by the walker-level speculative-entry
648
+ * synthesis pass); write / edit candidates leave it undefined (no
649
+ * walker ran).
650
+ */
651
+ readonly walkerState?: Readonly<WhenWalkerState>;
652
+ }
653
+
654
+ /**
655
+ * Outcome of `evaluateCandidate`:
656
+ * - {@link ToolCallEventResult} — rule fired + was NOT overridden.
657
+ * Caller returns this to stop
658
+ * evaluation for the whole event.
659
+ * - `"no-fire"` — rule didn't match this candidate.
660
+ * Caller continues to the next
661
+ * candidate (bash) or next rule
662
+ * (write / edit).
663
+ * - `"overridden"` — rule fired but an override comment
664
+ * was accepted + audit-logged.
665
+ * Caller moves to the next rule;
666
+ * for bash that also means stopping
667
+ * the ref loop (override covers the
668
+ * whole tool_call per v1 semantics).
669
+ */
670
+ type CandidateOutcome = ToolCallEventResult | "no-fire" | "overridden";
671
+
672
+ /**
673
+ * Run a rule's predicate chain (pattern → requires → unless → when).
674
+ * Returns the built {@link PredicateContext} when every predicate
675
+ * passes (rule fires), or `null` when the chain short-circuits to
676
+ * "no-fire" — **either** because a predicate legitimately rejected
677
+ * the candidate, **or** because a predicate threw.
678
+ *
679
+ * Throws are the S1 hardening: a predicate function (built-in or
680
+ * plugin-supplied) that throws synchronously or rejects asynchronously
681
+ * gets its error logged with the rule name + source and the rule is
682
+ * treated as NOT firing. Evaluation continues with the next rule.
683
+ *
684
+ * Why "does not fire" (vs "block" / "abort the whole evaluate"):
685
+ * - Mirrors the observer-dispatcher's per-observer isolation —
686
+ * one broken predicate must not poison the rest of the rule list.
687
+ * - A buggy predicate blocking everything would be worse UX than
688
+ * a buggy predicate silently failing — the block reason would
689
+ * leak the raw error message to the LLM (the pre-hardening
690
+ * behaviour). Top-level engine-throws still fail CLOSED; see
691
+ * {@link evaluateEvent}.
692
+ */
693
+ async function runPredicateChain(
694
+ rule: Rule,
695
+ cand: Candidate,
696
+ shared: SharedEvalContext,
697
+ ): Promise<PredicateContext | null> {
698
+ const source = shared.ruleSources.get(rule) ?? "user";
699
+ try {
700
+ // Pattern-miss is the common case; exit before allocating ctx.
701
+ if (!matchesPattern(rule.pattern, cand.target)) return null;
702
+
703
+ const ctx: PredicateContext = {
704
+ cwd: cand.cwd,
705
+ tool: cand.tool,
706
+ input: cand.input,
707
+ agentLoopIndex: shared.agentLoopIndex,
708
+ exec: shared.exec,
709
+ appendEntry: shared.appendEntry,
710
+ findEntries: shared.findEntries,
711
+ ...(cand.walkerState !== undefined
712
+ ? { walkerState: cand.walkerState }
713
+ : {}),
714
+ };
715
+
716
+ if (rule.requires !== undefined) {
717
+ const ok = await matchesPatternOrFn(rule.requires, cand.target, ctx);
718
+ if (!ok) return null;
719
+ }
720
+ if (rule.unless !== undefined) {
721
+ const ok = await matchesPatternOrFn(rule.unless, cand.target, ctx);
722
+ if (ok) return null;
723
+ }
724
+ const whenOk = await evaluateWhen(
725
+ rule.when,
726
+ { cwd: cand.cwd },
727
+ ctx,
728
+ shared.predicates,
729
+ rule.name,
730
+ source,
731
+ );
732
+ if (!whenOk) return null;
733
+
734
+ return ctx;
735
+ } catch (err) {
736
+ console.warn(
737
+ `[pi-steering] predicate threw for rule "${rule.name}"@${source}: ${formatError(err)}`,
738
+ );
739
+ return null;
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Evaluate one candidate against one rule. This is the single pipeline
745
+ * every tool funnels through — differences between bash, write, and
746
+ * edit live entirely in the {@link Candidate} input.
747
+ *
748
+ * Evaluation order (short-circuits on first failure):
749
+ *
750
+ * 1. `pattern` — required; if no match we exit before allocating
751
+ * the predicate context.
752
+ * 2. `requires` — optional AND.
753
+ * 3. `unless` — optional exemption.
754
+ * 4. `when` — clause tree (`cwd`, `not`, `condition`, plugin
755
+ * predicates).
756
+ *
757
+ * All four steps are wrapped in a try/catch via
758
+ * {@link runPredicateChain} — a throw is logged and treated as "rule
759
+ * did not fire". That way a buggy predicate neither short-circuits the
760
+ * whole rule list (a broken guardrail rule silently poisoning the
761
+ * rest) nor leaks its raw `error.message` back to the agent via a
762
+ * pi-level error tool_result.
763
+ *
764
+ * On rule fire, check for an override comment addressing the rule by
765
+ * name (unless the rule opts out of overrides). An accepted override
766
+ * logs a `steering-override` audit entry and returns `"overridden"`.
767
+ */
768
+ async function evaluateCandidate(
769
+ rule: Rule,
770
+ cand: Candidate,
771
+ shared: SharedEvalContext,
772
+ ): Promise<CandidateOutcome> {
773
+ const ctx = await runPredicateChain(rule, cand, shared);
774
+ if (ctx === null) return "no-fire";
775
+
776
+ // Rule fires. Check for override (if allowed) before committing to
777
+ // blocking.
778
+ const noOverride = effectiveNoOverride(rule, shared.defaultNoOverride);
779
+ if (!noOverride) {
780
+ const reason = extractOverride(cand.overrideCarrier, rule.name);
781
+ if (reason !== null) {
782
+ // Go through the wrapped `shared.appendEntry` so the
783
+ // `_agentLoopIndex` auto-tag lands on the audit entry. Rules
784
+ // using `when.happened: { event: "steering-override", in:
785
+ // "agent_loop" }` rely on the tag to filter overrides by the
786
+ // current loop; a direct `host.appendEntry` here would bypass
787
+ // the wrapper and leave the entry invisible to that predicate.
788
+ shared.appendEntry("steering-override", {
789
+ rule: rule.name,
790
+ reason,
791
+ ...cand.overrideEntryExtras,
792
+ timestamp: new Date().toISOString(),
793
+ });
794
+ return "overridden";
795
+ }
796
+ }
797
+
798
+ // Block is going to fire. Run the optional side-effect hook before
799
+ // returning the verdict — rules using `onFire` to self-mark (e.g.
800
+ // "write a session entry so my next attempt this agent loop passes")
801
+ // need the write to land before the agent sees the block. Override
802
+ // paths above already returned, so onFire is skipped when the rule
803
+ // was overridden; fail-closed defaults with no override comment fall
804
+ // through here normally.
805
+ //
806
+ // Fail-closed semantics on onFire errors: a sync throw or rejected
807
+ // promise is logged and SWALLOWED — the block still returns. The
808
+ // block decision already passed every predicate; a broken
809
+ // best-effort side effect must not silently invalidate it. Mirrors
810
+ // the observer-dispatcher's per-observer try/catch (observers are
811
+ // isolated for the same reason).
812
+ if (rule.onFire) {
813
+ try {
814
+ await rule.onFire(ctx);
815
+ } catch (err) {
816
+ console.warn(
817
+ `[pi-steering] onFire for rule "${rule.name}" threw: ${formatError(err)}`,
818
+ );
819
+ }
820
+ }
821
+
822
+ return {
823
+ block: true,
824
+ reason: await formatReason(
825
+ rule,
826
+ cand.tool,
827
+ noOverride,
828
+ shared.ruleSources.get(rule) ?? "user",
829
+ ctx,
830
+ ),
831
+ };
832
+ }
833
+
834
+ async function evaluateEvent(
835
+ event: ToolCallEvent,
836
+ ctx: ExtensionContext,
837
+ agentLoopIndex: number,
838
+ rules: readonly Rule[],
839
+ trackers: Record<string, Tracker<unknown>>,
840
+ predicates: ResolvedPluginState["predicates"],
841
+ host: EvaluatorHost,
842
+ defaultNoOverride: boolean,
843
+ ruleSources: ReadonlyMap<Rule, string>,
844
+ allObservers: readonly Observer[],
845
+ ): Promise<ToolCallEventResult | void> {
846
+ // Top-level fail-closed wrap (S1). If the engine's own scaffolding
847
+ // throws — parse errors, walker bugs, corrupted session JSONL, etc.
848
+ // — we block the tool AS A SAFETY MEASURE and tag the reason so the
849
+ // agent sees it came from the engine, not from a rule or plugin.
850
+ // Per-predicate throws are handled one level down in
851
+ // {@link runPredicateChain} (treated as "rule does not fire"); this
852
+ // outer wrap only catches throws OUTSIDE the per-rule try/catch.
853
+ try {
854
+ return await evaluateEventInner(
855
+ event,
856
+ ctx,
857
+ agentLoopIndex,
858
+ rules,
859
+ trackers,
860
+ predicates,
861
+ host,
862
+ defaultNoOverride,
863
+ ruleSources,
864
+ allObservers,
865
+ );
866
+ } catch (err) {
867
+ console.error(`[pi-steering] steering engine threw: ${formatError(err)}`);
868
+ return {
869
+ block: true,
870
+ reason:
871
+ "[steering:engine@internal] steering engine error; " +
872
+ "tool blocked as a safety measure",
873
+ };
874
+ }
875
+ }
876
+
877
+ async function evaluateEventInner(
878
+ event: ToolCallEvent,
879
+ ctx: ExtensionContext,
880
+ agentLoopIndex: number,
881
+ rules: readonly Rule[],
882
+ trackers: Record<string, Tracker<unknown>>,
883
+ predicates: ResolvedPluginState["predicates"],
884
+ host: EvaluatorHost,
885
+ defaultNoOverride: boolean,
886
+ ruleSources: ReadonlyMap<Rule, string>,
887
+ allObservers: readonly Observer[],
888
+ ): Promise<ToolCallEventResult | void> {
889
+ // Shared per-call closures: exec memoized by (cmd, args, cwd);
890
+ // findEntries reads the current session JSONL on demand; appendEntry
891
+ // auto-tags writes with `_agentLoopIndex` so rules using
892
+ // `when.happened` can filter by agent-loop scope.
893
+ //
894
+ // findEntries + appendEntry share a session-entry cache so a write
895
+ // performed by an earlier rule's onFire (or by the override-audit
896
+ // path) invalidates the cached read — later rules' when.happened
897
+ // predicates see the fresh write instead of a stale snapshot
898
+ // (S2/E1). The evaluator itself doesn't interleave writes with reads,
899
+ // but onFire + override-audit do.
900
+ const exec = createExecCache(host, ctx.cwd);
901
+ const entryCache = createSessionEntryCache();
902
+ const findEntries = createFindEntries(ctx, entryCache);
903
+ const appendEntry = createAppendEntry(host, agentLoopIndex, entryCache);
904
+
905
+ const shared: SharedEvalContext = {
906
+ agentLoopIndex,
907
+ predicates,
908
+ exec,
909
+ appendEntry,
910
+ findEntries,
911
+ host,
912
+ defaultNoOverride,
913
+ ruleSources,
914
+ };
915
+
916
+ // Bash state is lazy: non-bash rules don't pay for parse / walk.
917
+ let bashState: BashRefState[] | null = null;
918
+ const bashEvent = isToolCallEventType("bash", event) ? event : null;
919
+
920
+ // Edit events share `allNewText` across every field="content" rule.
921
+ // Computed lazily on the first edit rule so a config with only bash /
922
+ // write rules doesn't pay the join cost. `null` sentinel is safe
923
+ // because `edits` is always a non-null array on edit events.
924
+ const editEvent = isToolCallEventType("edit", event) ? event : null;
925
+ let editAllNewText: string | null = null;
926
+
927
+ for (const rule of rules) {
928
+ if (rule.tool !== event.toolName) continue;
929
+
930
+ if (rule.tool === "bash") {
931
+ if (!bashEvent) continue;
932
+ if (bashState === null) {
933
+ bashState = prepareBashState(
934
+ bashEvent.input.command,
935
+ ctx.cwd,
936
+ trackers,
937
+ allObservers,
938
+ );
939
+ }
940
+ const result = await evaluateBashRule(
941
+ rule,
942
+ bashEvent.input.command,
943
+ bashState,
944
+ shared,
945
+ );
946
+ if (result !== undefined) return result;
947
+ continue;
948
+ }
949
+
950
+ if (rule.tool === "write" && isToolCallEventType("write", event)) {
951
+ const target =
952
+ rule.field === "path" ? event.input.path : event.input.content;
953
+ const result = await evaluateWriteEditRule(
954
+ rule,
955
+ {
956
+ tool: "write",
957
+ path: event.input.path,
958
+ content: event.input.content,
959
+ // Shell env assignments don't apply to file-surface tools;
960
+ // shape as `[]` rather than `undefined` so plugin authors
961
+ // can treat the field uniformly across tools.
962
+ envAssignments: [],
963
+ },
964
+ target,
965
+ // override-comment scanned against content (the natural
966
+ // carrier for write override comments — v1 parity).
967
+ event.input.content,
968
+ event.input.path,
969
+ ctx.cwd,
970
+ shared,
971
+ );
972
+ if (result !== undefined) return result;
973
+ continue;
974
+ }
975
+
976
+ if (rule.tool === "edit" && editEvent) {
977
+ // Joined newText is needed as override carrier for EVERY edit
978
+ // rule plus as `target` for field="content" rules. Compute once
979
+ // per tool_call on the first edit rule, reuse for the rest.
980
+ if (editAllNewText === null) {
981
+ editAllNewText = editEvent.input.edits.map((e) => e.newText).join("\n");
982
+ }
983
+ const target =
984
+ rule.field === "path" ? editEvent.input.path : editAllNewText;
985
+ const result = await evaluateWriteEditRule(
986
+ rule,
987
+ {
988
+ tool: "edit",
989
+ path: editEvent.input.path,
990
+ edits: editEvent.input.edits,
991
+ // See the write branch above: `[]` for uniform shape.
992
+ envAssignments: [],
993
+ },
994
+ target,
995
+ editAllNewText,
996
+ editEvent.input.path,
997
+ ctx.cwd,
998
+ shared,
999
+ );
1000
+ if (result !== undefined) return result;
1001
+ }
1002
+ }
1003
+ return undefined;
1004
+ }
1005
+
1006
+ /**
1007
+ * Per-rule bash evaluation. Iterates every extracted command ref as
1008
+ * a {@link Candidate}. The first ref that fires the rule (pattern +
1009
+ * requires + unless + when) decides the verdict. Per v1 semantics, an
1010
+ * accepted override covers the whole tool_call — we stop scanning
1011
+ * further refs and hand control back to the caller.
1012
+ */
1013
+ async function evaluateBashRule(
1014
+ rule: Rule,
1015
+ rawCommand: string,
1016
+ state: BashRefState[],
1017
+ shared: SharedEvalContext,
1018
+ ): Promise<ToolCallEventResult | void> {
1019
+ for (const refState of state) {
1020
+ const cand: Candidate = {
1021
+ target: refState.text,
1022
+ cwd:
1023
+ typeof refState.walkerState["cwd"] === "string"
1024
+ ? (refState.walkerState["cwd"] as string)
1025
+ : "unknown",
1026
+ input: {
1027
+ tool: "bash",
1028
+ command: refState.text,
1029
+ basename: refState.basename,
1030
+ args: refState.args,
1031
+ envAssignments: refState.envAssignments,
1032
+ },
1033
+ overrideCarrier: rawCommand,
1034
+ tool: "bash",
1035
+ overrideEntryExtras: { command: rawCommand },
1036
+ walkerState: refState.walkerState,
1037
+ };
1038
+ const r = await evaluateCandidate(rule, cand, shared);
1039
+ if (r === "no-fire") continue;
1040
+ if (r === "overridden") return undefined; // v1: override covers whole tool_call
1041
+ return r;
1042
+ }
1043
+ return undefined;
1044
+ }
1045
+
1046
+ /**
1047
+ * Per-rule write / edit evaluation. Produces a single {@link Candidate}
1048
+ * and defers to {@link evaluateCandidate}.
1049
+ *
1050
+ * `target` is the pre-resolved string the rule's pattern tests against
1051
+ * — the caller computes it once per rule (reading `path` or the joined
1052
+ * `newText`), which lets edit tool_calls share the join across every
1053
+ * field="content" rule. `overrideCarrier` is the text scanned for
1054
+ * override comments (per v1 parity, content / joined newText even for
1055
+ * field="path" rules).
1056
+ */
1057
+ async function evaluateWriteEditRule(
1058
+ rule: Rule,
1059
+ input: PredicateToolInput,
1060
+ target: string,
1061
+ overrideCarrier: string,
1062
+ path: string,
1063
+ sessionCwd: string,
1064
+ shared: SharedEvalContext,
1065
+ ): Promise<ToolCallEventResult | void> {
1066
+ const cand: Candidate = {
1067
+ target,
1068
+ cwd: sessionCwd,
1069
+ input,
1070
+ overrideCarrier,
1071
+ tool: rule.tool as "write" | "edit",
1072
+ overrideEntryExtras: { path },
1073
+ };
1074
+ const r = await evaluateCandidate(rule, cand, shared);
1075
+ if (r === "no-fire" || r === "overridden") return undefined;
1076
+ return r;
1077
+ }
1078
+
1079
+ // ---------------------------------------------------------------------------
1080
+ // Error formatting
1081
+ // ---------------------------------------------------------------------------
1082
+
1083
+ /**
1084
+ * Format an unknown thrown value for a warning log. Shared across the
1085
+ * three places the evaluator catches throws:
1086
+ *
1087
+ * - per-predicate try/catch in {@link runPredicateChain} (S1).
1088
+ * - per-rule `onFire` try/catch in {@link evaluateCandidate}.
1089
+ * - top-level engine try/catch in {@link evaluateEvent}.
1090
+ *
1091
+ * Mirrors the observer-dispatcher's `formatError` so the log shape
1092
+ * stays consistent across the two hook surfaces: `message\nstack` for
1093
+ * proper Errors, best-effort JSON otherwise, falling through to
1094
+ * `String(err)`.
1095
+ */
1096
+ function formatError(err: unknown): string {
1097
+ if (err instanceof Error) return `${err.message}\n${err.stack ?? ""}`;
1098
+ try {
1099
+ return JSON.stringify(err);
1100
+ } catch {
1101
+ return String(err);
1102
+ }
1103
+ }
1104
+
1105
+ // Re-export supporting types for consumers embedding the evaluator.
1106
+ export type { EvaluatorHost } from "./evaluator-internals/context.ts";