@open-agent-toolkit/cli 0.2.25 → 0.2.27

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 (87) hide show
  1. package/assets/NOTICES.md +156 -0
  2. package/assets/docs/cli-utilities/configuration.md +42 -11
  3. package/assets/docs/contributing/explainer-kit-verification.md +125 -0
  4. package/assets/docs/contributing/index.md +1 -0
  5. package/assets/docs/reference/troubleshooting.md +47 -0
  6. package/assets/docs/workflows/projects/artifacts.md +24 -6
  7. package/assets/docs/workflows/projects/dispatch-ceiling.md +67 -16
  8. package/assets/docs/workflows/skills/explainer-kit-providers.md +144 -0
  9. package/assets/docs/workflows/skills/explainer-kit.md +121 -69
  10. package/assets/docs/workflows/skills/index.md +1 -0
  11. package/assets/public-package-versions.json +4 -4
  12. package/assets/skills/explainer-kit/SKILL.md +18 -3
  13. package/assets/skills/explainer-kit/recipes/project-recap.json +43 -16
  14. package/assets/skills/explainer-kit/references/contracts.md +167 -20
  15. package/assets/skills/explainer-kit/references/golden-conformance.md +80 -0
  16. package/assets/skills/explainer-kit/references/visual-authoring.md +92 -0
  17. package/assets/skills/explainer-kit/references/visual-review.md +57 -0
  18. package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +172 -1
  19. package/assets/skills/explainer-kit/schemas/build-record.schema.json +7 -1
  20. package/assets/skills/explainer-kit/schemas/fact-base.schema.json +38 -2
  21. package/assets/skills/explainer-kit/schemas/manifest.schema.json +25 -1
  22. package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -0
  23. package/assets/skills/explainer-kit/schemas/set-plan.v1.schema.json +149 -0
  24. package/assets/skills/explainer-kit/schemas/visual-review-request.v1.schema.json +117 -0
  25. package/assets/skills/explainer-kit/schemas/visual-review-result.v1.schema.json +80 -0
  26. package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +148 -4
  27. package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +243 -0
  28. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +586 -8
  29. package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +285 -8
  30. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +35 -0
  31. package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +144 -8
  32. package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +379 -0
  33. package/assets/skills/explainer-kit/scripts/lib/png.mjs +287 -0
  34. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +280 -8
  35. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +132 -3
  36. package/assets/skills/explainer-kit/scripts/lib/records.mjs +513 -21
  37. package/assets/skills/explainer-kit/scripts/lib/render.mjs +67 -3
  38. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +43 -1
  39. package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +208 -0
  40. package/assets/skills/explainer-kit/scripts/lib/source-backlinks.mjs +218 -0
  41. package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +380 -0
  42. package/assets/skills/explainer-kit/scripts/render-qa.mjs +48 -10
  43. package/assets/skills/explainer-kit/scripts/run.mjs +859 -134
  44. package/assets/skills/oat-explainer-kit/SKILL.md +40 -12
  45. package/assets/skills/oat-explainer-kit/references/author-callback.md +12 -10
  46. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +40 -2
  47. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +72 -0
  48. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +167 -5
  49. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +92 -5
  50. package/assets/skills/oat-explainer-kit/scripts/run.mjs +324 -2
  51. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
  52. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
  53. package/assets/skills/oat-project-implement/SKILL.md +9 -11
  54. package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +18 -9
  55. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
  56. package/assets/skills/oat-project-implement/references/phase-execution.md +13 -4
  57. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
  58. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
  59. package/dist/commands/config/index.d.ts.map +1 -1
  60. package/dist/commands/config/index.js +27 -3
  61. package/dist/commands/project/archive/archive-utils.d.ts +1 -0
  62. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  63. package/dist/commands/project/archive/archive-utils.js +109 -42
  64. package/dist/commands/project/archive/explainer-package-coverage.d.ts +14 -0
  65. package/dist/commands/project/archive/explainer-package-coverage.d.ts.map +1 -0
  66. package/dist/commands/project/archive/explainer-package-coverage.js +27 -0
  67. package/dist/commands/project/archive/explainer-source-backlinks.d.ts +18 -0
  68. package/dist/commands/project/archive/explainer-source-backlinks.d.ts.map +1 -0
  69. package/dist/commands/project/archive/explainer-source-backlinks.js +27 -0
  70. package/dist/commands/project/archive/push-runner.d.ts +2 -1
  71. package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
  72. package/dist/commands/project/archive/push-runner.js +5 -1
  73. package/dist/commands/project/dispatch-ceiling/index.d.ts.map +1 -1
  74. package/dist/commands/project/dispatch-ceiling/index.js +90 -0
  75. package/dist/config/dispatch-notices.d.ts +8 -0
  76. package/dist/config/dispatch-notices.d.ts.map +1 -0
  77. package/dist/config/dispatch-notices.js +79 -0
  78. package/dist/config/dispatch-policy-options.d.ts +2 -0
  79. package/dist/config/dispatch-policy-options.d.ts.map +1 -1
  80. package/dist/config/dispatch-policy-options.js +14 -2
  81. package/dist/providers/identity/dispatch-report.d.ts +17 -0
  82. package/dist/providers/identity/dispatch-report.d.ts.map +1 -1
  83. package/dist/providers/identity/dispatch-report.js +30 -0
  84. package/dist/release/public-package-contract.d.ts +6 -0
  85. package/dist/release/public-package-contract.d.ts.map +1 -1
  86. package/dist/release/public-package-contract.js +75 -0
  87. package/package.json +2 -2
@@ -1,9 +1,28 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { readFile, readdir, rm } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
-
5
- import { validateContract } from './contracts.mjs';
6
- import { createConfinedRunRoot, writeJsonAtomic } from './fs-safe.mjs';
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
+
19
+ export {
20
+ PACKAGE_COVERAGE_VERSION,
21
+ SET_PLAN_RECORD_PATHS,
22
+ VISUAL_REVISION_PATH,
23
+ requiredImmutablePackagePaths,
24
+ validateImmutablePackageEvidence,
25
+ } from './package-coverage.mjs';
7
26
 
8
27
  const STAGE_IDS = [
9
28
  'validate',
@@ -21,21 +40,19 @@ const ALLOWED_TRANSITIONS = {
21
40
  pending: new Set(['running', 'failed', 'skipped']),
22
41
  running: new Set(['running', 'passed', 'warned', 'failed']),
23
42
  };
43
+ const RESUME_TOKEN_V2_PREFIX = 'ekrt2:';
44
+ const RESUME_TOKEN_V2_PATTERN = /^ekrt2:[a-f0-9]{64}$/;
45
+
46
+ export function canonicalPersistedRunRequest(request, { outputRoot } = {}) {
47
+ return privacySafeRequest(
48
+ normalizeRunRequest(request, {
49
+ ...(outputRoot !== undefined && { outputRoot }),
50
+ }),
51
+ );
52
+ }
24
53
 
25
54
  export async function initializeRun(request) {
26
- if (!isObject(request)) {
27
- throw new TypeError('Run request must be an object.');
28
- }
29
-
30
- const normalizedRequest = structuredClone(request);
31
- normalizedRequest.slug = normalizeRequestSlug(request.slug);
32
- normalizedRequest.theme = {
33
- ...(isObject(normalizedRequest.theme) ? normalizedRequest.theme : {}),
34
- renderStrategy: normalizedRequest.theme?.renderStrategy ?? 'default-only',
35
- };
36
-
37
- assertValidContract('run-request', normalizedRequest);
38
-
55
+ const normalizedRequest = normalizeRunRequest(request);
39
56
  const paths = await createConfinedRunRoot(
40
57
  normalizedRequest.outputRoot,
41
58
  normalizedRequest.slug,
@@ -75,7 +92,9 @@ export async function initializeRun(request) {
75
92
  await writeJsonAtomic(
76
93
  run.runRoot,
77
94
  'run-request.json',
78
- privacySafeRequest(normalizedRequest),
95
+ canonicalPersistedRunRequest(normalizedRequest, {
96
+ outputRoot: paths.outputRoot,
97
+ }),
79
98
  );
80
99
 
81
100
  return run;
@@ -146,7 +165,13 @@ export async function updateBuildRecord(run, stage) {
146
165
  ) {
147
166
  record.outcome = record.stages.some(({ status }) => status === 'failed')
148
167
  ? 'failed'
149
- : 'built-not-durable';
168
+ : record.stages.some(({ warnings }) =>
169
+ warnings.some((warning) =>
170
+ warning.startsWith('visual-review-required:'),
171
+ ),
172
+ )
173
+ ? 'built-needs-review'
174
+ : 'built-not-durable';
150
175
  record.completedAt = timestamp;
151
176
  } else {
152
177
  record.outcome = 'incomplete';
@@ -158,6 +183,142 @@ export async function updateBuildRecord(run, stage) {
158
183
  return record;
159
184
  }
160
185
 
186
+ export async function writeVisualReviewAttempt(run, { attempt, review } = {}) {
187
+ assertRun(run);
188
+ if (![1, 2].includes(attempt) || !isObject(review)) {
189
+ throw new TypeError('Visual review records require attempt 1 or 2.');
190
+ }
191
+ const requestValidation = validateContract(
192
+ 'visual-review-request',
193
+ review.request,
194
+ );
195
+ const resultValidation = validateContract(
196
+ 'visual-review-result',
197
+ review.result,
198
+ { visualReviewRequest: review.request },
199
+ );
200
+ if (!requestValidation.valid || !resultValidation.valid) {
201
+ throw new Error(
202
+ 'Visual review records must contain valid bound contracts.',
203
+ );
204
+ }
205
+
206
+ const directory = `qa/visual-review/attempt-${attempt}`;
207
+ const retainedRequest = structuredClone(review.request);
208
+ const paths = [];
209
+ for (const artifact of retainedRequest.renderedArtifacts) {
210
+ for (const evidence of artifact.evidence) {
211
+ const screenshotPath = `${directory}/evidence/${artifact.artifactId}/${evidence.viewport}.png`;
212
+ const metricsPath = `${directory}/evidence/${artifact.artifactId}/${evidence.viewport}.json`;
213
+ await copyConfinedEvidence(
214
+ run.runRoot,
215
+ evidence.screenshotPath,
216
+ screenshotPath,
217
+ evidence.screenshotHash,
218
+ );
219
+ await copyConfinedEvidence(
220
+ run.runRoot,
221
+ evidence.metricsPath,
222
+ metricsPath,
223
+ evidence.metricsHash,
224
+ );
225
+ paths.push(screenshotPath, metricsPath);
226
+ }
227
+ }
228
+ const requestPath = `${directory}/request.json`;
229
+ const resultPath = `${directory}/result.json`;
230
+ await writeJsonAtomic(run.runRoot, requestPath, retainedRequest);
231
+ await writeJsonAtomic(run.runRoot, resultPath, review.result);
232
+ return [...paths, requestPath, resultPath];
233
+ }
234
+
235
+ export async function writeVisualReviewFailure(
236
+ run,
237
+ { attempt, error, evidence = [] } = {},
238
+ ) {
239
+ assertRun(run);
240
+ if (
241
+ ![1, 2].includes(attempt) ||
242
+ !(error instanceof Error) ||
243
+ !Array.isArray(evidence)
244
+ ) {
245
+ throw new TypeError(
246
+ 'Visual review failures require an attempt, Error, and evidence array.',
247
+ );
248
+ }
249
+ const path = `qa/review-gate/attempt-${attempt}-error.json`;
250
+ await writeJsonAtomic(run.runRoot, path, {
251
+ schemaVersion: 'explainer-kit.visual-review-error/v1',
252
+ attempt,
253
+ code: error.code ?? 'E_VISUAL_REVIEW',
254
+ message: error.message,
255
+ evidencePaths: evidence.flatMap(({ screenshotPath, metricsPath }) => [
256
+ screenshotPath,
257
+ metricsPath,
258
+ ]),
259
+ });
260
+ return [path];
261
+ }
262
+
263
+ export async function writeVisualRevision(run, { artifactIds, changes } = {}) {
264
+ assertRun(run);
265
+ if (
266
+ !Array.isArray(artifactIds) ||
267
+ artifactIds.length === 0 ||
268
+ new Set(artifactIds).size !== artifactIds.length ||
269
+ !Array.isArray(changes) ||
270
+ changes.length !== artifactIds.length ||
271
+ changes.some(
272
+ (change) =>
273
+ !isObject(change) ||
274
+ !artifactIds.includes(change.artifactId) ||
275
+ typeof change.contentPath !== 'string' ||
276
+ typeof change.authorResultPath !== 'string' ||
277
+ !/^sha256:[a-f0-9]{64}$/.test(change.previousHash) ||
278
+ !/^sha256:[a-f0-9]{64}$/.test(change.revisedHash),
279
+ )
280
+ ) {
281
+ throw new TypeError(
282
+ 'One visual revision requires unique corrected artifacts and hash-bound changes.',
283
+ );
284
+ }
285
+ try {
286
+ await access(join(run.runRoot, VISUAL_REVISION_PATH));
287
+ throw new Error('Only one visual revision may be retained per run.');
288
+ } catch (error) {
289
+ if (error?.code !== 'ENOENT') throw error;
290
+ }
291
+ await writeJsonAtomic(run.runRoot, VISUAL_REVISION_PATH, {
292
+ schemaVersion: 'explainer-kit.visual-revision/v1',
293
+ attempt: 1,
294
+ artifactIds: [...artifactIds],
295
+ changes: structuredClone(changes),
296
+ });
297
+ return [VISUAL_REVISION_PATH];
298
+ }
299
+
300
+ async function copyConfinedEvidence(
301
+ runRoot,
302
+ sourcePath,
303
+ targetPath,
304
+ expectedHash,
305
+ ) {
306
+ const confined = await resolveRootConfinedPath(runRoot, sourcePath);
307
+ if (!confined.valid) {
308
+ throw new Error(
309
+ `Visual review evidence is not run-root confined: ${sourcePath}`,
310
+ );
311
+ }
312
+ const bytes = await readFile(confined.absolutePath);
313
+ const hash = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
314
+ if (hash !== expectedHash) {
315
+ throw new Error(
316
+ `Visual review evidence hash changed before retention: ${sourcePath}`,
317
+ );
318
+ }
319
+ await writeFileAtomic(runRoot, targetPath, bytes);
320
+ }
321
+
161
322
  export async function reopenBuildStages(run, { ids, reason }) {
162
323
  assertRun(run);
163
324
  if (
@@ -229,11 +390,319 @@ export async function writeManifestAtomic(run, manifest) {
229
390
  }
230
391
 
231
392
  const buildRecord = JSON.parse(await readFile(run.buildRecordPath, 'utf8'));
393
+ const runMode =
394
+ manifest.recipe?.id === 'project-recap'
395
+ ? await verifiedRunRequestMode(run, manifest)
396
+ : undefined;
397
+ assertImmutablePackageCoverage(manifest, { runMode });
398
+ await validateImmutablePackageEvidence(manifest, {
399
+ runMode,
400
+ read: (path) => readFile(join(run.runRoot, path)),
401
+ });
232
402
  assertValidContract('manifest', manifest, { buildRecord });
233
403
  await writeJsonAtomic(run.runRoot, 'manifest.json', manifest);
234
404
  return run.manifestPath;
235
405
  }
236
406
 
407
+ function assertImmutablePackageCoverage(manifest, options) {
408
+ const recorded = manifest.immutableHashes;
409
+ if (!isObject(recorded)) {
410
+ throw new Error('Manifest does not identify immutable package hashes.');
411
+ }
412
+ const missing = requiredImmutablePackagePaths(manifest, options).filter(
413
+ (path) => typeof recorded[path] !== 'string',
414
+ );
415
+ if (missing.length > 0) {
416
+ throw new Error(
417
+ `Manifest immutable hashes do not cover the canonical package: ${missing.join(', ')}.`,
418
+ );
419
+ }
420
+ }
421
+
422
+ async function verifiedRunRequestMode(run, manifest) {
423
+ const expectedHash = manifest.immutableHashes?.['run-request.json'];
424
+ if (!/^sha256:[a-f0-9]{64}$/.test(expectedHash)) {
425
+ throw new Error(
426
+ 'Manifest must identify a valid immutable run-request.json hash before package coverage is evaluated.',
427
+ );
428
+ }
429
+ const bytes = await readFile(run.requestPath);
430
+ const actualHash = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
431
+ if (actualHash !== expectedHash) {
432
+ throw new Error(
433
+ 'Immutable package hash mismatch for run-request.json before package coverage is evaluated.',
434
+ );
435
+ }
436
+ let request;
437
+ try {
438
+ request = JSON.parse(bytes.toString('utf8'));
439
+ } catch {
440
+ throw new Error(
441
+ 'Hash-verified run-request.json must contain valid JSON before package coverage is evaluated.',
442
+ );
443
+ }
444
+ if (!['interactive', 'unattended'].includes(request?.mode)) {
445
+ throw new Error(
446
+ 'Hash-verified run-request.json must declare interactive or unattended mode.',
447
+ );
448
+ }
449
+ return request.mode;
450
+ }
451
+
452
+ export async function writeSetPlanRecords(run, { request, plan }) {
453
+ assertRun(run);
454
+ if (
455
+ !isObject(request) ||
456
+ request.schemaVersion !== 'explainer-kit.set-plan-request/v1'
457
+ ) {
458
+ throw new TypeError('Set-plan request must use the v1 record contract.');
459
+ }
460
+ assertValidContract('set-plan', plan);
461
+
462
+ const retainedRequest = {
463
+ ...structuredClone(request),
464
+ planHash: canonicalHash(plan),
465
+ };
466
+ const records = [
467
+ ['source/set-plan/request.json', retainedRequest],
468
+ ['source/set-plan/result.json', plan],
469
+ [
470
+ 'source/set-plan/ledger.json',
471
+ {
472
+ schemaVersion: 'explainer-kit.set-plan-ledger/v1',
473
+ planId: plan.planId,
474
+ ...plan.ledger,
475
+ },
476
+ ],
477
+ [
478
+ 'source/set-plan/portfolio.json',
479
+ {
480
+ schemaVersion: 'explainer-kit.set-plan-portfolio/v1',
481
+ planId: plan.planId,
482
+ artifacts: plan.portfolio,
483
+ },
484
+ ],
485
+ [
486
+ 'source/set-plan/drafts.json',
487
+ {
488
+ schemaVersion: 'explainer-kit.set-plan-drafts/v1',
489
+ drafts: plan.portfolio.map(
490
+ ({ artifactId, draft, visualIntent, justification }) => ({
491
+ artifactId,
492
+ draft,
493
+ visualIntent,
494
+ ...(justification && { justification }),
495
+ }),
496
+ ),
497
+ },
498
+ ],
499
+ ];
500
+ for (const [relativePath] of records) {
501
+ try {
502
+ await access(join(run.runRoot, relativePath));
503
+ throw new Error(
504
+ `Immutable set-plan record ${relativePath} already exists.`,
505
+ );
506
+ } catch (error) {
507
+ if (error?.code !== 'ENOENT') throw error;
508
+ }
509
+ }
510
+ for (const [relativePath, value] of records) {
511
+ await writeJsonAtomic(run.runRoot, relativePath, value);
512
+ }
513
+ return records.map(([relativePath]) => relativePath);
514
+ }
515
+
516
+ export async function createSetPlanResumeToken(run) {
517
+ assertRun(run);
518
+ if (typeof run.outputRoot !== 'string' || !isAbsolute(run.outputRoot)) {
519
+ throw new TypeError(
520
+ 'Authenticated resume tokens require a canonical absolute output root.',
521
+ );
522
+ }
523
+ const digest = await hashResumeToken(run, {
524
+ domain: 'explainer-kit.set-plan-resume/v2\0',
525
+ bindCanonicalRoot: true,
526
+ bindRequest: true,
527
+ });
528
+ return `${RESUME_TOKEN_V2_PREFIX}${digest}`;
529
+ }
530
+
531
+ async function hashResumeToken(
532
+ run,
533
+ { domain, bindCanonicalRoot, bindRequest },
534
+ ) {
535
+ const tokenHash = createHash('sha256');
536
+ tokenHash.update(domain);
537
+ tokenHash.update(run.runId);
538
+ tokenHash.update('\0');
539
+ if (bindCanonicalRoot) {
540
+ tokenHash.update(run.outputRoot);
541
+ tokenHash.update('\0');
542
+ }
543
+ try {
544
+ if (bindRequest) {
545
+ const requestBytes = await readFile(
546
+ run.requestPath ?? join(run.runRoot, 'run-request.json'),
547
+ );
548
+ updateResumeTokenFileHash(tokenHash, 'run-request.json', requestBytes);
549
+ }
550
+ for (const relativePath of SET_PLAN_RECORD_PATHS) {
551
+ const bytes = await readFile(join(run.runRoot, relativePath));
552
+ updateResumeTokenFileHash(tokenHash, relativePath, bytes);
553
+ }
554
+ } catch (error) {
555
+ throw resumeRecordError(
556
+ `Unable to hash the complete retained resume identity: ${error.message}`,
557
+ );
558
+ }
559
+ return tokenHash.digest('hex');
560
+ }
561
+
562
+ function updateResumeTokenFileHash(tokenHash, relativePath, bytes) {
563
+ const byteHash = createHash('sha256').update(bytes).digest();
564
+ tokenHash.update(relativePath);
565
+ tokenHash.update('\0');
566
+ tokenHash.update(byteHash);
567
+ }
568
+
569
+ export async function verifySetPlanResumeToken(run, resumeToken) {
570
+ assertRun(run);
571
+ if (
572
+ typeof resumeToken !== 'string' ||
573
+ !RESUME_TOKEN_V2_PATTERN.test(resumeToken)
574
+ ) {
575
+ throw resumeRecordError(
576
+ 'Interactive approval resume requires a valid external resume token.',
577
+ );
578
+ }
579
+ const expectedToken = await createSetPlanResumeToken(run);
580
+ const expected = Buffer.from(expectedToken, 'ascii');
581
+ const supplied = Buffer.from(resumeToken, 'ascii');
582
+ if (
583
+ supplied.byteLength !== expected.byteLength ||
584
+ !timingSafeEqual(supplied, expected)
585
+ ) {
586
+ throw resumeRecordError(
587
+ 'Interactive approval resume token does not match the retained set plan.',
588
+ );
589
+ }
590
+ }
591
+
592
+ export async function readSetPlanRecords(run, { factBase, recipe }) {
593
+ assertRun(run);
594
+ if (
595
+ !isObject(factBase) ||
596
+ !Array.isArray(factBase.sources) ||
597
+ !isObject(recipe) ||
598
+ typeof recipe.id !== 'string' ||
599
+ typeof recipe.version !== 'string'
600
+ ) {
601
+ throw resumeRecordError(
602
+ 'Retained set-plan validation requires the fact base and active recipe.',
603
+ );
604
+ }
605
+
606
+ let values;
607
+ try {
608
+ values = await Promise.all(
609
+ SET_PLAN_RECORD_PATHS.map((relativePath) =>
610
+ readJson(join(run.runRoot, relativePath)),
611
+ ),
612
+ );
613
+ } catch (error) {
614
+ throw resumeRecordError(
615
+ `Unable to read the complete retained set plan: ${error.message}`,
616
+ );
617
+ }
618
+ const [request, plan, ledger, portfolio, drafts] = values;
619
+ const requestKeys = [
620
+ 'discovery',
621
+ 'factBaseHash',
622
+ 'planHash',
623
+ 'recipe',
624
+ 'schemaVersion',
625
+ 'sourceIds',
626
+ ];
627
+ if (
628
+ !isObject(request) ||
629
+ canonicalHash(Object.keys(request).sort()) !== canonicalHash(requestKeys) ||
630
+ request.schemaVersion !== 'explainer-kit.set-plan-request/v1' ||
631
+ canonicalHash(request.recipe) !==
632
+ canonicalHash({ id: recipe.id, version: recipe.version }) ||
633
+ request.factBaseHash !== canonicalHash(factBase) ||
634
+ request.planHash !== canonicalHash(plan) ||
635
+ !isObject(request.discovery) ||
636
+ !Number.isInteger(request.discovery.rounds) ||
637
+ request.discovery.rounds < 0 ||
638
+ !Array.isArray(request.discovery.findings) ||
639
+ !['not-requested', 'two-empty-rounds', 'hard-maximum'].includes(
640
+ request.discovery.reason,
641
+ )
642
+ ) {
643
+ throw resumeRecordError(
644
+ 'Retained set-plan request does not match the fact base and active recipe.',
645
+ );
646
+ }
647
+
648
+ const planValidation = validateContract('set-plan', plan);
649
+ if (!planValidation.valid) {
650
+ throw resumeRecordError('Retained set-plan result is invalid.');
651
+ }
652
+ const expectedSourceIds = factBase.sources
653
+ .map(({ id }) => id)
654
+ .filter((id) => !id.startsWith('critic:'));
655
+ if (
656
+ canonicalHash(plan.recipe) !==
657
+ canonicalHash({ id: recipe.id, version: recipe.version }) ||
658
+ canonicalHash(request.sourceIds) !== canonicalHash(expectedSourceIds) ||
659
+ canonicalHash(plan.sourceIds) !== canonicalHash(request.sourceIds)
660
+ ) {
661
+ throw resumeRecordError(
662
+ 'Retained set-plan request and result identities have drifted.',
663
+ );
664
+ }
665
+
666
+ const expectedLedger = {
667
+ schemaVersion: 'explainer-kit.set-plan-ledger/v1',
668
+ planId: plan.planId,
669
+ ...plan.ledger,
670
+ };
671
+ const expectedPortfolio = {
672
+ schemaVersion: 'explainer-kit.set-plan-portfolio/v1',
673
+ planId: plan.planId,
674
+ artifacts: plan.portfolio,
675
+ };
676
+ const expectedDrafts = {
677
+ schemaVersion: 'explainer-kit.set-plan-drafts/v1',
678
+ drafts: plan.portfolio.map(
679
+ ({ artifactId, draft, visualIntent, justification }) => ({
680
+ artifactId,
681
+ draft,
682
+ visualIntent,
683
+ ...(justification && { justification }),
684
+ }),
685
+ ),
686
+ };
687
+ for (const [label, actual, expected] of [
688
+ ['ledger', ledger, expectedLedger],
689
+ ['portfolio', portfolio, expectedPortfolio],
690
+ ['drafts', drafts, expectedDrafts],
691
+ ]) {
692
+ if (canonicalHash(actual) !== canonicalHash(expected)) {
693
+ throw resumeRecordError(
694
+ `Retained set-plan ${label} projection does not match the canonical result.`,
695
+ );
696
+ }
697
+ }
698
+
699
+ return {
700
+ request,
701
+ plan,
702
+ paths: [...SET_PLAN_RECORD_PATHS],
703
+ };
704
+ }
705
+
237
706
  function normalizeRequestSlug(slug) {
238
707
  if (
239
708
  typeof slug !== 'string' ||
@@ -259,6 +728,23 @@ function normalizeRequestSlug(slug) {
259
728
  return normalized;
260
729
  }
261
730
 
731
+ function normalizeRunRequest(request, { outputRoot } = {}) {
732
+ if (!isObject(request)) {
733
+ throw new TypeError('Run request must be an object.');
734
+ }
735
+ const normalized = structuredClone(request);
736
+ normalized.slug = normalizeRequestSlug(request.slug);
737
+ normalized.theme = {
738
+ ...(isObject(normalized.theme) ? normalized.theme : {}),
739
+ renderStrategy: normalized.theme?.renderStrategy ?? 'default-only',
740
+ };
741
+ if (outputRoot !== undefined) {
742
+ normalized.outputRoot = outputRoot;
743
+ }
744
+ assertValidContract('run-request', normalized);
745
+ return normalized;
746
+ }
747
+
262
748
  function privacySafeRequest(request) {
263
749
  const persisted = structuredClone(request);
264
750
  const retainRaw = persisted.privacy?.retainRawArtDirection === true;
@@ -288,6 +774,12 @@ function assertRun(run) {
288
774
  }
289
775
  }
290
776
 
777
+ function resumeRecordError(message) {
778
+ const error = new Error(message);
779
+ error.code = 'E_APPROVAL_RESUME';
780
+ return error;
781
+ }
782
+
291
783
  async function clearRunRoot(run) {
292
784
  const entries = await readdir(run.runRoot, { withFileTypes: true });
293
785
  if (entries.length === 0) return;