@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,62 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * `push-requires-tests` — example rule.
6
+ *
7
+ * Demonstrates three v0.1.0 engine features in one rule:
8
+ *
9
+ * 1. Observer → rule coupling via shared constants (ADR §5, §14).
10
+ * `npm-test-tracker` writes `TEST_PASSED_EVENT` on every
11
+ * successful `npm test`; this rule gates `git push` on
12
+ * `when.happened`, which fires when the event has NOT happened.
13
+ *
14
+ * 2. Temporal invalidation via `since` (PR §4). A separate observer
15
+ * `retest-required-tracker` writes `RETEST_REQUIRED_EVENT` on
16
+ * every `git pull`. Even if tests passed earlier in the loop, a
17
+ * subsequent pull stale-s the test state — the rule fires again,
18
+ * forcing a re-run against the updated tree.
19
+ *
20
+ * 3. `&&`-chain speculative allow (PR §4). Because the engine
21
+ * sees `npm-test-tracker`'s `writes: [TEST_PASSED_EVENT]`, it
22
+ * treats `npm test && git push` as safe: the push is gated on
23
+ * the prior `&&` ref, and `&&` short-circuits on test failure.
24
+ * This breaks the "block → agent retries same chain → block"
25
+ * loop without weakening the guardrail for non-chained pushes.
26
+ *
27
+ * `in: "agent_loop"` scopes everything to the current user prompt +
28
+ * its tool calls. Running tests in a prior agent loop doesn't let
29
+ * you push in this one.
30
+ *
31
+ * Override: disallowed. Pushing without proof of green tests is an
32
+ * inherent-risk action here.
33
+ */
34
+
35
+ import type { Rule } from "@cad0p/pi-steering";
36
+ import { TEST_PASSED_EVENT } from "../observers/npm-test-tracker.ts";
37
+ import { RETEST_REQUIRED_EVENT } from "../observers/retest-required-tracker.ts";
38
+
39
+ export const pushRequiresTests = {
40
+ name: "push-requires-tests",
41
+ tool: "bash",
42
+ field: "command",
43
+ pattern: /^git\s+push\b/,
44
+ when: {
45
+ // Fires when TEST_PASSED_EVENT has NOT been written in the
46
+ // current agent loop, OR its most-recent entry is older than
47
+ // the most-recent RETEST_REQUIRED_EVENT (e.g. a later `git pull`
48
+ // stale-d the test state).
49
+ happened: {
50
+ event: TEST_PASSED_EVENT,
51
+ in: "agent_loop",
52
+ since: RETEST_REQUIRED_EVENT,
53
+ },
54
+ },
55
+ reason:
56
+ "Run `npm test` successfully in this agent loop before pushing. " +
57
+ "If you ran `git pull` after the last test, re-run tests.",
58
+ noOverride: true,
59
+ // Declaring the observer's write-type in our `writes` is NOT
60
+ // required — the observer already declares it. This rule doesn't
61
+ // write anything, so no `writes` here.
62
+ } as const satisfies Rule;
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "@cad0p/pi-steering",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "AST-backed steering hooks for pi \u2014 deterministic tool-call guardrails with command-level effective-cwd scoping.",
6
+ "keywords": [
7
+ "pi-package",
8
+ "extension",
9
+ "steering",
10
+ "guardrails",
11
+ "safety",
12
+ "hooks",
13
+ "ast",
14
+ "bash"
15
+ ],
16
+ "type": "module",
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "bin": {
20
+ "pi-steering": "./dist/bin/pi-steering.js"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ },
27
+ "./plugins/git": {
28
+ "types": "./dist/plugins/git/index.d.ts",
29
+ "import": "./dist/plugins/git/index.js"
30
+ },
31
+ "./testing": {
32
+ "types": "./dist/testing/index.d.ts",
33
+ "import": "./dist/testing/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "examples",
40
+ "skills",
41
+ "README.md",
42
+ "CHANGELOG.md",
43
+ "LICENSE"
44
+ ],
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/cad0p/pi-steering.git"
48
+ },
49
+ "license": "MIT",
50
+ "pi": {
51
+ "extensions": [
52
+ "./src/index.ts"
53
+ ],
54
+ "skills": [
55
+ "./skills"
56
+ ]
57
+ },
58
+ "engines": {
59
+ "node": ">=22"
60
+ },
61
+ "scripts": {
62
+ "clean": "rm -rf dist tsconfig.build.tsbuildinfo",
63
+ "build": "tsc -p tsconfig.build.json",
64
+ "prepare": "pnpm build",
65
+ "test": "node --test --experimental-strip-types 'src/**/*.test.ts'",
66
+ "test:examples": "pnpm -r test",
67
+ "typecheck": "tsc --noEmit",
68
+ "typecheck:examples": "pnpm -r typecheck",
69
+ "lint": "biome check src/ examples/ skills/ scripts/",
70
+ "lint:fix": "biome check --write src/ examples/ skills/ scripts/",
71
+ "smoke": "node scripts/smoke.mjs"
72
+ },
73
+ "dependencies": {
74
+ "unbash": "^3.0.0",
75
+ "@cad0p/unbash-walker": "^0.1.0"
76
+ },
77
+ "peerDependencies": {
78
+ "@earendil-works/pi-coding-agent": "*"
79
+ },
80
+ "devDependencies": {
81
+ "@biomejs/biome": "^2.3.14",
82
+ "@types/node": "^20.19.39",
83
+ "typescript": "^5.4.5"
84
+ },
85
+ "publishConfig": {
86
+ "access": "public"
87
+ }
88
+ }
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: steering-authoring
3
+ description: Author declarative steering rules for pi-steering. Use when the user asks to block or allow agent tool calls, write guardrails for bash/write/edit, author pi steering rules, add rule plugins, or convert a JSON steering config to the TypeScript config.
4
+ ---
5
+
6
+ # pi-steering
7
+
8
+ You have `@cad0p/pi-steering` installed. It blocks and allows agent tool calls (bash, write, edit) via declarative rules authored in TypeScript.
9
+
10
+ ## Where things live
11
+
12
+ - Rules: `.pi/steering/index.ts` (directory form) or `.pi/steering.ts` (single-file form).
13
+ - Local plugins: `.pi/steering/plugins/*.ts`, imported into `index.ts`.
14
+ - Tests: `.pi/steering/*.test.ts` using `@cad0p/pi-steering/testing`.
15
+
16
+ The loader walks up from `cwd` to the nearest `.pi/` dir, falling back to `~/.pi/`. See the README for the full precedence order.
17
+
18
+ ## Common operations
19
+
20
+ | User says | You do |
21
+ |---|---|
22
+ | "block X" | Add a `Rule` with `tool`, `field`, and `pattern` to the `rules` array. |
23
+ | "block X only in dir Y" | Add `when: { cwd: /Y/ }` to the rule. The walker resolves `cd "$WS/pkg"`-style dynamic targets through `envTracker`; intractable targets (unknown `$VAR`, command substitution) surface as `"unknown"` and fire `onUnknown: "block"` by default. |
24
+ | "block X unless on branch Z" | `when: { not: { branch: /Z/ } }` — requires the git plugin. |
25
+ | "block X unless `--flag`" | `unless: /--flag\b/`. |
26
+ | "require Y before X" | Observer that `appendEntry`s a marker, plus a rule whose `when.happened` gates on it (`{ event, in: "agent_loop" }`). Prefer this to hand-rolled `findEntries` + `agentLoopIndex` comparisons — same semantics, less code. |
27
+ | "require Y in a **prior** tool_call, not same-chain" | `when: { happened: { event, in: "agent_loop", notIn: "tool_call" } }`. `notIn` is scope subtraction — it removes the narrower scope from the broader one, so `&&`-chain bypass is blocked. Distinct from clause-level `not` (boolean negation). |
28
+ | "invalidate Y when Z happens" | `when: { happened: { event: Y_EVENT, in: "agent_loop", since: Z_EVENT } }`. Y only counts if its latest entry is newer than Z's latest entry in scope. If Z never happened, the clause degrades to a simple presence check. |
29
+ | "put runtime state in the block message" | `reason: (ctx) => \`blocked at \${ctx.walkerState?.cwd}\`` — `Rule.reason` accepts a sync or async function. The engine awaits it and prefixes with `[steering:name@source]` like a string reason. If it throws, a fail-safe fallback text fires and the error is logged to `console.warn`; the block still fires. |
30
+ | "add a custom check" | Write a plugin in `.pi/steering/plugins/`, import it into `index.ts`, register it in `plugins: [...]`. |
31
+ | "change the reason on a built-in rule" | Import the original rule from its plugin, spread it with `{ ...original, name: "new-name", reason: "..." }`, and use `disabledRules: ["original-name"]` + add the replacement. Preserves pattern / when / observer. |
32
+ | "test this rule" | Create `steering.test.ts` using `expectBlocks` / `expectAllows` / `loadHarness`. |
33
+ | "convert my JSON config to TypeScript" | Run `pi-steering import-json .pi/steering.json -o .pi/steering.ts`. Plugins, observers, and function predicates don't round-trip — author those directly in TS. |
34
+ | "publish a pi-steering plugin" | Package as `pi-steering-<domain>` (unscoped) with `keywords: ["pi-package", "pi-steering-package"]` in package.json. peerDep on `@cad0p/pi-steering`. |
35
+
36
+ ## Minimal config
37
+
38
+ ```ts
39
+ import { defineConfig } from "@cad0p/pi-steering";
40
+
41
+ export default defineConfig({
42
+ rules: [
43
+ {
44
+ name: "no-dangerous-command",
45
+ tool: "bash",
46
+ field: "command",
47
+ pattern: /^dangerous-command\b/,
48
+ reason: "don't run this",
49
+ },
50
+ ],
51
+ });
52
+ ```
53
+
54
+ `DEFAULT_RULES` (e.g. `no-force-push`, `no-rm-rf-slash`) are included automatically. `DEFAULT_PLUGINS` is empty by design — plugins are opt-in (declare `plugins: [gitPlugin]`), which keeps `defineConfig`'s type unions (typo-checking on `disabledRules` / `disabledPlugins`) in sync with what's actually loaded. Disable defaults via `disabledRules: ["name"]` or opt out entirely with `disableDefaults: true`.
55
+
56
+ ## Git plugin (branch / upstream / commits-ahead predicates)
57
+
58
+ ```ts
59
+ import { defineConfig } from "@cad0p/pi-steering";
60
+ import gitPlugin from "@cad0p/pi-steering/plugins/git";
61
+
62
+ export default defineConfig({
63
+ plugins: [gitPlugin],
64
+ rules: [
65
+ {
66
+ name: "no-main-push",
67
+ tool: "bash",
68
+ field: "command",
69
+ pattern: /^git\s+push\b/,
70
+ when: { branch: /^(main|master|mainline|trunk)$/ },
71
+ reason: "don't push from main",
72
+ },
73
+ ],
74
+ });
75
+ ```
76
+
77
+ ## Testing
78
+
79
+ ```ts
80
+ // .pi/steering/steering.test.ts
81
+ import { describe, it } from "node:test";
82
+ import { expectAllows, expectBlocks, loadHarness } from "@cad0p/pi-steering/testing";
83
+ import config from "./index.ts";
84
+
85
+ describe("my steering config", () => {
86
+ const harness = loadHarness({ config, includeDefaults: true });
87
+
88
+ it("blocks dangerous-command", async () => {
89
+ await expectBlocks(harness, { command: "dangerous-command run" });
90
+ });
91
+
92
+ it("allows safe commands", async () => {
93
+ await expectAllows(harness, { command: "ls" });
94
+ });
95
+ });
96
+ ```
97
+
98
+ Run with `node --test --experimental-strip-types '.pi/steering/**/*.test.ts'`.
99
+
100
+ ## Details
101
+
102
+ Read [the package README](../../README.md) for the full schema, plugin authoring, observer / turn-state patterns, override semantics, and the JSON-to-TS conversion surface.
@@ -0,0 +1,116 @@
1
+ # Internal invariants — `S`, `E`, and `O` tags
2
+
3
+ Short reference for the load-bearing invariants flagged across the
4
+ engine source. These are NOT a public API contract — they are
5
+ maintainer shorthand to keep related call sites traceable across
6
+ files. Source comments cite the tag and rely on this file for the
7
+ definition.
8
+
9
+ This file ships with the package as a maintainer-facing glossary;
10
+ it is not part of the public API surface (no exports), but is
11
+ included in the npm tarball for source-readers consulting tagged
12
+ call sites on disk.
13
+
14
+ ## Safety invariants (`S`)
15
+
16
+ The engine evaluates user-authored steering rules against
17
+ LLM-proposed tool calls. The `S` invariants are the layered defenses
18
+ that keep a buggy or malformed plugin / rule from silently
19
+ failing OPEN the gate.
20
+
21
+ ### `S1` — fail-closed isolation
22
+
23
+ **Where:** `evaluator.ts` (`evaluateEvent` top-level wrap; per-rule
24
+ try/catch), `evaluator-internals/predicates.ts` (per-predicate
25
+ try/catch in `runPredicateChain`).
26
+
27
+ A predicate that throws — built-in or plugin-supplied, sync or async
28
+ — is treated as "rule does not fire", logged via `console.warn` with
29
+ the rule name + `@<source>` tag + key, and evaluation continues with
30
+ the next rule. The top-level wrap in `evaluator.ts` is the outermost
31
+ catch: if the engine's own scaffolding throws (parse errors, walker
32
+ bugs, corrupted session JSONL), the tool is BLOCKED with an
33
+ engine-tagged reason so the agent sees the throw came from the
34
+ engine, not from a rule.
35
+
36
+ The same pattern applies to observers in `observer-dispatcher.ts` —
37
+ a throwing observer is isolated to its own dispatch and never
38
+ escalates.
39
+
40
+ ### `S2` — write-through-read consistency
41
+
42
+ **Where:** `evaluator-internals/context.ts` (`createAppendEntry` /
43
+ `createFindEntries` paired-cache invariant).
44
+
45
+ When `createAppendEntry` and `createFindEntries` share the same
46
+ cache map (the evaluator wires them this way per tool_call; the
47
+ observer-dispatcher wires them per tool_result),
48
+ `createAppendEntry` invalidates the cache entry for the written
49
+ `customType` so the next paired `createFindEntries` call re-reads.
50
+ Callers omitting the shared cache get per-closure snapshot behavior.
51
+
52
+ ### `S3` — name validation
53
+
54
+ **Where:** `plugin-merger.ts` (`validateName`; production pipeline
55
+ via `validateUserConfigNames` + `resolvePlugins`), `evaluator.ts`
56
+ (defensive throw in `buildEvaluator`), `observer-dispatcher.ts`
57
+ (defensive throw in `buildObserverDispatcher`).
58
+
59
+ Rule / plugin / observer names flow into user-visible strings — the
60
+ `[steering:<name>@<source>]` block-reason tag shown to the LLM, the
61
+ `@<source>` tag in warning logs, override-comment target matching,
62
+ `disabledRules` / `disabledPlugins` config references. Names
63
+ containing whitespace, control characters, `]`, or newlines let a
64
+ malicious or careless config author forge block reasons that
65
+ deceive the agent.
66
+
67
+ Validated at production call sites (`validateUserConfigNames`,
68
+ `resolvePlugins`) and as defense-in-depth at `buildEvaluator` /
69
+ `buildObserverDispatcher` — see per-site JSDocs.
70
+
71
+ ## Evaluation invariants (`E`)
72
+
73
+ ### `E1` — cross-rule write visibility within a phase
74
+
75
+ **Where:** paired-cache wiring in `evaluator.ts` and
76
+ `observer-dispatcher.ts`; cache mechanism in
77
+ `evaluator-internals/context.ts`.
78
+
79
+ Within a single tool_call (or tool_result) phase, rule B's
80
+ `when.happened` predicate MUST see entries rule A's `onFire` wrote
81
+ earlier in the same phase. Implementation: shared cache invalidation
82
+ per `S2`.
83
+
84
+ ## Orchestration invariants (`O`)
85
+
86
+ ### `O1` — observer-drop parity between runtime and CLI
87
+
88
+ **Where:** `internal/session-runtime.ts` (`buildSessionRuntime` →
89
+ `finalizePluginState`); `bin/pi-steering.ts`
90
+ (`runCliMergeWithInfoCapture`).
91
+
92
+ **What:** Both surfaces apply `disabledRules` filtering BEFORE
93
+ running `dropUnusedObservers`, so an observer whose only consumers
94
+ are disabled rules surfaces the same `console.info` breadcrumb in
95
+ both paths. A future surface that bypasses this ordering would see
96
+ different observer-drop behavior than the runtime.
97
+
98
+ **Pinned by:** `internal/session-runtime.test.ts` (runtime branch);
99
+ `bin/pi-steering.test.ts` (CLI branch).
100
+
101
+ ### `O2` — single-emission lock for cross-detector tracker-name collisions
102
+
103
+ **Where:** `internal/session-runtime.ts` (`runMergerPipeline`);
104
+ `loader.ts` (`detectTrackerNameCollisions`); `plugin-merger.ts`
105
+ (`resolvePlugins`).
106
+
107
+ **What:** Both `buildConfig` (loader-side) and `resolvePlugins`
108
+ (merger-side) independently detect tracker-name collisions.
109
+ `runMergerPipeline` short-circuits before invoking `resolvePlugins`
110
+ when any merge-side diagnostic is error-class, so the aggregated
111
+ error message lists each tracker-name collision exactly once.
112
+
113
+ **Pinned by:** `internal/session-runtime.test.ts` "throws on an
114
+ error-class diagnostic regardless of failOnWarnings" (single-emission
115
+ lock); `factory-time-load.test.ts` "throws on tracker-name-collision"
116
+ (integration mirror).
@@ -0,0 +1,243 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Shared test-double helpers.
6
+ *
7
+ * `evaluator.test.ts` and `observer-dispatcher.test.ts` both need:
8
+ *
9
+ * - a minimal {@link ExtensionContext} stub whose `sessionManager`
10
+ * only exposes `getEntries()` (everything else throws on access),
11
+ * - a "tracked host" {@link EvaluatorHost} that records every
12
+ * `exec` / `appendEntry` call plus pushes `appendEntry` payloads
13
+ * into an entries array shaped like the pi session JSONL, so the
14
+ * same array can back a `makeCtx` stub and let tests assert
15
+ * cross-handler `findEntries` visibility.
16
+ *
17
+ * The two former copies diverged only in whether `makeHost` accepted an
18
+ * `exec` override (evaluator tests need it to count child-process
19
+ * invocations for the memoization assertions; observer tests don't).
20
+ * That's now a single option on the unified helper.
21
+ *
22
+ * Kept OUT of the public surface: `__test-helpers__` is a leading-double-
23
+ * underscore convention indicating "test only"; nothing under `src/`
24
+ * imports it at runtime.
25
+ */
26
+
27
+ import {
28
+ mkdirSync,
29
+ mkdtempSync,
30
+ realpathSync,
31
+ rmSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import { tmpdir } from "node:os";
35
+ import { join } from "node:path";
36
+ import { afterEach, beforeEach } from "node:test";
37
+ import type {
38
+ ExtensionContext,
39
+ ExecResult as PiExecResult,
40
+ } from "@earendil-works/pi-coding-agent";
41
+ import type { EvaluatorHost } from "./evaluator-internals/context.ts";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Isolated $HOME fixture
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Per-test scratch `$HOME` fixture. Registers `beforeEach` /
49
+ * `afterEach` that:
50
+ *
51
+ * - `mkdtempSync` a fresh temp dir using `prefix`,
52
+ * - save `process.env["HOME"]`, point it at the temp dir,
53
+ * - restore `process.env["HOME"]` and recursively remove the temp
54
+ * dir on teardown.
55
+ *
56
+ * Used by every test surface that exercises the loader walk-up
57
+ * (`index.test.ts`, `loader.test.ts`, `internal/session-runtime.test.ts`)
58
+ * so the per-file scratch-HOME boilerplate stays in one place.
59
+ *
60
+ * The temp dir path is exposed via the optional `onReady` callback,
61
+ * fired inside `beforeEach`; tests typically stash it in a
62
+ * describe-scoped `let` for terser reads.
63
+ */
64
+ export function useIsolatedHome(
65
+ prefix: string,
66
+ onReady?: (tmp: string) => void,
67
+ ): void {
68
+ let tmp: string;
69
+ let priorHome: string | undefined;
70
+ beforeEach(() => {
71
+ tmp = mkdtempSync(join(tmpdir(), prefix));
72
+ priorHome = process.env["HOME"];
73
+ process.env["HOME"] = tmp;
74
+ onReady?.(tmp);
75
+ });
76
+ afterEach(() => {
77
+ if (priorHome === undefined) delete process.env["HOME"];
78
+ else process.env["HOME"] = priorHome;
79
+ rmSync(tmp, { recursive: true, force: true });
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Like {@link useIsolatedHome} but also chdirs into the scratch dir, so factory-time tests find the per-test config via the loader walk-up. macOS tmpdir is a symlink; canonicalized via `realpathSync` so cwd-mismatch tests don't see false-divergence.
85
+ */
86
+ export function useScratchHome(
87
+ prefix: string,
88
+ onReady?: (tmp: string) => void,
89
+ ): void {
90
+ let tmp: string;
91
+ let priorHome: string | undefined;
92
+ let priorCwd: string;
93
+ beforeEach(() => {
94
+ priorCwd = process.cwd();
95
+ tmp = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
96
+ priorHome = process.env["HOME"];
97
+ process.env["HOME"] = tmp;
98
+ process.chdir(tmp);
99
+ onReady?.(tmp);
100
+ });
101
+ afterEach(() => {
102
+ process.chdir(priorCwd);
103
+ if (priorHome === undefined) delete process.env["HOME"];
104
+ else process.env["HOME"] = priorHome;
105
+ rmSync(tmp, { recursive: true, force: true });
106
+ });
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Steering-config fixture writers
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Write a single-file steering config to `<dir>/.pi/steering.ts`.
115
+ * `body` is the full module source (must include `export default`).
116
+ * Used by suites whose fixtures embed regex literals or other
117
+ * non-JSON-friendly module shapes inline.
118
+ */
119
+ export function writeSteeringSingleFileConfig(dir: string, body: string): void {
120
+ mkdirSync(join(dir, ".pi"), { recursive: true });
121
+ writeFileSync(join(dir, ".pi", "steering.ts"), body, "utf8");
122
+ }
123
+
124
+ /**
125
+ * Write a directory-form steering config to
126
+ * `<dir>/.pi/steering/index.ts`. `body` is the full module source
127
+ * (must include `export default`). Mirrors the layout the bin tests
128
+ * use for their isolated `@cad0p/pi-steering` invocations.
129
+ */
130
+ export function writeSteeringDirConfig(dir: string, body: string): void {
131
+ const pi = join(dir, ".pi", "steering");
132
+ mkdirSync(pi, { recursive: true });
133
+ writeFileSync(join(pi, "index.ts"), body, "utf8");
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Session-entry shape
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Exact shape pi's `sessionManager.getEntries()` returns for entries
142
+ * produced by `appendEntry`. The evaluator filters to `type: "custom"`,
143
+ * matches by `customType`, and reads `{ data, timestamp }` — other
144
+ * fields (`id`, `parentId`) exist on real entries so we mirror them
145
+ * here to avoid silent type drift.
146
+ */
147
+ export interface CustomEntry {
148
+ readonly type: "custom";
149
+ readonly customType: string;
150
+ readonly data: unknown;
151
+ readonly timestamp: string;
152
+ readonly id: string;
153
+ readonly parentId: string | null;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // ExtensionContext stub
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Minimal stub for pi's `ExtensionContext`. Only the fields the
162
+ * evaluator + observer-dispatcher read are populated; everything else
163
+ * throws if touched so accidental reliance on unsupported surface
164
+ * breaks loudly.
165
+ *
166
+ * The `entries` array mimics `sessionManager.getEntries()` output —
167
+ * tests that want cross-handler `findEntries` visibility pass
168
+ * `host.entries` (from {@link makeTrackedHost}) here so the host's
169
+ * `appendEntry` writes show up on subsequent reads.
170
+ */
171
+ export function makeCtx(
172
+ cwd: string,
173
+ entries: ReadonlyArray<CustomEntry> = [],
174
+ ): ExtensionContext {
175
+ return {
176
+ cwd,
177
+ sessionManager: {
178
+ getEntries: () => entries,
179
+ // Other SessionManager methods are stubbed to throw via the
180
+ // unknown-cast below; any accidental dependency surfaces as a
181
+ // clear TypeError rather than silently passing.
182
+ } as unknown as ExtensionContext["sessionManager"],
183
+ } as ExtensionContext;
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Tracked EvaluatorHost
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Tracked {@link EvaluatorHost} recording every exec / appendEntry
192
+ * call so tests can assert memoization + audit logging.
193
+ *
194
+ * `entries` is the backing array `makeCtx` wraps when tests want the
195
+ * host's `appendEntry` writes visible to a later `findEntries` read.
196
+ * Timestamps are monotonically-incrementing second-level ISO strings
197
+ * so ordering asserts stay stable inside the same millisecond.
198
+ */
199
+ export interface TrackedHost extends EvaluatorHost {
200
+ readonly execCalls: Array<{ cmd: string; args: string[]; cwd: string }>;
201
+ readonly appended: Array<{ type: string; data: unknown }>;
202
+ readonly entries: CustomEntry[];
203
+ }
204
+
205
+ /**
206
+ * Build a {@link TrackedHost}. Optional `exec` override lets evaluator
207
+ * tests count real invocations against the cache (the default exec
208
+ * returns `{ stdout: "", stderr: "", code: 0, killed: false }`).
209
+ */
210
+ export function makeTrackedHost(options?: {
211
+ exec?: (cmd: string, args: string[], cwd: string) => Promise<PiExecResult>;
212
+ }): TrackedHost {
213
+ const execCalls: TrackedHost["execCalls"] = [];
214
+ const appended: TrackedHost["appended"] = [];
215
+ const entries: CustomEntry[] = [];
216
+ let idCounter = 0;
217
+ return {
218
+ execCalls,
219
+ appended,
220
+ entries,
221
+ exec: async (cmd, args, opts) => {
222
+ const cwd = opts?.cwd ?? "/";
223
+ execCalls.push({ cmd, args: [...args], cwd });
224
+ if (options?.exec) {
225
+ return options.exec(cmd, args, cwd);
226
+ }
227
+ return { stdout: "", stderr: "", code: 0, killed: false };
228
+ },
229
+ appendEntry: (type, data) => {
230
+ appended.push({ type, data });
231
+ entries.push({
232
+ type: "custom",
233
+ customType: type,
234
+ data,
235
+ timestamp: new Date(
236
+ Date.UTC(2026, 0, 1, 0, 0, idCounter++),
237
+ ).toISOString(),
238
+ id: `entry-${idCounter}`,
239
+ parentId: null,
240
+ });
241
+ },
242
+ };
243
+ }