@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/loader.ts ADDED
@@ -0,0 +1,581 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * TS config loader. Walk up cwd → `$HOME`, find `.pi/steering/index.ts`
6
+ * or `.pi/steering.ts` per layer, dynamic-import each, merge
7
+ * inner-first. Per-symbol JSDoc carries the contract; see also the
8
+ * {@link SteeringDiagnostic} / {@link SteeringDiagnosticKind} JSDoc
9
+ * for the diagnostic stream.
10
+ */
11
+
12
+ import { existsSync, readdirSync, statSync } from "node:fs";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+ import { EVALUATOR_BUILTIN_TRACKERS } from "./evaluator.ts";
16
+ import { runMergerPipeline } from "./internal/session-runtime.ts";
17
+ import { formatTrackerNameCollisionMessage } from "./plugin-merger.ts";
18
+ import type {
19
+ Observer,
20
+ Plugin,
21
+ Rule,
22
+ SteeringConfig,
23
+ SteeringDiagnostic,
24
+ } from "./schema.ts";
25
+
26
+ /**
27
+ * Minimum Node version that supports native `.ts` import via type
28
+ * stripping (without a `--experimental-strip-types` flag). Shipped
29
+ * stable in Node 22.6+. We require 22.x outright to keep the error
30
+ * message simple.
31
+ */
32
+ const MIN_NODE_MAJOR = 22;
33
+
34
+ /**
35
+ * Runtime check: throws when Node is older than the minimum required
36
+ * for native `.ts` import. See README §Install for the supported
37
+ * Node range.
38
+ */
39
+ function assertNodeVersion(): void {
40
+ const raw = process.versions.node;
41
+ const major = Number.parseInt(raw.split(".")[0] ?? "0", 10);
42
+ if (Number.isNaN(major) || major < MIN_NODE_MAJOR) {
43
+ throw new Error(
44
+ `pi-steering requires Node >= ${MIN_NODE_MAJOR} ` +
45
+ `for native .ts loading (found ${raw}). ` +
46
+ `Upgrade Node, or stay on v1 JSON configs (\`.pi/steering.json\`).`,
47
+ );
48
+ }
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Discovery
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Candidate file paths for a given directory's `.pi/steering/...` slot,
57
+ * in priority order. First existing file wins.
58
+ *
59
+ * Exported for tests — not part of the library's public API.
60
+ */
61
+ export function configCandidates(dir: string): string[] {
62
+ return [
63
+ join(dir, ".pi", "steering", "index.ts"),
64
+ join(dir, ".pi", "steering.ts"),
65
+ ];
66
+ }
67
+
68
+ /**
69
+ * Return the non-`.ts` files that exist under `<dir>/.pi/steering/` so
70
+ * callers can warn about them. Uses a best-effort fs read: a missing
71
+ * directory returns an empty list.
72
+ */
73
+ function unexpectedFilesUnderSteering(dir: string): string[] {
74
+ const steeringDir = join(dir, ".pi", "steering");
75
+ if (!existsSync(steeringDir)) return [];
76
+ try {
77
+ const entries = readdirSync(steeringDir);
78
+ const out: string[] = [];
79
+ for (const name of entries) {
80
+ const full = join(steeringDir, name);
81
+ try {
82
+ const st = statSync(full);
83
+ if (!st.isFile()) continue;
84
+ if (name === "index.ts") continue;
85
+ if (name.endsWith(".ts")) continue; // allow helpers like `rules.ts`
86
+ out.push(full);
87
+ } catch {
88
+ // skip unreadable entry
89
+ }
90
+ }
91
+ return out;
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Walk up from `cwd` to `$HOME` (inclusive, or to the filesystem root
99
+ * if HOME is unset / outside the cwd's ancestry), returning the list
100
+ * of directories INNER-FIRST — so `[cwd, cwd/parent, ..., HOME]`.
101
+ *
102
+ * Exported for tests.
103
+ */
104
+ export function ancestorChain(cwd: string): string[] {
105
+ const home = process.env["HOME"] ?? "";
106
+ const out: string[] = [];
107
+ const seen = new Set<string>();
108
+ let current = resolve(cwd);
109
+ while (true) {
110
+ if (seen.has(current)) break; // symlink-loop guard
111
+ seen.add(current);
112
+ out.push(current);
113
+ if (current === home || current === "/") break;
114
+ const parent = dirname(current);
115
+ if (parent === current) break; // filesystem root
116
+ current = parent;
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Find the config file (if any) for a single layer. Returns the
123
+ * resolved file path and a `layer-form-coexistence` diagnostic when
124
+ * both `.pi/steering/index.ts` and `.pi/steering.ts` coexist in the
125
+ * same directory (the directory form wins).
126
+ *
127
+ * Exported for tests.
128
+ */
129
+ export function findConfigFile(dir: string): {
130
+ file: string | null;
131
+ diagnostic: SteeringDiagnostic | null;
132
+ } {
133
+ const [indexForm, flatForm] = configCandidates(dir);
134
+ const indexExists = indexForm !== undefined && existsSync(indexForm);
135
+ const flatExists = flatForm !== undefined && existsSync(flatForm);
136
+ let diagnostic: SteeringDiagnostic | null = null;
137
+ if (indexExists && flatExists && indexForm !== undefined) {
138
+ // `path` points at the directory holding the conflict so the
139
+ // renderer's `${path}: ${message}` prefix surfaces the parent
140
+ // once and the message names which two forms coexist. The winning
141
+ // file path is implicit (the directory form always wins).
142
+ diagnostic = {
143
+ type: "warning",
144
+ kind: "layer-form-coexistence",
145
+ path: dir,
146
+ message:
147
+ "both .pi/steering.ts and .pi/steering/index.ts exist; using " +
148
+ "directory form. Delete .pi/steering.ts to remove this warning.",
149
+ };
150
+ }
151
+ if (indexExists) return { file: indexForm ?? null, diagnostic };
152
+ if (flatExists) return { file: flatForm ?? null, diagnostic };
153
+ return { file: null, diagnostic };
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Loading
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Dynamic-import a single config file and return its default export.
162
+ * The module MUST `export default` a {@link SteeringConfig} object —
163
+ * the loader does not accept module-namespace imports as a fallback,
164
+ * to keep the authoring contract unambiguous.
165
+ *
166
+ * Throws a scoped error when the import fails, when the module has no
167
+ * default export, or when the default export isn't a plain object —
168
+ * the caller surfaces these per-layer without bringing the whole
169
+ * session down (a single bad layer shouldn't nuke the engine).
170
+ *
171
+ * Each call appends a unique `?t=<timestamp>` query string to the
172
+ * import URL. Node's ESM module map is keyed on URL and persists for
173
+ * the lifetime of the process, so a plain `await import(url)` returns
174
+ * the cached module forever — even after `/reload` and even after
175
+ * the file's content has changed (or, worse, after a previous load
176
+ * threw, since failed loads are also cached). The cache-bust forces
177
+ * Node to re-fetch and re-evaluate the file each call. See the
178
+ * "Hot-reload" section in the README for the limits of this approach
179
+ * (it does NOT bust transitively-imported plugin packages, which the
180
+ * user config reaches via static `import` of bare specifiers like
181
+ * `"pi-steering"` — those still require a full pi restart).
182
+ */
183
+ async function importConfigFile(path: string): Promise<SteeringConfig> {
184
+ const url = pathToFileURL(path).href;
185
+ /**
186
+ * Cache-bust query string. `process.hrtime.bigint()` returns
187
+ * monotonic nanoseconds since an arbitrary process-relative
188
+ * origin — each call returns a strictly greater value, even
189
+ * back-to-back within the same millisecond, so no two
190
+ * `importConfigFile` invocations ever share a URL. If a future
191
+ * Node version changes the cache-bust contract (e.g. ignores
192
+ * query strings on `file:` URLs), this will silently revert to
193
+ * the cached-forever behavior, and the integration tests in
194
+ * `loader.test.ts` will fail.
195
+ */
196
+ const bust = `${url.includes("?") ? "&" : "?"}t=${process.hrtime.bigint()}`;
197
+ const mod = (await import(url + bust)) as {
198
+ default?: unknown;
199
+ } & Record<string, unknown>;
200
+
201
+ if (mod.default === undefined) {
202
+ // Path is intentionally omitted from the message; the caller wraps
203
+ // this throw in a `layer-import-failed` diagnostic whose own `path`
204
+ // field is the single source of truth for the file location.
205
+ throw new Error(
206
+ "config file must have a default export. Use " +
207
+ "`export default { ... } satisfies SteeringConfig` or " +
208
+ "`export default defineConfig({ ... })`.",
209
+ );
210
+ }
211
+ const candidate = mod.default;
212
+ if (
213
+ candidate === null ||
214
+ typeof candidate !== "object" ||
215
+ Array.isArray(candidate)
216
+ ) {
217
+ throw new Error(
218
+ `config file default export must be a SteeringConfig object, ` +
219
+ `got ${Array.isArray(candidate) ? "array" : typeof candidate}.`,
220
+ );
221
+ }
222
+ return candidate as SteeringConfig;
223
+ }
224
+
225
+ /**
226
+ * Walk up from `cwd` collecting config layers. Returns INNER-FIRST
227
+ * (caller passes to {@link buildConfig}, which expects inner-first so
228
+ * early entries take precedence on collisions).
229
+ *
230
+ * Issues encountered along the way (per-layer import failure, dual
231
+ * form coexistence, stray non-`.ts` file under `.pi/steering/`)
232
+ * surface as structured {@link SteeringDiagnostic} entries on the
233
+ * returned object. The loader does not log to `console.warn` directly
234
+ * — the bridge runtime owns the policy decision (throw vs. log) once
235
+ * it has collected diagnostics from every source.
236
+ *
237
+ * @throws when Node is older than {@link MIN_NODE_MAJOR}.
238
+ */
239
+ export async function loadConfigs(cwd: string): Promise<{
240
+ layers: SteeringConfig[];
241
+ diagnostics: SteeringDiagnostic[];
242
+ }> {
243
+ assertNodeVersion();
244
+
245
+ const dirs = ancestorChain(cwd);
246
+ const layers: SteeringConfig[] = [];
247
+ const diagnostics: SteeringDiagnostic[] = [];
248
+ for (const dir of dirs) {
249
+ const { file, diagnostic } = findConfigFile(dir);
250
+ if (diagnostic !== null) diagnostics.push(diagnostic);
251
+ if (file === null) {
252
+ // Surface stray files under `.pi/steering/` that the loader
253
+ // won't pick up. Only check when the directory exists but has
254
+ // no `index.ts` — otherwise a project without any steering
255
+ // directory would emit noise.
256
+ const steeringDir = join(dir, ".pi", "steering");
257
+ if (existsSync(steeringDir)) {
258
+ for (const stray of unexpectedFilesUnderSteering(dir)) {
259
+ diagnostics.push({
260
+ type: "warning",
261
+ kind: "layer-stray-file",
262
+ path: stray,
263
+ message: "ignoring non-.ts file under .pi/steering/",
264
+ });
265
+ }
266
+ }
267
+ continue;
268
+ }
269
+ try {
270
+ layers.push(await importConfigFile(file));
271
+ } catch (err) {
272
+ // Use err.message to drop the `Error: ` class prefix; native
273
+ // runtime errors (jiti syntax errors) may embed their path inside
274
+ // the message and we accept that duplication.
275
+ const body = err instanceof Error ? err.message : String(err);
276
+ diagnostics.push({
277
+ type: "warning",
278
+ kind: "layer-import-failed",
279
+ path: file,
280
+ message: `failed to import: ${body}`,
281
+ });
282
+ }
283
+ }
284
+ return { layers, diagnostics };
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Merging
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * Collect plugins across layers, recording a diagnostic for each
293
+ * cross-layer duplicate plugin name. First-registered wins (inner
294
+ * layer is first — matches pi's project-local → global convention).
295
+ *
296
+ * The caller is responsible for unioning `config.disabledPlugins`
297
+ * across layers and passing the result as `disabledPlugins` (see
298
+ * `buildConfig`). Plugins whose name appears in the supplied set are
299
+ * still merged into the output (so downstream surfaces like
300
+ * `pi-steering list` can render them tagged as disabled), but they're
301
+ * EXEMPT from collision detection. A user resolving a duplicate-plugin
302
+ * warning by adding the plugin to `disabledPlugins` should see the
303
+ * warning go away in the same config edit; detect-then-disable would
304
+ * still surface the warning even though the disable already settled
305
+ * the conflict.
306
+ */
307
+ function mergePlugins(
308
+ layers: readonly SteeringConfig[],
309
+ disabledPlugins: ReadonlySet<string>,
310
+ diagnostics: SteeringDiagnostic[],
311
+ ): Plugin[] {
312
+ const seen = new Set<string>();
313
+ const out: Plugin[] = [];
314
+ for (const layer of layers) {
315
+ if (!layer.plugins) continue;
316
+ for (const plugin of layer.plugins) {
317
+ if (seen.has(plugin.name)) {
318
+ if (!disabledPlugins.has(plugin.name)) {
319
+ diagnostics.push({
320
+ type: "warning",
321
+ kind: "plugin-name-collision",
322
+ message: `duplicate plugin "${plugin.name}"; keeping first-registered entry.`,
323
+ });
324
+ }
325
+ continue;
326
+ }
327
+ seen.add(plugin.name);
328
+ out.push(plugin);
329
+ }
330
+ }
331
+ return out;
332
+ }
333
+
334
+ /**
335
+ * Merge rules across layers — inner layer's rule name overrides outer.
336
+ * Declaration order within a layer is preserved; cross-layer order is
337
+ * "first layer that mentions a given rule name wins for its slot".
338
+ *
339
+ * Records a diagnostic on duplicate names WITHIN a single layer
340
+ * (authoring mistake) — mirrors {@link mergeObservers}. Cross-layer
341
+ * collisions stay silent: overriding a rule by name is the documented
342
+ * customization path.
343
+ *
344
+ * Disabled rules are merged but exempt from collision detection (see
345
+ * {@link mergePlugins} for rationale).
346
+ */
347
+ function mergeRules(
348
+ layers: readonly SteeringConfig[],
349
+ disabledRules: ReadonlySet<string>,
350
+ diagnostics: SteeringDiagnostic[],
351
+ ): Rule[] {
352
+ const byName = new Map<string, Rule>();
353
+ for (const layer of layers) {
354
+ if (!layer.rules) continue;
355
+ const seenInLayer = new Set<string>();
356
+ for (const rule of layer.rules) {
357
+ if (seenInLayer.has(rule.name)) {
358
+ if (!disabledRules.has(rule.name)) {
359
+ diagnostics.push({
360
+ type: "warning",
361
+ kind: "rule-name-collision",
362
+ message:
363
+ `duplicate rule "${rule.name}" within single config layer; ` +
364
+ "keeping first, dropping subsequent",
365
+ });
366
+ }
367
+ continue;
368
+ }
369
+ seenInLayer.add(rule.name);
370
+ if (!byName.has(rule.name)) {
371
+ byName.set(rule.name, rule);
372
+ }
373
+ }
374
+ }
375
+ return [...byName.values()];
376
+ }
377
+
378
+ /**
379
+ * Merge observers across layers — inner layer's observer name
380
+ * overrides outer. Records a diagnostic on duplicate names WITHIN a
381
+ * single layer (authoring mistake); cross-layer overrides are silent
382
+ * (intentional customization).
383
+ */
384
+ function mergeObservers(
385
+ layers: readonly SteeringConfig[],
386
+ diagnostics: SteeringDiagnostic[],
387
+ ): Observer[] {
388
+ const byName = new Map<string, Observer>();
389
+ for (const layer of layers) {
390
+ if (!layer.observers) continue;
391
+ const seenInLayer = new Set<string>();
392
+ for (const obs of layer.observers) {
393
+ if (seenInLayer.has(obs.name)) {
394
+ diagnostics.push({
395
+ type: "warning",
396
+ kind: "observer-name-collision",
397
+ message: `duplicate observer "${obs.name}"; keeping first-registered entry.`,
398
+ });
399
+ continue;
400
+ }
401
+ seenInLayer.add(obs.name);
402
+ if (!byName.has(obs.name)) {
403
+ byName.set(obs.name, obs);
404
+ }
405
+ }
406
+ }
407
+ return [...byName.values()];
408
+ }
409
+
410
+ /**
411
+ * Merge simple string-list fields (`disabledRules`, `disabledPlugins`)
412
+ * as a union across layers. Preserves first-seen order for deterministic
413
+ * output in tests.
414
+ */
415
+ function mergeStringUnion(
416
+ layers: readonly SteeringConfig[],
417
+ key: "disabledRules" | "disabledPlugins",
418
+ ): string[] | undefined {
419
+ const seen = new Set<string>();
420
+ let any = false;
421
+ for (const layer of layers) {
422
+ const list = layer[key];
423
+ if (list === undefined) continue;
424
+ any = true;
425
+ for (const item of list) seen.add(item);
426
+ }
427
+ return any ? [...seen] : undefined;
428
+ }
429
+
430
+ /**
431
+ * Inner-wins boolean merge over walked-up layers. Walks left-to-right
432
+ * (inner-first); returns the first explicit boolean or `undefined`.
433
+ * Used by `buildConfig` and the session runtime for the inner-wins
434
+ * boolean fields. Internal — not in the package's `exports` surface.
435
+ */
436
+ export function mergeBool(
437
+ layers: readonly SteeringConfig[],
438
+ key: "defaultNoOverride" | "disableDefaults" | "failOnWarnings",
439
+ ): boolean | undefined {
440
+ for (const layer of layers) {
441
+ const v = layer[key];
442
+ if (typeof v === "boolean") return v;
443
+ }
444
+ return undefined;
445
+ }
446
+
447
+ /**
448
+ * Detect plugins registering a tracker under the same name and push
449
+ * an error-class diagnostic for each collision. Two plugins claiming
450
+ * the same state dimension is always a bug; the runtime escalates
451
+ * these to a thrown error regardless of the user's strict-mode
452
+ * preference.
453
+ *
454
+ * Plugins whose name appears in `disabledPlugins` are skipped before
455
+ * collision detection (mirrors {@link mergePlugins}).
456
+ */
457
+ function detectTrackerNameCollisions(
458
+ plugins: readonly Plugin[],
459
+ disabledPlugins: ReadonlySet<string>,
460
+ diagnostics: SteeringDiagnostic[],
461
+ ): void {
462
+ const seen = new Map<string, string>(); // trackerName -> pluginName
463
+ for (const plugin of plugins) {
464
+ if (disabledPlugins.has(plugin.name)) continue;
465
+ if (!plugin.trackers) continue;
466
+ for (const trackerName of Object.keys(plugin.trackers)) {
467
+ const prior = seen.get(trackerName);
468
+ if (prior !== undefined) {
469
+ diagnostics.push({
470
+ type: "error",
471
+ kind: "tracker-name-collision",
472
+ message: formatTrackerNameCollisionMessage(
473
+ prior,
474
+ plugin.name,
475
+ trackerName,
476
+ ),
477
+ });
478
+ continue;
479
+ }
480
+ seen.set(trackerName, plugin.name);
481
+ }
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Merge `layers` (inner-first) into a single effective
487
+ * {@link SteeringConfig}. An optional `defaults` config is treated as
488
+ * the OUTERMOST layer — its fields apply when no real layer specifies
489
+ * them, otherwise real layers override.
490
+ *
491
+ * Cross-layer plugin name collisions, within-layer rule + observer
492
+ * name collisions, and cross-layer tracker name collisions surface
493
+ * as structured {@link SteeringDiagnostic} entries on the returned
494
+ * object. Predicate-key + tracker-extension collisions are detected
495
+ * in `resolvePlugins`, not here — buildConfig handles cross-layer and
496
+ * within-layer name-collision shapes only.
497
+ */
498
+ export function buildConfig(
499
+ layers: readonly SteeringConfig[],
500
+ defaults?: SteeringConfig,
501
+ ): { config: SteeringConfig; diagnostics: SteeringDiagnostic[] } {
502
+ // Build the effective inner-first layer list. `defaults` goes at
503
+ // the END (outermost position) so inner real layers override it.
504
+ const effective: SteeringConfig[] = [...layers];
505
+ if (defaults !== undefined) effective.push(defaults);
506
+
507
+ const diagnostics: SteeringDiagnostic[] = [];
508
+
509
+ const disabledPluginsList = mergeStringUnion(effective, "disabledPlugins");
510
+ const disabledRulesList = mergeStringUnion(effective, "disabledRules");
511
+ const disabledPluginsSet = new Set(disabledPluginsList ?? []);
512
+ const disabledRulesSet = new Set(disabledRulesList ?? []);
513
+
514
+ // Predicate + tracker-extension collisions are detected in
515
+ // resolvePlugins, not here. buildConfig handles cross-layer and
516
+ // within-layer name-collision shapes only.
517
+ const plugins = mergePlugins(effective, disabledPluginsSet, diagnostics);
518
+ detectTrackerNameCollisions(plugins, disabledPluginsSet, diagnostics);
519
+
520
+ const rules = mergeRules(effective, disabledRulesSet, diagnostics);
521
+ const observers = mergeObservers(effective, diagnostics);
522
+
523
+ const out: SteeringConfig = {};
524
+ if (plugins.length > 0) out.plugins = plugins;
525
+ if (rules.length > 0) out.rules = rules;
526
+ if (observers.length > 0) out.observers = observers;
527
+
528
+ if (disabledRulesList !== undefined) out.disabledRules = disabledRulesList;
529
+ if (disabledPluginsList !== undefined) {
530
+ out.disabledPlugins = disabledPluginsList;
531
+ }
532
+
533
+ const defaultNoOverride = mergeBool(effective, "defaultNoOverride");
534
+ if (defaultNoOverride !== undefined) {
535
+ out.defaultNoOverride = defaultNoOverride;
536
+ }
537
+ const disableDefaults = mergeBool(effective, "disableDefaults");
538
+ if (disableDefaults !== undefined) out.disableDefaults = disableDefaults;
539
+ const failOnWarnings = mergeBool(effective, "failOnWarnings");
540
+ if (failOnWarnings !== undefined) out.failOnWarnings = failOnWarnings;
541
+
542
+ return { config: out, diagnostics };
543
+ }
544
+
545
+ /**
546
+ * Convenience: load all layers for `cwd`, run the loader-side merge
547
+ * (`buildConfig`), then the plugin merger (`resolvePlugins`) with
548
+ * user-config rule + observer name validation between the two passes.
549
+ * Diagnostics from every surface flow into a single returned array,
550
+ * so an external embedder writing their own bridge or pre-flight
551
+ * check sees the SAME diagnostic stream the production runtime sees
552
+ * — no surface is silently skipped.
553
+ *
554
+ * Diagnostics return in declaration order; merge-side errors
555
+ * short-circuit `resolvePlugins` before its diagnostics are added.
556
+ *
557
+ * Production-strictness divergence: `loadSteeringConfig` does NOT
558
+ * apply the strict-mode `failOnWarnings` throw policy that
559
+ * `buildSessionRuntime` does. The function never throws on
560
+ * diagnostics; embedders apply their own throw + warning policy.
561
+ * See `failOnWarnings` on {@link SteeringConfig} for production-
562
+ * faithful pre-flight semantics.
563
+ *
564
+ * @throws when Node < {@link MIN_NODE_MAJOR} (propagated from
565
+ * `loadConfigs`).
566
+ */
567
+ export async function loadSteeringConfig(
568
+ cwd: string,
569
+ defaults?: SteeringConfig,
570
+ ): Promise<{ config: SteeringConfig; diagnostics: SteeringDiagnostic[] }> {
571
+ const { layers, diagnostics: loaderDiagnostics } = await loadConfigs(cwd);
572
+ const { merged, diagnostics: mergeAndResolveDiagnostics } = runMergerPipeline(
573
+ layers,
574
+ defaults,
575
+ EVALUATOR_BUILTIN_TRACKERS,
576
+ );
577
+ return {
578
+ config: merged,
579
+ diagnostics: [...loaderDiagnostics, ...mergeAndResolveDiagnostics],
580
+ };
581
+ }