@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,1037 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Predicate evaluators for the v2 engine.
6
+ *
7
+ * Three public entry points:
8
+ *
9
+ * - {@link matchesPatternOrFn} — resolves `pattern` / `requires` /
10
+ * `unless` values against a target
11
+ * string.
12
+ * - {@link evaluateWhen} — walks a {@link TopLevelWhenClause} tree,
13
+ * dispatching built-in (`cwd`, `not`,
14
+ * `condition`) + plugin-registered
15
+ * predicates.
16
+ * - {@link UnknownPredicateError} — thrown when a {@link TopLevelWhenClause} names a
17
+ * predicate nobody registered. Kept as
18
+ * a named error so callers can catch
19
+ * it by type; the message includes the
20
+ * offending key.
21
+ *
22
+ * The walker's `cwdTracker.unknown` sentinel is `"unknown"`. That's the
23
+ * string we compare against for `onUnknown` policy application on the
24
+ * built-in `cwd` predicate. Plugin-registered trackers emit their own
25
+ * unknown sentinels; handling those is the plugin handler's job.
26
+ */
27
+
28
+ import { isPattern } from "../internal/pattern-utils.ts";
29
+ import type {
30
+ Pattern,
31
+ PredicateContext,
32
+ PredicateFn,
33
+ PredicateHandler,
34
+ PredicateModifiers,
35
+ PredicateVerdict,
36
+ ReservedPredicateKey,
37
+ TopLevelWhenClause,
38
+ TopLevelWhenClauseNoRecurse,
39
+ } from "../schema.ts";
40
+ import { AGENT_LOOP_INDEX_KEY } from "./context.ts";
41
+ import type { SyntheticEntry } from "./speculative-synthesis.ts";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Reserved predicate keys (runtime mirror of `ReservedPredicateKey`)
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Runtime list of predicate keys that plugins are NOT allowed to
49
+ * register. Mirrors the type-level {@link ReservedPredicateKey} from
50
+ * `schema.ts`. Adding a new modifier to {@link PredicateModifiers} OR a
51
+ * new operator field requires updating both this list and the matching
52
+ * type union; the {@link reservedPredicateKeysCoverReservedTypes}
53
+ * sync-pinning test in `evaluator.test.ts` fails when the two drift.
54
+ *
55
+ * The engine (in the plugin merger) throws at plugin-registration time
56
+ * if a plugin attempts a reserved key, with a concrete error message
57
+ * pointing the plugin author at the collision and suggesting an
58
+ * alternative name.
59
+ */
60
+ export const RESERVED_PREDICATE_KEYS = [
61
+ // Operator fields (must mirror schema.ts's `OperatorField` union).
62
+ "not",
63
+ // Modifier keys (must mirror `keyof PredicateModifiers`).
64
+ "onUnknown",
65
+ ] as const satisfies readonly ReservedPredicateKey[];
66
+
67
+ /**
68
+ * Type-level assertion that {@link RESERVED_PREDICATE_KEYS} covers
69
+ * every member of {@link ReservedPredicateKey}. If a future modifier or
70
+ * operator is added without updating the runtime list, the
71
+ * `_RESERVED_PREDICATE_KEYS_COVERS_TYPE` constant fails to typecheck
72
+ * (assignability is in the wrong direction). The constant is
73
+ * `_`-prefixed and never read at runtime; its sole job is to fail
74
+ * compilation when the two surfaces drift.
75
+ */
76
+ type _ReservedKeyCoverage =
77
+ ReservedPredicateKey extends (typeof RESERVED_PREDICATE_KEYS)[number]
78
+ ? true
79
+ : false;
80
+ const _RESERVED_PREDICATE_KEYS_COVERS_TYPE: _ReservedKeyCoverage = true;
81
+ void _RESERVED_PREDICATE_KEYS_COVERS_TYPE;
82
+
83
+ /**
84
+ * Whether a string key is reserved (cannot be used as a plugin
85
+ * predicate name). Used by the plugin merger and by
86
+ * {@link validateWhenClauseShape} when computing the leaf-key set of
87
+ * a `not:` block.
88
+ */
89
+ export function isReservedPredicateKey(
90
+ key: string,
91
+ ): key is ReservedPredicateKey {
92
+ return (RESERVED_PREDICATE_KEYS as readonly string[]).includes(key);
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // When-clause shape validation (config-resolve time)
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Throws if a `when:` or `not:` block contains no predicate-leaf keys
101
+ * after stripping modifier keys.
102
+ *
103
+ * Catches three foot-guns at config-resolve time so the engine never
104
+ * has to silently skip a malformed clause:
105
+ * - `when: {}` — zero keys.
106
+ * - `not: { onUnknown: "block" }` — one key, but it's a modifier; no
107
+ * leaves means the block has nothing to evaluate.
108
+ * - `when: { not: { not: ... } }` — nested `not:` inside a not-block,
109
+ * authored via JSON load or `as any` escape hatch (the type-level
110
+ * ban via {@link TopLevelWhenClauseNoRecurse} catches authoring-
111
+ * time mistakes; the same recursion catches the JSON / `as any`
112
+ * escape hatch).
113
+ *
114
+ * The `not:` operator field itself counts as a leaf at the outer
115
+ * `when:` level (it produces a verdict via Kleene composition of the
116
+ * inner not-block); only modifier keys are stripped. Built-in
117
+ * non-registry keys (`condition`, `happened`, `cwd`) count as leaves;
118
+ * plugin-registered predicates count as leaves regardless of whether
119
+ * the plugin is currently loaded — the unknown-predicate check fires
120
+ * later via {@link UnknownPredicateError}.
121
+ *
122
+ * Recurses into the `not:` block to enforce the same shape there.
123
+ *
124
+ * `path` describes the call site for error messages, e.g.
125
+ * `'rule "no-main-commit".when'` or `'rule "no-git-worktree".when.not'`.
126
+ */
127
+ export function validateWhenClauseShape(
128
+ block: TopLevelWhenClause<string> | undefined,
129
+ path: string,
130
+ ): void {
131
+ if (block === undefined) return;
132
+ let leafKeys = 0;
133
+ for (const key of Object.keys(block)) {
134
+ const v = (block as Record<string, unknown>)[key];
135
+ if (v === undefined) continue;
136
+ // Strip modifier keys only — the operator field `not:` produces a
137
+ // verdict via Kleene composition of the inner not-block, so it
138
+ // counts as a leaf for the outer level's leaf-count.
139
+ if (isModifierKey(key)) continue;
140
+ leafKeys += 1;
141
+ }
142
+ if (leafKeys === 0) {
143
+ throw new Error(
144
+ `[pi-steering] ${path} contains no predicate leaves; ` +
145
+ `a clause must contain at least one predicate (cwd:, branch:, ` +
146
+ `commitsAhead:, condition:, happened:, not:, etc.). Modifier keys ` +
147
+ `(${MODIFIER_KEYS.join(", ")}) alone are not enough — add a leaf ` +
148
+ `or remove the empty clause.`,
149
+ );
150
+ }
151
+ // Recurse into the `not:` block. `condition:` is a function leaf,
152
+ // no recursion. Other plugin keys can carry nested objects (e.g.
153
+ // the built-in `happened` shape) but those aren't when-clauses, so
154
+ // recursion is scoped to the `not:` operator only.
155
+ const notBlock = (block as { not?: unknown }).not;
156
+ if (
157
+ notBlock !== undefined &&
158
+ typeof notBlock === "object" &&
159
+ notBlock !== null
160
+ ) {
161
+ // Reject nested `not:` at runtime. The type-level ban
162
+ // ({@link TopLevelWhenClauseNoRecurse} omits the `not?:` field)
163
+ // catches authoring-time mistakes, but JSON-loaded configs and
164
+ // `as any` escape hatches can author the shape; without this
165
+ // guard the engine's reserved-key skip would silently drop the
166
+ // inner `not:` (zero verdicts → vacuous true → outer not-flip =
167
+ // false → rule never fires).
168
+ if (
169
+ "not" in notBlock &&
170
+ (notBlock as { not?: unknown }).not !== undefined
171
+ ) {
172
+ throw new Error(
173
+ `[pi-steering] '${path}.not' contains a nested 'not:' key. ` +
174
+ `Use a single 'not:' wrapper; nested 'not: { not: ... }' is ` +
175
+ `semantically equivalent to the unwrapped form and is forbidden ` +
176
+ `by the schema.`,
177
+ );
178
+ }
179
+ validateWhenClauseShape(
180
+ notBlock as TopLevelWhenClause<string>,
181
+ `${path}.not`,
182
+ );
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Modifier-only subset of {@link RESERVED_PREDICATE_KEYS} — used by
188
+ * {@link validateWhenClauseShape} to strip modifiers when counting
189
+ * leaves. Operator fields (currently `"not"`) are NOT modifiers; they
190
+ * produce verdicts and count as leaves.
191
+ *
192
+ * Type-level coverage assertion (mirrors the
193
+ * {@link _RESERVED_PREDICATE_KEYS_COVERS_TYPE} pattern): the
194
+ * `satisfies readonly (keyof PredicateModifiers)[]` clause pins each
195
+ * entry to a real modifier key, AND the
196
+ * {@link _MODIFIER_KEYS_COVERS_TYPE} constant fails compilation if a
197
+ * future modifier (e.g. a hypothetical v0.2 `priority?: number`) is
198
+ * added to {@link PredicateModifiers} without updating this list.
199
+ * Without the lockstep check, the new modifier would be counted as a
200
+ * leaf by {@link validateWhenClauseShape}, masking empty-clause
201
+ * configs that are now "only modifiers, no real leaves."
202
+ */
203
+ export const MODIFIER_KEYS = [
204
+ "onUnknown",
205
+ ] as const satisfies readonly (keyof PredicateModifiers)[];
206
+
207
+ /**
208
+ * Type-level assertion that {@link MODIFIER_KEYS} covers every
209
+ * member of `keyof PredicateModifiers`. Mirrors the
210
+ * {@link _RESERVED_PREDICATE_KEYS_COVERS_TYPE} pattern — the constant
211
+ * is `_`-prefixed and never read at runtime; its sole job is to fail
212
+ * compilation when a new modifier is added without updating the
213
+ * runtime list.
214
+ */
215
+ type _ModifierKeyCoverage =
216
+ keyof PredicateModifiers extends (typeof MODIFIER_KEYS)[number]
217
+ ? true
218
+ : false;
219
+ const _MODIFIER_KEYS_COVERS_TYPE: _ModifierKeyCoverage = true;
220
+ void _MODIFIER_KEYS_COVERS_TYPE;
221
+
222
+ function isModifierKey(key: string): boolean {
223
+ return (MODIFIER_KEYS as readonly string[]).includes(key);
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Pattern / PredicateFn resolution
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Regex-compile cache: reuses the same RegExp object for the same string
232
+ * source. Rule patterns are typically long-lived; caching avoids
233
+ * recompilation on every tool_call while still being safe for ad-hoc
234
+ * patterns (weak in the worst case, Map in practice).
235
+ *
236
+ * Module-scoped so it lives across evaluator instances — same rule
237
+ * definition in two configs produces the same RegExp. Cheap enough
238
+ * we don't bother with eviction.
239
+ */
240
+ const REGEX_CACHE = new Map<string, RegExp>();
241
+
242
+ function compileRegex(source: string): RegExp {
243
+ const hit = REGEX_CACHE.get(source);
244
+ if (hit !== undefined) return hit;
245
+ const re = new RegExp(source);
246
+ REGEX_CACHE.set(source, re);
247
+ return re;
248
+ }
249
+
250
+ /**
251
+ * Match a string against a {@link Pattern} (string source or RegExp).
252
+ * Patterns are compiled once and cached; RegExps pass through.
253
+ */
254
+ export function matchesPattern(pattern: Pattern, target: string): boolean {
255
+ if (pattern instanceof RegExp) return pattern.test(target);
256
+ return compileRegex(pattern).test(target);
257
+ }
258
+
259
+ /**
260
+ * Evaluate a rule-level predicate (`pattern`, `requires`, `unless`).
261
+ *
262
+ * Accepts the same union v1's `Rule` supported plus {@link PredicateFn}:
263
+ * - `string` / `RegExp` → pattern match against `target`.
264
+ * - `function` → call with `ctx`, coerce result to boolean.
265
+ */
266
+ export async function matchesPatternOrFn(
267
+ value: Pattern | PredicateFn,
268
+ target: string,
269
+ ctx: PredicateContext,
270
+ ): Promise<boolean> {
271
+ if (typeof value === "function") {
272
+ const r = await value(ctx);
273
+ return Boolean(r);
274
+ }
275
+ return matchesPattern(value, target);
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // TopLevelWhenClause dispatch
280
+ // ---------------------------------------------------------------------------
281
+
282
+ /**
283
+ * Thrown when a {@link TopLevelWhenClause} references a predicate name that no
284
+ * plugin has registered. The error message includes the offending key
285
+ * so the source of the typo / missing plugin is clear at the site of
286
+ * the rule.
287
+ *
288
+ * Schema-level typo detection doesn't cover this because the
289
+ * `TopLevelWhenClause` mapped-type's index signature is deliberately
290
+ * loose (`unknown`) — per
291
+ * the ADR, plugin predicates can accept arbitrary arg shapes. The
292
+ * trade-off is that we surface the error at evaluation time instead of
293
+ * load time; the key-scoped message keeps that tolerable.
294
+ */
295
+ export class UnknownPredicateError extends Error {
296
+ readonly key: string;
297
+ constructor(key: string) {
298
+ super(
299
+ `[pi-steering] unknown when.${key} predicate — ` +
300
+ `no plugin registered a handler for this key. ` +
301
+ `Check for typos, or add a plugin that provides "${key}".`,
302
+ );
303
+ this.name = "UnknownPredicateError";
304
+ this.key = key;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Built-in `when.cwd` predicate. Accepts shorthand `Pattern`,
310
+ * shorthand `Pattern[]` (OR-of-matches), or the object form
311
+ * `{ pattern: Pattern | Pattern[]; onUnknown? }`. Returns a trinary
312
+ * {@link PredicateVerdict}: `true` / `false` for definite matches
313
+ * against the walker-resolved cwd, or `"unknown"` when the walker's
314
+ * `cwdTracker` couldn't resolve the effective cwd statically (the
315
+ * cwd-tracker `"unknown"` sentinel).
316
+ *
317
+ * The `onUnknown:` modifier on the object form is NOT consumed here.
318
+ * Trinary unknown is surfaced to the caller; the leaf-trinary adapter
319
+ * (outer level) or the not-block evaluator (inner level) applies the
320
+ * `onUnknown:` policy uniformly across leaves.
321
+ *
322
+ * Array semantics: OR-of-matches (predicate matches when the resolved
323
+ * cwd matches ANY of the listed patterns). Empty arrays are invalid
324
+ * (returns `false`); arrays containing non-Pattern values are invalid
325
+ * (returns `false`). Asymmetry: a malformed non-array scalar (e.g.
326
+ * `cwd: 123`) keeps the pre-extension fail-CLOSED behavior — under
327
+ * unknown cwd the predicate surfaces `"unknown"`; under known cwd the
328
+ * trailing `matchesPattern` regex-coercion almost always falls through
329
+ * to `false`. See inline comments for the empirical regex-character-
330
+ * class rationale.
331
+ *
332
+ * Fast path: the common shorthand form `when.cwd: /regex/` (or a
333
+ * string pattern) is read directly — no normalization object
334
+ * allocated. Only the object form `{ pattern, onUnknown }` takes the
335
+ * slightly-slower path of reading the pattern field.
336
+ */
337
+ function evaluateCwd(value: unknown, walkerCwd: string): PredicateVerdict {
338
+ // Shorthand Pattern form (string or RegExp).
339
+ if (typeof value === "string" || value instanceof RegExp) {
340
+ if (walkerCwd === "unknown") return "unknown";
341
+ return matchesPattern(value, walkerCwd);
342
+ }
343
+ // Shorthand Pattern[] form (non-empty, all-Pattern).
344
+ if (Array.isArray(value) && value.every(isPattern)) {
345
+ if (value.length === 0) return false; // empty array invalid → rule skips
346
+ if (walkerCwd === "unknown") return "unknown";
347
+ return value.some((p) => matchesPattern(p, walkerCwd));
348
+ }
349
+ // Object form: { pattern: Pattern | Pattern[]; onUnknown? } — the
350
+ // `onUnknown:` modifier is consumed by the caller-side leaf adapter,
351
+ // not here. We surface trinary unknown uniformly.
352
+ if (
353
+ value !== null &&
354
+ typeof value === "object" &&
355
+ "pattern" in (value as Record<string, unknown>)
356
+ ) {
357
+ const obj = value as { pattern: unknown };
358
+ if (isPattern(obj.pattern)) {
359
+ if (walkerCwd === "unknown") return "unknown";
360
+ return matchesPattern(obj.pattern, walkerCwd);
361
+ }
362
+ if (Array.isArray(obj.pattern) && obj.pattern.every(isPattern)) {
363
+ if (obj.pattern.length === 0) return false;
364
+ if (walkerCwd === "unknown") return "unknown";
365
+ return obj.pattern.some((p) => matchesPattern(p, walkerCwd));
366
+ }
367
+ }
368
+ // Explicit fail-skip for array-shaped input that isn't all-Pattern.
369
+ // Without this, `cwd: [/foo/, 123]` falls through to the malformed
370
+ // shorthand path below — `new RegExp(String([/foo/, 123]))` compiles
371
+ // to `/\/foo\/,123/`, which only matches paths containing the
372
+ // literal substring `/foo/,123` (effectively skip under known cwd),
373
+ // but the unknown-cwd branch still surfaces unknown. Asymmetric with
374
+ // the gitPlugin sites' clean null-→-skip path; pin uniformly.
375
+ if (Array.isArray(value)) return false;
376
+ // Object that fell out of the object-form branch (e.g.
377
+ // `{ pattern: [/foo/, 123] }` or `{ pattern: 123 }`) — fail-skip
378
+ // uniformly with the array-shorthand and the gitPlugin sites'
379
+ // null-→-skip path. Without this guard, the trailing
380
+ // `matchesPattern(value as Pattern, walkerCwd)` would silently
381
+ // regex-coerce the malformed object via `String(obj)` →
382
+ // `"[object Object]"`, which JS parses as a single character class
383
+ // `/[object Object]/` matching any of {b, c, e, j, o, t, space, O}.
384
+ // Under known cwd that regex matches almost every real path — silent
385
+ // fail-OPEN-fire, masking the config error. The unknown-cwd branch
386
+ // surfacing unknown is also asymmetric with the shorthand-array
387
+ // malformed path that fail-skips uniformly. This guard makes
388
+ // object-form malformed input skip uniformly.
389
+ if (value !== null && typeof value === "object") return false;
390
+ // Malformed non-array input — treat as fail-closed shorthand attempt
391
+ // (preserves existing pre-extension behavior for non-array malformed
392
+ // values: under unknown cwd, surface unknown; under known cwd,
393
+ // attempt a regex coercion which almost certainly produces `false`).
394
+ if (walkerCwd === "unknown") return "unknown";
395
+ return matchesPattern(value as Pattern, walkerCwd);
396
+ }
397
+
398
+ /**
399
+ * Built-in `when.happened` predicate. Merges real session entries
400
+ * (from `ctx.findEntries`, scope-filtered) with speculative entries
401
+ * (from `ctx.walkerState.events[event]`, produced by the walker-level
402
+ * synthesis pass — see {@link synthesizeSpeculativeEntries}) and
403
+ * returns **true when the unified timeline says the event has NOT
404
+ * happened** — i.e. the rule should fire.
405
+ *
406
+ * Single pipeline: the merge via timestamp ordering collapses the
407
+ * prior two-path structure (specialized tool_call-scope speculative-
408
+ * allow running only on stale/absent real entries) into one uniform
409
+ * sort-and-compare. Synthetic entries carry reserved timestamps above
410
+ * all real entries in the same type (see
411
+ * {@link synthesizeSpeculativeEntries}'s timestamp convention); so on
412
+ * an `&&` chain where the prior ref would produce `event`, the
413
+ * merged timeline correctly treats the event as fresher than any
414
+ * stale real entry of the same type.
415
+ *
416
+ * ADR §5 scope semantics are applied to real entries only —
417
+ * speculative entries are always considered in-scope. Synthetic
418
+ * entries represent "about to happen in the current tool_call", and
419
+ * the current tool_call is always part of the current agent_loop and
420
+ * session, so a scope subset check adds no signal. This also means a
421
+ * rule using `in: "agent_loop"` and a rule using `in: "session"` see
422
+ * the same speculative view (correct — "about to happen" is scope-
423
+ * independent; a speculative entry newer than ALL real entries for
424
+ * the type is newer than any scope subset too).
425
+ *
426
+ * Inversion is handled by the caller via `when.not`. Authors wanting
427
+ * "fires when the event HAS happened" wrap this clause in `not:`.
428
+ */
429
+ function evaluateHappened(
430
+ value: unknown,
431
+ ctx: PredicateContext,
432
+ ruleName: string,
433
+ ): boolean {
434
+ if (
435
+ value === null ||
436
+ typeof value !== "object" ||
437
+ !("event" in value) ||
438
+ !("in" in value)
439
+ ) {
440
+ throw new Error(
441
+ `[pi-steering] Rule "${ruleName}": when.happened ` +
442
+ `expected { event: string; in: "agent_loop" | "session" | "tool_call"; since?: string; notIn?: "agent_loop" | "session" | "tool_call" }; ` +
443
+ `got ${JSON.stringify(value)}`,
444
+ );
445
+ }
446
+ const {
447
+ event,
448
+ in: scope,
449
+ since,
450
+ notIn,
451
+ } = value as {
452
+ event: string;
453
+ in: unknown;
454
+ since?: unknown;
455
+ notIn?: unknown;
456
+ };
457
+ // Validate the scope string. The type system says
458
+ // `"agent_loop" | "session" | "tool_call"`, but a typo like
459
+ // `"agentLoop"` slips through TypeScript when the value arrives
460
+ // from a JSON source (import-json CLI, hand-written config, etc.).
461
+ // Surface those as loud runtime errors rather than silent
462
+ // fallthrough.
463
+ if (scope !== "agent_loop" && scope !== "session" && scope !== "tool_call") {
464
+ throw new Error(
465
+ `[pi-steering] Rule "${ruleName}": ` +
466
+ `when.happened.in must be "agent_loop", "session", or "tool_call"; ` +
467
+ `got ${JSON.stringify(scope)}`,
468
+ );
469
+ }
470
+ if (since !== undefined && typeof since !== "string") {
471
+ throw new Error(
472
+ `[pi-steering] Rule "${ruleName}": ` +
473
+ `when.happened.since must be a string if present; ` +
474
+ `got ${JSON.stringify(since)}`,
475
+ );
476
+ }
477
+
478
+ // Optional `notIn`: scope-subtraction modifier. Flat string — no
479
+ // nested object shape. Validated here rather than at load time to
480
+ // match the existing unknown-scope validation pattern (engine has no
481
+ // schema-level validation pass).
482
+ let innerScope: "agent_loop" | "session" | "tool_call" | null = null;
483
+ if (notIn !== undefined) {
484
+ if (
485
+ notIn !== "agent_loop" &&
486
+ notIn !== "session" &&
487
+ notIn !== "tool_call"
488
+ ) {
489
+ throw new Error(
490
+ `[pi-steering] Rule "${ruleName}": ` +
491
+ `when.happened.notIn must be "agent_loop", "session", or "tool_call"; ` +
492
+ `got ${JSON.stringify(notIn)}`,
493
+ );
494
+ }
495
+ if (notIn === scope) {
496
+ throw new Error(
497
+ `[pi-steering] Rule "${ruleName}": ` +
498
+ `when.happened.in and when.happened.notIn are identical (${JSON.stringify(scope)}); subtraction is empty. Remove the "notIn" modifier.`,
499
+ );
500
+ }
501
+ if (SCOPE_ORDER[notIn] > SCOPE_ORDER[scope]) {
502
+ throw new Error(
503
+ `[pi-steering] Rule "${ruleName}": ` +
504
+ `when.happened.notIn (${JSON.stringify(notIn)}) is a superset of when.happened.in (${JSON.stringify(scope)}); subtraction is empty. Adjust the scopes.`,
505
+ );
506
+ }
507
+ innerScope = notIn;
508
+ }
509
+
510
+ const sinceValue = typeof since === "string" ? since : undefined;
511
+
512
+ const eventLatest = latestTimestampSubtracted(event, scope, innerScope, ctx);
513
+ if (eventLatest === null) {
514
+ // Event absent in the (subtracted) timeline → rule fires.
515
+ return true;
516
+ }
517
+ if (sinceValue === undefined) {
518
+ // Simple presence check: event happened → rule does NOT fire.
519
+ return false;
520
+ }
521
+ const sinceLatest = latestTimestampSubtracted(
522
+ sinceValue,
523
+ scope,
524
+ innerScope,
525
+ ctx,
526
+ );
527
+ if (sinceLatest === null) {
528
+ // Invalidator never written in the (subtracted) timeline →
529
+ // degrade to simple-happened semantics (event wins).
530
+ return false;
531
+ }
532
+ // Both present. Event counts as happened iff its latest entry
533
+ // is strictly newer than the invalidator's.
534
+ return eventLatest <= sinceLatest;
535
+ }
536
+
537
+ /**
538
+ * Latest timestamp across the unified real + speculative timeline
539
+ * for the given customType, with optional set-subtraction against an
540
+ * inner scope. `null` when no entries remain after subtraction.
541
+ *
542
+ * Semantics:
543
+ * - Outer scope `"tool_call"`: real entries are skipped entirely
544
+ * (real entries are never "within this one bash invocation");
545
+ * only speculative entries count. Exactly the existing "about to
546
+ * happen in THIS command" semantic.
547
+ * - Outer `"agent_loop"`: real entries scope-filtered by
548
+ * `_agentLoopIndex`; speculative always included.
549
+ * - Outer `"session"`: all real entries; speculative always included.
550
+ *
551
+ * When `innerScope` is non-null, the subtraction removes entries that
552
+ * are in `innerScope` from the entry stream BEFORE the timestamp max.
553
+ * Since speculative entries are `tool_call`-scope by construction and
554
+ * `tool_call ⊂ agent_loop ⊂ session`, ANY non-null `innerScope`
555
+ * subtracts all speculative entries. For real entries, the inner
556
+ * scope's membership predicate gates which are excluded.
557
+ *
558
+ * Invariant (enforced by {@link evaluateHappened}'s validation):
559
+ * `innerScope === null` OR `SCOPE_ORDER[innerScope] <= SCOPE_ORDER[outer]`
560
+ * AND `innerScope !== outer`. Callers passing anything else get a
561
+ * configuration error before arriving here.
562
+ */
563
+ function latestTimestampSubtracted(
564
+ customType: string,
565
+ outer: "agent_loop" | "session" | "tool_call",
566
+ innerScope: "agent_loop" | "session" | "tool_call" | null,
567
+ ctx: PredicateContext,
568
+ ): number | null {
569
+ let latest = -Infinity;
570
+
571
+ // Real entries: in outer scope AND NOT in inner scope.
572
+ // Outer = "tool_call" excludes all real entries outright.
573
+ if (outer !== "tool_call") {
574
+ const inOuter = realEntryInScope(outer, ctx);
575
+ const inInner =
576
+ innerScope !== null && innerScope !== "tool_call"
577
+ ? realEntryInScope(innerScope, ctx)
578
+ : null;
579
+ for (const entry of ctx.findEntries<Record<string, unknown>>(customType)) {
580
+ if (!inOuter(entry)) continue;
581
+ if (inInner !== null && inInner(entry)) continue;
582
+ if (entry.timestamp > latest) latest = entry.timestamp;
583
+ }
584
+ }
585
+
586
+ // Speculative entries are always `tool_call` scope. Any non-null
587
+ // inner scope subtracts them (tool_call itself, or a superset that
588
+ // includes tool_call). When inner is null, keep them.
589
+ if (innerScope === null) {
590
+ const speculative = speculativeEntriesFor(ctx, customType);
591
+ for (const entry of speculative) {
592
+ if (entry.timestamp > latest) latest = entry.timestamp;
593
+ }
594
+ }
595
+
596
+ return latest === -Infinity ? null : latest;
597
+ }
598
+
599
+ /**
600
+ * Read the speculative-entry slice for `customType` off
601
+ * `ctx.walkerState.events`. Returns an empty array when walkerState
602
+ * is undefined (non-bash candidates) or carries no `events` field
603
+ * (configs with no observers producing synthesis entries for this
604
+ * event → the synthesis pass returned empty views per ref).
605
+ */
606
+ function speculativeEntriesFor(
607
+ ctx: PredicateContext,
608
+ customType: string,
609
+ ): readonly SyntheticEntry[] {
610
+ const events = ctx.walkerState?.["events"] as
611
+ | Readonly<Record<string, readonly SyntheticEntry[]>>
612
+ | undefined;
613
+ return events?.[customType] ?? [];
614
+ }
615
+
616
+ /**
617
+ * Scope nesting order used for superset detection in happened.notIn
618
+ * validation. `tool_call ⊂ agent_loop ⊂ session`; a higher number
619
+ * means a broader scope.
620
+ */
621
+ const SCOPE_ORDER = {
622
+ tool_call: 0,
623
+ agent_loop: 1,
624
+ session: 2,
625
+ } as const;
626
+
627
+ /**
628
+ * Build a per-entry filter for a scope as it applies to REAL entries
629
+ * (session JSONL). Speculative entries are filtered elsewhere since
630
+ * they have their own scope semantics.
631
+ *
632
+ * For a scope `tool_call`, real entries never match (no real entry
633
+ * originates from the current tool_call's speculative view).
634
+ */
635
+ function realEntryInScope(
636
+ scope: "agent_loop" | "session" | "tool_call",
637
+ ctx: PredicateContext,
638
+ ): (entry: { data: Record<string, unknown> }) => boolean {
639
+ if (scope === "session") {
640
+ return () => true;
641
+ }
642
+ if (scope === "tool_call") {
643
+ return () => false;
644
+ }
645
+ const target = ctx.agentLoopIndex;
646
+ return (entry) => {
647
+ const tag = entry.data?.[AGENT_LOOP_INDEX_KEY];
648
+ return tag === target;
649
+ };
650
+ }
651
+
652
+ /**
653
+ * Walker state consumed by `when` evaluation. Today just the per-ref
654
+ * cwd; the shape is open for future built-ins (e.g. branch) to pull
655
+ * their own fields from the same snapshot.
656
+ *
657
+ * @internal — not a plugin-author surface. Plugin predicates consume
658
+ * `ctx.walkerState` (the public `Readonly<Record<string, unknown>>`
659
+ * on {@link PredicateContext}) instead.
660
+ */
661
+ interface WhenWalkerState {
662
+ readonly cwd: string;
663
+ }
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // Trinary leaf adapter + Kleene composition
667
+ // ---------------------------------------------------------------------------
668
+
669
+ /**
670
+ * Read the leaf-level `onUnknown:` modifier from a leaf value. Bare
671
+ * forms (string, RegExp, array, boolean, number, etc.) carry no
672
+ * modifiers; only the spread object form's `onUnknown:` field is
673
+ * consulted. Falls back to `"block"` (fail-CLOSED) when absent.
674
+ *
675
+ * Strict equality on `"allow"` mirrors the engine's typo-defense: any
676
+ * other value (`"Allow"` capitalization typo, `"BLOCK"`, `undefined`,
677
+ * numeric, etc.) collapses to `"block"`.
678
+ */
679
+ function readLeafOnUnknown(value: unknown): "allow" | "block" {
680
+ if (
681
+ value !== null &&
682
+ typeof value === "object" &&
683
+ !Array.isArray(value) &&
684
+ !(value instanceof RegExp) &&
685
+ "onUnknown" in (value as Record<string, unknown>)
686
+ ) {
687
+ const v = (value as { onUnknown?: unknown }).onUnknown;
688
+ return v === "allow" ? "allow" : "block";
689
+ }
690
+ return "block";
691
+ }
692
+
693
+ /**
694
+ * Project a {@link PredicateVerdict} to a definite boolean using the
695
+ * supplied `onUnknown:` policy:
696
+ * - `"block"` (default, fail-CLOSED): `"unknown"` → `true` (the
697
+ * leaf reports "match" so the rule fires).
698
+ * - `"allow"` (fail-OPEN): `"unknown"` → `false` (the leaf reports
699
+ * "no match" so the rule skips).
700
+ *
701
+ * `true` / `false` pass through unchanged.
702
+ */
703
+ function projectVerdict(
704
+ verdict: PredicateVerdict,
705
+ onUnknown: "allow" | "block",
706
+ ): boolean {
707
+ if (verdict === "unknown") return onUnknown === "block";
708
+ return verdict;
709
+ }
710
+
711
+ /**
712
+ * Invoke a plugin-registered handler against the leaf value, awaiting
713
+ * any returned promise and narrowing the result to a trinary
714
+ * {@link PredicateVerdict}. A handler that throws synchronously OR
715
+ * returns a rejected promise is caught and treated as `"unknown"`,
716
+ * matching the spec's contract: "Throwing is equivalent to returning
717
+ * `"unknown"`; prefer explicit returns."
718
+ *
719
+ * The throw-as-unknown semantics preserve fail-CLOSED-by-default — a
720
+ * buggy plugin handler whose `"unknown"` then routes through the
721
+ * default `onUnknown: "block"` policy keeps the rule firing instead
722
+ * of silently fail-OPEN-skipping. Handler errors are logged via
723
+ * `console.warn` so plugin authors can debug; the rule name +
724
+ * `@<source>` tag + key are included so the source of the throw is
725
+ * unambiguous and operators can grep the warning channel by source
726
+ * tag (matching the S1 wrapper format `predicate threw for rule
727
+ * "<name>"@<source>` in {@link runPredicateChain}). See
728
+ * ../INVARIANTS.md for the S/E tag glossary.
729
+ */
730
+ async function evaluateLeafTrinary(
731
+ handler: PredicateHandler,
732
+ value: unknown,
733
+ ctx: PredicateContext,
734
+ ruleName: string,
735
+ source: string,
736
+ key: string,
737
+ ): Promise<PredicateVerdict> {
738
+ try {
739
+ const result = await handler(value, ctx);
740
+ if (result === true) return true;
741
+ if (result === false) return false;
742
+ if (result === "unknown") return "unknown";
743
+ // Defensive: a handler returning anything else is buggy. Treat as
744
+ // unknown for fail-CLOSED-by-default; log so the plugin author can
745
+ // trace it.
746
+ console.warn(
747
+ `[pi-steering] Rule "${ruleName}"@${source}: when.${key} handler returned ` +
748
+ `${JSON.stringify(result)}; expected boolean | "unknown". ` +
749
+ `Treating as "unknown"; the configured onUnknown policy will ` +
750
+ `project this to a definite verdict.`,
751
+ );
752
+ return "unknown";
753
+ } catch (err) {
754
+ const msg =
755
+ err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err);
756
+ console.warn(
757
+ `[pi-steering] Rule "${ruleName}"@${source}: when.${key} handler threw: ${msg}`,
758
+ );
759
+ return "unknown";
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Kleene 3-valued AND across an array of trinary verdicts. Used to
765
+ * compose multi-leaf `not:` blocks before the not-flip applies.
766
+ *
767
+ * Truth table (`x AND y`):
768
+ * true & true = true
769
+ * false & anything = false (false absorbs)
770
+ * anything & false = false
771
+ * true & unknown = unknown
772
+ * unknown & true = unknown
773
+ * unknown & unknown = unknown
774
+ *
775
+ * Empty input is `true` (vacuous truth) — but not-block evaluation
776
+ * rejects the empty case at config-resolve time via
777
+ * {@link validateWhenClauseShape}, so this code path only runs on
778
+ * non-empty leaf sets.
779
+ */
780
+ function kleeneAnd(verdicts: readonly PredicateVerdict[]): PredicateVerdict {
781
+ let anyUnknown = false;
782
+ for (const v of verdicts) {
783
+ if (v === false) return false; // false absorbs
784
+ if (v === "unknown") anyUnknown = true;
785
+ }
786
+ return anyUnknown ? "unknown" : true;
787
+ }
788
+
789
+ /**
790
+ * Evaluate a `not:` block per the corrected pseudocode.
791
+ *
792
+ * Inside `not:`, leaves are composed with Kleene 3-valued AND; the
793
+ * block-level `onUnknown:` modifier (default `"block"`) projects the
794
+ * unknown-leaf case to a definite verdict BEFORE the not-flip applies.
795
+ * This is a deliberate deviation from pure Kleene: the flip is skipped
796
+ * when leaves resolve via `onUnknown:` policy so that
797
+ * `not: { cwd: P, onUnknown: "block" }` directly means "rule fires"
798
+ * without requiring the user to invert.
799
+ *
800
+ * Returns a definite boolean (the not-clause's contribution to the
801
+ * outer when-clause's AND): `true` means the not-clause matched (rule
802
+ * fires); `false` means it didn't.
803
+ *
804
+ * Truth-table coverage:
805
+ * - all-true leaves → not(true) = false (rule skips on this
806
+ * not-clause).
807
+ * - any-false leaf → false absorbs in Kleene AND → not(false) =
808
+ * true (rule fires).
809
+ * - some-unknown, no-false → Kleene AND = "unknown" → block-level
810
+ * `onUnknown:` policy projects directly without the flip:
811
+ * "block" → not-clause = true (fail-CLOSED — rule fires)
812
+ * "allow" → not-clause = false (fail-OPEN — rule skips)
813
+ */
814
+ async function evaluateNotBlock(
815
+ block: TopLevelWhenClauseNoRecurse<string>,
816
+ state: WhenWalkerState,
817
+ ctx: PredicateContext,
818
+ predicates: Record<string, PredicateHandler>,
819
+ ruleName: string,
820
+ source: string,
821
+ ): Promise<boolean> {
822
+ // Read block-level `onUnknown:` modifier. Default fail-CLOSED.
823
+ const blockOnUnknown =
824
+ (block as { onUnknown?: unknown }).onUnknown === "allow"
825
+ ? "allow"
826
+ : "block";
827
+
828
+ // Evaluate each leaf to a trinary verdict. Reserved keys (modifiers
829
+ // + the operator field) are skipped; nested `not:` recursion is
830
+ // rejected at runtime in {@link validateWhenClauseShape} — the
831
+ // type-level ban via {@link TopLevelWhenClauseNoRecurse} catches
832
+ // authoring-time mistakes, the validator catches JSON / `as any`
833
+ // escape hatches before the engine ever runs. The unknown-predicate
834
+ // check still fires for unregistered keys.
835
+ const verdicts: PredicateVerdict[] = [];
836
+ for (const [key, value] of Object.entries(block)) {
837
+ if (value === undefined) continue;
838
+ if (isReservedPredicateKey(key)) continue;
839
+
840
+ // Built-in: cwd — trinary on walker-unknown sentinel.
841
+ if (key === "cwd") {
842
+ verdicts.push(evaluateCwd(value, state.cwd));
843
+ continue;
844
+ }
845
+
846
+ // Built-in: happened — boolean leaf, no walker-unknown semantics
847
+ // (it consults session entries / speculative entries).
848
+ if (key === "happened") {
849
+ verdicts.push(evaluateHappened(value, ctx, ruleName));
850
+ continue;
851
+ }
852
+
853
+ // Built-in: condition — escape-hatch boolean callback. Treat
854
+ // throws as `"unknown"` for parity with plugin handlers.
855
+ if (key === "condition") {
856
+ const fn = value as PredicateFn;
857
+ try {
858
+ const result = await fn(ctx);
859
+ verdicts.push(Boolean(result));
860
+ } catch (err) {
861
+ const msg =
862
+ err instanceof Error
863
+ ? `${err.message}\n${err.stack ?? ""}`
864
+ : String(err);
865
+ console.warn(
866
+ `[pi-steering] Rule "${ruleName}"@${source}: when.condition (inside not:) ` +
867
+ `threw: ${msg}`,
868
+ );
869
+ verdicts.push("unknown");
870
+ }
871
+ continue;
872
+ }
873
+
874
+ // Plugin-registered predicate. Unknown predicate → named error.
875
+ const handler = predicates[key];
876
+ if (handler === undefined) throw new UnknownPredicateError(key);
877
+ verdicts.push(
878
+ await evaluateLeafTrinary(handler, value, ctx, ruleName, source, key),
879
+ );
880
+ }
881
+
882
+ const combined = kleeneAnd(verdicts);
883
+
884
+ if (combined === false) {
885
+ // false absorbs → not(false) = true (rule fires on this not-clause).
886
+ return true;
887
+ }
888
+ if (combined === "unknown") {
889
+ // Skip the not-flip: the block-level `onUnknown:` policy directly
890
+ // produces the rule-level outcome. "block" → fire; "allow" → skip.
891
+ return blockOnUnknown === "block";
892
+ }
893
+ // All-true → not(true) = false (rule skips on this not-clause).
894
+ return false;
895
+ }
896
+
897
+ /**
898
+ * Evaluate a {@link TopLevelWhenClause}: returns true if every predicate in the
899
+ * clause "matches" for the given context. An empty / undefined clause
900
+ * trivially matches (rule fires regardless of `when`).
901
+ *
902
+ * Dispatch table:
903
+ * - `cwd` — built-in (walker-tied), consumes `state.cwd`. Returns
904
+ * trinary; outer leaf-level `onUnknown:` modifier on
905
+ * the spread form projects to a definite boolean via
906
+ * {@link projectVerdict} (default `"block"` =
907
+ * fail-CLOSED).
908
+ * - `happened` — built-in (session-entry-scoped), consumes
909
+ * `ctx.findEntries` + `ctx.agentLoopIndex`. Boolean.
910
+ * - `not` — nested `not:` block; dispatched to
911
+ * {@link evaluateNotBlock} which composes leaves with
912
+ * Kleene 3-valued AND and applies the block-level
913
+ * `onUnknown:` policy without the not-flip on unknown
914
+ * leaves.
915
+ * - `condition` — {@link PredicateFn}; call with ctx. Throws caught
916
+ * and treated as `"unknown"`. Outer-level
917
+ * `condition:` is bare-`PredicateFn`-typed (no
918
+ * spread shape), so the projection always uses the
919
+ * default `"block"` policy and a throwing condition
920
+ * fires the rule fail-CLOSED. Mirrors the inner
921
+ * not-block exception treatment + the
922
+ * plugin-handler contract in
923
+ * {@link evaluateLeafTrinary}.
924
+ * - anything else — `predicates[key]`; trinary handler with
925
+ * leaf-level `onUnknown:` modifier projection. Throws
926
+ * treated as `"unknown"` per spec.
927
+ *
928
+ * Reserved keys (`onUnknown`, future modifiers) are skipped here too —
929
+ * they're meaningful as siblings to leaves at the outer level (per
930
+ * spread form `{ pattern, onUnknown }` placement) but the engine
931
+ * doesn't iterate them as standalone keys; the leaf adapter consumes
932
+ * them inline. A bare `onUnknown:` at the outer level (without a
933
+ * containing leaf) is type-banned but skipped here defensively.
934
+ */
935
+ export async function evaluateWhen(
936
+ when: TopLevelWhenClause<string> | undefined,
937
+ state: WhenWalkerState,
938
+ ctx: PredicateContext,
939
+ predicates: Record<string, PredicateHandler>,
940
+ ruleName: string,
941
+ source: string,
942
+ ): Promise<boolean> {
943
+ if (!when) return true;
944
+
945
+ for (const [key, value] of Object.entries(when)) {
946
+ if (value === undefined) continue;
947
+
948
+ // Skip modifier-key siblings at the outer level (defensive — the
949
+ // type system bans rule-level `onUnknown:`, but a JSON config
950
+ // could slip one through). The `not:` operator field is NOT
951
+ // skipped here — it's a leaf that produces a verdict via Kleene
952
+ // composition of the inner not-block, dispatched below.
953
+ if (isModifierKey(key)) continue;
954
+
955
+ // Built-in: cwd. Trinary leaf with leaf-level `onUnknown:` policy.
956
+ if (key === "cwd") {
957
+ const verdict = evaluateCwd(value, state.cwd);
958
+ const onUnknown = readLeafOnUnknown(value);
959
+ if (!projectVerdict(verdict, onUnknown)) return false;
960
+ continue;
961
+ }
962
+
963
+ // Built-in: happened (session-entry presence check). Boolean.
964
+ if (key === "happened") {
965
+ if (!evaluateHappened(value, ctx, ruleName)) return false;
966
+ continue;
967
+ }
968
+
969
+ // Built-in: not (recursive inversion via the corrected
970
+ // not-block evaluator).
971
+ if (key === "not") {
972
+ const nested = value as TopLevelWhenClauseNoRecurse<string>;
973
+ const notFires = await evaluateNotBlock(
974
+ nested,
975
+ state,
976
+ ctx,
977
+ predicates,
978
+ ruleName,
979
+ source,
980
+ );
981
+ if (!notFires) return false;
982
+ continue;
983
+ }
984
+
985
+ // Built-in: condition (escape-hatch function). The callback
986
+ // returns boolean; throws (sync or rejected promise) are caught
987
+ // and projected via the "unknown" policy. Outer-level
988
+ // `condition:` is bare-`PredicateFn`-typed (no spread shape), so
989
+ // no leaf-level `onUnknown:` opt-in exists at this site — the
990
+ // projection always uses the default `"block"` policy and a
991
+ // throwing condition fires the rule fail-CLOSED. This mirrors the
992
+ // inner not-block branch's exception treatment so a `condition:`
993
+ // callback exhibits identical behavior at outer-vs-inner
994
+ // placement, and matches the plugin-handler exception contract
995
+ // in {@link evaluateLeafTrinary}.
996
+ if (key === "condition") {
997
+ const fn = value as PredicateFn;
998
+ let verdict: PredicateVerdict;
999
+ try {
1000
+ verdict = Boolean(await fn(ctx));
1001
+ } catch (err) {
1002
+ const msg =
1003
+ err instanceof Error
1004
+ ? `${err.message}\n${err.stack ?? ""}`
1005
+ : String(err);
1006
+ console.warn(
1007
+ `[pi-steering] Rule "${ruleName}"@${source}: when.condition threw: ${msg}`,
1008
+ );
1009
+ verdict = "unknown";
1010
+ }
1011
+ // `condition?:` is bare PredicateFn (no spread shape); leaf-level
1012
+ // `onUnknown:` is not reachable from the schema. Hard-code default
1013
+ // `"block"` policy for symmetry with the plugin-handler exception
1014
+ // contract. Authors needing fail-OPEN wrap inside
1015
+ // `not: { condition: fn, onUnknown: "allow" }` (block-level
1016
+ // modifier) OR catch the throw inside the callback body.
1017
+ if (!projectVerdict(verdict, "block")) return false;
1018
+ continue;
1019
+ }
1020
+
1021
+ // Plugin-registered predicate. Trinary leaf adapter awaits, narrows,
1022
+ // catches throws, then leaf-level `onUnknown:` projects to boolean.
1023
+ const handler = predicates[key];
1024
+ if (handler === undefined) throw new UnknownPredicateError(key);
1025
+ const verdict = await evaluateLeafTrinary(
1026
+ handler,
1027
+ value,
1028
+ ctx,
1029
+ ruleName,
1030
+ source,
1031
+ key,
1032
+ );
1033
+ const onUnknown = readLeafOnUnknown(value);
1034
+ if (!projectVerdict(verdict, onUnknown)) return false;
1035
+ }
1036
+ return true;
1037
+ }