@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,584 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: MIT
3
+ // Part of pi-steering.
4
+
5
+ /**
6
+ * `@cad0p/pi-steering` CLI. Two subcommands:
7
+ *
8
+ * pi-steering import-json <input.json> [-o <output.ts>]
9
+ * Convert a v1 JSON config to the v2 TS config shape.
10
+ *
11
+ * pi-steering list [--format=text|json]
12
+ * Resolve the walk-up config from the CWD and print the
13
+ * effective plugins / rules / observers / disables.
14
+ *
15
+ * Exit codes:
16
+ * 0 - success
17
+ * 1 - invalid arguments / file read / parse error / `list`
18
+ * surfaced one or more error-class diagnostics (CI-lint signal)
19
+ * 2 - import-json conversion error ({@link FromJSONError})
20
+ */
21
+
22
+ import { readFile, writeFile } from "node:fs/promises";
23
+ import { FromJSONError, fromJSON } from "../compat.ts";
24
+ import { EVALUATOR_BUILTIN_TRACKERS } from "../evaluator.ts";
25
+ import { finalizePluginState } from "../internal/finalize-plugin-state.ts";
26
+ import {
27
+ formatSingleLineDiagnostic,
28
+ runMergerPipeline,
29
+ } from "../internal/session-runtime.ts";
30
+ import { loadConfigs } from "../loader.ts";
31
+ import type {
32
+ Observer,
33
+ Rule,
34
+ SteeringConfig,
35
+ SteeringDiagnostic,
36
+ TopLevelWhenClause,
37
+ } from "../schema.ts";
38
+
39
+ /**
40
+ * CLI entrypoint. Exported (not just `void main(...)` at module top)
41
+ * so the test suite can exercise the argument parser without spawning
42
+ * a subprocess. Real invocation goes through the bottom-of-file
43
+ * bootstrap when this module is run as `node pi-steering.js`.
44
+ */
45
+ export async function main(argv: string[]): Promise<number> {
46
+ const args = argv.slice(2);
47
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
48
+ printHelp();
49
+ return 0;
50
+ }
51
+
52
+ const [subcommand, ...rest] = args;
53
+ if (subcommand === "import-json") {
54
+ return runImportJson(rest);
55
+ }
56
+ if (subcommand === "list") {
57
+ return runList(rest);
58
+ }
59
+
60
+ process.stderr.write(`pi-steering: unknown subcommand "${subcommand}"\n\n`);
61
+ printHelp();
62
+ return 1;
63
+ }
64
+
65
+ function printHelp(): void {
66
+ process.stdout.write(`pi-steering — tools for pi-steering
67
+
68
+ USAGE
69
+ pi-steering <subcommand> [options]
70
+
71
+ SUBCOMMANDS
72
+ import-json <input.json> [-o <output.ts>]
73
+ Convert a v1 JSON steering config to v2 TS form. Writes to
74
+ <output.ts> if specified, else stdout. See the README for
75
+ the JSON-to-TS conversion surface and rejected features.
76
+
77
+ list [--format=text|json]
78
+ Load the effective config for the current directory (walk-up
79
+ from cwd) and print the resolved plugins, rules, and observers,
80
+ grouped by source. Useful for answering "which rules are
81
+ active here?" without reading the config files by hand.
82
+
83
+ OPTIONS
84
+ -h, --help Show this help.
85
+
86
+ `);
87
+ }
88
+
89
+ async function runImportJson(args: string[]): Promise<number> {
90
+ let inputPath: string | null = null;
91
+ let outputPath: string | null = null;
92
+
93
+ for (let i = 0; i < args.length; i++) {
94
+ const a = args[i];
95
+ if (a === "-o" || a === "--output") {
96
+ const next = args[++i];
97
+ if (next === undefined) {
98
+ process.stderr.write("pi-steering: -o requires an argument\n");
99
+ return 1;
100
+ }
101
+ outputPath = next;
102
+ } else if (a !== undefined && a.startsWith("-")) {
103
+ process.stderr.write(`pi-steering: unknown flag "${a}"\n`);
104
+ return 1;
105
+ } else if (inputPath === null && a !== undefined) {
106
+ inputPath = a;
107
+ } else {
108
+ process.stderr.write("pi-steering: too many positional arguments\n");
109
+ return 1;
110
+ }
111
+ }
112
+
113
+ if (inputPath === null) {
114
+ process.stderr.write("pi-steering: import-json requires an input file\n");
115
+ return 1;
116
+ }
117
+
118
+ let raw: string;
119
+ try {
120
+ raw = await readFile(inputPath, "utf8");
121
+ } catch (err) {
122
+ process.stderr.write(
123
+ `pi-steering: cannot read ${inputPath}: ${
124
+ err instanceof Error ? err.message : String(err)
125
+ }\n`,
126
+ );
127
+ return 1;
128
+ }
129
+
130
+ let json: unknown;
131
+ try {
132
+ json = JSON.parse(raw);
133
+ } catch (err) {
134
+ process.stderr.write(
135
+ `pi-steering: ${inputPath} is not valid JSON: ${
136
+ err instanceof Error ? err.message : String(err)
137
+ }\n`,
138
+ );
139
+ return 1;
140
+ }
141
+
142
+ let config: SteeringConfig;
143
+ try {
144
+ config = fromJSON(json);
145
+ } catch (err) {
146
+ if (err instanceof FromJSONError) {
147
+ process.stderr.write(
148
+ `pi-steering: conversion failed at ${err.path}: ${err.message}\n`,
149
+ );
150
+ return 2;
151
+ }
152
+ throw err;
153
+ }
154
+
155
+ const output = renderConfig(config);
156
+
157
+ if (outputPath !== null) {
158
+ await writeFile(outputPath, output, "utf8");
159
+ process.stdout.write(`Wrote ${outputPath}\n`);
160
+ } else {
161
+ process.stdout.write(output);
162
+ }
163
+ return 0;
164
+ }
165
+
166
+ /**
167
+ * Render a {@link SteeringConfig} as a v2 TS file using `defineConfig`.
168
+ * Uses `JSON.stringify` for the config value since v1 rules only carry
169
+ * string / number / boolean / array — JSON literal rendering is
170
+ * semantically identical to the TS object literal.
171
+ */
172
+ function renderConfig(config: SteeringConfig): string {
173
+ const body = JSON.stringify(config, null, 2);
174
+ return `// Generated by \`pi-steering import-json\`.
175
+ // Edit freely — this is your steering config now.
176
+
177
+ import { defineConfig } from "@cad0p/pi-steering";
178
+
179
+ export default defineConfig(${body});
180
+ `;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // `list` subcommand
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Parse the `list` flag set and print the resolved config.
189
+ *
190
+ * Flags:
191
+ * --format=text (default)
192
+ * --format=json machine-readable JSON
193
+ * -h / --help per-subcommand help
194
+ */
195
+ async function runList(args: string[]): Promise<number> {
196
+ let format: "text" | "json" = "text";
197
+ for (const a of args) {
198
+ if (a === "--help" || a === "-h") {
199
+ printListHelp();
200
+ return 0;
201
+ }
202
+ if (a === "--format=text") {
203
+ format = "text";
204
+ } else if (a === "--format=json") {
205
+ format = "json";
206
+ } else if (a.startsWith("--format=")) {
207
+ process.stderr.write(
208
+ `pi-steering: unknown --format value "${a.slice("--format=".length)}"; use text|json\n`,
209
+ );
210
+ return 1;
211
+ } else {
212
+ process.stderr.write(`pi-steering: unknown flag "${a}"\n`);
213
+ return 1;
214
+ }
215
+ }
216
+
217
+ // Walk up from cwd and merge. CLI deliberately omits
218
+ // DEFAULT_PLUGINS / DEFAULT_RULES; runtime injects them.
219
+ let layers: readonly SteeringConfig[];
220
+ let loaderDiagnostics: readonly SteeringDiagnostic[] = [];
221
+ try {
222
+ ({ layers, diagnostics: loaderDiagnostics } = await loadConfigs(
223
+ process.cwd(),
224
+ ));
225
+ } catch (err) {
226
+ process.stderr.write(
227
+ `pi-steering: failed to load config: ${
228
+ err instanceof Error ? err.message : String(err)
229
+ }\n`,
230
+ );
231
+ return 1;
232
+ }
233
+
234
+ // Surface loader-side diagnostics on stderr in the legacy shape so
235
+ // users running `pi-steering list` against a tree with broken layers,
236
+ // dual-form coexistence, stray files, or cross-layer collisions see
237
+ // them — restores the pre-refactor visibility the loader's direct
238
+ // `console.warn` calls used to provide. Track whether any error-
239
+ // class diagnostic was emitted so the CLI can exit non-zero, giving
240
+ // CI lint pipelines a binary signal that the config would refuse to
241
+ // start in production.
242
+ //
243
+ // CLI deliberately renders errors inline rather than via the
244
+ // aggregated thrown-Error form: the audience is a CI grep target,
245
+ // not a human reading a single Error.message.
246
+ let sawError = false;
247
+ const recordDiagnostic = (d: SteeringDiagnostic) => {
248
+ if (d.type === "error") sawError = true;
249
+ process.stderr.write(`${formatSingleLineDiagnostic(d)}\n`);
250
+ };
251
+ for (const d of loaderDiagnostics) {
252
+ recordDiagnostic(d);
253
+ }
254
+
255
+ if (layers.length === 0) {
256
+ if (format === "json") {
257
+ process.stdout.write(`${JSON.stringify(emptyListJSON(), null, 2)}\n`);
258
+ } else {
259
+ process.stdout.write("No steering config found.\n");
260
+ }
261
+ return sawError ? 1 : 0;
262
+ }
263
+
264
+ const { config, diagnostics: mergeAndResolveDiagnostics } =
265
+ runCliMergeWithInfoCapture(layers);
266
+ for (const d of mergeAndResolveDiagnostics) {
267
+ recordDiagnostic(d);
268
+ }
269
+
270
+ if (format === "json") {
271
+ process.stdout.write(
272
+ `${JSON.stringify(renderListJSON(config), null, 2)}\n`,
273
+ );
274
+ } else {
275
+ process.stdout.write(renderListText(config));
276
+ }
277
+ return sawError ? 1 : 0;
278
+ }
279
+
280
+ /**
281
+ * CLI variant of the merge pipeline. Redirects `console.info`
282
+ * breadcrumbs (disabled-plugin / disabled-rule / dropped-observer)
283
+ * onto stderr so stdout stays clean for `--format=json`. After
284
+ * {@link runMergerPipeline}, mirrors {@link buildSessionRuntime}'s
285
+ * `disabledRules` filter + `finalizePluginState` so `pi-steering
286
+ * list` reports the same observer-drop set production sees.
287
+ */
288
+ function runCliMergeWithInfoCapture(layers: readonly SteeringConfig[]): {
289
+ config: SteeringConfig;
290
+ diagnostics: SteeringDiagnostic[];
291
+ } {
292
+ const originalInfo = console.info;
293
+ console.info = (...args: unknown[]) => {
294
+ process.stderr.write(`${args.map((a) => String(a)).join(" ")}\n`);
295
+ };
296
+ try {
297
+ const { merged, resolved, diagnostics } = runMergerPipeline(
298
+ layers,
299
+ undefined,
300
+ EVALUATOR_BUILTIN_TRACKERS,
301
+ );
302
+ // Skipped on merge short-circuit; without resolved we can't
303
+ // enumerate plugin-side observers.
304
+ if (resolved !== null) {
305
+ const userObservers = merged.observers ?? [];
306
+ // Mirror the runtime's `disabledRules` filter (see
307
+ // `buildSessionRuntime` in `internal/session-runtime.ts`)
308
+ // before invoking `finalizePluginState`. Without this
309
+ // filter, observers whose only consumer is a disabled rule
310
+ // would appear consumed in the CLI but get dropped by the
311
+ // runtime — a divergence between `pi-steering list` and
312
+ // what production sees.
313
+ const disabledRules = new Set(merged.disabledRules ?? []);
314
+ const userRules = (merged.rules ?? []).filter(
315
+ (r) => !disabledRules.has(r.name),
316
+ );
317
+ finalizePluginState(
318
+ userRules,
319
+ resolved.rules,
320
+ userObservers,
321
+ resolved.observers,
322
+ );
323
+ }
324
+ return { config: merged, diagnostics };
325
+ } finally {
326
+ console.info = originalInfo;
327
+ }
328
+ }
329
+
330
+ function printListHelp(): void {
331
+ process.stdout.write(`pi-steering list — show the resolved config
332
+
333
+ USAGE
334
+ pi-steering list [--format=text|json]
335
+
336
+ Walks up from the current directory looking for .pi/steering/index.ts
337
+ (or .pi/steering.ts) at each ancestor. Merges the layers inner-first
338
+ and prints the effective plugins, rules, and observers.
339
+
340
+ FLAGS
341
+ --format=text (default) human-readable grouped output
342
+ --format=json machine-readable JSON
343
+ -h, --help show this help
344
+
345
+ EXIT CODES
346
+ 0 resolved successfully (warnings, if any, are fail-soft)
347
+ 1 one or more error-class diagnostics surfaced — production
348
+ runtime would refuse to start on this config; CI lint pipelines
349
+ can gate on this code
350
+
351
+ EXAMPLES
352
+ pi-steering list
353
+ pi-steering list --format=json
354
+
355
+ `);
356
+ }
357
+
358
+ /**
359
+ * Curated mapping from a plugin's `name` to a short human-readable
360
+ * source label (used in the `name [source]` header in text output).
361
+ * Covers plugins shipped by this package. Unknown plugin names get
362
+ * no bracket — the plugin name alone is enough for the user to
363
+ * locate it.
364
+ */
365
+ const KNOWN_PLUGIN_SOURCES: Record<string, string> = {
366
+ git: "pi-steering/plugins/git",
367
+ };
368
+
369
+ /**
370
+ * Render the config as JSON for machine consumption.
371
+ *
372
+ * Shape:
373
+ * {
374
+ * plugins: [{ name, source?, rules: [{ name, tool, when }], observers: [...] }],
375
+ * userRules: [{ name, tool, when }],
376
+ * userObservers: [{ name, writes }],
377
+ * disabled: { rules: [...], plugins: [...] },
378
+ * defaultNoOverride: bool|null,
379
+ * disableDefaults: bool|null
380
+ * }
381
+ */
382
+ function renderListJSON(config: SteeringConfig): unknown {
383
+ const disabledSet = new Set(config.disabledRules ?? []);
384
+ const disabledPluginsSet = new Set(config.disabledPlugins ?? []);
385
+ const plugins = (config.plugins ?? []).map((p) => ({
386
+ name: p.name,
387
+ ...(KNOWN_PLUGIN_SOURCES[p.name] !== undefined
388
+ ? { source: KNOWN_PLUGIN_SOURCES[p.name] }
389
+ : {}),
390
+ ...(disabledPluginsSet.has(p.name) ? { disabled: true } : {}),
391
+ rules: (p.rules ?? []).map((r) => ruleJSON(r, disabledSet)),
392
+ observers: (p.observers ?? []).map((o) => observerJSON(o)),
393
+ }));
394
+
395
+ return {
396
+ plugins,
397
+ userRules: (config.rules ?? []).map((r) => ruleJSON(r, disabledSet)),
398
+ userObservers: (config.observers ?? []).map((o) => observerJSON(o)),
399
+ disabled: {
400
+ rules: config.disabledRules ?? [],
401
+ plugins: config.disabledPlugins ?? [],
402
+ },
403
+ defaultNoOverride: config.defaultNoOverride ?? null,
404
+ disableDefaults: config.disableDefaults ?? null,
405
+ };
406
+ }
407
+
408
+ function emptyListJSON(): unknown {
409
+ return {
410
+ plugins: [],
411
+ userRules: [],
412
+ userObservers: [],
413
+ disabled: { rules: [], plugins: [] },
414
+ defaultNoOverride: null,
415
+ disableDefaults: null,
416
+ };
417
+ }
418
+
419
+ function ruleJSON(r: Rule, disabledSet?: ReadonlySet<string>): unknown {
420
+ return {
421
+ name: r.name,
422
+ tool: r.tool,
423
+ ...(r.when !== undefined ? { when: whenSummaryKeys(r.when) } : {}),
424
+ ...(disabledSet?.has(r.name) ? { disabled: true } : {}),
425
+ };
426
+ }
427
+
428
+ function observerJSON(o: Observer): unknown {
429
+ return {
430
+ name: o.name,
431
+ writes: o.writes ?? [],
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Render the config as a grouped text block.
437
+ *
438
+ * Shape mirrors ADR §16:
439
+ *
440
+ * Resolved config: 2 plugins, 4 rules, 1 observer.
441
+ *
442
+ * git [pi-steering/plugins/git]
443
+ * no-main-commit bash when: branch
444
+ * ...
445
+ *
446
+ * User (.pi/steering/index.ts):
447
+ * (none)
448
+ *
449
+ * Disabled: (none)
450
+ */
451
+ function renderListText(config: SteeringConfig): string {
452
+ const plugins = config.plugins ?? [];
453
+ const userRules = config.rules ?? [];
454
+ const userObservers = config.observers ?? [];
455
+ const disabled = config.disabledRules ?? [];
456
+ const disabledPlugins = config.disabledPlugins ?? [];
457
+ const disabledSet = new Set(disabled);
458
+ const disabledPluginsSet = new Set(disabledPlugins);
459
+
460
+ const totalRules =
461
+ plugins.reduce((n, p) => n + (p.rules?.length ?? 0), 0) + userRules.length;
462
+ const totalObservers =
463
+ plugins.reduce((n, p) => n + (p.observers?.length ?? 0), 0) +
464
+ userObservers.length;
465
+
466
+ const lines: string[] = [];
467
+ lines.push(
468
+ `Resolved config: ${plugins.length} ${plural("plugin", plugins.length)}, ${totalRules} ${plural("rule", totalRules)}, ${totalObservers} ${plural("observer", totalObservers)}.`,
469
+ );
470
+ lines.push("");
471
+
472
+ // Per-plugin block.
473
+ for (const plugin of plugins) {
474
+ const source = KNOWN_PLUGIN_SOURCES[plugin.name];
475
+ const pluginDisabled = disabledPluginsSet.has(plugin.name);
476
+ const suffix = pluginDisabled ? " (disabled)" : "";
477
+ const header = source
478
+ ? `${plugin.name} [${source}]${suffix}`
479
+ : `${plugin.name}${suffix}`;
480
+ lines.push(header);
481
+ renderRuleLines(plugin.rules ?? [], lines, disabledSet);
482
+ renderObserverLines(plugin.observers ?? [], lines);
483
+ lines.push("");
484
+ }
485
+
486
+ // User block.
487
+ lines.push("User (.pi/steering/index.ts):");
488
+ if (userRules.length === 0 && userObservers.length === 0) {
489
+ lines.push(" (none)");
490
+ } else {
491
+ renderRuleLines(userRules, lines, disabledSet);
492
+ renderObserverLines(userObservers, lines);
493
+ }
494
+ lines.push("");
495
+
496
+ // Disabled block.
497
+ if (disabled.length === 0 && disabledPlugins.length === 0) {
498
+ lines.push("Disabled: (none)");
499
+ } else {
500
+ if (disabled.length > 0) {
501
+ lines.push(`Disabled rules: ${disabled.join(", ")}`);
502
+ }
503
+ if (disabledPlugins.length > 0) {
504
+ lines.push(`Disabled plugins: ${disabledPlugins.join(", ")}`);
505
+ }
506
+ }
507
+ lines.push("");
508
+
509
+ return lines.join("\n");
510
+ }
511
+
512
+ function renderRuleLines(
513
+ rules: readonly Rule[],
514
+ lines: string[],
515
+ disabledSet?: ReadonlySet<string>,
516
+ ): void {
517
+ if (rules.length === 0) return;
518
+ const nameWidth = Math.max(...rules.map((r) => r.name.length), 20);
519
+ for (const r of rules) {
520
+ const tools = r.tool.padEnd(5);
521
+ const whenSummary = r.when ? ` when: ${whenSummaryKeys(r.when)}` : "";
522
+ const disabledSuffix = disabledSet?.has(r.name) ? " (disabled)" : "";
523
+ lines.push(
524
+ ` ${r.name.padEnd(nameWidth)} ${tools}${whenSummary}${disabledSuffix}`,
525
+ );
526
+ }
527
+ }
528
+
529
+ function renderObserverLines(
530
+ observers: readonly Observer[],
531
+ lines: string[],
532
+ ): void {
533
+ if (observers.length === 0) return;
534
+ for (const o of observers) {
535
+ const writes =
536
+ o.writes && o.writes.length > 0 ? ` writes: ${o.writes.join(", ")}` : "";
537
+ lines.push(` observer: ${o.name}${writes}`);
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Compact summary of a `TopLevelWhenClause`. Returns a comma-separated list
543
+ * of keys (e.g. `branch, cwd`). Built-in keys get special labels so
544
+ * the output is informative without dumping full predicate values:
545
+ * - `happened` becomes `happened:<event>`
546
+ * - `not` becomes `not:...`
547
+ * - `condition` stays `condition`
548
+ * - plugin predicates just show the key name (`branch`, `upstream`, …).
549
+ */
550
+ function whenSummaryKeys(when: TopLevelWhenClause<string>): string {
551
+ const parts: string[] = [];
552
+ for (const key of Object.keys(when)) {
553
+ if (key === "happened") {
554
+ const happened = when.happened;
555
+ if (happened !== undefined) {
556
+ parts.push(`happened:${happened.event}`);
557
+ } else {
558
+ parts.push("happened");
559
+ }
560
+ } else if (key === "not") {
561
+ parts.push("not:...");
562
+ } else {
563
+ // Everything else (cwd, condition, plugin predicates) renders
564
+ // as its key name — the source file is the canonical place
565
+ // to look up the full value.
566
+ parts.push(key);
567
+ }
568
+ }
569
+ return parts.join(", ");
570
+ }
571
+
572
+ function plural(word: string, n: number): string {
573
+ return n === 1 ? word : `${word}s`;
574
+ }
575
+
576
+ // Bootstrap: only invoke `main` when this module is the entry point,
577
+ // not when imported for testing. `import.meta.url` matches `argv[1]`
578
+ // when run as `node pi-steering.js`.
579
+ const entry = process.argv[1];
580
+ if (entry !== undefined && import.meta.url === `file://${entry}`) {
581
+ void main(process.argv).then((code) => {
582
+ process.exit(code);
583
+ });
584
+ }