@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,1696 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for the Phase 5a testing primitives (`./index.ts`).
6
+ *
7
+ * Coverage axis:
8
+ * - `loadHarness` — evaluator + dispatcher built from a
9
+ * minimal config; includeDefaults on/off;
10
+ * plugin merging; config.disabledRules; custom
11
+ * host override.
12
+ * - `mockContext` — default shape; per-option overrides; exec
13
+ * stubbing + unstubbed reject; findEntries
14
+ * filter + timestamp parsing; appendEntry
15
+ * capture visible via getAppendedEntries.
16
+ * - `mockObserverContext` — default shape; exec stub pass-through;
17
+ * entries/appendEntry capture.
18
+ * - `getAppendedEntries` — empty on fresh ctx, populated after
19
+ * appendEntry, empty on non-mock ctx.
20
+ *
21
+ * These are UNIT tests against the primitives — they don't exercise
22
+ * the underlying evaluator / observer dispatcher semantics in depth
23
+ * (those are covered in the *.test.ts suites). Here we verify the
24
+ * wrappers assemble the right plumbing.
25
+ */
26
+
27
+ import assert from "node:assert/strict";
28
+ import { describe, it } from "node:test";
29
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
30
+ import type { EvaluatorHost } from "../evaluator.ts";
31
+ import type {
32
+ Observer,
33
+ ObserverContext,
34
+ Plugin,
35
+ PredicateContext,
36
+ PredicateHandler,
37
+ Rule,
38
+ } from "../schema.ts";
39
+ import {
40
+ createRecordingHost,
41
+ expectAllows,
42
+ expectBlocks,
43
+ expectRuleFires,
44
+ formatMatrix,
45
+ getAppendedEntries,
46
+ loadHarness,
47
+ mockContext,
48
+ mockExtensionContext,
49
+ mockObserverContext,
50
+ priorEntry,
51
+ runMatrix,
52
+ testObserver,
53
+ testPredicate,
54
+ } from "./index.ts";
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Shared stubs
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Minimal `ExtensionContext` stub for harness evaluate/dispatch
62
+ * invocations. Only `cwd` + `sessionManager.getEntries` are read by
63
+ * the evaluator pipeline; everything else we leave unset.
64
+ */
65
+ function makeExtCtx(cwd = "/repo"): ExtensionContext {
66
+ return {
67
+ cwd,
68
+ sessionManager: {
69
+ getEntries: () => [],
70
+ } as unknown as ExtensionContext["sessionManager"],
71
+ } as ExtensionContext;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // loadHarness
76
+ // ---------------------------------------------------------------------------
77
+
78
+ describe("loadHarness", () => {
79
+ it("builds evaluator + dispatcher from a minimal (empty) config", async () => {
80
+ const h = loadHarness({ config: {} });
81
+ // evaluator fires: allow (no rules → undefined).
82
+ const res = await h.evaluate(
83
+ {
84
+ type: "tool_call",
85
+ toolCallId: "t",
86
+ toolName: "bash",
87
+ input: { command: "echo hi" },
88
+ },
89
+ makeExtCtx(),
90
+ 0,
91
+ );
92
+ assert.equal(res, undefined);
93
+ // dispatcher fires: no observers, no throw.
94
+ await h.dispatch(
95
+ {
96
+ type: "tool_result",
97
+ toolCallId: "t",
98
+ toolName: "bash",
99
+ input: { command: "echo hi" },
100
+ content: [{ type: "text", text: "" }],
101
+ isError: false,
102
+ details: { exitCode: 0 },
103
+ } as unknown as Parameters<typeof h.dispatch>[0],
104
+ makeExtCtx(),
105
+ 0,
106
+ );
107
+ // Resolved shape is sane.
108
+ assert.deepEqual(h.resolved.rules, []);
109
+ assert.deepEqual(h.resolved.observers, []);
110
+ });
111
+
112
+ it("includeDefaults: true injects DEFAULT_RULES (no-force-push fires)", async () => {
113
+ const h = loadHarness({ config: {}, includeDefaults: true });
114
+ const res = await h.evaluate(
115
+ {
116
+ type: "tool_call",
117
+ toolCallId: "t",
118
+ toolName: "bash",
119
+ input: { command: "git push --force" },
120
+ },
121
+ makeExtCtx(),
122
+ 0,
123
+ );
124
+ assert.ok(res && res.block === true);
125
+ assert.match(res.reason ?? "", /\[steering:no-force-push@[^\]]+\]/);
126
+ });
127
+
128
+ it("includeDefaults: false (default) does NOT inject defaults", async () => {
129
+ const h = loadHarness({ config: {} });
130
+ const res = await h.evaluate(
131
+ {
132
+ type: "tool_call",
133
+ toolCallId: "t",
134
+ toolName: "bash",
135
+ input: { command: "git push --force" },
136
+ },
137
+ makeExtCtx(),
138
+ 0,
139
+ );
140
+ assert.equal(res, undefined);
141
+ });
142
+
143
+ it("merges a custom plugin's rules + predicates", async () => {
144
+ // Custom rule + plugin predicate; evaluate a matching bash call.
145
+ const rule: Rule = {
146
+ name: "no-cowsay",
147
+ tool: "bash",
148
+ field: "command",
149
+ pattern: "^cowsay\\b",
150
+ reason: "no cows",
151
+ };
152
+ const plugin: Plugin = {
153
+ name: "petting-zoo",
154
+ predicates: {
155
+ // Loose predicate: always-true; here to verify the merger
156
+ // registered it so `when` can reference it by name.
157
+ always: async () => true,
158
+ },
159
+ rules: [rule],
160
+ };
161
+ const h = loadHarness({ config: { plugins: [plugin] } });
162
+ assert.ok(h.resolved.predicates["always"] !== undefined);
163
+ assert.equal(h.resolved.rules.length, 1);
164
+ assert.equal(h.resolved.rules[0]!.name, "no-cowsay");
165
+
166
+ const res = await h.evaluate(
167
+ {
168
+ type: "tool_call",
169
+ toolCallId: "t",
170
+ toolName: "bash",
171
+ input: { command: "cowsay hello" },
172
+ },
173
+ makeExtCtx(),
174
+ 0,
175
+ );
176
+ assert.ok(res && res.block === true);
177
+ });
178
+
179
+ it("applies config.disabledRules to named rules", async () => {
180
+ const h = loadHarness({
181
+ config: { disabledRules: ["no-force-push"] },
182
+ includeDefaults: true,
183
+ });
184
+ // Rule was filtered out → force-push no longer blocks.
185
+ const res = await h.evaluate(
186
+ {
187
+ type: "tool_call",
188
+ toolCallId: "t",
189
+ toolName: "bash",
190
+ input: { command: "git push --force" },
191
+ },
192
+ makeExtCtx(),
193
+ 0,
194
+ );
195
+ assert.equal(res, undefined);
196
+ });
197
+
198
+ it("respects a custom host option", async () => {
199
+ let execCalls = 0;
200
+ const host: EvaluatorHost = {
201
+ exec: async () => {
202
+ execCalls++;
203
+ return { stdout: "main", stderr: "", code: 0, killed: false };
204
+ },
205
+ appendEntry: () => {},
206
+ };
207
+ // A rule with a condition that calls ctx.exec.
208
+ const rule: Rule = {
209
+ name: "uses-exec",
210
+ tool: "bash",
211
+ field: "command",
212
+ pattern: "^git\\b",
213
+ reason: "calls exec",
214
+ when: {
215
+ condition: async (ctx) => {
216
+ await ctx.exec("git", ["rev-parse", "HEAD"]);
217
+ return true;
218
+ },
219
+ },
220
+ };
221
+ const h = loadHarness({ config: { rules: [rule] }, host });
222
+ const res = await h.evaluate(
223
+ {
224
+ type: "tool_call",
225
+ toolCallId: "t",
226
+ toolName: "bash",
227
+ input: { command: "git status" },
228
+ },
229
+ makeExtCtx(),
230
+ 0,
231
+ );
232
+ assert.ok(res && res.block === true);
233
+ assert.equal(execCalls, 1);
234
+ });
235
+
236
+ it("default host's unstubbed exec surfaces as a logged warning + fires fail-CLOSED (S1)", async () => {
237
+ // S1: a throwing predicate (here: the default host's `exec`
238
+ // throwing 'exec not stubbed') is caught by the evaluator and
239
+ // treated as `"unknown"`. Outer-level `condition:` is
240
+ // bare-`PredicateFn`-typed (no leaf-level `onUnknown:` opt-in
241
+ // available), so the projection always uses the default `"block"`
242
+ // policy and the rule fires fail-CLOSED. The test's original
243
+ // intent — 'authors who forget to stub exec see a clear error' —
244
+ // still holds: the warning names the rule + its source and carries
245
+ // the original error message.
246
+ //
247
+ // Symmetry note: outer-level `condition:` throws now mirror the
248
+ // inner not-block treatment + the plugin-handler exception
249
+ // contract — throw → `"unknown"` → default `"block"` projection.
250
+ // Authors who want fail-OPEN treatment for a throwing condition
251
+ // need to express the policy at the containing block level (e.g.
252
+ // inside `not: { condition: fn, onUnknown: "allow" }`).
253
+ const rule: Rule = {
254
+ name: "uses-exec",
255
+ tool: "bash",
256
+ field: "command",
257
+ pattern: "^git\\b",
258
+ reason: "calls exec",
259
+ when: {
260
+ condition: async (ctx) => {
261
+ await ctx.exec("git", ["rev-parse", "HEAD"]);
262
+ return true;
263
+ },
264
+ },
265
+ };
266
+ const h = loadHarness({ config: { rules: [rule] } });
267
+ const warnings: string[] = [];
268
+ const originalWarn = console.warn;
269
+ console.warn = (...args: unknown[]) => {
270
+ warnings.push(args.map((a) => String(a)).join(" "));
271
+ };
272
+ try {
273
+ const result = await h.evaluate(
274
+ {
275
+ type: "tool_call",
276
+ toolCallId: "t",
277
+ toolName: "bash",
278
+ input: { command: "git status" },
279
+ },
280
+ makeExtCtx(),
281
+ 0,
282
+ );
283
+ // Rule fires fail-CLOSED (condition: threw → unknown → default
284
+ // leaf-level `"block"` policy fires the rule).
285
+ assert.ok(result && result.block === true);
286
+ // Warning names the rule + its `@<source>` tag + the
287
+ // unstubbed-exec message via the `when.condition threw` channel.
288
+ assert.ok(
289
+ warnings.some((w) =>
290
+ /Rule "uses-exec"@user.*when\.condition threw.*exec not stubbed/.test(
291
+ w,
292
+ ),
293
+ ),
294
+ `no matching warning in:\n${warnings.join("\n")}`,
295
+ );
296
+ } finally {
297
+ console.warn = originalWarn;
298
+ }
299
+ });
300
+
301
+ it("exposes harness.config reflecting the effective (disable-filtered) state (T3)", () => {
302
+ const harness = loadHarness({
303
+ config: {
304
+ rules: [
305
+ {
306
+ name: "keep-me",
307
+ tool: "bash",
308
+ field: "command",
309
+ pattern: /^keep/,
310
+ reason: "keep",
311
+ },
312
+ {
313
+ name: "drop-me",
314
+ tool: "bash",
315
+ field: "command",
316
+ pattern: /^drop/,
317
+ reason: "drop",
318
+ },
319
+ ],
320
+ disabledRules: ["drop-me"],
321
+ },
322
+ });
323
+ // Config mirror is post-filter: only `keep-me` survives in
324
+ // `config.rules`; the `disabledRules` list is preserved for
325
+ // introspection.
326
+ assert.ok(harness.config);
327
+ assert.equal(harness.config.rules?.length, 1);
328
+ assert.equal(harness.config.rules?.[0]?.name, "keep-me");
329
+ assert.deepEqual(harness.config.disabledRules, ["drop-me"]);
330
+ });
331
+
332
+ it("exposes harness.resolved with plugin-side rules after merger (T3)", () => {
333
+ const pluginRule = {
334
+ name: "plugin-rule",
335
+ tool: "bash" as const,
336
+ field: "command" as const,
337
+ pattern: /^keep/,
338
+ reason: "plugin",
339
+ };
340
+ const droppedPluginRule = {
341
+ name: "dropped-plugin-rule",
342
+ tool: "bash" as const,
343
+ field: "command" as const,
344
+ pattern: /^drop/,
345
+ reason: "drop",
346
+ };
347
+ const harness = loadHarness({
348
+ config: {
349
+ plugins: [{ name: "demo", rules: [pluginRule, droppedPluginRule] }],
350
+ disabledRules: ["dropped-plugin-rule"],
351
+ },
352
+ });
353
+ // `resolved` reflects plugin-merger output: plugin-shipped rules
354
+ // after disable filtering. User rules live on `harness.config`,
355
+ // not here.
356
+ const resolvedNames = harness.resolved.rules.map((r) => r.name);
357
+ assert.deepEqual(resolvedNames, ["plugin-rule"]);
358
+ assert.ok(
359
+ !resolvedNames.includes("dropped-plugin-rule"),
360
+ "disabled plugin rule must not appear in resolved state",
361
+ );
362
+ });
363
+
364
+ it("surfaces an empty diagnostics array for a clean config", () => {
365
+ const rule = {
366
+ name: "clean",
367
+ tool: "bash" as const,
368
+ field: "command" as const,
369
+ pattern: /^never$/,
370
+ reason: "clean",
371
+ };
372
+ const harness = loadHarness({ config: { rules: [rule] } });
373
+ assert.deepEqual(harness.diagnostics, []);
374
+ });
375
+
376
+ it("does NOT throw on a malformed plugin name; surfaces it as an invalid-name error-class diagnostic", () => {
377
+ // `validateName` flows through the diagnostic stream rather than
378
+ // throwing so plugin-author tests can read the failure surface
379
+ // from `harness.diagnostics` instead of catching a thrown Error.
380
+ const plugin: Plugin = {
381
+ // Spaces in a plugin name forge the `[steering:<name>@<source>]`
382
+ // block-reason tag (S3 boundary).
383
+ name: "bad name",
384
+ };
385
+ const harness = loadHarness({ config: { plugins: [plugin] } });
386
+ const hit = harness.diagnostics.find((d) => d.kind === "invalid-name");
387
+ assert.ok(
388
+ hit,
389
+ `expected an invalid-name diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
390
+ );
391
+ assert.equal(hit.type, "error");
392
+ assert.match(hit.message, /^plugin name "bad name".*disallowed/);
393
+ });
394
+
395
+ it("returns a no-op harness on a plugin-merger-side error-class diagnostic (reserved-tracker-name)", () => {
396
+ // Symmetric short-circuit: any error-class diagnostic — from the
397
+ // loader, the cross-config merge, OR the plugin merger — produces
398
+ // a no-op harness so plugin-author tests see uniform behavior
399
+ // regardless of which surface flagged the problem. Before the
400
+ // short-circuit moved to AFTER `resolvePlugins`, plugin-merger
401
+ // errors flowed through to a partially-built harness.
402
+ const t = {
403
+ initial: "?" as const,
404
+ unknown: "unknown" as const,
405
+ modifiers: {},
406
+ subshellSemantics: "isolated" as const,
407
+ };
408
+ const plugin: Plugin = {
409
+ name: "reserved-name-plugin",
410
+ trackers: { events: t as never },
411
+ rules: [
412
+ {
413
+ name: "would-block",
414
+ tool: "bash" as const,
415
+ field: "command" as const,
416
+ pattern: /^.*$/,
417
+ reason: "never",
418
+ },
419
+ ],
420
+ };
421
+ const harness = loadHarness({ config: { plugins: [plugin] } });
422
+ // Diagnostic surfaced.
423
+ const hit = harness.diagnostics.find(
424
+ (d) => d.kind === "reserved-tracker-name",
425
+ );
426
+ assert.ok(
427
+ hit,
428
+ `expected a reserved-tracker-name diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
429
+ );
430
+ // Harness is no-op: resolved is empty (no rules / no observers /
431
+ // no trackers), so the would-block rule that the plugin shipped
432
+ // alongside the bad tracker doesn't fire either.
433
+ assert.equal(harness.resolved.rules.length, 0);
434
+ assert.equal(harness.resolved.observers.length, 0);
435
+ assert.deepEqual(harness.resolved.trackers, {});
436
+ // Consistency: resolved.diagnostics mirrors the outer
437
+ // harness.diagnostics in the no-op short-circuit branch so
438
+ // consumers reading either surface get the same list.
439
+ assert.equal(
440
+ harness.resolved.diagnostics.length,
441
+ harness.diagnostics.length,
442
+ );
443
+ assert.ok(
444
+ harness.resolved.diagnostics.some(
445
+ (d) => d.kind === "reserved-tracker-name",
446
+ ),
447
+ );
448
+ });
449
+
450
+ it("surfaces an error-class diagnostic when a plugin claims a reserved tracker name", () => {
451
+ // Plugin authors writing tests against `loadHarness` should see
452
+ // reserved-name violations in their diagnostics array, NOT a thrown
453
+ // error that hides which other diagnostics fired alongside.
454
+ const t = {
455
+ initial: "?" as const,
456
+ unknown: "unknown" as const,
457
+ modifiers: {},
458
+ subshellSemantics: "isolated" as const,
459
+ };
460
+ const plugin: Plugin = {
461
+ name: "reserved-name-plugin",
462
+ // `events` is reserved for the evaluator's speculative-entry
463
+ // synthesis; plugins must not register a tracker under that name.
464
+ trackers: { events: t as never },
465
+ };
466
+ const harness = loadHarness({ config: { plugins: [plugin] } });
467
+ const hit = harness.diagnostics.find(
468
+ (d) => d.kind === "reserved-tracker-name",
469
+ );
470
+ assert.ok(
471
+ hit,
472
+ `expected a reserved-tracker-name diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
473
+ );
474
+ assert.equal(hit.type, "error");
475
+ });
476
+
477
+ it("surfaces a warning-class diagnostic when two plugins register the same predicate key", () => {
478
+ const plugin1: Plugin = {
479
+ name: "p1",
480
+ predicates: { branch: () => true },
481
+ };
482
+ const plugin2: Plugin = {
483
+ name: "p2",
484
+ predicates: { branch: () => false },
485
+ };
486
+ const harness = loadHarness({
487
+ config: { plugins: [plugin1, plugin2] },
488
+ });
489
+ const hit = harness.diagnostics.find(
490
+ (d) => d.kind === "predicate-collision",
491
+ );
492
+ assert.ok(
493
+ hit,
494
+ `expected a predicate-collision diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
495
+ );
496
+ assert.equal(hit.type, "warning");
497
+ });
498
+
499
+ it("does NOT throw on a tracker-name collision; surfaces it as a single error-class diagnostic", () => {
500
+ // loadHarness aggregates loader + buildConfig + resolvePlugins
501
+ // diagnostics and short-circuits to a no-op evaluator/dispatcher
502
+ // when any error-class diagnostic fires, so plugin-author tests
503
+ // can read the diagnostic from `harness.diagnostics` instead of
504
+ // catching a thrown Error. The shared merge-pipeline helper
505
+ // short-circuits before resolvePlugins, so the same collision
506
+ // should appear exactly once even though both buildConfig's
507
+ // detectTrackerNameCollisions and resolvePlugins independently
508
+ // flag the same shape.
509
+ const t = {
510
+ initial: "?" as const,
511
+ unknown: "unknown" as const,
512
+ modifiers: {},
513
+ subshellSemantics: "isolated" as const,
514
+ };
515
+ const harness = loadHarness({
516
+ config: {
517
+ plugins: [
518
+ { name: "pa", trackers: { branch: t as never } },
519
+ { name: "pb", trackers: { branch: t as never } },
520
+ ],
521
+ },
522
+ });
523
+ const hits = harness.diagnostics.filter(
524
+ (d) => d.kind === "tracker-name-collision",
525
+ );
526
+ assert.equal(
527
+ hits.length,
528
+ 1,
529
+ `expected a single tracker-name-collision diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
530
+ );
531
+ const [hit] = hits;
532
+ assert.equal(hit?.type, "error");
533
+ });
534
+
535
+ it("does NOT throw on a malformed user-config rule name; surfaces it as an invalid-name error-class diagnostic", () => {
536
+ // User-config rule name validation runs inside the shared
537
+ // merge-pipeline helper before `resolvePlugins`, so a malformed
538
+ // rule name in the harness's input config produces the same
539
+ // `kind: "invalid-name"` diagnostic shape as a malformed
540
+ // plugin-shipped rule name. Without unification the harness
541
+ // would throw a plain `pi-steering: rule name "..."` Error from
542
+ // `buildEvaluator`, contradicting the documented "does NOT throw
543
+ // on error-class diagnostics" contract.
544
+ const harness = loadHarness({
545
+ config: {
546
+ rules: [
547
+ {
548
+ name: "phony] BAD",
549
+ tool: "bash" as const,
550
+ field: "command" as const,
551
+ pattern: /^never$/,
552
+ reason: "r",
553
+ },
554
+ ],
555
+ },
556
+ });
557
+ const hit = harness.diagnostics.find((d) => d.kind === "invalid-name");
558
+ assert.ok(
559
+ hit,
560
+ `expected an invalid-name diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
561
+ );
562
+ assert.equal(hit.type, "error");
563
+ assert.match(hit.message, /^rule name "phony\] BAD".*disallowed/);
564
+ assert.match(hit.message, /\(user config\)/);
565
+ // Harness short-circuits to a no-op evaluator/dispatcher pair
566
+ // the same way it does for plugin-shipped errors.
567
+ assert.equal(harness.resolved.rules.length, 0);
568
+ });
569
+
570
+ it("does NOT throw on a malformed user-config observer name; surfaces it as an invalid-name error-class diagnostic", () => {
571
+ // Same shape as the rule-name case but for observers — confirms
572
+ // the validation covers both surfaces of `validateUserConfigNames`.
573
+ const harness = loadHarness({
574
+ config: {
575
+ observers: [
576
+ {
577
+ name: "bad observer name",
578
+ watch: { toolName: "bash" as const },
579
+ onResult: () => {},
580
+ },
581
+ ],
582
+ },
583
+ });
584
+ const hit = harness.diagnostics.find((d) => d.kind === "invalid-name");
585
+ assert.ok(
586
+ hit,
587
+ `expected an invalid-name diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
588
+ );
589
+ assert.equal(hit.type, "error");
590
+ assert.match(hit.message, /^observer name "bad observer name".*disallowed/);
591
+ assert.match(hit.message, /\(user config\)/);
592
+ });
593
+
594
+ it("surfaces BOTH a tracker-name-collision AND a malformed user-config rule name in one harness load", () => {
595
+ // Combined error: tracker-name-collision (merge-side) plus a
596
+ // malformed user-config rule name. Pins that user-config name
597
+ // validation runs unconditionally so both surface in one
598
+ // harness load, not on consecutive runs.
599
+ const t = {
600
+ initial: "?" as const,
601
+ unknown: "unknown" as const,
602
+ modifiers: {},
603
+ subshellSemantics: "isolated" as const,
604
+ };
605
+ const harness = loadHarness({
606
+ config: {
607
+ plugins: [
608
+ { name: "pa", trackers: { branch: t as never } },
609
+ { name: "pb", trackers: { branch: t as never } },
610
+ ],
611
+ rules: [
612
+ {
613
+ name: "phony] BAD",
614
+ tool: "bash" as const,
615
+ field: "command" as const,
616
+ pattern: /^never$/,
617
+ reason: "r",
618
+ },
619
+ ],
620
+ },
621
+ });
622
+ assert.ok(
623
+ harness.diagnostics.some((d) => d.kind === "tracker-name-collision"),
624
+ `expected a tracker-name-collision diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
625
+ );
626
+ assert.ok(
627
+ harness.diagnostics.some(
628
+ (d) =>
629
+ d.kind === "invalid-name" &&
630
+ /phony\] BAD/.test(d.message) &&
631
+ /\(user config\)/.test(d.message),
632
+ ),
633
+ `expected an invalid-name diagnostic for the malformed user-config rule; got: ${JSON.stringify(harness.diagnostics)}`,
634
+ );
635
+ });
636
+ });
637
+
638
+ // ---------------------------------------------------------------------------
639
+ // mockContext
640
+ // ---------------------------------------------------------------------------
641
+
642
+ describe("mockContext", () => {
643
+ it("returns a ctx with all required PredicateContext fields populated", () => {
644
+ const ctx = mockContext();
645
+ assert.equal(typeof ctx.cwd, "string");
646
+ assert.equal(ctx.tool, "bash");
647
+ assert.deepEqual(ctx.input, { tool: "bash", command: "" });
648
+ assert.equal(ctx.agentLoopIndex, 0);
649
+ assert.equal(typeof ctx.exec, "function");
650
+ assert.equal(typeof ctx.appendEntry, "function");
651
+ assert.equal(typeof ctx.findEntries, "function");
652
+ // Default walkerState populates cwd + an empty env map (Tier B /
653
+ // D1: plugin authors reading `walkerState.env.get(...)` get a
654
+ // well-typed Map even when the test doesn't wire up the env
655
+ // tracker explicitly).
656
+ assert.deepEqual(ctx.walkerState, {
657
+ cwd: "/tmp/test",
658
+ env: new Map<string, string>(),
659
+ });
660
+ });
661
+
662
+ it("applies cwd / agentLoopIndex / tool / input / walkerState overrides", () => {
663
+ const ctx = mockContext({
664
+ cwd: "/work",
665
+ agentLoopIndex: 7,
666
+ tool: "write",
667
+ input: { tool: "write", path: "/a.ts", content: "x" },
668
+ walkerState: { cwd: "/work", branch: "main" },
669
+ });
670
+ assert.equal(ctx.cwd, "/work");
671
+ assert.equal(ctx.agentLoopIndex, 7);
672
+ assert.equal(ctx.tool, "write");
673
+ assert.deepEqual(ctx.input, {
674
+ tool: "write",
675
+ path: "/a.ts",
676
+ content: "x",
677
+ });
678
+ // walkerState overrides merge OVER the defaults (shallow merge),
679
+ // so branch lands, cwd overrides the default, and env stays as the
680
+ // default empty Map — matching production evaluator shape.
681
+ assert.deepEqual(ctx.walkerState, {
682
+ cwd: "/work",
683
+ env: new Map(),
684
+ branch: "main",
685
+ });
686
+ });
687
+
688
+ it("derives default input shape per tool", () => {
689
+ const w = mockContext({ tool: "write" });
690
+ assert.deepEqual(w.input, { tool: "write", path: "", content: "" });
691
+ const e = mockContext({ tool: "edit" });
692
+ assert.deepEqual(e.input, { tool: "edit", path: "", edits: [] });
693
+ });
694
+
695
+ it("stubs exec: passes through cmd/args/opts", async () => {
696
+ const seen: Array<{
697
+ cmd: string;
698
+ args: readonly string[];
699
+ cwd?: string | undefined;
700
+ }> = [];
701
+ const ctx = mockContext({
702
+ exec: (cmd, args, opts) => {
703
+ seen.push({ cmd, args, cwd: opts?.cwd });
704
+ return { stdout: "ok", stderr: "", exitCode: 0 };
705
+ },
706
+ });
707
+ const r = await ctx.exec("git", ["status"], { cwd: "/x" });
708
+ assert.equal(r.stdout, "ok");
709
+ assert.deepEqual(seen, [{ cmd: "git", args: ["status"], cwd: "/x" }]);
710
+ });
711
+
712
+ it("unstubbed exec rejects with a clear error", async () => {
713
+ const ctx = mockContext();
714
+ await assert.rejects(
715
+ () => ctx.exec("git", ["status"]),
716
+ /mockContext: exec not stubbed/,
717
+ );
718
+ });
719
+
720
+ it("findEntries filters by customType and parses timestamps to epoch-ms", () => {
721
+ const iso = "2026-01-02T03:04:05.000Z";
722
+ const ctx = mockContext({
723
+ entries: [
724
+ {
725
+ type: "custom",
726
+ customType: "a",
727
+ data: { v: 1 },
728
+ timestamp: iso,
729
+ },
730
+ {
731
+ type: "custom",
732
+ customType: "b",
733
+ data: { v: 2 },
734
+ timestamp: iso,
735
+ },
736
+ {
737
+ type: "custom",
738
+ customType: "a",
739
+ data: { v: 3 },
740
+ timestamp: "not-a-date",
741
+ },
742
+ ],
743
+ });
744
+ const hits = ctx.findEntries<{ v: number }>("a");
745
+ assert.equal(hits.length, 2);
746
+ assert.deepEqual(hits[0]!.data, { v: 1 });
747
+ assert.equal(hits[0]!.timestamp, Date.parse(iso));
748
+ // Unparseable timestamp → 0 (documented fallback).
749
+ assert.equal(hits[1]!.timestamp, 0);
750
+ // No match → empty.
751
+ assert.deepEqual(ctx.findEntries("missing"), []);
752
+ });
753
+
754
+ it("appendEntry writes are captured (visible via getAppendedEntries)", () => {
755
+ // Auto-tag: object payloads get `_agentLoopIndex` merged in,
756
+ // bare calls wrap as `{ value: undefined, _agentLoopIndex }`.
757
+ // Same shape the real engine writes (production parity is the
758
+ // whole point of routing through createAppendEntry).
759
+ const ctx = mockContext();
760
+ ctx.appendEntry("x", { a: 1 });
761
+ ctx.appendEntry("y"); // no data — pi allows bare customType.
762
+ const captured = getAppendedEntries(ctx);
763
+ assert.equal(captured.length, 2);
764
+ assert.equal(captured[0]!.customType, "x");
765
+ assert.deepEqual(captured[0]!.data, { a: 1, _agentLoopIndex: 0 });
766
+ assert.equal(captured[1]!.customType, "y");
767
+ assert.deepEqual(captured[1]!.data, {
768
+ value: undefined,
769
+ _agentLoopIndex: 0,
770
+ });
771
+ });
772
+
773
+ it("appendEntry auto-tags object payloads with agentLoopIndex (G2)", () => {
774
+ const ctx = mockContext({ agentLoopIndex: 5 });
775
+ ctx.appendEntry("marker", { foo: 1 });
776
+ const entries = getAppendedEntries(ctx);
777
+ assert.deepEqual(entries, [
778
+ { customType: "marker", data: { foo: 1, _agentLoopIndex: 5 } },
779
+ ]);
780
+ });
781
+
782
+ it("appendEntry wraps primitive payloads as { value, _agentLoopIndex } (G2)", () => {
783
+ const ctx = mockContext({ agentLoopIndex: 2 });
784
+ ctx.appendEntry("num", 42);
785
+ const entries = getAppendedEntries(ctx);
786
+ assert.deepEqual(entries, [
787
+ { customType: "num", data: { value: 42, _agentLoopIndex: 2 } },
788
+ ]);
789
+ });
790
+
791
+ it("appendEntry wraps array payloads (F2 / G2 / G3)", () => {
792
+ const ctx = mockContext({ agentLoopIndex: 5 });
793
+ ctx.appendEntry("items", [1, 2, 3]);
794
+ const entries = getAppendedEntries(ctx);
795
+ assert.deepEqual(entries, [
796
+ { customType: "items", data: { value: [1, 2, 3], _agentLoopIndex: 5 } },
797
+ ]);
798
+ });
799
+
800
+ // ---- toolCallEvents: walkerState.events surface for tool_call-
801
+ // ---- scope `when.happened` and plugin predicates over synthesized entries
802
+
803
+ it("toolCallEvents default: walkerState has no `events` key when the option is omitted", () => {
804
+ // Matches the production shape for non-bash candidates or configs
805
+ // with no eligible observer — the evaluator's synthesis pass runs
806
+ // only for bash, and mockContext doesn't manufacture one on the
807
+ // caller's behalf.
808
+ const ctx = mockContext();
809
+ assert.equal(
810
+ (ctx.walkerState as Record<string, unknown> | undefined)?.["events"],
811
+ undefined,
812
+ );
813
+ });
814
+
815
+ it("toolCallEvents threads through to ctx.walkerState.events", () => {
816
+ // Surface-level: plugin authors drive `when.happened` with `in: "tool_call"`
817
+ // in isolation by passing `toolCallEvents`. The option merges
818
+ // into walkerState under the reserved `events` key, same shape
819
+ // the walker-level synthesis pass produces in production.
820
+ const events = {
821
+ SYNC: [{ data: {}, timestamp: 2 ** 52 + 1, speculative: true as const }],
822
+ };
823
+ const ctx = mockContext({ toolCallEvents: events });
824
+ const got = (ctx.walkerState as Record<string, unknown>)["events"];
825
+ assert.equal(got, events);
826
+ });
827
+
828
+ it("toolCallEvents overrides an `events` entry placed on walkerState directly", () => {
829
+ // Explicit option wins: the caller who opts into `toolCallEvents`
830
+ // gets the canonical shape without having to strip their own
831
+ // `walkerState.events` entry. Mirrors the evaluator's merge order
832
+ // (`{ ...trackerState, events }`).
833
+ const override = {
834
+ X: [{ data: 1, timestamp: 7, speculative: true as const }],
835
+ };
836
+ const ctx = mockContext({
837
+ walkerState: { cwd: "/w", events: { X: [] } },
838
+ toolCallEvents: override,
839
+ });
840
+ const got = (ctx.walkerState as Record<string, unknown>)["events"];
841
+ assert.equal(got, override);
842
+ });
843
+
844
+ it("testPredicate forwards toolCallEvents so plugin predicates over walkerState.events can be driven", async () => {
845
+ // End-to-end: a plugin predicate introspects walkerState.events,
846
+ // and testPredicate (which forwards the full options object to
847
+ // mockContext) lets the caller exercise both branches.
848
+ const fires: PredicateHandler<string> = async (event, ctx) => {
849
+ const events = (ctx.walkerState as Record<string, unknown> | undefined)?.[
850
+ "events"
851
+ ] as Record<string, readonly unknown[]> | undefined;
852
+ return (events?.[event]?.length ?? 0) > 0;
853
+ };
854
+
855
+ const cold = await testPredicate(fires, "SYNC", {});
856
+ assert.equal(cold, false, "no toolCallEvents → predicate sees nothing");
857
+
858
+ const warm = await testPredicate(fires, "SYNC", {
859
+ toolCallEvents: {
860
+ SYNC: [
861
+ { data: {}, timestamp: 2 ** 52 + 1, speculative: true as const },
862
+ ],
863
+ },
864
+ });
865
+ assert.equal(warm, true, "toolCallEvents populated → predicate fires");
866
+ });
867
+
868
+ // -------------------------------------------------------------------
869
+ // Env surface (Tier B / PR #5 — D1)
870
+ // -------------------------------------------------------------------
871
+
872
+ it("default walkerState carries an empty env Map", () => {
873
+ const ctx = mockContext();
874
+ const env = ctx.walkerState?.["env"];
875
+ assert.ok(env instanceof Map, "walkerState.env is a Map");
876
+ assert.equal((env as Map<string, string>).size, 0);
877
+ });
878
+
879
+ it("walkerState override SHALLOW-MERGES with defaults so env stays a Map (M8)", () => {
880
+ // Correctness fix M8: `mockContext({ walkerState: { cwd: "/x" } })`
881
+ // previously replaced the entire default, dropping `env: new Map()`
882
+ // and crashing any predicate that did `ctx.walkerState.env.get(...)`.
883
+ // The merge now preserves defaults for fields the override omits.
884
+ const ctx = mockContext({ walkerState: { cwd: "/x" } });
885
+ assert.equal(ctx.walkerState?.["cwd"], "/x", "override cwd lands");
886
+ const env = ctx.walkerState?.["env"];
887
+ assert.ok(
888
+ env instanceof Map,
889
+ "walkerState.env is still a Map — default not dropped by partial override",
890
+ );
891
+ assert.equal((env as Map<string, string>).size, 0);
892
+ });
893
+
894
+ it("walkerState.env override threads through to ctx", () => {
895
+ const ctx = mockContext({
896
+ walkerState: {
897
+ cwd: "/ws/pkg",
898
+ env: new Map([["WS", "/ws"]]),
899
+ },
900
+ });
901
+ const env = ctx.walkerState?.["env"] as ReadonlyMap<string, string>;
902
+ assert.equal(env.get("WS"), "/ws");
903
+ assert.equal(ctx.walkerState?.["cwd"], "/ws/pkg");
904
+ });
905
+
906
+ it("walkerState.cwd === 'unknown' is a legal input (exercises onUnknown branch)", async () => {
907
+ // Spec requirement: mockContext should accept walkerState.cwd
908
+ // at the "unknown" sentinel so tests can exercise the
909
+ // fail-closed `onUnknown: 'block'` path without wiring up a
910
+ // full walker.
911
+ const ctx = mockContext({
912
+ walkerState: { cwd: "unknown", env: new Map() },
913
+ });
914
+ assert.equal(ctx.walkerState?.["cwd"], "unknown");
915
+
916
+ // Sanity: a plugin predicate reading the sentinel can discriminate.
917
+ const onUnknownBlock = async (_args: unknown, c: typeof ctx) =>
918
+ c.walkerState?.["cwd"] === "unknown";
919
+ assert.equal(await onUnknownBlock(null, ctx), true);
920
+ });
921
+
922
+ it("env is consumable by a ReasonFn-style predicate via testPredicate", async () => {
923
+ // End-to-end: a plugin reads walkerState.env.get('NAME') and
924
+ // fires the rule when the var resolves to a specific value.
925
+ const envGuard: PredicateHandler<string> = async (name, ctx) => {
926
+ const env = ctx.walkerState?.["env"] as
927
+ | ReadonlyMap<string, string>
928
+ | undefined;
929
+ return env?.get(name) === "/workspace";
930
+ };
931
+ const hit = await testPredicate(envGuard, "WS", {
932
+ walkerState: {
933
+ cwd: "/start",
934
+ env: new Map([["WS", "/workspace"]]),
935
+ },
936
+ });
937
+ assert.equal(hit, true);
938
+
939
+ const miss = await testPredicate(envGuard, "WS", {
940
+ walkerState: { cwd: "/start", env: new Map() },
941
+ });
942
+ assert.equal(miss, false);
943
+ });
944
+ });
945
+
946
+ // ---------------------------------------------------------------------------
947
+ // mockObserverContext
948
+ // ---------------------------------------------------------------------------
949
+
950
+ describe("mockObserverContext", () => {
951
+ it("returns a ctx with all required ObserverContext fields populated", () => {
952
+ const ctx = mockObserverContext();
953
+ assert.equal(ctx.cwd, "/tmp/test");
954
+ assert.equal(ctx.agentLoopIndex, 0);
955
+ assert.equal(typeof ctx.appendEntry, "function");
956
+ assert.equal(typeof ctx.findEntries, "function");
957
+ });
958
+
959
+ it("applies cwd / agentLoopIndex / entries overrides", () => {
960
+ const iso = "2026-03-04T05:06:07.000Z";
961
+ const ctx = mockObserverContext({
962
+ cwd: "/work",
963
+ agentLoopIndex: 3,
964
+ entries: [
965
+ {
966
+ type: "custom",
967
+ customType: "seen",
968
+ data: { n: 42 },
969
+ timestamp: iso,
970
+ },
971
+ ],
972
+ });
973
+ assert.equal(ctx.cwd, "/work");
974
+ assert.equal(ctx.agentLoopIndex, 3);
975
+ const hits = ctx.findEntries<{ n: number }>("seen");
976
+ assert.equal(hits.length, 1);
977
+ assert.deepEqual(hits[0]!.data, { n: 42 });
978
+ assert.equal(hits[0]!.timestamp, Date.parse(iso));
979
+ });
980
+
981
+ it("appendEntry captures are independent per context", () => {
982
+ const a = mockObserverContext({ agentLoopIndex: 0 });
983
+ const b = mockObserverContext({ agentLoopIndex: 0 });
984
+ a.appendEntry("one", { x: 1 });
985
+ b.appendEntry("two", { y: 2 });
986
+ assert.deepEqual(getAppendedEntries(a), [
987
+ { customType: "one", data: { x: 1, _agentLoopIndex: 0 } },
988
+ ]);
989
+ assert.deepEqual(getAppendedEntries(b), [
990
+ { customType: "two", data: { y: 2, _agentLoopIndex: 0 } },
991
+ ]);
992
+ });
993
+
994
+ it("observer appendEntry auto-tags with agentLoopIndex (G2)", () => {
995
+ const ctx = mockObserverContext({ agentLoopIndex: 9 });
996
+ ctx.appendEntry("seen", { foo: 1 });
997
+ assert.deepEqual(getAppendedEntries(ctx), [
998
+ { customType: "seen", data: { foo: 1, _agentLoopIndex: 9 } },
999
+ ]);
1000
+ });
1001
+
1002
+ it("observer appendEntry wraps primitive payloads (G2)", () => {
1003
+ const ctx = mockObserverContext({ agentLoopIndex: 3 });
1004
+ ctx.appendEntry("n", 7);
1005
+ assert.deepEqual(getAppendedEntries(ctx), [
1006
+ { customType: "n", data: { value: 7, _agentLoopIndex: 3 } },
1007
+ ]);
1008
+ });
1009
+ });
1010
+
1011
+ // ---------------------------------------------------------------------------
1012
+ // getAppendedEntries
1013
+ // ---------------------------------------------------------------------------
1014
+
1015
+ describe("getAppendedEntries", () => {
1016
+ it("returns empty for a freshly-built mockContext (nothing appended yet)", () => {
1017
+ const ctx = mockContext();
1018
+ assert.deepEqual(getAppendedEntries(ctx), []);
1019
+ });
1020
+
1021
+ it("returns writes after ctx.appendEntry calls", () => {
1022
+ const ctx = mockContext();
1023
+ ctx.appendEntry("x", { a: 1 });
1024
+ const captured = getAppendedEntries(ctx);
1025
+ assert.deepEqual(captured, [
1026
+ { customType: "x", data: { a: 1, _agentLoopIndex: 0 } },
1027
+ ]);
1028
+ });
1029
+
1030
+ it("returns empty for a non-mock context (safe lookup, no throw)", () => {
1031
+ // Craft a minimal non-mock PredicateContext. Shape-only — we never
1032
+ // call its methods.
1033
+ const adhoc: PredicateContext = {
1034
+ cwd: "/",
1035
+ tool: "bash",
1036
+ input: { tool: "bash", command: "" },
1037
+ agentLoopIndex: 0,
1038
+ exec: () => Promise.resolve({ stdout: "", stderr: "", exitCode: 0 }),
1039
+ appendEntry: () => {},
1040
+ findEntries: () => [],
1041
+ };
1042
+ assert.deepEqual(getAppendedEntries(adhoc), []);
1043
+ // Same for an ad-hoc ObserverContext.
1044
+ const adhocObs: ObserverContext = {
1045
+ cwd: "/",
1046
+ agentLoopIndex: 0,
1047
+ appendEntry: () => {},
1048
+ findEntries: () => [],
1049
+ };
1050
+ assert.deepEqual(getAppendedEntries(adhocObs), []);
1051
+ });
1052
+
1053
+ it("returned snapshot is decoupled from later writes", () => {
1054
+ const ctx = mockContext();
1055
+ ctx.appendEntry("first");
1056
+ const snap = getAppendedEntries(ctx);
1057
+ ctx.appendEntry("second");
1058
+ // Snapshot captured only the first entry.
1059
+ assert.equal(snap.length, 1);
1060
+ // Re-reading picks up both.
1061
+ assert.equal(getAppendedEntries(ctx).length, 2);
1062
+ });
1063
+ });
1064
+
1065
+ // ===========================================================================
1066
+ // Phase 5b — Convenience wrappers
1067
+ // ===========================================================================
1068
+
1069
+ describe("testPredicate", () => {
1070
+ it("returns the predicate's boolean verdict", async () => {
1071
+ const alwaysTrue: PredicateHandler<unknown> = async () => true;
1072
+ const alwaysFalse: PredicateHandler<unknown> = async () => false;
1073
+ assert.equal(await testPredicate(alwaysTrue, null), true);
1074
+ assert.equal(await testPredicate(alwaysFalse, null), false);
1075
+ });
1076
+
1077
+ it("threads args + ctx (walkerState + exec stub) to the predicate", async () => {
1078
+ const branchEq: PredicateHandler<string> = async (arg, ctx) => {
1079
+ return ctx.walkerState?.["branch"] === arg;
1080
+ };
1081
+ const fires = await testPredicate(branchEq, "main", {
1082
+ walkerState: { branch: "main" },
1083
+ });
1084
+ assert.equal(fires, true);
1085
+ });
1086
+
1087
+ it("predicates can call stubbed exec via ctx", async () => {
1088
+ const readsExec: PredicateHandler<unknown> = async (_, ctx) => {
1089
+ const r = await ctx.exec("echo", ["hi"]);
1090
+ return r.exitCode === 0;
1091
+ };
1092
+ const fires = await testPredicate(readsExec, null, {
1093
+ exec: () => ({ stdout: "hi", stderr: "", exitCode: 0 }),
1094
+ });
1095
+ assert.equal(fires, true);
1096
+ });
1097
+ });
1098
+
1099
+ describe("testObserver", () => {
1100
+ it("fires onResult when watch matches (or watch is absent)", async () => {
1101
+ const obs: Observer = {
1102
+ name: "all-events",
1103
+ onResult: (_evt, ctx) => {
1104
+ ctx.appendEntry("seen");
1105
+ },
1106
+ };
1107
+ const { entries, watchMatched } = await testObserver(obs, {
1108
+ toolName: "bash",
1109
+ input: { command: "ls" },
1110
+ output: {},
1111
+ exitCode: 0,
1112
+ });
1113
+ assert.equal(watchMatched, true);
1114
+ assert.equal(entries.length, 1);
1115
+ assert.equal(entries[0]?.customType, "seen");
1116
+ });
1117
+
1118
+ it("does NOT fire onResult when watch filter rejects", async () => {
1119
+ const obs: Observer = {
1120
+ name: "bash-only",
1121
+ watch: { toolName: "bash" },
1122
+ onResult: (_evt, ctx) => {
1123
+ ctx.appendEntry("seen");
1124
+ },
1125
+ };
1126
+ const { entries, watchMatched } = await testObserver(obs, {
1127
+ toolName: "read",
1128
+ input: {},
1129
+ output: {},
1130
+ });
1131
+ assert.equal(watchMatched, false);
1132
+ assert.equal(entries.length, 0);
1133
+ });
1134
+
1135
+ it("watch.inputMatches with absent key is fail-closed", async () => {
1136
+ const obs: Observer = {
1137
+ name: "cmd-match",
1138
+ watch: { inputMatches: { command: /^git/ } },
1139
+ onResult: () => {},
1140
+ };
1141
+ const { watchMatched } = await testObserver(obs, {
1142
+ toolName: "read",
1143
+ input: {}, // no `command` field
1144
+ output: {},
1145
+ });
1146
+ assert.equal(watchMatched, false);
1147
+ });
1148
+
1149
+ it("watch.exitCode: success / failure / numeric / any", async () => {
1150
+ const mk = (code: number | "success" | "failure" | "any") => ({
1151
+ name: "e" + String(code),
1152
+ watch: { exitCode: code },
1153
+ onResult: () => {},
1154
+ });
1155
+ const ok = { toolName: "bash", input: {}, output: {}, exitCode: 0 };
1156
+ const fail = {
1157
+ toolName: "bash",
1158
+ input: {},
1159
+ output: {},
1160
+ exitCode: 2,
1161
+ };
1162
+ assert.equal((await testObserver(mk("success"), ok)).watchMatched, true);
1163
+ assert.equal((await testObserver(mk("success"), fail)).watchMatched, false);
1164
+ assert.equal((await testObserver(mk("failure"), fail)).watchMatched, true);
1165
+ assert.equal((await testObserver(mk(2), fail)).watchMatched, true);
1166
+ assert.equal((await testObserver(mk("any"), fail)).watchMatched, true);
1167
+ });
1168
+
1169
+ it("warns when options.exec is set (observers don't see exec)", async () => {
1170
+ const obs: Observer = { name: "n", onResult: () => {} };
1171
+ const warnings: unknown[][] = [];
1172
+ const orig = console.warn;
1173
+ console.warn = (...args: unknown[]) => warnings.push(args);
1174
+ try {
1175
+ await testObserver(
1176
+ obs,
1177
+ { toolName: "bash", input: {}, output: {} },
1178
+ { exec: () => ({ stdout: "", stderr: "", exitCode: 0 }) },
1179
+ );
1180
+ } finally {
1181
+ console.warn = orig;
1182
+ }
1183
+ assert.equal(warnings.length, 1);
1184
+ assert.match(String(warnings[0]?.[0] ?? ""), /exec option ignored/);
1185
+ });
1186
+
1187
+ it("watch.inputMatches.command is wrapper-aware on bash events (ADR §12)", async () => {
1188
+ // Regression test for the former testing-side reimplementation
1189
+ // of matchesWatch: it did a raw-string match only, so a watch of
1190
+ // `command: /^git commit/` silently missed `sh -c 'git commit ...'`
1191
+ // while production correctly fired on it. Now `testObserver`
1192
+ // shares `matchesWatch` with the dispatcher so both paths agree.
1193
+ const fired: string[] = [];
1194
+ const obs: Observer = {
1195
+ name: "commit-watcher",
1196
+ watch: {
1197
+ toolName: "bash",
1198
+ inputMatches: { command: /^git commit/ },
1199
+ },
1200
+ onResult: (evt) => {
1201
+ fired.push(String((evt.input as { command?: unknown }).command));
1202
+ },
1203
+ };
1204
+
1205
+ // Raw match still works (outer command IS `git commit ...`).
1206
+ const raw = await testObserver(obs, {
1207
+ toolName: "bash",
1208
+ input: { command: 'git commit -m "x"' },
1209
+ output: {},
1210
+ exitCode: 0,
1211
+ });
1212
+ assert.equal(raw.watchMatched, true);
1213
+
1214
+ // Wrapper-aware: outer is `sh -c '...'`, the inner extracted ref
1215
+ // is `git commit -m "x"` — the pattern matches the inner ref, so
1216
+ // the observer fires. This previously failed silently under the
1217
+ // reimplemented filter.
1218
+ const wrapped = await testObserver(obs, {
1219
+ toolName: "bash",
1220
+ input: { command: `sh -c 'git commit -m "x"'` },
1221
+ output: {},
1222
+ exitCode: 0,
1223
+ });
1224
+ assert.equal(wrapped.watchMatched, true);
1225
+ assert.equal(fired.length, 2);
1226
+
1227
+ // Negative control: an unrelated outer command doesn't match,
1228
+ // even though it's a bash event.
1229
+ const miss = await testObserver(obs, {
1230
+ toolName: "bash",
1231
+ input: { command: "ls -la" },
1232
+ output: {},
1233
+ exitCode: 0,
1234
+ });
1235
+ assert.equal(miss.watchMatched, false);
1236
+ });
1237
+ });
1238
+
1239
+ describe("expectBlocks / expectAllows / expectRuleFires", () => {
1240
+ const blockAllRule: Rule = {
1241
+ name: "block-all",
1242
+ tool: "bash",
1243
+ field: "command",
1244
+ pattern: /.*/,
1245
+ reason: "test block",
1246
+ noOverride: true,
1247
+ };
1248
+
1249
+ it("expectBlocks returns result on block", async () => {
1250
+ const harness = loadHarness({ config: { rules: [blockAllRule] } });
1251
+ const result = await expectBlocks(harness, { command: "anything" });
1252
+ assert.ok(result);
1253
+ assert.equal(result.block, true);
1254
+ });
1255
+
1256
+ it("expectBlocks throws AssertionError on allow", async () => {
1257
+ const harness = loadHarness({ config: { rules: [] } });
1258
+ await assert.rejects(
1259
+ () => expectBlocks(harness, { command: "anything" }),
1260
+ /expected block, got allow/,
1261
+ );
1262
+ });
1263
+
1264
+ it("expectBlocks { rule } asserts rule name match", async () => {
1265
+ const harness = loadHarness({ config: { rules: [blockAllRule] } });
1266
+ await expectBlocks(harness, { command: "x" }, { rule: "block-all" });
1267
+ await assert.rejects(
1268
+ () => expectBlocks(harness, { command: "x" }, { rule: "other-rule" }),
1269
+ /expected rule "other-rule" to fire/,
1270
+ );
1271
+ });
1272
+
1273
+ it("expectBlocks { reason: RegExp } asserts reason match", async () => {
1274
+ const harness = loadHarness({ config: { rules: [blockAllRule] } });
1275
+ await expectBlocks(harness, { command: "x" }, { reason: /test block/ });
1276
+ await assert.rejects(
1277
+ () => expectBlocks(harness, { command: "x" }, { reason: /wrong reason/ }),
1278
+ /reason did not match/,
1279
+ );
1280
+ });
1281
+
1282
+ it("expectAllows succeeds on no block", async () => {
1283
+ const harness = loadHarness({ config: { rules: [] } });
1284
+ await expectAllows(harness, { command: "anything" });
1285
+ });
1286
+
1287
+ it("expectAllows throws on block", async () => {
1288
+ const harness = loadHarness({ config: { rules: [blockAllRule] } });
1289
+ await assert.rejects(
1290
+ () => expectAllows(harness, { command: "x" }),
1291
+ /expected allow, got block/,
1292
+ );
1293
+ });
1294
+
1295
+ it("expectRuleFires delegates to expectBlocks { rule }", async () => {
1296
+ const harness = loadHarness({ config: { rules: [blockAllRule] } });
1297
+ await expectRuleFires(harness, { command: "x" }, "block-all");
1298
+ await assert.rejects(
1299
+ () => expectRuleFires(harness, { command: "x" }, "nope"),
1300
+ /expected rule "nope" to fire/,
1301
+ );
1302
+ });
1303
+
1304
+ it("accepts a WriteShorthand", async () => {
1305
+ const writeBlock: Rule = {
1306
+ name: "write-block",
1307
+ tool: "write",
1308
+ field: "content",
1309
+ pattern: /forbidden/,
1310
+ reason: "no",
1311
+ noOverride: true,
1312
+ };
1313
+ const harness = loadHarness({ config: { rules: [writeBlock] } });
1314
+ await expectBlocks(harness, {
1315
+ write: { path: "f.txt", content: "forbidden content" },
1316
+ });
1317
+ await expectAllows(harness, {
1318
+ write: { path: "f.txt", content: "ok content" },
1319
+ });
1320
+ });
1321
+ });
1322
+
1323
+ describe("runMatrix / formatMatrix", () => {
1324
+ const blockAllRule: Rule = {
1325
+ name: "block-all",
1326
+ tool: "bash",
1327
+ field: "command",
1328
+ pattern: /.*/,
1329
+ reason: "test block",
1330
+ noOverride: true,
1331
+ };
1332
+
1333
+ it("tallies pass / fail counts", async () => {
1334
+ const harness = loadHarness({ config: { rules: [blockAllRule] } });
1335
+ const result = await runMatrix(harness, [
1336
+ { name: "a", event: { command: "x" }, expect: "block" },
1337
+ {
1338
+ name: "b",
1339
+ event: { command: "y" },
1340
+ expect: { block: true, rule: "block-all" },
1341
+ },
1342
+ { name: "c", event: { command: "z" }, expect: "allow" }, // will fail
1343
+ ]);
1344
+ assert.equal(result.total, 3);
1345
+ assert.equal(result.passed, 2);
1346
+ assert.equal(result.failed, 1);
1347
+ assert.equal(result.cases[0]?.passed, true);
1348
+ assert.equal(result.cases[1]?.passed, true);
1349
+ assert.equal(result.cases[2]?.passed, false);
1350
+ assert.match(
1351
+ result.cases[2]?.errorMessage ?? "",
1352
+ /expected allow.*got block/,
1353
+ );
1354
+ });
1355
+
1356
+ it("block:{rule} expectation catches wrong-rule fires", async () => {
1357
+ const r1: Rule = {
1358
+ name: "rule-one",
1359
+ tool: "bash",
1360
+ field: "command",
1361
+ pattern: /^x/,
1362
+ reason: "r1",
1363
+ noOverride: true,
1364
+ };
1365
+ const r2: Rule = {
1366
+ name: "rule-two",
1367
+ tool: "bash",
1368
+ field: "command",
1369
+ pattern: /^y/,
1370
+ reason: "r2",
1371
+ noOverride: true,
1372
+ };
1373
+ const harness = loadHarness({ config: { rules: [r1, r2] } });
1374
+ const result = await runMatrix(harness, [
1375
+ {
1376
+ name: "wrong-rule",
1377
+ event: { command: "x" },
1378
+ expect: { block: true, rule: "rule-two" }, // r1 fires, not r2
1379
+ },
1380
+ ]);
1381
+ assert.equal(result.passed, 0);
1382
+ assert.equal(result.failed, 1);
1383
+ assert.match(
1384
+ result.cases[0]?.errorMessage ?? "",
1385
+ /expected rule "rule-two"; got "rule-one"/,
1386
+ );
1387
+ });
1388
+
1389
+ it("never throws — failures appear in the result", async () => {
1390
+ const harness = loadHarness({ config: { rules: [] } });
1391
+ // All cases will fail.
1392
+ const result = await runMatrix(harness, [
1393
+ { name: "f1", event: { command: "x" }, expect: "block" },
1394
+ { name: "f2", event: { command: "y" }, expect: "block" },
1395
+ ]);
1396
+ assert.equal(result.passed, 0);
1397
+ assert.equal(result.failed, 2);
1398
+ });
1399
+
1400
+ it("formatMatrix renders a readable report", async () => {
1401
+ const harness = loadHarness({ config: { rules: [blockAllRule] } });
1402
+ const result = await runMatrix(harness, [
1403
+ { name: "case-a", event: { command: "x" }, expect: "block" },
1404
+ { name: "case-b", event: { command: "y" }, expect: "allow" },
1405
+ ]);
1406
+ const report = formatMatrix(result);
1407
+ assert.match(report, /MATRIX — 2 cases\. 1 pass, 1 fail/);
1408
+ assert.match(report, /\[case-a\].*expect:block.*actual:BLOCK/);
1409
+ assert.match(report, /\[case-b\].*expect:allow.*actual:BLOCK.*FAIL/);
1410
+ assert.match(report, /PASS: 1\/2/);
1411
+ });
1412
+ });
1413
+
1414
+ // ===========================================================================
1415
+ // createRecordingHost + mockExtensionContext
1416
+ // ===========================================================================
1417
+
1418
+ describe("createRecordingHost", () => {
1419
+ it("returns a host with empty entries / execCalls / appendedEntries", () => {
1420
+ const host = createRecordingHost();
1421
+ assert.deepEqual(host.entries, []);
1422
+ assert.deepEqual(host.execCalls, []);
1423
+ assert.deepEqual(host.appendedEntries, []);
1424
+ assert.equal(typeof host.exec, "function");
1425
+ assert.equal(typeof host.appendEntry, "function");
1426
+ });
1427
+
1428
+ it("appendEntry records both raw (appendedEntries) and session-shape (entries) logs", () => {
1429
+ const host = createRecordingHost();
1430
+ host.appendEntry("marker", { foo: 1 });
1431
+ host.appendEntry("marker-bare"); // bare call — pi allows no data.
1432
+ assert.equal(host.appendedEntries.length, 2);
1433
+ assert.deepEqual(host.appendedEntries[0], {
1434
+ type: "marker",
1435
+ data: { foo: 1 },
1436
+ });
1437
+ assert.deepEqual(host.appendedEntries[1], {
1438
+ type: "marker-bare",
1439
+ data: undefined,
1440
+ });
1441
+
1442
+ assert.equal(host.entries.length, 2);
1443
+ // Session-shape entries mirror what pi's sessionManager emits:
1444
+ // type, customType, data, timestamp, id, parentId.
1445
+ assert.equal(host.entries[0]?.type, "custom");
1446
+ assert.equal(host.entries[0]?.customType, "marker");
1447
+ assert.deepEqual(host.entries[0]?.data, { foo: 1 });
1448
+ assert.equal(typeof host.entries[0]?.timestamp, "string");
1449
+ assert.match(
1450
+ host.entries[0]?.timestamp ?? "",
1451
+ /^2026-01-01T00:00:/, // monotonic ISO starting at the 2026-01-01 epoch.
1452
+ );
1453
+ assert.equal(host.entries[0]?.parentId, null);
1454
+ // Timestamps strictly increase so chronological asserts are stable.
1455
+ assert.ok(
1456
+ (host.entries[0]?.timestamp ?? "") < (host.entries[1]?.timestamp ?? ""),
1457
+ "entry timestamps should be monotonically increasing",
1458
+ );
1459
+ });
1460
+
1461
+ it("exec defaults to empty-success, records cmd/args/cwd per call", async () => {
1462
+ const host = createRecordingHost();
1463
+ const r = await host.exec("git", ["status"], { cwd: "/work" });
1464
+ assert.deepEqual(r, {
1465
+ stdout: "",
1466
+ stderr: "",
1467
+ code: 0,
1468
+ killed: false,
1469
+ });
1470
+ assert.equal(host.execCalls.length, 1);
1471
+ assert.deepEqual(host.execCalls[0], {
1472
+ cmd: "git",
1473
+ args: ["status"],
1474
+ cwd: "/work",
1475
+ });
1476
+
1477
+ // A call without explicit cwd falls back to "/".
1478
+ await host.exec("pwd", []);
1479
+ assert.equal(host.execCalls[1]?.cwd, "/");
1480
+ });
1481
+
1482
+ it("exec override is threaded through, default still records the call", async () => {
1483
+ const host = createRecordingHost({
1484
+ exec: async (cmd, _args, cwd) => ({
1485
+ stdout: `ran:${cmd}@${cwd}`,
1486
+ stderr: "",
1487
+ code: 0,
1488
+ killed: false,
1489
+ }),
1490
+ });
1491
+ const r = await host.exec("echo", ["hi"], { cwd: "/x" });
1492
+ assert.equal(r.stdout, "ran:echo@/x");
1493
+ assert.equal(host.execCalls.length, 1);
1494
+ });
1495
+
1496
+ it("defensively copies args so later mutation doesn't corrupt the record", async () => {
1497
+ const host = createRecordingHost();
1498
+ const args = ["status"];
1499
+ await host.exec("git", args, { cwd: "/x" });
1500
+ args.push("--mutated");
1501
+ assert.deepEqual(host.execCalls[0]?.args, ["status"]);
1502
+ });
1503
+ });
1504
+
1505
+ describe("mockExtensionContext", () => {
1506
+ it("exposes cwd + sessionManager.getEntries", () => {
1507
+ const entries = [
1508
+ {
1509
+ type: "custom" as const,
1510
+ customType: "seen",
1511
+ data: { n: 1 },
1512
+ timestamp: "2026-01-01T00:00:00.000Z",
1513
+ id: "e1",
1514
+ parentId: null,
1515
+ },
1516
+ ];
1517
+ const ctx = mockExtensionContext("/repo", entries);
1518
+ assert.equal(ctx.cwd, "/repo");
1519
+ assert.deepEqual(ctx.sessionManager.getEntries(), entries);
1520
+ });
1521
+
1522
+ it("defaults entries to an empty array when omitted", () => {
1523
+ const ctx = mockExtensionContext("/repo");
1524
+ assert.deepEqual(ctx.sessionManager.getEntries(), []);
1525
+ });
1526
+
1527
+ it("round-trips: host.appendEntry writes visible via ctx.sessionManager.getEntries", () => {
1528
+ // The core contract: feeding `host.entries` into the ctx lets
1529
+ // the engine's writes flow back into its subsequent reads.
1530
+ const host = createRecordingHost();
1531
+ const ctx = mockExtensionContext("/repo", host.entries);
1532
+
1533
+ assert.deepEqual(ctx.sessionManager.getEntries(), []);
1534
+ host.appendEntry("mark", { a: 1 });
1535
+ const read = ctx.sessionManager.getEntries();
1536
+ assert.equal(read.length, 1);
1537
+ const first = read[0];
1538
+ assert.ok(first && first.type === "custom");
1539
+ assert.equal(first.customType, "mark");
1540
+ assert.deepEqual(first.data, { a: 1 });
1541
+
1542
+ host.appendEntry("mark", { a: 2 });
1543
+ assert.equal(ctx.sessionManager.getEntries().length, 2);
1544
+ });
1545
+
1546
+ it("drives harness.evaluate + harness.dispatch end-to-end with a shared entries store", async () => {
1547
+ // Demonstrates the intended plugin-author usage pattern:
1548
+ // harness + recording host + shared ctx lets a test assert on
1549
+ // the cross-hook observer → evaluator handoff without any
1550
+ // `as any` escape hatches.
1551
+ const rule: Rule = {
1552
+ name: "needs-mark",
1553
+ tool: "bash",
1554
+ field: "command",
1555
+ pattern: /^git pu/,
1556
+ reason: "needs mark",
1557
+ noOverride: true,
1558
+ when: {
1559
+ // Default `when.happened` semantic: fires when the type has
1560
+ // NOT been written in the scope (ADR §5).
1561
+ happened: { event: "test-passed", in: "agent_loop" },
1562
+ },
1563
+ };
1564
+ const observer: Observer = {
1565
+ name: "test-tracker",
1566
+ watch: { toolName: "bash", inputMatches: { command: /^npm test/ } },
1567
+ onResult: (_evt, obsCtx) => {
1568
+ obsCtx.appendEntry("test-passed", {});
1569
+ },
1570
+ };
1571
+
1572
+ const host = createRecordingHost();
1573
+ const ctx = mockExtensionContext("/repo", host.entries);
1574
+ const harness = loadHarness({
1575
+ config: { rules: [rule], observers: [observer] },
1576
+ host,
1577
+ });
1578
+
1579
+ // Without a prior test-passed entry, the guarded command blocks.
1580
+ const blocked = await harness.evaluate(
1581
+ {
1582
+ type: "tool_call",
1583
+ toolCallId: "tc1",
1584
+ toolName: "bash",
1585
+ input: { command: "git pu origin feat/x" },
1586
+ } as unknown as Parameters<typeof harness.evaluate>[0],
1587
+ ctx,
1588
+ 0,
1589
+ );
1590
+ assert.ok(blocked && blocked.block === true);
1591
+
1592
+ // Dispatch an `npm test` success → observer records test-passed.
1593
+ await harness.dispatch(
1594
+ {
1595
+ type: "tool_result",
1596
+ toolCallId: "tc1",
1597
+ toolName: "bash",
1598
+ input: { command: "npm test" },
1599
+ content: [],
1600
+ details: { exitCode: 0 },
1601
+ } as unknown as Parameters<typeof harness.dispatch>[0],
1602
+ ctx,
1603
+ 0,
1604
+ );
1605
+ assert.ok(
1606
+ host.entries.some((e) => e.customType === "test-passed"),
1607
+ "observer should have recorded a test-passed entry",
1608
+ );
1609
+
1610
+ // Subsequent guarded command in the same agent loop passes.
1611
+ const allowed = await harness.evaluate(
1612
+ {
1613
+ type: "tool_call",
1614
+ toolCallId: "tc2",
1615
+ toolName: "bash",
1616
+ input: { command: "git pu origin feat/x" },
1617
+ } as unknown as Parameters<typeof harness.evaluate>[0],
1618
+ ctx,
1619
+ 0,
1620
+ );
1621
+ assert.equal(allowed, undefined);
1622
+ });
1623
+ });
1624
+
1625
+ // ---------------------------------------------------------------------------
1626
+ // priorEntry
1627
+ // ---------------------------------------------------------------------------
1628
+
1629
+ describe("priorEntry", () => {
1630
+ it("merges _agentLoopIndex into plain-object data", () => {
1631
+ const entry = priorEntry(
1632
+ "ws-sync-done",
1633
+ { note: "merged" },
1634
+ { agentLoopIndex: 5 },
1635
+ );
1636
+ assert.deepEqual(entry, {
1637
+ type: "custom",
1638
+ customType: "ws-sync-done",
1639
+ timestamp: "2026-01-01T00:00:00.000Z",
1640
+ data: { note: "merged", _agentLoopIndex: 5 },
1641
+ });
1642
+ });
1643
+
1644
+ it("wraps primitive data as { value, _agentLoopIndex }", () => {
1645
+ const entry = priorEntry("counter", 42, { agentLoopIndex: 2 });
1646
+ assert.deepEqual(entry.data, { value: 42, _agentLoopIndex: 2 });
1647
+ });
1648
+
1649
+ it("wraps array data as { value, _agentLoopIndex } (not merged)", () => {
1650
+ const entry = priorEntry("list", [1, 2, 3], { agentLoopIndex: 1 });
1651
+ assert.deepEqual(entry.data, { value: [1, 2, 3], _agentLoopIndex: 1 });
1652
+ });
1653
+
1654
+ it("wraps undefined data as { value: undefined, _agentLoopIndex }", () => {
1655
+ const entry = priorEntry("flag", undefined, { agentLoopIndex: 3 });
1656
+ assert.deepEqual(entry.data, { value: undefined, _agentLoopIndex: 3 });
1657
+ });
1658
+
1659
+ it("wraps null data as { value: null, _agentLoopIndex } (null is not a plain object)", () => {
1660
+ const entry = priorEntry("sentinel", null, { agentLoopIndex: 0 });
1661
+ assert.deepEqual(entry.data, { value: null, _agentLoopIndex: 0 });
1662
+ });
1663
+
1664
+ it("defaults agentLoopIndex to 0 when opts omitted", () => {
1665
+ const entry = priorEntry("marker", {});
1666
+ assert.deepEqual(entry.data, { _agentLoopIndex: 0 });
1667
+ assert.equal(entry.timestamp, "2026-01-01T00:00:00.000Z");
1668
+ assert.equal(entry.type, "custom");
1669
+ assert.equal(entry.customType, "marker");
1670
+ });
1671
+
1672
+ it("respects custom timestamp when provided", () => {
1673
+ const entry = priorEntry(
1674
+ "t",
1675
+ {},
1676
+ {
1677
+ agentLoopIndex: 0,
1678
+ timestamp: "2026-06-15T12:34:56.789Z",
1679
+ },
1680
+ );
1681
+ assert.equal(entry.timestamp, "2026-06-15T12:34:56.789Z");
1682
+ });
1683
+
1684
+ it("round-trip with mockContext: entry is visible to findEntries with scope tag intact", () => {
1685
+ // The entry should flow from `entries` through `findEntries` with
1686
+ // its data shape preserved, INCLUDING the _agentLoopIndex tag —
1687
+ // that's what lets the engine's agent_loop scope filter match it.
1688
+ const ctx = mockContext({
1689
+ agentLoopIndex: 5,
1690
+ entries: [priorEntry("ws-sync-done", {}, { agentLoopIndex: 5 })],
1691
+ });
1692
+ const hits = ctx.findEntries<{ _agentLoopIndex: number }>("ws-sync-done");
1693
+ assert.equal(hits.length, 1);
1694
+ assert.equal(hits[0]?.data._agentLoopIndex, 5);
1695
+ });
1696
+ });