@open-agent-toolkit/cli 0.2.19 → 0.2.21

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 (66) hide show
  1. package/assets/config/dispatch-matrix-recommendation.json +15 -5
  2. package/assets/docs/cli-utilities/configuration.md +10 -5
  3. package/assets/docs/contributing/index.md +1 -0
  4. package/assets/docs/contributing/verifying-cursor-pins.md +282 -0
  5. package/assets/docs/workflows/projects/artifacts.md +1 -1
  6. package/assets/docs/workflows/projects/dispatch-ceiling.md +50 -4
  7. package/assets/docs/workflows/skills/explainer-kit.md +188 -23
  8. package/assets/docs/workflows/skills/index.md +1 -1
  9. package/assets/public-package-versions.json +4 -4
  10. package/assets/skills/explainer-kit/SKILL.md +87 -25
  11. package/assets/skills/explainer-kit/briefs/deep-dive.md +35 -0
  12. package/assets/skills/explainer-kit/briefs/engineer-tour.md +45 -0
  13. package/assets/skills/explainer-kit/briefs/program-recap.md +41 -0
  14. package/assets/skills/explainer-kit/briefs/project-explainer.md +38 -0
  15. package/assets/skills/explainer-kit/briefs/project-page.md +38 -0
  16. package/assets/skills/explainer-kit/briefs/project-recap.md +47 -0
  17. package/assets/skills/explainer-kit/briefs/supporting-diagram.md +28 -0
  18. package/assets/skills/explainer-kit/briefs/walkthrough-deck.md +35 -0
  19. package/assets/skills/explainer-kit/examples/project-recap/content.md +57 -14
  20. package/assets/skills/explainer-kit/examples/project-recap/fact-base.json +104 -0
  21. package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +26 -4
  22. package/assets/skills/explainer-kit/recipes/engineer-tour.json +27 -10
  23. package/assets/skills/explainer-kit/recipes/program-recap.json +35 -11
  24. package/assets/skills/explainer-kit/recipes/project-explainer.json +27 -10
  25. package/assets/skills/explainer-kit/recipes/project-recap.json +43 -11
  26. package/assets/skills/explainer-kit/references/contracts.md +45 -19
  27. package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +41 -0
  28. package/assets/skills/explainer-kit/schemas/author-result.v2.schema.json +52 -0
  29. package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +442 -0
  30. package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +223 -10
  31. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +28 -43
  32. package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +237 -0
  33. package/assets/skills/explainer-kit/scripts/lib/html-safety.mjs +680 -0
  34. package/assets/skills/explainer-kit/scripts/lib/markdown.mjs +414 -0
  35. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +309 -10
  36. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +313 -41
  37. package/assets/skills/explainer-kit/scripts/lib/records.mjs +61 -0
  38. package/assets/skills/explainer-kit/scripts/lib/render.mjs +166 -12
  39. package/assets/skills/explainer-kit/scripts/render-qa.mjs +147 -2
  40. package/assets/skills/explainer-kit/scripts/run.mjs +796 -272
  41. package/assets/skills/explainer-kit/templates/deck-shell.html +25 -5
  42. package/assets/skills/explainer-kit/templates/diagram-shell.html +29 -7
  43. package/assets/skills/explainer-kit/templates/engineer-tour.html +133 -9
  44. package/assets/skills/explainer-kit/templates/house-style.html +82 -0
  45. package/assets/skills/oat-explainer-kit/SKILL.md +16 -9
  46. package/assets/skills/oat-explainer-kit/references/author-callback.md +51 -0
  47. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +10 -8
  48. package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +14 -0
  49. package/assets/skills/oat-explainer-kit/scripts/run.mjs +7 -11
  50. package/assets/skills/oat-project-complete/SKILL.md +18 -2
  51. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  52. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +7 -1
  53. package/assets/skills/oat-wave-execute/SKILL.md +12 -19
  54. package/assets/skills/oat-wave-program/SKILL.md +12 -13
  55. package/assets/skills/subagent-orchestration/SKILL.md +1 -1
  56. package/assets/skills/subagent-orchestration/references/evidence-and-refresh.md +153 -15
  57. package/assets/skills/subagent-orchestration/references/model-selection-principles.md +75 -3
  58. package/assets/skills/subagent-orchestration/references/provider-claude.md +95 -35
  59. package/assets/skills/subagent-orchestration/references/provider-codex.md +36 -10
  60. package/assets/skills/subagent-orchestration/references/provider-cursor.md +91 -15
  61. package/dist/providers/cursor/codec/catalog.d.ts +19 -1
  62. package/dist/providers/cursor/codec/catalog.d.ts.map +1 -1
  63. package/dist/providers/cursor/codec/catalog.js +54 -0
  64. package/package.json +2 -2
  65. package/assets/skills/explainer-kit/schemas/author-request.schema.json +0 -85
  66. package/assets/skills/explainer-kit/schemas/author-result.schema.json +0 -65
@@ -1,11 +1,11 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
 
3
- const RECIPE_SCHEMA_VERSION = 'explainer-kit.recipe/v1';
4
- const RECIPE_ROOT_KEYS = [
5
- 'artifacts',
3
+ const RECIPE_SCHEMA_V2 = 'explainer-kit.recipe/v2';
4
+ const RECIPE_V2_ROOT_KEYS = [
6
5
  'discoveryLimits',
6
+ 'expansion',
7
+ 'floor',
7
8
  'id',
8
- 'requiredNarrative',
9
9
  'schemaVersion',
10
10
  'sourceRoles',
11
11
  'version',
@@ -17,7 +17,25 @@ const SOURCE_ROLE_KEYS = [
17
17
  'required',
18
18
  'role',
19
19
  ];
20
- const ARTIFACT_KEYS = ['id', 'required', 'template', 'type'];
20
+ const FLOOR_KEYS = [
21
+ 'authoring',
22
+ 'briefRef',
23
+ 'id',
24
+ 'required',
25
+ 'requiredNarrative',
26
+ 'template',
27
+ 'type',
28
+ ];
29
+ const EXPANSION_KEYS = ['limits', 'profiles'];
30
+ const PROFILE_REQUIRED_KEYS = [
31
+ 'authoring',
32
+ 'briefRef',
33
+ 'maxCount',
34
+ 'profileId',
35
+ 'type',
36
+ ];
37
+ const PROFILE_KEYS = [...PROFILE_REQUIRED_KEYS, 'shell'];
38
+ const EXPANSION_LIMIT_KEYS = ['maxArtifacts', 'maxPerType'];
21
39
  const DISCOVERY_LIMIT_KEYS = ['consecutiveNoNewFindingsRounds', 'maxRounds'];
22
40
  const SOURCE_KINDS = new Set([
23
41
  'file',
@@ -27,7 +45,15 @@ const SOURCE_KINDS = new Set([
27
45
  'session',
28
46
  'other',
29
47
  ]);
30
- const ARTIFACT_TYPES = new Set(['hub', 'diagram', 'explainer', 'deck']);
48
+ const ARTIFACT_TYPES = new Set([
49
+ 'hub',
50
+ 'diagram',
51
+ 'explainer',
52
+ 'deck',
53
+ 'catalog',
54
+ ]);
55
+ const AUTHORING_TYPES = new Set(['markdown', 'html']);
56
+ const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
31
57
  const RECIPE_FILES = [
32
58
  'project-explainer.json',
33
59
  'project-recap.json',
@@ -55,6 +81,142 @@ export function loadRecipe(id, version) {
55
81
  return structuredClone(recipe);
56
82
  }
57
83
 
84
+ export function recipeFloor(recipe) {
85
+ if (recipe?.schemaVersion === RECIPE_SCHEMA_V2) {
86
+ return structuredClone(recipe.floor);
87
+ }
88
+ throw new TypeError('Recipe has an unsupported schemaVersion');
89
+ }
90
+
91
+ export function recipeExpansion(recipe) {
92
+ if (recipe?.schemaVersion === RECIPE_SCHEMA_V2) {
93
+ return structuredClone(recipe.expansion);
94
+ }
95
+ throw new TypeError('Recipe has an unsupported schemaVersion');
96
+ }
97
+
98
+ export function recipeRequiredNarrative(recipe, artifactId) {
99
+ if (recipe?.schemaVersion === RECIPE_SCHEMA_V2) {
100
+ const artifact = recipe.floor.find(({ id }) => id === artifactId);
101
+ return [...(artifact?.requiredNarrative ?? [])];
102
+ }
103
+ throw new TypeError('Recipe has an unsupported schemaVersion');
104
+ }
105
+
106
+ export function evaluateExpansionProposals(recipe, proposals) {
107
+ const floorIds = new Set(recipeFloor(recipe).map(({ id }) => id));
108
+ const expansion = recipeExpansion(recipe);
109
+ const profiles = new Map(
110
+ expansion.profiles.map((profile) => [profile.profileId, profile]),
111
+ );
112
+ const errors = [];
113
+ const accepted = [];
114
+ const rejected = [];
115
+ const warnings = new Set();
116
+ const seenIds = new Set();
117
+ const acceptedByProfile = new Map();
118
+ const acceptedByType = new Map();
119
+ const maxPerType = new Map(
120
+ Object.entries(expansion.limits.maxPerType ?? {}),
121
+ );
122
+
123
+ if (!Array.isArray(proposals)) {
124
+ return {
125
+ valid: false,
126
+ accepted,
127
+ rejected,
128
+ warnings: [],
129
+ errors: ['Expansion proposals must be an array'],
130
+ };
131
+ }
132
+
133
+ for (const proposal of proposals) {
134
+ if (
135
+ !isObject(proposal) ||
136
+ !hasExactKeys(proposal, ['id', 'profileId', 'rationale'])
137
+ ) {
138
+ errors.push(
139
+ 'Expansion proposals require only id, profileId, and rationale',
140
+ );
141
+ continue;
142
+ }
143
+ if (!SAFE_ID.test(proposal.id)) {
144
+ errors.push(`Unsafe expansion artifact id: ${proposal.id}`);
145
+ continue;
146
+ }
147
+ if (floorIds.has(proposal.id)) {
148
+ errors.push(`Expansion artifact id collides with floor: ${proposal.id}`);
149
+ continue;
150
+ }
151
+ if (seenIds.has(proposal.id)) {
152
+ errors.push(`Duplicate expansion artifact id: ${proposal.id}`);
153
+ continue;
154
+ }
155
+ seenIds.add(proposal.id);
156
+
157
+ const profile = profiles.get(proposal.profileId);
158
+ if (!profile) {
159
+ errors.push(`Unknown expansion profile: ${proposal.profileId}`);
160
+ continue;
161
+ }
162
+ if (
163
+ typeof proposal.rationale !== 'string' ||
164
+ proposal.rationale.length === 0
165
+ ) {
166
+ errors.push(`Expansion proposal ${proposal.id} requires a rationale`);
167
+ continue;
168
+ }
169
+
170
+ const profileCount = acceptedByProfile.get(profile.profileId) ?? 0;
171
+ if (profileCount >= profile.maxCount) {
172
+ rejected.push({
173
+ ...structuredClone(proposal),
174
+ status: 'rejected',
175
+ reason: 'profile-limit',
176
+ });
177
+ warnings.add('expansion-profile-limit-exceeded');
178
+ continue;
179
+ }
180
+ // A declared type cap binds across profiles, so proposals cannot spread
181
+ // over sibling profiles that share one artifact type to evade it.
182
+ const typeCount = acceptedByType.get(profile.type) ?? 0;
183
+ if (maxPerType.has(profile.type) && typeCount >= maxPerType.get(profile.type)) {
184
+ rejected.push({
185
+ ...structuredClone(proposal),
186
+ status: 'rejected',
187
+ reason: 'type-limit',
188
+ });
189
+ warnings.add('expansion-type-limit-exceeded');
190
+ continue;
191
+ }
192
+ if (accepted.length >= expansion.limits.maxArtifacts) {
193
+ rejected.push({
194
+ ...structuredClone(proposal),
195
+ status: 'rejected',
196
+ reason: 'recipe-limit',
197
+ });
198
+ warnings.add('expansion-artifact-limit-exceeded');
199
+ continue;
200
+ }
201
+
202
+ accepted.push({
203
+ ...structuredClone(proposal),
204
+ status: 'accepted',
205
+ profile: structuredClone(profile),
206
+ });
207
+ acceptedByProfile.set(profile.profileId, profileCount + 1);
208
+ acceptedByType.set(profile.type, typeCount + 1);
209
+ }
210
+
211
+ return {
212
+ valid: errors.length === 0,
213
+ accepted,
214
+ rejected,
215
+ warnings: [...warnings],
216
+ errors,
217
+ };
218
+ }
219
+
58
220
  export function validateSourceBindings(recipe, bindings) {
59
221
  const errors = [];
60
222
  if (!Array.isArray(bindings)) {
@@ -100,7 +262,7 @@ export function validateContentModel(recipe, contentModel) {
100
262
  }
101
263
 
102
264
  if (
103
- !recipe.artifacts.some(
265
+ !recipeFloor(recipe).some(
104
266
  (artifact) => artifact.id === contentModel.artifactId,
105
267
  )
106
268
  ) {
@@ -129,11 +291,12 @@ export function validateContentModel(recipe, contentModel) {
129
291
  }
130
292
  }
131
293
 
132
- for (const sectionId of recipe.requiredNarrative) {
294
+ for (const sectionId of recipeRequiredNarrative(
295
+ recipe,
296
+ contentModel.artifactId,
297
+ )) {
133
298
  const count = sectionCounts.get(sectionId) ?? 0;
134
- if (count === 0) {
135
- errors.push(`Missing required narrative section: ${sectionId}`);
136
- } else if (count > 1) {
299
+ if (count > 1) {
137
300
  errors.push(`Duplicate narrative section: ${sectionId}`);
138
301
  }
139
302
  }
@@ -162,19 +325,16 @@ export function shouldStopDiscovery(recipe, findingsByRound) {
162
325
  .every((count) => count === 0);
163
326
  }
164
327
 
165
- function validateRecipe(recipe, file) {
328
+ export function validateRecipe(recipe, file = 'recipe') {
166
329
  assertObject(recipe, `${file} recipe`);
167
- assertExactKeys(recipe, RECIPE_ROOT_KEYS, `${file} recipe`);
168
- assert(
169
- recipe.schemaVersion === RECIPE_SCHEMA_VERSION,
170
- `${file} has unsupported schemaVersion`,
171
- );
330
+ if (recipe.schemaVersion === RECIPE_SCHEMA_V2) {
331
+ assertExactKeys(recipe, RECIPE_V2_ROOT_KEYS, `${file} recipe`);
332
+ validateV2Shape(recipe, file);
333
+ } else {
334
+ assert(false, `${file} has unsupported schemaVersion`);
335
+ }
172
336
  assertNonEmptyString(recipe.id, `${file} id`);
173
337
  assertNonEmptyString(recipe.version, `${file} version`);
174
- assertUniqueNonEmptyStrings(
175
- recipe.requiredNarrative,
176
- `${file} requiredNarrative`,
177
- );
178
338
 
179
339
  assert(
180
340
  Array.isArray(recipe.sourceRoles) && recipe.sourceRoles.length > 0,
@@ -207,38 +367,124 @@ function validateRecipe(recipe, file) {
207
367
  }
208
368
  assertUnique(roleNames, `${file} source role names`);
209
369
 
370
+ validateDiscoveryLimits(recipe.discoveryLimits, file);
371
+ return recipe;
372
+ }
373
+
374
+ function validateV2Shape(recipe, file) {
210
375
  assert(
211
- Array.isArray(recipe.artifacts) && recipe.artifacts.length > 0,
212
- `${file} artifacts must be a non-empty array`,
376
+ Array.isArray(recipe.floor) && recipe.floor.length > 0,
377
+ `${file} floor must be a non-empty array`,
213
378
  );
214
- const artifactIds = [];
215
- for (const artifact of recipe.artifacts) {
216
- assertObject(artifact, `${file} artifact`);
217
- assertExactKeys(artifact, ARTIFACT_KEYS, `${file} artifact`);
218
- assertNonEmptyString(artifact.id, `${file} artifact id`);
219
- artifactIds.push(artifact.id);
379
+ const floorIds = [];
380
+ for (const artifact of recipe.floor) {
381
+ assertObject(artifact, `${file} floor entry`);
382
+ assertExactKeys(artifact, FLOOR_KEYS, `${file} floor entry`);
383
+ assertSafeId(artifact.id, `${file} floor id`);
384
+ floorIds.push(artifact.id);
220
385
  assert(
221
386
  ARTIFACT_TYPES.has(artifact.type),
222
- `${file} artifact has unsupported type`,
387
+ `${file} floor has unsupported type`,
388
+ );
389
+ assert(
390
+ AUTHORING_TYPES.has(artifact.authoring),
391
+ `${file} floor has unsupported authoring`,
392
+ );
393
+ assertNonEmptyString(artifact.template, `${file} floor template`);
394
+ assertNonEmptyString(artifact.briefRef, `${file} floor briefRef`);
395
+ assert(typeof artifact.required === 'boolean', `${file} floor required`);
396
+ assertUniqueNonEmptyStrings(
397
+ artifact.requiredNarrative,
398
+ `${file} floor requiredNarrative`,
399
+ );
400
+ }
401
+ assertUnique(floorIds, `${file} floor ids`);
402
+
403
+ assertObject(recipe.expansion, `${file} expansion`);
404
+ assertExactKeys(recipe.expansion, EXPANSION_KEYS, `${file} expansion`);
405
+ assert(
406
+ Array.isArray(recipe.expansion.profiles),
407
+ `${file} expansion profiles must be an array`,
408
+ );
409
+ const profileIds = [];
410
+ for (const profile of recipe.expansion.profiles) {
411
+ assertObject(profile, `${file} expansion profile`);
412
+ assertAllowedKeys(
413
+ profile,
414
+ PROFILE_KEYS,
415
+ PROFILE_REQUIRED_KEYS,
416
+ `${file} expansion profile`,
417
+ );
418
+ assertSafeId(profile.profileId, `${file} expansion profileId`);
419
+ profileIds.push(profile.profileId);
420
+ assert(
421
+ !floorIds.includes(profile.profileId),
422
+ `${file} expansion profileId collides with floor id`,
423
+ );
424
+ assert(
425
+ ARTIFACT_TYPES.has(profile.type),
426
+ `${file} expansion profile has unsupported type`,
427
+ );
428
+ assert(
429
+ AUTHORING_TYPES.has(profile.authoring),
430
+ `${file} expansion profile has unsupported authoring`,
223
431
  );
224
- assertNonEmptyString(artifact.template, `${file} artifact template`);
225
- assert(typeof artifact.required === 'boolean', `${file} artifact required`);
432
+ assertNonEmptyString(
433
+ profile.briefRef,
434
+ `${file} expansion profile briefRef`,
435
+ );
436
+ assertFiniteCount(profile.maxCount, `${file} expansion profile maxCount`);
437
+ if (profile.authoring === 'html' || 'shell' in profile) {
438
+ assertNonEmptyString(
439
+ profile.shell,
440
+ `${file} expansion profile ${profile.profileId} shell`,
441
+ );
442
+ }
443
+ }
444
+ assertUnique(profileIds, `${file} expansion profileIds`);
445
+
446
+ assertObject(recipe.expansion.limits, `${file} expansion limits`);
447
+ assertAllowedKeys(
448
+ recipe.expansion.limits,
449
+ EXPANSION_LIMIT_KEYS,
450
+ ['maxArtifacts'],
451
+ `${file} expansion limits`,
452
+ );
453
+ assertFiniteCount(
454
+ recipe.expansion.limits.maxArtifacts,
455
+ `${file} expansion maxArtifacts`,
456
+ );
457
+ if ('maxPerType' in recipe.expansion.limits) {
458
+ assertObject(
459
+ recipe.expansion.limits.maxPerType,
460
+ `${file} expansion maxPerType`,
461
+ );
462
+ for (const [type, limit] of Object.entries(
463
+ recipe.expansion.limits.maxPerType,
464
+ )) {
465
+ assert(
466
+ ARTIFACT_TYPES.has(type),
467
+ `${file} expansion maxPerType has unsupported type`,
468
+ );
469
+ assertFiniteCount(limit, `${file} expansion maxPerType ${type}`);
470
+ }
226
471
  }
227
- assertUnique(artifactIds, `${file} artifact ids`);
472
+ }
228
473
 
229
- assertObject(recipe.discoveryLimits, `${file} discoveryLimits`);
474
+ function validateDiscoveryLimits(discoveryLimits, file) {
475
+ assertObject(discoveryLimits, `${file} discoveryLimits`);
230
476
  assertExactKeys(
231
- recipe.discoveryLimits,
477
+ discoveryLimits,
232
478
  DISCOVERY_LIMIT_KEYS,
233
479
  `${file} discoveryLimits`,
234
480
  );
235
481
  assert(
236
- recipe.discoveryLimits.consecutiveNoNewFindingsRounds === 2,
482
+ discoveryLimits.consecutiveNoNewFindingsRounds === 2,
237
483
  `${file} discovery must stop after exactly two no-new-findings rounds`,
238
484
  );
239
485
  assert(
240
- Number.isInteger(recipe.discoveryLimits.maxRounds) &&
241
- recipe.discoveryLimits.maxRounds >= 2,
486
+ Number.isInteger(discoveryLimits.maxRounds) &&
487
+ discoveryLimits.maxRounds >= 2,
242
488
  `${file} discovery maxRounds must be an integer of at least two`,
243
489
  );
244
490
  }
@@ -248,14 +494,28 @@ function assertObject(value, label) {
248
494
  }
249
495
 
250
496
  function assertExactKeys(value, expected, label) {
251
- const actual = Object.keys(value).sort();
497
+ const matches = hasExactKeys(value, expected);
498
+ assert(matches, `${label} has unknown or missing keys`);
499
+ }
500
+
501
+ function assertAllowedKeys(value, allowed, required, label) {
502
+ const actual = Object.keys(value);
252
503
  assert(
253
- actual.length === expected.length &&
254
- actual.every((key, index) => key === expected[index]),
504
+ actual.every((key) => allowed.includes(key)) &&
505
+ required.every((key) => key in value),
255
506
  `${label} has unknown or missing keys`,
256
507
  );
257
508
  }
258
509
 
510
+ function hasExactKeys(value, expected) {
511
+ const actual = Object.keys(value).sort();
512
+ const sortedExpected = [...expected].sort();
513
+ return (
514
+ actual.length === sortedExpected.length &&
515
+ actual.every((key, index) => key === sortedExpected[index])
516
+ );
517
+ }
518
+
259
519
  function assertNonEmptyString(value, label) {
260
520
  assert(
261
521
  typeof value === 'string' && value.length > 0,
@@ -263,6 +523,18 @@ function assertNonEmptyString(value, label) {
263
523
  );
264
524
  }
265
525
 
526
+ function assertSafeId(value, label) {
527
+ assertNonEmptyString(value, label);
528
+ assert(SAFE_ID.test(value), `${label} must be a safe slug`);
529
+ }
530
+
531
+ function assertFiniteCount(value, label) {
532
+ assert(
533
+ Number.isInteger(value) && Number.isFinite(value) && value >= 0,
534
+ `${label} must be a finite non-negative integer`,
535
+ );
536
+ }
537
+
266
538
  function assertUniqueNonEmptyStrings(value, label) {
267
539
  assert(
268
540
  Array.isArray(value) && value.length > 0,
@@ -158,6 +158,67 @@ export async function updateBuildRecord(run, stage) {
158
158
  return record;
159
159
  }
160
160
 
161
+ export async function reopenBuildStages(run, { ids, reason }) {
162
+ assertRun(run);
163
+ if (
164
+ !Array.isArray(ids) ||
165
+ ids.length === 0 ||
166
+ ids.some((id) => !STAGE_IDS.includes(id)) ||
167
+ typeof reason !== 'string' ||
168
+ reason.length === 0
169
+ ) {
170
+ throw new Error('Stage reopen requires supported stage ids and a reason.');
171
+ }
172
+
173
+ const indexes = ids.map((id) => STAGE_IDS.indexOf(id));
174
+ const firstIndex = Math.min(...indexes);
175
+ const lastIndex = Math.max(...indexes);
176
+ if (
177
+ new Set(indexes).size !== indexes.length ||
178
+ indexes.some((index, offset) => index !== firstIndex + offset) ||
179
+ ids.some((id, index) => STAGE_IDS[firstIndex + index] !== id)
180
+ ) {
181
+ throw new Error('Reopened stages must be unique and contiguous in order.');
182
+ }
183
+
184
+ const record = JSON.parse(await readFile(run.buildRecordPath, 'utf8'));
185
+ if (record.runId !== run.runId) {
186
+ throw new Error('Build record does not belong to this run.');
187
+ }
188
+ if (
189
+ !record.stages
190
+ .slice(0, firstIndex)
191
+ .every(({ status }) => SUCCESSFUL_TERMINAL_STATUSES.has(status)) ||
192
+ !record.stages
193
+ .slice(firstIndex, lastIndex + 1)
194
+ .every(({ status }) => SUCCESSFUL_TERMINAL_STATUSES.has(status)) ||
195
+ !record.stages
196
+ .slice(lastIndex + 1)
197
+ .every(({ status }) => status === 'pending')
198
+ ) {
199
+ throw new Error(
200
+ 'Stages may reopen only after successful prerequisites and before downstream work.',
201
+ );
202
+ }
203
+
204
+ const timestamp = new Date().toISOString();
205
+ for (let index = firstIndex; index <= lastIndex; index += 1) {
206
+ const previous = record.stages[index];
207
+ record.stages[index] = {
208
+ id: previous.id,
209
+ status: 'pending',
210
+ outputPaths: [],
211
+ warnings: [...previous.warnings, `stage-reopened:${reason}:${timestamp}`],
212
+ };
213
+ }
214
+ record.outcome = 'incomplete';
215
+ delete record.completedAt;
216
+
217
+ assertValidContract('build-record', record);
218
+ await writeJsonAtomic(run.runRoot, 'build-record.json', record);
219
+ return record;
220
+ }
221
+
161
222
  export async function writeManifestAtomic(run, manifest) {
162
223
  assertRun(run);
163
224
  if (!isObject(manifest)) {