@cad0p/pi-steering 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +29 -0
  3. package/README.md +920 -0
  4. package/dist/__test-helpers__.d.ts +95 -0
  5. package/dist/__test-helpers__.d.ts.map +1 -0
  6. package/dist/__test-helpers__.js +174 -0
  7. package/dist/__test-helpers__.js.map +1 -0
  8. package/dist/bin/pi-steering.d.ts +9 -0
  9. package/dist/bin/pi-steering.d.ts.map +1 -0
  10. package/dist/bin/pi-steering.js +506 -0
  11. package/dist/bin/pi-steering.js.map +1 -0
  12. package/dist/compat.d.ts +50 -0
  13. package/dist/compat.d.ts.map +1 -0
  14. package/dist/compat.js +180 -0
  15. package/dist/compat.js.map +1 -0
  16. package/dist/defaults.d.ts +61 -0
  17. package/dist/defaults.d.ts.map +1 -0
  18. package/dist/defaults.js +106 -0
  19. package/dist/defaults.js.map +1 -0
  20. package/dist/define-config.d.ts +235 -0
  21. package/dist/define-config.d.ts.map +1 -0
  22. package/dist/define-config.js +118 -0
  23. package/dist/define-config.js.map +1 -0
  24. package/dist/define-predicate.d.ts +49 -0
  25. package/dist/define-predicate.d.ts.map +1 -0
  26. package/dist/define-predicate.js +35 -0
  27. package/dist/define-predicate.js.map +1 -0
  28. package/dist/evaluator-internals/context.d.ts +186 -0
  29. package/dist/evaluator-internals/context.d.ts.map +1 -0
  30. package/dist/evaluator-internals/context.js +219 -0
  31. package/dist/evaluator-internals/context.js.map +1 -0
  32. package/dist/evaluator-internals/override.d.ts +19 -0
  33. package/dist/evaluator-internals/override.d.ts.map +1 -0
  34. package/dist/evaluator-internals/override.js +79 -0
  35. package/dist/evaluator-internals/override.js.map +1 -0
  36. package/dist/evaluator-internals/predicates.d.ts +152 -0
  37. package/dist/evaluator-internals/predicates.d.ts.map +1 -0
  38. package/dist/evaluator-internals/predicates.js +849 -0
  39. package/dist/evaluator-internals/predicates.js.map +1 -0
  40. package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
  41. package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
  42. package/dist/evaluator-internals/speculative-synthesis.js +101 -0
  43. package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
  44. package/dist/evaluator.d.ts +101 -0
  45. package/dist/evaluator.d.ts.map +1 -0
  46. package/dist/evaluator.js +714 -0
  47. package/dist/evaluator.js.map +1 -0
  48. package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
  49. package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
  50. package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
  51. package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
  52. package/dist/index.d.ts +42 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +93 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/internal/drop-unused-observers.d.ts +43 -0
  57. package/dist/internal/drop-unused-observers.d.ts.map +1 -0
  58. package/dist/internal/drop-unused-observers.js +50 -0
  59. package/dist/internal/drop-unused-observers.js.map +1 -0
  60. package/dist/internal/finalize-plugin-state.d.ts +22 -0
  61. package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
  62. package/dist/internal/finalize-plugin-state.js +14 -0
  63. package/dist/internal/finalize-plugin-state.js.map +1 -0
  64. package/dist/internal/merge-observers.d.ts +30 -0
  65. package/dist/internal/merge-observers.d.ts.map +1 -0
  66. package/dist/internal/merge-observers.js +27 -0
  67. package/dist/internal/merge-observers.js.map +1 -0
  68. package/dist/internal/pattern-utils.d.ts +33 -0
  69. package/dist/internal/pattern-utils.d.ts.map +1 -0
  70. package/dist/internal/pattern-utils.js +20 -0
  71. package/dist/internal/pattern-utils.js.map +1 -0
  72. package/dist/internal/ref-text.d.ts +11 -0
  73. package/dist/internal/ref-text.d.ts.map +1 -0
  74. package/dist/internal/ref-text.js +15 -0
  75. package/dist/internal/ref-text.js.map +1 -0
  76. package/dist/internal/session-runtime.d.ts +44 -0
  77. package/dist/internal/session-runtime.d.ts.map +1 -0
  78. package/dist/internal/session-runtime.js +143 -0
  79. package/dist/internal/session-runtime.js.map +1 -0
  80. package/dist/internal/watch-matcher.d.ts +56 -0
  81. package/dist/internal/watch-matcher.d.ts.map +1 -0
  82. package/dist/internal/watch-matcher.js +179 -0
  83. package/dist/internal/watch-matcher.js.map +1 -0
  84. package/dist/loader.d.ts +97 -0
  85. package/dist/loader.d.ts.map +1 -0
  86. package/dist/loader.js +527 -0
  87. package/dist/loader.js.map +1 -0
  88. package/dist/observer-dispatcher.d.ts +69 -0
  89. package/dist/observer-dispatcher.d.ts.map +1 -0
  90. package/dist/observer-dispatcher.js +184 -0
  91. package/dist/observer-dispatcher.js.map +1 -0
  92. package/dist/plugin-merger.d.ts +175 -0
  93. package/dist/plugin-merger.d.ts.map +1 -0
  94. package/dist/plugin-merger.js +450 -0
  95. package/dist/plugin-merger.js.map +1 -0
  96. package/dist/plugins/git/branch-tracker.d.ts +85 -0
  97. package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
  98. package/dist/plugins/git/branch-tracker.js +181 -0
  99. package/dist/plugins/git/branch-tracker.js.map +1 -0
  100. package/dist/plugins/git/cwd-extensions.d.ts +10 -0
  101. package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
  102. package/dist/plugins/git/cwd-extensions.js +155 -0
  103. package/dist/plugins/git/cwd-extensions.js.map +1 -0
  104. package/dist/plugins/git/git-ops.d.ts +89 -0
  105. package/dist/plugins/git/git-ops.d.ts.map +1 -0
  106. package/dist/plugins/git/git-ops.js +107 -0
  107. package/dist/plugins/git/git-ops.js.map +1 -0
  108. package/dist/plugins/git/index.d.ts +225 -0
  109. package/dist/plugins/git/index.d.ts.map +1 -0
  110. package/dist/plugins/git/index.js +52 -0
  111. package/dist/plugins/git/index.js.map +1 -0
  112. package/dist/plugins/git/predicates.d.ts +312 -0
  113. package/dist/plugins/git/predicates.d.ts.map +1 -0
  114. package/dist/plugins/git/predicates.js +580 -0
  115. package/dist/plugins/git/predicates.js.map +1 -0
  116. package/dist/plugins/git/rules.d.ts +209 -0
  117. package/dist/plugins/git/rules.d.ts.map +1 -0
  118. package/dist/plugins/git/rules.js +267 -0
  119. package/dist/plugins/git/rules.js.map +1 -0
  120. package/dist/schema.d.ts +1799 -0
  121. package/dist/schema.d.ts.map +1 -0
  122. package/dist/schema.js +4 -0
  123. package/dist/schema.js.map +1 -0
  124. package/dist/testing/index.d.ts +621 -0
  125. package/dist/testing/index.d.ts.map +1 -0
  126. package/dist/testing/index.js +747 -0
  127. package/dist/testing/index.js.map +1 -0
  128. package/examples/README.md +83 -0
  129. package/examples/combined-git-discipline/README.md +52 -0
  130. package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
  131. package/examples/combined-git-discipline/package.json +18 -0
  132. package/examples/combined-git-discipline/steering.json +27 -0
  133. package/examples/combined-git-discipline/steering.test.ts +42 -0
  134. package/examples/combined-git-discipline/steering.ts +64 -0
  135. package/examples/combined-git-discipline/tsconfig.json +7 -0
  136. package/examples/draft-prs-only/README.md +42 -0
  137. package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
  138. package/examples/draft-prs-only/package.json +18 -0
  139. package/examples/draft-prs-only/steering.json +12 -0
  140. package/examples/draft-prs-only/steering.test.ts +48 -0
  141. package/examples/draft-prs-only/steering.ts +36 -0
  142. package/examples/draft-prs-only/tsconfig.json +7 -0
  143. package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
  144. package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
  145. package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
  146. package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
  147. package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
  148. package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
  149. package/examples/force-push-strict/README.md +75 -0
  150. package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
  151. package/examples/force-push-strict/package.json +18 -0
  152. package/examples/force-push-strict/steering.json +12 -0
  153. package/examples/force-push-strict/steering.test.ts +51 -0
  154. package/examples/force-push-strict/steering.ts +54 -0
  155. package/examples/force-push-strict/tsconfig.json +7 -0
  156. package/examples/no-amend/README.md +52 -0
  157. package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
  158. package/examples/no-amend/package.json +18 -0
  159. package/examples/no-amend/steering.cwd-scoped.json +12 -0
  160. package/examples/no-amend/steering.json +11 -0
  161. package/examples/no-amend/steering.test.ts +49 -0
  162. package/examples/no-amend/steering.ts +38 -0
  163. package/examples/no-amend/tsconfig.json +7 -0
  164. package/examples/work-item-plugin/README.md +103 -0
  165. package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
  166. package/examples/work-item-plugin/package.json +19 -0
  167. package/examples/work-item-plugin/src/index.test.ts +168 -0
  168. package/examples/work-item-plugin/src/index.ts +122 -0
  169. package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
  170. package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
  171. package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
  172. package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
  173. package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
  174. package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
  175. package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
  176. package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
  177. package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
  178. package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
  179. package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
  180. package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
  181. package/examples/work-item-plugin/tsconfig.json +8 -0
  182. package/package.json +88 -0
  183. package/skills/steering-authoring/SKILL.md +102 -0
  184. package/src/INVARIANTS.md +116 -0
  185. package/src/__test-helpers__.ts +243 -0
  186. package/src/bin/pi-steering.test.ts +920 -0
  187. package/src/bin/pi-steering.ts +584 -0
  188. package/src/compat.test.ts +431 -0
  189. package/src/compat.ts +282 -0
  190. package/src/defaults.test.ts +585 -0
  191. package/src/defaults.ts +148 -0
  192. package/src/define-config.test.ts +888 -0
  193. package/src/define-config.ts +375 -0
  194. package/src/define-predicate.test.ts +70 -0
  195. package/src/define-predicate.ts +57 -0
  196. package/src/evaluator-internals/context.ts +290 -0
  197. package/src/evaluator-internals/override.test.ts +99 -0
  198. package/src/evaluator-internals/override.ts +83 -0
  199. package/src/evaluator-internals/predicates.ts +1037 -0
  200. package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
  201. package/src/evaluator-internals/speculative-synthesis.ts +199 -0
  202. package/src/evaluator.test.ts +6296 -0
  203. package/src/evaluator.ts +1106 -0
  204. package/src/factory-time-load.test.ts +630 -0
  205. package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
  206. package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
  207. package/src/index.test.ts +836 -0
  208. package/src/index.ts +229 -0
  209. package/src/internal/drop-unused-observers.test.ts +141 -0
  210. package/src/internal/drop-unused-observers.ts +81 -0
  211. package/src/internal/finalize-plugin-state.ts +43 -0
  212. package/src/internal/merge-observers.ts +51 -0
  213. package/src/internal/pattern-utils.test.ts +52 -0
  214. package/src/internal/pattern-utils.ts +38 -0
  215. package/src/internal/ref-text.test.ts +54 -0
  216. package/src/internal/ref-text.ts +17 -0
  217. package/src/internal/session-runtime.test.ts +629 -0
  218. package/src/internal/session-runtime.ts +201 -0
  219. package/src/internal/watch-matcher.test.ts +326 -0
  220. package/src/internal/watch-matcher.ts +200 -0
  221. package/src/jsdoc-propagation.test.ts +277 -0
  222. package/src/loader.test.ts +1098 -0
  223. package/src/loader.ts +581 -0
  224. package/src/not-block-onunknown.test.ts +554 -0
  225. package/src/observer-dispatcher.test.ts +939 -0
  226. package/src/observer-dispatcher.ts +290 -0
  227. package/src/per-predicate-typing.test.ts +361 -0
  228. package/src/plugin-merger.test.ts +790 -0
  229. package/src/plugin-merger.ts +598 -0
  230. package/src/plugins/git/README.md +485 -0
  231. package/src/plugins/git/branch-tracker.test.ts +281 -0
  232. package/src/plugins/git/branch-tracker.ts +187 -0
  233. package/src/plugins/git/cwd-extensions.test.ts +230 -0
  234. package/src/plugins/git/cwd-extensions.ts +160 -0
  235. package/src/plugins/git/git-ops.test.ts +397 -0
  236. package/src/plugins/git/git-ops.ts +173 -0
  237. package/src/plugins/git/index.ts +253 -0
  238. package/src/plugins/git/integration.test.ts +705 -0
  239. package/src/plugins/git/predicates.test.ts +1401 -0
  240. package/src/plugins/git/predicates.ts +693 -0
  241. package/src/plugins/git/rules.test.ts +1001 -0
  242. package/src/plugins/git/rules.ts +307 -0
  243. package/src/schema.test.ts +259 -0
  244. package/src/schema.ts +1963 -0
  245. package/src/testing/index.test.ts +1696 -0
  246. package/src/testing/index.ts +1472 -0
package/src/index.ts ADDED
@@ -0,0 +1,229 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+ //
4
+ // pi-steering — deterministic steering hooks for pi agents.
5
+ // Inspired by @samfp/pi-steering-hooks (schema, override-comment,
6
+ // defaults). AST backend + command-level effective-cwd via
7
+ // unbash-walker. This file is the thin wiring layer between pi's
8
+ // extension API and the engine (loader + plugin-merger + evaluator
9
+ // + observer-dispatcher).
10
+
11
+ import type {
12
+ ExtensionAPI,
13
+ ExtensionContext,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import type { EvaluatorHost } from "./evaluator.ts";
16
+ import { buildSessionRuntime } from "./internal/session-runtime.ts";
17
+
18
+ /**
19
+ * Pi extension factory. Wires the steering engine onto pi's
20
+ * lifecycle events.
21
+ *
22
+ * Strict-mode contract: see {@link SteeringConfig.failOnWarnings}.
23
+ *
24
+ * Lifecycle wiring:
25
+ *
26
+ * - factory time — eager load via {@link buildSessionRuntime};
27
+ * throws on diagnostic per the strict-mode
28
+ * contract.
29
+ * - `agent_start` — bump the internal `agentLoopIndex` counter
30
+ * so tool_call / tool_result handlers can
31
+ * forward it into the evaluator + dispatcher.
32
+ * One agent loop = one user prompt + all the
33
+ * tool calls it spawns.
34
+ * - `session_start` — emit a `console.warn` if the resumed
35
+ * session's `ctx.cwd` differs from the launch
36
+ * cwd captured at factory time (cross-project
37
+ * resume). The engine continues evaluating
38
+ * with launch-cwd rules.
39
+ * - `tool_call` / `tool_result` route through the evaluator and
40
+ * dispatcher (see {@link buildSessionRuntime}).
41
+ *
42
+ * Exported as the default export per pi's extension convention.
43
+ */
44
+ export default async function register(pi: ExtensionAPI): Promise<void> {
45
+ let agentLoopIndex = 0;
46
+
47
+ const host: EvaluatorHost = {
48
+ exec: pi.exec,
49
+ appendEntry: pi.appendEntry,
50
+ };
51
+
52
+ const launchCwd = process.cwd();
53
+
54
+ const { evaluator, dispatcher } = await buildSessionRuntime(launchCwd, host);
55
+
56
+ pi.on("agent_start", () => {
57
+ agentLoopIndex += 1;
58
+ });
59
+
60
+ pi.on("session_start", (_event, ctx: ExtensionContext) => {
61
+ if (ctx.cwd !== launchCwd) {
62
+ console.warn(
63
+ `[pi-steering] session cwd (${ctx.cwd}) differs from launch cwd ` +
64
+ `(${launchCwd}). Steering rules loaded from launch cwd; ` +
65
+ `session-cwd rules NOT applied. To use session-cwd rules, ` +
66
+ `exit pi and re-launch from ${ctx.cwd}.`,
67
+ );
68
+ }
69
+ });
70
+
71
+ pi.on("tool_call", (event, ctx) =>
72
+ evaluator.evaluate(event, ctx, agentLoopIndex),
73
+ );
74
+
75
+ pi.on("tool_result", (event, ctx) =>
76
+ dispatcher.dispatch(event, ctx, agentLoopIndex),
77
+ );
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Public surface — the engine.
82
+ //
83
+ // Consumers embedding the engine (building their own extensions, a CLI
84
+ // that lints commands, a test harness, …) import these from the
85
+ // package root.
86
+ // ---------------------------------------------------------------------------
87
+
88
+ // Walker types re-exported for plugin authors. Forward-compatible with
89
+ // future unbash-walker extraction — imports from this package won't
90
+ // break.
91
+ export type {
92
+ Command,
93
+ CommandRef,
94
+ EnvState,
95
+ Modifier,
96
+ Node,
97
+ Script,
98
+ SubshellSemantics,
99
+ Tracker,
100
+ WalkResult,
101
+ Word,
102
+ WordPart,
103
+ } from "@cad0p/unbash-walker";
104
+ // Walker functions re-exported for plugin authors writing custom
105
+ // predicates and trackers. Forward-compatible with future
106
+ // unbash-walker extraction.
107
+ export {
108
+ cwdTracker,
109
+ envTracker,
110
+ expandWrapperCommands,
111
+ extractAllCommandsFromAST,
112
+ formatCommand,
113
+ getBasename,
114
+ getCommandArgs,
115
+ getCommandName,
116
+ isStaticallyResolvable,
117
+ parse,
118
+ resolveWord,
119
+ walk,
120
+ } from "@cad0p/unbash-walker";
121
+ // JSON compat — convert v1 JSON configs to v2 TS configs.
122
+ export { FromJSONError, fromJSON } from "./compat.ts";
123
+ // Defaults — bundled rule and plugin starter set.
124
+ export { DEFAULT_PLUGINS, DEFAULT_RULES } from "./defaults.ts";
125
+ export type { DefineConfigInput } from "./define-config.ts";
126
+ // Config helper (preferred entry point).
127
+ export { defineConfig } from "./define-config.ts";
128
+ // Predicate helper.
129
+ export { definePredicate } from "./define-predicate.ts";
130
+ // Auto-tag key for session-entry writes. Exposed so plugin authors
131
+ // inspecting raw session entries via `findEntries` can reference the
132
+ // constant instead of hardcoding the string.
133
+ export { AGENT_LOOP_INDEX_KEY } from "./evaluator-internals/context.ts";
134
+ // Reason-text helper for custom predicates that read runtime
135
+ // `ctx.cwd` (shell-exec or filesystem queries) rather than
136
+ // walker-tracked state.
137
+ //
138
+ // `walkerUnknownCwdReason`: composable agent-facing reason text
139
+ // for ReasonFns to call on the walker-unknown branch of those
140
+ // runtime-cwd predicates (typical handler shape:
141
+ // `if (ctx.walkerState?.cwd === "unknown") return "unknown";`,
142
+ // then the engine projects via `onUnknown:` policy).
143
+ export { walkerUnknownCwdReason } from "./helpers/walker-unknown-cwd-reason.ts";
144
+ // Loader — walk-up config discovery + merge.
145
+ export { buildConfig, loadConfigs, loadSteeringConfig } from "./loader.ts";
146
+ // Schema types — the public authoring surface.
147
+ export type {
148
+ AnyPredicateHandler,
149
+ BaseRule,
150
+ BashRule,
151
+ BuiltInWhenLeaves,
152
+ BuiltInWhenLeavesInner,
153
+ BuiltInWhenLeavesOuter,
154
+ DefaultSpreadBase,
155
+ EditRule,
156
+ ExecOpts,
157
+ ExecResult,
158
+ InnerValue,
159
+ Observer,
160
+ ObserverContext,
161
+ ObserverWatch,
162
+ OperatorField,
163
+ OuterValue,
164
+ Pattern,
165
+ Patterns,
166
+ Plugin,
167
+ PluginPredicateKey,
168
+ PredicateContext,
169
+ PredicateFn,
170
+ PredicateHandler,
171
+ PredicateModifiers,
172
+ PredicateShape,
173
+ PredicateToolInput,
174
+ PredicateVerdict,
175
+ ReasonFn,
176
+ ReservedPredicateKey,
177
+ Rule,
178
+ SteeringConfig,
179
+ SteeringDiagnostic,
180
+ SteeringDiagnosticKind,
181
+ ToolResultEvent,
182
+ TopLevelWhenClause,
183
+ TopLevelWhenClauseNoRecurse,
184
+ WhenClause,
185
+ WhenWalkerState,
186
+ WriteRule,
187
+ } from "./schema.ts";
188
+ export type {
189
+ BashShorthand,
190
+ CreateRecordingHostOptions,
191
+ EditShorthand,
192
+ ExpectBlocksOptions,
193
+ Harness,
194
+ LoadHarnessOptions,
195
+ MatrixCase,
196
+ MatrixCaseResult,
197
+ MatrixResult,
198
+ MockContextOptions,
199
+ MockEntry,
200
+ MockObserverContextOptions,
201
+ PriorEntryOptions,
202
+ RecordedExecCall,
203
+ RecordedSessionEntry,
204
+ RecordingHost,
205
+ ToolCallShorthand,
206
+ ToolResultShorthand,
207
+ WriteShorthand,
208
+ } from "./testing/index.ts";
209
+ // Testing primitives — re-exported at the root for discoverability.
210
+ // The canonical import path is `@cad0p/pi-steering/testing`;
211
+ // this root re-export means a test file that already imports
212
+ // `defineConfig` from the root doesn't need a second import line for
213
+ // `loadHarness`. See `./testing/index.ts` for the API docs.
214
+ export {
215
+ createRecordingHost,
216
+ expectAllows,
217
+ expectBlocks,
218
+ expectRuleFires,
219
+ formatMatrix,
220
+ getAppendedEntries,
221
+ loadHarness,
222
+ mockContext,
223
+ mockExtensionContext,
224
+ mockObserverContext,
225
+ priorEntry,
226
+ runMatrix,
227
+ testObserver,
228
+ testPredicate,
229
+ } from "./testing/index.ts";
@@ -0,0 +1,141 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ import assert from "node:assert/strict";
5
+ import { describe, it } from "node:test";
6
+ import type { Observer, Rule } from "../schema.ts";
7
+ import { dropUnusedObservers } from "./drop-unused-observers.ts";
8
+
9
+ /** Minimal Observer for tests. */
10
+ function mkObserver(name: string, writes?: readonly string[]): Observer {
11
+ const base = {
12
+ name,
13
+ watch: { toolName: "bash" as const },
14
+ onResult: () => {},
15
+ } satisfies Observer;
16
+ return writes === undefined ? base : { ...base, writes };
17
+ }
18
+
19
+ /** Minimal Rule with a specific `happened` shape. */
20
+ function mkRuleWithHappened(name: string, event: string, since?: string): Rule {
21
+ return {
22
+ name,
23
+ tool: "bash",
24
+ field: "command",
25
+ pattern: /^x/,
26
+ when: {
27
+ happened: since
28
+ ? { event, in: "agent_loop", since }
29
+ : { event, in: "agent_loop" },
30
+ },
31
+ reason: "test",
32
+ };
33
+ }
34
+
35
+ /** Rule with no happened at all. */
36
+ function mkRuleNoHappened(name: string): Rule {
37
+ return {
38
+ name,
39
+ tool: "bash",
40
+ field: "command",
41
+ pattern: /^x/,
42
+ reason: "test",
43
+ };
44
+ }
45
+
46
+ describe("dropUnusedObservers", () => {
47
+ it("keeps observer whose write is consumed by a rule's happened.event", () => {
48
+ const observers = [mkObserver("a", ["X"])];
49
+ const rules = [mkRuleWithHappened("r1", "X")];
50
+ const { kept, dropped } = dropUnusedObservers(observers, rules);
51
+ assert.equal(kept.length, 1);
52
+ assert.equal(kept[0]?.name, "a");
53
+ assert.equal(dropped.length, 0);
54
+ });
55
+
56
+ it("drops observer whose writes are not consumed", () => {
57
+ const observers = [mkObserver("a", ["X"])];
58
+ const rules = [mkRuleNoHappened("r1")];
59
+ const { kept, dropped } = dropUnusedObservers(observers, rules);
60
+ assert.equal(kept.length, 0);
61
+ assert.equal(dropped.length, 1);
62
+ assert.equal(dropped[0]?.name, "a");
63
+ assert.deepEqual(dropped[0]?.writes, ["X"]);
64
+ assert.equal(dropped[0]?.reason, "unused-writes");
65
+ });
66
+
67
+ it("keeps observer when at least one of its writes is consumed", () => {
68
+ const observers = [mkObserver("a", ["X", "Y"])];
69
+ const rules = [mkRuleWithHappened("r1", "X")];
70
+ const { kept, dropped } = dropUnusedObservers(observers, rules);
71
+ assert.equal(kept.length, 1);
72
+ assert.equal(dropped.length, 0);
73
+ });
74
+
75
+ it("keeps observer with `writes: undefined` (conservative)", () => {
76
+ const observers = [mkObserver("a", undefined)];
77
+ const rules = [mkRuleNoHappened("r1")];
78
+ const { kept, dropped } = dropUnusedObservers(observers, rules);
79
+ assert.equal(kept.length, 1);
80
+ assert.equal(dropped.length, 0);
81
+ });
82
+
83
+ it("keeps observer with empty `writes: []` (conservative parity)", () => {
84
+ const observers = [mkObserver("a", [])];
85
+ const rules = [mkRuleNoHappened("r1")];
86
+ const { kept, dropped } = dropUnusedObservers(observers, rules);
87
+ assert.equal(kept.length, 1);
88
+ assert.equal(dropped.length, 0);
89
+ });
90
+
91
+ it("keeps observer whose write is consumed only by happened.since", () => {
92
+ const observers = [mkObserver("a", ["X"])];
93
+ const rules = [mkRuleWithHappened("r1", "Y", /* since */ "X")];
94
+ const { kept, dropped } = dropUnusedObservers(observers, rules);
95
+ assert.equal(kept.length, 1);
96
+ assert.equal(dropped.length, 0);
97
+ });
98
+
99
+ it("partitions a mixed observer set correctly", () => {
100
+ const observers = [
101
+ mkObserver("consumed", ["X"]),
102
+ mkObserver("unused", ["Z"]),
103
+ mkObserver("undeclared"),
104
+ ];
105
+ const rules = [mkRuleWithHappened("r1", "X")];
106
+ const { kept, dropped } = dropUnusedObservers(observers, rules);
107
+ assert.deepEqual(kept.map((o) => o.name).sort(), [
108
+ "consumed",
109
+ "undeclared",
110
+ ]);
111
+ assert.deepEqual(
112
+ dropped.map((d) => d.name),
113
+ ["unused"],
114
+ );
115
+ });
116
+
117
+ it("drops everything when no rule uses `when.happened`", () => {
118
+ const observers = [mkObserver("a", ["X"]), mkObserver("b", ["Y", "Z"])];
119
+ const rules = [mkRuleNoHappened("r1"), mkRuleNoHappened("r2")];
120
+ const { kept, dropped } = dropUnusedObservers(observers, rules);
121
+ assert.equal(kept.length, 0);
122
+ assert.equal(dropped.length, 2);
123
+ });
124
+
125
+ it("returns empty arrays for empty inputs", () => {
126
+ const { kept, dropped } = dropUnusedObservers([], []);
127
+ assert.equal(kept.length, 0);
128
+ assert.equal(dropped.length, 0);
129
+ });
130
+
131
+ it("honors caller-applied rule filtering (disabledRules)", () => {
132
+ // Caller is responsible for filtering disabled rules before
133
+ // passing to this helper — demonstrate that a rule NOT in the
134
+ // input list does NOT cause its observer writes to stay alive.
135
+ const observers = [mkObserver("a", ["X"])];
136
+ const rulesAfterDisable: Rule[] = []; // r1 (consumer) disabled → filtered out
137
+ const { kept, dropped } = dropUnusedObservers(observers, rulesAfterDisable);
138
+ assert.equal(kept.length, 0);
139
+ assert.equal(dropped.length, 1);
140
+ });
141
+ });
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Observer-drop optimization.
6
+ *
7
+ * At config-build time, observers whose declared `writes: [...]` are
8
+ * entirely unconsumed by any rule's `happened.event` / `happened.since`
9
+ * can be dropped: they'd only fire on `tool_result` and write entries
10
+ * no rule ever reads. Dropping them skips both the write (cheaper
11
+ * session JSONL) and the speculative-synthesis contribution (no chain
12
+ * entries for events nobody consults).
13
+ *
14
+ * Observers with `writes === undefined` or `writes: []` are kept
15
+ * unconditionally — without a declaration we can't prove they're
16
+ * unused, and `[]` is conservative parity with undeclared. Plugin
17
+ * authors are encouraged to declare `writes` both for this
18
+ * optimization and for `defineConfig`'s compile-time type inference.
19
+ *
20
+ * Pure function — no logging. Callers (session-runtime, testing)
21
+ * log on drop so tests can inspect `dropped` directly without
22
+ * intercepting stdout.
23
+ */
24
+
25
+ import type { Observer, Rule } from "../schema.ts";
26
+
27
+ /** An observer dropped by {@link dropUnusedObservers}. */
28
+ export interface DroppedObserver {
29
+ readonly name: string;
30
+ readonly writes: readonly string[];
31
+ readonly reason: "unused-writes";
32
+ }
33
+
34
+ /**
35
+ * Partition `observers` into kept and dropped based on whether each
36
+ * observer's declared writes are consumed by any rule.
37
+ *
38
+ * @param observers - Observers to filter (any origin — plugin-merged
39
+ * or user-authored).
40
+ * @param rules - Full rule set whose `happened.event` / `happened.since`
41
+ * references determine consumption. Disabled rules should already
42
+ * be filtered out by the caller — this helper honors whatever's
43
+ * passed in.
44
+ */
45
+ export function dropUnusedObservers(
46
+ observers: readonly Observer[],
47
+ rules: readonly Rule[],
48
+ ): { kept: readonly Observer[]; dropped: readonly DroppedObserver[] } {
49
+ const consumed = collectConsumedEvents(rules);
50
+ const kept: Observer[] = [];
51
+ const dropped: DroppedObserver[] = [];
52
+ for (const o of observers) {
53
+ if (o.writes === undefined || o.writes.length === 0) {
54
+ kept.push(o);
55
+ continue;
56
+ }
57
+ if (o.writes.some((w) => consumed.has(w))) {
58
+ kept.push(o);
59
+ continue;
60
+ }
61
+ dropped.push({
62
+ name: o.name,
63
+ writes: o.writes,
64
+ reason: "unused-writes",
65
+ });
66
+ }
67
+ return { kept, dropped };
68
+ }
69
+
70
+ /** Collect every event referenced by any rule's `happened.event` /
71
+ * `happened.since`. Rules without `when.happened` contribute nothing. */
72
+ function collectConsumedEvents(rules: readonly Rule[]): Set<string> {
73
+ const consumed = new Set<string>();
74
+ for (const rule of rules) {
75
+ const h = rule.when?.happened;
76
+ if (h === undefined) continue;
77
+ if (typeof h.event === "string") consumed.add(h.event);
78
+ if (typeof h.since === "string") consumed.add(h.since);
79
+ }
80
+ return consumed;
81
+ }
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Late-stage plugin-state finalization shared by `buildSessionRuntime`,
6
+ * `loadHarness`, and the CLI `list` path. Centralizes the
7
+ * `dropUnusedObservers` + breadcrumb pass so all three stay byte-equal
8
+ * in their breadcrumb output and observer-drop semantics.
9
+ *
10
+ * Drops observers whose declared `writes` are unconsumed across both
11
+ * plugin-merged and user-authored streams, using the union of all
12
+ * rule `happened` references. Emits an `info`-level breadcrumb per
13
+ * dropped observer so plugin authors debugging "why isn't my
14
+ * observer firing?" have a trail to follow without it bubbling up as
15
+ * a diagnostic the user has to action.
16
+ *
17
+ * Callers must pre-filter `config.disabledRules` out of `userRules`
18
+ * — see {@link dropUnusedObservers}'s contract.
19
+ */
20
+
21
+ import type { Observer, Rule } from "../schema.ts";
22
+ import { dropUnusedObservers } from "./drop-unused-observers.ts";
23
+
24
+ export function finalizePluginState(
25
+ userRules: readonly Rule[],
26
+ pluginRules: readonly Rule[],
27
+ userObservers: readonly Observer[],
28
+ pluginObservers: readonly Observer[],
29
+ ): {
30
+ pluginKept: readonly Observer[];
31
+ userKept: readonly Observer[];
32
+ } {
33
+ const allRules = [...userRules, ...pluginRules];
34
+ const pluginDrop = dropUnusedObservers(pluginObservers, allRules);
35
+ const userDrop = dropUnusedObservers(userObservers, allRules);
36
+ for (const d of [...pluginDrop.dropped, ...userDrop.dropped]) {
37
+ console.info(
38
+ `[pi-steering] observer '${d.name}' dropped; its writes ` +
39
+ `(${d.writes.join(", ")}) are not consumed by any rule`,
40
+ );
41
+ }
42
+ return { pluginKept: pluginDrop.kept, userKept: userDrop.kept };
43
+ }
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Shared observer-merge helper used by both the evaluator's reverse
6
+ * index (`when.happened` with `in: "tool_call"`) and the observer-dispatcher's
7
+ * fire list.
8
+ *
9
+ * Both callers need the same answer to the question "what observers
10
+ * does this config have, after resolving plugins and applying user
11
+ * overrides?" Keeping a single implementation here prevents silent
12
+ * drift — if the dispatcher drops a plugin observer because a user
13
+ * observer shadows it by name, the evaluator's reverse-index must do
14
+ * the same, or tool_call-scope speculative allow can grant on a watch
15
+ * pattern that will never actually fire.
16
+ *
17
+ * Merge semantics (must match `buildObserverDispatcher`):
18
+ * 1. User observers come first (user-wins on name collision).
19
+ * 2. Plugin observers fill in any names not already seen.
20
+ *
21
+ * The order mirrors the rule-list "user overrides plugin by declaring
22
+ * their own" pattern documented on `buildObserverDispatcher`.
23
+ */
24
+
25
+ import type { Observer } from "../schema.ts";
26
+
27
+ /**
28
+ * Merge user-level and plugin-level observers into a single list with
29
+ * first-registered dedup by `name`. User observers always precede
30
+ * plugin observers of the same name.
31
+ *
32
+ * Returns a fresh array; neither input is mutated.
33
+ */
34
+ export function mergeObserversUserFirst(
35
+ userObservers: readonly Observer[],
36
+ pluginObservers: readonly Observer[],
37
+ ): Observer[] {
38
+ const merged: Observer[] = [];
39
+ const seen = new Set<string>();
40
+ for (const o of userObservers) {
41
+ if (seen.has(o.name)) continue;
42
+ seen.add(o.name);
43
+ merged.push(o);
44
+ }
45
+ for (const o of pluginObservers) {
46
+ if (seen.has(o.name)) continue;
47
+ seen.add(o.name);
48
+ merged.push(o);
49
+ }
50
+ return merged;
51
+ }
@@ -0,0 +1,52 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ import assert from "node:assert/strict";
5
+ import { describe, it } from "node:test";
6
+ import { isPattern } from "./pattern-utils.ts";
7
+
8
+ describe("isPattern", () => {
9
+ it("narrows strings to Pattern", () => {
10
+ assert.equal(isPattern("hello"), true);
11
+ assert.equal(isPattern(""), true);
12
+ });
13
+
14
+ it("narrows RegExp to Pattern", () => {
15
+ assert.equal(isPattern(/foo/), true);
16
+ assert.equal(isPattern(/(?:)/), true);
17
+ assert.equal(isPattern(/^bar$/i), true);
18
+ });
19
+
20
+ it("rejects primitives that aren't string", () => {
21
+ assert.equal(isPattern(123), false);
22
+ assert.equal(isPattern(true), false);
23
+ assert.equal(isPattern(false), false);
24
+ assert.equal(isPattern(null), false);
25
+ assert.equal(isPattern(undefined), false);
26
+ assert.equal(isPattern(Symbol("foo")), false);
27
+ });
28
+
29
+ it("rejects objects + arrays", () => {
30
+ assert.equal(isPattern({}), false);
31
+ assert.equal(isPattern({ pattern: "x" }), false);
32
+ assert.equal(isPattern([]), false);
33
+ assert.equal(isPattern(["foo"]), false);
34
+ assert.equal(isPattern([/foo/]), false);
35
+ });
36
+
37
+ it("narrows correctly through Array.every (compile-time check)", () => {
38
+ // This is the exact use site that motivated the type predicate:
39
+ // `arr.every(isPattern)` MUST narrow `unknown[]` to `Pattern[]`
40
+ // at the type level. The runtime assertion below is incidental;
41
+ // the real test is that this file typechecks.
42
+ const arr: unknown[] = ["foo", /bar/];
43
+ if (arr.every(isPattern)) {
44
+ // Inside this branch, `arr` is Pattern[]; the .some call
45
+ // would be a type error if narrowing weren't working.
46
+ const someResult: boolean = arr.some(
47
+ (p) => typeof p === "string" || p instanceof RegExp,
48
+ );
49
+ assert.equal(someResult, true);
50
+ }
51
+ });
52
+ });
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Shared pattern utilities reused across the engine and the gitPlugin's
6
+ * predicate handlers. Lives in `internal/` because both the engine
7
+ * (`evaluator-internals/predicates.ts`) and the gitPlugin
8
+ * (`plugins/git/predicates.ts`) need the same `Pattern` narrowing
9
+ * helper, and the engine cannot import from a plugin without inverting
10
+ * the dependency graph.
11
+ *
12
+ * The two normalization sites stay independent (the engine's
13
+ * `evaluateCwd` keeps its inline fast-path optimization for the
14
+ * single-pattern shorthand; the gitPlugin's `unwrapPatternArg`
15
+ * normalizes once and shares the result across `branch` / `upstream`
16
+ * / `remote`); only the type predicate is shared.
17
+ */
18
+
19
+ import type { Pattern } from "../schema.ts";
20
+
21
+ /**
22
+ * Type predicate for {@link Pattern} (`string | RegExp`). Used by the
23
+ * pattern-valued predicates (`cwd`, `branch`, `upstream`, `remote`) to
24
+ * narrow `unknown[]` array elements to `Pattern[]` for safe iteration:
25
+ *
26
+ * if (Array.isArray(value) && value.every(isPattern)) {
27
+ * // value is Pattern[] here
28
+ * return value.some(p => matchesPattern(p, target));
29
+ * }
30
+ *
31
+ * Without the `v is Pattern` type predicate, the same expression
32
+ * (`value.every(v => typeof v === "string" || v instanceof RegExp)`)
33
+ * does NOT narrow `value` from `unknown[]` to `Pattern[]` — TypeScript
34
+ * needs the explicit type-predicate signature for the narrowing to
35
+ * propagate through `Array.isArray` + `.every`.
36
+ */
37
+ export const isPattern = (v: unknown): v is Pattern =>
38
+ typeof v === "string" || v instanceof RegExp;
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+ //
4
+ // Unit tests for refToText. The function is a thin wrapper around
5
+ // unbash-walker's getBasename / getCommandArgs; these tests pin the
6
+ // exact rendered shape observer-watch patterns see so a future
7
+ // refactor that accidentally changes spacing (e.g. drops the trim)
8
+ // doesn't silently break every `watch.inputMatches.command` in the
9
+ // wild.
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import { extractAllCommandsFromAST, parse } from "@cad0p/unbash-walker";
14
+ import { refToText } from "./ref-text.ts";
15
+
16
+ function refsFor(command: string) {
17
+ const script = parse(command);
18
+ return extractAllCommandsFromAST(script, command);
19
+ }
20
+
21
+ describe("refToText", () => {
22
+ it("renders bare command with no args", () => {
23
+ const [ref] = refsFor("alpha");
24
+ assert.equal(refToText(ref!), "alpha");
25
+ });
26
+
27
+ it("renders command with single arg", () => {
28
+ const [ref] = refsFor("git push");
29
+ assert.equal(refToText(ref!), "git push");
30
+ });
31
+
32
+ it("renders command with multiple args, space-joined", () => {
33
+ const [ref] = refsFor("git push origin main --force");
34
+ assert.equal(refToText(ref!), "git push origin main --force");
35
+ });
36
+
37
+ it("trims trailing space from empty args list", () => {
38
+ const [ref] = refsFor("noop");
39
+ assert.equal(
40
+ refToText(ref!),
41
+ "noop",
42
+ "no trailing space when args is empty",
43
+ );
44
+ });
45
+
46
+ it("basename strips path prefix", () => {
47
+ const [ref] = refsFor("/usr/local/bin/cr --all");
48
+ assert.equal(
49
+ refToText(ref!),
50
+ "cr --all",
51
+ "rendered command uses basename, not absolute path",
52
+ );
53
+ });
54
+ });