@bompus/house-rules 0.0.0-stage → 0.13.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 (236) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +378 -2
  5. package/THIRD_PARTY_NOTICES.md +188 -0
  6. package/compose.mjs +125 -0
  7. package/composition.mjs +361 -0
  8. package/config-view.mjs +209 -0
  9. package/config.mjs +381 -0
  10. package/docs/assets/banner-dark.png +0 -0
  11. package/docs/assets/banner-light.png +0 -0
  12. package/docs/assets/overview.png +0 -0
  13. package/docs/configuration.md +269 -0
  14. package/docs/skill-names.md +50 -0
  15. package/examples/person/house-rules.json +15 -0
  16. package/examples/person/rules/10-tooling.md +8 -0
  17. package/examples/person/rules/20-reporting.md +9 -0
  18. package/examples/project/AGENTS.md +18 -0
  19. package/package.json +42 -4
  20. package/rule-references.mjs +91 -0
  21. package/rules/core.md +340 -0
  22. package/rules/modifiers/backlog-outline.md +25 -0
  23. package/rules/modifiers/backlog-visual.md +16 -0
  24. package/rules/modifiers/coded-offers.md +70 -0
  25. package/rules/modifiers/cross-session-relay.md +23 -0
  26. package/rules/modifiers/effort-estimates.md +41 -0
  27. package/rules/modifiers/land-when-done.md +12 -0
  28. package/rules/modifiers/low-quota-handoff.md +20 -0
  29. package/rules/modifiers/model-default-questions.md +33 -0
  30. package/rules/modifiers/multi-agent.md +38 -0
  31. package/rules/modifiers/no-attribution.md +16 -0
  32. package/rules/modifiers/periodic-updates.md +25 -0
  33. package/rules/modifiers/plan-files.md +82 -0
  34. package/rules/modifiers/question-cards.md +48 -0
  35. package/rules/modifiers/release-batching.md +15 -0
  36. package/rules/modifiers/scratch-on-disk.md +15 -0
  37. package/rules/modifiers/shared-host-load.md +19 -0
  38. package/rules/modifiers/solo-operator.md +13 -0
  39. package/rules/modifiers/squash-landing.md +22 -0
  40. package/rules/modifiers/upstream-contributions.md +45 -0
  41. package/rules/modifiers/web-image-formats.md +35 -0
  42. package/rules/references/backlog-view.md +154 -0
  43. package/rules/references/github-actions.md +45 -0
  44. package/rules/references/landing.md +39 -0
  45. package/rules/references/multi-agent.md +51 -0
  46. package/rules/references/release-batching.md +66 -0
  47. package/rules/references/squash-landing.md +50 -0
  48. package/rules/references/thread-visuals.md +45 -0
  49. package/setup.mjs +445 -0
  50. package/skills/hr-agent-guidance-audit/LICENSE +21 -0
  51. package/skills/hr-agent-guidance-audit/SKILL.md +91 -0
  52. package/skills/hr-agent-guidance-audit/agents/openai.yaml +2 -0
  53. package/skills/hr-agent-guidance-audit/references/prune-workflow.md +72 -0
  54. package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +60 -0
  55. package/skills/hr-agent-guidance-audit/references/retirement-verdicts.md +49 -0
  56. package/skills/hr-agent-guidance-refresh/LICENSE +21 -0
  57. package/skills/hr-agent-guidance-refresh/SKILL.md +36 -0
  58. package/skills/hr-agent-guidance-refresh/agents/openai.yaml +2 -0
  59. package/skills/hr-agent-guidance-refresh/references/verification.md +47 -0
  60. package/skills/hr-api-exposure-check/SKILL.md +159 -0
  61. package/skills/hr-api-exposure-check/agents/openai.yaml +3 -0
  62. package/skills/hr-audit-choices/SKILL.md +67 -0
  63. package/skills/hr-audit-choices/agents/openai.yaml +2 -0
  64. package/skills/hr-benchmarking/NOTICE.md +36 -0
  65. package/skills/hr-benchmarking/SKILL.md +279 -0
  66. package/skills/hr-benchmarking/agents/openai.yaml +2 -0
  67. package/skills/hr-benchmarking/references/javascript.md +130 -0
  68. package/skills/hr-benchmarking/references/tools.md +165 -0
  69. package/skills/hr-benchmarking/scripts/process-attribution.ps1 +20 -0
  70. package/skills/hr-benchmarking/scripts/process-attribution.py +107 -0
  71. package/skills/hr-better-accessibility/LICENSE +22 -0
  72. package/skills/hr-better-accessibility/NOTICE.md +46 -0
  73. package/skills/hr-better-accessibility/SKILL.md +119 -0
  74. package/skills/hr-better-accessibility/agents/openai.yaml +3 -0
  75. package/skills/hr-better-accessibility/focus-and-keyboard.md +130 -0
  76. package/skills/hr-better-accessibility/forms.md +82 -0
  77. package/skills/hr-better-accessibility/hit-areas.md +98 -0
  78. package/skills/hr-better-accessibility/motion-and-zoom.md +81 -0
  79. package/skills/hr-better-accessibility/screen-readers.md +102 -0
  80. package/skills/hr-better-accessibility/semantics-and-aria.md +84 -0
  81. package/skills/hr-better-colors/LICENSE +21 -0
  82. package/skills/hr-better-colors/NOTICE.md +37 -0
  83. package/skills/hr-better-colors/SKILL.md +114 -0
  84. package/skills/hr-better-colors/agents/openai.yaml +3 -0
  85. package/skills/hr-better-colors/color-formats.md +90 -0
  86. package/skills/hr-better-colors/color-usage.md +140 -0
  87. package/skills/hr-better-colors/contrast.md +80 -0
  88. package/skills/hr-better-colors/palette-generation.md +104 -0
  89. package/skills/hr-better-colors/palette-structure.md +76 -0
  90. package/skills/hr-better-colors/token-naming.md +97 -0
  91. package/skills/hr-better-layout/LICENSE +22 -0
  92. package/skills/hr-better-layout/NOTICE.md +48 -0
  93. package/skills/hr-better-layout/SKILL.md +92 -0
  94. package/skills/hr-better-layout/agents/openai.yaml +3 -0
  95. package/skills/hr-better-layout/grouping-and-alignment.md +144 -0
  96. package/skills/hr-better-layout/spacing-and-adaptivity.md +215 -0
  97. package/skills/hr-better-typography/LICENSE +22 -0
  98. package/skills/hr-better-typography/NOTICE.md +46 -0
  99. package/skills/hr-better-typography/SKILL.md +172 -0
  100. package/skills/hr-better-typography/agents/openai.yaml +3 -0
  101. package/skills/hr-better-typography/choosing-fonts.md +69 -0
  102. package/skills/hr-better-typography/css-cheat-sheet.md +65 -0
  103. package/skills/hr-better-typography/details-and-accessibility.md +119 -0
  104. package/skills/hr-better-typography/spacing-and-sizing.md +131 -0
  105. package/skills/hr-better-typography/variable-fonts-and-opentype.md +105 -0
  106. package/skills/hr-better-typography/wrapping-and-punctuation.md +57 -0
  107. package/skills/hr-better-writing/LICENSE +22 -0
  108. package/skills/hr-better-writing/NOTICE.md +46 -0
  109. package/skills/hr-better-writing/SKILL.md +122 -0
  110. package/skills/hr-better-writing/agents/openai.yaml +3 -0
  111. package/skills/hr-code-review/LICENSE +21 -0
  112. package/skills/hr-code-review/NOTICE.md +26 -0
  113. package/skills/hr-code-review/SKILL.md +163 -0
  114. package/skills/hr-code-review/agents/openai.yaml +3 -0
  115. package/skills/hr-code-review/references/acp-client.md +43 -0
  116. package/skills/hr-code-review/references/evidence.md +57 -0
  117. package/skills/hr-code-review/references/impact.md +103 -0
  118. package/skills/hr-code-review/references/panel-adjudication.md +58 -0
  119. package/skills/hr-code-review/references/panel-plan.md +61 -0
  120. package/skills/hr-code-review/references/panel-recommendations.json +105 -0
  121. package/skills/hr-code-review/references/panel-recommendations.md +46 -0
  122. package/skills/hr-code-review/references/panel-setup.md +54 -0
  123. package/skills/hr-code-review/references/reviewer-role.md +31 -0
  124. package/skills/hr-code-review/scripts/acp-panel-client.mjs +299 -0
  125. package/skills/hr-code-review/scripts/check-receipts.mjs +263 -0
  126. package/skills/hr-code-review/scripts/panel-plan.mjs +69 -0
  127. package/skills/hr-code-review/scripts/panel-preferences.mjs +220 -0
  128. package/skills/hr-design-exploration/SKILL.md +105 -0
  129. package/skills/hr-design-exploration/agents/openai.yaml +4 -0
  130. package/skills/hr-diagnosing-bugs/NOTICE.md +45 -0
  131. package/skills/hr-diagnosing-bugs/SKILL.md +108 -0
  132. package/skills/hr-diagnosing-bugs/agents/openai.yaml +3 -0
  133. package/skills/hr-diagnosing-bugs/references/reporting.md +97 -0
  134. package/skills/hr-diagnosing-bugs/scripts/check-pr-offer.mjs +47 -0
  135. package/skills/hr-explain-code/NOTICE.md +37 -0
  136. package/skills/hr-explain-code/SKILL.md +110 -0
  137. package/skills/hr-explain-code/agents/openai.yaml +5 -0
  138. package/skills/hr-explain-code/references/helper-brief.md +39 -0
  139. package/skills/hr-extract-shared-steps/SKILL.md +134 -0
  140. package/skills/hr-extract-shared-steps/agents/openai.yaml +3 -0
  141. package/skills/hr-forgotten-work-audit/SKILL.md +130 -0
  142. package/skills/hr-forgotten-work-audit/references/brief-template.md +56 -0
  143. package/skills/hr-forgotten-work-audit/references/result-format.md +46 -0
  144. package/skills/hr-handoff/LICENSE +21 -0
  145. package/skills/hr-handoff/NOTICE.md +29 -0
  146. package/skills/hr-handoff/SKILL.md +114 -0
  147. package/skills/hr-handoff/agents/openai.yaml +5 -0
  148. package/skills/hr-handoff/assets/ledger.example.json +10 -0
  149. package/skills/hr-handoff/assets/receipt.example.json +76 -0
  150. package/skills/hr-handoff/assets/source.example.json +20 -0
  151. package/skills/hr-handoff/references/reconciliation.md +124 -0
  152. package/skills/hr-handoff/references/successor-thread.md +76 -0
  153. package/skills/hr-handoff/scripts/check-reconciliation.mjs +324 -0
  154. package/skills/hr-house-rules-setup/SKILL.md +114 -0
  155. package/skills/hr-house-rules-setup/agents/openai.yaml +5 -0
  156. package/skills/hr-issue-tracker-setup/SKILL.md +49 -0
  157. package/skills/hr-issue-tracker-setup/agents/openai.yaml +5 -0
  158. package/skills/hr-lean-plan/SKILL.md +146 -0
  159. package/skills/hr-lean-plan/agents/openai.yaml +5 -0
  160. package/skills/hr-maintainability-review/SKILL.md +186 -0
  161. package/skills/hr-maintainability-review/agents/openai.yaml +5 -0
  162. package/skills/hr-manual-qa/NOTICE.md +36 -0
  163. package/skills/hr-manual-qa/SKILL.md +37 -0
  164. package/skills/hr-manual-qa/agents/openai.yaml +3 -0
  165. package/skills/hr-navigation-retro/LICENSE +21 -0
  166. package/skills/hr-navigation-retro/NOTICE.md +5 -0
  167. package/skills/hr-navigation-retro/SKILL.md +86 -0
  168. package/skills/hr-navigation-retro/agents/openai.yaml +4 -0
  169. package/skills/hr-navigation-retro/references/inputs.md +67 -0
  170. package/skills/hr-navigation-retro/scripts/retro.py +552 -0
  171. package/skills/hr-navigation-retro/scripts/transcripts.py +205 -0
  172. package/skills/hr-ordering-tests/SKILL.md +51 -0
  173. package/skills/hr-ordering-tests/agents/openai.yaml +2 -0
  174. package/skills/hr-plain-prose/SKILL.md +264 -0
  175. package/skills/hr-plain-prose/agents/openai.yaml +3 -0
  176. package/skills/hr-plain-prose/scripts/check.mjs +331 -0
  177. package/skills/hr-pr-followup/LICENSE +21 -0
  178. package/skills/hr-pr-followup/NOTICE.md +9 -0
  179. package/skills/hr-pr-followup/SKILL.md +74 -0
  180. package/skills/hr-pr-followup/agents/openai.yaml +2 -0
  181. package/skills/hr-pr-followup/references/review-helpers.md +111 -0
  182. package/skills/hr-pr-followup/scripts/check-landing.py +204 -0
  183. package/skills/hr-pr-followup/scripts/check-pr-review.ts +510 -0
  184. package/skills/hr-pr-followup/scripts/merge-pr.sh +100 -0
  185. package/skills/hr-pr-followup/scripts/resolve-pr-thread.ts +219 -0
  186. package/skills/hr-pr-followup/scripts/verify-landed.sh +184 -0
  187. package/skills/hr-progress-report/SKILL.md +49 -0
  188. package/skills/hr-progress-report/agents/openai.yaml +5 -0
  189. package/skills/hr-read-reddit/SKILL.md +44 -0
  190. package/skills/hr-read-reddit/agents/openai.yaml +2 -0
  191. package/skills/hr-read-reddit/scripts/reddit.ts +252 -0
  192. package/skills/hr-read-x-links/SKILL.md +42 -0
  193. package/skills/hr-read-x-links/agents/openai.yaml +2 -0
  194. package/skills/hr-read-x-links/scripts/x-post.ts +264 -0
  195. package/skills/hr-research-shared-links/SKILL.md +103 -0
  196. package/skills/hr-split-to-prs/LICENSE +21 -0
  197. package/skills/hr-split-to-prs/NOTICE.md +9 -0
  198. package/skills/hr-split-to-prs/SKILL.md +61 -0
  199. package/skills/hr-split-to-prs/agents/openai.yaml +2 -0
  200. package/skills/hr-sqlite/SKILL.md +174 -0
  201. package/skills/hr-sqlite/agents/openai.yaml +2 -0
  202. package/skills/hr-sqlite/references/evidence.md +94 -0
  203. package/skills/hr-sqlite/references/tool-decisions.md +39 -0
  204. package/skills/hr-sqlite/scripts/inspect-sqlite.py +245 -0
  205. package/skills/hr-stock-ui-audit/SKILL.md +132 -0
  206. package/skills/hr-stock-ui-audit/agents/openai.yaml +3 -0
  207. package/skills/hr-stock-ui-audit/scripts/scan.mjs +619 -0
  208. package/skills/hr-tdd/LICENSE +21 -0
  209. package/skills/hr-tdd/NOTICE.md +10 -0
  210. package/skills/hr-tdd/SKILL.md +40 -0
  211. package/skills/hr-tdd/agents/openai.yaml +3 -0
  212. package/skills/hr-tdd/mocking.md +39 -0
  213. package/skills/hr-tdd/tests.md +77 -0
  214. package/skills/hr-test-audit/LICENSE +21 -0
  215. package/skills/hr-test-audit/NOTICE.md +67 -0
  216. package/skills/hr-test-audit/SKILL.md +146 -0
  217. package/skills/hr-test-audit/agents/openai.yaml +3 -0
  218. package/skills/hr-test-audit/scripts/floor-guard.ts +137 -0
  219. package/skills/hr-usage-report/SKILL.md +90 -0
  220. package/skills/hr-usage-report/agents/openai.yaml +2 -0
  221. package/skills/hr-usage-report/assets/example-report.json +125 -0
  222. package/skills/hr-usage-report/assets/report-template.html +377 -0
  223. package/skills/hr-usage-report/scripts/render-report.ts +86 -0
  224. package/skills/hr-usage-report/scripts/usage-by-model.ts +499 -0
  225. package/skills/hr-what-next/SKILL.md +151 -0
  226. package/skills/hr-what-next/agents/openai.yaml +4 -0
  227. package/skills/hr-what-next/references/reporting.md +59 -0
  228. package/skills/hr-what-next/scripts/check-open-work.mjs +711 -0
  229. package/skills/hr-writing-for-agents/NOTICE.md +46 -0
  230. package/skills/hr-writing-for-agents/SKILL-MECHANICS.md +83 -0
  231. package/skills/hr-writing-for-agents/SKILL.md +108 -0
  232. package/skills/hr-writing-for-agents/agents/openai.yaml +3 -0
  233. package/skills/hr-writing-pr/NOTICE.md +48 -0
  234. package/skills/hr-writing-pr/SKILL.md +119 -0
  235. package/skills/hr-writing-pr/agents/openai.yaml +2 -0
  236. package/skills/hr-writing-pr/scripts/benefit-chart.py +200 -0
@@ -0,0 +1,209 @@
1
+ // Human output is grouped and aligned; JSON output bypasses this renderer.
2
+ import { styleText } from "node:util";
3
+
4
+ const clean = (value) => String(value ?? "").replace(/\p{Cc}/gu, " ");
5
+ function display() {
6
+ const color =
7
+ process.stdout.isTTY && !Object.hasOwn(process.env, "NO_COLOR") && process.env.TERM !== "dumb";
8
+ const paint = (style, text) => (color ? styleText(style, text, { validateStream: false }) : text);
9
+ const width = Math.max(24, Math.min(process.stdout.columns ?? 100, 120));
10
+ const wrap = (text, size) => {
11
+ const words = clean(text).split(/\s+/);
12
+ const lines = [""];
13
+ for (let word of words) {
14
+ while (word.length > size) {
15
+ if (lines.at(-1)) lines.push("");
16
+ lines[lines.length - 1] = word.slice(0, size);
17
+ word = word.slice(size);
18
+ lines.push("");
19
+ }
20
+ if (lines.at(-1).length + word.length + 1 > size) lines.push("");
21
+ lines[lines.length - 1] += (lines.at(-1) ? " " : "") + word;
22
+ }
23
+ return lines;
24
+ };
25
+ const table = (headers, rows) => {
26
+ const first = Math.max(headers[0].length, ...rows.map((row) => clean(row[0]).length));
27
+ const second = Math.max(headers[1].length, ...rows.map((row) => clean(row[1]).length));
28
+ if (first + second + 14 > width) {
29
+ return rows
30
+ .flatMap((row) => [
31
+ ...wrap(`${row[0]} [${row[1]}]`, width - 2).map((l) => ` ${paint("bold", l)}`),
32
+ ...wrap(row[2], width - 4).map((l) => ` ${l}`),
33
+ "",
34
+ ])
35
+ .join("\n");
36
+ }
37
+ const third = width - first - second - 8;
38
+ const line = (a, b, c) => {
39
+ const state = b.padEnd(second);
40
+ return ` ${a.padEnd(first)} ${paint(b === "on" ? "green" : b === "off" ? "dim" : "cyan", state)} ${c}`;
41
+ };
42
+ const heading = paint("dim", line(...headers));
43
+ return [
44
+ heading,
45
+ ...rows.flatMap(([a, b, c]) =>
46
+ wrap(c, third).map((text, index) =>
47
+ line(index ? "" : clean(a), index ? "" : clean(b), text),
48
+ ),
49
+ ),
50
+ ].join("\n");
51
+ };
52
+ return { paint, table, wrap, width };
53
+ }
54
+
55
+ export function renderHelp() {
56
+ const { paint, table } = display();
57
+ return [
58
+ "",
59
+ ` ${paint("dim", "╭─")} ${paint("cyan", paint("bold", "HOUSE RULES"))} ${paint("dim", "configuration")}`,
60
+ "",
61
+ " node compose.mjs config <command> [options]",
62
+ "",
63
+ table(
64
+ ["Command", "Writes", "Purpose"],
65
+ [
66
+ ["catalog", "no", "Browse modifiers, skills and rule sections."],
67
+ ["status", "no", "See selections, personal origins and current revision."],
68
+ ["validate", "no", "Check configuration and compose every selected layer."],
69
+ ["preview", "no", "Review proposed selections; --rules prints composed rules."],
70
+ ["set", "opt-in", "Preview changes, then save with --apply --expect <revision>."],
71
+ ],
72
+ ),
73
+ "",
74
+ " Selection",
75
+ " --enable-modifier <name> --disable-modifier <name>",
76
+ " --enable-skill <name> --disable-skill <name>",
77
+ " --questions default|plain|coded|cards",
78
+ " --panel-file <json> Replace portable panel preferences (preview/set)",
79
+ " --expect-panel <revision> Require reviewed panel input when applying",
80
+ "",
81
+ " Output",
82
+ " --config <path> --json --rules (preview only)",
83
+ "",
84
+ " Repeat toggle flags to change several selections together.",
85
+ " Saving config does not regenerate output or connect agent hosts.",
86
+ " Panel preferences do not discover providers, enable a skill or run models.",
87
+ "",
88
+ ].join("\n");
89
+ }
90
+
91
+ export function renderConfig(report) {
92
+ const { paint, table, wrap, width } = display();
93
+ const lines = [
94
+ "",
95
+ ` ${paint("dim", "╭─")} ${paint("cyan", paint("bold", "HOUSE RULES"))} ${paint("dim", report.command)}`,
96
+ "",
97
+ ];
98
+ const section = (label) => lines.push(` ${paint("bold", label)}`, "");
99
+ const detail = (text) => lines.push(...wrap(text, width - 2).map((l) => ` ${l}`));
100
+ detail(`Config ${report.configPath}${report.exists ? "" : " (not created)"}`);
101
+ detail(`Revision ${report.applied?.revision ?? report.revision}`);
102
+ lines.push("");
103
+ const enabled = report.modifiers.filter((m) => m.enabled).length;
104
+ detail(
105
+ `${enabled}/${report.modifiers.length} modifiers enabled ${report.skills.filter((s) => s.enabled).length}/${report.skills.length} skills selected ${report.rules.length} rule sections`,
106
+ );
107
+ detail(`Configured questions ${report.questions} Host loading ${report.hostLoading}`);
108
+ lines.push("");
109
+ if (["set", "preview"].includes(report.command)) {
110
+ section(
111
+ report.applied
112
+ ? report.applied.changed
113
+ ? "Configuration saved"
114
+ : "Already configured"
115
+ : "Change preview",
116
+ );
117
+ if (!report.changes.length) detail("No selection changes.");
118
+ for (const change of report.changes) {
119
+ detail(change.key);
120
+ const removed = change.before.filter((name) => !change.after.includes(name));
121
+ const added = change.after.filter((name) => !change.before.includes(name));
122
+ for (const [sign, names, style] of [
123
+ ["-", removed, "red"],
124
+ ["+", added, "green"],
125
+ ]) {
126
+ for (const name of names) lines.push(` ${paint(style, `${sign} ${clean(name)}`)}`);
127
+ }
128
+ if (!removed.length && !added.length) detail("Selection order changed.");
129
+ }
130
+ if (report.panel) {
131
+ section(report.panel.changed ? "Panel section replacement" : "Panel section unchanged");
132
+ detail(`Panel input revision ${report.panel.revision}`);
133
+ detail("Before");
134
+ for (const line of JSON.stringify(report.panel.before, null, 2).split("\n")) detail(line);
135
+ detail("After");
136
+ for (const line of JSON.stringify(report.panel.after, null, 2).split("\n")) detail(line);
137
+ detail(
138
+ `Preferences ${report.applied ? "configured" : "proposed"} only. Route access, effort and permissions are unverified.`,
139
+ );
140
+ }
141
+ lines.push("");
142
+ if (!report.applied) {
143
+ const panelGuard = report.panel ? ` --expect-panel ${report.panel.revision}` : "";
144
+ detail(
145
+ report.command === "set"
146
+ ? `To save, repeat with --apply --expect ${report.revision}${panelGuard}`
147
+ : `To save, run config set with the same selection flags plus --apply --expect ${report.revision}${panelGuard}`,
148
+ );
149
+ detail("Review personal rule overrides below before choosing a question format.");
150
+ } else
151
+ detail(
152
+ "Next, recompose output with compose.mjs or rerun the installer. Existing skills directories are retained; host connection is separate.",
153
+ );
154
+ lines.push("");
155
+ }
156
+ if (report.command === "validate") detail("Configuration and composition are valid.");
157
+ else if (["status", "catalog"].includes(report.command)) {
158
+ section("Modifiers");
159
+ lines.push(
160
+ table(
161
+ ["Name", "State", "Purpose"],
162
+ report.modifiers.map((m) => [m.name, m.enabled ? "on" : "off", m.description]),
163
+ ),
164
+ "",
165
+ );
166
+ section("Skills");
167
+ lines.push(
168
+ table(
169
+ ["Name", "State", report.command === "catalog" ? "Purpose" : "Origin"],
170
+ report.skills.map((s) => [
171
+ s.name,
172
+ s.enabled ? "on" : "off",
173
+ report.command === "catalog" ? s.description : s.origin,
174
+ ]),
175
+ ),
176
+ "",
177
+ );
178
+ section("Effective rules");
179
+ lines.push(
180
+ table(
181
+ ["Section", "State", "Source"],
182
+ report.rules.map((r) => [r.heading, "active", r.origin]),
183
+ ),
184
+ "",
185
+ );
186
+ section("Personal layers");
187
+ for (const layer of report.layers) detail(layer);
188
+ }
189
+ const personal = report.rules.filter(
190
+ (rule) => rule.source !== "core" && !rule.source.startsWith("modifier "),
191
+ );
192
+ if (["preview", "set"].includes(report.command) && personal.length) {
193
+ section("Personal rule overrides and additions");
194
+ lines.push(
195
+ table(
196
+ ["Section", "State", "Source"],
197
+ personal.map((r) => [r.heading, "active", r.origin]),
198
+ ),
199
+ "",
200
+ );
201
+ }
202
+ for (const name of report.unavailable)
203
+ detail(`Retained exclusion with no current source: ${name}`);
204
+ for (const warning of report.warnings) detail(`Warning: ${warning}`);
205
+ lines.push("");
206
+ detail("Status describes composed selections. It does not verify an agent's loaded context.");
207
+ lines.push(` ${paint("dim", "╰─")}`);
208
+ return lines.join("\n");
209
+ }
package/config.mjs ADDED
@@ -0,0 +1,381 @@
1
+ // Selection management for compose.mjs config. JSON is the only settings store.
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import {
4
+ closeSync,
5
+ existsSync,
6
+ fsyncSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ renameSync,
13
+ statSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { basename, dirname, join, relative, resolve } from "node:path";
18
+ import { parseArgs } from "node:util";
19
+ import { fileURLToPath } from "node:url";
20
+ const SHIPPED_SKILLS = join(dirname(fileURLToPath(import.meta.url)), "skills");
21
+ import {
22
+ composeConfiguration,
23
+ configurationPath,
24
+ modifierList,
25
+ parseFragment,
26
+ skillSources,
27
+ splitSections,
28
+ } from "./composition.mjs";
29
+ import { renderConfig, renderHelp } from "./config-view.mjs";
30
+ import { changePanelPreferences } from "./skills/hr-code-review/scripts/panel-preferences.mjs";
31
+
32
+ const COMMANDS = ["catalog", "status", "validate", "preview", "set"];
33
+ const TOGGLES = ["enable-modifier", "disable-modifier", "enable-skill", "disable-skill"];
34
+ const revisionOf = (text) =>
35
+ text === null ? "missing" : createHash("sha256").update(text).digest("hex");
36
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
37
+
38
+ function stringList(value, key) {
39
+ if (value === undefined) return;
40
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim()))
41
+ throw new Error(`${key} must be an array of non-empty strings`);
42
+ if (new Set(value).size !== value.length) throw new Error(`${key} contains duplicates`);
43
+ }
44
+
45
+ export const QUESTION_PRESETS = ["default", "plain", "coded", "cards"];
46
+ export const QUESTION_MODIFIERS = ["model-default-questions", "coded-offers", "question-cards"];
47
+ const BACKLOG_VIEWS = ["backlog-visual", "backlog-outline"];
48
+
49
+ function validateStructure(config) {
50
+ if (!object(config)) throw new Error("configuration must be a JSON object");
51
+ for (const key of ["modifiers", "layers"]) stringList(config[key], key);
52
+ if (config.skills !== undefined && !object(config.skills))
53
+ throw new Error("skills must be an object");
54
+ for (const key of ["include", "exclude", "independent"])
55
+ stringList(config.skills?.[key], `skills.${key}`);
56
+ }
57
+
58
+ function validateSelection(config) {
59
+ validateStructure(config);
60
+ const known = new Set(modifierList().map((m) => m.name));
61
+ for (const name of config.modifiers ?? []) {
62
+ if (!known.has(name))
63
+ throw new Error(
64
+ `unknown modifier "${name}" (see config catalog); preview its removal with config set --disable-modifier <name>, then save it with config set --disable-modifier <name> --apply --expect <revision>`,
65
+ );
66
+ }
67
+ if (BACKLOG_VIEWS.every((name) => config.modifiers?.includes(name)))
68
+ throw new Error(
69
+ `${BACKLOG_VIEWS.join(" and ")} cannot be combined; enable one, or neither for the table alone`,
70
+ );
71
+ if (config.modifiers?.includes("question-cards") && !config.modifiers.includes("coded-offers"))
72
+ throw new Error(
73
+ "question-cards requires coded-offers; use --questions cards or disable question-cards",
74
+ );
75
+ if (
76
+ config.modifiers?.includes("model-default-questions") &&
77
+ config.modifiers.some((name) => ["coded-offers", "question-cards"].includes(name))
78
+ )
79
+ throw new Error(
80
+ "model-default-questions cannot combine with coded-offers or question-cards; use --questions",
81
+ );
82
+ }
83
+
84
+ export function readConfiguration(path) {
85
+ // Preserve the caller's config directory: relative layers use the same base as composition.
86
+ path = resolve(path);
87
+ let text = null;
88
+ try {
89
+ if (!lstatSync(path).isFile())
90
+ throw new Error("config must be a regular file, not a symlink or directory");
91
+ text = readFileSync(path, "utf8");
92
+ } catch (e) {
93
+ if (e.code !== "ENOENT") throw e;
94
+ }
95
+ let config = {};
96
+ try {
97
+ if (text !== null) config = JSON.parse(text.replace(/^\uFEFF/, ""));
98
+ } catch {
99
+ throw new Error(`invalid JSON in ${path}`);
100
+ }
101
+ validateStructure(config);
102
+ return { path, text, revision: revisionOf(text), config };
103
+ }
104
+
105
+ export function selectionReport(config, path) {
106
+ validateSelection(config);
107
+ const result = composeConfiguration(config, dirname(path));
108
+ const allSkills = skillSources(
109
+ { ...config, skills: { ...config.skills, include: undefined, exclude: [] } },
110
+ dirname(path),
111
+ );
112
+ const selected = new Set(config.modifiers ?? []);
113
+ const modifiers = modifierList().map((m) => ({ ...m, enabled: selected.has(m.name) }));
114
+ const skills = [...allSkills].map(([name, source]) => ({
115
+ name,
116
+ source,
117
+ origin: source === join(SHIPPED_SKILLS, name) ? "shipped" : relative(dirname(path), source),
118
+ enabled: result.skills.has(name),
119
+ explicitOnly:
120
+ parseFragment(readFileSync(join(source, "SKILL.md"), "utf8"), source).meta[
121
+ "disable-model-invocation"
122
+ ] === "true",
123
+ description:
124
+ parseFragment(readFileSync(join(source, "SKILL.md"), "utf8"), source).meta.description ?? "",
125
+ }));
126
+ const unavailable = (config.skills?.exclude ?? []).filter((name) => !allSkills.has(name));
127
+ const rules = splitSections(result.rules).sections.map((s) => ({
128
+ heading: s.heading,
129
+ source: "core",
130
+ }));
131
+ const origins = new Map();
132
+ for (const f of result.fragments) {
133
+ for (const section of splitSections(f.body).sections) origins.set(section.heading, f.source);
134
+ }
135
+ for (const rule of rules) {
136
+ rule.source = origins.get(rule.heading) ?? "core";
137
+ rule.origin =
138
+ rule.source === "core" || rule.source.startsWith("modifier ")
139
+ ? rule.source
140
+ : relative(dirname(path), rule.source);
141
+ }
142
+ return {
143
+ modifiers,
144
+ skills,
145
+ rules,
146
+ unavailable,
147
+ layers: (config.layers ?? ["."]).map((layer) => resolve(dirname(path), layer)),
148
+ fragments: result.fragments.map((f) => ({ source: f.source, ...f.meta })),
149
+ questions: selected.has("model-default-questions")
150
+ ? "default"
151
+ : selected.has("question-cards")
152
+ ? "cards"
153
+ : selected.has("coded-offers")
154
+ ? "coded"
155
+ : "plain",
156
+ warnings: result.warnings,
157
+ composedRules: result.rules,
158
+ hostLoading: "unverified",
159
+ };
160
+ }
161
+
162
+ export function changeSelection(config, options, path) {
163
+ const next = structuredClone(config);
164
+ const modifiers = new Set(next.modifiers ?? []);
165
+ const excluded = new Set(next.skills?.exclude ?? []);
166
+ const included = next.skills?.include === undefined ? null : new Set(next.skills.include);
167
+ const knownModifiers = new Set(modifierList().map((m) => m.name));
168
+ // Use discovery even for excluded skills; preserve unrelated existing exclusions.
169
+ const knownSkills = new Set(
170
+ skillSources(
171
+ { ...next, skills: { ...next.skills, include: undefined, exclude: [] } },
172
+ dirname(path),
173
+ ).keys(),
174
+ );
175
+ for (const [kind, known, choices] of [
176
+ ["modifier", knownModifiers, modifiers],
177
+ ["skill", knownSkills, excluded],
178
+ ]) {
179
+ const enable = options[`enable-${kind}`] ?? [];
180
+ const disable = options[`disable-${kind}`] ?? [];
181
+ for (const name of [...enable, ...disable]) {
182
+ if (!known.has(name) && !(kind === "modifier" && disable.includes(name) && choices.has(name)))
183
+ throw new Error(`unknown ${kind} "${name}" (see config catalog)`);
184
+ if (enable.includes(name) && disable.includes(name))
185
+ throw new Error(`cannot enable and disable "${name}" together`);
186
+ if (
187
+ kind === "modifier" &&
188
+ options.questions !== undefined &&
189
+ QUESTION_MODIFIERS.includes(name)
190
+ )
191
+ throw new Error("use --questions or explicit question modifiers, not both");
192
+ }
193
+ for (const name of enable) {
194
+ if (kind === "modifier") choices.add(name);
195
+ else {
196
+ choices.delete(name);
197
+ if (included && existsSync(join(SHIPPED_SKILLS, name, "SKILL.md"))) included.add(name);
198
+ }
199
+ }
200
+ for (const name of disable) {
201
+ if (kind === "modifier") choices.delete(name);
202
+ else choices.add(name);
203
+ }
204
+ }
205
+ if (options.questions !== undefined) {
206
+ if (!QUESTION_PRESETS.includes(options.questions))
207
+ throw new Error("--questions must be default, plain, coded or cards");
208
+ if (options.questions === "plain" || options.questions === "default")
209
+ modifiers.delete("coded-offers");
210
+ else modifiers.add("coded-offers");
211
+ if (options.questions === "cards") modifiers.add("question-cards");
212
+ else modifiers.delete("question-cards");
213
+ if (options.questions === "default") modifiers.add("model-default-questions");
214
+ else modifiers.delete("model-default-questions");
215
+ }
216
+ if (JSON.stringify([...modifiers]) !== JSON.stringify(next.modifiers ?? []))
217
+ next.modifiers = [...modifiers];
218
+ if (JSON.stringify([...excluded]) !== JSON.stringify(next.skills?.exclude ?? []))
219
+ next.skills = { ...next.skills, exclude: [...excluded] };
220
+ if (included) next.skills.include = [...included];
221
+ selectionReport(next, path); // Composition and legacy-name errors precede every write.
222
+ return next;
223
+ }
224
+
225
+ // Cooperating CLI writers share an exclusive lock; expected revision guards stale previews.
226
+ // Manual editors do not share this lock. Recheck before rename, but they can still race that rename.
227
+ export function writeConfiguration(snapshot, next, expected) {
228
+ if (!expected) throw new Error("--apply requires --expect <revision> from status or a preview");
229
+ selectionReport(next, snapshot.path);
230
+ mkdirSync(dirname(snapshot.path), { recursive: true });
231
+ // Lock aliases of the same parent together without changing relative layer discovery.
232
+ const lock = `${join(realpathSync(dirname(snapshot.path)), basename(snapshot.path))}.lock`;
233
+ let lockFd;
234
+ try {
235
+ lockFd = openSync(lock, "wx", 0o600);
236
+ } catch (e) {
237
+ if (e.code === "EEXIST")
238
+ throw new Error(
239
+ `configuration is locked: ${lock}; inspect the owner before removing an abandoned lock`,
240
+ );
241
+ throw e;
242
+ }
243
+ const temporary = `${snapshot.path}.${randomUUID()}.tmp`;
244
+ try {
245
+ writeFileSync(lockFd, JSON.stringify({ pid: process.pid }));
246
+ const current = readConfiguration(snapshot.path);
247
+ if (current.revision !== expected || current.revision !== snapshot.revision)
248
+ throw new Error("configuration changed since preview; read status and preview again");
249
+ if (current.text !== null && JSON.stringify(current.config) === JSON.stringify(next))
250
+ return { changed: false, revision: current.revision };
251
+ const text = `${JSON.stringify(next, null, 2)}\n`;
252
+ const fd = openSync(
253
+ temporary,
254
+ "wx",
255
+ current.text === null ? 0o600 : statSync(current.path).mode & 0o777,
256
+ );
257
+ try {
258
+ writeFileSync(fd, text);
259
+ fsyncSync(fd);
260
+ } finally {
261
+ closeSync(fd);
262
+ }
263
+ if (readConfiguration(snapshot.path).revision !== current.revision)
264
+ throw new Error("configuration changed during validation; preview again");
265
+ renameSync(temporary, snapshot.path);
266
+ return { changed: true, revision: revisionOf(text) };
267
+ } finally {
268
+ try {
269
+ if (existsSync(temporary)) unlinkSync(temporary);
270
+ } finally {
271
+ try {
272
+ closeSync(lockFd);
273
+ } finally {
274
+ unlinkSync(lock);
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ export function selectionChanges(beforeConfig, afterConfig) {
281
+ return [
282
+ { key: "modifiers", before: beforeConfig.modifiers ?? [], after: afterConfig.modifiers ?? [] },
283
+ {
284
+ key: "skills.include",
285
+ before: beforeConfig.skills?.include ?? [],
286
+ after: afterConfig.skills?.include ?? [],
287
+ },
288
+ {
289
+ key: "skills.exclude",
290
+ before: beforeConfig.skills?.exclude ?? [],
291
+ after: afterConfig.skills?.exclude ?? [],
292
+ },
293
+ ].filter(({ before, after }) => JSON.stringify(before) !== JSON.stringify(after));
294
+ }
295
+
296
+ export function configCommand(argv) {
297
+ const command = argv[0] ?? "help";
298
+ if (command === "help" || command === "--help") return console.log(renderHelp());
299
+ if (!COMMANDS.includes(command))
300
+ throw new Error(`unknown config command "${command}" (see config help)`);
301
+ const { values } = parseArgs({
302
+ args: argv.slice(1),
303
+ options: {
304
+ config: { type: "string" },
305
+ help: { type: "boolean" },
306
+ json: { type: "boolean" },
307
+ rules: { type: "boolean" },
308
+ questions: { type: "string" },
309
+ apply: { type: "boolean" },
310
+ expect: { type: "string" },
311
+ "panel-file": { type: "string" },
312
+ "expect-panel": { type: "string" },
313
+ ...Object.fromEntries(TOGGLES.map((key) => [key, { type: "string", multiple: true }])),
314
+ },
315
+ });
316
+ if (values.help) return console.log(renderHelp());
317
+ const changing = Boolean(
318
+ values.questions !== undefined ||
319
+ values["panel-file"] !== undefined ||
320
+ TOGGLES.some((key) => values[key]?.length),
321
+ );
322
+ if (
323
+ (changing || values.apply || values.expect !== undefined) &&
324
+ !["set", "preview"].includes(command)
325
+ )
326
+ throw new Error("selection flags belong to config set or preview");
327
+ if (values.apply && command !== "set") throw new Error("--apply belongs to config set");
328
+ if (values.expect !== undefined && !values.apply) throw new Error("--expect requires --apply");
329
+ if (values["expect-panel"] !== undefined && (!values.apply || values["panel-file"] === undefined))
330
+ throw new Error("--expect-panel requires --apply and --panel-file");
331
+ if (values.rules && values.json) throw new Error("choose --rules or --json, not both");
332
+ if (values.rules && command !== "preview") throw new Error("--rules belongs to config preview");
333
+ if (values.rules && values["panel-file"] !== undefined)
334
+ throw new Error("--panel-file requires a change report; omit --rules");
335
+ const snapshot = readConfiguration(configurationPath(values.config));
336
+ let next = changing ? changeSelection(snapshot.config, values, snapshot.path) : snapshot.config;
337
+ let panelRevision;
338
+ if (values["panel-file"] !== undefined) {
339
+ const path = resolve(values["panel-file"]);
340
+ if (!lstatSync(path).isFile())
341
+ throw new Error("panel input must be a regular file, not a symlink or directory");
342
+ const text = readFileSync(path, "utf8");
343
+ panelRevision = revisionOf(text);
344
+ if (values.apply && values["expect-panel"] !== panelRevision)
345
+ throw new Error("panel input changed or --expect-panel missing; preview again");
346
+ let preferences;
347
+ try {
348
+ preferences = JSON.parse(text.replace(/^\uFEFF/, ""));
349
+ } catch {
350
+ throw new Error("invalid JSON in panel input");
351
+ }
352
+ next = changePanelPreferences(next, preferences);
353
+ }
354
+ const report = selectionReport(next, snapshot.path);
355
+ const changes = selectionChanges(snapshot.config, next);
356
+ const applied = values.apply ? writeConfiguration(snapshot, next, values.expect) : null;
357
+ const output = {
358
+ command,
359
+ configPath: snapshot.path,
360
+ exists: values.apply || snapshot.text !== null,
361
+ revision: applied?.revision ?? snapshot.revision,
362
+ changes,
363
+ applied,
364
+ ...(values["panel-file"] === undefined
365
+ ? {}
366
+ : {
367
+ panel: {
368
+ state: values.apply ? "configured" : "proposed",
369
+ revision: panelRevision,
370
+ before: snapshot.config.panel ?? null,
371
+ after: next.panel,
372
+ changed: JSON.stringify(snapshot.config.panel) !== JSON.stringify(next.panel),
373
+ readiness: "unverified",
374
+ },
375
+ }),
376
+ ...report,
377
+ };
378
+ if (values.rules) return process.stdout.write(report.composedRules);
379
+ if (values.json) console.log(JSON.stringify(output, null, 2));
380
+ else console.log(renderConfig(output));
381
+ }
Binary file
Binary file
Binary file