@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,693 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Predicate handlers for the git plugin.
6
+ *
7
+ * Each handler is a `PredicateHandler<A>` where `A` is the
8
+ * plugin-author-facing argument shape the rule passes under the
9
+ * matching `when.<key>` slot. See each handler's JSDoc for the
10
+ * accepted shape and worked examples.
11
+ *
12
+ * Evaluation strategy:
13
+ *
14
+ * - `branch` - read `ctx.walkerState.branch` first (set by
15
+ * the branch tracker on in-chain checkouts).
16
+ * If the tracker resolved statically, use
17
+ * that value. If the tracker reports
18
+ * `"unknown"` (dynamic checkout like
19
+ * `git checkout $VAR` that the walker
20
+ * couldn't resolve), apply `onUnknown`
21
+ * policy without falling back to `exec` -
22
+ * a `git branch --show-current` call would
23
+ * return the PRE-checkout branch and
24
+ * silently defeat the walker. If no
25
+ * tracker state exists (no checkout in
26
+ * chain), shell out via `git branch
27
+ * --show-current`.
28
+ * - `upstream` - no tracker today; always shell out via
29
+ * `git rev-parse --abbrev-ref @{upstream}`.
30
+ * Inlines a {@link cwdIsWalkerUnknown} guard
31
+ * so walker-unknown cwd surfaces trinary
32
+ * `"unknown"` instead of shelling against
33
+ * the pi session cwd — the engine's
34
+ * `onUnknown:` policy then projects to a
35
+ * definite verdict (default `"block"` =
36
+ * fail-CLOSED).
37
+ * - `commitsAhead` - shell out via `git rev-list --count`.
38
+ * Inlines the same walker-unknown guard as
39
+ * `upstream`.
40
+ * - `hasStagedChanges` - shell out via `git diff --cached --quiet`.
41
+ * - `isClean` - shell out via `git status --porcelain`.
42
+ * - `remote` - shell out via `git config --get
43
+ * remote.origin.url`.
44
+ *
45
+ * `ctx.exec` is memoized per `(cmd, args, cwd)` within one tool_call
46
+ * (see the evaluator's `createExecCache`) so multiple rules reading
47
+ * the same git state don't re-fork git.
48
+ *
49
+ * `onUnknown` policy for string-valued predicates (`branch`,
50
+ * `upstream`, `remote`): fail-closed by default. When the underlying
51
+ * query fails (not a repo, no upstream configured, ...), the predicate
52
+ * reports "match" so the rule fires. Rule authors opt into the
53
+ * "allow-through" behavior explicitly by passing
54
+ * `{ pattern, onUnknown: "allow" }`.
55
+ */
56
+
57
+ import { isPattern } from "../../internal/pattern-utils.ts";
58
+ import type {
59
+ AnyPredicateHandler,
60
+ Pattern,
61
+ PredicateContext,
62
+ PredicateHandler,
63
+ PredicateShape,
64
+ PredicateVerdict,
65
+ } from "../../schema.ts";
66
+ import { NO_CHECKOUT_IN_CHAIN } from "./branch-tracker.ts";
67
+ import {
68
+ getCommitsAhead,
69
+ getRemoteUrl,
70
+ getStagedChanges,
71
+ getUpstream,
72
+ getWorkingTreeClean,
73
+ } from "./git-ops.ts";
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Walker-unknown cwd guard (inline trinary)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Inline trinary guard for runtime-cwd predicates: returns `true` when
81
+ * the walker couldn't statically resolve the command's effective cwd
82
+ * (the cwd-tracker `"unknown"` sentinel is on `ctx.walkerState.cwd`),
83
+ * which signals to the caller that the handler should bail with
84
+ * `"unknown"` instead of querying the wrong repo.
85
+ *
86
+ * Each runtime-cwd handler in this module starts with:
87
+ *
88
+ * ```ts
89
+ * if (cwdIsWalkerUnknown(ctx)) return "unknown";
90
+ * ```
91
+ *
92
+ * Surfacing walker-unknown as trinary `"unknown"` lets the engine
93
+ * apply the leaf-level (or block-level, inside `not:`) `onUnknown:`
94
+ * policy. Default `"block"` is fail-CLOSED (the rule fires); a user
95
+ * with `onUnknown: "allow"` opts into fail-OPEN handling.
96
+ * Self-documenting + composable.
97
+ */
98
+ function cwdIsWalkerUnknown(ctx: PredicateContext): boolean {
99
+ return ctx.walkerState?.cwd === "unknown";
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Shared helpers
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Normalize the shorthand forms accepted by the pattern-valued
108
+ * predicates (`branch`, `upstream`, `remote`) into a canonical
109
+ * `{ patterns, onUnknown }` shape:
110
+ *
111
+ * - `Pattern` -> `{ patterns: [pattern], onUnknown: "block" }`
112
+ * - `Pattern[]` (non-empty, all-Pattern) -> `{ patterns, onUnknown: "block" }`
113
+ * - `{ pattern: Pattern, onUnknown? }` -> object used as-is,
114
+ * `pattern` re-wrapped
115
+ * into a single-element
116
+ * array; `onUnknown`
117
+ * defaults to `"block"`.
118
+ * - `{ pattern: Pattern[], onUnknown? }` -> array preserved,
119
+ * same `onUnknown`
120
+ * handling.
121
+ *
122
+ * Array semantics are OR-of-matches: the rule fires when the input
123
+ * matches ANY of the listed patterns. Array form requires at least
124
+ * one pattern (empty arrays are invalid).
125
+ *
126
+ * Returning `null` means the author supplied something that isn't a
127
+ * valid value for this predicate (e.g. a bare number, an empty array,
128
+ * or an array containing a non-Pattern value); handlers treat that as
129
+ * a non-match and don't throw - invalid config shouldn't crash the
130
+ * evaluator, but it also shouldn't silently fire.
131
+ */
132
+ function unwrapPatternArg(value: unknown): {
133
+ patterns: Pattern[];
134
+ onUnknown: "allow" | "block";
135
+ } | null {
136
+ // Shorthand: single Pattern.
137
+ if (isPattern(value)) {
138
+ return { patterns: [value], onUnknown: "block" };
139
+ }
140
+ // Shorthand: Pattern[] (must be non-empty + all-Pattern).
141
+ if (Array.isArray(value) && value.every(isPattern)) {
142
+ if (value.length === 0) return null;
143
+ return { patterns: value, onUnknown: "block" };
144
+ }
145
+ // Object form: { pattern: Pattern | Pattern[]; onUnknown? }.
146
+ if (
147
+ value !== null &&
148
+ typeof value === "object" &&
149
+ "pattern" in (value as Record<string, unknown>)
150
+ ) {
151
+ const obj = value as {
152
+ pattern?: unknown;
153
+ onUnknown?: "allow" | "block";
154
+ };
155
+ const onUnknown = obj.onUnknown === "allow" ? "allow" : "block";
156
+ if (isPattern(obj.pattern)) {
157
+ return { patterns: [obj.pattern], onUnknown };
158
+ }
159
+ if (Array.isArray(obj.pattern) && obj.pattern.every(isPattern)) {
160
+ if (obj.pattern.length === 0) return null;
161
+ return { patterns: obj.pattern, onUnknown };
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /** Test a Pattern against a concrete string. */
168
+ function matchPattern(pattern: Pattern, target: string): boolean {
169
+ if (pattern instanceof RegExp) return pattern.test(target);
170
+ return new RegExp(pattern).test(target);
171
+ }
172
+
173
+ /**
174
+ * Apply the predicate's `onUnknown` policy to a shell failure or
175
+ * tracker-unknown case. "block" means the predicate reports "match"
176
+ * (rule fires); "allow" means the predicate reports "no match" (rule
177
+ * skips). Fail-closed default.
178
+ */
179
+ function unknownVerdict(onUnknown: "allow" | "block"): boolean {
180
+ return onUnknown === "block";
181
+ }
182
+
183
+ /**
184
+ * Unwrap the boolean payload from a {@link PredicateShape}<boolean>
185
+ * argument. Accepts the bare form (`true` / `false`) and the
186
+ * spread form (`{ value: true, onUnknown? }` /
187
+ * `{ value: false, onUnknown? }`); the engine's `readLeafOnUnknown`
188
+ * reads any `onUnknown:` sibling and `projectVerdict` applies the
189
+ * policy to the handler's `"unknown"` returns. The handler itself
190
+ * treats `onUnknown:` as an opaque sibling field and only consumes
191
+ * `value:`.
192
+ *
193
+ * Returns `undefined` on malformed input — the caller decides what
194
+ * to do with that (typically `return false`, mirroring the existing
195
+ * pattern-unwrap fail-closed contract).
196
+ *
197
+ * Used by {@link isClean} and {@link hasStagedChanges}; both ship
198
+ * with `PredicateShape<boolean>` in the registry so the bare/spread
199
+ * shape is identical at the type level too.
200
+ *
201
+ * @internal
202
+ */
203
+ function unwrapBooleanLeafArg(args: unknown): boolean | undefined {
204
+ if (typeof args === "boolean") return args;
205
+ if (
206
+ args !== null &&
207
+ typeof args === "object" &&
208
+ typeof (args as { value?: unknown }).value === "boolean"
209
+ ) {
210
+ return (args as { value: boolean }).value;
211
+ }
212
+ return undefined;
213
+ }
214
+
215
+ /**
216
+ * Test-internal export of {@link unwrapBooleanLeafArg}. Module-private
217
+ * by intent; the `@internal` JSDoc tag (TypeScript ecosystem-standard)
218
+ * flags "not part of the public surface" and the underscore prefix
219
+ * mirrors the convention so external consumers can grep-discover it
220
+ * too. Direct unit tests pin malformed-input branches that are hard
221
+ * to drive via the engine end-to-end.
222
+ *
223
+ * @internal
224
+ */
225
+ export const _unwrapBooleanLeafArg = unwrapBooleanLeafArg;
226
+
227
+ /**
228
+ * Direct one-shot git exec used only by the `branch` predicate's
229
+ * tracker-missing fallback (the predicate's three-way tracker
230
+ * discrimination stays in predicate-land; see the `branch` JSDoc
231
+ * below AND `./git-ops.ts` file header "Branch caveat" for why
232
+ * `getBranch` is NOT called here).
233
+ * Other predicates delegate to helpers in `./git-ops.ts` and don't
234
+ * need this.
235
+ *
236
+ * Mirrors the failure-collapse contract of `tryGit` in git-ops:
237
+ * non-zero exit, spawn error, or thrown exception → `null`.
238
+ */
239
+ async function tryExec(
240
+ ctx: PredicateContext,
241
+ cmd: string,
242
+ args: readonly string[],
243
+ cwd?: string,
244
+ ): Promise<string | null> {
245
+ try {
246
+ const res = await ctx.exec(
247
+ cmd,
248
+ [...args],
249
+ cwd !== undefined ? { cwd } : undefined,
250
+ );
251
+ if (res.exitCode !== 0) return null;
252
+ return res.stdout.trim();
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Resolved outcome of reading a string tracker value from
260
+ * `ctx.walkerState[key]`. Callers MUST distinguish the three cases:
261
+ *
262
+ * - `value` - the tracker resolved the value statically for this
263
+ * command ref. Use it directly.
264
+ * - `unknown` - the tracker observed a write it couldn't resolve
265
+ * statically (e.g. `git checkout $VAR`). The walker
266
+ * deliberately surfaces this to signal "a change
267
+ * happened but I can't name the new value". Falling
268
+ * through to `exec` would return the PRE-write
269
+ * value and silently defeat the walker's static
270
+ * tracking - exactly the case it exists for.
271
+ * Callers must apply their `onUnknown` policy.
272
+ * - `missing` - no tracker modifier fired for this dimension in
273
+ * this ref's scope (the walker threaded the
274
+ * tracker's initial sentinel, or `walkerState` has
275
+ * no key for this tracker at all). `exec` fallback
276
+ * is correct here: the shell's current state is the
277
+ * value the predicate wants.
278
+ *
279
+ * The three-way split requires cooperation from the tracker: its
280
+ * `initial` value must be distinct from its `unknown` sentinel, so
281
+ * the predicate can tell "no modifier fired" apart from "modifier
282
+ * fired and couldn't resolve". `branchTracker` does this via
283
+ * {@link NO_CHECKOUT_IN_CHAIN}. A tracker that reuses `"unknown"`
284
+ * for both initial and unknown would collapse these two cases -
285
+ * preserved here as `missing` for backward compatibility (the
286
+ * predicate then behaves as it did pre-U1, shelling out on any
287
+ * unknown).
288
+ */
289
+ export type WalkerStringResult =
290
+ | { kind: "value"; value: string }
291
+ | { kind: "unknown" }
292
+ | { kind: "missing" };
293
+
294
+ /**
295
+ * Resolve a string tracker value from `ctx.walkerState[key]` into a
296
+ * three-state discriminated result. See {@link WalkerStringResult}
297
+ * for why callers must not conflate `unknown` with `missing`.
298
+ *
299
+ * `initialSentinel` is the tracker's initial value (distinct from
300
+ * its `unknown` sentinel). When `walkerState[key]` equals this
301
+ * sentinel, the result is `missing` - no modifier fired for this
302
+ * dimension in this ref's scope.
303
+ */
304
+ export function walkerString(
305
+ ctx: PredicateContext,
306
+ key: string,
307
+ initialSentinel: string,
308
+ ): WalkerStringResult {
309
+ // Guard against a tracker that reuses `"unknown"` as its initial
310
+ // sentinel. Accepting such a value here would silently collapse the
311
+ // three-way discrimination back into the pre-U1 two-step: the
312
+ // `missing` branch would swallow genuine dynamic-checkout signals
313
+ // and the predicate would exec-fallback onto the PRE-checkout
314
+ // branch — exactly the bug U1 exists to prevent. The JSDoc on
315
+ // WalkerStringResult documents this; the assertion makes the
316
+ // contract un-foot-shootable for new tracker authors.
317
+ if (initialSentinel === "unknown") {
318
+ throw new Error(
319
+ `[pi-steering/git] walkerString: tracker initialSentinel cannot be ` +
320
+ `"unknown" — it's reserved for the unresolvable-dynamic-value ` +
321
+ `signal. Use a distinct initial value (e.g. "" or a sentinel ` +
322
+ `like NO_CHECKOUT_IN_CHAIN). See WalkerStringResult JSDoc.`,
323
+ );
324
+ }
325
+ const v = ctx.walkerState?.[key];
326
+ if (typeof v !== "string") return { kind: "missing" };
327
+ if (v === initialSentinel) return { kind: "missing" };
328
+ if (v === "unknown") return { kind: "unknown" };
329
+ return { kind: "value", value: v };
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // branch
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /**
337
+ * `when.branch` - match the current git branch.
338
+ *
339
+ * Accepted arg shapes:
340
+ *
341
+ * ```ts
342
+ * when: { branch: /^main$/ } // single Pattern
343
+ * when: { branch: "^feat-" } // single Pattern (string)
344
+ * when: { branch: [/^main$/, /^master$/, /^trunk$/] } // Pattern[] (any-of)
345
+ * when: { branch: { pattern: /^main$/, onUnknown: "allow" } } // object form
346
+ * when: { branch: { pattern: [/^main$/, /^master$/], onUnknown: "allow" } }
347
+ * ```
348
+ *
349
+ * Array semantics: OR-of-matches (rule fires when the resolved
350
+ * branch matches ANY of the listed patterns). Empty arrays are
351
+ * invalid (rule skips); arrays containing non-Pattern values are
352
+ * invalid (rule skips).
353
+ *
354
+ * Resolution order:
355
+ * 1. `ctx.walkerState.branch` - set by the branch tracker when the
356
+ * current bash chain contains `git checkout` / `git switch`.
357
+ * Three outcomes:
358
+ * - value resolved statically (e.g. `git checkout main`) ->
359
+ * match the pattern against it.
360
+ * - `"unknown"` sentinel (dynamic checkout like `git checkout
361
+ * $VAR`) -> apply `onUnknown` policy. Do NOT fall through
362
+ * to exec: a `git branch --show-current` call here would
363
+ * return the PRE-checkout branch (the walker exists to
364
+ * track exactly this kind of in-chain change statically).
365
+ * - missing (no checkout in chain) -> fall through to exec.
366
+ * 2. `git branch --show-current` in `ctx.cwd`. Empty stdout is
367
+ * treated as "no branch" (detached HEAD) - the predicate falls
368
+ * back to `onUnknown`.
369
+ *
370
+ * `onUnknown` default is `"block"` (fail-closed): if we can't
371
+ * determine the branch, the predicate reports "match" so
372
+ * branch-gated rules still fire.
373
+ */
374
+ export const branch: PredicateHandler = async (value, ctx) => {
375
+ const arg = unwrapPatternArg(value);
376
+ if (arg === null) return false;
377
+
378
+ // 1. Walker state (tracker-resolved mid-command).
379
+ const fromWalker = walkerString(ctx, "branch", NO_CHECKOUT_IN_CHAIN);
380
+ if (fromWalker.kind === "value") {
381
+ return arg.patterns.some((p) => matchPattern(p, fromWalker.value));
382
+ }
383
+ if (fromWalker.kind === "unknown") {
384
+ // Dynamic in-chain checkout. Exec would return the PRE-checkout
385
+ // branch, which is the case the walker exists to catch. Apply
386
+ // the predicate's `onUnknown` policy instead of falling through.
387
+ return unknownVerdict(arg.onUnknown);
388
+ }
389
+
390
+ // 2. Shell out (tracker saw no in-chain checkout).
391
+ const out = await tryExec(ctx, "git", ["branch", "--show-current"], ctx.cwd);
392
+ if (out === null || out.length === 0) return unknownVerdict(arg.onUnknown);
393
+ return arg.patterns.some((p) => matchPattern(p, out));
394
+ };
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // upstream
398
+ // ---------------------------------------------------------------------------
399
+
400
+ /**
401
+ * `when.upstream` - match the current branch's configured upstream.
402
+ *
403
+ * Accepted arg shapes: same as {@link branch}.
404
+ *
405
+ * Resolves via `git rev-parse --abbrev-ref @{upstream}`. A branch
406
+ * without an upstream set returns a non-zero exit; the predicate then
407
+ * applies `onUnknown`.
408
+ *
409
+ * No tracker today - upstream configuration isn't changed by in-chain
410
+ * git commands at a rate that justifies modelling it (and `git push
411
+ * -u origin main` changes it but only AFTER the push succeeds, which
412
+ * is past the point where a pre-execution guard would act). The
413
+ * per-tool_call exec cache ensures multiple upstream-gated rules share
414
+ * one git call.
415
+ *
416
+ * Runtime-cwd guard: `getUpstream` shells out at `ctx.cwd`. When the
417
+ * walker surfaces `ctx.walkerState.cwd === "unknown"` (dynamic
418
+ * `cd "$VAR/pkg"` the walker couldn't resolve), the exec would run
419
+ * against the pi session cwd — the wrong repo — and a user who opted
420
+ * into `onUnknown: "allow"` would get a silent fail-OPEN. The handler
421
+ * inlines a {@link cwdIsWalkerUnknown} check at the top and surfaces
422
+ * trinary `"unknown"`; the engine's leaf-level (outer) or block-level
423
+ * (inside `not:`) `onUnknown:` policy then projects to the right
424
+ * boolean (default `"block"` = fail-CLOSED).
425
+ *
426
+ * @see walkerUnknownCwdReason — compose the agent-facing reason text
427
+ * for the walker-unknown-cwd fail-closed branch in your rule's
428
+ * ReasonFn.
429
+ */
430
+ export const upstream: PredicateHandler = async (value, ctx) => {
431
+ if (cwdIsWalkerUnknown(ctx)) return "unknown";
432
+ const arg = unwrapPatternArg(value);
433
+ if (arg === null) return false;
434
+
435
+ const out = await getUpstream(ctx);
436
+ if (out === null) return unknownVerdict(arg.onUnknown);
437
+ return arg.patterns.some((p) => matchPattern(p, out));
438
+ };
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // commitsAhead
442
+ // ---------------------------------------------------------------------------
443
+
444
+ /**
445
+ * Argument shape for {@link commitsAhead}.
446
+ *
447
+ * ```ts
448
+ * when: { commitsAhead: { eq: 1 } } // exactly one ahead
449
+ * when: { commitsAhead: { gt: 0 } } // at least one
450
+ * when: { commitsAhead: { lt: 5 } } // fewer than five
451
+ * when: { commitsAhead: { gt: 0, lt: 5 } } // 1..4
452
+ * when: { commitsAhead: { wrt: "origin/main", eq: 1 } }
453
+ * ```
454
+ *
455
+ * At least one of `eq` / `gt` / `lt` MUST be specified. All provided
456
+ * comparisons must pass (AND). `wrt` is the git revision expression
457
+ * to count commits behind (`git rev-list --count WRT..HEAD`); it
458
+ * defaults to `@{upstream}`.
459
+ */
460
+ export interface CommitsAheadArgs {
461
+ /** Git revision to count commits ahead of. Defaults to `@{upstream}`. */
462
+ wrt?: string;
463
+ /** Exact equality: `count === eq`. */
464
+ eq?: number;
465
+ /** Strict greater-than: `count > gt`. */
466
+ gt?: number;
467
+ /** Strict less-than: `count < lt`. */
468
+ lt?: number;
469
+ }
470
+
471
+ /**
472
+ * `when.commitsAhead` - match when commits-ahead-of-WRT satisfy every
473
+ * supplied comparator.
474
+ *
475
+ * Returns `false` (rule doesn't fire) when:
476
+ * - the arg shape isn't an object with at least one of `eq` / `gt`
477
+ * / `lt`,
478
+ * - the `git rev-list` call fails,
479
+ * - the comparator chain doesn't match.
480
+ *
481
+ * No `onUnknown` here: commits-ahead is a numeric comparator, not a
482
+ * pattern match, and "I couldn't learn the answer" arguably shouldn't
483
+ * fire a rule that's gated on a specific count. Authors who want the
484
+ * fail-closed behavior can layer `{ upstream: "..." }` first in the
485
+ * same `when` (AND semantics via the ADR's plugin predicates) - that
486
+ * handles the "no upstream" case with explicit `onUnknown`.
487
+ *
488
+ * Runtime-cwd guard: `getCommitsAhead` shells out at `ctx.cwd`. When
489
+ * the walker surfaces `ctx.walkerState.cwd === "unknown"`, the exec
490
+ * would run against the pi session cwd — wrong repo — and the
491
+ * `count === null` failure path returns `false`, silently skipping
492
+ * the rule (fail-OPEN). The handler inlines a
493
+ * {@link cwdIsWalkerUnknown} check at the top and surfaces trinary
494
+ * `"unknown"`; the engine's `onUnknown:` policy then projects to the
495
+ * right boolean (default `"block"` = fail-CLOSED, matching the policy
496
+ * used by the other runtime-cwd predicates in this plugin).
497
+ *
498
+ * @see walkerUnknownCwdReason — compose the agent-facing reason text
499
+ * for the walker-unknown-cwd fail-closed branch in your rule's
500
+ * ReasonFn.
501
+ * @see PiSteeringPredicates.commitsAhead — the registry entry that
502
+ * declares the bare / spreadBase shape this handler dispatches
503
+ * on.
504
+ */
505
+ export const commitsAhead: PredicateHandler<number | CommitsAheadArgs> = async (
506
+ args,
507
+ ctx,
508
+ ) => {
509
+ if (cwdIsWalkerUnknown(ctx)) return "unknown";
510
+ // Schema advertises `PredicateShape<number, { eq?, gt?, lt?, wrt? }>`
511
+ // — bare-number `commitsAhead: N` is sugar for `{ eq: N }`.
512
+ let eq: number | undefined;
513
+ let gt: number | undefined;
514
+ let lt: number | undefined;
515
+ let wrt: string = "@{upstream}";
516
+ if (typeof args === "number") {
517
+ eq = args;
518
+ } else if (args !== null && typeof args === "object") {
519
+ const shape = args as CommitsAheadArgs;
520
+ eq = shape.eq;
521
+ gt = shape.gt;
522
+ lt = shape.lt;
523
+ wrt = shape.wrt ?? "@{upstream}";
524
+ } else {
525
+ return false;
526
+ }
527
+ if (eq === undefined && gt === undefined && lt === undefined) {
528
+ return false;
529
+ }
530
+
531
+ const count = await getCommitsAhead(ctx, wrt);
532
+ if (count === null) return false;
533
+
534
+ if (eq !== undefined && count !== eq) return false;
535
+ if (gt !== undefined && !(count > gt)) return false;
536
+ if (lt !== undefined && !(count < lt)) return false;
537
+ return true;
538
+ };
539
+
540
+ // ---------------------------------------------------------------------------
541
+ // hasStagedChanges
542
+ // ---------------------------------------------------------------------------
543
+
544
+ /**
545
+ * `when.hasStagedChanges` - match on the presence / absence of staged
546
+ * changes in the repo at `ctx.cwd`.
547
+ *
548
+ * - `when: { hasStagedChanges: true }` - fires when there ARE staged
549
+ * changes.
550
+ * - `when: { hasStagedChanges: false }` - fires when there are NOT.
551
+ *
552
+ * Uses `git diff --cached --quiet`: exit 0 = no staged changes, exit
553
+ * 1 = staged changes exist. On any other exit / spawn failure, we
554
+ * conservatively report `false` - the caller can AND this with an
555
+ * `upstream` check if fail-closed behavior is needed.
556
+ *
557
+ * Runtime-cwd guard: the underlying `git diff --cached` call runs
558
+ * at `ctx.cwd`. When the walker surfaces `ctx.walkerState.cwd ===
559
+ * "unknown"` (dynamic `cd "$VAR/pkg"` the walker couldn't resolve),
560
+ * `ctx.cwd` falls back to the pre-cd ambient cwd — the PI session
561
+ * cwd, not the intended subpackage. The handler inlines a
562
+ * {@link cwdIsWalkerUnknown} check at the top and surfaces trinary
563
+ * `"unknown"`; the engine's `onUnknown:` policy then projects to the
564
+ * right boolean (default `"block"` = fail-CLOSED).
565
+ *
566
+ * @see walkerUnknownCwdReason — compose the agent-facing reason text
567
+ * for the walker-unknown-cwd fail-closed branch in your rule's
568
+ * ReasonFn.
569
+ * @see PiSteeringPredicates.hasStagedChanges — the registry entry
570
+ * that declares the bare / spreadBase shape this handler
571
+ * dispatches on.
572
+ */
573
+ export const hasStagedChanges: PredicateHandler<
574
+ boolean | { value: boolean; onUnknown?: "allow" | "block" }
575
+ > = async (args, ctx) => {
576
+ if (cwdIsWalkerUnknown(ctx)) return "unknown";
577
+ // Schema's `PredicateShape<boolean>` auto-detects spreadBase to
578
+ // `{ value: boolean; onUnknown? }`; unwrap consumes `value:` only.
579
+ // Authors attach modifiers via `{ value: true, onUnknown: "allow" }`;
580
+ // the engine reads any `onUnknown:` sibling via readLeafOnUnknown
581
+ // and projects the handler's `"unknown"` returns under that policy
582
+ // (the handler itself treats `onUnknown:` as an opaque sibling field).
583
+ const expected = unwrapBooleanLeafArg(args);
584
+ if (expected === undefined) return false;
585
+ const state = await getStagedChanges(ctx);
586
+ if (state === null) return false; // unknown — don't fire
587
+ return expected === state;
588
+ };
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // isClean
592
+ // ---------------------------------------------------------------------------
593
+
594
+ /**
595
+ * `when.isClean` - match on the working tree's cleanliness at
596
+ * `ctx.cwd`.
597
+ *
598
+ * - `when: { isClean: true }` - fires when the working tree is
599
+ * clean (no unstaged, no untracked, no staged changes).
600
+ * - `when: { isClean: false }` - fires when the working tree is
601
+ * dirty.
602
+ *
603
+ * Uses `git status --porcelain`: empty stdout = clean. Non-zero exit
604
+ * returns `false` (unknown); pair with an `upstream` check for
605
+ * fail-closed behavior.
606
+ *
607
+ * Runtime-cwd guard: same rationale as {@link hasStagedChanges} — the
608
+ * handler inlines a {@link cwdIsWalkerUnknown} check at the top and
609
+ * surfaces trinary `"unknown"` when the walker couldn't statically
610
+ * resolve the command's effective cwd, rather than silently running
611
+ * `git status` at the pi session cwd.
612
+ *
613
+ * @see walkerUnknownCwdReason — compose the agent-facing reason text
614
+ * for the walker-unknown-cwd fail-closed branch in your rule's
615
+ * ReasonFn.
616
+ * @see PiSteeringPredicates.isClean — the registry entry that
617
+ * declares the bare / spreadBase shape this handler dispatches
618
+ * on.
619
+ */
620
+ export const isClean: PredicateHandler<
621
+ boolean | { value: boolean; onUnknown?: "allow" | "block" }
622
+ > = async (args, ctx) => {
623
+ if (cwdIsWalkerUnknown(ctx)) return "unknown";
624
+ // Schema's `PredicateShape<boolean>` auto-detects spreadBase to
625
+ // `{ value: boolean; onUnknown? }`; unwrap consumes `value:` only.
626
+ // Authors attach modifiers via `{ value: true, onUnknown: "allow" }`;
627
+ // the engine reads any `onUnknown:` sibling via readLeafOnUnknown
628
+ // and projects the handler's `"unknown"` returns under that policy
629
+ // (the handler itself treats `onUnknown:` as an opaque sibling field).
630
+ const expected = unwrapBooleanLeafArg(args);
631
+ if (expected === undefined) return false;
632
+ const clean = await getWorkingTreeClean(ctx);
633
+ if (clean === null) return false;
634
+ return expected === clean;
635
+ };
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // remote
639
+ // ---------------------------------------------------------------------------
640
+
641
+ /**
642
+ * `when.remote` - match the repo's `origin` remote URL.
643
+ *
644
+ * Accepted arg shapes: same as {@link branch}. Useful for rules that
645
+ * should only fire in specific repos ("never force-push to
646
+ * github.com/org/prod").
647
+ *
648
+ * Resolves via `git config --get remote.origin.url`. Non-zero exit
649
+ * (no origin configured) falls back to `onUnknown`.
650
+ *
651
+ * Runtime-cwd guard: the handler inlines a {@link cwdIsWalkerUnknown}
652
+ * check at the top and surfaces trinary `"unknown"` when the walker
653
+ * couldn't statically resolve the command's effective cwd — querying
654
+ * the wrong repo's remote would silently mis-route a repo-gated rule.
655
+ * Same rationale as {@link hasStagedChanges}.
656
+ *
657
+ * @see walkerUnknownCwdReason — compose the agent-facing reason text
658
+ * for the walker-unknown-cwd fail-closed branch in your rule's
659
+ * ReasonFn.
660
+ */
661
+ export const remote: PredicateHandler = async (value, ctx) => {
662
+ if (cwdIsWalkerUnknown(ctx)) return "unknown";
663
+ const arg = unwrapPatternArg(value);
664
+ if (arg === null) return false;
665
+
666
+ const out = await getRemoteUrl(ctx);
667
+ if (out === null) return unknownVerdict(arg.onUnknown);
668
+ return arg.patterns.some((p) => matchPattern(p, out));
669
+ };
670
+
671
+ // ---------------------------------------------------------------------------
672
+ // Plugin-level export
673
+ // ---------------------------------------------------------------------------
674
+
675
+ /**
676
+ * Bundle of predicate handlers the git plugin registers under
677
+ * `Plugin.predicates`. Keys become the `when.<key>` slots rule authors
678
+ * see.
679
+ *
680
+ * Typed as `Record<string, AnyPredicateHandler>` to match
681
+ * {@link Plugin.predicates} at the registry boundary — each handler's
682
+ * concrete argument shape is preserved in its individual declaration
683
+ * above, and consumers can import `commitsAhead`, `isClean`, etc.
684
+ * directly when they want the narrow type.
685
+ */
686
+ export const predicates: Record<string, AnyPredicateHandler> = {
687
+ branch,
688
+ upstream,
689
+ commitsAhead,
690
+ hasStagedChanges,
691
+ isClean,
692
+ remote,
693
+ };