@ngockhoale/ukit 3.3.2 → 3.4.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 (89) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/manifests/engineConformance.yaml +17 -1
  3. package/manifests/hostCapabilities.yaml +68 -1
  4. package/manifests/platform.full.yaml +138 -0
  5. package/manifests/platform.user.yaml +255 -3
  6. package/package.json +1 -1
  7. package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
  8. package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
  9. package/scripts/probe/codex-capability-probe.mjs +169 -0
  10. package/src/cli/commands/doctor.js +168 -0
  11. package/src/cli/commands/indexTools.js +7 -0
  12. package/src/cli/commands/metrics.js +66 -2
  13. package/src/cli/commands/playbook.js +4 -4
  14. package/src/cli/commands/vm.js +49 -8
  15. package/src/core/agentRuntime/adapters.js +328 -27
  16. package/src/core/agentRuntime/artifacts.js +89 -0
  17. package/src/core/agentRuntime/context.js +345 -1
  18. package/src/core/agentRuntime/contract.js +296 -0
  19. package/src/core/agentRuntime/eventStore.js +176 -0
  20. package/src/core/agentRuntime/shadowRun.js +481 -5
  21. package/src/core/agentRuntime/telemetry.js +121 -0
  22. package/src/core/observability/emit/lifecycle.js +68 -1
  23. package/src/core/observability/emit/sessionBoot.js +393 -0
  24. package/src/core/observability/privacy/allowlist.js +10 -1
  25. package/src/core/observability/schema/registry.js +10 -0
  26. package/src/core/runtimeConfig.js +133 -0
  27. package/src/core/userPlaybooks.js +18 -3
  28. package/src/decision/registry.js +19 -0
  29. package/src/diagnostics/feedbackEvents.js +7 -4
  30. package/src/diagnostics/routeOutcomes.js +51 -6
  31. package/src/diagnostics/skillAccuracy.js +43 -3
  32. package/src/index/crossCheckMatrix.js +412 -0
  33. package/src/index/fixLoopEscalation.js +453 -0
  34. package/src/index/playbookRegistry.js +691 -0
  35. package/src/index/reviewPolicy.js +368 -0
  36. package/src/index/routeResolver.js +915 -0
  37. package/src/index/sessionHistoryExtractor.js +359 -0
  38. package/src/index/taskRouting.js +764 -581
  39. package/src/index/tierSelection.js +308 -0
  40. package/src/index/verificationMap.js +404 -0
  41. package/template_project/.claude/hooks/observability-emit.mjs +14 -0
  42. package/template_project/.claude/hooks/record-execution.mjs +19 -1
  43. package/template_project/.claude/hooks/skill-router.sh +691 -25
  44. package/template_project/.claude/hooks/verification-guard.sh +230 -1
  45. package/template_project/.claude/settings.json +2 -2
  46. package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
  47. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
  48. package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
  49. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
  50. package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
  51. package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
  52. package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
  53. package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
  54. package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
  55. package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
  56. package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
  57. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
  58. package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
  59. package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
  60. package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
  61. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
  62. package/template_project/.codex/README.md +8 -0
  63. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  64. package/template_project/ukit/README.md +1 -1
  65. package/template_project/ukit/storage/config.json +20 -0
  66. package/template_user/playbooks/architecture-decision.md +28 -0
  67. package/template_user/playbooks/autonomous-run.md +43 -0
  68. package/template_user/playbooks/autopilot-full.md +59 -0
  69. package/template_user/playbooks/autopilot-stack.md +54 -0
  70. package/template_user/playbooks/babysit.md +39 -0
  71. package/template_user/playbooks/bug-fix.md +3 -1
  72. package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
  73. package/template_user/playbooks/hillclimb.md +44 -0
  74. package/template_user/playbooks/investigation.md +21 -0
  75. package/template_user/playbooks/migration.md +21 -0
  76. package/template_user/playbooks/open-pr.md +48 -0
  77. package/template_user/playbooks/orchestrate.md +45 -0
  78. package/template_user/playbooks/performance.md +33 -0
  79. package/template_user/playbooks/prototype.md +28 -0
  80. package/template_user/playbooks/refactor.md +19 -0
  81. package/template_user/playbooks/release.md +28 -0
  82. package/template_user/playbooks/runtime-forensics.md +23 -0
  83. package/template_user/playbooks/session-pickup.md +31 -0
  84. package/template_user/playbooks/shipping.md +53 -0
  85. package/template_user/playbooks/skill-evaluation.md +48 -0
  86. package/template_user/playbooks/small-feature.md +20 -0
  87. package/template_user/playbooks/verification-map.json +153 -0
  88. package/template_user/playbooks/verification.md +22 -0
  89. package/template_user/playbooks/worktree-cleanup.md +37 -0
@@ -46,9 +46,13 @@ import { spawn } from 'node:child_process';
46
46
 
47
47
  import {
48
48
  validateSemanticEvent,
49
+ validateTaskContract,
50
+ validateResultEnvelope,
49
51
  CONTRACT_VERSION,
50
52
  MAX_SAFE_PAYLOAD_BYTES,
51
53
  } from './contract.js';
54
+ import { resolveConfigStage } from '../runtimeConfig.js';
55
+ import { resolveRunArtifact } from './eventStore.js';
52
56
  import {
53
57
  makeSpanContext,
54
58
  emitSpanRecord,
@@ -421,9 +425,10 @@ export function detectHost(env = process.env) {
421
425
  }
422
426
 
423
427
  /**
424
- * Evaluate one host lane against the current environment.
428
+ * Evaluate the owned-runner primitives for one lane. Shared by the host
429
+ * lanes (probeHostAdapter) and the owned-vm lane
430
+ * (probeSubagentCapabilities) so both speak the same status ladder.
425
431
  *
426
- * @param {string} name lane name (HOST_NAMES member or arbitrary string)
427
432
  * @param {object} [opts]
428
433
  * @param {boolean} [opts.liveHostE2E] declare live host E2E evidence —
429
434
  * the ONLY path to 'supported'. Never derived from primitive presence.
@@ -431,20 +436,9 @@ export function detectHost(env = process.env) {
431
436
  * @param {Function} [opts.killImpl] required primitive (default process.kill)
432
437
  * @param {Function} [opts.probeImpl] required liveness primitive (default sig-0)
433
438
  * @param {string} [opts.platform] default process.platform
434
- * @returns {{name:string|null, supported:string, reason:string|null,
435
- * completionApi:string|null, primitives:object}}
439
+ * @returns {{supported:string, reason:string|null, primitives:object}}
436
440
  */
437
- export function probeHostAdapter(name, opts = {}) {
438
- const descriptor = HOST_ADAPTERS[name];
439
- if (!descriptor) {
440
- return {
441
- name: null,
442
- supported: HOST_UNSUPPORTED,
443
- reason: 'unknown_host',
444
- completionApi: null,
445
- primitives: {},
446
- };
447
- }
441
+ function probeRunnerPrimitives(opts = {}) {
448
442
  const platform = typeof opts.platform === 'string' ? opts.platform : process.platform;
449
443
  // `undefined` picks the Node defaults; any other non-function value is a
450
444
  // caller-supplied (possibly deliberately absent) primitive — probes must
@@ -467,37 +461,54 @@ export function probeHostAdapter(name, opts = {}) {
467
461
  };
468
462
  if (platform === 'win32') {
469
463
  return {
470
- name,
471
464
  supported: HOST_UNSUPPORTED,
472
465
  reason: 'process_group_kill_unavailable',
473
- completionApi: descriptor.completionApi,
474
466
  primitives,
475
467
  };
476
468
  }
477
469
  if (!primitives.spawn || !primitives.livenessProbe || !primitives.processGroupKill) {
478
470
  return {
479
- name,
480
471
  supported: HOST_UNSUPPORTED,
481
472
  reason: 'host_spawn_unavailable',
482
- completionApi: descriptor.completionApi,
483
473
  primitives,
484
474
  };
485
475
  }
486
476
  if (opts.liveHostE2E === true) {
477
+ return { supported: HOST_SUPPORTED, reason: null, primitives };
478
+ }
479
+ return {
480
+ supported: HOST_UNSUPPORTED_PROBE,
481
+ reason: 'live_host_e2e_unproven',
482
+ primitives,
483
+ };
484
+ }
485
+
486
+ /**
487
+ * Evaluate one host lane against the current environment.
488
+ *
489
+ * @param {string} name lane name (HOST_NAMES member or arbitrary string)
490
+ * @param {object} [opts] same options as probeRunnerPrimitives
491
+ * @returns {{name:string|null, supported:string, reason:string|null,
492
+ * completionApi:string|null, primitives:object}}
493
+ */
494
+ export function probeHostAdapter(name, opts = {}) {
495
+ const descriptor = HOST_ADAPTERS[name];
496
+ if (!descriptor) {
487
497
  return {
488
- name,
489
- supported: HOST_SUPPORTED,
490
- reason: null,
491
- completionApi: descriptor.completionApi,
492
- primitives,
498
+ name: null,
499
+ supported: HOST_UNSUPPORTED,
500
+ reason: 'unknown_host',
501
+ completionApi: null,
502
+ primitives: {},
493
503
  };
494
504
  }
505
+ const probe = probeRunnerPrimitives(opts);
495
506
  return {
496
507
  name,
497
- supported: HOST_UNSUPPORTED_PROBE,
498
- reason: 'live_host_e2e_unproven',
508
+ supported: probe.supported,
509
+ reason: probe.reason,
499
510
  completionApi: descriptor.completionApi,
500
- primitives,
511
+ primitives: probe.primitives,
501
512
  };
502
513
  }
503
514
 
@@ -510,3 +521,293 @@ export function listHostAdapters(opts = {}) {
510
521
  for (const name of HOST_NAMES) out[name] = probeHostAdapter(name, opts);
511
522
  return out;
512
523
  }
524
+
525
+ // --- C89-004: subagent-orchestration capability probe (SPEC §8 FR-06, §12) --
526
+ // Truth table the orchestrator reads before promising lazy disclosure, tool
527
+ // filtering, model binding, or a real fork. Statuses:
528
+ // 'supported' — UKit owns the mechanism (owned-runner completion
529
+ // journal) OR the caller declared live evidence
530
+ // (opts.liveHostE2E + a per-capability evidenceRef).
531
+ // 'advisory' — a surface exists but UKit cannot bound how the host
532
+ // consumes it (catalog mirrors, modelRoles hints,
533
+ // frontmatter tool lists, artifactDir scoping).
534
+ // 'unsupported' — the capability does not exist on this lane: no host
535
+ // API. Evidence can never promote these cells.
536
+ // 'unverified' — reachable in principle but unproven here (fixture-only
537
+ // or no declared live evidence). The only promotable class.
538
+ // Published mirror: manifests/hostCapabilities.yaml `subagentOrchestration`
539
+ // + docs/HOST_CAPABILITY_MATRIX.md § Subagent orchestration (SPEC §12).
540
+
541
+ export const SUBAGENT_CAPABILITIES = Object.freeze([
542
+ 'spawn',
543
+ 'toolFiltering',
544
+ 'skillCatalog',
545
+ 'agentCatalog',
546
+ 'writeScope',
547
+ 'modelBind',
548
+ 'fork',
549
+ 'usageCache',
550
+ 'completionSignal',
551
+ ]);
552
+
553
+ export const SUBAGENT_CAPABILITY_LANES = Object.freeze([
554
+ ...HOST_NAMES,
555
+ 'owned-vm',
556
+ ]);
557
+
558
+ // Status words for capability cells — distinct namespace from the lane
559
+ // ladder (supported/unsupported-probe/unsupported) on purpose.
560
+ const CAP_SUPPORTED = 'supported';
561
+ const CAP_ADVISORY = 'advisory';
562
+ const CAP_UNSUPPORTED = 'unsupported';
563
+ const CAP_UNVERIFIED = 'unverified';
564
+
565
+ // Owned completion journal is UKit machinery, identical on every lane and
566
+ // exercised by real-subprocess tests — supported by construction, with an
567
+ // evidenceRef so the cell is never an empty claim.
568
+ const OWNED_COMPLETION_EVIDENCE = 'tests/core/agentRuntime/hostAdapters.test.js';
569
+
570
+ // Per-lane static baselines. Only 'unverified' cells promote to
571
+ // 'supported' via caller evidence; 'advisory' is a fixed bound (a surface
572
+ // UKit cannot bound is advisory no matter how often it is exercised) and
573
+ // 'unsupported' names a missing API evidence cannot fabricate.
574
+ const SUBAGENT_CAPABILITY_BASELINES = Object.freeze({
575
+ 'omp': Object.freeze({
576
+ toolFiltering: { status: CAP_ADVISORY, reason: 'advisory_only' },
577
+ skillCatalog: { status: CAP_ADVISORY, reason: 'advisory_only' },
578
+ agentCatalog: { status: CAP_ADVISORY, reason: 'advisory_only' },
579
+ writeScope: { status: CAP_ADVISORY, reason: 'advisory_only' },
580
+ modelBind: { status: CAP_ADVISORY, reason: 'advisory_only' },
581
+ fork: { status: CAP_UNSUPPORTED, reason: 'no_host_state_inheritance_api' },
582
+ usageCache: { status: CAP_ADVISORY, reason: 'advisory_only' },
583
+ }),
584
+ 'claude-code': Object.freeze({
585
+ toolFiltering: { status: CAP_ADVISORY, reason: 'advisory_only' },
586
+ skillCatalog: { status: CAP_ADVISORY, reason: 'advisory_only' },
587
+ agentCatalog: { status: CAP_ADVISORY, reason: 'advisory_only' },
588
+ writeScope: { status: CAP_ADVISORY, reason: 'advisory_only' },
589
+ modelBind: { status: CAP_ADVISORY, reason: 'advisory_only' },
590
+ fork: { status: CAP_UNSUPPORTED, reason: 'no_host_state_inheritance_api' },
591
+ usageCache: { status: CAP_ADVISORY, reason: 'advisory_only' },
592
+ }),
593
+ 'codex': Object.freeze({
594
+ toolFiltering: { status: CAP_UNSUPPORTED, reason: 'no_host_tool_filtering_api' },
595
+ skillCatalog: { status: CAP_ADVISORY, reason: 'advisory_only' },
596
+ agentCatalog: { status: CAP_ADVISORY, reason: 'advisory_only' },
597
+ writeScope: { status: CAP_UNSUPPORTED, reason: 'no_host_write_scope_api' },
598
+ modelBind: { status: CAP_UNSUPPORTED, reason: 'no_host_model_binding_api' },
599
+ fork: { status: CAP_UNSUPPORTED, reason: 'no_host_state_inheritance_api' },
600
+ usageCache: { status: CAP_UNSUPPORTED, reason: 'no_provider_usage_api' },
601
+ }),
602
+ 'owned-vm': Object.freeze({
603
+ toolFiltering: { status: CAP_UNSUPPORTED, reason: 'no_child_tool_surface' },
604
+ skillCatalog: { status: CAP_UNSUPPORTED, reason: 'no_child_catalog_surface' },
605
+ agentCatalog: { status: CAP_UNSUPPORTED, reason: 'no_child_catalog_surface' },
606
+ writeScope: { status: CAP_ADVISORY, reason: 'advisory_only' },
607
+ modelBind: { status: CAP_UNSUPPORTED, reason: 'no_child_model_binding' },
608
+ fork: { status: CAP_UNSUPPORTED, reason: 'no_host_state_inheritance_api' },
609
+ usageCache: { status: CAP_UNSUPPORTED, reason: 'no_provider_usage_api' },
610
+ }),
611
+ });
612
+
613
+ function capCell(status, reason, evidenceRef) {
614
+ const cell = { status, reason: reason ?? null };
615
+ if (typeof evidenceRef === 'string' && evidenceRef !== '') {
616
+ cell.evidenceRef = evidenceRef;
617
+ }
618
+ return cell;
619
+ }
620
+
621
+ /**
622
+ * Probe one lane's subagent-orchestration control surface.
623
+ *
624
+ * Never fabricates control: a host API UKit does not have stays
625
+ * 'unsupported', advisory surfaces stay 'advisory', and 'supported' exists
626
+ * only where UKit owns the mechanism (completionSignal — the eventStore
627
+ * journal) or where the caller declared live evidence for a promotable
628
+ * (default-'unverified') cell.
629
+ *
630
+ * @param {string|null|undefined} [host] lane name; undefined → ambient
631
+ * detectHost(opts.env) — ambiguous/absent markers resolve a null lane
632
+ * and every cell reports unverified/unsupported, never a guessed lane.
633
+ * @param {object} [opts]
634
+ * @param {object} [opts.env] env map for ambient detection
635
+ * @param {boolean} [opts.liveHostE2E] declared live E2E evidence for the
636
+ * owned-runner spawn surface (same bar as probeHostAdapter)
637
+ * @param {Object<string,string>} [opts.evidence] per-capability evidence
638
+ * refs; a promotable cell reports 'supported' only with BOTH live
639
+ * evidence context (liveHostE2E for spawn) and a non-empty ref.
640
+ * @param {Function} [opts.spawnImpl] owned-runner primitive override
641
+ * @param {Function} [opts.killImpl] owned-runner primitive override
642
+ * @param {Function} [opts.probeImpl] owned-runner primitive override
643
+ * @param {string} [opts.platform] default process.platform
644
+ * @returns {{host:string|null, capabilities:
645
+ * Record<string,{status:string, reason:string|null, evidenceRef?:string}>}}
646
+ */
647
+ export function probeSubagentCapabilities(host, opts = {}) {
648
+ const lane = host === undefined || host === null
649
+ ? detectHost(opts.env)
650
+ : host;
651
+ const resolved = SUBAGENT_CAPABILITY_LANES.includes(lane) ? lane : null;
652
+ const capabilities = {};
653
+
654
+ if (resolved === null) {
655
+ // Unknown explicit name or unresolved ambient markers → conservative
656
+ // fallback: runner-bound cells report the lane probe verdict, the rest
657
+ // stay unverified. Nothing is ever guessed or faked supported.
658
+ const probe = lane === undefined || lane === null
659
+ ? { supported: HOST_UNSUPPORTED, reason: 'host_unresolved' }
660
+ : probeHostAdapter(lane, opts);
661
+ const unresolved = lane === undefined || lane === null;
662
+ for (const cap of SUBAGENT_CAPABILITIES) {
663
+ if (cap === 'spawn') {
664
+ capabilities[cap] = capCell(
665
+ unresolved || probe.supported !== HOST_UNSUPPORTED
666
+ ? CAP_UNVERIFIED
667
+ : CAP_UNSUPPORTED,
668
+ unresolved ? 'host_unresolved' : (probe.reason ?? 'host_unresolved'),
669
+ );
670
+ } else {
671
+ // completionSignal and every host-exposed control: unknown or
672
+ // unresolved lane → unverified. The owned journal exists, but it
673
+ // is never promised for a lane we cannot name.
674
+ capabilities[cap] = capCell(
675
+ CAP_UNVERIFIED,
676
+ unresolved ? 'host_unresolved' : (probe.reason ?? 'unknown_host'),
677
+ );
678
+ }
679
+ }
680
+ return { host: resolved, capabilities };
681
+ }
682
+
683
+ const baselines = SUBAGENT_CAPABILITY_BASELINES[resolved];
684
+ const runner = probeRunnerPrimitives(opts);
685
+ const evidence = opts.evidence && typeof opts.evidence === 'object'
686
+ ? opts.evidence
687
+ : {};
688
+ const liveSpawn = opts.liveHostE2E === true
689
+ && typeof evidence.spawn === 'string'
690
+ && evidence.spawn !== '';
691
+
692
+ for (const cap of SUBAGENT_CAPABILITIES) {
693
+ if (cap === 'spawn') {
694
+ // Runner primitive status mirrors the lane probe; 'supported' only
695
+ // with declared live E2E AND a cell-level evidenceRef.
696
+ if (runner.supported === HOST_UNSUPPORTED) {
697
+ capabilities.spawn = capCell(CAP_UNSUPPORTED, runner.reason);
698
+ } else if (runner.supported === HOST_SUPPORTED && liveSpawn) {
699
+ capabilities.spawn = capCell(CAP_SUPPORTED, null, evidence.spawn);
700
+ } else {
701
+ capabilities.spawn = capCell(
702
+ CAP_UNVERIFIED,
703
+ runner.supported === HOST_SUPPORTED
704
+ ? 'evidence_ref_required'
705
+ : runner.reason,
706
+ );
707
+ }
708
+ continue;
709
+ }
710
+ if (cap === 'completionSignal') {
711
+ capabilities.completionSignal = runner.supported === HOST_UNSUPPORTED
712
+ ? capCell(CAP_UNSUPPORTED, runner.reason)
713
+ : capCell(CAP_SUPPORTED, null, OWNED_COMPLETION_EVIDENCE);
714
+ continue;
715
+ }
716
+ const base = baselines[cap];
717
+ const ref = typeof evidence[cap] === 'string' && evidence[cap] !== ''
718
+ ? evidence[cap]
719
+ : null;
720
+ if (base.status === CAP_UNVERIFIED && ref && runner.supported === HOST_SUPPORTED) {
721
+ capabilities[cap] = capCell(CAP_SUPPORTED, null, ref);
722
+ } else {
723
+ capabilities[cap] = capCell(base.status, base.reason);
724
+ }
725
+ }
726
+ return { host: resolved, capabilities };
727
+ }
728
+
729
+ // --- C89-003: ResultEnvelope ingestion on the owned runner (SPEC §5, §11) --
730
+ // `subagentOrchestrator.referenceHandoff.stage` gates the whole lane: 'off'
731
+ // (or absent/malformed config) is a typed no-op — no fs access, no schema
732
+ // work, the legacy stdout/stderr surface is byte-identical. When enabled the
733
+ // runner accepts a child's compact ResultEnvelope, verifies every evidence/
734
+ // artifact ref through `resolveRunArtifact` inside the run root, and returns
735
+ // the compact control result. An envelope that claims 'complete' while any
736
+ // of its evidence is missing, corrupt, truncated, or out-of-scope downgrades
737
+ // to 'partial' — false completeness is never served.
738
+
739
+ const REFERENCE_HANDOFF_FLAG = 'subagentOrchestrator.referenceHandoff.stage';
740
+ /** Cap on refs resolved per envelope — ingestion work stays bounded. */
741
+ const MAX_ENVELOPE_REFS = 64;
742
+
743
+ /**
744
+ * Ingest one child ResultEnvelope on the owned runner.
745
+ *
746
+ * @param {object} args
747
+ * @param {object} [args.config] runtime config (stage gate)
748
+ * @param {string} args.runRoot run directory the refs are scoped to
749
+ * @param {object} args.caller `{runId, role?}` — resolution authorization
750
+ * @param {object} [args.contract] TaskContract the child ran under; when
751
+ * present it is validated before the envelope is even looked at
752
+ * @param {object} args.envelope ResultEnvelope v1
753
+ * @returns {Promise<{ok:false, code:string, errors?:string[]}
754
+ * |{ok:true, status:string, summary:string, claims:object[],
755
+ * resolvedRefs:object[], unresolvedRefs:object[],
756
+ * provenance:object}>}
757
+ */
758
+ export async function ingestResultEnvelope({
759
+ config = null,
760
+ runRoot,
761
+ caller,
762
+ contract,
763
+ envelope,
764
+ } = {}) {
765
+ if (resolveConfigStage(config, REFERENCE_HANDOFF_FLAG) === 'off') {
766
+ return { ok: false, code: 'reference_handoff_off' };
767
+ }
768
+ if (contract !== undefined) {
769
+ const v = validateTaskContract(contract);
770
+ if (!v.valid) return { ok: false, code: 'invalid_contract', errors: v.errors };
771
+ }
772
+ const v = validateResultEnvelope(envelope);
773
+ if (!v.valid) return { ok: false, code: 'invalid_envelope', errors: v.errors };
774
+
775
+ // Review fix (C89 R1): claim-level evidenceRefs were schema-validated but
776
+ // never resolved — a 'complete' envelope with all claim evidence missing
777
+ // would still report complete. Resolve every declared ref through the same
778
+ // bounded loop; failures land in unresolvedRefs and downgrade status.
779
+ const claimRefs = (envelope.claims ?? []).flatMap((claim) => claim.evidenceRefs ?? []);
780
+ const refs = [...envelope.evidenceRefs, ...envelope.artifactRefs, ...claimRefs];
781
+ const resolvedRefs = [];
782
+ const unresolvedRefs = [...envelope.unresolved];
783
+ for (const ref of refs.slice(0, MAX_ENVELOPE_REFS)) {
784
+ const resolved = await resolveRunArtifact(ref, { runRoot, caller });
785
+ if (resolved.status === 'unavailable') {
786
+ unresolvedRefs.push({ ref, reason: resolved.reason });
787
+ } else {
788
+ resolvedRefs.push({ ref, verified: true, sha256: resolved.sha256 });
789
+ if (resolved.truncated === true) {
790
+ unresolvedRefs.push({ ref, reason: 'truncated' });
791
+ }
792
+ }
793
+ }
794
+ for (const ref of refs.slice(MAX_ENVELOPE_REFS)) {
795
+ unresolvedRefs.push({ ref, reason: 'ref_cap_exceeded' });
796
+ }
797
+
798
+ // Never upgrade a child's own status; 'complete' downgrades to 'partial'
799
+ // the moment any declared evidence fails to resolve verified.
800
+ const status = envelope.status === 'complete' && unresolvedRefs.length > 0
801
+ ? 'partial'
802
+ : envelope.status;
803
+
804
+ return {
805
+ ok: true,
806
+ status,
807
+ summary: envelope.summary,
808
+ claims: envelope.claims,
809
+ resolvedRefs,
810
+ unresolvedRefs,
811
+ provenance: envelope.provenance,
812
+ };
813
+ }
@@ -190,3 +190,92 @@ export function createArtifactWriter(dir, limits = {}, deps = {}) {
190
190
 
191
191
  return { open };
192
192
  }
193
+
194
+ // --- C89-003: single-artifact commit (SPEC §5) ------------------------------
195
+ // `createArtifactWriter` streams child stdout/stderr; `writeRunArtifact`
196
+ // commits ONE caller-supplied buffer (e.g. a full child report) as a
197
+ // checksummed original inside the run directory — the file an envelope's
198
+ // evidenceRefs point at. Commit is atomic (temp + fsync + rename) and every
199
+ // failure leaves nothing behind.
200
+
201
+ const ARTIFACT_NAME_RE = /^[A-Za-z0-9._-]{1,200}$/;
202
+
203
+ // Real FileHandle exposes .sync(), not .fsync(); tolerate both.
204
+ const defaultSyncImpl = async (handle) => {
205
+ if (handle && typeof handle.sync === 'function') return handle.sync();
206
+ if (handle && typeof handle.fsync === 'function') return handle.fsync();
207
+ return undefined;
208
+ };
209
+
210
+ /**
211
+ * Persist `bytes` as `<dir>/<name>` and return a checksummed ref:
212
+ * `{path, sha256, bytes, truncated:false, stream:null}`. `name` must be a
213
+ * plain basename — any path component or traversal rejects typed before a
214
+ * single byte touches disk.
215
+ *
216
+ * @param {string} dir run artifact directory
217
+ * @param {string} name basename-only file name
218
+ * @param {Buffer|Uint8Array|string} bytes content to persist
219
+ * @param {object} [opts] `{maxBytes, deps:{mkdirImpl,openImpl,writeImpl,fsyncImpl,closeImpl,renameImpl,unlinkImpl}}`
220
+ * @returns {Promise<object>} artifact ref (same shape family as stream refs)
221
+ */
222
+ export async function writeRunArtifact(dir, name, bytes, opts = {}) {
223
+ if (
224
+ typeof dir !== 'string' || dir === ''
225
+ || typeof name !== 'string'
226
+ || !ARTIFACT_NAME_RE.test(name)
227
+ || name === '.' || name === '..'
228
+ || path.basename(name) !== name
229
+ ) {
230
+ throw new ArtifactError(
231
+ 'invalid_artifact_name',
232
+ `artifact name must be a basename: ${String(name)}`,
233
+ );
234
+ }
235
+ const maxBytes = Number.isInteger(opts.maxBytes) && opts.maxBytes >= 0
236
+ ? opts.maxBytes
237
+ : DEFAULT_LIMITS.maxOutputBytes;
238
+ const buf = Buffer.isBuffer(bytes)
239
+ ? bytes
240
+ : Buffer.from(bytes === undefined || bytes === null ? '' : bytes);
241
+ if (buf.length > maxBytes) {
242
+ throw new ArtifactError(
243
+ 'artifact_too_large',
244
+ `artifact ${name} is ${buf.length} bytes (cap ${maxBytes})`,
245
+ );
246
+ }
247
+ const d = {
248
+ mkdirImpl: opts.deps?.mkdirImpl ?? ((p) => fs.mkdir(p, { recursive: true })),
249
+ openImpl: opts.deps?.openImpl ?? ((p) => fs.open(p, 'w')),
250
+ writeImpl: opts.deps?.writeImpl ?? ((h, b) => h.write(b)),
251
+ fsyncImpl: opts.deps?.fsyncImpl ?? defaultSyncImpl,
252
+ closeImpl: opts.deps?.closeImpl ?? ((h) => h.close()),
253
+ renameImpl: opts.deps?.renameImpl ?? ((a, b) => fs.rename(a, b)),
254
+ unlinkImpl: opts.deps?.unlinkImpl ?? ((p) => fs.unlink(p)),
255
+ };
256
+
257
+ const finalPath = path.join(dir, name);
258
+ const tmpPath = path.join(dir, `.${name}.${crypto.randomUUID()}.tmp`);
259
+ let handle;
260
+ try {
261
+ await d.mkdirImpl(dir);
262
+ handle = await d.openImpl(tmpPath);
263
+ if (buf.length > 0) await d.writeImpl(handle, buf);
264
+ await d.fsyncImpl(handle);
265
+ await d.closeImpl(handle);
266
+ handle = undefined;
267
+ await d.renameImpl(tmpPath, finalPath);
268
+ } catch (err) {
269
+ if (handle) {
270
+ try { await d.closeImpl(handle); } catch { /* error already in flight */ }
271
+ }
272
+ try { await d.unlinkImpl(tmpPath); } catch { /* nothing landed */ }
273
+ throw toArtifactError(err);
274
+ }
275
+ return {
276
+ path: finalPath,
277
+ sha256: crypto.createHash('sha256').update(buf).digest('hex'),
278
+ bytes: buf.length,
279
+ truncated: false,
280
+ };
281
+ }