@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,290 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Predicate context construction for the v2 evaluator.
6
+ *
7
+ * Two concerns live here because they collaborate tightly:
8
+ *
9
+ * 1. `createExecCache` — memoizes `exec(cmd, args, opts)` by
10
+ * `(cmd, args, cwd)` so every rule evaluated for ONE tool_call
11
+ * sees the same result for the same query, without re-running the
12
+ * underlying child process. A fresh cache is created per
13
+ * tool_call; cross-call results are never shared.
14
+ * 2. `createFindEntries` — wraps pi's `sessionManager.getEntries()`
15
+ * into the {@link PredicateContext.findEntries} shape, filtering
16
+ * to `type: "custom"` entries by `customType` and flattening to
17
+ * `{ data, timestamp }` (timestamps normalized from ISO strings to
18
+ * epoch ms, matching what observers producing entries can rely on).
19
+ *
20
+ * The evaluator itself assembles the final {@link PredicateContext}
21
+ * from these closures plus per-candidate fields (cwd / tool / input /
22
+ * agentLoopIndex) as an object literal — no helper needed once the shape
23
+ * is shared across bash and write/edit code paths.
24
+ *
25
+ * Kept internal (under `evaluator-internals/`) so consumers can swap
26
+ * the evaluator without inheriting its helper surface. The only
27
+ * re-export is through `../evaluator.ts`.
28
+ */
29
+
30
+ import type {
31
+ ExtensionAPI,
32
+ ExtensionContext,
33
+ ExecOptions as PiExecOptions,
34
+ ExecResult as PiExecResult,
35
+ } from "@earendil-works/pi-coding-agent";
36
+ import type { ExecOpts, ExecResult, PredicateContext } from "../schema.ts";
37
+
38
+ /**
39
+ * Narrow host surface the evaluator needs from the pi runtime. Lets
40
+ * tests pass a stub without building a full fake `ExtensionAPI`, and
41
+ * keeps the evaluator decoupled from the unrelated parts of pi's API
42
+ * (tool registration, slash commands, OAuth, …).
43
+ */
44
+ export interface EvaluatorHost {
45
+ /** See {@link ExtensionAPI.exec}. */
46
+ exec: ExtensionAPI["exec"];
47
+ /** See {@link ExtensionAPI.appendEntry}. */
48
+ appendEntry: ExtensionAPI["appendEntry"];
49
+ }
50
+
51
+ /**
52
+ * Key used by the per-tool-call exec cache. Null-byte separator is safe
53
+ * because neither a command path nor POSIX argv can legitimately contain
54
+ * a NUL byte; collisions are impossible in practice.
55
+ */
56
+ function execCacheKey(
57
+ cmd: string,
58
+ args: readonly string[],
59
+ cwd: string,
60
+ ): string {
61
+ return `${cmd}\x00${args.join("\x00")}\x00${cwd}`;
62
+ }
63
+
64
+ /**
65
+ * Bridge pi's `ExecResult` (uses `code`) to the schema's `ExecResult`
66
+ * (uses `exitCode`). Dropping `killed` is intentional — predicate
67
+ * authors don't need to distinguish "timed out" from "exited
68
+ * non-zero"; both surface as a non-zero exit for guardrail purposes.
69
+ */
70
+ function toSchemaExecResult(r: PiExecResult): ExecResult {
71
+ return { stdout: r.stdout, stderr: r.stderr, exitCode: r.code };
72
+ }
73
+
74
+ /**
75
+ * Create a tool_call-scoped exec function that memoizes by
76
+ * `(cmd, args, cwd)`. Caches only the PROMISE so concurrent
77
+ * predicate evaluations that hit the same key await the same
78
+ * in-flight child process — not N redundant ones.
79
+ *
80
+ * `opts.cwd` defaults to `sessionCwd` (mirroring how predicates see
81
+ * their "current cwd" via {@link PredicateContext.cwd}). `timeoutMs`
82
+ * is forwarded as `timeout`.
83
+ */
84
+ export function createExecCache(
85
+ host: EvaluatorHost,
86
+ sessionCwd: string,
87
+ ): PredicateContext["exec"] {
88
+ const cache = new Map<string, Promise<ExecResult>>();
89
+ return (cmd, args, opts) => {
90
+ const cwd = opts?.cwd ?? sessionCwd;
91
+ const key = execCacheKey(cmd, args, cwd);
92
+ const hit = cache.get(key);
93
+ if (hit !== undefined) return hit;
94
+ const piOpts: PiExecOptions = { cwd };
95
+ if (opts?.timeoutMs !== undefined) piOpts.timeout = opts.timeoutMs;
96
+ const p = host.exec(cmd, args, piOpts).then(toSchemaExecResult);
97
+ cache.set(key, p);
98
+ return p;
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Key under which the engine auto-injects the current `agentLoopIndex`
104
+ * into every entry written via `PredicateContext.appendEntry` or
105
+ * `ObserverContext.appendEntry`. Rules using
106
+ * `when.happened: { in: "agent_loop" }` filter session entries by
107
+ * comparing this key against `ctx.agentLoopIndex`.
108
+ *
109
+ * Part of the on-disk session-JSONL format, exposed as a public
110
+ * module-level constant. Re-exported from the package root
111
+ * so plugin authors who manually inspect entries via
112
+ * `findEntries` can import the constant by name rather than
113
+ * hardcoding the string — a future rename would then break at
114
+ * import time instead of silently producing un-filtered entries.
115
+ */
116
+ export const AGENT_LOOP_INDEX_KEY = "_agentLoopIndex" as const;
117
+
118
+ /**
119
+ * Narrow "is plain object" guard used to distinguish payloads that
120
+ * are safe to merge into (spread) from payloads that must be wrapped
121
+ * as `{ value, _agentLoopIndex }`.
122
+ *
123
+ * Anything that is NOT a plain object (arrays, Date, Map, Set, Error,
124
+ * class instances, functions, null, undefined, primitives) falls into
125
+ * the wrap branch. Direct `{...}` or `Object.create(null)` shapes fall
126
+ * into the merge branch.
127
+ *
128
+ * Two-stage detection:
129
+ * 1. `Object.prototype.toString.call(x)` returns `"[object Object]"`
130
+ * only for plain objects and for class instances of user-defined
131
+ * classes. It correctly excludes arrays, Date, Map, Set, Error,
132
+ * etc.
133
+ * 2. A prototype check then rejects user-class instances: plain
134
+ * objects have `Object.prototype` (or `null` for
135
+ * `Object.create(null)`) as their prototype; `new Box(...)` has
136
+ * `Box.prototype`, which is neither.
137
+ *
138
+ * The explicit `Array.isArray` check is belt-and-suspenders — some
139
+ * runtimes have historically misreported arrays via `toString`, and
140
+ * the array case is the one most likely to hit this wrapper (an
141
+ * observer appending a list of watched paths). Cheap to check twice.
142
+ */
143
+ export function isPlainObject(x: unknown): x is Record<string, unknown> {
144
+ if (x === null || typeof x !== "object") return false;
145
+ if (Array.isArray(x)) return false;
146
+ if (Object.prototype.toString.call(x) !== "[object Object]") return false;
147
+ const proto = Object.getPrototypeOf(x);
148
+ if (proto !== null && proto !== Object.prototype) return false;
149
+ return true;
150
+ }
151
+
152
+ /**
153
+ * Wrap a raw `host.appendEntry` so every write auto-injects the
154
+ * current `agentLoopIndex` into the payload. Plain-object payloads
155
+ * get the field merged in; everything else — primitives, arrays,
156
+ * `Date`, `Map`, `Set`, `Error`, class instances, functions, null,
157
+ * undefined — is wrapped as `{ value, _agentLoopIndex }` so
158
+ * downstream consumers always see a consistent object shape.
159
+ *
160
+ * The "everything else" branch exists because the naive spread
161
+ * (`{ ...data, ... }`) silently corrupts non-plain objects: arrays
162
+ * become pseudo-objects with string-indexed keys, Date / Map / Set /
163
+ * Error instances lose their internal state entirely, etc. Wrapping
164
+ * under `value` preserves the original reference unchanged.
165
+ *
166
+ * The returned closure matches both {@link PredicateContext.appendEntry}
167
+ * and {@link ObserverContext.appendEntry} so the evaluator and the
168
+ * observer dispatcher share one wrapper.
169
+ *
170
+ * `findEntriesCache` (optional) is a cache map shared with a sibling
171
+ * {@link createFindEntries} closure. When supplied, every `appendEntry`
172
+ * call invalidates the cache entry for the written `customType` so
173
+ * the next `findEntries(customType)` re-reads the session JSONL and
174
+ * sees the newly-written entry (S2/E1; see ../INVARIANTS.md for the
175
+ * S/E tag glossary). Omit the parameter to keep
176
+ * the pre-S2 behaviour (no invalidation) — handy for tests or callers
177
+ * that don't pair the two closures.
178
+ */
179
+ export function createAppendEntry(
180
+ host: EvaluatorHost,
181
+ agentLoopIndex: number,
182
+ findEntriesCache?: Map<string, Array<{ data: unknown; timestamp: number }>>,
183
+ ): PredicateContext["appendEntry"] {
184
+ return <T>(customType: string, data?: T) => {
185
+ const tagged = isPlainObject(data)
186
+ ? { ...data, [AGENT_LOOP_INDEX_KEY]: agentLoopIndex }
187
+ : { value: data, [AGENT_LOOP_INDEX_KEY]: agentLoopIndex };
188
+ host.appendEntry(customType, tagged);
189
+ // S2/E1: drop the cached read for this customType so a later
190
+ // `findEntries(customType)` call from the same phase re-materializes
191
+ // the list and sees the write we just made. Without this, a rule's
192
+ // `onFire` that writes + a later rule's `when.happened` that reads
193
+ // see inconsistent snapshots within one tool_call.
194
+ findEntriesCache?.delete(customType);
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Adapt pi's `sessionManager.getEntries()` into the typed-and-filtered
200
+ * view predicates (and observers) expect.
201
+ *
202
+ * Strategy:
203
+ * - pick only `type: "custom"` entries (the shape `pi.appendEntry`
204
+ * produces — see `CustomEntry` in pi's session-manager),
205
+ * - filter by `customType`,
206
+ * - project to `{ data, timestamp }` where `timestamp` is epoch-ms
207
+ * (parsed from the entry's ISO string). Epoch-ms is what turn-state
208
+ * checks want for chronological comparisons without having to
209
+ * re-parse.
210
+ *
211
+ * Results are memoized PER invocation of `createFindEntries` by
212
+ * customType. The evaluator rebuilds the closure on every tool_call;
213
+ * the observer dispatcher rebuilds on every tool_result. So each phase
214
+ * sees a consistent snapshot across reads.
215
+ *
216
+ * Cross-rule write visibility (S2/E1): when the same phase also uses
217
+ * a paired {@link createAppendEntry} with the SAME cache map, a write
218
+ * during rule A's `onFire` invalidates the cached read for that
219
+ * customType so rule B's `when.happened` predicate sees the fresh
220
+ * entry. Callers that want this consistency pass in a shared cache
221
+ * via the optional `cache` parameter; callers that omit it get the
222
+ * old per-closure snapshot behaviour (pre-S2), which is sound only
223
+ * when the closure never interleaves reads with writes.
224
+ *
225
+ * The `ctx` argument is the pi `ExtensionContext` — we re-read
226
+ * `getEntries()` only on a cache miss. Cache keys are per-closure (or
227
+ * per shared cache) so cross-tool_call or cross-tool_result reads
228
+ * always see the freshest state (a new closure = a new cache).
229
+ */
230
+ export function createFindEntries(
231
+ ctx: ExtensionContext,
232
+ cache?: Map<string, Array<{ data: unknown; timestamp: number }>>,
233
+ ): PredicateContext["findEntries"] {
234
+ const entryCache =
235
+ cache ?? new Map<string, Array<{ data: unknown; timestamp: number }>>();
236
+ return <T>(customType: string) => {
237
+ const hit = entryCache.get(customType);
238
+ if (hit !== undefined) {
239
+ return hit as Array<{ data: T; timestamp: number }>;
240
+ }
241
+ const out: Array<{ data: T; timestamp: number }> = [];
242
+ for (const entry of ctx.sessionManager.getEntries()) {
243
+ if (entry.type !== "custom") continue;
244
+ if (entry.customType !== customType) continue;
245
+ const ts = Date.parse(entry.timestamp);
246
+ out.push({
247
+ data: entry.data as T,
248
+ timestamp: Number.isNaN(ts) ? 0 : ts,
249
+ });
250
+ }
251
+ entryCache.set(
252
+ customType,
253
+ out as Array<{ data: unknown; timestamp: number }>,
254
+ );
255
+ return out;
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Allocate a fresh session-entry cache shared between a paired
261
+ * {@link createFindEntries} + {@link createAppendEntry} for the same
262
+ * tool_call (evaluator) or tool_result (observer dispatcher) phase.
263
+ *
264
+ * Using a shared cache gives two guarantees the evaluator + dispatcher
265
+ * rely on:
266
+ *
267
+ * 1. Consistent reads: N calls to `findEntries(type)` within one
268
+ * phase materialize the entry list ONCE per type.
269
+ * 2. Write-through-reads (S2/E1): a write via the paired
270
+ * `appendEntry` invalidates that type's cached list, so the next
271
+ * read re-scans the session JSONL and observes the write. Without
272
+ * this, a rule's `onFire` appending X followed by a later rule's
273
+ * `when.happened: { event: X }` would read a stale pre-write
274
+ * snapshot.
275
+ *
276
+ * Consumers who don't need write-through-reads (tests, one-shot
277
+ * `findEntries` calls) can pass a fresh cache or omit the parameter
278
+ * on both constructors — the closures then each get their own cache
279
+ * map and behave like the pre-S2 implementation.
280
+ */
281
+ export function createSessionEntryCache(): Map<
282
+ string,
283
+ Array<{ data: unknown; timestamp: number }>
284
+ > {
285
+ return new Map<string, Array<{ data: unknown; timestamp: number }>>();
286
+ }
287
+
288
+ // Silence a re-import of ExecOpts that older linters flag (we only use
289
+ // the type via PredicateContext["exec"] signature above).
290
+ export type { ExecOpts };
@@ -0,0 +1,99 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Unit tests for `extractOverride` — the inline override-comment
6
+ * detector the v2 evaluator uses to surface `# steering-override:
7
+ * <rule> — <reason>` annotations on bash / write / edit inputs.
8
+ *
9
+ * Ported from the v1 suite in `../../evaluator.test.ts` (the
10
+ * `describe("extractOverride", ...)` block) so the v2 module is
11
+ * directly pinned against the v1 behavior it claims parity with.
12
+ * Phase 3c deletes v1; these tests preserve every documented regex
13
+ * edge case independently of the evaluator pipeline.
14
+ *
15
+ * Each test targets one axis of the override grammar (leader
16
+ * character, separator character, empty reason, stacked overrides,
17
+ * mismatched rule name, …). Kept as a flat `describe` block to match
18
+ * the v1 shape for diff-friendly porting.
19
+ */
20
+
21
+ import assert from "node:assert/strict";
22
+ import { describe, it } from "node:test";
23
+ import { extractOverride } from "./override.ts";
24
+
25
+ describe("extractOverride", () => {
26
+ it("extracts reason from a hash-leader override", () => {
27
+ const r = extractOverride(
28
+ "git push --force # steering-override: no-force-push \u2014 coordinated rewrite",
29
+ "no-force-push",
30
+ );
31
+ assert.equal(r, "coordinated rewrite");
32
+ });
33
+
34
+ it("extracts reason from a slash-leader override", () => {
35
+ const r = extractOverride(
36
+ "// steering-override: no-console \u2014 debug session only",
37
+ "no-console",
38
+ );
39
+ assert.equal(r, "debug session only");
40
+ });
41
+
42
+ it("accepts em dash, en dash, and hyphen as separators", () => {
43
+ assert.equal(
44
+ extractOverride("# steering-override: r \u2014 em", "r"),
45
+ "em",
46
+ );
47
+ assert.equal(
48
+ extractOverride("# steering-override: r \u2013 en", "r"),
49
+ "en",
50
+ );
51
+ assert.equal(
52
+ extractOverride("# steering-override: r - hyphen", "r"),
53
+ "hyphen",
54
+ );
55
+ });
56
+
57
+ it("returns null when no override is present", () => {
58
+ assert.equal(extractOverride("git push --force", "no-force-push"), null);
59
+ });
60
+
61
+ it("returns null when the override targets a different rule", () => {
62
+ const r = extractOverride(
63
+ "# steering-override: other-rule \u2014 reason",
64
+ "no-force-push",
65
+ );
66
+ assert.equal(r, null);
67
+ });
68
+
69
+ it("returns null when reason is empty", () => {
70
+ const r = extractOverride("# steering-override: r \u2014 ", "r");
71
+ assert.equal(r, null);
72
+ });
73
+
74
+ it("stacked overrides: looking up first rule returns its reason only", () => {
75
+ const text =
76
+ "cmd # steering-override: rule-a \u2014 reason-a # steering-override: rule-b \u2014 reason-b";
77
+ assert.equal(extractOverride(text, "rule-a"), "reason-a");
78
+ });
79
+
80
+ it("stacked overrides: looking up second rule returns its reason only", () => {
81
+ const text =
82
+ "cmd # steering-override: rule-a \u2014 reason-a # steering-override: rule-b \u2014 reason-b";
83
+ assert.equal(extractOverride(text, "rule-b"), "reason-b");
84
+ });
85
+
86
+ it("stacked overrides: unrelated lookup returns null (no bleed from either)", () => {
87
+ const text =
88
+ "cmd # steering-override: rule-a \u2014 reason-a # steering-override: rule-b \u2014 reason-b";
89
+ assert.equal(extractOverride(text, "rule-c"), null);
90
+ });
91
+
92
+ it("stacked overrides: empty reason on first is skipped, scanner finds second match for same rule", () => {
93
+ // First `foo` override has no reason (whitespace only). The scanner
94
+ // must keep going and surface the second `foo` override's reason.
95
+ const text =
96
+ "# steering-override: foo \u2014 # steering-override: foo \u2014 actual reason";
97
+ assert.equal(extractOverride(text, "foo"), "actual reason");
98
+ });
99
+ });
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Inline override-comment detection for the v2 evaluator.
6
+ *
7
+ * Ported verbatim from the v1 evaluator's `extractOverride` (see
8
+ * `../../evaluator.ts`). Syntax and behaviour match exactly so the
9
+ * extension runtime can swap from v1 to v2 without users rewriting
10
+ * their override comments. See the v1 evaluator's JSDoc for the full
11
+ * syntax grammar.
12
+ *
13
+ * Exposed as a standalone module so the evaluator and its tests can
14
+ * assert override detection independently of the rest of the pipeline.
15
+ */
16
+
17
+ /**
18
+ * Global-flag regex matching every override comment in a text blob.
19
+ *
20
+ * leader: `#`, `//`, `/*`, `<!--`, `--`, `%%`, `;;`
21
+ * key: `steering-override:`
22
+ * name: `[A-Za-z0-9_-]+`
23
+ * sep: `—` (em dash), `–` (en dash), or `-` (hyphen)
24
+ * reason: anything up to `*\u002f`, `-->`, or end of line
25
+ */
26
+ const OVERRIDE_RE =
27
+ /(?:#|\/\/|\/\*|<!--|--|%%|;;)\s*steering-override:\s*([A-Za-z0-9_-]+)\s*[\u2014\u2013-]\s*(.*?)(?:\*\/|-->|$)/gm;
28
+
29
+ /**
30
+ * Shorter marker-only regex used to detect when the lazy reason capture
31
+ * in {@link OVERRIDE_RE} swallowed a subsequent override marker. When the
32
+ * scanner finds one, it trims the reason at the marker and rewinds so
33
+ * the next iteration parses the subsequent override cleanly.
34
+ */
35
+ const OVERRIDE_MARKER_RE = /(?:#|\/\/|\/\*|<!--|--|%%|;;)\s*steering-override:/;
36
+
37
+ /**
38
+ * Extract an inline override-comment's reason that targets a specific
39
+ * rule name. Returns the trimmed reason, or null when no override
40
+ * addressed to `ruleName` is present (empty reasons are treated as
41
+ * "no override" — users must supply an explicit justification).
42
+ *
43
+ * Behaviour parity with v1 `extractOverride`:
44
+ *
45
+ * - case-sensitive rule-name match,
46
+ * - supports stacked overrides on one line (e.g.
47
+ * `cmd # steering-override: a — r1 # steering-override: b — r2`),
48
+ * - first-match-wins across the scanned comments,
49
+ * - all leader styles from v1 accepted.
50
+ *
51
+ * The v1 implementation is the authoritative reference for edge cases
52
+ * — see `evaluator.ts` in this package's v1 tree.
53
+ */
54
+ export function extractOverride(text: string, ruleName: string): string | null {
55
+ // Reset `lastIndex` — the regex is module-scoped and shared across
56
+ // calls. Without the reset two back-to-back calls with different
57
+ // texts would pick up mid-string from the previous scan.
58
+ OVERRIDE_RE.lastIndex = 0;
59
+ let m = OVERRIDE_RE.exec(text);
60
+ while (m !== null) {
61
+ let reason = m[2] ?? "";
62
+ // Compute where the reason capture started in the source so we
63
+ // can rewind the scanner if we trim a trailing next-override
64
+ // marker out of the lazy capture.
65
+ const reasonStart = (m.index ?? 0) + m[0].length - reason.length;
66
+ const nextIdx = reason.search(OVERRIDE_MARKER_RE);
67
+ if (nextIdx !== -1) {
68
+ reason = reason.slice(0, nextIdx);
69
+ OVERRIDE_RE.lastIndex = reasonStart + nextIdx;
70
+ }
71
+ if (m[1] !== ruleName) {
72
+ m = OVERRIDE_RE.exec(text);
73
+ continue;
74
+ }
75
+ reason = reason.trim();
76
+ if (reason === "") {
77
+ m = OVERRIDE_RE.exec(text);
78
+ continue;
79
+ }
80
+ return reason;
81
+ }
82
+ return null;
83
+ }