@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.
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
@@ -1,4 +1,5 @@
1
- // Bounded runtime-context compiler (G5 / TASK-002).
1
+ // Bounded runtime-context compiler (G5 / TASK-002) + subagent context
2
+ // manifest builder (C89-006 / SPEC §7).
2
3
  // Pure module: no I/O, no runtimeConfig reads, no adapter/gate imports.
3
4
  // Compiles goal / verified state / evidence / memory records into a frozen
4
5
  // BoundedContext under a hard byte budget that includes framing and
@@ -10,7 +11,9 @@
10
11
  // ineligible records are excluded (omitted with the policy reason), and
11
12
  // eligible-but-unverified records are labeled 'unverified' and quoted —
12
13
  // never injected as instruction.
14
+ import crypto from 'node:crypto';
13
15
 
16
+ import { validateTaskContract } from './contract.js';
14
17
  import { eligible } from '../memory/policy.js';
15
18
 
16
19
  const STALE_MS = 15 * 60 * 1000; // default freshness window for evidence
@@ -147,3 +150,344 @@ export function compileRuntimeContext({
147
150
  }),
148
151
  };
149
152
  }
153
+
154
+ // --- C89-006: subagent ContextManifest (SPEC §7, FR-05) ---------------------
155
+ // `buildSubagentContext` selects isolated/curated context for a delegated
156
+ // role from caller-supplied candidates and emits a traceable manifest:
157
+ // every include/exclude carries reason + provenance + a deterministic token
158
+ // estimate, required contract fields are never dropped by size pressure,
159
+ // sensitive/secret refs are redacted before any retrieval runs, exact
160
+ // duplicates collapse while keeping lineage (sourceRefs), and a requested
161
+ // `fork` is honestly labeled `fork_unavailable` until a host probe proves
162
+ // real state inheritance — a summary copy is never a fork.
163
+ //
164
+ // The builder stays pure: it performs no fs/config/adapter access itself.
165
+ // Optional original verification is injected through `resolveOriginal`
166
+ // (wired to eventStore.resolveRunArtifact by callers that need it);
167
+ // persistence is eventStore.persistContextManifestRef, not this module.
168
+
169
+ const MANIFEST_VERSION = 1;
170
+ export const CONTEXT_MANIFEST_POLICY_VERSION = 'context-selection.v1';
171
+
172
+ // Deterministic local estimator (chars/4, min 1) — matches the codebase
173
+ // convention without importing the I/O-bearing token module.
174
+ function estimateTokens(value) {
175
+ const text = String(value ?? '');
176
+ if (!text) return 0;
177
+ return Math.max(1, Math.ceil(text.length / 4));
178
+ }
179
+
180
+ const isNonEmptyStr = (v) => typeof v === 'string' && v.length > 0;
181
+ const isPlainRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
182
+
183
+ // Fingerprint an item's content exactly (SPEC §7: exact dedupe only first —
184
+ // semantic pruning is deferred). Items with no content fall back to the ref.
185
+ function fingerprintOf(ref, text) {
186
+ const payload = text != null && text !== '' ? text : `<no-content:${ref}>`;
187
+ return crypto.createHash('sha256').update(payload).digest('hex');
188
+ }
189
+
190
+ const KNOWN_MODES = Object.freeze(['isolated', 'curated', 'fork']);
191
+
192
+ function forkCellStatus(hostCapabilities) {
193
+ return hostCapabilities?.capabilities?.fork?.status ?? null;
194
+ }
195
+
196
+ /**
197
+ * Assemble a subagent ContextManifest (SPEC §7).
198
+ *
199
+ * @param {object} args
200
+ * @param {object} args.taskContract TaskContract v1 (validateTaskContract
201
+ * enforced at this boundary — a malformed contract gets no context).
202
+ * @param {string} args.role child role; defaults to taskContract.role.
203
+ * @param {string} [args.mode] 'isolated'|'curated'|'fork'; defaults to
204
+ * taskContract.contextMode then 'curated'. 'fork' resolves
205
+ * 'fork_unavailable' unless hostCapabilities proves real inheritance.
206
+ * @param {Array<object>} [args.candidates] parent state/evidence/memory
207
+ * candidates. Fields: ref, content|text, required, kind, audience,
208
+ * sensitivity ('sensitive' → redacted), provenance, observedAt, ttlMs,
209
+ * memoryRecord (routed through memory/policy eligible()), artifactRef
210
+ * (resolved via resolveOriginal when supplied).
211
+ * @param {object} [args.hostCapabilities] probeSubagentCapabilities result.
212
+ * @param {object} [args.policyContext] {projectId, projectAliases,
213
+ * includeUser} for memory eligible().
214
+ * @param {Function} [args.resolveOriginal] async (artifactRef) →
215
+ * {ok:true,sha256?}|{status:'unavailable',reason} — injected resolver;
216
+ * availability checks run AFTER sensitivity eligibility so secrets are
217
+ * never fetched.
218
+ * @param {number} [args.budgetTokens] advisory cap: optional items that do
219
+ * not fit are excluded 'over_budget'; required items are never dropped —
220
+ * an exceedance flips manifest.overBudget instead (quality > budget).
221
+ * @param {string} [args.runId] parent run id; defaults taskContract.id.
222
+ * @param {number} [args.now] clock for staleness checks.
223
+ * @returns {Promise<{ok:true, mode:'isolated'|'curated'|'fork_unavailable'|'fork',
224
+ * manifest:object, contextRefs:string[]}|{ok:false, code:string, errors?:string[]}>}
225
+ */
226
+ export async function buildSubagentContext({
227
+ taskContract,
228
+ role,
229
+ mode,
230
+ candidates,
231
+ hostCapabilities,
232
+ policyContext = {},
233
+ resolveOriginal = null,
234
+ budgetTokens = null,
235
+ runId,
236
+ now,
237
+ } = {}) {
238
+ if (taskContract === undefined || taskContract === null) {
239
+ return { ok: false, code: 'malformed_input' };
240
+ }
241
+ const contractVerdict = validateTaskContract(taskContract);
242
+ if (!contractVerdict.valid) {
243
+ return { ok: false, code: 'invalid_contract', errors: contractVerdict.errors };
244
+ }
245
+ if (candidates != null && !Array.isArray(candidates)) {
246
+ return { ok: false, code: 'malformed_input' };
247
+ }
248
+
249
+ const effectiveRole = isNonEmptyStr(role) ? role : taskContract.role;
250
+ const requestedMode = mode ?? taskContract.contextMode ?? 'curated';
251
+ if (!KNOWN_MODES.includes(requestedMode)) {
252
+ return { ok: false, code: 'malformed_input' };
253
+ }
254
+
255
+ // Fork honesty (SPEC §7): a real fork exists only where a host probe proves
256
+ // state inheritance; everything else is the curated fallback labeled
257
+ // 'fork_unavailable' — copying a short summary is never labeled fork.
258
+ const effectiveMode = requestedMode === 'fork'
259
+ ? (forkCellStatus(hostCapabilities) === 'supported' ? 'fork' : 'fork_unavailable')
260
+ : requestedMode;
261
+
262
+ const effectiveRunId = isNonEmptyStr(runId) ? runId : taskContract.id;
263
+ const nowMs = typeof now === 'number' ? now : Date.now();
264
+ const budget = Number.isInteger(budgetTokens) && budgetTokens >= 0
265
+ ? budgetTokens
266
+ : null;
267
+
268
+ const items = [];
269
+ const pushItem = (entry) => {
270
+ items.push(Object.freeze(entry));
271
+ return entry;
272
+ };
273
+ const exclude = (ref, reason, extra = {}) =>
274
+ pushItem({
275
+ ref,
276
+ included: false,
277
+ reason,
278
+ tokenEstimate: estimateTokens(extra.text ?? ref),
279
+ provenance: extra.provenance ?? 'unknown',
280
+ ...(extra.detail != null ? { detail: extra.detail } : {}),
281
+ });
282
+
283
+ // Required contract-derived items first: objective / scope / constraints /
284
+ // expectedOutput / stopConditions / evidenceRequirement / knownFacts are
285
+ // the acceptance boundary and are never droppable by size pressure.
286
+ const contractItem = (ref, text, provenance = 'task-contract') =>
287
+ pushItem({
288
+ ref,
289
+ included: true,
290
+ reason: 'required_contract',
291
+ tokenEstimate: estimateTokens(text),
292
+ provenance,
293
+ fingerprint: fingerprintOf(ref, text),
294
+ });
295
+
296
+ contractItem('contract:objective', taskContract.objective);
297
+ for (const [i, p] of (taskContract.scope?.include ?? []).entries()) {
298
+ contractItem(`contract:scope-include:${i}`, `scope include: ${p}`);
299
+ }
300
+ for (const [i, p] of (taskContract.scope?.exclude ?? []).entries()) {
301
+ contractItem(`contract:scope-exclude:${i}`, `scope exclude: ${p}`);
302
+ }
303
+ (taskContract.constraints ?? []).forEach((c, i) => {
304
+ contractItem(`contract:constraint:${i}`, `constraint: ${c}`);
305
+ });
306
+ contractItem('contract:expected-output', taskContract.expectedOutput);
307
+ if (taskContract.evidenceRequirement != null) {
308
+ contractItem('contract:evidence-requirement', taskContract.evidenceRequirement);
309
+ }
310
+ (taskContract.stopConditions ?? []).forEach((s, i) => {
311
+ contractItem(`contract:stop-condition:${i}`, `stop: ${s}`);
312
+ });
313
+ (taskContract.knownFacts ?? []).forEach((f, i) => {
314
+ contractItem(`contract:known-fact:${i}`, f.statement, f.source);
315
+ });
316
+
317
+ const memContext = {
318
+ projectId: isNonEmptyStr(policyContext.projectId) ? policyContext.projectId : null,
319
+ projectAliases: Array.isArray(policyContext.projectAliases)
320
+ ? policyContext.projectAliases
321
+ : undefined,
322
+ includeUser: policyContext.includeUser === true,
323
+ now: nowMs,
324
+ };
325
+
326
+ const seenFingerprints = new Map(); // fingerprint → first ref (lineage)
327
+ let tokensUsed = items.reduce((n, i) => n + i.tokenEstimate, 0);
328
+ let overBudget = budget != null && tokensUsed > budget;
329
+
330
+ for (const [idx, cand] of (candidates ?? []).entries()) {
331
+ const ref = isNonEmptyStr(cand?.ref) ? cand.ref : `candidate:${idx}`;
332
+ const provenance = isNonEmptyStr(cand?.provenance)
333
+ ? cand.provenance
334
+ : isNonEmptyStr(cand?.source) ? cand.source : 'unknown';
335
+ const text = typeof cand?.content === 'string' ? cand.content
336
+ : typeof cand?.text === 'string' ? cand.text : null;
337
+ const tokenEstimate = estimateTokens(text ?? ref);
338
+
339
+ if (!isPlainRec(cand) || !isNonEmptyStr(cand.ref)) {
340
+ exclude(ref, 'malformed_candidate', { text, provenance });
341
+ continue;
342
+ }
343
+ // Secrets never reach the child — redact before any original resolution
344
+ // so the resolver never fetches bytes it must not see.
345
+ if (cand.sensitivity === 'sensitive' || cand.artifactRef?.sensitivity === 'sensitive') {
346
+ exclude(ref, 'redacted', { text, provenance });
347
+ continue;
348
+ }
349
+ if (cand.memoryRecord != null) {
350
+ const verdict = eligible(cand.memoryRecord, memContext);
351
+ if (!verdict.ok) {
352
+ exclude(ref, verdict.reason, { text, provenance });
353
+ continue;
354
+ }
355
+ }
356
+ // Cross-run refs are out of scope for this child — deny before resolve.
357
+ if (isNonEmptyStr(cand.artifactRef?.runId) && cand.artifactRef.runId !== effectiveRunId) {
358
+ exclude(ref, 'cross_run', { text, provenance });
359
+ continue;
360
+ }
361
+ // Verify the original when a resolver is wired: a missing or
362
+ // checksum-changed original is marked invalid, never presented as
363
+ // current, and never silently omitted.
364
+ if (typeof resolveOriginal === 'function' && isPlainRec(cand.artifactRef)) {
365
+ let resolved = null;
366
+ try {
367
+ resolved = await resolveOriginal(cand.artifactRef);
368
+ } catch {
369
+ resolved = null;
370
+ }
371
+ if (resolved == null || resolved.status === 'unavailable') {
372
+ const reason = resolved?.reason;
373
+ const marked = reason === 'checksum_mismatch' || reason === 'bytes_mismatch'
374
+ ? 'stale_original'
375
+ : 'original_unavailable';
376
+ exclude(ref, marked, { text, provenance, detail: reason ?? 'unresolved' });
377
+ continue;
378
+ }
379
+ }
380
+ if (typeof cand.observedAt === 'number') {
381
+ const ttl = typeof cand.ttlMs === 'number' ? cand.ttlMs : STALE_MS;
382
+ if (nowMs - cand.observedAt > ttl) {
383
+ exclude(ref, 'stale', { text, provenance });
384
+ continue;
385
+ }
386
+ }
387
+ if (cand.audience != null) {
388
+ const aud = Array.isArray(cand.audience) ? cand.audience : [cand.audience];
389
+ if (!aud.includes(effectiveRole)) {
390
+ exclude(ref, 'audience_mismatch', { text, provenance });
391
+ continue;
392
+ }
393
+ }
394
+ // Isolated lanes (SPEC §6 reviewer default) receive requirements / diffs /
395
+ // tests / evidence — implementer narrative is out by construction.
396
+ if (effectiveMode === 'isolated' && cand.kind === 'narrative' && cand.required !== true) {
397
+ exclude(ref, 'isolated_excludes_narrative', { text, provenance });
398
+ continue;
399
+ }
400
+
401
+ const fingerprint = fingerprintOf(ref, text);
402
+
403
+ if (cand.required === true) {
404
+ pushItem({
405
+ ref,
406
+ included: true,
407
+ reason: 'required',
408
+ tokenEstimate,
409
+ provenance,
410
+ fingerprint,
411
+ });
412
+ tokensUsed += tokenEstimate;
413
+ if (budget != null && tokensUsed > budget) overBudget = true;
414
+ seenFingerprints.set(fingerprint, ref);
415
+ continue;
416
+ }
417
+
418
+ // Unknown relevance preserves the item (SPEC §7): the default action is
419
+ // include, so ambiguous candidates are carried with provenance intact.
420
+ const prior = seenFingerprints.get(fingerprint);
421
+ if (prior != null) {
422
+ // Exact-duplicate collapse — lineage kept on both sides.
423
+ const kept = items.find((i) => i.ref === prior);
424
+ const merged = {
425
+ ...kept,
426
+ sourceRefs: Object.freeze([...(kept.sourceRefs ?? []), ref]),
427
+ };
428
+ items[items.indexOf(kept)] = Object.freeze(merged);
429
+ exclude(ref, 'duplicate', { text, provenance, detail: `of:${prior}` });
430
+ continue;
431
+ }
432
+ if (budget != null && tokensUsed + tokenEstimate > budget) {
433
+ exclude(ref, 'over_budget', { text, provenance });
434
+ continue;
435
+ }
436
+ const entry = {
437
+ ref,
438
+ included: true,
439
+ reason: 'relevant',
440
+ tokenEstimate,
441
+ provenance,
442
+ fingerprint,
443
+ };
444
+ if (cand.memoryRecord != null
445
+ && cand.memoryRecord.verified !== true
446
+ && cand.memoryRecord.trust_tier !== 'verified') {
447
+ entry.label = 'unverified';
448
+ }
449
+ pushItem(entry);
450
+ seenFingerprints.set(fingerprint, ref);
451
+ tokensUsed += tokenEstimate;
452
+ }
453
+
454
+ const contextRefs = Object.freeze(
455
+ items.filter((i) => i.included).map((i) => i.ref),
456
+ );
457
+ const excludedCandidates = Object.freeze(
458
+ items.filter((i) => !i.included).map((i) => i.ref),
459
+ );
460
+
461
+ // Source fingerprint: deterministic over what was fed in — requested mode,
462
+ // run binding, and the candidate ref set — so manifests can be compared
463
+ // across identical inputs without trusting a regenerated build.
464
+ const sourceFingerprint = crypto.createHash('sha256').update(JSON.stringify({
465
+ runId: effectiveRunId,
466
+ requestedMode,
467
+ refs: (candidates ?? []).map((c, i) =>
468
+ isNonEmptyStr(c?.ref) ? c.ref : `candidate:${i}`),
469
+ })).digest('hex');
470
+
471
+ const manifest = {
472
+ version: MANIFEST_VERSION,
473
+ runId: effectiveRunId,
474
+ mode: effectiveMode,
475
+ policyVersion: CONTEXT_MANIFEST_POLICY_VERSION,
476
+ // No shipped host exposes prompt-assembly control — the manifest is an
477
+ // advisory selection, never a claim about the child's actual prompt.
478
+ enforcement: 'advisory',
479
+ items: Object.freeze(items),
480
+ excludedCandidates,
481
+ tokensUsed,
482
+ overBudget,
483
+ sourceFingerprint,
484
+ };
485
+ if (effectiveMode !== requestedMode) manifest.requestedMode = requestedMode;
486
+
487
+ return {
488
+ ok: true,
489
+ mode: effectiveMode,
490
+ manifest: Object.freeze(manifest),
491
+ contextRefs,
492
+ };
493
+ }
@@ -322,3 +322,299 @@ export function validateNodeQuestion(question, bounds = {}) {
322
322
  }
323
323
  return ok();
324
324
  }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // C89-003 — TaskContract v1 / ResultEnvelope v1 (SPEC §4–5).
328
+ //
329
+ // Additive envelope schema: the validators below return `{valid, errors[]}`
330
+ // (callers need the full error list at a trust boundary) instead of the
331
+ // `{ok, code}` shape used by the state-machine validators above. The envelope
332
+ // carries its own `version: 1` — CONTRACT_VERSION stays pinned to the
333
+ // operation-state contract and is not repurposed.
334
+
335
+ export const TASK_CONTRACT_VERSION = 1;
336
+ export const RESULT_ENVELOPE_VERSION = 1;
337
+
338
+ /** ResultEnvelope v1 status vocabulary (SPEC §5). */
339
+ export const RESULT_ENVELOPE_STATUSES = Object.freeze([
340
+ 'complete',
341
+ 'partial',
342
+ 'blocked',
343
+ 'needs_context',
344
+ 'needs_capability',
345
+ 'needs_delegation',
346
+ 'needs_review',
347
+ 'failed',
348
+ ]);
349
+
350
+ /** Roles a TaskContract may delegate to (SPEC §6 initial set). */
351
+ export const SUBAGENT_ROLES = Object.freeze(['retriever', 'reasoner', 'reviewer']);
352
+
353
+ /**
354
+ * Context modes known to the v1 contract (SPEC §7). Real `fork` is absent:
355
+ * no host proves state inheritance at this contract version, so the honest
356
+ * v1 vocabulary stops at `fork_unavailable`.
357
+ */
358
+ export const SUBAGENT_CONTEXT_MODES = Object.freeze(['isolated', 'curated', 'fork_unavailable']);
359
+
360
+ /** Bounded budget vocabulary — no numeric caps in v1 (SPEC §10). */
361
+ export const BUDGET_CLASSES = Object.freeze(['small', 'medium', 'large']);
362
+
363
+ /** Sensitivity annotation on a run-artifact ref (SPEC §5). */
364
+ export const REF_SENSITIVITIES = Object.freeze(['default', 'sensitive']);
365
+
366
+ /** How a caller's write scope is realized on this host (SPEC §4). */
367
+ export const WRITE_SCOPE_ENFORCEMENT = Object.freeze(['enforced', 'advisory']);
368
+
369
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
370
+
371
+ const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
372
+ const isStringArray = (v) => Array.isArray(v) && v.every(isNonEmptyString);
373
+ const isPlainRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
374
+
375
+ const TASK_CONTRACT_KEYS = Object.freeze([
376
+ 'version', 'id', 'objective', 'scope', 'constraints', 'knownFacts',
377
+ 'expectedOutput', 'evidenceRequirement', 'role', 'contextMode',
378
+ 'capabilities', 'writeScope', 'budgetClass', 'stopConditions',
379
+ ]);
380
+
381
+ const RESULT_ENVELOPE_KEYS = Object.freeze([
382
+ 'version', 'status', 'summary', 'claims', 'evidenceRefs', 'artifactRefs',
383
+ 'unresolved', 'provenance',
384
+ ]);
385
+
386
+ const REF_KEYS = Object.freeze([
387
+ 'path', 'sha256', 'bytes', 'runId', 'truncated', 'sensitivity',
388
+ 'version', 'capturedAt', 'stream',
389
+ ]);
390
+
391
+ const CLAIM_KEYS = Object.freeze(['statement', 'evidenceRefs', 'confidence']);
392
+ const SCOPE_KEYS = Object.freeze(['include', 'exclude']);
393
+ const WRITE_SCOPE_KEYS = Object.freeze(['include', 'exclude', 'enforcement']);
394
+ const KNOWN_FACT_KEYS = Object.freeze(['statement', 'source']);
395
+ const UNRESOLVED_KEYS = Object.freeze(['reason', 'ref']);
396
+
397
+ function pushUnknownKeys(errors, obj, allowed, label) {
398
+ for (const key of Object.keys(obj)) {
399
+ if (!allowed.includes(key)) errors.push(`${label}: unknown field '${key}'`);
400
+ }
401
+ }
402
+
403
+ function validateScopeObject(value, label, allowedKeys, errors) {
404
+ if (!isPlainRecord(value)) {
405
+ errors.push(`${label} must be an object`);
406
+ return;
407
+ }
408
+ pushUnknownKeys(errors, value, allowedKeys, label);
409
+ if (value.include !== undefined && !isStringArray(value.include)) {
410
+ errors.push(`${label}.include must be an array of non-empty strings`);
411
+ }
412
+ if (value.exclude !== undefined && !isStringArray(value.exclude)) {
413
+ errors.push(`${label}.exclude must be an array of non-empty strings`);
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Validate one run-artifact reference (SPEC §5). A ref is meaningful only
419
+ * when it can be checksum-verified: `path` AND `sha256` are both required.
420
+ */
421
+ function validateResultRef(ref, label, errors) {
422
+ if (!isPlainRecord(ref)) {
423
+ errors.push(`${label} must be an object`);
424
+ return;
425
+ }
426
+ pushUnknownKeys(errors, ref, REF_KEYS, label);
427
+ if (!isNonEmptyString(ref.path)) {
428
+ errors.push(`${label}.path must be a non-empty string`);
429
+ }
430
+ if (!SHA256_HEX_RE.test(ref.sha256)) {
431
+ errors.push(`${label}.sha256 must be a 64-hex digest`);
432
+ }
433
+ if (ref.bytes !== undefined && (!Number.isInteger(ref.bytes) || ref.bytes < 0)) {
434
+ errors.push(`${label}.bytes must be a non-negative integer`);
435
+ }
436
+ if (ref.runId !== undefined && !isNonEmptyString(ref.runId)) {
437
+ errors.push(`${label}.runId must be a non-empty string`);
438
+ }
439
+ if (ref.truncated !== undefined && typeof ref.truncated !== 'boolean') {
440
+ errors.push(`${label}.truncated must be a boolean`);
441
+ }
442
+ if (ref.sensitivity !== undefined && !REF_SENSITIVITIES.includes(ref.sensitivity)) {
443
+ errors.push(`${label}.sensitivity must be one of: ${REF_SENSITIVITIES.join(', ')}`);
444
+ }
445
+ if (ref.version !== undefined && ref.version !== RESULT_ENVELOPE_VERSION) {
446
+ errors.push(`${label}.version must be ${RESULT_ENVELOPE_VERSION}`);
447
+ }
448
+ if (ref.capturedAt !== undefined
449
+ && (typeof ref.capturedAt !== 'string' || Number.isNaN(Date.parse(ref.capturedAt)))) {
450
+ errors.push(`${label}.capturedAt must be an ISO timestamp`);
451
+ }
452
+ if (ref.stream !== undefined && ref.stream !== 'stdout' && ref.stream !== 'stderr') {
453
+ errors.push(`${label}.stream must be 'stdout' or 'stderr'`);
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Validate a TaskContract v1 (SPEC §4). Required fields: id, objective,
459
+ * scope, constraints, expectedOutput, role. The schema is closed — unknown
460
+ * top-level keys reject so a later contract version cannot smuggle
461
+ * unvalidated fields through a v1 boundary.
462
+ *
463
+ * @param {*} value
464
+ * @returns {{valid:boolean, errors:string[]}}
465
+ */
466
+ export function validateTaskContract(value) {
467
+ const errors = [];
468
+ if (!isPlainRecord(value)) {
469
+ return { valid: false, errors: ['contract must be an object'] };
470
+ }
471
+ pushUnknownKeys(errors, value, TASK_CONTRACT_KEYS, 'contract');
472
+ if (value.version !== TASK_CONTRACT_VERSION) {
473
+ errors.push(`version must be ${TASK_CONTRACT_VERSION}`);
474
+ }
475
+ if (!isNonEmptyString(value.id)) errors.push('id must be a non-empty string');
476
+ if (!isNonEmptyString(value.objective)) errors.push('objective must be a non-empty string');
477
+ if (value.scope === undefined) {
478
+ errors.push('scope is required');
479
+ } else {
480
+ validateScopeObject(value.scope, 'scope', SCOPE_KEYS, errors);
481
+ }
482
+ if (!isStringArray(value.constraints)) {
483
+ errors.push('constraints must be an array of non-empty strings');
484
+ }
485
+ if (!isNonEmptyString(value.expectedOutput)) {
486
+ errors.push('expectedOutput must be a non-empty string');
487
+ }
488
+ if (!SUBAGENT_ROLES.includes(value.role)) {
489
+ errors.push(`role must be one of: ${SUBAGENT_ROLES.join(', ')}`);
490
+ }
491
+
492
+ if (value.knownFacts !== undefined) {
493
+ if (!Array.isArray(value.knownFacts)) {
494
+ errors.push('knownFacts must be an array of {statement, source}');
495
+ } else {
496
+ value.knownFacts.forEach((fact, i) => {
497
+ if (!isPlainRecord(fact)) {
498
+ errors.push(`knownFacts[${i}] must be an object`);
499
+ return;
500
+ }
501
+ pushUnknownKeys(errors, fact, KNOWN_FACT_KEYS, `knownFacts[${i}]`);
502
+ if (!isNonEmptyString(fact.statement)) {
503
+ errors.push(`knownFacts[${i}].statement must be a non-empty string`);
504
+ }
505
+ // SPEC §4: externally supplied evidence requires provenance.
506
+ if (!isNonEmptyString(fact.source)) {
507
+ errors.push(`knownFacts[${i}].source must be a non-empty provenance string`);
508
+ }
509
+ });
510
+ }
511
+ }
512
+ if (value.evidenceRequirement !== undefined && !isNonEmptyString(value.evidenceRequirement)) {
513
+ errors.push('evidenceRequirement must be a non-empty string');
514
+ }
515
+ if (value.contextMode !== undefined && !SUBAGENT_CONTEXT_MODES.includes(value.contextMode)) {
516
+ errors.push(`contextMode must be one of: ${SUBAGENT_CONTEXT_MODES.join(', ')}`);
517
+ }
518
+ if (value.capabilities !== undefined && !isStringArray(value.capabilities)) {
519
+ errors.push('capabilities must be an array of non-empty strings');
520
+ }
521
+ if (value.writeScope !== undefined) {
522
+ if (!isPlainRecord(value.writeScope)) {
523
+ errors.push('writeScope must be an object');
524
+ } else {
525
+ validateScopeObject(value.writeScope, 'writeScope', WRITE_SCOPE_KEYS, errors);
526
+ if (value.writeScope.enforcement !== undefined
527
+ && !WRITE_SCOPE_ENFORCEMENT.includes(value.writeScope.enforcement)) {
528
+ errors.push(`writeScope.enforcement must be one of: ${WRITE_SCOPE_ENFORCEMENT.join(', ')}`);
529
+ }
530
+ }
531
+ }
532
+ if (value.budgetClass !== undefined && !BUDGET_CLASSES.includes(value.budgetClass)) {
533
+ errors.push(`budgetClass must be one of: ${BUDGET_CLASSES.join(', ')}`);
534
+ }
535
+ if (value.stopConditions !== undefined && !isStringArray(value.stopConditions)) {
536
+ errors.push('stopConditions must be an array of non-empty strings');
537
+ }
538
+ return { valid: errors.length === 0, errors };
539
+ }
540
+
541
+ /**
542
+ * Validate a ResultEnvelope v1 (SPEC §5). The compact control result a child
543
+ * hands the parent: every status in RESULT_ENVELOPE_STATUSES is terminal for
544
+ * the child, evidence is by-ref only (never raw bytes).
545
+ *
546
+ * @param {*} value
547
+ * @returns {{valid:boolean, errors:string[]}}
548
+ */
549
+ export function validateResultEnvelope(value) {
550
+ const errors = [];
551
+ if (!isPlainRecord(value)) {
552
+ return { valid: false, errors: ['envelope must be an object'] };
553
+ }
554
+ pushUnknownKeys(errors, value, RESULT_ENVELOPE_KEYS, 'envelope');
555
+ if (value.version !== RESULT_ENVELOPE_VERSION) {
556
+ errors.push(`version must be ${RESULT_ENVELOPE_VERSION}`);
557
+ }
558
+ if (!RESULT_ENVELOPE_STATUSES.includes(value.status)) {
559
+ errors.push(`status must be one of: ${RESULT_ENVELOPE_STATUSES.join(', ')}`);
560
+ }
561
+ if (typeof value.summary !== 'string') {
562
+ errors.push('summary must be a string');
563
+ }
564
+ if (!Array.isArray(value.claims)) {
565
+ errors.push('claims must be an array');
566
+ } else {
567
+ value.claims.forEach((claim, i) => {
568
+ const label = `claims[${i}]`;
569
+ if (!isPlainRecord(claim)) {
570
+ errors.push(`${label} must be an object`);
571
+ return;
572
+ }
573
+ pushUnknownKeys(errors, claim, CLAIM_KEYS, label);
574
+ if (!isNonEmptyString(claim.statement)) {
575
+ errors.push(`${label}.statement must be a non-empty string`);
576
+ }
577
+ if (!Array.isArray(claim.evidenceRefs)) {
578
+ errors.push(`${label}.evidenceRefs must be an array`);
579
+ } else {
580
+ claim.evidenceRefs.forEach((ref, j) => validateResultRef(ref, `${label}.evidenceRefs[${j}]`, errors));
581
+ }
582
+ if (claim.confidence !== undefined
583
+ && (typeof claim.confidence !== 'number' || claim.confidence < 0 || claim.confidence > 1)) {
584
+ errors.push(`${label}.confidence must be a number in [0,1]`);
585
+ }
586
+ });
587
+ }
588
+ if (!Array.isArray(value.evidenceRefs)) {
589
+ errors.push('evidenceRefs must be an array');
590
+ } else {
591
+ value.evidenceRefs.forEach((ref, i) => validateResultRef(ref, `evidenceRefs[${i}]`, errors));
592
+ }
593
+ if (!Array.isArray(value.artifactRefs)) {
594
+ errors.push('artifactRefs must be an array');
595
+ } else {
596
+ value.artifactRefs.forEach((ref, i) => validateResultRef(ref, `artifactRefs[${i}]`, errors));
597
+ }
598
+ if (!Array.isArray(value.unresolved)) {
599
+ errors.push('unresolved must be an array');
600
+ } else {
601
+ value.unresolved.forEach((entry, i) => {
602
+ const label = `unresolved[${i}]`;
603
+ if (!isPlainRecord(entry)) {
604
+ errors.push(`${label} must be an object`);
605
+ return;
606
+ }
607
+ pushUnknownKeys(errors, entry, UNRESOLVED_KEYS, label);
608
+ if (!isNonEmptyString(entry.reason)) {
609
+ errors.push(`${label}.reason must be a non-empty string`);
610
+ }
611
+ if (entry.ref !== undefined) validateResultRef(entry.ref, `${label}.ref`, errors);
612
+ });
613
+ }
614
+ if (!isPlainRecord(value.provenance)) {
615
+ errors.push('provenance must be an object');
616
+ } else if (!isNonEmptyString(value.provenance.producer)) {
617
+ errors.push('provenance.producer must be a non-empty string');
618
+ }
619
+ return { valid: errors.length === 0, errors };
620
+ }