@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.
- package/assets/config/dispatch-matrix-recommendation.json +15 -5
- package/assets/docs/cli-utilities/configuration.md +10 -5
- package/assets/docs/contributing/index.md +1 -0
- package/assets/docs/contributing/verifying-cursor-pins.md +282 -0
- package/assets/docs/workflows/projects/artifacts.md +1 -1
- package/assets/docs/workflows/projects/dispatch-ceiling.md +50 -4
- package/assets/docs/workflows/skills/explainer-kit.md +188 -23
- package/assets/docs/workflows/skills/index.md +1 -1
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +87 -25
- package/assets/skills/explainer-kit/briefs/deep-dive.md +35 -0
- package/assets/skills/explainer-kit/briefs/engineer-tour.md +45 -0
- package/assets/skills/explainer-kit/briefs/program-recap.md +41 -0
- package/assets/skills/explainer-kit/briefs/project-explainer.md +38 -0
- package/assets/skills/explainer-kit/briefs/project-page.md +38 -0
- package/assets/skills/explainer-kit/briefs/project-recap.md +47 -0
- package/assets/skills/explainer-kit/briefs/supporting-diagram.md +28 -0
- package/assets/skills/explainer-kit/briefs/walkthrough-deck.md +35 -0
- package/assets/skills/explainer-kit/examples/project-recap/content.md +57 -14
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.json +104 -0
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +26 -4
- package/assets/skills/explainer-kit/recipes/engineer-tour.json +27 -10
- package/assets/skills/explainer-kit/recipes/program-recap.json +35 -11
- package/assets/skills/explainer-kit/recipes/project-explainer.json +27 -10
- package/assets/skills/explainer-kit/recipes/project-recap.json +43 -11
- package/assets/skills/explainer-kit/references/contracts.md +45 -19
- package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +41 -0
- package/assets/skills/explainer-kit/schemas/author-result.v2.schema.json +52 -0
- package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +442 -0
- package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +223 -10
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +28 -43
- package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +237 -0
- package/assets/skills/explainer-kit/scripts/lib/html-safety.mjs +680 -0
- package/assets/skills/explainer-kit/scripts/lib/markdown.mjs +414 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +309 -10
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +313 -41
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +61 -0
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +166 -12
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +147 -2
- package/assets/skills/explainer-kit/scripts/run.mjs +796 -272
- package/assets/skills/explainer-kit/templates/deck-shell.html +25 -5
- package/assets/skills/explainer-kit/templates/diagram-shell.html +29 -7
- package/assets/skills/explainer-kit/templates/engineer-tour.html +133 -9
- package/assets/skills/explainer-kit/templates/house-style.html +82 -0
- package/assets/skills/oat-explainer-kit/SKILL.md +16 -9
- package/assets/skills/oat-explainer-kit/references/author-callback.md +51 -0
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +10 -8
- package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +14 -0
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +7 -11
- package/assets/skills/oat-project-complete/SKILL.md +18 -2
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +7 -1
- package/assets/skills/oat-wave-execute/SKILL.md +12 -19
- package/assets/skills/oat-wave-program/SKILL.md +12 -13
- package/assets/skills/subagent-orchestration/SKILL.md +1 -1
- package/assets/skills/subagent-orchestration/references/evidence-and-refresh.md +153 -15
- package/assets/skills/subagent-orchestration/references/model-selection-principles.md +75 -3
- package/assets/skills/subagent-orchestration/references/provider-claude.md +95 -35
- package/assets/skills/subagent-orchestration/references/provider-codex.md +36 -10
- package/assets/skills/subagent-orchestration/references/provider-cursor.md +91 -15
- package/dist/providers/cursor/codec/catalog.d.ts +19 -1
- package/dist/providers/cursor/codec/catalog.d.ts.map +1 -1
- package/dist/providers/cursor/codec/catalog.js +54 -0
- package/package.json +2 -2
- package/assets/skills/explainer-kit/schemas/author-request.schema.json +0 -85
- 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
|
|
4
|
-
const
|
|
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
|
|
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([
|
|
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.
|
|
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
|
|
294
|
+
for (const sectionId of recipeRequiredNarrative(
|
|
295
|
+
recipe,
|
|
296
|
+
contentModel.artifactId,
|
|
297
|
+
)) {
|
|
133
298
|
const count = sectionCounts.get(sectionId) ?? 0;
|
|
134
|
-
if (count
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
recipe
|
|
170
|
-
|
|
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.
|
|
212
|
-
`${file}
|
|
376
|
+
Array.isArray(recipe.floor) && recipe.floor.length > 0,
|
|
377
|
+
`${file} floor must be a non-empty array`,
|
|
213
378
|
);
|
|
214
|
-
const
|
|
215
|
-
for (const artifact of recipe.
|
|
216
|
-
assertObject(artifact, `${file}
|
|
217
|
-
assertExactKeys(artifact,
|
|
218
|
-
|
|
219
|
-
|
|
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}
|
|
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(
|
|
225
|
-
|
|
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
|
-
|
|
472
|
+
}
|
|
228
473
|
|
|
229
|
-
|
|
474
|
+
function validateDiscoveryLimits(discoveryLimits, file) {
|
|
475
|
+
assertObject(discoveryLimits, `${file} discoveryLimits`);
|
|
230
476
|
assertExactKeys(
|
|
231
|
-
|
|
477
|
+
discoveryLimits,
|
|
232
478
|
DISCOVERY_LIMIT_KEYS,
|
|
233
479
|
`${file} discoveryLimits`,
|
|
234
480
|
);
|
|
235
481
|
assert(
|
|
236
|
-
|
|
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(
|
|
241
|
-
|
|
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
|
|
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.
|
|
254
|
-
|
|
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)) {
|