@bluewombat/runtime 0.8.0 → 0.10.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.
Files changed (141) hide show
  1. package/README.md +236 -142
  2. package/SPECS.md +138 -97
  3. package/dist/cli.js +35 -37
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config/agent-shorthand.d.ts +24 -4
  6. package/dist/config/agent-shorthand.d.ts.map +1 -1
  7. package/dist/config/agent-shorthand.js +65 -28
  8. package/dist/config/agent-shorthand.js.map +1 -1
  9. package/dist/config/defaults.d.ts +0 -2
  10. package/dist/config/defaults.d.ts.map +1 -1
  11. package/dist/config/defaults.js +0 -2
  12. package/dist/config/defaults.js.map +1 -1
  13. package/dist/config/find-config.d.ts +3 -2
  14. package/dist/config/find-config.d.ts.map +1 -1
  15. package/dist/config/find-config.js +4 -4
  16. package/dist/config/find-config.js.map +1 -1
  17. package/dist/config/home.d.ts +2 -0
  18. package/dist/config/home.d.ts.map +1 -1
  19. package/dist/config/home.js +4 -2
  20. package/dist/config/home.js.map +1 -1
  21. package/dist/config/load-config.d.ts +2 -2
  22. package/dist/config/load-config.d.ts.map +1 -1
  23. package/dist/config/load-config.js +342 -73
  24. package/dist/config/load-config.js.map +1 -1
  25. package/dist/config/parse-args.d.ts +1 -1
  26. package/dist/config/parse-args.d.ts.map +1 -1
  27. package/dist/config/parse-args.js +132 -103
  28. package/dist/config/parse-args.js.map +1 -1
  29. package/dist/config/types.d.ts +142 -38
  30. package/dist/config/types.d.ts.map +1 -1
  31. package/dist/config/types.js +13 -1
  32. package/dist/config/types.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/loop/authority.d.ts +41 -5
  38. package/dist/loop/authority.d.ts.map +1 -1
  39. package/dist/loop/authority.js +68 -24
  40. package/dist/loop/authority.js.map +1 -1
  41. package/dist/loop/context.d.ts +23 -3
  42. package/dist/loop/context.d.ts.map +1 -1
  43. package/dist/loop/deliveries.d.ts +16 -16
  44. package/dist/loop/deliveries.d.ts.map +1 -1
  45. package/dist/loop/deliveries.js +35 -187
  46. package/dist/loop/deliveries.js.map +1 -1
  47. package/dist/loop/describe.d.ts +4 -6
  48. package/dist/loop/describe.d.ts.map +1 -1
  49. package/dist/loop/describe.js +2 -2
  50. package/dist/loop/describe.js.map +1 -1
  51. package/dist/loop/drive.d.ts +11 -7
  52. package/dist/loop/drive.d.ts.map +1 -1
  53. package/dist/loop/drive.js +24 -38
  54. package/dist/loop/drive.js.map +1 -1
  55. package/dist/loop/features.d.ts +23 -0
  56. package/dist/loop/features.d.ts.map +1 -0
  57. package/dist/loop/features.js +834 -0
  58. package/dist/loop/features.js.map +1 -0
  59. package/dist/loop/journal.d.ts +1 -1
  60. package/dist/loop/journal.js +2 -2
  61. package/dist/loop/journal.js.map +1 -1
  62. package/dist/loop/lock.d.ts +1 -1
  63. package/dist/loop/lock.js +1 -1
  64. package/dist/loop/open-host.d.ts.map +1 -1
  65. package/dist/loop/open-host.js +132 -31
  66. package/dist/loop/open-host.js.map +1 -1
  67. package/dist/loop/probe.d.ts +1 -1
  68. package/dist/loop/probe.d.ts.map +1 -1
  69. package/dist/loop/probe.js +51 -22
  70. package/dist/loop/probe.js.map +1 -1
  71. package/dist/loop/process-tree.d.ts +1 -1
  72. package/dist/loop/process-tree.js +1 -1
  73. package/dist/loop/report.d.ts +9 -7
  74. package/dist/loop/report.d.ts.map +1 -1
  75. package/dist/loop/report.js +49 -106
  76. package/dist/loop/report.js.map +1 -1
  77. package/dist/loop/streams.d.ts +5 -5
  78. package/dist/loop/streams.d.ts.map +1 -1
  79. package/dist/loop/streams.js +3 -3
  80. package/dist/loop/streams.js.map +1 -1
  81. package/dist/loop/tick.d.ts +6 -4
  82. package/dist/loop/tick.d.ts.map +1 -1
  83. package/dist/loop/tick.js +50 -78
  84. package/dist/loop/tick.js.map +1 -1
  85. package/dist/loop/trace.d.ts +5 -5
  86. package/dist/loop/trace.d.ts.map +1 -1
  87. package/dist/loop/trace.js +5 -14
  88. package/dist/loop/trace.js.map +1 -1
  89. package/dist/loop/transformers.d.ts +18 -14
  90. package/dist/loop/transformers.d.ts.map +1 -1
  91. package/dist/loop/transformers.js +139 -74
  92. package/dist/loop/transformers.js.map +1 -1
  93. package/dist/loop/warm.d.ts +3 -3
  94. package/dist/loop/warm.js +4 -4
  95. package/dist/loop/warm.js.map +1 -1
  96. package/dist/loop/work-line.d.ts +1 -1
  97. package/dist/operator/agent-plugins.d.ts +11 -0
  98. package/dist/operator/agent-plugins.d.ts.map +1 -1
  99. package/dist/operator/agent-plugins.js +17 -11
  100. package/dist/operator/agent-plugins.js.map +1 -1
  101. package/dist/operator/board.d.ts +3 -8
  102. package/dist/operator/board.d.ts.map +1 -1
  103. package/dist/operator/board.js +2 -11
  104. package/dist/operator/board.js.map +1 -1
  105. package/dist/operator/cancel.d.ts +2 -2
  106. package/dist/operator/cancel.js +6 -6
  107. package/dist/operator/cancel.js.map +1 -1
  108. package/dist/operator/doctor.d.ts +9 -0
  109. package/dist/operator/doctor.d.ts.map +1 -1
  110. package/dist/operator/doctor.js +110 -53
  111. package/dist/operator/doctor.js.map +1 -1
  112. package/dist/operator/explain.d.ts +76 -0
  113. package/dist/operator/explain.d.ts.map +1 -0
  114. package/dist/operator/explain.js +585 -0
  115. package/dist/operator/explain.js.map +1 -0
  116. package/dist/operator/film.d.ts +3 -11
  117. package/dist/operator/film.d.ts.map +1 -1
  118. package/dist/operator/film.js +8 -93
  119. package/dist/operator/film.js.map +1 -1
  120. package/dist/operator/init.d.ts.map +1 -1
  121. package/dist/operator/init.js +52 -55
  122. package/dist/operator/init.js.map +1 -1
  123. package/dist/operator/live.js +1 -1
  124. package/dist/operator/live.js.map +1 -1
  125. package/dist/operator/log.js +12 -6
  126. package/dist/operator/log.js.map +1 -1
  127. package/dist/operator/release.d.ts +2 -2
  128. package/dist/operator/release.js +7 -7
  129. package/dist/operator/release.js.map +1 -1
  130. package/dist/operator/run.d.ts +25 -0
  131. package/dist/operator/run.d.ts.map +1 -0
  132. package/dist/operator/run.js +70 -0
  133. package/dist/operator/run.js.map +1 -0
  134. package/dist/operator/setup.d.ts.map +1 -1
  135. package/dist/operator/setup.js +22 -16
  136. package/dist/operator/setup.js.map +1 -1
  137. package/dist/plugins/manager.d.ts +33 -2
  138. package/dist/plugins/manager.d.ts.map +1 -1
  139. package/dist/plugins/manager.js +67 -0
  140. package/dist/plugins/manager.js.map +1 -1
  141. package/package.json +15 -16
@@ -2,29 +2,44 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  import { expandAgentPass, readAgents, SHORT_PASS_KEYS, } from "./agent-shorthand.js";
5
+ import { SKIP_WHEN } from "./types.js";
5
6
  const KNOWN_KEYS = new Set([
6
- "manager",
7
- "managerOptions",
7
+ "featureTracker",
8
+ "ticketTracker",
8
9
  "home",
9
10
  "workLine",
10
11
  "workspaceRoot",
11
12
  "ledger",
12
13
  "persist",
13
14
  "agents",
14
- "planner",
15
15
  "builder",
16
- "assembly",
16
+ "ticket",
17
+ "feature",
17
18
  "authority",
18
19
  "observability",
19
20
  "timeoutMs",
20
- "maxRefusals",
21
21
  "pollIntervalMs",
22
22
  ]);
23
- const WORK_LINE_KEYS = new Set(["stable", "branch", "isolation", "isolationOptions", "warm"]);
23
+ const WORK_LINE_KEYS = new Set([
24
+ "stable",
25
+ "targetStable",
26
+ "branch",
27
+ "isolation",
28
+ "isolationOptions",
29
+ "warm",
30
+ ]);
24
31
  const PASS_KEYS = new Set(["cmd", "timeoutMs"]);
25
32
  const GATED_PASS_KEYS = new Set(["cmd", "timeoutMs", "gates"]);
26
- const STAGE_KEYS = new Set(["producer", "repair", "maxAttempts"]);
27
- const ASSEMBLY_KEYS = new Set(["fix", "validate", "maxAttempts", "gates"]);
33
+ const STAGE_KEYS = new Set(["tests", "e2e", "producer", "repair", "gates", "maxAttempts"]);
34
+ const TICKET_KEYS = new Set([
35
+ "maxParallel",
36
+ "maxAttempts",
37
+ "maxRefusals",
38
+ "fix",
39
+ "validate",
40
+ "gates",
41
+ ]);
42
+ const FEATURE_KEYS = new Set(["maxAttempts", "maxRefusals", "fix", "validate", "gates"]);
28
43
  const GATES_KEYS = new Set(["defaultTimeoutMs", "gates"]);
29
44
  const AUTHORITY_KEYS = new Set(["enabled", "publish", "refresh", "describe"]);
30
45
  const OBSERVABILITY_KEYS = new Set(["streams"]);
@@ -62,19 +77,14 @@ export function loadConfig(path) {
62
77
  }
63
78
  const configDir = dirname(file);
64
79
  const invocation = { configDir };
65
- const manager = stringField(object, "manager");
66
- if (manager.ok === false) {
67
- return manager;
68
- }
69
- if (manager.value !== undefined) {
70
- invocation.manager = manager.value;
71
- }
72
- if (object.managerOptions !== undefined) {
73
- const options = object.managerOptions;
74
- if (options === null || typeof options !== "object" || Array.isArray(options)) {
75
- return { ok: false, reason: 'Config "managerOptions" must be a YAML mapping.' };
80
+ for (const side of ["featureTracker", "ticketTracker"]) {
81
+ const spec = readManagerSpec(object[side], side);
82
+ if (spec.ok === false) {
83
+ return spec;
84
+ }
85
+ if (spec.value !== undefined) {
86
+ invocation[side] = spec.value;
76
87
  }
77
- invocation.managerOptions = { ...options };
78
88
  }
79
89
  const home = stringField(object, "home");
80
90
  if (home.ok === false) {
@@ -91,6 +101,9 @@ export function loadConfig(path) {
91
101
  if (workLine.stable !== undefined) {
92
102
  invocation.workLineStable = workLine.stable;
93
103
  }
104
+ if (workLine.targetStable !== undefined) {
105
+ invocation.workLineTargetStable = workLine.targetStable;
106
+ }
94
107
  if (workLine.branch !== undefined) {
95
108
  invocation.workLineBranch = workLine.branch;
96
109
  }
@@ -133,13 +146,6 @@ export function loadConfig(path) {
133
146
  }
134
147
  agents = read.value;
135
148
  }
136
- if (object.planner !== undefined) {
137
- const planner = readGatedPass(object.planner, configDir, "planner", agents);
138
- if (planner.ok === false) {
139
- return planner;
140
- }
141
- invocation.planner = planner.value;
142
- }
143
149
  if (object.builder !== undefined) {
144
150
  const builder = readBuilder(object.builder, configDir, agents);
145
151
  if (builder.ok === false) {
@@ -147,12 +153,19 @@ export function loadConfig(path) {
147
153
  }
148
154
  invocation.builder = builder.value;
149
155
  }
150
- if (object.assembly !== undefined) {
151
- const assembly = readAssembly(object.assembly, configDir, agents);
152
- if (assembly.ok === false) {
153
- return assembly;
156
+ if (object.ticket !== undefined) {
157
+ const ticket = readTicket(object.ticket, configDir, agents);
158
+ if (ticket.ok === false) {
159
+ return ticket;
160
+ }
161
+ invocation.ticket = ticket.value;
162
+ }
163
+ if (object.feature !== undefined) {
164
+ const feature = readFeature(object.feature, configDir, agents);
165
+ if (feature.ok === false) {
166
+ return feature;
154
167
  }
155
- invocation.assembly = assembly.value;
168
+ invocation.feature = feature.value;
156
169
  }
157
170
  if (object.authority !== undefined) {
158
171
  const authority = readAuthority(object.authority, configDir, agents);
@@ -175,15 +188,6 @@ export function loadConfig(path) {
175
188
  if (timeoutMs.value !== undefined) {
176
189
  invocation.timeoutMs = timeoutMs.value;
177
190
  }
178
- for (const key of ["maxRefusals"]) {
179
- const read = positiveIntField(object, key);
180
- if (read.ok === false) {
181
- return read;
182
- }
183
- if (read.value !== undefined) {
184
- invocation[key] = read.value;
185
- }
186
- }
187
191
  const pollIntervalMs = positiveIntField(object, "pollIntervalMs");
188
192
  if (pollIntervalMs.ok === false) {
189
193
  return pollIntervalMs;
@@ -191,8 +195,79 @@ export function loadConfig(path) {
191
195
  if (pollIntervalMs.value !== undefined) {
192
196
  invocation.pollIntervalMs = pollIntervalMs.value;
193
197
  }
198
+ const e2eRefusal = checkE2eDeclaration(invocation);
199
+ if (e2eRefusal !== undefined) {
200
+ return { ok: false, reason: e2eRefusal };
201
+ }
194
202
  return { ok: true, invocation };
195
203
  }
204
+ /**
205
+ * The e2e check is declared by a pair: `builder.e2e`, which makes the Ticket that holds the
206
+ * suite, and a Feature Gate with `skipWhen: skip-test-e2e`, which runs it. One without the other
207
+ * is a check that never runs or a Ticket nobody judges, so neither is accepted alone. A `skipWhen`
208
+ * outside a Feature's Gates has nothing to read.
209
+ */
210
+ function checkE2eDeclaration(invocation) {
211
+ const outside = [
212
+ ["builder.gates", invocation.builder?.producer.gates],
213
+ ["builder.tests.gates", invocation.builder?.tests?.gates],
214
+ ["builder.e2e.gates", invocation.builder?.e2e?.gates],
215
+ ["ticket.gates", invocation.ticket?.gates],
216
+ ["ticket.fix.gates", invocation.ticket?.fix?.gates],
217
+ ["ticket.validate.gates", invocation.ticket?.validate?.gates],
218
+ ];
219
+ for (const [key, gates] of outside) {
220
+ const bad = gates?.find((gate) => gate.skipWhen !== undefined);
221
+ if (bad !== undefined) {
222
+ return `Config "${key}" gate "${bad.id}" has "skipWhen", which only a Feature's gates (feature.validate, feature.gates, feature.fix) can have.`;
223
+ }
224
+ }
225
+ const feature = invocation.feature;
226
+ const skippable = [
227
+ ...(feature?.validate.gates ?? []),
228
+ ...(feature?.gates ?? []),
229
+ ...(feature?.fix.gates ?? []),
230
+ ].filter((gate) => gate.skipWhen === "skip-test-e2e");
231
+ const declared = invocation.builder?.e2e !== undefined;
232
+ if (skippable.length > 0 && !declared) {
233
+ return `Config gate "${skippable[0]?.id}" has "skipWhen: skip-test-e2e", so the e2e check is declared: "builder.e2e" is required (the pass that makes the Ticket holding the suite).`;
234
+ }
235
+ if (declared && feature !== undefined && skippable.length === 0) {
236
+ return 'Config "builder.e2e" is declared, but no Feature gate runs the suite. Add a gate to "feature.validate.gates" with "skipWhen: skip-test-e2e".';
237
+ }
238
+ return undefined;
239
+ }
240
+ const MANAGER_SPEC_KEYS = new Set(["package", "options"]);
241
+ function readManagerSpec(value, side) {
242
+ if (value === undefined) {
243
+ return { ok: true, value: undefined };
244
+ }
245
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
246
+ return { ok: false, reason: `Config "${side}" must be a YAML mapping (package, options).` };
247
+ }
248
+ const object = value;
249
+ for (const key of Object.keys(object)) {
250
+ if (!MANAGER_SPEC_KEYS.has(key)) {
251
+ return { ok: false, reason: `Config "${side}" has unknown key "${key}".` };
252
+ }
253
+ }
254
+ const spec = {};
255
+ const name = stringField(object, "package");
256
+ if (name.ok === false) {
257
+ return { ok: false, reason: name.reason.replace('"package"', `"${side}.package"`) };
258
+ }
259
+ if (name.value !== undefined) {
260
+ spec.package = name.value;
261
+ }
262
+ if (object.options !== undefined) {
263
+ const options = object.options;
264
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
265
+ return { ok: false, reason: `Config "${side}.options" must be a YAML mapping.` };
266
+ }
267
+ spec.options = { ...options };
268
+ }
269
+ return { ok: true, value: spec };
270
+ }
196
271
  function stringField(object, key) {
197
272
  const value = object[key];
198
273
  if (value === undefined) {
@@ -239,6 +314,10 @@ function readWorkLine(value, configDir) {
239
314
  if (stable.ok === false) {
240
315
  return { ok: false, reason: `Config "workLine.stable" must be a non-empty string.` };
241
316
  }
317
+ const targetStable = stringField(object, "targetStable");
318
+ if (targetStable.ok === false) {
319
+ return { ok: false, reason: `Config "workLine.targetStable" must be a non-empty string.` };
320
+ }
242
321
  const branch = stringField(object, "branch");
243
322
  if (branch.ok === false) {
244
323
  return { ok: false, reason: `Config "workLine.branch" must be a non-empty string.` };
@@ -268,6 +347,9 @@ function readWorkLine(value, configDir) {
268
347
  return {
269
348
  ok: true,
270
349
  ...(stable.value === undefined ? {} : { stable: resolve(configDir, stable.value) }),
350
+ ...(targetStable.value === undefined
351
+ ? {}
352
+ : { targetStable: resolve(configDir, targetStable.value) }),
271
353
  ...(branch.value === undefined ? {} : { branch: branch.value }),
272
354
  ...(isolation.value === undefined ? {} : { isolation: isolation.value }),
273
355
  ...(isolationOptions === undefined
@@ -410,7 +492,7 @@ function readObservability(value, configDir) {
410
492
  // This system writes here, so it resolves against the config directory —
411
493
  // the same rule as a slot's `--transcript-dir`. Left relative to whatever
412
494
  // a child's working directory happens to be, the films would land inside a
413
- // Task workspace and be folded into the feature.
495
+ // Task workspace and be folded into the ticket.
414
496
  spec.dir = resolve(configDir, fields.dir);
415
497
  }
416
498
  if (fields.keep !== undefined) {
@@ -439,8 +521,9 @@ function readGates(value, configDir, key) {
439
521
  if (fallback.ok === false) {
440
522
  return { ok: false, reason: `Config "${key}.defaultTimeoutMs" must be a positive integer.` };
441
523
  }
524
+ // A bare `gates:` in YAML is a null: an empty list, written the short way.
442
525
  const entries = object.gates;
443
- if (entries === undefined) {
526
+ if (entries === undefined || entries === null) {
444
527
  return { ok: true, value: [] };
445
528
  }
446
529
  if (!Array.isArray(entries)) {
@@ -478,7 +561,19 @@ function readGates(value, configDir, key) {
478
561
  reason: `Config gate "${id}" has no "timeoutMs", and "${key}" names no "defaultTimeoutMs".`,
479
562
  };
480
563
  }
481
- gates.push({ id, argv: resolveArgv(argv, configDir), timeoutMs });
564
+ const skipWhen = entry.skipWhen;
565
+ if (skipWhen !== undefined && !SKIP_WHEN.includes(skipWhen)) {
566
+ return {
567
+ ok: false,
568
+ reason: `Config gate "${id}" has "skipWhen: ${String(skipWhen)}". Known: ${SKIP_WHEN.join(", ")}.`,
569
+ };
570
+ }
571
+ gates.push({
572
+ id,
573
+ argv: resolveArgv(argv, configDir),
574
+ timeoutMs,
575
+ ...(skipWhen === undefined ? {} : { skipWhen: skipWhen }),
576
+ });
482
577
  }
483
578
  return { ok: true, value: gates };
484
579
  }
@@ -487,7 +582,7 @@ function readGates(value, configDir, key) {
487
582
  *
488
583
  * `timeoutMs` is required: without it nothing bounds an agent, and an agent
489
584
  * that never returns is the failure this shape exists to prevent. `repair` and
490
- * `assembly` inherit both the command and the ceiling, so a Project with one
585
+ * `ticket` inherit both the command and the ceiling, so a Project with one
491
586
  * agent writes one command.
492
587
  */
493
588
  function readPass(value, configDir, key) {
@@ -520,12 +615,13 @@ function readPass(value, configDir, key) {
520
615
  };
521
616
  }
522
617
  /**
523
- * A command, and its own Gate sequence — `planner`, `builder.producer`,
524
- * `builder.repair`, `assembly.fix`, `assembly.validate`. Deliberately its own
525
- * ~10 lines rather than a parameterized `readPass`: each reader's error
526
- * messages and key set stay independently legible.
618
+ * A command, and its own Gate sequence — `ticket.fix`, `ticket.validate`.
619
+ * `builder.producer` and `builder.repair` read the same shape with `gated`
620
+ * false: their Gates are the one `builder.gates` list, read by `readBuilder`.
621
+ * Deliberately its own ~10 lines rather than a parameterized `readPass`: each
622
+ * reader's error messages and key set stay independently legible.
527
623
  */
528
- function readGatedPass(value, configDir, key, agents) {
624
+ function readGatedPass(value, configDir, key, agents, gated = true) {
529
625
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
530
626
  return {
531
627
  ok: false,
@@ -535,8 +631,14 @@ function readGatedPass(value, configDir, key, agents) {
535
631
  const object = value;
536
632
  const short = object.agent !== undefined;
537
633
  for (const name of Object.keys(object)) {
538
- const known = GATED_PASS_KEYS.has(name) ||
539
- (short && (SHORT_PASS_KEYS.has(name) || (name === "read" && key === "planner")));
634
+ if (!gated && name === "gates") {
635
+ return {
636
+ ok: false,
637
+ reason: `Config "${key}" has no "gates". The builder's Gates are "builder.gates": ` +
638
+ "one list, judging the producer and the repair alike.",
639
+ };
640
+ }
641
+ const known = GATED_PASS_KEYS.has(name) || (short && SHORT_PASS_KEYS.has(name));
540
642
  if (!known) {
541
643
  return { ok: false, reason: `Config "${key}" has unknown key "${name}".` };
542
644
  }
@@ -577,7 +679,80 @@ function readGatedPass(value, configDir, key, agents) {
577
679
  return { ok: true, value: { cmd, timeoutMs: timeoutMs.value, gates } };
578
680
  }
579
681
  /**
580
- * Making a Subtask: both passes named, neither borrowing the other's command.
682
+ * The pass that writes the tests first: an agent and the Gates that check what
683
+ * it wrote, or `agent: false` and nothing else, which writes none. Gates with
684
+ * no agent would have nothing to check, so they are refused rather than ignored.
685
+ */
686
+ function readTests(value, configDir, agents) {
687
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
688
+ return {
689
+ ok: false,
690
+ reason: 'Config "builder.tests" must be a YAML mapping with an agent (or "cmd") and "timeoutMs", or "agent: false".',
691
+ };
692
+ }
693
+ const object = value;
694
+ if (object.agent !== false) {
695
+ return readGatedPass(value, configDir, "builder.tests", agents, true);
696
+ }
697
+ for (const name of Object.keys(object)) {
698
+ if (name !== "agent" && name !== "gates") {
699
+ return {
700
+ ok: false,
701
+ reason: `Config "builder.tests" has "agent: false", so it has no "${name}": no tests are written first.`,
702
+ };
703
+ }
704
+ }
705
+ const emptyList = Array.isArray(object.gates) && object.gates.length === 0;
706
+ if (object.gates !== undefined && object.gates !== null && !emptyList) {
707
+ const gates = readGates(object.gates, configDir, "builder.tests.gates");
708
+ if (gates.ok === false) {
709
+ return gates;
710
+ }
711
+ if (gates.value.length > 0) {
712
+ return {
713
+ ok: false,
714
+ reason: 'Config "builder.tests" has "agent: false", so its gates would have no tests to check. Remove them, or give it an agent.',
715
+ };
716
+ }
717
+ }
718
+ return { ok: true, value: { agent: false, gates: [] } };
719
+ }
720
+ /**
721
+ * A review: an agent and its Gates, or `agent: false` and the Gates alone.
722
+ * The agent is turned off by saying so; a review with neither an agent nor a
723
+ * Gate would judge nothing, and is refused.
724
+ */
725
+ function readReview(value, configDir, key, agents) {
726
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
727
+ return {
728
+ ok: false,
729
+ reason: `Config "${key}" must be a YAML mapping with an agent (or "cmd") and "timeoutMs", or "agent: false" and "gates".`,
730
+ };
731
+ }
732
+ const object = value;
733
+ if (object.agent !== false) {
734
+ return readGatedPass(value, configDir, key, agents);
735
+ }
736
+ for (const name of Object.keys(object)) {
737
+ if (name !== "agent" && name !== "gates") {
738
+ return {
739
+ ok: false,
740
+ reason: `Config "${key}" has "agent: false", so it has no "${name}": only its "gates" run.`,
741
+ };
742
+ }
743
+ }
744
+ // No Gates either: the review is off, which is how a Project says it.
745
+ if (object.gates === undefined || object.gates === null) {
746
+ return { ok: true, value: { agent: false, gates: [] } };
747
+ }
748
+ const gates = readGates(object.gates, configDir, `${key}.gates`);
749
+ if (gates.ok === false) {
750
+ return gates;
751
+ }
752
+ return { ok: true, value: { agent: false, gates: gates.value } };
753
+ }
754
+ /**
755
+ * Making the unit of work: both passes named, neither borrowing the other's command.
581
756
  */
582
757
  function readBuilder(value, configDir, agents) {
583
758
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -589,23 +764,53 @@ function readBuilder(value, configDir, agents) {
589
764
  return { ok: false, reason: `Config "builder" has unknown key "${name}".` };
590
765
  }
591
766
  }
767
+ if (object.tests === undefined) {
768
+ return {
769
+ ok: false,
770
+ reason: 'Config "builder" needs "tests": the agent that writes the tests before the code. To write none first, say "tests: { agent: false }".',
771
+ };
772
+ }
773
+ const readTestsPass = readTests(object.tests, configDir, agents);
774
+ if (readTestsPass.ok === false) {
775
+ return readTestsPass;
776
+ }
777
+ let e2e;
778
+ if (object.e2e !== undefined) {
779
+ const read = readGatedPass(object.e2e, configDir, "builder.e2e", agents);
780
+ if (read.ok === false) {
781
+ return read;
782
+ }
783
+ e2e = read.value;
784
+ }
592
785
  if (object.producer === undefined) {
593
786
  return { ok: false, reason: 'Config "builder" needs a "producer".' };
594
787
  }
595
788
  if (object.repair === undefined) {
596
789
  return { ok: false, reason: 'Config "builder" needs a "repair".' };
597
790
  }
598
- const readProducer = readGatedPass(object.producer, configDir, "builder.producer", agents);
791
+ const readProducer = readGatedPass(object.producer, configDir, "builder.producer", agents, false);
599
792
  if (readProducer.ok === false) {
600
793
  return readProducer;
601
794
  }
602
- const readRepair = readGatedPass(object.repair, configDir, "builder.repair", agents);
795
+ const readRepair = readGatedPass(object.repair, configDir, "builder.repair", agents, false);
603
796
  if (readRepair.ok === false) {
604
797
  return readRepair;
605
798
  }
799
+ let gates = [];
800
+ if (object.gates !== undefined) {
801
+ const read = readGates(object.gates, configDir, "builder.gates");
802
+ if (read.ok === false) {
803
+ return read;
804
+ }
805
+ gates = read.value;
806
+ }
807
+ // One list, two passes: a repair is judged by exactly what judged the producer,
808
+ // so it cannot undo what a Gate refused. Implementer still takes two sequences.
606
809
  const spec = {
607
- producer: readProducer.value,
608
- repair: readRepair.value,
810
+ tests: readTestsPass.value,
811
+ producer: { ...readProducer.value, gates },
812
+ repair: { ...readRepair.value, gates: [...gates] },
813
+ ...(e2e === undefined ? {} : { e2e }),
609
814
  };
610
815
  const maxAttempts = positiveIntField(object, "maxAttempts");
611
816
  if (maxAttempts.ok === false) {
@@ -617,50 +822,58 @@ function readBuilder(value, configDir, agents) {
617
822
  return { ok: true, value: spec };
618
823
  }
619
824
  /**
620
- * Judging the assembled feature. A command here is a fix of what a judgement
825
+ * Judging the assembled ticket. A command here is a fix of what a judgement
621
826
  * refused, not a second first-pass of the request.
622
827
  */
623
- function readAssembly(value, configDir, agents) {
828
+ function readTicket(value, configDir, agents) {
624
829
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
625
- return { ok: false, reason: 'Config "assembly" must be a YAML mapping.' };
830
+ return { ok: false, reason: 'Config "ticket" must be a YAML mapping.' };
626
831
  }
627
832
  const object = value;
628
833
  for (const name of Object.keys(object)) {
629
834
  if (name === "producer" || name === "repair") {
630
835
  return {
631
836
  ok: false,
632
- reason: `Config "assembly" has no "${name}". The assembled feature is judged ` +
633
- '("assembly.gates"); a refusal is "assembly.fix".',
837
+ reason: `Config "ticket" has no "${name}". The assembled ticket is judged ` +
838
+ '("ticket.gates"); a refusal is "ticket.fix".',
634
839
  };
635
840
  }
636
- if (!ASSEMBLY_KEYS.has(name)) {
637
- return { ok: false, reason: `Config "assembly" has unknown key "${name}".` };
841
+ if (!TICKET_KEYS.has(name)) {
842
+ return { ok: false, reason: `Config "ticket" has unknown key "${name}".` };
638
843
  }
639
844
  }
845
+ if (object.validate === undefined) {
846
+ return {
847
+ ok: false,
848
+ reason: 'Config "ticket" needs a "validate": the review of the assembled ticket. To turn it off, write "validate: { agent: false }".',
849
+ };
850
+ }
640
851
  const spec = { gates: [] };
641
852
  if (object.fix !== undefined) {
642
- const read = readGatedPass(object.fix, configDir, "assembly.fix", agents);
853
+ const read = readGatedPass(object.fix, configDir, "ticket.fix", agents);
643
854
  if (read.ok === false) {
644
855
  return read;
645
856
  }
646
857
  spec.fix = read.value;
647
858
  }
648
859
  if (object.validate !== undefined) {
649
- const read = readGatedPass(object.validate, configDir, "assembly.validate", agents);
860
+ const read = readReview(object.validate, configDir, "ticket.validate", agents);
650
861
  if (read.ok === false) {
651
862
  return read;
652
863
  }
653
864
  spec.validate = read.value;
654
865
  }
655
- const maxAttempts = positiveIntField(object, "maxAttempts");
656
- if (maxAttempts.ok === false) {
657
- return { ok: false, reason: 'Config "assembly.maxAttempts" must be a positive integer.' };
658
- }
659
- if (maxAttempts.value !== undefined) {
660
- spec.maxAttempts = maxAttempts.value;
866
+ for (const key of ["maxParallel", "maxAttempts", "maxRefusals"]) {
867
+ const read = positiveIntField(object, key);
868
+ if (read.ok === false) {
869
+ return { ok: false, reason: `Config "ticket.${key}" must be a positive integer.` };
870
+ }
871
+ if (read.value !== undefined) {
872
+ spec[key] = read.value;
873
+ }
661
874
  }
662
875
  if (object.gates !== undefined) {
663
- const gates = readGates(object.gates, configDir, "assembly.gates");
876
+ const gates = readGates(object.gates, configDir, "ticket.gates");
664
877
  if (gates.ok === false) {
665
878
  return gates;
666
879
  }
@@ -668,6 +881,62 @@ function readAssembly(value, configDir, agents) {
668
881
  }
669
882
  return { ok: true, value: spec };
670
883
  }
884
+ /**
885
+ * Judging a Feature's whole line, and repairing what that judgement refused.
886
+ * Its own gates, fix and budget: nothing is shared with a Ticket's.
887
+ */
888
+ function readFeature(value, configDir, agents) {
889
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
890
+ return { ok: false, reason: 'Config "feature" must be a YAML mapping.' };
891
+ }
892
+ const object = value;
893
+ for (const name of Object.keys(object)) {
894
+ if (!FEATURE_KEYS.has(name)) {
895
+ return { ok: false, reason: `Config "feature" has unknown key "${name}".` };
896
+ }
897
+ }
898
+ if (object.fix === undefined) {
899
+ return {
900
+ ok: false,
901
+ reason: 'Config "feature" needs a "fix": what repairs the line when its gates refuse it. There is no feature without one.',
902
+ };
903
+ }
904
+ if (object.validate === undefined) {
905
+ return {
906
+ ok: false,
907
+ reason: 'Config "feature" needs a "validate": the review of the whole line against the Feature\'s request, whose gates run the project\'s own checks on it.',
908
+ };
909
+ }
910
+ if (object.gates === undefined) {
911
+ return {
912
+ ok: false,
913
+ reason: 'Config "feature" needs "gates": what judges the published line before it is folded (the CI). They are not borrowed from "ticket".',
914
+ };
915
+ }
916
+ const validate = readReview(object.validate, configDir, "feature.validate", agents);
917
+ if (validate.ok === false) {
918
+ return validate;
919
+ }
920
+ const fix = readGatedPass(object.fix, configDir, "feature.fix", agents);
921
+ if (fix.ok === false) {
922
+ return fix;
923
+ }
924
+ const gates = readGates(object.gates, configDir, "feature.gates");
925
+ if (gates.ok === false) {
926
+ return gates;
927
+ }
928
+ const spec = { fix: fix.value, validate: validate.value, gates: gates.value };
929
+ for (const key of ["maxAttempts", "maxRefusals"]) {
930
+ const read = positiveIntField(object, key);
931
+ if (read.ok === false) {
932
+ return { ok: false, reason: `Config "feature.${key}" must be a positive integer.` };
933
+ }
934
+ if (read.value !== undefined) {
935
+ spec[key] = read.value;
936
+ }
937
+ }
938
+ return { ok: true, value: spec };
939
+ }
671
940
  /**
672
941
  * Flags whose value is a path this system writes to.
673
942
  *