@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 +41 -0
- package/src/index.test.ts +125 -0
- package/src/index.ts +146 -0
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
|
+
}
|