@open-agent-toolkit/cli 0.2.20 → 0.2.22

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 (58) hide show
  1. package/assets/docs/workflows/projects/artifacts.md +1 -1
  2. package/assets/docs/workflows/skills/explainer-kit.md +188 -23
  3. package/assets/docs/workflows/skills/index.md +1 -1
  4. package/assets/public-package-versions.json +4 -4
  5. package/assets/skills/explainer-kit/SKILL.md +87 -25
  6. package/assets/skills/explainer-kit/briefs/deep-dive.md +35 -0
  7. package/assets/skills/explainer-kit/briefs/engineer-tour.md +45 -0
  8. package/assets/skills/explainer-kit/briefs/program-recap.md +41 -0
  9. package/assets/skills/explainer-kit/briefs/project-explainer.md +38 -0
  10. package/assets/skills/explainer-kit/briefs/project-page.md +38 -0
  11. package/assets/skills/explainer-kit/briefs/project-recap.md +47 -0
  12. package/assets/skills/explainer-kit/briefs/supporting-diagram.md +28 -0
  13. package/assets/skills/explainer-kit/briefs/walkthrough-deck.md +35 -0
  14. package/assets/skills/explainer-kit/examples/project-recap/content.md +57 -14
  15. package/assets/skills/explainer-kit/examples/project-recap/fact-base.json +104 -0
  16. package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +26 -4
  17. package/assets/skills/explainer-kit/recipes/engineer-tour.json +27 -10
  18. package/assets/skills/explainer-kit/recipes/program-recap.json +35 -11
  19. package/assets/skills/explainer-kit/recipes/project-explainer.json +27 -10
  20. package/assets/skills/explainer-kit/recipes/project-recap.json +43 -11
  21. package/assets/skills/explainer-kit/references/contracts.md +45 -19
  22. package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +41 -0
  23. package/assets/skills/explainer-kit/schemas/author-result.v2.schema.json +52 -0
  24. package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +442 -0
  25. package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +223 -10
  26. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +28 -43
  27. package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +237 -0
  28. package/assets/skills/explainer-kit/scripts/lib/html-safety.mjs +687 -0
  29. package/assets/skills/explainer-kit/scripts/lib/markdown.mjs +414 -0
  30. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +313 -14
  31. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +314 -41
  32. package/assets/skills/explainer-kit/scripts/lib/records.mjs +61 -0
  33. package/assets/skills/explainer-kit/scripts/lib/render.mjs +166 -12
  34. package/assets/skills/explainer-kit/scripts/render-qa.mjs +152 -2
  35. package/assets/skills/explainer-kit/scripts/run.mjs +789 -272
  36. package/assets/skills/explainer-kit/templates/deck-shell.html +25 -5
  37. package/assets/skills/explainer-kit/templates/diagram-shell.html +29 -7
  38. package/assets/skills/explainer-kit/templates/engineer-tour.html +133 -9
  39. package/assets/skills/explainer-kit/templates/house-style.html +82 -0
  40. package/assets/skills/oat-brainstorm/SKILL.md +1 -1
  41. package/assets/skills/oat-brainstorm/scripts/helper.js +18 -11
  42. package/assets/skills/oat-brainstorm/scripts/server.cjs +109 -55
  43. package/assets/skills/oat-explainer-kit/SKILL.md +16 -9
  44. package/assets/skills/oat-explainer-kit/references/author-callback.md +51 -0
  45. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +10 -8
  46. package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +14 -0
  47. package/assets/skills/oat-explainer-kit/scripts/run.mjs +7 -11
  48. package/assets/skills/oat-project-complete/SKILL.md +18 -2
  49. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  50. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +7 -1
  51. package/assets/skills/oat-wave-execute/SKILL.md +12 -19
  52. package/assets/skills/oat-wave-program/SKILL.md +12 -13
  53. package/dist/commands/init/tools/index.d.ts.map +1 -1
  54. package/dist/commands/init/tools/index.js +4 -4
  55. package/dist/engine/compute-plan.js +3 -3
  56. package/package.json +2 -2
  57. package/assets/skills/explainer-kit/schemas/author-request.schema.json +0 -85
  58. 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,143 @@ 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(Object.entries(expansion.limits.maxPerType ?? {}));
120
+
121
+ if (!Array.isArray(proposals)) {
122
+ return {
123
+ valid: false,
124
+ accepted,
125
+ rejected,
126
+ warnings: [],
127
+ errors: ['Expansion proposals must be an array'],
128
+ };
129
+ }
130
+
131
+ for (const proposal of proposals) {
132
+ if (
133
+ !isObject(proposal) ||
134
+ !hasExactKeys(proposal, ['id', 'profileId', 'rationale'])
135
+ ) {
136
+ errors.push(
137
+ 'Expansion proposals require only id, profileId, and rationale',
138
+ );
139
+ continue;
140
+ }
141
+ if (!SAFE_ID.test(proposal.id)) {
142
+ errors.push(`Unsafe expansion artifact id: ${proposal.id}`);
143
+ continue;
144
+ }
145
+ if (floorIds.has(proposal.id)) {
146
+ errors.push(`Expansion artifact id collides with floor: ${proposal.id}`);
147
+ continue;
148
+ }
149
+ if (seenIds.has(proposal.id)) {
150
+ errors.push(`Duplicate expansion artifact id: ${proposal.id}`);
151
+ continue;
152
+ }
153
+ seenIds.add(proposal.id);
154
+
155
+ const profile = profiles.get(proposal.profileId);
156
+ if (!profile) {
157
+ errors.push(`Unknown expansion profile: ${proposal.profileId}`);
158
+ continue;
159
+ }
160
+ if (
161
+ typeof proposal.rationale !== 'string' ||
162
+ proposal.rationale.length === 0
163
+ ) {
164
+ errors.push(`Expansion proposal ${proposal.id} requires a rationale`);
165
+ continue;
166
+ }
167
+
168
+ const profileCount = acceptedByProfile.get(profile.profileId) ?? 0;
169
+ if (profileCount >= profile.maxCount) {
170
+ rejected.push({
171
+ ...structuredClone(proposal),
172
+ status: 'rejected',
173
+ reason: 'profile-limit',
174
+ });
175
+ warnings.add('expansion-profile-limit-exceeded');
176
+ continue;
177
+ }
178
+ // A declared type cap binds across profiles, so proposals cannot spread
179
+ // over sibling profiles that share one artifact type to evade it.
180
+ const typeCount = acceptedByType.get(profile.type) ?? 0;
181
+ if (
182
+ maxPerType.has(profile.type) &&
183
+ typeCount >= maxPerType.get(profile.type)
184
+ ) {
185
+ rejected.push({
186
+ ...structuredClone(proposal),
187
+ status: 'rejected',
188
+ reason: 'type-limit',
189
+ });
190
+ warnings.add('expansion-type-limit-exceeded');
191
+ continue;
192
+ }
193
+ if (accepted.length >= expansion.limits.maxArtifacts) {
194
+ rejected.push({
195
+ ...structuredClone(proposal),
196
+ status: 'rejected',
197
+ reason: 'recipe-limit',
198
+ });
199
+ warnings.add('expansion-artifact-limit-exceeded');
200
+ continue;
201
+ }
202
+
203
+ accepted.push({
204
+ ...structuredClone(proposal),
205
+ status: 'accepted',
206
+ profile: structuredClone(profile),
207
+ });
208
+ acceptedByProfile.set(profile.profileId, profileCount + 1);
209
+ acceptedByType.set(profile.type, typeCount + 1);
210
+ }
211
+
212
+ return {
213
+ valid: errors.length === 0,
214
+ accepted,
215
+ rejected,
216
+ warnings: [...warnings],
217
+ errors,
218
+ };
219
+ }
220
+
58
221
  export function validateSourceBindings(recipe, bindings) {
59
222
  const errors = [];
60
223
  if (!Array.isArray(bindings)) {
@@ -100,7 +263,7 @@ export function validateContentModel(recipe, contentModel) {
100
263
  }
101
264
 
102
265
  if (
103
- !recipe.artifacts.some(
266
+ !recipeFloor(recipe).some(
104
267
  (artifact) => artifact.id === contentModel.artifactId,
105
268
  )
106
269
  ) {
@@ -129,11 +292,12 @@ export function validateContentModel(recipe, contentModel) {
129
292
  }
130
293
  }
131
294
 
132
- for (const sectionId of recipe.requiredNarrative) {
295
+ for (const sectionId of recipeRequiredNarrative(
296
+ recipe,
297
+ contentModel.artifactId,
298
+ )) {
133
299
  const count = sectionCounts.get(sectionId) ?? 0;
134
- if (count === 0) {
135
- errors.push(`Missing required narrative section: ${sectionId}`);
136
- } else if (count > 1) {
300
+ if (count > 1) {
137
301
  errors.push(`Duplicate narrative section: ${sectionId}`);
138
302
  }
139
303
  }
@@ -162,19 +326,16 @@ export function shouldStopDiscovery(recipe, findingsByRound) {
162
326
  .every((count) => count === 0);
163
327
  }
164
328
 
165
- function validateRecipe(recipe, file) {
329
+ export function validateRecipe(recipe, file = 'recipe') {
166
330
  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
- );
331
+ if (recipe.schemaVersion === RECIPE_SCHEMA_V2) {
332
+ assertExactKeys(recipe, RECIPE_V2_ROOT_KEYS, `${file} recipe`);
333
+ validateV2Shape(recipe, file);
334
+ } else {
335
+ assert(false, `${file} has unsupported schemaVersion`);
336
+ }
172
337
  assertNonEmptyString(recipe.id, `${file} id`);
173
338
  assertNonEmptyString(recipe.version, `${file} version`);
174
- assertUniqueNonEmptyStrings(
175
- recipe.requiredNarrative,
176
- `${file} requiredNarrative`,
177
- );
178
339
 
179
340
  assert(
180
341
  Array.isArray(recipe.sourceRoles) && recipe.sourceRoles.length > 0,
@@ -207,38 +368,124 @@ function validateRecipe(recipe, file) {
207
368
  }
208
369
  assertUnique(roleNames, `${file} source role names`);
209
370
 
371
+ validateDiscoveryLimits(recipe.discoveryLimits, file);
372
+ return recipe;
373
+ }
374
+
375
+ function validateV2Shape(recipe, file) {
210
376
  assert(
211
- Array.isArray(recipe.artifacts) && recipe.artifacts.length > 0,
212
- `${file} artifacts must be a non-empty array`,
377
+ Array.isArray(recipe.floor) && recipe.floor.length > 0,
378
+ `${file} floor must be a non-empty array`,
213
379
  );
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);
380
+ const floorIds = [];
381
+ for (const artifact of recipe.floor) {
382
+ assertObject(artifact, `${file} floor entry`);
383
+ assertExactKeys(artifact, FLOOR_KEYS, `${file} floor entry`);
384
+ assertSafeId(artifact.id, `${file} floor id`);
385
+ floorIds.push(artifact.id);
220
386
  assert(
221
387
  ARTIFACT_TYPES.has(artifact.type),
222
- `${file} artifact has unsupported type`,
388
+ `${file} floor has unsupported type`,
389
+ );
390
+ assert(
391
+ AUTHORING_TYPES.has(artifact.authoring),
392
+ `${file} floor has unsupported authoring`,
393
+ );
394
+ assertNonEmptyString(artifact.template, `${file} floor template`);
395
+ assertNonEmptyString(artifact.briefRef, `${file} floor briefRef`);
396
+ assert(typeof artifact.required === 'boolean', `${file} floor required`);
397
+ assertUniqueNonEmptyStrings(
398
+ artifact.requiredNarrative,
399
+ `${file} floor requiredNarrative`,
223
400
  );
224
- assertNonEmptyString(artifact.template, `${file} artifact template`);
225
- assert(typeof artifact.required === 'boolean', `${file} artifact required`);
226
401
  }
227
- assertUnique(artifactIds, `${file} artifact ids`);
402
+ assertUnique(floorIds, `${file} floor ids`);
228
403
 
229
- assertObject(recipe.discoveryLimits, `${file} discoveryLimits`);
404
+ assertObject(recipe.expansion, `${file} expansion`);
405
+ assertExactKeys(recipe.expansion, EXPANSION_KEYS, `${file} expansion`);
406
+ assert(
407
+ Array.isArray(recipe.expansion.profiles),
408
+ `${file} expansion profiles must be an array`,
409
+ );
410
+ const profileIds = [];
411
+ for (const profile of recipe.expansion.profiles) {
412
+ assertObject(profile, `${file} expansion profile`);
413
+ assertAllowedKeys(
414
+ profile,
415
+ PROFILE_KEYS,
416
+ PROFILE_REQUIRED_KEYS,
417
+ `${file} expansion profile`,
418
+ );
419
+ assertSafeId(profile.profileId, `${file} expansion profileId`);
420
+ profileIds.push(profile.profileId);
421
+ assert(
422
+ !floorIds.includes(profile.profileId),
423
+ `${file} expansion profileId collides with floor id`,
424
+ );
425
+ assert(
426
+ ARTIFACT_TYPES.has(profile.type),
427
+ `${file} expansion profile has unsupported type`,
428
+ );
429
+ assert(
430
+ AUTHORING_TYPES.has(profile.authoring),
431
+ `${file} expansion profile has unsupported authoring`,
432
+ );
433
+ assertNonEmptyString(
434
+ profile.briefRef,
435
+ `${file} expansion profile briefRef`,
436
+ );
437
+ assertFiniteCount(profile.maxCount, `${file} expansion profile maxCount`);
438
+ if (profile.authoring === 'html' || 'shell' in profile) {
439
+ assertNonEmptyString(
440
+ profile.shell,
441
+ `${file} expansion profile ${profile.profileId} shell`,
442
+ );
443
+ }
444
+ }
445
+ assertUnique(profileIds, `${file} expansion profileIds`);
446
+
447
+ assertObject(recipe.expansion.limits, `${file} expansion limits`);
448
+ assertAllowedKeys(
449
+ recipe.expansion.limits,
450
+ EXPANSION_LIMIT_KEYS,
451
+ ['maxArtifacts'],
452
+ `${file} expansion limits`,
453
+ );
454
+ assertFiniteCount(
455
+ recipe.expansion.limits.maxArtifacts,
456
+ `${file} expansion maxArtifacts`,
457
+ );
458
+ if ('maxPerType' in recipe.expansion.limits) {
459
+ assertObject(
460
+ recipe.expansion.limits.maxPerType,
461
+ `${file} expansion maxPerType`,
462
+ );
463
+ for (const [type, limit] of Object.entries(
464
+ recipe.expansion.limits.maxPerType,
465
+ )) {
466
+ assert(
467
+ ARTIFACT_TYPES.has(type),
468
+ `${file} expansion maxPerType has unsupported type`,
469
+ );
470
+ assertFiniteCount(limit, `${file} expansion maxPerType ${type}`);
471
+ }
472
+ }
473
+ }
474
+
475
+ function validateDiscoveryLimits(discoveryLimits, file) {
476
+ assertObject(discoveryLimits, `${file} discoveryLimits`);
230
477
  assertExactKeys(
231
- recipe.discoveryLimits,
478
+ discoveryLimits,
232
479
  DISCOVERY_LIMIT_KEYS,
233
480
  `${file} discoveryLimits`,
234
481
  );
235
482
  assert(
236
- recipe.discoveryLimits.consecutiveNoNewFindingsRounds === 2,
483
+ discoveryLimits.consecutiveNoNewFindingsRounds === 2,
237
484
  `${file} discovery must stop after exactly two no-new-findings rounds`,
238
485
  );
239
486
  assert(
240
- Number.isInteger(recipe.discoveryLimits.maxRounds) &&
241
- recipe.discoveryLimits.maxRounds >= 2,
487
+ Number.isInteger(discoveryLimits.maxRounds) &&
488
+ discoveryLimits.maxRounds >= 2,
242
489
  `${file} discovery maxRounds must be an integer of at least two`,
243
490
  );
244
491
  }
@@ -248,14 +495,28 @@ function assertObject(value, label) {
248
495
  }
249
496
 
250
497
  function assertExactKeys(value, expected, label) {
251
- const actual = Object.keys(value).sort();
498
+ const matches = hasExactKeys(value, expected);
499
+ assert(matches, `${label} has unknown or missing keys`);
500
+ }
501
+
502
+ function assertAllowedKeys(value, allowed, required, label) {
503
+ const actual = Object.keys(value);
252
504
  assert(
253
- actual.length === expected.length &&
254
- actual.every((key, index) => key === expected[index]),
505
+ actual.every((key) => allowed.includes(key)) &&
506
+ required.every((key) => key in value),
255
507
  `${label} has unknown or missing keys`,
256
508
  );
257
509
  }
258
510
 
511
+ function hasExactKeys(value, expected) {
512
+ const actual = Object.keys(value).sort();
513
+ const sortedExpected = [...expected].sort();
514
+ return (
515
+ actual.length === sortedExpected.length &&
516
+ actual.every((key, index) => key === sortedExpected[index])
517
+ );
518
+ }
519
+
259
520
  function assertNonEmptyString(value, label) {
260
521
  assert(
261
522
  typeof value === 'string' && value.length > 0,
@@ -263,6 +524,18 @@ function assertNonEmptyString(value, label) {
263
524
  );
264
525
  }
265
526
 
527
+ function assertSafeId(value, label) {
528
+ assertNonEmptyString(value, label);
529
+ assert(SAFE_ID.test(value), `${label} must be a safe slug`);
530
+ }
531
+
532
+ function assertFiniteCount(value, label) {
533
+ assert(
534
+ Number.isInteger(value) && Number.isFinite(value) && value >= 0,
535
+ `${label} must be a finite non-negative integer`,
536
+ );
537
+ }
538
+
266
539
  function assertUniqueNonEmptyStrings(value, label) {
267
540
  assert(
268
541
  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)) {