@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,290 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * v2 observer dispatcher.
6
+ *
7
+ * Observers are the `tool_result` side of the steering engine: rules
8
+ * decide "can this run" pre-execution; observers record "what happened"
9
+ * post-execution. Typical use is to `appendEntry` into pi's session
10
+ * JSONL so later predicates can gate on prior turn state (the
11
+ * "description was read in a PRIOR turn" idiom from the ADR).
12
+ *
13
+ * This module merges user-declared observers (from
14
+ * {@link SteeringConfig.observers}) with plugin-shipped ones (from
15
+ * {@link ResolvedPluginState.observers}), then on every `tool_result`:
16
+ *
17
+ * 1. Applies the observer's `watch` filter (toolName + inputMatches
18
+ * + exitCode). No `watch` means "fire on every tool_result".
19
+ * 2. Calls `observer.onResult(event, observerCtx)`. Awaits if it
20
+ * returns a promise.
21
+ * 3. Catches thrown errors per-observer — one buggy observer does
22
+ * NOT prevent the rest from running.
23
+ *
24
+ * Observer context (`ObserverContext`) is built fresh per event, using
25
+ * `appendEntry` and `findEntries` closures shared with the evaluator.
26
+ * `exec` is deliberately NOT exposed — observers are expected to be
27
+ * lightweight state-recording hooks. Complex tool_result analysis that
28
+ * needs to shell out belongs in a separate pi extension hook or in a
29
+ * rule's `when.condition` pre-execution, not in an observer.
30
+ *
31
+ * Wiring (Phase 3c): the extension runtime subscribes to `tool_result`
32
+ * and forwards the event + current `agentLoopIndex` into `dispatch`.
33
+ */
34
+
35
+ import type {
36
+ ExtensionContext,
37
+ ToolResultEvent as PiToolResultEvent,
38
+ } from "@earendil-works/pi-coding-agent";
39
+ import {
40
+ createAppendEntry,
41
+ createFindEntries,
42
+ createSessionEntryCache,
43
+ type EvaluatorHost,
44
+ } from "./evaluator-internals/context.ts";
45
+ import { mergeObserversUserFirst } from "./internal/merge-observers.ts";
46
+ import {
47
+ extractRefTextsForBash,
48
+ matchesWatch,
49
+ } from "./internal/watch-matcher.ts";
50
+ import type { ResolvedPluginState } from "./plugin-merger.ts";
51
+ import { validateName } from "./plugin-merger.ts";
52
+ import type {
53
+ Observer,
54
+ ObserverContext,
55
+ ToolResultEvent as SchemaToolResultEvent,
56
+ } from "./schema.ts";
57
+
58
+ // Re-export the shared filter contract so existing consumers that
59
+ // imported `matchesWatch` from this module (most notably the testing
60
+ // harness's `testObserver`) don't need to switch imports. The source
61
+ // of truth lives in `./internal/watch-matcher.ts`; this re-export
62
+ // keeps the public surface stable.
63
+ export { matchesWatch } from "./internal/watch-matcher.ts";
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Public surface
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Runtime-facing dispatcher handle. Phase 3c holds an instance per
71
+ * session and calls {@link dispatch} from the pi `tool_result` listener.
72
+ */
73
+ export interface ObserverDispatcher {
74
+ /**
75
+ * Dispatch a single `tool_result` to every matching observer.
76
+ *
77
+ * Resolves when all observer handlers have settled. Handlers that
78
+ * throw are caught + logged via `console.warn` and do not prevent
79
+ * subsequent observers from running.
80
+ */
81
+ dispatch(
82
+ event: PiToolResultEvent,
83
+ ctx: ExtensionContext,
84
+ agentLoopIndex: number,
85
+ ): Promise<void>;
86
+ }
87
+
88
+ /**
89
+ * Construct an {@link ObserverDispatcher}.
90
+ *
91
+ * Arguments:
92
+ * - `resolved` — merged plugin state from {@link resolvePlugins}.
93
+ * Source of plugin-shipped observers and the
94
+ * registry used by the evaluator at the same
95
+ * level.
96
+ * - `userObservers` — the user's `config.observers` list (already
97
+ * deduped at the loader level in Phase 2). User
98
+ * observers fire BEFORE plugin observers on the
99
+ * same event; within each group, registration
100
+ * order decides.
101
+ * - `host` — narrow surface exposing pi's `exec` +
102
+ * `appendEntry`. Passed straight through to the
103
+ * per-event observer context.
104
+ */
105
+ export function buildObserverDispatcher(
106
+ resolved: ResolvedPluginState,
107
+ userObservers: readonly Observer[],
108
+ host: EvaluatorHost,
109
+ ): ObserverDispatcher {
110
+ // S3 defense-in-depth: validate user-authored observer names so a
111
+ // malformed name surfaces with the same `(user config)` label as
112
+ // production diagnostics. Production routes through
113
+ // `runMergerPipeline`'s `invalid-name` diagnostic; this throw
114
+ // covers direct-caller paths (unit tests, SDK embedders).
115
+ // See ./INVARIANTS.md for the S/E tag glossary.
116
+ for (const o of userObservers) {
117
+ const d = validateName("observer", o.name, "user config");
118
+ if (d !== undefined) throw new Error(`[pi-steering] ${d.message}`);
119
+ }
120
+
121
+ // Merge user and plugin observers; duplicates of observer.name are
122
+ // deduped here by first-registered (user takes precedence over a
123
+ // plugin observer of the same name — matches the "user overrides
124
+ // plugin by declaring their own" pattern the rule list uses). Shared
125
+ // with the evaluator's speculative-synthesis reverse-index via
126
+ // `mergeObserversUserFirst` so both callers see the same final list.
127
+ const merged = mergeObserversUserFirst(userObservers, resolved.observers);
128
+
129
+ return {
130
+ dispatch: (event, ctx, agentLoopIndex) =>
131
+ dispatchEvent(event, ctx, agentLoopIndex, merged, host),
132
+ };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Dispatch
137
+ // ---------------------------------------------------------------------------
138
+
139
+ async function dispatchEvent(
140
+ event: PiToolResultEvent,
141
+ ctx: ExtensionContext,
142
+ agentLoopIndex: number,
143
+ observers: readonly Observer[],
144
+ host: EvaluatorHost,
145
+ ): Promise<void> {
146
+ // Top-level fail-open wrap. Per-observer throws are already
147
+ // isolated in the inner loop; this outer wrap exists so a throw in
148
+ // the dispatch SCAFFOLDING (e.g. a session-JSONL read blowing up
149
+ // inside `createFindEntries`, or an unexpected shape on the
150
+ // incoming event) is logged rather than propagating back into pi's
151
+ // `tool_result` hook. Observers are best-effort state recorders —
152
+ // a broken engine should not take down the tool_result pipeline.
153
+ try {
154
+ await dispatchEventInner(event, ctx, agentLoopIndex, observers, host);
155
+ } catch (err) {
156
+ console.warn(
157
+ `[pi-steering] observer dispatcher threw: ${formatError(err)}`,
158
+ );
159
+ }
160
+ }
161
+
162
+ async function dispatchEventInner(
163
+ event: PiToolResultEvent,
164
+ ctx: ExtensionContext,
165
+ agentLoopIndex: number,
166
+ observers: readonly Observer[],
167
+ host: EvaluatorHost,
168
+ ): Promise<void> {
169
+ // Shared per-event session-entry cache: findEntries + appendEntry
170
+ // share it so an earlier observer's appendEntry invalidates the
171
+ // cached read for that customType, and a later observer's
172
+ // findEntries(customType) sees the fresh write (S2/E1). Without the
173
+ // shared cache, observer A appending "description-read" + observer
174
+ // B reading "description-read" on the same event would see a stale
175
+ // pre-write snapshot.
176
+ const entryCache = createSessionEntryCache();
177
+ const findEntries = createFindEntries(ctx, entryCache);
178
+ // Shared appendEntry: auto-tags writes with `_agentLoopIndex` so
179
+ // `when.happened: { in: "agent_loop" }` can filter by agent-loop
180
+ // scope. Safe to hoist out of the loop: the wrapper is stateless.
181
+ const appendEntry = createAppendEntry(host, agentLoopIndex, entryCache);
182
+
183
+ // Hoist the per-event projections out of the loop so N observers
184
+ // each get the identical event shape + exit code without paying N
185
+ // copies of the same work.
186
+ const exitCode = extractExitCode(event);
187
+ const schemaEvent = toSchemaEvent(event, exitCode);
188
+
189
+ // Wrapper-aware command-ref cache (ADR §12). Populated lazily on the
190
+ // first observer whose watch filter references `inputMatches.command`
191
+ // against a bash event; reused across subsequent observers on the
192
+ // same event so `sh -c '…'` / `sudo …` are parsed at most once per
193
+ // dispatch regardless of how many observers share the filter shape.
194
+ // `null` encodes "extraction attempted but failed (parse error or
195
+ // non-bash event)" so we don't retry per observer.
196
+ let refTextsCache: readonly string[] | null | undefined;
197
+ const getRefTexts = (): readonly string[] | null => {
198
+ if (refTextsCache !== undefined) return refTextsCache;
199
+ refTextsCache = extractRefTextsForBash(schemaEvent);
200
+ return refTextsCache;
201
+ };
202
+
203
+ for (const observer of observers) {
204
+ if (!matchesWatch(observer.watch, schemaEvent, getRefTexts)) continue;
205
+
206
+ // Each observer gets its own ctx so appendEntry writes attribute
207
+ // cleanly. `exec` is intentionally absent — observers are recording
208
+ // hooks, not shell-out points.
209
+ const observerCtx: ObserverContext = {
210
+ cwd: ctx.cwd,
211
+ agentLoopIndex,
212
+ appendEntry,
213
+ findEntries,
214
+ };
215
+
216
+ try {
217
+ const result = observer.onResult(schemaEvent, observerCtx);
218
+ if (result instanceof Promise) {
219
+ await result;
220
+ }
221
+ } catch (err) {
222
+ // One observer's bug must not poison the rest. Log with the
223
+ // observer name so the operator can locate + disable it.
224
+ console.warn(
225
+ `[pi-steering] observer "${observer.name}" threw: ${formatError(err)}`,
226
+ );
227
+ }
228
+ }
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Schema event projection
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * Extract an exit code from a pi tool_result event. Only bash events
237
+ * carry one (via `details.exitCode`). Other tool results lack a
238
+ * meaningful numeric code; we return `undefined` and let the watch
239
+ * filter's `exitCode` check decide.
240
+ */
241
+ function extractExitCode(event: PiToolResultEvent): number | undefined {
242
+ if (event.toolName !== "bash") return undefined;
243
+ const details = event.details as { exitCode?: number } | undefined;
244
+ if (!details || typeof details.exitCode !== "number") return undefined;
245
+ return details.exitCode;
246
+ }
247
+
248
+ /**
249
+ * Project pi's concrete `ToolResultEvent` onto the schema's minimal
250
+ * {@link SchemaToolResultEvent}. Observers are typed against the schema
251
+ * shape — they don't depend on pi's internal tool details unions. The
252
+ * fields we fill:
253
+ *
254
+ * - `toolName` + `input` — direct.
255
+ * - `output` — pi's `content` (TextContent/ImageContent
256
+ * array) passed through unchanged. Observer
257
+ * handlers cast to the shape they expect.
258
+ * - `exitCode` — passed in precomputed by the caller
259
+ * (so the extraction runs once per event,
260
+ * not once per matching observer); bash
261
+ * events only, others leave it `undefined`.
262
+ */
263
+ function toSchemaEvent(
264
+ event: PiToolResultEvent,
265
+ exitCode: number | undefined,
266
+ ): SchemaToolResultEvent {
267
+ const out: SchemaToolResultEvent = {
268
+ toolName: event.toolName,
269
+ input: event.input,
270
+ output: event.content,
271
+ };
272
+ if (exitCode !== undefined) out.exitCode = exitCode;
273
+ return out;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Error formatting
278
+ // ---------------------------------------------------------------------------
279
+
280
+ function formatError(err: unknown): string {
281
+ if (err instanceof Error) return `${err.message}\n${err.stack ?? ""}`;
282
+ try {
283
+ return JSON.stringify(err);
284
+ } catch {
285
+ return String(err);
286
+ }
287
+ }
288
+
289
+ // Re-export host type for symmetry with evaluator.ts.
290
+ export type { EvaluatorHost } from "./evaluator-internals/context.ts";
@@ -0,0 +1,361 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Type-level pinning for the per-predicate typing scaffold introduced
6
+ * alongside the not-block onUnknown semantics.
7
+ *
8
+ * Runtime assertions are minimal — the value of these tests is the
9
+ * compile-time pinning of the registry-driven mapped types
10
+ * ({@link TopLevelWhenClause}, {@link TopLevelWhenClauseNoRecurse},
11
+ * {@link OuterValue}, {@link InnerValue}). If a future change widens
12
+ * a type incorrectly (e.g. accepting a leaf-level `onUnknown:` inside
13
+ * `not:`, or adding `not: not:` recursion), the `// @ts-expect-error`
14
+ * directives here surface the regression at typecheck time.
15
+ *
16
+ * Plugin-author registry shape: the `gitPlugin` module augments
17
+ * `PiSteeringPredicates` with `branch:`, `upstream:`, `remote:`,
18
+ * `isClean:`, `hasStagedChanges:`, `commitsAhead:` (see
19
+ * `plugins/git/index.ts`). `cwd:` is a built-in non-registry leaf on
20
+ * {@link BuiltInWhenLeaves} and is tested via
21
+ * {@link TopLevelWhenClause} directly. This test file imports
22
+ * gitPlugin so the augmentation is in scope before the type-level
23
+ * pins below.
24
+ */
25
+
26
+ import assert from "node:assert/strict";
27
+ import { describe, it } from "node:test";
28
+ // Importing gitPlugin pulls in its `declare global { interface
29
+ // PiSteeringPredicates { ... } }` augmentation; without this import
30
+ // the registry would be empty and `PluginPredicateKey` would be
31
+ // `never`, collapsing every mapped type to `{}` with the operator
32
+ // field. The reference below keeps the import live for the
33
+ // type-system without triggering unused-import diagnostics.
34
+ import gitPlugin from "./plugins/git/index.ts";
35
+ import type {
36
+ CommitsAheadArgs,
37
+ commitsAhead,
38
+ hasStagedChanges,
39
+ isClean,
40
+ } from "./plugins/git/predicates.ts";
41
+ import type {
42
+ BuiltInWhenLeaves,
43
+ InnerValue,
44
+ OuterValue,
45
+ PluginPredicateKey,
46
+ PredicateHandler,
47
+ TopLevelWhenClause,
48
+ TopLevelWhenClauseNoRecurse,
49
+ } from "./schema.ts";
50
+
51
+ const _gitPluginRegistered: typeof gitPlugin = gitPlugin;
52
+ void _gitPluginRegistered;
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Positive cases — must typecheck
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe("per-predicate typing: positive cases", () => {
59
+ it("registry pins gitPlugin's 6 predicate keys after `declare global`", () => {
60
+ // Type-level assertion: every gitPlugin predicate key is a
61
+ // member of `PluginPredicateKey`. A regression that drops one
62
+ // of the augmented entries surfaces here as a type error.
63
+ // `cwd` is NOT in this set — it's a built-in non-registry leaf
64
+ // on BuiltInWhenLeaves (covered by the `BuiltInWhenLeaves`
65
+ // shape pin in not-block-onunknown.test.ts).
66
+ const keys: PluginPredicateKey[] = [
67
+ "branch",
68
+ "upstream",
69
+ "remote",
70
+ "isClean",
71
+ "hasStagedChanges",
72
+ "commitsAhead",
73
+ ];
74
+ assert.equal(keys.length, 6);
75
+ });
76
+
77
+ it("TopLevelWhenClause: bare leaf forms (Pattern, boolean, number)", () => {
78
+ const w: TopLevelWhenClause = {
79
+ cwd: /work/,
80
+ branch: "main",
81
+ isClean: true,
82
+ commitsAhead: 1,
83
+ };
84
+ assert.ok(w.cwd !== undefined);
85
+ });
86
+
87
+ it("TopLevelWhenClause: spread leaf forms with leaf-level onUnknown:", () => {
88
+ const w: TopLevelWhenClause = {
89
+ cwd: { pattern: /work/, onUnknown: "allow" },
90
+ isClean: { value: true, onUnknown: "allow" },
91
+ commitsAhead: { eq: 0, onUnknown: "allow" },
92
+ };
93
+ assert.ok(w.cwd !== undefined);
94
+ });
95
+
96
+ it("TopLevelWhenClause: not?: with bare inner leaves + block-level onUnknown:", () => {
97
+ const w: TopLevelWhenClause = {
98
+ not: {
99
+ cwd: /github/,
100
+ onUnknown: "block",
101
+ },
102
+ };
103
+ assert.ok(w.not?.cwd !== undefined);
104
+ });
105
+
106
+ it("TopLevelWhenClauseNoRecurse: spreadBase form (no modifiers) inside not:", () => {
107
+ const w: TopLevelWhenClause = {
108
+ not: {
109
+ commitsAhead: { lt: 5 },
110
+ cwd: { pattern: /github/ },
111
+ onUnknown: "block",
112
+ },
113
+ };
114
+ assert.ok(w.not?.commitsAhead !== undefined);
115
+ });
116
+
117
+ it("OuterValue<K>: bare OR (spreadBase + modifiers) (built-in cwd via BuiltInWhenLeaves)", () => {
118
+ // `cwd:` is a built-in leaf on {@link BuiltInWhenLeaves}, not a
119
+ // registry-driven key, so its type pin uses the interface field
120
+ // directly rather than `OuterValue<"cwd">`. Registry-key version
121
+ // uses `branch:` (gitPlugin-augmented), with the same
122
+ // bare-or-spread+modifiers shape contract.
123
+ const bareCwd: NonNullable<BuiltInWhenLeaves["cwd"]> = /pattern/;
124
+ const spreadCwd: NonNullable<BuiltInWhenLeaves["cwd"]> = {
125
+ pattern: /pattern/,
126
+ onUnknown: "allow",
127
+ };
128
+ const bareBranch: OuterValue<"branch"> = /pattern/;
129
+ const spreadBranch: OuterValue<"branch"> = {
130
+ pattern: /pattern/,
131
+ onUnknown: "allow",
132
+ };
133
+ assert.ok(bareCwd !== undefined);
134
+ assert.ok(spreadCwd !== undefined);
135
+ assert.ok(bareBranch !== undefined);
136
+ assert.ok(spreadBranch !== undefined);
137
+ });
138
+
139
+ it("InnerValue<K>: bare OR spreadBase (NO modifiers) (built-in cwd inside not: via BuiltInWhenLeaves)", () => {
140
+ // Inside `not:`, `cwd:` retains its built-in shape (Pattern |
141
+ // Pattern[] | { pattern, onUnknown? }) —
142
+ // {@link TopLevelWhenClauseNoRecurse} intersects
143
+ // {@link BuiltInWhenLeaves} verbatim. Registry-key version uses
144
+ // `branch:` to pin the bare-or-spreadBase (no modifiers) contract.
145
+ const bareBranch: InnerValue<"branch"> = /pattern/;
146
+ const spreadBranch: InnerValue<"branch"> = { pattern: /pattern/ };
147
+ assert.ok(bareBranch !== undefined);
148
+ assert.ok(spreadBranch !== undefined);
149
+ });
150
+
151
+ it('InnerValue<"upstream">: bare OR spreadBase (NO modifiers)', () => {
152
+ // Mirrors the `branch` pin above for the other Pattern-leaf
153
+ // registry-driven predicates so a future shape regression on
154
+ // any one of them surfaces independently.
155
+ const bare: InnerValue<"upstream"> = /origin\/main/;
156
+ const spread: InnerValue<"upstream"> = { pattern: /origin\/main/ };
157
+ assert.ok(bare !== undefined);
158
+ assert.ok(spread !== undefined);
159
+ });
160
+
161
+ it('InnerValue<"remote">: bare OR spreadBase (NO modifiers)', () => {
162
+ const bare: InnerValue<"remote"> = /github\.com/;
163
+ const spread: InnerValue<"remote"> = { pattern: /github\.com/ };
164
+ assert.ok(bare !== undefined);
165
+ assert.ok(spread !== undefined);
166
+ });
167
+
168
+ it('InnerValue<"hasStagedChanges">: bare boolean OR { value: boolean } (NO modifiers)', () => {
169
+ const bare: InnerValue<"hasStagedChanges"> = true;
170
+ const spread: InnerValue<"hasStagedChanges"> = { value: true };
171
+ assert.ok(bare !== undefined);
172
+ assert.ok(spread !== undefined);
173
+ });
174
+
175
+ it("upstream: bare + spread + leaf-level onUnknown:", () => {
176
+ const bare: OuterValue<"upstream"> = /origin\/main/;
177
+ const spread: OuterValue<"upstream"> = {
178
+ pattern: /origin\/main/,
179
+ onUnknown: "allow",
180
+ };
181
+ assert.ok(bare !== undefined);
182
+ assert.ok(spread !== undefined);
183
+ });
184
+
185
+ it("remote: bare + spread + leaf-level onUnknown:", () => {
186
+ const bare: OuterValue<"remote"> = /github\.com/;
187
+ const spread: OuterValue<"remote"> = {
188
+ pattern: /github\.com/,
189
+ onUnknown: "allow",
190
+ };
191
+ assert.ok(bare !== undefined);
192
+ assert.ok(spread !== undefined);
193
+ });
194
+
195
+ it("hasStagedChanges: bare boolean + spread { value, onUnknown? }", () => {
196
+ const bare: OuterValue<"hasStagedChanges"> = true;
197
+ const spread: OuterValue<"hasStagedChanges"> = {
198
+ value: true,
199
+ onUnknown: "allow",
200
+ };
201
+ assert.ok(bare !== undefined);
202
+ assert.ok(spread !== undefined);
203
+ });
204
+ });
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Negative cases — must NOT typecheck (// @ts-expect-error guards)
208
+ // ---------------------------------------------------------------------------
209
+
210
+ describe("per-predicate typing: negative cases (compile-time)", () => {
211
+ it("rule-level onUnknown: is forbidden on TopLevelWhenClause", () => {
212
+ const w: TopLevelWhenClause = {
213
+ cwd: /work/,
214
+ // @ts-expect-error — rule-level onUnknown: is not on TopLevelWhenClause's type.
215
+ onUnknown: "block",
216
+ };
217
+ assert.ok(w.cwd !== undefined);
218
+ });
219
+
220
+ it("leaf-level onUnknown: inside not: is forbidden (modifiers live at block level)", () => {
221
+ const w: TopLevelWhenClause = {
222
+ not: {
223
+ // @ts-expect-error — InnerValue<"branch"> is bare | spreadBase, no modifiers.
224
+ branch: { pattern: "main", onUnknown: "allow" },
225
+ },
226
+ };
227
+ assert.ok(w.not !== undefined);
228
+ });
229
+
230
+ it("leaf-level onUnknown: inside not: is forbidden for boolean predicates too", () => {
231
+ const w: TopLevelWhenClause = {
232
+ not: {
233
+ // @ts-expect-error — InnerValue<"isClean"> is boolean | { value: boolean }, no modifiers.
234
+ isClean: { value: true, onUnknown: "allow" },
235
+ },
236
+ };
237
+ assert.ok(w.not !== undefined);
238
+ });
239
+
240
+ it("leaf-level onUnknown: inside not: is forbidden for object-shaped predicates too", () => {
241
+ const w: TopLevelWhenClause = {
242
+ not: {
243
+ // @ts-expect-error — InnerValue<"commitsAhead"> is number | spreadBase, no modifiers.
244
+ commitsAhead: { lt: 5, onUnknown: "allow" },
245
+ },
246
+ };
247
+ assert.ok(w.not !== undefined);
248
+ });
249
+
250
+ it("not: not: recursion is forbidden", () => {
251
+ const w: TopLevelWhenClause = {
252
+ not: {
253
+ // @ts-expect-error — TopLevelWhenClauseNoRecurse has no `not?:` field.
254
+ not: { cwd: "/" },
255
+ },
256
+ };
257
+ assert.ok(w.not !== undefined);
258
+ });
259
+ });
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // PredicateShape auto-detect via DefaultSpreadBase
263
+ // ---------------------------------------------------------------------------
264
+
265
+ describe("PredicateShape: DefaultSpreadBase auto-detection", () => {
266
+ it("PredicateShape<Patterns> auto-detects spreadBase to { pattern: Patterns }", () => {
267
+ // Type-only assertion via assignability. If the auto-detect
268
+ // breaks (e.g. tuple-wrap removed and union distributes), this
269
+ // fails to typecheck. `branch:` carries the same Patterns-bare
270
+ // shape as the built-in `cwd:` leaf and lives in the registry,
271
+ // so it's the right registry-side anchor for this pin.
272
+ type BranchShape = PiSteeringPredicates["branch"];
273
+ const _spread: BranchShape["spreadBase"] = { pattern: /work/ };
274
+ void _spread;
275
+ assert.ok(true);
276
+ });
277
+
278
+ it("PredicateShape<boolean> auto-detects spreadBase to { value: boolean }", () => {
279
+ type IsCleanShape = PiSteeringPredicates["isClean"];
280
+ const _spread: IsCleanShape["spreadBase"] = { value: true };
281
+ void _spread;
282
+ assert.ok(true);
283
+ });
284
+
285
+ it("PredicateShape<number, ExplicitSpreadBase> uses the explicit override", () => {
286
+ // commitsAhead has explicit SpreadBase = { eq?, gt?, lt?, wrt? }
287
+ // (auto-detect from `number` would give { value: number }).
288
+ type CAShape = PiSteeringPredicates["commitsAhead"];
289
+ const _spread: CAShape["spreadBase"] = { eq: 1, wrt: "origin/main" };
290
+ void _spread;
291
+ assert.ok(true);
292
+ });
293
+ });
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // PluginPredicateKey reserved-key filter
297
+ // ---------------------------------------------------------------------------
298
+
299
+ describe("PluginPredicateKey: reserved-key filter", () => {
300
+ it("filters out reserved names (`not`, `onUnknown`)", () => {
301
+ // Belt-and-suspenders type-only check. The runtime guard in
302
+ // `plugin-merger.ts` is the authoritative gate (it throws at
303
+ // config-resolve time when a plugin attempts to register a
304
+ // reserved key); this assertion pins the type-level filter that
305
+ // drops reserved names from the registry-driven mapped types
306
+ // (`TopLevelWhenClause`, `TopLevelWhenClauseNoRecurse`) so an IDE
307
+ // hover never suggests a key the runtime would reject.
308
+ type ContainsNot = "not" extends PluginPredicateKey ? true : false;
309
+ type ContainsOnUnknown = "onUnknown" extends PluginPredicateKey
310
+ ? true
311
+ : false;
312
+ const _a: ContainsNot = false;
313
+ const _b: ContainsOnUnknown = false;
314
+ void _a;
315
+ void _b;
316
+ assert.ok(true);
317
+ });
318
+ });
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // gitPlugin handler signatures (named exports vs registry shapes)
322
+ // ---------------------------------------------------------------------------
323
+
324
+ describe("gitPlugin handler signatures: named-export type pins", () => {
325
+ it("`commitsAhead` is `PredicateHandler<number | CommitsAheadArgs>`", () => {
326
+ // Pins the named export against the registry shape. If a future
327
+ // change widens the handler arg (e.g., adding a new union member
328
+ // without updating the registry's `PredicateShape<number,
329
+ // CommitsAheadArgs>` SpreadBase), the conditional collapses to
330
+ // `false` and the assignment fails to typecheck.
331
+ type _CommitsAheadSig =
332
+ typeof commitsAhead extends PredicateHandler<number | CommitsAheadArgs>
333
+ ? true
334
+ : false;
335
+ const _checkCommitsAhead: _CommitsAheadSig = true;
336
+ void _checkCommitsAhead;
337
+ assert.ok(true);
338
+ });
339
+
340
+ it("`isClean` is `PredicateHandler<boolean | { value: boolean }>`", () => {
341
+ type _IsCleanSig =
342
+ typeof isClean extends PredicateHandler<boolean | { value: boolean }>
343
+ ? true
344
+ : false;
345
+ const _checkIsClean: _IsCleanSig = true;
346
+ void _checkIsClean;
347
+ assert.ok(true);
348
+ });
349
+
350
+ it("`hasStagedChanges` is `PredicateHandler<boolean | { value: boolean }>`", () => {
351
+ type _HasStagedSig =
352
+ typeof hasStagedChanges extends PredicateHandler<
353
+ boolean | { value: boolean }
354
+ >
355
+ ? true
356
+ : false;
357
+ const _checkHasStaged: _HasStagedSig = true;
358
+ void _checkHasStaged;
359
+ assert.ok(true);
360
+ });
361
+ });