@open-agent-toolkit/cli 0.2.5 → 0.2.6

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 (103) hide show
  1. package/assets/docs/cli-utilities/configuration.md +40 -0
  2. package/assets/docs/cli-utilities/tool-packs.md +31 -0
  3. package/assets/docs/workflows/projects/artifacts.md +27 -0
  4. package/assets/docs/workflows/skills/explainer-kit.md +125 -0
  5. package/assets/docs/workflows/skills/index.md +4 -0
  6. package/assets/migration/pjm-restructure.md +12 -0
  7. package/assets/public-package-versions.json +4 -4
  8. package/assets/skills/explainer-kit/SKILL.md +95 -0
  9. package/assets/skills/explainer-kit/examples/project-explainer/content.md +31 -0
  10. package/assets/skills/explainer-kit/examples/project-explainer/fact-base.md +22 -0
  11. package/assets/skills/explainer-kit/examples/project-recap/content.md +34 -0
  12. package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +22 -0
  13. package/assets/skills/explainer-kit/examples/theme-bundle.json +87 -0
  14. package/assets/skills/explainer-kit/palettes/ember.json +37 -0
  15. package/assets/skills/explainer-kit/palettes/forest.json +37 -0
  16. package/assets/skills/explainer-kit/palettes/neutral.json +37 -0
  17. package/assets/skills/explainer-kit/palettes/ocean.json +37 -0
  18. package/assets/skills/explainer-kit/palettes/violet.json +37 -0
  19. package/assets/skills/explainer-kit/profiles/clean.json +42 -0
  20. package/assets/skills/explainer-kit/profiles/editorial.json +42 -0
  21. package/assets/skills/explainer-kit/profiles/technical.json +39 -0
  22. package/assets/skills/explainer-kit/recipes/engineer-tour.json +33 -0
  23. package/assets/skills/explainer-kit/recipes/program-recap.json +34 -0
  24. package/assets/skills/explainer-kit/recipes/project-explainer.json +33 -0
  25. package/assets/skills/explainer-kit/recipes/project-recap.json +34 -0
  26. package/assets/skills/explainer-kit/references/contracts.md +101 -0
  27. package/assets/skills/explainer-kit/references/destination-contract.md +94 -0
  28. package/assets/skills/explainer-kit/references/extension-contract.md +71 -0
  29. package/assets/skills/explainer-kit/references/fact-base-contract.md +119 -0
  30. package/assets/skills/explainer-kit/schemas/build-record.schema.json +92 -0
  31. package/assets/skills/explainer-kit/schemas/durability-evidence.schema.json +53 -0
  32. package/assets/skills/explainer-kit/schemas/fact-base.schema.json +129 -0
  33. package/assets/skills/explainer-kit/schemas/manifest.schema.json +193 -0
  34. package/assets/skills/explainer-kit/schemas/publish-receipt.schema.json +97 -0
  35. package/assets/skills/explainer-kit/schemas/publish-request.schema.json +32 -0
  36. package/assets/skills/explainer-kit/schemas/run-request.schema.json +132 -0
  37. package/assets/skills/explainer-kit/schemas/theme.schema.json +217 -0
  38. package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +180 -0
  39. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +680 -0
  40. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +538 -0
  41. package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +494 -0
  42. package/assets/skills/explainer-kit/scripts/lib/fs-safe.mjs +218 -0
  43. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +512 -0
  44. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +289 -0
  45. package/assets/skills/explainer-kit/scripts/lib/records.mjs +231 -0
  46. package/assets/skills/explainer-kit/scripts/lib/render.mjs +485 -0
  47. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +587 -0
  48. package/assets/skills/explainer-kit/scripts/lib/safe-paths.mjs +96 -0
  49. package/assets/skills/explainer-kit/scripts/lib/theme.mjs +332 -0
  50. package/assets/skills/explainer-kit/scripts/publish.mjs +76 -0
  51. package/assets/skills/explainer-kit/scripts/record-durability.mjs +64 -0
  52. package/assets/skills/explainer-kit/scripts/render-qa.mjs +273 -0
  53. package/assets/skills/explainer-kit/scripts/run.mjs +778 -0
  54. package/assets/skills/explainer-kit/scripts/validate.mjs +59 -0
  55. package/assets/skills/explainer-kit/templates/deck-shell.html +233 -0
  56. package/assets/skills/explainer-kit/templates/diagram-shell.html +219 -0
  57. package/assets/skills/explainer-kit/templates/engineer-tour.html +273 -0
  58. package/assets/skills/explainer-kit/templates/house-style.html +218 -0
  59. package/assets/skills/oat-explainer-kit/SKILL.md +92 -0
  60. package/assets/skills/oat-explainer-kit/references/config-contract.md +67 -0
  61. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +143 -0
  62. package/assets/skills/oat-explainer-kit/references/migration.md +143 -0
  63. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +103 -0
  64. package/assets/skills/oat-explainer-kit/scripts/check-core.mjs +127 -0
  65. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +392 -0
  66. package/assets/skills/oat-explainer-kit/scripts/persist-intent.mjs +130 -0
  67. package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +293 -0
  68. package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +209 -0
  69. package/assets/skills/oat-explainer-kit/scripts/resolve-paths.mjs +166 -0
  70. package/assets/skills/oat-explainer-kit/scripts/run.mjs +284 -0
  71. package/assets/skills/oat-project-autonomous/SKILL.md +22 -1
  72. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +5 -1
  73. package/assets/skills/oat-project-complete/SKILL.md +166 -28
  74. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +5 -1
  75. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  76. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +17 -0
  77. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +5 -1
  78. package/assets/skills/oat-project-plan/SKILL.md +30 -1
  79. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +5 -1
  80. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +5 -1
  81. package/assets/skills/oat-project-summary/SKILL.md +20 -1
  82. package/dist/commands/config/index.d.ts.map +1 -1
  83. package/dist/commands/config/index.js +232 -1
  84. package/dist/commands/init/tools/shared/skill-manifest.d.ts +2 -2
  85. package/dist/commands/init/tools/shared/skill-manifest.d.ts.map +1 -1
  86. package/dist/commands/init/tools/shared/skill-manifest.js +2 -0
  87. package/dist/commands/project/archive/archive-utils.d.ts +12 -0
  88. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  89. package/dist/commands/project/archive/archive-utils.js +320 -8
  90. package/dist/commands/project/archive/index.d.ts.map +1 -1
  91. package/dist/commands/project/archive/index.js +1 -0
  92. package/dist/commands/project/archive/push-runner.d.ts +1 -0
  93. package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
  94. package/dist/commands/project/archive/push-runner.js +9 -2
  95. package/dist/config/oat-config.d.ts +26 -0
  96. package/dist/config/oat-config.d.ts.map +1 -1
  97. package/dist/config/oat-config.js +88 -0
  98. package/dist/config/resolve.d.ts.map +1 -1
  99. package/dist/config/resolve.js +18 -0
  100. package/dist/validation/project-state.d.ts +3 -0
  101. package/dist/validation/project-state.d.ts.map +1 -1
  102. package/dist/validation/project-state.js +91 -0
  103. package/package.json +2 -2
@@ -0,0 +1,289 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const RECIPE_SCHEMA_VERSION = 'explainer-kit.recipe/v1';
4
+ const RECIPE_ROOT_KEYS = [
5
+ 'artifacts',
6
+ 'discoveryLimits',
7
+ 'id',
8
+ 'requiredNarrative',
9
+ 'schemaVersion',
10
+ 'sourceRoles',
11
+ 'version',
12
+ ];
13
+ const SOURCE_ROLE_KEYS = [
14
+ 'accepts',
15
+ 'maxBindings',
16
+ 'minBindings',
17
+ 'required',
18
+ 'role',
19
+ ];
20
+ const ARTIFACT_KEYS = ['id', 'required', 'template', 'type'];
21
+ const DISCOVERY_LIMIT_KEYS = ['consecutiveNoNewFindingsRounds', 'maxRounds'];
22
+ const SOURCE_KINDS = new Set([
23
+ 'file',
24
+ 'directory',
25
+ 'git',
26
+ 'github',
27
+ 'session',
28
+ 'other',
29
+ ]);
30
+ const ARTIFACT_TYPES = new Set(['hub', 'diagram', 'explainer', 'deck']);
31
+ const RECIPE_FILES = [
32
+ 'project-explainer.json',
33
+ 'project-recap.json',
34
+ 'engineer-tour.json',
35
+ 'program-recap.json',
36
+ ];
37
+
38
+ const RECIPES = new Map(
39
+ RECIPE_FILES.map((file) => {
40
+ const recipe = JSON.parse(
41
+ readFileSync(new URL(`../../recipes/${file}`, import.meta.url), 'utf8'),
42
+ );
43
+ validateRecipe(recipe, file);
44
+ return [`${recipe.id}@${recipe.version}`, recipe];
45
+ }),
46
+ );
47
+
48
+ export function loadRecipe(id, version) {
49
+ const recipe = RECIPES.get(`${id}@${version}`);
50
+ if (!recipe) {
51
+ const error = new Error(`Unsupported recipe: ${id}@${version}`);
52
+ error.code = 'E_RECIPE_UNSUPPORTED';
53
+ throw error;
54
+ }
55
+ return structuredClone(recipe);
56
+ }
57
+
58
+ export function validateSourceBindings(recipe, bindings) {
59
+ const errors = [];
60
+ if (!Array.isArray(bindings)) {
61
+ return { valid: false, errors: ['Source bindings must be an array'] };
62
+ }
63
+
64
+ const roles = new Map(recipe.sourceRoles.map((role) => [role.role, role]));
65
+ const sourceSets = new Map();
66
+ for (const binding of bindings) {
67
+ const role = roles.get(binding?.role);
68
+ if (!role) {
69
+ errors.push(`Unknown source role: ${binding?.role}`);
70
+ continue;
71
+ }
72
+ const sets = sourceSets.get(role.role) ?? new Set();
73
+ sets.add(binding.sourceSetId ?? Symbol());
74
+ sourceSets.set(role.role, sets);
75
+ if (!role.accepts.includes(binding.kind)) {
76
+ errors.push(
77
+ `Source role ${role.role} does not accept kind ${binding.kind}`,
78
+ );
79
+ }
80
+ }
81
+
82
+ for (const role of recipe.sourceRoles) {
83
+ const count = sourceSets.get(role.role)?.size ?? 0;
84
+ if (role.required && count < role.minBindings) {
85
+ errors.push(`Missing required source role: ${role.role}`);
86
+ }
87
+ if (count > role.maxBindings) {
88
+ errors.push(
89
+ `Source role ${role.role} allows at most ${role.maxBindings} binding`,
90
+ );
91
+ }
92
+ }
93
+ return { valid: errors.length === 0, errors };
94
+ }
95
+
96
+ export function validateContentModel(recipe, contentModel) {
97
+ const errors = [];
98
+ if (!isObject(contentModel)) {
99
+ return { valid: false, errors: ['Content model must be an object'] };
100
+ }
101
+
102
+ if (
103
+ !recipe.artifacts.some(
104
+ (artifact) => artifact.id === contentModel.artifactId,
105
+ )
106
+ ) {
107
+ errors.push(`Unknown recipe artifact: ${contentModel.artifactId}`);
108
+ }
109
+ if (!Array.isArray(contentModel.sections)) {
110
+ errors.push('Content model sections must be an array');
111
+ return { valid: false, errors };
112
+ }
113
+
114
+ const sectionCounts = new Map();
115
+ for (const section of contentModel.sections) {
116
+ if (
117
+ !isObject(section) ||
118
+ typeof section.id !== 'string' ||
119
+ typeof section.content !== 'string'
120
+ ) {
121
+ errors.push('Narrative sections require string id and content');
122
+ continue;
123
+ }
124
+ sectionCounts.set(section.id, (sectionCounts.get(section.id) ?? 0) + 1);
125
+ if (/<\s*script\b/i.test(section.content)) {
126
+ errors.push(
127
+ `Narrative section ${section.id} contains raw script content`,
128
+ );
129
+ }
130
+ }
131
+
132
+ for (const sectionId of recipe.requiredNarrative) {
133
+ const count = sectionCounts.get(sectionId) ?? 0;
134
+ if (count === 0) {
135
+ errors.push(`Missing required narrative section: ${sectionId}`);
136
+ } else if (count > 1) {
137
+ errors.push(`Duplicate narrative section: ${sectionId}`);
138
+ }
139
+ }
140
+ return { valid: errors.length === 0, errors };
141
+ }
142
+
143
+ export function shouldStopDiscovery(recipe, findingsByRound) {
144
+ if (
145
+ !Array.isArray(findingsByRound) ||
146
+ findingsByRound.some((count) => !Number.isInteger(count) || count < 0)
147
+ ) {
148
+ throw new TypeError(
149
+ 'Discovery round findings must be an array of non-negative integers',
150
+ );
151
+ }
152
+
153
+ const { consecutiveNoNewFindingsRounds, maxRounds } = recipe.discoveryLimits;
154
+ if (findingsByRound.length >= maxRounds) {
155
+ return true;
156
+ }
157
+ if (findingsByRound.length < consecutiveNoNewFindingsRounds) {
158
+ return false;
159
+ }
160
+ return findingsByRound
161
+ .slice(-consecutiveNoNewFindingsRounds)
162
+ .every((count) => count === 0);
163
+ }
164
+
165
+ function validateRecipe(recipe, file) {
166
+ 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
+ );
172
+ assertNonEmptyString(recipe.id, `${file} id`);
173
+ assertNonEmptyString(recipe.version, `${file} version`);
174
+ assertUniqueNonEmptyStrings(
175
+ recipe.requiredNarrative,
176
+ `${file} requiredNarrative`,
177
+ );
178
+
179
+ assert(
180
+ Array.isArray(recipe.sourceRoles) && recipe.sourceRoles.length > 0,
181
+ `${file} sourceRoles must be a non-empty array`,
182
+ );
183
+ const roleNames = [];
184
+ for (const role of recipe.sourceRoles) {
185
+ assertObject(role, `${file} source role`);
186
+ assertExactKeys(role, SOURCE_ROLE_KEYS, `${file} source role`);
187
+ assertNonEmptyString(role.role, `${file} source role name`);
188
+ roleNames.push(role.role);
189
+ assert(typeof role.required === 'boolean', `${file} role required`);
190
+ assert(
191
+ Array.isArray(role.accepts) &&
192
+ role.accepts.length > 0 &&
193
+ role.accepts.every((kind) => SOURCE_KINDS.has(kind)),
194
+ `${file} role accepts unsupported source kinds`,
195
+ );
196
+ assert(
197
+ Number.isInteger(role.minBindings) &&
198
+ role.minBindings >= 0 &&
199
+ Number.isInteger(role.maxBindings) &&
200
+ role.maxBindings >= role.minBindings,
201
+ `${file} role has invalid binding limits`,
202
+ );
203
+ assert(
204
+ role.required === role.minBindings > 0,
205
+ `${file} role required flag must match minBindings`,
206
+ );
207
+ }
208
+ assertUnique(roleNames, `${file} source role names`);
209
+
210
+ assert(
211
+ Array.isArray(recipe.artifacts) && recipe.artifacts.length > 0,
212
+ `${file} artifacts must be a non-empty array`,
213
+ );
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);
220
+ assert(
221
+ ARTIFACT_TYPES.has(artifact.type),
222
+ `${file} artifact has unsupported type`,
223
+ );
224
+ assertNonEmptyString(artifact.template, `${file} artifact template`);
225
+ assert(typeof artifact.required === 'boolean', `${file} artifact required`);
226
+ }
227
+ assertUnique(artifactIds, `${file} artifact ids`);
228
+
229
+ assertObject(recipe.discoveryLimits, `${file} discoveryLimits`);
230
+ assertExactKeys(
231
+ recipe.discoveryLimits,
232
+ DISCOVERY_LIMIT_KEYS,
233
+ `${file} discoveryLimits`,
234
+ );
235
+ assert(
236
+ recipe.discoveryLimits.consecutiveNoNewFindingsRounds === 2,
237
+ `${file} discovery must stop after exactly two no-new-findings rounds`,
238
+ );
239
+ assert(
240
+ Number.isInteger(recipe.discoveryLimits.maxRounds) &&
241
+ recipe.discoveryLimits.maxRounds >= 2,
242
+ `${file} discovery maxRounds must be an integer of at least two`,
243
+ );
244
+ }
245
+
246
+ function assertObject(value, label) {
247
+ assert(isObject(value), `${label} must be an object`);
248
+ }
249
+
250
+ function assertExactKeys(value, expected, label) {
251
+ const actual = Object.keys(value).sort();
252
+ assert(
253
+ actual.length === expected.length &&
254
+ actual.every((key, index) => key === expected[index]),
255
+ `${label} has unknown or missing keys`,
256
+ );
257
+ }
258
+
259
+ function assertNonEmptyString(value, label) {
260
+ assert(
261
+ typeof value === 'string' && value.length > 0,
262
+ `${label} must be a non-empty string`,
263
+ );
264
+ }
265
+
266
+ function assertUniqueNonEmptyStrings(value, label) {
267
+ assert(
268
+ Array.isArray(value) && value.length > 0,
269
+ `${label} must be a non-empty array`,
270
+ );
271
+ for (const entry of value) {
272
+ assertNonEmptyString(entry, label);
273
+ }
274
+ assertUnique(value, label);
275
+ }
276
+
277
+ function assertUnique(values, label) {
278
+ assert(new Set(values).size === values.length, `${label} must be unique`);
279
+ }
280
+
281
+ function assert(condition, message) {
282
+ if (!condition) {
283
+ throw new TypeError(message);
284
+ }
285
+ }
286
+
287
+ function isObject(value) {
288
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
289
+ }
@@ -0,0 +1,231 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ import { validateContract } from './contracts.mjs';
6
+ import { createConfinedRunRoot, writeJsonAtomic } from './fs-safe.mjs';
7
+
8
+ const STAGE_IDS = [
9
+ 'validate',
10
+ 'fact-base',
11
+ 'content',
12
+ 'theme',
13
+ 'render',
14
+ 'qa',
15
+ 'durability',
16
+ 'publish',
17
+ ];
18
+ const SUCCESSFUL_TERMINAL_STATUSES = new Set(['passed', 'warned', 'skipped']);
19
+ const TERMINAL_STATUSES = new Set([...SUCCESSFUL_TERMINAL_STATUSES, 'failed']);
20
+ const ALLOWED_TRANSITIONS = {
21
+ pending: new Set(['running', 'failed', 'skipped']),
22
+ running: new Set(['running', 'passed', 'warned', 'failed']),
23
+ };
24
+
25
+ export async function initializeRun(request) {
26
+ if (!isObject(request)) {
27
+ throw new TypeError('Run request must be an object.');
28
+ }
29
+
30
+ const normalizedRequest = structuredClone(request);
31
+ normalizedRequest.slug = normalizeRequestSlug(request.slug);
32
+ normalizedRequest.theme = {
33
+ ...(isObject(normalizedRequest.theme) ? normalizedRequest.theme : {}),
34
+ renderStrategy: normalizedRequest.theme?.renderStrategy ?? 'default-only',
35
+ };
36
+
37
+ assertValidContract('run-request', normalizedRequest);
38
+
39
+ const paths = await createConfinedRunRoot(
40
+ normalizedRequest.outputRoot,
41
+ normalizedRequest.slug,
42
+ );
43
+ normalizedRequest.outputRoot = paths.outputRoot;
44
+
45
+ const runId = `run-${randomUUID()}`;
46
+ const startedAt = new Date().toISOString();
47
+ const buildRecord = {
48
+ schemaVersion: 'explainer-kit.build-record/v1',
49
+ runId,
50
+ renderStrategy: normalizedRequest.theme.renderStrategy,
51
+ startedAt,
52
+ stages: STAGE_IDS.map((id) => ({
53
+ id,
54
+ status: 'pending',
55
+ outputPaths: [],
56
+ warnings: [],
57
+ })),
58
+ outcome: 'incomplete',
59
+ };
60
+ assertValidContract('build-record', buildRecord);
61
+
62
+ const run = {
63
+ runId,
64
+ slug: paths.slug,
65
+ outputRoot: paths.outputRoot,
66
+ runRoot: paths.runRoot,
67
+ requestPath: join(paths.runRoot, 'run-request.json'),
68
+ buildRecordPath: join(paths.runRoot, 'build-record.json'),
69
+ manifestPath: join(paths.runRoot, 'manifest.json'),
70
+ request: normalizedRequest,
71
+ };
72
+
73
+ await writeJsonAtomic(run.runRoot, 'build-record.json', buildRecord);
74
+ await writeJsonAtomic(
75
+ run.runRoot,
76
+ 'run-request.json',
77
+ privacySafeRequest(normalizedRequest),
78
+ );
79
+
80
+ return run;
81
+ }
82
+
83
+ export async function updateBuildRecord(run, stage) {
84
+ assertRun(run);
85
+ if (!isObject(stage) || !STAGE_IDS.includes(stage.id)) {
86
+ throw new Error('Stage update must identify a supported stage.');
87
+ }
88
+
89
+ const record = JSON.parse(await readFile(run.buildRecordPath, 'utf8'));
90
+ if (record.runId !== run.runId) {
91
+ throw new Error('Build record does not belong to this run.');
92
+ }
93
+
94
+ const stageIndex = STAGE_IDS.indexOf(stage.id);
95
+ const current = record.stages[stageIndex];
96
+ const allowed = ALLOWED_TRANSITIONS[current.status];
97
+ if (!allowed?.has(stage.status)) {
98
+ throw new Error(
99
+ `Stage transitions must be monotonic; ${stage.id} is terminal or cannot move from ${current.status} to ${stage.status}.`,
100
+ );
101
+ }
102
+
103
+ if (
104
+ current.status === 'pending' &&
105
+ stageIndex > 0 &&
106
+ !record.stages
107
+ .slice(0, stageIndex)
108
+ .every(({ status }) => SUCCESSFUL_TERMINAL_STATUSES.has(status))
109
+ ) {
110
+ throw new Error(
111
+ `Stage ${stage.id} cannot start before every prior stage completes in order.`,
112
+ );
113
+ }
114
+
115
+ const timestamp = new Date().toISOString();
116
+ const next = {
117
+ ...current,
118
+ status: stage.status,
119
+ outputPaths: stage.outputPaths ?? current.outputPaths,
120
+ warnings: stage.warnings ?? current.warnings,
121
+ };
122
+ if (stage.error !== undefined) {
123
+ next.error = stage.error;
124
+ }
125
+ if (stage.status === 'running' && next.startedAt === undefined) {
126
+ next.startedAt = timestamp;
127
+ }
128
+ if (TERMINAL_STATUSES.has(stage.status)) {
129
+ next.startedAt ??= timestamp;
130
+ next.completedAt = timestamp;
131
+ }
132
+ if (stage.status === 'failed' && !isObject(next.error)) {
133
+ throw new Error('Failed stages require structured recovery details.');
134
+ }
135
+ if (stage.status !== 'failed' && 'error' in next) {
136
+ throw new Error('Only failed stages may contain an error.');
137
+ }
138
+
139
+ record.stages[stageIndex] = next;
140
+ if (stage.status === 'failed') {
141
+ record.outcome = 'failed';
142
+ record.completedAt = timestamp;
143
+ } else if (
144
+ record.stages.every(({ status }) => TERMINAL_STATUSES.has(status))
145
+ ) {
146
+ record.outcome = record.stages.some(({ status }) => status === 'failed')
147
+ ? 'failed'
148
+ : 'built-not-durable';
149
+ record.completedAt = timestamp;
150
+ } else {
151
+ record.outcome = 'incomplete';
152
+ delete record.completedAt;
153
+ }
154
+
155
+ assertValidContract('build-record', record);
156
+ await writeJsonAtomic(run.runRoot, 'build-record.json', record);
157
+ return record;
158
+ }
159
+
160
+ export async function writeManifestAtomic(run, manifest) {
161
+ assertRun(run);
162
+ if (!isObject(manifest)) {
163
+ throw new TypeError('Manifest must be an object.');
164
+ }
165
+ if (manifest.runId !== run.runId || manifest.slug !== run.slug) {
166
+ throw new Error('Manifest identity does not match the initialized run.');
167
+ }
168
+
169
+ const buildRecord = JSON.parse(await readFile(run.buildRecordPath, 'utf8'));
170
+ assertValidContract('manifest', manifest, { buildRecord });
171
+ await writeJsonAtomic(run.runRoot, 'manifest.json', manifest);
172
+ return run.manifestPath;
173
+ }
174
+
175
+ function normalizeRequestSlug(slug) {
176
+ if (
177
+ typeof slug !== 'string' ||
178
+ slug.includes('/') ||
179
+ slug.includes('\\') ||
180
+ slug.includes('\0') ||
181
+ slug === '.' ||
182
+ slug === '..'
183
+ ) {
184
+ throw new Error('Slug cannot contain path traversal or separators.');
185
+ }
186
+
187
+ const normalized = slug
188
+ .normalize('NFKD')
189
+ .replaceAll(/\p{Mark}/gu, '')
190
+ .toLowerCase()
191
+ .trim()
192
+ .replaceAll(/[^a-z0-9]+/g, '-')
193
+ .replaceAll(/^-+|-+$/g, '');
194
+ if (!normalized) {
195
+ throw new Error('Slug must contain at least one letter or number.');
196
+ }
197
+ return normalized;
198
+ }
199
+
200
+ function privacySafeRequest(request) {
201
+ const persisted = structuredClone(request);
202
+ const retainRaw = persisted.privacy?.retainRawArtDirection === true;
203
+ if (!retainRaw && isObject(persisted.theme)) {
204
+ delete persisted.theme.artDirection;
205
+ }
206
+ return persisted;
207
+ }
208
+
209
+ function assertValidContract(kind, value, context) {
210
+ const result = validateContract(kind, value, context);
211
+ if (!result.valid) {
212
+ const details = result.errors
213
+ .map(({ path, code, message }) => `${path} [${code}]: ${message}`)
214
+ .join('; ');
215
+ throw new Error(`Invalid ${kind}: ${details}`);
216
+ }
217
+ }
218
+
219
+ function assertRun(run) {
220
+ if (
221
+ !isObject(run) ||
222
+ typeof run.runId !== 'string' ||
223
+ typeof run.runRoot !== 'string'
224
+ ) {
225
+ throw new TypeError('Run must be returned by initializeRun().');
226
+ }
227
+ }
228
+
229
+ function isObject(value) {
230
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
231
+ }