@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,99 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * `workItemFormat` — example plugin predicate.
6
+ *
7
+ * Demonstrates two things at once:
8
+ *
9
+ * 1. How to use `definePredicate<T>` for a typed arg shape.
10
+ * 2. How to read structured args via `input.args` (ADR §9).
11
+ *
12
+ * Accepted arg shape:
13
+ *
14
+ * ```ts
15
+ * when: { workItemFormat: { pattern: /\[PROJ-\d+\]/ } }
16
+ * ```
17
+ *
18
+ * Semantics:
19
+ *
20
+ * - Extracts the commit message from a `-m` flag in `input.args`.
21
+ * This is the preferred path because `input.args` preserves
22
+ * quoting via `Word.value` — `-m "feat: [PROJ-42]"` lands as a
23
+ * single Word with `value === "feat: [PROJ-42]"`.
24
+ * - If `-m` can't be found in `input.args` (the user authored a
25
+ * rule for a non-commit command, or args is absent for some
26
+ * reason), falls back to testing `input.command` directly. The
27
+ * fallback is conservative: it just checks if the pattern
28
+ * appears anywhere in the flattened command string.
29
+ *
30
+ * Fail-closed: if the predicate can't determine a message at all,
31
+ * returns `false` (rule does NOT fire) so authoring typos don't
32
+ * spuriously block every commit.
33
+ */
34
+
35
+ import type { Word } from "@cad0p/pi-steering";
36
+ import { definePredicate } from "@cad0p/pi-steering";
37
+
38
+ /** Argument shape the `when.workItemFormat` slot accepts. */
39
+ export interface WorkItemFormatArgs {
40
+ /** Required regex the commit message must match. */
41
+ pattern: RegExp;
42
+ }
43
+
44
+ /**
45
+ * Walk `args` looking for a `-m` / `--message` flag and return the
46
+ * message value (the Word that FOLLOWS the flag). Returns `null` if
47
+ * no `-m`-like flag is present.
48
+ *
49
+ * Notes:
50
+ * - `.value` is the quote-unwrapped lexical value (`feat: x`),
51
+ * `.text` is the raw source (`"feat: x"`). Rules care about
52
+ * lexical content.
53
+ * - The combined form `-m"msg"` is not parsed as a single Word by
54
+ * unbash (shell doesn't either — the quotes end the flag token
55
+ * prematurely). We treat `-m` and `--message` as space-separated
56
+ * only, which matches 99% of human usage.
57
+ */
58
+ function extractMessage(args: readonly Word[]): string | null {
59
+ for (let i = 0; i < args.length; i++) {
60
+ const w = args[i];
61
+ if (w === undefined) continue;
62
+ if (w.value === "-m" || w.value === "--message") {
63
+ const next = args[i + 1];
64
+ if (next !== undefined) {
65
+ return next.value;
66
+ }
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * The predicate handler itself. See module doc for the arg shape and
74
+ * fallback policy.
75
+ */
76
+ export const workItemFormat = definePredicate<WorkItemFormatArgs>(
77
+ (args, ctx) => {
78
+ if (!(args?.pattern instanceof RegExp)) return false;
79
+
80
+ // Preferred: structured args with quote-awareness.
81
+ if (ctx.input.args !== undefined) {
82
+ const msg = extractMessage(ctx.input.args);
83
+ if (msg !== null) {
84
+ return args.pattern.test(msg);
85
+ }
86
+ }
87
+
88
+ // Fallback: match anywhere in the flattened command string.
89
+ // Conservative — a rule whose `pattern` already anchored on
90
+ // `git commit -m` has narrowed the scope, so arbitrary
91
+ // command content shouldn't match the ticket regex by
92
+ // accident.
93
+ if (ctx.input.command !== undefined) {
94
+ return args.pattern.test(ctx.input.command);
95
+ }
96
+
97
+ return false;
98
+ },
99
+ );
@@ -0,0 +1,107 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * Tests for `commit-description-check`. The critical path is the
6
+ * two-call sequence within one agent loop:
7
+ * 1. First commit blocks, onFire writes the self-mark.
8
+ * 2. Second commit in the same loop passes.
9
+ * 3. A commit in the NEXT agent loop blocks again.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { describe, it } from "node:test";
14
+ import {
15
+ createRecordingHost,
16
+ expectAllows,
17
+ loadHarness,
18
+ mockExtensionContext,
19
+ } from "@cad0p/pi-steering/testing";
20
+ import {
21
+ commitDescriptionCheck,
22
+ DESCRIPTION_REVIEWED_EVENT,
23
+ } from "./commit-description-check.ts";
24
+
25
+ describe("commit-description-check", () => {
26
+ it("blocks the first commit per loop, allows the second (self-mark)", async () => {
27
+ // Recording host + shared ExtensionContext: writes via
28
+ // host.appendEntry flow back into ctx.sessionManager.getEntries
29
+ // so later evaluate() calls see the self-mark the previous call
30
+ // wrote. Same plumbing the real pi runtime wires, minus the
31
+ // child-process bits.
32
+ const host = createRecordingHost();
33
+ const ctx = mockExtensionContext("/tmp/test", host.entries);
34
+
35
+ const harness = loadHarness({
36
+ config: { rules: [commitDescriptionCheck] },
37
+ host,
38
+ });
39
+
40
+ // First commit in loop 1 — blocks AND self-marks.
41
+ const first = await harness.evaluate(
42
+ {
43
+ type: "tool_call",
44
+ toolCallId: "tc1",
45
+ toolName: "bash",
46
+ input: { command: 'git commit -m "first"' },
47
+ } as unknown as Parameters<typeof harness.evaluate>[0],
48
+ ctx,
49
+ 1,
50
+ );
51
+ assert.ok(
52
+ first !== undefined && first !== null,
53
+ "first commit should block",
54
+ );
55
+
56
+ // `onFire` wrote the reminder entry.
57
+ assert.ok(
58
+ host.entries.some((e) => e.customType === DESCRIPTION_REVIEWED_EVENT),
59
+ "onFire did not self-mark the DESCRIPTION_REVIEWED_EVENT entry",
60
+ );
61
+
62
+ // Second commit in the same loop — the reminder entry is now
63
+ // present, so `when.happened` no longer fires; the rule
64
+ // passes.
65
+ const second = await harness.evaluate(
66
+ {
67
+ type: "tool_call",
68
+ toolCallId: "tc2",
69
+ toolName: "bash",
70
+ input: { command: 'git commit -m "second"' },
71
+ } as unknown as Parameters<typeof harness.evaluate>[0],
72
+ ctx,
73
+ 1,
74
+ );
75
+ assert.equal(
76
+ second,
77
+ undefined,
78
+ "second commit in the same loop should pass after self-mark",
79
+ );
80
+
81
+ // New agent loop (index bumps) — the entry's
82
+ // `_agentLoopIndex` is 1, but this is loop 2, so
83
+ // `when.happened` filters it out and the reminder fires
84
+ // again.
85
+ const nextLoop = await harness.evaluate(
86
+ {
87
+ type: "tool_call",
88
+ toolCallId: "tc3",
89
+ toolName: "bash",
90
+ input: { command: 'git commit -m "new loop"' },
91
+ } as unknown as Parameters<typeof harness.evaluate>[0],
92
+ ctx,
93
+ 2,
94
+ );
95
+ assert.ok(
96
+ nextLoop !== undefined && nextLoop !== null,
97
+ "new-loop commit should block — stale reminder doesn't carry over",
98
+ );
99
+ });
100
+
101
+ it("does NOT fire on a non-commit command", async () => {
102
+ const harness = loadHarness({
103
+ config: { rules: [commitDescriptionCheck] },
104
+ });
105
+ await expectAllows(harness, { command: "git status" });
106
+ });
107
+ });
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * `commit-description-check` — self-marking reminder rule.
6
+ *
7
+ * Demonstrates `Rule.onFire` + self-marking (ADR §6, §14):
8
+ *
9
+ * - Pattern `/^git\s+commit\b/` matches any commit command.
10
+ * - `when.happened: { event: DESCRIPTION_REVIEWED_EVENT, in: "agent_loop" }`
11
+ * fires when the reminder entry has NOT been written this loop.
12
+ * - `onFire: markDescriptionReviewed` writes that entry when the
13
+ * rule blocks. First commit per agent loop → blocks with the
14
+ * reminder, self-marks. Second commit in the same loop → the
15
+ * entry is now present, `when.happened` no longer fires, commit
16
+ * is allowed.
17
+ *
18
+ * ## Encapsulation (ADR §14)
19
+ *
20
+ * No corresponding observer ships the `DESCRIPTION_REVIEWED_EVENT`
21
+ * event — the rule is the producer and the consumer. The convention
22
+ * for that case is: the constant + helper live IN THE RULE FILE
23
+ * itself. Observer files own their constants; self-marking rule
24
+ * files own theirs.
25
+ *
26
+ * Override: allowed. Skipping the reminder is a workflow choice.
27
+ */
28
+
29
+ import type { PredicateContext, Rule } from "@cad0p/pi-steering";
30
+
31
+ /**
32
+ * Session-entry type written by this rule's `onFire`. Exported so
33
+ * tests (or any other rule / observer in the same plugin) can
34
+ * reference the same literal.
35
+ */
36
+ export const DESCRIPTION_REVIEWED_EVENT =
37
+ "example-description-reviewed" as const;
38
+
39
+ /**
40
+ * Shape of the payload written on self-mark. Minimal — the rule just
41
+ * needs a presence marker; the timestamp the engine attaches via
42
+ * `findEntries` is enough for any downstream logic.
43
+ */
44
+ interface DescriptionReviewedPayload {
45
+ /** Which commit command variant the reminder fired on. */
46
+ command: string;
47
+ }
48
+
49
+ /**
50
+ * Helper that writes the reminder entry. The ADR §14 pattern: both
51
+ * the constant and the writer live with the producing rule. If a
52
+ * future observer needed to ALSO write this type, we'd move both into
53
+ * an observer file; here, the rule is the sole writer so the rule
54
+ * file owns it.
55
+ */
56
+ export function markDescriptionReviewed(
57
+ ctx: PredicateContext,
58
+ payload: DescriptionReviewedPayload = { command: "" },
59
+ ): void {
60
+ ctx.appendEntry<DescriptionReviewedPayload>(
61
+ DESCRIPTION_REVIEWED_EVENT,
62
+ payload,
63
+ );
64
+ }
65
+
66
+ export const commitDescriptionCheck = {
67
+ name: "commit-description-check",
68
+ tool: "bash",
69
+ field: "command",
70
+ pattern: /^git\s+commit\b/,
71
+ when: {
72
+ happened: { event: DESCRIPTION_REVIEWED_EVENT, in: "agent_loop" },
73
+ },
74
+ reason:
75
+ "Re-read the commit description before committing. This reminder fires once per agent loop — your next commit in this loop will go through.",
76
+ noOverride: false,
77
+ writes: [DESCRIPTION_REVIEWED_EVENT],
78
+ onFire: (ctx) => {
79
+ markDescriptionReviewed(ctx, {
80
+ command: ctx.input.command ?? "",
81
+ });
82
+ },
83
+ } as const satisfies Rule;
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * Integration tests for `commit-requires-work-item`.
6
+ *
7
+ * Uses `loadHarness` + `expectBlocks` / `expectAllows` — same shape
8
+ * a real plugin's test would take. Builds a minimal config with just
9
+ * this rule + the `workItemFormat` predicate wired through a fake
10
+ * plugin. Keeps the test isolated from any upstream plugin behavior.
11
+ */
12
+
13
+ import { describe, it } from "node:test";
14
+ import type { Plugin } from "@cad0p/pi-steering";
15
+ import { expectAllows, expectBlocks, loadHarness } from "@cad0p/pi-steering/testing";
16
+ import { workItemFormat } from "../predicates/work-item-format.ts";
17
+ import { commitRequiresWorkItem } from "./commit-requires-work-item.ts";
18
+
19
+ /**
20
+ * Minimal test plugin registering the predicate. We could use the
21
+ * whole `work-item` plugin default export here, but wiring the single
22
+ * predicate keeps each rule test focused on its own behavior without
23
+ * pulling in unrelated observers / rules.
24
+ */
25
+ const testPlugin: Plugin = {
26
+ name: "test",
27
+ predicates: { workItemFormat },
28
+ };
29
+
30
+ describe("commit-requires-work-item", () => {
31
+ const harness = loadHarness({
32
+ config: {
33
+ plugins: [testPlugin],
34
+ rules: [commitRequiresWorkItem],
35
+ },
36
+ });
37
+
38
+ it("blocks a commit missing the work-item tag", async () => {
39
+ await expectBlocks(
40
+ harness,
41
+ { command: 'git commit -m "feat: add thing"' },
42
+ { rule: "commit-requires-work-item" },
43
+ );
44
+ });
45
+
46
+ it("allows a commit containing [PROJ-N]", async () => {
47
+ await expectAllows(harness, {
48
+ command: 'git commit -m "feat: add thing [PROJ-42]"',
49
+ });
50
+ });
51
+
52
+ it("allows commits using --message long form", async () => {
53
+ await expectAllows(harness, {
54
+ command: 'git commit --message "fix [PROJ-1] bad thing"',
55
+ });
56
+ });
57
+
58
+ it('does NOT fire on git log --grep="commit"', async () => {
59
+ // Make sure the pattern doesn't spuriously match arbitrary
60
+ // commit-containing text.
61
+ await expectAllows(harness, {
62
+ command: 'git log --grep="commit"',
63
+ });
64
+ });
65
+
66
+ it("does NOT fire on `git commit` without -m (the rule's pattern requires -m)", async () => {
67
+ // The rule pattern anchors on `-m\s`, so this never even
68
+ // reaches the predicate.
69
+ await expectAllows(harness, { command: "git commit --amend" });
70
+ });
71
+
72
+ it("blocks a commit whose [PROJ- tag uses the wrong format", async () => {
73
+ await expectBlocks(
74
+ harness,
75
+ { command: 'git commit -m "feat [PROJXX] oops"' },
76
+ { rule: "commit-requires-work-item" },
77
+ );
78
+ });
79
+ });
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * `commit-requires-work-item` — example rule.
6
+ *
7
+ * Demonstrates:
8
+ * - A plugin-registered predicate (`workItemFormat`) in use from a
9
+ * rule's `when.*` slot.
10
+ * - `when.not` for inversion — the predicate reports "the commit
11
+ * message MATCHES `[PROJ-N]`" (positive), so we wrap it in `not`
12
+ * to get "the message does NOT match" (the firing condition).
13
+ * - The typed-arg authoring pattern — `{ pattern: /\[PROJ-\d+\]/ }`.
14
+ * - A `pattern` anchored on the subcommand slot so `git log --grep
15
+ * "commit"` doesn't spuriously match.
16
+ *
17
+ * Semantics:
18
+ * - Fires on `git commit [...] -m <msg>` where `<msg>` does NOT
19
+ * contain a `[PROJ-N]` token.
20
+ * - The predicate's fallback path keeps us safe: if someone writes
21
+ * a commit with no `-m` reachable (e.g. `git commit
22
+ * --file /tmp/msg.txt`), the predicate falls back to
23
+ * pattern-matching the whole command — which will miss, so
24
+ * `not` makes this rule FIRE. That's the conservative default
25
+ * for a plugin whose whole purpose is "require a ticket".
26
+ *
27
+ * Override: allowed (workflow rule, not inherent-destructive). The
28
+ * engine's default is `noOverride: true`; rule authors opt out
29
+ * explicitly.
30
+ */
31
+
32
+ import type { Rule } from "@cad0p/pi-steering";
33
+
34
+ export const commitRequiresWorkItem = {
35
+ name: "commit-requires-work-item",
36
+ tool: "bash",
37
+ field: "command",
38
+ // Anchored on `git commit` — the pre-subcommand flag slot (`git -C
39
+ // /other commit …`) is intentionally omitted to keep the example
40
+ // compact. The git plugin's `no-main-commit` rule in the package
41
+ // shows the full slot pattern for production use.
42
+ pattern: /^git\s+commit\b.*-m\s/,
43
+ when: {
44
+ // Invert the predicate: fire when the work-item tag is MISSING.
45
+ not: {
46
+ // The plugin-registered `workItemFormat` predicate — see
47
+ // ../predicates/work-item-format.ts. `[PROJ-N]` is the
48
+ // placeholder — a real adopter replaces with e.g.
49
+ // `[PROJECT-\d+]` / `[JIRA-\d+]`.
50
+ workItemFormat: { pattern: /\[PROJ-\d+\]/ },
51
+ },
52
+ },
53
+ reason:
54
+ "Commit messages must reference a work item ticket, e.g., [PROJ-123].",
55
+ noOverride: false,
56
+ } as const satisfies Rule;
@@ -0,0 +1,245 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of the pi-steering work-item-plugin example.
3
+
4
+ /**
5
+ * Integration tests for `push-requires-tests`.
6
+ *
7
+ * Exercises the observer → rule handoff end-to-end via `loadHarness`:
8
+ *
9
+ * 1. Push without a prior TEST_PASSED_EVENT entry → block.
10
+ * 2. Dispatch an `npm test` success event through the harness →
11
+ * the observer writes TEST_PASSED_EVENT into the session entries.
12
+ * 3. Push in the same agent loop → allow.
13
+ * 4. Dispatch a `git pull` → observer writes RETEST_REQUIRED_EVENT,
14
+ * stale-ing the test entry. Push now blocks again (PR §4 `since`).
15
+ * 5. Chain-aware: `npm test && git push` allowed pre-execution
16
+ * because the prior `&&` ref matches the npm-test observer (PR §4).
17
+ *
18
+ * Step (2) uses `harness.dispatch` — the same call production uses on
19
+ * `tool_result`. `createRecordingHost` + `mockExtensionContext` share
20
+ * a single entries array so writes via `harness.dispatch` flow back
21
+ * into `harness.evaluate`'s view of the session.
22
+ */
23
+
24
+ import assert from "node:assert/strict";
25
+ import { describe, it } from "node:test";
26
+ import type { Plugin } from "@cad0p/pi-steering";
27
+ import {
28
+ createRecordingHost,
29
+ expectAllows,
30
+ expectBlocks,
31
+ loadHarness,
32
+ mockExtensionContext,
33
+ } from "@cad0p/pi-steering/testing";
34
+ import {
35
+ npmTestTracker,
36
+ TEST_PASSED_EVENT,
37
+ } from "../observers/npm-test-tracker.ts";
38
+ import {
39
+ RETEST_REQUIRED_EVENT,
40
+ retestRequiredTracker,
41
+ } from "../observers/retest-required-tracker.ts";
42
+ import { pushRequiresTests } from "./push-requires-tests.ts";
43
+
44
+ /**
45
+ * Plugin bundling the two observers this rule interacts with. Keeps
46
+ * the test config focused on what's under exercise — no unrelated
47
+ * rules from the work-item plugin.
48
+ */
49
+ const testPlugin: Plugin = {
50
+ name: "test",
51
+ observers: [npmTestTracker, retestRequiredTracker],
52
+ };
53
+
54
+ describe("push-requires-tests", () => {
55
+ it("blocks git push when no TEST_PASSED entry exists", async () => {
56
+ const harness = loadHarness({
57
+ config: {
58
+ plugins: [testPlugin],
59
+ rules: [pushRequiresTests],
60
+ },
61
+ });
62
+ await expectBlocks(
63
+ harness,
64
+ { command: "git push origin feat/x" },
65
+ { rule: "push-requires-tests" },
66
+ );
67
+ });
68
+
69
+ it("does NOT fire on unrelated commands", async () => {
70
+ const harness = loadHarness({
71
+ config: {
72
+ plugins: [testPlugin],
73
+ rules: [pushRequiresTests],
74
+ },
75
+ });
76
+ await expectAllows(harness, { command: "git status" });
77
+ });
78
+
79
+ it("allows git push after observer records a TEST_PASSED entry", async () => {
80
+ // Recording host + shared ExtensionContext: writes via
81
+ // host.appendEntry (from the dispatcher) flow into
82
+ // ctx.sessionManager.getEntries so the later evaluate() sees
83
+ // the TEST_PASSED entry the observer recorded. Mirrors the
84
+ // bridge the real pi runtime builds in `src/index.ts`.
85
+ const host = createRecordingHost();
86
+ const ctx = mockExtensionContext("/tmp/test", host.entries);
87
+
88
+ const harness = loadHarness({
89
+ config: {
90
+ plugins: [testPlugin],
91
+ rules: [pushRequiresTests],
92
+ },
93
+ host,
94
+ });
95
+
96
+ // Simulate: pi emits a tool_result for a successful `npm test`.
97
+ await harness.dispatch(
98
+ {
99
+ type: "tool_result",
100
+ toolCallId: "tc1",
101
+ toolName: "bash",
102
+ input: { command: "npm test" },
103
+ content: [],
104
+ details: { exitCode: 0 },
105
+ } as unknown as Parameters<typeof harness.dispatch>[0],
106
+ ctx,
107
+ 0,
108
+ );
109
+
110
+ // The observer should have written TEST_PASSED_EVENT.
111
+ assert.ok(
112
+ host.entries.some((e) => e.customType === TEST_PASSED_EVENT),
113
+ "observer did not record TEST_PASSED_EVENT",
114
+ );
115
+
116
+ // Now the push should be allowed — the rule's
117
+ // `when.happened` finds the entry in the current agent loop.
118
+ const result = await harness.evaluate(
119
+ {
120
+ type: "tool_call",
121
+ toolCallId: "tc2",
122
+ toolName: "bash",
123
+ input: { command: "git push origin feat/x" },
124
+ } as unknown as Parameters<typeof harness.evaluate>[0],
125
+ ctx,
126
+ 0,
127
+ );
128
+ assert.equal(result, undefined);
129
+ });
130
+
131
+ it("re-blocks push after `git pull` stale-s the test entry (happened.since)", async () => {
132
+ // Demonstrates the PR #4 `since` invalidation sentinel. The
133
+ // rule is gated on `TEST_PASSED_EVENT` but `since: RETEST_REQUIRED_EVENT`
134
+ // means a later pull stale-s the test state.
135
+ const host = createRecordingHost();
136
+ const ctx = mockExtensionContext("/tmp/test", host.entries);
137
+
138
+ const harness = loadHarness({
139
+ config: {
140
+ plugins: [testPlugin],
141
+ rules: [pushRequiresTests],
142
+ },
143
+ host,
144
+ });
145
+
146
+ // 1. Tests pass.
147
+ await harness.dispatch(
148
+ {
149
+ type: "tool_result",
150
+ toolCallId: "tc1",
151
+ toolName: "bash",
152
+ input: { command: "npm test" },
153
+ content: [],
154
+ details: { exitCode: 0 },
155
+ } as unknown as Parameters<typeof harness.dispatch>[0],
156
+ ctx,
157
+ 0,
158
+ );
159
+
160
+ // 2. Pull stale-s the test state.
161
+ await harness.dispatch(
162
+ {
163
+ type: "tool_result",
164
+ toolCallId: "tc2",
165
+ toolName: "bash",
166
+ input: { command: "git pull" },
167
+ content: [],
168
+ details: { exitCode: 0 },
169
+ } as unknown as Parameters<typeof harness.dispatch>[0],
170
+ ctx,
171
+ 0,
172
+ );
173
+
174
+ assert.ok(
175
+ host.entries.some((e) => e.customType === RETEST_REQUIRED_EVENT),
176
+ "retest observer did not record RETEST_REQUIRED_EVENT",
177
+ );
178
+
179
+ // 3. Push blocked now — the most-recent TEST_PASSED_EVENT is
180
+ // older than the most-recent RETEST_REQUIRED_EVENT.
181
+ const result = await harness.evaluate(
182
+ {
183
+ type: "tool_call",
184
+ toolCallId: "tc3",
185
+ toolName: "bash",
186
+ input: { command: "git push origin feat/x" },
187
+ } as unknown as Parameters<typeof harness.evaluate>[0],
188
+ ctx,
189
+ 0,
190
+ );
191
+ assert.ok(result && "block" in result, "post-pull push should block");
192
+ });
193
+
194
+ it("`&&`-chain: `npm test && git push` is allowed pre-execution", async () => {
195
+ // Demonstrates the PR #4 speculative allow. The
196
+ // evaluator runs BEFORE the chain executes, so the observer
197
+ // hasn't written TEST_PASSED_EVENT yet — but because the prior
198
+ // `&&` ref matches the npm-test observer's watch, the engine
199
+ // speculatively allows. Safe: if `npm test` fails, `&&` short-
200
+ // circuits and `git push` never runs.
201
+ const harness = loadHarness({
202
+ config: {
203
+ plugins: [testPlugin],
204
+ rules: [pushRequiresTests],
205
+ },
206
+ });
207
+ await expectAllows(harness, {
208
+ command: "npm test && git push origin feat/x",
209
+ });
210
+ });
211
+
212
+ it("`&&`-chain: `git push && npm test` is NOT allowed (push is first)", async () => {
213
+ // Order matters — the speculative allow looks at PRIOR && refs
214
+ // only. Here push is first, so there's no prior observer to
215
+ // cite; the rule fires normally.
216
+ const harness = loadHarness({
217
+ config: {
218
+ plugins: [testPlugin],
219
+ rules: [pushRequiresTests],
220
+ },
221
+ });
222
+ await expectBlocks(
223
+ harness,
224
+ { command: "git push origin feat/x && npm test" },
225
+ { rule: "push-requires-tests" },
226
+ );
227
+ });
228
+
229
+ it("`&&`-chain: `npm test ; git push` still blocks (`;` does not qualify)", async () => {
230
+ // Only `&&` predecessors qualify for speculative allow. A
231
+ // `;`-joined prior doesn't short-circuit, so granting the
232
+ // allow would be unsafe.
233
+ const harness = loadHarness({
234
+ config: {
235
+ plugins: [testPlugin],
236
+ rules: [pushRequiresTests],
237
+ },
238
+ });
239
+ await expectBlocks(
240
+ harness,
241
+ { command: "npm test ; git push origin feat/x" },
242
+ { rule: "push-requires-tests" },
243
+ );
244
+ });
245
+ });