@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,307 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Plugin-shipped rules for the git plugin.
6
+ *
7
+ * Rules here ship as SUGGESTED defaults - the plugin is opt-in (users
8
+ * must explicitly import and list it under `plugins: [...]`), so
9
+ * shipping a curated starter set matches the ADR's "distribution unit
10
+ * for rule packs" framing.
11
+ *
12
+ * Users who want the branch predicate but NOT `no-main-commit` can
13
+ * keep it by disabling the rule selectively:
14
+ *
15
+ * ```ts
16
+ * defineConfig({
17
+ * plugins: [gitPlugin],
18
+ * disabledRules: ["no-main-commit"],
19
+ * });
20
+ * ```
21
+ *
22
+ * Rules ride on the branch predicate registered in `./predicates.ts`
23
+ * and the branch tracker in `./branch-tracker.ts`. The tracker makes
24
+ * the rule bypass-proof against the `git checkout main && git commit`
25
+ * pattern: the walker folds the checkout into the branch seen by the
26
+ * commit, so the rule still fires.
27
+ */
28
+
29
+ import type { Rule } from "../../schema.ts";
30
+ import { NO_CHECKOUT_IN_CHAIN } from "./branch-tracker.ts";
31
+ import { walkerString } from "./predicates.ts";
32
+
33
+ /**
34
+ * Bash command pattern matching `git commit` (with optional pre-subcommand
35
+ * flag slots like `git -C /path commit ...`). Shared by `no-main-commit`
36
+ * and `no-main-commit-github` so the family stays byte-equal as the
37
+ * regex evolves; reorderings that touch one rule's pattern can't
38
+ * silently drift from the other.
39
+ *
40
+ * Exported so tests can pin each rule's `pattern` field against this
41
+ * constant by value (`noMainCommit.pattern === GIT_COMMIT_PATTERN`).
42
+ * That catches accidental divergence between the two rules' patterns
43
+ * (e.g. one drops a `\b`, the other doesn't) and removal/rename of
44
+ * the constant itself. It does NOT catch a future inlining of the
45
+ * literal at a rule's definition site with the SAME bytes — string
46
+ * primitives compare by value, so byte-equal copy-pasted literals
47
+ * pass `===`. Plugin authors who need true shared-reference pinning
48
+ * should use a `RegExp` (object) constant instead of a string source.
49
+ */
50
+ export const GIT_COMMIT_PATTERN =
51
+ "^git\\b(?:\\s+-{1,2}[A-Za-z]\\S*(?:\\s+\\S+)?)*\\s+commit\\b";
52
+
53
+ /**
54
+ * Protected branch names that the gitPlugin's commit-on-main rules
55
+ * block by default (`main`, `master`, `mainline`, `trunk`). Shared by
56
+ * `no-main-commit` and `no-main-commit-github` so the protected-
57
+ * branch list stays uniform across the rule family — adding an alias
58
+ * here (e.g. a vendor-specific default-branch name) automatically
59
+ * propagates to both rules.
60
+ *
61
+ * `RegExp` (object) constant rather than a string source: that gives
62
+ * true shared-reference pinning at the test layer
63
+ * (`noMainCommit.when.branch === PROTECTED_BRANCH_PATTERN`), which
64
+ * also catches a future inline of the SAME bytes at a rule's
65
+ * definition site — something the string-source `GIT_COMMIT_PATTERN`
66
+ * pin can't do (see its JSDoc for the value-vs-reference tradeoff).
67
+ */
68
+ export const PROTECTED_BRANCH_PATTERN = /^(main|master|mainline|trunk)$/;
69
+
70
+ /**
71
+ * `no-main-commit` - block direct commits to a protected branch
72
+ * (main / master / mainline / trunk).
73
+ *
74
+ * Fires on:
75
+ * - `git commit -m "..."` when the current branch is one of the
76
+ * protected names,
77
+ * - `git checkout main && git commit ...` (the branch tracker folds
78
+ * the checkout into the branch state for the commit),
79
+ * - `sh -c 'git commit ...'` (wrapper expansion),
80
+ * - `git -C /other commit ...` where the repo at `/other` is on
81
+ * main (the `branch` predicate queries git at the effective cwd).
82
+ *
83
+ * Does NOT fire on:
84
+ * - `git commit` while on a feature branch,
85
+ * - `git log --grep="commit"` (anchored to `git commit`, not
86
+ * arbitrary git subcommands),
87
+ * - `echo 'git commit -m "x"'` (extraction anchors to the
88
+ * basename).
89
+ *
90
+ * Fail-closed on unresolvable branch: if the branch predicate can't
91
+ * determine the current branch (detached HEAD, not a repo, or the
92
+ * tracker collapsed to `unknown` via `git checkout $VAR`), the rule
93
+ * fires by default. Authors who want the allow-through behavior
94
+ * supply the object form explicitly:
95
+ *
96
+ * `when: { branch: { pattern: /.../, onUnknown: "allow" } }`
97
+ *
98
+ * Reason text is dynamic via {@link ReasonFn}: when the branch
99
+ * tracker has resolved a concrete branch name for the guarded
100
+ * command (statically from a `git checkout <name>` earlier in the
101
+ * chain), the name is injected into the block message so the agent
102
+ * sees "You are on 'main'" instead of a generic reminder. The
103
+ * ReasonFn filters out the tracker's internal sentinels
104
+ * (`NO_CHECKOUT_IN_CHAIN` — no in-chain checkout, exec-fallback
105
+ * path; `"unknown"` — dynamic checkout the walker couldn't
106
+ * resolve) so those strings never leak into the agent-facing
107
+ * message; the static actionable tail still guides the agent to a
108
+ * feature branch in those cases.
109
+ *
110
+ * Pairs with {@link noMainCommitGithub} (specialization for
111
+ * github.com clones, placed BEFORE this rule in the rule array so
112
+ * first-match-wins routes the github-flavored guidance to github
113
+ * users; non-github contexts fall through to this generic rule).
114
+ *
115
+ * Override: allowed (the rule is overridable via a
116
+ * `# steering-override: no-main-commit` comment). This is a workflow
117
+ * rule, not an inherent-destructiveness rule - authors override when
118
+ * the commit is intentional (e.g. release process on `main`).
119
+ */
120
+ export const noMainCommit = {
121
+ name: "no-main-commit",
122
+ tool: "bash",
123
+ field: "command",
124
+ pattern: GIT_COMMIT_PATTERN,
125
+ when: { branch: PROTECTED_BRANCH_PATTERN },
126
+ reason: (ctx) => {
127
+ // Delegate the sentinel classification to `walkerString` — the
128
+ // same three-way discrimination (value / unknown / missing)
129
+ // every other branch-consumer in this plugin uses. Single source
130
+ // of truth for tracker-sentinel semantics; future sentinel
131
+ // additions update one site (the classifier in predicates.ts),
132
+ // not this filter too. Empty-string remains filtered inline as
133
+ // a defensive check against future tracker contracts (detached
134
+ // HEAD or similar); the branch tracker doesn't emit it today.
135
+ const res = walkerString(ctx, "branch", NO_CHECKOUT_IN_CHAIN);
136
+ const branch =
137
+ res.kind === "value" && res.value !== "" ? res.value : undefined;
138
+ const onClause = branch !== undefined ? ` You are on '${branch}'.` : "";
139
+ return (
140
+ `Don't commit directly to a protected branch ` +
141
+ `(main / master / mainline / trunk).${onClause} ` +
142
+ `Create a feature branch first: \`git checkout -b feat/...\`.`
143
+ );
144
+ },
145
+ // Explicit override-OK: workflow rules are intentionally
146
+ // overridable.
147
+ noOverride: false,
148
+ } as const satisfies Rule;
149
+
150
+ /**
151
+ * `no-main-commit-github` — block direct commits to a protected
152
+ * branch (main / master / mainline / trunk) on github.com clones.
153
+ * Specialization of {@link noMainCommit} that emits PR-flow guidance
154
+ * instead of the generic feature-branch reminder.
155
+ *
156
+ * Pairs with {@link noMainCommit}: this rule is more specific (adds
157
+ * `remote:` check), placed BEFORE `noMainCommit` in the plugin's
158
+ * rule array so first-match-wins routing surfaces the github-
159
+ * flavored reason on github clones. Non-github contexts (Brazil
160
+ * packages, vault paths, /tmp scratch repos with non-github remotes)
161
+ * fall through to the generic `noMainCommit`.
162
+ *
163
+ * Override: allowed (intentionally overridable for legitimate cases
164
+ * like release-process commits to main). User can:
165
+ * - Disable: `disabledRules: ["no-main-commit-github"]`
166
+ * - Per-invocation: `# steering-override: no-main-commit-github` comment
167
+ * - Customize: see gitPlugin's README "Customization" section
168
+ *
169
+ * @remarks Both `https://github.com/...` and `git@github.com:...`
170
+ * clone URLs are matched (the `remote:` regex's `[/:]`
171
+ * character class accepts both the HTTPS path separator
172
+ * and the SSH user-host separator).
173
+ *
174
+ * @remarks `remote:` is configured as `{ pattern, onUnknown: "allow" }`.
175
+ * The `onUnknown: "allow"` argument governs the case where
176
+ * the walker resolved cwd but the inner exec couldn't
177
+ * determine the remote URL — i.e. a known-cwd repo with no
178
+ * `origin` remote configured (fresh-init, repo with
179
+ * `upstream` but no `origin`, or other exec failure). In that
180
+ * case the predicate skips and the engine falls through to
181
+ * the generic `noMainCommit` (correct generic message).
182
+ * The branch predicate stays at default `onUnknown: "block"`
183
+ * (fail-closed) so protected-branch detection isn't weakened.
184
+ *
185
+ * Walker-unknown cwd: the `remote:` leaf surfaces trinary
186
+ * `"unknown"` (via the inline walker-unknown-cwd guard at the top of
187
+ * the handler) before the inner exec runs whenever
188
+ * `walkerState.cwd === "unknown"`. The engine's leaf-level
189
+ * `onUnknown:` policy reads the `"allow"` set on the leaf, projects
190
+ * `"unknown" → false`, and the github rule SKIPS. The engine then
191
+ * falls through to the generic {@link noMainCommit} (only `branch:`
192
+ * leaf), which fires fail-CLOSED and emits the generic
193
+ * protected-branch reason. The github-specific rule deliberately
194
+ * does NOT fire under walker-unknown cwd — a github-flavored reason
195
+ * would overstate what the engine has confirmed.
196
+ *
197
+ * Walker-unknown branch state: when `walkerState.cwd` is known but
198
+ * the branch tracker collapses to its `"unknown"` sentinel (dynamic
199
+ * checkout the walker couldn't resolve), the reason fn routes to a
200
+ * "could not verify the current branch" message rather than
201
+ * asserting a specific protected branch the engine hasn't
202
+ * confirmed.
203
+ *
204
+ * Pattern is shared with `noMainCommit` via the exported
205
+ * {@link GIT_COMMIT_PATTERN} constant so the two rules' bash-
206
+ * command applicability stays byte-equal as the family evolves.
207
+ *
208
+ * @see {@link noMainCommit}
209
+ */
210
+ export const noMainCommitGithub = {
211
+ name: "no-main-commit-github",
212
+ tool: "bash",
213
+ field: "command",
214
+ pattern: GIT_COMMIT_PATTERN,
215
+ when: {
216
+ branch: PROTECTED_BRANCH_PATTERN,
217
+ // Intentional fail-OPEN — falls through to the generic
218
+ // `noMainCommit` rule when origin can't be resolved.
219
+ //
220
+ // `onUnknown: "allow"` posture: a github-specialized rule
221
+ // should only fire when github context is confirmed. The
222
+ // `"allow"` covers two opt-out paths:
223
+ // 1. Known-cwd no-`origin` case (fresh-init repo, repo with
224
+ // `upstream` but no `origin`, or other exec failure):
225
+ // handler returns `false`, leaf projects to false, github
226
+ // rule skips. Generic `noMainCommit` fires next — user
227
+ // gets the correct generic message rather than PR-flow
228
+ // guidance for a repo where there's no PR to open.
229
+ // 2. Walker-unknown cwd (`cd "$VAR" && git commit`): the
230
+ // handler's inline guard surfaces trinary `"unknown"`,
231
+ // the leaf-level `"allow"` projects to false, github rule
232
+ // skips. Generic `noMainCommit` fires fail-CLOSED via the
233
+ // branch predicate's default `onUnknown: "block"`.
234
+ // Both paths land on the same generic protected-branch reason,
235
+ // avoiding overstated github-specific claims when context is
236
+ // unverified.
237
+ remote: { pattern: /github\.com[/:]/, onUnknown: "allow" },
238
+ },
239
+ reason: (ctx) => {
240
+ // Walker-unknown branch: protected-branch unverified. Don't
241
+ // make a positive claim about which protected branch is
242
+ // involved.
243
+ //
244
+ // (CWD is always known in this rule's reason fn — `remote:`'s
245
+ // `onUnknown: "allow"` projects walker-unknown CWD to false at
246
+ // the leaf level, so the rule skips to the generic
247
+ // `noMainCommit` before this reason fn runs.)
248
+ const branchRes = walkerString(ctx, "branch", NO_CHECKOUT_IN_CHAIN);
249
+ if (branchRes.kind === "unknown") {
250
+ return (
251
+ `Could not verify the current branch — your command used a ` +
252
+ `dynamic checkout target (\`git checkout $VAR\`) that ` +
253
+ `couldn't be statically resolved. If you're on a protected ` +
254
+ `branch (main / master / mainline / trunk) on a github ` +
255
+ `clone, open a PR for review instead of committing directly.` +
256
+ `\n\n` +
257
+ `Safety: NEVER merge a PR or mark it ready-for-review unless ` +
258
+ `the user explicitly asks. Wait for explicit user instruction.`
259
+ );
260
+ }
261
+ // Reuse the same walkerString-based branch interpolation idiom
262
+ // noMainCommit's reason fn uses (single source of truth for
263
+ // tracker-sentinel semantics).
264
+ const branch =
265
+ branchRes.kind === "value" && branchRes.value !== ""
266
+ ? branchRes.value
267
+ : undefined;
268
+ const onClause = branch !== undefined ? ` You are on '${branch}'.` : "";
269
+ // Prose says "protected branch" rather than "main branch":
270
+ // the `when:` clause matches all four protected branch names
271
+ // (main / master / mainline / trunk). Mirrors
272
+ // `noMainCommit`'s wording, which lists them.
273
+ return (
274
+ `You're on a github clone's protected branch.${onClause} Open ` +
275
+ `a PR for review; \`gh pr merge\` lands the change after ` +
276
+ `approval. Direct commits to a protected branch bypass review ` +
277
+ `and break PR discipline.` +
278
+ `\n\n` +
279
+ `Safety: NEVER merge a PR or mark it ready-for-review unless ` +
280
+ `the user explicitly asks. Wait for explicit user instruction.`
281
+ );
282
+ },
283
+ // Explicit override-OK: workflow rules are intentionally
284
+ // overridable. Mirrors `noMainCommit`'s posture. Without this
285
+ // field the schema defaults to `defaultNoOverride: true`
286
+ // (fail-closed), making the rule non-overridable and contradicting
287
+ // the JSDoc above.
288
+ noOverride: false,
289
+ } as const satisfies Rule;
290
+
291
+ /**
292
+ * Suggested rules for the git plugin.
293
+ *
294
+ * **Order matters — first-match-wins.** The github-specific rule
295
+ * (`no-main-commit-github`) is placed BEFORE the generic
296
+ * (`no-main-commit`) so on github clones + on main, the github
297
+ * rule's `remote:` predicate matches → fires first → user gets
298
+ * PR-flow guidance. On non-github contexts (Brazil packages, vault
299
+ * paths, scratch repos with non-github remotes) the github rule's
300
+ * `remote:` predicate doesn't match → the engine falls through to
301
+ * the generic `no-main-commit`. Reordering for stylistic reasons
302
+ * breaks this routing; pinned via a unit test in `./rules.test.ts`.
303
+ */
304
+ export const rules = [
305
+ noMainCommitGithub,
306
+ noMainCommit,
307
+ ] as const satisfies readonly Rule[];
@@ -0,0 +1,259 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Runtime shape smoke tests for the v2 schema types.
6
+ *
7
+ * These tests construct object literals and assert their runtime shape —
8
+ * they do NOT currently exercise compile-time type enforcement via
9
+ * `@ts-expect-error` directives. Real type-level regression tests for
10
+ * `defineConfig` inference live in `define-config.test.ts`.
11
+ *
12
+ * The value here is: if the schema types drift incompatibly with their
13
+ * runtime contract (e.g., a field is renamed but the Rule interface
14
+ * isn't updated), these tests fail to compile even without explicit
15
+ * negative assertions. For richer type enforcement, extend with
16
+ * `@ts-expect-error` cases or move to `define-config.test.ts`.
17
+ */
18
+
19
+ import assert from "node:assert/strict";
20
+ import { describe, it } from "node:test";
21
+ import type {
22
+ Observer,
23
+ Plugin,
24
+ PredicateContext,
25
+ PredicateFn,
26
+ PredicateHandler,
27
+ Rule,
28
+ SteeringConfig,
29
+ WhenClause,
30
+ } from "./schema.ts";
31
+
32
+ describe("schema: shape smoke tests", () => {
33
+ it("Rule accepts a minimal bash rule with a string pattern", () => {
34
+ const rule: Rule = {
35
+ name: "test",
36
+ tool: "bash",
37
+ field: "command",
38
+ pattern: "^git push --force",
39
+ reason: "nope",
40
+ };
41
+ assert.equal(rule.name, "test");
42
+ });
43
+
44
+ it("Rule accepts RegExp in pattern / requires / unless", () => {
45
+ const rule: Rule = {
46
+ name: "regex-rule",
47
+ tool: "bash",
48
+ field: "command",
49
+ pattern: /^git push/,
50
+ requires: /--force/,
51
+ unless: /--force-with-lease/,
52
+ reason: "nope",
53
+ };
54
+ assert.ok(rule.pattern instanceof RegExp);
55
+ });
56
+
57
+ it("Rule accepts PredicateFn in requires / unless", () => {
58
+ const always: PredicateFn = async (_ctx) => true;
59
+ const rule: Rule = {
60
+ name: "fn-rule",
61
+ tool: "bash",
62
+ field: "command",
63
+ pattern: "^git",
64
+ requires: always,
65
+ unless: async (ctx) => ctx.tool === "bash",
66
+ reason: "nope",
67
+ };
68
+ assert.equal(typeof rule.requires, "function");
69
+ });
70
+
71
+ it("WhenClause accepts cwd as pattern or object with onUnknown", () => {
72
+ const w1: WhenClause = { cwd: "^/workplace" };
73
+ const w2: WhenClause = { cwd: /^\/workplace/ };
74
+ const w3: WhenClause = {
75
+ cwd: { pattern: /^\/workplace/, onUnknown: "block" },
76
+ };
77
+ assert.ok(w1.cwd !== undefined);
78
+ assert.ok(w2.cwd !== undefined);
79
+ assert.ok(w3.cwd !== undefined);
80
+ });
81
+
82
+ it("WhenClause.condition accepts a PredicateFn", () => {
83
+ const w: WhenClause = {
84
+ condition: (ctx) => ctx.tool === "bash",
85
+ };
86
+ assert.equal(typeof w.condition, "function");
87
+ });
88
+
89
+ it("WhenClause accepts plugin-registered custom keys", () => {
90
+ // Shape a plugin predicate registers. Consumers widen the
91
+ // index signature to carry arbitrary shapes.
92
+ const w: WhenClause = {
93
+ commitsAhead: { wrt: "origin/main", eq: 1 },
94
+ };
95
+ assert.ok("commitsAhead" in w);
96
+ });
97
+
98
+ it("Observer accepts minimal shape (name + onResult)", () => {
99
+ const obs: Observer = {
100
+ name: "spy",
101
+ onResult: (_ev, _ctx) => {},
102
+ };
103
+ assert.equal(obs.name, "spy");
104
+ });
105
+
106
+ it("Observer.watch accepts every documented filter", () => {
107
+ const obs: Observer = {
108
+ name: "filtered",
109
+ watch: {
110
+ toolName: "bash",
111
+ inputMatches: { command: /^git/ },
112
+ exitCode: "success",
113
+ },
114
+ onResult: () => {},
115
+ };
116
+ assert.equal(obs.watch?.exitCode, "success");
117
+ });
118
+
119
+ it("Plugin accepts every documented extension point", () => {
120
+ const fakeHandler: PredicateHandler<{ wrt: string }> = (args, _ctx) =>
121
+ args.wrt.startsWith("origin/");
122
+ const plugin: Plugin = {
123
+ name: "fake",
124
+ // No cast on the typed `PredicateHandler<{ wrt: string }>` here —
125
+ // Item 2 of PR #5 switched `Plugin.predicates` to
126
+ // `Record<string, AnyPredicateHandler>`, so specifically-typed
127
+ // handlers assign into the registry slot directly.
128
+ predicates: { commitsAhead: fakeHandler },
129
+ rules: [
130
+ {
131
+ name: "r",
132
+ tool: "bash",
133
+ field: "command",
134
+ pattern: /^x/,
135
+ reason: "n",
136
+ },
137
+ ],
138
+ observers: [{ name: "o", onResult: () => {} }],
139
+ // trackers and trackerExtensions left undefined — the
140
+ // schema-level type is just `Record<string, Tracker<unknown>>`,
141
+ // constructing a live tracker here would pull in walker
142
+ // runtime code we don't need for a shape test.
143
+ };
144
+ assert.equal(plugin.name, "fake");
145
+ });
146
+
147
+ it("Plugin.predicates accepts typed PredicateHandler<A> values without a cast", () => {
148
+ // Pins the Item 2 fix: `Plugin.predicates` is
149
+ // `Record<string, AnyPredicateHandler>`, so a specifically-typed
150
+ // `PredicateHandler<FooArgs>` assigns cast-free at the registry
151
+ // slot. If this starts failing to compile, the alias was
152
+ // widened/narrowed incompatibly — every downstream plugin that
153
+ // registers a typed handler will break on the same change.
154
+ interface FooArgs {
155
+ threshold: number;
156
+ }
157
+ const typed: PredicateHandler<FooArgs> = (args) => args.threshold > 0;
158
+ const plugin = {
159
+ name: "typed-predicates",
160
+ predicates: { foo: typed },
161
+ } satisfies Plugin;
162
+ assert.ok(plugin.predicates);
163
+ });
164
+
165
+ it("Rule.observer accepts inline Observer and string reference", () => {
166
+ const inline: Rule = {
167
+ name: "inline-obs",
168
+ tool: "bash",
169
+ field: "command",
170
+ pattern: /./,
171
+ reason: "r",
172
+ observer: { name: "o", onResult: () => {} },
173
+ };
174
+ const byName: Rule<"o"> = {
175
+ name: "by-name",
176
+ tool: "bash",
177
+ field: "command",
178
+ pattern: /./,
179
+ reason: "r",
180
+ observer: "o",
181
+ };
182
+ assert.equal(byName.observer, "o");
183
+ assert.ok(typeof inline.observer === "object");
184
+ });
185
+
186
+ it("Rule accepts writes + onFire for self-marking patterns", () => {
187
+ const rule: Rule = {
188
+ name: "self-marker",
189
+ tool: "bash",
190
+ field: "command",
191
+ pattern: /./,
192
+ reason: "r",
193
+ writes: ["cr-attempted"],
194
+ onFire: (ctx) => {
195
+ ctx.appendEntry("cr-attempted", {});
196
+ },
197
+ };
198
+ assert.deepEqual(rule.writes, ["cr-attempted"]);
199
+ assert.equal(typeof rule.onFire, "function");
200
+ });
201
+
202
+ it("Observer accepts writes declaration", () => {
203
+ const obs: Observer = {
204
+ name: "tracker",
205
+ writes: ["ws-sync-done", "ws-sync-failed"],
206
+ onResult: () => {},
207
+ };
208
+ assert.deepEqual(obs.writes, ["ws-sync-done", "ws-sync-failed"]);
209
+ });
210
+
211
+ it("WhenClause.happened accepts the { event, in } shape", () => {
212
+ const loop: WhenClause = {
213
+ happened: { event: "ws-sync-done", in: "agent_loop" },
214
+ };
215
+ const session: WhenClause = {
216
+ happened: { event: "welcome-shown", in: "session" },
217
+ };
218
+ assert.equal(loop.happened?.event, "ws-sync-done");
219
+ assert.equal(session.happened?.in, "session");
220
+ });
221
+
222
+ it("SteeringConfig accepts every top-level field", () => {
223
+ const cfg: SteeringConfig = {
224
+ defaultNoOverride: true,
225
+ disabledRules: ["no-force-push"],
226
+ disabledPlugins: ["git"],
227
+ disableDefaults: false,
228
+ plugins: [],
229
+ rules: [],
230
+ observers: [],
231
+ };
232
+ assert.equal(cfg.defaultNoOverride, true);
233
+ });
234
+
235
+ it("PredicateContext exposes the documented surface", () => {
236
+ // Pure shape test — we never invoke the context.
237
+ const fake: PredicateContext = {
238
+ cwd: "/",
239
+ tool: "bash",
240
+ input: { tool: "bash", command: "echo" },
241
+ agentLoopIndex: 0,
242
+ exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
243
+ appendEntry: () => {},
244
+ findEntries: () => [],
245
+ };
246
+ assert.equal(fake.cwd, "/");
247
+ });
248
+ });
249
+
250
+ describe("package-root exports", () => {
251
+ it("AGENT_LOOP_INDEX_KEY resolves to the on-disk JSONL tag string (C3)", async () => {
252
+ // Pins the public export path. Plugin authors inspecting raw
253
+ // session entries via `findEntries` import the constant instead
254
+ // of hardcoding the string — a future rename would then break at
255
+ // import time, not at runtime.
256
+ const rootExports = await import("./index.ts");
257
+ assert.equal(rootExports.AGENT_LOOP_INDEX_KEY, "_agentLoopIndex");
258
+ });
259
+ });