@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
package/src/schema.ts ADDED
@@ -0,0 +1,1963 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * v2 config schema - TS-first rules, plugins, observers, predicates.
6
+ *
7
+ * Additive to the v1 schema (see `../schema.ts`). The existing evaluator
8
+ * continues to drive the pi extension runtime on the v1 types; v2 types
9
+ * live in parallel and power the new `defineConfig` / TS loader path.
10
+ * Phase 3 rewrites the evaluator on top of this module and retires v1.
11
+ *
12
+ * Design references (see the accepted ADR, linked from PR #2's
13
+ * description):
14
+ * - "Design → Rule schema" → {@link Rule}, {@link TopLevelWhenClause},
15
+ * {@link Pattern}, {@link PredicateFn},
16
+ * {@link PredicateHandler}.
17
+ * - "Design → Observer schema" → {@link Observer},
18
+ * {@link ObserverContext}.
19
+ * - "Design → Plugin schema" → {@link Plugin}.
20
+ * - "Design → Predicate context" → {@link PredicateContext}.
21
+ * - "Design → Override default" → {@link SteeringConfig.defaultNoOverride}
22
+ * (default `true`, fail-closed).
23
+ *
24
+ * Nothing in this module executes rules, observers, or predicates. It
25
+ * only defines shapes. Evaluation is Phase 3's concern.
26
+ */
27
+
28
+ import type { EnvState, Tracker, Word } from "@cad0p/unbash-walker";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Primitive predicate types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Static or regex pattern accepted by all built-in string-valued
36
+ * predicates (`when.cwd`, `when.branch`, `when.upstream`, ...).
37
+ *
38
+ * A plain string is treated as a regex source (compiled once at load
39
+ * time by the evaluator - users escape literals themselves). A RegExp
40
+ * is used as-is.
41
+ *
42
+ * See ADR "Design → Rule schema" → Pattern.
43
+ *
44
+ * @see {@link Patterns} for the OR-of-matches shorthand used by
45
+ * pattern-leaf predicate registry augmentations.
46
+ */
47
+ export type Pattern = string | RegExp;
48
+
49
+ /**
50
+ * The OR-of-patterns shorthand for pattern-leaf predicate
51
+ * registrations: a single {@link Pattern} or an array of
52
+ * {@link Pattern}s (interpreted as OR-of-matches: any pattern
53
+ * matching counts as a hit).
54
+ *
55
+ * Use as the bare-type parameter of {@link PredicateShape} when
56
+ * registering a pattern-leaf predicate that accepts both bare
57
+ * shorthand (`when: { myPredicate: /^foo$/ }`) and explicit object
58
+ * form (`when: { myPredicate: { pattern: /^foo$/ } }`):
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * import type { Patterns, PredicateShape } from "@cad0p/pi-steering";
63
+ *
64
+ * declare global {
65
+ * interface PiSteeringPredicates {
66
+ * myPredicate: PredicateShape<Patterns>;
67
+ * }
68
+ * }
69
+ * ```
70
+ */
71
+ export type Patterns = Pattern | Pattern[];
72
+
73
+ /**
74
+ * Escape-hatch predicate: arbitrary user-supplied logic evaluated with a
75
+ * {@link PredicateContext}. Returned value gates whether the surrounding
76
+ * rule fires. Async OK - evaluator awaits it.
77
+ *
78
+ * Used as the value of `when.condition`, and as the fallback shape for
79
+ * plugin-registered custom keys on a {@link TopLevelWhenClause}.
80
+ *
81
+ * See ADR "Design → Rule schema" → PredicateFn.
82
+ */
83
+ export type PredicateFn = (ctx: PredicateContext) => boolean | Promise<boolean>;
84
+
85
+ /**
86
+ * Dynamic block-reason function. When {@link Rule.reason} is a
87
+ * function, the evaluator invokes it with the same
88
+ * {@link PredicateContext} the predicates saw and prefixes the
89
+ * returned string with `[steering:<rule>@<source>] `. Async OK
90
+ * (evaluator awaits); thrown errors are logged to `console.warn` and
91
+ * replaced with a fail-safe fallback (`(reason failed to format;
92
+ * see log)`) so a broken reason doesn't leak its raw error message
93
+ * to the LLM.
94
+ *
95
+ * Use the function form when the block's human-readable context
96
+ * depends on runtime state — e.g. "Could not verify upstream at
97
+ * effective cwd \${ctx.walkerState.cwd}". Plain string reasons are
98
+ * preferred when the reason is static; they avoid the evaluator's
99
+ * extra await + try/catch.
100
+ *
101
+ * @example
102
+ * // Inject the current branch name into the reason when the walker
103
+ * // resolved it statically; fall back to a generic message when the
104
+ * // walker bails or the tracker's initial-sentinel flows through.
105
+ * reason: (ctx) => {
106
+ * const raw = ctx.walkerState?.branch;
107
+ * const branch =
108
+ * typeof raw === "string" && raw !== "" && raw !== "unknown"
109
+ * ? raw
110
+ * : undefined;
111
+ * const onClause = branch ? ` You are on '${branch}'.` : "";
112
+ * return `Don't commit directly to a protected branch.${onClause}`;
113
+ * };
114
+ */
115
+ export type ReasonFn = (ctx: PredicateContext) => string | Promise<string>;
116
+
117
+ /**
118
+ * Plugin-registered predicate *handler*. Differs from {@link PredicateFn}
119
+ * only in that the first argument is the structured argument the user
120
+ * supplied under their custom `when.<key>` slot. Example:
121
+ *
122
+ * ```ts
123
+ * // user config
124
+ * when: { commitsAhead: { wrt: "origin/main", eq: 1 } }
125
+ *
126
+ * // plugin registration
127
+ * predicates: {
128
+ * commitsAhead: (args: { wrt: string; eq: number }, ctx) => { ... }
129
+ * }
130
+ * ```
131
+ *
132
+ * `args` is whatever the rule author put under that key - the handler is
133
+ * responsible for validating its shape. `ctx` is the same
134
+ * {@link PredicateContext} the escape-hatch form receives.
135
+ *
136
+ * Returns a {@link PredicateVerdict} (`true | false | "unknown"`).
137
+ * Pre-trinary handlers returning plain `boolean` remain source-compatible:
138
+ * `boolean` is a subtype of `PredicateVerdict`, so existing handlers assign
139
+ * unchanged. Handlers that need to surface walker-unknown state to the
140
+ * engine return the literal string `"unknown"`; the engine then applies
141
+ * the leaf's `onUnknown:` modifier (or, inside `not:`, the block-level
142
+ * `onUnknown:`) to produce the leaf's verdict for downstream composition.
143
+ *
144
+ * Throwing inside a handler is equivalent to returning `"unknown"`: the
145
+ * engine catches and treats the leaf as unknown, then applies the
146
+ * `onUnknown:` policy. Prefer explicit returns; the catch exists so a
147
+ * buggy handler can't silently fail-OPEN by skipping its rule.
148
+ *
149
+ * See ADR "Design → Rule schema" → PredicateHandler.
150
+ */
151
+ export type PredicateHandler<A = unknown> = (
152
+ args: A,
153
+ ctx: PredicateContext,
154
+ ) => PredicateVerdict | Promise<PredicateVerdict>;
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Per-predicate typing — registry, modifiers, mapped types
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Trinary verdict surfaced by predicate handlers. `true` / `false` are
162
+ * the definite answers; the literal string `"unknown"` signals the
163
+ * predicate could not resolve its value (typically because some piece
164
+ * of walker-tracked state — cwd, branch, etc. — wasn't statically
165
+ * resolvable). The engine then applies the leaf's `onUnknown:` policy
166
+ * (or, inside `not:`, the block-level policy) to project the trinary
167
+ * verdict back to a definite boolean for rule-level composition.
168
+ */
169
+ export type PredicateVerdict = boolean | "unknown";
170
+
171
+ /**
172
+ * Predicate modifiers — optional fields that can be added to a
173
+ * predicate's spread form (outer leaf level) OR to the not-block top
174
+ * level. Single source of truth for what predicate authors and users
175
+ * can configure beyond the bare value. Adding a new modifier here
176
+ * propagates everywhere in the schema (every predicate's spread form,
177
+ * every not-block top level) and automatically reserves its key name
178
+ * via {@link ReservedPredicateKey}.
179
+ */
180
+ export interface PredicateModifiers {
181
+ /**
182
+ * Walker-unknown policy. When the predicate's value can't be
183
+ * resolved at walker time (dynamic cwd / branch via `cd "$VAR"`,
184
+ * `git checkout $VAR`, etc.), this policy decides the predicate's
185
+ * verdict:
186
+ * - `"block"` (default): treat as fail-CLOSED — predicate fires.
187
+ * - `"allow"`: treat as fail-OPEN — predicate skips.
188
+ *
189
+ * At the leaf level (outer when-clause), this is per-predicate.
190
+ * At the not-block top level, this applies to ALL leaves in the
191
+ * not-block — leaf-level `onUnknown:` is forbidden inside `not:`
192
+ * (type-level error) so the user can't write the silent
193
+ * fail-OPEN `not: { cwd: P }` shape.
194
+ */
195
+ onUnknown?: "allow" | "block";
196
+ }
197
+
198
+ /**
199
+ * Default spread BASE (without modifiers) inferred from `Bare`'s shape:
200
+ * - `Bare extends object` → `Bare` directly (intersection at use site).
201
+ * - `Bare extends Patterns` → `{ pattern: Bare }` wrapper.
202
+ * - else (primitive) → `{ value: Bare }` wrapper.
203
+ *
204
+ * Note the order: object check FIRST so a pure-object predicate
205
+ * auto-detects to intersection (clean sibling-modifier UX). Pattern
206
+ * check second to capture the built-in `string | RegExp | array`
207
+ * family. Primitive fallback for `boolean`, `number`, etc.
208
+ *
209
+ * The tuple-wrap (`[Bare] extends [...]`) prevents distributive
210
+ * conditional behavior across union members of `Bare`.
211
+ */
212
+ export type DefaultSpreadBase<Bare> = [Bare] extends [object]
213
+ ? Bare
214
+ : [Bare] extends [Patterns]
215
+ ? { pattern: Bare }
216
+ : { value: Bare };
217
+
218
+ /**
219
+ * Shape of a single entry in the {@link PiSteeringPredicates} registry.
220
+ * Each predicate declares its `bare` form and (optionally) an explicit
221
+ * `spreadBase` — the spread's object form WITHOUT modifiers.
222
+ *
223
+ * - `bare`: the value users write at the leaf (Pattern, boolean,
224
+ * number, etc.).
225
+ * - `SpreadBase` (param): the spread's object form WITHOUT modifiers;
226
+ * defaults to {@link DefaultSpreadBase} from `Bare`.
227
+ * - `spread` (derived at use site): `spreadBase & PredicateModifiers`
228
+ * — the form users write at the leaf to specify modifiers.
229
+ * Inner `not:` form omits modifiers (leaf-level `onUnknown:`
230
+ * inside `not:` is forbidden); outer leaf form intersects
231
+ * with `PredicateModifiers`.
232
+ *
233
+ * @see PredicateModifiers for the modifier surface available on every
234
+ * predicate's spread form.
235
+ * @see DefaultSpreadBase for how the SpreadBase auto-detects from Bare.
236
+ */
237
+ export interface PredicateShape<Bare, SpreadBase = DefaultSpreadBase<Bare>> {
238
+ /**
239
+ * The bare value users write at the leaf (no wrapper, no modifiers).
240
+ *
241
+ * For primitive {@link Bare} types (Pattern, boolean, number) this
242
+ * is the shorthand authors reach for first — `cwd: /work/`,
243
+ * `isClean: true`, `commitsAhead: 2`. The {@link spreadBase} form
244
+ * mirrors the bare value as `{ value: Bare }` (or an explicit
245
+ * `SpreadBase` shape for mixed-bare predicates like `commitsAhead`'s
246
+ * comparator bag) when authors need to attach leaf-level modifiers
247
+ * via `& PredicateModifiers`.
248
+ *
249
+ * For object {@link Bare} types the bare form IS the object shape
250
+ * directly (no `value:` wrapper); the {@link spreadBase} adds
251
+ * modifier slots without changing the structural shape.
252
+ */
253
+ bare: Bare;
254
+ /**
255
+ * The object form WITHOUT modifiers. Modifiers are added at use site
256
+ * via `& PredicateModifiers` (outer leaf level) or at the not-block
257
+ * top level (inside `not:`).
258
+ */
259
+ spreadBase: SpreadBase;
260
+ }
261
+
262
+ declare global {
263
+ /**
264
+ * Plugin-registered predicate registry. Empty by default; plugins
265
+ * extend via TypeScript module augmentation (`declare global { interface
266
+ * PiSteeringPredicates { ... } }`) to register typed predicates with
267
+ * autocomplete + JSDoc.
268
+ *
269
+ * Keys must NOT collide with {@link ReservedPredicateKey} (the
270
+ * operator-field union plus modifier keys); the type-level filter
271
+ * via {@link Exclude} drops collisions silently, and the engine
272
+ * throws at plugin-registration time with a concrete error message
273
+ * pointing the plugin author at the collision.
274
+ *
275
+ * Do NOT add an index signature (e.g. `[k: string]: PredicateShape<unknown>`)
276
+ * to this interface — it would widen `keyof PiSteeringPredicates` to
277
+ * `string`, defeating the reserved-key filter (`Exclude<string,
278
+ * "not" | "onUnknown">` is just `string` again).
279
+ *
280
+ * @example Plugin author registering a typed predicate
281
+ * ```ts
282
+ * import type { Plugin, PredicateShape } from "@cad0p/pi-steering";
283
+ * import { workItemFormat } from "./predicates/work-item-format.ts";
284
+ * import type { WorkItemFormatArgs } from "./predicates/work-item-format.ts";
285
+ *
286
+ * declare global {
287
+ * interface PiSteeringPredicates {
288
+ * workItemFormat: PredicateShape<WorkItemFormatArgs>;
289
+ * }
290
+ * }
291
+ *
292
+ * const myPlugin = {
293
+ * name: "work-item",
294
+ * predicates: { workItemFormat },
295
+ * } as const satisfies Plugin;
296
+ * ```
297
+ *
298
+ * @see {@link PredicateShape} for the bare / spreadBase shape contract.
299
+ * @see {@link DefaultSpreadBase} for how spreadBase auto-detects from `bare`.
300
+ * @see {@link PredicateModifiers} for available leaf-level modifier fields.
301
+ * @see The `gitPlugin` declaration in `plugins/git/index.ts` for a
302
+ * multi-predicate registry block (branch, upstream, remote,
303
+ * isClean, hasStagedChanges, commitsAhead).
304
+ * @see `examples/work-item-plugin/` for an end-to-end external-plugin
305
+ * reference: handler module, predicate-bare-shape registration,
306
+ * and a worked rule + tests.
307
+ */
308
+ interface PiSteeringPredicates {
309
+ // Empty by default. Plugins augment via `declare global`.
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Operator fields on `TopLevelWhenClause`. Currently just `"not"`;
315
+ * future v0.2 may add `"or"` / `"and"` operators. Kept as a separate
316
+ * union so reserved-key derivation stays lockstep with operator
317
+ * additions.
318
+ */
319
+ export type OperatorField = "not";
320
+
321
+ /**
322
+ * Reserved predicate keys derived from `OperatorField | keyof
323
+ * PredicateModifiers`. Plugin authors cannot register predicates with
324
+ * these names — they collide with the schema's `not?:` operator field
325
+ * and the `& PredicateModifiers` intersection on spread forms.
326
+ *
327
+ * Adding a new modifier to {@link PredicateModifiers} automatically
328
+ * reserves its key (lockstep via `keyof`); adding a new operator
329
+ * requires extending {@link OperatorField}.
330
+ *
331
+ * The type-level filter via {@link PluginPredicateKey} removes these
332
+ * names from the mapped types; the runtime constant
333
+ * `RESERVED_PREDICATE_KEYS` (in `evaluator-internals/predicates.ts`)
334
+ * mirrors this set so the engine throws at plugin-registration time
335
+ * with a concrete error message.
336
+ */
337
+ export type ReservedPredicateKey = OperatorField | keyof PredicateModifiers;
338
+
339
+ /**
340
+ * Plugin-registered predicate keys with reserved names filtered out.
341
+ * Used as the value-position constraint on {@link OuterValue} /
342
+ * {@link InnerValue}, and as the `K & PluginPredicateKey`
343
+ * intersection narrowing inside {@link TopLevelWhenClause} /
344
+ * {@link TopLevelWhenClauseNoRecurse}. NOT used as the mapped-type
345
+ * iteration source — those mapped types iterate `keyof
346
+ * PiSteeringPredicates` directly with an `as`-filter, see their
347
+ * mapping-shape note for why.
348
+ */
349
+ export type PluginPredicateKey = Exclude<
350
+ keyof PiSteeringPredicates,
351
+ ReservedPredicateKey
352
+ >;
353
+
354
+ /**
355
+ * Outer leaf value: the bare form OR the spreadBase intersected with
356
+ * {@link PredicateModifiers}. Used at the top-level `when:` clause
357
+ * where each plugin-registered predicate accepts modifiers per-leaf.
358
+ *
359
+ * @see TopLevelWhenClause for the surface that consumes this mapped
360
+ * type.
361
+ */
362
+ export type OuterValue<K extends PluginPredicateKey> =
363
+ | PiSteeringPredicates[K]["bare"]
364
+ | (PiSteeringPredicates[K]["spreadBase"] & PredicateModifiers);
365
+
366
+ /**
367
+ * Inner leaf value (inside `not:`): bare form OR spreadBase WITHOUT
368
+ * modifiers. Modifiers live at the not-block top level — leaf-level
369
+ * modifiers inside `not:` are forbidden so the silent fail-OPEN
370
+ * `not: { cwd: P }` shape can't be reproduced via leaf-level
371
+ * `onUnknown:` placement.
372
+ *
373
+ * @see TopLevelWhenClauseNoRecurse for the surface that consumes this
374
+ * mapped type.
375
+ */
376
+ export type InnerValue<K extends PluginPredicateKey> =
377
+ | PiSteeringPredicates[K]["bare"]
378
+ | PiSteeringPredicates[K]["spreadBase"];
379
+
380
+ /**
381
+ * Built-in non-registry leaves attached to a {@link Rule.when}
382
+ * clause — outer flavor.
383
+ *
384
+ * These predicates ship with the engine itself (not via a plugin),
385
+ * so they aren't in {@link PiSteeringPredicates} but DO need to
386
+ * surface on {@link TopLevelWhenClause} as typed fields.
387
+ *
388
+ * The `Writes` generic threads through {@link defineConfig} so that
389
+ * `when.happened.event` / `when.happened.since` references are
390
+ * compile-time-checked against the union of declared `writes`
391
+ * arrays across plugins + observers.
392
+ *
393
+ * Currently three non-registry leaves: `happened?:`, `condition?:`,
394
+ * `cwd?:`. The shape is pinned in tests (a future widening — e.g.,
395
+ * adding a new built-in `tool?:` leaf — fails the type-pin and
396
+ * forces a deliberate decision).
397
+ *
398
+ * ## Outer / Inner split
399
+ *
400
+ * `cwd:`'s spread form differs depending on placement:
401
+ *
402
+ * - Outer (rule-level `when:`) — leaf-level `onUnknown?:` allowed,
403
+ * honored by the engine's `evaluateCwd` + `projectVerdict` flow.
404
+ * - Inner (inside `not:`) — leaf-level `onUnknown?:` forbidden
405
+ * (parity with registry-driven inner predicates per
406
+ * {@link InnerValue}). Walker-unknown cwd inside `not:` projects
407
+ * via the block-level `onUnknown:` modifier (default `"block"`).
408
+ *
409
+ * Two parallel interfaces formalize this split: this type
410
+ * (`BuiltInWhenLeavesOuter`) and {@link BuiltInWhenLeavesInner}. The
411
+ * legacy `BuiltInWhenLeaves` symbol is preserved as a deprecated
412
+ * alias to `Outer` for backward compatibility — new code should
413
+ * import the explicit Outer / Inner flavor.
414
+ *
415
+ * This is the outer-flavor interface. `cwd?:` accepts
416
+ * `Pattern | Pattern[] | { pattern, onUnknown? }` (leaf-level
417
+ * `onUnknown:` honored at the outer when-level via `evaluateCwd` /
418
+ * `projectVerdict`).
419
+ *
420
+ * @see BuiltInWhenLeavesInner for the parallel inner-flavor type
421
+ * used inside `not:`.
422
+ */
423
+ export interface BuiltInWhenLeavesOuter<Writes extends string = string> {
424
+ /**
425
+ * Rule fires when the given `event` has NOT happened in the given
426
+ * scope. Typical usage: "block `cr` unless sync has happened" -
427
+ * `happened: { event: "rds-ws-sync-done", in: "agent_loop" }`.
428
+ *
429
+ * Scopes:
430
+ * - `"agent_loop"` - filter session entries by
431
+ * `entry.data._agentLoopIndex === ctx.agentLoopIndex`. The engine
432
+ * auto-injects that tag on every `appendEntry` write, so plugin
433
+ * authors don't have to remember to tag manually.
434
+ * - `"session"` - no scope filter. Any entry of `event` present
435
+ * in the session JSONL satisfies.
436
+ * - `"tool_call"` - only consider speculative entries synthesized
437
+ * for THIS tool_call's `&&`-chain. Real (persisted) entries are
438
+ * ignored entirely. Use when the rule requires the event to be
439
+ * CHAINED directly before the guarded command (e.g. `sync && cr`)
440
+ * rather than merely "somewhere this agent loop". Pairs naturally
441
+ * with observer `writes:` declarations on observers whose
442
+ * watch-matched refs produce speculative entries; no-op when no
443
+ * observer writes the event.
444
+ *
445
+ * Inversion: place inside `not` to flip the clause-level boolean -
446
+ * `not: { happened: { event, in } }` fires when the event HAS
447
+ * happened. See ADR §5.
448
+ *
449
+ * Optional `since` sentinel (temporal ordering): when present,
450
+ * `event` is considered "happened" only if its most-recent entry
451
+ * in scope is newer than the most-recent `since` entry in scope.
452
+ * If `since` has never been written, the clause behaves as if
453
+ * `since` were absent (simple presence check on `event`).
454
+ *
455
+ * Use for invalidation semantics: "rule fires when sync has not
456
+ * happened in this agent_loop, OR the last sync is older than the
457
+ * last upstream-fail." Pattern:
458
+ * `happened: { event: SYNC_DONE_EVENT, in: "agent_loop",
459
+ * since: UPSTREAM_FAILED_EVENT }`.
460
+ *
461
+ * Optional `notIn` (set subtraction over scopes): when present,
462
+ * entries in `notIn` scope are excluded from the `in`-scoped entry
463
+ * stream BEFORE the `ts_max` comparison runs. Typical use:
464
+ * `happened: { event, in: "agent_loop", notIn: "tool_call" }` -
465
+ * "happened in a prior tool_call in this agent loop". Excludes
466
+ * same-tool_call speculative entries so `someCmd && guardedCmd`
467
+ * can't bypass the rule via tool_call-scope speculative synthesis.
468
+ *
469
+ * Distinct from the clause-level {@link TopLevelWhenClause.not},
470
+ * which is boolean negation of a sub-clause. `notIn` is set
471
+ * subtraction; separate keyword so the two operators can't be
472
+ * confused.
473
+ *
474
+ * Invalid scope combinations throw at evaluation time with the
475
+ * rule name prefixed:
476
+ * - Supersets (e.g. `in: "agent_loop", notIn: "session"`) - the
477
+ * subtraction is always empty.
478
+ * - Identicals (`notIn === in`) - the subtraction is always empty.
479
+ *
480
+ * Compile-time constraint: inside {@link defineConfig}, both the
481
+ * `event` and `since` fields are narrowed to the union of all
482
+ * `writes` declared across plugin rules, plugin observers, user
483
+ * rules, and user observers. Typos become compile errors. Outside
484
+ * `defineConfig` the `Writes` parameter defaults to `string` so the
485
+ * check is skipped.
486
+ */
487
+ happened?: {
488
+ event: Writes;
489
+ in: "agent_loop" | "session" | "tool_call";
490
+ since?: Writes;
491
+ notIn?: "agent_loop" | "session" | "tool_call";
492
+ };
493
+
494
+ /**
495
+ * Escape-hatch predicate for one-off logic. Prefer plugin-registered
496
+ * predicates when the logic is reusable; use `condition` for
497
+ * genuinely local checks that don't warrant a plugin.
498
+ *
499
+ * Throws (sync or rejected promise) are caught and treated as
500
+ * `"unknown"`. Outer-level `condition:` is bare-`PredicateFn`-typed
501
+ * (no spread shape), so the projection always uses the default
502
+ * `"block"` policy: a throwing condition fires the rule fail-CLOSED.
503
+ * Authors needing fail-OPEN wrap inside
504
+ * `not: { condition: fn, onUnknown: "allow" }` (block-level
505
+ * modifier) OR catch the throw inside the callback body. Mirrors
506
+ * the plugin-handler exception contract.
507
+ */
508
+ condition?: PredicateFn;
509
+
510
+ /**
511
+ * Constrain the rule to commands whose *effective* cwd matches
512
+ * the given pattern. For bash, the walker's `cwdTracker` resolves
513
+ * the effective cwd per extracted command (so
514
+ * `cd ~/personal && git commit --amend` evaluates against
515
+ * `~/personal`). For write / edit, the session cwd is used directly.
516
+ *
517
+ * Bare form: a single {@link Pattern} or an OR-of-patterns array.
518
+ * Spread form: `{ pattern, onUnknown? }` — the object lets authors
519
+ * opt into `onUnknown: "allow"` when a command's cwd can't be
520
+ * statically resolved (e.g. `cd $VAR && ...`). Default is `"block"`
521
+ * — fail-closed.
522
+ *
523
+ * Array form (`Pattern[]` or `{ pattern: Pattern[]; onUnknown? }`)
524
+ * matches OR-of-patterns: the predicate fires when the resolved cwd
525
+ * matches ANY of the listed patterns. Empty arrays are invalid (rule
526
+ * skips); arrays containing non-Pattern values are invalid (rule
527
+ * skips). Array form sugars vault-path or workspace-tree exemptions:
528
+ *
529
+ * ```ts
530
+ * when: { cwd: [/\/Goldmine\//, /\/\.cache\/napkin-distill\//] }
531
+ * when: { cwd: { pattern: [/\.test$/, /\.spec$/], onUnknown: "allow" } }
532
+ * ```
533
+ *
534
+ * `cwd:` is the sole walker-tied built-in leaf; all other dimensions
535
+ * (`branch`, `upstream`, ...) come from plugins. It lives on
536
+ * {@link BuiltInWhenLeaves} (not the registry) so authors can write
537
+ * `when: { cwd: /work/ }` against pi-steering core without needing
538
+ * gitPlugin's module augmentation in scope.
539
+ *
540
+ * See ADR "Design → Override default and `onUnknown`".
541
+ */
542
+ cwd?:
543
+ | Pattern
544
+ | Pattern[]
545
+ | { pattern: Pattern | Pattern[]; onUnknown?: "allow" | "block" };
546
+ }
547
+
548
+ /**
549
+ * Inner-flavor non-registry built-in leaves. Lives on
550
+ * {@link TopLevelWhenClauseNoRecurse} (the body of `not:`). `cwd?:`
551
+ * accepts `Pattern | Pattern[] | { pattern }` — NO leaf-level
552
+ * `onUnknown?:`. Modifiers live at the not-block level via
553
+ * `& PredicateModifiers`, matching the constraint registry-driven
554
+ * inner predicates already enforce via `InnerValue<K>`.
555
+ *
556
+ * `happened?:` and `condition?:` are identical to
557
+ * {@link BuiltInWhenLeavesOuter}; only `cwd:` differs. The engine
558
+ * reads block-level `onUnknown:` inside `not:` regardless of leaf
559
+ * shape, so this type formalizes that constraint at the authoring
560
+ * surface (preventing the silent fail-OPEN class where leaf-level
561
+ * `onUnknown:` looks meaningful but is ignored at runtime).
562
+ *
563
+ * @see BuiltInWhenLeavesOuter for the design rationale, the
564
+ * Outer/Inner split, and the parallel outer-flavor type.
565
+ */
566
+ export interface BuiltInWhenLeavesInner<Writes extends string = string> {
567
+ /** Identical to {@link BuiltInWhenLeavesOuter.happened}. */
568
+ happened?: BuiltInWhenLeavesOuter<Writes>["happened"];
569
+
570
+ /** Identical to {@link BuiltInWhenLeavesOuter.condition}. */
571
+ condition?: BuiltInWhenLeavesOuter<Writes>["condition"];
572
+
573
+ /**
574
+ * Same `cwd:` semantics as {@link BuiltInWhenLeavesOuter.cwd} but
575
+ * the spread form's `onUnknown?:` is dropped — modifiers live at
576
+ * the not-block level via `& PredicateModifiers`. Walker-unknown
577
+ * cwd inside `not:` projects via the block-level `onUnknown:`
578
+ * (default `"block"` = fail-CLOSED, rule fires).
579
+ */
580
+ cwd?: Pattern | Pattern[] | { pattern: Pattern | Pattern[] };
581
+ }
582
+
583
+ /**
584
+ * Backward-compatible alias for {@link BuiltInWhenLeavesOuter}.
585
+ *
586
+ * Retained so external code importing `BuiltInWhenLeaves` (and the
587
+ * public-surface shape pin in `not-block-onunknown.test.ts`) keeps
588
+ * working after the Outer/Inner split.
589
+ *
590
+ * @deprecated Use {@link BuiltInWhenLeavesOuter} for outer-level
591
+ * authoring or {@link BuiltInWhenLeavesInner} for `not:` block
592
+ * bodies. This alias is retained for backward compatibility and may
593
+ * be removed in a future release.
594
+ */
595
+ export type BuiltInWhenLeaves<Writes extends string = string> =
596
+ BuiltInWhenLeavesOuter<Writes>;
597
+
598
+ /**
599
+ * Top-level when-clause attached to a {@link Rule}. Each
600
+ * plugin-registered predicate (filtered for reserved keys) gets a
601
+ * leaf-level field accepting the bare or spread form. The `not?:`
602
+ * operator allows one level of negation (no recursion).
603
+ *
604
+ * Generic over `Writes` so the built-in `happened?:` leaf's `event` /
605
+ * `since` references narrow to the union of declared `writes` strings
606
+ * threaded through by {@link defineConfig}.
607
+ *
608
+ * Mapping shape note: the constraint is `keyof PiSteeringPredicates`
609
+ * with an `as`-filter excluding {@link ReservedPredicateKey}, rather
610
+ * than the pre-computed alias `[K in PluginPredicateKey]`. Both shapes
611
+ * produce the same keyset, but only the homomorphic-with-filter form
612
+ * propagates JSDoc on hover from `PiSteeringPredicates.<key>` source
613
+ * declarations onto the synthesized field. The `& PluginPredicateKey`
614
+ * intersection in the value position narrows `K` back to the
615
+ * constraint expected by {@link OuterValue}. Keep both the constraint
616
+ * and the `as`-filter inlined — extracting either to a type alias
617
+ * silently regresses the propagation.
618
+ *
619
+ * Hover-on-`defineConfig`-inline-rules caveat: passing rule literals
620
+ * directly into `defineConfig({ rules: [{ ... }] })` bypasses the
621
+ * mapped-type linkage — the `const R extends readonly Rule[]`
622
+ * signature narrows the literal to its `const`-inferred shape.
623
+ * Factor rules out (`const myRule = { ... } as const satisfies Rule`)
624
+ * when hover-rich authoring matters; see the {@link defineConfig} JSDoc.
625
+ *
626
+ * @see TopLevelWhenClauseNoRecurse for the body of `not:`.
627
+ * @see PredicateModifiers for available leaf-level modifier fields.
628
+ * @see BuiltInWhenLeaves for the engine's non-registry leaf set.
629
+ */
630
+ export type TopLevelWhenClause<Writes extends string = string> = {
631
+ [K in keyof PiSteeringPredicates as K extends ReservedPredicateKey
632
+ ? never
633
+ : K]?: OuterValue<K & PluginPredicateKey>;
634
+ } & BuiltInWhenLeavesOuter<Writes> & {
635
+ /**
636
+ * Logical NOT: rule fires when the inner predicates' AND is false.
637
+ *
638
+ * Multi-leaf semantics: leaves AND together with Kleene 3-valued
639
+ * logic. Walker-unknown leaves resolve via the block-level
640
+ * `onUnknown:` modifier (default `"block"` = fail-CLOSED, rule
641
+ * fires).
642
+ *
643
+ * No leaf-level `onUnknown:` here (forbidden at type level —
644
+ * modifiers live at the not-block level). No `not:` recursion
645
+ * (forbidden at type level — semantically equivalent to the
646
+ * unwrapped form). Nested `not:` is also rejected at runtime by
647
+ * `validateWhenClauseShape` for JSON / `as any` escape hatches.
648
+ *
649
+ * @see TopLevelWhenClauseNoRecurse
650
+ * @see PredicateModifiers
651
+ */
652
+ not?: TopLevelWhenClauseNoRecurse<Writes>;
653
+ };
654
+
655
+ /**
656
+ * Body of a `not:` block: predicates with their bare / spreadBase
657
+ * forms (NO leaf-level modifiers — modifiers live at this block's top
658
+ * level via `& PredicateModifiers`). No nested `not:` (no recursion).
659
+ *
660
+ * Generic over `Writes` so the built-in `happened?:` leaf inherits
661
+ * the same compile-time event-narrowing as the outer level.
662
+ *
663
+ * Same homomorphic-with-filter mapping shape as
664
+ * {@link TopLevelWhenClause} (and the same `defineConfig`-inline
665
+ * caveat). See the mapping-shape note there for the rationale.
666
+ *
667
+ * @see TopLevelWhenClause for the rule-attached when-clause.
668
+ * @see PredicateModifiers for block-level modifier fields.
669
+ */
670
+ export type TopLevelWhenClauseNoRecurse<Writes extends string = string> = {
671
+ [K in keyof PiSteeringPredicates as K extends ReservedPredicateKey
672
+ ? never
673
+ : K]?: InnerValue<K & PluginPredicateKey>;
674
+ } & BuiltInWhenLeavesInner<Writes> &
675
+ PredicateModifiers;
676
+
677
+ /**
678
+ * Type-erased alias for {@link PredicateHandler} used at registry
679
+ * boundaries (notably {@link Plugin.predicates}).
680
+ *
681
+ * TypeScript treats function-argument types as contravariant: a
682
+ * `PredicateHandler<CommitsAheadArgs>` is **not** assignable to a
683
+ * `PredicateHandler<unknown>` because the handler needs to *accept*
684
+ * `unknown`, while the specialized handler only accepts a narrower
685
+ * shape. Using `any` at the registry slot leverages TS's bivariance
686
+ * fallback — specifically-typed handlers assign without a cast, and
687
+ * the engine's generic call site stays safe because it passes the
688
+ * matching `when.<name>` value straight through to the handler
689
+ * (the handler already validates its own arg shape).
690
+ *
691
+ * Prefer this alias at any `Record<string, PredicateHandler<…>>`
692
+ * boundary where the value shape is per-key heterogeneous.
693
+ *
694
+ * ## Write-through registry slot, not a safe read type
695
+ *
696
+ * This alias exists so heterogeneous handler maps accept typed
697
+ * handlers cast-free on the WRITE side (plugin author stuffs a
698
+ * `PredicateHandler<FooArgs>` into `Plugin.predicates`). On the READ
699
+ * side (a consumer iterating `plugin.predicates`, or a decorator /
700
+ * middleware layering over a plugin's handlers) the retrieved value
701
+ * carries `args: any` — no compile-time narrowing. Consumers that
702
+ * want typed reads should narrow back to `PredicateHandler<TArgs>`
703
+ * at their call site.
704
+ *
705
+ * ## Handler authors: declare `PredicateHandler<YourArgs>`, not this
706
+ *
707
+ * Do NOT use `AnyPredicateHandler` as the type annotation for a
708
+ * handler declaration:
709
+ *
710
+ * // wrong — `args` is `any`, no narrowing inside the body.
711
+ * const myHandler: AnyPredicateHandler = (args, ctx) => { ... };
712
+ *
713
+ * // right — narrow `args` at the declaration; the result still
714
+ * // assigns cast-free into `Plugin.predicates`.
715
+ * const myHandler: PredicateHandler<MyArgs> = (args, ctx) => { ... };
716
+ *
717
+ * Also note: because `any` at the boundary disables compile-time
718
+ * narrowing on the engine's CALL site too, typed handlers MUST
719
+ * still validate their own `args` shape at the top of the function
720
+ * body. The engine passes the verbatim `when.<name>` value, so TS
721
+ * can't protect you from a user writing
722
+ * `when: { myPredicate: "not-the-shape-you-expected" }`.
723
+ * (See `isClean`'s `if (typeof args !== "boolean") return false`
724
+ * pattern for the canonical guard.)
725
+ */
726
+ export type AnyPredicateHandler = PredicateHandler<any>;
727
+
728
+ // ---------------------------------------------------------------------------
729
+ // When clause
730
+ // ---------------------------------------------------------------------------
731
+
732
+ /**
733
+ * Legacy v0.0.x predicate block; see {@link TopLevelWhenClause} for
734
+ * the authoring surface attached to {@link Rule.when}.
735
+ *
736
+ * The legacy interface is retained as an internal type for the JSON
737
+ * v1→v2 path in `compat.ts`, where the loose `[customKey: string]:
738
+ * unknown` index signature is needed to accept arbitrary plugin keys
739
+ * during deserialization. Plugin authors should NOT type their
740
+ * helpers against `WhenClause` — use {@link TopLevelWhenClause} for
741
+ * outer when-clauses or {@link TopLevelWhenClauseNoRecurse} for
742
+ * not-block bodies (both are registry-driven and enforce the
743
+ * five compile-time constraints documented on {@link BaseRule.when}).
744
+ *
745
+ * Note: even though this legacy interface permits `not: { not: ... }`
746
+ * recursion at the type level, the engine's runtime
747
+ * `validateWhenClauseShape` rejects nested-`not` shapes at
748
+ * `buildEvaluator` time. Authors smuggling depth-2 recursion through
749
+ * JSON v1 / `as any` casts hit the runtime guard, not silent
750
+ * acceptance.
751
+ *
752
+ * @deprecated Internal v1-compat type. Use {@link TopLevelWhenClause}
753
+ * at authoring sites; this interface is preserved only
754
+ * for the JSON v1→v2 conversion path in `compat.ts`.
755
+ */
756
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
757
+ export interface WhenClause<Writes extends string = string> {
758
+ /**
759
+ * @deprecated Use {@link TopLevelWhenClause}'s `cwd?:` leaf (lifted
760
+ * onto {@link BuiltInWhenLeaves}). Retained for v1
761
+ * JSON→v2 conversion compatibility only.
762
+ * @see BuiltInWhenLeaves.cwd
763
+ */
764
+ cwd?:
765
+ | Pattern
766
+ | Pattern[]
767
+ | { pattern: Pattern | Pattern[]; onUnknown?: "allow" | "block" };
768
+
769
+ /**
770
+ * @deprecated Use {@link TopLevelWhenClause}'s `happened?:` leaf
771
+ * (lifted onto {@link BuiltInWhenLeaves}). Retained for
772
+ * v1 JSON→v2 conversion compatibility only.
773
+ * @see BuiltInWhenLeaves.happened for the canonical semantics
774
+ * (scopes, `since`, `notIn`, runtime errors).
775
+ */
776
+ happened?: {
777
+ event: Writes;
778
+ in: "agent_loop" | "session" | "tool_call";
779
+ since?: Writes;
780
+ notIn?: "agent_loop" | "session" | "tool_call";
781
+ };
782
+
783
+ /**
784
+ * @deprecated Use {@link TopLevelWhenClause}'s `not?:` operator field.
785
+ */
786
+ not?: WhenClause<Writes>;
787
+
788
+ /**
789
+ * @deprecated Use {@link TopLevelWhenClause}'s `condition?:` leaf
790
+ * (lifted onto {@link BuiltInWhenLeaves}). Retained for
791
+ * v1 JSON→v2 conversion compatibility only.
792
+ * @see BuiltInWhenLeaves.condition
793
+ */
794
+ condition?: PredicateFn;
795
+
796
+ /**
797
+ * @deprecated v1-compat loose index signature — plugin-registered
798
+ * predicates now live on {@link PiSteeringPredicates}
799
+ * with per-key compile-time typing via
800
+ * {@link OuterValue} / {@link InnerValue}. Retained for
801
+ * v1 JSON→v2 conversion compatibility only.
802
+ */
803
+ [customKey: string]:
804
+ | Pattern
805
+ | Pattern[]
806
+ | PredicateFn
807
+ | WhenClause
808
+ | { pattern: Pattern; onUnknown?: "allow" | "block" }
809
+ | { pattern: Pattern | Pattern[]; onUnknown?: "allow" | "block" }
810
+ | unknown;
811
+ }
812
+
813
+ // ---------------------------------------------------------------------------
814
+ // Rule (discriminated union by `tool`)
815
+ // ---------------------------------------------------------------------------
816
+
817
+ /**
818
+ * Fields common to every tool-specific rule variant.
819
+ *
820
+ * `BaseRule` is the shared slice - everything except the `tool`
821
+ * discriminant and the tool-specific {@link BashRule.field} /
822
+ * {@link WriteRule.field} / {@link EditRule.field} sub-unions. The
823
+ * exported user-facing type is {@link Rule}, the discriminated union
824
+ * over the three tool variants; authors should reach for `Rule`
825
+ * unless they're writing generic rule-handling code that already
826
+ * knows the tool at its call site.
827
+ *
828
+ * Generic parameter `ObsName` constrains the string form of the
829
+ * {@link observer} field. {@link defineConfig} threads through the union
830
+ * of observer names gathered from plugins + inline observers, producing
831
+ * compile-time errors on typos. When authoring rules outside
832
+ * `defineConfig` (with plain `satisfies SteeringConfig`), the default
833
+ * `string` flows through and cross-reference checking is skipped.
834
+ *
835
+ * See ADR "Design → Rule schema".
836
+ */
837
+ export interface BaseRule<
838
+ ObsName extends string = string,
839
+ Writes extends string = string,
840
+ > {
841
+ /** Unique rule identifier. Used in override comments and audit logs. */
842
+ name: string;
843
+
844
+ /**
845
+ * Main match predicate. See {@link Pattern}. The rule fires only
846
+ * if this matches the chosen `field` value (for bash, the
847
+ * AST-extracted command string per ref).
848
+ */
849
+ pattern: Pattern;
850
+
851
+ /**
852
+ * Optional extra AND predicate - when provided, the rule fires
853
+ * only if this also matches. Accepts a pattern or a function so
854
+ * plugins can layer structured checks on top of the main match.
855
+ */
856
+ requires?: Pattern | PredicateFn;
857
+
858
+ /**
859
+ * Exemption predicate - when provided and matches, the rule does
860
+ * NOT fire. Same shape choice as {@link requires}.
861
+ */
862
+ unless?: Pattern | PredicateFn;
863
+
864
+ /**
865
+ * Composable predicate block. See {@link TopLevelWhenClause}.
866
+ *
867
+ * `Writes` is the union of session-entry event literals the rule's
868
+ * `when.happened.event` is allowed to reference. Threaded through by
869
+ * {@link defineConfig} from all declared `writes` arrays in scope.
870
+ *
871
+ * The five compile-time constraints from the not-block onUnknown
872
+ * design land here:
873
+ * 1. Each plugin-registered predicate is shape-checked against
874
+ * its `PiSteeringPredicates[K]` registry entry (bare /
875
+ * spreadBase). Typos / unknown predicates surface as
876
+ * compile errors at the rule definition.
877
+ * 2. Reserved keys (`not`, `onUnknown`, plus future modifiers)
878
+ * are dropped from the registry-driven mapped type via
879
+ * {@link PluginPredicateKey} so a plugin author can't shadow
880
+ * the operator/modifier surface.
881
+ * 3. Leaf-level `onUnknown:` inside `not:` is forbidden (the
882
+ * inner mapped type uses {@link InnerValue} which excludes
883
+ * modifiers — those live at the not-block top level).
884
+ * 4. `not: not:` recursion is forbidden
885
+ * ({@link TopLevelWhenClauseNoRecurse} has no `not?:` field).
886
+ * Belt-and-suspenders runtime guard in
887
+ * {@link validateWhenClauseShape} catches JSON / `as any`
888
+ * escape hatches.
889
+ * 5. Rule-level `onUnknown:` is forbidden
890
+ * ({@link TopLevelWhenClause} doesn't intersect with
891
+ * {@link PredicateModifiers}; only the inner `not:` body does).
892
+ */
893
+ when?: TopLevelWhenClause<Writes>;
894
+
895
+ /**
896
+ * Message shown to the agent when blocked.
897
+ *
898
+ * A plain string is the most common shape and should be actionable
899
+ * (e.g. "Use `git commit --no-verify` to bypass"). The evaluator
900
+ * prefixes every block reason with `[steering:<rule>@<source>] `
901
+ * so the agent sees which rule fired and where it came from
902
+ * (ADR §11).
903
+ *
904
+ * A {@link ReasonFn} is invoked with the same
905
+ * {@link PredicateContext} the predicates saw. Use the function
906
+ * form when the reason text depends on runtime state - e.g. the
907
+ * walker's effective cwd, a resolved branch name, or a count
908
+ * pulled from `ctx.findEntries`. The evaluator awaits the return
909
+ * and applies the source-tag prefix identically to the string form.
910
+ *
911
+ * Fail-safe on throw: if the reason function throws synchronously
912
+ * or its returned promise rejects, the evaluator logs the error
913
+ * with `console.warn` and emits a fallback message
914
+ * (`[steering:<rule>@<source>] (reason failed to format; see log)`).
915
+ * The block verdict still lands - a broken reason doesn't release
916
+ * the rule's guard or leak raw error text to the LLM.
917
+ *
918
+ * Tag→body separator is paragraph-aware: a body containing `\n\n`
919
+ * (or its CRLF equivalent `\r\n\r\n`, defensive against bodies
920
+ * imported from Windows line-ending sources — CRLF templating
921
+ * layers, hand-typed Windows-IDE strings) renders with the
922
+ * `[steering:...]` tag on its own line followed by a paragraph
923
+ * break (`\n\n`); otherwise the tag and body share a single
924
+ * space-separated line. The trigger is double-newline
925
+ * specifically — single `\n` characters inside an otherwise
926
+ * single-paragraph body keep the single-space layout. The emitted
927
+ * separator is always normalized to `\n\n` regardless of which
928
+ * form (`\n\n` or `\r\n\r\n`) triggered it. Multi-paragraph
929
+ * reasons get the prefix-on-its-own-line layout automatically
930
+ * without the rule author managing leading whitespace.
931
+ *
932
+ * Body→override-hint separator mirrors the same paragraph-aware
933
+ * rule. Single-paragraph bodies keep a single-space prefix on the
934
+ * override hint (byte-identical to the pre-paragraph-aware
935
+ * rendering); multi-paragraph bodies promote the override hint
936
+ * to its own paragraph (`${body}\n\n${hint}`) so a safety
937
+ * paragraph stays visually standalone rather than running on
938
+ * into an inline "To override" sentence.
939
+ */
940
+ reason: string | ReasonFn;
941
+
942
+ /**
943
+ * If `true`, no override escape hatch. If `false`, override always
944
+ * allowed. Omitted: falls back to
945
+ * {@link SteeringConfig.defaultNoOverride} (defaults to `true` -
946
+ * fail-closed).
947
+ */
948
+ noOverride?: boolean;
949
+
950
+ /**
951
+ * Observer to attach to this rule. The observer fires on matching
952
+ * `tool_result` events and can record per-turn state the rule
953
+ * consults via {@link PredicateContext.findEntries}.
954
+ *
955
+ * Either an inline {@link Observer} or a string referencing an
956
+ * observer registered on a plugin or at the config's top level.
957
+ * String references are constrained to the union of observer names
958
+ * known at {@link defineConfig} call sites (typo → compile error).
959
+ */
960
+ observer?: Observer | ObsName;
961
+
962
+ /**
963
+ * Session-entry custom types this rule's {@link onFire} may write.
964
+ *
965
+ * **Compile-time effect (via {@link defineConfig}):** the union of
966
+ * all `writes` literals declared across plugin rules, plugin
967
+ * observers, user rules, and user observers constrains the `event`
968
+ * field of every {@link BuiltInWhenLeavesOuter.happened} inside the same config.
969
+ * Declaring a write here makes it referenceable from
970
+ * `when.happened.event` anywhere in that config; omitting it leaves
971
+ * the string out of the union and downstream references to it are
972
+ * rejected as typos.
973
+ *
974
+ * **Authoring pattern.** Enforcement depends on TypeScript preserving
975
+ * the literal types of your `writes` arrays. Use one of:
976
+ * - `as const satisfies Rule` on a rule object literal, OR
977
+ * - `const satisfies Rule` on an object literal, OR
978
+ * - declaring the rule INSIDE the `defineConfig({ rules: [...] })`
979
+ * call so inference flows directly through the `const P`, `const R`
980
+ * generics.
981
+ *
982
+ * **Footgun: bare `: Rule` / `: Observer` / `: Plugin` annotations
983
+ * widen the literal `writes` array to `readonly string[]`. The engine
984
+ * can no longer project string-literal members, so `AllWrites`
985
+ * collapses to `never` - meaning EVERY `when.happened.event`
986
+ * reference in the config is rejected as a typo, not silently
987
+ * accepted.
988
+ *
989
+ * **Runtime effect:** none. `writes` is purely documentation +
990
+ * type-level plumbing - the engine does NOT verify that `onFire`
991
+ * only calls `ctx.appendEntry` with declared types.
992
+ *
993
+ * **Opt-out:** authors who build their config via
994
+ * `satisfies SteeringConfig` instead of `defineConfig` lose the
995
+ * compile-time check - the `SteeringConfig` shape defaults the
996
+ * {@link Rule} generics to `string`, so `when.happened.event` is
997
+ * unconstrained. `defineConfig` is the entry point that enforces.
998
+ *
999
+ * The wider warning - "name" / "plugin" literals widening to
1000
+ * `string` - causes the opposite failure: typos in `disabledRules`
1001
+ * / `disabledPlugins` start compiling silently. Always use
1002
+ * `as const satisfies` for reusable constants.
1003
+ */
1004
+ writes?: readonly string[];
1005
+
1006
+ /**
1007
+ * Side-effect hook invoked when the rule decides to fire (all
1008
+ * predicates passed) and BEFORE the block verdict is returned.
1009
+ *
1010
+ * Use for self-marking patterns where the rule's fire IS the event
1011
+ * (e.g. `cr-description-check` - first attempt per agent loop blocks
1012
+ * as reminder, self-marks via `onFire` so subsequent attempts pass).
1013
+ * Anything written via `ctx.appendEntry` gets auto-tagged with the
1014
+ * current `_agentLoopIndex` so a follow-up `when.happened:
1015
+ * { in: "agent_loop" }` check can detect it.
1016
+ *
1017
+ * Timing guarantees:
1018
+ * - Runs after `pattern` / `requires` / `unless` / `when` have all
1019
+ * evaluated favourably. If `when.cwd` or any other predicate
1020
+ * fails, the rule doesn't fire and `onFire` doesn't run.
1021
+ * - Runs for rules that will actually BLOCK. Rules suppressed by an
1022
+ * inline override comment do NOT trigger `onFire` - the agent
1023
+ * overrode the rule, so its side effects are bypassed too.
1024
+ * - Fail-closed rules (noOverride omitted or true) ignore override
1025
+ * comments entirely, so `onFire` runs on every fire even when
1026
+ * the agent wrote an override comment the engine rejected.
1027
+ *
1028
+ * Error handling: `onFire` is a best-effort side effect. If it
1029
+ * throws (sync) or its returned promise rejects, the engine logs
1030
+ * the error with `console.warn` and proceeds to return the block
1031
+ * verdict. The block is not affected by an `onFire` failure - the
1032
+ * block decision already passed every predicate, and a broken
1033
+ * self-mark must not invalidate it. Mirrors the observer
1034
+ * dispatcher's per-observer isolation.
1035
+ *
1036
+ * Async OK: the evaluator awaits.
1037
+ */
1038
+ onFire?: (ctx: PredicateContext) => void | Promise<void>;
1039
+ }
1040
+
1041
+ /**
1042
+ * Bash rule: gates pi's `bash` tool.
1043
+ *
1044
+ * `field` is constrained to `"command"` - the evaluator always runs
1045
+ * bash rules against the extracted command string per ref (see
1046
+ * `evaluator.ts` bash branch). There is no useful "test a bash rule
1047
+ * against a path" mode: bash has no path. `field: "path"` /
1048
+ * `field: "content"` on a bash rule silently misbehaved in the
1049
+ * previous (non-discriminated) schema; the union here makes the
1050
+ * mistake a compile error.
1051
+ *
1052
+ * Inside a rule's predicates / `onFire`, the context exposes the
1053
+ * extracted command plus `args` (quote-aware `Word[]`) and
1054
+ * `basename` - those are populated per-ref by the evaluator, not by
1055
+ * the rule author.
1056
+ */
1057
+ export interface BashRule<
1058
+ ObsName extends string = string,
1059
+ Writes extends string = string,
1060
+ > extends BaseRule<ObsName, Writes> {
1061
+ tool: "bash";
1062
+ field: "command";
1063
+ }
1064
+
1065
+ /**
1066
+ * Write rule: gates pi's `write` tool (whole-file writes).
1067
+ *
1068
+ * `field` picks the input slot the {@link pattern} tests against:
1069
+ * - `"path"` - the target path (regex-gate paths a file may be
1070
+ * written to).
1071
+ * - `"content"` - the full file contents the agent is writing.
1072
+ */
1073
+ export interface WriteRule<
1074
+ ObsName extends string = string,
1075
+ Writes extends string = string,
1076
+ > extends BaseRule<ObsName, Writes> {
1077
+ tool: "write";
1078
+ field: "path" | "content";
1079
+ }
1080
+
1081
+ /**
1082
+ * Edit rule: gates pi's `edit` tool (targeted oldText/newText patches).
1083
+ *
1084
+ * `field` picks the input slot the {@link pattern} tests against:
1085
+ * - `"path"` - the target path.
1086
+ * - `"content"` - the concatenated `newText` of every edit in the
1087
+ * tool call (evaluator joins with `\n`). This mirrors
1088
+ * `write.content` so authors can use one rule class
1089
+ * for both file surfaces.
1090
+ */
1091
+ export interface EditRule<
1092
+ ObsName extends string = string,
1093
+ Writes extends string = string,
1094
+ > extends BaseRule<ObsName, Writes> {
1095
+ tool: "edit";
1096
+ field: "path" | "content";
1097
+ }
1098
+
1099
+ /**
1100
+ * A single steering rule - discriminated union over the three
1101
+ * gatable tools. The `tool` discriminant determines which `field`
1102
+ * values are legal: bash rules test against `"command"`, write / edit
1103
+ * rules test against `"path"` or `"content"`. Invalid combinations
1104
+ * (`{ tool: "bash", field: "path" }`, `{ tool: "write", field:
1105
+ * "command" }`, ...) are TS errors.
1106
+ *
1107
+ * Shape refinements vs. v1:
1108
+ * - `pattern` accepts `RegExp` in addition to `string`.
1109
+ * - `requires` / `unless` accept `Pattern | PredicateFn`.
1110
+ * - `when` is a {@link TopLevelWhenClause} — registry-driven
1111
+ * mapped type with one level of `not:` allowed (no nested
1112
+ * `not: not: ...`).
1113
+ * - `observer` references an {@link Observer} by name (string) or
1114
+ * inline definition.
1115
+ * - `Rule` is a discriminated union by `tool`.
1116
+ *
1117
+ * See ADR "Design → Rule schema".
1118
+ */
1119
+ export type Rule<
1120
+ ObsName extends string = string,
1121
+ Writes extends string = string,
1122
+ > =
1123
+ | BashRule<ObsName, Writes>
1124
+ | WriteRule<ObsName, Writes>
1125
+ | EditRule<ObsName, Writes>;
1126
+
1127
+ // ---------------------------------------------------------------------------
1128
+ // Observer
1129
+ // ---------------------------------------------------------------------------
1130
+
1131
+ /**
1132
+ * Filter applied to `tool_result` events before {@link Observer.onResult}
1133
+ * runs. Omitted: the observer fires on every result the engine sees.
1134
+ *
1135
+ * See ADR "Design → Observer schema".
1136
+ */
1137
+ export interface ObserverWatch {
1138
+ /**
1139
+ * Only fire on results from this tool. Use to narrow observers that
1140
+ * only care about a specific tool surface (e.g. `read` results).
1141
+ */
1142
+ toolName?: "bash" | "read" | "write" | "edit" | (string & {});
1143
+
1144
+ /**
1145
+ * Per-field regex constraints on the tool INPUT. Observer fires only
1146
+ * when every listed field matches. Keys are tool-input field names
1147
+ * (e.g. `path` for `read`, `command` for `bash`).
1148
+ */
1149
+ inputMatches?: Record<string, Pattern>;
1150
+
1151
+ /**
1152
+ * Constrain by tool exit-code / success-failure classification.
1153
+ * - `"success"` / `"failure"` - string classification
1154
+ * - `number` - exact exit code match (bash)
1155
+ * - `"any"` - explicit no-filter
1156
+ */
1157
+ exitCode?: number | "success" | "failure" | "any";
1158
+ }
1159
+
1160
+ /**
1161
+ * Context passed to an observer's {@link Observer.onResult} callback.
1162
+ *
1163
+ * See ADR "Design → Observer schema".
1164
+ */
1165
+ export interface ObserverContext {
1166
+ /** Session cwd at the time the tool_result arrived. */
1167
+ cwd: string;
1168
+
1169
+ /**
1170
+ * Monotonically-increasing agent-loop counter maintained by the
1171
+ * engine. Bumped on each `agent_start` pi event (one agent loop =
1172
+ * one user prompt + its tool calls). Observers writing session
1173
+ * entries get this tag auto-injected into the payload so rules
1174
+ * using `when.happened` with `in: "agent_loop"` can filter by it.
1175
+ */
1176
+ agentLoopIndex: number;
1177
+
1178
+ /**
1179
+ * Append a typed entry into pi's session JSONL. Observers
1180
+ * typically use this to record "the agent did X in turn N" so
1181
+ * later predicates can gate on it via
1182
+ * {@link PredicateContext.findEntries}.
1183
+ */
1184
+ appendEntry: <T>(customType: string, data?: T) => void;
1185
+
1186
+ /**
1187
+ * Read all prior typed entries of the given custom type from pi's
1188
+ * session JSONL. Handy for observers that need to coalesce state
1189
+ * across turns (e.g. "has the agent read the CR description yet?").
1190
+ */
1191
+ findEntries: <T>(customType: string) => Array<{ data: T; timestamp: number }>;
1192
+ }
1193
+
1194
+ /**
1195
+ * A reactive hook: runs on `tool_result` events, typically to record
1196
+ * per-turn state for later predicates to consult.
1197
+ *
1198
+ * Observers are named + deduped (first-registered wins; later
1199
+ * declarations log a WARN). A rule may reference an observer by name
1200
+ * via {@link Rule.observer}, letting plugins ship reusable observers
1201
+ * and multiple rules share a single entry-producing observer.
1202
+ *
1203
+ * See ADR "Design → Observer schema" and "Precedence: first-wins
1204
+ * everywhere".
1205
+ */
1206
+ export interface Observer {
1207
+ /**
1208
+ * Unique name. Used for dedup across plugins + inline observers.
1209
+ * Referenced from {@link Rule.observer} as a string.
1210
+ */
1211
+ name: string;
1212
+
1213
+ /**
1214
+ * Session-entry custom types this observer's {@link onResult} may
1215
+ * write.
1216
+ *
1217
+ * **Compile-time effect (via {@link defineConfig}):** the union of
1218
+ * all `writes` literals declared across plugin rules, plugin
1219
+ * observers, user rules, and user observers constrains the `event`
1220
+ * field of every {@link BuiltInWhenLeavesOuter.happened} inside the same config.
1221
+ * Declaring a write here makes it referenceable from
1222
+ * `when.happened.event` anywhere in that config; omitting it leaves
1223
+ * the string out of the union and downstream references to it are
1224
+ * rejected as typos.
1225
+ *
1226
+ * **Authoring pattern.** See {@link Rule.writes} for the full
1227
+ * footgun note - TL;DR: use `as const satisfies Observer` on
1228
+ * reusable observer constants, or declare them inline inside
1229
+ * `defineConfig({ observers: [...] })`. Bare `: Observer` annotations
1230
+ * widen `writes` to `readonly string[]` and collapse `AllWrites` to
1231
+ * `never`, rejecting every `when.happened.event` reference.
1232
+ *
1233
+ * **Runtime effect:** none. `writes` is purely documentation +
1234
+ * type-level plumbing - the engine does NOT verify that `onResult`
1235
+ * only calls `ctx.appendEntry` with declared types.
1236
+ *
1237
+ * **Opt-out:** authors who build their config via
1238
+ * `satisfies SteeringConfig` instead of `defineConfig` lose the
1239
+ * compile-time check. `defineConfig` is the entry point that
1240
+ * enforces.
1241
+ */
1242
+ writes?: readonly string[];
1243
+
1244
+ /**
1245
+ * Filter narrowing which tool_result events trigger this observer.
1246
+ * Omitted: every tool_result fires onResult.
1247
+ */
1248
+ watch?: ObserverWatch;
1249
+
1250
+ /**
1251
+ * Called on every matching tool_result event. Typically writes an
1252
+ * entry via `ctx.appendEntry(customType, data)`; occasionally
1253
+ * performs side effects (logging). Must be idempotent - the same
1254
+ * event MAY fire the observer more than once across pi's
1255
+ * lifecycle (e.g. session restart mid-turn).
1256
+ */
1257
+ onResult: (
1258
+ event: ToolResultEvent,
1259
+ ctx: ObserverContext,
1260
+ ) => void | Promise<void>;
1261
+ }
1262
+
1263
+ /**
1264
+ * Shape of a tool_result event as observed by an {@link Observer}.
1265
+ *
1266
+ * Intentionally minimal: the fields the schema commits to are the
1267
+ * ones every tool_result carries. Tool-specific `input` / `output`
1268
+ * fields are `unknown` here - observer authors cast to the known
1269
+ * shape for the tool they're watching.
1270
+ */
1271
+ export interface ToolResultEvent {
1272
+ /** Tool name the result pertains to (e.g. `"bash"`, `"read"`). */
1273
+ toolName: string;
1274
+ /** Tool input as originally passed to the tool. Shape varies by tool. */
1275
+ input: unknown;
1276
+ /** Tool output / result payload. Shape varies by tool. */
1277
+ output: unknown;
1278
+ /** Exit code (bash) or undefined for non-command tools. */
1279
+ exitCode?: number;
1280
+ }
1281
+
1282
+ // ---------------------------------------------------------------------------
1283
+ // Predicate context
1284
+ // ---------------------------------------------------------------------------
1285
+
1286
+ /**
1287
+ * Tool input signature reduced to the fields a predicate may read.
1288
+ *
1289
+ * Predicates are tool-agnostic (the same predicate can gate bash, write,
1290
+ * or edit rules). `tool` tells the predicate which discriminator applies;
1291
+ * the evaluator populates whichever fields belong to that tool.
1292
+ *
1293
+ * Bash note (per ADR §9): `command`, `basename`, and `args` are
1294
+ * populated PER extracted command ref - a bash invocation of
1295
+ * `git push --force && ls` runs the predicate once per ref, with
1296
+ * `command: "git push --force"` (flattened for pattern matching),
1297
+ * `basename: "git"`, and `args: [<Word>, <Word>]` (suffix `Word[]`
1298
+ * with quote-aware `.value`). `rawCommand` and full AST node access
1299
+ * are deliberately NOT exposed - the wrapper context would be wrong
1300
+ * for inner refs, and AST walking belongs in plugin code that imports
1301
+ * unbash-walker directly.
1302
+ */
1303
+ export interface PredicateToolInput {
1304
+ tool: "bash" | "write" | "edit";
1305
+ /** bash: flattened `basename + args` string, per extracted ref. */
1306
+ command?: string;
1307
+ /**
1308
+ * bash: extracted ref basename (e.g. `"git"` for `/usr/bin/git`).
1309
+ * Sugar over `command.split(/\s+/)[0]` that handles path stripping
1310
+ * correctly. Undefined for non-bash tools.
1311
+ */
1312
+ basename?: string;
1313
+ /**
1314
+ * bash: suffix `Word[]` for the extracted ref - quote-aware
1315
+ * structured access with `.value` giving the lexical value and
1316
+ * `.text` the raw source. Prefer this over splitting `command`
1317
+ * when the predicate needs to preserve quoting (e.g. reading a
1318
+ * `-m "conventional: subject"` message without munging spaces).
1319
+ *
1320
+ * Sourced from `CommandRef.node.suffix` via unbash-walker; the
1321
+ * walker already parses into Word[] so we expose it directly.
1322
+ * Undefined for non-bash tools.
1323
+ */
1324
+ args?: readonly Word[];
1325
+ /**
1326
+ * bash: shell env-assignment prefix for the extracted ref -
1327
+ * `AWS_PROFILE=dev aws s3 ls` exposes `[W("AWS_PROFILE=dev")]`
1328
+ * here (with `args` still `[W("s3"), W("ls")]`). Multiple
1329
+ * assignments come through in source order. Enables plugins to
1330
+ * inspect shell env vars via structured access instead of
1331
+ * regex-on-raw-command.
1332
+ *
1333
+ * Sourced from `CommandRef.node.prefix` via unbash-walker. Each
1334
+ * prefix element is projected into a `Word` whose `.text` preserves
1335
+ * the full `KEY=VALUE` source token (with quoting, if any);
1336
+ * consumers split on `=` to separate key from value. Dynamic
1337
+ * values like `A=$VAR` come through as-is - the token syntax is
1338
+ * visible in `.text`, so callers can detect the expansion
1339
+ * themselves.
1340
+ *
1341
+ * Always an empty array for `write` / `edit` tools (shell env
1342
+ * assignments don't apply to file-surface tools); shaped as
1343
+ * `[]` rather than `undefined` so plugin authors can treat the
1344
+ * field uniformly.
1345
+ */
1346
+ envAssignments?: readonly Word[];
1347
+ /** write / edit: the target path. */
1348
+ path?: string;
1349
+ /** write: the file content being written. */
1350
+ content?: string;
1351
+ /** edit: the replacement edits. Shape preserved from pi's edit tool. */
1352
+ edits?: ReadonlyArray<{ oldText: string; newText: string }>;
1353
+ }
1354
+
1355
+ /**
1356
+ * Options forwarded to {@link PredicateContext.exec} - narrow surface
1357
+ * over child_process, scoped to the handful of knobs predicates need.
1358
+ */
1359
+ export interface ExecOpts {
1360
+ /** Working directory. Defaults to the session cwd. */
1361
+ cwd?: string;
1362
+ /** Max runtime in ms. Predicates should cap this. */
1363
+ timeoutMs?: number;
1364
+ }
1365
+
1366
+ /**
1367
+ * Return value of {@link PredicateContext.exec}.
1368
+ */
1369
+ export interface ExecResult {
1370
+ stdout: string;
1371
+ stderr: string;
1372
+ exitCode: number;
1373
+ }
1374
+
1375
+ /**
1376
+ * Shape of the walker-state snapshot the evaluator populates on
1377
+ * {@link PredicateContext.walkerState} for bash rules. Consumed by
1378
+ * the built-in `when.cwd` / `when.happened` predicates and by
1379
+ * plugin-authored predicates that read per-ref tracker state.
1380
+ *
1381
+ * All fields are read-only. The evaluator assembles a fresh object
1382
+ * per extracted command ref - mutation has no effect on subsequent
1383
+ * refs or on any persisted state.
1384
+ *
1385
+ * The type is open-ended (`readonly [key: string]: unknown`) because
1386
+ * plugins register new trackers at config-build time; the schema
1387
+ * can't commit to the complete key set. Plugin authors documenting
1388
+ * their own predicates should narrow via their tracker's known
1389
+ * value type (e.g. the git plugin's branch predicate reads
1390
+ * `ctx.walkerState.branch` as a string | "unknown" sentinel).
1391
+ *
1392
+ * For `write` / `edit` rules there's no walker invocation and
1393
+ * {@link PredicateContext.walkerState} is `undefined`. Bash rules
1394
+ * always see at minimum `{ cwd, env, events }`.
1395
+ */
1396
+ export interface WhenWalkerState {
1397
+ /**
1398
+ * Effective cwd at this ref, per the walker's `cwdTracker`. For
1399
+ * dynamic cd targets the walker couldn't resolve statically
1400
+ * (unknown `$VAR`, command substitution, arithmetic), this is the
1401
+ * literal string `"unknown"` - the cwdTracker's sentinel. The
1402
+ * built-in `when.cwd` predicate applies its `onUnknown: 'allow' |
1403
+ * 'block'` policy on that sentinel (default `'block'`, fail-
1404
+ * closed). Plugin predicates reading this field directly should
1405
+ * check for the sentinel before pattern-matching, or use the
1406
+ * sugar form `when.cwd: { pattern, onUnknown }` via the engine.
1407
+ */
1408
+ readonly cwd: string;
1409
+
1410
+ /**
1411
+ * Env map at this ref, per the walker's `envTracker`. Carries
1412
+ * statically-resolved bare assignments (`FOO=bar`), `export`
1413
+ * writes, and `unset` deletions from the current scope, seeded
1414
+ * from `process.env.{HOME, USER, PWD}` at tracker initialization.
1415
+ *
1416
+ * Plugin predicates consume this to expand `$VAR` / `~` in
1417
+ * user-supplied patterns, or to implement a `when.envVar`-style
1418
+ * predicate. Read via `ctx.walkerState.env.get("NAME")`. Returns
1419
+ * `undefined` for any name the walker hasn't seen - callers apply
1420
+ * their own fallback (or route through the `resolveWord` helper
1421
+ * re-exported from the package root for word-level resolution).
1422
+ */
1423
+ readonly env: EnvState;
1424
+
1425
+ /**
1426
+ * Additional tracker-registered fields (e.g. the git plugin's
1427
+ * `branch`) and reserved keys (`events`). Indexed loosely so
1428
+ * plugins adding new trackers don't need a schema amendment.
1429
+ */
1430
+ readonly [key: string]: unknown;
1431
+ }
1432
+
1433
+ /**
1434
+ * Context passed to a predicate (either {@link PredicateFn} or a
1435
+ * plugin's {@link PredicateHandler}).
1436
+ *
1437
+ * Rationale per ADR "Design → Predicate context":
1438
+ * - `cwd`, `tool`, `input` - what the agent is about to do.
1439
+ * - `agentLoopIndex` - engine-maintained counter bumped on each
1440
+ * pi `agent_start` event (one agent loop = one user prompt + its
1441
+ * tool calls). Rules gate "since the user's last message" state
1442
+ * by comparing entries' auto-tagged `_agentLoopIndex` against
1443
+ * `ctx.agentLoopIndex`, which is what `when.happened` with
1444
+ * `in: "agent_loop"` does internally.
1445
+ * - `exec` - shell escape hatch. The evaluator memoizes results per
1446
+ * `(cmd, args, cwd)` within a single tool_call; no cross-call cache.
1447
+ * This schema commits to the TYPE only - memoization is the
1448
+ * evaluator's concern (Phase 3).
1449
+ * - `appendEntry` / `findEntries` - pi's session JSONL mirror of
1450
+ * what observers write. Predicates consult prior entries to
1451
+ * implement turn-state checks.
1452
+ */
1453
+ export interface PredicateContext {
1454
+ /** Session cwd (or, for bash rules, the effective cwd of the command). */
1455
+ cwd: string;
1456
+
1457
+ /** Which pi tool is being gated. */
1458
+ tool: "bash" | "write" | "edit";
1459
+
1460
+ /** Tool input - evaluator populates whichever fields apply to `tool`. */
1461
+ input: PredicateToolInput;
1462
+
1463
+ /**
1464
+ * Engine-maintained agent-loop counter (bumped on each pi
1465
+ * `agent_start` event). See {@link ObserverContext.agentLoopIndex}.
1466
+ */
1467
+ agentLoopIndex: number;
1468
+
1469
+ /**
1470
+ * Run a command and return its result. Memoized by the evaluator
1471
+ * per `(cmd, args, cwd)` within a single tool_call evaluation.
1472
+ *
1473
+ * Stability guarantee: across rules evaluated for the SAME
1474
+ * tool_call, identical `(cmd, args, cwd)` tuples return the same
1475
+ * ExecResult without re-executing. Across tool_calls, no cache -
1476
+ * the world can change between turns.
1477
+ */
1478
+ exec: (cmd: string, args: string[], opts?: ExecOpts) => Promise<ExecResult>;
1479
+
1480
+ /**
1481
+ * Append a typed entry into pi's session JSONL. Parallels
1482
+ * {@link ObserverContext.appendEntry} so predicates can record
1483
+ * decisions (though typically writing is an observer's job).
1484
+ */
1485
+ appendEntry: <T>(customType: string, data?: T) => void;
1486
+
1487
+ /**
1488
+ * Read all prior typed entries of the given custom type. Used for
1489
+ * turn-state predicates.
1490
+ */
1491
+ findEntries: <T>(customType: string) => Array<{ data: T; timestamp: number }>;
1492
+
1493
+ /**
1494
+ * Walker state snapshot for the command being evaluated. Populated
1495
+ * only for bash rules - the walker runs once per tool_call over the
1496
+ * full command and produces a per-ref snapshot of every registered
1497
+ * tracker (`cwd`, `env`, plus plugin-registered dimensions like
1498
+ * `branch`). For `write` / `edit` rules there is no walker, so this
1499
+ * is `undefined`.
1500
+ *
1501
+ * Plugin predicates consult `walkerState[<tracker-name>]` to read
1502
+ * statically-resolved values (branch after `git checkout X`, cwd
1503
+ * after `cd /path`, env after `FOO=bar` / `export FOO=bar`) without
1504
+ * re-running the tracker's work. When the tracker can't resolve
1505
+ * statically the value is the tracker's `unknown` sentinel -
1506
+ * handlers apply their `onUnknown` policy.
1507
+ *
1508
+ * Typed as {@link WhenWalkerState} (open-ended string-indexed) so
1509
+ * the schema commits to the two built-in keys (`cwd`, `env`) plus
1510
+ * the reserved `events` slot while leaving room for plugin
1511
+ * extensions.
1512
+ *
1513
+ * Reserved key `events`: `Record<customType, SyntheticEntry[]>`.
1514
+ * Populated by the walker-level speculative-entry synthesis pass
1515
+ * (see `evaluator-internals/speculative-synthesis.ts`). Carries
1516
+ * per-ref speculative entries representing "events about to happen"
1517
+ * via continuous `&&` chains from observers' `writes:` declarations.
1518
+ * Each entry carries a `{ data, timestamp, speculative: true }`
1519
+ * shape; timestamps are in a reserved range (above any real entry)
1520
+ * monotonic in AST order. The built-in `when.happened` predicate
1521
+ * merges these with real entries via timestamp comparison;
1522
+ * plugin-authored predicates can opt out by filtering
1523
+ * `e.speculative === true`. Trackers cannot claim the `events`
1524
+ * key - plugin registration rejects it as reserved.
1525
+ */
1526
+ walkerState?: Readonly<WhenWalkerState>;
1527
+ }
1528
+
1529
+ // ---------------------------------------------------------------------------
1530
+ // Plugin
1531
+ // ---------------------------------------------------------------------------
1532
+
1533
+ /**
1534
+ * A plugin - distribution unit for rule packs and extension points.
1535
+ *
1536
+ * Plugins register zero or more of:
1537
+ * - {@link predicates} - new `when.<key>` slots
1538
+ * - {@link rules} - bundled rules users can enable/disable
1539
+ * - {@link observers} - reusable observer definitions
1540
+ * - {@link trackers} - new walker state dimensions
1541
+ * - {@link trackerExtensions} - modifiers for existing trackers
1542
+ *
1543
+ * See ADR "Design → Plugin schema". Plugin loading precedence is
1544
+ * "first-wins" (project-local → user's `plugins` array → built-in
1545
+ * defaults); name collisions on predicates / rules / observers /
1546
+ * tracker-extensions log a WARN and keep the first-registered entry.
1547
+ * Tracker-*name* collisions are a hard error - two plugins claiming the
1548
+ * same state dimension is always a bug.
1549
+ */
1550
+ export interface Plugin {
1551
+ /** Unique plugin identifier. Used for `disabledPlugins` + warning messages. */
1552
+ name: string;
1553
+
1554
+ /**
1555
+ * Predicate handlers keyed by the `when.<key>` slot they register.
1556
+ * See {@link PredicateHandler}.
1557
+ */
1558
+ predicates?: Record<string, AnyPredicateHandler>;
1559
+
1560
+ /** Rules the plugin suggests. Users can opt out via `disabledRules: [...]`. */
1561
+ rules?: readonly Rule[];
1562
+
1563
+ /** Observers the plugin ships. Referenced by name from rules. */
1564
+ observers?: readonly Observer[];
1565
+
1566
+ /**
1567
+ * NEW trackers the plugin introduces. Keys are tracker names (e.g.
1568
+ * `branch`). A name collision between plugins is a hard error.
1569
+ */
1570
+ trackers?: Record<string, Tracker<unknown>>;
1571
+
1572
+ /**
1573
+ * Modifiers added to an EXISTING tracker. Outer key is the tracker
1574
+ * name (e.g. `cwd`), inner key is the command basename the modifier
1575
+ * triggers on (e.g. `git` - to register a `--git-dir=...` parser on
1576
+ * top of the built-in cwd tracker).
1577
+ *
1578
+ * The inner value accepts either a single {@link Modifier} or a
1579
+ * readonly array of them, mirroring {@link Tracker.modifiers} on the
1580
+ * walker side. Plugins can register multiple modifiers under one
1581
+ * `(tracker, basename)` pair - e.g. distinct parsers for different
1582
+ * subcommands of the same CLI that all share a basename.
1583
+ *
1584
+ * Collisions on a `(tracker, basename)` pair log a WARN and keep
1585
+ * the first-registered entry.
1586
+ *
1587
+ * Typed as `unknown` at this schema level - concrete plugins
1588
+ * declare their own modifier types tied to the tracker value they
1589
+ * extend.
1590
+ */
1591
+ trackerExtensions?: Record<
1592
+ string,
1593
+ Record<
1594
+ string,
1595
+ | import("@cad0p/unbash-walker").Modifier<unknown>
1596
+ | readonly import("@cad0p/unbash-walker").Modifier<unknown>[]
1597
+ >
1598
+ >;
1599
+ }
1600
+
1601
+ // ---------------------------------------------------------------------------
1602
+ // SteeringConfig
1603
+ // ---------------------------------------------------------------------------
1604
+
1605
+ /**
1606
+ * Top-level v2 config shape. What a user's `.pi/steering.ts` /
1607
+ * `.pi/steering/index.ts` file default-exports (possibly via
1608
+ * {@link defineConfig} or `satisfies SteeringConfig`).
1609
+ *
1610
+ * The loader walks up from the session cwd to `$HOME`, collects every
1611
+ * layer, and merges them into a single effective config with inner
1612
+ * (closer to cwd) layers taking precedence on name collisions.
1613
+ *
1614
+ * The five array-typed fields (`disabledRules`, `disabledPlugins`,
1615
+ * `plugins`, `rules`, `observers`) are all `readonly`. This is load-
1616
+ * bearing: {@link DefineConfigInput} extends `SteeringConfig` and
1617
+ * narrows each array field with a const-generic-aware subtype (e.g.
1618
+ * `disabledRules` becomes `readonly AllRuleNames<P, R>[]`). TypeScript's
1619
+ * interface-extension check runs on assignability, and `readonly T[]`
1620
+ * is not assignable to `T[]` — demoting any of these fields to a
1621
+ * mutable array would break every typed-name override on
1622
+ * `DefineConfigInput`. The compiler will catch the regression at
1623
+ * the `extends` site, but the choice originates here.
1624
+ *
1625
+ * See ADR "Design → File layout and loader behavior" and
1626
+ * "Design → Override default and `onUnknown`".
1627
+ */
1628
+ export interface SteeringConfig {
1629
+ /**
1630
+ * Default value for {@link Rule.noOverride} when a rule doesn't
1631
+ * specify its own. Defaults to `true` (fail-closed - overrides
1632
+ * must be explicit opt-in per rule).
1633
+ *
1634
+ * Walk-up merge: inner layer wins when specified; missing layer
1635
+ * leaves the running value alone.
1636
+ *
1637
+ * `buildConfig` preserves `undefined` in the merged output so
1638
+ * downstream evaluators can distinguish "user didn't specify" from
1639
+ * "user explicitly chose false". The fail-closed `?? true` coercion
1640
+ * happens at evaluator time.
1641
+ *
1642
+ * See ADR "Design → Override default".
1643
+ */
1644
+ defaultNoOverride?: boolean;
1645
+
1646
+ /**
1647
+ * Rules to disable by name. Additive union across layers.
1648
+ *
1649
+ * Past-participle form (`disabledRules`) reads as a predicate on
1650
+ * state - "these are the rules that are disabled." Distinct from
1651
+ * the imperative flag {@link disableDefaults} (action: disable
1652
+ * the default plugins + rules).
1653
+ *
1654
+ * Disabling a rule is by-design behavior, not a configuration
1655
+ * issue — it does NOT contribute to the diagnostic stream. See
1656
+ * {@link SteeringDiagnosticKind} for the by-design-vs-issue carveout.
1657
+ *
1658
+ * **Navigation note:** these are string literals projected from
1659
+ * `DEFAULT_RULES` (engine defaults), `plugin.rules[*].name`, and
1660
+ * inline `rules[*].name`. Ctrl+Click on a literal jumps to the
1661
+ * `AllRuleNames` union, NOT the rule's source — TypeScript-language
1662
+ * limitation on string-literal union members. To inspect a shipped
1663
+ * default's `reason` / `pattern`, import `DEFAULT_RULES` directly:
1664
+ *
1665
+ * ```ts
1666
+ * import { DEFAULT_RULES } from "@cad0p/pi-steering";
1667
+ * // hover DEFAULT_RULES[0] to see the rule body
1668
+ * ```
1669
+ */
1670
+ disabledRules?: readonly string[];
1671
+
1672
+ /**
1673
+ * Plugins to disable by name. Additive union across layers.
1674
+ * A disabled plugin contributes NOTHING - no rules, no observers,
1675
+ * no predicates, no trackers.
1676
+ *
1677
+ * Disabling a plugin is by-design behavior, not a configuration
1678
+ * issue — it does NOT contribute to the diagnostic stream. See
1679
+ * {@link SteeringDiagnosticKind} for the by-design-vs-issue carveout.
1680
+ *
1681
+ * **Navigation note:** same TypeScript-language limitation as
1682
+ * {@link disabledRules} — Ctrl+Click on a string literal jumps to
1683
+ * the `AllPluginNames` union, not the plugin's source. To inspect a
1684
+ * shipped default plugin, import `DEFAULT_PLUGINS` directly:
1685
+ *
1686
+ * ```ts
1687
+ * import { DEFAULT_PLUGINS } from "@cad0p/pi-steering";
1688
+ * // hover DEFAULT_PLUGINS[0] to see the plugin body
1689
+ * ```
1690
+ */
1691
+ disabledPlugins?: readonly string[];
1692
+
1693
+ /**
1694
+ * Skip the package's built-in default plugins + default rules.
1695
+ * Handy for isolated test harnesses or strict minimal configs.
1696
+ *
1697
+ * Kept in imperative form (action flag: "disable the defaults")
1698
+ * to distinguish shape at a glance from the past-participle
1699
+ * {@link disabledRules} / {@link disabledPlugins} lists.
1700
+ *
1701
+ * Walk-up merge: inner layer wins when specified.
1702
+ */
1703
+ disableDefaults?: boolean;
1704
+
1705
+ /**
1706
+ * Strict-mode opt-out. When `true` (default), any warning-class
1707
+ * {@link SteeringDiagnostic} produced while loading the config
1708
+ * escalates to a thrown error that disables the bridge for the
1709
+ * session. When explicitly set to `false`, warnings fall through
1710
+ * to `console.warn` and the bridge keeps running with whatever
1711
+ * subset of plugins / rules / observers loaded successfully.
1712
+ *
1713
+ * Error-class diagnostics ALWAYS throw regardless of this flag
1714
+ * (e.g. tracker name collision, reserved name violation) — the
1715
+ * engine cannot operate safely with those issues present.
1716
+ *
1717
+ * Walk-up merge: inner layer wins when specified, identical to
1718
+ * {@link disableDefaults}.
1719
+ *
1720
+ * Prior art: Rollup's `failAfterWarnings`, Maven's `failOnWarning`.
1721
+ */
1722
+ failOnWarnings?: boolean;
1723
+
1724
+ /** Plugins to load. Order matters for first-wins name collisions. */
1725
+ plugins?: readonly Plugin[];
1726
+
1727
+ /** User-authored rules. */
1728
+ rules?: readonly Rule[];
1729
+
1730
+ /** Inline observers (rules reference by name). */
1731
+ observers?: readonly Observer[];
1732
+ }
1733
+
1734
+ // ---------------------------------------------------------------------------
1735
+ // SteeringDiagnostic
1736
+ // ---------------------------------------------------------------------------
1737
+
1738
+ /**
1739
+ * Discriminator categorizing what kind of issue a diagnostic
1740
+ * describes. Stable across versions so tooling and tests can dispatch
1741
+ * on `kind` without parsing the human-readable {@link
1742
+ * SteeringDiagnostic.message}.
1743
+ *
1744
+ * The set is split between two surfaces:
1745
+ * - LOADER (`layer-form-coexistence`, `layer-import-failed`,
1746
+ * `layer-stray-file`, `plugin-name-collision`,
1747
+ * `rule-name-collision`, `observer-name-collision`,
1748
+ * `tracker-name-collision`) — produced while walking up the
1749
+ * filesystem, importing per-layer config files, and merging
1750
+ * layers into a single effective config. The collision kinds in
1751
+ * this group flag duplicates surfaced during layer merge — most
1752
+ * are user-authored (`plugin-name-collision`, `rule-name-collision`,
1753
+ * `observer-name-collision`), but `tracker-name-collision` flags
1754
+ * duplicate plugin-shipped trackers when those plugins surface
1755
+ * together via the merge.
1756
+ * - PLUGIN-MERGER (`predicate-collision`, `observer-collision`,
1757
+ * `rule-collision`, `extension-orphan`, `reserved-tracker-name`,
1758
+ * `reserved-predicate-key`, `invalid-name`) — produced while
1759
+ * resolving plugin shapes into the runtime registry. The
1760
+ * collision kinds in this group flag duplicates among
1761
+ * plugin-author-shipped declarations across the active plugin
1762
+ * set; `invalid-name` flags a plugin / rule / observer name
1763
+ * containing characters that are disallowed in source-tagged
1764
+ * block reasons.
1765
+ *
1766
+ * Disabling a plugin via `config.disabledPlugins` or a plugin-shipped
1767
+ * rule via `config.disabledRules` is by-design behavior, not a
1768
+ * configuration issue, so neither contributes to the diagnostic
1769
+ * stream. Both surface as `console.info` breadcrumbs from
1770
+ * `resolvePlugins` for plugin authors debugging "why isn't my plugin
1771
+ * firing?" — mirrors the unused-observer drop pattern in
1772
+ * `internal/session-runtime.ts`.
1773
+ */
1774
+ export type SteeringDiagnosticKind =
1775
+ /**
1776
+ * Both `.pi/steering/index.ts` AND `.pi/steering.ts` exist at the
1777
+ * same directory. The directory form wins; the flat form is
1778
+ * ignored. Almost always a forgotten cleanup; delete the unused
1779
+ * file to silence the diagnostic.
1780
+ */
1781
+ | "layer-form-coexistence"
1782
+ /**
1783
+ * A layer's `.pi/steering/index.ts` (or `.pi/steering.ts`) was
1784
+ * found on disk but its dynamic import threw — typically a syntax
1785
+ * error or a missing default export. The layer is skipped; outer
1786
+ * layers continue to load.
1787
+ */
1788
+ | "layer-import-failed"
1789
+ /**
1790
+ * A non-`.ts` file lives under `<dir>/.pi/steering/` (e.g.
1791
+ * `rules.json`, `rules.mjs`). Helpers ending in `.ts` are allowed;
1792
+ * other extensions are flagged so the user can rename or delete
1793
+ * the stray file.
1794
+ */
1795
+ | "layer-stray-file"
1796
+ /**
1797
+ * Two layers register a plugin with the same `name`. The inner
1798
+ * (closer to cwd) layer wins; the outer layer's plugin is dropped.
1799
+ */
1800
+ | "plugin-name-collision"
1801
+ /**
1802
+ * A single layer declares two rules under the same `name`. The
1803
+ * first-declared rule survives; subsequent duplicates are dropped.
1804
+ * Cross-layer rule shadowing is intentional and not flagged.
1805
+ */
1806
+ | "rule-name-collision"
1807
+ /**
1808
+ * A single layer declares two observers under the same `name`.
1809
+ * The first-declared observer survives; subsequent duplicates are
1810
+ * dropped. Cross-layer observer shadowing is intentional and not
1811
+ * flagged.
1812
+ */
1813
+ | "observer-name-collision"
1814
+ /**
1815
+ * Two plugins both register a tracker under the same name. Always
1816
+ * an error — two plugins claiming the same state dimension is a
1817
+ * bug, not a soft override. Rename one tracker or disable one
1818
+ * plugin.
1819
+ */
1820
+ | "tracker-name-collision"
1821
+ /**
1822
+ * Two plugins both register a predicate handler under the same
1823
+ * `when.<key>`. The first-registered handler wins; the later
1824
+ * plugin's handler is dropped.
1825
+ */
1826
+ | "predicate-collision"
1827
+ /**
1828
+ * Two plugins both register an observer with the same `name`.
1829
+ * The first-registered observer wins; the later plugin's observer
1830
+ * is dropped. Distinct from `observer-name-collision` which
1831
+ * applies to within-layer duplicates in user-authored config.
1832
+ */
1833
+ | "observer-collision"
1834
+ /**
1835
+ * Two plugins both ship a rule with the same `name`. The
1836
+ * first-registered rule wins; the later plugin's rule is dropped.
1837
+ * Distinct from `rule-name-collision` which applies to
1838
+ * within-layer duplicates in user-authored config.
1839
+ */
1840
+ | "rule-collision"
1841
+ /**
1842
+ * A plugin's `trackerExtensions` references a tracker name that
1843
+ * no plugin (and no built-in walker tracker) registers. The
1844
+ * extension is ignored.
1845
+ */
1846
+ | "extension-orphan"
1847
+ /**
1848
+ * A plugin attempts to register a tracker under a reserved name
1849
+ * (e.g. `events`). Always an error — reserved names are owned by
1850
+ * the engine. Rename the tracker.
1851
+ */
1852
+ | "reserved-tracker-name"
1853
+ /**
1854
+ * A plugin attempts to register a predicate handler under a
1855
+ * reserved key (an operator field like `not` or a modifier key
1856
+ * like `onUnknown`). Always an error — reserved keys collide
1857
+ * with the schema's operator/modifier surface. Rename the
1858
+ * predicate.
1859
+ */
1860
+ | "reserved-predicate-key"
1861
+ /**
1862
+ * A plugin / rule / observer name contains characters that are
1863
+ * disallowed in the `[steering:<name>@<source>]` block-reason
1864
+ * tag shown to the LLM, in `disabledRules` / `disabledPlugins`
1865
+ * config references, or in override-comment targets. Always an
1866
+ * error — names flow into user-visible strings and a malformed
1867
+ * (or maliciously-crafted) name lets a config author forge
1868
+ * block reasons that deceive the agent. Allowed: letters,
1869
+ * digits, underscores, dashes; must start with a letter or
1870
+ * digit. Rename the offending object in source.
1871
+ */
1872
+ | "invalid-name";
1873
+
1874
+ /**
1875
+ * Structured issue surfaced while loading a steering config.
1876
+ *
1877
+ * Diagnostics flow up from the loader and the plugin merger into the
1878
+ * bridge runtime, which decides whether to throw or log per the user's
1879
+ * strict-mode preference. The shape is stable so tests and future
1880
+ * tooling can dispatch on {@link kind} without scanning {@link message}
1881
+ * substrings.
1882
+ *
1883
+ * Channel-ownership split (loader / merger vs. runtime). Diagnostics
1884
+ * captured in this stream are by-design surfaced to the strict-mode
1885
+ * runtime so it can decide whether to throw or pass through to
1886
+ * `console.warn`. The loader (`loader.ts`) does not call
1887
+ * `console.*` directly — the runtime owns the policy decision.
1888
+ * However, by-design info breadcrumbs that are NOT configuration
1889
+ * issues (`disabledPlugins` and `disabledRules` opt-outs from
1890
+ * `resolvePlugins`, dropped-observer notices from
1891
+ * `dropUnusedObservers`) go directly to `console.info` from where
1892
+ * they're produced. They're not in this kind union because they
1893
+ * describe normal behavior the user opted into, not problems that
1894
+ * need actioning.
1895
+ *
1896
+ * Render-format matrix — the same diagnostic surfaces in two
1897
+ * shapes depending on which renderer the runtime picks:
1898
+ *
1899
+ * - Multi-line aggregate (thrown `Error` from `buildSessionRuntime`):
1900
+ * a header line ("N config issues:") followed by a per-line bullet
1901
+ * ` - [type] <path: >?<message>`. One `Error.message`, multi-line.
1902
+ * Used when at least one diagnostic must abort the session.
1903
+ * Produced by `formatAggregatedDiagnostics`.
1904
+ * - Single-line per-diagnostic (`formatSingleLineDiagnostic`):
1905
+ * `[pi-steering] [<severity>] <path: >?<message>` per diagnostic.
1906
+ * Severity tag (`[error]` / `[warning]`) follows the same
1907
+ * bracketed convention as the multi-line aggregate's per-line
1908
+ * bullets. Routed to `console.warn` for legacy fail-soft mode
1909
+ * (`failOnWarnings: false`). Only warnings reach this route in
1910
+ * practice — error-class diagnostics escalate to a thrown error
1911
+ * via the aggregated form before warnings are flushed. Also
1912
+ * routed to stderr for the CLI `pi-steering list` pre-flight
1913
+ * surface (both warnings and errors render here; the bracketed
1914
+ * severity tag distinguishes them). The function itself accepts
1915
+ * both severities; the warnings-only narrowing is a property of
1916
+ * the `console.warn` route's caller, not the formatter.
1917
+ *
1918
+ * The CLI prints diagnostics inline as the loader yields them, rather
1919
+ * than aggregating into a thrown error — the single-line shape
1920
+ * gives `pi-steering list` users immediate per-issue feedback.
1921
+ */
1922
+ export interface SteeringDiagnostic {
1923
+ /**
1924
+ * Severity of the diagnostic.
1925
+ *
1926
+ * - `"warning"` — informational; safe to ignore in legacy
1927
+ * fail-soft mode.
1928
+ * - `"error"` — pi-steering cannot operate safely with this
1929
+ * issue present (e.g. tracker name collision); always escalates
1930
+ * to a thrown error regardless of the user's strict-mode
1931
+ * preference.
1932
+ */
1933
+ type: "warning" | "error";
1934
+
1935
+ /** Discriminator for programmatic dispatch and test assertions. */
1936
+ kind: SteeringDiagnosticKind;
1937
+
1938
+ /** Agent-facing message; includes context like layer path or names. */
1939
+ message: string;
1940
+
1941
+ /**
1942
+ * Source path, when applicable. Per kind:
1943
+ * - `layer-import-failed`: the source file the loader couldn't import.
1944
+ * - `layer-stray-file`: the stray file under `.pi/steering/`.
1945
+ * - `layer-form-coexistence`: the directory holding both forms
1946
+ * (`.pi/steering.ts` AND `.pi/steering/index.ts`); the dir is
1947
+ * intentional rather than picking one of the two coexisting files
1948
+ * arbitrarily.
1949
+ * - Within-layer collisions (`rule-name-collision`,
1950
+ * `observer-name-collision`): unset (the diagnostic names the
1951
+ * offending object inside `message`).
1952
+ * - Cross-layer collisions and plugin-shipped diagnostics
1953
+ * (`plugin-name-collision`, `tracker-name-collision`,
1954
+ * `predicate-collision`, `observer-collision`, `rule-collision`,
1955
+ * `extension-orphan`, `reserved-tracker-name`,
1956
+ * `reserved-predicate-key`, `invalid-name`): unset by design.
1957
+ * These diagnostics name the participants (layer paths or
1958
+ * plugin names) inside `message` because there is no single
1959
+ * source path — the collision spans multiple layers or
1960
+ * plugins.
1961
+ */
1962
+ path?: string;
1963
+ }