@ngockhoale/ukit 3.3.3 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/manifests/engineConformance.yaml +17 -1
- package/manifests/hostCapabilities.yaml +68 -1
- package/manifests/platform.full.yaml +138 -0
- package/manifests/platform.user.yaml +255 -3
- package/package.json +1 -1
- package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
- package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
- package/scripts/probe/codex-capability-probe.mjs +169 -0
- package/src/cli/commands/doctor.js +168 -0
- package/src/cli/commands/indexTools.js +7 -0
- package/src/cli/commands/metrics.js +66 -2
- package/src/cli/commands/playbook.js +4 -4
- package/src/cli/commands/vm.js +49 -8
- package/src/core/agentRuntime/adapters.js +328 -27
- package/src/core/agentRuntime/artifacts.js +89 -0
- package/src/core/agentRuntime/context.js +345 -1
- package/src/core/agentRuntime/contract.js +296 -0
- package/src/core/agentRuntime/eventStore.js +176 -0
- package/src/core/agentRuntime/shadowRun.js +481 -5
- package/src/core/agentRuntime/telemetry.js +121 -0
- package/src/core/observability/emit/lifecycle.js +68 -1
- package/src/core/observability/emit/sessionBoot.js +393 -0
- package/src/core/observability/privacy/allowlist.js +10 -1
- package/src/core/observability/schema/registry.js +10 -0
- package/src/core/runtimeConfig.js +133 -0
- package/src/core/userPlaybooks.js +18 -3
- package/src/decision/registry.js +19 -0
- package/src/diagnostics/feedbackEvents.js +7 -4
- package/src/diagnostics/routeOutcomes.js +51 -6
- package/src/diagnostics/skillAccuracy.js +43 -3
- package/src/index/crossCheckMatrix.js +412 -0
- package/src/index/fixLoopEscalation.js +453 -0
- package/src/index/playbookRegistry.js +691 -0
- package/src/index/reviewPolicy.js +368 -0
- package/src/index/routeResolver.js +915 -0
- package/src/index/sessionHistoryExtractor.js +359 -0
- package/src/index/taskRouting.js +764 -581
- package/src/index/tierSelection.js +308 -0
- package/src/index/verificationMap.js +404 -0
- package/template_project/.claude/hooks/observability-emit.mjs +14 -0
- package/template_project/.claude/hooks/record-execution.mjs +19 -1
- package/template_project/.claude/hooks/skill-router.sh +691 -25
- package/template_project/.claude/hooks/verification-guard.sh +230 -1
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
- package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
- package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
- package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
- package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
- package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
- package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
- package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
- package/template_project/.codex/README.md +8 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
- package/template_project/ukit/README.md +1 -1
- package/template_project/ukit/storage/config.json +20 -0
- package/template_user/playbooks/architecture-decision.md +28 -0
- package/template_user/playbooks/autonomous-run.md +43 -0
- package/template_user/playbooks/autopilot-full.md +59 -0
- package/template_user/playbooks/autopilot-stack.md +54 -0
- package/template_user/playbooks/babysit.md +39 -0
- package/template_user/playbooks/bug-fix.md +3 -1
- package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
- package/template_user/playbooks/hillclimb.md +44 -0
- package/template_user/playbooks/investigation.md +21 -0
- package/template_user/playbooks/migration.md +21 -0
- package/template_user/playbooks/open-pr.md +48 -0
- package/template_user/playbooks/orchestrate.md +45 -0
- package/template_user/playbooks/performance.md +33 -0
- package/template_user/playbooks/prototype.md +28 -0
- package/template_user/playbooks/refactor.md +19 -0
- package/template_user/playbooks/release.md +28 -0
- package/template_user/playbooks/runtime-forensics.md +23 -0
- package/template_user/playbooks/session-pickup.md +31 -0
- package/template_user/playbooks/shipping.md +53 -0
- package/template_user/playbooks/skill-evaluation.md +48 -0
- package/template_user/playbooks/small-feature.md +20 -0
- package/template_user/playbooks/verification-map.json +153 -0
- package/template_user/playbooks/verification.md +22 -0
- 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
|
|
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 {{
|
|
435
|
-
* completionApi:string|null, primitives:object}}
|
|
439
|
+
* @returns {{supported:string, reason:string|null, primitives:object}}
|
|
436
440
|
*/
|
|
437
|
-
|
|
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:
|
|
490
|
-
reason:
|
|
491
|
-
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:
|
|
498
|
-
reason:
|
|
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
|
+
}
|