@boxxy/planning 0.0.0-stage → 0.1.3

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,46 @@
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 wakes and the item phases that start, while each limit has a free
16
+ seat.
17
+
18
+ The package does no I/O: the caller passes in the snapshot, the loaded rules and the time.
19
+
20
+ ## Install
21
+
22
+ Install the package from [npm](https://www.npmjs.com/package/@boxxy/planning) with Bun:
23
+
24
+ ```bash
25
+ bun add @boxxy/planning
26
+ ```
27
+
28
+ The package runs on [Bun](https://bun.sh) 1.3 or newer.
29
+
30
+ ## Features
31
+
32
+ - `buildFrame`, which returns the candidates, their holds, lifts, signal values and filter-field
33
+ values, and each plug-in fault
34
+ ([planning: buildFrame and decide](../../docs/design/boxxy/slice-1.md#planning-buildframe-and-decide)).
35
+ - `decide`, which puts each wake first, then picks pool by pool while each limit that counts a pick
36
+ has a free seat, gives the reason for each item and wake it passes over, and never names a concept
37
+ ([planning: limits and the decision](../../docs/design/boxxy/planning.md#limits-and-the-decision)).
38
+ - `concepts`, the `pool`, `defer` and `expedite` concepts
39
+ ([the slice 1 concepts](../../docs/design/boxxy/slice-1.md#the-slice-1-concepts)).
40
+ - `conceptPins`, each concept's pin, which the build generates from the concept's source
41
+ ([rules for slice 1](../../docs/design/boxxy/slice-1.md#rules-for-slice-1)).
42
+
43
+ ## Documentation
44
+
45
+ The [planning design](../../docs/design/boxxy/planning.md) says why concepts are plug-ins, and
46
+ [`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 defer: Concept;
@@ -0,0 +1,2 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ export declare const expedite: Concept;
@@ -0,0 +1,2 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ export declare const pool: Concept;
@@ -0,0 +1,2 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ export declare const concepts: readonly Concept[];
@@ -0,0 +1,9 @@
1
+ import type { Decision, Frame, Pool, PoolName, Snapshot } from '@boxxy/model';
2
+ interface DecideInput {
3
+ readonly frame: Frame;
4
+ readonly pools: readonly Pool[];
5
+ readonly snapshot: Pick<Snapshot, 'limits' | 'wakes'>;
6
+ readonly backoff: readonly PoolName[];
7
+ }
8
+ export declare function decide(input: DecideInput): Decision;
9
+ 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, ItemId, ItemView, PropagationLine, RelationRow, Resolved, SignalKey } from '@boxxy/model';
2
+ interface PropagateInput {
3
+ readonly lines: readonly PropagationLine[];
4
+ readonly items: readonly ItemView[];
5
+ readonly relations: readonly RelationRow[];
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.3",
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.3",
35
+ "@boxxy/model": "0.1.3",
36
+ "@boxxy/rules": "0.1.3",
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,276 @@
1
+ import type {
2
+ Fault,
3
+ FilterField,
4
+ Frame,
5
+ FrameItem,
6
+ FrameValues,
7
+ Hold,
8
+ Instant,
9
+ ItemId,
10
+ ItemView,
11
+ Lift,
12
+ LoadedRules,
13
+ PlanContext,
14
+ Resolved,
15
+ SignalKey,
16
+ Snapshot,
17
+ Waiver,
18
+ } from '@boxxy/model';
19
+ import { signalKey } from '@boxxy/model';
20
+ import invariant from 'tiny-invariant';
21
+ import { formatFault } from './format-fault.ts';
22
+ import { propagate } from './propagate.ts';
23
+
24
+ interface BuildFrameInput {
25
+ readonly snapshot: Snapshot;
26
+ readonly rules: LoadedRules;
27
+ readonly now: Instant;
28
+ }
29
+
30
+ export function buildFrame(input: BuildFrameInput): Frame {
31
+ const items = input.snapshot.items;
32
+ const candidates = collectCandidates(items);
33
+
34
+ const buildContext = (values: FrameValues): PlanContext => ({
35
+ now: input.now,
36
+ levers: input.snapshot.levers,
37
+ relations: input.snapshot.relations,
38
+ values,
39
+ });
40
+
41
+ const carriedKeys = new Set(input.rules.propagation.map((line) => signalKey(line.value, {})));
42
+
43
+ const carried = [...carriedKeys].map((key) =>
44
+ collectSignalValues(key, getSignal(input.rules, key), items, buildContext(new Map())),
45
+ );
46
+
47
+ const propagated = propagate({
48
+ lines: input.rules.propagation,
49
+ items,
50
+ relations: input.snapshot.relations,
51
+ signals: input.rules.signals,
52
+ values: new Map(carried.map((entry) => [entry.key, entry.values])),
53
+ });
54
+
55
+ const others = [...input.rules.signals]
56
+ .filter(([key]) => !carriedKeys.has(key))
57
+ .map(([key, signal]) =>
58
+ collectSignalValues(key, signal, candidates, buildContext(propagated.values)),
59
+ );
60
+
61
+ const values = new Map([
62
+ ...propagated.values,
63
+ ...others.map((entry): [SignalKey, Map<ItemId, number>] => [entry.key, entry.values]),
64
+ ]);
65
+
66
+ const context = buildContext(values);
67
+ const waivers = [...input.rules.registry.waiver.values()];
68
+ const gated = candidates.map((item) => collectHolds(item, input.rules.gates, waivers, context));
69
+ const filterFields = [...input.rules.registry['filter-field'].values()];
70
+ const fields = candidates.map((item) => collectFieldValues(item, filterFields));
71
+
72
+ return {
73
+ candidates,
74
+ fields: new Map(fields.map((entry) => [entry.item, entry.values])),
75
+ values,
76
+ holds: new Map(
77
+ gated.flatMap((entry) => (entry.holds.length > 0 ? [[entry.item, entry.holds]] : [])),
78
+ ),
79
+ lifted: new Map(
80
+ gated.flatMap((entry) => (entry.lifted.length > 0 ? [[entry.item, entry.lifted]] : [])),
81
+ ),
82
+ faults: [
83
+ ...carried.flatMap((entry) => entry.faults),
84
+ ...propagated.faults,
85
+ ...others.flatMap((entry) => entry.faults),
86
+ ...gated.flatMap((entry) => entry.faults),
87
+ ...fields.flatMap((entry) => entry.faults),
88
+ ],
89
+ };
90
+ }
91
+
92
+ function collectCandidates(items: readonly ItemView[]): FrameItem[] {
93
+ return items.flatMap((item) => {
94
+ if (item.derived.state !== 'ready') {
95
+ return [];
96
+ }
97
+
98
+ const position = item.position;
99
+
100
+ invariant(position.kind === 'phase', `ready item ${item.id} stands at no phase`);
101
+
102
+ return [{ ...item, phase: position.phase, round: position.round }];
103
+ });
104
+ }
105
+
106
+ interface SignalValues {
107
+ readonly key: SignalKey;
108
+ readonly values: Map<ItemId, number>;
109
+ readonly faults: readonly Fault[];
110
+ }
111
+
112
+ function collectSignalValues(
113
+ key: SignalKey,
114
+ signal: Resolved<'signal'>,
115
+ items: readonly ItemView[],
116
+ context: PlanContext,
117
+ ): SignalValues {
118
+ const values = new Map<ItemId, number>();
119
+
120
+ const faults: Fault[] = [];
121
+
122
+ for (const item of items) {
123
+ try {
124
+ const value = signal.open((plugin, params) => plugin.value(item, context, params));
125
+
126
+ if (value !== null) {
127
+ values.set(item.id, value);
128
+ }
129
+ } catch (error) {
130
+ faults.push({
131
+ plugin: signal.name,
132
+ kind: 'signal',
133
+ item: item.id,
134
+ error: formatFault(error),
135
+ });
136
+ }
137
+ }
138
+
139
+ return { key, values, faults };
140
+ }
141
+
142
+ function getSignal(rules: LoadedRules, key: SignalKey): Resolved<'signal'> {
143
+ const signal = rules.signals.get(key);
144
+
145
+ invariant(signal, `the loader resolves the signal of each propagation line, and missed ${key}`);
146
+
147
+ return signal;
148
+ }
149
+
150
+ interface ItemHolds {
151
+ readonly item: ItemId;
152
+ readonly holds: Hold[];
153
+ readonly lifted: Lift[];
154
+ readonly faults: readonly Fault[];
155
+ }
156
+
157
+ function collectHolds(
158
+ item: FrameItem,
159
+ gates: readonly Resolved<'gate'>[],
160
+ waivers: readonly Waiver[],
161
+ context: PlanContext,
162
+ ): ItemHolds {
163
+ const outcomes = gates.map((gate) => pickGateOutcome(item, gate, context));
164
+ const faults = outcomes.flatMap((outcome) => outcome.faults);
165
+ const holds: Hold[] = [];
166
+ const lifted: Lift[] = [];
167
+
168
+ for (const outcome of outcomes) {
169
+ if (outcome.hold !== null) {
170
+ const waived = outcome.liftable
171
+ ? pickLift(item, outcome.hold, waivers, context)
172
+ : { lift: null, faults: [] };
173
+
174
+ faults.push(...waived.faults);
175
+
176
+ if (waived.lift === null) {
177
+ holds.push(outcome.hold);
178
+ } else {
179
+ lifted.push(waived.lift);
180
+ }
181
+ }
182
+ }
183
+
184
+ return { item: item.id, holds, lifted, faults };
185
+ }
186
+
187
+ interface GateOutcome {
188
+ readonly hold: Hold | null;
189
+ readonly liftable: boolean;
190
+ readonly faults: readonly Fault[];
191
+ }
192
+
193
+ function pickGateOutcome(
194
+ item: FrameItem,
195
+ gate: Resolved<'gate'>,
196
+ context: PlanContext,
197
+ ): GateOutcome {
198
+ try {
199
+ return gate.open((plugin, params) => ({
200
+ hold: plugin.hold(item, context, params),
201
+ liftable: plugin.liftable,
202
+ faults: [],
203
+ }));
204
+ } catch (error) {
205
+ const message = formatFault(error);
206
+
207
+ return {
208
+ hold: { gate: gate.name, reason: message },
209
+ liftable: false,
210
+ faults: [{ plugin: gate.name, kind: 'gate', item: item.id, error: message }],
211
+ };
212
+ }
213
+ }
214
+
215
+ interface Waived {
216
+ readonly lift: Lift | null;
217
+ readonly faults: readonly Fault[];
218
+ }
219
+
220
+ function pickLift(
221
+ item: FrameItem,
222
+ hold: Hold,
223
+ waivers: readonly Waiver[],
224
+ context: PlanContext,
225
+ ): Waived {
226
+ const faults: Fault[] = [];
227
+
228
+ for (const waiver of waivers.filter((candidate) => candidate.lifts === hold.gate)) {
229
+ try {
230
+ const lift = waiver.lift(item, hold, context);
231
+
232
+ if (lift !== null) {
233
+ return { lift, faults };
234
+ }
235
+ } catch (error) {
236
+ faults.push({
237
+ plugin: waiver.name,
238
+ kind: 'waiver',
239
+ item: item.id,
240
+ error: formatFault(error),
241
+ });
242
+ }
243
+ }
244
+
245
+ return { lift: null, faults };
246
+ }
247
+
248
+ interface FieldValues {
249
+ readonly item: ItemId;
250
+ readonly values: Readonly<Record<string, readonly string[]>>;
251
+ readonly faults: readonly Fault[];
252
+ }
253
+
254
+ function collectFieldValues(item: FrameItem, fields: readonly FilterField[]): FieldValues {
255
+ const values: Record<string, readonly string[]> = {};
256
+ const faults: Fault[] = [];
257
+
258
+ for (const field of fields) {
259
+ try {
260
+ const value = field.value(item);
261
+
262
+ if (value !== null) {
263
+ values[field.name] = typeof value === 'string' ? [value] : value;
264
+ }
265
+ } catch (error) {
266
+ faults.push({
267
+ plugin: field.name,
268
+ kind: 'filter-field',
269
+ item: item.id,
270
+ error: formatFault(error),
271
+ });
272
+ }
273
+ }
274
+
275
+ return { item: item.id, values, faults };
276
+ }
@@ -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 defer: 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 expedite: 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,23 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ import { defineSignal } from '@boxxy/model';
3
+ import { z } from 'zod';
4
+
5
+ export const pool: Concept = {
6
+ name: 'pool',
7
+ version: 2,
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
+ // an item that sets no design mode runs in agent mode
21
+ { name: 'designMode', value: (item) => item.fields.designMode ?? 'agent' },
22
+ ],
23
+ };
@@ -0,0 +1,6 @@
1
+ import type { Concept } from '@boxxy/model';
2
+ import { defer } from './concepts/defer.ts';
3
+ import { expedite } from './concepts/expedite.ts';
4
+ import { pool } from './concepts/pool.ts';
5
+
6
+ export const concepts: readonly Concept[] = [pool, defer, expedite];
package/src/decide.ts ADDED
@@ -0,0 +1,187 @@
1
+ import type {
2
+ Decision,
3
+ FieldValues,
4
+ Filter,
5
+ Frame,
6
+ FrameItem,
7
+ LimitFill,
8
+ LimitName,
9
+ Pool,
10
+ PoolName,
11
+ Snapshot,
12
+ StartPick,
13
+ Wait,
14
+ WaitReason,
15
+ WakePick,
16
+ } from '@boxxy/model';
17
+ import { compareItemIDs } from './compare-item-ids.ts';
18
+
19
+ interface DecideInput {
20
+ readonly frame: Frame;
21
+ readonly pools: readonly Pool[];
22
+ readonly snapshot: Pick<Snapshot, 'limits' | 'wakes'>;
23
+
24
+ // the pools whose capacity or usage-limit backoff runs at the claim's time
25
+ readonly backoff: readonly PoolName[];
26
+ }
27
+
28
+ export function decide(input: DecideInput): Decision {
29
+ const seats = buildSeats(input.snapshot.limits);
30
+
31
+ const backoff = new Set(input.backoff);
32
+
33
+ const picks: (StartPick | WakePick)[] = [];
34
+ const waits: Wait[] = [];
35
+
36
+ for (const wake of input.snapshot.wakes) {
37
+ const subject = { kind: 'wake', attempt: wake.attempt } as const;
38
+ const taken = backoff.has(wake.pool) ? backoffOf(wake.pool) : seats.take(wake.fields);
39
+
40
+ if (taken.kind === 'taken') {
41
+ picks.push({ kind: 'wake', attempt: wake.attempt, limits: taken.limits });
42
+ } else {
43
+ waits.push({ subject, reason: taken.reason });
44
+ }
45
+ }
46
+
47
+ const offered = collectOffers(input.frame, input.pools);
48
+
49
+ waits.push(...offered.unpooled);
50
+
51
+ for (const offer of offered.offers) {
52
+ const subject = { kind: 'item', item: offer.item.id } as const;
53
+ const taken = backoff.has(offer.pool) ? backoffOf(offer.pool) : seats.take(offer.fields);
54
+
55
+ if (taken.kind === 'taken') {
56
+ picks.push({
57
+ kind: 'start',
58
+ item: offer.item.id,
59
+ phase: offer.item.phase,
60
+ round: offer.item.round,
61
+ pool: offer.pool,
62
+ fields: offer.fields,
63
+ limits: taken.limits,
64
+ });
65
+ } else {
66
+ waits.push({ subject, reason: taken.reason });
67
+ }
68
+ }
69
+
70
+ return { picks, waits };
71
+ }
72
+
73
+ type SeatTaking =
74
+ | { readonly kind: 'taken'; readonly limits: readonly LimitName[] }
75
+ | { readonly kind: 'refused'; readonly reason: WaitReason };
76
+
77
+ interface Seats {
78
+ take: (fields: FieldValues) => SeatTaking;
79
+ }
80
+
81
+ // The free seats of each limit, counted down by each pick of this decision. A limit held over its
82
+ // max after a wake over the cap has fewer than none.
83
+ function buildSeats(limits: readonly LimitFill[]): Seats {
84
+ const free = new Map(limits.map((limit) => [limit.name, limit.max - limit.held]));
85
+
86
+ return {
87
+ take: (fields) => {
88
+ const counting = limits.filter((limit) => isMatch(limit.counts, fields));
89
+ const full = counting.find((limit) => (free.get(limit.name) ?? 0) <= 0);
90
+
91
+ if (full !== undefined) {
92
+ return { kind: 'refused', reason: { kind: 'limit-full', limit: full.name } };
93
+ }
94
+
95
+ for (const limit of counting) {
96
+ free.set(limit.name, (free.get(limit.name) ?? 0) - 1);
97
+ }
98
+
99
+ return { kind: 'taken', limits: counting.map((limit) => limit.name) };
100
+ },
101
+ };
102
+ }
103
+
104
+ // An item matches a filter when, for every field, one of its values is in the filter's list.
105
+ function isMatch(filter: Filter, fields: FieldValues): boolean {
106
+ return Object.entries(filter).every(([field, accepted]) =>
107
+ (fields[field] ?? []).some((value) => accepted.includes(value)),
108
+ );
109
+ }
110
+
111
+ function backoffOf(pool: PoolName): SeatTaking {
112
+ return { kind: 'refused', reason: { kind: 'backoff', pool } };
113
+ }
114
+
115
+ interface Offer {
116
+ readonly item: FrameItem;
117
+ readonly pool: PoolName;
118
+ readonly fields: FieldValues;
119
+ }
120
+
121
+ interface Offers {
122
+ // in pool order, then by each pool's `order`
123
+ readonly offers: readonly Offer[];
124
+ readonly unpooled: readonly Wait[];
125
+ }
126
+
127
+ // Each candidate that no gate holds belongs to the first pool, in declared order, that takes it.
128
+ function collectOffers(frame: Frame, pools: readonly Pool[]): Offers {
129
+ const free = frame.candidates.filter((item) => (frame.holds.get(item.id) ?? []).length === 0);
130
+
131
+ const byPool = new Map<PoolName, FrameItem[]>(pools.map((pool) => [pool.name, []]));
132
+
133
+ const unpooled: Wait[] = [];
134
+
135
+ for (const item of free) {
136
+ const fields = frame.fields.get(item.id) ?? {};
137
+ const pool = pools.find((candidate) => isMatch(candidate.takes, fields));
138
+
139
+ if (pool === undefined) {
140
+ unpooled.push({ subject: { kind: 'item', item: item.id }, reason: { kind: 'no-pool' } });
141
+ } else {
142
+ byPool.get(pool.name)?.push(item);
143
+ }
144
+ }
145
+
146
+ const offers = pools.flatMap((pool) =>
147
+ (byPool.get(pool.name) ?? [])
148
+ .toSorted((left, right) => compareCandidates(left, right, pool, frame.values))
149
+ .map((item) => ({ item, pool: pool.name, fields: frame.fields.get(item.id) ?? {} })),
150
+ );
151
+
152
+ return { offers, unpooled };
153
+ }
154
+
155
+ function compareCandidates(
156
+ left: FrameItem,
157
+ right: FrameItem,
158
+ pool: Pool,
159
+ values: Frame['values'],
160
+ ): number {
161
+ for (const key of pool.order) {
162
+ const byItem = values.get(key);
163
+ const order = compareValues(byItem?.get(left.id), byItem?.get(right.id));
164
+
165
+ if (order !== 0) {
166
+ return order;
167
+ }
168
+ }
169
+
170
+ return compareItemIDs(left.id, right.id);
171
+ }
172
+
173
+ function compareValues(left: number | undefined, right: number | undefined): number {
174
+ if (left === right) {
175
+ return 0;
176
+ }
177
+
178
+ if (left === undefined) {
179
+ return 1;
180
+ }
181
+
182
+ if (right === undefined) {
183
+ return -1;
184
+ }
185
+
186
+ return left < right ? -1 : 1;
187
+ }
@@ -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
+ 'cbe173e108ca3b290f7f48ea3a4145b28f44611ae7f4a43d29430f08b88f46df',
12
+ ),
13
+ },
14
+ ],
15
+ [
16
+ 'expedite',
17
+ {
18
+ ref: 'expedite@1',
19
+ sourceHash: contentHashSchema.parse(
20
+ 'f75e37b479e49da4e68aab6f3d8e9960b04608eb7a9fdd53d4349578e1c213d3',
21
+ ),
22
+ },
23
+ ],
24
+ [
25
+ 'pool',
26
+ {
27
+ ref: 'pool@2',
28
+ sourceHash: contentHashSchema.parse(
29
+ '1631a22966bd50fa39a8acc79f242fb0ac6603ead15b8f4ca3907f5e2cdd1214',
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,132 @@
1
+ import type {
2
+ Fault,
3
+ ItemId,
4
+ ItemView,
5
+ PropagationLine,
6
+ RelationRow,
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 ItemView[];
18
+ readonly relations: readonly RelationRow[];
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, relations: input.relations }));
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 relations: readonly RelationRow[];
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.relations);
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
+ relations: readonly RelationRow[],
116
+ ): Map<ItemId, ItemId[]> {
117
+ const next = new Map<ItemId, ItemId[]>();
118
+
119
+ for (const relation of relations.filter((candidate) => candidate.type === line.over)) {
120
+ const [from, to] =
121
+ line.toward === 'source'
122
+ ? [relation.target, relation.source]
123
+ : [relation.source, relation.target];
124
+
125
+ const neighbours = next.get(from) ?? [];
126
+
127
+ neighbours.push(to);
128
+ next.set(from, neighbours);
129
+ }
130
+
131
+ return next;
132
+ }
@@ -0,0 +1,77 @@
1
+ import { contentHash } from '@boxxy/content-hash';
2
+ import type { Concept, ConceptPin, JsonValue, LoadedRules } from '@boxxy/model';
3
+ import { buildMockPhaseKind } from '@boxxy/model/test-utils/build-mock-phase-kind';
4
+ import { loadRules } from '@boxxy/rules';
5
+ import invariant from 'tiny-invariant';
6
+ import { concepts as builtInConcepts } from '../concepts.ts';
7
+
8
+ interface MockRulesInput {
9
+ readonly concepts?: readonly Concept[];
10
+ readonly pools?: readonly JsonValue[];
11
+ readonly lead?: readonly JsonValue[];
12
+ readonly propagation?: readonly JsonValue[];
13
+ }
14
+
15
+ export function buildMockLoadedRules(input: MockRulesInput = {}): LoadedRules {
16
+ const concepts = input.concepts ?? builtInConcepts;
17
+
18
+ const file = {
19
+ version: 1,
20
+ concepts: concepts.map((concept) => `${concept.name}@${concept.version}`),
21
+ pipelines: {
22
+ standard: [{ phase: 'build', maxAttempts: 2, timeoutMinutes: 180, on: { built: 'next' } }],
23
+ },
24
+ kinds: { 'core.issue': { pipeline: 'standard', recipes: { build: 'build-v1' } } },
25
+ relationTypes: ['blocks'],
26
+ limits: [{ name: 'workers', max: 7, counts: {} }],
27
+ pools: input.pools ?? [
28
+ { name: 'build', takes: { phase: 'build', kind: ['core.issue'] }, order: ['oldest'] },
29
+ ],
30
+ lead: input.lead ?? ['expedite'],
31
+ propagation: input.propagation ?? [{ value: 'expedite', over: 'blocks', toward: 'source' }],
32
+ recipes: {
33
+ 'build-v1': {
34
+ agent: 'claude',
35
+ model: 'example-model',
36
+ effort: 'high',
37
+ prompt: 'prompts/build-v1.md',
38
+ source: 'subscription',
39
+ skills: [],
40
+ },
41
+ },
42
+ sleep: { afterIdleMinutes: 30, afterQuietMinutes: 60, afterUnattendedMinutes: 240 },
43
+ };
44
+
45
+ const encoder = new TextEncoder();
46
+
47
+ // only modules/rules may import yaml, and JSON is YAML, so the loader reads this as it is
48
+ const rulesBytes = encoder.encode(JSON.stringify(file));
49
+
50
+ const pins = concepts.map((concept): [string, ConceptPin] => [
51
+ concept.name,
52
+ {
53
+ ref: `${concept.name}@${concept.version}`,
54
+ sourceHash: contentHash(`${concept.name} source`),
55
+ },
56
+ ]);
57
+
58
+ const result = loadRules({
59
+ checkout: {
60
+ commit: '3f2a1c0',
61
+ files: new Map([
62
+ ['rules.yaml', rulesBytes],
63
+ ['prompts/build-v1.md', encoder.encode('Build {{title}}.\n')],
64
+ ]),
65
+ },
66
+ concepts,
67
+ pins: new Map(pins),
68
+ phases: [buildMockPhaseKind({ name: 'build', outcomes: ['built'] })],
69
+ });
70
+
71
+ invariant(
72
+ result.kind === 'loaded',
73
+ `the mock rules file failed to load: ${JSON.stringify(result)}`,
74
+ );
75
+
76
+ return result.rules;
77
+ }