@bluewombat/runtime 0.9.0 → 0.11.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 (113) hide show
  1. package/README.md +200 -99
  2. package/SPECS.md +121 -19
  3. package/dist/cli.js +35 -4
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config/agent-shorthand.d.ts +25 -5
  6. package/dist/config/agent-shorthand.d.ts.map +1 -1
  7. package/dist/config/agent-shorthand.js +65 -21
  8. package/dist/config/agent-shorthand.js.map +1 -1
  9. package/dist/config/fixture-yaml.d.ts +8 -0
  10. package/dist/config/fixture-yaml.d.ts.map +1 -0
  11. package/dist/config/fixture-yaml.js +30 -0
  12. package/dist/config/fixture-yaml.js.map +1 -0
  13. package/dist/config/home.d.ts +2 -0
  14. package/dist/config/home.d.ts.map +1 -1
  15. package/dist/config/home.js +2 -0
  16. package/dist/config/home.js.map +1 -1
  17. package/dist/config/load-config.d.ts.map +1 -1
  18. package/dist/config/load-config.js +301 -62
  19. package/dist/config/load-config.js.map +1 -1
  20. package/dist/config/parse-args.d.ts +1 -1
  21. package/dist/config/parse-args.d.ts.map +1 -1
  22. package/dist/config/parse-args.js +84 -58
  23. package/dist/config/parse-args.js.map +1 -1
  24. package/dist/config/types.d.ts +96 -16
  25. package/dist/config/types.d.ts.map +1 -1
  26. package/dist/config/types.js +13 -1
  27. package/dist/config/types.js.map +1 -1
  28. package/dist/index.d.ts +3 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/loop/authority.d.ts +38 -1
  33. package/dist/loop/authority.d.ts.map +1 -1
  34. package/dist/loop/authority.js +41 -2
  35. package/dist/loop/authority.js.map +1 -1
  36. package/dist/loop/context.d.ts +16 -1
  37. package/dist/loop/context.d.ts.map +1 -1
  38. package/dist/loop/drive.d.ts +9 -3
  39. package/dist/loop/drive.d.ts.map +1 -1
  40. package/dist/loop/drive.js +17 -7
  41. package/dist/loop/drive.js.map +1 -1
  42. package/dist/loop/features.d.ts +16 -0
  43. package/dist/loop/features.d.ts.map +1 -1
  44. package/dist/loop/features.js +404 -62
  45. package/dist/loop/features.js.map +1 -1
  46. package/dist/loop/open-host.d.ts +10 -0
  47. package/dist/loop/open-host.d.ts.map +1 -1
  48. package/dist/loop/open-host.js +111 -9
  49. package/dist/loop/open-host.js.map +1 -1
  50. package/dist/loop/report.d.ts.map +1 -1
  51. package/dist/loop/report.js +21 -9
  52. package/dist/loop/report.js.map +1 -1
  53. package/dist/loop/streams.d.ts +3 -0
  54. package/dist/loop/streams.d.ts.map +1 -1
  55. package/dist/loop/streams.js +2 -1
  56. package/dist/loop/streams.js.map +1 -1
  57. package/dist/loop/tick.d.ts +3 -0
  58. package/dist/loop/tick.d.ts.map +1 -1
  59. package/dist/loop/tick.js.map +1 -1
  60. package/dist/loop/trace.d.ts +1 -0
  61. package/dist/loop/trace.d.ts.map +1 -1
  62. package/dist/loop/trace.js +2 -2
  63. package/dist/loop/trace.js.map +1 -1
  64. package/dist/loop/transformers.d.ts +16 -2
  65. package/dist/loop/transformers.d.ts.map +1 -1
  66. package/dist/loop/transformers.js +126 -22
  67. package/dist/loop/transformers.js.map +1 -1
  68. package/dist/loop/warm.js +1 -1
  69. package/dist/loop/warm.js.map +1 -1
  70. package/dist/operator/agent-plugins.d.ts.map +1 -1
  71. package/dist/operator/agent-plugins.js +14 -8
  72. package/dist/operator/agent-plugins.js.map +1 -1
  73. package/dist/operator/board.d.ts +43 -4
  74. package/dist/operator/board.d.ts.map +1 -1
  75. package/dist/operator/board.js +153 -10
  76. package/dist/operator/board.js.map +1 -1
  77. package/dist/operator/doctor.d.ts +1 -1
  78. package/dist/operator/doctor.d.ts.map +1 -1
  79. package/dist/operator/doctor.js +76 -29
  80. package/dist/operator/doctor.js.map +1 -1
  81. package/dist/operator/explain.d.ts.map +1 -1
  82. package/dist/operator/explain.js +36 -12
  83. package/dist/operator/explain.js.map +1 -1
  84. package/dist/operator/film.js +0 -3
  85. package/dist/operator/film.js.map +1 -1
  86. package/dist/operator/incident-dossier.d.ts +154 -0
  87. package/dist/operator/incident-dossier.d.ts.map +1 -0
  88. package/dist/operator/incident-dossier.js +696 -0
  89. package/dist/operator/incident-dossier.js.map +1 -0
  90. package/dist/operator/incident-system.d.ts +48 -0
  91. package/dist/operator/incident-system.d.ts.map +1 -0
  92. package/dist/operator/incident-system.js +152 -0
  93. package/dist/operator/incident-system.js.map +1 -0
  94. package/dist/operator/incident.d.ts +27 -0
  95. package/dist/operator/incident.d.ts.map +1 -0
  96. package/dist/operator/incident.js +220 -0
  97. package/dist/operator/incident.js.map +1 -0
  98. package/dist/operator/init.js +27 -22
  99. package/dist/operator/init.js.map +1 -1
  100. package/dist/operator/live.js +4 -4
  101. package/dist/operator/live.js.map +1 -1
  102. package/dist/operator/log.d.ts +2 -0
  103. package/dist/operator/log.d.ts.map +1 -1
  104. package/dist/operator/log.js +7 -3
  105. package/dist/operator/log.js.map +1 -1
  106. package/dist/operator/restart.d.ts +20 -0
  107. package/dist/operator/restart.d.ts.map +1 -0
  108. package/dist/operator/restart.js +63 -0
  109. package/dist/operator/restart.js.map +1 -0
  110. package/dist/operator/setup.d.ts.map +1 -1
  111. package/dist/operator/setup.js +2 -0
  112. package/dist/operator/setup.js.map +1 -1
  113. package/package.json +14 -14
@@ -11,6 +11,8 @@ export declare const HOME_LAYOUT: {
11
11
  readonly workspaces: "workspaces";
12
12
  readonly ledger: "ledger";
13
13
  readonly workLine: "work-line";
14
+ readonly targetLine: "target-line";
15
+ readonly featureWorkspaces: "feature-workspaces";
14
16
  };
15
17
  export declare function homeOf(configDir: string, home: string | undefined): string;
16
18
  export declare function inHome(home: string, part: keyof typeof HOME_LAYOUT): string;
@@ -1 +1 @@
1
- {"version":3,"file":"home.d.ts","sourceRoot":"","sources":["../../src/config/home.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,aAAkB,CAAC;AAE5C,eAAO,MAAM,WAAW;;;;CAId,CAAC;AAEX,wBAAgB,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAE1E;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,WAAW,GAAG,MAAM,CAE3E"}
1
+ {"version":3,"file":"home.d.ts","sourceRoot":"","sources":["../../src/config/home.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,aAAkB,CAAC;AAE5C,eAAO,MAAM,WAAW;;;;;;CAMd,CAAC;AAEX,wBAAgB,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAE1E;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,WAAW,GAAG,MAAM,CAE3E"}
@@ -13,6 +13,8 @@ export const HOME_LAYOUT = {
13
13
  workspaces: "workspaces",
14
14
  ledger: "ledger",
15
15
  workLine: "work-line",
16
+ targetLine: "target-line",
17
+ featureWorkspaces: "feature-workspaces",
16
18
  };
17
19
  export function homeOf(configDir, home) {
18
20
  return resolve(configDir, home ?? DEFAULT_HOME);
@@ -1 +1 @@
1
- {"version":3,"file":"home.js","sourceRoot":"","sources":["../../src/config/home.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,KAAK,QAAQ,EAAE,CAAC;AAE5C,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,UAAU,EAAE,YAAY;IACxB,MAAM,EAAE,QAAQ;IAChB,QAAQ,EAAE,WAAW;CACb,CAAC;AAEX,MAAM,UAAU,MAAM,CAAC,SAAiB,EAAE,IAAwB;IAChE,OAAO,OAAO,CAAC,SAAS,EAAE,IAAI,IAAI,YAAY,CAAC,CAAC;AAClD,CAAC;AAED,MAAM,UAAU,MAAM,CAAC,IAAY,EAAE,IAA8B;IACjE,OAAO,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC;AACvC,CAAC"}
1
+ {"version":3,"file":"home.js","sourceRoot":"","sources":["../../src/config/home.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,KAAK,QAAQ,EAAE,CAAC;AAE5C,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,UAAU,EAAE,YAAY;IACxB,MAAM,EAAE,QAAQ;IAChB,QAAQ,EAAE,WAAW;IACrB,UAAU,EAAE,aAAa;IACzB,iBAAiB,EAAE,oBAAoB;CAC/B,CAAC;AAEX,MAAM,UAAU,MAAM,CAAC,SAAiB,EAAE,IAAwB;IAChE,OAAO,OAAO,CAAC,SAAS,EAAE,IAAI,IAAI,YAAY,CAAC,CAAC;AAClD,CAAC;AAED,MAAM,UAAU,MAAM,CAAC,IAAY,EAAE,IAA8B;IACjE,OAAO,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC;AACvC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"load-config.d.ts","sourceRoot":"","sources":["../../src/config/load-config.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAIV,SAAS,EAMV,MAAM,YAAY,CAAC;AA+BpB,MAAM,MAAM,YAAY,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,UAAU,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/F;;GAEG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAiKrD"}
1
+ {"version":3,"file":"load-config.d.ts","sourceRoot":"","sources":["../../src/config/load-config.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAIV,SAAS,EASV,MAAM,YAAY,CAAC;AAwDpB,MAAM,MAAM,YAAY,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,UAAU,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/F;;GAEG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAwLrD"}
@@ -2,28 +2,53 @@ 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
- "featureTracker",
7
- "ticketTracker",
8
7
  "home",
9
8
  "workLine",
10
9
  "workspaceRoot",
11
10
  "ledger",
12
11
  "persist",
13
12
  "agents",
14
- "builder",
15
- "assembly",
13
+ "ticket",
14
+ "feature",
16
15
  "authority",
17
16
  "observability",
18
17
  "timeoutMs",
19
- "maxRefusals",
20
18
  "pollIntervalMs",
21
19
  ]);
22
- const WORK_LINE_KEYS = new Set(["stable", "branch", "isolation", "isolationOptions", "warm"]);
20
+ const WORK_LINE_KEYS = new Set([
21
+ "stable",
22
+ "targetStable",
23
+ "branch",
24
+ "isolation",
25
+ "isolationOptions",
26
+ "warm",
27
+ ]);
23
28
  const PASS_KEYS = new Set(["cmd", "timeoutMs"]);
24
29
  const GATED_PASS_KEYS = new Set(["cmd", "timeoutMs", "gates"]);
25
- const STAGE_KEYS = new Set(["producer", "repair", "maxAttempts"]);
26
- const ASSEMBLY_KEYS = new Set(["fix", "validate", "maxAttempts", "gates"]);
30
+ const STAGE_KEYS = new Set(["producer", "repair", "gates", "maxAttempts"]);
31
+ const TICKET_KEYS = new Set([
32
+ "tracker",
33
+ "build",
34
+ "maxParallel",
35
+ "maxAttempts",
36
+ "maxRefusals",
37
+ "fix",
38
+ "validate",
39
+ "gates",
40
+ ]);
41
+ const FEATURE_KEYS = new Set([
42
+ "tracker",
43
+ "test",
44
+ "maxAttempts",
45
+ "maxRefusals",
46
+ "fix",
47
+ "validate",
48
+ "gates",
49
+ ]);
50
+ /** Keys of a level that name what it builds or tracks, not how its result is judged. */
51
+ const BUILDING_KEYS = new Set(["tracker", "build", "test"]);
27
52
  const GATES_KEYS = new Set(["defaultTimeoutMs", "gates"]);
28
53
  const AUTHORITY_KEYS = new Set(["enabled", "publish", "refresh", "describe"]);
29
54
  const OBSERVABILITY_KEYS = new Set(["streams"]);
@@ -61,8 +86,15 @@ export function loadConfig(path) {
61
86
  }
62
87
  const configDir = dirname(file);
63
88
  const invocation = { configDir };
64
- for (const side of ["featureTracker", "ticketTracker"]) {
65
- const spec = readManagerSpec(object[side], side);
89
+ for (const [level, side] of [
90
+ ["feature", "featureTracker"],
91
+ ["ticket", "ticketTracker"],
92
+ ]) {
93
+ const block = object[level];
94
+ const raw = block !== null && typeof block === "object" && !Array.isArray(block)
95
+ ? block.tracker
96
+ : undefined;
97
+ const spec = readManagerSpec(raw, `${level}.tracker`);
66
98
  if (spec.ok === false) {
67
99
  return spec;
68
100
  }
@@ -85,6 +117,9 @@ export function loadConfig(path) {
85
117
  if (workLine.stable !== undefined) {
86
118
  invocation.workLineStable = workLine.stable;
87
119
  }
120
+ if (workLine.targetStable !== undefined) {
121
+ invocation.workLineTargetStable = workLine.targetStable;
122
+ }
88
123
  if (workLine.branch !== undefined) {
89
124
  invocation.workLineBranch = workLine.branch;
90
125
  }
@@ -127,19 +162,35 @@ export function loadConfig(path) {
127
162
  }
128
163
  agents = read.value;
129
164
  }
130
- if (object.builder !== undefined) {
131
- const builder = readBuilder(object.builder, configDir, agents);
132
- if (builder.ok === false) {
133
- return builder;
165
+ if (object.ticket !== undefined) {
166
+ const ticket = readTicket(object.ticket, configDir, agents);
167
+ if (ticket.ok === false) {
168
+ return ticket;
169
+ }
170
+ if (ticket.builder !== undefined) {
171
+ invocation.builder = ticket.builder;
172
+ }
173
+ if (ticket.value !== undefined) {
174
+ invocation.ticket = ticket.value;
134
175
  }
135
- invocation.builder = builder.value;
136
176
  }
137
- if (object.assembly !== undefined) {
138
- const assembly = readAssembly(object.assembly, configDir, agents);
139
- if (assembly.ok === false) {
140
- return assembly;
177
+ if (object.feature !== undefined) {
178
+ const feature = readFeature(object.feature, configDir, agents);
179
+ if (feature.ok === false) {
180
+ return feature;
181
+ }
182
+ if (feature.test !== undefined) {
183
+ if (invocation.builder === undefined) {
184
+ return {
185
+ ok: false,
186
+ reason: 'Config "feature.test" needs "ticket.build": the Ticket that holds the suite is made like any other.',
187
+ };
188
+ }
189
+ invocation.builder.featureTest = feature.test;
190
+ }
191
+ if (feature.value !== undefined) {
192
+ invocation.feature = feature.value;
141
193
  }
142
- invocation.assembly = assembly.value;
143
194
  }
144
195
  if (object.authority !== undefined) {
145
196
  const authority = readAuthority(object.authority, configDir, agents);
@@ -162,15 +213,6 @@ export function loadConfig(path) {
162
213
  if (timeoutMs.value !== undefined) {
163
214
  invocation.timeoutMs = timeoutMs.value;
164
215
  }
165
- for (const key of ["maxRefusals"]) {
166
- const read = positiveIntField(object, key);
167
- if (read.ok === false) {
168
- return read;
169
- }
170
- if (read.value !== undefined) {
171
- invocation[key] = read.value;
172
- }
173
- }
174
216
  const pollIntervalMs = positiveIntField(object, "pollIntervalMs");
175
217
  if (pollIntervalMs.ok === false) {
176
218
  return pollIntervalMs;
@@ -178,8 +220,47 @@ export function loadConfig(path) {
178
220
  if (pollIntervalMs.value !== undefined) {
179
221
  invocation.pollIntervalMs = pollIntervalMs.value;
180
222
  }
223
+ const featureTestRefusal = checkFeatureTestDeclaration(invocation);
224
+ if (featureTestRefusal !== undefined) {
225
+ return { ok: false, reason: featureTestRefusal };
226
+ }
181
227
  return { ok: true, invocation };
182
228
  }
229
+ /**
230
+ * The feature-test check is declared by a pair: `feature.test`, which makes the Ticket that holds the
231
+ * suite, and a Feature Gate with `skipWhen: skip-feature-test`, which runs it. One without the other
232
+ * is a check that never runs or a Ticket nobody judges, so neither is accepted alone. A `skipWhen`
233
+ * outside a Feature's Gates has nothing to read.
234
+ */
235
+ function checkFeatureTestDeclaration(invocation) {
236
+ const outside = [
237
+ ["ticket.build.gates", invocation.builder?.producer.gates],
238
+ ["feature.test.gates", invocation.builder?.featureTest?.gates],
239
+ ["ticket.gates", invocation.ticket?.gates],
240
+ ["ticket.fix.gates", invocation.ticket?.fix?.gates],
241
+ ["ticket.validate.gates", invocation.ticket?.validate?.gates],
242
+ ];
243
+ for (const [key, gates] of outside) {
244
+ const bad = gates?.find((gate) => gate.skipWhen !== undefined);
245
+ if (bad !== undefined) {
246
+ return `Config "${key}" gate "${bad.id}" has "skipWhen", which only a Feature's gates (feature.validate, feature.gates, feature.fix) can have.`;
247
+ }
248
+ }
249
+ const feature = invocation.feature;
250
+ const skippable = [
251
+ ...(feature?.validate.gates ?? []),
252
+ ...(feature?.gates ?? []),
253
+ ...(feature?.fix.gates ?? []),
254
+ ].filter((gate) => gate.skipWhen === "skip-feature-test");
255
+ const declared = invocation.builder?.featureTest !== undefined;
256
+ if (skippable.length > 0 && !declared) {
257
+ return `Config gate "${skippable[0]?.id}" has "skipWhen: skip-feature-test", so the feature-test check is declared: "feature.test" is required (the pass that makes the Ticket holding the suite).`;
258
+ }
259
+ if (declared && feature !== undefined && skippable.length === 0) {
260
+ return 'Config "feature.test" is declared, but no Feature gate runs the suite. Add a gate to "feature.validate.gates" with "skipWhen: skip-feature-test".';
261
+ }
262
+ return undefined;
263
+ }
183
264
  const MANAGER_SPEC_KEYS = new Set(["package", "options"]);
184
265
  function readManagerSpec(value, side) {
185
266
  if (value === undefined) {
@@ -257,6 +338,10 @@ function readWorkLine(value, configDir) {
257
338
  if (stable.ok === false) {
258
339
  return { ok: false, reason: `Config "workLine.stable" must be a non-empty string.` };
259
340
  }
341
+ const targetStable = stringField(object, "targetStable");
342
+ if (targetStable.ok === false) {
343
+ return { ok: false, reason: `Config "workLine.targetStable" must be a non-empty string.` };
344
+ }
260
345
  const branch = stringField(object, "branch");
261
346
  if (branch.ok === false) {
262
347
  return { ok: false, reason: `Config "workLine.branch" must be a non-empty string.` };
@@ -286,6 +371,9 @@ function readWorkLine(value, configDir) {
286
371
  return {
287
372
  ok: true,
288
373
  ...(stable.value === undefined ? {} : { stable: resolve(configDir, stable.value) }),
374
+ ...(targetStable.value === undefined
375
+ ? {}
376
+ : { targetStable: resolve(configDir, targetStable.value) }),
289
377
  ...(branch.value === undefined ? {} : { branch: branch.value }),
290
378
  ...(isolation.value === undefined ? {} : { isolation: isolation.value }),
291
379
  ...(isolationOptions === undefined
@@ -457,8 +545,9 @@ function readGates(value, configDir, key) {
457
545
  if (fallback.ok === false) {
458
546
  return { ok: false, reason: `Config "${key}.defaultTimeoutMs" must be a positive integer.` };
459
547
  }
548
+ // A bare `gates:` in YAML is a null: an empty list, written the short way.
460
549
  const entries = object.gates;
461
- if (entries === undefined) {
550
+ if (entries === undefined || entries === null) {
462
551
  return { ok: true, value: [] };
463
552
  }
464
553
  if (!Array.isArray(entries)) {
@@ -496,7 +585,19 @@ function readGates(value, configDir, key) {
496
585
  reason: `Config gate "${id}" has no "timeoutMs", and "${key}" names no "defaultTimeoutMs".`,
497
586
  };
498
587
  }
499
- gates.push({ id, argv: resolveArgv(argv, configDir), timeoutMs });
588
+ const skipWhen = entry.skipWhen;
589
+ if (skipWhen !== undefined && !SKIP_WHEN.includes(skipWhen)) {
590
+ return {
591
+ ok: false,
592
+ reason: `Config gate "${id}" has "skipWhen: ${String(skipWhen)}". Known: ${SKIP_WHEN.join(", ")}.`,
593
+ };
594
+ }
595
+ gates.push({
596
+ id,
597
+ argv: resolveArgv(argv, configDir),
598
+ timeoutMs,
599
+ ...(skipWhen === undefined ? {} : { skipWhen: skipWhen }),
600
+ });
500
601
  }
501
602
  return { ok: true, value: gates };
502
603
  }
@@ -505,7 +606,7 @@ function readGates(value, configDir, key) {
505
606
  *
506
607
  * `timeoutMs` is required: without it nothing bounds an agent, and an agent
507
608
  * that never returns is the failure this shape exists to prevent. `repair` and
508
- * `assembly` inherit both the command and the ceiling, so a Project with one
609
+ * `ticket` inherit both the command and the ceiling, so a Project with one
509
610
  * agent writes one command.
510
611
  */
511
612
  function readPass(value, configDir, key) {
@@ -538,12 +639,13 @@ function readPass(value, configDir, key) {
538
639
  };
539
640
  }
540
641
  /**
541
- * A command, and its own Gate sequence — `builder.producer`,
542
- * `builder.repair`, `assembly.fix`, `assembly.validate`. Deliberately its own
543
- * ~10 lines rather than a parameterized `readPass`: each reader's error
544
- * messages and key set stay independently legible.
642
+ * A command, and its own Gate sequence — `ticket.fix`, `ticket.validate`.
643
+ * `ticket.build.producer` and `ticket.build.repair` read the same shape with `gated`
644
+ * false: their Gates are the one `ticket.build.gates` list, read by `readBuilder`.
645
+ * Deliberately its own ~10 lines rather than a parameterized `readPass`: each
646
+ * reader's error messages and key set stay independently legible.
545
647
  */
546
- function readGatedPass(value, configDir, key, agents) {
648
+ function readGatedPass(value, configDir, key, agents, gated = true) {
547
649
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
548
650
  return {
549
651
  ok: false,
@@ -553,6 +655,13 @@ function readGatedPass(value, configDir, key, agents) {
553
655
  const object = value;
554
656
  const short = object.agent !== undefined;
555
657
  for (const name of Object.keys(object)) {
658
+ if (!gated && name === "gates") {
659
+ return {
660
+ ok: false,
661
+ reason: `Config "${key}" has no "gates". The builder's Gates are "ticket.build.gates": ` +
662
+ "one list, judging the producer and the repair alike.",
663
+ };
664
+ }
556
665
  const known = GATED_PASS_KEYS.has(name) || (short && SHORT_PASS_KEYS.has(name));
557
666
  if (!known) {
558
667
  return { ok: false, reason: `Config "${key}" has unknown key "${name}".` };
@@ -593,40 +702,84 @@ function readGatedPass(value, configDir, key, agents) {
593
702
  }
594
703
  return { ok: true, value: { cmd, timeoutMs: timeoutMs.value, gates } };
595
704
  }
705
+ /**
706
+ * A review: an agent and its Gates, or `agent: false` and the Gates alone.
707
+ * The agent is turned off by saying so; a review with neither an agent nor a
708
+ * Gate would judge nothing, and is refused.
709
+ */
710
+ function readReview(value, configDir, key, agents) {
711
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
712
+ return {
713
+ ok: false,
714
+ reason: `Config "${key}" must be a YAML mapping with an agent (or "cmd") and "timeoutMs", or "agent: false" and "gates".`,
715
+ };
716
+ }
717
+ const object = value;
718
+ if (object.agent !== false) {
719
+ return readGatedPass(value, configDir, key, agents);
720
+ }
721
+ for (const name of Object.keys(object)) {
722
+ if (name !== "agent" && name !== "gates") {
723
+ return {
724
+ ok: false,
725
+ reason: `Config "${key}" has "agent: false", so it has no "${name}": only its "gates" run.`,
726
+ };
727
+ }
728
+ }
729
+ // No Gates either: the review is off, which is how a Project says it.
730
+ if (object.gates === undefined || object.gates === null) {
731
+ return { ok: true, value: { agent: false, gates: [] } };
732
+ }
733
+ const gates = readGates(object.gates, configDir, `${key}.gates`);
734
+ if (gates.ok === false) {
735
+ return gates;
736
+ }
737
+ return { ok: true, value: { agent: false, gates: gates.value } };
738
+ }
596
739
  /**
597
740
  * Making the unit of work: both passes named, neither borrowing the other's command.
598
741
  */
599
742
  function readBuilder(value, configDir, agents) {
600
743
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
601
- return { ok: false, reason: 'Config "builder" must be a YAML mapping.' };
744
+ return { ok: false, reason: 'Config "ticket.build" must be a YAML mapping.' };
602
745
  }
603
746
  const object = value;
604
747
  for (const name of Object.keys(object)) {
605
748
  if (!STAGE_KEYS.has(name)) {
606
- return { ok: false, reason: `Config "builder" has unknown key "${name}".` };
749
+ return { ok: false, reason: `Config "ticket.build" has unknown key "${name}".` };
607
750
  }
608
751
  }
609
752
  if (object.producer === undefined) {
610
- return { ok: false, reason: 'Config "builder" needs a "producer".' };
753
+ return { ok: false, reason: 'Config "ticket.build" needs a "producer".' };
611
754
  }
612
755
  if (object.repair === undefined) {
613
- return { ok: false, reason: 'Config "builder" needs a "repair".' };
756
+ return { ok: false, reason: 'Config "ticket.build" needs a "repair".' };
614
757
  }
615
- const readProducer = readGatedPass(object.producer, configDir, "builder.producer", agents);
758
+ const readProducer = readGatedPass(object.producer, configDir, "ticket.build.producer", agents, false);
616
759
  if (readProducer.ok === false) {
617
760
  return readProducer;
618
761
  }
619
- const readRepair = readGatedPass(object.repair, configDir, "builder.repair", agents);
762
+ const readRepair = readGatedPass(object.repair, configDir, "ticket.build.repair", agents, false);
620
763
  if (readRepair.ok === false) {
621
764
  return readRepair;
622
765
  }
766
+ let gates = [];
767
+ if (object.gates !== undefined) {
768
+ const read = readGates(object.gates, configDir, "ticket.build.gates");
769
+ if (read.ok === false) {
770
+ return read;
771
+ }
772
+ gates = read.value;
773
+ }
774
+ // One list, two passes: a repair is judged by exactly what judged the producer,
775
+ // so it cannot undo what a Gate refused. Implementer still takes two sequences.
623
776
  const spec = {
624
- producer: readProducer.value,
625
- repair: readRepair.value,
777
+ producer: { ...readProducer.value, gates },
778
+ repair: { ...readRepair.value, gates: [...gates] },
626
779
  };
627
780
  const maxAttempts = positiveIntField(object, "maxAttempts");
628
781
  if (maxAttempts.ok === false) {
629
- return { ok: false, reason: 'Config "builder.maxAttempts" must be a positive integer.' };
782
+ return { ok: false, reason: 'Config "ticket.build.maxAttempts" must be a positive integer.' };
630
783
  }
631
784
  if (maxAttempts.value !== undefined) {
632
785
  spec.maxAttempts = maxAttempts.value;
@@ -637,53 +790,139 @@ function readBuilder(value, configDir, agents) {
637
790
  * Judging the assembled ticket. A command here is a fix of what a judgement
638
791
  * refused, not a second first-pass of the request.
639
792
  */
640
- function readAssembly(value, configDir, agents) {
793
+ function readTicket(value, configDir, agents) {
641
794
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
642
- return { ok: false, reason: 'Config "assembly" must be a YAML mapping.' };
795
+ return { ok: false, reason: 'Config "ticket" must be a YAML mapping.' };
643
796
  }
644
797
  const object = value;
645
798
  for (const name of Object.keys(object)) {
646
799
  if (name === "producer" || name === "repair") {
647
800
  return {
648
801
  ok: false,
649
- reason: `Config "assembly" has no "${name}". The assembled ticket is judged ` +
650
- '("assembly.gates"); a refusal is "assembly.fix".',
802
+ reason: `Config "ticket" has no "${name}". The assembled ticket is judged ` +
803
+ '("ticket.gates"); a refusal is "ticket.fix".',
651
804
  };
652
805
  }
653
- if (!ASSEMBLY_KEYS.has(name)) {
654
- return { ok: false, reason: `Config "assembly" has unknown key "${name}".` };
806
+ if (!TICKET_KEYS.has(name)) {
807
+ return { ok: false, reason: `Config "ticket" has unknown key "${name}".` };
808
+ }
809
+ }
810
+ let builder;
811
+ if (object.build !== undefined) {
812
+ const read = readBuilder(object.build, configDir, agents);
813
+ if (read.ok === false) {
814
+ return read;
655
815
  }
816
+ builder = read.value;
817
+ }
818
+ if (Object.keys(object).every((name) => BUILDING_KEYS.has(name))) {
819
+ return { ok: true, ...(builder === undefined ? {} : { builder }) };
820
+ }
821
+ if (object.validate === undefined) {
822
+ return {
823
+ ok: false,
824
+ reason: 'Config "ticket" needs a "validate": the review of the assembled ticket. To turn it off, write "validate: { agent: false }".',
825
+ };
656
826
  }
657
827
  const spec = { gates: [] };
658
828
  if (object.fix !== undefined) {
659
- const read = readGatedPass(object.fix, configDir, "assembly.fix", agents);
829
+ const read = readGatedPass(object.fix, configDir, "ticket.fix", agents);
660
830
  if (read.ok === false) {
661
831
  return read;
662
832
  }
663
833
  spec.fix = read.value;
664
834
  }
665
835
  if (object.validate !== undefined) {
666
- const read = readGatedPass(object.validate, configDir, "assembly.validate", agents);
836
+ const read = readReview(object.validate, configDir, "ticket.validate", agents);
667
837
  if (read.ok === false) {
668
838
  return read;
669
839
  }
670
840
  spec.validate = read.value;
671
841
  }
672
- const maxAttempts = positiveIntField(object, "maxAttempts");
673
- if (maxAttempts.ok === false) {
674
- return { ok: false, reason: 'Config "assembly.maxAttempts" must be a positive integer.' };
675
- }
676
- if (maxAttempts.value !== undefined) {
677
- spec.maxAttempts = maxAttempts.value;
842
+ for (const key of ["maxParallel", "maxAttempts", "maxRefusals"]) {
843
+ const read = positiveIntField(object, key);
844
+ if (read.ok === false) {
845
+ return { ok: false, reason: `Config "ticket.${key}" must be a positive integer.` };
846
+ }
847
+ if (read.value !== undefined) {
848
+ spec[key] = read.value;
849
+ }
678
850
  }
679
851
  if (object.gates !== undefined) {
680
- const gates = readGates(object.gates, configDir, "assembly.gates");
852
+ const gates = readGates(object.gates, configDir, "ticket.gates");
681
853
  if (gates.ok === false) {
682
854
  return gates;
683
855
  }
684
856
  spec.gates = gates.value;
685
857
  }
686
- return { ok: true, value: spec };
858
+ return { ok: true, value: spec, ...(builder === undefined ? {} : { builder }) };
859
+ }
860
+ /**
861
+ * Judging a Feature's whole line, and repairing what that judgement refused.
862
+ * Its own gates, fix and budget: nothing is shared with a Ticket's.
863
+ */
864
+ function readFeature(value, configDir, agents) {
865
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
866
+ return { ok: false, reason: 'Config "feature" must be a YAML mapping.' };
867
+ }
868
+ const object = value;
869
+ for (const name of Object.keys(object)) {
870
+ if (!FEATURE_KEYS.has(name)) {
871
+ return { ok: false, reason: `Config "feature" has unknown key "${name}".` };
872
+ }
873
+ }
874
+ let test;
875
+ if (object.test !== undefined) {
876
+ const read = readGatedPass(object.test, configDir, "feature.test", agents);
877
+ if (read.ok === false) {
878
+ return read;
879
+ }
880
+ test = read.value;
881
+ }
882
+ if (Object.keys(object).every((name) => BUILDING_KEYS.has(name))) {
883
+ return { ok: true, ...(test === undefined ? {} : { test }) };
884
+ }
885
+ if (object.fix === undefined) {
886
+ return {
887
+ ok: false,
888
+ reason: 'Config "feature" needs a "fix": what repairs the line when its gates refuse it. There is no feature without one.',
889
+ };
890
+ }
891
+ if (object.validate === undefined) {
892
+ return {
893
+ ok: false,
894
+ 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.',
895
+ };
896
+ }
897
+ if (object.gates === undefined) {
898
+ return {
899
+ ok: false,
900
+ reason: 'Config "feature" needs "gates": what judges the published line before it is folded (the CI). They are not borrowed from "ticket".',
901
+ };
902
+ }
903
+ const validate = readReview(object.validate, configDir, "feature.validate", agents);
904
+ if (validate.ok === false) {
905
+ return validate;
906
+ }
907
+ const fix = readGatedPass(object.fix, configDir, "feature.fix", agents);
908
+ if (fix.ok === false) {
909
+ return fix;
910
+ }
911
+ const gates = readGates(object.gates, configDir, "feature.gates");
912
+ if (gates.ok === false) {
913
+ return gates;
914
+ }
915
+ const spec = { fix: fix.value, validate: validate.value, gates: gates.value };
916
+ for (const key of ["maxAttempts", "maxRefusals"]) {
917
+ const read = positiveIntField(object, key);
918
+ if (read.ok === false) {
919
+ return { ok: false, reason: `Config "feature.${key}" must be a positive integer.` };
920
+ }
921
+ if (read.value !== undefined) {
922
+ spec[key] = read.value;
923
+ }
924
+ }
925
+ return { ok: true, value: spec, ...(test === undefined ? {} : { test }) };
687
926
  }
688
927
  /**
689
928
  * Flags whose value is a path this system writes to.