@open-agent-toolkit/cli 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/docs/cli-utilities/configuration.md +40 -0
- package/assets/docs/cli-utilities/tool-packs.md +31 -0
- package/assets/docs/workflows/projects/artifacts.md +27 -0
- package/assets/docs/workflows/skills/explainer-kit.md +125 -0
- package/assets/docs/workflows/skills/index.md +4 -0
- package/assets/migration/pjm-restructure.md +12 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +95 -0
- package/assets/skills/explainer-kit/examples/project-explainer/content.md +31 -0
- package/assets/skills/explainer-kit/examples/project-explainer/fact-base.md +22 -0
- package/assets/skills/explainer-kit/examples/project-recap/content.md +34 -0
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +22 -0
- package/assets/skills/explainer-kit/examples/theme-bundle.json +87 -0
- package/assets/skills/explainer-kit/palettes/ember.json +37 -0
- package/assets/skills/explainer-kit/palettes/forest.json +37 -0
- package/assets/skills/explainer-kit/palettes/neutral.json +37 -0
- package/assets/skills/explainer-kit/palettes/ocean.json +37 -0
- package/assets/skills/explainer-kit/palettes/violet.json +37 -0
- package/assets/skills/explainer-kit/profiles/clean.json +42 -0
- package/assets/skills/explainer-kit/profiles/editorial.json +42 -0
- package/assets/skills/explainer-kit/profiles/technical.json +39 -0
- package/assets/skills/explainer-kit/recipes/engineer-tour.json +33 -0
- package/assets/skills/explainer-kit/recipes/project-explainer.json +33 -0
- package/assets/skills/explainer-kit/recipes/project-recap.json +34 -0
- package/assets/skills/explainer-kit/references/contracts.md +101 -0
- package/assets/skills/explainer-kit/references/destination-contract.md +94 -0
- package/assets/skills/explainer-kit/references/extension-contract.md +71 -0
- package/assets/skills/explainer-kit/references/fact-base-contract.md +119 -0
- package/assets/skills/explainer-kit/schemas/build-record.schema.json +92 -0
- package/assets/skills/explainer-kit/schemas/durability-evidence.schema.json +53 -0
- package/assets/skills/explainer-kit/schemas/fact-base.schema.json +129 -0
- package/assets/skills/explainer-kit/schemas/manifest.schema.json +193 -0
- package/assets/skills/explainer-kit/schemas/publish-receipt.schema.json +97 -0
- package/assets/skills/explainer-kit/schemas/publish-request.schema.json +32 -0
- package/assets/skills/explainer-kit/schemas/run-request.schema.json +132 -0
- package/assets/skills/explainer-kit/schemas/theme.schema.json +217 -0
- package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +180 -0
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +680 -0
- package/assets/skills/explainer-kit/scripts/lib/durability.mjs +538 -0
- package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +494 -0
- package/assets/skills/explainer-kit/scripts/lib/fs-safe.mjs +218 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +512 -0
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +289 -0
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +231 -0
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +485 -0
- package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +587 -0
- package/assets/skills/explainer-kit/scripts/lib/safe-paths.mjs +96 -0
- package/assets/skills/explainer-kit/scripts/lib/theme.mjs +332 -0
- package/assets/skills/explainer-kit/scripts/publish.mjs +76 -0
- package/assets/skills/explainer-kit/scripts/record-durability.mjs +64 -0
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +273 -0
- package/assets/skills/explainer-kit/scripts/run.mjs +778 -0
- package/assets/skills/explainer-kit/scripts/validate.mjs +59 -0
- package/assets/skills/explainer-kit/templates/deck-shell.html +233 -0
- package/assets/skills/explainer-kit/templates/diagram-shell.html +219 -0
- package/assets/skills/explainer-kit/templates/engineer-tour.html +273 -0
- package/assets/skills/explainer-kit/templates/house-style.html +218 -0
- package/assets/skills/oat-explainer-kit/SKILL.md +92 -0
- package/assets/skills/oat-explainer-kit/references/config-contract.md +67 -0
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +143 -0
- package/assets/skills/oat-explainer-kit/references/migration.md +143 -0
- package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +103 -0
- package/assets/skills/oat-explainer-kit/scripts/check-core.mjs +127 -0
- package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +392 -0
- package/assets/skills/oat-explainer-kit/scripts/persist-intent.mjs +130 -0
- package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +293 -0
- package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +209 -0
- package/assets/skills/oat-explainer-kit/scripts/resolve-paths.mjs +166 -0
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +284 -0
- package/assets/skills/oat-project-autonomous/SKILL.md +22 -1
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +5 -1
- package/assets/skills/oat-project-complete/SKILL.md +166 -28
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +17 -0
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-plan/SKILL.md +30 -1
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-summary/SKILL.md +20 -1
- package/assets/skills/oat-wave-execute/SKILL.md +1 -1
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +232 -1
- package/dist/commands/init/tools/shared/skill-manifest.d.ts +2 -2
- package/dist/commands/init/tools/shared/skill-manifest.d.ts.map +1 -1
- package/dist/commands/init/tools/shared/skill-manifest.js +2 -0
- package/dist/commands/project/archive/archive-utils.d.ts +12 -0
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +320 -8
- package/dist/commands/project/archive/index.d.ts.map +1 -1
- package/dist/commands/project/archive/index.js +1 -0
- package/dist/commands/project/archive/push-runner.d.ts +1 -0
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +9 -2
- package/dist/config/oat-config.d.ts +26 -0
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +88 -0
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config/resolve.js +18 -0
- package/dist/validation/project-state.d.ts +3 -0
- package/dist/validation/project-state.d.ts.map +1 -1
- package/dist/validation/project-state.js +91 -0
- package/package.json +2 -2
- /package/assets/skills/{oat-wave-execute/assets/program-recap.recipe.json → explainer-kit/recipes/program-recap.json} +0 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { resolveContentApproval } from './lib/content-approval.mjs';
|
|
9
|
+
import { canonicalHash, validateContract } from './lib/contracts.mjs';
|
|
10
|
+
import { processFactBase } from './lib/fact-base.mjs';
|
|
11
|
+
import { writeJsonAtomic, writeTextAtomic } from './lib/fs-safe.mjs';
|
|
12
|
+
import { auditArtifactSet } from './lib/qa.mjs';
|
|
13
|
+
import {
|
|
14
|
+
loadRecipe,
|
|
15
|
+
shouldStopDiscovery,
|
|
16
|
+
validateContentModel,
|
|
17
|
+
validateSourceBindings,
|
|
18
|
+
} from './lib/recipes.mjs';
|
|
19
|
+
import {
|
|
20
|
+
initializeRun,
|
|
21
|
+
updateBuildRecord,
|
|
22
|
+
writeManifestAtomic,
|
|
23
|
+
} from './lib/records.mjs';
|
|
24
|
+
import { renderArtifact } from './lib/render.mjs';
|
|
25
|
+
import { resolveTheme } from './lib/theme.mjs';
|
|
26
|
+
|
|
27
|
+
export async function runExplainer(request, options = {}) {
|
|
28
|
+
assertValidRequest(request);
|
|
29
|
+
const recipe = loadRecipe(request.recipe.id, request.recipe.version);
|
|
30
|
+
const resumed = await loadResumableRun(request);
|
|
31
|
+
const run = resumed ?? (await initializeRun(request));
|
|
32
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
33
|
+
const state = {
|
|
34
|
+
run,
|
|
35
|
+
recipe,
|
|
36
|
+
factBase: null,
|
|
37
|
+
factBaseHash: null,
|
|
38
|
+
inputHashes: {},
|
|
39
|
+
contentModels: [],
|
|
40
|
+
contentPaths: new Map(),
|
|
41
|
+
theme: null,
|
|
42
|
+
renderStrategy: run.request.theme.renderStrategy,
|
|
43
|
+
rendered: [],
|
|
44
|
+
artifacts: [],
|
|
45
|
+
warnings: [],
|
|
46
|
+
discovery: { rounds: 0, findings: [], reason: 'not-requested' },
|
|
47
|
+
approval: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (resumed) {
|
|
52
|
+
await hydrateResumableState(state);
|
|
53
|
+
} else {
|
|
54
|
+
validateRecipeSources(recipe, run.request.factBase);
|
|
55
|
+
await executeStage(run, 'validate', options, async () => ({
|
|
56
|
+
outputPaths: ['run-request.json'],
|
|
57
|
+
}));
|
|
58
|
+
await executeStage(run, 'fact-base', options, async () => {
|
|
59
|
+
const processed = await buildFactBase(
|
|
60
|
+
run.request.factBase,
|
|
61
|
+
options,
|
|
62
|
+
now,
|
|
63
|
+
);
|
|
64
|
+
state.factBase = processed.factBase;
|
|
65
|
+
state.warnings.push(...processed.checks.warnings);
|
|
66
|
+
state.inputHashes = inputHashes(processed.factBase);
|
|
67
|
+
state.factBaseHash = canonicalHash(processed.factBase);
|
|
68
|
+
await writeJsonAtomic(
|
|
69
|
+
run.runRoot,
|
|
70
|
+
'source/fact-base.json',
|
|
71
|
+
state.factBase,
|
|
72
|
+
);
|
|
73
|
+
await writeTextAtomic(
|
|
74
|
+
run.runRoot,
|
|
75
|
+
'source/fact-base.md',
|
|
76
|
+
factBaseMarkdown(state.factBase),
|
|
77
|
+
);
|
|
78
|
+
return {
|
|
79
|
+
outputPaths: ['source/fact-base.json', 'source/fact-base.md'],
|
|
80
|
+
warnings: processed.checks.warnings,
|
|
81
|
+
status: processed.checks.warnings.length > 0 ? 'warned' : 'passed',
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
await executeStage(run, 'content', options, async () => {
|
|
85
|
+
state.discovery = await runDiscovery(recipe, state.factBase, options);
|
|
86
|
+
state.contentModels = recipe.artifacts.map((artifact) =>
|
|
87
|
+
createContentModel(recipe, artifact, run.slug, state.factBase),
|
|
88
|
+
);
|
|
89
|
+
for (const model of state.contentModels) {
|
|
90
|
+
const validation = validateContentModel(recipe, model);
|
|
91
|
+
if (!validation.valid) {
|
|
92
|
+
throw codedError(
|
|
93
|
+
'E_CONTENT',
|
|
94
|
+
`Invalid content model: ${validation.errors.join('; ')}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const path = `source/content/${model.artifactId}.md`;
|
|
98
|
+
await writeTextAtomic(run.runRoot, path, contentMarkdown(model));
|
|
99
|
+
state.contentPaths.set(model.artifactId, path);
|
|
100
|
+
}
|
|
101
|
+
return { outputPaths: [...state.contentPaths.values()] };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
state.approval = await resolveContentApproval(
|
|
106
|
+
run,
|
|
107
|
+
run.request.mode,
|
|
108
|
+
options.reviewedSource,
|
|
109
|
+
);
|
|
110
|
+
if (!state.approval.canResume) {
|
|
111
|
+
return resultFor(state);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await executeStage(run, 'theme', options, async () => {
|
|
115
|
+
const resolved = await resolveTheme(run.request.theme);
|
|
116
|
+
state.theme = resolved.theme;
|
|
117
|
+
state.renderStrategy = resolved.renderStrategy;
|
|
118
|
+
state.warnings.push(...resolved.warnings);
|
|
119
|
+
await writeJsonAtomic(run.runRoot, 'theme.resolved.json', state.theme);
|
|
120
|
+
return {
|
|
121
|
+
outputPaths: ['theme.resolved.json'],
|
|
122
|
+
warnings: resolved.warnings,
|
|
123
|
+
status: resolved.warnings.length > 0 ? 'warned' : 'passed',
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
await executeStage(run, 'render', options, async () => {
|
|
127
|
+
for (const recipeArtifact of recipe.artifacts) {
|
|
128
|
+
const content = state.contentModels.find(
|
|
129
|
+
({ artifactId }) => artifactId === recipeArtifact.id,
|
|
130
|
+
);
|
|
131
|
+
const rendered = await renderArtifact({
|
|
132
|
+
recipeArtifact,
|
|
133
|
+
content,
|
|
134
|
+
theme: state.theme,
|
|
135
|
+
renderStrategy: state.renderStrategy,
|
|
136
|
+
...(run.request.publicBaseUrl && {
|
|
137
|
+
publicBaseUrl: run.request.publicBaseUrl,
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
await writeTextAtomic(
|
|
141
|
+
run.runRoot,
|
|
142
|
+
rendered.renderedPath,
|
|
143
|
+
rendered.html,
|
|
144
|
+
);
|
|
145
|
+
state.rendered.push(rendered);
|
|
146
|
+
state.artifacts.push({
|
|
147
|
+
id: rendered.artifactId,
|
|
148
|
+
type: rendered.type,
|
|
149
|
+
contentPath: state.contentPaths.get(rendered.artifactId),
|
|
150
|
+
renderedPath: rendered.renderedPath,
|
|
151
|
+
mediaType: rendered.mediaType,
|
|
152
|
+
status: 'built',
|
|
153
|
+
hash: hashBytes(rendered.html),
|
|
154
|
+
rebuildable: false,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
outputPaths: state.rendered.map(({ renderedPath }) => renderedPath),
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
await executeStage(run, 'qa', options, async () => {
|
|
162
|
+
const report = await auditArtifactSet({
|
|
163
|
+
artifacts: state.rendered.map((artifact) => ({
|
|
164
|
+
id: artifact.artifactId,
|
|
165
|
+
type: artifact.type,
|
|
166
|
+
html: artifact.html,
|
|
167
|
+
})),
|
|
168
|
+
...(options.denylist && { denylist: options.denylist }),
|
|
169
|
+
...(options.browserProbe && { browserProbe: options.browserProbe }),
|
|
170
|
+
...(options.widths && { widths: options.widths }),
|
|
171
|
+
});
|
|
172
|
+
if (!report.valid) {
|
|
173
|
+
throw codedError(
|
|
174
|
+
'E_QA',
|
|
175
|
+
report.issues
|
|
176
|
+
.map(({ code, message }) => `${code}: ${message}`)
|
|
177
|
+
.join('; '),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
outputPaths: state.rendered.map(({ renderedPath }) => renderedPath),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await executeDurabilityAndPublish(state, options, now);
|
|
186
|
+
await persistManifest(state, now());
|
|
187
|
+
return resultFor(state);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (state.theme && state.factBase) {
|
|
190
|
+
await persistFailureManifest(state, error, now()).catch(() => {});
|
|
191
|
+
}
|
|
192
|
+
return resultFor(state, error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function loadResumableRun(request) {
|
|
197
|
+
const normalized = structuredClone(request);
|
|
198
|
+
normalized.theme = {
|
|
199
|
+
...(normalized.theme ?? {}),
|
|
200
|
+
renderStrategy: normalized.theme?.renderStrategy ?? 'default-only',
|
|
201
|
+
};
|
|
202
|
+
const runRoot = join(resolve(normalized.outputRoot), normalized.slug);
|
|
203
|
+
let approval;
|
|
204
|
+
let record;
|
|
205
|
+
let persistedRequest;
|
|
206
|
+
try {
|
|
207
|
+
[approval, record, persistedRequest] = await Promise.all([
|
|
208
|
+
readJson(join(runRoot, 'source/content-approval.json')),
|
|
209
|
+
readJson(join(runRoot, 'build-record.json')),
|
|
210
|
+
readJson(join(runRoot, 'run-request.json')),
|
|
211
|
+
]);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error?.code === 'ENOENT') return null;
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const content = record.stages?.find(({ id }) => id === 'content');
|
|
218
|
+
const theme = record.stages?.find(({ id }) => id === 'theme');
|
|
219
|
+
if (
|
|
220
|
+
content?.status !== 'passed' ||
|
|
221
|
+
theme?.status !== 'pending' ||
|
|
222
|
+
approval.runId !== record.runId
|
|
223
|
+
) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
persistedRequest.slug !== normalized.slug ||
|
|
228
|
+
persistedRequest.recipe?.id !== normalized.recipe.id ||
|
|
229
|
+
persistedRequest.recipe?.version !== normalized.recipe.version ||
|
|
230
|
+
persistedRequest.mode !== normalized.mode
|
|
231
|
+
) {
|
|
232
|
+
throw codedError(
|
|
233
|
+
'E_APPROVAL_RESUME',
|
|
234
|
+
'The resumable run does not match the current request identity.',
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const canonicalRunRoot = join(persistedRequest.outputRoot, normalized.slug);
|
|
239
|
+
return {
|
|
240
|
+
runId: record.runId,
|
|
241
|
+
slug: normalized.slug,
|
|
242
|
+
outputRoot: persistedRequest.outputRoot,
|
|
243
|
+
runRoot: canonicalRunRoot,
|
|
244
|
+
requestPath: join(canonicalRunRoot, 'run-request.json'),
|
|
245
|
+
buildRecordPath: join(canonicalRunRoot, 'build-record.json'),
|
|
246
|
+
manifestPath: join(canonicalRunRoot, 'manifest.json'),
|
|
247
|
+
request: normalized,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function hydrateResumableState(state) {
|
|
252
|
+
state.factBase = await readJson(
|
|
253
|
+
join(state.run.runRoot, 'source/fact-base.json'),
|
|
254
|
+
);
|
|
255
|
+
state.inputHashes = inputHashes(state.factBase);
|
|
256
|
+
state.factBaseHash = canonicalHash(state.factBase);
|
|
257
|
+
state.contentModels = [];
|
|
258
|
+
for (const artifact of state.recipe.artifacts) {
|
|
259
|
+
const base = createContentModel(
|
|
260
|
+
state.recipe,
|
|
261
|
+
artifact,
|
|
262
|
+
state.run.slug,
|
|
263
|
+
state.factBase,
|
|
264
|
+
);
|
|
265
|
+
const path = `source/content/${base.artifactId}.md`;
|
|
266
|
+
const model = contentModelFromMarkdown(
|
|
267
|
+
base,
|
|
268
|
+
await readFile(join(state.run.runRoot, path), 'utf8'),
|
|
269
|
+
);
|
|
270
|
+
const validation = validateContentModel(state.recipe, model);
|
|
271
|
+
if (!validation.valid) {
|
|
272
|
+
throw codedError(
|
|
273
|
+
'E_CONTENT',
|
|
274
|
+
`Reviewed content is invalid: ${validation.errors.join('; ')}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
state.contentModels.push(model);
|
|
278
|
+
state.contentPaths.set(model.artifactId, path);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function readJson(path) {
|
|
283
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function runExplainerCli(
|
|
287
|
+
argv = process.argv.slice(2),
|
|
288
|
+
io = console,
|
|
289
|
+
) {
|
|
290
|
+
try {
|
|
291
|
+
const parsed = await parseCli(argv);
|
|
292
|
+
const request = JSON.parse(await readFile(parsed.requestPath, 'utf8'));
|
|
293
|
+
const result = await runExplainer(request, parsed.options);
|
|
294
|
+
io.log(JSON.stringify(result, null, 2));
|
|
295
|
+
return result.outcome === 'failed' ? 1 : 0;
|
|
296
|
+
} catch (error) {
|
|
297
|
+
io.log(
|
|
298
|
+
JSON.stringify(
|
|
299
|
+
{
|
|
300
|
+
outcome: 'failed',
|
|
301
|
+
errors: [
|
|
302
|
+
{
|
|
303
|
+
code: error.code ?? 'E_INPUT_SCHEMA',
|
|
304
|
+
message: safeMessage(error),
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
null,
|
|
309
|
+
2,
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
return 1;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function executeStage(run, id, options, operation) {
|
|
317
|
+
await updateBuildRecord(run, { id, status: 'running' });
|
|
318
|
+
try {
|
|
319
|
+
await options.hooks?.beforeStage?.(id, run);
|
|
320
|
+
const result = (await operation()) ?? {};
|
|
321
|
+
await options.hooks?.afterStage?.(id, run);
|
|
322
|
+
return updateBuildRecord(run, {
|
|
323
|
+
id,
|
|
324
|
+
status: result.status ?? 'passed',
|
|
325
|
+
outputPaths: result.outputPaths ?? [],
|
|
326
|
+
warnings: result.warnings ?? [],
|
|
327
|
+
});
|
|
328
|
+
} catch (error) {
|
|
329
|
+
await updateBuildRecord(run, {
|
|
330
|
+
id,
|
|
331
|
+
status: 'failed',
|
|
332
|
+
error: {
|
|
333
|
+
code: error.code ?? stageErrorCode(id),
|
|
334
|
+
message: safeMessage(error),
|
|
335
|
+
recovery: [
|
|
336
|
+
`Correct the ${id} inputs or implementation and start a new run.`,
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function buildFactBase(binding, options, now) {
|
|
345
|
+
if (binding.mode === 'supplied') {
|
|
346
|
+
const factBase = JSON.parse(await readFile(binding.path, 'utf8'));
|
|
347
|
+
return processFactBase(
|
|
348
|
+
{ mode: 'supplied', freshnessPolicy: binding.freshnessPolicy, factBase },
|
|
349
|
+
{
|
|
350
|
+
now: now(),
|
|
351
|
+
...(options.maxFactAgeMs && { maxAgeMs: options.maxFactAgeMs }),
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const sourceDocuments = await Promise.all(
|
|
357
|
+
binding.sources.map(async (source) => {
|
|
358
|
+
if (
|
|
359
|
+
source.kind !== 'file' &&
|
|
360
|
+
typeof options.sourceLoader !== 'function'
|
|
361
|
+
) {
|
|
362
|
+
throw codedError(
|
|
363
|
+
'E_FACT_BASE',
|
|
364
|
+
`Core file loading cannot resolve source kind ${source.kind}; supply an explicit sourceLoader.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const raw = options.sourceLoader
|
|
368
|
+
? await options.sourceLoader(source)
|
|
369
|
+
: JSON.parse(await readFile(source.locator, 'utf8'));
|
|
370
|
+
if (!Array.isArray(raw.claims)) {
|
|
371
|
+
throw codedError(
|
|
372
|
+
'E_FACT_BASE',
|
|
373
|
+
`Federated source ${source.id} must provide a claims array.`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const serialized = JSON.stringify(raw);
|
|
377
|
+
return {
|
|
378
|
+
source: {
|
|
379
|
+
...source,
|
|
380
|
+
hash: hashBytes(serialized),
|
|
381
|
+
observedAt: raw.observedAt ?? now(),
|
|
382
|
+
},
|
|
383
|
+
claims: raw.claims,
|
|
384
|
+
};
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
387
|
+
return processFactBase(
|
|
388
|
+
{
|
|
389
|
+
mode: 'federated',
|
|
390
|
+
freshnessPolicy: binding.freshnessPolicy,
|
|
391
|
+
sourceDocuments,
|
|
392
|
+
overrides: options.overrides ?? [],
|
|
393
|
+
},
|
|
394
|
+
{ critic: options.critic, now: now() },
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function runDiscovery(recipe, factBase, options) {
|
|
399
|
+
if (typeof options.discover !== 'function') {
|
|
400
|
+
return { rounds: 0, findings: [], reason: 'not-requested' };
|
|
401
|
+
}
|
|
402
|
+
const findings = [];
|
|
403
|
+
const counts = [];
|
|
404
|
+
while (!shouldStopDiscovery(recipe, counts)) {
|
|
405
|
+
const round = counts.length + 1;
|
|
406
|
+
const roundFindings = await options.discover({
|
|
407
|
+
round,
|
|
408
|
+
recipe: structuredClone(recipe),
|
|
409
|
+
factBase: structuredClone(factBase),
|
|
410
|
+
});
|
|
411
|
+
if (!Array.isArray(roundFindings)) {
|
|
412
|
+
throw codedError('E_CONTENT', 'Discovery callback must return an array.');
|
|
413
|
+
}
|
|
414
|
+
counts.push(roundFindings.length);
|
|
415
|
+
findings.push(...roundFindings);
|
|
416
|
+
}
|
|
417
|
+
const emptyLimit = recipe.discoveryLimits.consecutiveNoNewFindingsRounds;
|
|
418
|
+
const stoppedEmpty =
|
|
419
|
+
counts.slice(-emptyLimit).length === emptyLimit &&
|
|
420
|
+
counts.slice(-emptyLimit).every((count) => count === 0);
|
|
421
|
+
return {
|
|
422
|
+
rounds: counts.length,
|
|
423
|
+
findings,
|
|
424
|
+
reason: stoppedEmpty ? 'two-empty-rounds' : 'hard-maximum',
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function executeDurabilityAndPublish(state, options, now) {
|
|
429
|
+
const strategy = state.run.request.durability?.strategy ?? 'none';
|
|
430
|
+
if (strategy === 'none') {
|
|
431
|
+
await updateBuildRecord(state.run, { id: 'durability', status: 'skipped' });
|
|
432
|
+
await updateBuildRecord(state.run, { id: 'publish', status: 'skipped' });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (strategy === 'commit') {
|
|
437
|
+
await updateBuildRecord(state.run, { id: 'durability', status: 'running' });
|
|
438
|
+
await persistManifest(state, now());
|
|
439
|
+
if (typeof options.durability !== 'function') {
|
|
440
|
+
throw codedError(
|
|
441
|
+
'E_DURABILITY',
|
|
442
|
+
'Commit durability was requested without a durability callback.',
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
await options.durability({
|
|
446
|
+
runRoot: state.run.runRoot,
|
|
447
|
+
manifestPath: state.run.manifestPath,
|
|
448
|
+
buildRecordPath: state.run.buildRecordPath,
|
|
449
|
+
});
|
|
450
|
+
await updateBuildRecord(state.run, {
|
|
451
|
+
id: 'durability',
|
|
452
|
+
status: 'warned',
|
|
453
|
+
warnings: [
|
|
454
|
+
'Commit durability requires caller-created evidence through record-durability.mjs.',
|
|
455
|
+
],
|
|
456
|
+
});
|
|
457
|
+
await updateBuildRecord(state.run, { id: 'publish', status: 'skipped' });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
await updateBuildRecord(state.run, { id: 'durability', status: 'skipped' });
|
|
462
|
+
await updateBuildRecord(state.run, { id: 'publish', status: 'running' });
|
|
463
|
+
await persistManifest(state, now());
|
|
464
|
+
if (typeof options.publish !== 'function') {
|
|
465
|
+
throw codedError(
|
|
466
|
+
'E_PUBLISH',
|
|
467
|
+
'Publish durability was requested without an explicit publisher callback.',
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
await options.publish({
|
|
471
|
+
request: structuredClone(state.run.request.durability.publish),
|
|
472
|
+
runRoot: state.run.runRoot,
|
|
473
|
+
manifestPath: state.run.manifestPath,
|
|
474
|
+
});
|
|
475
|
+
await updateBuildRecord(state.run, {
|
|
476
|
+
id: 'publish',
|
|
477
|
+
status: 'warned',
|
|
478
|
+
warnings: [
|
|
479
|
+
'Publishing completed; verified receipt evidence must be recorded separately.',
|
|
480
|
+
],
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function persistManifest(state, createdAt) {
|
|
485
|
+
const record = JSON.parse(await readFile(state.run.buildRecordPath, 'utf8'));
|
|
486
|
+
const manifest = manifestFor(
|
|
487
|
+
state,
|
|
488
|
+
record,
|
|
489
|
+
createdAt,
|
|
490
|
+
await immutableHashesFor(state),
|
|
491
|
+
);
|
|
492
|
+
await writeManifestAtomic(state.run, manifest);
|
|
493
|
+
return manifest;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function persistFailureManifest(state, error, createdAt) {
|
|
497
|
+
const record = JSON.parse(await readFile(state.run.buildRecordPath, 'utf8'));
|
|
498
|
+
const recordedIds = new Set(state.artifacts.map(({ id }) => id));
|
|
499
|
+
state.artifacts.push(
|
|
500
|
+
...state.recipe.artifacts
|
|
501
|
+
.filter(({ id }) => !recordedIds.has(id))
|
|
502
|
+
.map((artifact) => ({
|
|
503
|
+
id: artifact.id,
|
|
504
|
+
type: artifact.type,
|
|
505
|
+
contentPath: state.contentPaths.get(artifact.id),
|
|
506
|
+
status: 'failed',
|
|
507
|
+
rebuildable: false,
|
|
508
|
+
failure: {
|
|
509
|
+
code: error.code ?? 'E_RENDER',
|
|
510
|
+
message: safeMessage(error),
|
|
511
|
+
recovery: ['Correct the failed stage and start a new run.'],
|
|
512
|
+
},
|
|
513
|
+
})),
|
|
514
|
+
);
|
|
515
|
+
return writeManifestAtomic(
|
|
516
|
+
state.run,
|
|
517
|
+
manifestFor(state, record, createdAt, await immutableHashesFor(state)),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function manifestFor(state, buildRecord, createdAt, immutableHashes) {
|
|
522
|
+
return {
|
|
523
|
+
schemaVersion: 'explainer-kit.manifest/v1',
|
|
524
|
+
runId: state.run.runId,
|
|
525
|
+
slug: state.run.slug,
|
|
526
|
+
recipe: {
|
|
527
|
+
id: state.recipe.id,
|
|
528
|
+
version: state.recipe.version,
|
|
529
|
+
},
|
|
530
|
+
createdAt,
|
|
531
|
+
source: {
|
|
532
|
+
factBasePath: 'source/fact-base.json',
|
|
533
|
+
factBaseHash: state.factBaseHash,
|
|
534
|
+
inputHashes: state.inputHashes,
|
|
535
|
+
},
|
|
536
|
+
theme: {
|
|
537
|
+
path: 'theme.resolved.json',
|
|
538
|
+
hash: canonicalHash(state.theme),
|
|
539
|
+
derived: state.theme.provenance.derived,
|
|
540
|
+
},
|
|
541
|
+
artifacts: state.artifacts,
|
|
542
|
+
immutableHashes,
|
|
543
|
+
outcome: buildRecord.outcome,
|
|
544
|
+
buildRecord: {
|
|
545
|
+
path: 'build-record.json',
|
|
546
|
+
hash: canonicalHash(buildRecord),
|
|
547
|
+
},
|
|
548
|
+
warnings: [...new Set(state.warnings)],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function createContentModel(recipe, artifact, slug, factBase) {
|
|
553
|
+
const facts = [
|
|
554
|
+
...factBase.claims.map(({ text }) => text),
|
|
555
|
+
...factBase.unresolvedClaims.map(
|
|
556
|
+
({ text }) => `Needs confirmation: ${text}`,
|
|
557
|
+
),
|
|
558
|
+
];
|
|
559
|
+
const summary = facts.length > 0 ? facts.join(' ') : 'No confirmed facts.';
|
|
560
|
+
return {
|
|
561
|
+
artifactId: artifact.id,
|
|
562
|
+
slug,
|
|
563
|
+
title: humanize(recipe.id),
|
|
564
|
+
description: `Approved-source ${humanize(recipe.id).toLowerCase()}.`,
|
|
565
|
+
eyebrow: 'Explainer Kit',
|
|
566
|
+
footer: 'Generated from the retained reconciled fact base.',
|
|
567
|
+
sections: recipe.requiredNarrative.map((id) => ({
|
|
568
|
+
id,
|
|
569
|
+
title: humanize(id),
|
|
570
|
+
content: summary,
|
|
571
|
+
})),
|
|
572
|
+
artifactLinks: [],
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function validateRecipeSources(recipe, binding) {
|
|
577
|
+
const primaryRole = recipe.sourceRoles[0]?.role;
|
|
578
|
+
const bindings =
|
|
579
|
+
binding.mode === 'supplied'
|
|
580
|
+
? [{ role: primaryRole, kind: 'file', sourceSetId: 'supplied-fact-base' }]
|
|
581
|
+
: binding.sources.map((source) => ({
|
|
582
|
+
role: source.role,
|
|
583
|
+
kind: source.kind,
|
|
584
|
+
sourceSetId: source.sourceSetId,
|
|
585
|
+
}));
|
|
586
|
+
const result = validateSourceBindings(recipe, bindings);
|
|
587
|
+
if (!result.valid) {
|
|
588
|
+
throw codedError('E_FACT_BASE', result.errors.join('; '));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function immutableHashesFor(state) {
|
|
593
|
+
const paths = [
|
|
594
|
+
'source/fact-base.json',
|
|
595
|
+
'source/fact-base.md',
|
|
596
|
+
...state.contentPaths.values(),
|
|
597
|
+
...(state.theme ? ['theme.resolved.json'] : []),
|
|
598
|
+
...state.artifacts
|
|
599
|
+
.filter(
|
|
600
|
+
({ status, renderedPath }) =>
|
|
601
|
+
status === 'built' && typeof renderedPath === 'string',
|
|
602
|
+
)
|
|
603
|
+
.map(({ renderedPath }) => renderedPath),
|
|
604
|
+
];
|
|
605
|
+
return Object.fromEntries(
|
|
606
|
+
await Promise.all(
|
|
607
|
+
[...new Set(paths)].map(async (path) => [
|
|
608
|
+
path,
|
|
609
|
+
hashBytes(await readFile(join(state.run.runRoot, path))),
|
|
610
|
+
]),
|
|
611
|
+
),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function inputHashes(factBase) {
|
|
616
|
+
return Object.fromEntries(
|
|
617
|
+
factBase.sources.map(({ id, hash }) => [`inputs/${safeId(id)}`, hash]),
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function factBaseMarkdown(factBase) {
|
|
622
|
+
const confirmed = factBase.claims
|
|
623
|
+
.map(({ id, text }) => `- **${id}:** ${text}`)
|
|
624
|
+
.join('\n');
|
|
625
|
+
const unresolved = factBase.unresolvedClaims
|
|
626
|
+
.map(({ id, text, reason }) => `- **${id} (${reason}):** ${text}`)
|
|
627
|
+
.join('\n');
|
|
628
|
+
return `# Fact base\n\n## Confirmed claims\n\n${confirmed || '- None.'}\n\n## Unresolved claims\n\n${unresolved || '- None.'}\n`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function contentMarkdown(model) {
|
|
632
|
+
return `# ${model.title}\n\n${model.sections
|
|
633
|
+
.map(({ title, content }) => `## ${title}\n\n${content}`)
|
|
634
|
+
.join('\n\n')}\n`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function contentModelFromMarkdown(base, markdown) {
|
|
638
|
+
const title = markdown.match(/^# (.+)$/m)?.[1]?.trim();
|
|
639
|
+
const sections = [];
|
|
640
|
+
const headings = [...markdown.matchAll(/^## (.+)$/gm)];
|
|
641
|
+
for (const [index, heading] of headings.entries()) {
|
|
642
|
+
const start = heading.index + heading[0].length;
|
|
643
|
+
const end = headings[index + 1]?.index ?? markdown.length;
|
|
644
|
+
const sectionTitle = heading[1].trim();
|
|
645
|
+
sections.push({
|
|
646
|
+
id: slugify(sectionTitle),
|
|
647
|
+
title: sectionTitle,
|
|
648
|
+
content: markdown.slice(start, end).trim(),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
...base,
|
|
653
|
+
...(title && { title }),
|
|
654
|
+
sections,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function assertValidRequest(request) {
|
|
659
|
+
const result = validateContract('run-request', request);
|
|
660
|
+
if (!result.valid) {
|
|
661
|
+
throw codedError(
|
|
662
|
+
'E_INPUT_SCHEMA',
|
|
663
|
+
result.errors
|
|
664
|
+
.map(({ path, code, message }) => `${path} [${code}]: ${message}`)
|
|
665
|
+
.join('; '),
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function resultFor(state, error) {
|
|
671
|
+
return {
|
|
672
|
+
runId: state.run.runId,
|
|
673
|
+
runRoot: state.run.runRoot,
|
|
674
|
+
manifestPath: state.run.manifestPath,
|
|
675
|
+
buildRecordPath: state.run.buildRecordPath,
|
|
676
|
+
outcome: error
|
|
677
|
+
? 'failed'
|
|
678
|
+
: state.approval?.canResume === false
|
|
679
|
+
? 'incomplete'
|
|
680
|
+
: 'built-not-durable',
|
|
681
|
+
warnings: [...new Set(state.warnings)],
|
|
682
|
+
discovery: state.discovery,
|
|
683
|
+
...(state.approval && {
|
|
684
|
+
approval: {
|
|
685
|
+
status: state.approval.status,
|
|
686
|
+
path: state.approval.path,
|
|
687
|
+
},
|
|
688
|
+
}),
|
|
689
|
+
...(error && {
|
|
690
|
+
errors: [{ code: error.code ?? 'E_RUN', message: safeMessage(error) }],
|
|
691
|
+
}),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function parseCli(argv) {
|
|
696
|
+
let requestPath;
|
|
697
|
+
const options = {};
|
|
698
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
699
|
+
const value = argv[index];
|
|
700
|
+
if (value === '--request') {
|
|
701
|
+
requestPath = argv[++index];
|
|
702
|
+
} else if (value === '--reviewed-source') {
|
|
703
|
+
const path = argv[++index];
|
|
704
|
+
if (!path) throw new Error('--reviewed-source requires a JSON path.');
|
|
705
|
+
options.reviewedSource = JSON.parse(await readFile(path, 'utf8'));
|
|
706
|
+
} else if (
|
|
707
|
+
['--critic-module', '--publish-module', '--durability-module'].includes(
|
|
708
|
+
value,
|
|
709
|
+
)
|
|
710
|
+
) {
|
|
711
|
+
const path = argv[++index];
|
|
712
|
+
if (!path) throw new Error(`${value} requires a module path.`);
|
|
713
|
+
const loaded = await import(pathToFileURL(resolve(path)).href);
|
|
714
|
+
const key = value.slice(2, -'-module'.length);
|
|
715
|
+
options[key] = loaded.default ?? loaded[key];
|
|
716
|
+
if (typeof options[key] !== 'function') {
|
|
717
|
+
throw new Error(`${value} must export a callback function.`);
|
|
718
|
+
}
|
|
719
|
+
} else {
|
|
720
|
+
throw new Error(`Unknown option: ${value}.`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (!requestPath) {
|
|
724
|
+
throw new Error(
|
|
725
|
+
'Usage: run.mjs --request <json> [--reviewed-source <json>] [--critic-module <mjs>] [--publish-module <mjs>] [--durability-module <mjs>]',
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
return { requestPath, options };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function hashBytes(value) {
|
|
732
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function safeId(value) {
|
|
736
|
+
return value.replaceAll(/[^a-zA-Z0-9._-]/g, '-');
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function slugify(value) {
|
|
740
|
+
return value
|
|
741
|
+
.toLowerCase()
|
|
742
|
+
.trim()
|
|
743
|
+
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
744
|
+
.replaceAll(/^-+|-+$/g, '');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function humanize(value) {
|
|
748
|
+
return value
|
|
749
|
+
.split('-')
|
|
750
|
+
.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`)
|
|
751
|
+
.join(' ');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function codedError(code, message) {
|
|
755
|
+
const error = new Error(message);
|
|
756
|
+
error.code = code;
|
|
757
|
+
return error;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function stageErrorCode(stage) {
|
|
761
|
+
return `E_${stage.toUpperCase().replaceAll('-', '_')}`;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function safeMessage(error) {
|
|
765
|
+
return (error instanceof Error ? error.message : String(error))
|
|
766
|
+
.replaceAll(
|
|
767
|
+
/(?:aws_secret_access_key|aws_session_token|password|private_key)\s*[:=]\s*\S+/gi,
|
|
768
|
+
'[redacted]',
|
|
769
|
+
)
|
|
770
|
+
.slice(0, 2000);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (
|
|
774
|
+
process.argv[1] &&
|
|
775
|
+
pathToFileURL(process.argv[1]).href === import.meta.url
|
|
776
|
+
) {
|
|
777
|
+
process.exitCode = await runExplainerCli();
|
|
778
|
+
}
|