@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,598 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Plugin merger — flatten a list of plugins + a SteeringConfig into a
6
+ * single `ResolvedPluginState` the evaluator and observer dispatcher can
7
+ * drive off directly.
8
+ *
9
+ * Per the accepted ADR ("Design → Plugin schema" and "Precedence:
10
+ * first-wins everywhere"):
11
+ *
12
+ * - predicates / rules / observers — first-registered wins on name
13
+ * collision; later entries logged as WARNings.
14
+ * - trackers — HARD ERROR on name collision (two plugins claiming the
15
+ * same state dimension is always a bug, not a soft-override).
16
+ * - trackerExtensions — later plugins can layer modifiers onto an
17
+ * existing tracker under a `(tracker, basename)` slot. Multiple
18
+ * entries under the same slot are preserved in registration order.
19
+ * Extensions targeting an unregistered tracker are warned about and
20
+ * ignored.
21
+ * - config.disabledRules / config.disabledPlugins — filter rules and
22
+ * whole plugins by name. Disabled entries are surfaced via
23
+ * `console.info` breadcrumbs (NOT diagnostics, since disabling is
24
+ * by-design behavior). `config.disableDefaults` is the caller's
25
+ * problem:
26
+ * the caller chooses whether to include DEFAULT_PLUGINS in the input
27
+ * list (handled upstream by the extension runtime).
28
+ *
29
+ * The composed trackers map returned here is what the runtime passes to
30
+ * `walk()`; the raw `trackers` map from individual plugins is kept on
31
+ * the result as well for introspection / tests.
32
+ */
33
+
34
+ import type { Modifier, Tracker } from "@cad0p/unbash-walker";
35
+ import {
36
+ isReservedPredicateKey,
37
+ RESERVED_PREDICATE_KEYS,
38
+ } from "./evaluator-internals/predicates.ts";
39
+ import type {
40
+ Observer,
41
+ OperatorField,
42
+ Plugin,
43
+ PredicateHandler,
44
+ PredicateModifiers,
45
+ ReservedPredicateKey,
46
+ Rule,
47
+ SteeringConfig,
48
+ SteeringDiagnostic,
49
+ } from "./schema.ts";
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Shared diagnostic message formatters
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Single source of truth for the `tracker-name-collision` diagnostic
57
+ * message. Both `loader.ts:detectTrackerNameCollisions` and
58
+ * `plugin-merger.ts:resolvePlugins` call this so the wording stays in
59
+ * lock-step.
60
+ */
61
+ export function formatTrackerNameCollisionMessage(
62
+ firstRegisteredPlugin: string,
63
+ secondRegisteredPlugin: string,
64
+ trackerName: string,
65
+ ): string {
66
+ return (
67
+ `tracker name collision: both plugins "${firstRegisteredPlugin}" and ` +
68
+ `"${secondRegisteredPlugin}" register a tracker called "${trackerName}". ` +
69
+ "Two plugins claiming the same state dimension is always a " +
70
+ "bug — rename one tracker or disable one plugin."
71
+ );
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Name validation (S3)
76
+ // ---------------------------------------------------------------------------
77
+ //
78
+ // See ./INVARIANTS.md for the S/E tag glossary.
79
+
80
+ /**
81
+ * Allowed shape for rule / plugin / observer names. Letters, digits,
82
+ * underscores, and dashes; must start with a letter or digit. Matches
83
+ * the character class used by the override-comment parser
84
+ * (`./evaluator-internals/override.ts`), so every legal rule name is
85
+ * also a legal override-comment target — and vice versa.
86
+ *
87
+ * The starting-with-a-digit branch is deliberate: prefixing a rule
88
+ * with a year or group number (`2026-release`, `01-critical`) is a
89
+ * common authoring pattern and we don't want to reject it.
90
+ */
91
+ const NAME_REGEX = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
92
+
93
+ /**
94
+ * S3: validate a rule / plugin / observer name at load time. Names
95
+ * flow into user-visible strings — the `[steering:<name>@<source>]`
96
+ * block-reason tag shown to the LLM, the `@<source>` tag in warning
97
+ * logs, override-comment target matching, `disabledRules` /
98
+ * `disabledPlugins` config references. Names containing whitespace,
99
+ * control characters, `]`, or newlines let a malicious (or careless)
100
+ * config author forge block reasons that deceive the agent:
101
+ *
102
+ * name: "phony] ALL CLEAR [real"
103
+ * → reason: "[steering:phony] ALL CLEAR [real@user] ..."
104
+ *
105
+ * Returns an error-class `SteeringDiagnostic` with `kind:
106
+ * "invalid-name"` when the name is malformed; `undefined` when the
107
+ * name passes. Callers in the diagnostic-aggregation flow
108
+ * (`resolvePlugins`) push the returned diagnostic onto their local
109
+ * stream so the strict-mode runtime sees it alongside other
110
+ * error-class diagnostics. Direct callers outside the aggregation
111
+ * flow (`buildEvaluator`, `buildObserverDispatcher`) translate the
112
+ * returned diagnostic into a thrown `Error` at build time so the
113
+ * malformed name short-circuits the user-config wiring before the
114
+ * first tool_call.
115
+ *
116
+ * The validation kind is plumbed through to the message so the
117
+ * author knows exactly which of their objects is at fault (`rule
118
+ * name`, `plugin name`, `observer name`).
119
+ */
120
+ export function validateName(
121
+ kind: "rule" | "plugin" | "observer",
122
+ value: unknown,
123
+ context?: string,
124
+ ): SteeringDiagnostic | undefined {
125
+ if (typeof value !== "string" || !NAME_REGEX.test(value)) {
126
+ const shown =
127
+ typeof value === "string" ? JSON.stringify(value) : String(value);
128
+ const suffix = context !== undefined ? ` (${context})` : "";
129
+ return {
130
+ type: "error",
131
+ kind: "invalid-name",
132
+ message:
133
+ `${kind} name ${shown}${suffix} contains disallowed ` +
134
+ `characters. Allowed: letters, digits, underscores, dashes; ` +
135
+ `must start with a letter or digit.`,
136
+ };
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ /**
142
+ * Validate the `name` field on every user-config rule and observer.
143
+ * Plugin-shipped rule / observer / plugin names are validated inside
144
+ * {@link resolvePlugins}; user-config rules and observers reach
145
+ * {@link validateName} only at factory time (via
146
+ * `buildEvaluator` / `buildObserverDispatcher`'s build-time throw).
147
+ *
148
+ * The CLI's `pi-steering list` pre-flight surface uses this helper
149
+ * to flag the same class of malformed names BEFORE the user hits a
150
+ * thrown error from the bridge factory — otherwise a config with a
151
+ * malformed user-config rule name renders as a valid listing on
152
+ * stdout, then production refuses to start on the same config.
153
+ *
154
+ * Operates on the raw user-authored `layers` array — NOT on the
155
+ * post-merge `SteeringConfig`. The merged config can include
156
+ * default rules injected by `buildConfig` (when `disableDefaults`
157
+ * is false); validating those would attribute package-controlled
158
+ * names to a `(user config)` source, which is a misnomer. Default
159
+ * rule names ship in `DEFAULT_RULES` and are package-controlled —
160
+ * they don't pass through this validator.
161
+ *
162
+ * Note: `layer.observers` covers user-authored observers only.
163
+ * Plugin-shipped observers live under `layer.plugins[].observers`
164
+ * and are validated by {@link resolvePlugins}.
165
+ */
166
+ export function validateUserConfigNames(
167
+ layers: readonly SteeringConfig[],
168
+ ): SteeringDiagnostic[] {
169
+ const diagnostics: SteeringDiagnostic[] = [];
170
+ for (const layer of layers) {
171
+ for (const rule of layer.rules ?? []) {
172
+ const d = validateName("rule", rule.name, "user config");
173
+ if (d !== undefined) diagnostics.push(d);
174
+ }
175
+ for (const observer of layer.observers ?? []) {
176
+ const d = validateName("observer", observer.name, "user config");
177
+ if (d !== undefined) diagnostics.push(d);
178
+ }
179
+ }
180
+ return diagnostics;
181
+ }
182
+
183
+ /**
184
+ * Fully-resolved plugin state: the evaluator + observer dispatcher drive
185
+ * off this shape. All maps / arrays are freshly built and safe for the
186
+ * caller to stash on the extension closure.
187
+ */
188
+ export interface ResolvedPluginState {
189
+ /** Plugin-registered predicate handlers, keyed by `when.<key>`. */
190
+ predicates: Record<string, PredicateHandler>;
191
+
192
+ /** Observers in registration order, deduped by name. */
193
+ observers: Observer[];
194
+
195
+ /**
196
+ * Plugin-declared trackers (NOT yet composed with trackerExtensions).
197
+ * Exposed for introspection and tests; the runtime should use
198
+ * {@link composedTrackers} when calling {@link walk}.
199
+ */
200
+ trackers: Record<string, Tracker<unknown>>;
201
+
202
+ /**
203
+ * Modifiers layered on by `trackerExtensions`, keyed by
204
+ * `[trackerName][basename]`. Multiple modifiers under one slot are
205
+ * appended in registration order. Consumers typically use
206
+ * {@link composedTrackers} instead.
207
+ */
208
+ trackerModifiers: Record<string, Record<string, Modifier<unknown>[]>>;
209
+
210
+ /**
211
+ * Trackers after applying {@link trackerModifiers} on top of each
212
+ * plugin's own `modifiers` map. This is the map that gets passed to
213
+ * unbash-walker's `walk()` at evaluation time.
214
+ */
215
+ composedTrackers: Record<string, Tracker<unknown>>;
216
+
217
+ /** Plugin-shipped rules in registration order, deduped by name. */
218
+ rules: Rule[];
219
+
220
+ /**
221
+ * Rule-name → plugin-name mapping for every rule surviving in
222
+ * {@link rules}. Consumed by the evaluator to source-tag block
223
+ * reasons as `[steering:<rule>@<plugin>] …`. User-defined rules
224
+ * (`SteeringConfig.rules`) are NOT in this map — the evaluator
225
+ * defaults to `@user` for anything missing.
226
+ */
227
+ rulePluginOwners: Record<string, string>;
228
+
229
+ /**
230
+ * Diagnostics observed while resolving plugins. Includes both
231
+ * non-fatal collisions (warning class) and reserved-name violations
232
+ * that the runtime escalates to a thrown error regardless of
233
+ * strict-mode settings.
234
+ */
235
+ diagnostics: SteeringDiagnostic[];
236
+ }
237
+
238
+ /**
239
+ * Treat either a single Modifier or an array of them as a fresh
240
+ * array. Always allocates so callers can mutate safely without
241
+ * affecting the input plugin's modifier map.
242
+ */
243
+ function toModifierList<T>(
244
+ value: Modifier<T> | readonly Modifier<T>[],
245
+ ): Modifier<T>[] {
246
+ return Array.isArray(value) ? [...value] : [value as Modifier<T>];
247
+ }
248
+
249
+ /**
250
+ * Build a new tracker with `extras` modifiers appended to the tracker's
251
+ * own `modifiers` map. Existing basename entries become arrays with the
252
+ * extras appended; new basenames land as their own entries.
253
+ *
254
+ * Intentionally non-mutating — the input tracker may be shared across
255
+ * test runs or plugin registrations, so we copy before layering.
256
+ */
257
+ function composeTracker(
258
+ tracker: Tracker<unknown>,
259
+ extras: Record<string, Modifier<unknown>[]> | undefined,
260
+ ): Tracker<unknown> {
261
+ if (!extras || Object.keys(extras).length === 0) return tracker;
262
+
263
+ const merged: Record<string, Modifier<unknown> | Modifier<unknown>[]> = {};
264
+ // Start with the tracker's own modifiers (shallow-copy the values so
265
+ // we don't mutate the original map when we append extras below).
266
+ for (const [basename, mod] of Object.entries(tracker.modifiers)) {
267
+ merged[basename] = Array.isArray(mod)
268
+ ? [...(mod as Modifier<unknown>[])]
269
+ : mod;
270
+ }
271
+ for (const [basename, mods] of Object.entries(extras)) {
272
+ const existing = merged[basename];
273
+ if (existing === undefined) {
274
+ // Fresh basename: preserve array form when multiple extras land
275
+ // together, collapse to single when there's just one.
276
+ merged[basename] = mods.length === 1 ? mods[0]! : [...mods];
277
+ continue;
278
+ }
279
+ const existingList = Array.isArray(existing)
280
+ ? (existing as Modifier<unknown>[])
281
+ : [existing as Modifier<unknown>];
282
+ merged[basename] = [...existingList, ...mods];
283
+ }
284
+
285
+ return {
286
+ ...tracker,
287
+ modifiers: merged,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Merge a list of plugins together, applying the config's `disabledRules` /
293
+ * `disabledPlugins` filters along the way.
294
+ *
295
+ * The caller is responsible for composing the plugin list — including
296
+ * whether to prepend DEFAULT_PLUGINS. This function does not consult
297
+ * `config.disableDefaults`; that decision sits one layer up in the
298
+ * extension runtime.
299
+ *
300
+ * Collision semantics per the ADR:
301
+ * - predicate / observer / plugin-shipped-rule name collision — first
302
+ * wins, recorded as a warning-class diagnostic.
303
+ * - tracker name collision — recorded as an error-class diagnostic.
304
+ * The loader-side `buildConfig` (`detectTrackerNameCollisions`)
305
+ * records this same kind for callers going through the standard
306
+ * pipeline; this in-merger check covers direct `resolvePlugins`
307
+ * callers (testing, external embed) that bypass `buildConfig`.
308
+ * Direct callers should check `result.diagnostics.some(d => d.type === "error")`
309
+ * before using the resolved state — same contract as `loadHarness`.
310
+ * - reserved tracker name (`events`) and reserved predicate keys
311
+ * (operator/modifier surface) — recorded as error-class
312
+ * diagnostics; the runtime escalates to a thrown error regardless
313
+ * of strict-mode settings.
314
+ * - trackerExtension targeting an unregistered tracker — recorded
315
+ * as a warning-class diagnostic, extension ignored.
316
+ *
317
+ * `knownBuiltinTrackers` lists tracker names the caller guarantees are
318
+ * injected at a later wiring stage (e.g. the evaluator's built-in
319
+ * `cwd` tracker). Extensions targeting these names are KEPT in
320
+ * `trackerModifiers` (so the caller can compose them onto the built-in
321
+ * tracker) without emitting an orphan warning. Omitted / empty list
322
+ * means "no built-ins" — every extension must target a
323
+ * plugin-registered tracker.
324
+ */
325
+ export function resolvePlugins(
326
+ plugins: readonly Plugin[],
327
+ config: SteeringConfig,
328
+ knownBuiltinTrackers: readonly string[] = [],
329
+ ): ResolvedPluginState {
330
+ const diagnostics: SteeringDiagnostic[] = [];
331
+ const disabledPlugins = new Set(config.disabledPlugins ?? []);
332
+ const disabledRules = new Set(config.disabledRules ?? []);
333
+
334
+ // S3: validate plugin names (and their rule + observer names) at
335
+ // load time so an evil / careless plugin can't plant a name like
336
+ // "phony] ALL CLEAR [real" that forges the
337
+ // `[steering:<name>@<source>]` tag the block reason exposes to the
338
+ // LLM. Plugin validation runs BEFORE the disabledPlugins filter so
339
+ // a malformed-named plugin still records a diagnostic even if the
340
+ // user tried to disable it — the name is written on disk and
341
+ // shouldn't be tolerated silently. Plugins with malformed names
342
+ // are skipped from the rest of the merger so the bad name doesn't
343
+ // leak into downstream collision keys.
344
+ const validNamedPlugins: Plugin[] = [];
345
+ for (const plugin of plugins) {
346
+ let pluginValid = true;
347
+ const pluginD = validateName("plugin", plugin.name);
348
+ if (pluginD !== undefined) {
349
+ diagnostics.push(pluginD);
350
+ pluginValid = false;
351
+ }
352
+ for (const rule of plugin.rules ?? []) {
353
+ const d = validateName("rule", rule.name, `plugin "${plugin.name}"`);
354
+ if (d !== undefined) {
355
+ diagnostics.push(d);
356
+ pluginValid = false;
357
+ }
358
+ }
359
+ for (const obs of plugin.observers ?? []) {
360
+ const d = validateName("observer", obs.name, `plugin "${plugin.name}"`);
361
+ if (d !== undefined) {
362
+ diagnostics.push(d);
363
+ pluginValid = false;
364
+ }
365
+ }
366
+ if (pluginValid) validNamedPlugins.push(plugin);
367
+ }
368
+
369
+ // Filter plugins honoring `disabledPlugins`. Disabled plugins are a
370
+ // by-design behavior, not a configuration issue, so they don't
371
+ // contribute to the diagnostic stream (escalating them to a throw
372
+ // under strict mode would make `disabledPlugins` unusable). Surface
373
+ // them via `console.info` for plugin authors debugging "why isn't
374
+ // my plugin firing?" — mirrors the breadcrumb pattern used for
375
+ // dropped observers in `internal/session-runtime.ts`.
376
+ const activePlugins: Plugin[] = [];
377
+ for (const plugin of validNamedPlugins) {
378
+ if (disabledPlugins.has(plugin.name)) {
379
+ console.info(
380
+ `[pi-steering] plugin "${plugin.name}" disabled via config.disabledPlugins`,
381
+ );
382
+ continue;
383
+ }
384
+ activePlugins.push(plugin);
385
+ }
386
+
387
+ // --- trackers ----------------------------------------------------------
388
+ // Hard-error on name collisions: two plugins claiming the same state
389
+ // dimension is always a bug.
390
+ const trackers: Record<string, Tracker<unknown>> = {};
391
+ const trackerOwner = new Map<string, string>(); // trackerName -> pluginName
392
+ for (const plugin of activePlugins) {
393
+ if (!plugin.trackers) continue;
394
+ for (const [name, tracker] of Object.entries(plugin.trackers)) {
395
+ // Reserved key: plugin-registered trackers may not claim `events`;
396
+ // the evaluator merges synthesized speculative entries under that
397
+ // name (see schema.ts `PredicateContext.walkerState` JSDoc).
398
+ if (name === "events") {
399
+ diagnostics.push({
400
+ type: "error",
401
+ kind: "reserved-tracker-name",
402
+ message:
403
+ `tracker name "events" is reserved: plugin "${plugin.name}" ` +
404
+ "registers a tracker under that name but the evaluator uses " +
405
+ "it on `walkerState` for speculative-entry synthesis consumed " +
406
+ "by the built-in `when.happened` predicate. Rename the tracker.",
407
+ });
408
+ continue;
409
+ }
410
+ const prior = trackerOwner.get(name);
411
+ if (prior !== undefined) {
412
+ diagnostics.push({
413
+ type: "error",
414
+ kind: "tracker-name-collision",
415
+ message: formatTrackerNameCollisionMessage(prior, plugin.name, name),
416
+ });
417
+ continue;
418
+ }
419
+ trackerOwner.set(name, plugin.name);
420
+ trackers[name] = tracker;
421
+ }
422
+ }
423
+
424
+ // --- tracker extensions ----------------------------------------------
425
+ // Modifiers to layer onto trackers, keyed by [trackerName][basename].
426
+ // Registration order is preserved — matches
427
+ // `Tracker.modifiers: Record<basename, Modifier | Modifier[]>`'s "apply
428
+ // left-to-right" semantics.
429
+ const trackerModifiers: Record<
430
+ string,
431
+ Record<string, Modifier<unknown>[]>
432
+ > = {};
433
+ const builtins = new Set(knownBuiltinTrackers);
434
+ for (const plugin of activePlugins) {
435
+ if (!plugin.trackerExtensions) continue;
436
+ for (const [trackerName, basenameMap] of Object.entries(
437
+ plugin.trackerExtensions,
438
+ )) {
439
+ if (!(trackerName in trackers) && !builtins.has(trackerName)) {
440
+ diagnostics.push({
441
+ type: "warning",
442
+ kind: "extension-orphan",
443
+ message:
444
+ `plugin "${plugin.name}" extends tracker "${trackerName}" ` +
445
+ `but no plugin registers it; extension ignored`,
446
+ });
447
+ continue;
448
+ }
449
+ let trackerBucket = trackerModifiers[trackerName];
450
+ if (trackerBucket === undefined) {
451
+ trackerBucket = {};
452
+ trackerModifiers[trackerName] = trackerBucket;
453
+ }
454
+ for (const [basename, mods] of Object.entries(basenameMap)) {
455
+ const list = toModifierList<unknown>(mods);
456
+ const existing = trackerBucket[basename];
457
+ if (existing === undefined) {
458
+ trackerBucket[basename] = list;
459
+ } else {
460
+ existing.push(...list);
461
+ }
462
+ }
463
+ }
464
+ }
465
+
466
+ // Compose extensions ON TOP of each tracker's own modifiers map.
467
+ const composedTrackers: Record<string, Tracker<unknown>> = {};
468
+ for (const [name, tracker] of Object.entries(trackers)) {
469
+ composedTrackers[name] = composeTracker(tracker, trackerModifiers[name]);
470
+ }
471
+
472
+ // --- predicates --------------------------------------------------------
473
+ const predicates: Record<string, PredicateHandler> = {};
474
+ const predicateOwner = new Map<string, string>();
475
+ for (const plugin of activePlugins) {
476
+ if (!plugin.predicates) continue;
477
+ for (const [key, handler] of Object.entries(plugin.predicates)) {
478
+ // Reserved-key check fires at registration time so plugin authors
479
+ // get immediate feedback instead of an opaque type error at the
480
+ // user's rule site (the type-level filter via `Exclude` silently
481
+ // drops reserved keys from the registry surface). Adding a new
482
+ // modifier to `PredicateModifiers` automatically reserves its key
483
+ // via `RESERVED_PREDICATE_KEYS`; the type-vs-runtime sync is
484
+ // pinned by the `_RESERVED_PREDICATE_KEYS_COVERS_TYPE` assertion
485
+ // in `evaluator-internals/predicates.ts`.
486
+ if (isReservedPredicateKey(key)) {
487
+ // Per-key suggestion list for the diagnostic message.
488
+ // `Record<ReservedPredicateKey, string>` is type-exhaustive,
489
+ // so adding a new modifier to `PredicateModifiers` (which
490
+ // auto-extends `RESERVED_PREDICATE_KEYS`) forces a new entry
491
+ // here rather than silently flowing through a generic
492
+ // fallback.
493
+ const suggestions: Record<ReservedPredicateKey, string> = {
494
+ not: '"isNot", "negate"',
495
+ onUnknown: '"unknownPolicy", "walkerUnknownPolicy"',
496
+ };
497
+ // `key` is narrowed to `ReservedPredicateKey` by the
498
+ // `isReservedPredicateKey` type guard above.
499
+ const suggestion = suggestions[key];
500
+ diagnostics.push({
501
+ type: "error",
502
+ kind: "reserved-predicate-key",
503
+ message:
504
+ `Plugin "${plugin.name}" attempted to register reserved ` +
505
+ `predicate key "${key}". This name conflicts with the ` +
506
+ `schema's operator/modifier surface ` +
507
+ `(${RESERVED_PREDICATE_KEYS.join(", ")}). Choose a ` +
508
+ `different name (e.g., ${suggestion}).`,
509
+ });
510
+ continue;
511
+ }
512
+ const prior = predicateOwner.get(key);
513
+ if (prior !== undefined) {
514
+ diagnostics.push({
515
+ type: "warning",
516
+ kind: "predicate-collision",
517
+ message:
518
+ `duplicate predicate "when.${key}" — plugins "${prior}" ` +
519
+ `(kept) and "${plugin.name}" (ignored); first-registered wins`,
520
+ });
521
+ continue;
522
+ }
523
+ predicateOwner.set(key, plugin.name);
524
+ predicates[key] = handler;
525
+ }
526
+ }
527
+
528
+ // --- observers ---------------------------------------------------------
529
+ const observers: Observer[] = [];
530
+ const observerOwner = new Map<string, string>();
531
+ for (const plugin of activePlugins) {
532
+ if (!plugin.observers) continue;
533
+ for (const observer of plugin.observers) {
534
+ const prior = observerOwner.get(observer.name);
535
+ if (prior !== undefined) {
536
+ diagnostics.push({
537
+ type: "warning",
538
+ kind: "observer-collision",
539
+ message:
540
+ `duplicate observer "${observer.name}" — plugins "${prior}" ` +
541
+ `(kept) and "${plugin.name}" (ignored); first-registered wins`,
542
+ });
543
+ continue;
544
+ }
545
+ observerOwner.set(observer.name, plugin.name);
546
+ observers.push(observer);
547
+ }
548
+ }
549
+
550
+ // --- rules -------------------------------------------------------------
551
+ // Plugin-shipped rules; config.rules stay in their own slot on the
552
+ // caller side. `config.disabledRules` filters BOTH plugin rules and config
553
+ // rules, so we apply it here for plugin rules and the runtime applies
554
+ // it again on the config side.
555
+ const rules: Rule[] = [];
556
+ const ruleOwner = new Map<string, string>();
557
+ for (const plugin of activePlugins) {
558
+ if (!plugin.rules) continue;
559
+ for (const rule of plugin.rules) {
560
+ if (disabledRules.has(rule.name)) {
561
+ // Disabled plugin-shipped rules are by-design behavior, not a
562
+ // configuration issue. Mirror the `disabledPlugins` breadcrumb
563
+ // above: `console.info` for plugin authors debugging "why
564
+ // isn't my rule firing?" without escalating to the diagnostic
565
+ // stream (which strict mode would throw on).
566
+ console.info(
567
+ `[pi-steering] rule "${rule.name}" (from plugin "${plugin.name}") ` +
568
+ `disabled via config.disabledRules`,
569
+ );
570
+ continue;
571
+ }
572
+ const prior = ruleOwner.get(rule.name);
573
+ if (prior !== undefined) {
574
+ diagnostics.push({
575
+ type: "warning",
576
+ kind: "rule-collision",
577
+ message:
578
+ `duplicate rule "${rule.name}" — plugins "${prior}" ` +
579
+ `(kept) and "${plugin.name}" (ignored); first-registered wins`,
580
+ });
581
+ continue;
582
+ }
583
+ ruleOwner.set(rule.name, plugin.name);
584
+ rules.push(rule);
585
+ }
586
+ }
587
+
588
+ return {
589
+ predicates,
590
+ observers,
591
+ trackers,
592
+ trackerModifiers,
593
+ composedTrackers,
594
+ rules,
595
+ rulePluginOwners: Object.fromEntries(ruleOwner),
596
+ diagnostics,
597
+ };
598
+ }