@cad0p/pi-steering 0.1.0

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