@crewhaus/rules-engine 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.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/rules-engine",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Cross-cutting (Track 10) — reads multi-language rule packs from rules/{common,typescript,python,...} and injects them as system-prompt prefixes. Source: ECC reference repo.",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/rules-engine"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/rules-engine#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,125 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadRules, renderRules, resolveProfile } from "./index";
6
+
7
+ let projectRoot: string;
8
+
9
+ beforeEach(() => {
10
+ projectRoot = mkdtempSync(join(tmpdir(), "rules-engine-test-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(projectRoot, { recursive: true, force: true });
15
+ });
16
+
17
+ function seed(bucket: string, file: string, body: string): void {
18
+ const dir = join(projectRoot, "rules", bucket);
19
+ mkdirSync(dir, { recursive: true });
20
+ writeFileSync(join(dir, file), body);
21
+ }
22
+
23
+ describe("loadRules", () => {
24
+ test("returns empty array when rules/ is missing", () => {
25
+ expect(loadRules({ projectRoot }).length).toBe(0);
26
+ });
27
+
28
+ test("returns common rules when no languages requested", () => {
29
+ seed("common", "always-be-kind.md", "Be kind.");
30
+ seed("typescript", "no-any.md", "Avoid any.");
31
+ const rules = loadRules({ projectRoot });
32
+ expect(rules.length).toBe(1);
33
+ expect(rules[0]?.id).toBe("always-be-kind");
34
+ expect(rules[0]?.bucket).toBe("common");
35
+ });
36
+
37
+ test("includes language buckets in standard profile (bucket-alphabetical order)", () => {
38
+ seed("common", "a.md", "x");
39
+ seed("typescript", "b.md", "x");
40
+ seed("python", "c.md", "x");
41
+ const rules = loadRules({
42
+ projectRoot,
43
+ languages: ["typescript", "python"],
44
+ });
45
+ // Buckets sort alphabetically: common, python, typescript.
46
+ const out = rules.map((r) => `${r.bucket}/${r.id}`);
47
+ expect(out).toEqual(["common/a", "python/c", "typescript/b"]);
48
+ });
49
+
50
+ test("ignores language buckets in core profile", () => {
51
+ seed("common", "a.md", "x");
52
+ seed("typescript", "b.md", "x");
53
+ const rules = loadRules({
54
+ projectRoot,
55
+ profile: "core",
56
+ languages: ["typescript"],
57
+ });
58
+ expect(rules.length).toBe(1);
59
+ expect(rules[0]?.bucket).toBe("common");
60
+ });
61
+
62
+ test("includes all present buckets in full profile", () => {
63
+ seed("common", "a.md", "x");
64
+ seed("typescript", "b.md", "x");
65
+ seed("rust", "c.md", "x");
66
+ const rules = loadRules({ projectRoot, profile: "full" });
67
+ expect(rules.length).toBe(3);
68
+ });
69
+
70
+ test("orders deterministically — bucket alphabetical, then file alphabetical", () => {
71
+ seed("python", "z.md", "x");
72
+ seed("python", "a.md", "x");
73
+ seed("common", "m.md", "x");
74
+ const rules = loadRules({ projectRoot, languages: ["python"] });
75
+ const ids = rules.map((r) => `${r.bucket}/${r.id}`);
76
+ expect(ids).toEqual(["common/m", "python/a", "python/z"]);
77
+ });
78
+
79
+ test("skips files that aren't .md or .txt", () => {
80
+ seed("common", "rule.md", "x");
81
+ seed("common", "ignore.json", "{}");
82
+ const rules = loadRules({ projectRoot });
83
+ expect(rules.length).toBe(1);
84
+ });
85
+ });
86
+
87
+ describe("renderRules", () => {
88
+ test("returns empty string for empty input", () => {
89
+ expect(renderRules([])).toBe("");
90
+ });
91
+
92
+ test("emits a section per bucket and a subsection per rule", () => {
93
+ seed("common", "a.md", "Body A");
94
+ seed("typescript", "b.md", "Body B");
95
+ const rendered = renderRules(loadRules({ projectRoot, languages: ["typescript"] }));
96
+ expect(rendered).toContain("# Project rules");
97
+ expect(rendered).toContain("## common");
98
+ expect(rendered).toContain("## typescript");
99
+ expect(rendered).toContain("### a");
100
+ expect(rendered).toContain("### b");
101
+ expect(rendered).toContain("Body A");
102
+ expect(rendered).toContain("Body B");
103
+ });
104
+
105
+ test("is byte-stable across calls (prompt-cache friendly)", () => {
106
+ seed("common", "a.md", "Body");
107
+ const rendered1 = renderRules(loadRules({ projectRoot }));
108
+ const rendered2 = renderRules(loadRules({ projectRoot }));
109
+ expect(rendered1).toBe(rendered2);
110
+ });
111
+ });
112
+
113
+ describe("resolveProfile", () => {
114
+ test("recognised values pass through", () => {
115
+ expect(resolveProfile("core")).toBe("core");
116
+ expect(resolveProfile("standard")).toBe("standard");
117
+ expect(resolveProfile("full")).toBe("full");
118
+ });
119
+
120
+ test("undefined and unrecognised default to standard", () => {
121
+ expect(resolveProfile(undefined)).toBe("standard");
122
+ expect(resolveProfile("unknown")).toBe("standard");
123
+ expect(resolveProfile("")).toBe("standard");
124
+ });
125
+ });
package/src/index.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Cross-cutting (Track 10) — `rules-engine`. Reads multi-language
3
+ * always-follow rule packs from `rules/{common,typescript,python,…}`
4
+ * and renders them as a system-prompt prefix.
5
+ *
6
+ * Source: ECC (https://github.com/affaan-m/ECC). ECC ships 12
7
+ * language ecosystems' worth of rule files, organized as a folder per
8
+ * language plus a `common/` folder for cross-language guidelines.
9
+ * Rules are *always-follow* (distinct from skills, which are
10
+ * workflow-triggered).
11
+ *
12
+ * The CrewHaus take:
13
+ *
14
+ * - `rules/` is project-rooted. CrewHaus reads from `rules/common/`
15
+ * and from `rules/<language>/` based on the spec's declared
16
+ * language(s).
17
+ * - Each file under `rules/<bucket>/` is one rule. The filename
18
+ * (kebab-case) becomes the rule's `id`; the file body is the
19
+ * rule text.
20
+ * - The rules are concatenated with section headers and injected
21
+ * into the agent's system prompt by callers (runtime-core's
22
+ * prompt-builder).
23
+ * - Empty/missing `rules/` directory is a no-op (graceful default).
24
+ *
25
+ * Also supports a `CREWHAUS_HOOK_PROFILE`-style env-var (here named
26
+ * `CREWHAUS_RULES_PROFILE`) to gate which buckets are included at
27
+ * runtime, e.g. `CREWHAUS_RULES_PROFILE=core` to limit to `common/`
28
+ * only.
29
+ *
30
+ * Reference repo: ECC (https://github.com/affaan-m/ECC).
31
+ */
32
+ import { readFileSync, readdirSync, statSync } from "node:fs";
33
+ import { join, resolve } from "node:path";
34
+ import { CrewhausError } from "@crewhaus/errors";
35
+
36
+ export class RulesEngineError extends CrewhausError {
37
+ override readonly name = "RulesEngineError";
38
+ constructor(message: string, cause?: unknown) {
39
+ super("config", message, cause);
40
+ }
41
+ }
42
+
43
+ export type Rule = {
44
+ readonly id: string;
45
+ readonly bucket: string;
46
+ readonly body: string;
47
+ };
48
+
49
+ export type LoadRulesOptions = {
50
+ /** Project root that contains the `rules/` directory. */
51
+ readonly projectRoot: string;
52
+ /** Languages to include (always merged with `common`). */
53
+ readonly languages?: ReadonlyArray<string>;
54
+ /**
55
+ * Override the profile env var read (`CREWHAUS_RULES_PROFILE`).
56
+ * `core` → common/ only. `standard` → common + named languages.
57
+ * `full` → every bucket present under `rules/`. Default: standard.
58
+ */
59
+ readonly profile?: "core" | "standard" | "full";
60
+ };
61
+
62
+ /**
63
+ * Load and order all rules per the profile + languages. Pure (no
64
+ * env read) — the CLI is responsible for resolving the env var.
65
+ *
66
+ * Returns rules in deterministic order (bucket alphabetical, then
67
+ * file alphabetical) so prompt-cache prefixes stay stable across runs.
68
+ */
69
+ export function loadRules(opts: LoadRulesOptions): ReadonlyArray<Rule> {
70
+ const root = resolve(opts.projectRoot);
71
+ const rulesDir = join(root, "rules");
72
+ let buckets: string[];
73
+ try {
74
+ buckets = readdirSync(rulesDir).filter((name) => {
75
+ try {
76
+ return statSync(join(rulesDir, name)).isDirectory();
77
+ } catch {
78
+ return false;
79
+ }
80
+ });
81
+ } catch {
82
+ return [];
83
+ }
84
+ const profile = opts.profile ?? "standard";
85
+ const wanted = new Set<string>();
86
+ wanted.add("common");
87
+ if (profile === "standard") {
88
+ for (const lang of opts.languages ?? []) wanted.add(lang);
89
+ } else if (profile === "full") {
90
+ for (const b of buckets) wanted.add(b);
91
+ }
92
+ const selectedBuckets = buckets.filter((b) => wanted.has(b)).sort();
93
+
94
+ const out: Rule[] = [];
95
+ for (const bucket of selectedBuckets) {
96
+ const bucketDir = join(rulesDir, bucket);
97
+ let files: string[];
98
+ try {
99
+ files = readdirSync(bucketDir).filter((f) => f.endsWith(".md") || f.endsWith(".txt"));
100
+ } catch {
101
+ continue;
102
+ }
103
+ for (const file of files.sort()) {
104
+ const id = file.replace(/\.(md|txt)$/, "");
105
+ let body: string;
106
+ try {
107
+ body = readFileSync(join(bucketDir, file), "utf8");
108
+ } catch (err) {
109
+ throw new RulesEngineError(
110
+ `failed to read rule ${bucket}/${file}: ${(err as Error).message}`,
111
+ );
112
+ }
113
+ out.push({ id, bucket, body });
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * Render an ordered rule list as a markdown system-prompt prefix.
121
+ * Used by runtime-core to fold rules into the prompt during turn
122
+ * construction. Idempotent: identical input → identical output bytes,
123
+ * which preserves prompt-cache hits across turns.
124
+ */
125
+ export function renderRules(rules: ReadonlyArray<Rule>): string {
126
+ if (rules.length === 0) return "";
127
+ const sections: string[] = ["# Project rules", ""];
128
+ let lastBucket: string | undefined;
129
+ for (const rule of rules) {
130
+ if (rule.bucket !== lastBucket) {
131
+ sections.push(`## ${rule.bucket}`, "");
132
+ lastBucket = rule.bucket;
133
+ }
134
+ sections.push(`### ${rule.id}`, "", rule.body.trim(), "");
135
+ }
136
+ return sections.join("\n");
137
+ }
138
+
139
+ /**
140
+ * Convenience env-var read for the CLI. Validates the profile
141
+ * value; falls back to `standard` for any unrecognised value.
142
+ */
143
+ export function resolveProfile(envValue: string | undefined): "core" | "standard" | "full" {
144
+ if (envValue === "core" || envValue === "standard" || envValue === "full") return envValue;
145
+ return "standard";
146
+ }