@boxxy/rules 0.1.0 → 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.
Files changed (44) hide show
  1. package/README.md +8 -7
  2. package/dist/dry-run/dry-run-rules.d.ts +9 -0
  3. package/dist/index.d.ts +4 -1
  4. package/dist/loading/collect-filter-rejections.d.ts +3 -0
  5. package/dist/loading/collect-recipe-rejections.d.ts +3 -0
  6. package/dist/loading/collect-step-rejections.d.ts +3 -0
  7. package/dist/{load-rules.d.ts → loading/load-rules.d.ts} +2 -1
  8. package/dist/loading/to-filter.d.ts +3 -0
  9. package/dist/parse/rules-file-schema.d.ts +84 -0
  10. package/dist/{build-registry.d.ts → registry/build-registry.d.ts} +1 -1
  11. package/dist/{resolve-plugin.d.ts → registry/resolve-plugin.d.ts} +1 -1
  12. package/dist/{resolve-signal-ref.d.ts → registry/resolve-signal-ref.d.ts} +1 -1
  13. package/dist/test-utils/build-example-load-input.d.ts +11 -0
  14. package/dist/test-utils/load-mock-rules.d.ts +7 -0
  15. package/dist/types.d.ts +12 -1
  16. package/example/prompts/build-v1.md +4 -0
  17. package/example/prompts/design-attended-v1.md +4 -0
  18. package/example/prompts/design-v1.md +4 -0
  19. package/example/prompts/review-v1.md +4 -0
  20. package/example/prompts/triage-v1.md +5 -0
  21. package/example/rules.yaml +110 -0
  22. package/package.json +7 -4
  23. package/src/dry-run/dry-run-rules.ts +53 -0
  24. package/src/index.ts +4 -1
  25. package/src/{build-rejection.ts → loading/build-rejection.ts} +1 -1
  26. package/src/loading/collect-filter-rejections.ts +44 -0
  27. package/src/loading/collect-recipe-rejections.ts +60 -0
  28. package/src/loading/collect-step-rejections.ts +131 -0
  29. package/src/{load-rules.ts → loading/load-rules.ts} +112 -74
  30. package/src/loading/to-filter.ts +12 -0
  31. package/src/parse/rules-file-schema.ts +177 -0
  32. package/src/{build-registry.ts → registry/build-registry.ts} +2 -2
  33. package/src/{resolve-plugin.ts → registry/resolve-plugin.ts} +2 -2
  34. package/src/{resolve-signal-ref.ts → registry/resolve-signal-ref.ts} +2 -2
  35. package/src/test-utils/build-example-load-input.ts +90 -0
  36. package/src/test-utils/load-mock-rules.ts +84 -0
  37. package/src/types.ts +30 -1
  38. package/dist/rules-file-schema.d.ts +0 -49
  39. package/src/rules-file-schema.ts +0 -112
  40. /package/dist/{build-rejection.d.ts → loading/build-rejection.d.ts} +0 -0
  41. /package/dist/{parse-concept-entry.d.ts → parse/parse-concept-entry.d.ts} +0 -0
  42. /package/dist/{parse-rules-file.d.ts → parse/parse-rules-file.d.ts} +0 -0
  43. /package/src/{parse-concept-entry.ts → parse/parse-concept-entry.ts} +0 -0
  44. /package/src/{parse-rules-file.ts → parse/parse-rules-file.ts} +0 -0
@@ -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 CHANGED
@@ -1,4 +1,14 @@
1
- import type { Concept, LoadRejection, Resolved, SignalKey } from '@boxxy/model';
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';
2
12
 
3
13
  export interface Checked<T> {
4
14
  readonly value: T | null;
@@ -14,3 +24,22 @@ export interface PinnedConcept {
14
24
  readonly concept: Concept;
15
25
  readonly entryIndex: number;
16
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
+ }
@@ -1,49 +0,0 @@
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>;
@@ -1,112 +0,0 @@
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
- }