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