@bompus/house-rules 0.0.0-stage → 0.11.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 (191) hide show
  1. package/CONTRIBUTING.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +347 -2
  4. package/THIRD_PARTY_NOTICES.md +188 -0
  5. package/compose.mjs +119 -0
  6. package/composition.mjs +353 -0
  7. package/config-view.mjs +194 -0
  8. package/config.mjs +320 -0
  9. package/docs/assets/banner-dark.png +0 -0
  10. package/docs/assets/banner-light.png +0 -0
  11. package/docs/assets/overview.png +0 -0
  12. package/docs/configuration.md +193 -0
  13. package/docs/npm-release.md +45 -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 +40 -4
  20. package/rule-references.mjs +91 -0
  21. package/rules/core.md +431 -0
  22. package/rules/modifiers/coded-offers.md +40 -0
  23. package/rules/modifiers/effort-estimates.md +41 -0
  24. package/rules/modifiers/land-when-done.md +12 -0
  25. package/rules/modifiers/low-quota-handoff.md +19 -0
  26. package/rules/modifiers/multi-agent.md +34 -0
  27. package/rules/modifiers/no-attribution.md +10 -0
  28. package/rules/modifiers/plan-files.md +50 -0
  29. package/rules/modifiers/question-cards.md +48 -0
  30. package/rules/modifiers/release-batching.md +13 -0
  31. package/rules/modifiers/scratch-on-disk.md +15 -0
  32. package/rules/modifiers/shared-host-load.md +48 -0
  33. package/rules/modifiers/solo-operator.md +13 -0
  34. package/rules/modifiers/squash-landing.md +22 -0
  35. package/rules/modifiers/swarmail.md +12 -0
  36. package/rules/references/github-actions.md +45 -0
  37. package/rules/references/landing.md +39 -0
  38. package/rules/references/multi-agent.md +47 -0
  39. package/rules/references/release-batching.md +43 -0
  40. package/rules/references/squash-landing.md +44 -0
  41. package/setup.mjs +375 -0
  42. package/skills/hr-agent-guidance-audit/LICENSE +21 -0
  43. package/skills/hr-agent-guidance-audit/SKILL.md +86 -0
  44. package/skills/hr-agent-guidance-audit/agents/openai.yaml +2 -0
  45. package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +60 -0
  46. package/skills/hr-agent-guidance-refresh/LICENSE +21 -0
  47. package/skills/hr-agent-guidance-refresh/SKILL.md +68 -0
  48. package/skills/hr-agent-guidance-refresh/agents/openai.yaml +2 -0
  49. package/skills/hr-agent-guidance-refresh/references/verification.md +47 -0
  50. package/skills/hr-api-exposure-check/SKILL.md +159 -0
  51. package/skills/hr-api-exposure-check/agents/openai.yaml +3 -0
  52. package/skills/hr-audit-choices/SKILL.md +67 -0
  53. package/skills/hr-audit-choices/agents/openai.yaml +2 -0
  54. package/skills/hr-benchmarking/NOTICE.md +36 -0
  55. package/skills/hr-benchmarking/SKILL.md +316 -0
  56. package/skills/hr-benchmarking/agents/openai.yaml +2 -0
  57. package/skills/hr-benchmarking/references/javascript.md +17 -0
  58. package/skills/hr-benchmarking/references/priority-findings-20261006.md +115 -0
  59. package/skills/hr-benchmarking/references/tools.md +160 -0
  60. package/skills/hr-benchmarking/scripts/process-attribution.ps1 +20 -0
  61. package/skills/hr-benchmarking/scripts/process-attribution.py +107 -0
  62. package/skills/hr-better-accessibility/LICENSE +22 -0
  63. package/skills/hr-better-accessibility/NOTICE.md +46 -0
  64. package/skills/hr-better-accessibility/SKILL.md +119 -0
  65. package/skills/hr-better-accessibility/agents/openai.yaml +3 -0
  66. package/skills/hr-better-accessibility/focus-and-keyboard.md +130 -0
  67. package/skills/hr-better-accessibility/forms.md +82 -0
  68. package/skills/hr-better-accessibility/hit-areas.md +98 -0
  69. package/skills/hr-better-accessibility/motion-and-zoom.md +81 -0
  70. package/skills/hr-better-accessibility/screen-readers.md +101 -0
  71. package/skills/hr-better-accessibility/semantics-and-aria.md +84 -0
  72. package/skills/hr-better-colors/LICENSE +21 -0
  73. package/skills/hr-better-colors/NOTICE.md +37 -0
  74. package/skills/hr-better-colors/SKILL.md +114 -0
  75. package/skills/hr-better-colors/agents/openai.yaml +3 -0
  76. package/skills/hr-better-colors/color-formats.md +90 -0
  77. package/skills/hr-better-colors/color-usage.md +140 -0
  78. package/skills/hr-better-colors/contrast.md +80 -0
  79. package/skills/hr-better-colors/palette-generation.md +104 -0
  80. package/skills/hr-better-colors/palette-structure.md +76 -0
  81. package/skills/hr-better-colors/token-naming.md +97 -0
  82. package/skills/hr-better-layout/LICENSE +22 -0
  83. package/skills/hr-better-layout/NOTICE.md +48 -0
  84. package/skills/hr-better-layout/SKILL.md +92 -0
  85. package/skills/hr-better-layout/agents/openai.yaml +3 -0
  86. package/skills/hr-better-layout/grouping-and-alignment.md +144 -0
  87. package/skills/hr-better-layout/spacing-and-adaptivity.md +215 -0
  88. package/skills/hr-better-typography/LICENSE +22 -0
  89. package/skills/hr-better-typography/NOTICE.md +46 -0
  90. package/skills/hr-better-typography/SKILL.md +172 -0
  91. package/skills/hr-better-typography/agents/openai.yaml +3 -0
  92. package/skills/hr-better-typography/choosing-fonts.md +69 -0
  93. package/skills/hr-better-typography/css-cheat-sheet.md +65 -0
  94. package/skills/hr-better-typography/details-and-accessibility.md +119 -0
  95. package/skills/hr-better-typography/spacing-and-sizing.md +131 -0
  96. package/skills/hr-better-typography/variable-fonts-and-opentype.md +105 -0
  97. package/skills/hr-better-typography/wrapping-and-punctuation.md +57 -0
  98. package/skills/hr-better-writing/LICENSE +22 -0
  99. package/skills/hr-better-writing/NOTICE.md +46 -0
  100. package/skills/hr-better-writing/SKILL.md +122 -0
  101. package/skills/hr-better-writing/agents/openai.yaml +3 -0
  102. package/skills/hr-change-impact/SKILL.md +113 -0
  103. package/skills/hr-change-impact/agents/openai.yaml +5 -0
  104. package/skills/hr-code-review/LICENSE +21 -0
  105. package/skills/hr-code-review/NOTICE.md +26 -0
  106. package/skills/hr-code-review/SKILL.md +147 -0
  107. package/skills/hr-code-review/agents/openai.yaml +3 -0
  108. package/skills/hr-code-review/references/evidence.md +57 -0
  109. package/skills/hr-design-exploration/SKILL.md +82 -0
  110. package/skills/hr-design-exploration/agents/openai.yaml +4 -0
  111. package/skills/hr-diagnosing-bugs/NOTICE.md +45 -0
  112. package/skills/hr-diagnosing-bugs/SKILL.md +108 -0
  113. package/skills/hr-diagnosing-bugs/agents/openai.yaml +3 -0
  114. package/skills/hr-diagnosing-bugs/references/reporting.md +72 -0
  115. package/skills/hr-explain-code/NOTICE.md +37 -0
  116. package/skills/hr-explain-code/SKILL.md +110 -0
  117. package/skills/hr-explain-code/agents/openai.yaml +5 -0
  118. package/skills/hr-explain-code/references/helper-brief.md +39 -0
  119. package/skills/hr-extract-shared-steps/SKILL.md +134 -0
  120. package/skills/hr-extract-shared-steps/agents/openai.yaml +3 -0
  121. package/skills/hr-handoff/SKILL.md +91 -0
  122. package/skills/hr-handoff/agents/openai.yaml +5 -0
  123. package/skills/hr-handoff/assets/ledger.example.json +10 -0
  124. package/skills/hr-handoff/assets/receipt.example.json +76 -0
  125. package/skills/hr-handoff/assets/source.example.json +20 -0
  126. package/skills/hr-handoff/references/reconciliation.md +105 -0
  127. package/skills/hr-handoff/scripts/check-reconciliation.mjs +282 -0
  128. package/skills/hr-house-rules-setup/SKILL.md +100 -0
  129. package/skills/hr-house-rules-setup/agents/openai.yaml +5 -0
  130. package/skills/hr-issue-tracker-setup/SKILL.md +49 -0
  131. package/skills/hr-issue-tracker-setup/agents/openai.yaml +5 -0
  132. package/skills/hr-lean-plan/SKILL.md +142 -0
  133. package/skills/hr-lean-plan/agents/openai.yaml +5 -0
  134. package/skills/hr-maintainability-review/SKILL.md +176 -0
  135. package/skills/hr-maintainability-review/agents/openai.yaml +5 -0
  136. package/skills/hr-manual-qa/NOTICE.md +36 -0
  137. package/skills/hr-manual-qa/SKILL.md +37 -0
  138. package/skills/hr-manual-qa/agents/openai.yaml +3 -0
  139. package/skills/hr-navigation-retro/LICENSE +21 -0
  140. package/skills/hr-navigation-retro/NOTICE.md +5 -0
  141. package/skills/hr-navigation-retro/SKILL.md +86 -0
  142. package/skills/hr-navigation-retro/agents/openai.yaml +4 -0
  143. package/skills/hr-navigation-retro/references/inputs.md +67 -0
  144. package/skills/hr-navigation-retro/scripts/retro.py +552 -0
  145. package/skills/hr-navigation-retro/scripts/transcripts.py +205 -0
  146. package/skills/hr-ordering-tests/SKILL.md +51 -0
  147. package/skills/hr-ordering-tests/agents/openai.yaml +2 -0
  148. package/skills/hr-plain-prose/SKILL.md +261 -0
  149. package/skills/hr-plain-prose/agents/openai.yaml +3 -0
  150. package/skills/hr-plain-prose/scripts/check.mjs +331 -0
  151. package/skills/hr-pr-followup/LICENSE +21 -0
  152. package/skills/hr-pr-followup/NOTICE.md +9 -0
  153. package/skills/hr-pr-followup/SKILL.md +71 -0
  154. package/skills/hr-pr-followup/agents/openai.yaml +2 -0
  155. package/skills/hr-pr-followup/references/review-helpers.md +90 -0
  156. package/skills/hr-pr-followup/scripts/check-pr-review.ts +510 -0
  157. package/skills/hr-pr-followup/scripts/resolve-pr-thread.ts +219 -0
  158. package/skills/hr-progress-report/SKILL.md +49 -0
  159. package/skills/hr-progress-report/agents/openai.yaml +5 -0
  160. package/skills/hr-read-reddit/SKILL.md +44 -0
  161. package/skills/hr-read-reddit/agents/openai.yaml +2 -0
  162. package/skills/hr-read-reddit/scripts/reddit.ts +252 -0
  163. package/skills/hr-read-x-links/SKILL.md +42 -0
  164. package/skills/hr-read-x-links/agents/openai.yaml +2 -0
  165. package/skills/hr-read-x-links/scripts/x-post.ts +264 -0
  166. package/skills/hr-split-to-prs/LICENSE +21 -0
  167. package/skills/hr-split-to-prs/NOTICE.md +9 -0
  168. package/skills/hr-split-to-prs/SKILL.md +61 -0
  169. package/skills/hr-split-to-prs/agents/openai.yaml +2 -0
  170. package/skills/hr-stock-ui-audit/SKILL.md +132 -0
  171. package/skills/hr-stock-ui-audit/agents/openai.yaml +3 -0
  172. package/skills/hr-stock-ui-audit/scripts/scan.mjs +619 -0
  173. package/skills/hr-tdd/LICENSE +21 -0
  174. package/skills/hr-tdd/NOTICE.md +10 -0
  175. package/skills/hr-tdd/SKILL.md +40 -0
  176. package/skills/hr-tdd/agents/openai.yaml +3 -0
  177. package/skills/hr-tdd/mocking.md +39 -0
  178. package/skills/hr-tdd/tests.md +77 -0
  179. package/skills/hr-test-audit/SKILL.md +146 -0
  180. package/skills/hr-test-audit/agents/openai.yaml +3 -0
  181. package/skills/hr-test-audit/scripts/floor-guard.ts +137 -0
  182. package/skills/hr-what-next/SKILL.md +61 -0
  183. package/skills/hr-what-next/agents/openai.yaml +4 -0
  184. package/skills/hr-what-next/references/reporting.md +68 -0
  185. package/skills/hr-writing-for-agents/NOTICE.md +46 -0
  186. package/skills/hr-writing-for-agents/SKILL-MECHANICS.md +83 -0
  187. package/skills/hr-writing-for-agents/SKILL.md +108 -0
  188. package/skills/hr-writing-for-agents/agents/openai.yaml +3 -0
  189. package/skills/hr-writing-pr/NOTICE.md +48 -0
  190. package/skills/hr-writing-pr/SKILL.md +104 -0
  191. package/skills/hr-writing-pr/agents/openai.yaml +2 -0
package/compose.mjs ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ // Composes rules and skills into explicit output paths; config commands manage selections.
3
+ // node compose.mjs --config <path> [--out <file>] [--skills-out <dir>]
4
+ // node compose.mjs config help
5
+ import {
6
+ cpSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { parseArgs } from "node:util";
17
+ import { composeConfiguration, configurationPath, modifierList } from "./composition.mjs";
18
+ import { configCommand } from "./config.mjs";
19
+ import { setupCommand } from "./setup.mjs";
20
+ import { checkOutputPath, planReferenceOutput, writeReferenceOutput } from "./rule-references.mjs";
21
+ export {
22
+ compose,
23
+ composeConfiguration,
24
+ configurationPath,
25
+ loadFragments,
26
+ modifierList,
27
+ parseFragment,
28
+ skillSources,
29
+ splitSections,
30
+ } from "./composition.mjs";
31
+ const BASE = dirname(fileURLToPath(import.meta.url));
32
+ const NOTICES = ["LICENSE", "THIRD_PARTY_NOTICES.md"];
33
+
34
+ function main(argv) {
35
+ if (argv[0] === "setup") return setupCommand(argv.slice(1));
36
+ if (argv[0] === "config") {
37
+ return configCommand(argv.slice(1));
38
+ }
39
+ const { values } = parseArgs({
40
+ args: argv,
41
+ options: {
42
+ config: { type: "string" },
43
+ out: { type: "string" },
44
+ "skills-out": { type: "string" },
45
+ "references-out": { type: "string" },
46
+ list: { type: "boolean" },
47
+ },
48
+ });
49
+ if (values.list) {
50
+ for (const m of modifierList()) console.log(`${m.name}: ${m.description ?? ""}`);
51
+ return;
52
+ }
53
+ const configPath = configurationPath(values.config);
54
+ const config = JSON.parse(readFileSync(configPath, "utf8").replace(/^\uFEFF/, ""));
55
+ const { out, "skills-out": skillsOut, "references-out": explicitReferences } = values;
56
+ if (explicitReferences && !out) throw new Error("--references-out requires --out");
57
+ const referencesOut = out
58
+ ? resolve(explicitReferences ?? join(dirname(resolve(out)), "house-rules-references"))
59
+ : null;
60
+ const { fragments, rules, skills, warnings, references } = composeConfiguration(
61
+ config,
62
+ dirname(configPath),
63
+ referencesOut
64
+ ? { referencesDirectory: relative(dirname(resolve(out)), referencesOut) || "." }
65
+ : {},
66
+ );
67
+ for (const warning of warnings) console.error(`warning: ${warning}`);
68
+ const paths = [
69
+ out && { path: resolve(out), directory: false },
70
+ skillsOut && { path: resolve(skillsOut), directory: true },
71
+ referencesOut && { path: referencesOut, directory: true },
72
+ ].filter(Boolean);
73
+ for (const { path, directory } of paths) checkOutputPath(path, directory);
74
+ for (const a of paths)
75
+ for (const b of paths) {
76
+ if (a === b) continue;
77
+ const rel = relative(a.path, b.path);
78
+ if (
79
+ !rel ||
80
+ (rel !== ".." &&
81
+ !rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) &&
82
+ !isAbsolute(rel))
83
+ )
84
+ throw new Error("Output paths must not overlap");
85
+ }
86
+ // Composition errors and a non-empty --skills-out stop the run before anything is written.
87
+ if (skillsOut && existsSync(skillsOut) && readdirSync(skillsOut).length)
88
+ throw new Error(`--skills-out ${skillsOut} must be empty or absent`);
89
+ const referenceWrites = references.size ? planReferenceOutput(referencesOut, references) : [];
90
+ writeReferenceOutput(referenceWrites);
91
+ if (out) {
92
+ mkdirSync(dirname(resolve(out)), { recursive: true });
93
+ writeFileSync(out, rules);
94
+ } else process.stdout.write(rules);
95
+ if (skillsOut) {
96
+ // Skill tests stay in the source checkout; hosts get only what the skill uses.
97
+ for (const [name, from] of skills)
98
+ cpSync(from, join(skillsOut, name), {
99
+ recursive: true,
100
+ filter: (src) => src !== join(from, "test"),
101
+ });
102
+ for (const f of NOTICES) cpSync(join(BASE, f), join(skillsOut, f));
103
+ }
104
+ console.error(
105
+ `composed ${fragments.length} fragment(s), ${rules.split("\n").length} lines${skillsOut ? `, ${skills.size} skill(s)` : ""}`,
106
+ );
107
+ }
108
+
109
+ if (
110
+ process.argv[1] &&
111
+ realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))
112
+ ) {
113
+ try {
114
+ await main(process.argv.slice(2));
115
+ } catch (e) {
116
+ console.error(`house-rules: ${e.message}`);
117
+ process.exit(e.exitCode ?? 1);
118
+ }
119
+ }
@@ -0,0 +1,353 @@
1
+ // Composition and source discovery shared by output generation and configuration management.
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const BASE = dirname(fileURLToPath(import.meta.url));
8
+ const PROTECTED = "End of every reply";
9
+ // The protected section points at § Offers, so Offers may be replaced but not removed.
10
+ const REQUIRED = [PROTECTED, "Offers"];
11
+ const OPS = ["replaces", "after", "before", "removes"];
12
+ const KEYS = [...OPS, "description", "requires", "reference"];
13
+
14
+ // Windows line endings and a byte-order mark would otherwise hide frontmatter and headings.
15
+ const normalize = (text) => text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
16
+
17
+ // Minimal frontmatter: `key: value` lines between `---` fences.
18
+ export function parseFragment(rawText, source) {
19
+ const text = normalize(rawText);
20
+ const m = /^---\n([\s\S]*?)\n---\n?/.exec(text);
21
+ const meta = {};
22
+ if (m) {
23
+ for (const line of m[1].split("\n")) {
24
+ const i = line.indexOf(":");
25
+ if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
26
+ }
27
+ }
28
+ return { meta, body: (m ? text.slice(m[0].length) : text).trim(), source };
29
+ }
30
+
31
+ // Splits on level-2 headings outside fenced code blocks; level-3 and deeper stay inside their section.
32
+ export function splitSections(rawText) {
33
+ const parts = [[]];
34
+ let fence = null;
35
+ for (const line of normalize(rawText).split("\n")) {
36
+ const f = /^(`{3,}|~{3,})/.exec(line);
37
+ if (f && (!fence || f[1][0] === fence[0])) fence = fence ? null : f[1];
38
+ if (!fence && line.startsWith("## ")) parts.push([]);
39
+ parts.at(-1).push(line);
40
+ }
41
+ const preamble = parts.shift().join("\n").trim();
42
+ const sections = parts.map((lines) => ({
43
+ heading: lines[0].slice(3).trim(),
44
+ text: lines.join("\n").trim(),
45
+ }));
46
+ return { preamble, sections };
47
+ }
48
+
49
+ function composeParts(coreText, fragments, referencesDirectory, candidates) {
50
+ const core = parseFragment(coreText, "core");
51
+ if ("reference" in core.meta && "references" in core.meta)
52
+ throw new Error("core: use reference or references, not both");
53
+ const paths =
54
+ "references" in core.meta
55
+ ? core.meta.references.split(",").map((path) => path.trim())
56
+ : "reference" in core.meta
57
+ ? [core.meta.reference]
58
+ : [];
59
+ if (paths.some((path) => !path) || new Set(paths).size !== paths.length)
60
+ throw new Error("core: references must contain distinct non-empty paths");
61
+ const outputs = new Map();
62
+ if (paths.length) {
63
+ core.path = join(BASE, "rules/core.md");
64
+ core.root = BASE;
65
+ for (const path of paths) {
66
+ const output = renderReference(core, referencesDirectory, true, path);
67
+ if (outputs.has(output.heading))
68
+ throw new Error(`core: duplicate reference section "${output.heading}"`);
69
+ outputs.set(output.heading, output);
70
+ }
71
+ coreText = core.body;
72
+ }
73
+ const { preamble, sections } = splitSections(coreText);
74
+ if (referencesDirectory !== undefined)
75
+ for (const section of sections) section.referenceOutput = outputs.get(section.heading);
76
+ const find = (heading, f) => {
77
+ const i = sections.findIndex((s) => s.heading === heading);
78
+ if (i < 0) throw new Error(`${f.source}: no section "${heading}" to target`);
79
+ return i;
80
+ };
81
+ for (const f of fragments) {
82
+ const unknown = Object.keys(f.meta).filter((k) => !KEYS.includes(k));
83
+ if (unknown.length)
84
+ throw new Error(
85
+ `${f.source}: unknown frontmatter key "${unknown[0]}" (use ${KEYS.join(", ")})`,
86
+ );
87
+ const ops = OPS.filter((k) => k in f.meta);
88
+ if (ops.length > 1) throw new Error(`${f.source}: use one of ${OPS.join(", ")}`);
89
+ const op = ops[0];
90
+ const target = op && f.meta[op];
91
+ if (op && !target) throw new Error(`${f.source}: ${op}: needs a section heading`);
92
+ if (target === PROTECTED && op !== "after")
93
+ throw new Error(
94
+ `${f.source}: "${PROTECTED}" stays first and can only be added to with after:`,
95
+ );
96
+ if (op === "removes") {
97
+ if (REQUIRED.includes(target))
98
+ throw new Error(`${f.source}: "${target}" can be replaced, not removed`);
99
+ if (f.body) throw new Error(`${f.source}: a removes: fragment has no body`);
100
+ sections.splice(find(target, f), 1);
101
+ continue;
102
+ }
103
+ const { preamble: stray, sections: added } = splitSections(f.body);
104
+ for (const section of added) section.referenceOutput = f.referenceOutput;
105
+ if (!added.length || stray)
106
+ throw new Error(`${f.source}: body must start with a "## " heading`);
107
+ if (op === "replaces") {
108
+ // A replacement keeps the `after:` tag of the section it replaces, so later `after:` fragments still queue behind it.
109
+ const i = find(target, f);
110
+ sections.splice(i, 1, ...added.map((s) => ({ ...s, after: sections[i].after })));
111
+ } else if (op === "before") sections.splice(find(target, f), 0, ...added);
112
+ else if (op === "after") {
113
+ // Earlier `after:` fragments for the same target stay first, in configured order.
114
+ let i = find(target, f) + 1;
115
+ while (sections[i]?.after === target) i++;
116
+ sections.splice(i, 0, ...added.map((s) => ({ ...s, after: target })));
117
+ } else sections.push(...added);
118
+ }
119
+ const seen = new Set();
120
+ for (const s of sections) {
121
+ if (seen.has(s.heading))
122
+ throw new Error(`duplicate section "${s.heading}"; use replaces: to override it`);
123
+ seen.add(s.heading);
124
+ if (s.referenceOutput) {
125
+ const resource = s.referenceOutput;
126
+ const previous = candidates.get(resource.name);
127
+ if (previous && previous.text !== resource.text)
128
+ throw new Error(`conflicting reference "${resource.name}"`);
129
+ candidates.set(resource.name, resource);
130
+ }
131
+ }
132
+ if (sections[0]?.heading !== PROTECTED)
133
+ throw new Error(`"${PROTECTED}" must stay the first section`);
134
+ for (const h of REQUIRED)
135
+ if (!seen.has(h))
136
+ throw new Error(`a "${h}" section is required; a replacement must keep that heading`);
137
+ return [preamble, ...sections.map((s) => s.text)].filter(Boolean).join("\n\n") + "\n";
138
+ }
139
+
140
+ export function compose(coreText, fragments) {
141
+ const inline = fragments.map((fragment) => {
142
+ if (!("reference" in fragment.meta) || !fragment.body.includes(`](${fragment.meta.reference})`))
143
+ return fragment;
144
+ const f = { ...fragment };
145
+ if (!f.path) {
146
+ const name =
147
+ typeof f.source === "string" ? f.source.replace(/^modifier /, "").replace(/\.md$/, "") : "";
148
+ const path = join(BASE, "rules/modifiers", `${name}.md`);
149
+ if (
150
+ !/^[a-z0-9-]+$/.test(name) ||
151
+ !existsSync(path) ||
152
+ parseFragment(readFileSync(path, "utf8"), name).body !== f.body
153
+ )
154
+ throw new Error(`${f.source}: use loadFragments to establish the reference source layer`);
155
+ f.path = path;
156
+ f.root = BASE;
157
+ }
158
+ renderReference(f, undefined);
159
+ return f;
160
+ });
161
+ return composeParts(coreText, inline, undefined, new Map());
162
+ }
163
+
164
+ const mdFiles = (dir) =>
165
+ existsSync(dir)
166
+ ? readdirSync(dir)
167
+ .filter((f) => f.endsWith(".md"))
168
+ .sort()
169
+ : [];
170
+
171
+ export function modifierList() {
172
+ const dir = join(BASE, "rules/modifiers");
173
+ return mdFiles(dir).map((f) => ({
174
+ name: f.slice(0, -3),
175
+ ...parseFragment(readFileSync(join(dir, f), "utf8"), f).meta,
176
+ }));
177
+ }
178
+
179
+ // The directory holding house-rules.json is the user's layer unless the config lists others.
180
+ const layersOf = (config) => config.layers ?? ["."];
181
+
182
+ function loadRawFragments(config, configDir) {
183
+ const fragments = (config.modifiers ?? []).map((name) => {
184
+ const path = join(BASE, "rules/modifiers", `${name}.md`);
185
+ if (!existsSync(path)) throw new Error(`unknown modifier "${name}" (see --list)`);
186
+ return { ...parseFragment(readFileSync(path, "utf8"), `modifier ${name}`), path, root: BASE };
187
+ });
188
+ for (const layer of layersOf(config)) {
189
+ const dir = join(resolve(configDir, layer), "rules");
190
+ for (const f of mdFiles(dir))
191
+ fragments.push({
192
+ ...parseFragment(readFileSync(join(dir, f), "utf8"), join(dir, f)),
193
+ path: join(dir, f),
194
+ root: resolve(configDir, layer),
195
+ });
196
+ }
197
+ return fragments;
198
+ }
199
+
200
+ // Later layers' skills shadow base skills of the same name.
201
+ export function skillSources(config, configDir) {
202
+ const base = join(BASE, "skills");
203
+ const shipped = new Set(
204
+ readdirSync(base).filter((name) => existsSync(join(base, name, "SKILL.md"))),
205
+ );
206
+ const legacyNames = new Map(
207
+ [...shipped].filter((name) => name.startsWith("hr-")).map((name) => [name.slice(3), name]),
208
+ );
209
+ const exclude = new Set(config.skills?.exclude ?? []);
210
+ const independent = new Set(config.skills?.independent ?? []);
211
+ const included = config.skills?.include;
212
+ if (included !== undefined) {
213
+ if (
214
+ !Array.isArray(included) ||
215
+ included.some((name) => typeof name !== "string" || !name.trim())
216
+ )
217
+ throw new Error("skills.include must be an array of non-empty strings");
218
+ if (new Set(included).size !== included.length)
219
+ throw new Error("skills.include contains duplicates");
220
+ for (const name of included) {
221
+ if (!shipped.has(name))
222
+ throw new Error(`unknown shipped skill "${name}" in skills.include (see config catalog)`);
223
+ }
224
+ }
225
+ for (const name of exclude) {
226
+ if (legacyNames.has(name) && !independent.has(name))
227
+ throw new Error(
228
+ `legacy skill name "${name}" in skills.exclude; replace it with "${legacyNames.get(name)}"`,
229
+ );
230
+ }
231
+ const sources = new Map();
232
+ for (const root of [
233
+ base,
234
+ ...layersOf(config).map((l) => join(resolve(configDir, l), "skills")),
235
+ ]) {
236
+ if (!existsSync(root)) continue;
237
+ for (const name of readdirSync(root)) {
238
+ if (!existsSync(join(root, name, "SKILL.md"))) continue;
239
+ if (legacyNames.has(name) && !independent.has(name))
240
+ throw new Error(
241
+ `legacy skill name "${name}" in a personal layer; rename its directory and frontmatter name to "${legacyNames.get(name)}", or list "${name}" in skills.independent to keep it as an independent skill`,
242
+ );
243
+ if (root !== base && legacyNames.get(name.slice(3)) === name) {
244
+ const declaredName = parseFragment(readFileSync(join(root, name, "SKILL.md"), "utf8"), name)
245
+ .meta.name?.replace(/\s+#.*$/, "")
246
+ .replace(/^(['"])(.*)\1$/, "$2");
247
+ if (legacyNames.has(declaredName))
248
+ throw new Error(
249
+ `legacy skill name "${declaredName}" in frontmatter; set name to "${name}" to match its renamed override directory`,
250
+ );
251
+ }
252
+ if (
253
+ !exclude.has(name) &&
254
+ (root !== base || included === undefined || included.includes(name))
255
+ )
256
+ sources.set(name, join(root, name));
257
+ }
258
+ }
259
+ return sources;
260
+ }
261
+
262
+ export function configurationPath(path) {
263
+ return resolve(
264
+ path ??
265
+ join(
266
+ process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
267
+ "house-rules/house-rules.json",
268
+ ),
269
+ );
270
+ }
271
+
272
+ function renderReference(f, referencesDirectory, core = false, reference = f.meta.reference) {
273
+ const invalidReference = `${f.source}: reference must be a regular file within its source layer`;
274
+ const path = resolve(dirname(f.path), reference);
275
+ if (!reference || !existsSync(path) || !lstatSync(path).isFile())
276
+ throw new Error(invalidReference);
277
+ const inside = relative(realpathSync(f.root), realpathSync(path));
278
+ if (inside.startsWith("..") || isAbsolute(inside)) throw new Error(invalidReference);
279
+ const name = basename(path);
280
+ if (!/^[a-z0-9][a-z0-9-]*\.md$/.test(name))
281
+ throw new Error(`${f.source}: invalid reference filename`);
282
+ const text = readFileSync(path, "utf8");
283
+ const original = splitSections(text);
284
+ const parts = splitSections(f.body);
285
+ const section = core
286
+ ? parts.sections.find((s) => s.heading === original.sections[0]?.heading)
287
+ : null;
288
+ const short = core ? splitSections(section?.text ?? "") : parts;
289
+ if (
290
+ original.preamble ||
291
+ short.preamble ||
292
+ original.sections.length !== 1 ||
293
+ short.sections.length !== 1 ||
294
+ original.sections[0].heading !== short.sections[0].heading
295
+ )
296
+ throw new Error(`${f.source}: reference must contain the same single section heading`);
297
+ const sourceLink = `](${reference})`;
298
+ if (!(core ? section.text : f.body).includes(sourceLink))
299
+ throw new Error(`${f.source}: reference link missing from body`);
300
+ if (referencesDirectory === undefined) {
301
+ f.body = core ? f.body.replace(section.text, text.trim()) : text.trim();
302
+ } else {
303
+ const href = encodeURI(
304
+ `${referencesDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${name}`,
305
+ ).replace(/[()#?]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
306
+ f.body = f.body.replaceAll(sourceLink, `](${href})`);
307
+ if (!core) f.referenceOutput = { name, text, heading: original.sections[0].heading };
308
+ }
309
+ return { name, text, heading: original.sections[0].heading };
310
+ }
311
+
312
+ function referenceFragments(config, configDir, referencesDirectory) {
313
+ if (
314
+ referencesDirectory !== undefined &&
315
+ (typeof referencesDirectory !== "string" || !referencesDirectory.trim())
316
+ )
317
+ throw new Error("referencesDirectory must be a non-empty string");
318
+ const fragments = loadRawFragments(config, configDir);
319
+ const candidates = new Map();
320
+ for (const f of fragments) {
321
+ if (!("reference" in f.meta)) continue;
322
+ renderReference(f, referencesDirectory);
323
+ }
324
+ return { fragments, candidates };
325
+ }
326
+
327
+ // Existing low-level callers receive complete inline procedure bodies too.
328
+ export function loadFragments(config, configDir) {
329
+ return referenceFragments(config, configDir).fragments;
330
+ }
331
+
332
+ export function composeConfiguration(config, configDir, { referencesDirectory } = {}) {
333
+ const { fragments, candidates } = referenceFragments(config, configDir, referencesDirectory);
334
+ const rules = composeParts(
335
+ readFileSync(join(BASE, "rules/core.md"), "utf8"),
336
+ fragments,
337
+ referencesDirectory,
338
+ candidates,
339
+ );
340
+ const references = new Map([...candidates].map(([name, value]) => [name, value.text]));
341
+ const skills = skillSources(config, configDir);
342
+ const warnings = [];
343
+ for (const f of fragments) {
344
+ for (const need of (f.meta.requires ?? "")
345
+ .split(",")
346
+ .map((s) => s.trim())
347
+ .filter(Boolean)) {
348
+ if (!skills.has(need))
349
+ warnings.push(`${f.source} expects the "${need}" skill, which is not in the composed set`);
350
+ }
351
+ }
352
+ return { fragments, rules, skills, warnings, references };
353
+ }
@@ -0,0 +1,194 @@
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 plain|coded|cards",
78
+ "",
79
+ " Output",
80
+ " --config <path> --json --rules (preview only)",
81
+ "",
82
+ " Repeat toggle flags to change several selections together.",
83
+ " Saving config does not regenerate output or connect agent hosts.",
84
+ "",
85
+ ].join("\n");
86
+ }
87
+
88
+ export function renderConfig(report) {
89
+ const { paint, table, wrap, width } = display();
90
+ const lines = [
91
+ "",
92
+ ` ${paint("dim", "╭─")} ${paint("cyan", paint("bold", "HOUSE RULES"))} ${paint("dim", report.command)}`,
93
+ "",
94
+ ];
95
+ const section = (label) => lines.push(` ${paint("bold", label)}`, "");
96
+ const detail = (text) => lines.push(...wrap(text, width - 2).map((l) => ` ${l}`));
97
+ detail(`Config ${report.configPath}${report.exists ? "" : " (not created)"}`);
98
+ detail(`Revision ${report.applied?.revision ?? report.revision}`);
99
+ lines.push("");
100
+ const enabled = report.modifiers.filter((m) => m.enabled).length;
101
+ detail(
102
+ `${enabled}/${report.modifiers.length} modifiers enabled ${report.skills.filter((s) => s.enabled).length}/${report.skills.length} skills selected ${report.rules.length} rule sections`,
103
+ );
104
+ detail(`Configured questions ${report.questions} Host loading ${report.hostLoading}`);
105
+ lines.push("");
106
+ if (["set", "preview"].includes(report.command)) {
107
+ section(
108
+ report.applied
109
+ ? report.applied.changed
110
+ ? "Configuration saved"
111
+ : "Already configured"
112
+ : "Change preview",
113
+ );
114
+ if (!report.changes.length) detail("No selection changes.");
115
+ for (const change of report.changes) {
116
+ detail(change.key);
117
+ const removed = change.before.filter((name) => !change.after.includes(name));
118
+ const added = change.after.filter((name) => !change.before.includes(name));
119
+ for (const [sign, names, style] of [
120
+ ["-", removed, "red"],
121
+ ["+", added, "green"],
122
+ ]) {
123
+ for (const name of names) lines.push(` ${paint(style, `${sign} ${clean(name)}`)}`);
124
+ }
125
+ if (!removed.length && !added.length) detail("Selection order changed.");
126
+ }
127
+ lines.push("");
128
+ if (!report.applied) {
129
+ detail(
130
+ report.command === "set"
131
+ ? `To save, repeat with --apply --expect ${report.revision}`
132
+ : `To save, run config set with the same selection flags plus --apply --expect ${report.revision}`,
133
+ );
134
+ detail("Review personal rule overrides below before choosing a question format.");
135
+ } else
136
+ detail(
137
+ "Next, recompose output with compose.mjs or rerun the installer. Existing skills directories are retained; host connection is separate.",
138
+ );
139
+ lines.push("");
140
+ }
141
+ if (report.command === "validate") detail("Configuration and composition are valid.");
142
+ else if (["status", "catalog"].includes(report.command)) {
143
+ section("Modifiers");
144
+ lines.push(
145
+ table(
146
+ ["Name", "State", "Purpose"],
147
+ report.modifiers.map((m) => [m.name, m.enabled ? "on" : "off", m.description]),
148
+ ),
149
+ "",
150
+ );
151
+ section("Skills");
152
+ lines.push(
153
+ table(
154
+ ["Name", "State", report.command === "catalog" ? "Purpose" : "Origin"],
155
+ report.skills.map((s) => [
156
+ s.name,
157
+ s.enabled ? "on" : "off",
158
+ report.command === "catalog" ? s.description : s.origin,
159
+ ]),
160
+ ),
161
+ "",
162
+ );
163
+ section("Effective rules");
164
+ lines.push(
165
+ table(
166
+ ["Section", "State", "Source"],
167
+ report.rules.map((r) => [r.heading, "active", r.origin]),
168
+ ),
169
+ "",
170
+ );
171
+ section("Personal layers");
172
+ for (const layer of report.layers) detail(layer);
173
+ }
174
+ const personal = report.rules.filter(
175
+ (rule) => rule.source !== "core" && !rule.source.startsWith("modifier "),
176
+ );
177
+ if (["preview", "set"].includes(report.command) && personal.length) {
178
+ section("Personal rule overrides and additions");
179
+ lines.push(
180
+ table(
181
+ ["Section", "State", "Source"],
182
+ personal.map((r) => [r.heading, "active", r.origin]),
183
+ ),
184
+ "",
185
+ );
186
+ }
187
+ for (const name of report.unavailable)
188
+ detail(`Retained exclusion with no current source: ${name}`);
189
+ for (const warning of report.warnings) detail(`Warning: ${warning}`);
190
+ lines.push("");
191
+ detail("Status describes composed selections. It does not verify an agent's loaded context.");
192
+ lines.push(` ${paint("dim", "╰─")}`);
193
+ return lines.join("\n");
194
+ }