@open-agent-toolkit/cli 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/assets/config/dispatch-matrix-recommendation.json +15 -5
  2. package/assets/docs/cli-utilities/configuration.md +10 -5
  3. package/assets/docs/contributing/index.md +1 -0
  4. package/assets/docs/contributing/verifying-cursor-pins.md +282 -0
  5. package/assets/docs/workflows/projects/artifacts.md +1 -1
  6. package/assets/docs/workflows/projects/dispatch-ceiling.md +50 -4
  7. package/assets/docs/workflows/skills/explainer-kit.md +188 -23
  8. package/assets/docs/workflows/skills/index.md +1 -1
  9. package/assets/public-package-versions.json +4 -4
  10. package/assets/skills/explainer-kit/SKILL.md +87 -25
  11. package/assets/skills/explainer-kit/briefs/deep-dive.md +35 -0
  12. package/assets/skills/explainer-kit/briefs/engineer-tour.md +45 -0
  13. package/assets/skills/explainer-kit/briefs/program-recap.md +41 -0
  14. package/assets/skills/explainer-kit/briefs/project-explainer.md +38 -0
  15. package/assets/skills/explainer-kit/briefs/project-page.md +38 -0
  16. package/assets/skills/explainer-kit/briefs/project-recap.md +47 -0
  17. package/assets/skills/explainer-kit/briefs/supporting-diagram.md +28 -0
  18. package/assets/skills/explainer-kit/briefs/walkthrough-deck.md +35 -0
  19. package/assets/skills/explainer-kit/examples/project-recap/content.md +57 -14
  20. package/assets/skills/explainer-kit/examples/project-recap/fact-base.json +104 -0
  21. package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +26 -4
  22. package/assets/skills/explainer-kit/recipes/engineer-tour.json +27 -10
  23. package/assets/skills/explainer-kit/recipes/program-recap.json +35 -11
  24. package/assets/skills/explainer-kit/recipes/project-explainer.json +27 -10
  25. package/assets/skills/explainer-kit/recipes/project-recap.json +43 -11
  26. package/assets/skills/explainer-kit/references/contracts.md +45 -19
  27. package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +41 -0
  28. package/assets/skills/explainer-kit/schemas/author-result.v2.schema.json +52 -0
  29. package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +442 -0
  30. package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +223 -10
  31. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +28 -43
  32. package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +237 -0
  33. package/assets/skills/explainer-kit/scripts/lib/html-safety.mjs +680 -0
  34. package/assets/skills/explainer-kit/scripts/lib/markdown.mjs +414 -0
  35. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +309 -10
  36. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +313 -41
  37. package/assets/skills/explainer-kit/scripts/lib/records.mjs +61 -0
  38. package/assets/skills/explainer-kit/scripts/lib/render.mjs +166 -12
  39. package/assets/skills/explainer-kit/scripts/render-qa.mjs +147 -2
  40. package/assets/skills/explainer-kit/scripts/run.mjs +796 -272
  41. package/assets/skills/explainer-kit/templates/deck-shell.html +25 -5
  42. package/assets/skills/explainer-kit/templates/diagram-shell.html +29 -7
  43. package/assets/skills/explainer-kit/templates/engineer-tour.html +133 -9
  44. package/assets/skills/explainer-kit/templates/house-style.html +82 -0
  45. package/assets/skills/oat-explainer-kit/SKILL.md +16 -9
  46. package/assets/skills/oat-explainer-kit/references/author-callback.md +51 -0
  47. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +10 -8
  48. package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +14 -0
  49. package/assets/skills/oat-explainer-kit/scripts/run.mjs +7 -11
  50. package/assets/skills/oat-project-complete/SKILL.md +18 -2
  51. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  52. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +7 -1
  53. package/assets/skills/oat-wave-execute/SKILL.md +12 -19
  54. package/assets/skills/oat-wave-program/SKILL.md +12 -13
  55. package/assets/skills/subagent-orchestration/SKILL.md +1 -1
  56. package/assets/skills/subagent-orchestration/references/evidence-and-refresh.md +153 -15
  57. package/assets/skills/subagent-orchestration/references/model-selection-principles.md +75 -3
  58. package/assets/skills/subagent-orchestration/references/provider-claude.md +95 -35
  59. package/assets/skills/subagent-orchestration/references/provider-codex.md +36 -10
  60. package/assets/skills/subagent-orchestration/references/provider-cursor.md +91 -15
  61. package/dist/providers/cursor/codec/catalog.d.ts +19 -1
  62. package/dist/providers/cursor/codec/catalog.d.ts.map +1 -1
  63. package/dist/providers/cursor/codec/catalog.js +54 -0
  64. package/package.json +2 -2
  65. package/assets/skills/explainer-kit/schemas/author-request.schema.json +0 -85
  66. package/assets/skills/explainer-kit/schemas/author-result.schema.json +0 -65
@@ -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,119 @@ 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 =
445
+ approval.status === 'rejected' ? REOPENED_ON_REJECTION : [];
446
+ state.warnings.push(
447
+ ...record.stages
448
+ .filter(({ id }) => !rerunning.includes(id))
449
+ .flatMap(({ warnings = [] }) =>
450
+ warnings.filter((warning) => !warning.startsWith('stage-reopened:')),
451
+ ),
452
+ );
270
453
  state.inputHashes = inputHashes(state.factBase);
271
454
  state.factBaseHash = canonicalHash(state.factBase);
272
455
  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('; ')}`,
456
+ const persistedArtifacts = Array.isArray(approval.artifacts)
457
+ ? approval.artifacts
458
+ : recipeFloor(state.recipe).map((artifact) => ({
459
+ artifactId: artifact.id,
460
+ origin: 'floor',
461
+ authoring: artifact.authoring ?? 'markdown',
462
+ contentPath: `source/content/${artifact.id}.md`,
463
+ }));
464
+ for (const persisted of persistedArtifacts) {
465
+ const artifact = resolvedArtifactFromApproval(state.recipe, persisted);
466
+ if (artifact.authoring === 'html') {
467
+ artifact.shellContent = await readSkillFile(
468
+ `templates/${artifact.shell}.html`,
290
469
  );
291
470
  }
292
- state.contentModels.push(model);
293
- state.contentPaths.set(model.artifactId, path);
471
+ const content = await readFile(
472
+ join(state.run.runRoot, persisted.contentPath),
473
+ 'utf8',
474
+ );
475
+ state.resolvedArtifacts.push(artifact);
476
+ state.authoredContent.set(artifact.id, content);
477
+ state.contentPaths.set(artifact.id, persisted.contentPath);
478
+ if (persisted.authorResultPath) {
479
+ state.authorResultPaths.push(persisted.authorResultPath);
480
+ }
294
481
  }
482
+ const links = expansionLinks(state.resolvedArtifacts);
483
+ for (const artifact of state.resolvedArtifacts) {
484
+ if (artifact.authoring !== 'markdown') continue;
485
+ state.contentModels.push(
486
+ markdownContentModel(
487
+ artifact,
488
+ state.run.slug,
489
+ state.authoredContent.get(artifact.id),
490
+ artifact.origin === 'floor' ? links : [],
491
+ ),
492
+ );
493
+ }
494
+ state.expansion.accepted = state.resolvedArtifacts
495
+ .filter(({ origin }) => origin === 'expansion')
496
+ .map((artifact) => ({
497
+ id: artifact.id,
498
+ profileId: artifact.profileId,
499
+ rationale: 'Persisted approved expansion artifact.',
500
+ status: 'accepted',
501
+ profile: expansionProfile(state.recipe, artifact.profileId),
502
+ }));
503
+ await hydrateRenderedState(state);
504
+ }
505
+
506
+ async function hydrateRenderedState(state) {
507
+ state.rendered = [];
508
+ state.artifacts = [];
509
+ for (const artifact of state.resolvedArtifacts) {
510
+ const descriptor =
511
+ artifact.authoring === 'markdown'
512
+ ? await renderArtifact({
513
+ recipeArtifact: renderDescriptor(artifact),
514
+ content: state.contentModels.find(
515
+ ({ artifactId }) => artifactId === artifact.id,
516
+ ),
517
+ theme: state.theme,
518
+ renderStrategy: state.renderStrategy,
519
+ ...(state.run.request.publicBaseUrl && {
520
+ publicBaseUrl: state.run.request.publicBaseUrl,
521
+ }),
522
+ })
523
+ : artisticRender(state, artifact);
524
+ const html = await readFile(
525
+ join(state.run.runRoot, descriptor.renderedPath),
526
+ 'utf8',
527
+ );
528
+ const rendered = { ...descriptor, html };
529
+ state.rendered.push(rendered);
530
+ state.artifacts.push(artifactRecord(state, rendered));
531
+ }
532
+ }
533
+
534
+ function artifactRecord(state, rendered) {
535
+ return {
536
+ id: rendered.artifactId,
537
+ type: rendered.type,
538
+ contentPath: state.contentPaths.get(rendered.artifactId),
539
+ renderedPath: rendered.renderedPath,
540
+ mediaType: rendered.mediaType,
541
+ status: 'built',
542
+ hash: hashBytes(rendered.html),
543
+ rebuildable: false,
544
+ };
295
545
  }
296
546
 
297
547
  async function readJson(path) {
@@ -338,7 +588,7 @@ async function executeStage(run, id, options, operation) {
338
588
  id,
339
589
  status: result.status ?? 'passed',
340
590
  outputPaths: result.outputPaths ?? [],
341
- warnings: result.warnings ?? [],
591
+ ...(result.warnings !== undefined && { warnings: result.warnings }),
342
592
  });
343
593
  } catch (error) {
344
594
  await updateBuildRecord(run, {
@@ -512,7 +762,7 @@ async function persistFailureManifest(state, error, createdAt) {
512
762
  const record = JSON.parse(await readFile(state.run.buildRecordPath, 'utf8'));
513
763
  const recordedIds = new Set(state.artifacts.map(({ id }) => id));
514
764
  state.artifacts.push(
515
- ...state.recipe.artifacts
765
+ ...recipeFloor(state.recipe)
516
766
  .filter(({ id }) => !recordedIds.has(id))
517
767
  .map((artifact) => ({
518
768
  id: artifact.id,
@@ -567,162 +817,457 @@ function manifestFor(state, buildRecord, createdAt, immutableHashes) {
567
817
  };
568
818
  }
569
819
 
570
- async function createAuthoredContent(state, author) {
820
+ async function createAuthoredContent(state, options, now) {
821
+ const author = options.author;
571
822
  if (typeof author !== 'function') {
572
823
  throw codedError(
573
824
  'E_AUTHOR_REQUIRED',
574
- 'Unattended runs require an explicit author callback.',
825
+ 'Explainer runs require an explicit author callback in both modes.',
575
826
  );
576
827
  }
828
+ const trust = authorTrustContext(options, now);
577
829
 
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
- }
830
+ const floor = await Promise.all(
831
+ recipeFloor(state.recipe).map((artifact) =>
832
+ authorArtifact(
833
+ state,
834
+ {
835
+ ...artifact,
836
+ origin: 'floor',
837
+ shell: artifact.authoring === 'html' ? artifact.template : undefined,
838
+ },
839
+ author,
840
+ trust,
841
+ ),
842
+ ),
843
+ );
844
+ const proposals = floor.flatMap(
845
+ ({ result }) => result.proposedArtifacts ?? [],
846
+ );
847
+ state.expansion = evaluateExpansionProposals(state.recipe, proposals);
848
+ if (!state.expansion.valid) {
849
+ throw codedError(
850
+ 'E_AUTHOR_RESULT',
851
+ `Invalid expansion proposals: ${state.expansion.errors.join('; ')}`,
852
+ );
853
+ }
607
854
 
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
- ) {
855
+ const expansions = [];
856
+ for (const accepted of state.expansion.accepted) {
857
+ const profile = accepted.profile;
858
+ const item = await authorArtifact(
859
+ state,
860
+ {
861
+ id: accepted.id,
862
+ type: profile.type,
863
+ authoring: profile.authoring,
864
+ briefRef: profile.briefRef,
865
+ shell: profile.shell,
866
+ template:
867
+ profile.authoring === 'markdown'
868
+ ? templateForType(profile.type)
869
+ : profile.shell,
870
+ required: false,
871
+ origin: 'expansion',
872
+ profileId: profile.profileId,
873
+ },
874
+ author,
875
+ trust,
876
+ );
877
+ if ((item.result.proposedArtifacts ?? []).length > 0) {
624
878
  throw codedError(
625
879
  'E_AUTHOR_RESULT',
626
- `Author result for ${artifact.id} must contain the exact required section IDs in order.`,
880
+ `Expansion artifact ${accepted.id} cannot propose nested artifacts.`,
627
881
  );
628
882
  }
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('; '),
883
+ expansions.push(item);
884
+ }
885
+
886
+ const authored = [...floor, ...expansions];
887
+ state.resolvedArtifacts = authored.map(({ artifact }) => artifact);
888
+ const links = expansionLinks(state.resolvedArtifacts);
889
+ for (const item of authored) {
890
+ await writeJsonAtomic(state.run.runRoot, item.resultPath, item.result);
891
+ await writeTextAtomic(state.run.runRoot, item.contentPath, item.content);
892
+ state.authorResultPaths.push(item.resultPath);
893
+ state.contentPaths.set(item.artifact.id, item.contentPath);
894
+ state.authoredContent.set(item.artifact.id, item.content);
895
+ if (item.artifact.authoring === 'markdown') {
896
+ state.contentModels.push(
897
+ assertValidContentModel(
898
+ state.recipe,
899
+ markdownContentModel(
900
+ item.artifact,
901
+ state.run.slug,
902
+ item.content,
903
+ item.artifact.origin === 'floor' ? links : [],
904
+ ),
905
+ item.artifact,
906
+ ),
643
907
  );
644
908
  }
909
+ }
910
+ }
645
911
 
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
- });
912
+ // Expansion artifacts are not recipe-floor entries, so only floor content models
913
+ // can be checked against the floor-scoped contract.
914
+ function assertValidContentModel(recipe, model, artifact) {
915
+ if (artifact.origin !== 'floor') return model;
916
+ const result = validateContentModel(recipe, model);
917
+ if (!result.valid) {
918
+ throw codedError(
919
+ 'E_CONTENT_MODEL',
920
+ `Authored content for ${artifact.id} violates the narrative contract: ${result.errors.join('; ')}`,
921
+ );
922
+ }
923
+ return model;
924
+ }
925
+
926
+ // Provenance authenticity cannot come from the party being identified, so
927
+ // identity and method are bound to trusted caller configuration and the
928
+ // generation time is stamped from the run's injected clock.
929
+ function authorTrustContext(options, now) {
930
+ const generatedAt = now();
931
+ const declared = options.authorProvenance;
932
+ if (declared === undefined) return { generatedAt, bound: null };
933
+ const valid =
934
+ typeof declared === 'object' &&
935
+ declared !== null &&
936
+ !Array.isArray(declared) &&
937
+ typeof declared.authorId === 'string' &&
938
+ declared.authorId.length > 0 &&
939
+ (declared.method === undefined ||
940
+ (typeof declared.method === 'string' && declared.method.length > 0));
941
+ if (!valid) {
942
+ throw codedError(
943
+ 'E_AUTHOR_PROVENANCE',
944
+ 'Trusted author provenance requires a non-empty authorId and, when present, a non-empty method.',
945
+ );
664
946
  }
947
+ return {
948
+ generatedAt,
949
+ bound: {
950
+ authorId: declared.authorId,
951
+ ...(declared.method !== undefined && { method: declared.method }),
952
+ },
953
+ };
954
+ }
665
955
 
666
- for (const item of authored) {
667
- await writeJsonAtomic(state.run.runRoot, item.resultPath, item.result);
956
+ function resolveAuthorProvenance(claimed, trust, artifactId) {
957
+ if ('trust' in claimed) {
958
+ throw codedError(
959
+ 'E_AUTHOR_PROVENANCE',
960
+ `Author result for ${artifactId} must not assert a provenance trust level; the core stamps it.`,
961
+ );
962
+ }
963
+ if (trust.bound === null) {
964
+ return {
965
+ ...claimed,
966
+ generatedAt: trust.generatedAt,
967
+ trust: 'self-asserted',
968
+ };
969
+ }
970
+ if (
971
+ claimed.authorId !== trust.bound.authorId ||
972
+ (trust.bound.method !== undefined && claimed.method !== trust.bound.method)
973
+ ) {
974
+ throw codedError(
975
+ 'E_AUTHOR_PROVENANCE',
976
+ `Author result for ${artifactId} claims provenance that does not match the trusted caller context.`,
977
+ );
978
+ }
979
+ return {
980
+ ...trust.bound,
981
+ generatedAt: trust.generatedAt,
982
+ trust: 'caller-bound',
983
+ };
984
+ }
985
+
986
+ async function authorArtifact(state, artifact, author, trust) {
987
+ const brief = await readSkillFile(artifact.briefRef);
988
+ const shellContent =
989
+ artifact.authoring === 'html'
990
+ ? await readSkillFile(`templates/${artifact.shell}.html`)
991
+ : undefined;
992
+ const resolvedArtifact = {
993
+ ...artifact,
994
+ ...(shellContent && { shellContent }),
995
+ };
996
+ const requiredNarrative =
997
+ artifact.origin === 'floor'
998
+ ? recipeRequiredNarrative(state.recipe, artifact.id)
999
+ : [];
1000
+ const authorRequest = {
1001
+ schemaVersion: 'explainer-kit.author-request/v2',
1002
+ artifactId: artifact.id,
1003
+ artifactType: artifact.type,
1004
+ authoring: artifact.authoring,
1005
+ brief,
1006
+ factBase: structuredClone(state.factBase),
1007
+ ...(shellContent && { shell: shellContent }),
1008
+ theme: structuredClone(state.theme),
1009
+ ...(artifact.origin === 'floor' &&
1010
+ requiredNarrative.length > 0 && {
1011
+ floor: { requiredNarrative },
1012
+ }),
1013
+ };
1014
+ const requestValidation = validateContract(
1015
+ 'author-request/v2',
1016
+ authorRequest,
1017
+ );
1018
+ if (!requestValidation.valid) {
1019
+ throw codedError(
1020
+ 'E_AUTHOR_REQUEST',
1021
+ contractErrorMessage('author request', requestValidation.errors),
1022
+ );
1023
+ }
1024
+
1025
+ const result = await author(structuredClone(authorRequest));
1026
+ const resultValidation = validateContract('author-result/v2', result);
1027
+ if (!resultValidation.valid) {
1028
+ throw codedError(
1029
+ 'E_AUTHOR_RESULT',
1030
+ contractErrorMessage('author result', resultValidation.errors),
1031
+ );
1032
+ }
1033
+ const content = result.content?.[artifact.authoring];
1034
+ if (result.artifactId !== artifact.id || typeof content !== 'string') {
1035
+ throw codedError(
1036
+ 'E_AUTHOR_RESULT',
1037
+ `Author result for ${artifact.id} must match its identity and ${artifact.authoring} path.`,
1038
+ );
1039
+ }
1040
+ const retained = {
1041
+ ...structuredClone(result),
1042
+ provenance: resolveAuthorProvenance(
1043
+ result.provenance,
1044
+ trust,
1045
+ artifact.id,
1046
+ ),
1047
+ };
1048
+ const retainedValidation = validateContract('author-result/v2', retained);
1049
+ if (!retainedValidation.valid) {
1050
+ throw codedError(
1051
+ 'E_AUTHOR_PROVENANCE',
1052
+ contractErrorMessage('retained author result', retainedValidation.errors),
1053
+ );
668
1054
  }
1055
+ const dumpCheck = checkSourceDumping({
1056
+ authoredText: content,
1057
+ sourceTexts: [
1058
+ ...state.factBase.claims,
1059
+ ...state.factBase.unresolvedClaims,
1060
+ ].map(({ text }) => text),
1061
+ });
1062
+ state.qaErrors.push(
1063
+ ...dumpCheck.issues.map((issue) => ({
1064
+ code: issue.code,
1065
+ message: issue.message,
1066
+ })),
1067
+ );
1068
+
669
1069
  return {
670
- models: authored.map(({ model }) => model),
671
- resultPaths: authored.map(({ resultPath }) => resultPath),
1070
+ artifact: resolvedArtifact,
1071
+ result: retained,
1072
+ resultPath: `source/author/${artifact.id}.json`,
1073
+ content,
1074
+ contentPath: `source/content/${artifact.id}.${artifact.authoring === 'markdown' ? 'md' : 'html'}`,
672
1075
  };
673
1076
  }
674
1077
 
1078
+ async function readSkillFile(relativePath) {
1079
+ return readFile(new URL(`../${relativePath}`, import.meta.url), 'utf8');
1080
+ }
1081
+
675
1082
  function contractErrorMessage(label, errors) {
676
1083
  return `Invalid ${label}: ${errors
677
1084
  .map(({ path, message }) => `${path}: ${message}`)
678
1085
  .join('; ')}`;
679
1086
  }
680
1087
 
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
- );
1088
+ // renderArtifact validates an exact key set, so normalized v2 floor entries
1089
+ // must be narrowed before they reach it.
1090
+ function renderDescriptor(artifact) {
1091
+ return {
1092
+ id: artifact.id,
1093
+ type: artifact.type,
1094
+ template: artifact.template,
1095
+ required: artifact.required,
1096
+ origin: artifact.origin,
1097
+ };
1098
+ }
1099
+
1100
+ const LEAD_SECTION_IDS = ['overview', 'introduction', 'lead'];
1101
+
1102
+ function markdownContentModel(artifact, slug, markdown, artifactLinks) {
1103
+ const titleMatch = markdown.match(/^# (.+)$/m);
1104
+ const title = titleMatch?.[1]?.trim() ?? humanize(artifact.id);
1105
+ const headings = [...markdown.matchAll(/^## (.+)$/gm)];
1106
+ if (headings.length === 0) {
1107
+ return contentModel({
1108
+ artifact,
1109
+ slug,
1110
+ title,
1111
+ artifactLinks,
1112
+ sections: [{ id: 'overview', title: 'Overview', content: markdown }],
1113
+ });
1114
+ }
1115
+
1116
+ // Prose between the document title and the first `##` is authored content,
1117
+ // so it is carried as a leading section rather than silently dropped.
1118
+ const bodyStart = titleMatch
1119
+ ? titleMatch.index + titleMatch[0].length
1120
+ : 0;
1121
+ const lead = markdown.slice(bodyStart, headings[0].index).trim();
1122
+ const authoredIds = new Set(
1123
+ headings.map((heading) => slugify(heading[1].trim())),
1124
+ );
1125
+ const sections = headings.map((heading, index) => ({
1126
+ id: slugify(heading[1].trim()),
1127
+ title: heading[1].trim(),
1128
+ content: markdown
1129
+ .slice(
1130
+ heading.index + heading[0].length,
1131
+ headings[index + 1]?.index ?? markdown.length,
1132
+ )
1133
+ .trim(),
1134
+ }));
1135
+ if (lead.length > 0) {
1136
+ const leadId =
1137
+ LEAD_SECTION_IDS.find((candidate) => !authoredIds.has(candidate)) ??
1138
+ 'lead';
1139
+ sections.unshift({
1140
+ id: leadId,
1141
+ title: humanize(leadId),
1142
+ content: lead,
1143
+ });
701
1144
  }
1145
+
1146
+ return contentModel({
1147
+ artifact,
1148
+ slug,
1149
+ title,
1150
+ artifactLinks,
1151
+ sections: disambiguateSectionIds(sections),
1152
+ });
1153
+ }
1154
+
1155
+ // A repeated heading is legitimate authoring, but duplicate anchors break
1156
+ // navigation, so later collisions get a deterministic suffix.
1157
+ function disambiguateSectionIds(sections) {
1158
+ const used = new Map();
1159
+ return sections.map((section) => {
1160
+ const seen = used.get(section.id) ?? 0;
1161
+ used.set(section.id, seen + 1);
1162
+ if (seen === 0) return section;
1163
+ let candidate = `${section.id}-${seen + 1}`;
1164
+ let offset = seen + 1;
1165
+ while (used.has(candidate)) {
1166
+ offset += 1;
1167
+ candidate = `${section.id}-${offset}`;
1168
+ }
1169
+ used.set(candidate, 1);
1170
+ return { ...section, id: candidate };
1171
+ });
1172
+ }
1173
+
1174
+ function contentModel({ artifact, slug, title, artifactLinks, sections }) {
702
1175
  return {
703
1176
  artifactId: artifact.id,
704
1177
  slug,
705
- title: humanize(recipe.id),
706
- description: `Approved-source ${humanize(recipe.id).toLowerCase()}.`,
1178
+ title,
1179
+ description: `Authored ${humanize(artifact.id).toLowerCase()}.`,
707
1180
  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: [],
1181
+ footer: 'Authored from the retained reconciled fact base.',
1182
+ sections,
1183
+ artifactLinks,
723
1184
  };
724
1185
  }
725
1186
 
1187
+ function approvalArtifacts(state) {
1188
+ return state.resolvedArtifacts.map((artifact) => ({
1189
+ artifactId: artifact.id,
1190
+ origin: artifact.origin,
1191
+ ...(artifact.profileId && { profileId: artifact.profileId }),
1192
+ authoring: artifact.authoring,
1193
+ contentPath:
1194
+ state.contentPaths.get(artifact.id) ??
1195
+ `source/content/${artifact.id}.${artifact.authoring === 'markdown' ? 'md' : 'html'}`,
1196
+ authorResultPath:
1197
+ state.authorResultPaths.find((path) =>
1198
+ path.endsWith(`/${artifact.id}.json`),
1199
+ ) ?? `source/author/${artifact.id}.json`,
1200
+ }));
1201
+ }
1202
+
1203
+ function expansionLinks(artifacts) {
1204
+ return artifacts
1205
+ .filter(({ origin }) => origin === 'expansion')
1206
+ .map((artifact) => ({
1207
+ id: artifact.id,
1208
+ type: artifact.type,
1209
+ label: humanize(artifact.id),
1210
+ origin: 'expansion',
1211
+ }));
1212
+ }
1213
+
1214
+ function expansionProfile(recipe, profileId) {
1215
+ return recipeExpansion(recipe).profiles.find(
1216
+ (profile) => profile.profileId === profileId,
1217
+ );
1218
+ }
1219
+
1220
+ function resolvedArtifactFromApproval(recipe, persisted) {
1221
+ if (persisted.origin === 'floor') {
1222
+ const artifact = recipeFloor(recipe).find(
1223
+ ({ id }) => id === persisted.artifactId,
1224
+ );
1225
+ if (!artifact) {
1226
+ throw codedError(
1227
+ 'E_APPROVAL_RESUME',
1228
+ `Approval references unknown floor artifact ${persisted.artifactId}.`,
1229
+ );
1230
+ }
1231
+ return {
1232
+ ...artifact,
1233
+ origin: 'floor',
1234
+ shell: artifact.authoring === 'html' ? artifact.template : undefined,
1235
+ };
1236
+ }
1237
+ const profile = expansionProfile(recipe, persisted.profileId);
1238
+ if (!profile) {
1239
+ throw codedError(
1240
+ 'E_APPROVAL_RESUME',
1241
+ `Approval references unknown expansion profile ${persisted.profileId}.`,
1242
+ );
1243
+ }
1244
+ return {
1245
+ id: persisted.artifactId,
1246
+ type: profile.type,
1247
+ authoring: profile.authoring,
1248
+ briefRef: profile.briefRef,
1249
+ shell: profile.shell,
1250
+ template:
1251
+ profile.authoring === 'markdown'
1252
+ ? templateForType(profile.type)
1253
+ : profile.shell,
1254
+ required: false,
1255
+ origin: 'expansion',
1256
+ profileId: profile.profileId,
1257
+ };
1258
+ }
1259
+
1260
+ function templateForType(type) {
1261
+ return (
1262
+ {
1263
+ hub: 'house-style',
1264
+ diagram: 'diagram-shell',
1265
+ explainer: 'engineer-tour',
1266
+ deck: 'deck-shell',
1267
+ }[type] ?? 'house-style'
1268
+ );
1269
+ }
1270
+
726
1271
  function validateRecipeSources(recipe, binding) {
727
1272
  const primaryRole = recipe.sourceRoles[0]?.role;
728
1273
  const bindings =
@@ -781,33 +1326,6 @@ function factBaseMarkdown(factBase) {
781
1326
  return `# Fact base\n\n## Confirmed claims\n\n${confirmed || '- None.'}\n\n## Unresolved claims\n\n${unresolved || '- None.'}\n`;
782
1327
  }
783
1328
 
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
1329
  function assertValidRequest(request) {
812
1330
  const result = validateContract('run-request', request);
813
1331
  if (!result.valid) {
@@ -831,12 +1349,18 @@ function resultFor(state, error) {
831
1349
  : state.approval?.canResume === false
832
1350
  ? 'incomplete'
833
1351
  : 'built-not-durable',
1352
+ ...(state.approval?.record?.marking && {
1353
+ marking: state.approval.record.marking,
1354
+ }),
834
1355
  warnings: [...new Set(state.warnings)],
835
1356
  discovery: state.discovery,
836
1357
  ...(state.approval && {
837
1358
  approval: {
838
1359
  status: state.approval.status,
839
1360
  path: state.approval.path,
1361
+ ...(state.approval.record.marking && {
1362
+ marking: state.approval.record.marking,
1363
+ }),
840
1364
  },
841
1365
  }),
842
1366
  ...(error && {