@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,122 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * Work-item example plugin — canonical reference for pi-steering
6
+ * plugin authors (ADR §15).
7
+ *
8
+ * What this plugin demonstrates, file-by-file:
9
+ *
10
+ * - `predicates/work-item-format.ts`
11
+ * - `definePredicate<T>` for typed predicate-arg variance.
12
+ * - Structured arg access via `input.args` (quote-aware).
13
+ * - `observers/npm-test-tracker.ts`
14
+ * - ADR §14 encapsulation convention: file exports the
15
+ * `<EVENT>_EVENT` constant AND a `mark<Event>(ctx)` helper;
16
+ * observer uses the helper.
17
+ * - `writes` declaration threading through for
18
+ * `defineConfig`'s compile-time type checking.
19
+ * - `observers/retest-required-tracker.ts`
20
+ * - Invalidation-sentinel pattern: observer writes
21
+ * `RETEST_REQUIRED_EVENT` on `git pull`, which stale-s
22
+ * prior `TEST_PASSED_EVENT` entries via `happened.since`.
23
+ * - `rules/commit-requires-work-item.ts`
24
+ * - Plugin-registered predicate consumption via `when.<key>`.
25
+ * - `not:` inversion in a {@link TopLevelWhenClause}.
26
+ * - `rules/push-requires-tests.ts`
27
+ * - `when.happened: { in: "agent_loop" }` gating.
28
+ * - Observer → rule coupling via the shared EVENT constants.
29
+ * - Temporal invalidation via `happened.since`.
30
+ * - Chain-aware speculative allow for `npm test && git push`.
31
+ * - `rules/commit-description-check.ts`
32
+ * - Self-marking rules with `onFire`.
33
+ * - Constant + helper co-located with the rule when no
34
+ * observer corresponds (ADR §14).
35
+ *
36
+ * Copy-adapt this layout. A real plugin likely ships more rules and
37
+ * possibly a tracker too — see `src/plugins/git/`
38
+ * for the tracker + tracker-extension pattern.
39
+ *
40
+ * ## Consuming this plugin
41
+ *
42
+ * ```ts
43
+ * import { defineConfig } from "@cad0p/pi-steering";
44
+ * import workItemPlugin from "@examples/work-item-plugin";
45
+ *
46
+ * export default defineConfig({
47
+ * plugins: [workItemPlugin],
48
+ * });
49
+ * ```
50
+ */
51
+
52
+ import type { Plugin, PredicateShape } from "@cad0p/pi-steering";
53
+ import {
54
+ npmTestTracker,
55
+ TEST_PASSED_EVENT,
56
+ } from "./observers/npm-test-tracker.ts";
57
+ import {
58
+ RETEST_REQUIRED_EVENT,
59
+ retestRequiredTracker,
60
+ } from "./observers/retest-required-tracker.ts";
61
+ import type { WorkItemFormatArgs } from "./predicates/work-item-format.ts";
62
+ import { workItemFormat } from "./predicates/work-item-format.ts";
63
+ import {
64
+ commitDescriptionCheck,
65
+ DESCRIPTION_REVIEWED_EVENT,
66
+ } from "./rules/commit-description-check.ts";
67
+ import { commitRequiresWorkItem } from "./rules/commit-requires-work-item.ts";
68
+ import { pushRequiresTests } from "./rules/push-requires-tests.ts";
69
+
70
+ declare global {
71
+ /**
72
+ * Plugin author registers `workItemFormat:` so the engine's mapped
73
+ * type ({@link TopLevelWhenClause}) accepts it as a leaf-level key
74
+ * with a typed argument shape. Without this augmentation, rules in
75
+ * this plugin (or any consumer's rules) referencing `workItemFormat:`
76
+ * would fail typecheck against the strict registry-driven type.
77
+ *
78
+ * @see PredicateShape, PiSteeringPredicates in pi-steering's schema.
79
+ */
80
+ interface PiSteeringPredicates {
81
+ workItemFormat: PredicateShape<WorkItemFormatArgs>;
82
+ }
83
+ }
84
+
85
+ // Re-export the type constants so consumers (e.g. another plugin or
86
+ // a user's custom rule) can gate on the same events without
87
+ // rediscovering the literal strings.
88
+ export { DESCRIPTION_REVIEWED_EVENT, RETEST_REQUIRED_EVENT, TEST_PASSED_EVENT };
89
+
90
+ /**
91
+ * The plugin. `as const satisfies Plugin` preserves the literal
92
+ * `name: "work-item"` and the `writes` tuples from rules/observers
93
+ * so `defineConfig` can cross-reference `when.happened.event` usages
94
+ * against this plugin's declared writes. See the ADR §7 footgun
95
+ * about bare `: Plugin` annotations.
96
+ */
97
+ const workItemPlugin = {
98
+ name: "work-item",
99
+ predicates: { workItemFormat },
100
+ rules: [commitRequiresWorkItem, pushRequiresTests, commitDescriptionCheck],
101
+ observers: [npmTestTracker, retestRequiredTracker],
102
+ } as const satisfies Plugin;
103
+
104
+ export default workItemPlugin;
105
+
106
+ export {
107
+ markTestPassed,
108
+ npmTestTracker,
109
+ } from "./observers/npm-test-tracker.ts";
110
+ export {
111
+ markRetestRequired,
112
+ retestRequiredTracker,
113
+ } from "./observers/retest-required-tracker.ts";
114
+ // Named re-exports — pick-your-piece imports for authors who want
115
+ // just one rule or the predicate.
116
+ export { workItemFormat } from "./predicates/work-item-format.ts";
117
+ export {
118
+ commitDescriptionCheck,
119
+ markDescriptionReviewed,
120
+ } from "./rules/commit-description-check.ts";
121
+ export { commitRequiresWorkItem } from "./rules/commit-requires-work-item.ts";
122
+ export { pushRequiresTests } from "./rules/push-requires-tests.ts";
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * Unit tests for the `npm-test-tracker` observer.
6
+ *
7
+ * Exercises the watch-filter + write-on-match contract via
8
+ * `testObserver`, which captures `appendEntry` calls for inspection.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import { testObserver } from "@cad0p/pi-steering/testing";
14
+ import { npmTestTracker, TEST_PASSED_EVENT } from "./npm-test-tracker.ts";
15
+
16
+ describe("npm-test-tracker observer", () => {
17
+ it("records a TEST_PASSED_EVENT entry on successful `npm test`", async () => {
18
+ const { entries, watchMatched } = await testObserver(npmTestTracker, {
19
+ toolName: "bash",
20
+ input: { command: "npm test" },
21
+ output: {},
22
+ exitCode: 0,
23
+ });
24
+
25
+ assert.equal(watchMatched, true);
26
+ assert.equal(entries.length, 1);
27
+ assert.equal(entries[0]?.customType, TEST_PASSED_EVENT);
28
+ assert.deepEqual(entries[0]?.data, {
29
+ command: "npm test",
30
+ // The dispatcher auto-tags plain-object payloads with
31
+ // `_agentLoopIndex` — default mockObserverContext has index
32
+ // 0.
33
+ _agentLoopIndex: 0,
34
+ });
35
+ });
36
+
37
+ it("does NOT fire on a failed `npm test` (exitCode non-zero)", async () => {
38
+ const { entries, watchMatched } = await testObserver(npmTestTracker, {
39
+ toolName: "bash",
40
+ input: { command: "npm test" },
41
+ output: {},
42
+ exitCode: 1,
43
+ });
44
+ assert.equal(watchMatched, false);
45
+ assert.equal(entries.length, 0);
46
+ });
47
+
48
+ it("does NOT fire on an unrelated command", async () => {
49
+ const { entries, watchMatched } = await testObserver(npmTestTracker, {
50
+ toolName: "bash",
51
+ input: { command: "npm install" },
52
+ output: {},
53
+ exitCode: 0,
54
+ });
55
+ assert.equal(watchMatched, false);
56
+ assert.equal(entries.length, 0);
57
+ });
58
+
59
+ it("fires on `npm test -- --grep foo` (watch regex unanchored to suffix)", async () => {
60
+ const { entries, watchMatched } = await testObserver(npmTestTracker, {
61
+ toolName: "bash",
62
+ input: { command: "npm test -- --grep foo" },
63
+ output: {},
64
+ exitCode: 0,
65
+ });
66
+ assert.equal(watchMatched, true);
67
+ assert.equal(entries.length, 1);
68
+ });
69
+
70
+ it("records the original command in the payload", async () => {
71
+ const { entries } = await testObserver(npmTestTracker, {
72
+ toolName: "bash",
73
+ input: { command: "npm test -- --reporter dot" },
74
+ output: {},
75
+ exitCode: 0,
76
+ });
77
+ const data = entries[0]?.data as { command: string };
78
+ assert.equal(data.command, "npm test -- --reporter dot");
79
+ });
80
+
81
+ it("tags writes with the agent-loop index from ctx", async () => {
82
+ const { entries } = await testObserver(
83
+ npmTestTracker,
84
+ {
85
+ toolName: "bash",
86
+ input: { command: "npm test" },
87
+ output: {},
88
+ exitCode: 0,
89
+ },
90
+ { agentLoopIndex: 42 },
91
+ );
92
+ const data = entries[0]?.data as { _agentLoopIndex: number };
93
+ assert.equal(data._agentLoopIndex, 42);
94
+ });
95
+ });
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * `npm-test-tracker` — example observer.
6
+ *
7
+ * Watches `bash` tool_results where the command matches `/^npm test/`
8
+ * with `exitCode: "success"`. On match, records a `TEST_PASSED_EVENT`
9
+ * session entry so rules can later consult whether tests have passed
10
+ * this agent loop.
11
+ *
12
+ * ## What this file demonstrates (ADR §14)
13
+ *
14
+ * The canonical observer encapsulation convention — every observer
15
+ * file exports three things:
16
+ *
17
+ * 1. A `<EVENT>_EVENT` constant (the session-entry event type).
18
+ * 2. A `mark<Event>` helper that calls `ctx.appendEntry(<TYPE>)`
19
+ * with the right shape.
20
+ * 3. The observer itself, using the helper.
21
+ *
22
+ * Rules that gate on this event import `TEST_PASSED_EVENT` (not the
23
+ * raw string) and/or call `markTestPassed` from their own `onFire`.
24
+ * The raw string literal lives in exactly one place — here — and
25
+ * downstream typos become compile errors.
26
+ */
27
+
28
+ import type { Observer, ObserverContext, PredicateContext } from "@cad0p/pi-steering";
29
+
30
+ /**
31
+ * Session-entry type written when `npm test` succeeds. Rules gate via
32
+ * `when: { happened: { event: TEST_PASSED_EVENT, in: "agent_loop" } }`.
33
+ */
34
+ export const TEST_PASSED_EVENT = "example-npm-test-passed" as const;
35
+
36
+ /**
37
+ * Typed shape for the payload we write. Observers that need richer
38
+ * state (e.g. the test command variant, time-stamp, etc.) extend this
39
+ * interface; keeping the write helper as the one-stop shape definition
40
+ * keeps rule authors reading just the observer file to understand the
41
+ * contract.
42
+ */
43
+ export interface TestPassedPayload {
44
+ /** Which command variant passed. Handy for debugging / filtering. */
45
+ command: string;
46
+ }
47
+
48
+ /**
49
+ * Record a "tests passed" session entry. Callable from either an
50
+ * observer (`tool_result` hook) or a rule's `onFire` hook — both
51
+ * receive a ctx with `appendEntry`. The loose context type here keeps
52
+ * the helper reusable across both.
53
+ */
54
+ export function markTestPassed(
55
+ ctx: ObserverContext | PredicateContext,
56
+ payload: TestPassedPayload = { command: "npm test" },
57
+ ): void {
58
+ ctx.appendEntry<TestPassedPayload>(TEST_PASSED_EVENT, payload);
59
+ }
60
+
61
+ /**
62
+ * The observer itself. `as const satisfies Observer` preserves the
63
+ * literal `name: "npm-test-tracker"` and the `writes` tuple so
64
+ * `defineConfig`'s compile-time cross-reference checking works —
65
+ * rules referencing `happened: { event: TEST_PASSED_EVENT }` get
66
+ * validated against this observer's declared writes.
67
+ *
68
+ * See ADR §7 (`writes` declarations) for the authoring-pattern
69
+ * footgun around bare `: Observer` annotations.
70
+ */
71
+ export const npmTestTracker = {
72
+ name: "npm-test-tracker",
73
+ writes: [TEST_PASSED_EVENT],
74
+ watch: {
75
+ toolName: "bash",
76
+ inputMatches: { command: /^npm\s+test\b/ },
77
+ exitCode: "success",
78
+ },
79
+ onResult: (event, ctx) => {
80
+ // Pull the command out of the raw event for the payload. The
81
+ // watch filter already guaranteed we're on a bash event whose
82
+ // `input.command` matches our regex — the cast is safe.
83
+ const input = event.input as { command?: string } | undefined;
84
+ markTestPassed(ctx, {
85
+ command: input?.command ?? "npm test",
86
+ });
87
+ },
88
+ } as const satisfies Observer;
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * Unit tests for the `retest-required-tracker` observer.
6
+ *
7
+ * Mirrors the structure of `npm-test-tracker.test.ts` — exercises
8
+ * the watch-filter + write-on-match contract via `testObserver`.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import { testObserver } from "@cad0p/pi-steering/testing";
14
+ import {
15
+ RETEST_REQUIRED_EVENT,
16
+ retestRequiredTracker,
17
+ } from "./retest-required-tracker.ts";
18
+
19
+ describe("retest-required-tracker observer", () => {
20
+ it("records a RETEST_REQUIRED_EVENT entry on successful `git pull`", async () => {
21
+ const { entries, watchMatched } = await testObserver(
22
+ retestRequiredTracker,
23
+ {
24
+ toolName: "bash",
25
+ input: { command: "git pull" },
26
+ output: {},
27
+ exitCode: 0,
28
+ },
29
+ );
30
+
31
+ assert.equal(watchMatched, true);
32
+ assert.equal(entries.length, 1);
33
+ assert.equal(entries[0]?.customType, RETEST_REQUIRED_EVENT);
34
+ assert.deepEqual(entries[0]?.data, {
35
+ command: "git pull",
36
+ _agentLoopIndex: 0,
37
+ });
38
+ });
39
+
40
+ it("does NOT fire on failed `git pull`", async () => {
41
+ const { entries, watchMatched } = await testObserver(
42
+ retestRequiredTracker,
43
+ {
44
+ toolName: "bash",
45
+ input: { command: "git pull" },
46
+ output: {},
47
+ exitCode: 1,
48
+ },
49
+ );
50
+ assert.equal(watchMatched, false);
51
+ assert.equal(entries.length, 0);
52
+ });
53
+
54
+ it("does NOT fire on unrelated commands", async () => {
55
+ const { entries, watchMatched } = await testObserver(
56
+ retestRequiredTracker,
57
+ {
58
+ toolName: "bash",
59
+ input: { command: "git push" },
60
+ output: {},
61
+ exitCode: 0,
62
+ },
63
+ );
64
+ assert.equal(watchMatched, false);
65
+ assert.equal(entries.length, 0);
66
+ });
67
+
68
+ it("fires on `git pull --rebase` (watch regex unanchored to suffix)", async () => {
69
+ const { entries, watchMatched } = await testObserver(
70
+ retestRequiredTracker,
71
+ {
72
+ toolName: "bash",
73
+ input: { command: "git pull --rebase" },
74
+ output: {},
75
+ exitCode: 0,
76
+ },
77
+ );
78
+ assert.equal(watchMatched, true);
79
+ assert.equal(entries.length, 1);
80
+ });
81
+ });
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * `retest-required-tracker` — invalidation observer for the `since`
6
+ * sentinel pattern.
7
+ *
8
+ * ## What this file demonstrates (commit-4 polish, ADR §5 `since`)
9
+ *
10
+ * Observers can write "invalidator" events whose presence stale-s an
11
+ * earlier satisfied `happened` clause:
12
+ *
13
+ * - `npm-test-tracker` writes `TEST_PASSED_EVENT` on `npm test`
14
+ * success.
15
+ * - `retest-required-tracker` (this file) writes `RETEST_REQUIRED_EVENT`
16
+ * on `git pull` success \u2014 the workspace
17
+ * just changed; prior test state is stale.
18
+ * - `push-requires-tests` gates via
19
+ * `happened: { event: TEST_PASSED_EVENT, in: "agent_loop",
20
+ * since: RETEST_REQUIRED_EVENT }`
21
+ * which fires when the most-recent `TEST_PASSED_EVENT` is older
22
+ * than the most-recent `RETEST_REQUIRED_EVENT` (or the tests
23
+ * never ran at all in this agent loop).
24
+ *
25
+ * Same encapsulation convention as `npm-test-tracker`: this file
26
+ * owns the `RETEST_REQUIRED_EVENT` constant + `markRetestRequired`
27
+ * helper + the observer, so `defineConfig` type-checks references
28
+ * from downstream rules.
29
+ */
30
+
31
+ import type { Observer, ObserverContext, PredicateContext } from "@cad0p/pi-steering";
32
+
33
+ /**
34
+ * Session-entry event written when `git pull` succeeds. Rules that
35
+ * previously satisfied `TEST_PASSED_EVENT` become stale relative to
36
+ * this event; `push-requires-tests` compares the two via
37
+ * `happened.since` to require a re-run after any pull.
38
+ */
39
+ export const RETEST_REQUIRED_EVENT = "example-retest-required" as const;
40
+
41
+ /**
42
+ * Payload shape. Minimal \u2014 we just need a presence marker with a
43
+ * timestamp (engine-attached via `findEntries`).
44
+ */
45
+ export interface RetestRequiredPayload {
46
+ /** Which pull command triggered the invalidation. Debug-only. */
47
+ command: string;
48
+ }
49
+
50
+ /**
51
+ * Record a "retest required" session entry. Callable from an observer
52
+ * or a rule's `onFire` \u2014 both receive a ctx with `appendEntry`.
53
+ */
54
+ export function markRetestRequired(
55
+ ctx: ObserverContext | PredicateContext,
56
+ payload: RetestRequiredPayload = { command: "git pull" },
57
+ ): void {
58
+ ctx.appendEntry<RetestRequiredPayload>(RETEST_REQUIRED_EVENT, payload);
59
+ }
60
+
61
+ /**
62
+ * The observer. `as const satisfies Observer` preserves the literal
63
+ * `writes` tuple so `defineConfig` threads `RETEST_REQUIRED_EVENT`
64
+ * into the `AllWrites` union \u2014 letting rules reference it from
65
+ * `when.happened.since` with a compile-time typo check.
66
+ */
67
+ export const retestRequiredTracker = {
68
+ name: "retest-required-tracker",
69
+ writes: [RETEST_REQUIRED_EVENT],
70
+ watch: {
71
+ toolName: "bash",
72
+ inputMatches: { command: /^git\s+pull\b/ },
73
+ exitCode: "success",
74
+ },
75
+ onResult: (event, ctx) => {
76
+ const input = event.input as { command?: string } | undefined;
77
+ markRetestRequired(ctx, {
78
+ command: input?.command ?? "git pull",
79
+ });
80
+ },
81
+ } as const satisfies Observer;
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * Unit tests for the `workItemFormat` predicate.
6
+ *
7
+ * Uses `testPredicate` from `@cad0p/pi-steering/testing` — the lightest
8
+ * possible way to exercise a predicate: construct a `PredicateContext`,
9
+ * call the handler, get the boolean verdict.
10
+ *
11
+ * We do NOT spin up a full harness here; `index.test.ts` covers the
12
+ * end-to-end path. Per-file unit tests focus on the one thing the file
13
+ * owns.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { describe, it } from "node:test";
18
+ import type { Word } from "@cad0p/pi-steering";
19
+ import { testPredicate } from "@cad0p/pi-steering/testing";
20
+ import { workItemFormat } from "./work-item-format.ts";
21
+
22
+ /**
23
+ * Tiny Word factory. Real Words carry `pos` / `end` / `parts` too, but
24
+ * the predicate only ever reads `.value`; omitting the rest keeps the
25
+ * test concise.
26
+ */
27
+ function W(value: string): Word {
28
+ return { value, text: value, pos: 0, end: value.length } as Word;
29
+ }
30
+
31
+ describe("workItemFormat", () => {
32
+ it("matches when the -m value contains the pattern", async () => {
33
+ const fires = await testPredicate(
34
+ workItemFormat,
35
+ { pattern: /\[PROJ-\d+\]/ },
36
+ {
37
+ input: {
38
+ tool: "bash",
39
+ command: 'git commit -m "feat: [PROJ-42] subject"',
40
+ basename: "git",
41
+ args: [W("commit"), W("-m"), W("feat: [PROJ-42] subject")],
42
+ },
43
+ },
44
+ );
45
+ assert.equal(fires, true);
46
+ });
47
+
48
+ it("does NOT match when the -m value lacks the pattern", async () => {
49
+ const fires = await testPredicate(
50
+ workItemFormat,
51
+ { pattern: /\[PROJ-\d+\]/ },
52
+ {
53
+ input: {
54
+ tool: "bash",
55
+ command: 'git commit -m "feat: subject only"',
56
+ basename: "git",
57
+ args: [W("commit"), W("-m"), W("feat: subject only")],
58
+ },
59
+ },
60
+ );
61
+ assert.equal(fires, false);
62
+ });
63
+
64
+ it("supports the --message long form", async () => {
65
+ const fires = await testPredicate(
66
+ workItemFormat,
67
+ { pattern: /\[PROJ-\d+\]/ },
68
+ {
69
+ input: {
70
+ tool: "bash",
71
+ command: 'git commit --message "[PROJ-1] fix"',
72
+ basename: "git",
73
+ args: [W("commit"), W("--message"), W("[PROJ-1] fix")],
74
+ },
75
+ },
76
+ );
77
+ assert.equal(fires, true);
78
+ });
79
+
80
+ it("preserves quote-aware value through -m (no whitespace munging)", async () => {
81
+ // Real-world-ish: a message with internal spaces. The Word's
82
+ // .value gives us the unwrapped literal — we don't have to
83
+ // un-quote ourselves.
84
+ const fires = await testPredicate(
85
+ workItemFormat,
86
+ { pattern: /\[PROJ-\d+\]/ },
87
+ {
88
+ input: {
89
+ tool: "bash",
90
+ command:
91
+ 'git commit -m "longer subject with spaces and [PROJ-7] mid-sentence"',
92
+ basename: "git",
93
+ args: [
94
+ W("commit"),
95
+ W("-m"),
96
+ W("longer subject with spaces and [PROJ-7] mid-sentence"),
97
+ ],
98
+ },
99
+ },
100
+ );
101
+ assert.equal(fires, true);
102
+ });
103
+
104
+ it("falls back to command string when no -m flag is found in args", async () => {
105
+ // Rule author wrote a `workItemFormat` predicate on a command
106
+ // whose args don't carry `-m` (e.g. `git commit` with no -m).
107
+ // Fallback matches against `input.command`.
108
+ const fires = await testPredicate(
109
+ workItemFormat,
110
+ { pattern: /\[PROJ-\d+\]/ },
111
+ {
112
+ input: {
113
+ tool: "bash",
114
+ command: "git commit",
115
+ basename: "git",
116
+ args: [W("commit")],
117
+ },
118
+ },
119
+ );
120
+ // No `-m`, and `"git commit"` has no PROJ token — does not fire.
121
+ assert.equal(fires, false);
122
+ });
123
+
124
+ it("returns false when args and command are both absent", async () => {
125
+ const fires = await testPredicate(
126
+ workItemFormat,
127
+ {
128
+ pattern: /\[PROJ-\d+\]/,
129
+ },
130
+ {
131
+ input: { tool: "bash" },
132
+ },
133
+ );
134
+ assert.equal(fires, false);
135
+ });
136
+
137
+ it("returns false when the arg shape is malformed", async () => {
138
+ // A rule author who passed a plain string instead of
139
+ // { pattern }. Fail-closed: predicate returns false rather than
140
+ // throwing.
141
+ const fires = await testPredicate(
142
+ workItemFormat,
143
+ // Intentionally wrong shape — widened via `as unknown` so the
144
+ // test isn't a type error (the predicate itself handles bad
145
+ // args defensively).
146
+ "not-an-object" as unknown as { pattern: RegExp },
147
+ {
148
+ input: {
149
+ tool: "bash",
150
+ command: 'git commit -m "[PROJ-1] x"',
151
+ args: [W("commit"), W("-m"), W("[PROJ-1] x")],
152
+ },
153
+ },
154
+ );
155
+ assert.equal(fires, false);
156
+ });
157
+ });