@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,836 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * End-to-end exercise of the pi extension wiring in `register()`.
6
+ *
7
+ * Uses an in-memory mock of `ExtensionAPI` that captures `on(...)`
8
+ * handlers and records `appendEntry(...)` + `exec(...)` calls. We then
9
+ * drive the extension by firing lifecycle events in order
10
+ * (`agent_start`, `session_start`, `tool_call`, `tool_result`) and
11
+ * assert on:
12
+ *
13
+ * - the `tool_call` handler's return value (block / allow),
14
+ * - the audit-log side effect for accepted overrides,
15
+ * - the observer-dispatcher side effect on matching tool_result
16
+ * events,
17
+ * - the walk-up TS-config loader: {@link buildSessionRuntime} reads
18
+ * `.pi/steering.ts` from an isolated `$HOME`.
19
+ *
20
+ * The bridge factory's lifecycle wiring + config-loading glue isn't
21
+ * covered by the unit suites; this file is the only end-to-end check.
22
+ */
23
+
24
+ import assert from "node:assert/strict";
25
+ import { join } from "node:path";
26
+ import { describe, it } from "node:test";
27
+ import type {
28
+ ExtensionAPI,
29
+ ToolCallEvent,
30
+ ToolCallEventResult,
31
+ ToolResultEvent,
32
+ } from "@earendil-works/pi-coding-agent";
33
+ import {
34
+ makeCtx,
35
+ useScratchHome,
36
+ writeSteeringSingleFileConfig,
37
+ } from "./__test-helpers__.ts";
38
+ import register from "./index.ts";
39
+
40
+ /* -------------------------------------------------------------------------- */
41
+ /* Mock ExtensionAPI */
42
+ /* -------------------------------------------------------------------------- */
43
+
44
+ type EventName = "agent_start" | "session_start" | "tool_call" | "tool_result";
45
+
46
+ interface Entry {
47
+ kind: string;
48
+ data: unknown;
49
+ }
50
+
51
+ /**
52
+ * In-memory mock of pi's ExtensionAPI. Only implements the surface the
53
+ * steering extension actually consumes:
54
+ * - `on()` captures handlers keyed by event name.
55
+ * - `exec()` + `appendEntry()` are recorded for assertion.
56
+ *
57
+ * Everything else throws if touched so accidental reliance on
58
+ * unsupported API surfaces breaks loudly.
59
+ */
60
+ interface MockPi {
61
+ api: unknown; // cast at call site to ExtensionAPI
62
+ handlers: Partial<
63
+ Record<EventName, (event: unknown, ctx: unknown) => unknown>
64
+ >;
65
+ entries: Entry[];
66
+ execCalls: Array<{ cmd: string; args: string[] }>;
67
+ }
68
+
69
+ function makeMockPi(): MockPi {
70
+ const handlers: MockPi["handlers"] = {};
71
+ const entries: Entry[] = [];
72
+ const execCalls: MockPi["execCalls"] = [];
73
+ const api = {
74
+ on(event: EventName, handler: (e: unknown, ctx: unknown) => unknown) {
75
+ handlers[event] = handler;
76
+ },
77
+ appendEntry(kind: string, data: unknown) {
78
+ entries.push({ kind, data });
79
+ },
80
+ async exec(cmd: string, args: string[]) {
81
+ execCalls.push({ cmd, args });
82
+ return { stdout: "", stderr: "", code: 0, killed: false };
83
+ },
84
+ };
85
+ return { api, handlers, entries, execCalls };
86
+ }
87
+
88
+ function fireAgentStart(mock: MockPi): void {
89
+ const h = mock.handlers.agent_start;
90
+ if (!h) throw new Error("agent_start handler not registered");
91
+ h({ type: "agent_start" }, {});
92
+ }
93
+
94
+ async function fireSessionStart(mock: MockPi, cwd: string): Promise<void> {
95
+ const h = mock.handlers.session_start;
96
+ if (!h) throw new Error("session_start handler not registered");
97
+ await h({ type: "session_start", reason: "startup" }, makeCtx(cwd));
98
+ }
99
+
100
+ async function fireBashToolCall(
101
+ mock: MockPi,
102
+ command: string,
103
+ cwd: string,
104
+ ): Promise<ToolCallEventResult | undefined> {
105
+ const h = mock.handlers.tool_call;
106
+ if (!h) throw new Error("tool_call handler not registered");
107
+ const event: ToolCallEvent = {
108
+ type: "tool_call",
109
+ toolName: "bash",
110
+ toolCallId: "call-1",
111
+ input: { command },
112
+ };
113
+ const r = await h(event, makeCtx(cwd));
114
+ return r as ToolCallEventResult | undefined;
115
+ }
116
+
117
+ async function fireWriteToolCall(
118
+ mock: MockPi,
119
+ path: string,
120
+ content: string,
121
+ cwd: string,
122
+ ): Promise<ToolCallEventResult | undefined> {
123
+ const h = mock.handlers.tool_call;
124
+ if (!h) throw new Error("tool_call handler not registered");
125
+ const event: ToolCallEvent = {
126
+ type: "tool_call",
127
+ toolName: "write",
128
+ toolCallId: "call-2",
129
+ input: { path, content },
130
+ };
131
+ const r = await h(event, makeCtx(cwd));
132
+ return r as ToolCallEventResult | undefined;
133
+ }
134
+
135
+ async function fireEditToolCall(
136
+ mock: MockPi,
137
+ path: string,
138
+ edits: ReadonlyArray<{ oldText: string; newText: string }>,
139
+ cwd: string,
140
+ ): Promise<ToolCallEventResult | undefined> {
141
+ const h = mock.handlers.tool_call;
142
+ if (!h) throw new Error("tool_call handler not registered");
143
+ const event: ToolCallEvent = {
144
+ type: "tool_call",
145
+ toolName: "edit",
146
+ toolCallId: "call-3",
147
+ input: { path, edits: [...edits] },
148
+ };
149
+ const r = await h(event, makeCtx(cwd));
150
+ return r as ToolCallEventResult | undefined;
151
+ }
152
+
153
+ async function fireBashToolResult(
154
+ mock: MockPi,
155
+ input: Record<string, unknown>,
156
+ exitCode: number,
157
+ cwd: string,
158
+ ): Promise<void> {
159
+ const h = mock.handlers.tool_result;
160
+ if (!h) throw new Error("tool_result handler not registered");
161
+ const event = {
162
+ type: "tool_result",
163
+ toolCallId: "call-1",
164
+ toolName: "bash",
165
+ input,
166
+ content: [],
167
+ isError: exitCode !== 0,
168
+ details: { exitCode },
169
+ } as unknown as ToolResultEvent;
170
+ await h(event, makeCtx(cwd));
171
+ }
172
+
173
+ /* -------------------------------------------------------------------------- */
174
+ /* Test harness: isolated $HOME per test */
175
+ /* -------------------------------------------------------------------------- */
176
+
177
+ let tmpHome: string;
178
+
179
+ /**
180
+ * Bind a fresh `$HOME` per test AND chdir into it via the shared
181
+ * {@link useScratchHome} helper. The bridge factory eagerly loads
182
+ * from `process.cwd()` at register time, so tests must launch from
183
+ * the scratch home for the loader walk-up to find the per-test
184
+ * config.
185
+ */
186
+ function useRegisterScratchHome(): void {
187
+ useScratchHome("pi-steering-register-", (t) => {
188
+ tmpHome = t;
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Write a TS config file at `.pi/steering.ts` under `dir`. Body is the
194
+ * default-exported object literal body (without the `export default`
195
+ * wrapper) so call sites read as declarative configs.
196
+ */
197
+ function writeSteeringConfig(dir: string, body: string): void {
198
+ writeSteeringSingleFileConfig(
199
+ dir,
200
+ `// Generated by index.test.ts\nexport default ${body};\n`,
201
+ );
202
+ }
203
+
204
+ /* -------------------------------------------------------------------------- */
205
+ /* factory-time-load + tool_call with default rules */
206
+ /* -------------------------------------------------------------------------- */
207
+
208
+ describe("register(): default rules wiring", () => {
209
+ useRegisterScratchHome();
210
+
211
+ it("blocks `git push --force` via default rule", async () => {
212
+ const mock = makeMockPi();
213
+ await register(mock.api as ExtensionAPI);
214
+ await fireSessionStart(mock, tmpHome);
215
+
216
+ const result = await fireBashToolCall(mock, "git push --force", tmpHome);
217
+ assert.ok(result, "expected a ToolCallEventResult");
218
+ assert.equal(result?.block, true);
219
+ assert.match(result?.reason ?? "", /no-force-push/);
220
+ });
221
+
222
+ it("allows `git push --force-with-lease`", async () => {
223
+ const mock = makeMockPi();
224
+ await register(mock.api as ExtensionAPI);
225
+ await fireSessionStart(mock, tmpHome);
226
+
227
+ const result = await fireBashToolCall(
228
+ mock,
229
+ "git push --force-with-lease",
230
+ tmpHome,
231
+ );
232
+ assert.equal(result, undefined);
233
+ });
234
+
235
+ it("blocks `git push --force` behind `sh -c` wrapper", async () => {
236
+ const mock = makeMockPi();
237
+ await register(mock.api as ExtensionAPI);
238
+ await fireSessionStart(mock, tmpHome);
239
+
240
+ const result = await fireBashToolCall(
241
+ mock,
242
+ "sh -c 'git push --force'",
243
+ tmpHome,
244
+ );
245
+ assert.equal(result?.block, true);
246
+ assert.match(result?.reason ?? "", /no-force-push/);
247
+ });
248
+
249
+ it("blocks `git -C /other/dir push --force` (pre-subcommand flag bypass)", async () => {
250
+ const mock = makeMockPi();
251
+ await register(mock.api as ExtensionAPI);
252
+ await fireSessionStart(mock, tmpHome);
253
+
254
+ const result = await fireBashToolCall(
255
+ mock,
256
+ "git -C /other/dir push --force",
257
+ tmpHome,
258
+ );
259
+ assert.equal(result?.block, true);
260
+ assert.match(result?.reason ?? "", /no-force-push/);
261
+ });
262
+
263
+ it("does NOT block `echo 'git push --force'` (echo args are not AST-extracted)", async () => {
264
+ const mock = makeMockPi();
265
+ await register(mock.api as ExtensionAPI);
266
+ await fireSessionStart(mock, tmpHome);
267
+
268
+ const result = await fireBashToolCall(
269
+ mock,
270
+ "echo 'git push --force'",
271
+ tmpHome,
272
+ );
273
+ assert.equal(result, undefined);
274
+ });
275
+
276
+ it("blocks `rm -rf /` and ignores override (noOverride: true)", async () => {
277
+ const mock = makeMockPi();
278
+ await register(mock.api as ExtensionAPI);
279
+ await fireSessionStart(mock, tmpHome);
280
+
281
+ const result = await fireBashToolCall(
282
+ mock,
283
+ "rm -rf / # steering-override: no-rm-rf-slash — test override",
284
+ tmpHome,
285
+ );
286
+ assert.equal(result?.block, true);
287
+ assert.match(result?.reason ?? "", /no-rm-rf-slash/);
288
+ // noOverride rules must NOT append an audit entry.
289
+ assert.equal(mock.entries.length, 0);
290
+ });
291
+ });
292
+
293
+ /* -------------------------------------------------------------------------- */
294
+ /* Override escape hatch + audit log */
295
+ /* -------------------------------------------------------------------------- */
296
+
297
+ describe("register(): inline override escape hatch", () => {
298
+ useRegisterScratchHome();
299
+
300
+ it("accepts override comment, does not block, appends audit entry", async () => {
301
+ // The v2 default is `defaultNoOverride: true` (fail-closed per
302
+ // ADR). Users who want overridable rules must opt in explicitly —
303
+ // the config here flips the default back to false so the shipped
304
+ // no-force-push rule becomes overridable.
305
+ writeSteeringConfig(tmpHome, "{ defaultNoOverride: false }");
306
+
307
+ const mock = makeMockPi();
308
+ await register(mock.api as ExtensionAPI);
309
+ await fireSessionStart(mock, tmpHome);
310
+
311
+ const result = await fireBashToolCall(
312
+ mock,
313
+ "git push --force # steering-override: no-force-push — coordinated revert",
314
+ tmpHome,
315
+ );
316
+ assert.equal(result, undefined, "accepted override should not block");
317
+
318
+ assert.equal(mock.entries.length, 1);
319
+ const entry = mock.entries[0];
320
+ assert.equal(entry?.kind, "steering-override");
321
+ const data = entry?.data as {
322
+ rule: string;
323
+ reason: string;
324
+ command: string;
325
+ timestamp: string;
326
+ };
327
+ assert.equal(data.rule, "no-force-push");
328
+ assert.equal(data.reason, "coordinated revert");
329
+ assert.match(data.command, /git push --force/);
330
+ assert.match(data.timestamp, /^\d{4}-\d{2}-\d{2}T/);
331
+ });
332
+
333
+ it("override targeted at a different rule does NOT suppress the block", async () => {
334
+ writeSteeringConfig(tmpHome, "{ defaultNoOverride: false }");
335
+
336
+ const mock = makeMockPi();
337
+ await register(mock.api as ExtensionAPI);
338
+ await fireSessionStart(mock, tmpHome);
339
+
340
+ const result = await fireBashToolCall(
341
+ mock,
342
+ "git push --force # steering-override: some-other-rule — unrelated",
343
+ tmpHome,
344
+ );
345
+ assert.equal(result?.block, true);
346
+ assert.equal(mock.entries.length, 0);
347
+ });
348
+
349
+ it("v2 default (fail-closed) blocks override attempts on shipped rules", async () => {
350
+ // NO config layer → defaultNoOverride defaults to `true`. The
351
+ // override comment is ignored and the block fires.
352
+ const mock = makeMockPi();
353
+ await register(mock.api as ExtensionAPI);
354
+ await fireSessionStart(mock, tmpHome);
355
+
356
+ const result = await fireBashToolCall(
357
+ mock,
358
+ "git push --force # steering-override: no-force-push — hotfix",
359
+ tmpHome,
360
+ );
361
+ assert.equal(result?.block, true);
362
+ // The block reason should NOT include "To override" — rule is
363
+ // non-overridable under fail-closed default.
364
+ assert.doesNotMatch(result?.reason ?? "", /To override/);
365
+ assert.equal(mock.entries.length, 0);
366
+ });
367
+ });
368
+
369
+ /* -------------------------------------------------------------------------- */
370
+ /* User-defined rules via .pi/steering.ts */
371
+ /* -------------------------------------------------------------------------- */
372
+
373
+ describe("register(): user-defined rules via .pi/steering.ts", () => {
374
+ useRegisterScratchHome();
375
+
376
+ it("blocks a write to a .env file via a user-defined write rule", async () => {
377
+ writeSteeringConfig(
378
+ tmpHome,
379
+ `{
380
+ rules: [
381
+ {
382
+ name: "no-env-files",
383
+ tool: "write",
384
+ field: "path",
385
+ pattern: /(^|\\/)\\.env(\\.|$)/,
386
+ reason: "never write .env files — contains secrets",
387
+ },
388
+ ],
389
+ }`,
390
+ );
391
+
392
+ const mock = makeMockPi();
393
+ await register(mock.api as ExtensionAPI);
394
+ await fireSessionStart(mock, tmpHome);
395
+
396
+ const result = await fireWriteToolCall(
397
+ mock,
398
+ join(tmpHome, "project", ".env"),
399
+ "SECRET_KEY=abc",
400
+ tmpHome,
401
+ );
402
+ assert.equal(result?.block, true);
403
+ assert.match(result?.reason ?? "", /no-env-files/);
404
+ });
405
+
406
+ it("blocks an edit that inserts a debugger statement (content rule)", async () => {
407
+ writeSteeringConfig(
408
+ tmpHome,
409
+ `{
410
+ rules: [
411
+ {
412
+ name: "no-debugger",
413
+ tool: "edit",
414
+ field: "content",
415
+ pattern: /\\bdebugger\\b/,
416
+ reason: "don't commit debugger statements",
417
+ },
418
+ ],
419
+ }`,
420
+ );
421
+
422
+ const mock = makeMockPi();
423
+ await register(mock.api as ExtensionAPI);
424
+ await fireSessionStart(mock, tmpHome);
425
+
426
+ const result = await fireEditToolCall(
427
+ mock,
428
+ join(tmpHome, "project", "a.ts"),
429
+ [{ oldText: "const x = 1;", newText: "const x = 1;\ndebugger;" }],
430
+ tmpHome,
431
+ );
432
+ assert.equal(result?.block, true);
433
+ assert.match(result?.reason ?? "", /no-debugger/);
434
+ });
435
+
436
+ it("when.cwd gates whether the rule fires", async () => {
437
+ writeSteeringConfig(
438
+ tmpHome,
439
+ `{
440
+ rules: [
441
+ {
442
+ name: "no-echo-in-special",
443
+ tool: "bash",
444
+ field: "command",
445
+ pattern: /\\becho\\b/,
446
+ reason: "echo not allowed in special tree",
447
+ when: { cwd: /\\/special\\// },
448
+ },
449
+ ],
450
+ }`,
451
+ );
452
+
453
+ const mock = makeMockPi();
454
+ await register(mock.api as ExtensionAPI);
455
+ await fireSessionStart(mock, tmpHome);
456
+
457
+ // cwd does not match → should NOT block.
458
+ assert.equal(
459
+ await fireBashToolCall(mock, "echo hi", "/home/me/normal"),
460
+ undefined,
461
+ );
462
+
463
+ // cwd matches → should block.
464
+ const blocked = await fireBashToolCall(
465
+ mock,
466
+ "echo hi",
467
+ "/home/me/special/sub",
468
+ );
469
+ assert.equal(blocked?.block, true);
470
+ assert.match(blocked?.reason ?? "", /no-echo-in-special/);
471
+ });
472
+
473
+ it("disabledRules list removes a default rule", async () => {
474
+ writeSteeringConfig(tmpHome, '{ disabledRules: ["no-force-push"] }');
475
+
476
+ const mock = makeMockPi();
477
+ await register(mock.api as ExtensionAPI);
478
+ await fireSessionStart(mock, tmpHome);
479
+
480
+ // Rule disabled → push --force no longer blocked.
481
+ assert.equal(
482
+ await fireBashToolCall(mock, "git push --force", tmpHome),
483
+ undefined,
484
+ );
485
+ });
486
+
487
+ it("disableDefaults: true removes BOTH default rules and default plugins", async () => {
488
+ // No user rule → combined with disableDefaults: true, nothing
489
+ // blocks. Proves DEFAULT_RULES aren't leaking through when the
490
+ // user opts out of defaults entirely.
491
+ writeSteeringConfig(tmpHome, "{ disableDefaults: true }");
492
+
493
+ const mock = makeMockPi();
494
+ await register(mock.api as ExtensionAPI);
495
+ await fireSessionStart(mock, tmpHome);
496
+
497
+ assert.equal(
498
+ await fireBashToolCall(mock, "git push --force", tmpHome),
499
+ undefined,
500
+ );
501
+ assert.equal(
502
+ await fireBashToolCall(mock, "rm -rf /", tmpHome),
503
+ undefined,
504
+ "disableDefaults skips even the noOverride no-rm-rf-slash",
505
+ );
506
+ });
507
+ });
508
+
509
+ /* -------------------------------------------------------------------------- */
510
+ /* Observer wiring via tool_result */
511
+ /* -------------------------------------------------------------------------- */
512
+
513
+ describe("register(): observer dispatcher wiring", () => {
514
+ useRegisterScratchHome();
515
+
516
+ it("runs observers on matching tool_result events", async () => {
517
+ // Use a module-scoped sentinel so the dynamically-imported config
518
+ // can signal the test. The loader uses `await import(url)` with a
519
+ // file:// URL; we bake the assertion hook directly into the
520
+ // observer's onResult via a global counter.
521
+ writeSteeringConfig(
522
+ tmpHome,
523
+ `{
524
+ observers: [
525
+ {
526
+ name: "count-bash-success",
527
+ watch: { toolName: "bash", exitCode: "success" },
528
+ onResult: (event, ctx) => {
529
+ ctx.appendEntry("bash-success", { cmd: (event.input as {command: string}).command });
530
+ },
531
+ },
532
+ ],
533
+ }`,
534
+ );
535
+
536
+ const mock = makeMockPi();
537
+ await register(mock.api as ExtensionAPI);
538
+ await fireSessionStart(mock, tmpHome);
539
+
540
+ await fireBashToolResult(mock, { command: "echo hi" }, 0, tmpHome);
541
+
542
+ const recorded = mock.entries.find((e) => e.kind === "bash-success");
543
+ assert.ok(recorded, "observer should have written an entry");
544
+ // The engine auto-injects `_agentLoopIndex` into every observer/
545
+ // predicate write so `when.happened: { in: "agent_loop" }` can
546
+ // filter by scope. First agent_start happens implicitly at
547
+ // session setup time — agentLoopIndex here is 0 because no
548
+ // agent_start events have fired in this test.
549
+ assert.deepEqual(recorded.data, {
550
+ cmd: "echo hi",
551
+ _agentLoopIndex: 0,
552
+ });
553
+ });
554
+
555
+ it("observer watch filter gates firing (failure exit code excluded)", async () => {
556
+ writeSteeringConfig(
557
+ tmpHome,
558
+ `{
559
+ observers: [
560
+ {
561
+ name: "count-bash-success",
562
+ watch: { toolName: "bash", exitCode: "success" },
563
+ onResult: (_event, ctx) => {
564
+ ctx.appendEntry("bash-success");
565
+ },
566
+ },
567
+ ],
568
+ }`,
569
+ );
570
+
571
+ const mock = makeMockPi();
572
+ await register(mock.api as ExtensionAPI);
573
+ await fireSessionStart(mock, tmpHome);
574
+
575
+ await fireBashToolResult(
576
+ mock,
577
+ { command: "exit 1" },
578
+ 1, // failure
579
+ tmpHome,
580
+ );
581
+
582
+ assert.equal(
583
+ mock.entries.find((e) => e.kind === "bash-success"),
584
+ undefined,
585
+ );
586
+ });
587
+ });
588
+
589
+ /* -------------------------------------------------------------------------- */
590
+ /* Non-targeted tool calls pass through */
591
+ /* -------------------------------------------------------------------------- */
592
+
593
+ describe("register(): unrelated tool calls pass through", () => {
594
+ useRegisterScratchHome();
595
+
596
+ it("returns undefined for a tool call that matches no rule", async () => {
597
+ const mock = makeMockPi();
598
+ await register(mock.api as ExtensionAPI);
599
+ await fireSessionStart(mock, tmpHome);
600
+
601
+ const result = await fireBashToolCall(mock, "ls -la", tmpHome);
602
+ assert.equal(result, undefined);
603
+ });
604
+
605
+ it("returns undefined for a `read` tool call (not in any rule's tool set)", async () => {
606
+ const mock = makeMockPi();
607
+ await register(mock.api as ExtensionAPI);
608
+ await fireSessionStart(mock, tmpHome);
609
+
610
+ const h = mock.handlers.tool_call;
611
+ assert.ok(h);
612
+ const event = {
613
+ type: "tool_call",
614
+ toolName: "read",
615
+ toolCallId: "call-read",
616
+ input: { path: "/etc/passwd" },
617
+ };
618
+ const result = await h(event, makeCtx(tmpHome));
619
+ assert.equal(result, undefined);
620
+ });
621
+ });
622
+
623
+ /* -------------------------------------------------------------------------- */
624
+ /* Agent-loop index threading */
625
+ /* -------------------------------------------------------------------------- */
626
+
627
+ describe("register(): agent_start bumps agentLoopIndex threaded into evaluator", () => {
628
+ useRegisterScratchHome();
629
+
630
+ it("passes the current agentLoopIndex into predicate context", async () => {
631
+ // Rule uses when.condition to assert agentLoopIndex threading.
632
+ // The condition appends an audit entry the test consults to
633
+ // confirm the agentLoopIndex the evaluator saw.
634
+ writeSteeringConfig(
635
+ tmpHome,
636
+ `{
637
+ rules: [
638
+ {
639
+ name: "capture-turn",
640
+ tool: "bash",
641
+ field: "command",
642
+ pattern: /^echo/,
643
+ reason: "capture",
644
+ when: {
645
+ condition: (ctx) => {
646
+ ctx.appendEntry("captured", { agentLoopIndex: ctx.agentLoopIndex });
647
+ return false; // never fires; only side effect matters
648
+ },
649
+ },
650
+ },
651
+ ],
652
+ }`,
653
+ );
654
+
655
+ const mock = makeMockPi();
656
+ await register(mock.api as ExtensionAPI);
657
+ await fireSessionStart(mock, tmpHome);
658
+
659
+ // Each agent_start bumps the engine's internal counter by 1.
660
+ // Fire 5 times so the first tool_call sees agentLoopIndex === 5.
661
+ fireAgentStart(mock);
662
+ fireAgentStart(mock);
663
+ fireAgentStart(mock);
664
+ fireAgentStart(mock);
665
+ fireAgentStart(mock);
666
+ await fireBashToolCall(mock, "echo hi", tmpHome);
667
+
668
+ const captured = mock.entries.find((e) => e.kind === "captured");
669
+ assert.ok(captured);
670
+ assert.deepEqual(
671
+ (captured.data as { agentLoopIndex: number }).agentLoopIndex,
672
+ 5,
673
+ );
674
+ });
675
+
676
+ it("tool_call fired before any agent_start sees agentLoopIndex === 0", async () => {
677
+ // Pins the counter's initial value. The counter bumps from 0 to 1
678
+ // on the first agent_start; a tool_call that happens BEFORE any
679
+ // agent_start (background tool, prompt autocompletion, extension
680
+ // smoke test) must see a well-defined — not undefined / NaN /
681
+ // -1 — agentLoopIndex. A later init-bug landing on undefined
682
+ // would pass all other tests but fail this one.
683
+ writeSteeringConfig(
684
+ tmpHome,
685
+ `{
686
+ rules: [
687
+ {
688
+ name: "capture-pre-agent-start",
689
+ tool: "bash",
690
+ field: "command",
691
+ pattern: /^echo/,
692
+ reason: "capture",
693
+ when: {
694
+ condition: (ctx) => {
695
+ ctx.appendEntry("captured", { agentLoopIndex: ctx.agentLoopIndex });
696
+ return false;
697
+ },
698
+ },
699
+ },
700
+ ],
701
+ }`,
702
+ );
703
+
704
+ const mock = makeMockPi();
705
+ await register(mock.api as ExtensionAPI);
706
+ await fireSessionStart(mock, tmpHome);
707
+ // Intentionally skip fireAgentStart.
708
+ await fireBashToolCall(mock, "echo hi", tmpHome);
709
+
710
+ const captured = mock.entries.find((e) => e.kind === "captured");
711
+ assert.ok(captured);
712
+ assert.equal(
713
+ (captured.data as { agentLoopIndex: number }).agentLoopIndex,
714
+ 0,
715
+ );
716
+ });
717
+
718
+ it("tool_call + tool_result in the same loop share the same agentLoopIndex", async () => {
719
+ // The predicate captures the agentLoopIndex it sees; the
720
+ // observer's auto-tagged write records the loop index the
721
+ // dispatcher saw. Both must agree, end-to-end via register().
722
+ writeSteeringConfig(
723
+ tmpHome,
724
+ `{
725
+ rules: [
726
+ {
727
+ name: "capture-predicate",
728
+ tool: "bash",
729
+ field: "command",
730
+ pattern: /^echo/,
731
+ reason: "r",
732
+ when: {
733
+ condition: (ctx) => {
734
+ ctx.appendEntry("pred", { agentLoopIndex: ctx.agentLoopIndex });
735
+ return false;
736
+ },
737
+ },
738
+ },
739
+ ],
740
+ observers: [
741
+ {
742
+ name: "capture-observer",
743
+ watch: { toolName: "bash", exitCode: "success" },
744
+ onResult: (_event, ctx) => {
745
+ ctx.appendEntry("obs", { ok: true });
746
+ },
747
+ },
748
+ ],
749
+ }`,
750
+ );
751
+
752
+ const mock = makeMockPi();
753
+ await register(mock.api as ExtensionAPI);
754
+ await fireSessionStart(mock, tmpHome);
755
+ fireAgentStart(mock); // loop 1
756
+ fireAgentStart(mock); // loop 2
757
+ await fireBashToolCall(mock, "echo hi", tmpHome);
758
+ await fireBashToolResult(mock, { command: "echo hi" }, 0, tmpHome);
759
+
760
+ const pred = mock.entries.find((e) => e.kind === "pred");
761
+ const obs = mock.entries.find((e) => e.kind === "obs");
762
+ assert.ok(pred, "predicate should have captured a pred entry");
763
+ assert.ok(obs, "observer should have captured an obs entry");
764
+ const predIdx = (pred.data as { agentLoopIndex: number }).agentLoopIndex;
765
+ const obsIdx = (obs.data as { _agentLoopIndex: number })._agentLoopIndex;
766
+ assert.equal(predIdx, 2);
767
+ assert.equal(obsIdx, 2);
768
+ assert.equal(
769
+ predIdx,
770
+ obsIdx,
771
+ "predicate and observer must observe the same agent_loop index",
772
+ );
773
+ });
774
+ });
775
+
776
+ /* -------------------------------------------------------------------------- */
777
+ /* register() coverage — two-pass disableDefaults merge through */
778
+ /* buildSessionRuntime */
779
+ /* -------------------------------------------------------------------------- */
780
+
781
+ describe("register(): two-pass disableDefaults merge", () => {
782
+ useRegisterScratchHome();
783
+
784
+ it("inner `disableDefaults: true` wins — defaults are NOT injected", async () => {
785
+ writeSteeringConfig(tmpHome, "{ disableDefaults: true }");
786
+
787
+ const mock = makeMockPi();
788
+ await register(mock.api as ExtensionAPI);
789
+ await fireSessionStart(mock, tmpHome);
790
+
791
+ // `disableDefaults: true` in the user layer suppresses
792
+ // DEFAULT_RULES entirely, so the canonical default-rule guard
793
+ // against `git push --force` no longer fires.
794
+ const result = await fireBashToolCall(mock, "git push --force", tmpHome);
795
+ assert.equal(result, undefined);
796
+ });
797
+
798
+ it("no `disableDefaults` — DEFAULT_RULES are injected", async () => {
799
+ // No config file at all.
800
+ const mock = makeMockPi();
801
+ await register(mock.api as ExtensionAPI);
802
+ await fireSessionStart(mock, tmpHome);
803
+
804
+ const pushResult = await fireBashToolCall(
805
+ mock,
806
+ "git push --force",
807
+ tmpHome,
808
+ );
809
+ assert.equal(pushResult?.block, true);
810
+ assert.match(pushResult?.reason ?? "", /no-force-push/);
811
+
812
+ const rmResult = await fireBashToolCall(mock, "rm -rf /", tmpHome);
813
+ assert.equal(rmResult?.block, true);
814
+ assert.match(rmResult?.reason ?? "", /no-rm-rf-slash/);
815
+ });
816
+
817
+ it("`disabledRules` filters default rules out of the merged config", async () => {
818
+ writeSteeringConfig(tmpHome, '{ disabledRules: ["no-force-push"] }');
819
+ const mock = makeMockPi();
820
+ await register(mock.api as ExtensionAPI);
821
+ await fireSessionStart(mock, tmpHome);
822
+
823
+ // `no-force-push` is disabled — the rule that would have blocked
824
+ // `git push --force` is gone.
825
+ const pushResult = await fireBashToolCall(
826
+ mock,
827
+ "git push --force",
828
+ tmpHome,
829
+ );
830
+ assert.equal(pushResult, undefined);
831
+
832
+ // Other defaults still present.
833
+ const rmResult = await fireBashToolCall(mock, "rm -rf /", tmpHome);
834
+ assert.equal(rmResult?.block, true);
835
+ });
836
+ });