@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,148 @@
1
+ # Example: dynamic-reason + walker-unknown-cwd
2
+
3
+ Worked example tying together two pi-steering primitives:
4
+
5
+ - Runtime-cwd predicate trinary surfacing (gitPlugin's `isClean` /
6
+ `hasStagedChanges` / `remote` / `upstream` / `commitsAhead` all inline a
7
+ walker-unknown-cwd guard at the top of the handler and surface trinary
8
+ `"unknown"` when the walker can't statically resolve cwd). The engine's
9
+ leaf-level (or block-level inside `not:`) `onUnknown:` policy then
10
+ projects to a definite verdict — default `"block"` (fail-CLOSED, rule
11
+ fires).
12
+ - `walkerUnknownCwdReason` (from `@cad0p/pi-steering`) — the agent-facing reason
13
+ text helper for the walker-unknown-cwd fail-CLOSED branch.
14
+
15
+ Together these give external plugin authors the ergonomics story for
16
+ runtime-cwd predicates: every gitPlugin runtime-cwd predicate gets the
17
+ engine's fail-CLOSED `onUnknown: "block"` semantics for free, and the
18
+ `walkerUnknownCwdReason` helper produces a useful agent-facing message
19
+ that distinguishes "the predicate fired because the state is genuinely
20
+ bad" from "the predicate fired because the walker couldn't tell".
21
+
22
+ ## Pattern
23
+
24
+ The rule's `reason` is a `ReasonFn` (not a static string). It branches on
25
+ `ctx.walkerState?.cwd === "unknown"`:
26
+
27
+ ```ts
28
+ reason: (ctx) => {
29
+ if (ctx.walkerState?.cwd === "unknown") {
30
+ // Walker couldn't statically resolve cwd. The predicate's inline
31
+ // guard surfaced trinary "unknown"; the engine's default
32
+ // onUnknown: "block" projected it to true so the rule fired
33
+ // fail-CLOSED. Produce a generic explanation via the helper, then
34
+ // append retry guidance.
35
+ return (
36
+ walkerUnknownCwdReason(ctx, "working tree status") +
37
+ " Run from inside the package directory with a literal path."
38
+ );
39
+ }
40
+ // Walker statically resolved cwd; the predicate genuinely fired
41
+ // (working tree is dirty). Domain-specific reason.
42
+ return "Working tree has uncommitted changes. Commit or stash before deploying.";
43
+ },
44
+ ```
45
+
46
+ The agent gets a useful next-step in both cases instead of a
47
+ generic "rule blocked" message.
48
+
49
+ The rule is gated by `when: { isClean: false }`. The next subsection
50
+ explains why this canonical positive form is preferred over
51
+ `when: { not: { isClean: true } }` even though both shapes now produce
52
+ the same fail-CLOSED behavior under walker-unknown cwd by default.
53
+
54
+ ## Why `isClean: false` over `not: { isClean: true }`
55
+
56
+ Both shapes describe "the working tree is NOT clean". Under the trinary
57
+ engine introduced in v0.1.0, both are fail-CLOSED by default on the
58
+ walker-unknown-cwd branch, so polarity-of-default is no longer the
59
+ deciding factor. The recommendation is now about readability, smaller
60
+ composition surface, and an end-to-end behavior gap on the
61
+ walker-known + git-fails row.
62
+
63
+ | state | `isClean` returns | `when: { isClean: false }` | `when: { not: { isClean: true } }` |
64
+ |-----------------------------|-------------------|----------------------------------|--------------------------------------|
65
+ | walker-unknown cwd | `"unknown"` | fires ✅ (leaf default block) | fires ✅ (block-level default block) |
66
+ | walker-known + clean | `true` | does NOT fire ✅ | does NOT fire ✅ |
67
+ | walker-known + dirty | `false` | fires ✅ | fires ✅ |
68
+ | walker-known + git fails | `false` | does NOT fire ❌ (fail-OPEN) | fires ⚠️ (Kleene-AND-false absorbs) |
69
+
70
+ The two shapes agree on every row except `walker-known + git fails`,
71
+ where they diverge:
72
+
73
+ - `isClean: false`: the handler returns boolean `false` on git failure
74
+ (`getWorkingTreeClean` returns `null` → handler short-circuits to
75
+ `false`, NOT `"unknown"`). Leaf verdict is `false` → rule skips.
76
+ - `not: { isClean: true }`: the handler again returns `false` on git
77
+ failure. Inner verdict is `false`, Kleene AND of a single false leaf
78
+ is `false`, the not-flip yields `true` → rule fires.
79
+
80
+ The asymmetry is real: `isClean: false` reads as "fire when the
81
+ handler positively asserts dirty"; `not: { isClean: true }` reads as
82
+ "fire when the handler does NOT positively assert clean", and a
83
+ failed git call is a non-clean-assertion under that wording. Pair
84
+ `isClean` with an `upstream` check to cover the git-fails branch
85
+ uniformly under either shape (handler returns `false` on git failure;
86
+ the `upstream:` leaf surfaces trinary `"unknown"` and the engine's
87
+ `onUnknown:` policy projects).
88
+
89
+ Why prefer `{ isClean: false }`:
90
+
91
+ - **Reads forward.** "Block when isClean is false" matches how a human
92
+ describes the rule out loud.
93
+ - **Smaller composition surface.** A bare leaf takes per-leaf
94
+ modifiers (`isClean: { value: false, onUnknown: "allow" }`) at the
95
+ leaf level. A `not:` block takes block-level modifiers and forbids
96
+ per-leaf modifiers inside; if you ever need a different `onUnknown:`
97
+ policy on this single leaf, the bare form is the simpler edit.
98
+ - **Idiomatic.** gitPlugin's `predicates.ts` JSDoc documents both
99
+ polarities of every boolean predicate (`isClean: true` /
100
+ `isClean: false`, `hasStagedChanges: true` / `hasStagedChanges: false`)
101
+ exactly so authors can avoid `not:` for negation when a documented
102
+ inverted shape exists.
103
+
104
+ ### Pair with `upstream:` to cover the git-fails branch
105
+
106
+ Neither bare-form nor `not:`-form covers the `walker-known + git fails`
107
+ row uniformly. Put a fail-CLOSED `upstream:` check first in the same
108
+ `when:` to convert the gitfailure into a definite verdict (the
109
+ `upstream:` handler surfaces `"unknown"` instead of returning a
110
+ boolean, so the engine's `onUnknown:` policy projects to fail-CLOSED
111
+ and the rule fires regardless of which `isClean:` shape you used):
112
+
113
+ ```ts
114
+ when: { upstream: /./, isClean: false }
115
+ ```
116
+
117
+ `{ not: { isClean: true } }` is correct under the new engine for the
118
+ walker-unknown row — pick whichever reads cleaner at the call site,
119
+ and pair it with `upstream:` either way.
120
+
121
+ Rule of thumb for any future plugin author copying this example:
122
+ prefer the predicate's documented inverted shape (e.g., `isClean: false`,
123
+ `hasStagedChanges: false`) when one exists. Reach for `not:` when
124
+ combining multiple leaves under a single negation
125
+ (`not: { branch: "main", remote: /github/ }`) or when no inverted
126
+ shape is available (regex predicates, etc.).
127
+
128
+ ## When to use this pattern
129
+
130
+ When your rule consumes any runtime-cwd predicate from a plugin
131
+ (gitPlugin's `isClean` / `hasStagedChanges` / `remote` / `upstream` /
132
+ `commitsAhead`, or any external plugin's runtime-cwd predicate that
133
+ inlines a `cwdIsWalkerUnknown` guard).
134
+
135
+ Without the two-branch ReasonFn, a rule with a static `reason: "..."`
136
+ would print the same message in both cases — uninformative when the
137
+ trigger was a dynamic-cwd target the walker bailed on.
138
+
139
+ ## Cross-references
140
+
141
+ - `walkerUnknownCwdReason` JSDoc — `src/helpers/walker-unknown-cwd-reason.ts`. Read for the helper's signature, the `verifying` arg contract, and a worked rule snippet.
142
+ - gitPlugin runtime-cwd predicates — `src/plugins/git/predicates.ts`. Each has a `@see {@link walkerUnknownCwdReason}` cross-link in its JSDoc, surfacing the helper at the predicate's hover location.
143
+
144
+ ## Run the tests
145
+
146
+ ```bash
147
+ pnpm --filter @examples/dynamic-reason-runtime-cwd test
148
+ ```
@@ -0,0 +1,43 @@
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+ basedir_win="$basedir"
4
+ exe=""
5
+ msys=""
6
+
7
+ case `uname -a` in
8
+ *CYGWIN*|*MINGW*|*MSYS*)
9
+ if command -v cygpath > /dev/null 2>&1; then
10
+ basedir_win=`cygpath -w "$basedir"`
11
+ fi
12
+ exe=".exe"
13
+ msys="true"
14
+ ;;
15
+ *WSL2*)
16
+ if command -v wslpath > /dev/null 2>&1; then
17
+ basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
18
+ if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
19
+ basedir_win="$basedir"
20
+ else
21
+ exe=".exe"
22
+ fi
23
+ fi
24
+ ;;
25
+ esac
26
+
27
+ if [ -z "$NODE_PATH" ]; then
28
+ export NODE_PATH="/home/pier/open-source/github/pi-steering/node_modules/.pnpm/node_modules"
29
+ else
30
+ export NODE_PATH="/home/pier/open-source/github/pi-steering/node_modules/.pnpm/node_modules:$NODE_PATH"
31
+ fi
32
+ if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
33
+ exec "$basedir/node.exe" "$basedir_win/../@cad0p/pi-steering/dist/bin/pi-steering.js" "$@"
34
+ elif [ -x "$basedir/node" ]; then
35
+ exec "$basedir/node" "$basedir/../@cad0p/pi-steering/dist/bin/pi-steering.js" "$@"
36
+ elif command -v node >/dev/null 2>&1; then
37
+ exec node "$basedir/../@cad0p/pi-steering/dist/bin/pi-steering.js" "$@"
38
+ elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
39
+ exec node.exe "$basedir_win/../@cad0p/pi-steering/dist/bin/pi-steering.js" "$@"
40
+ else
41
+ exec node "$basedir/../@cad0p/pi-steering/dist/bin/pi-steering.js" "$@"
42
+ fi
43
+ # cmd-shim-target=/home/pier/open-source/github/pi-steering/examples/dynamic-reason-runtime-cwd/node_modules/@cad0p/pi-steering/dist/bin/pi-steering.js
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@examples/dynamic-reason-runtime-cwd",
3
+ "version": "0.0.0-example",
4
+ "private": true,
5
+ "description": "Worked example: dynamic-reason + walker-unknown-cwd pattern (runtime-cwd predicate trinary surfacing + walkerUnknownCwdReason).",
6
+ "type": "module",
7
+ "peerDependencies": {
8
+ "@cad0p/pi-steering": "workspace:*"
9
+ },
10
+ "devDependencies": {
11
+ "@cad0p/pi-steering": "workspace:*"
12
+ },
13
+ "scripts": {
14
+ "pretest": "pnpm --filter @cad0p/pi-steering run clean && pnpm --filter @cad0p/pi-steering run build",
15
+ "test": "node --test --experimental-strip-types 'steering.test.ts'",
16
+ "typecheck": "tsc --noEmit"
17
+ }
18
+ }
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering / examples.
3
+
4
+ /**
5
+ * Tests for the dynamic-reason + walker-unknown-cwd example.
6
+ *
7
+ * Pin the two ReasonFn branches:
8
+ *
9
+ * 1. Walker-unknown cwd → reason text contains
10
+ * `walkerUnknownCwdReason`'s anchor ("current directory:") AND
11
+ * the example's appended retry guidance ("literal path"
12
+ * reference is in the helper, "package directory" is in the
13
+ * append).
14
+ * 2. Walker-known cwd → reason text is the static "uncommitted
15
+ * changes" message.
16
+ *
17
+ * Also smoke-test the config shape (registered rule, bash/command,
18
+ * non-empty reason callable). The third branch (walker-known + clean
19
+ * tree → no fire) is covered structurally via the rule-shape pin
20
+ * below — inverting the `when:` clause regresses the test — rather
21
+ * than via a full engine roundtrip; full-engine coverage of the
22
+ * three-branch matrix is a v0.1.x candidate.
23
+ */
24
+
25
+ import assert from "node:assert/strict";
26
+ import { describe, it } from "node:test";
27
+ import type { PredicateContext } from "@cad0p/pi-steering";
28
+ import { mockContext } from "@cad0p/pi-steering/testing";
29
+ import config from "./steering.ts";
30
+
31
+ /**
32
+ * Pull the only rule out of the config. Throws if the example
33
+ * regresses to a multi-rule shape — the tests below assume one rule.
34
+ */
35
+ function getDeployRule() {
36
+ assert.ok(config.rules !== undefined, "config.rules should be defined");
37
+ const rule = config.rules.find(
38
+ (r) => r.name === "deploy-requires-clean-tree",
39
+ );
40
+ assert.ok(rule, "deploy-requires-clean-tree rule must be registered");
41
+ return rule;
42
+ }
43
+
44
+ /**
45
+ * Invoke the rule's `reason` function. The example uses a function
46
+ * reason (not a string), so this helper unwraps the callable.
47
+ */
48
+ async function callReason(ctx: PredicateContext): Promise<string> {
49
+ const rule = getDeployRule();
50
+ assert.equal(
51
+ typeof rule.reason,
52
+ "function",
53
+ "this example's reason should be a ReasonFn",
54
+ );
55
+ const out = await (
56
+ rule.reason as (ctx: PredicateContext) => string | Promise<string>
57
+ )(ctx);
58
+ return out;
59
+ }
60
+
61
+ describe("example: dynamic-reason-runtime-cwd", () => {
62
+ it("rule.when uses the canonical positive form (`isClean: false`)", () => {
63
+ // Pins the rule's `when:` to `{ isClean: false }`. The equivalent
64
+ // `{ not: { isClean: true } }` shape produces the same fail-CLOSED
65
+ // behavior on the walker-unknown branch under the trinary engine's
66
+ // default block-level `onUnknown: "block"`, but diverges on the
67
+ // walker-known + git-fails row (see README truth table); the
68
+ // positive form is preferred for readability and per-leaf modifier
69
+ // composition. See README "Why isClean: false over not: { isClean: true }".
70
+ const rule = getDeployRule();
71
+ assert.deepEqual(rule.when, { isClean: false });
72
+ });
73
+
74
+ it("registers the deploy-requires-clean-tree rule (bash/command)", () => {
75
+ const rule = getDeployRule();
76
+ assert.equal(rule.tool, "bash");
77
+ assert.equal(rule.field, "command");
78
+ });
79
+
80
+ it("walker-unknown cwd → reason composes walkerUnknownCwdReason + retry guidance", async () => {
81
+ // Walker couldn't statically resolve cwd. The ReasonFn detects
82
+ // `ctx.walkerState.cwd === "unknown"` and delegates to
83
+ // `walkerUnknownCwdReason`, then appends domain-specific retry
84
+ // guidance.
85
+ const ctx = mockContext({
86
+ cwd: "/repo/root",
87
+ walkerState: { cwd: "unknown", env: new Map() },
88
+ });
89
+ const reason = await callReason(ctx);
90
+
91
+ // Helper anchor — pinned by walker-unknown-cwd-reason.test.ts.
92
+ assert.ok(
93
+ reason.includes("current directory:"),
94
+ `expected helper anchor, got: ${reason}`,
95
+ );
96
+ // Helper surfaces the actual ctx.cwd.
97
+ assert.ok(
98
+ reason.includes("/repo/root"),
99
+ `expected ctx.cwd in reason, got: ${reason}`,
100
+ );
101
+ // Example's appended retry guidance.
102
+ assert.ok(
103
+ reason.includes("package directory"),
104
+ `expected example's retry guidance ('package directory'), got: ${reason}`,
105
+ );
106
+ });
107
+
108
+ it("walker-known cwd → reason is the static `uncommitted changes` message", async () => {
109
+ // Walker statically resolved cwd. The dirty-tree path: gitPlugin's
110
+ // `isClean` returned false, the rule fires, and the ReasonFn
111
+ // produces the domain-specific reason text.
112
+ const ctx = mockContext({
113
+ cwd: "/repo/root",
114
+ walkerState: { cwd: "/repo/root", env: new Map() },
115
+ });
116
+ const reason = await callReason(ctx);
117
+
118
+ // Static text — wording-tweak refactors should not break this
119
+ // assertion; pin substrings, not exact equality.
120
+ assert.ok(
121
+ reason.toLowerCase().includes("uncommitted"),
122
+ `expected static reason to mention 'uncommitted', got: ${reason}`,
123
+ );
124
+ // And the helper anchor MUST be absent on the static branch.
125
+ assert.ok(
126
+ !reason.includes("current directory:"),
127
+ `static branch must not emit the helper anchor, got: ${reason}`,
128
+ );
129
+ });
130
+ });
@@ -0,0 +1,82 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering / examples.
3
+
4
+ /**
5
+ * Example: dynamic-reason + walker-unknown-cwd pattern.
6
+ *
7
+ * Demonstrates how external plugin authors compose runtime-cwd
8
+ * predicates (gitPlugin's `isClean` / `hasStagedChanges` / `remote` /
9
+ * `upstream` / `commitsAhead` — each inlines a walker-unknown-cwd
10
+ * guard at the top of its handler and surfaces trinary `"unknown"`)
11
+ * with informative agent-facing reasons that distinguish two
12
+ * branches:
13
+ *
14
+ * - Static cwd + predicate fires: domain-specific reason text
15
+ * (the working tree is genuinely dirty).
16
+ * - Walker-unknown cwd: `walkerUnknownCwdReason()` explains the
17
+ * walker couldn't statically resolve cwd, surfaces the actual
18
+ * `ctx.cwd`, and prompts a retry with a literal path. The
19
+ * example appends a small piece of domain-specific retry
20
+ * guidance after the helper's output.
21
+ *
22
+ * Drop this file in at `.pi/steering.ts` (or
23
+ * `.pi/steering/index.ts`) to activate. The example uses gitPlugin's
24
+ * `isClean` predicate to gate `npm run deploy` on a clean working
25
+ * tree.
26
+ */
27
+
28
+ import { defineConfig, type Rule, walkerUnknownCwdReason } from "@cad0p/pi-steering";
29
+ import gitPlugin from "@cad0p/pi-steering/plugins/git";
30
+
31
+ /**
32
+ * Rule: block `npm run deploy` when the working tree isn't clean.
33
+ *
34
+ * The `reason` field is a {@link ReasonFn} that branches on
35
+ * `ctx.walkerState?.cwd === "unknown"` to detect the
36
+ * walker-unknown-cwd fail-CLOSED branch. gitPlugin's `isClean` inlines
37
+ * a walker-unknown-cwd guard at the top of its handler and surfaces
38
+ * trinary `"unknown"` when the walker can't statically resolve cwd;
39
+ * the engine's leaf-level `onUnknown:` policy (default `"block"`)
40
+ * projects to a definite `true` and the rule fires fail-CLOSED. On
41
+ * that branch, `walkerUnknownCwdReason` produces the canonical
42
+ * agent-facing explanation; the example appends domain-specific
43
+ * retry guidance after it.
44
+ */
45
+ const deployRequiresCleanTree = {
46
+ name: "deploy-requires-clean-tree",
47
+ tool: "bash",
48
+ field: "command",
49
+ pattern: /^npm\s+run\s+deploy\b/,
50
+ // Canonical positive form: `isClean: false` ("fires when dirty")
51
+ // reads forward and lets per-leaf modifiers attach at the leaf if
52
+ // ever needed. The equivalent `not: { isClean: true }` form is also
53
+ // safe under the new engine — the trinary `"unknown"` from the
54
+ // inline walker-unknown-cwd guard composes via the not-block's
55
+ // default block-level `onUnknown: "block"` to fire fail-CLOSED on
56
+ // the walker-unknown branch — but `isClean: false` is the simpler
57
+ // shape when no other leaves share the not-block. See README
58
+ // "Why isClean: false over not: { isClean: true }" for the full
59
+ // truth table.
60
+ when: { isClean: false },
61
+ reason: (ctx) => {
62
+ if (ctx.walkerState?.cwd === "unknown") {
63
+ // Walker couldn't statically resolve cwd. The handler's
64
+ // inline guard surfaced trinary `"unknown"`; the engine's
65
+ // default leaf-level `onUnknown: "block"` projected it to true
66
+ // and the rule fired fail-CLOSED. Use the helper for a
67
+ // consistent agent-facing explanation; append domain-specific
68
+ // retry guidance.
69
+ return (
70
+ walkerUnknownCwdReason(ctx, "working tree status") +
71
+ " Run from inside the package directory with a literal path."
72
+ );
73
+ }
74
+ // Predicate fired with a known cwd: working tree is genuinely dirty.
75
+ return "Working tree has uncommitted changes. Commit or stash before deploying.";
76
+ },
77
+ } as const satisfies Rule;
78
+
79
+ export default defineConfig({
80
+ plugins: [gitPlugin],
81
+ rules: [deployRequiresCleanTree],
82
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true
5
+ },
6
+ "include": ["steering.ts", "steering.test.ts"]
7
+ }
@@ -0,0 +1,75 @@
1
+ # force-push-strict
2
+
3
+ A rule pack that blocks **every** form of `git push --force`, including `--force-with-lease`.
4
+
5
+ ## What it enforces
6
+
7
+ - `git push --force` → blocked
8
+ - `git push -f` → blocked
9
+ - `git push --force-with-lease` → **blocked** (this is the difference from the default)
10
+ - `git push origin main` → allowed
11
+
12
+ The pattern also handles git pre-subcommand flags (`git -C /other push --force`, `git -c key=val push -f`) and wrapper bypasses (`sh -c 'git push --force'`, `sudo xargs git push --force`, …) — all transparently, via the AST backend.
13
+
14
+ ## How it differs from the default
15
+
16
+ The built-in `no-force-push` rule permits `--force-with-lease` because the lease flag is the documented "safe" way to update a branch after a rebase. That's fine for most teams, but in environments where:
17
+
18
+ - the branch is shared broadly (`main`, `develop`, long-lived release branches),
19
+ - history integrity is a compliance requirement, or
20
+ - you want the agent to never reach for any `--force` variant as a first-line fix,
21
+
22
+ the lease-variant carve-out is an attack surface. This rule pack closes it.
23
+
24
+ ## When to use
25
+
26
+ - Strict-history environments (shared release branches, regulated contexts)
27
+ - Teams that want a single "never force push" discipline without agent-side judgement calls
28
+ - As a starting point for more restrictive house rules
29
+
30
+ ## Install
31
+
32
+ Two equivalent forms. Pick whichever matches your setup.
33
+
34
+ ### TypeScript form (canonical)
35
+
36
+ Copy [`steering.ts`](./steering.ts) to `~/.pi/steering.ts` (or
37
+ `<project-root>/.pi/steering.ts`) for a repo-scoped policy. The
38
+ TypeScript form participates in compile-time checking via
39
+ `defineConfig`. The loader accepts TypeScript only.
40
+
41
+ ### JSON form (for migration)
42
+
43
+ The loader does **not** load `.pi/steering.json` files directly. The
44
+ [`steering.json`](./steering.json) below is provided as a reference
45
+ shape for `pi-steering import-json` migration — convert it with
46
+ `pi-steering import-json steering.json -o .pi/steering/index.ts` (or
47
+ programmatically via `fromJSON` from `compat.ts`).
48
+
49
+ JSON is a deliberate subset of the TypeScript shape: pattern-string
50
+ rules, `requires` / `unless`, `when.cwd` (string pattern only), and
51
+ override flags. Plugins, observers, function-valued rule fields,
52
+ plugin-registered predicate keys (`when.<customKey>`), `when.not`,
53
+ and `when.condition` are TypeScript-only — equivalently, any `when`
54
+ clause member other than `when.cwd` is rejected.
55
+
56
+ The `disable` entry below turns off the built-in `no-force-push`; the
57
+ new `no-force-push-strict` rule takes its place:
58
+
59
+ ```json
60
+ {
61
+ "disable": ["no-force-push"],
62
+ "rules": [
63
+ {
64
+ "name": "no-force-push-strict",
65
+ "tool": "bash",
66
+ "field": "command",
67
+ "pattern": "^git\\b(?:\\s+-{1,2}[A-Za-z]\\S*(?:\\s+\\S+)?)*\\s+push\\b.*(?:--force\\b|\\s-f(?:\\s|$))",
68
+ "reason": "No force pushes of any kind, including --force-with-lease."
69
+ }
70
+ ]
71
+ }
72
+ ```
73
+
74
+ See [examples/README.md#json](../README.md#json) for the full migration
75
+ workflow and the v0 fallback option.
@@ -0,0 +1,43 @@
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+ basedir_win="$basedir"
4
+ exe=""
5
+ msys=""
6
+
7
+ case `uname -a` in
8
+ *CYGWIN*|*MINGW*|*MSYS*)
9
+ if command -v cygpath > /dev/null 2>&1; then
10
+ basedir_win=`cygpath -w "$basedir"`
11
+ fi
12
+ exe=".exe"
13
+ msys="true"
14
+ ;;
15
+ *WSL2*)
16
+ if command -v wslpath > /dev/null 2>&1; then
17
+ basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
18
+ if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
19
+ basedir_win="$basedir"
20
+ else
21
+ exe=".exe"
22
+ fi
23
+ fi
24
+ ;;
25
+ esac
26
+
27
+ if [ -z "$NODE_PATH" ]; then
28
+ export NODE_PATH="/home/pier/open-source/github/pi-steering/node_modules/.pnpm/node_modules"
29
+ else
30
+ export NODE_PATH="/home/pier/open-source/github/pi-steering/node_modules/.pnpm/node_modules:$NODE_PATH"
31
+ fi
32
+ if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
33
+ exec "$basedir/node.exe" "$basedir_win/../@cad0p/pi-steering/dist/bin/pi-steering.js" "$@"
34
+ elif [ -x "$basedir/node" ]; then
35
+ exec "$basedir/node" "$basedir/../@cad0p/pi-steering/dist/bin/pi-steering.js" "$@"
36
+ elif command -v node >/dev/null 2>&1; then
37
+ exec node "$basedir/../@cad0p/pi-steering/dist/bin/pi-steering.js" "$@"
38
+ elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
39
+ exec node.exe "$basedir_win/../@cad0p/pi-steering/dist/bin/pi-steering.js" "$@"
40
+ else
41
+ exec node "$basedir/../@cad0p/pi-steering/dist/bin/pi-steering.js" "$@"
42
+ fi
43
+ # cmd-shim-target=/home/pier/open-source/github/pi-steering/examples/force-push-strict/node_modules/@cad0p/pi-steering/dist/bin/pi-steering.js
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@examples/force-push-strict",
3
+ "version": "0.0.0-example",
4
+ "private": true,
5
+ "description": "Rule pack: no force pushes of any kind (strict).",
6
+ "type": "module",
7
+ "peerDependencies": {
8
+ "@cad0p/pi-steering": "workspace:*"
9
+ },
10
+ "devDependencies": {
11
+ "@cad0p/pi-steering": "workspace:*"
12
+ },
13
+ "scripts": {
14
+ "pretest": "pnpm --filter @cad0p/pi-steering run clean && pnpm --filter @cad0p/pi-steering run build",
15
+ "test": "node --test --experimental-strip-types 'steering.test.ts'",
16
+ "typecheck": "tsc --noEmit"
17
+ }
18
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "disable": ["no-force-push"],
3
+ "rules": [
4
+ {
5
+ "name": "no-force-push-strict",
6
+ "tool": "bash",
7
+ "field": "command",
8
+ "pattern": "^git\\b(?:\\s+-{1,2}[A-Za-z]\\S*(?:\\s+\\S+)?)*\\s+push\\b.*(?:--force\\b|\\s-f(?:\\s|$))",
9
+ "reason": "No force pushes of any kind, including --force-with-lease. Create a new commit, or reset + re-commit via a non-force path."
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering / examples.
3
+
4
+ /**
5
+ * Smoke test for the force-push-strict rule pack.
6
+ *
7
+ * Scope: README drift guard. Asserts that `steering.ts` compiles
8
+ * (covered by `tsc --noEmit` in the typecheck script) and that the
9
+ * resolved config has the expected shape. Full behavioral coverage
10
+ * (every pattern, every wrapper form) lives in the engine's own test
11
+ * suite, not here.
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { describe, it } from "node:test";
16
+ import config from "./steering.ts";
17
+
18
+ describe("example: force-push-strict", () => {
19
+ it("exports a SteeringConfig with at least one rule", () => {
20
+ assert.ok(config.rules !== undefined, "config.rules should be defined");
21
+ assert.ok(
22
+ config.rules.length >= 1,
23
+ "config.rules should have at least one rule",
24
+ );
25
+ });
26
+
27
+ it("registers the strict rule name", () => {
28
+ const names = config.rules!.map((r) => r.name);
29
+ assert.ok(
30
+ names.includes("no-force-push-strict"),
31
+ `expected no-force-push-strict in rules, got: ${names.join(", ")}`,
32
+ );
33
+ });
34
+
35
+ it("disables the default `no-force-push` so the stricter variant owns the reason message", () => {
36
+ assert.ok(
37
+ config.disabledRules?.includes("no-force-push"),
38
+ "expected disabledRules to include 'no-force-push'",
39
+ );
40
+ });
41
+
42
+ it("strict rule has a bash/command shape and a non-empty reason", () => {
43
+ const strict = config.rules!.find((r) => r.name === "no-force-push-strict");
44
+ assert.ok(strict, "no-force-push-strict not found");
45
+ assert.equal(strict!.tool, "bash");
46
+ assert.ok(
47
+ typeof strict!.reason === "string" && strict!.reason.length > 0,
48
+ "reason should be a non-empty string",
49
+ );
50
+ });
51
+ });