@boxxy/planning 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,43 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <h1>@boxxy/planning</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/planning"><img src="https://img.shields.io/npm/v/@boxxy/planning" 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 planning. `buildFrame` runs the plug-ins of the loaded rules over a snapshot of the open
15
+ items, and `decide` picks the next item phase for each free pool slot.
16
+
17
+ The package does no I/O: the caller passes in the snapshot, the loaded rules and the time.
18
+
19
+ ## Install
20
+
21
+ Install the package from [npm](https://www.npmjs.com/package/@boxxy/planning) with Bun:
22
+
23
+ ```bash
24
+ bun add @boxxy/planning
25
+ ```
26
+
27
+ The package runs on [Bun](https://bun.sh) 1.3 or newer.
28
+
29
+ ## Features
30
+
31
+ - `buildFrame`, which returns the candidates, their holds, lifts, signal values and filter-field
32
+ values, and each plug-in fault
33
+ ([planning: buildFrame and decide](../../docs/design/boxxy/slice-1.md#planning-buildframe-and-decide)).
34
+ - `decide`, which picks one candidate per free slot, pool by pool, and never names a concept.
35
+ - `concepts`, the `pool`, `defer` and `expedite` concepts
36
+ ([the slice 1 concepts](../../docs/design/boxxy/slice-1.md#the-slice-1-concepts)).
37
+ - `conceptPins`, each concept's pin, which the build generates from the concept's source
38
+ ([rules for slice 1](../../docs/design/boxxy/slice-1.md#rules-for-slice-1)).
39
+
40
+ ## Documentation
41
+
42
+ The [planning design](../../docs/design/boxxy/planning.md) says why concepts are plug-ins, and
43
+ [`04-planning.ts`](../../docs/design/boxxy/sketches/04-planning.ts) gives the shapes.
@@ -0,0 +1,8 @@
1
+ import type { Frame, Instant, LoadedRules, Snapshot } from '@boxxy/model';
2
+ interface BuildFrameInput {
3
+ readonly snapshot: Snapshot;
4
+ readonly rules: LoadedRules;
5
+ readonly now: Instant;
6
+ }
7
+ export declare function buildFrame(input: BuildFrameInput): Frame;
8
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { ItemId } from '@boxxy/model';
2
+ export declare function compareItemIDs(left: ItemId, right: ItemId): number;
@@ -0,0 +1,2 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ export declare const deferConcept: Concept;
@@ -0,0 +1,2 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ export declare const expediteConcept: Concept;
@@ -0,0 +1,2 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ export declare const poolConcept: Concept;
@@ -0,0 +1,2 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ export declare const concepts: readonly Concept[];
@@ -0,0 +1,8 @@
1
+ import type { Decision, Frame, Pool, PoolName } from '@boxxy/model';
2
+ interface DecideInput {
3
+ readonly frame: Frame;
4
+ readonly pools: readonly Pool[];
5
+ readonly freeSlots: Readonly<Record<PoolName, number>>;
6
+ }
7
+ export declare function decide(input: DecideInput): Decision;
8
+ export {};
@@ -0,0 +1 @@
1
+ export declare function formatFault(error: unknown): string;
@@ -0,0 +1,2 @@
1
+ import type { ConceptName, ConceptPin } from '@boxxy/model';
2
+ export declare const conceptPins: ReadonlyMap<ConceptName, ConceptPin>;
@@ -0,0 +1,4 @@
1
+ export { buildFrame } from './build-frame.ts';
2
+ export { concepts } from './concepts.ts';
3
+ export { decide } from './decide.ts';
4
+ export { conceptPins } from './generated/concept-pins.ts';
@@ -0,0 +1,14 @@
1
+ import type { Fault, FrameItem, ItemId, LinkRow, PropagationLine, Resolved, SignalKey } from '@boxxy/model';
2
+ interface PropagateInput {
3
+ readonly lines: readonly PropagationLine[];
4
+ readonly items: readonly FrameItem[];
5
+ readonly links: readonly LinkRow[];
6
+ readonly signals: ReadonlyMap<SignalKey, Resolved<'signal'>>;
7
+ readonly values: ReadonlyMap<SignalKey, ReadonlyMap<ItemId, number>>;
8
+ }
9
+ interface Propagated {
10
+ readonly values: Map<SignalKey, Map<ItemId, number>>;
11
+ readonly faults: readonly Fault[];
12
+ }
13
+ export declare function propagate(input: PropagateInput): Propagated;
14
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { Concept, JsonValue, LoadedRules } from '@boxxy/model';
2
+ interface MockRulesInput {
3
+ readonly concepts?: readonly Concept[];
4
+ readonly pools?: readonly JsonValue[];
5
+ readonly lead?: readonly JsonValue[];
6
+ readonly propagation?: readonly JsonValue[];
7
+ }
8
+ export declare function buildMockLoadedRules(input?: MockRulesInput): LoadedRules;
9
+ export {};
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@boxxy/planning",
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 planning: builds the frame from the plug-ins and picks the next work for each free slot",
5
+ "homepage": "https://github.com/zgeoff/boxxy/tree/main/modules/planning#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/planning"
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
+ "./test-utils/*": "./src/test-utils/*.ts"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "bun ../../scripts/write-concept-pins.ts && rm -rf dist && tsrs -p tsconfig.build.json",
31
+ "typecheck": "tsrs --noEmit"
32
+ },
33
+ "dependencies": {
34
+ "@boxxy/content-hash": "0.1.0",
35
+ "@boxxy/model": "0.1.1",
36
+ "@boxxy/rules": "0.1.0",
37
+ "tiny-invariant": "1.3.3",
38
+ "zod": "4.6.7"
39
+ },
40
+ "engines": {
41
+ "bun": ">=1.3",
42
+ "node": ">=24"
43
+ }
44
+ }
@@ -0,0 +1,274 @@
1
+ import type {
2
+ Fault,
3
+ FilterField,
4
+ Frame,
5
+ FrameItem,
6
+ FrameValues,
7
+ Hold,
8
+ Instant,
9
+ ItemId,
10
+ ItemKindName,
11
+ KindRules,
12
+ Lift,
13
+ LoadedRules,
14
+ PlanContext,
15
+ Resolved,
16
+ SignalKey,
17
+ Snapshot,
18
+ Waiver,
19
+ } from '@boxxy/model';
20
+ import { signalKey } from '@boxxy/model';
21
+ import invariant from 'tiny-invariant';
22
+ import { formatFault } from './format-fault.ts';
23
+ import { propagate } from './propagate.ts';
24
+
25
+ interface BuildFrameInput {
26
+ readonly snapshot: Snapshot;
27
+ readonly rules: LoadedRules;
28
+ readonly now: Instant;
29
+ }
30
+
31
+ export function buildFrame(input: BuildFrameInput): Frame {
32
+ const items = collectFrameItems(input.snapshot, input.rules.kinds);
33
+ const candidates = items.filter((item) => item.derived.state === 'ready');
34
+
35
+ const buildContext = (values: FrameValues): PlanContext => ({
36
+ now: input.now,
37
+ levers: input.snapshot.levers,
38
+ links: input.snapshot.links,
39
+ values,
40
+ });
41
+
42
+ const carriedKeys = new Set(input.rules.propagation.map((line) => signalKey(line.value, {})));
43
+
44
+ const carried = [...carriedKeys].map((key) =>
45
+ collectSignalValues(key, getSignal(input.rules, key), items, buildContext(new Map())),
46
+ );
47
+
48
+ const propagated = propagate({
49
+ lines: input.rules.propagation,
50
+ items,
51
+ links: input.snapshot.links,
52
+ signals: input.rules.signals,
53
+ values: new Map(carried.map((entry) => [entry.key, entry.values])),
54
+ });
55
+
56
+ const others = [...input.rules.signals]
57
+ .filter(([key]) => !carriedKeys.has(key))
58
+ .map(([key, signal]) =>
59
+ collectSignalValues(key, signal, candidates, buildContext(propagated.values)),
60
+ );
61
+
62
+ const values = new Map([
63
+ ...propagated.values,
64
+ ...others.map((entry): [SignalKey, Map<ItemId, number>] => [entry.key, entry.values]),
65
+ ]);
66
+
67
+ const context = buildContext(values);
68
+ const waivers = [...input.rules.registry.waiver.values()];
69
+ const gated = candidates.map((item) => collectHolds(item, input.rules.gates, waivers, context));
70
+ const filterFields = [...input.rules.registry['filter-field'].values()];
71
+ const fields = candidates.map((item) => collectFieldValues(item, filterFields));
72
+
73
+ return {
74
+ candidates,
75
+ fields: new Map(fields.map((entry) => [entry.item, entry.values])),
76
+ values,
77
+ holds: new Map(
78
+ gated.flatMap((entry) => (entry.holds.length > 0 ? [[entry.item, entry.holds]] : [])),
79
+ ),
80
+ lifted: new Map(
81
+ gated.flatMap((entry) => (entry.lifted.length > 0 ? [[entry.item, entry.lifted]] : [])),
82
+ ),
83
+ faults: [
84
+ ...carried.flatMap((entry) => entry.faults),
85
+ ...propagated.faults,
86
+ ...others.flatMap((entry) => entry.faults),
87
+ ...gated.flatMap((entry) => entry.faults),
88
+ ...fields.flatMap((entry) => entry.faults),
89
+ ],
90
+ };
91
+ }
92
+
93
+ function collectFrameItems(
94
+ snapshot: Snapshot,
95
+ kinds: ReadonlyMap<ItemKindName, KindRules>,
96
+ ): FrameItem[] {
97
+ return snapshot.items.flatMap((item) => {
98
+ const phase = kinds.get(item.kind)?.pipeline.steps[0]?.phase;
99
+
100
+ return phase === undefined ? [] : [{ ...item, phase }];
101
+ });
102
+ }
103
+
104
+ interface SignalValues {
105
+ readonly key: SignalKey;
106
+ readonly values: Map<ItemId, number>;
107
+ readonly faults: readonly Fault[];
108
+ }
109
+
110
+ function collectSignalValues(
111
+ key: SignalKey,
112
+ signal: Resolved<'signal'>,
113
+ items: readonly FrameItem[],
114
+ context: PlanContext,
115
+ ): SignalValues {
116
+ const values = new Map<ItemId, number>();
117
+
118
+ const faults: Fault[] = [];
119
+
120
+ for (const item of items) {
121
+ try {
122
+ const value = signal.open((plugin, params) => plugin.value(item, context, params));
123
+
124
+ if (value !== null) {
125
+ values.set(item.id, value);
126
+ }
127
+ } catch (error) {
128
+ faults.push({
129
+ plugin: signal.name,
130
+ kind: 'signal',
131
+ item: item.id,
132
+ error: formatFault(error),
133
+ });
134
+ }
135
+ }
136
+
137
+ return { key, values, faults };
138
+ }
139
+
140
+ function getSignal(rules: LoadedRules, key: SignalKey): Resolved<'signal'> {
141
+ const signal = rules.signals.get(key);
142
+
143
+ invariant(signal, `the loader resolves the signal of each propagation line, and missed ${key}`);
144
+
145
+ return signal;
146
+ }
147
+
148
+ interface ItemHolds {
149
+ readonly item: ItemId;
150
+ readonly holds: Hold[];
151
+ readonly lifted: Lift[];
152
+ readonly faults: readonly Fault[];
153
+ }
154
+
155
+ function collectHolds(
156
+ item: FrameItem,
157
+ gates: readonly Resolved<'gate'>[],
158
+ waivers: readonly Waiver[],
159
+ context: PlanContext,
160
+ ): ItemHolds {
161
+ const outcomes = gates.map((gate) => pickGateOutcome(item, gate, context));
162
+ const faults = outcomes.flatMap((outcome) => outcome.faults);
163
+ const holds: Hold[] = [];
164
+ const lifted: Lift[] = [];
165
+
166
+ for (const outcome of outcomes) {
167
+ if (outcome.hold !== null) {
168
+ const waived = outcome.liftable
169
+ ? pickLift(item, outcome.hold, waivers, context)
170
+ : { lift: null, faults: [] };
171
+
172
+ faults.push(...waived.faults);
173
+
174
+ if (waived.lift === null) {
175
+ holds.push(outcome.hold);
176
+ } else {
177
+ lifted.push(waived.lift);
178
+ }
179
+ }
180
+ }
181
+
182
+ return { item: item.id, holds, lifted, faults };
183
+ }
184
+
185
+ interface GateOutcome {
186
+ readonly hold: Hold | null;
187
+ readonly liftable: boolean;
188
+ readonly faults: readonly Fault[];
189
+ }
190
+
191
+ function pickGateOutcome(
192
+ item: FrameItem,
193
+ gate: Resolved<'gate'>,
194
+ context: PlanContext,
195
+ ): GateOutcome {
196
+ try {
197
+ return gate.open((plugin, params) => ({
198
+ hold: plugin.hold(item, context, params),
199
+ liftable: plugin.liftable,
200
+ faults: [],
201
+ }));
202
+ } catch (error) {
203
+ const message = formatFault(error);
204
+
205
+ return {
206
+ hold: { gate: gate.name, reason: message },
207
+ liftable: false,
208
+ faults: [{ plugin: gate.name, kind: 'gate', item: item.id, error: message }],
209
+ };
210
+ }
211
+ }
212
+
213
+ interface Waived {
214
+ readonly lift: Lift | null;
215
+ readonly faults: readonly Fault[];
216
+ }
217
+
218
+ function pickLift(
219
+ item: FrameItem,
220
+ hold: Hold,
221
+ waivers: readonly Waiver[],
222
+ context: PlanContext,
223
+ ): Waived {
224
+ const faults: Fault[] = [];
225
+
226
+ for (const waiver of waivers.filter((candidate) => candidate.lifts === hold.gate)) {
227
+ try {
228
+ const lift = waiver.lift(item, hold, context);
229
+
230
+ if (lift !== null) {
231
+ return { lift, faults };
232
+ }
233
+ } catch (error) {
234
+ faults.push({
235
+ plugin: waiver.name,
236
+ kind: 'waiver',
237
+ item: item.id,
238
+ error: formatFault(error),
239
+ });
240
+ }
241
+ }
242
+
243
+ return { lift: null, faults };
244
+ }
245
+
246
+ interface FieldValues {
247
+ readonly item: ItemId;
248
+ readonly values: Readonly<Record<string, readonly string[]>>;
249
+ readonly faults: readonly Fault[];
250
+ }
251
+
252
+ function collectFieldValues(item: FrameItem, fields: readonly FilterField[]): FieldValues {
253
+ const values: Record<string, readonly string[]> = {};
254
+ const faults: Fault[] = [];
255
+
256
+ for (const field of fields) {
257
+ try {
258
+ const value = field.value(item);
259
+
260
+ if (value !== null) {
261
+ values[field.name] = typeof value === 'string' ? [value] : value;
262
+ }
263
+ } catch (error) {
264
+ faults.push({
265
+ plugin: field.name,
266
+ kind: 'filter-field',
267
+ item: item.id,
268
+ error: formatFault(error),
269
+ });
270
+ }
271
+ }
272
+
273
+ return { item: item.id, values, faults };
274
+ }
@@ -0,0 +1,10 @@
1
+ import type { ItemId } from '@boxxy/model';
2
+
3
+ // compares by UTF-16 code units, so the order never depends on the locale
4
+ export function compareItemIDs(left: ItemId, right: ItemId): number {
5
+ if (left === right) {
6
+ return 0;
7
+ }
8
+
9
+ return left < right ? -1 : 1;
10
+ }
@@ -0,0 +1,50 @@
1
+ import type { Concept, LeverRow } from '@boxxy/model';
2
+ import { defineGate, signalKey } from '@boxxy/model';
3
+ import { z } from 'zod';
4
+
5
+ const expediteKey = signalKey('expedite', {});
6
+
7
+ export const deferConcept: Concept = {
8
+ name: 'defer',
9
+ version: 1,
10
+ gates: [
11
+ defineGate({
12
+ name: 'defer',
13
+ liftable: true,
14
+ params: z.object({}),
15
+ hold: (item, ctx) => {
16
+ const latest = findLatestDefer(ctx.levers.filter((lever) => lever.item === item.id));
17
+
18
+ if (latest === null || (latest.until !== null && latest.until <= ctx.now)) {
19
+ return null;
20
+ }
21
+
22
+ const reason = latest.reason ?? 'deferred';
23
+
24
+ return latest.until === null
25
+ ? { gate: 'defer', reason }
26
+ : { gate: 'defer', reason, until: latest.until };
27
+ },
28
+ }),
29
+ ],
30
+ waivers: [
31
+ {
32
+ name: 'expedite-lifts-defer',
33
+ lifts: 'defer',
34
+ lift: (item, hold, ctx) =>
35
+ ctx.values.get(expediteKey)?.has(item.id) === true
36
+ ? { waiver: 'expedite-lifts-defer', gate: hold.gate, reason: 'expedited' }
37
+ : null,
38
+ },
39
+ ],
40
+ levers: ['plan.defer', 'plan.undefer'],
41
+ };
42
+
43
+ function findLatestDefer(levers: readonly LeverRow[]): LeverRow | null {
44
+ return levers
45
+ .filter((lever) => lever.kind === 'plan.defer')
46
+ .reduce<LeverRow | null>(
47
+ (latest, lever) => (latest === null || lever.seq > latest.seq ? lever : latest),
48
+ null,
49
+ );
50
+ }
@@ -0,0 +1,24 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ import { defineSignal } from '@boxxy/model';
3
+ import { z } from 'zod';
4
+
5
+ export const expediteConcept: Concept = {
6
+ name: 'expedite',
7
+ version: 1,
8
+ signals: [
9
+ defineSignal({
10
+ name: 'expedite',
11
+ params: z.object({}),
12
+ value: (item, ctx) => {
13
+ const position = ctx.levers
14
+ .filter((lever) => lever.kind === 'plan.expedite')
15
+ .toSorted((left, right) => left.seq - right.seq)
16
+ .findIndex((lever) => lever.item === item.id);
17
+
18
+ return position === -1 ? null : position;
19
+ },
20
+ combine: (left, right) => Math.min(left, right),
21
+ }),
22
+ ],
23
+ levers: ['plan.expedite', 'plan.unexpedite'],
24
+ };
@@ -0,0 +1,20 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ import { defineSignal } from '@boxxy/model';
3
+ import { z } from 'zod';
4
+
5
+ export const poolConcept: Concept = {
6
+ name: 'pool',
7
+ version: 1,
8
+ signals: [
9
+ defineSignal({
10
+ name: 'oldest',
11
+ params: z.object({}),
12
+ value: (item) => item.filedAt,
13
+ }),
14
+ ],
15
+ filterFields: [
16
+ { name: 'phase', value: (item) => item.phase },
17
+ { name: 'kind', value: (item) => item.kind },
18
+ { name: 'repo', value: (item) => item.repo },
19
+ ],
20
+ };
@@ -0,0 +1,6 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ import { deferConcept } from './concepts/defer.ts';
3
+ import { expediteConcept } from './concepts/expedite.ts';
4
+ import { poolConcept } from './concepts/pool.ts';
5
+
6
+ export const concepts: readonly Concept[] = [poolConcept, deferConcept, expediteConcept];
package/src/decide.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { Decision, Frame, FrameItem, ItemId, Pick, Pool, PoolName } from '@boxxy/model';
2
+ import { compareItemIDs } from './compare-item-ids.ts';
3
+
4
+ interface DecideInput {
5
+ readonly frame: Frame;
6
+ readonly pools: readonly Pool[];
7
+ readonly freeSlots: Readonly<Record<PoolName, number>>;
8
+ }
9
+
10
+ export function decide(input: DecideInput): Decision {
11
+ const frame = input.frame;
12
+
13
+ const picked = new Set<ItemId>();
14
+
15
+ const picks: Pick[] = [];
16
+
17
+ for (const pool of input.pools) {
18
+ const free = input.freeSlots[pool.name] ?? 0;
19
+
20
+ const ranked = frame.candidates
21
+ .filter(
22
+ (item) =>
23
+ !picked.has(item.id) &&
24
+ (frame.holds.get(item.id) ?? []).length === 0 &&
25
+ isTakenBy(pool, frame.fields.get(item.id) ?? {}),
26
+ )
27
+ .toSorted((left, right) => compareCandidates(left, right, pool, frame));
28
+
29
+ for (const item of ranked.slice(0, free)) {
30
+ picked.add(item.id);
31
+ picks.push({ item: item.id, phase: item.phase, pool: pool.name });
32
+ }
33
+ }
34
+
35
+ return { picks };
36
+ }
37
+
38
+ function isTakenBy(pool: Pool, fields: Readonly<Record<string, readonly string[]>>): boolean {
39
+ return Object.entries(pool.takes).every(([field, accepted]) =>
40
+ (fields[field] ?? []).some((value) => accepted.includes(value)),
41
+ );
42
+ }
43
+
44
+ function compareCandidates(left: FrameItem, right: FrameItem, pool: Pool, frame: Frame): number {
45
+ for (const key of pool.order) {
46
+ const byItem = frame.values.get(key);
47
+ const order = compareValues(byItem?.get(left.id), byItem?.get(right.id));
48
+
49
+ if (order !== 0) {
50
+ return order;
51
+ }
52
+ }
53
+
54
+ return compareItemIDs(left.id, right.id);
55
+ }
56
+
57
+ function compareValues(left: number | undefined, right: number | undefined): number {
58
+ if (left === right) {
59
+ return 0;
60
+ }
61
+
62
+ if (left === undefined) {
63
+ return 1;
64
+ }
65
+
66
+ if (right === undefined) {
67
+ return -1;
68
+ }
69
+
70
+ return left < right ? -1 : 1;
71
+ }
@@ -0,0 +1,3 @@
1
+ export function formatFault(error: unknown): string {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
@@ -0,0 +1,33 @@
1
+ // scripts/write-concept-pins.ts writes this file; run `bun run build` in modules/planning
2
+ import { contentHashSchema } from '@boxxy/content-hash';
3
+ import type { ConceptName, ConceptPin } from '@boxxy/model';
4
+
5
+ export const conceptPins: ReadonlyMap<ConceptName, ConceptPin> = new Map([
6
+ [
7
+ 'defer',
8
+ {
9
+ ref: 'defer@1',
10
+ sourceHash: contentHashSchema.parse(
11
+ '465c5e5376b2de6814f08888cd479249e494d1986802aa6293e2495b88e846d9',
12
+ ),
13
+ },
14
+ ],
15
+ [
16
+ 'expedite',
17
+ {
18
+ ref: 'expedite@1',
19
+ sourceHash: contentHashSchema.parse(
20
+ '672b566d996e4b8d1aeb6e9e768460726473d6f0181a0eedcf09494687679882',
21
+ ),
22
+ },
23
+ ],
24
+ [
25
+ 'pool',
26
+ {
27
+ ref: 'pool@1',
28
+ sourceHash: contentHashSchema.parse(
29
+ '93bb6fefe3cf95fc8a15bbe9381733427ba34c2118626449c7828929f895b984',
30
+ ),
31
+ },
32
+ ],
33
+ ]);
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { buildFrame } from './build-frame.ts';
2
+ export { concepts } from './concepts.ts';
3
+ export { decide } from './decide.ts';
4
+ export { conceptPins } from './generated/concept-pins.ts';
@@ -0,0 +1,130 @@
1
+ import type {
2
+ Fault,
3
+ FrameItem,
4
+ ItemId,
5
+ LinkRow,
6
+ PropagationLine,
7
+ Resolved,
8
+ SignalKey,
9
+ } from '@boxxy/model';
10
+ import { signalKey } from '@boxxy/model';
11
+ import invariant from 'tiny-invariant';
12
+ import { compareItemIDs } from './compare-item-ids.ts';
13
+ import { formatFault } from './format-fault.ts';
14
+
15
+ interface PropagateInput {
16
+ readonly lines: readonly PropagationLine[];
17
+ readonly items: readonly FrameItem[];
18
+ readonly links: readonly LinkRow[];
19
+ readonly signals: ReadonlyMap<SignalKey, Resolved<'signal'>>;
20
+ readonly values: ReadonlyMap<SignalKey, ReadonlyMap<ItemId, number>>;
21
+ }
22
+
23
+ interface Propagated {
24
+ readonly values: Map<SignalKey, Map<ItemId, number>>;
25
+ readonly faults: readonly Fault[];
26
+ }
27
+
28
+ export function propagate(input: PropagateInput): Propagated {
29
+ const open = new Set(input.items.map((item) => item.id));
30
+
31
+ const faults: Fault[] = [];
32
+
33
+ const values = new Map(
34
+ [...input.values].map(([key, byItem]): [SignalKey, Map<ItemId, number>] => [
35
+ key,
36
+ new Map(byItem),
37
+ ]),
38
+ );
39
+
40
+ for (const line of input.lines) {
41
+ const key = signalKey(line.value, {});
42
+ const combine = getCombine(input.signals, key);
43
+
44
+ try {
45
+ const start = values.get(key) ?? new Map<ItemId, number>();
46
+
47
+ values.set(key, carryLine(line, combine, start, { open, links: input.links }));
48
+ } catch (error) {
49
+ faults.push({
50
+ plugin: `propagation:${line.value}:${line.over}`,
51
+ kind: 'propagation',
52
+ item: null,
53
+ error: formatFault(error),
54
+ });
55
+ }
56
+ }
57
+
58
+ return { values, faults };
59
+ }
60
+
61
+ type Combine = (left: number, right: number) => number;
62
+
63
+ function getCombine(signals: PropagateInput['signals'], key: SignalKey): Combine {
64
+ const combine = signals.get(key)?.open((plugin) => plugin.combine);
65
+
66
+ invariant(combine, `the loader refuses a propagation line whose signal ${key} has no combine`);
67
+
68
+ return combine;
69
+ }
70
+
71
+ interface LineGraph {
72
+ readonly open: ReadonlySet<ItemId>;
73
+ readonly links: readonly LinkRow[];
74
+ }
75
+
76
+ function carryLine(
77
+ line: PropagationLine,
78
+ combine: Combine,
79
+ start: ReadonlyMap<ItemId, number>,
80
+ graph: LineGraph,
81
+ ): Map<ItemId, number> {
82
+ const next = collectNeighbours(line, graph.links);
83
+
84
+ const carried = new Map(start);
85
+
86
+ const starts = [...start].toSorted(([left], [right]) => compareItemIDs(left, right));
87
+
88
+ for (const [origin, value] of starts) {
89
+ const visited = new Set([origin]);
90
+
91
+ const queue = [...(next.get(origin) ?? [])];
92
+
93
+ while (queue.length > 0) {
94
+ const reached = queue.shift();
95
+
96
+ if (reached === undefined || visited.has(reached) || !graph.open.has(reached)) {
97
+ continue;
98
+ }
99
+
100
+ visited.add(reached);
101
+
102
+ const current = carried.get(reached);
103
+ const combined = current === undefined ? value : combine(current, value);
104
+
105
+ carried.set(reached, combined);
106
+ queue.push(...(next.get(reached) ?? []));
107
+ }
108
+ }
109
+
110
+ return carried;
111
+ }
112
+
113
+ function collectNeighbours(
114
+ line: PropagationLine,
115
+ links: readonly LinkRow[],
116
+ ): Map<ItemId, ItemId[]> {
117
+ const next = new Map<ItemId, ItemId[]>();
118
+
119
+ for (const link of links.filter((candidate) => candidate.type === line.over)) {
120
+ const [from, to] =
121
+ line.toward === 'source' ? [link.target, link.source] : [link.source, link.target];
122
+
123
+ const neighbours = next.get(from) ?? [];
124
+
125
+ neighbours.push(to);
126
+ next.set(from, neighbours);
127
+ }
128
+
129
+ return next;
130
+ }
@@ -0,0 +1,79 @@
1
+ import { contentHash } from '@boxxy/content-hash';
2
+ import type { Concept, ConceptPin, JsonValue, LoadedRules } from '@boxxy/model';
3
+ import { loadRules } from '@boxxy/rules';
4
+ import invariant from 'tiny-invariant';
5
+ import { concepts as builtInConcepts } from '../concepts.ts';
6
+
7
+ interface MockRulesInput {
8
+ readonly concepts?: readonly Concept[];
9
+ readonly pools?: readonly JsonValue[];
10
+ readonly lead?: readonly JsonValue[];
11
+ readonly propagation?: readonly JsonValue[];
12
+ }
13
+
14
+ export function buildMockLoadedRules(input: MockRulesInput = {}): LoadedRules {
15
+ const concepts = input.concepts ?? builtInConcepts;
16
+
17
+ const file = {
18
+ version: 1,
19
+ concepts: concepts.map((concept) => `${concept.name}@${concept.version}`),
20
+ kinds: {
21
+ 'core.issue': {
22
+ pipeline: ['build'],
23
+ maxAttempts: { build: 2 },
24
+ recipes: { build: 'build-v1' },
25
+ },
26
+ },
27
+ pools: input.pools ?? [
28
+ {
29
+ name: 'build',
30
+ slots: 2,
31
+ takes: { phase: 'build', kind: ['core.issue'] },
32
+ order: ['oldest'],
33
+ },
34
+ ],
35
+ lead: input.lead ?? ['expedite'],
36
+ propagation: input.propagation ?? [{ value: 'expedite', over: 'blocks', toward: 'source' }],
37
+ recipes: {
38
+ 'build-v1': {
39
+ agent: 'claude',
40
+ model: 'example-model',
41
+ effort: 'high',
42
+ prompt: 'prompts/build-v1.md',
43
+ source: 'subscription',
44
+ },
45
+ },
46
+ };
47
+
48
+ const encoder = new TextEncoder();
49
+
50
+ // only modules/rules may import yaml, and JSON is YAML, so the loader reads this as it is
51
+ const rulesBytes = encoder.encode(JSON.stringify(file));
52
+
53
+ const pins = concepts.map((concept): [string, ConceptPin] => [
54
+ concept.name,
55
+ {
56
+ ref: `${concept.name}@${concept.version}`,
57
+ sourceHash: contentHash(`${concept.name} source`),
58
+ },
59
+ ]);
60
+
61
+ const result = loadRules({
62
+ checkout: {
63
+ commit: '3f2a1c0',
64
+ files: new Map([
65
+ ['rules.yaml', rulesBytes],
66
+ ['prompts/build-v1.md', encoder.encode('Build {{title}}.\n')],
67
+ ]),
68
+ },
69
+ concepts,
70
+ pins: new Map(pins),
71
+ });
72
+
73
+ invariant(
74
+ result.kind === 'loaded',
75
+ `the mock rules file failed to load: ${JSON.stringify(result)}`,
76
+ );
77
+
78
+ return result.rules;
79
+ }