@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
@@ -9,21 +9,37 @@ import { resolveContentApproval } from './lib/content-approval.mjs';
9
9
  import { canonicalHash, validateContract } from './lib/contracts.mjs';
10
10
  import { processFactBase } from './lib/fact-base.mjs';
11
11
  import { writeJsonAtomic, writeTextAtomic } from './lib/fs-safe.mjs';
12
- import { auditArtifactSet, checkSourceDumping } from './lib/qa.mjs';
12
+ import { validateHtmlSafety } from './lib/html-safety.mjs';
13
13
  import {
14
+ auditArtifactSet,
15
+ checkGuidelines,
16
+ checkSourceDumping,
17
+ RENDER_QA_WARNING_IDS,
18
+ renderQaWarningIds,
19
+ renderWarningIds,
20
+ } from './lib/qa.mjs';
21
+ import {
22
+ evaluateExpansionProposals,
14
23
  loadRecipe,
24
+ recipeExpansion,
25
+ recipeFloor,
26
+ recipeRequiredNarrative,
15
27
  shouldStopDiscovery,
16
28
  validateContentModel,
17
29
  validateSourceBindings,
18
30
  } from './lib/recipes.mjs';
19
31
  import {
20
32
  initializeRun,
33
+ reopenBuildStages,
21
34
  updateBuildRecord,
22
35
  writeManifestAtomic,
23
36
  } from './lib/records.mjs';
24
- import { renderArtifact } from './lib/render.mjs';
37
+ import { artifactPath, renderArtifact } from './lib/render.mjs';
25
38
  import { resolveTheme } from './lib/theme.mjs';
26
39
 
40
+ // Stages a rejected draft reruns once its content is corrected.
41
+ const REOPENED_ON_REJECTION = Object.freeze(['render', 'qa']);
42
+
27
43
  export async function runExplainer(request, options = {}) {
28
44
  assertValidRequest(request);
29
45
  const recipe = loadRecipe(request.recipe.id, request.recipe.version);
@@ -39,13 +55,26 @@ export async function runExplainer(request, options = {}) {
39
55
  contentModels: [],
40
56
  contentPaths: new Map(),
41
57
  authorResultPaths: [],
58
+ resolvedArtifacts: [],
59
+ authoredContent: new Map(),
60
+ expansion: {
61
+ valid: true,
62
+ accepted: [],
63
+ rejected: [],
64
+ warnings: [],
65
+ errors: [],
66
+ },
67
+ qaErrors: [],
42
68
  theme: null,
69
+ themeWarnings: [],
43
70
  renderStrategy: run.request.theme.renderStrategy,
44
71
  rendered: [],
45
72
  artifacts: [],
46
73
  warnings: [],
74
+ reopenedWarnings: {},
47
75
  discovery: { rounds: 0, findings: [], reason: 'not-requested' },
48
76
  approval: null,
77
+ resumedApprovalStatus: null,
49
78
  };
50
79
 
51
80
  try {
@@ -82,114 +111,54 @@ export async function runExplainer(request, options = {}) {
82
111
  status: processed.checks.warnings.length > 0 ? 'warned' : 'passed',
83
112
  };
84
113
  });
114
+ await prepareTheme(state);
85
115
  await executeStage(run, 'content', options, async () => {
86
116
  state.discovery = await runDiscovery(recipe, state.factBase, options);
87
- if (run.request.mode === 'unattended') {
88
- const authored = await createAuthoredContent(state, options.author);
89
- state.contentModels = authored.models;
90
- state.authorResultPaths = authored.resultPaths;
91
- } else {
92
- state.contentModels = recipe.artifacts.map((artifact) =>
93
- createContentModel(recipe, artifact, run.slug, state.factBase),
94
- );
95
- }
96
- for (const model of state.contentModels) {
97
- const validation = validateContentModel(recipe, model);
98
- if (!validation.valid) {
99
- throw codedError(
100
- 'E_CONTENT',
101
- `Invalid content model: ${validation.errors.join('; ')}`,
102
- );
103
- }
104
- const path = `source/content/${model.artifactId}.md`;
105
- await writeTextAtomic(run.runRoot, path, contentMarkdown(model));
106
- state.contentPaths.set(model.artifactId, path);
107
- }
108
- return { outputPaths: [...state.contentPaths.values()] };
117
+ await createAuthoredContent(state, options, now);
118
+ return {
119
+ outputPaths: [
120
+ ...state.contentPaths.values(),
121
+ ...state.authorResultPaths,
122
+ ],
123
+ warnings: state.expansion.warnings,
124
+ status: state.expansion.warnings.length > 0 ? 'warned' : 'passed',
125
+ };
109
126
  });
110
127
  }
111
128
 
129
+ if (!resumed) {
130
+ await executeThemeStage(state, options);
131
+ }
132
+ if (!resumed || state.resumedApprovalStatus === 'rejected') {
133
+ if (state.resumedApprovalStatus === 'rejected') {
134
+ const reopened = await reopenBuildStages(run, {
135
+ ids: [...REOPENED_ON_REJECTION],
136
+ reason: 'content-rejected',
137
+ });
138
+ // Reopen markers are the D4 audit trail, so they survive the rerun's
139
+ // replacement of each stage's warning set.
140
+ state.reopenedWarnings = Object.fromEntries(
141
+ reopened.stages.map(({ id, warnings }) => [
142
+ id,
143
+ warnings.filter((warning) => warning.startsWith('stage-reopened:')),
144
+ ]),
145
+ );
146
+ }
147
+ await executeRenderStage(state, options);
148
+ await executeQaStage(state, options);
149
+ }
150
+
112
151
  state.approval = await resolveContentApproval(
113
152
  run,
114
153
  run.request.mode,
115
154
  options.reviewedSource,
116
155
  state.authorResultPaths,
156
+ approvalArtifacts(state),
117
157
  );
118
158
  if (!state.approval.canResume) {
119
159
  return resultFor(state);
120
160
  }
121
161
 
122
- await executeStage(run, 'theme', options, async () => {
123
- const resolved = await resolveTheme(run.request.theme);
124
- state.theme = resolved.theme;
125
- state.renderStrategy = resolved.renderStrategy;
126
- state.warnings.push(...resolved.warnings);
127
- await writeJsonAtomic(run.runRoot, 'theme.resolved.json', state.theme);
128
- return {
129
- outputPaths: ['theme.resolved.json'],
130
- warnings: resolved.warnings,
131
- status: resolved.warnings.length > 0 ? 'warned' : 'passed',
132
- };
133
- });
134
- await executeStage(run, 'render', options, async () => {
135
- for (const recipeArtifact of recipe.artifacts) {
136
- const content = state.contentModels.find(
137
- ({ artifactId }) => artifactId === recipeArtifact.id,
138
- );
139
- const rendered = await renderArtifact({
140
- recipeArtifact,
141
- content,
142
- theme: state.theme,
143
- renderStrategy: state.renderStrategy,
144
- ...(run.request.publicBaseUrl && {
145
- publicBaseUrl: run.request.publicBaseUrl,
146
- }),
147
- });
148
- await writeTextAtomic(
149
- run.runRoot,
150
- rendered.renderedPath,
151
- rendered.html,
152
- );
153
- state.rendered.push(rendered);
154
- state.artifacts.push({
155
- id: rendered.artifactId,
156
- type: rendered.type,
157
- contentPath: state.contentPaths.get(rendered.artifactId),
158
- renderedPath: rendered.renderedPath,
159
- mediaType: rendered.mediaType,
160
- status: 'built',
161
- hash: hashBytes(rendered.html),
162
- rebuildable: false,
163
- });
164
- }
165
- return {
166
- outputPaths: state.rendered.map(({ renderedPath }) => renderedPath),
167
- };
168
- });
169
- await executeStage(run, 'qa', options, async () => {
170
- const report = await auditArtifactSet({
171
- artifacts: state.rendered.map((artifact) => ({
172
- id: artifact.artifactId,
173
- type: artifact.type,
174
- html: artifact.html,
175
- })),
176
- ...(options.denylist && { denylist: options.denylist }),
177
- ...(options.browserProbe && { browserProbe: options.browserProbe }),
178
- ...(options.widths && { widths: options.widths }),
179
- });
180
- if (!report.valid) {
181
- throw codedError(
182
- 'E_QA',
183
- report.issues
184
- .map(({ code, message }) => `${code}: ${message}`)
185
- .join('; '),
186
- );
187
- }
188
- return {
189
- outputPaths: state.rendered.map(({ renderedPath }) => renderedPath),
190
- };
191
- });
192
-
193
162
  await executeDurabilityAndPublish(state, options, now);
194
163
  await persistManifest(state, now());
195
164
  return resultFor(state);
@@ -201,6 +170,202 @@ export async function runExplainer(request, options = {}) {
201
170
  }
202
171
  }
203
172
 
173
+ async function executeThemeStage(state, options) {
174
+ await executeStage(state.run, 'theme', options, async () => {
175
+ await writeJsonAtomic(
176
+ state.run.runRoot,
177
+ 'theme.resolved.json',
178
+ state.theme,
179
+ );
180
+ return {
181
+ outputPaths: ['theme.resolved.json'],
182
+ warnings: state.themeWarnings,
183
+ status: state.themeWarnings.length > 0 ? 'warned' : 'passed',
184
+ };
185
+ });
186
+ }
187
+
188
+ async function prepareTheme(state) {
189
+ const resolved = await resolveTheme(state.run.request.theme);
190
+ state.theme = resolved.theme;
191
+ state.renderStrategy = resolved.renderStrategy;
192
+ state.themeWarnings = resolved.warnings;
193
+ state.warnings.push(...resolved.warnings);
194
+ }
195
+
196
+ async function executeRenderStage(state, options) {
197
+ state.rendered = [];
198
+ state.artifacts = [];
199
+ await executeStage(state.run, 'render', options, async () => {
200
+ for (const artifact of state.resolvedArtifacts) {
201
+ const rendered =
202
+ artifact.authoring === 'markdown'
203
+ ? await renderArtifact({
204
+ recipeArtifact: renderDescriptor(artifact),
205
+ content: state.contentModels.find(
206
+ ({ artifactId }) => artifactId === artifact.id,
207
+ ),
208
+ theme: state.theme,
209
+ renderStrategy: state.renderStrategy,
210
+ ...(state.run.request.publicBaseUrl && {
211
+ publicBaseUrl: state.run.request.publicBaseUrl,
212
+ }),
213
+ })
214
+ : artisticRender(state, artifact);
215
+ await writeTextAtomic(
216
+ state.run.runRoot,
217
+ rendered.renderedPath,
218
+ rendered.html,
219
+ );
220
+ state.rendered.push(rendered);
221
+ state.artifacts.push(artifactRecord(state, rendered));
222
+ }
223
+ // D7 degradation findings are guideline severity, so they travel to the
224
+ // run result and the manifest as warnings rather than failing the stage.
225
+ const degradation = renderWarningIds(
226
+ state.rendered.flatMap(
227
+ ({ warnings: sectionWarnings = [] }) => sectionWarnings,
228
+ ),
229
+ );
230
+ state.warnings.push(...degradation);
231
+ const warnings = [...(state.reopenedWarnings.render ?? []), ...degradation];
232
+ return {
233
+ outputPaths: state.rendered.map(({ renderedPath }) => renderedPath),
234
+ warnings,
235
+ status: warnings.length > 0 ? 'warned' : 'passed',
236
+ };
237
+ });
238
+ }
239
+
240
+ async function executeQaStage(state, options) {
241
+ await executeStage(state.run, 'qa', options, async () => {
242
+ const htmlSafetyErrors = [];
243
+ const qaWarnings = [];
244
+ return auditRenderedArtifacts(state, options, {
245
+ browserProbe: resolveBrowserProbe(options),
246
+ htmlSafetyErrors,
247
+ qaWarnings,
248
+ });
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Render QA drives a caller-supplied probe only. The core never launches a
254
+ * headless runtime itself; agents review rendered output in their own browser.
255
+ */
256
+ function resolveBrowserProbe(options) {
257
+ if (options.browserProbe === undefined) return null;
258
+ if (typeof options.browserProbe !== 'function') {
259
+ throw codedError(
260
+ 'E_BROWSER_PROBE',
261
+ 'options.browserProbe must be a function when supplied.',
262
+ );
263
+ }
264
+ return options.browserProbe;
265
+ }
266
+
267
+ async function auditRenderedArtifacts(
268
+ state,
269
+ options,
270
+ { browserProbe, htmlSafetyErrors, qaWarnings },
271
+ ) {
272
+ for (const artifact of state.resolvedArtifacts.filter(
273
+ ({ authoring }) => authoring === 'html',
274
+ )) {
275
+ const safety = validateHtmlSafety({
276
+ html: state.authoredContent.get(artifact.id),
277
+ shell: artifact.shellContent,
278
+ shellName: artifact.shell ?? artifact.template,
279
+ });
280
+ htmlSafetyErrors.push(
281
+ ...safety.errors.map((code) => ({
282
+ code,
283
+ message: `Artistic artifact ${artifact.id} failed DOM safety validation.`,
284
+ })),
285
+ );
286
+ qaWarnings.push(...safety.warnings);
287
+ }
288
+
289
+ const probeArtifacts = state.rendered.map((artifact) => ({
290
+ id: artifact.artifactId,
291
+ type: artifact.type,
292
+ html: artifact.html,
293
+ }));
294
+ const report = await auditArtifactSet({
295
+ artifacts: probeArtifacts,
296
+ ...(options.denylist && { denylist: options.denylist }),
297
+ ...(browserProbe && { browserProbe }),
298
+ ...(options.widths && { widths: options.widths }),
299
+ });
300
+ const hardIssues = report.issues.filter((issue) => isHardQaIssue(issue.code));
301
+ const warningIssues = report.issues.filter(
302
+ (issue) => !isHardQaIssue(issue.code),
303
+ );
304
+ // A code the render-QA vocabulary already covers must not also emit an ad
305
+ // hoc `qa-*` twin; the generic conversion is for structural codes only.
306
+ qaWarnings.push(
307
+ ...renderQaWarningIds(warningIssues),
308
+ ...warningIssues
309
+ .filter(({ code }) => renderQaWarningIds([{ code }]).length === 0)
310
+ .map(({ code }) => `qa-${code}`),
311
+ );
312
+ if (!browserProbe) {
313
+ qaWarnings.push(RENDER_QA_WARNING_IDS.skippedNoProbe);
314
+ }
315
+ const guidelines = checkGuidelines({
316
+ recipe: state.recipe,
317
+ artifacts: probeArtifacts,
318
+ expansion: state.expansion,
319
+ });
320
+ qaWarnings.push(...guidelines.warnings, ...state.expansion.warnings);
321
+
322
+ const errors = [...state.qaErrors, ...htmlSafetyErrors, ...hardIssues];
323
+ if (errors.length > 0) {
324
+ throw codedError(
325
+ 'E_QA',
326
+ errors.map(({ code, message }) => `${code}: ${message}`).join('; '),
327
+ );
328
+ }
329
+ state.warnings.push(...qaWarnings);
330
+ const warnings = [
331
+ ...(state.reopenedWarnings.qa ?? []),
332
+ ...new Set(qaWarnings),
333
+ ];
334
+ return {
335
+ outputPaths: state.rendered.map(({ renderedPath }) => renderedPath),
336
+ warnings,
337
+ status: warnings.length > 0 ? 'warned' : 'passed',
338
+ };
339
+ }
340
+
341
+ function isHardQaIssue(code) {
342
+ return (
343
+ [
344
+ 'denylisted-string',
345
+ 'external-asset',
346
+ 'link-form',
347
+ 'tag-balance',
348
+ 'unresolved-token',
349
+ ].includes(code) || code.startsWith('cohesion-')
350
+ );
351
+ }
352
+
353
+ function artisticRender(state, artifact) {
354
+ const renderedPath = artifactPath(renderDescriptor(artifact), state.run.slug);
355
+ const publicBaseUrl = state.run.request.publicBaseUrl?.replace(/\/+$/g, '');
356
+ return {
357
+ artifactId: artifact.id,
358
+ type: artifact.type,
359
+ renderedPath,
360
+ publicUrl: publicBaseUrl
361
+ ? `${publicBaseUrl}/${renderedPath.slice('site/'.length)}`
362
+ : undefined,
363
+ mediaType: 'text/html',
364
+ html: state.authoredContent.get(artifact.id),
365
+ warnings: [],
366
+ };
367
+ }
368
+
204
369
  async function loadResumableRun(request) {
205
370
  const normalized = structuredClone(request);
206
371
  normalized.theme = {
@@ -222,11 +387,16 @@ async function loadResumableRun(request) {
222
387
  throw error;
223
388
  }
224
389
 
225
- const content = record.stages?.find(({ id }) => id === 'content');
226
- const theme = record.stages?.find(({ id }) => id === 'theme');
390
+ const approvalUnresolved = ['pending', 'rejected'].includes(approval.status);
391
+ const completedBeforeApproval = ['content', 'theme', 'render', 'qa'].every(
392
+ (id) =>
393
+ ['passed', 'warned', 'skipped'].includes(
394
+ record.stages?.find((stage) => stage.id === id)?.status,
395
+ ),
396
+ );
227
397
  if (
228
- content?.status !== 'passed' ||
229
- theme?.status !== 'pending' ||
398
+ !approvalUnresolved ||
399
+ !completedBeforeApproval ||
230
400
  approval.runId !== record.runId
231
401
  ) {
232
402
  return null;
@@ -259,39 +429,118 @@ async function loadResumableRun(request) {
259
429
  }
260
430
 
261
431
  async function hydrateResumableState(state) {
262
- const [factBase, approval] = await Promise.all([
432
+ const [factBase, approval, theme, record] = await Promise.all([
263
433
  readJson(join(state.run.runRoot, 'source/fact-base.json')),
264
434
  readJson(join(state.run.runRoot, 'source/content-approval.json')),
435
+ readJson(join(state.run.runRoot, 'theme.resolved.json')),
436
+ readJson(state.run.buildRecordPath),
265
437
  ]);
266
438
  state.factBase = factBase;
267
- state.authorResultPaths = Array.isArray(approval.authorResultPaths)
268
- ? [...approval.authorResultPaths]
269
- : [];
439
+ state.theme = theme;
440
+ state.themeWarnings = [];
441
+ state.resumedApprovalStatus = approval.status;
442
+ // Reopened stages rerun against the corrected content, so carrying their
443
+ // prior warnings forward would outlive the fix that resolved them.
444
+ const rerunning = approval.status === 'rejected' ? REOPENED_ON_REJECTION : [];
445
+ state.warnings.push(
446
+ ...record.stages
447
+ .filter(({ id }) => !rerunning.includes(id))
448
+ .flatMap(({ warnings = [] }) =>
449
+ warnings.filter((warning) => !warning.startsWith('stage-reopened:')),
450
+ ),
451
+ );
270
452
  state.inputHashes = inputHashes(state.factBase);
271
453
  state.factBaseHash = canonicalHash(state.factBase);
272
454
  state.contentModels = [];
273
- for (const artifact of state.recipe.artifacts) {
274
- const base = createContentModel(
275
- state.recipe,
276
- artifact,
277
- state.run.slug,
278
- state.factBase,
279
- );
280
- const path = `source/content/${base.artifactId}.md`;
281
- const model = contentModelFromMarkdown(
282
- base,
283
- await readFile(join(state.run.runRoot, path), 'utf8'),
284
- );
285
- const validation = validateContentModel(state.recipe, model);
286
- if (!validation.valid) {
287
- throw codedError(
288
- 'E_CONTENT',
289
- `Reviewed content is invalid: ${validation.errors.join('; ')}`,
455
+ const persistedArtifacts = Array.isArray(approval.artifacts)
456
+ ? approval.artifacts
457
+ : recipeFloor(state.recipe).map((artifact) => ({
458
+ artifactId: artifact.id,
459
+ origin: 'floor',
460
+ authoring: artifact.authoring ?? 'markdown',
461
+ contentPath: `source/content/${artifact.id}.md`,
462
+ }));
463
+ for (const persisted of persistedArtifacts) {
464
+ const artifact = resolvedArtifactFromApproval(state.recipe, persisted);
465
+ if (artifact.authoring === 'html') {
466
+ artifact.shellContent = await readSkillFile(
467
+ `templates/${artifact.shell}.html`,
290
468
  );
291
469
  }
292
- state.contentModels.push(model);
293
- state.contentPaths.set(model.artifactId, path);
470
+ const content = await readFile(
471
+ join(state.run.runRoot, persisted.contentPath),
472
+ 'utf8',
473
+ );
474
+ state.resolvedArtifacts.push(artifact);
475
+ state.authoredContent.set(artifact.id, content);
476
+ state.contentPaths.set(artifact.id, persisted.contentPath);
477
+ if (persisted.authorResultPath) {
478
+ state.authorResultPaths.push(persisted.authorResultPath);
479
+ }
294
480
  }
481
+ const links = expansionLinks(state.resolvedArtifacts);
482
+ for (const artifact of state.resolvedArtifacts) {
483
+ if (artifact.authoring !== 'markdown') continue;
484
+ state.contentModels.push(
485
+ markdownContentModel(
486
+ artifact,
487
+ state.run.slug,
488
+ state.authoredContent.get(artifact.id),
489
+ artifact.origin === 'floor' ? links : [],
490
+ ),
491
+ );
492
+ }
493
+ state.expansion.accepted = state.resolvedArtifacts
494
+ .filter(({ origin }) => origin === 'expansion')
495
+ .map((artifact) => ({
496
+ id: artifact.id,
497
+ profileId: artifact.profileId,
498
+ rationale: 'Persisted approved expansion artifact.',
499
+ status: 'accepted',
500
+ profile: expansionProfile(state.recipe, artifact.profileId),
501
+ }));
502
+ await hydrateRenderedState(state);
503
+ }
504
+
505
+ async function hydrateRenderedState(state) {
506
+ state.rendered = [];
507
+ state.artifacts = [];
508
+ for (const artifact of state.resolvedArtifacts) {
509
+ const descriptor =
510
+ artifact.authoring === 'markdown'
511
+ ? await renderArtifact({
512
+ recipeArtifact: renderDescriptor(artifact),
513
+ content: state.contentModels.find(
514
+ ({ artifactId }) => artifactId === artifact.id,
515
+ ),
516
+ theme: state.theme,
517
+ renderStrategy: state.renderStrategy,
518
+ ...(state.run.request.publicBaseUrl && {
519
+ publicBaseUrl: state.run.request.publicBaseUrl,
520
+ }),
521
+ })
522
+ : artisticRender(state, artifact);
523
+ const html = await readFile(
524
+ join(state.run.runRoot, descriptor.renderedPath),
525
+ 'utf8',
526
+ );
527
+ const rendered = { ...descriptor, html };
528
+ state.rendered.push(rendered);
529
+ state.artifacts.push(artifactRecord(state, rendered));
530
+ }
531
+ }
532
+
533
+ function artifactRecord(state, rendered) {
534
+ return {
535
+ id: rendered.artifactId,
536
+ type: rendered.type,
537
+ contentPath: state.contentPaths.get(rendered.artifactId),
538
+ renderedPath: rendered.renderedPath,
539
+ mediaType: rendered.mediaType,
540
+ status: 'built',
541
+ hash: hashBytes(rendered.html),
542
+ rebuildable: false,
543
+ };
295
544
  }
296
545
 
297
546
  async function readJson(path) {
@@ -338,7 +587,7 @@ async function executeStage(run, id, options, operation) {
338
587
  id,
339
588
  status: result.status ?? 'passed',
340
589
  outputPaths: result.outputPaths ?? [],
341
- warnings: result.warnings ?? [],
590
+ ...(result.warnings !== undefined && { warnings: result.warnings }),
342
591
  });
343
592
  } catch (error) {
344
593
  await updateBuildRecord(run, {
@@ -512,7 +761,7 @@ async function persistFailureManifest(state, error, createdAt) {
512
761
  const record = JSON.parse(await readFile(state.run.buildRecordPath, 'utf8'));
513
762
  const recordedIds = new Set(state.artifacts.map(({ id }) => id));
514
763
  state.artifacts.push(
515
- ...state.recipe.artifacts
764
+ ...recipeFloor(state.recipe)
516
765
  .filter(({ id }) => !recordedIds.has(id))
517
766
  .map((artifact) => ({
518
767
  id: artifact.id,
@@ -567,162 +816,451 @@ function manifestFor(state, buildRecord, createdAt, immutableHashes) {
567
816
  };
568
817
  }
569
818
 
570
- async function createAuthoredContent(state, author) {
819
+ async function createAuthoredContent(state, options, now) {
820
+ const author = options.author;
571
821
  if (typeof author !== 'function') {
572
822
  throw codedError(
573
823
  'E_AUTHOR_REQUIRED',
574
- 'Unattended runs require an explicit author callback.',
824
+ 'Explainer runs require an explicit author callback in both modes.',
575
825
  );
576
826
  }
827
+ const trust = authorTrustContext(options, now);
577
828
 
578
- const authored = [];
579
- for (const artifact of state.recipe.artifacts) {
580
- const resultPath = `source/author/${artifact.id}.json`;
581
- const authorRequest = {
582
- schemaVersion: 'explainer-kit.author-request/v1',
583
- run: { runId: state.run.runId, slug: state.run.slug },
584
- recipe: {
585
- id: state.recipe.id,
586
- version: state.recipe.version,
587
- requiredNarrative: [...state.recipe.requiredNarrative],
588
- },
589
- artifact: {
590
- id: artifact.id,
591
- type: artifact.type,
592
- },
593
- narrativeOutline: state.recipe.requiredNarrative.map((id) => ({
594
- id,
595
- title: humanize(id),
596
- })),
597
- factBase: structuredClone(state.factBase),
598
- discovery: structuredClone(state.discovery),
599
- };
600
- const requestValidation = validateContract('author-request', authorRequest);
601
- if (!requestValidation.valid) {
602
- throw codedError(
603
- 'E_AUTHOR_REQUEST',
604
- contractErrorMessage('author request', requestValidation.errors),
605
- );
606
- }
829
+ const floor = await Promise.all(
830
+ recipeFloor(state.recipe).map((artifact) =>
831
+ authorArtifact(
832
+ state,
833
+ {
834
+ ...artifact,
835
+ origin: 'floor',
836
+ shell: artifact.authoring === 'html' ? artifact.template : undefined,
837
+ },
838
+ author,
839
+ trust,
840
+ ),
841
+ ),
842
+ );
843
+ const proposals = floor.flatMap(
844
+ ({ result }) => result.proposedArtifacts ?? [],
845
+ );
846
+ state.expansion = evaluateExpansionProposals(state.recipe, proposals);
847
+ if (!state.expansion.valid) {
848
+ throw codedError(
849
+ 'E_AUTHOR_RESULT',
850
+ `Invalid expansion proposals: ${state.expansion.errors.join('; ')}`,
851
+ );
852
+ }
607
853
 
608
- const result = await author(structuredClone(authorRequest));
609
- const resultValidation = validateContract('author-result', result);
610
- if (!resultValidation.valid) {
611
- throw codedError(
612
- 'E_AUTHOR_RESULT',
613
- contractErrorMessage('author result', resultValidation.errors),
614
- );
615
- }
616
- const sectionIds = result.content.sections.map(({ id }) => id);
617
- if (
618
- result.artifactId !== artifact.id ||
619
- sectionIds.length !== state.recipe.requiredNarrative.length ||
620
- state.recipe.requiredNarrative.some(
621
- (id, index) => sectionIds[index] !== id,
622
- )
623
- ) {
854
+ const expansions = [];
855
+ for (const accepted of state.expansion.accepted) {
856
+ const profile = accepted.profile;
857
+ const item = await authorArtifact(
858
+ state,
859
+ {
860
+ id: accepted.id,
861
+ type: profile.type,
862
+ authoring: profile.authoring,
863
+ briefRef: profile.briefRef,
864
+ shell: profile.shell,
865
+ template:
866
+ profile.authoring === 'markdown'
867
+ ? templateForType(profile.type)
868
+ : profile.shell,
869
+ required: false,
870
+ origin: 'expansion',
871
+ profileId: profile.profileId,
872
+ },
873
+ author,
874
+ trust,
875
+ );
876
+ if ((item.result.proposedArtifacts ?? []).length > 0) {
624
877
  throw codedError(
625
878
  'E_AUTHOR_RESULT',
626
- `Author result for ${artifact.id} must contain the exact required section IDs in order.`,
879
+ `Expansion artifact ${accepted.id} cannot propose nested artifacts.`,
627
880
  );
628
881
  }
629
- const dumpCheck = checkSourceDumping({
630
- authoredSections: result.content.sections.map(({ id, prose }) => ({
631
- id,
632
- text: prose,
633
- })),
634
- sourceTexts: [
635
- ...state.factBase.claims,
636
- ...state.factBase.unresolvedClaims,
637
- ].map(({ text }) => text),
638
- });
639
- if (!dumpCheck.valid) {
640
- throw codedError(
641
- 'E_SOURCE_DUMP',
642
- dumpCheck.issues.map(({ message }) => message).join('; '),
882
+ expansions.push(item);
883
+ }
884
+
885
+ const authored = [...floor, ...expansions];
886
+ state.resolvedArtifacts = authored.map(({ artifact }) => artifact);
887
+ const links = expansionLinks(state.resolvedArtifacts);
888
+ for (const item of authored) {
889
+ await writeJsonAtomic(state.run.runRoot, item.resultPath, item.result);
890
+ await writeTextAtomic(state.run.runRoot, item.contentPath, item.content);
891
+ state.authorResultPaths.push(item.resultPath);
892
+ state.contentPaths.set(item.artifact.id, item.contentPath);
893
+ state.authoredContent.set(item.artifact.id, item.content);
894
+ if (item.artifact.authoring === 'markdown') {
895
+ state.contentModels.push(
896
+ assertValidContentModel(
897
+ state.recipe,
898
+ markdownContentModel(
899
+ item.artifact,
900
+ state.run.slug,
901
+ item.content,
902
+ item.artifact.origin === 'floor' ? links : [],
903
+ ),
904
+ item.artifact,
905
+ ),
643
906
  );
644
907
  }
908
+ }
909
+ }
645
910
 
646
- authored.push({
647
- result: structuredClone(result),
648
- resultPath,
649
- model: {
650
- artifactId: artifact.id,
651
- slug: state.run.slug,
652
- title: result.content.title,
653
- description: result.content.description,
654
- ...(result.content.eyebrow && { eyebrow: result.content.eyebrow }),
655
- ...(result.content.footer && { footer: result.content.footer }),
656
- sections: result.content.sections.map(({ id, title, prose }) => ({
657
- id,
658
- title,
659
- content: prose,
660
- })),
661
- artifactLinks: result.content.artifactLinks ?? [],
662
- },
663
- });
911
+ // Expansion artifacts are not recipe-floor entries, so only floor content models
912
+ // can be checked against the floor-scoped contract.
913
+ function assertValidContentModel(recipe, model, artifact) {
914
+ if (artifact.origin !== 'floor') return model;
915
+ const result = validateContentModel(recipe, model);
916
+ if (!result.valid) {
917
+ throw codedError(
918
+ 'E_CONTENT_MODEL',
919
+ `Authored content for ${artifact.id} violates the narrative contract: ${result.errors.join('; ')}`,
920
+ );
664
921
  }
922
+ return model;
923
+ }
665
924
 
666
- for (const item of authored) {
667
- await writeJsonAtomic(state.run.runRoot, item.resultPath, item.result);
925
+ // Provenance authenticity cannot come from the party being identified, so
926
+ // identity and method are bound to trusted caller configuration and the
927
+ // generation time is stamped from the run's injected clock.
928
+ function authorTrustContext(options, now) {
929
+ const generatedAt = now();
930
+ const declared = options.authorProvenance;
931
+ if (declared === undefined) return { generatedAt, bound: null };
932
+ const valid =
933
+ typeof declared === 'object' &&
934
+ declared !== null &&
935
+ !Array.isArray(declared) &&
936
+ typeof declared.authorId === 'string' &&
937
+ declared.authorId.length > 0 &&
938
+ (declared.method === undefined ||
939
+ (typeof declared.method === 'string' && declared.method.length > 0));
940
+ if (!valid) {
941
+ throw codedError(
942
+ 'E_AUTHOR_PROVENANCE',
943
+ 'Trusted author provenance requires a non-empty authorId and, when present, a non-empty method.',
944
+ );
668
945
  }
669
946
  return {
670
- models: authored.map(({ model }) => model),
671
- resultPaths: authored.map(({ resultPath }) => resultPath),
947
+ generatedAt,
948
+ bound: {
949
+ authorId: declared.authorId,
950
+ ...(declared.method !== undefined && { method: declared.method }),
951
+ },
672
952
  };
673
953
  }
674
954
 
955
+ function resolveAuthorProvenance(claimed, trust, artifactId) {
956
+ if ('trust' in claimed) {
957
+ throw codedError(
958
+ 'E_AUTHOR_PROVENANCE',
959
+ `Author result for ${artifactId} must not assert a provenance trust level; the core stamps it.`,
960
+ );
961
+ }
962
+ if (trust.bound === null) {
963
+ return {
964
+ ...claimed,
965
+ generatedAt: trust.generatedAt,
966
+ trust: 'self-asserted',
967
+ };
968
+ }
969
+ if (
970
+ claimed.authorId !== trust.bound.authorId ||
971
+ (trust.bound.method !== undefined && claimed.method !== trust.bound.method)
972
+ ) {
973
+ throw codedError(
974
+ 'E_AUTHOR_PROVENANCE',
975
+ `Author result for ${artifactId} claims provenance that does not match the trusted caller context.`,
976
+ );
977
+ }
978
+ return {
979
+ ...trust.bound,
980
+ generatedAt: trust.generatedAt,
981
+ trust: 'caller-bound',
982
+ };
983
+ }
984
+
985
+ async function authorArtifact(state, artifact, author, trust) {
986
+ const brief = await readSkillFile(artifact.briefRef);
987
+ const shellContent =
988
+ artifact.authoring === 'html'
989
+ ? await readSkillFile(`templates/${artifact.shell}.html`)
990
+ : undefined;
991
+ const resolvedArtifact = {
992
+ ...artifact,
993
+ ...(shellContent && { shellContent }),
994
+ };
995
+ const requiredNarrative =
996
+ artifact.origin === 'floor'
997
+ ? recipeRequiredNarrative(state.recipe, artifact.id)
998
+ : [];
999
+ const authorRequest = {
1000
+ schemaVersion: 'explainer-kit.author-request/v2',
1001
+ artifactId: artifact.id,
1002
+ artifactType: artifact.type,
1003
+ authoring: artifact.authoring,
1004
+ brief,
1005
+ factBase: structuredClone(state.factBase),
1006
+ ...(shellContent && { shell: shellContent }),
1007
+ theme: structuredClone(state.theme),
1008
+ ...(artifact.origin === 'floor' &&
1009
+ requiredNarrative.length > 0 && {
1010
+ floor: { requiredNarrative },
1011
+ }),
1012
+ };
1013
+ const requestValidation = validateContract(
1014
+ 'author-request/v2',
1015
+ authorRequest,
1016
+ );
1017
+ if (!requestValidation.valid) {
1018
+ throw codedError(
1019
+ 'E_AUTHOR_REQUEST',
1020
+ contractErrorMessage('author request', requestValidation.errors),
1021
+ );
1022
+ }
1023
+
1024
+ const result = await author(structuredClone(authorRequest));
1025
+ const resultValidation = validateContract('author-result/v2', result);
1026
+ if (!resultValidation.valid) {
1027
+ throw codedError(
1028
+ 'E_AUTHOR_RESULT',
1029
+ contractErrorMessage('author result', resultValidation.errors),
1030
+ );
1031
+ }
1032
+ const content = result.content?.[artifact.authoring];
1033
+ if (result.artifactId !== artifact.id || typeof content !== 'string') {
1034
+ throw codedError(
1035
+ 'E_AUTHOR_RESULT',
1036
+ `Author result for ${artifact.id} must match its identity and ${artifact.authoring} path.`,
1037
+ );
1038
+ }
1039
+ const retained = {
1040
+ ...structuredClone(result),
1041
+ provenance: resolveAuthorProvenance(result.provenance, trust, artifact.id),
1042
+ };
1043
+ const retainedValidation = validateContract('author-result/v2', retained);
1044
+ if (!retainedValidation.valid) {
1045
+ throw codedError(
1046
+ 'E_AUTHOR_PROVENANCE',
1047
+ contractErrorMessage('retained author result', retainedValidation.errors),
1048
+ );
1049
+ }
1050
+ const dumpCheck = checkSourceDumping({
1051
+ authoredText: content,
1052
+ sourceTexts: [
1053
+ ...state.factBase.claims,
1054
+ ...state.factBase.unresolvedClaims,
1055
+ ].map(({ text }) => text),
1056
+ });
1057
+ state.qaErrors.push(
1058
+ ...dumpCheck.issues.map((issue) => ({
1059
+ code: issue.code,
1060
+ message: issue.message,
1061
+ })),
1062
+ );
1063
+
1064
+ return {
1065
+ artifact: resolvedArtifact,
1066
+ result: retained,
1067
+ resultPath: `source/author/${artifact.id}.json`,
1068
+ content,
1069
+ contentPath: `source/content/${artifact.id}.${artifact.authoring === 'markdown' ? 'md' : 'html'}`,
1070
+ };
1071
+ }
1072
+
1073
+ async function readSkillFile(relativePath) {
1074
+ return readFile(new URL(`../${relativePath}`, import.meta.url), 'utf8');
1075
+ }
1076
+
675
1077
  function contractErrorMessage(label, errors) {
676
1078
  return `Invalid ${label}: ${errors
677
1079
  .map(({ path, message }) => `${path}: ${message}`)
678
1080
  .join('; ')}`;
679
1081
  }
680
1082
 
681
- function createContentModel(recipe, artifact, slug, factBase) {
682
- const facts = [
683
- ...factBase.claims.map(({ text, sections }) => ({ text, sections })),
684
- ...factBase.unresolvedClaims.map(({ text, sections }) => ({
685
- text: `Needs confirmation: ${text}`,
686
- sections,
687
- })),
688
- ];
689
- const unknownSections = [
690
- ...new Set(
691
- facts
692
- .flatMap(({ sections }) => sections ?? [])
693
- .filter((section) => !recipe.requiredNarrative.includes(section)),
694
- ),
695
- ];
696
- if (unknownSections.length > 0) {
697
- throw codedError(
698
- 'E_CONTENT',
699
- `Unknown narrative section tags: ${unknownSections.join(', ')}`,
700
- );
1083
+ // renderArtifact validates an exact key set, so normalized v2 floor entries
1084
+ // must be narrowed before they reach it.
1085
+ function renderDescriptor(artifact) {
1086
+ return {
1087
+ id: artifact.id,
1088
+ type: artifact.type,
1089
+ template: artifact.template,
1090
+ required: artifact.required,
1091
+ origin: artifact.origin,
1092
+ };
1093
+ }
1094
+
1095
+ const LEAD_SECTION_IDS = ['overview', 'introduction', 'lead'];
1096
+
1097
+ function markdownContentModel(artifact, slug, markdown, artifactLinks) {
1098
+ const titleMatch = markdown.match(/^# (.+)$/m);
1099
+ const title = titleMatch?.[1]?.trim() ?? humanize(artifact.id);
1100
+ const headings = [...markdown.matchAll(/^## (.+)$/gm)];
1101
+ if (headings.length === 0) {
1102
+ return contentModel({
1103
+ artifact,
1104
+ slug,
1105
+ title,
1106
+ artifactLinks,
1107
+ sections: [{ id: 'overview', title: 'Overview', content: markdown }],
1108
+ });
1109
+ }
1110
+
1111
+ // Prose between the document title and the first `##` is authored content,
1112
+ // so it is carried as a leading section rather than silently dropped.
1113
+ const bodyStart = titleMatch ? titleMatch.index + titleMatch[0].length : 0;
1114
+ const lead = markdown.slice(bodyStart, headings[0].index).trim();
1115
+ const authoredIds = new Set(
1116
+ headings.map((heading) => slugify(heading[1].trim())),
1117
+ );
1118
+ const sections = headings.map((heading, index) => ({
1119
+ id: slugify(heading[1].trim()),
1120
+ title: heading[1].trim(),
1121
+ content: markdown
1122
+ .slice(
1123
+ heading.index + heading[0].length,
1124
+ headings[index + 1]?.index ?? markdown.length,
1125
+ )
1126
+ .trim(),
1127
+ }));
1128
+ if (lead.length > 0) {
1129
+ const leadId =
1130
+ LEAD_SECTION_IDS.find((candidate) => !authoredIds.has(candidate)) ??
1131
+ 'lead';
1132
+ sections.unshift({
1133
+ id: leadId,
1134
+ title: humanize(leadId),
1135
+ content: lead,
1136
+ });
701
1137
  }
1138
+
1139
+ return contentModel({
1140
+ artifact,
1141
+ slug,
1142
+ title,
1143
+ artifactLinks,
1144
+ sections: disambiguateSectionIds(sections),
1145
+ });
1146
+ }
1147
+
1148
+ // A repeated heading is legitimate authoring, but duplicate anchors break
1149
+ // navigation, so later collisions get a deterministic suffix.
1150
+ function disambiguateSectionIds(sections) {
1151
+ const used = new Map();
1152
+ return sections.map((section) => {
1153
+ const seen = used.get(section.id) ?? 0;
1154
+ used.set(section.id, seen + 1);
1155
+ if (seen === 0) return section;
1156
+ let candidate = `${section.id}-${seen + 1}`;
1157
+ let offset = seen + 1;
1158
+ while (used.has(candidate)) {
1159
+ offset += 1;
1160
+ candidate = `${section.id}-${offset}`;
1161
+ }
1162
+ used.set(candidate, 1);
1163
+ return { ...section, id: candidate };
1164
+ });
1165
+ }
1166
+
1167
+ function contentModel({ artifact, slug, title, artifactLinks, sections }) {
702
1168
  return {
703
1169
  artifactId: artifact.id,
704
1170
  slug,
705
- title: humanize(recipe.id),
706
- description: `Approved-source ${humanize(recipe.id).toLowerCase()}.`,
1171
+ title,
1172
+ description: `Authored ${humanize(artifact.id).toLowerCase()}.`,
707
1173
  eyebrow: 'Explainer Kit',
708
- footer: 'Generated from the retained reconciled fact base.',
709
- sections: recipe.requiredNarrative.map((id) => {
710
- const sectionFacts = facts
711
- .filter(({ sections }) => !sections || sections.includes(id))
712
- .map(({ text }) => text);
713
- return {
714
- id,
715
- title: humanize(id),
716
- content:
717
- sectionFacts.length > 0
718
- ? sectionFacts.join(' ')
719
- : 'No confirmed facts.',
720
- };
721
- }),
722
- artifactLinks: [],
1174
+ footer: 'Authored from the retained reconciled fact base.',
1175
+ sections,
1176
+ artifactLinks,
723
1177
  };
724
1178
  }
725
1179
 
1180
+ function approvalArtifacts(state) {
1181
+ return state.resolvedArtifacts.map((artifact) => ({
1182
+ artifactId: artifact.id,
1183
+ origin: artifact.origin,
1184
+ ...(artifact.profileId && { profileId: artifact.profileId }),
1185
+ authoring: artifact.authoring,
1186
+ contentPath:
1187
+ state.contentPaths.get(artifact.id) ??
1188
+ `source/content/${artifact.id}.${artifact.authoring === 'markdown' ? 'md' : 'html'}`,
1189
+ authorResultPath:
1190
+ state.authorResultPaths.find((path) =>
1191
+ path.endsWith(`/${artifact.id}.json`),
1192
+ ) ?? `source/author/${artifact.id}.json`,
1193
+ }));
1194
+ }
1195
+
1196
+ function expansionLinks(artifacts) {
1197
+ return artifacts
1198
+ .filter(({ origin }) => origin === 'expansion')
1199
+ .map((artifact) => ({
1200
+ id: artifact.id,
1201
+ type: artifact.type,
1202
+ label: humanize(artifact.id),
1203
+ origin: 'expansion',
1204
+ }));
1205
+ }
1206
+
1207
+ function expansionProfile(recipe, profileId) {
1208
+ return recipeExpansion(recipe).profiles.find(
1209
+ (profile) => profile.profileId === profileId,
1210
+ );
1211
+ }
1212
+
1213
+ function resolvedArtifactFromApproval(recipe, persisted) {
1214
+ if (persisted.origin === 'floor') {
1215
+ const artifact = recipeFloor(recipe).find(
1216
+ ({ id }) => id === persisted.artifactId,
1217
+ );
1218
+ if (!artifact) {
1219
+ throw codedError(
1220
+ 'E_APPROVAL_RESUME',
1221
+ `Approval references unknown floor artifact ${persisted.artifactId}.`,
1222
+ );
1223
+ }
1224
+ return {
1225
+ ...artifact,
1226
+ origin: 'floor',
1227
+ shell: artifact.authoring === 'html' ? artifact.template : undefined,
1228
+ };
1229
+ }
1230
+ const profile = expansionProfile(recipe, persisted.profileId);
1231
+ if (!profile) {
1232
+ throw codedError(
1233
+ 'E_APPROVAL_RESUME',
1234
+ `Approval references unknown expansion profile ${persisted.profileId}.`,
1235
+ );
1236
+ }
1237
+ return {
1238
+ id: persisted.artifactId,
1239
+ type: profile.type,
1240
+ authoring: profile.authoring,
1241
+ briefRef: profile.briefRef,
1242
+ shell: profile.shell,
1243
+ template:
1244
+ profile.authoring === 'markdown'
1245
+ ? templateForType(profile.type)
1246
+ : profile.shell,
1247
+ required: false,
1248
+ origin: 'expansion',
1249
+ profileId: profile.profileId,
1250
+ };
1251
+ }
1252
+
1253
+ function templateForType(type) {
1254
+ return (
1255
+ {
1256
+ hub: 'house-style',
1257
+ diagram: 'diagram-shell',
1258
+ explainer: 'engineer-tour',
1259
+ deck: 'deck-shell',
1260
+ }[type] ?? 'house-style'
1261
+ );
1262
+ }
1263
+
726
1264
  function validateRecipeSources(recipe, binding) {
727
1265
  const primaryRole = recipe.sourceRoles[0]?.role;
728
1266
  const bindings =
@@ -781,33 +1319,6 @@ function factBaseMarkdown(factBase) {
781
1319
  return `# Fact base\n\n## Confirmed claims\n\n${confirmed || '- None.'}\n\n## Unresolved claims\n\n${unresolved || '- None.'}\n`;
782
1320
  }
783
1321
 
784
- function contentMarkdown(model) {
785
- return `# ${model.title}\n\n${model.sections
786
- .map(({ title, content }) => `## ${title}\n\n${content}`)
787
- .join('\n\n')}\n`;
788
- }
789
-
790
- function contentModelFromMarkdown(base, markdown) {
791
- const title = markdown.match(/^# (.+)$/m)?.[1]?.trim();
792
- const sections = [];
793
- const headings = [...markdown.matchAll(/^## (.+)$/gm)];
794
- for (const [index, heading] of headings.entries()) {
795
- const start = heading.index + heading[0].length;
796
- const end = headings[index + 1]?.index ?? markdown.length;
797
- const sectionTitle = heading[1].trim();
798
- sections.push({
799
- id: slugify(sectionTitle),
800
- title: sectionTitle,
801
- content: markdown.slice(start, end).trim(),
802
- });
803
- }
804
- return {
805
- ...base,
806
- ...(title && { title }),
807
- sections,
808
- };
809
- }
810
-
811
1322
  function assertValidRequest(request) {
812
1323
  const result = validateContract('run-request', request);
813
1324
  if (!result.valid) {
@@ -831,12 +1342,18 @@ function resultFor(state, error) {
831
1342
  : state.approval?.canResume === false
832
1343
  ? 'incomplete'
833
1344
  : 'built-not-durable',
1345
+ ...(state.approval?.record?.marking && {
1346
+ marking: state.approval.record.marking,
1347
+ }),
834
1348
  warnings: [...new Set(state.warnings)],
835
1349
  discovery: state.discovery,
836
1350
  ...(state.approval && {
837
1351
  approval: {
838
1352
  status: state.approval.status,
839
1353
  path: state.approval.path,
1354
+ ...(state.approval.record.marking && {
1355
+ marking: state.approval.record.marking,
1356
+ }),
840
1357
  },
841
1358
  }),
842
1359
  ...(error && {