@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.
- package/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/src/cli/commands/memory.js +11 -87
- package/src/cli/commands/selfImprove.js +55 -0
- package/src/cli/commands/telemetry.js +45 -3
- package/src/cli/index.js +7 -0
- package/src/core/agentRuntime/adapters.js +177 -0
- package/src/core/agentRuntime/contract.js +77 -0
- package/src/core/agentRuntime/diagnostics.js +343 -42
- package/src/core/agentRuntime/eventStore.js +139 -0
- package/src/core/agentRuntime/planCompiler.js +45 -6
- package/src/core/agentRuntime/planLibrary.js +43 -7
- package/src/core/agentRuntime/plans/bugfix-loop.json +1 -0
- package/src/core/agentRuntime/plans/flag-promotion.json +138 -0
- package/src/core/agentRuntime/plans/handoff-review-batch.json +1 -0
- package/src/core/agentRuntime/plans/release-check.json +2 -1
- package/src/core/agentRuntime/promotion.js +147 -9
- package/src/core/agentRuntime/runtimeSupport.js +18 -0
- package/src/core/agentRuntime/supervisor.js +44 -2
- package/src/core/agentRuntime/telemetry.js +123 -0
- package/src/core/agentRuntime/vmEngine.js +51 -10
- package/src/core/memory/episodes.js +168 -0
- package/src/core/metadata.js +21 -0
- package/src/core/runInstallPipeline.js +6 -1
- package/src/core/runtimeConfig.js +71 -40
- package/src/decision/client.js +4 -5
- package/src/decision/runtimeDecide.js +183 -5
- package/src/learning/selfImprove.js +205 -0
- package/src/learning/tunedOverlay.js +112 -0
- package/template_project/.claude/hooks/session-episode.sh +35 -15
- package/template_project/.claude/ukit/index/route-task.mjs +13 -0
- package/template_project/.claude/ukit/index/unic-decision.mjs +1 -2
- package/template_project/.claude/ukit/runtime/self-improve-trigger.mjs +98 -0
- 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
|
|
7
|
-
*
|
|
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
|
-
*
|
|
173
|
-
*
|
|
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) =>
|
|
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 } }.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
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
|
-
* @
|
|
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
|
},
|