@boxxy/rules 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the boxxy authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <h1>@boxxy/rules</h1>
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ <p>
5
+ <a href="https://www.npmjs.com/package/@boxxy/rules"><img src="https://img.shields.io/npm/v/@boxxy/rules" alt="npm version"></a>
6
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
7
+ </p>
8
+
9
+ <p>
10
+ <a href="../../docs/design/boxxy/README.md">Documentation</a>
11
+ </p>
12
+ </div>
13
+
14
+ boxxy's rules loader. `loadRules` checks a rules checkout's `rules.yaml` against the concepts boxxy
15
+ compiles in, builds the plug-in registry, and gives the loaded rules a version.
16
+
17
+ The package does no I/O: the caller reads the checkout and passes in its commit hash and its files'
18
+ bytes.
19
+
20
+ ## Install
21
+
22
+ Install the package from [npm](https://www.npmjs.com/package/@boxxy/rules) with Bun:
23
+
24
+ ```bash
25
+ bun add @boxxy/rules
26
+ ```
27
+
28
+ The package runs on [Bun](https://bun.sh) 1.3 or newer.
29
+
30
+ ## Features
31
+
32
+ - `loadRules`, which returns the loaded rules, or every rejection with its file, its path in the
33
+ file and its reason ([rules for slice 1](../../docs/design/boxxy/slice-1.md#rules-for-slice-1)).
34
+ - The rules version, a content hash of the commit, the files the loader read and each concept pin
35
+ ([the rules version](../../docs/design/boxxy/rules.md#the-rules-version)).
36
+
37
+ ## Documentation
38
+
39
+ The [rules design](../../docs/design/boxxy/rules.md) says why rules live in git, and
40
+ [`01-registries.ts`](../../docs/design/boxxy/sketches/01-registries.ts) gives the shape of the
41
+ loaded rules.
@@ -0,0 +1,6 @@
1
+ import type { ConceptPin, LoadRejection, Registry } from '@boxxy/model';
2
+ import type { PinnedConcept } from './types.ts';
3
+ export declare function buildRegistry(pinned: readonly PinnedConcept[], pins: readonly ConceptPin[]): {
4
+ readonly value: Registry;
5
+ readonly rejections: readonly LoadRejection[];
6
+ };
@@ -0,0 +1,2 @@
1
+ import type { LoadRejection, LoadRejectionReason } from '@boxxy/model';
2
+ export declare function buildRejection(path: readonly PropertyKey[], reason: LoadRejectionReason): LoadRejection;
@@ -0,0 +1 @@
1
+ export declare const rulesFileName = "rules.yaml";
@@ -0,0 +1 @@
1
+ export { loadRules } from './load-rules.ts';
@@ -0,0 +1,11 @@
1
+ import type { Concept, ConceptName, ConceptPin, LoadResult } from '@boxxy/model';
2
+ interface LoadRulesInput {
3
+ readonly checkout: {
4
+ readonly commit: string;
5
+ readonly files: ReadonlyMap<string, Uint8Array>;
6
+ };
7
+ readonly concepts: readonly Concept[];
8
+ readonly pins: ReadonlyMap<ConceptName, ConceptPin>;
9
+ }
10
+ export declare function loadRules(input: LoadRulesInput): LoadResult;
11
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { ConceptEntry, ConceptName } from '@boxxy/model';
2
+ export declare function parseConceptEntry(entry: ConceptEntry): {
3
+ readonly name: ConceptName;
4
+ readonly version: number;
5
+ };
@@ -0,0 +1,11 @@
1
+ import type { LoadRejection } from '@boxxy/model';
2
+ import type { ParsedRulesFile } from './rules-file-schema.ts';
3
+ type RulesFileParse = {
4
+ readonly kind: 'parsed';
5
+ readonly file: ParsedRulesFile;
6
+ } | {
7
+ readonly kind: 'rejected';
8
+ readonly rejections: readonly LoadRejection[];
9
+ };
10
+ export declare function parseRulesFile(fileName: string, bytes: Uint8Array): RulesFileParse;
11
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { JsonValue } from '@boxxy/content-hash';
2
+ import type { ParamKindName, Registered, Resolved } from '@boxxy/model';
3
+ import type { Checked } from './types.ts';
4
+ export declare function resolvePlugin<K extends ParamKindName>(registered: Registered<K>, params: JsonValue, key: string, path: readonly PropertyKey[]): Checked<Resolved<K>>;
@@ -0,0 +1,3 @@
1
+ import type { Registry, SignalRef } from '@boxxy/model';
2
+ import type { Checked, ResolvedSignal } from './types.ts';
3
+ export declare function resolveSignalRef(ref: SignalRef, path: readonly PropertyKey[], registry: Registry): Checked<ResolvedSignal>;
@@ -0,0 +1,49 @@
1
+ import type { PipelineStep } from '@boxxy/model';
2
+ import { z } from 'zod';
3
+ export declare const rulesFileSchema: z.ZodObject<{
4
+ version: z.ZodLiteral<1>;
5
+ concepts: z.ZodArray<z.ZodTemplateLiteral<`${string}@${number}`>>;
6
+ kinds: z.ZodRecord<z.core.$ZodBranded<z.ZodString, "ItemKindName", "out">, z.ZodPipe<z.ZodObject<{
7
+ pipeline: z.ZodArray<z.core.$ZodBranded<z.ZodString, "PhaseName", "out">>;
8
+ maxAttempts: z.ZodRecord<z.core.$ZodBranded<z.ZodString, "PhaseName", "out">, z.ZodInt>;
9
+ recipes: z.ZodRecord<z.core.$ZodBranded<z.ZodString, "PhaseName", "out">, z.core.$ZodBranded<z.ZodString, "RecipeName", "out">>;
10
+ }, z.core.$strict>, z.ZodTransform<{
11
+ pipeline: (string & z.$brand<"PhaseName">)[];
12
+ maxAttempts: Record<string & z.$brand<"PhaseName">, number>;
13
+ recipes: Record<string & z.$brand<"PhaseName">, string & z.$brand<"RecipeName">>;
14
+ steps: PipelineStep[];
15
+ }, {
16
+ pipeline: (string & z.$brand<"PhaseName">)[];
17
+ maxAttempts: Record<string & z.$brand<"PhaseName">, number>;
18
+ recipes: Record<string & z.$brand<"PhaseName">, string & z.$brand<"RecipeName">>;
19
+ }>>>;
20
+ pools: z.ZodArray<z.ZodObject<{
21
+ name: z.core.$ZodBranded<z.ZodString, "PoolName", "out">;
22
+ slots: z.ZodInt;
23
+ takes: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
24
+ order: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodJSONSchema>]>>;
25
+ }, z.core.$strict>>;
26
+ lead: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodJSONSchema>]>>;
27
+ propagation: z.ZodArray<z.ZodObject<{
28
+ value: z.ZodString;
29
+ over: z.core.$ZodBranded<z.ZodString, "LinkTypeName", "out">;
30
+ toward: z.ZodEnum<{
31
+ source: "source";
32
+ target: "target";
33
+ }>;
34
+ }, z.core.$strict>>;
35
+ recipes: z.ZodRecord<z.core.$ZodBranded<z.ZodString, "RecipeName", "out">, z.ZodObject<{
36
+ agent: z.ZodString;
37
+ model: z.ZodString;
38
+ effort: z.ZodEnum<{
39
+ high: "high";
40
+ low: "low";
41
+ max: "max";
42
+ medium: "medium";
43
+ xhigh: "xhigh";
44
+ }>;
45
+ prompt: z.ZodString;
46
+ source: z.core.$ZodBranded<z.ZodString, "SourceName", "out">;
47
+ }, z.core.$strict>>;
48
+ }, z.core.$strict>;
49
+ export type ParsedRulesFile = z.output<typeof rulesFileSchema>;
@@ -0,0 +1,13 @@
1
+ import type { Concept, LoadRejection, Resolved, SignalKey } from '@boxxy/model';
2
+ export interface Checked<T> {
3
+ readonly value: T | null;
4
+ readonly rejections: readonly LoadRejection[];
5
+ }
6
+ export interface ResolvedSignal {
7
+ readonly key: SignalKey;
8
+ readonly resolved: Resolved<'signal'>;
9
+ }
10
+ export interface PinnedConcept {
11
+ readonly concept: Concept;
12
+ readonly entryIndex: number;
13
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@boxxy/rules",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "boxxy's rules loader: checks a rules checkout and builds the plug-in registry",
5
+ "homepage": "https://github.com/zgeoff/boxxy/tree/main/modules/rules#readme",
6
+ "bugs": "https://github.com/zgeoff/boxxy/issues",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/zgeoff/boxxy.git",
11
+ "directory": "modules/rules"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.ts",
16
+ "dist"
17
+ ],
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./src/index.ts"
23
+ }
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "build": "rm -rf dist && tsrs -p tsconfig.build.json",
30
+ "typecheck": "tsrs --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@boxxy/content-hash": "0.1.0",
34
+ "@boxxy/model": "0.1.1",
35
+ "tiny-invariant": "1.3.3",
36
+ "ts-pattern": "5.9.2",
37
+ "yaml": "2.9.1",
38
+ "zod": "4.6.7"
39
+ },
40
+ "engines": {
41
+ "bun": ">=1.3",
42
+ "node": ">=24"
43
+ }
44
+ }
@@ -0,0 +1,67 @@
1
+ import type { ConceptPin, LoadRejection, Registry } from '@boxxy/model';
2
+ import { buildRejection } from './build-rejection.ts';
3
+ import type { PinnedConcept } from './types.ts';
4
+
5
+ export function buildRegistry(
6
+ pinned: readonly PinnedConcept[],
7
+ pins: readonly ConceptPin[],
8
+ ): { readonly value: Registry; readonly rejections: readonly LoadRejection[] } {
9
+ const signal = collectByName(pinned, (concept) => concept.signals ?? []);
10
+ const gate = collectByName(pinned, (concept) => concept.gates ?? []);
11
+ const waiver = collectByName(pinned, (concept) => concept.waivers ?? []);
12
+ const filterField = collectByName(pinned, (concept) => concept.filterFields ?? []);
13
+
14
+ const lever = collectByName(pinned, (concept) =>
15
+ (concept.levers ?? []).map((kind) => ({ name: kind, kind })),
16
+ );
17
+
18
+ return {
19
+ value: {
20
+ signal: signal.byName,
21
+ gate: gate.byName,
22
+ admission: new Map(),
23
+ waiver: waiver.byName,
24
+ 'filter-field': filterField.byName,
25
+ lever: lever.byName,
26
+ view: new Map(),
27
+ pins,
28
+ },
29
+ rejections: [
30
+ ...signal.rejections,
31
+ ...gate.rejections,
32
+ ...waiver.rejections,
33
+ ...filterField.rejections,
34
+ ...lever.rejections,
35
+ ],
36
+ };
37
+ }
38
+
39
+ function collectByName<T extends { readonly name: string }>(
40
+ pinned: readonly PinnedConcept[],
41
+ pluginsOf: (concept: PinnedConcept['concept']) => readonly T[],
42
+ ): { readonly byName: ReadonlyMap<string, T>; readonly rejections: readonly LoadRejection[] } {
43
+ const byName = new Map<string, T>();
44
+ const owners = new Map<string, string>();
45
+
46
+ const rejections: LoadRejection[] = [];
47
+
48
+ for (const entry of pinned) {
49
+ for (const plugin of pluginsOf(entry.concept)) {
50
+ const owner = owners.get(plugin.name);
51
+
52
+ if (owner === undefined) {
53
+ byName.set(plugin.name, plugin);
54
+ owners.set(plugin.name, entry.concept.name);
55
+ } else {
56
+ rejections.push(
57
+ buildRejection(['concepts', entry.entryIndex], {
58
+ kind: 'invalid',
59
+ message: `the concepts ${owner} and ${entry.concept.name} both register ${plugin.name}`,
60
+ }),
61
+ );
62
+ }
63
+ }
64
+ }
65
+
66
+ return { byName, rejections };
67
+ }
@@ -0,0 +1,10 @@
1
+ import type { LoadRejection, LoadRejectionReason } from '@boxxy/model';
2
+ import { formatKeyPath } from '@boxxy/model';
3
+ import { rulesFileName } from './constants.ts';
4
+
5
+ export function buildRejection(
6
+ path: readonly PropertyKey[],
7
+ reason: LoadRejectionReason,
8
+ ): LoadRejection {
9
+ return { file: rulesFileName, path: formatKeyPath(path), reason };
10
+ }
@@ -0,0 +1 @@
1
+ export const rulesFileName = 'rules.yaml';
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { loadRules } from './load-rules.ts';
@@ -0,0 +1,320 @@
1
+ import type { TreeFile } from '@boxxy/content-hash';
2
+ import { contentHash, hashBytes, hashTree } from '@boxxy/content-hash';
3
+ import type {
4
+ Concept,
5
+ ConceptName,
6
+ ConceptPin,
7
+ ItemKindName,
8
+ KindRules,
9
+ LoadRejection,
10
+ LoadResult,
11
+ LoadedRecipe,
12
+ Pool,
13
+ RecipeName,
14
+ RulesVersion,
15
+ } from '@boxxy/model';
16
+ import { brandSchemas } from '@boxxy/model';
17
+ import invariant from 'tiny-invariant';
18
+ import { match } from 'ts-pattern';
19
+ import { buildRegistry } from './build-registry.ts';
20
+ import { buildRejection } from './build-rejection.ts';
21
+ import { rulesFileName } from './constants.ts';
22
+ import { parseConceptEntry } from './parse-concept-entry.ts';
23
+ import { parseRulesFile } from './parse-rules-file.ts';
24
+ import { resolvePlugin } from './resolve-plugin.ts';
25
+ import { resolveSignalRef } from './resolve-signal-ref.ts';
26
+ import type { ParsedRulesFile } from './rules-file-schema.ts';
27
+ import type { Checked, PinnedConcept, ResolvedSignal } from './types.ts';
28
+
29
+ interface LoadRulesInput {
30
+ readonly checkout: {
31
+ readonly commit: string;
32
+ readonly files: ReadonlyMap<string, Uint8Array>;
33
+ };
34
+ readonly concepts: readonly Concept[];
35
+ readonly pins: ReadonlyMap<ConceptName, ConceptPin>;
36
+ }
37
+
38
+ export function loadRules(input: LoadRulesInput): LoadResult {
39
+ const rulesBytes = input.checkout.files.get(rulesFileName);
40
+
41
+ if (rulesBytes === undefined) {
42
+ return {
43
+ kind: 'rejected',
44
+ rejections: [
45
+ buildRejection([], { kind: 'invalid', message: `the checkout has no ${rulesFileName}` }),
46
+ ],
47
+ };
48
+ }
49
+
50
+ return match(parseRulesFile(rulesFileName, rulesBytes))
51
+ .with({ kind: 'rejected' }, (rejected): LoadResult => rejected)
52
+ .with({ kind: 'parsed' }, (parsed) => buildLoadResult(parsed.file, rulesBytes, input))
53
+ .exhaustive();
54
+ }
55
+
56
+ function buildLoadResult(
57
+ file: ParsedRulesFile,
58
+ rulesBytes: Uint8Array,
59
+ input: LoadRulesInput,
60
+ ): LoadResult {
61
+ const pinned = collectPinnedConcepts(file, input.concepts);
62
+
63
+ const built = buildRegistry(
64
+ pinned.value,
65
+ pinned.value.map((entry) => getPin(input.pins, entry.concept)),
66
+ );
67
+
68
+ const registry = built.value;
69
+ const lead = file.lead.map((ref, index) => resolveSignalRef(ref, ['lead', index], registry));
70
+
71
+ const pools = file.pools.map((pool, poolIndex) => ({
72
+ pool,
73
+ order: pool.order.map((ref, index) =>
74
+ resolveSignalRef(ref, ['pools', poolIndex, 'order', index], registry),
75
+ ),
76
+ }));
77
+
78
+ const propagation = file.propagation.map((line, index) =>
79
+ resolveSignalRef(line.value, ['propagation', index, 'value'], registry),
80
+ );
81
+
82
+ const gates = pinned.value.flatMap((entry) =>
83
+ (entry.concept.gates ?? []).map((gate) =>
84
+ resolvePlugin(gate, {}, gate.name, ['concepts', entry.entryIndex]),
85
+ ),
86
+ );
87
+
88
+ const prompts = collectPrompts(file, input.checkout.files);
89
+ const signals = [...lead, ...pools.flatMap((pool) => pool.order), ...propagation];
90
+
91
+ const rejections = [
92
+ ...pinned.rejections,
93
+ ...built.rejections,
94
+ ...[...signals, ...gates].flatMap((checked) => checked.rejections),
95
+ ...collectPropagationRejections(file, propagation),
96
+ ...collectMissingRecipes(file),
97
+ ...prompts.rejections,
98
+ ];
99
+
100
+ if (rejections.length > 0) {
101
+ return { kind: 'rejected', rejections };
102
+ }
103
+
104
+ const version = buildRulesVersion(
105
+ input.checkout.commit,
106
+ [
107
+ { path: rulesFileName, bytes: rulesBytes, executable: false },
108
+ ...prompts.value.map((prompt) => ({
109
+ path: prompt.recipe.prompt,
110
+ bytes: prompt.bytes,
111
+ executable: false,
112
+ })),
113
+ ],
114
+ registry.pins,
115
+ );
116
+
117
+ const leadKeys = lead.map((checked) => getValue(checked).key);
118
+ const kinds = buildKinds(file);
119
+
120
+ return {
121
+ kind: 'loaded',
122
+ rules: {
123
+ version,
124
+ commit: input.checkout.commit,
125
+ registry,
126
+ signals: new Map(
127
+ signals.map((checked) => [getValue(checked).key, getValue(checked).resolved]),
128
+ ),
129
+ gates: gates.map((checked) => getValue(checked)),
130
+ kinds,
131
+ maxAttemptsByKind: new Map(
132
+ [...kinds].map(([kind, kindRules]) => [
133
+ kind,
134
+ new Map(kindRules.pipeline.steps.map((step) => [step.phase, step.maxAttempts])),
135
+ ]),
136
+ ),
137
+ pools: pools.map((entry): Pool => ({
138
+ name: entry.pool.name,
139
+ slots: entry.pool.slots,
140
+ takes: buildTakes(entry.pool.takes),
141
+ order: [...new Set([...leadKeys, ...entry.order.map((checked) => getValue(checked).key)])],
142
+ })),
143
+ propagation: file.propagation,
144
+ recipes: new Map(
145
+ prompts.value.map((prompt): [RecipeName, LoadedRecipe] => [
146
+ prompt.name,
147
+ {
148
+ name: prompt.name,
149
+ recipe: prompt.recipe,
150
+ promptHash: hashBytes(prompt.bytes),
151
+ rulesVersion: version,
152
+ },
153
+ ]),
154
+ ),
155
+ },
156
+ warnings: [],
157
+ };
158
+ }
159
+
160
+ function collectPinnedConcepts(
161
+ file: ParsedRulesFile,
162
+ concepts: readonly Concept[],
163
+ ): { readonly value: readonly PinnedConcept[]; readonly rejections: readonly LoadRejection[] } {
164
+ const pinned: PinnedConcept[] = [];
165
+ const rejections: LoadRejection[] = [];
166
+
167
+ for (const [entryIndex, entry] of file.concepts.entries()) {
168
+ const ref = parseConceptEntry(entry);
169
+
170
+ const concept = concepts.find(
171
+ (candidate) => candidate.name === ref.name && candidate.version === ref.version,
172
+ );
173
+
174
+ if (concept === undefined) {
175
+ rejections.push(
176
+ buildRejection(['concepts', entryIndex], { kind: 'unknown-concept', ref: entry }),
177
+ );
178
+ } else {
179
+ pinned.push({ concept, entryIndex });
180
+ }
181
+ }
182
+
183
+ return { value: pinned, rejections };
184
+ }
185
+
186
+ function getPin(pins: ReadonlyMap<ConceptName, ConceptPin>, concept: Concept): ConceptPin {
187
+ const ref = `${concept.name}@${concept.version}` as const;
188
+ const pin = pins.get(concept.name);
189
+
190
+ invariant(pin?.ref === ref, `the server passes a pin for each concept, and has none for ${ref}`);
191
+
192
+ return pin;
193
+ }
194
+
195
+ interface RecipePrompt {
196
+ readonly name: RecipeName;
197
+ readonly recipe: ParsedRulesFile['recipes'][RecipeName];
198
+ readonly bytes: Uint8Array;
199
+ }
200
+
201
+ function collectPrompts(
202
+ file: ParsedRulesFile,
203
+ files: ReadonlyMap<string, Uint8Array>,
204
+ ): { readonly value: readonly RecipePrompt[]; readonly rejections: readonly LoadRejection[] } {
205
+ const prompts: RecipePrompt[] = [];
206
+ const rejections: LoadRejection[] = [];
207
+
208
+ for (const [name, recipe] of Object.entries(file.recipes)) {
209
+ const bytes = files.get(recipe.prompt);
210
+
211
+ if (bytes === undefined) {
212
+ rejections.push(
213
+ buildRejection(['recipes', name, 'prompt'], {
214
+ kind: 'missing-prompt',
215
+ path: recipe.prompt,
216
+ }),
217
+ );
218
+ } else {
219
+ prompts.push({ name: brandSchemas.recipeName.parse(name), recipe, bytes });
220
+ }
221
+ }
222
+
223
+ return { value: prompts, rejections };
224
+ }
225
+
226
+ const knownLinkTypes: ReadonlySet<string> = new Set(['blocks']);
227
+
228
+ function collectPropagationRejections(
229
+ file: ParsedRulesFile,
230
+ signals: readonly Checked<ResolvedSignal>[],
231
+ ): LoadRejection[] {
232
+ return file.propagation.flatMap((line, index) => {
233
+ const rejections: LoadRejection[] = [];
234
+ const signal = signals[index]?.value ?? null;
235
+
236
+ if (!knownLinkTypes.has(line.over)) {
237
+ rejections.push(
238
+ buildRejection(['propagation', index, 'over'], {
239
+ kind: 'unknown-link-type',
240
+ link: line.over,
241
+ }),
242
+ );
243
+ }
244
+
245
+ if (signal !== null && !signal.resolved.open((plugin) => plugin.combine !== undefined)) {
246
+ rejections.push(
247
+ buildRejection(['propagation', index, 'value'], {
248
+ kind: 'signal-without-combine',
249
+ signal: line.value,
250
+ }),
251
+ );
252
+ }
253
+
254
+ return rejections;
255
+ });
256
+ }
257
+
258
+ function collectMissingRecipes(file: ParsedRulesFile): LoadRejection[] {
259
+ return Object.entries(file.kinds).flatMap(([itemKind, kind]) =>
260
+ kind.pipeline
261
+ .filter(
262
+ (phase) =>
263
+ !Object.hasOwn(kind.recipes, phase) ||
264
+ !Object.hasOwn(file.recipes, kind.recipes[phase] ?? ''),
265
+ )
266
+ .map((phase) =>
267
+ buildRejection(['kinds', itemKind, 'recipes', phase], {
268
+ kind: 'missing-recipe',
269
+ itemKind,
270
+ phase,
271
+ }),
272
+ ),
273
+ );
274
+ }
275
+
276
+ function buildRulesVersion(
277
+ commit: string,
278
+ files: readonly TreeFile[],
279
+ pins: readonly ConceptPin[],
280
+ ): RulesVersion {
281
+ return brandSchemas.rulesVersion.parse(
282
+ contentHash({
283
+ commit,
284
+ files: hashTree(files),
285
+ pins: pins.map((pin) => ({ ref: pin.ref, sourceHash: pin.sourceHash })),
286
+ }),
287
+ );
288
+ }
289
+
290
+ function getValue<T>(checked: Checked<T>): T {
291
+ invariant(checked.value !== null, 'the loader builds rules only when every check passed');
292
+
293
+ return checked.value;
294
+ }
295
+
296
+ function buildKinds(file: ParsedRulesFile): ReadonlyMap<ItemKindName, KindRules> {
297
+ return new Map(
298
+ Object.entries(file.kinds).map(([name, kind]): [ItemKindName, KindRules] => [
299
+ brandSchemas.itemKindName.parse(name),
300
+ {
301
+ pipeline: {
302
+ name: brandSchemas.pipelineName.parse(name),
303
+ steps: kind.steps,
304
+ },
305
+ recipes: kind.recipes,
306
+ },
307
+ ]),
308
+ );
309
+ }
310
+
311
+ function buildTakes(
312
+ takes: ParsedRulesFile['pools'][number]['takes'],
313
+ ): Readonly<Record<string, readonly string[]>> {
314
+ return Object.fromEntries(
315
+ Object.entries(takes).map(([field, value]) => [
316
+ field,
317
+ typeof value === 'string' ? [value] : value,
318
+ ]),
319
+ );
320
+ }
@@ -0,0 +1,10 @@
1
+ import type { ConceptEntry, ConceptName } from '@boxxy/model';
2
+
3
+ export function parseConceptEntry(entry: ConceptEntry): {
4
+ readonly name: ConceptName;
5
+ readonly version: number;
6
+ } {
7
+ const at = entry.lastIndexOf('@');
8
+
9
+ return { name: entry.slice(0, at), version: Number(entry.slice(at + 1)) };
10
+ }
@@ -0,0 +1,109 @@
1
+ import type { LoadRejection } from '@boxxy/model';
2
+ import { formatKeyPath } from '@boxxy/model';
3
+ import { match } from 'ts-pattern';
4
+ import type { Document } from 'yaml';
5
+ import { parseDocument } from 'yaml';
6
+ import type { ParsedRulesFile } from './rules-file-schema.ts';
7
+ import { rulesFileSchema } from './rules-file-schema.ts';
8
+
9
+ type RulesFileParse =
10
+ | { readonly kind: 'parsed'; readonly file: ParsedRulesFile }
11
+ | { readonly kind: 'rejected'; readonly rejections: readonly LoadRejection[] };
12
+
13
+ export function parseRulesFile(fileName: string, bytes: Uint8Array): RulesFileParse {
14
+ return match(toText(bytes))
15
+ .with({ kind: 'refused' }, (refused): RulesFileParse => ({
16
+ kind: 'rejected',
17
+ rejections: [buildInvalid(fileName, '', refused.message)],
18
+ }))
19
+ .with({ kind: 'converted' }, (text) => parseRulesText(fileName, text.value))
20
+ .exhaustive();
21
+ }
22
+
23
+ type Converted<T> =
24
+ | { readonly kind: 'converted'; readonly value: T }
25
+ | { readonly kind: 'refused'; readonly message: string };
26
+
27
+ function toText(bytes: Uint8Array): Converted<string> {
28
+ // TextDecoder reports bytes that aren't UTF-8 only by throwing, in its fatal mode
29
+ try {
30
+ return { kind: 'converted', value: new TextDecoder('utf-8', { fatal: true }).decode(bytes) };
31
+ } catch {
32
+ return { kind: 'refused', message: 'the file is not valid UTF-8' };
33
+ }
34
+ }
35
+
36
+ function buildInvalid(file: string, path: string, message: string): LoadRejection {
37
+ return { file, path, reason: { kind: 'invalid', message } };
38
+ }
39
+
40
+ function parseRulesText(fileName: string, text: string): RulesFileParse {
41
+ const document = parseDocument(text);
42
+
43
+ if (document.errors.length > 0) {
44
+ return {
45
+ kind: 'rejected',
46
+ rejections: document.errors.map((error) => buildInvalid(fileName, '', error.message)),
47
+ };
48
+ }
49
+
50
+ return match(toPlainValue(document))
51
+ .with({ kind: 'refused' }, (refused): RulesFileParse => ({
52
+ kind: 'rejected',
53
+ rejections: [buildInvalid(fileName, '', refused.message)],
54
+ }))
55
+ .with({ kind: 'converted' }, (plain) => parseRulesValue(fileName, plain.value))
56
+ .exhaustive();
57
+ }
58
+
59
+ function toPlainValue(document: Document): Converted<unknown> {
60
+ // yaml throws, and returns no error, on a document whose aliases expand past its alias limit
61
+ try {
62
+ const value: unknown = document.toJS();
63
+
64
+ return { kind: 'converted', value };
65
+ } catch (error) {
66
+ return { kind: 'refused', message: error instanceof Error ? error.message : String(error) };
67
+ }
68
+ }
69
+
70
+ function parseRulesValue(fileName: string, value: unknown): RulesFileParse {
71
+ // zod's record schema drops a `__proto__` key with no issue, so the loader would miss its value
72
+ const protoKeyPaths = collectProtoKeyPaths(value, []);
73
+
74
+ if (protoKeyPaths.length > 0) {
75
+ return {
76
+ kind: 'rejected',
77
+ rejections: protoKeyPaths.map((path) =>
78
+ buildInvalid(fileName, formatKeyPath(path), 'the key __proto__ is reserved'),
79
+ ),
80
+ };
81
+ }
82
+
83
+ const checked = rulesFileSchema.safeParse(value);
84
+
85
+ if (!checked.success) {
86
+ return {
87
+ kind: 'rejected',
88
+ rejections: checked.error.issues.map((issue) =>
89
+ buildInvalid(fileName, formatKeyPath(issue.path), issue.message),
90
+ ),
91
+ };
92
+ }
93
+
94
+ return { kind: 'parsed', file: checked.data };
95
+ }
96
+
97
+ function collectProtoKeyPaths(value: unknown, path: readonly PropertyKey[]): PropertyKey[][] {
98
+ if (Array.isArray(value)) {
99
+ return value.flatMap((item: unknown, index) => collectProtoKeyPaths(item, [...path, index]));
100
+ }
101
+
102
+ if (typeof value !== 'object' || value === null) {
103
+ return [];
104
+ }
105
+
106
+ return Object.entries(value).flatMap(([key, item]: [string, unknown]) =>
107
+ key === '__proto__' ? [[...path, key]] : collectProtoKeyPaths(item, [...path, key]),
108
+ );
109
+ }
@@ -0,0 +1,51 @@
1
+ import type { JsonValue } from '@boxxy/content-hash';
2
+ import type { ParamKindName, Registered, Resolved } from '@boxxy/model';
3
+ import { formatKeyPath } from '@boxxy/model';
4
+ import type { z } from 'zod';
5
+ import { buildRejection } from './build-rejection.ts';
6
+ import type { Checked } from './types.ts';
7
+
8
+ export function resolvePlugin<K extends ParamKindName>(
9
+ registered: Registered<K>,
10
+ params: JsonValue,
11
+ key: string,
12
+ path: readonly PropertyKey[],
13
+ ): Checked<Resolved<K>> {
14
+ return registered.open((plugin): Checked<Resolved<K>> => {
15
+ const checked = plugin.params.safeParse(params);
16
+
17
+ if (!checked.success) {
18
+ return {
19
+ value: null,
20
+ rejections: [
21
+ buildRejection(path, {
22
+ kind: 'bad-plugin-params',
23
+ pluginKind: registered.kind,
24
+ name: registered.name,
25
+ message: formatIssues(checked.error.issues),
26
+ }),
27
+ ],
28
+ };
29
+ }
30
+
31
+ const parsedParams = checked.data;
32
+
33
+ return {
34
+ value: {
35
+ kind: registered.kind,
36
+ name: registered.name,
37
+ key,
38
+ open: (use) => use(plugin, parsedParams),
39
+ },
40
+ rejections: [],
41
+ };
42
+ });
43
+ }
44
+
45
+ function formatIssues(issues: readonly z.core.$ZodIssue[]): string {
46
+ return issues
47
+ .map((issue) =>
48
+ issue.path.length > 0 ? `${formatKeyPath(issue.path)}: ${issue.message}` : issue.message,
49
+ )
50
+ .join('; ');
51
+ }
@@ -0,0 +1,43 @@
1
+ import type { JsonValue } from '@boxxy/content-hash';
2
+ import type { Registry, SignalRef } from '@boxxy/model';
3
+ import { signalKey } from '@boxxy/model';
4
+ import invariant from 'tiny-invariant';
5
+ import { buildRejection } from './build-rejection.ts';
6
+ import { resolvePlugin } from './resolve-plugin.ts';
7
+ import type { Checked, ResolvedSignal } from './types.ts';
8
+
9
+ export function resolveSignalRef(
10
+ ref: SignalRef,
11
+ path: readonly PropertyKey[],
12
+ registry: Registry,
13
+ ): Checked<ResolvedSignal> {
14
+ const parts = toPluginRefParts(ref);
15
+ const registered = registry.signal.get(parts.name);
16
+
17
+ if (registered === undefined) {
18
+ return {
19
+ value: null,
20
+ rejections: [buildRejection(path, { kind: 'unknown-signal', signal: parts.name })],
21
+ };
22
+ }
23
+
24
+ const key = signalKey(parts.name, parts.params);
25
+ const checked = resolvePlugin(registered, parts.params, key, path);
26
+
27
+ return {
28
+ value: checked.value === null ? null : { key, resolved: checked.value },
29
+ rejections: checked.rejections,
30
+ };
31
+ }
32
+
33
+ function toPluginRefParts(ref: SignalRef): { readonly name: string; readonly params: JsonValue } {
34
+ if (typeof ref === 'string') {
35
+ return { name: ref, params: {} };
36
+ }
37
+
38
+ const [entry] = Object.entries(ref);
39
+
40
+ invariant(entry, 'the rules file schema gives a plug-in reference with parameters one key');
41
+
42
+ return { name: entry[0], params: entry[1] };
43
+ }
@@ -0,0 +1,112 @@
1
+ import type { Effort, PipelineStep, RulesFile } from '@boxxy/model';
2
+ import { brandSchemas } from '@boxxy/model';
3
+ import { z } from 'zod';
4
+ import { parseConceptEntry } from './parse-concept-entry.ts';
5
+
6
+ const nameSchema = z.string().min(1);
7
+
8
+ const pluginRefSchema = z.union([
9
+ nameSchema,
10
+ z.record(nameSchema, z.json()).refine((ref) => Object.keys(ref).length === 1, {
11
+ message: 'a plug-in reference with parameters is a map with one key, the plug-in name',
12
+ }),
13
+ ]);
14
+
15
+ const kindSchema = z
16
+ .strictObject({
17
+ pipeline: z.array(brandSchemas.phaseName).min(1),
18
+ maxAttempts: z.record(brandSchemas.phaseName, z.int().positive()),
19
+ recipes: z.record(brandSchemas.phaseName, brandSchemas.recipeName),
20
+ })
21
+ .transform((kind, ctx) => {
22
+ const steps: PipelineStep[] = [];
23
+
24
+ for (const phase of kind.pipeline) {
25
+ const maxAttempts = Object.hasOwn(kind.maxAttempts, phase)
26
+ ? kind.maxAttempts[phase]
27
+ : undefined;
28
+
29
+ if (maxAttempts === undefined) {
30
+ ctx.issues.push({
31
+ code: 'custom',
32
+ input: kind.maxAttempts,
33
+ path: ['maxAttempts', phase],
34
+ message: `the pipeline phase ${phase} has no maxAttempts`,
35
+ });
36
+ } else {
37
+ steps.push({ phase, maxAttempts });
38
+ }
39
+ }
40
+
41
+ return { ...kind, steps };
42
+ });
43
+
44
+ const takesValueSchema = z.union([nameSchema, z.array(nameSchema).min(1)]);
45
+
46
+ const poolSchema = z.strictObject({
47
+ name: brandSchemas.poolName,
48
+ slots: z.int().positive(),
49
+ takes: z.record(nameSchema, takesValueSchema),
50
+ order: z.array(pluginRefSchema),
51
+ });
52
+
53
+ const efforts = ['low', 'medium', 'high', 'xhigh', 'max'] as const satisfies readonly Effort[];
54
+
55
+ const recipeSchema = z.strictObject({
56
+ agent: nameSchema,
57
+ model: nameSchema,
58
+ effort: z.enum(efforts),
59
+ prompt: nameSchema.refine(isCheckoutPath, {
60
+ message: 'a prompt path runs from the checkout root, with no empty, "." or ".." folder',
61
+ }),
62
+ source: brandSchemas.sourceName,
63
+ });
64
+
65
+ const conceptEntrySchema = z.templateLiteral([nameSchema, '@', z.int().positive()]);
66
+
67
+ const propagationLineSchema = z.strictObject({
68
+ value: nameSchema,
69
+ over: brandSchemas.linkTypeName,
70
+ toward: z.enum(['source', 'target']),
71
+ });
72
+
73
+ export const rulesFileSchema = z
74
+ .strictObject({
75
+ version: z.literal(1),
76
+ concepts: z.array(conceptEntrySchema),
77
+ kinds: z.record(brandSchemas.itemKindName, kindSchema),
78
+ pools: z.array(poolSchema),
79
+ lead: z.array(pluginRefSchema),
80
+ propagation: z.array(propagationLineSchema),
81
+ recipes: z.record(brandSchemas.recipeName, recipeSchema),
82
+ })
83
+ .superRefine((file, ctx) => {
84
+ const conceptNames = file.concepts.map((entry) => parseConceptEntry(entry).name);
85
+ const poolNames: string[] = file.pools.map((pool) => pool.name);
86
+
87
+ for (const [index, name] of conceptNames.entries()) {
88
+ if (conceptNames.indexOf(name) !== index) {
89
+ ctx.addIssue({
90
+ code: 'custom',
91
+ path: ['concepts', index],
92
+ message: `the concept ${name} is pinned twice`,
93
+ });
94
+ }
95
+ }
96
+
97
+ for (const [index, name] of poolNames.entries()) {
98
+ if (poolNames.indexOf(name) !== index) {
99
+ ctx.addIssue({
100
+ code: 'custom',
101
+ path: ['pools', index, 'name'],
102
+ message: `the pool name ${name} is used twice`,
103
+ });
104
+ }
105
+ }
106
+ }) satisfies z.ZodType<RulesFile>;
107
+
108
+ export type ParsedRulesFile = z.output<typeof rulesFileSchema>;
109
+
110
+ function isCheckoutPath(path: string): boolean {
111
+ return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
112
+ }
package/src/types.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { Concept, LoadRejection, Resolved, SignalKey } from '@boxxy/model';
2
+
3
+ export interface Checked<T> {
4
+ readonly value: T | null;
5
+ readonly rejections: readonly LoadRejection[];
6
+ }
7
+
8
+ export interface ResolvedSignal {
9
+ readonly key: SignalKey;
10
+ readonly resolved: Resolved<'signal'>;
11
+ }
12
+
13
+ export interface PinnedConcept {
14
+ readonly concept: Concept;
15
+ readonly entryIndex: number;
16
+ }