@ngockhoale/ukit 3.1.9 → 3.3.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 (34) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/memory.js +11 -87
  4. package/src/cli/commands/selfImprove.js +55 -0
  5. package/src/cli/commands/telemetry.js +45 -3
  6. package/src/cli/index.js +7 -0
  7. package/src/core/agentRuntime/adapters.js +177 -0
  8. package/src/core/agentRuntime/contract.js +77 -0
  9. package/src/core/agentRuntime/diagnostics.js +343 -42
  10. package/src/core/agentRuntime/eventStore.js +139 -0
  11. package/src/core/agentRuntime/planCompiler.js +45 -6
  12. package/src/core/agentRuntime/planLibrary.js +43 -7
  13. package/src/core/agentRuntime/plans/bugfix-loop.json +1 -0
  14. package/src/core/agentRuntime/plans/flag-promotion.json +138 -0
  15. package/src/core/agentRuntime/plans/handoff-review-batch.json +1 -0
  16. package/src/core/agentRuntime/plans/release-check.json +2 -1
  17. package/src/core/agentRuntime/promotion.js +147 -9
  18. package/src/core/agentRuntime/runtimeSupport.js +18 -0
  19. package/src/core/agentRuntime/supervisor.js +44 -2
  20. package/src/core/agentRuntime/telemetry.js +123 -0
  21. package/src/core/agentRuntime/vmEngine.js +51 -10
  22. package/src/core/memory/episodes.js +168 -0
  23. package/src/core/metadata.js +21 -0
  24. package/src/core/runInstallPipeline.js +6 -1
  25. package/src/core/runtimeConfig.js +71 -40
  26. package/src/decision/client.js +4 -5
  27. package/src/decision/runtimeDecide.js +183 -5
  28. package/src/learning/selfImprove.js +205 -0
  29. package/src/learning/tunedOverlay.js +112 -0
  30. package/template_project/.claude/hooks/session-episode.sh +35 -15
  31. package/template_project/.claude/ukit/index/route-task.mjs +13 -0
  32. package/template_project/.claude/ukit/index/unic-decision.mjs +1 -2
  33. package/template_project/.claude/ukit/runtime/self-improve-trigger.mjs +98 -0
  34. package/template_project/ukit/storage/config.json +68 -17
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Pure plan compiler: validates a small DAG IR (frozen opcode set: RUN,
5
5
  * WAIT_EVENT, BRANCH, COMPLETE, ESCALATE — plus IR v2 wrappers PARALLEL
6
- * and TIMEOUT) and emits an immutable
7
- * ValidatedPlan { planVersion, nodes, entryNodes, order }.
6
+ * and TIMEOUT, gated behind an explicit opt-in per the V-01 flag contract
7
+ * in compilePlan()) and emits an immutable
8
+ * ValidatedPlan { planVersion, irVersion, nodes, entryNodes, order }.
8
9
  *
9
10
  * No I/O, no runtimeConfig reads, no imports of vmEngine. The only
10
11
  * dependency is the read-only SIDE_EFFECT_CLASSES table from contract.js.
@@ -14,7 +15,7 @@
14
15
 
15
16
  import { createHash } from 'node:crypto';
16
17
 
17
- import { SIDE_EFFECT_CLASSES } from './contract.js';
18
+ import { SIDE_EFFECT_CLASSES, IR_VERSIONS, validateNodeQuestion } from './contract.js';
18
19
 
19
20
  export const PLAN_OPS = Object.freeze([
20
21
  'RUN',
@@ -26,6 +27,9 @@ export const PLAN_OPS = Object.freeze([
26
27
  'TIMEOUT',
27
28
  ]);
28
29
 
30
+ /** Ops that require an explicit IR v2 opt-in (V-01 flag gate). */
31
+ const V2_OPS = new Set(['PARALLEL', 'TIMEOUT']);
32
+
29
33
  const OP_SET = new Set(PLAN_OPS);
30
34
  const SIDE_EFFECT_SET = new Set(SIDE_EFFECT_CLASSES);
31
35
 
@@ -169,8 +173,14 @@ function validateRetryPolicy(retry, nodeId, declaredEffects, errors) {
169
173
  /**
170
174
  * compilePlan(ir, opts) — SPEC §4.
171
175
  *
172
- * @param {object} ir `{ nodes: [...] }`
173
- * @param {{maxNodes?:number, allowedOps?:string[]}} [opts]
176
+ * V-01 gate: IR v2 ops (PARALLEL, TIMEOUT) parse only when the caller opts
177
+ * in — `ir.planVersion: 'v2'`, `opts.irVersion: 'v2'` (how callers forward a
178
+ * non-'off' decisionRuntime.vm stage), or an `opts.allowedOps` list that
179
+ * explicitly names them. v1 plans compile byte-identical under every flag
180
+ * state; their planVersion hash never gets the 'v2:' prefix.
181
+ *
182
+ * @param {object} ir `{ nodes: [...], planVersion?: 'v1'|'v2' }`
183
+ * @param {{maxNodes?:number, allowedOps?:string[], irVersion?:string}} [opts]
174
184
  * @returns {{ok:true, plan:object}|{ok:false, errors:object[]}}
175
185
  */
176
186
  export function compilePlan(ir, opts = {}) {
@@ -182,6 +192,19 @@ export function compilePlan(ir, opts = {}) {
182
192
  ? new Set(opts.allowedOps)
183
193
  : OP_SET;
184
194
 
195
+ // V-01 flag gate (see docstring): BOTH channels are validated ('v1' is an
196
+ // explicit no-op, anything else is a typo that must not widen parsing);
197
+ // v2 enables when EITHER the artifact or the caller opts in — a stage-off
198
+ // caller ('v1') can never veto an artifact's own planVersion:'v2'.
199
+ const declared = [opts.irVersion, ir?.planVersion].filter((v) => v !== undefined);
200
+ for (const v of declared) {
201
+ if (!IR_VERSIONS.includes(v)) {
202
+ err(errors, 'invalid_ir_version', undefined,
203
+ `IR version ${JSON.stringify(v)} must be one of: ${IR_VERSIONS.join(', ')}`);
204
+ }
205
+ }
206
+ const v2Enabled = declared.includes('v2');
207
+
185
208
  const rawNodes = Array.isArray(ir?.nodes) ? ir.nodes : [];
186
209
  if (rawNodes.length === 0) {
187
210
  err(errors, 'empty_plan', undefined, 'ir.nodes must be a non-empty array');
@@ -212,6 +235,13 @@ export function compilePlan(ir, opts = {}) {
212
235
  err(errors, 'unknown_op', nodeId, `op ${JSON.stringify(op)} not allowed`);
213
236
  continue;
214
237
  }
238
+ // Gate BEFORE other per-node checks so a gated op surfaces exactly one
239
+ // typed error; explicit allowedOps lists are the caller's opt-in.
240
+ if (V2_OPS.has(op) && !v2Enabled && !Array.isArray(opts.allowedOps)) {
241
+ err(errors, 'ir_v2_disabled', nodeId,
242
+ `op ${op} requires IR v2 (ir.planVersion:'v2', opts.irVersion:'v2', or a non-off decisionRuntime.vm stage forwarded by the caller)`);
243
+ continue;
244
+ }
215
245
 
216
246
  const deps = node.deps === undefined ? [] : node.deps;
217
247
  if (!Array.isArray(deps) || deps.some((d) => !isNonEmptyString(d))) {
@@ -309,6 +339,10 @@ export function compilePlan(ir, opts = {}) {
309
339
  }
310
340
 
311
341
 
342
+ if (node.question !== undefined && !validateNodeQuestion(node.question).ok) {
343
+ err(errors, 'malformed_question', nodeId, 'question block fails validateNodeQuestion');
344
+ }
345
+
312
346
  const retry = validateRetryPolicy(node.retry, nodeId, declaredEffects, errors);
313
347
 
314
348
  const compiledNode = {
@@ -316,6 +350,7 @@ export function compilePlan(ir, opts = {}) {
316
350
  op,
317
351
  deps: [...deps],
318
352
  ...(retry ? { retry } : {}),
353
+ ...(node.question !== undefined ? { question: deepFreeze({ ...node.question }) } : {}),
319
354
  ...(node.selector !== undefined ? { selector: deepFreeze({ ...node.selector }) } : {}),
320
355
  ...(node.timeoutMs !== undefined ? { timeoutMs: node.timeoutMs } : {}),
321
356
  ...(node.spec !== undefined ? { spec: deepFreeze({ ...node.spec }) } : {}),
@@ -385,7 +420,7 @@ export function compilePlan(ir, opts = {}) {
385
420
  // v1 plans hash exactly as before; IR v2 usage switches the canonical
386
421
  // input to a 'v2:'-prefixed serialization so v2 planVersions can never
387
422
  // collide with (or silently change) v1 hashes.
388
- const usesV2 = [...compiled.values()].some((n) => n.op === 'PARALLEL' || n.op === 'TIMEOUT');
423
+ const usesV2 = [...compiled.values()].some((n) => V2_OPS.has(n.op));
389
424
 
390
425
  if (errors.length > 0) {
391
426
  return { ok: false, errors };
@@ -404,6 +439,10 @@ export function compilePlan(ir, opts = {}) {
404
439
 
405
440
  const plan = Object.freeze({
406
441
  planVersion,
442
+ // IR version is derived from the ops actually used, not the opt-in
443
+ // label: a 'v2'-declared plan that uses only v1 ops reports 'v1' and
444
+ // hashes as v1.
445
+ irVersion: usesV2 ? 'v2' : 'v1',
407
446
  nodes: Object.freeze(nodes),
408
447
  entryNodes: Object.freeze(entryNodes),
409
448
  order: Object.freeze(order),
@@ -4,9 +4,17 @@
4
4
  *
5
5
  * Loads the IR documents under `src/core/agentRuntime/plans/<planId>.json`,
6
6
  * compiles each through `compilePlan`, and returns an immutable catalog:
7
- * { planId -> { planId, description, irHash, plan } }. `irHash` is a sha256
8
- * over the canonical IR bytes — changing a plan bumps its version artifact
9
- * (the compiled `planVersion` is also content-derived inside compilePlan).
7
+ * { planId -> { ok, planId, description, specText, irHash, planVersion, plan } }.
8
+ * `irHash` is a sha256 over the raw artifact bytes; `planVersion` is the
9
+ * content-derived hash inside the compiled ValidatedPlan. Each plan JSON
10
+ * carries a `specText` field — the human-authored spec the IR was compiled
11
+ * from — so spec → artifact stays in one file and edits bump `irHash`.
12
+ *
13
+ * Release pinning: `PLAN_PINS` records the compiled planVersion of each
14
+ * shipped plan. Editing a plan's nodes changes its planVersion, so a plan
15
+ * that drifts from its pin fails load with `plan_version_mismatch` instead
16
+ * of silently shipping a different workflow. Bump the pin in the same
17
+ * commit that changes the plan — that re-pin is the release review point.
10
18
  *
11
19
  * Read-only: no writes, no host calls. Compile errors surface as
12
20
  * { ok:false, errors } entries — they never throw so a bad draft plan cannot
@@ -18,10 +26,20 @@ import fs from 'node:fs';
18
26
  import path from 'node:path';
19
27
  import { fileURLToPath } from 'node:url';
20
28
 
29
+
30
+ import { resolveDecisionRuntimeStage } from '../runtimeConfig.js';
21
31
  import { compilePlan } from './planCompiler.js';
22
32
 
23
33
  const PLANS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'plans');
24
34
 
35
+ /** planId -> compiled planVersion pinned at ship time. */
36
+ export const PLAN_PINS = Object.freeze({
37
+ 'bugfix-loop': '959c23b0a834b022',
38
+ 'flag-promotion': 'c7af6411d4be570b',
39
+ 'handoff-review-batch': 'bb3194edfc33ea0e',
40
+ 'release-check': 'b59e5be91a205aa2',
41
+ });
42
+
25
43
  export function listPlanIds({ plansDir = PLANS_DIR } = {}) {
26
44
  let entries;
27
45
  try {
@@ -35,7 +53,7 @@ export function listPlanIds({ plansDir = PLANS_DIR } = {}) {
35
53
  .sort();
36
54
  }
37
55
 
38
- export function loadPlan(planId, { plansDir = PLANS_DIR } = {}) {
56
+ export function loadPlan(planId, { plansDir = PLANS_DIR, pins = PLAN_PINS, config = null } = {}) {
39
57
  if (typeof planId !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
40
58
  return { ok: false, errors: [{ code: 'invalid_plan_id', planId }] };
41
59
  }
@@ -52,22 +70,40 @@ export function loadPlan(planId, { plansDir = PLANS_DIR } = {}) {
52
70
  } catch {
53
71
  return { ok: false, errors: [{ code: 'plan_malformed', planId }] };
54
72
  }
55
- const result = compilePlan(ir);
73
+ // V-01 flag gate: a non-'off' decisionRuntime.vm stage enables IR v2 op
74
+ // parsing for every loaded plan; 'off' (or absent config) compiles v1-only
75
+ // unless the plan artifact itself declares planVersion:'v2'.
76
+ const irVersion = resolveDecisionRuntimeStage(config, 'vm') === 'off' ? 'v1' : 'v2';
77
+ const result = compilePlan(ir, { irVersion });
56
78
  if (!result.ok) return result;
79
+ const pinned = pins?.[planId];
80
+ if (pinned !== undefined && pinned !== result.plan.planVersion) {
81
+ return {
82
+ ok: false,
83
+ errors: [{
84
+ code: 'plan_version_mismatch',
85
+ planId,
86
+ expected: pinned,
87
+ actual: result.plan.planVersion,
88
+ }],
89
+ };
90
+ }
57
91
  const irHash = createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, 16);
58
92
  return {
59
93
  ok: true,
60
94
  planId,
61
95
  description: typeof ir?.description === 'string' ? ir.description : null,
96
+ specText: typeof ir?.specText === 'string' ? ir.specText : null,
62
97
  irHash,
98
+ planVersion: result.plan.planVersion,
63
99
  plan: result.plan,
64
100
  };
65
101
  }
66
102
 
67
- export function loadPlanLibrary({ plansDir = PLANS_DIR } = {}) {
103
+ export function loadPlanLibrary({ plansDir = PLANS_DIR, pins = PLAN_PINS, config = null } = {}) {
68
104
  const catalog = {};
69
105
  for (const planId of listPlanIds({ plansDir })) {
70
- catalog[planId] = loadPlan(planId, { plansDir });
106
+ catalog[planId] = loadPlan(planId, { plansDir, pins, config });
71
107
  }
72
108
  return Object.freeze(catalog);
73
109
  }
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "planId": "bugfix-loop",
3
3
  "description": "Reproduce a failing test, locate the cause via index, patch the smallest surface, re-run the test to green, then summarize the fix. Reference plan for the language-compiled runtime (V4 seed).",
4
+ "specText": "Given a reported failing test or command: (1) reproduce it and capture the output; (2) query the index on the error/symbol, open the top suspects, and trace backward to the source line; (3) apply the minimal source patch at the located cause; (4) re-run the failing test plus the affected suite slice until green; (5) complete with a root-cause + fix summary. No escalation path — a failure at any step surfaces as a failed node for the caller to handle.",
4
5
  "nodes": [
5
6
  {
6
7
  "id": "reproduce",
@@ -0,0 +1,138 @@
1
+ {
2
+ "planId": "flag-promotion",
3
+ "description": "Stage-promotion gate for decisionRuntime.vm-family flags: replay baseline → sampled shadow runs → quality-floor branch → promote one stage or rollback to off. Reference plan for the language-compiled runtime (V4 seed; mirrors promotion.js).",
4
+ "specText": "Promote a decision-runtime flag one stage (off → shadow → canary → default): (1) replay the recorded baseline to fix quality floor and wall-time/cost targets; (2) run N sampled shadow runs under the candidate stage; (3) branch on the sampled results — meets the pre-registered floor → update the flag stage and complete as promoted, misses → roll the flag back to off and complete as rolled-back. Rollback keeps deterministic owners authoritative; a failed gate never silently promotes.",
5
+ "nodes": [
6
+ {
7
+ "id": "baseline-replay",
8
+ "op": "RUN",
9
+ "deps": [],
10
+ "timeoutMs": 300000,
11
+ "retry": {
12
+ "sideEffectClass": "read_only",
13
+ "maxAttempts": 2,
14
+ "backoffMs": 500
15
+ },
16
+ "selector": {
17
+ "eventType": "operation.completed"
18
+ },
19
+ "spec": {
20
+ "cmd": "replay the recorded baseline run; fix the quality floor + wall-time/cost targets",
21
+ "sideEffects": [
22
+ "read_only"
23
+ ],
24
+ "writes": [],
25
+ "allowedPaths": []
26
+ }
27
+ },
28
+ {
29
+ "id": "shadow-run",
30
+ "op": "RUN",
31
+ "deps": [
32
+ "baseline-replay"
33
+ ],
34
+ "timeoutMs": 600000,
35
+ "retry": {
36
+ "sideEffectClass": "read_only",
37
+ "maxAttempts": 1,
38
+ "backoffMs": 0
39
+ },
40
+ "selector": {
41
+ "eventType": "operation.completed"
42
+ },
43
+ "spec": {
44
+ "cmd": "execute N sampled shadow runs under the candidate stage and record outcomes",
45
+ "sideEffects": [
46
+ "read_only"
47
+ ],
48
+ "writes": [],
49
+ "allowedPaths": []
50
+ }
51
+ },
52
+ {
53
+ "id": "gate",
54
+ "op": "BRANCH",
55
+ "deps": [
56
+ "shadow-run"
57
+ ],
58
+ "selector": {
59
+ "eventType": "branch.selected",
60
+ "field": "verdict"
61
+ },
62
+ "branches": {
63
+ "meets-floor": "promote-flag",
64
+ "misses-floor": "rollback-flag"
65
+ }
66
+ },
67
+ {
68
+ "id": "promote-flag",
69
+ "op": "RUN",
70
+ "deps": [
71
+ "gate"
72
+ ],
73
+ "timeoutMs": 120000,
74
+ "retry": {
75
+ "sideEffectClass": "idempotent_write",
76
+ "maxAttempts": 2,
77
+ "backoffMs": 500
78
+ },
79
+ "selector": {
80
+ "eventType": "operation.completed"
81
+ },
82
+ "spec": {
83
+ "cmd": "advance the flag to the next stage in config (off -> shadow -> canary -> default)",
84
+ "sideEffects": [
85
+ "idempotent_write"
86
+ ],
87
+ "writes": [
88
+ ".ukit/storage/config.json"
89
+ ],
90
+ "allowedPaths": [
91
+ ".ukit/storage"
92
+ ]
93
+ }
94
+ },
95
+ {
96
+ "id": "rollback-flag",
97
+ "op": "RUN",
98
+ "deps": [
99
+ "gate"
100
+ ],
101
+ "timeoutMs": 120000,
102
+ "retry": {
103
+ "sideEffectClass": "idempotent_write",
104
+ "maxAttempts": 2,
105
+ "backoffMs": 500
106
+ },
107
+ "selector": {
108
+ "eventType": "operation.completed"
109
+ },
110
+ "spec": {
111
+ "cmd": "roll the flag back to 'off' — deterministic lane owners stay authoritative",
112
+ "sideEffects": [
113
+ "idempotent_write"
114
+ ],
115
+ "writes": [
116
+ ".ukit/storage/config.json"
117
+ ],
118
+ "allowedPaths": [
119
+ ".ukit/storage"
120
+ ]
121
+ }
122
+ },
123
+ {
124
+ "id": "promoted",
125
+ "op": "COMPLETE",
126
+ "deps": [
127
+ "promote-flag"
128
+ ]
129
+ },
130
+ {
131
+ "id": "rolled-back",
132
+ "op": "COMPLETE",
133
+ "deps": [
134
+ "rollback-flag"
135
+ ]
136
+ }
137
+ ]
138
+ }
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "planId": "handoff-review-batch",
3
3
  "description": "Collect pending_review tasks, fan out per-panel-member reviews, aggregate verdicts, and escalate on CHANGES-REQUESTED/CRITICAL. Reference plan for the language-compiled runtime (V4 seed).",
4
+ "specText": "(1) List pending_review task files under docs/AI_HANDOFF/tasks; (2) wait for the panel's per-member verdict rows (event 'review.verdicts', boundary 'task-review'); (3) aggregate verdicts via review-panel-aggregate.mjs — the lead member's verdict takes precedence; (4) branch on the aggregated verdict: approved → complete; blocked (CHANGES-REQUESTED/CRITICAL) → escalate, the deterministic gate stays authoritative.",
4
5
  "nodes": [
5
6
  {
6
7
  "id": "collect-tasks",
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "planId": "release-check",
3
- "description": "Version parity gate before ship: bump \u2192 targeted tests + twin parity \u2192 commit/push/tag \u2192 npm publish \u2192 parity verify. Reference plan for the language-compiled runtime (V4 seed).",
3
+ "description": "Version parity gate before ship: bump → targeted tests + twin parity → commit/push/tag → npm publish → parity verify. Reference plan for the language-compiled runtime (V4 seed).",
4
+ "specText": "Version-parity ship gate: (1) bump package.json via 'npm version <v> --no-git-tag-version' and sync the baseline-reconciliation version; (2) run the targeted vitest slice plus templateParity and docs render checks; (3) commit → push → tag → GitHub release → npm publish; (4) poll 'npm view dist-tags.latest' until it equals the package.json version; (5) complete. Any parity failure leaves the plan failed — no silent drift.",
4
5
  "nodes": [
5
6
  {
6
7
  "id": "version-bump",
@@ -1,13 +1,29 @@
1
1
  // src/core/agentRuntime/promotion.js
2
- // Pure caller-wait promotion decision (SPEC §2 G6-FR01/02, §4).
3
- // selectWaitPolicy(profile, operation, resources) → frozen
4
- // { mode: 'foreground'|'handle', reason: string }
5
- // 'handle' is permitted only when the injected frozen DurationProfile records
6
- // the operation class as long-running (promoteAfterMs: number) AND
7
- // resources.pressure is not 'critical'. Every other input resolves
8
- // 'foreground' with a typed reason — never throws, never returns undefined.
9
- // Promotion changes caller waiting semantics only: this module performs no
10
- // I/O, reads no config, and never spawns/signals an OS process.
2
+ // Two pure promotion surfaces, no I/O, no config mutation:
3
+ //
4
+ // 1. Caller-wait promotion (SPEC §2 G6-FR01/02, §4).
5
+ // selectWaitPolicy(profile, operation, resources) → frozen
6
+ // { mode: 'foreground'|'handle', reason: string }
7
+ // 'handle' is permitted only when the injected frozen DurationProfile
8
+ // records the operation class as long-running (promoteAfterMs: number)
9
+ // AND resources.pressure is not 'critical'. Every other input resolves
10
+ // 'foreground' with a typed reason — never throws, never returns
11
+ // undefined. Promotion changes caller waiting semantics only.
12
+ //
13
+ // 2. decisionRuntime stage promotion (V-03 / G-V3).
14
+ // promote(config, evidence) → frozen { stage, promoted, reason }
15
+ // moves one family flag (default 'vm') through off → shadow → canary →
16
+ // default only when pre-registered non-inferiority criteria hold on
17
+ // enough sampled runs (PROMOTION_CRITERIA). 'off' never self-promotes —
18
+ // entering shadow is an operator act; evidence only advances an active
19
+ // rollout. Instant rollback: evidence.rollback === true or any sampled
20
+ // run carrying a forbidden failure resolves 'off', keeping deterministic
21
+ // owners authoritative (zero VM calls). One noisy run (qualityDelta
22
+ // below floor, wall/cost ratio above target, malformed row) blocks the
23
+ // step — it can never flip a default.
24
+
25
+ import { resolveDecisionRuntimeStage } from '../runtimeConfig.js';
26
+ import { COMPARABLE_RUNS, QUALITY_SCORE_FLOOR } from './evaluation.js';
11
27
 
12
28
  const PROFILE_VERSION = 1;
13
29
 
@@ -51,3 +67,125 @@ export function selectWaitPolicy(profile, operation, resources) {
51
67
 
52
68
  return policy('handle', `class:${cls}:promoteAfterMs:${promoteAfterMs}`);
53
69
  }
70
+
71
+ // --- V-03: decisionRuntime stage promotion (G-V3) ----------------------------
72
+
73
+ // Reserved rollout order — mirrors VALID_ROUTE_STAGES in runtimeConfig.js.
74
+ export const PROMOTION_STAGE_ORDER = Object.freeze(['off', 'shadow', 'canary', 'default']);
75
+
76
+ // Pre-registered non-inferiority criteria (G-V3): frozen before any rollout
77
+ // exists; per-key entries may tighten the shared default, never widen it.
78
+ // minSampledRuns — evidence must contain >= N sampled runs to advance
79
+ // qualityFloor — every sampled run's qualityDelta >= -qualityFloor
80
+ // maxWallP95Ratio — every sampled run's wall p95 ratio vs baseline <= this
81
+ // maxCostRatio — every sampled run's cost ratio vs baseline <= this
82
+ const DEFAULT_PROMOTION_CRITERIA = Object.freeze({
83
+ minSampledRuns: COMPARABLE_RUNS,
84
+ qualityFloor: QUALITY_SCORE_FLOOR,
85
+ maxWallP95Ratio: 1.0,
86
+ maxCostRatio: 1.0,
87
+ });
88
+
89
+ // Criteria registry. Pinned family flags (decisionRuntime.vm first) share the
90
+ // pre-registered default until a tightened entry lands — absent key always
91
+ // resolves the default, so an unknown flag can never promote on looser terms.
92
+ export const PROMOTION_CRITERIA = Object.freeze({
93
+ default: DEFAULT_PROMOTION_CRITERIA,
94
+ });
95
+
96
+ export function promotionCriteriaFor(key) {
97
+ const entry = PROMOTION_CRITERIA[key];
98
+ return isObj(entry) ? entry : DEFAULT_PROMOTION_CRITERIA;
99
+ }
100
+
101
+ function isNum(v) {
102
+ return typeof v === 'number' && Number.isFinite(v);
103
+ }
104
+
105
+ function runForbidden(run) {
106
+ return run.forbidden === true || (isNum(run.forbiddenFailures) && run.forbiddenFailures > 0);
107
+ }
108
+
109
+ // One sampled run vs the registered non-inferiority contract. A run that
110
+ // cannot produce a required metric never counts as passing — missing data
111
+ // is treated like a failing run, never guessed.
112
+ function runNonInferior(run, criteria) {
113
+ if (!isNum(run.qualityDelta) || run.qualityDelta < -criteria.qualityFloor) return 'quality:floor';
114
+ if (!isNum(run.wallP95Ratio) || run.wallP95Ratio > criteria.maxWallP95Ratio) return 'wall:target';
115
+ if (!isNum(run.costRatio) || run.costRatio > criteria.maxCostRatio) return 'cost:target';
116
+ return null;
117
+ }
118
+
119
+ function stageResult(stage, promoted, reason) {
120
+ return Object.freeze({ stage, promoted, reason });
121
+ }
122
+
123
+ /**
124
+ * Pure stage-promotion decision for one decisionRuntime family flag.
125
+ * @param {object} config runtime config (stage resolved via
126
+ * resolveDecisionRuntimeStage; malformed → 'off')
127
+ * @param {object} evidence { key?, rollback?, sampledRuns?, forbiddenFailures?,
128
+ * runs?: [{qualityDelta, wallP95Ratio, costRatio,
129
+ * forbidden?, forbiddenFailures?}],
130
+ * comparison?: GateComparison,
131
+ * qualityDelta?, wallP95Ratio?, costRatio? }
132
+ * @returns {{stage:string, promoted:boolean, reason:string}} frozen
133
+ */
134
+ export function promote(config, evidence = {}) {
135
+ const ev = isObj(evidence) ? evidence : {};
136
+ const key = typeof ev.key === 'string' && ev.key !== '' ? ev.key : 'vm';
137
+ const criteria = promotionCriteriaFor(key);
138
+ const stage = resolveDecisionRuntimeStage(config, key);
139
+ const runs = Array.isArray(ev.runs) ? ev.runs : null;
140
+ const forbiddenRuns = runs == null ? 0 : runs.filter((r) => isObj(r) && runForbidden(r)).length;
141
+ const forbiddenFailures = isNum(ev.forbiddenFailures) ? ev.forbiddenFailures : forbiddenRuns;
142
+
143
+ // Instant rollback: explicit request or any forbidden failure in the
144
+ // sampled evidence drops the flag to 'off' — deterministic owners become
145
+ // authoritative again with zero VM involvement.
146
+ if (ev.rollback === true) return stageResult('off', stage !== 'off', 'rollback:requested');
147
+ if (forbiddenFailures > 0) return stageResult('off', stage !== 'off', `rollback:forbidden:${forbiddenFailures}`);
148
+
149
+ // 'off' never self-promotes — entering shadow is an operator act;
150
+ // 'default' (or any unresolvable index) is terminal.
151
+ const index = PROMOTION_STAGE_ORDER.indexOf(stage);
152
+ if (index <= 0) return stageResult(stage, false, `stage:${stage}`);
153
+ if (index >= PROMOTION_STAGE_ORDER.length - 1) return stageResult(stage, false, 'stage:terminal');
154
+ const next = PROMOTION_STAGE_ORDER[index + 1];
155
+
156
+ const sampledRuns = isNum(ev.sampledRuns) ? ev.sampledRuns : (runs == null ? 0 : runs.length);
157
+ if (sampledRuns < criteria.minSampledRuns) {
158
+ return stageResult(stage, false, `evidence:runs:${sampledRuns}<${criteria.minSampledRuns}`);
159
+ }
160
+
161
+ // Optional aggregate gate verdict (evaluation.computeGateComparison):
162
+ // non-comparable or failed comparisons hold the stage, never flip it.
163
+ if (isObj(ev.comparison)) {
164
+ if (ev.comparison.comparable !== true) return stageResult(stage, false, 'comparison:incomparable');
165
+ if (ev.comparison.verdict !== 'pass') {
166
+ return stageResult(stage, false, `comparison:${ev.comparison.verdict ?? 'fail'}`);
167
+ }
168
+ }
169
+
170
+ if (runs != null) {
171
+ for (let i = 0; i < runs.length; i += 1) {
172
+ const run = runs[i];
173
+ if (!isObj(run)) return stageResult(stage, false, `run:${i}:malformed`);
174
+ const failing = runNonInferior(run, criteria);
175
+ if (failing != null) return stageResult(stage, false, `run:${i}:${failing}`);
176
+ }
177
+ } else {
178
+ if (!isNum(ev.qualityDelta) || ev.qualityDelta < -criteria.qualityFloor) {
179
+ return stageResult(stage, false, 'quality:floor');
180
+ }
181
+ if (!isNum(ev.wallP95Ratio) || ev.wallP95Ratio > criteria.maxWallP95Ratio) {
182
+ return stageResult(stage, false, 'wall:target');
183
+ }
184
+ if (!isNum(ev.costRatio) || ev.costRatio > criteria.maxCostRatio) {
185
+ return stageResult(stage, false, 'cost:target');
186
+ }
187
+ }
188
+
189
+ return stageResult(next, true, `promote:${stage}->${next}`);
190
+ }
191
+
@@ -28,6 +28,7 @@ import path from 'node:path';
28
28
  import crypto from 'node:crypto';
29
29
 
30
30
  import { readJournal } from './eventStore.js';
31
+ import { listHostAdapters } from './adapters.js';
31
32
  import { sanitizeForSupport } from '../observability/privacy/sanitizeForSupport.js';
32
33
  import { REDACTION_VERSION } from '../observability/privacy/allowlist.js';
33
34
 
@@ -235,3 +236,20 @@ export async function buildRuntimeSupportRecords(dir, opts = {}) {
235
236
  redaction_version: REDACTION_VERSION,
236
237
  };
237
238
  }
239
+
240
+ /**
241
+ * V-02 (AGENT_VM_RUNTIME_PLAN item V2): per-host capability map for the
242
+ * owned-runner lanes. Pure delegation to `listHostAdapters` — every lane
243
+ * reports `{supported, reason, completionApi, primitives}` honestly:
244
+ * 'supported' only with declared live host E2E, 'unsupported-probe'
245
+ * when primitives exist but live evidence is missing, 'unsupported'
246
+ * with a typed reason otherwise. No journal reads, no fs writes.
247
+ *
248
+ * @param {object} [opts] forwarded to probeHostAdapter (spawnImpl,
249
+ * killImpl, probeImpl, platform, liveHostE2E)
250
+ * @returns {{[host:string]: {supported:string, reason:string|null,
251
+ * completionApi:string|null, primitives:object}}}
252
+ */
253
+ export function buildHostCapabilityMap(opts = {}) {
254
+ return listHostAdapters(opts);
255
+ }
@@ -34,7 +34,12 @@ import {
34
34
  import { classifyObservation } from './liveness.js';
35
35
  import { createArtifactWriter } from './artifacts.js';
36
36
  import { reconcileOwnedOperations } from './recovery.js';
37
- import { beginToolSpan, endToolSpan } from './adapters.js';
37
+ import {
38
+ beginToolSpan,
39
+ endToolSpan,
40
+ probeHostAdapter,
41
+ detectHost,
42
+ } from './adapters.js';
38
43
  import {
39
44
  resolveTelemetry,
40
45
  newRunContext,
@@ -86,6 +91,9 @@ function specIsValid(spec) {
86
91
  const hasArgv = Array.isArray(spec.argv) && spec.argv.length > 0
87
92
  && spec.argv.every((a) => typeof a === 'string' && a !== '');
88
93
  if (!hasCommand && !hasArgv) return false;
94
+ // V-02: optional host lane selector — absent or non-empty string.
95
+ if (spec.host !== undefined
96
+ && (typeof spec.host !== 'string' || spec.host === '')) return false;
89
97
  return SIDE_EFFECT_CLASSES.includes(spec.sideEffectClass);
90
98
  }
91
99
 
@@ -105,7 +113,11 @@ function specIsValid(spec) {
105
113
  * @param {Function} [opts.fsyncImpl] artifact fsync
106
114
  * @param {Function} [opts.appendEventImpl] journal append (test injection)
107
115
  * @param {object} [opts.artifactDeps] artifact writer dep overrides
108
- * @returns {object} Supervisor — SPEC §4 surface.
116
+ * @param {string} [opts.host] V-02 host lane ('omp'|'claude-code'|'codex') —
117
+ * default for specs without `spec.host`; absent spec.host falls back to
118
+ * ambient `detectHost()`, then no lane. A lane probed `unsupported`
119
+ * refuses `start()` typed, `unsupported-probe` lanes still run (live
120
+ * host E2E is an evidence claim, not a launch gate — G-V2).
109
121
  */
110
122
  export function createSupervisor(opts = {}) {
111
123
  const runtimeDir = opts.runtimeDir;
@@ -223,6 +235,17 @@ export function createSupervisor(opts = {}) {
223
235
  const spawnImpl = opts.spawnImpl ?? defaultSpawnImpl;
224
236
  const killImpl = opts.killImpl ?? defaultKillImpl;
225
237
  const probeImpl = opts.probeImpl ?? defaultProbeImpl;
238
+
239
+ // V-02 host lane resolution. Precedence per launch: spec.host →
240
+ // opts.host → ambient detectHost() → no lane (legacy contract,
241
+ // byte-identical to pre-V2 behaviour). The probe shares this
242
+ // supervisor's injectable primitives so a test-supplied spawn is what
243
+ // gets evaluated, never the ambient ones.
244
+ const configuredHost = typeof opts.host === 'string' && opts.host !== '' ? opts.host : undefined;
245
+ const hostLaneFor = (spec) => {
246
+ const name = spec?.host ?? configuredHost ?? detectHost(opts.env);
247
+ return name ? probeHostAdapter(name, { spawnImpl, killImpl, probeImpl }) : null;
248
+ };
226
249
  const append = opts.appendEventImpl ?? appendEvent;
227
250
  const clock = opts.clock && typeof opts.clock.setTimeout === 'function'
228
251
  ? opts.clock
@@ -520,6 +543,23 @@ export function createSupervisor(opts = {}) {
520
543
  });
521
544
  return unsupported('invalid_spec');
522
545
  }
546
+ // V-02 host lane gate: only a probed `unsupported` lane refuses —
547
+ // typed, zero fs writes (same contract as launch_disabled).
548
+ // `unsupported-probe` lanes launch: the runner is primitive-level
549
+ // identical on every engine; live-E2E status is a capability-map
550
+ // claim, never a launch precondition.
551
+ const hostLane = hostLaneFor(spec);
552
+ if (hostLane && hostLane.supported === 'unsupported') {
553
+ endExecutionSpan(ctx, 'blocked', {
554
+ operation: spec.operationId,
555
+ reason_code: 'GATE_BLOCKED',
556
+ });
557
+ return {
558
+ unsupported: true,
559
+ code: hostLane.reason === 'unknown_host' ? 'host_unknown' : 'host_unsupported',
560
+ reason: hostLane.reason,
561
+ };
562
+ }
523
563
  // G6 promotion consult — shadow evaluates+records a receipt; served
524
564
  // wait semantics below are always the prior path.
525
565
  if (promotionStage() !== 'off') {
@@ -556,6 +596,7 @@ export function createSupervisor(opts = {}) {
556
596
  await transition(op, 'starting', {
557
597
  attempt: spec.attempt,
558
598
  sideEffectClass: spec.sideEffectClass,
599
+ ...(hostLane ? { host: hostLane.name } : {}),
559
600
  });
560
601
 
561
602
  let proc;
@@ -635,6 +676,7 @@ export function createSupervisor(opts = {}) {
635
676
  operationId: op.id,
636
677
  pid: proc.pid,
637
678
  state: op.state,
679
+ host: hostLane ? hostLane.name : null,
638
680
  get currentState() {
639
681
  return op.state;
640
682
  },