@boxxy/rules 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 +21 -0
- package/README.md +41 -2
- package/dist/constants.d.ts +1 -0
- package/dist/dry-run/dry-run-rules.d.ts +9 -0
- package/dist/index.d.ts +4 -0
- package/dist/loading/build-rejection.d.ts +2 -0
- package/dist/loading/collect-filter-rejections.d.ts +3 -0
- package/dist/loading/collect-recipe-rejections.d.ts +3 -0
- package/dist/loading/collect-step-rejections.d.ts +3 -0
- package/dist/loading/load-rules.d.ts +12 -0
- package/dist/loading/to-filter.d.ts +3 -0
- package/dist/parse/parse-concept-entry.d.ts +5 -0
- package/dist/parse/parse-rules-file.d.ts +11 -0
- package/dist/parse/rules-file-schema.d.ts +84 -0
- package/dist/registry/build-registry.d.ts +6 -0
- package/dist/registry/resolve-plugin.d.ts +4 -0
- package/dist/registry/resolve-signal-ref.d.ts +3 -0
- package/dist/test-utils/build-example-load-input.d.ts +11 -0
- package/dist/test-utils/load-mock-rules.d.ts +7 -0
- package/dist/types.d.ts +24 -0
- package/example/prompts/build-v1.md +4 -0
- package/example/prompts/design-attended-v1.md +4 -0
- package/example/prompts/design-v1.md +4 -0
- package/example/prompts/review-v1.md +4 -0
- package/example/prompts/triage-v1.md +5 -0
- package/example/rules.yaml +110 -0
- package/package.json +45 -4
- package/src/constants.ts +1 -0
- package/src/dry-run/dry-run-rules.ts +53 -0
- package/src/index.ts +4 -0
- package/src/loading/build-rejection.ts +10 -0
- package/src/loading/collect-filter-rejections.ts +44 -0
- package/src/loading/collect-recipe-rejections.ts +60 -0
- package/src/loading/collect-step-rejections.ts +131 -0
- package/src/loading/load-rules.ts +358 -0
- package/src/loading/to-filter.ts +12 -0
- package/src/parse/parse-concept-entry.ts +10 -0
- package/src/parse/parse-rules-file.ts +109 -0
- package/src/parse/rules-file-schema.ts +177 -0
- package/src/registry/build-registry.ts +67 -0
- package/src/registry/resolve-plugin.ts +51 -0
- package/src/registry/resolve-signal-ref.ts +43 -0
- package/src/test-utils/build-example-load-input.ts +90 -0
- package/src/test-utils/load-mock-rules.ts +84 -0
- package/src/types.ts +45 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { Effort } 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 edgeSchema = z.union([
|
|
16
|
+
z.literal('next'),
|
|
17
|
+
z.literal('again'),
|
|
18
|
+
z.strictObject({
|
|
19
|
+
to: brandSchemas.phaseName,
|
|
20
|
+
max: z.int().positive().exactOptional(),
|
|
21
|
+
resume: z.boolean().exactOptional(),
|
|
22
|
+
}),
|
|
23
|
+
z.strictObject({ end: nameSchema }),
|
|
24
|
+
z.strictObject({ hold: brandSchemas.rulesHoldReason }),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// `timeoutMinutes` is optional here so that the loader refuses a step without it by its own reason
|
|
28
|
+
const stepSchema = z.strictObject({
|
|
29
|
+
phase: brandSchemas.phaseName,
|
|
30
|
+
maxAttempts: z.int().positive(),
|
|
31
|
+
timeoutMinutes: z.int().positive().optional(),
|
|
32
|
+
on: z.record(nameSchema, edgeSchema),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const kindSchema = z.strictObject({
|
|
36
|
+
pipeline: brandSchemas.pipelineName,
|
|
37
|
+
|
|
38
|
+
// by phase, and by `<phase>/attended` for an attended session of the phase
|
|
39
|
+
recipes: z.record(nameSchema, brandSchemas.recipeName),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const filterSchema = z.record(nameSchema, z.union([nameSchema, z.array(nameSchema).min(1)]));
|
|
43
|
+
|
|
44
|
+
const limitSchema = z.strictObject({
|
|
45
|
+
name: brandSchemas.limitName,
|
|
46
|
+
max: z.int().positive(),
|
|
47
|
+
counts: filterSchema,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const poolSchema = z.strictObject({
|
|
51
|
+
name: brandSchemas.poolName,
|
|
52
|
+
slots: z.never({ error: 'a pool has no slot count; a limit caps its attempts' }).optional(),
|
|
53
|
+
takes: filterSchema,
|
|
54
|
+
order: z.array(pluginRefSchema),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const efforts = ['low', 'medium', 'high', 'xhigh', 'max'] as const satisfies readonly Effort[];
|
|
58
|
+
|
|
59
|
+
const recipeSchema = z.strictObject({
|
|
60
|
+
agent: nameSchema,
|
|
61
|
+
model: nameSchema,
|
|
62
|
+
effort: z.enum(efforts),
|
|
63
|
+
prompt: nameSchema.refine(isCheckoutPath, {
|
|
64
|
+
message: 'a prompt path runs from the checkout root, with no empty, "." or ".." folder',
|
|
65
|
+
}),
|
|
66
|
+
source: brandSchemas.sourceName,
|
|
67
|
+
skills: z.array(nameSchema),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const skillsPinSchema = z.strictObject({
|
|
71
|
+
source: brandSchemas.repoName,
|
|
72
|
+
ref: brandSchemas.commitSha,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const conceptEntrySchema = z.templateLiteral([nameSchema, '@', z.int().positive()]);
|
|
76
|
+
|
|
77
|
+
const propagationLineSchema = z.strictObject({
|
|
78
|
+
value: nameSchema,
|
|
79
|
+
over: brandSchemas.relationTypeName,
|
|
80
|
+
toward: z.enum(['source', 'target']),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const minutesSchema = z.int().positive();
|
|
84
|
+
|
|
85
|
+
export const rulesFileSchema = z
|
|
86
|
+
.strictObject({
|
|
87
|
+
version: z.literal(1),
|
|
88
|
+
concepts: z.array(conceptEntrySchema),
|
|
89
|
+
pipelines: z.record(brandSchemas.pipelineName, z.array(stepSchema).min(1)),
|
|
90
|
+
kinds: z.record(brandSchemas.itemKindName, kindSchema),
|
|
91
|
+
skills: skillsPinSchema.optional(),
|
|
92
|
+
relationTypes: z.array(brandSchemas.relationTypeName),
|
|
93
|
+
recipes: z.record(brandSchemas.recipeName, recipeSchema),
|
|
94
|
+
limits: z.array(limitSchema),
|
|
95
|
+
pools: z.array(poolSchema),
|
|
96
|
+
lead: z.array(pluginRefSchema).default([]),
|
|
97
|
+
propagation: z.array(propagationLineSchema).default([]),
|
|
98
|
+
sleep: z.strictObject({
|
|
99
|
+
afterIdleMinutes: minutesSchema,
|
|
100
|
+
afterQuietMinutes: minutesSchema,
|
|
101
|
+
afterUnattendedMinutes: minutesSchema,
|
|
102
|
+
}),
|
|
103
|
+
})
|
|
104
|
+
.superRefine((file, ctx) => {
|
|
105
|
+
for (const issue of [
|
|
106
|
+
...collectRepeats(
|
|
107
|
+
file.concepts.map((entry) => parseConceptEntry(entry).name),
|
|
108
|
+
['concepts'],
|
|
109
|
+
[],
|
|
110
|
+
(name) => `the concept ${name} is pinned twice`,
|
|
111
|
+
),
|
|
112
|
+
...collectRepeats(
|
|
113
|
+
file.pools.map((pool) => pool.name),
|
|
114
|
+
['pools'],
|
|
115
|
+
['name'],
|
|
116
|
+
(name) => `the pool name ${name} is used twice`,
|
|
117
|
+
),
|
|
118
|
+
...collectRepeats(
|
|
119
|
+
file.limits.map((limit) => limit.name),
|
|
120
|
+
['limits'],
|
|
121
|
+
['name'],
|
|
122
|
+
(name) => `the limit name ${name} is used twice`,
|
|
123
|
+
),
|
|
124
|
+
...collectRepeats(
|
|
125
|
+
file.relationTypes,
|
|
126
|
+
['relationTypes'],
|
|
127
|
+
[],
|
|
128
|
+
(name) => `the relation type ${name} is listed twice`,
|
|
129
|
+
),
|
|
130
|
+
...Object.entries(file.pipelines).flatMap(([pipeline, steps]) =>
|
|
131
|
+
collectRepeats(
|
|
132
|
+
steps.map((step) => step.phase),
|
|
133
|
+
['pipelines', pipeline],
|
|
134
|
+
['phase'],
|
|
135
|
+
(phase) => `the pipeline ${pipeline} names the phase ${phase} twice`,
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
...Object.entries(file.kinds).flatMap(([kind, rules]) =>
|
|
139
|
+
Object.hasOwn(file.pipelines, rules.pipeline)
|
|
140
|
+
? []
|
|
141
|
+
: [
|
|
142
|
+
{
|
|
143
|
+
path: ['kinds', kind, 'pipeline'],
|
|
144
|
+
message: `the file has no pipeline ${rules.pipeline}`,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
),
|
|
148
|
+
]) {
|
|
149
|
+
ctx.addIssue({ code: 'custom', ...issue });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export type ParsedRulesFile = z.output<typeof rulesFileSchema>;
|
|
154
|
+
|
|
155
|
+
export type ParsedStep = z.output<typeof stepSchema>;
|
|
156
|
+
|
|
157
|
+
function isCheckoutPath(path: string): boolean {
|
|
158
|
+
return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface FileIssue {
|
|
162
|
+
readonly path: PropertyKey[];
|
|
163
|
+
readonly message: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function collectRepeats(
|
|
167
|
+
names: readonly string[],
|
|
168
|
+
listPath: readonly PropertyKey[],
|
|
169
|
+
itemPath: readonly PropertyKey[],
|
|
170
|
+
formatMessage: (name: string) => string,
|
|
171
|
+
): FileIssue[] {
|
|
172
|
+
return names.flatMap((name, index) =>
|
|
173
|
+
names.indexOf(name) === index
|
|
174
|
+
? []
|
|
175
|
+
: [{ path: [...listPath, index, ...itemPath], message: formatMessage(name) }],
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ConceptPin, LoadRejection, Registry } from '@boxxy/model';
|
|
2
|
+
import { buildRejection } from '../loading/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,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 '../loading/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 '../loading/build-rejection.ts';
|
|
6
|
+
import type { Checked, ResolvedSignal } from '../types.ts';
|
|
7
|
+
import { resolvePlugin } from './resolve-plugin.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,90 @@
|
|
|
1
|
+
import { contentHash } from '@boxxy/content-hash';
|
|
2
|
+
import type { Concept, ConceptName, ConceptPin } from '@boxxy/model';
|
|
3
|
+
import { defineGate, defineSignal } from '@boxxy/model';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import buildPrompt from '../../example/prompts/build-v1.md' with { type: 'text' };
|
|
6
|
+
import designAttendedPrompt from '../../example/prompts/design-attended-v1.md' with { type: 'text' };
|
|
7
|
+
import designPrompt from '../../example/prompts/design-v1.md' with { type: 'text' };
|
|
8
|
+
import reviewPrompt from '../../example/prompts/review-v1.md' with { type: 'text' };
|
|
9
|
+
import triagePrompt from '../../example/prompts/triage-v1.md' with { type: 'text' };
|
|
10
|
+
import exampleRules from '../../example/rules.yaml' with { type: 'text' };
|
|
11
|
+
|
|
12
|
+
interface ExampleLoadInput {
|
|
13
|
+
readonly checkout: {
|
|
14
|
+
readonly commit: string;
|
|
15
|
+
readonly files: ReadonlyMap<string, Uint8Array>;
|
|
16
|
+
};
|
|
17
|
+
readonly concepts: readonly Concept[];
|
|
18
|
+
readonly pins: ReadonlyMap<ConceptName, ConceptPin>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The example rules file with its prompts, and stand-ins for the three concepts it pins. The
|
|
22
|
+
// caller adds the phase kinds.
|
|
23
|
+
export function buildExampleLoadInput(): ExampleLoadInput {
|
|
24
|
+
const encoder = new TextEncoder();
|
|
25
|
+
|
|
26
|
+
const prompts = [
|
|
27
|
+
['triage-v1', triagePrompt],
|
|
28
|
+
['design-v1', designPrompt],
|
|
29
|
+
['design-attended-v1', designAttendedPrompt],
|
|
30
|
+
['build-v1', buildPrompt],
|
|
31
|
+
['review-v1', reviewPrompt],
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
checkout: {
|
|
36
|
+
commit: '3f2a1c0',
|
|
37
|
+
files: new Map([
|
|
38
|
+
['rules.yaml', encoder.encode(z.string().parse(exampleRules))],
|
|
39
|
+
...prompts.map(([name, text]): [string, Uint8Array] => [
|
|
40
|
+
`prompts/${name}.md`,
|
|
41
|
+
encoder.encode(z.string().parse(text)),
|
|
42
|
+
]),
|
|
43
|
+
]),
|
|
44
|
+
},
|
|
45
|
+
concepts: buildStandInConcepts(),
|
|
46
|
+
pins: new Map<string, ConceptPin>([
|
|
47
|
+
['pool', { ref: 'pool@2', sourceHash: contentHash('pool source') }],
|
|
48
|
+
['defer', { ref: 'defer@1', sourceHash: contentHash('defer source') }],
|
|
49
|
+
['expedite', { ref: 'expedite@1', sourceHash: contentHash('expedite source') }],
|
|
50
|
+
]),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildStandInConcepts(): Concept[] {
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
name: 'pool',
|
|
58
|
+
version: 2,
|
|
59
|
+
signals: [defineSignal({ name: 'oldest', params: z.object({}), value: () => null })],
|
|
60
|
+
filterFields: [
|
|
61
|
+
{ name: 'phase', value: () => null },
|
|
62
|
+
{ name: 'kind', value: () => null },
|
|
63
|
+
{ name: 'repo', value: () => null },
|
|
64
|
+
{ name: 'designMode', value: () => null },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'defer',
|
|
69
|
+
version: 1,
|
|
70
|
+
gates: [
|
|
71
|
+
defineGate({ name: 'defer', liftable: true, params: z.object({}), hold: () => null }),
|
|
72
|
+
],
|
|
73
|
+
waivers: [{ name: 'expedite-lifts-defer', lifts: 'defer', lift: () => null }],
|
|
74
|
+
levers: ['plan.defer', 'plan.undefer'],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'expedite',
|
|
78
|
+
version: 1,
|
|
79
|
+
signals: [
|
|
80
|
+
defineSignal({
|
|
81
|
+
name: 'expedite',
|
|
82
|
+
params: z.object({}),
|
|
83
|
+
value: () => null,
|
|
84
|
+
combine: (left, right) => Math.min(left, right),
|
|
85
|
+
}),
|
|
86
|
+
],
|
|
87
|
+
levers: ['plan.expedite', 'plan.unexpedite'],
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { JsonValue, LoadedRules } from '@boxxy/model';
|
|
2
|
+
import { buildMockPhaseKind } from '@boxxy/model/test-utils/build-mock-phase-kind';
|
|
3
|
+
import invariant from 'tiny-invariant';
|
|
4
|
+
import { loadRules } from '../loading/load-rules.ts';
|
|
5
|
+
|
|
6
|
+
interface MockRulesInput {
|
|
7
|
+
// the steps of the `standard` pipeline, as the rules file writes them
|
|
8
|
+
readonly steps: readonly JsonValue[];
|
|
9
|
+
|
|
10
|
+
// the item kinds and the pipeline name each uses; one `core.issue` kind on `standard` when left out
|
|
11
|
+
readonly kinds?: Readonly<Record<string, string>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const phaseOutcomes: Readonly<Record<string, readonly string[]>> = {
|
|
15
|
+
triage: ['accepted', 'duplicate', 'declined'],
|
|
16
|
+
build: ['built', 'blocked', 'abandoned'],
|
|
17
|
+
review: ['passed', 'changes-requested'],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// A rules file with the three phases triage, build and review, loaded through the real loader
|
|
21
|
+
export function loadMockRules(input: MockRulesInput): LoadedRules {
|
|
22
|
+
const recipes = Object.fromEntries(
|
|
23
|
+
Object.keys(phaseOutcomes).map((phase) => [phase, `${phase}-v1`]),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const file = {
|
|
27
|
+
version: 1,
|
|
28
|
+
concepts: [],
|
|
29
|
+
pipelines: { standard: input.steps },
|
|
30
|
+
kinds: Object.fromEntries(
|
|
31
|
+
Object.entries(input.kinds ?? { 'core.issue': 'standard' }).map(([kind, pipeline]) => [
|
|
32
|
+
kind,
|
|
33
|
+
{ pipeline, recipes },
|
|
34
|
+
]),
|
|
35
|
+
),
|
|
36
|
+
relationTypes: ['blocks'],
|
|
37
|
+
limits: [{ name: 'workers', max: 7, counts: {} }],
|
|
38
|
+
pools: [],
|
|
39
|
+
recipes: Object.fromEntries(
|
|
40
|
+
Object.keys(phaseOutcomes).map((phase) => [
|
|
41
|
+
`${phase}-v1`,
|
|
42
|
+
{
|
|
43
|
+
agent: 'claude',
|
|
44
|
+
model: 'example-model',
|
|
45
|
+
effort: 'high',
|
|
46
|
+
prompt: `prompts/${phase}-v1.md`,
|
|
47
|
+
source: 'subscription',
|
|
48
|
+
skills: [],
|
|
49
|
+
},
|
|
50
|
+
]),
|
|
51
|
+
),
|
|
52
|
+
sleep: { afterIdleMinutes: 30, afterQuietMinutes: 60, afterUnattendedMinutes: 240 },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const encoder = new TextEncoder();
|
|
56
|
+
|
|
57
|
+
const phases = Object.entries(phaseOutcomes).map(([name, outcomes]) =>
|
|
58
|
+
buildMockPhaseKind({ name, outcomes }),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const promptFiles = Object.keys(phaseOutcomes).map((phase): [string, Uint8Array] => [
|
|
62
|
+
`prompts/${phase}-v1.md`,
|
|
63
|
+
encoder.encode(`Do the ${phase} of {{title}}.\n`),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const rulesFile: [string, Uint8Array] = ['rules.yaml', encoder.encode(JSON.stringify(file))];
|
|
67
|
+
|
|
68
|
+
const result = loadRules({
|
|
69
|
+
checkout: {
|
|
70
|
+
commit: '3f2a1c0',
|
|
71
|
+
files: new Map([rulesFile, ...promptFiles]),
|
|
72
|
+
},
|
|
73
|
+
concepts: [],
|
|
74
|
+
pins: new Map(),
|
|
75
|
+
phases,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
invariant(
|
|
79
|
+
result.kind === 'loaded',
|
|
80
|
+
`the mock rules file failed to load: ${JSON.stringify(result)}`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return result.rules;
|
|
84
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Concept,
|
|
3
|
+
ItemId,
|
|
4
|
+
ItemView,
|
|
5
|
+
LoadRejection,
|
|
6
|
+
Position,
|
|
7
|
+
Resolved,
|
|
8
|
+
RetryLever,
|
|
9
|
+
Settled,
|
|
10
|
+
SignalKey,
|
|
11
|
+
} from '@boxxy/model';
|
|
12
|
+
|
|
13
|
+
export interface Checked<T> {
|
|
14
|
+
readonly value: T | null;
|
|
15
|
+
readonly rejections: readonly LoadRejection[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResolvedSignal {
|
|
19
|
+
readonly key: SignalKey;
|
|
20
|
+
readonly resolved: Resolved<'signal'>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PinnedConcept {
|
|
24
|
+
readonly concept: Concept;
|
|
25
|
+
readonly entryIndex: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RulesDryRunLine {
|
|
29
|
+
readonly item: ItemId;
|
|
30
|
+
|
|
31
|
+
// under the rules the server runs
|
|
32
|
+
readonly now: Position;
|
|
33
|
+
|
|
34
|
+
// under the proposed rules
|
|
35
|
+
readonly then: Position;
|
|
36
|
+
readonly moved: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DryRunItem {
|
|
40
|
+
readonly item: ItemView;
|
|
41
|
+
|
|
42
|
+
// the item's settled attempts, oldest first
|
|
43
|
+
readonly attempts: readonly Settled[];
|
|
44
|
+
readonly retries: readonly RetryLever[];
|
|
45
|
+
}
|