@open-agent-toolkit/cli 0.2.73 → 0.2.74

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 (149) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/cli-utilities/configuration.md +3 -56
  3. package/assets/docs/cli-utilities/tool-packs.md +4 -4
  4. package/assets/docs/contributing/code.md +2 -2
  5. package/assets/docs/contributing/index.md +0 -1
  6. package/assets/docs/reference/cli-reference.md +1 -1
  7. package/assets/docs/reference/troubleshooting.md +17 -40
  8. package/assets/docs/workflows/projects/artifacts.md +16 -34
  9. package/assets/docs/workflows/projects/lifecycle.md +20 -19
  10. package/assets/docs/workflows/skills/explainer-kit.md +180 -440
  11. package/assets/docs/workflows/skills/index.md +1 -2
  12. package/assets/public-package-versions.json +4 -4
  13. package/assets/skills/explainer-kit/SKILL.md +96 -171
  14. package/assets/skills/explainer-kit/recipes/engineer-tour.json +1 -22
  15. package/assets/skills/explainer-kit/recipes/program-recap.json +1 -29
  16. package/assets/skills/explainer-kit/recipes/project-explainer.json +1 -22
  17. package/assets/skills/explainer-kit/recipes/project-recap.v2.json +1 -43
  18. package/assets/skills/explainer-kit/references/fact-base-contract.md +76 -101
  19. package/assets/skills/explainer-kit/references/recap-authoring.md +99 -0
  20. package/assets/skills/explainer-kit/schemas/manifest.schema.json +13 -135
  21. package/assets/skills/explainer-kit/scripts/bundle.mjs +712 -0
  22. package/assets/skills/explainer-kit/scripts/lib/claim-subject.mjs +40 -0
  23. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +113 -1504
  24. package/assets/skills/explainer-kit/scripts/lib/failure.mjs +13 -0
  25. package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +57 -403
  26. package/assets/skills/explainer-kit/scripts/lib/qa-result.mjs +168 -0
  27. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +90 -488
  28. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +28 -545
  29. package/assets/skills/explainer-kit/scripts/lib/run-package.mjs +120 -0
  30. package/assets/skills/explainer-kit/scripts/lib/sanitize.mjs +42 -0
  31. package/assets/skills/explainer-kit/scripts/record.mjs +335 -0
  32. package/assets/skills/explainer-kit/scripts/verify.mjs +648 -0
  33. package/assets/skills/oat-explainer-kit/SKILL.md +57 -117
  34. package/assets/skills/oat-explainer-kit/references/config-contract.md +20 -64
  35. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +85 -237
  36. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +18 -10
  37. package/assets/skills/oat-explainer-kit/scripts/check-core.mjs +1 -0
  38. package/assets/skills/oat-explainer-kit/scripts/check-terminal-outcome.mjs +256 -21
  39. package/assets/skills/oat-explainer-kit/scripts/persist-intent.mjs +82 -3
  40. package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +0 -162
  41. package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +57 -123
  42. package/assets/skills/oat-project-autonomous/SKILL.md +14 -13
  43. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +165 -165
  44. package/assets/skills/oat-project-complete/SKILL.md +168 -349
  45. package/assets/skills/oat-project-complete/scripts/consume-persisted-recap-intent.mjs +125 -0
  46. package/assets/skills/oat-project-complete/scripts/execute-synced-archive-entry.mjs +0 -30
  47. package/assets/skills/oat-project-complete/scripts/parse-completion-retry-fields.mjs +4 -37
  48. package/assets/skills/oat-project-complete/scripts/parse-synced-archive-resume-fields.mjs +17 -16
  49. package/assets/skills/oat-project-complete/scripts/recover-completion-receipts.mjs +9 -174
  50. package/assets/skills/oat-project-complete/scripts/resolve-completion-retry.mjs +1 -2
  51. package/assets/skills/oat-project-document/SKILL.md +1 -1
  52. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +165 -165
  53. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  54. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +44 -56
  55. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +165 -165
  56. package/assets/skills/oat-project-lite/SKILL.md +1 -1
  57. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +165 -165
  58. package/assets/skills/oat-project-plan/SKILL.md +5 -6
  59. package/assets/skills/oat-project-pr-final/SKILL.md +1 -1
  60. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +165 -165
  61. package/assets/skills/oat-project-quick-start/SKILL.md +1 -1
  62. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +165 -165
  63. package/assets/skills/oat-project-summary/SKILL.md +35 -25
  64. package/assets/skills/oat-wave-execute/SKILL.md +11 -36
  65. package/assets/skills/oat-wave-program/SKILL.md +13 -37
  66. package/dist/commands/config/index.d.ts.map +1 -1
  67. package/dist/commands/config/index.js +9 -113
  68. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  69. package/dist/commands/project/archive/archive-utils.js +32 -184
  70. package/dist/commands/project/archive/explainer-package-coverage.d.ts +3 -8
  71. package/dist/commands/project/archive/explainer-package-coverage.d.ts.map +1 -1
  72. package/dist/commands/project/archive/explainer-package-coverage.js +1 -1
  73. package/dist/config/oat-config.d.ts +0 -11
  74. package/dist/config/oat-config.d.ts.map +1 -1
  75. package/dist/config/oat-config.js +0 -34
  76. package/dist/config/resolve.d.ts.map +1 -1
  77. package/dist/config/resolve.js +0 -8
  78. package/dist/release/public-package-contract.d.ts.map +1 -1
  79. package/dist/release/public-package-contract.js +2 -10
  80. package/dist/validation/project-state.d.ts.map +1 -1
  81. package/dist/validation/project-state.js +42 -3
  82. package/package.json +2 -2
  83. package/assets/docs/contributing/explainer-kit-verification.md +0 -133
  84. package/assets/docs/workflows/skills/explainer-kit-providers.md +0 -155
  85. package/assets/skills/explainer-kit/briefs/deep-dive.md +0 -35
  86. package/assets/skills/explainer-kit/briefs/project-page.md +0 -38
  87. package/assets/skills/explainer-kit/briefs/supporting-diagram.md +0 -28
  88. package/assets/skills/explainer-kit/briefs/walkthrough-deck.md +0 -35
  89. package/assets/skills/explainer-kit/examples/project-explainer/content.md +0 -31
  90. package/assets/skills/explainer-kit/examples/project-explainer/fact-base.md +0 -22
  91. package/assets/skills/explainer-kit/examples/project-recap/content.md +0 -77
  92. package/assets/skills/explainer-kit/examples/project-recap/fact-base.json +0 -104
  93. package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +0 -44
  94. package/assets/skills/explainer-kit/examples/theme-bundle.json +0 -87
  95. package/assets/skills/explainer-kit/recipes/project-recap.v1.json +0 -93
  96. package/assets/skills/explainer-kit/references/contracts.md +0 -324
  97. package/assets/skills/explainer-kit/references/destination-contract.md +0 -210
  98. package/assets/skills/explainer-kit/references/extension-contract.md +0 -80
  99. package/assets/skills/explainer-kit/references/golden-conformance.md +0 -80
  100. package/assets/skills/explainer-kit/references/visual-review.md +0 -71
  101. package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +0 -212
  102. package/assets/skills/explainer-kit/schemas/author-request.v3.schema.json +0 -241
  103. package/assets/skills/explainer-kit/schemas/author-result.v2.schema.json +0 -52
  104. package/assets/skills/explainer-kit/schemas/build-record.schema.json +0 -98
  105. package/assets/skills/explainer-kit/schemas/durability-evidence.schema.json +0 -53
  106. package/assets/skills/explainer-kit/schemas/publish-receipt.v1.schema.json +0 -97
  107. package/assets/skills/explainer-kit/schemas/publish-receipt.v2.schema.json +0 -215
  108. package/assets/skills/explainer-kit/schemas/publish-request.v1.schema.json +0 -32
  109. package/assets/skills/explainer-kit/schemas/publish-request.v2.schema.json +0 -34
  110. package/assets/skills/explainer-kit/schemas/run-request.schema.json +0 -147
  111. package/assets/skills/explainer-kit/schemas/set-plan.v1.schema.json +0 -149
  112. package/assets/skills/explainer-kit/schemas/terminal-evidence.v1.schema.json +0 -81
  113. package/assets/skills/explainer-kit/schemas/visual-review-evidence.v1.schema.json +0 -66
  114. package/assets/skills/explainer-kit/schemas/visual-review-request.v1.schema.json +0 -117
  115. package/assets/skills/explainer-kit/schemas/visual-review-result.v1.schema.json +0 -80
  116. package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +0 -349
  117. package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +0 -418
  118. package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +0 -514
  119. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +0 -643
  120. package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +0 -664
  121. package/assets/skills/explainer-kit/scripts/lib/internal-references.mjs +0 -538
  122. package/assets/skills/explainer-kit/scripts/lib/markdown.mjs +0 -414
  123. package/assets/skills/explainer-kit/scripts/lib/publication-policy.mjs +0 -54
  124. package/assets/skills/explainer-kit/scripts/lib/records.mjs +0 -954
  125. package/assets/skills/explainer-kit/scripts/lib/render.mjs +0 -703
  126. package/assets/skills/explainer-kit/scripts/lib/s3-roots.mjs +0 -353
  127. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +0 -759
  128. package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +0 -209
  129. package/assets/skills/explainer-kit/scripts/lib/source-backlinks.mjs +0 -218
  130. package/assets/skills/explainer-kit/scripts/lib/terminal-evidence.mjs +0 -157
  131. package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +0 -400
  132. package/assets/skills/explainer-kit/scripts/publish.mjs +0 -76
  133. package/assets/skills/explainer-kit/scripts/record-durability.mjs +0 -64
  134. package/assets/skills/explainer-kit/scripts/render-qa.mjs +0 -461
  135. package/assets/skills/explainer-kit/scripts/run.mjs +0 -3038
  136. package/assets/skills/explainer-kit/scripts/validate.mjs +0 -59
  137. package/assets/skills/oat-explainer-kit/references/author-callback.md +0 -53
  138. package/assets/skills/oat-explainer-kit/references/migration.md +0 -144
  139. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +0 -83
  140. package/assets/skills/oat-explainer-kit/scripts/derive-destination.mjs +0 -91
  141. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +0 -531
  142. package/assets/skills/oat-explainer-kit/scripts/probe-recap-seams.mjs +0 -371
  143. package/assets/skills/oat-explainer-kit/scripts/run.mjs +0 -905
  144. package/dist/commands/project/archive/explainer-source-backlinks.d.ts +0 -18
  145. package/dist/commands/project/archive/explainer-source-backlinks.d.ts.map +0 -1
  146. package/dist/commands/project/archive/explainer-source-backlinks.js +0 -27
  147. package/dist/commands/project/archive/explainer-terminal-evidence.d.ts +0 -29
  148. package/dist/commands/project/archive/explainer-terminal-evidence.d.ts.map +0 -1
  149. package/dist/commands/project/archive/explainer-terminal-evidence.js +0 -37
@@ -1,954 +0,0 @@
1
- import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
2
- import { access, readFile, readdir, rm } from 'node:fs/promises';
3
- import { isAbsolute, join } from 'node:path';
4
-
5
- import { canonicalHash, validateContract } from './contracts.mjs';
6
- import {
7
- createConfinedRunRoot,
8
- writeFileAtomic,
9
- writeJsonAtomic,
10
- } from './fs-safe.mjs';
11
- import {
12
- SET_PLAN_RECORD_PATHS,
13
- VISUAL_REVISION_PATH,
14
- requiredImmutablePackagePaths,
15
- validateImmutablePackageEvidence,
16
- } from './package-coverage.mjs';
17
- import { resolveRootConfinedPath } from './safe-paths.mjs';
18
- import {
19
- assertTerminalEvidence,
20
- createTerminalEvidence,
21
- createVisualReviewEvidence,
22
- } from './terminal-evidence.mjs';
23
-
24
- export {
25
- PACKAGE_COVERAGE_VERSION,
26
- SET_PLAN_RECORD_PATHS,
27
- VISUAL_REVISION_PATH,
28
- requiredImmutablePackagePaths,
29
- validateImmutablePackageEvidence,
30
- } from './package-coverage.mjs';
31
-
32
- const STAGE_IDS = [
33
- 'validate',
34
- 'fact-base',
35
- 'content',
36
- 'theme',
37
- 'render',
38
- 'qa',
39
- 'durability',
40
- 'publish',
41
- ];
42
- const SUCCESSFUL_TERMINAL_STATUSES = new Set(['passed', 'warned', 'skipped']);
43
- const TERMINAL_STATUSES = new Set([...SUCCESSFUL_TERMINAL_STATUSES, 'failed']);
44
- const ALLOWED_TRANSITIONS = {
45
- pending: new Set(['running', 'failed', 'skipped']),
46
- running: new Set(['running', 'passed', 'warned', 'failed']),
47
- };
48
- const RESUME_TOKEN_V2_PREFIX = 'ekrt2:';
49
- const RESUME_TOKEN_V2_PATTERN = /^ekrt2:[a-f0-9]{64}$/;
50
-
51
- export function canonicalPersistedRunRequest(request, { outputRoot } = {}) {
52
- return privacySafeRequest(
53
- normalizeRunRequest(request, {
54
- ...(outputRoot !== undefined && { outputRoot }),
55
- }),
56
- );
57
- }
58
-
59
- export async function initializeRun(request) {
60
- const normalizedRequest = normalizeRunRequest(request);
61
- const paths = await createConfinedRunRoot(
62
- normalizedRequest.outputRoot,
63
- normalizedRequest.slug,
64
- );
65
- normalizedRequest.outputRoot = paths.outputRoot;
66
-
67
- const runId = `run-${randomUUID()}`;
68
- const startedAt = new Date().toISOString();
69
- const buildRecord = {
70
- schemaVersion: 'explainer-kit.build-record/v1',
71
- runId,
72
- renderStrategy: normalizedRequest.theme.renderStrategy,
73
- startedAt,
74
- stages: STAGE_IDS.map((id) => ({
75
- id,
76
- status: 'pending',
77
- outputPaths: [],
78
- warnings: [],
79
- })),
80
- outcome: 'incomplete',
81
- };
82
- assertValidContract('build-record', buildRecord);
83
-
84
- const run = {
85
- runId,
86
- slug: paths.slug,
87
- outputRoot: paths.outputRoot,
88
- runRoot: paths.runRoot,
89
- requestPath: join(paths.runRoot, 'run-request.json'),
90
- buildRecordPath: join(paths.runRoot, 'build-record.json'),
91
- manifestPath: join(paths.runRoot, 'manifest.json'),
92
- request: normalizedRequest,
93
- };
94
-
95
- await clearRunRoot(run);
96
- await writeJsonAtomic(run.runRoot, 'build-record.json', buildRecord);
97
- await writeJsonAtomic(
98
- run.runRoot,
99
- 'run-request.json',
100
- canonicalPersistedRunRequest(normalizedRequest, {
101
- outputRoot: paths.outputRoot,
102
- }),
103
- );
104
-
105
- return run;
106
- }
107
-
108
- export async function updateBuildRecord(run, stage) {
109
- assertRun(run);
110
- if (!isObject(stage) || !STAGE_IDS.includes(stage.id)) {
111
- throw new Error('Stage update must identify a supported stage.');
112
- }
113
-
114
- const record = JSON.parse(await readFile(run.buildRecordPath, 'utf8'));
115
- if (record.runId !== run.runId) {
116
- throw new Error('Build record does not belong to this run.');
117
- }
118
-
119
- const stageIndex = STAGE_IDS.indexOf(stage.id);
120
- const current = record.stages[stageIndex];
121
- const allowed = ALLOWED_TRANSITIONS[current.status];
122
- if (!allowed?.has(stage.status)) {
123
- throw new Error(
124
- `Stage transitions must be monotonic; ${stage.id} is terminal or cannot move from ${current.status} to ${stage.status}.`,
125
- );
126
- }
127
-
128
- if (
129
- current.status === 'pending' &&
130
- stageIndex > 0 &&
131
- !record.stages
132
- .slice(0, stageIndex)
133
- .every(({ status }) => SUCCESSFUL_TERMINAL_STATUSES.has(status))
134
- ) {
135
- throw new Error(
136
- `Stage ${stage.id} cannot start before every prior stage completes in order.`,
137
- );
138
- }
139
-
140
- const timestamp = new Date().toISOString();
141
- const next = {
142
- ...current,
143
- status: stage.status,
144
- outputPaths: stage.outputPaths ?? current.outputPaths,
145
- warnings: structuredClone(stage.warnings ?? current.warnings),
146
- };
147
- if (stage.error !== undefined) {
148
- next.error = localStageFailure(stage.id);
149
- }
150
- if (stage.status === 'running' && next.startedAt === undefined) {
151
- next.startedAt = timestamp;
152
- }
153
- if (TERMINAL_STATUSES.has(stage.status)) {
154
- next.startedAt ??= timestamp;
155
- next.completedAt = timestamp;
156
- }
157
- if (stage.status === 'failed' && !isObject(next.error)) {
158
- throw new Error('Failed stages require structured recovery details.');
159
- }
160
- if (stage.status !== 'failed' && 'error' in next) {
161
- throw new Error('Only failed stages may contain an error.');
162
- }
163
-
164
- record.stages[stageIndex] = next;
165
- if (stage.status === 'failed') {
166
- record.outcome = 'failed';
167
- record.completedAt = timestamp;
168
- } else if (
169
- record.stages.every(({ status }) => TERMINAL_STATUSES.has(status))
170
- ) {
171
- record.outcome = record.stages.some(({ status }) => status === 'failed')
172
- ? 'failed'
173
- : record.stages.some(({ warnings }) =>
174
- warnings.some((warning) =>
175
- warning.startsWith('visual-review-required:'),
176
- ),
177
- )
178
- ? 'built-needs-review'
179
- : 'built-not-durable';
180
- record.completedAt = timestamp;
181
- } else {
182
- record.outcome = 'incomplete';
183
- delete record.completedAt;
184
- }
185
-
186
- assertValidContract('build-record', record);
187
- await writeJsonAtomic(run.runRoot, 'build-record.json', record);
188
- return record;
189
- }
190
-
191
- export async function writeVisualReviewAttempt(run, { attempt, review } = {}) {
192
- assertRun(run);
193
- if (![1, 2].includes(attempt) || !isObject(review)) {
194
- throw new TypeError('Visual review records require attempt 1 or 2.');
195
- }
196
- const requestValidation = validateContract(
197
- 'visual-review-request',
198
- review.request,
199
- );
200
- const resultValidation = validateContract(
201
- 'visual-review-result',
202
- review.result,
203
- { visualReviewRequest: review.request },
204
- );
205
- if (!requestValidation.valid || !resultValidation.valid) {
206
- throw new Error(
207
- 'Visual review records must contain valid bound contracts.',
208
- );
209
- }
210
- const retainedResult = createVisualReviewEvidence({
211
- request: review.request,
212
- attempt,
213
- result: review.result,
214
- });
215
- return writeRetainedVisualReview(run, {
216
- attempt,
217
- request: review.request,
218
- retainedResult,
219
- });
220
- }
221
-
222
- export async function writeVisualReviewFailure(
223
- run,
224
- { attempt, request, kind = 'provider-failure' } = {},
225
- ) {
226
- assertRun(run);
227
- if (
228
- ![1, 2].includes(attempt) ||
229
- !isObject(request) ||
230
- !['provider-failure', 'pipeline-failure'].includes(kind)
231
- ) {
232
- throw new TypeError(
233
- 'Visual review failures require a bound request, attempt, and local failure kind.',
234
- );
235
- }
236
- const retainedResult = createVisualReviewEvidence({
237
- request,
238
- attempt,
239
- failureKind: kind,
240
- });
241
- return writeRetainedVisualReview(run, {
242
- attempt,
243
- request,
244
- retainedResult,
245
- });
246
- }
247
-
248
- async function writeRetainedVisualReview(
249
- run,
250
- { attempt, request, retainedResult },
251
- ) {
252
- const directory = `qa/visual-review/attempt-${attempt}`;
253
- const retainedRequest = structuredClone(request);
254
- const paths = [];
255
- for (const artifact of retainedRequest.renderedArtifacts) {
256
- for (const evidence of artifact.evidence) {
257
- const screenshotPath = `${directory}/evidence/${artifact.artifactId}/${evidence.viewport}.png`;
258
- const metricsPath = `${directory}/evidence/${artifact.artifactId}/${evidence.viewport}.json`;
259
- await copyConfinedEvidence(
260
- run.runRoot,
261
- evidence.screenshotPath,
262
- screenshotPath,
263
- evidence.screenshotHash,
264
- );
265
- await copyConfinedEvidence(
266
- run.runRoot,
267
- evidence.metricsPath,
268
- metricsPath,
269
- evidence.metricsHash,
270
- );
271
- paths.push(screenshotPath, metricsPath);
272
- }
273
- }
274
- const requestPath = `${directory}/request.json`;
275
- const resultPath = `${directory}/result.json`;
276
- await writeJsonAtomic(run.runRoot, requestPath, retainedRequest);
277
- await writeJsonAtomic(run.runRoot, resultPath, retainedResult);
278
- return [...paths, requestPath, resultPath];
279
- }
280
-
281
- export async function writeTerminalEvidence(run, input = {}) {
282
- assertRun(run);
283
- const allowed = new Set([
284
- 'outcome',
285
- 'manifest',
286
- 'reasons',
287
- 'evidenceDisposition',
288
- ]);
289
- const unsupported = Object.keys(input).filter((key) => !allowed.has(key));
290
- if (unsupported.length > 0) {
291
- throw new TypeError(
292
- `Terminal evidence rejects legacy or diagnostic fields: ${unsupported.join(', ')}.`,
293
- );
294
- }
295
- const { outcome, manifest, reasons, evidenceDisposition } = input;
296
- if (
297
- !['built-needs-review', 'failed'].includes(outcome) ||
298
- !['retained', 'partial', 'superseded', 'unavailable'].includes(
299
- evidenceDisposition,
300
- ) ||
301
- !Array.isArray(reasons) ||
302
- reasons.some((reason) => !isObject(reason))
303
- ) {
304
- throw new TypeError('Terminal evidence has an invalid compact shape.');
305
- }
306
- if (
307
- manifest !== undefined &&
308
- (!isObject(manifest) ||
309
- manifest.runId !== run.runId ||
310
- manifest.slug !== run.slug)
311
- ) {
312
- throw new Error(
313
- 'Terminal evidence manifest identity does not match the run.',
314
- );
315
- }
316
- const path = 'terminal-evidence.json';
317
- try {
318
- await access(join(run.runRoot, path));
319
- throw new Error('Terminal evidence is immutable once retained.');
320
- } catch (caught) {
321
- if (caught?.code !== 'ENOENT') throw caught;
322
- }
323
- const evidence = createTerminalEvidence({
324
- runId: run.runId,
325
- outcome,
326
- manifest,
327
- reasons,
328
- evidenceDisposition,
329
- });
330
- await writeJsonAtomic(run.runRoot, path, evidence);
331
- return path;
332
- }
333
-
334
- export async function supersedeTerminalEvidence(
335
- run,
336
- { manifest, supersededBy } = {},
337
- ) {
338
- assertRun(run);
339
- if (
340
- !isObject(manifest) ||
341
- manifest.runId !== run.runId ||
342
- manifest.slug !== run.slug
343
- ) {
344
- throw new Error(
345
- 'Terminal evidence manifest identity does not match the run.',
346
- );
347
- }
348
- const path = 'terminal-evidence.json';
349
- const retained = JSON.parse(await readFile(join(run.runRoot, path), 'utf8'));
350
- assertTerminalEvidence(retained, { manifest });
351
- if (retained.evidenceDisposition === 'superseded') {
352
- throw new Error('Terminal evidence is already superseded.');
353
- }
354
- const superseded = createTerminalEvidence({
355
- runId: run.runId,
356
- outcome: manifest.outcome,
357
- manifest,
358
- reasons: [
359
- {
360
- stage: 'finalization',
361
- kind: 'superseded',
362
- count: 1,
363
- },
364
- ],
365
- evidenceDisposition: 'superseded',
366
- supersededBy,
367
- });
368
- await writeJsonAtomic(run.runRoot, path, superseded);
369
- return path;
370
- }
371
-
372
- export async function writeVisualRevision(run, { artifactIds, changes } = {}) {
373
- assertRun(run);
374
- if (
375
- !Array.isArray(artifactIds) ||
376
- artifactIds.length === 0 ||
377
- new Set(artifactIds).size !== artifactIds.length ||
378
- !Array.isArray(changes) ||
379
- changes.length !== artifactIds.length ||
380
- changes.some(
381
- (change) =>
382
- !isObject(change) ||
383
- !artifactIds.includes(change.artifactId) ||
384
- typeof change.contentPath !== 'string' ||
385
- typeof change.authorResultPath !== 'string' ||
386
- !/^sha256:[a-f0-9]{64}$/.test(change.previousHash) ||
387
- !/^sha256:[a-f0-9]{64}$/.test(change.revisedHash),
388
- )
389
- ) {
390
- throw new TypeError(
391
- 'One visual revision requires unique corrected artifacts and hash-bound changes.',
392
- );
393
- }
394
- try {
395
- await access(join(run.runRoot, VISUAL_REVISION_PATH));
396
- throw new Error('Only one visual revision may be retained per run.');
397
- } catch (error) {
398
- if (error?.code !== 'ENOENT') throw error;
399
- }
400
- await writeJsonAtomic(run.runRoot, VISUAL_REVISION_PATH, {
401
- schemaVersion: 'explainer-kit.visual-revision/v1',
402
- attempt: 1,
403
- artifactIds: [...artifactIds],
404
- changes: structuredClone(changes),
405
- });
406
- return [VISUAL_REVISION_PATH];
407
- }
408
-
409
- async function copyConfinedEvidence(
410
- runRoot,
411
- sourcePath,
412
- targetPath,
413
- expectedHash,
414
- ) {
415
- const confined = await resolveRootConfinedPath(runRoot, sourcePath);
416
- if (!confined.valid) {
417
- throw new Error(
418
- `Visual review evidence is not run-root confined: ${sourcePath}`,
419
- );
420
- }
421
- const bytes = await readFile(confined.absolutePath);
422
- const hash = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
423
- if (hash !== expectedHash) {
424
- throw new Error(
425
- `Visual review evidence hash changed before retention: ${sourcePath}`,
426
- );
427
- }
428
- await writeFileAtomic(runRoot, targetPath, bytes);
429
- }
430
-
431
- export async function reopenBuildStages(run, { ids, reason }) {
432
- assertRun(run);
433
- if (
434
- !Array.isArray(ids) ||
435
- ids.length === 0 ||
436
- ids.some((id) => !STAGE_IDS.includes(id)) ||
437
- typeof reason !== 'string' ||
438
- reason.length === 0
439
- ) {
440
- throw new Error('Stage reopen requires supported stage ids and a reason.');
441
- }
442
-
443
- const indexes = ids.map((id) => STAGE_IDS.indexOf(id));
444
- const firstIndex = Math.min(...indexes);
445
- const lastIndex = Math.max(...indexes);
446
- if (
447
- new Set(indexes).size !== indexes.length ||
448
- indexes.some((index, offset) => index !== firstIndex + offset) ||
449
- ids.some((id, index) => STAGE_IDS[firstIndex + index] !== id)
450
- ) {
451
- throw new Error('Reopened stages must be unique and contiguous in order.');
452
- }
453
-
454
- const record = JSON.parse(await readFile(run.buildRecordPath, 'utf8'));
455
- if (record.runId !== run.runId) {
456
- throw new Error('Build record does not belong to this run.');
457
- }
458
- if (
459
- !record.stages
460
- .slice(0, firstIndex)
461
- .every(({ status }) => SUCCESSFUL_TERMINAL_STATUSES.has(status)) ||
462
- !record.stages
463
- .slice(firstIndex, lastIndex + 1)
464
- .every(({ status }) => SUCCESSFUL_TERMINAL_STATUSES.has(status)) ||
465
- !record.stages
466
- .slice(lastIndex + 1)
467
- .every(({ status }) => status === 'pending')
468
- ) {
469
- throw new Error(
470
- 'Stages may reopen only after successful prerequisites and before downstream work.',
471
- );
472
- }
473
-
474
- const timestamp = new Date().toISOString();
475
- for (let index = firstIndex; index <= lastIndex; index += 1) {
476
- const previous = record.stages[index];
477
- record.stages[index] = {
478
- id: previous.id,
479
- status: 'pending',
480
- outputPaths: [],
481
- warnings: [...previous.warnings, `stage-reopened:${reason}:${timestamp}`],
482
- };
483
- }
484
- record.outcome = 'incomplete';
485
- delete record.completedAt;
486
-
487
- assertValidContract('build-record', record);
488
- await writeJsonAtomic(run.runRoot, 'build-record.json', record);
489
- return record;
490
- }
491
-
492
- export async function writeManifestAtomic(run, manifest) {
493
- assertRun(run);
494
- if (!isObject(manifest)) {
495
- throw new TypeError('Manifest must be an object.');
496
- }
497
- if (manifest.runId !== run.runId || manifest.slug !== run.slug) {
498
- throw new Error('Manifest identity does not match the initialized run.');
499
- }
500
-
501
- const buildRecord = JSON.parse(await readFile(run.buildRecordPath, 'utf8'));
502
- const runMode =
503
- manifest.recipe?.id === 'project-recap'
504
- ? await verifiedRunRequestMode(run, manifest)
505
- : undefined;
506
- assertImmutablePackageCoverage(manifest, { runMode });
507
- await validateImmutablePackageEvidence(manifest, {
508
- runMode,
509
- read: (path) => readFile(join(run.runRoot, path)),
510
- });
511
- assertValidContract('manifest', manifest, { buildRecord });
512
- await writeJsonAtomic(run.runRoot, 'manifest.json', manifest);
513
- return run.manifestPath;
514
- }
515
-
516
- function assertImmutablePackageCoverage(manifest, options) {
517
- const recorded = manifest.immutableHashes;
518
- if (!isObject(recorded)) {
519
- throw new Error('Manifest does not identify immutable package hashes.');
520
- }
521
- const missing = requiredImmutablePackagePaths(manifest, options).filter(
522
- (path) => typeof recorded[path] !== 'string',
523
- );
524
- if (missing.length > 0) {
525
- throw new Error(
526
- `Manifest immutable hashes do not cover the canonical package: ${missing.join(', ')}.`,
527
- );
528
- }
529
- }
530
-
531
- async function verifiedRunRequestMode(run, manifest) {
532
- const expectedHash = manifest.immutableHashes?.['run-request.json'];
533
- if (!/^sha256:[a-f0-9]{64}$/.test(expectedHash)) {
534
- throw new Error(
535
- 'Manifest must identify a valid immutable run-request.json hash before package coverage is evaluated.',
536
- );
537
- }
538
- const bytes = await readFile(run.requestPath);
539
- const actualHash = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
540
- if (actualHash !== expectedHash) {
541
- throw new Error(
542
- 'Immutable package hash mismatch for run-request.json before package coverage is evaluated.',
543
- );
544
- }
545
- let request;
546
- try {
547
- request = JSON.parse(bytes.toString('utf8'));
548
- } catch {
549
- throw new Error(
550
- 'Hash-verified run-request.json must contain valid JSON before package coverage is evaluated.',
551
- );
552
- }
553
- if (!['interactive', 'unattended'].includes(request?.mode)) {
554
- throw new Error(
555
- 'Hash-verified run-request.json must declare interactive or unattended mode.',
556
- );
557
- }
558
- return request.mode;
559
- }
560
-
561
- export async function writeSetPlanRecords(run, { request, plan }) {
562
- assertRun(run);
563
- if (
564
- !isObject(request) ||
565
- request.schemaVersion !== 'explainer-kit.set-plan-request/v1'
566
- ) {
567
- throw new TypeError('Set-plan request must use the v1 record contract.');
568
- }
569
- assertValidContract('set-plan', plan);
570
-
571
- const retainedRequest = {
572
- ...structuredClone(request),
573
- planHash: canonicalHash(plan),
574
- };
575
- const records = [
576
- ['source/set-plan/request.json', retainedRequest],
577
- ['source/set-plan/result.json', plan],
578
- [
579
- 'source/set-plan/ledger.json',
580
- {
581
- schemaVersion: 'explainer-kit.set-plan-ledger/v1',
582
- planId: plan.planId,
583
- ...plan.ledger,
584
- },
585
- ],
586
- [
587
- 'source/set-plan/portfolio.json',
588
- {
589
- schemaVersion: 'explainer-kit.set-plan-portfolio/v1',
590
- planId: plan.planId,
591
- artifacts: plan.portfolio,
592
- },
593
- ],
594
- [
595
- 'source/set-plan/drafts.json',
596
- {
597
- schemaVersion: 'explainer-kit.set-plan-drafts/v1',
598
- drafts: plan.portfolio.map(
599
- ({ artifactId, draft, visualIntent, justification }) => ({
600
- artifactId,
601
- draft,
602
- visualIntent,
603
- ...(justification && { justification }),
604
- }),
605
- ),
606
- },
607
- ],
608
- ];
609
- for (const [relativePath] of records) {
610
- try {
611
- await access(join(run.runRoot, relativePath));
612
- throw new Error(
613
- `Immutable set-plan record ${relativePath} already exists.`,
614
- );
615
- } catch (error) {
616
- if (error?.code !== 'ENOENT') throw error;
617
- }
618
- }
619
- for (const [relativePath, value] of records) {
620
- await writeJsonAtomic(run.runRoot, relativePath, value);
621
- }
622
- return records.map(([relativePath]) => relativePath);
623
- }
624
-
625
- export async function createSetPlanResumeToken(run) {
626
- assertRun(run);
627
- if (typeof run.outputRoot !== 'string' || !isAbsolute(run.outputRoot)) {
628
- throw new TypeError(
629
- 'Authenticated resume tokens require a canonical absolute output root.',
630
- );
631
- }
632
- const digest = await hashResumeToken(run, {
633
- domain: 'explainer-kit.set-plan-resume/v2\0',
634
- bindCanonicalRoot: true,
635
- bindRequest: true,
636
- });
637
- return `${RESUME_TOKEN_V2_PREFIX}${digest}`;
638
- }
639
-
640
- async function hashResumeToken(
641
- run,
642
- { domain, bindCanonicalRoot, bindRequest },
643
- ) {
644
- const tokenHash = createHash('sha256');
645
- tokenHash.update(domain);
646
- tokenHash.update(run.runId);
647
- tokenHash.update('\0');
648
- if (bindCanonicalRoot) {
649
- tokenHash.update(run.outputRoot);
650
- tokenHash.update('\0');
651
- }
652
- try {
653
- if (bindRequest) {
654
- const requestBytes = await readFile(
655
- run.requestPath ?? join(run.runRoot, 'run-request.json'),
656
- );
657
- updateResumeTokenFileHash(tokenHash, 'run-request.json', requestBytes);
658
- }
659
- for (const relativePath of SET_PLAN_RECORD_PATHS) {
660
- const bytes = await readFile(join(run.runRoot, relativePath));
661
- updateResumeTokenFileHash(tokenHash, relativePath, bytes);
662
- }
663
- } catch (error) {
664
- throw resumeRecordError(
665
- `Unable to hash the complete retained resume identity: ${error.message}`,
666
- );
667
- }
668
- return tokenHash.digest('hex');
669
- }
670
-
671
- function updateResumeTokenFileHash(tokenHash, relativePath, bytes) {
672
- const byteHash = createHash('sha256').update(bytes).digest();
673
- tokenHash.update(relativePath);
674
- tokenHash.update('\0');
675
- tokenHash.update(byteHash);
676
- }
677
-
678
- export async function verifySetPlanResumeToken(run, resumeToken) {
679
- assertRun(run);
680
- if (
681
- typeof resumeToken !== 'string' ||
682
- !RESUME_TOKEN_V2_PATTERN.test(resumeToken)
683
- ) {
684
- throw resumeRecordError(
685
- 'Interactive approval resume requires a valid external resume token.',
686
- );
687
- }
688
- const expectedToken = await createSetPlanResumeToken(run);
689
- const expected = Buffer.from(expectedToken, 'ascii');
690
- const supplied = Buffer.from(resumeToken, 'ascii');
691
- if (
692
- supplied.byteLength !== expected.byteLength ||
693
- !timingSafeEqual(supplied, expected)
694
- ) {
695
- throw resumeRecordError(
696
- 'Interactive approval resume token does not match the retained set plan.',
697
- );
698
- }
699
- }
700
-
701
- export async function readSetPlanRecords(run, { factBase, recipe }) {
702
- assertRun(run);
703
- if (
704
- !isObject(factBase) ||
705
- !Array.isArray(factBase.sources) ||
706
- !isObject(recipe) ||
707
- typeof recipe.id !== 'string' ||
708
- typeof recipe.version !== 'string'
709
- ) {
710
- throw resumeRecordError(
711
- 'Retained set-plan validation requires the fact base and active recipe.',
712
- );
713
- }
714
-
715
- let values;
716
- try {
717
- values = await Promise.all(
718
- SET_PLAN_RECORD_PATHS.map((relativePath) =>
719
- readJson(join(run.runRoot, relativePath)),
720
- ),
721
- );
722
- } catch (error) {
723
- throw resumeRecordError(
724
- `Unable to read the complete retained set plan: ${error.message}`,
725
- );
726
- }
727
- const [request, plan, ledger, portfolio, drafts] = values;
728
- const requestKeys = [
729
- 'discovery',
730
- 'factBaseHash',
731
- 'planHash',
732
- 'recipe',
733
- 'schemaVersion',
734
- 'sourceIds',
735
- ];
736
- if (
737
- !isObject(request) ||
738
- canonicalHash(Object.keys(request).sort()) !== canonicalHash(requestKeys) ||
739
- request.schemaVersion !== 'explainer-kit.set-plan-request/v1' ||
740
- canonicalHash(request.recipe) !==
741
- canonicalHash({ id: recipe.id, version: recipe.version }) ||
742
- request.factBaseHash !== canonicalHash(factBase) ||
743
- request.planHash !== canonicalHash(plan) ||
744
- !isObject(request.discovery) ||
745
- !Number.isInteger(request.discovery.rounds) ||
746
- request.discovery.rounds < 0 ||
747
- !Array.isArray(request.discovery.findings) ||
748
- !['not-requested', 'two-empty-rounds', 'hard-maximum'].includes(
749
- request.discovery.reason,
750
- )
751
- ) {
752
- throw resumeRecordError(
753
- 'Retained set-plan request does not match the fact base and active recipe.',
754
- );
755
- }
756
-
757
- const planValidation = validateContract('set-plan', plan);
758
- if (!planValidation.valid) {
759
- throw resumeRecordError('Retained set-plan result is invalid.');
760
- }
761
- const expectedSourceIds = factBase.sources
762
- .map(({ id }) => id)
763
- .filter((id) => !id.startsWith('critic:'));
764
- if (
765
- canonicalHash(plan.recipe) !==
766
- canonicalHash({ id: recipe.id, version: recipe.version }) ||
767
- canonicalHash(request.sourceIds) !== canonicalHash(expectedSourceIds) ||
768
- canonicalHash(plan.sourceIds) !== canonicalHash(request.sourceIds)
769
- ) {
770
- throw resumeRecordError(
771
- 'Retained set-plan request and result identities have drifted.',
772
- );
773
- }
774
-
775
- const expectedLedger = {
776
- schemaVersion: 'explainer-kit.set-plan-ledger/v1',
777
- planId: plan.planId,
778
- ...plan.ledger,
779
- };
780
- const expectedPortfolio = {
781
- schemaVersion: 'explainer-kit.set-plan-portfolio/v1',
782
- planId: plan.planId,
783
- artifacts: plan.portfolio,
784
- };
785
- const expectedDrafts = {
786
- schemaVersion: 'explainer-kit.set-plan-drafts/v1',
787
- drafts: plan.portfolio.map(
788
- ({ artifactId, draft, visualIntent, justification }) => ({
789
- artifactId,
790
- draft,
791
- visualIntent,
792
- ...(justification && { justification }),
793
- }),
794
- ),
795
- };
796
- for (const [label, actual, expected] of [
797
- ['ledger', ledger, expectedLedger],
798
- ['portfolio', portfolio, expectedPortfolio],
799
- ['drafts', drafts, expectedDrafts],
800
- ]) {
801
- if (canonicalHash(actual) !== canonicalHash(expected)) {
802
- throw resumeRecordError(
803
- `Retained set-plan ${label} projection does not match the canonical result.`,
804
- );
805
- }
806
- }
807
-
808
- return {
809
- request,
810
- plan,
811
- paths: [...SET_PLAN_RECORD_PATHS],
812
- };
813
- }
814
-
815
- function normalizeRequestSlug(slug) {
816
- if (
817
- typeof slug !== 'string' ||
818
- slug.includes('/') ||
819
- slug.includes('\\') ||
820
- slug.includes('\0') ||
821
- slug === '.' ||
822
- slug === '..'
823
- ) {
824
- throw new Error('Slug cannot contain path traversal or separators.');
825
- }
826
-
827
- const normalized = slug
828
- .normalize('NFKD')
829
- .replaceAll(/\p{Mark}/gu, '')
830
- .toLowerCase()
831
- .trim()
832
- .replaceAll(/[^a-z0-9]+/g, '-')
833
- .replaceAll(/^-+|-+$/g, '');
834
- if (!normalized) {
835
- throw new Error('Slug must contain at least one letter or number.');
836
- }
837
- return normalized;
838
- }
839
-
840
- function normalizeRunRequest(request, { outputRoot } = {}) {
841
- if (!isObject(request)) {
842
- throw new TypeError('Run request must be an object.');
843
- }
844
- const normalized = structuredClone(request);
845
- normalized.slug = normalizeRequestSlug(request.slug);
846
- normalized.theme = {
847
- ...(isObject(normalized.theme) ? normalized.theme : {}),
848
- renderStrategy: normalized.theme?.renderStrategy ?? 'default-only',
849
- };
850
- if (outputRoot !== undefined) {
851
- normalized.outputRoot = outputRoot;
852
- }
853
- assertValidContract('run-request', normalized);
854
- return normalized;
855
- }
856
-
857
- function privacySafeRequest(request) {
858
- const persisted = structuredClone(request);
859
- const retainRaw = persisted.privacy?.retainRawArtDirection === true;
860
- if (!retainRaw && isObject(persisted.theme)) {
861
- delete persisted.theme.artDirection;
862
- }
863
- return persisted;
864
- }
865
-
866
- function assertValidContract(kind, value, context) {
867
- const result = validateContract(kind, value, context);
868
- if (!result.valid) {
869
- const details = result.errors
870
- .map(({ path, code, message }) => `${path} [${code}]: ${message}`)
871
- .join('; ');
872
- throw new Error(`Invalid ${kind}: ${details}`);
873
- }
874
- }
875
-
876
- function localStageFailure(stageId) {
877
- return {
878
- code: `E_${stageId.toUpperCase().replaceAll('-', '_')}`,
879
- message: `The ${stageId} stage failed.`,
880
- recovery: [`Correct the ${stageId} stage and start a new run.`],
881
- };
882
- }
883
-
884
- function assertRun(run) {
885
- if (
886
- !isObject(run) ||
887
- typeof run.runId !== 'string' ||
888
- typeof run.runRoot !== 'string'
889
- ) {
890
- throw new TypeError('Run must be returned by initializeRun().');
891
- }
892
- }
893
-
894
- function resumeRecordError(message) {
895
- const error = new Error(message);
896
- error.code = 'E_APPROVAL_RESUME';
897
- return error;
898
- }
899
-
900
- async function clearRunRoot(run) {
901
- const entries = await readdir(run.runRoot, { withFileTypes: true });
902
- if (entries.length === 0) return;
903
- await assertOwnedRunRoot(run);
904
- const removable = [];
905
- for (const entry of entries) {
906
- const path = join(run.runRoot, entry.name);
907
- if (!(await containsSymlink(path, entry))) {
908
- removable.push(path);
909
- }
910
- }
911
- await Promise.all(
912
- removable.map((path) => rm(path, { recursive: true, force: true })),
913
- );
914
- }
915
-
916
- async function assertOwnedRunRoot(run) {
917
- try {
918
- const [persistedRequest, persistedRecord] = await Promise.all([
919
- readJson(join(run.runRoot, 'run-request.json')),
920
- readJson(join(run.runRoot, 'build-record.json')),
921
- ]);
922
- assertValidContract('run-request', persistedRequest);
923
- assertValidContract('build-record', persistedRecord);
924
- if (
925
- persistedRequest.slug !== run.slug ||
926
- persistedRequest.outputRoot !== run.outputRoot
927
- ) {
928
- throw new Error('Prior run identity does not match this slug.');
929
- }
930
- } catch {
931
- throw new Error(
932
- 'Existing slug directory is not a prior Explainer Kit run; refusing to clear it.',
933
- );
934
- }
935
- }
936
-
937
- async function containsSymlink(path, entry) {
938
- if (entry.isSymbolicLink()) return true;
939
- if (!entry.isDirectory()) return false;
940
- for (const child of await readdir(path, { withFileTypes: true })) {
941
- if (await containsSymlink(join(path, child.name), child)) {
942
- return true;
943
- }
944
- }
945
- return false;
946
- }
947
-
948
- async function readJson(path) {
949
- return JSON.parse(await readFile(path, 'utf8'));
950
- }
951
-
952
- function isObject(value) {
953
- return typeof value === 'object' && value !== null && !Array.isArray(value);
954
- }