@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,630 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Factory-time load + cwd-mismatch integration tests.
6
+ *
7
+ * Direct unit-level tests for `buildSessionRuntime` live in
8
+ * `internal/session-runtime.test.ts`; the bridge-glue tests
9
+ * (lifecycle wiring, default-rules, agent_loop threading) live in
10
+ * `index.test.ts`. This file covers 15 integration scenarios where
11
+ * the bridge factory's eager-load path through `buildSessionRuntime`
12
+ * exercises the strict-mode throw rule end-to-end:
13
+ *
14
+ * - tracker-name-collision error throws even with
15
+ * `failOnWarnings: false`,
16
+ * - per-layer import failure escalates,
17
+ * - predicate / observer / rule plugin-merger collisions
18
+ * escalate (proves merger warnings are now plumbed),
19
+ * - `failOnWarnings: false` falls through to `console.warn` for
20
+ * warning-class only,
21
+ * - `disabledPlugins` resolves a would-be plugin-name-collision
22
+ * before the collision check runs,
23
+ * - `failOnWarnings` undefined coerces to true (default),
24
+ * - cwd-mismatch fires the `session_start` console.warn while
25
+ * leaving the evaluator + dispatcher non-null,
26
+ * - aggregated render snapshot pins the multi-line format.
27
+ *
28
+ * The bridge factory is async; tests `chdir` into a fresh scratch
29
+ * `$HOME` so the loader walk-up reads the per-test config.
30
+ */
31
+
32
+ import assert from "node:assert/strict";
33
+ import { mkdirSync, writeFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { afterEach, beforeEach, describe, it } from "node:test";
36
+ import type {
37
+ ExtensionAPI,
38
+ ToolCallEvent,
39
+ ToolCallEventResult,
40
+ } from "@earendil-works/pi-coding-agent";
41
+ import {
42
+ makeCtx,
43
+ useScratchHome,
44
+ writeSteeringSingleFileConfig,
45
+ } from "./__test-helpers__.ts";
46
+ import register from "./index.ts";
47
+
48
+ /* -------------------------------------------------------------------------- */
49
+ /* Mock ExtensionAPI */
50
+ /* -------------------------------------------------------------------------- */
51
+
52
+ type EventName = "agent_start" | "session_start" | "tool_call" | "tool_result";
53
+
54
+ interface MockPi {
55
+ api: unknown;
56
+ handlers: Partial<
57
+ Record<EventName, (event: unknown, ctx: unknown) => unknown>
58
+ >;
59
+ }
60
+
61
+ function makeMockPi(): MockPi {
62
+ const handlers: MockPi["handlers"] = {};
63
+ const api = {
64
+ on(event: EventName, handler: (e: unknown, ctx: unknown) => unknown) {
65
+ handlers[event] = handler;
66
+ },
67
+ appendEntry() {},
68
+ async exec() {
69
+ return { stdout: "", stderr: "", code: 0, killed: false };
70
+ },
71
+ };
72
+ return { api, handlers };
73
+ }
74
+
75
+ /* -------------------------------------------------------------------------- */
76
+ /* Scratch home + chdir fixture */
77
+ /* -------------------------------------------------------------------------- */
78
+
79
+ let tmpHome: string;
80
+ function useFactoryTimeScratchHome(): void {
81
+ useScratchHome("pi-steering-factory-time-", (t) => {
82
+ tmpHome = t;
83
+ });
84
+ }
85
+
86
+ /* -------------------------------------------------------------------------- */
87
+ /* console.warn capture */
88
+ /* -------------------------------------------------------------------------- */
89
+
90
+ let capturedWarns: string[];
91
+ let priorWarn: typeof console.warn;
92
+ function captureWarns(): void {
93
+ beforeEach(() => {
94
+ capturedWarns = [];
95
+ priorWarn = console.warn;
96
+ console.warn = (msg: unknown) => {
97
+ capturedWarns.push(String(msg));
98
+ };
99
+ });
100
+ afterEach(() => {
101
+ console.warn = priorWarn;
102
+ });
103
+ }
104
+
105
+ /* -------------------------------------------------------------------------- */
106
+ /* register()-throws assertion helper */
107
+ /* -------------------------------------------------------------------------- */
108
+
109
+ /**
110
+ * Asserts that `register()` rejects with an Error whose `message`
111
+ * matches every regex in `matchers`. Optional `extraChecks` runs
112
+ * additional assertions against the rejected error (e.g. count
113
+ * matches, structured fields).
114
+ *
115
+ * Folds the per-site `const mock = makeMockPi(); await
116
+ * assert.rejects(...)` boilerplate.
117
+ */
118
+ async function expectRegisterThrow(
119
+ matchers: RegExp[],
120
+ extraChecks?: (err: Error) => void,
121
+ ): Promise<void> {
122
+ const mock = makeMockPi();
123
+ await assert.rejects(
124
+ () => register(mock.api as ExtensionAPI),
125
+ (err: Error) => {
126
+ for (const m of matchers) {
127
+ assert.match(err.message, m);
128
+ }
129
+ extraChecks?.(err);
130
+ return true;
131
+ },
132
+ );
133
+ }
134
+
135
+ /* -------------------------------------------------------------------------- */
136
+ /* Factory throws on each diagnostic kind */
137
+ /* -------------------------------------------------------------------------- */
138
+
139
+ describe("register(): factory throws on diagnostics", () => {
140
+ useFactoryTimeScratchHome();
141
+ captureWarns();
142
+
143
+ it("throws on tracker-name-collision (error-class, failOnWarnings default)", async () => {
144
+ writeSteeringSingleFileConfig(
145
+ tmpHome,
146
+ `const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
147
+ export default {
148
+ disableDefaults: true,
149
+ plugins: [
150
+ { name: "pa", trackers: { branch: t } },
151
+ { name: "pb", trackers: { branch: t } },
152
+ ],
153
+ };`,
154
+ );
155
+ await expectRegisterThrow(
156
+ [/\[error\]/, /tracker name collision/, /"branch"/],
157
+ (err) => {
158
+ // Integration mirror of O2 single-emission lock; see
159
+ // internal/session-runtime.test.ts.
160
+ const collisionMatches = err.message.match(/tracker name collision/g);
161
+ assert.equal(
162
+ collisionMatches?.length,
163
+ 1,
164
+ "tracker-name-collision must appear exactly once",
165
+ );
166
+ },
167
+ );
168
+ });
169
+
170
+ it("throws on reserved-tracker-name (error-class)", async () => {
171
+ writeSteeringSingleFileConfig(
172
+ tmpHome,
173
+ `const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
174
+ export default {
175
+ disableDefaults: true,
176
+ plugins: [
177
+ { name: "pa", trackers: { events: t } },
178
+ ],
179
+ };`,
180
+ );
181
+ await expectRegisterThrow([
182
+ /\[error\]/,
183
+ /tracker name "events" is reserved/,
184
+ ]);
185
+ });
186
+
187
+ it("throws on reserved-predicate-key (error-class)", async () => {
188
+ writeSteeringSingleFileConfig(
189
+ tmpHome,
190
+ `export default {
191
+ disableDefaults: true,
192
+ plugins: [
193
+ {
194
+ name: "pa",
195
+ predicates: {
196
+ onUnknown: () => () => true,
197
+ },
198
+ },
199
+ ],
200
+ };`,
201
+ );
202
+ await expectRegisterThrow([
203
+ /\[error\]/,
204
+ /reserved predicate key "onUnknown"/,
205
+ ]);
206
+ });
207
+
208
+ it("throws on tracker-name-collision EVEN WITH failOnWarnings: false", async () => {
209
+ // Errors override the opt-out; the engine cannot operate
210
+ // safely with two plugins claiming the same state dimension.
211
+ writeSteeringSingleFileConfig(
212
+ tmpHome,
213
+ `const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
214
+ export default {
215
+ disableDefaults: true,
216
+ failOnWarnings: false,
217
+ plugins: [
218
+ { name: "pa", trackers: { branch: t } },
219
+ { name: "pb", trackers: { branch: t } },
220
+ ],
221
+ };`,
222
+ );
223
+ await expectRegisterThrow([/\[error\]/, /tracker name collision/]);
224
+ });
225
+
226
+ it("throws on plugin-name-collision (warning-class, failOnWarnings default)", async () => {
227
+ // Two layers ship the same plugin name. The collision is
228
+ // warning-class but escalates under the default
229
+ // `failOnWarnings: true`.
230
+ mkdirSync(join(tmpHome, "inner"), { recursive: true });
231
+ writeSteeringSingleFileConfig(
232
+ tmpHome,
233
+ `export default {
234
+ disableDefaults: true,
235
+ plugins: [{ name: "shared" }],
236
+ };`,
237
+ );
238
+ writeSteeringSingleFileConfig(
239
+ join(tmpHome, "inner"),
240
+ `export default {
241
+ plugins: [{ name: "shared" }],
242
+ };`,
243
+ );
244
+ process.chdir(join(tmpHome, "inner"));
245
+ await expectRegisterThrow([/\[warning\]/, /plugin "shared"/]);
246
+ });
247
+
248
+ it("throws on per-layer import failure", async () => {
249
+ mkdirSync(join(tmpHome, ".pi"), { recursive: true });
250
+ writeFileSync(
251
+ join(tmpHome, ".pi", "steering.ts"),
252
+ "export default this is not valid typescript;\n",
253
+ "utf8",
254
+ );
255
+ // Narrow on the actual parse-failure detail so a future loader
256
+ // change that swallows the underlying error message (and leaves
257
+ // only "failed to import") trips this assertion. Node's TS
258
+ // stripper emits `Expected '...', got '...'` for syntax errors;
259
+ // older jiti-style strippers emit `SyntaxError` or `Unexpected`.
260
+ await expectRegisterThrow([
261
+ /\[warning\]/,
262
+ /failed to import/i,
263
+ /Expected|SyntaxError|Unexpected/i,
264
+ ]);
265
+ });
266
+
267
+ it("throws on predicate-collision (proves resolvePlugins warnings are plumbed)", async () => {
268
+ writeSteeringSingleFileConfig(
269
+ tmpHome,
270
+ `export default {
271
+ disableDefaults: true,
272
+ plugins: [
273
+ {
274
+ name: "pa",
275
+ predicates: { sharedKey: () => () => true },
276
+ },
277
+ {
278
+ name: "pb",
279
+ predicates: { sharedKey: () => () => true },
280
+ },
281
+ ],
282
+ };`,
283
+ );
284
+ await expectRegisterThrow([
285
+ /\[warning\]/,
286
+ /duplicate predicate "when\.sharedKey"/,
287
+ ]);
288
+ });
289
+
290
+ it("throws on observer-collision", async () => {
291
+ writeSteeringSingleFileConfig(
292
+ tmpHome,
293
+ `export default {
294
+ disableDefaults: true,
295
+ plugins: [
296
+ {
297
+ name: "pa",
298
+ observers: [{ name: "obs-x", onResult: () => {} }],
299
+ },
300
+ {
301
+ name: "pb",
302
+ observers: [{ name: "obs-x", onResult: () => {} }],
303
+ },
304
+ ],
305
+ };`,
306
+ );
307
+ await expectRegisterThrow([/\[warning\]/, /duplicate observer "obs-x"/]);
308
+ });
309
+
310
+ it("throws on rule-collision", async () => {
311
+ writeSteeringSingleFileConfig(
312
+ tmpHome,
313
+ `export default {
314
+ disableDefaults: true,
315
+ plugins: [
316
+ {
317
+ name: "pa",
318
+ rules: [{
319
+ name: "dup",
320
+ tool: "bash",
321
+ field: "command",
322
+ pattern: /^never-a$/,
323
+ reason: "from pa",
324
+ }],
325
+ },
326
+ {
327
+ name: "pb",
328
+ rules: [{
329
+ name: "dup",
330
+ tool: "bash",
331
+ field: "command",
332
+ pattern: /^never-b$/,
333
+ reason: "from pb",
334
+ }],
335
+ },
336
+ ],
337
+ };`,
338
+ );
339
+ await expectRegisterThrow([/\[warning\]/, /duplicate rule "dup"/]);
340
+ });
341
+ });
342
+
343
+ /* -------------------------------------------------------------------------- */
344
+ /* Factory does NOT throw */
345
+ /* -------------------------------------------------------------------------- */
346
+
347
+ describe("register(): factory does NOT throw", () => {
348
+ useFactoryTimeScratchHome();
349
+ captureWarns();
350
+
351
+ it("warning-class with failOnWarnings: false falls through to console.warn", async () => {
352
+ writeSteeringSingleFileConfig(
353
+ tmpHome,
354
+ `export default {
355
+ disableDefaults: true,
356
+ failOnWarnings: false,
357
+ plugins: [
358
+ {
359
+ name: "pa",
360
+ observers: [{ name: "obs-x", onResult: () => {} }],
361
+ },
362
+ {
363
+ name: "pb",
364
+ observers: [{ name: "obs-x", onResult: () => {} }],
365
+ },
366
+ ],
367
+ };`,
368
+ );
369
+ const mock = makeMockPi();
370
+ // Should resolve without throwing.
371
+ await register(mock.api as ExtensionAPI);
372
+ // At least one captured warn matches the unified single-line
373
+ // shape: `[pi-steering] [warning] <message>`. The aggregated-throw
374
+ // format (with `<count> config issue` header) only renders for
375
+ // thrown errors; this opt-out path goes through console.warn.
376
+ const observerWarn = capturedWarns.find((m) =>
377
+ /\[pi-steering\].*observer.*obs-x/i.test(m),
378
+ );
379
+ assert.ok(
380
+ observerWarn !== undefined,
381
+ `expected observer-collision warn on console.warn; got: ${JSON.stringify(capturedWarns)}`,
382
+ );
383
+ assert.match(observerWarn, /\[warning\]/);
384
+ assert.doesNotMatch(observerWarn, /config issue/);
385
+ });
386
+
387
+ it("disabledPlugins resolves plugin-name-collision before the check runs", async () => {
388
+ // The outer layer's `disabledPlugins: ["shared"]` removes
389
+ // the would-be collision before cross-layer detection,
390
+ // matching the disable-then-detect ordering. No throw, no
391
+ // warn.
392
+ mkdirSync(join(tmpHome, "inner"), { recursive: true });
393
+ writeSteeringSingleFileConfig(
394
+ tmpHome,
395
+ `export default {
396
+ disableDefaults: true,
397
+ disabledPlugins: ["shared"],
398
+ plugins: [{ name: "shared" }],
399
+ };`,
400
+ );
401
+ writeSteeringSingleFileConfig(
402
+ join(tmpHome, "inner"),
403
+ `export default {
404
+ plugins: [{ name: "shared" }],
405
+ };`,
406
+ );
407
+ process.chdir(join(tmpHome, "inner"));
408
+ const mock = makeMockPi();
409
+ await register(mock.api as ExtensionAPI);
410
+ const collisionWarn = capturedWarns.find((m) => /plugin "shared"/i.test(m));
411
+ assert.equal(
412
+ collisionWarn,
413
+ undefined,
414
+ `expected NO plugin-collision warn; got: ${JSON.stringify(capturedWarns)}`,
415
+ );
416
+ });
417
+
418
+ it("failOnWarnings undefined coerces to true (default-on)", async () => {
419
+ // No `failOnWarnings` field at all; the runtime treats the
420
+ // absence as `true` and escalates the warning.
421
+ writeSteeringSingleFileConfig(
422
+ tmpHome,
423
+ `export default {
424
+ disableDefaults: true,
425
+ plugins: [
426
+ {
427
+ name: "pa",
428
+ observers: [{ name: "obs-x", onResult: () => {} }],
429
+ },
430
+ {
431
+ name: "pb",
432
+ observers: [{ name: "obs-x", onResult: () => {} }],
433
+ },
434
+ ],
435
+ };`,
436
+ );
437
+ await expectRegisterThrow([/\[warning\]/]);
438
+ });
439
+ });
440
+
441
+ /* -------------------------------------------------------------------------- */
442
+ /* Cwd-mismatch session_start console.warn */
443
+ /* -------------------------------------------------------------------------- */
444
+
445
+ describe("register(): cwd-mismatch session_start warn", () => {
446
+ useFactoryTimeScratchHome();
447
+ captureWarns();
448
+
449
+ it("emits console.warn when ctx.cwd !== launchCwd; engine continues evaluating", async () => {
450
+ // A user-defined rule lives in the launch-cwd config (tmpHome) but
451
+ // not in foreignCwd's walk-up; if it still fires after the
452
+ // cwd-mismatch warn, launch-cwd config remained in force.
453
+ writeSteeringSingleFileConfig(
454
+ tmpHome,
455
+ `export default {
456
+ rules: [
457
+ {
458
+ name: "block-launch-cwd-only-rule",
459
+ tool: "bash",
460
+ field: "command",
461
+ pattern: /^echo LAUNCH_CWD_PROBE$/,
462
+ reason: "launch-cwd-only rule used by the cwd-mismatch test",
463
+ },
464
+ ],
465
+ };`,
466
+ );
467
+ const mock = makeMockPi();
468
+ await register(mock.api as ExtensionAPI);
469
+
470
+ // Handlers must be wired (evaluator + dispatcher non-null).
471
+ assert.ok(mock.handlers.session_start);
472
+ assert.ok(mock.handlers.tool_call);
473
+ assert.ok(mock.handlers.tool_result);
474
+
475
+ // Fire session_start with a foreign cwd.
476
+ const foreignCwd = "/tmp/some/other/project";
477
+ await mock.handlers.session_start(
478
+ { type: "session_start", reason: "startup" },
479
+ makeCtx(foreignCwd),
480
+ );
481
+
482
+ const cwdMismatchWarn = capturedWarns.find((m) =>
483
+ /\[pi-steering\] session cwd \(/.test(m),
484
+ );
485
+ assert.ok(
486
+ cwdMismatchWarn !== undefined,
487
+ `expected cwd-mismatch warn; got: ${JSON.stringify(capturedWarns)}`,
488
+ );
489
+ assert.match(cwdMismatchWarn, /differs from launch cwd/);
490
+ assert.ok(
491
+ cwdMismatchWarn.includes(foreignCwd),
492
+ `expected warn to contain foreignCwd; got: ${cwdMismatchWarn}`,
493
+ );
494
+ assert.ok(
495
+ cwdMismatchWarn.includes(tmpHome),
496
+ `expected warn to contain tmpHome; got: ${cwdMismatchWarn}`,
497
+ );
498
+
499
+ // Engine continues evaluating: the launch-cwd-only rule still
500
+ // fires under the foreign-cwd ctx. Match on the rule name
501
+ // (carried in `reason` as `[steering:<rule-name>@<source>]`) so a
502
+ // future default-rule shadow matching the same pattern wouldn't
503
+ // silently satisfy `block === true`.
504
+ const blockedEvent: ToolCallEvent = {
505
+ type: "tool_call",
506
+ toolName: "bash",
507
+ toolCallId: "call-1",
508
+ input: { command: "echo LAUNCH_CWD_PROBE" },
509
+ };
510
+ const blocked = (await mock.handlers.tool_call(
511
+ blockedEvent,
512
+ makeCtx(foreignCwd),
513
+ )) as ToolCallEventResult | undefined;
514
+ assert.equal(blocked?.block, true);
515
+ assert.match(
516
+ blocked?.reason ?? "",
517
+ /block-launch-cwd-only-rule/,
518
+ "expected the launch-cwd-only rule to fire — not a default-rule shadow",
519
+ );
520
+ });
521
+
522
+ it("does NOT emit cwd-mismatch warn when ctx.cwd === launchCwd", async () => {
523
+ writeSteeringSingleFileConfig(tmpHome, "export default {};");
524
+ const mock = makeMockPi();
525
+ await register(mock.api as ExtensionAPI);
526
+
527
+ await mock.handlers.session_start!(
528
+ { type: "session_start", reason: "startup" },
529
+ makeCtx(tmpHome),
530
+ );
531
+
532
+ const cwdMismatchWarn = capturedWarns.find((m) =>
533
+ /\[pi-steering\] session cwd \(/.test(m),
534
+ );
535
+ assert.equal(
536
+ cwdMismatchWarn,
537
+ undefined,
538
+ `expected NO cwd-mismatch warn for matching cwds; got: ${JSON.stringify(capturedWarns)}`,
539
+ );
540
+ });
541
+ });
542
+
543
+ /* -------------------------------------------------------------------------- */
544
+ /* Aggregated render snapshot — 1 error + 2 warnings */
545
+ /* -------------------------------------------------------------------------- */
546
+
547
+ describe("register(): aggregated render snapshot", () => {
548
+ useFactoryTimeScratchHome();
549
+ captureWarns();
550
+
551
+ it("renders 1 error + 2 warnings with errors-first ordering and the multi-line shape", async () => {
552
+ // Layout matches the 05-aggregated-render-mixed e2e
553
+ // fixture: plugin-a registers reserved tracker `events`
554
+ // (error), and both plugins ship `obs-x` (warning) + `dup`
555
+ // (warning).
556
+ writeSteeringSingleFileConfig(
557
+ tmpHome,
558
+ `const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
559
+ export default {
560
+ disableDefaults: true,
561
+ plugins: [
562
+ {
563
+ name: "plugin-a",
564
+ trackers: { events: t },
565
+ observers: [{ name: "obs-x", onResult: () => {} }],
566
+ rules: [{
567
+ name: "dup",
568
+ tool: "bash",
569
+ field: "command",
570
+ pattern: /^never-a$/,
571
+ reason: "from plugin-a",
572
+ }],
573
+ },
574
+ {
575
+ name: "plugin-b",
576
+ observers: [{ name: "obs-x", onResult: () => {} }],
577
+ rules: [{
578
+ name: "dup",
579
+ tool: "bash",
580
+ field: "command",
581
+ pattern: /^never-b$/,
582
+ reason: "from plugin-b",
583
+ }],
584
+ },
585
+ ],
586
+ };`,
587
+ );
588
+ const mock = makeMockPi();
589
+ const err = await register(mock.api as ExtensionAPI).then(
590
+ () => null,
591
+ (e: Error) => e,
592
+ );
593
+ assert.ok(err, "expected register to throw");
594
+
595
+ // Header: plural form ("issues"), no leading prefix (pi adds
596
+ // "Failed to load extension: " automatically downstream).
597
+ assert.match(err.message, /^3 config issues:\n/);
598
+
599
+ // Bullet shape: two-space indent, dash, space, severity in
600
+ // brackets, no padding for column alignment.
601
+ const lines = err.message.split("\n");
602
+ assert.equal(
603
+ lines.length,
604
+ 4,
605
+ `expected header + 3 bullets; got: ${err.message}`,
606
+ );
607
+ // Errors-first ordering: line 1 is the [error] bullet. Tighten
608
+ // to enforce that no path prefix slips between the severity
609
+ // tag and the message text — a future change adding a path
610
+ // prefix to `reserved-tracker-name` would surface here.
611
+ assert.match(
612
+ lines[1]!,
613
+ /^ {2}- \[error\] tracker name "events" is reserved/,
614
+ );
615
+ assert.match(lines[2]!, /^ {2}- \[warning\] /);
616
+ assert.match(lines[3]!, /^ {2}- \[warning\] /);
617
+
618
+ // Warnings preserve declaration order from `resolvePlugins`
619
+ // (observer pass before rule pass).
620
+ assert.match(lines[2]!, /duplicate observer "obs-x"/);
621
+ assert.match(lines[3]!, /duplicate rule "dup"/);
622
+
623
+ // No padding (severity tag flush against the next token).
624
+ assert.doesNotMatch(err.message, /\[error\] {2,}/);
625
+ assert.doesNotMatch(err.message, /\[warning\] {2,}/);
626
+
627
+ // No footer (the message ends at the last bullet).
628
+ assert.equal(lines[lines.length - 1]!.startsWith(" - "), true);
629
+ });
630
+ });
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for `walkerUnknownCwdReason`.
6
+ *
7
+ * Pin the helper's contract via semantic substring assertions, NOT
8
+ * exact-string equality or length checks. Wording-tweak refactors
9
+ * (clarifying the "dynamic cwd" phrasing, swapping em-dash for colon)
10
+ * shouldn't fail these tests; the contract is "the message tells the
11
+ * agent what failed, where the shell is, and how to retry".
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { describe, it } from "node:test";
16
+ import { mockContext } from "../testing/index.ts";
17
+ import { walkerUnknownCwdReason } from "./walker-unknown-cwd-reason.ts";
18
+
19
+ describe("walkerUnknownCwdReason", () => {
20
+ it("includes the `verifying` arg verbatim", () => {
21
+ const ctx = mockContext({ cwd: "/tmp/test-fixture" });
22
+ const out = walkerUnknownCwdReason(ctx, "working tree status");
23
+ assert.ok(
24
+ out.includes("working tree status"),
25
+ `expected message to include the verifying arg, got: ${out}`,
26
+ );
27
+ });
28
+
29
+ it("includes the value of ctx.cwd", () => {
30
+ const ctx = mockContext({ cwd: "/tmp/test-fixture" });
31
+ const out = walkerUnknownCwdReason(ctx, "branch");
32
+ assert.ok(
33
+ out.includes("/tmp/test-fixture"),
34
+ `expected message to include ctx.cwd value, got: ${out}`,
35
+ );
36
+ });
37
+
38
+ it("contains the literal substring 'current directory:'", () => {
39
+ // Pin the cue label so agents (and downstream consumers
40
+ // composing extra retry guidance) can rely on a stable anchor
41
+ // when scanning the reason text.
42
+ const ctx = mockContext({ cwd: "/anywhere" });
43
+ const out = walkerUnknownCwdReason(ctx, "upstream");
44
+ assert.ok(
45
+ out.includes("current directory:"),
46
+ `expected 'current directory:' anchor, got: ${out}`,
47
+ );
48
+ });
49
+
50
+ it("ends with 'Retry with a literal path.'", () => {
51
+ // Sentence-terminating: the helper is composable via string
52
+ // concatenation (call sites append domain-specific guidance),
53
+ // so the base message must end cleanly.
54
+ const ctx = mockContext({ cwd: "/x" });
55
+ const out = walkerUnknownCwdReason(ctx, "remote");
56
+ assert.ok(
57
+ out.endsWith("Retry with a literal path."),
58
+ `expected trailing 'Retry with a literal path.', got: ${out}`,
59
+ );
60
+ });
61
+
62
+ it("is a single line (no embedded newlines)", () => {
63
+ // Pi surfaces ReasonFn output to the agent as a single
64
+ // blocked-rule reason; embedded newlines would look ragged in
65
+ // the agent's transcript. Pin single-line.
66
+ const ctx = mockContext({ cwd: "/repo" });
67
+ const out = walkerUnknownCwdReason(ctx, "commit count");
68
+ assert.ok(
69
+ !out.includes("\n"),
70
+ `expected single-line reason, got: ${JSON.stringify(out)}`,
71
+ );
72
+ });
73
+ });