pi-background-tasks 2.5.0 → 2.6.0

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 (203) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/README.md +70 -25
  3. package/TESTING.md +42 -2
  4. package/TEST_PLAN.md +7 -4
  5. package/dist/extensions/anthropic-attribution-child.js +4 -0
  6. package/dist/extensions/anthropic-attribution-child.js.map +1 -0
  7. package/dist/extensions/anthropic-attribution.js +107 -0
  8. package/dist/extensions/anthropic-attribution.js.map +1 -0
  9. package/dist/extensions/background-tasks.js +2 -0
  10. package/dist/extensions/background-tasks.js.map +1 -0
  11. package/dist/extensions/delegate-child.js +2 -0
  12. package/dist/extensions/delegate-child.js.map +1 -0
  13. package/dist/extensions/fusion-child.js +2 -0
  14. package/dist/extensions/fusion-child.js.map +1 -0
  15. package/dist/package.json +5 -0
  16. package/dist/src/core/anthropic-attribution-path.js +24 -0
  17. package/dist/src/core/anthropic-attribution-path.js.map +1 -0
  18. package/dist/src/core/anthropic-attribution.js +2247 -0
  19. package/dist/src/core/anthropic-attribution.js.map +1 -0
  20. package/dist/src/core/attested-pi-contract.js +5 -0
  21. package/dist/src/core/attested-pi-contract.js.map +1 -0
  22. package/dist/src/core/attested-pi-run.js +749 -0
  23. package/dist/src/core/attested-pi-run.js.map +1 -0
  24. package/dist/src/core/canonical-json.js +19 -0
  25. package/dist/src/core/canonical-json.js.map +1 -0
  26. package/dist/src/core/common.js +787 -0
  27. package/dist/src/core/common.js.map +1 -0
  28. package/dist/src/core/config.js +78 -0
  29. package/dist/src/core/config.js.map +1 -0
  30. package/dist/src/core/context/parent-snapshot.js +75 -0
  31. package/dist/src/core/context/parent-snapshot.js.map +1 -0
  32. package/dist/src/core/context/token-budget.js +631 -0
  33. package/dist/src/core/context/token-budget.js.map +1 -0
  34. package/dist/src/core/context/visible-conversation-v2.js +390 -0
  35. package/dist/src/core/context/visible-conversation-v2.js.map +1 -0
  36. package/dist/src/core/delegate/artifacts.js +357 -0
  37. package/dist/src/core/delegate/artifacts.js.map +1 -0
  38. package/dist/src/core/delegate/budget.js +266 -0
  39. package/dist/src/core/delegate/budget.js.map +1 -0
  40. package/dist/src/core/delegate/facade-contract.js +7 -0
  41. package/dist/src/core/delegate/facade-contract.js.map +1 -0
  42. package/dist/src/core/delegate/hook-contract-evidence.json +18 -0
  43. package/dist/src/core/delegate/hook-contract.js +124 -0
  44. package/dist/src/core/delegate/hook-contract.js.map +1 -0
  45. package/dist/src/core/delegate/launch.js +366 -0
  46. package/dist/src/core/delegate/launch.js.map +1 -0
  47. package/dist/src/core/delegate/result-package.js +293 -0
  48. package/dist/src/core/delegate/result-package.js.map +1 -0
  49. package/dist/src/core/delegate/runner.js +398 -0
  50. package/dist/src/core/delegate/runner.js.map +1 -0
  51. package/dist/src/core/delegate/seed.js +334 -0
  52. package/dist/src/core/delegate/seed.js.map +1 -0
  53. package/dist/src/core/delegate/types.js +103 -0
  54. package/dist/src/core/delegate/types.js.map +1 -0
  55. package/dist/src/core/durable-fs.js +372 -0
  56. package/dist/src/core/durable-fs.js.map +1 -0
  57. package/dist/src/core/extension-api.js +382 -0
  58. package/dist/src/core/extension-api.js.map +1 -0
  59. package/dist/src/core/fusion/artifacts.js +654 -0
  60. package/dist/src/core/fusion/artifacts.js.map +1 -0
  61. package/dist/src/core/fusion/budget.js +949 -0
  62. package/dist/src/core/fusion/budget.js.map +1 -0
  63. package/dist/src/core/fusion/child-protocol.js +176 -0
  64. package/dist/src/core/fusion/child-protocol.js.map +1 -0
  65. package/dist/src/core/fusion/claude-cache.js +156 -0
  66. package/dist/src/core/fusion/claude-cache.js.map +1 -0
  67. package/dist/src/core/fusion/clean-context.js +64 -0
  68. package/dist/src/core/fusion/clean-context.js.map +1 -0
  69. package/dist/src/core/fusion/config.js +349 -0
  70. package/dist/src/core/fusion/config.js.map +1 -0
  71. package/dist/src/core/fusion/context.js +184 -0
  72. package/dist/src/core/fusion/context.js.map +1 -0
  73. package/dist/src/core/fusion/evaluation.js +696 -0
  74. package/dist/src/core/fusion/evaluation.js.map +1 -0
  75. package/dist/src/core/fusion/facade-contract.js +3 -0
  76. package/dist/src/core/fusion/facade-contract.js.map +1 -0
  77. package/dist/src/core/fusion/orchestrator.js +867 -0
  78. package/dist/src/core/fusion/orchestrator.js.map +1 -0
  79. package/dist/src/core/fusion/output-contract.js +27 -0
  80. package/dist/src/core/fusion/output-contract.js.map +1 -0
  81. package/dist/src/core/fusion/pi-child.js +1741 -0
  82. package/dist/src/core/fusion/pi-child.js.map +1 -0
  83. package/dist/src/core/fusion/prompts.js +260 -0
  84. package/dist/src/core/fusion/prompts.js.map +1 -0
  85. package/dist/src/core/fusion/result-package.js +857 -0
  86. package/dist/src/core/fusion/result-package.js.map +1 -0
  87. package/dist/src/core/fusion/source-policy.js +259 -0
  88. package/dist/src/core/fusion/source-policy.js.map +1 -0
  89. package/dist/src/core/fusion/types.js +207 -0
  90. package/dist/src/core/fusion/types.js.map +1 -0
  91. package/dist/src/core/fusion/web-fetch.js +777 -0
  92. package/dist/src/core/fusion/web-fetch.js.map +1 -0
  93. package/dist/src/core/fusion/workflows.js +126 -0
  94. package/dist/src/core/fusion/workflows.js.map +1 -0
  95. package/dist/src/core/lazy-module.js +181 -0
  96. package/dist/src/core/lazy-module.js.map +1 -0
  97. package/dist/src/core/pi-launch.js +431 -0
  98. package/dist/src/core/pi-launch.js.map +1 -0
  99. package/dist/src/core/registry.js +3331 -0
  100. package/dist/src/core/registry.js.map +1 -0
  101. package/dist/src/core/reload-shell-owner.js +1396 -0
  102. package/dist/src/core/reload-shell-owner.js.map +1 -0
  103. package/dist/src/core/shell-policy.js +80 -0
  104. package/dist/src/core/shell-policy.js.map +1 -0
  105. package/dist/src/core/task-durable.js +51 -0
  106. package/dist/src/core/task-durable.js.map +1 -0
  107. package/dist/src/core/update-check.js +92 -0
  108. package/dist/src/core/update-check.js.map +1 -0
  109. package/dist/src/core/windows-taskkill.js +185 -0
  110. package/dist/src/core/windows-taskkill.js.map +1 -0
  111. package/dist/src/delegate-child-extension.js +776 -0
  112. package/dist/src/delegate-child-extension.js.map +1 -0
  113. package/dist/src/delegate-extension.js +746 -0
  114. package/dist/src/delegate-extension.js.map +1 -0
  115. package/dist/src/extension.js +953 -0
  116. package/dist/src/extension.js.map +1 -0
  117. package/dist/src/fusion-child-extension.js +760 -0
  118. package/dist/src/fusion-child-extension.js.map +1 -0
  119. package/dist/src/fusion-extension.js +1030 -0
  120. package/dist/src/fusion-extension.js.map +1 -0
  121. package/dist/src/ui/background-tasks-manager.js +689 -0
  122. package/dist/src/ui/background-tasks-manager.js.map +1 -0
  123. package/dist/src/ui/fusion-model-selector.js +277 -0
  124. package/dist/src/ui/fusion-model-selector.js.map +1 -0
  125. package/docs/INDEX.md +35 -33
  126. package/docs/api/eventbus-v1.md +21 -5
  127. package/docs/choose-a-workflow.md +2 -0
  128. package/docs/commands/bg-clear.md +3 -3
  129. package/docs/commands/bg-update.md +3 -3
  130. package/docs/commands/bg.md +18 -6
  131. package/docs/commands/claude-cache.md +4 -4
  132. package/docs/commands/fusion-models.md +49 -4
  133. package/docs/commands/fusion.md +3 -3
  134. package/docs/commands/jobs.md +3 -3
  135. package/docs/commands/kill.md +3 -3
  136. package/docs/commands/logs.md +3 -3
  137. package/docs/commands/task-manager.md +5 -5
  138. package/docs/concepts/completion-delivery.md +6 -2
  139. package/docs/getting-started.md +7 -1
  140. package/docs/manifest.json +228 -42
  141. package/docs/operations/configuration.md +58 -5
  142. package/docs/operations/releasing.md +4 -2
  143. package/docs/operations/testing.md +22 -0
  144. package/docs/operations/troubleshooting.md +6 -2
  145. package/docs/read-before-edit.md +44 -31
  146. package/docs/reference/runtime-contracts.md +87 -51
  147. package/docs/reference/shortcuts-and-dock.md +28 -14
  148. package/docs/subsystems/anthropic-attribution.md +23 -11
  149. package/docs/subsystems/attested-pi-runs.md +12 -7
  150. package/docs/subsystems/background-task-runtime.md +53 -11
  151. package/docs/subsystems/child-launch-durability-and-safety.md +28 -9
  152. package/docs/subsystems/delegation.md +16 -3
  153. package/docs/subsystems/docs-freshness-gate.md +14 -7
  154. package/docs/subsystems/fusion.md +36 -11
  155. package/docs/subsystems/host-ui-and-telemetry.md +22 -9
  156. package/docs/tools/bg_delegate.md +3 -1
  157. package/docs/tools/bg_kill.md +3 -1
  158. package/docs/tools/bg_logs.md +3 -1
  159. package/docs/tools/bg_result.md +5 -3
  160. package/docs/tools/bg_run.md +31 -5
  161. package/docs/tools/bg_run_pi_attested.md +3 -1
  162. package/docs/tools/bg_status.md +3 -1
  163. package/docs/tools/fusion_investigate.md +3 -1
  164. package/docs/tools/fusion_reason.md +3 -1
  165. package/docs/tools/fusion_research.md +3 -1
  166. package/docs/tools/fusion_validate.md +3 -1
  167. package/extensions/anthropic-attribution-child.ts +3 -0
  168. package/extensions/anthropic-attribution.ts +166 -1
  169. package/package.json +12 -10
  170. package/src/core/anthropic-attribution-path.ts +5 -4
  171. package/src/core/anthropic-attribution.ts +293 -113
  172. package/src/core/attested-pi-contract.ts +4 -0
  173. package/src/core/attested-pi-run.ts +468 -81
  174. package/src/core/canonical-json.ts +21 -0
  175. package/src/core/common.ts +450 -40
  176. package/src/core/config.ts +121 -0
  177. package/src/core/context/visible-conversation-v2.ts +3 -6
  178. package/src/core/delegate/artifacts.ts +13 -9
  179. package/src/core/delegate/budget.ts +13 -6
  180. package/src/core/delegate/facade-contract.ts +6 -0
  181. package/src/core/delegate/result-package.ts +32 -14
  182. package/src/core/delegate/runner.ts +129 -58
  183. package/src/core/delegate/seed.ts +5 -7
  184. package/src/core/durable-fs.ts +178 -30
  185. package/src/core/extension-api.ts +36 -8
  186. package/src/core/fusion/artifacts.ts +17 -16
  187. package/src/core/fusion/clean-context.ts +8 -5
  188. package/src/core/fusion/config.ts +2 -1
  189. package/src/core/fusion/context.ts +2 -6
  190. package/src/core/fusion/facade-contract.ts +2 -0
  191. package/src/core/fusion/orchestrator.ts +3 -3
  192. package/src/core/fusion/prompts.ts +1 -1
  193. package/src/core/fusion/result-package.ts +259 -72
  194. package/src/core/fusion/source-policy.ts +43 -17
  195. package/src/core/lazy-module.ts +215 -0
  196. package/src/core/pi-launch.ts +390 -65
  197. package/src/core/registry.ts +2010 -381
  198. package/src/core/reload-shell-owner.ts +1662 -0
  199. package/src/core/shell-policy.ts +134 -0
  200. package/src/core/task-durable.ts +67 -0
  201. package/src/delegate-extension.ts +404 -144
  202. package/src/extension.ts +387 -114
  203. package/src/fusion-extension.ts +202 -96
@@ -1,6 +1,6 @@
1
1
  import { lstat, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { canonicalJson, sha256Buffer } from '../attested-pi-run.js';
3
+ import { canonicalJson, sha256Buffer } from '../canonical-json.js';
4
4
  import { parseJsonText, type JsonObject } from '../common.js';
5
5
  import {
6
6
  FUSION_FAILURE_SUMMARY_ATTEMPT_CAP,
@@ -461,21 +461,41 @@ export async function readFusionCommittedResult(
461
461
  // Keep the verified details below 8 KiB even after that model-visible envelope.
462
462
  const FAILURE_VIEW_MAX_BYTES = 6 * 1024;
463
463
  const FAILURE_CODES = new Set([
464
- 'config_invalid', 'config_conflict', 'model_unavailable', 'context_capture_failed',
465
- 'context_policy_unsupported_block', 'prompt_budget_exceeded_forecast',
466
- 'prompt_budget_exceeded_measured', 'model_capacity_unknown', 'child_spawn_failed',
467
- 'child_stdin_failed', 'child_event_invalid', 'child_exit_failed',
468
- 'child_runtime_limit_exceeded', 'child_runtime_payload_invalid',
469
- 'child_cache_policy_invalid', 'child_timeout', 'child_output_cap', 'child_cancelled',
470
- 'evaluation_invalid', 'artifact_error', 'state_transition_invalid', 'orchestration_failed',
464
+ 'config_invalid',
465
+ 'config_conflict',
466
+ 'model_unavailable',
467
+ 'context_capture_failed',
468
+ 'context_policy_unsupported_block',
469
+ 'prompt_budget_exceeded_forecast',
470
+ 'prompt_budget_exceeded_measured',
471
+ 'model_capacity_unknown',
472
+ 'child_spawn_failed',
473
+ 'child_stdin_failed',
474
+ 'child_event_invalid',
475
+ 'child_exit_failed',
476
+ 'child_runtime_limit_exceeded',
477
+ 'child_runtime_payload_invalid',
478
+ 'child_cache_policy_invalid',
479
+ 'child_timeout',
480
+ 'child_output_cap',
481
+ 'child_cancelled',
482
+ 'evaluation_invalid',
483
+ 'artifact_error',
484
+ 'state_transition_invalid',
485
+ 'orchestration_failed',
471
486
  ]);
472
487
  const FAILURE_REMEDIATION_IDS = new Set([
473
- 'inspect_manifest_bound_evidence', 'inspect_terminal_error', 'split_or_reduce_work',
488
+ 'inspect_manifest_bound_evidence',
489
+ 'inspect_terminal_error',
490
+ 'split_or_reduce_work',
474
491
  'retry_same_route_after_operator_review',
475
492
  ]);
476
493
  const FAILURE_CLASSIFICATIONS = new Set([
477
- 'complete_stage_output', 'partial_stage_output', 'oversized_original',
478
- 'empty_rejected_output', 'evidence_only',
494
+ 'complete_stage_output',
495
+ 'partial_stage_output',
496
+ 'oversized_original',
497
+ 'empty_rejected_output',
498
+ 'evidence_only',
479
499
  ]);
480
500
 
481
501
  interface TrustedFailureManifest {
@@ -498,7 +518,8 @@ function failureUnavailable(
498
518
  summary_status: status,
499
519
  terminal_state: state,
500
520
  answer: { present: false, reason: 'run_did_not_commit' },
501
- summary_unavailable_reason: status === 'unavailable' ? 'manifest_untrusted' : 'summary_integrity_failed',
521
+ summary_unavailable_reason:
522
+ status === 'unavailable' ? 'manifest_untrusted' : 'summary_integrity_failed',
502
523
  };
503
524
  }
504
525
 
@@ -531,7 +552,11 @@ function failureRef(value: unknown, label: string, artifactDir: string): FusionA
531
552
  }
532
553
 
533
554
  function sameFailureRef(left: FusionArtifactRef, right: FusionArtifactRef): boolean {
534
- return left.path === right.path && left.byte_length === right.byte_length && left.sha256 === right.sha256;
555
+ return (
556
+ left.path === right.path &&
557
+ left.byte_length === right.byte_length &&
558
+ left.sha256 === right.sha256
559
+ );
535
560
  }
536
561
 
537
562
  function trustedFailureManifest(
@@ -540,12 +565,16 @@ function trustedFailureManifest(
540
565
  ): TrustedFailureManifest {
541
566
  if (!isRecord(value)) throw new Error('manifest must be an object');
542
567
  const schemaVersion = value['schema_version'];
543
- if (schemaVersion !== FUSION_MANIFEST_SCHEMA_VERSION && schemaVersion !== FUSION_LEGACY_MANIFEST_SCHEMA_VERSION)
568
+ if (
569
+ schemaVersion !== FUSION_MANIFEST_SCHEMA_VERSION &&
570
+ schemaVersion !== FUSION_LEGACY_MANIFEST_SCHEMA_VERSION
571
+ )
544
572
  throw new Error('manifest schema version mismatch');
545
573
  if (value['run_id'] !== options.runId || value['workflow'] !== options.workflow)
546
574
  throw new Error('manifest identity mismatch');
547
575
  const state = value['state'];
548
- if (state !== 'failed' && state !== 'cancelled') throw new Error('manifest is not failed or cancelled');
576
+ if (state !== 'failed' && state !== 'cancelled')
577
+ throw new Error('manifest is not failed or cancelled');
549
578
  const source = value['source'];
550
579
  if (source !== 'command' && source !== 'tool') throw new Error('manifest source is invalid');
551
580
  const artifactsValue = value['artifacts'];
@@ -571,14 +600,18 @@ function trustedFailureManifest(
571
600
  for (const attemptValue of attemptsValue) {
572
601
  if (!isRecord(attemptValue)) throw new Error('manifest attempt is invalid');
573
602
  const metadata = failureAttempt({
574
- stage: attemptValue['stage'], slot: attemptValue['slot'], attempt: attemptValue['attempt'],
575
- status: attemptValue['status'], child_created: attemptValue['child_created'],
603
+ stage: attemptValue['stage'],
604
+ slot: attemptValue['slot'],
605
+ attempt: attemptValue['attempt'],
606
+ status: attemptValue['status'],
607
+ child_created: attemptValue['child_created'],
576
608
  });
577
609
  attempts.push(metadata);
578
610
  const response = attemptArtifact(attemptValue['response_path'], 'manifest response_path');
579
611
  if (response !== undefined) {
580
612
  const responseRef = artifacts[response];
581
- if (responseRef === undefined) throw new Error('manifest response_path is not manifest-bound');
613
+ if (responseRef === undefined)
614
+ throw new Error('manifest response_path is not manifest-bound');
582
615
  classifications[response] =
583
616
  responseRef.byte_length === 0 && metadata.status !== 'completed'
584
617
  ? 'empty_rejected_output'
@@ -596,30 +629,48 @@ function trustedFailureManifest(
596
629
  recovery['original_response_path'],
597
630
  'manifest output_recovery.original_response_path',
598
631
  );
599
- if (original === undefined) throw new Error('manifest output recovery has no original response');
632
+ if (original === undefined)
633
+ throw new Error('manifest output recovery has no original response');
600
634
  classifications[original] = 'oversized_original';
601
635
  }
602
636
  }
603
- attempts.sort((left, right) =>
604
- compareFailureText(left.stage, right.stage) ||
605
- (left.slot ?? 0) - (right.slot ?? 0) ||
606
- left.attempt - right.attempt,
637
+ attempts.sort(
638
+ (left, right) =>
639
+ compareFailureText(left.stage, right.stage) ||
640
+ (left.slot ?? 0) - (right.slot ?? 0) ||
641
+ left.attempt - right.attempt,
607
642
  );
608
643
  const manifestUsage = failureUsage(value['usage'], options.artifactDir);
609
644
  const error = value['error'];
610
- if (error !== undefined && typeof error !== 'string') throw new Error('manifest error is invalid');
611
- return { schemaVersion, source, state, usage: manifestUsage, artifacts, attempts, classifications, ...(error === undefined ? {} : { error }) };
645
+ if (error !== undefined && typeof error !== 'string')
646
+ throw new Error('manifest error is invalid');
647
+ return {
648
+ schemaVersion,
649
+ source,
650
+ state,
651
+ usage: manifestUsage,
652
+ artifacts,
653
+ attempts,
654
+ classifications,
655
+ ...(error === undefined ? {} : { error }),
656
+ };
612
657
  }
613
658
 
614
659
  function failureMessage(value: unknown): FusionFailureSummaryV1['failure']['message'] {
615
660
  if (!isRecord(value)) throw new Error('failure message is invalid');
616
- assertOnlyKeys(value, ['byte_length', 'sha256', 'inline_message', 'omission_reason'], 'failure message', 'failure-summary.json');
661
+ assertOnlyKeys(
662
+ value,
663
+ ['byte_length', 'sha256', 'inline_message', 'omission_reason'],
664
+ 'failure message',
665
+ 'failure-summary.json',
666
+ );
617
667
  const byteLength = failureInteger(value['byte_length'], 'failure message byte_length');
618
668
  const sha256 = failureString(value['sha256'], 'failure message sha256');
619
669
  if (!SHA256_PATTERN.test(sha256)) throw new Error('failure message sha256 is invalid');
620
670
  const inline = value['inline_message'];
621
671
  const omission = value['omission_reason'];
622
- if ((inline === undefined) === (omission === undefined)) throw new Error('failure message must have exactly one representation');
672
+ if ((inline === undefined) === (omission === undefined))
673
+ throw new Error('failure message must have exactly one representation');
623
674
  if (inline !== undefined) {
624
675
  if (
625
676
  typeof inline !== 'string' ||
@@ -654,7 +705,12 @@ function failureList<T>(
654
705
 
655
706
  function failureAttempt(value: unknown): FusionFailureAttemptMetadata {
656
707
  if (!isRecord(value)) throw new Error('failure attempt is invalid');
657
- assertOnlyKeys(value, ['stage', 'slot', 'attempt', 'status', 'child_created'], 'failure attempt', 'failure-summary.json');
708
+ assertOnlyKeys(
709
+ value,
710
+ ['stage', 'slot', 'attempt', 'status', 'child_created'],
711
+ 'failure attempt',
712
+ 'failure-summary.json',
713
+ );
658
714
  const stage = failureStage(value['stage'], 'failure attempt stage');
659
715
  const slot = value['slot'];
660
716
  if (slot !== undefined && slot !== 1 && slot !== 2 && slot !== 3)
@@ -662,11 +718,19 @@ function failureAttempt(value: unknown): FusionFailureAttemptMetadata {
662
718
  if ((stage === 'candidate') !== (slot !== undefined))
663
719
  throw new Error('failure attempt stage/slot is inconsistent');
664
720
  const status = value['status'];
665
- if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') throw new Error('failure attempt status is invalid');
666
- if (typeof value['child_created'] !== 'boolean') throw new Error('failure attempt child_created is invalid');
721
+ if (status !== 'completed' && status !== 'failed' && status !== 'cancelled')
722
+ throw new Error('failure attempt status is invalid');
723
+ if (typeof value['child_created'] !== 'boolean')
724
+ throw new Error('failure attempt child_created is invalid');
667
725
  const attempt = failureInteger(value['attempt'], 'failure attempt number');
668
726
  if (attempt === 0) throw new Error('failure attempt number must be positive');
669
- return { stage, ...(slot === undefined ? {} : { slot }), attempt, status, child_created: value['child_created'] };
727
+ return {
728
+ stage,
729
+ ...(slot === undefined ? {} : { slot }),
730
+ attempt,
731
+ status,
732
+ child_created: value['child_created'],
733
+ };
670
734
  }
671
735
 
672
736
  function failureEvidence(
@@ -686,35 +750,81 @@ function failureEvidence(
686
750
  if (manifestRef === undefined || !sameFailureRef(ref, manifestRef))
687
751
  throw new Error('failure evidence ref diverges from manifest');
688
752
  const expectedClassification = manifest.classifications[name] ?? 'evidence_only';
689
- if (classification !== expectedClassification) throw new Error('failure evidence classification diverges from manifest');
753
+ if (classification !== expectedClassification)
754
+ throw new Error('failure evidence classification diverges from manifest');
690
755
  return { name, classification: expectedClassification, ref };
691
756
  }
692
757
 
693
- function failureProgress(value: unknown, manifest: TrustedFailureManifest, artifactDir: string): FusionRunProgress {
758
+ function failureProgress(
759
+ value: unknown,
760
+ manifest: TrustedFailureManifest,
761
+ artifactDir: string,
762
+ ): FusionRunProgress {
694
763
  if (!isRecord(value)) throw new Error('failure progress is invalid');
695
- assertOnlyKeys(value, ['manifest_state', 'candidates', 'evaluation', 'merge', 'usage_so_far'], 'failure progress', artifactDir);
696
- if (value['manifest_state'] !== manifest.state) throw new Error('failure progress state diverges from manifest');
697
- const stage = (entry: unknown, label: string, candidates: boolean): FusionRunProgress['candidates'] => {
764
+ assertOnlyKeys(
765
+ value,
766
+ ['manifest_state', 'candidates', 'evaluation', 'merge', 'usage_so_far'],
767
+ 'failure progress',
768
+ artifactDir,
769
+ );
770
+ if (value['manifest_state'] !== manifest.state)
771
+ throw new Error('failure progress state diverges from manifest');
772
+ const stage = (
773
+ entry: unknown,
774
+ label: string,
775
+ candidates: boolean,
776
+ ): FusionRunProgress['candidates'] => {
698
777
  if (!isRecord(entry)) throw new Error(`${label} is invalid`);
699
- assertOnlyKeys(entry, ['status', 'attempts_recorded', 'children_created', 'children_completed', 'children_failed', 'children_cancelled', 'not_started_slots'], label, artifactDir);
778
+ assertOnlyKeys(
779
+ entry,
780
+ [
781
+ 'status',
782
+ 'attempts_recorded',
783
+ 'children_created',
784
+ 'children_completed',
785
+ 'children_failed',
786
+ 'children_cancelled',
787
+ 'not_started_slots',
788
+ ],
789
+ label,
790
+ artifactDir,
791
+ );
700
792
  const status = entry['status'];
701
- if (status !== 'not_started' && status !== 'incomplete' && status !== 'completed') throw new Error(`${label}.status is invalid`);
793
+ if (status !== 'not_started' && status !== 'incomplete' && status !== 'completed')
794
+ throw new Error(`${label}.status is invalid`);
702
795
  const notStarted = entry['not_started_slots'];
703
- if (candidates ? !Number.isSafeInteger(notStarted) || Number(notStarted) < 0 || Number(notStarted) > 3 : notStarted !== undefined)
796
+ if (
797
+ candidates
798
+ ? !Number.isSafeInteger(notStarted) || Number(notStarted) < 0 || Number(notStarted) > 3
799
+ : notStarted !== undefined
800
+ )
704
801
  throw new Error(`${label}.not_started_slots is invalid`);
705
802
  return {
706
803
  status,
707
804
  attempts_recorded: failureInteger(entry['attempts_recorded'], `${label}.attempts_recorded`),
708
805
  children_created: failureInteger(entry['children_created'], `${label}.children_created`),
709
- children_completed: failureInteger(entry['children_completed'], `${label}.children_completed`),
806
+ children_completed: failureInteger(
807
+ entry['children_completed'],
808
+ `${label}.children_completed`,
809
+ ),
710
810
  children_failed: failureInteger(entry['children_failed'], `${label}.children_failed`),
711
- children_cancelled: failureInteger(entry['children_cancelled'], `${label}.children_cancelled`),
811
+ children_cancelled: failureInteger(
812
+ entry['children_cancelled'],
813
+ `${label}.children_cancelled`,
814
+ ),
712
815
  ...(candidates ? { not_started_slots: Number(notStarted) } : {}),
713
816
  };
714
817
  };
715
818
  const usageSoFar = failureUsage(value['usage_so_far'], artifactDir);
716
- if (canonicalJson(usageSoFar) !== canonicalJson(manifest.usage)) throw new Error('failure progress usage diverges from manifest');
717
- return { manifest_state: manifest.state, candidates: stage(value['candidates'], 'failure candidates', true), evaluation: stage(value['evaluation'], 'failure evaluation', false), merge: stage(value['merge'], 'failure merge', false), usage_so_far: usageSoFar };
819
+ if (canonicalJson(usageSoFar) !== canonicalJson(manifest.usage))
820
+ throw new Error('failure progress usage diverges from manifest');
821
+ return {
822
+ manifest_state: manifest.state,
823
+ candidates: stage(value['candidates'], 'failure candidates', true),
824
+ evaluation: stage(value['evaluation'], 'failure evaluation', false),
825
+ merge: stage(value['merge'], 'failure merge', false),
826
+ usage_so_far: usageSoFar,
827
+ };
718
828
  }
719
829
 
720
830
  function parseFailureSummary(
@@ -723,22 +833,57 @@ function parseFailureSummary(
723
833
  options: ReadFusionCommittedResultOptions,
724
834
  ): FusionFailureSummaryV1 {
725
835
  if (!isRecord(value)) throw new Error('failure summary must be an object');
726
- assertOnlyKeys(value, ['schema_version', 'run_id', 'workflow', 'source', 'terminal_state', 'created_at', 'answer', 'failure', 'progress', 'usage_so_far', 'attempts', 'evidence_artifacts', 'remediation_ids'], 'failure summary', options.artifactDir);
727
- if (value['schema_version'] !== FUSION_FAILURE_SUMMARY_SCHEMA_VERSION || value['run_id'] !== options.runId || value['workflow'] !== options.workflow || value['source'] !== manifest.source || value['terminal_state'] !== manifest.state || typeof value['created_at'] !== 'string')
836
+ assertOnlyKeys(
837
+ value,
838
+ [
839
+ 'schema_version',
840
+ 'run_id',
841
+ 'workflow',
842
+ 'source',
843
+ 'terminal_state',
844
+ 'created_at',
845
+ 'answer',
846
+ 'failure',
847
+ 'progress',
848
+ 'usage_so_far',
849
+ 'attempts',
850
+ 'evidence_artifacts',
851
+ 'remediation_ids',
852
+ ],
853
+ 'failure summary',
854
+ options.artifactDir,
855
+ );
856
+ if (
857
+ value['schema_version'] !== FUSION_FAILURE_SUMMARY_SCHEMA_VERSION ||
858
+ value['run_id'] !== options.runId ||
859
+ value['workflow'] !== options.workflow ||
860
+ value['source'] !== manifest.source ||
861
+ value['terminal_state'] !== manifest.state ||
862
+ typeof value['created_at'] !== 'string'
863
+ )
728
864
  throw new Error('failure summary identity is invalid');
729
865
  const answer = value['answer'];
730
- if (!isRecord(answer) || answer['present'] !== false || answer['reason'] !== 'run_did_not_commit') throw new Error('failure summary answer assertion is invalid');
866
+ if (!isRecord(answer) || answer['present'] !== false || answer['reason'] !== 'run_did_not_commit')
867
+ throw new Error('failure summary answer assertion is invalid');
731
868
  const failure = value['failure'];
732
869
  if (!isRecord(failure)) throw new Error('failure summary failure metadata is invalid');
733
- assertOnlyKeys(failure, ['code', 'stage', 'slot', 'attempt', 'child_created', 'message'], 'failure metadata', options.artifactDir);
870
+ assertOnlyKeys(
871
+ failure,
872
+ ['code', 'stage', 'slot', 'attempt', 'child_created', 'message'],
873
+ 'failure metadata',
874
+ options.artifactDir,
875
+ );
734
876
  const code = failure['code'];
735
- if (code !== null && (typeof code !== 'string' || !FAILURE_CODES.has(code))) throw new Error('failure code is invalid');
877
+ if (code !== null && (typeof code !== 'string' || !FAILURE_CODES.has(code)))
878
+ throw new Error('failure code is invalid');
736
879
  const stageValue = failure['stage'];
737
880
  const stage = stageValue === undefined ? undefined : failureStage(stageValue, 'failure stage');
738
881
  const slot = failure['slot'];
739
- if (slot !== undefined && slot !== 1 && slot !== 2 && slot !== 3) throw new Error('failure slot is invalid');
882
+ if (slot !== undefined && slot !== 1 && slot !== 2 && slot !== 3)
883
+ throw new Error('failure slot is invalid');
740
884
  if (failure['attempt'] !== undefined) failureInteger(failure['attempt'], 'failure attempt');
741
- if (typeof failure['child_created'] !== 'boolean') throw new Error('failure child_created is invalid');
885
+ if (typeof failure['child_created'] !== 'boolean')
886
+ throw new Error('failure child_created is invalid');
742
887
  const progress = failureProgress(value['progress'], manifest, options.artifactDir);
743
888
  const expectedProgress = buildFusionRunProgress(manifest);
744
889
  if (canonicalJson(progress) !== canonicalJson(expectedProgress))
@@ -775,7 +920,11 @@ function parseFailureSummary(
775
920
  );
776
921
  const expectedEvidence = Object.entries(manifest.artifacts)
777
922
  .filter(([name]) => name !== 'failure-summary.json')
778
- .map(([name, ref]) => ({ name, classification: manifest.classifications[name] ?? 'evidence_only', ref }))
923
+ .map(([name, ref]) => ({
924
+ name,
925
+ classification: manifest.classifications[name] ?? 'evidence_only',
926
+ ref,
927
+ }))
779
928
  .sort((left, right) => compareFailureText(left.name, right.name));
780
929
  const expectedEvidenceListedCount = Math.min(
781
930
  expectedEvidence.length,
@@ -818,11 +967,25 @@ function parseFailureSummary(
818
967
  if (canonicalJson(terminalMessage) !== canonicalJson(expectedMessage))
819
968
  throw new Error('failure message diverges from manifest terminal error');
820
969
  return {
821
- schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION, run_id: options.runId, workflow: options.workflow,
822
- source: manifest.source, terminal_state: manifest.state, created_at: value['created_at'],
970
+ schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
971
+ run_id: options.runId,
972
+ workflow: options.workflow,
973
+ source: manifest.source,
974
+ terminal_state: manifest.state,
975
+ created_at: value['created_at'],
823
976
  answer: { present: false, reason: 'run_did_not_commit' },
824
- failure: { code: code as FusionFailureSummaryV1['failure']['code'], ...(stage === undefined ? {} : { stage }), ...(slot === undefined ? {} : { slot }), ...(failure['attempt'] === undefined ? {} : { attempt: Number(failure['attempt']) }), child_created: failure['child_created'], message: terminalMessage },
825
- progress, usage_so_far: usageSoFar, attempts, evidence_artifacts: evidence,
977
+ failure: {
978
+ code: code as FusionFailureSummaryV1['failure']['code'],
979
+ ...(stage === undefined ? {} : { stage }),
980
+ ...(slot === undefined ? {} : { slot }),
981
+ ...(failure['attempt'] === undefined ? {} : { attempt: Number(failure['attempt']) }),
982
+ child_created: failure['child_created'],
983
+ message: terminalMessage,
984
+ },
985
+ progress,
986
+ usage_so_far: usageSoFar,
987
+ attempts,
988
+ evidence_artifacts: evidence,
826
989
  remediation_ids: [...remediationIds] as FusionFailureSummaryV1['remediation_ids'],
827
990
  };
828
991
  }
@@ -842,17 +1005,21 @@ function boundedFailureView(
842
1005
  status: FusionFailureResultView['summary_status'],
843
1006
  summaryRef?: FusionArtifactRef,
844
1007
  ): FusionFailureResultView {
845
- const attempts = { listed: [...source.attempts.listed], omitted_count: source.attempts.omitted_count };
846
- const evidence = { listed: [...source.evidence_artifacts.listed], omitted_count: source.evidence_artifacts.omitted_count };
1008
+ const attempts = {
1009
+ listed: [...source.attempts.listed],
1010
+ omitted_count: source.attempts.omitted_count,
1011
+ };
1012
+ const evidence = {
1013
+ listed: [...source.evidence_artifacts.listed],
1014
+ omitted_count: source.evidence_artifacts.omitted_count,
1015
+ };
847
1016
  const failure = source.failure;
848
1017
  const view: FusionFailureResultView = {
849
1018
  schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
850
1019
  summary_status: status,
851
1020
  terminal_state: source.terminal_state,
852
1021
  answer: { present: false, reason: 'run_did_not_commit' },
853
- ...(failure === undefined
854
- ? {}
855
- : { failure: { ...failure, message: { ...failure.message } } }),
1022
+ ...(failure === undefined ? {} : { failure: { ...failure, message: { ...failure.message } } }),
856
1023
  progress: source.progress,
857
1024
  usage_so_far: source.usage_so_far,
858
1025
  attempts,
@@ -860,7 +1027,8 @@ function boundedFailureView(
860
1027
  remediation_ids: source.remediation_ids,
861
1028
  ...(summaryRef === undefined ? {} : { failure_summary_ref: summaryRef }),
862
1029
  };
863
- const fits = (): boolean => Buffer.byteLength(canonicalJson(view), 'utf8') <= FAILURE_VIEW_MAX_BYTES;
1030
+ const fits = (): boolean =>
1031
+ Buffer.byteLength(canonicalJson(view), 'utf8') <= FAILURE_VIEW_MAX_BYTES;
864
1032
  if (!fits() && view.failure?.message.inline_message !== undefined) {
865
1033
  const message = view.failure.message;
866
1034
  view.failure = {
@@ -872,15 +1040,22 @@ function boundedFailureView(
872
1040
  },
873
1041
  };
874
1042
  }
875
- while (!fits() && evidence.listed.length > 0) { evidence.listed.pop(); evidence.omitted_count += 1; }
876
- while (!fits() && attempts.listed.length > 0) { attempts.listed.pop(); attempts.omitted_count += 1; }
877
- if (!fits()) throw new Error('failure result view exceeds its byte budget without a safe whole-section omission');
1043
+ while (!fits() && evidence.listed.length > 0) {
1044
+ evidence.listed.pop();
1045
+ evidence.omitted_count += 1;
1046
+ }
1047
+ while (!fits() && attempts.listed.length > 0) {
1048
+ attempts.listed.pop();
1049
+ attempts.omitted_count += 1;
1050
+ }
1051
+ if (!fits())
1052
+ throw new Error(
1053
+ 'failure result view exceeds its byte budget without a safe whole-section omission',
1054
+ );
878
1055
  return view;
879
1056
  }
880
1057
 
881
- function legacyFailureSource(
882
- manifest: TrustedFailureManifest,
883
- ): FailureViewSource {
1058
+ function legacyFailureSource(manifest: TrustedFailureManifest): FailureViewSource {
884
1059
  const message =
885
1060
  manifest.error === undefined
886
1061
  ? undefined
@@ -913,11 +1088,13 @@ function legacyFailureSource(
913
1088
  usage_so_far: manifest.usage,
914
1089
  attempts: {
915
1090
  listed: attempts.filter((_attempt, index) => index < FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
916
- omitted_count: attempts.length - Math.min(attempts.length, FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
1091
+ omitted_count:
1092
+ attempts.length - Math.min(attempts.length, FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
917
1093
  },
918
1094
  evidence_artifacts: {
919
1095
  listed: evidence.filter((_evidence, index) => index < FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
920
- omitted_count: evidence.length - Math.min(evidence.length, FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
1096
+ omitted_count:
1097
+ evidence.length - Math.min(evidence.length, FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
921
1098
  },
922
1099
  remediation_ids: ['inspect_manifest_bound_evidence', 'inspect_terminal_error'],
923
1100
  };
@@ -929,7 +1106,11 @@ export async function readFusionFailureResult(
929
1106
  ): Promise<FusionFailureResultView> {
930
1107
  let manifest: TrustedFailureManifest;
931
1108
  try {
932
- const file = await readUtf8(join(options.artifactDirAbs, 'manifest.json'), 'manifest.json', options.artifactDir);
1109
+ const file = await readUtf8(
1110
+ join(options.artifactDirAbs, 'manifest.json'),
1111
+ 'manifest.json',
1112
+ options.artifactDir,
1113
+ );
933
1114
  manifest = trustedFailureManifest(parseJsonText(file.text), options);
934
1115
  } catch {
935
1116
  return failureUnavailable('failed', 'unavailable');
@@ -938,7 +1119,10 @@ export async function readFusionFailureResult(
938
1119
  if (summaryRef === undefined) {
939
1120
  return boundedFailureView(legacyFailureSource(manifest), 'legacy_manifest_only');
940
1121
  }
941
- if (manifest.schemaVersion !== FUSION_MANIFEST_SCHEMA_VERSION || summaryRef.path !== 'failure-summary.json')
1122
+ if (
1123
+ manifest.schemaVersion !== FUSION_MANIFEST_SCHEMA_VERSION ||
1124
+ summaryRef.path !== 'failure-summary.json'
1125
+ )
942
1126
  return failureUnavailable(manifest.state, 'integrity_failed');
943
1127
  try {
944
1128
  if (summaryRef.byte_length > FUSION_FAILURE_SUMMARY_MAX_BYTES)
@@ -949,7 +1133,10 @@ export async function readFusionFailureResult(
949
1133
  options.artifactDir,
950
1134
  FUSION_FAILURE_SUMMARY_MAX_BYTES,
951
1135
  );
952
- if (file.bytes.length !== summaryRef.byte_length || sha256Buffer(file.bytes) !== summaryRef.sha256)
1136
+ if (
1137
+ file.bytes.length !== summaryRef.byte_length ||
1138
+ sha256Buffer(file.bytes) !== summaryRef.sha256
1139
+ )
953
1140
  throw new Error('failure summary hash/length mismatch');
954
1141
  const summary = parseFailureSummary(parseJsonText(file.text), manifest, options);
955
1142
  return boundedFailureView(summary, 'verified', summaryRef);
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { open } from 'node:fs/promises';
4
4
  import { isIP } from 'node:net';
5
- import { canonicalJson } from '../attested-pi-run.js';
5
+ import { canonicalJson } from '../canonical-json.js';
6
6
  import { isJsonObject, parseJsonText } from '../common.js';
7
7
  import {
8
8
  FUSION_SOURCE_POLICY_SCHEMA_VERSION,
@@ -111,13 +111,19 @@ export function canonicalizeFusionPublicUrl(value: string): string {
111
111
  });
112
112
  }
113
113
  const ipKind = isIP(normalizedHost);
114
- if ((ipKind === 4 && isBlockedIpv4(normalizedHost)) || (ipKind === 6 && isBlockedIpv6(normalizedHost))) {
114
+ if (
115
+ (ipKind === 4 && isBlockedIpv4(normalizedHost)) ||
116
+ (ipKind === 6 && isBlockedIpv6(normalizedHost))
117
+ ) {
115
118
  throw new FusionError('fusion research source URL must be public, not private/reserved', {
116
119
  code: 'orchestration_failed',
117
120
  childCreated: false,
118
121
  });
119
122
  }
120
- if ((url.protocol === 'http:' && url.port === '80') || (url.protocol === 'https:' && url.port === '443')) {
123
+ if (
124
+ (url.protocol === 'http:' && url.port === '80') ||
125
+ (url.protocol === 'https:' && url.port === '443')
126
+ ) {
121
127
  url.port = '';
122
128
  }
123
129
  return url.toString();
@@ -183,21 +189,28 @@ export function sourcePolicyCanonicalBytes(policy: FusionSourcePolicyV1): string
183
189
 
184
190
  function requireString(record: Record<PropertyKey, unknown>, key: string, label: string): string {
185
191
  const value = record[key];
186
- if (typeof value !== 'string' || value.length === 0) throw new Error(`${label}.${key} must be non-blank string`);
192
+ if (typeof value !== 'string' || value.length === 0)
193
+ throw new Error(`${label}.${key} must be non-blank string`);
187
194
  return value;
188
195
  }
189
196
 
190
197
  export function parseFusionSourcePolicy(value: unknown): FusionSourcePolicyV1 {
191
- if (!isJsonObject(value) || Array.isArray(value)) throw new Error('fusion source policy must be object');
198
+ if (!isJsonObject(value) || Array.isArray(value))
199
+ throw new Error('fusion source policy must be object');
192
200
  const keys = Object.keys(value).sort();
193
201
  const expected = ['cwd', 'root_sha256', 'schema_version', 'sources', 'workflow'];
194
- if (keys.join('\0') !== expected.join('\0')) throw new Error('fusion source policy keys mismatch');
195
- if (value['schema_version'] !== FUSION_SOURCE_POLICY_SCHEMA_VERSION) throw new Error('fusion source policy schema_version mismatch');
196
- if (value['workflow'] !== 'research') throw new Error('fusion source policy workflow must be research');
202
+ if (keys.join('\0') !== expected.join('\0'))
203
+ throw new Error('fusion source policy keys mismatch');
204
+ if (value['schema_version'] !== FUSION_SOURCE_POLICY_SCHEMA_VERSION)
205
+ throw new Error('fusion source policy schema_version mismatch');
206
+ if (value['workflow'] !== 'research')
207
+ throw new Error('fusion source policy workflow must be research');
197
208
  const cwd = requireString(value, 'cwd', 'fusion source policy');
198
209
  const rootSha256 = requireString(value, 'root_sha256', 'fusion source policy');
199
- if (!SHA256_HEX.test(rootSha256)) throw new Error('fusion source policy.root_sha256 must be sha256');
200
- if (!Array.isArray(value['sources'])) throw new Error('fusion source policy.sources must be array');
210
+ if (!SHA256_HEX.test(rootSha256))
211
+ throw new Error('fusion source policy.root_sha256 must be sha256');
212
+ if (!Array.isArray(value['sources']))
213
+ throw new Error('fusion source policy.sources must be array');
201
214
  const sources = value['sources'].map((item, index): FusionDeclaredSourceV1 => {
202
215
  const label = `fusion source policy.sources[${String(index)}]`;
203
216
  if (!isJsonObject(item) || Array.isArray(item)) throw new Error(`${label} must be object`);
@@ -209,10 +222,12 @@ export function parseFusionSourcePolicy(value: unknown): FusionSourcePolicyV1 {
209
222
  const canonical_url = requireString(item, 'canonical_url', label);
210
223
  const sha256 = requireString(item, 'sha256', label);
211
224
  if (!SHA256_HEX.test(sha256)) throw new Error(`${label}.sha256 must be sha256`);
212
- if (canonicalizeFusionPublicUrl(url) !== canonical_url) throw new Error(`${label}.canonical_url mismatch`);
225
+ if (canonicalizeFusionPublicUrl(url) !== canonical_url)
226
+ throw new Error(`${label}.canonical_url mismatch`);
213
227
  if (url !== canonical_url) throw new Error(`${label}.url must equal canonical_url`);
214
228
  if (purpose.trim() !== purpose) throw new Error(`${label}.purpose must be trimmed`);
215
- if (sha256Text(`${canonical_url}\u0000${purpose}`) !== sha256) throw new Error(`${label}.sha256 mismatch`);
229
+ if (sha256Text(`${canonical_url}\u0000${purpose}`) !== sha256)
230
+ throw new Error(`${label}.sha256 mismatch`);
216
231
  return { url, canonical_url, purpose, sha256 };
217
232
  });
218
233
  const seen = new Set<string>();
@@ -222,8 +237,14 @@ export function parseFusionSourcePolicy(value: unknown): FusionSourcePolicyV1 {
222
237
  }
223
238
  seen.add(source.canonical_url);
224
239
  }
225
- const body = { schema_version: FUSION_SOURCE_POLICY_SCHEMA_VERSION, workflow: 'research' as const, cwd, sources } as const;
226
- if (sha256Text(canonicalJson(body)) !== rootSha256) throw new Error('fusion source policy root_sha256 mismatch');
240
+ const body = {
241
+ schema_version: FUSION_SOURCE_POLICY_SCHEMA_VERSION,
242
+ workflow: 'research' as const,
243
+ cwd,
244
+ sources,
245
+ } as const;
246
+ if (sha256Text(canonicalJson(body)) !== rootSha256)
247
+ throw new Error('fusion source policy root_sha256 mismatch');
227
248
  return { ...body, root_sha256: rootSha256 };
228
249
  }
229
250
 
@@ -246,12 +267,17 @@ async function readRegularFileNoSymlink(path: string, label: string): Promise<Bu
246
267
  }
247
268
  }
248
269
 
249
- export async function readFusionSourcePolicyFile(path: string, expectedSha256: string): Promise<FusionSourcePolicyV1> {
250
- if (!SHA256_HEX.test(expectedSha256)) throw new Error('fusion source policy expected hash is malformed');
270
+ export async function readFusionSourcePolicyFile(
271
+ path: string,
272
+ expectedSha256: string,
273
+ ): Promise<FusionSourcePolicyV1> {
274
+ if (!SHA256_HEX.test(expectedSha256))
275
+ throw new Error('fusion source policy expected hash is malformed');
251
276
  const bytes = await readRegularFileNoSymlink(path, 'fusion source policy');
252
277
  const actual = createHash('sha256').update(bytes).digest('hex');
253
278
  if (actual !== expectedSha256) throw new Error('fusion source policy artifact hash mismatch');
254
279
  const text = bytes.toString('utf8');
255
- if (!Buffer.from(text, 'utf8').equals(bytes)) throw new Error('fusion source policy is not UTF-8');
280
+ if (!Buffer.from(text, 'utf8').equals(bytes))
281
+ throw new Error('fusion source policy is not UTF-8');
256
282
  return parseFusionSourcePolicy(parseJsonText(text));
257
283
  }