@ngockhoale/ukit 3.1.6 → 3.1.8

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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.1.8 - 2026-09-26
6
+
7
+ - **Agent VM IR v2**: `PARALLEL` (bounded fan-out, `join:'all'`) and `TIMEOUT` (deadline wrapper, `onTimeout: fail|escalate`, 30-minute ceiling) opcodes added to `planCompiler` + `vmEngine`; fault-injection coverage for crash-between-fan-out-and-join, non-idempotent child recovery, duplicate delivery. v2 plans hash under a `v2:`-prefixed serialization; v1 hashes unchanged.
8
+ - **Install seeds `.ukit/storage/observability/`** — the segment writer's safe `resolveRoot` rejects a missing parent, so the first emit on a fresh install previously failed with ENOENT. `ukit install` now creates the directory (advisory, never fails).
9
+
10
+ ## 3.1.7 - 2026-09-26
11
+
12
+ - **Observability + memory recording enabled by default on install.** Shipped `config.json` now sets `observability.stage: 'default'` (recorder + analytics + projector + evaluation — the full Data Foundation pipeline) and `learning.episodes.autoWrite: true` + `learning.candidates.stage: 'shadow'` (ukit-memory records evidence without auto-applying anything). Privacy gates (`sanitizeObserved`, `sanitizeForSupport` default-deny) are unchanged. Code defaults in `runtimeConfig.js` updated to match. New: `planLibrary.js` + 3 versioned reference plans (`bugfix-loop`, `handoff-review-batch`, `release-check`) as the V4 artifact seed for the Agent VM phase-2 plan.
13
+
5
14
  ## 3.1.6 - 2026-09-26
6
15
 
7
16
  - **Decision endpoint URL normalization**: a `UKIT_DECISION_BASE_URL` that already ends in `/v1` (OpenAI convention) no longer produces `…/v1/v1/chat/completions`; the client strips a trailing `/v1` before joining. Applied to `src/decision/client.js` and mirrored in `unic-decision.mjs`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.1.6",
3
+ "version": "3.1.8",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -296,6 +296,13 @@ export async function runInstall({ packageRoot, projectRoot, packageVersion, arg
296
296
  reason: error?.message ?? 'provision_failed',
297
297
  };
298
298
  }
299
+
300
+ // Observability storage root: the segment writer's resolveRoot refuses a
301
+ // missing parent, so seed <project>/.ukit/storage/observability/ at install
302
+ // (advisory — never fails install). The recorder creates segments inside.
303
+ try {
304
+ await fs.mkdir(path.join(projectRoot, '.ukit', 'storage', 'observability'), { recursive: true });
305
+ } catch { /* advisory — never fail install */ }
299
306
  const { create, update, unchanged, skip } = result.summary;
300
307
 
301
308
  console.log(`[UKit] Project: ${result.projectContext.project.name}`);
@@ -1,8 +1,9 @@
1
1
  /**
2
- * agentRuntime/planCompiler.js — decision-first-runtime G4 (DR-06), IR v1.
2
+ * agentRuntime/planCompiler.js — decision-first-runtime G4 (DR-06), IR v1+v2.
3
3
  *
4
4
  * Pure plan compiler: validates a small DAG IR (frozen opcode set: RUN,
5
- * WAIT_EVENT, BRANCH, COMPLETE, ESCALATE) and emits an immutable
5
+ * WAIT_EVENT, BRANCH, COMPLETE, ESCALATE — plus IR v2 wrappers PARALLEL
6
+ * and TIMEOUT) and emits an immutable
6
7
  * ValidatedPlan { planVersion, nodes, entryNodes, order }.
7
8
  *
8
9
  * No I/O, no runtimeConfig reads, no imports of vmEngine. The only
@@ -21,6 +22,8 @@ export const PLAN_OPS = Object.freeze([
21
22
  'BRANCH',
22
23
  'COMPLETE',
23
24
  'ESCALATE',
25
+ 'PARALLEL',
26
+ 'TIMEOUT',
24
27
  ]);
25
28
 
26
29
  const OP_SET = new Set(PLAN_OPS);
@@ -34,6 +37,10 @@ const DEFAULT_MAX_ATTEMPTS = 1;
34
37
  const MAX_ATTEMPTS_CEILING = 3;
35
38
  const SELECTOR_OPS = new Set(['eq', 'neq', 'in', 'exists']);
36
39
  const PATH_TRAVERSAL = /(^|\/)\.\.(\/|$)/;
40
+ /** Ceiling for TIMEOUT.deadlineMs — bounded deadlines only (IR v2). */
41
+ const MAX_DEADLINE_MS = 1_800_000;
42
+ const TIMEOUT_POLICIES = new Set(['fail', 'escalate']);
43
+ const WRAPPABLE_OPS = new Set(['RUN', 'WAIT_EVENT']);
37
44
 
38
45
  function err(errors, code, nodeId, detail) {
39
46
  errors.push({ code, nodeId, detail });
@@ -254,6 +261,54 @@ export function compilePlan(ir, opts = {}) {
254
261
  }
255
262
  }
256
263
 
264
+ let children;
265
+ if (op === 'PARALLEL') {
266
+ children = node.children;
267
+ if (!Array.isArray(children) || children.length === 0) {
268
+ err(errors, 'invalid_children', nodeId, 'PARALLEL requires a non-empty children array');
269
+ } else if (children.some((c) => !isNonEmptyString(c)) || new Set(children).size !== children.length) {
270
+ err(errors, 'invalid_children', nodeId, 'PARALLEL children must be unique node ids');
271
+ } else {
272
+ for (const child of children) {
273
+ if (!ids.has(child)) {
274
+ err(errors, 'missing_ref', nodeId, `child ${JSON.stringify(child)} not in plan`);
275
+ }
276
+ if (child === nodeId) {
277
+ err(errors, 'cycle', nodeId, 'PARALLEL self-reference');
278
+ }
279
+ }
280
+ }
281
+ const join = node.join === undefined ? 'all' : node.join;
282
+ if (join !== 'all') {
283
+ err(errors, 'invalid_children', nodeId, `join ${JSON.stringify(node.join)} unsupported (only 'all')`);
284
+ }
285
+ }
286
+
287
+ let wrappedId;
288
+ if (op === 'TIMEOUT') {
289
+ wrappedId = node.node;
290
+ if (!isNonEmptyString(wrappedId)) {
291
+ err(errors, 'missing_ref', nodeId, 'TIMEOUT requires node: <nodeId>');
292
+ } else {
293
+ if (!ids.has(wrappedId)) {
294
+ err(errors, 'missing_ref', nodeId, `timeout target ${JSON.stringify(wrappedId)} not in plan`);
295
+ }
296
+ if (wrappedId === nodeId) {
297
+ err(errors, 'cycle', nodeId, 'TIMEOUT self-reference');
298
+ }
299
+ }
300
+ const deadline = node.deadlineMs;
301
+ if (!Number.isInteger(deadline) || deadline <= 0 || deadline > MAX_DEADLINE_MS) {
302
+ err(errors, 'unbounded_timeout', nodeId,
303
+ `deadlineMs must be an integer in 1..${MAX_DEADLINE_MS} (MAX_DEADLINE_MS)`);
304
+ }
305
+ const onTimeout = node.onTimeout === undefined ? 'fail' : node.onTimeout;
306
+ if (!TIMEOUT_POLICIES.has(onTimeout)) {
307
+ err(errors, 'invalid_timeout', nodeId, `onTimeout ${JSON.stringify(node.onTimeout)} unsupported`);
308
+ }
309
+ }
310
+
311
+
257
312
  const retry = validateRetryPolicy(node.retry, nodeId, declaredEffects, errors);
258
313
 
259
314
  const compiledNode = {
@@ -264,6 +319,11 @@ export function compilePlan(ir, opts = {}) {
264
319
  ...(node.selector !== undefined ? { selector: deepFreeze({ ...node.selector }) } : {}),
265
320
  ...(node.timeoutMs !== undefined ? { timeoutMs: node.timeoutMs } : {}),
266
321
  ...(node.spec !== undefined ? { spec: deepFreeze({ ...node.spec }) } : {}),
322
+ ...(Array.isArray(children) ? { children: deepFreeze([...children]) } : {}),
323
+ ...(wrappedId !== undefined ? { node: wrappedId } : {}),
324
+ ...(node.deadlineMs !== undefined ? { deadlineMs: node.deadlineMs } : {}),
325
+ ...(op === 'TIMEOUT' ? { onTimeout: node.onTimeout === undefined ? 'fail' : node.onTimeout } : {}),
326
+ ...(op === 'PARALLEL' ? { join: node.join === undefined ? 'all' : node.join } : {}),
267
327
  ...(branches !== undefined ? { branches: deepFreeze({ ...branches }) } : {}),
268
328
  };
269
329
  compiled.set(nodeId, compiledNode);
@@ -281,6 +341,30 @@ export function compilePlan(ir, opts = {}) {
281
341
  }
282
342
  }
283
343
 
344
+ // IR v2 wrapper containment (checked after all nodes are compiled):
345
+ // every PARALLEL child / TIMEOUT wrapped node is a RUN/WAIT_EVENT that
346
+ // declares exactly the wrapper as its dep — it can never be an entry
347
+ // node, never activate outside its wrapper, and the deps edge is what
348
+ // keeps the wrapper before it in `order`.
349
+ for (const node of compiled.values()) {
350
+ let ids = null;
351
+ if (node.op === 'PARALLEL') ids = node.children;
352
+ if (node.op === 'TIMEOUT' && isNonEmptyString(node.node)) ids = [node.node];
353
+ if (!ids) continue;
354
+ for (const childId of ids) {
355
+ const child = compiled.get(childId);
356
+ if (!child) continue; // missing_ref already reported
357
+ if (!WRAPPABLE_OPS.has(child.op)) {
358
+ err(errors, 'invalid_children', node.id,
359
+ `${node.op} may only wrap RUN/WAIT_EVENT nodes; ${childId} is ${child.op}`);
360
+ }
361
+ if (!(child.deps.length === 1 && child.deps[0] === node.id)) {
362
+ err(errors, 'missing_ref', childId,
363
+ `wrapped node must declare exactly deps:['${node.id}']`);
364
+ }
365
+ }
366
+ }
367
+
284
368
  const order = [];
285
369
  const queue = rawNodes
286
370
  .map((n) => n?.id)
@@ -298,12 +382,18 @@ export function compilePlan(ir, opts = {}) {
298
382
  err(errors, 'cycle', undefined, 'dependency cycle detected');
299
383
  }
300
384
 
385
+ // v1 plans hash exactly as before; IR v2 usage switches the canonical
386
+ // input to a 'v2:'-prefixed serialization so v2 planVersions can never
387
+ // collide with (or silently change) v1 hashes.
388
+ const usesV2 = [...compiled.values()].some((n) => n.op === 'PARALLEL' || n.op === 'TIMEOUT');
389
+
301
390
  if (errors.length > 0) {
302
391
  return { ok: false, errors };
303
392
  }
304
393
 
394
+ const canonical = canonicalize(order.map((id) => compiled.get(id)));
305
395
  const planVersion = createHash('sha256')
306
- .update(canonicalize(order.map((id) => compiled.get(id))), 'utf8')
396
+ .update(usesV2 ? `v2:${canonical}` : canonical, 'utf8')
307
397
  .digest('hex')
308
398
  .slice(0, 16);
309
399
 
@@ -0,0 +1,73 @@
1
+ /**
2
+ * agentRuntime/planLibrary.js — V4 (phase-2 plan): versioned reference-plan
3
+ * artifacts.
4
+ *
5
+ * Loads the IR documents under `src/core/agentRuntime/plans/<planId>.json`,
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).
10
+ *
11
+ * Read-only: no writes, no host calls. Compile errors surface as
12
+ * { ok:false, errors } entries — they never throw so a bad draft plan cannot
13
+ * break the caller.
14
+ */
15
+
16
+ import { createHash } from 'node:crypto';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ import { compilePlan } from './planCompiler.js';
22
+
23
+ const PLANS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'plans');
24
+
25
+ export function listPlanIds({ plansDir = PLANS_DIR } = {}) {
26
+ let entries;
27
+ try {
28
+ entries = fs.readdirSync(plansDir, { withFileTypes: true });
29
+ } catch {
30
+ return [];
31
+ }
32
+ return entries
33
+ .filter((e) => e.isFile() && e.name.endsWith('.json'))
34
+ .map((e) => e.name.slice(0, -'.json'.length))
35
+ .sort();
36
+ }
37
+
38
+ export function loadPlan(planId, { plansDir = PLANS_DIR } = {}) {
39
+ if (typeof planId !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
40
+ return { ok: false, errors: [{ code: 'invalid_plan_id', planId }] };
41
+ }
42
+ const file = path.join(plansDir, `${planId}.json`);
43
+ let raw;
44
+ try {
45
+ raw = fs.readFileSync(file, 'utf8');
46
+ } catch {
47
+ return { ok: false, errors: [{ code: 'plan_not_found', planId }] };
48
+ }
49
+ let ir;
50
+ try {
51
+ ir = JSON.parse(raw);
52
+ } catch {
53
+ return { ok: false, errors: [{ code: 'plan_malformed', planId }] };
54
+ }
55
+ const result = compilePlan(ir);
56
+ if (!result.ok) return result;
57
+ const irHash = createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, 16);
58
+ return {
59
+ ok: true,
60
+ planId,
61
+ description: typeof ir?.description === 'string' ? ir.description : null,
62
+ irHash,
63
+ plan: result.plan,
64
+ };
65
+ }
66
+
67
+ export function loadPlanLibrary({ plansDir = PLANS_DIR } = {}) {
68
+ const catalog = {};
69
+ for (const planId of listPlanIds({ plansDir })) {
70
+ catalog[planId] = loadPlan(planId, { plansDir });
71
+ }
72
+ return Object.freeze(catalog);
73
+ }
@@ -0,0 +1,107 @@
1
+ {
2
+ "planId": "bugfix-loop",
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
+ "nodes": [
5
+ {
6
+ "id": "reproduce",
7
+ "op": "RUN",
8
+ "deps": [],
9
+ "timeoutMs": 300000,
10
+ "retry": {
11
+ "sideEffectClass": "read_only",
12
+ "maxAttempts": 1,
13
+ "backoffMs": 0
14
+ },
15
+ "selector": {
16
+ "eventType": "operation.completed"
17
+ },
18
+ "spec": {
19
+ "cmd": "run the reported failing test/command and capture output",
20
+ "sideEffects": [
21
+ "read_only"
22
+ ],
23
+ "writes": [],
24
+ "allowedPaths": []
25
+ }
26
+ },
27
+ {
28
+ "id": "locate-cause",
29
+ "op": "RUN",
30
+ "deps": [
31
+ "reproduce"
32
+ ],
33
+ "timeoutMs": 300000,
34
+ "retry": {
35
+ "sideEffectClass": "read_only",
36
+ "maxAttempts": 2,
37
+ "backoffMs": 500
38
+ },
39
+ "selector": {
40
+ "eventType": "operation.completed"
41
+ },
42
+ "spec": {
43
+ "cmd": "query-index on error/symbol, open top suspects, trace backward to the source line",
44
+ "sideEffects": [
45
+ "read_only"
46
+ ],
47
+ "writes": [],
48
+ "allowedPaths": []
49
+ }
50
+ },
51
+ {
52
+ "id": "patch",
53
+ "op": "RUN",
54
+ "deps": [
55
+ "locate-cause"
56
+ ],
57
+ "timeoutMs": 300000,
58
+ "retry": {
59
+ "sideEffectClass": "write",
60
+ "maxAttempts": 1,
61
+ "backoffMs": 0
62
+ },
63
+ "selector": {
64
+ "eventType": "operation.completed"
65
+ },
66
+ "spec": {
67
+ "cmd": "apply the minimal source fix at the located cause",
68
+ "sideEffects": [
69
+ "write"
70
+ ],
71
+ "writes": [],
72
+ "allowedPaths": []
73
+ }
74
+ },
75
+ {
76
+ "id": "verify",
77
+ "op": "RUN",
78
+ "deps": [
79
+ "patch"
80
+ ],
81
+ "timeoutMs": 600000,
82
+ "retry": {
83
+ "sideEffectClass": "read_only",
84
+ "maxAttempts": 1,
85
+ "backoffMs": 0
86
+ },
87
+ "selector": {
88
+ "eventType": "operation.completed"
89
+ },
90
+ "spec": {
91
+ "cmd": "re-run the failing test plus the affected suite slice; require green",
92
+ "sideEffects": [
93
+ "read_only"
94
+ ],
95
+ "writes": [],
96
+ "allowedPaths": []
97
+ }
98
+ },
99
+ {
100
+ "id": "done",
101
+ "op": "COMPLETE",
102
+ "deps": [
103
+ "verify"
104
+ ]
105
+ }
106
+ ]
107
+ }
@@ -0,0 +1,116 @@
1
+ {
2
+ "planId": "handoff-review-batch",
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
+ "nodes": [
5
+ {
6
+ "id": "collect-tasks",
7
+ "op": "RUN",
8
+ "deps": [],
9
+ "timeoutMs": 60000,
10
+ "retry": {
11
+ "sideEffectClass": "read_only",
12
+ "maxAttempts": 2,
13
+ "backoffMs": 500
14
+ },
15
+ "selector": {
16
+ "eventType": "operation.completed"
17
+ },
18
+ "spec": {
19
+ "cmd": "list pending_review task files from docs/AI_HANDOFF/tasks",
20
+ "sideEffects": [
21
+ "read_only"
22
+ ],
23
+ "writes": [],
24
+ "allowedPaths": [
25
+ "docs/AI_HANDOFF/tasks"
26
+ ]
27
+ }
28
+ },
29
+ {
30
+ "id": "wait-reviews",
31
+ "op": "WAIT_EVENT",
32
+ "deps": [
33
+ "collect-tasks"
34
+ ],
35
+ "timeoutMs": 3600000,
36
+ "retry": {
37
+ "sideEffectClass": "read_only",
38
+ "maxAttempts": 1,
39
+ "backoffMs": 0
40
+ },
41
+ "selector": {
42
+ "eventType": "review.verdicts",
43
+ "boundary": "task-review"
44
+ },
45
+ "spec": {
46
+ "cmd": "wait for panel verdict rows (per member, per task)",
47
+ "sideEffects": [
48
+ "read_only"
49
+ ],
50
+ "writes": [],
51
+ "allowedPaths": []
52
+ }
53
+ },
54
+ {
55
+ "id": "aggregate",
56
+ "op": "RUN",
57
+ "deps": [
58
+ "wait-reviews"
59
+ ],
60
+ "timeoutMs": 120000,
61
+ "retry": {
62
+ "sideEffectClass": "read_only",
63
+ "maxAttempts": 1,
64
+ "backoffMs": 0
65
+ },
66
+ "selector": {
67
+ "eventType": "operation.completed"
68
+ },
69
+ "spec": {
70
+ "cmd": "node .claude/ukit/index/review-panel-aggregate.mjs <tasks> \u2014 lead member takes precedence",
71
+ "sideEffects": [
72
+ "read_only",
73
+ "idempotent_write"
74
+ ],
75
+ "writes": [
76
+ "docs/AI_HANDOFF/tasks"
77
+ ],
78
+ "allowedPaths": [
79
+ "docs/AI_HANDOFF"
80
+ ]
81
+ }
82
+ },
83
+ {
84
+ "id": "gate",
85
+ "op": "BRANCH",
86
+ "deps": [
87
+ "aggregate"
88
+ ],
89
+ "selector": {
90
+ "eventType": "branch.selected",
91
+ "field": "verdict"
92
+ },
93
+ "branches": {
94
+ "approved": "done",
95
+ "blocked": "escalate"
96
+ }
97
+ },
98
+ {
99
+ "id": "done",
100
+ "op": "COMPLETE",
101
+ "deps": [
102
+ "gate"
103
+ ]
104
+ },
105
+ {
106
+ "id": "escalate",
107
+ "op": "ESCALATE",
108
+ "deps": [
109
+ "gate"
110
+ ],
111
+ "spec": {
112
+ "reason": "changes-requested-or-critical \u2014 deterministic gate stays authoritative"
113
+ }
114
+ }
115
+ ]
116
+ }
@@ -0,0 +1,113 @@
1
+ {
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).",
4
+ "nodes": [
5
+ {
6
+ "id": "version-bump",
7
+ "op": "RUN",
8
+ "deps": [],
9
+ "timeoutMs": 60000,
10
+ "retry": {
11
+ "sideEffectClass": "idempotent_write",
12
+ "maxAttempts": 2,
13
+ "backoffMs": 500
14
+ },
15
+ "selector": {
16
+ "eventType": "operation.completed"
17
+ },
18
+ "spec": {
19
+ "cmd": "npm version <v> --no-git-tag-version; sync baseline-reconciliation version",
20
+ "sideEffects": [
21
+ "idempotent_write"
22
+ ],
23
+ "writes": [
24
+ "package.json",
25
+ "docs/AI_HANDOFF/inventory/baseline-reconciliation.md"
26
+ ],
27
+ "allowedPaths": [
28
+ "package.json",
29
+ "docs/AI_HANDOFF"
30
+ ]
31
+ }
32
+ },
33
+ {
34
+ "id": "verify-tests",
35
+ "op": "RUN",
36
+ "deps": [
37
+ "version-bump"
38
+ ],
39
+ "timeoutMs": 600000,
40
+ "retry": {
41
+ "sideEffectClass": "read_only",
42
+ "maxAttempts": 1,
43
+ "backoffMs": 0
44
+ },
45
+ "selector": {
46
+ "eventType": "operation.completed"
47
+ },
48
+ "spec": {
49
+ "cmd": "targeted vitest slice + templateParity + docs render check",
50
+ "sideEffects": [
51
+ "read_only"
52
+ ],
53
+ "writes": [],
54
+ "allowedPaths": []
55
+ }
56
+ },
57
+ {
58
+ "id": "ship",
59
+ "op": "RUN",
60
+ "deps": [
61
+ "verify-tests"
62
+ ],
63
+ "timeoutMs": 300000,
64
+ "retry": {
65
+ "sideEffectClass": "network",
66
+ "maxAttempts": 1,
67
+ "backoffMs": 0
68
+ },
69
+ "selector": {
70
+ "eventType": "operation.completed"
71
+ },
72
+ "spec": {
73
+ "cmd": "commit \u2192 push \u2192 tag \u2192 GitHub release \u2192 npm publish",
74
+ "sideEffects": [
75
+ "network"
76
+ ],
77
+ "writes": [],
78
+ "allowedPaths": []
79
+ }
80
+ },
81
+ {
82
+ "id": "parity",
83
+ "op": "RUN",
84
+ "deps": [
85
+ "ship"
86
+ ],
87
+ "timeoutMs": 180000,
88
+ "retry": {
89
+ "sideEffectClass": "network",
90
+ "maxAttempts": 1,
91
+ "backoffMs": 0
92
+ },
93
+ "selector": {
94
+ "eventType": "operation.completed"
95
+ },
96
+ "spec": {
97
+ "cmd": "npm view dist-tags.latest must equal package.json version (poll until published)",
98
+ "sideEffects": [
99
+ "network"
100
+ ],
101
+ "writes": [],
102
+ "allowedPaths": []
103
+ }
104
+ },
105
+ {
106
+ "id": "done",
107
+ "op": "COMPLETE",
108
+ "deps": [
109
+ "parity"
110
+ ]
111
+ }
112
+ ]
113
+ }
@@ -4,7 +4,11 @@
4
4
  * flag; this module reads no config itself).
5
5
  *
6
6
  * Executes a compiled ValidatedPlan (SPEC §4 frozen shape — literal nodes
7
- * Map, entryNodes, topological order) purely against G1 durable primitives:
7
+ * Map, entryNodes, topological order) purely against G1 durable
8
+ * primitives. IR v2 adds wrapper opcodes: PARALLEL fans out to RUN/
9
+ * WAIT_EVENT children through the same activation path and folds when
10
+ * every child is terminal; TIMEOUT wraps one node and resolves on child
11
+ * terminal state or an elapsed deadline swept by deliver()/resume():
8
12
  *
9
13
  * - cursor + continuations are registered BEFORE the first node activation
10
14
  * (register-before-start is structural; no "start without register" path)
@@ -206,6 +210,15 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
206
210
  inst.nodes[id].state = 'cancelled';
207
211
  }
208
212
  }
213
+ // a wrapped child needing recovery is an ambiguous window for the
214
+ // wrapper too — propagate so the wrapper never joins over a child
215
+ // whose side effects cannot be proven
216
+ const wrapperId = nodeId != null ? wrapperOf(inst, nodeId) : null;
217
+ if (wrapperId != null
218
+ && inst.nodes[wrapperId].state !== 'recovery_required'
219
+ && !isTerminal(inst.nodes[wrapperId].state)) {
220
+ await moveNode(inst, wrapperId, 'recovery_required', { reason });
221
+ }
209
222
  }
210
223
 
211
224
  async function cancelDependents(inst, nodeId) {
@@ -264,6 +277,27 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
264
277
  inst.forcedStatus = 'recovery_required';
265
278
  break;
266
279
  }
280
+ case 'PARALLEL': {
281
+ setNodeState(inst, nodeId, 'running');
282
+ await appendTransition(inst, nodeId, 'running');
283
+ // bounded fan-out: children are contained RUN/WAIT_EVENT nodes
284
+ // (deps === [this node] per compilePlan) — each takes the normal
285
+ // register-before-start activation path, deterministically in
286
+ // declared order, so a crash mid fan-out replays child-by-child
287
+ for (const childId of node.children ?? []) {
288
+ await activate(inst, childId, { suppressStart });
289
+ }
290
+ await resolveParallel(inst, nodeId);
291
+ break;
292
+ }
293
+ case 'TIMEOUT': {
294
+ setNodeState(inst, nodeId, 'running');
295
+ inst.nodes[nodeId].startedAt = clock().getTime();
296
+ await appendTransition(inst, nodeId, 'running');
297
+ await activate(inst, node.node, { suppressStart });
298
+ await resolveTimeout(inst, nodeId);
299
+ break;
300
+ }
267
301
  default:
268
302
  throw new VmEngineError('unknown_opcode', `${nodeId}: ${node.op}`);
269
303
  }
@@ -277,6 +311,142 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
277
311
  }
278
312
  }
279
313
 
314
+ /** The IR v2 wrapper (PARALLEL parent or TIMEOUT owner) containing nodeId. */
315
+ function wrapperOf(inst, nodeId) {
316
+ for (const [id, node] of inst.nodeMap) {
317
+ if (node.op === 'PARALLEL' && node.children?.includes(nodeId)) return id;
318
+ if (node.op === 'TIMEOUT' && node.node === nodeId) return id;
319
+ }
320
+ return null;
321
+ }
322
+
323
+ /**
324
+ * Fold a PARALLEL wrapper once every child is terminal (join: 'all').
325
+ * All-completed -> completed (and dependents activate); any failure wins
326
+ * over cancellation. Deterministic: the first failure/cancellation in
327
+ * declared child order decides the propagated reason.
328
+ */
329
+ async function resolveParallel(inst, nodeId, { suppressStart = false } = {}) {
330
+ const node = inst.nodeMap.get(nodeId);
331
+ if (!node || node.op !== 'PARALLEL') return null;
332
+ if (inst.nodes[nodeId].state !== 'running') return null;
333
+ const children = node.children ?? [];
334
+ if (!children.every((c) => isTerminal(inst.nodes[c]?.state))) return null;
335
+
336
+ const failed = children.find((c) => inst.nodes[c].state === 'failed');
337
+ const cancelled = children.find((c) => inst.nodes[c].state === 'cancelled');
338
+ if (failed) {
339
+ await moveNode(inst, nodeId, 'failed', { reason: `child_failed:${failed}` });
340
+ inst.recoveryReason = inst.recoveryReason ?? `parallel_child_failed:${failed}`;
341
+ return { node: nodeId, to: 'failed' };
342
+ }
343
+ if (cancelled) {
344
+ // mirror the RUN cancel path: running -> cancel_pending -> cancelled
345
+ setNodeState(inst, nodeId, 'cancel_pending');
346
+ await appendTransition(inst, nodeId, 'cancel_pending');
347
+ await moveNode(inst, nodeId, 'cancelled', { reason: `child_cancelled:${cancelled}` });
348
+ await cancelDependents(inst, nodeId);
349
+ return { node: nodeId, to: 'cancelled' };
350
+ }
351
+ await moveNode(inst, nodeId, 'completed');
352
+ await activateDependents(inst, nodeId, { suppressStart });
353
+ return { node: nodeId, to: 'completed' };
354
+ }
355
+
356
+ /**
357
+ * Resolve a TIMEOUT wrapper when its wrapped node is terminal — the
358
+ * wrapper mirrors the child's terminal state, never inventing an outcome.
359
+ */
360
+ async function resolveTimeout(inst, nodeId, { suppressStart = false } = {}) {
361
+ const node = inst.nodeMap.get(nodeId);
362
+ if (!node || node.op !== 'TIMEOUT') return null;
363
+ const wstate = inst.nodes[nodeId].state;
364
+ if (wstate !== 'running') return null;
365
+ const childId = node.node;
366
+ const cstate = inst.nodes[childId]?.state;
367
+ if (!isTerminal(cstate)) return null;
368
+
369
+ if (cstate === 'completed') {
370
+ await moveNode(inst, nodeId, 'completed');
371
+ await activateDependents(inst, nodeId, { suppressStart });
372
+ return { node: nodeId, to: 'completed' };
373
+ }
374
+ if (cstate === 'cancelled') {
375
+ setNodeState(inst, nodeId, 'cancel_pending');
376
+ await appendTransition(inst, nodeId, 'cancel_pending');
377
+ await moveNode(inst, nodeId, 'cancelled', { reason: `child_cancelled:${childId}` });
378
+ await cancelDependents(inst, nodeId);
379
+ return { node: nodeId, to: 'cancelled' };
380
+ }
381
+ await moveNode(inst, nodeId, 'failed', { reason: `child_failed:${childId}` });
382
+ inst.recoveryReason = inst.recoveryReason ?? `timeout_child_failed:${childId}`;
383
+ return { node: nodeId, to: 'failed' };
384
+ }
385
+
386
+ /**
387
+ * Fire a TIMEOUT whose deadline elapsed while the wrapped node is still
388
+ * non-terminal. 'fail' marks the wrapped node failed with
389
+ * 'deadline_exceeded' (a non-terminal, non-running wrapped node fails
390
+ * closed to recovery_required — the activation window is ambiguous);
391
+ * 'escalate' routes to ESCALATE semantics (wrapper completes escalated,
392
+ * plan forced recovery_required, wrapped node parked recovery_required).
393
+ */
394
+ async function fireTimeout(inst, nodeId) {
395
+ const node = inst.nodeMap.get(nodeId);
396
+ const childId = node.node;
397
+ const cstate = inst.nodes[childId]?.state;
398
+
399
+ if (node.onTimeout === 'escalate') {
400
+ if (!isTerminal(cstate)) {
401
+ await moveNode(inst, childId, 'recovery_required', { reason: 'deadline_exceeded' });
402
+ }
403
+ await moveNode(inst, nodeId, 'completed', { escalated: true, reason: 'deadline_exceeded' });
404
+ inst.recoveryReason = 'escalated';
405
+ inst.forcedStatus = 'recovery_required';
406
+ return { node: nodeId, to: 'completed', escalated: true };
407
+ }
408
+ if (cstate === 'running') {
409
+ await moveNode(inst, childId, 'failed', { reason: 'deadline_exceeded' });
410
+ } else if (!isTerminal(cstate)) {
411
+ await moveNode(inst, childId, 'recovery_required', { reason: 'deadline_exceeded' });
412
+ }
413
+ await moveNode(inst, nodeId, 'failed', { reason: 'deadline_exceeded' });
414
+ inst.recoveryReason = inst.recoveryReason ?? `deadline_exceeded:${childId}`;
415
+ return { node: nodeId, to: 'failed' };
416
+ }
417
+
418
+ /**
419
+ * Every TIMEOUT whose deadline elapsed while it is still running fires
420
+ * now — deterministic against the engine clock, journal-first like any
421
+ * other transition. Called from deliver() (before the delivered event is
422
+ * evaluated, so a deadline wins a same-instant race) and resume().
423
+ */
424
+ async function sweepTimeouts(inst, { suppressStart = false } = {}) {
425
+ const nowMs = clock().getTime();
426
+ const results = [];
427
+ for (const [id, node] of inst.nodeMap) {
428
+ if (node.op !== 'TIMEOUT') continue;
429
+ const rec = inst.nodes[id];
430
+ if (rec.state !== 'running' || rec.startedAt == null) continue;
431
+ if (nowMs - rec.startedAt < node.deadlineMs) continue;
432
+ const childState = inst.nodes[node.node]?.state;
433
+ results.push(isTerminal(childState)
434
+ ? await resolveTimeout(inst, id, { suppressStart })
435
+ : await fireTimeout(inst, id));
436
+ }
437
+ return results.filter(Boolean);
438
+ }
439
+
440
+ /** A wrapped child resolved — fold its wrapper (no-op when none exists). */
441
+ async function onChildResolved(inst, nodeId, { suppressStart = false } = {}) {
442
+ const wrapperId = wrapperOf(inst, nodeId);
443
+ if (wrapperId == null) return null;
444
+ const wrapper = inst.nodeMap.get(wrapperId);
445
+ return wrapper.op === 'TIMEOUT'
446
+ ? resolveTimeout(inst, wrapperId, { suppressStart })
447
+ : resolveParallel(inst, wrapperId, { suppressStart });
448
+ }
449
+
280
450
  function findWaiting(inst, event) {
281
451
  const nodeId = event.operationId.slice(inst.planInstanceId.length + 1);
282
452
  const node = inst.nodeMap.get(nodeId);
@@ -312,6 +482,14 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
312
482
  return { node: nodeId, to: 'completed', branch: value };
313
483
  }
314
484
 
485
+ // PARALLEL/TIMEOUT consume no external events — a classified route to a
486
+ // wrapper fails closed instead of silently completing it (wrappers only
487
+ // resolve from their contained nodes' states)
488
+ if (node.op === 'PARALLEL' || node.op === 'TIMEOUT') {
489
+ await markRecoveryRequired(inst, nodeId, `unroutable_event:${node.op}`);
490
+ return { node: nodeId, to: 'recovery_required' };
491
+ }
492
+
315
493
  // RUN / WAIT_EVENT
316
494
  const to = NODE_OUTCOMES[outcome];
317
495
  if (to == null) {
@@ -325,11 +503,13 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
325
503
  await appendTransition(inst, nodeId, 'cancel_pending');
326
504
  await moveNode(inst, nodeId, 'cancelled');
327
505
  await cancelDependents(inst, nodeId);
506
+ await onChildResolved(inst, nodeId, { suppressStart });
328
507
  return { node: nodeId, to: 'cancelled' };
329
508
  }
330
509
  if (to === 'completed') {
331
510
  await moveNode(inst, nodeId, 'completed');
332
511
  await activateDependents(inst, nodeId, { suppressStart });
512
+ await onChildResolved(inst, nodeId, { suppressStart });
333
513
  } else {
334
514
  // 'failed': bounded auto-retry only for AUTO_RETRYABLE classes per
335
515
  // validateRetry — the retry edge is running->retry_pending->running,
@@ -348,6 +528,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
348
528
  }
349
529
  await moveNode(inst, nodeId, 'failed', { reason: retry.code });
350
530
  inst.recoveryReason = inst.recoveryReason ?? `node_failed:${nodeId}:${retry.code}`;
531
+ await onChildResolved(inst, nodeId, { suppressStart });
351
532
  }
352
533
  return { node: nodeId, to };
353
534
  }
@@ -390,7 +571,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
390
571
  forcedStatus: null,
391
572
  };
392
573
  for (const [id] of nodes) {
393
- inst.nodes[id] = { state: 'queued', attempt: 0, lastEventSeq: 0 };
574
+ inst.nodes[id] = { state: 'queued', attempt: 0, lastEventSeq: 0, startedAt: null };
394
575
  inst.cursors[id] = { lastSeq: 0, seenEventIds: new Set() };
395
576
  inst.journalSeq[id] = 0;
396
577
  }
@@ -459,6 +640,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
459
640
  state: seed.state ?? 'queued',
460
641
  attempt: seed.attempt ?? 0,
461
642
  lastEventSeq: seed.lastEventSeq ?? 0,
643
+ startedAt: null, // journal-derived below; never trusted from the cache
462
644
  };
463
645
  inst.cursors[id] = { lastSeq: 0, seenEventIds: new Set() };
464
646
  inst.journalSeq[id] = 0;
@@ -471,6 +653,12 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
471
653
  for (const [id] of nodeMap) {
472
654
  for await (const ev of readJournal(dir, opId(planInstanceId, id))) {
473
655
  inst.journalSeq[id] = ev.seq;
656
+ // a TIMEOUT deadline is anchored to the FIRST activation record's
657
+ // journal timestamp — replay must land on the same deadline
658
+ if (ev.eventType === 'operation.transition' && ev.safePayload?.to === 'starting'
659
+ && inst.nodes[id].startedAt == null) {
660
+ inst.nodes[id].startedAt = Date.parse(ev.observedAt) || null;
661
+ }
474
662
  }
475
663
  }
476
664
  // continuation registration survived the crash on disk — recover the ids
@@ -505,6 +693,11 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
505
693
  const pi = sep > 0 ? event.operationId.slice(0, sep) : null;
506
694
  const inst = pi ? await ensureInst(pi) : null;
507
695
  if (!inst) return { consumed: false, code: 'unroutable' };
696
+
697
+ // fire elapsed TIMEOUT deadlines before the delivered event is
698
+ // evaluated: a deadline wins the same-instant race (deterministic),
699
+ // and journaled wrapper folds replay identically on resume
700
+ const swept = await sweepTimeouts(inst);
508
701
  const { nodeId, node, matched } = findWaiting(inst, event);
509
702
  const cursor = inst.cursors[nodeId] ?? { lastSeq: 0, seenEventIds: new Set() };
510
703
 
@@ -559,7 +752,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
559
752
  return { consumed: false, code: 'duplicate' };
560
753
  }
561
754
 
562
- const transitions = [await applyOutcome(inst, nodeId, node, outcome, event)];
755
+ const transitions = [...swept, await applyOutcome(inst, nodeId, node, outcome, event)];
563
756
  await persist(inst);
564
757
  return { consumed: true, transitions };
565
758
  }
@@ -584,6 +777,11 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
584
777
  cursor.seenEventIds.add(ev.eventId);
585
778
  if (ev.eventType === 'operation.transition' && ev.safePayload?.to) {
586
779
  derived = ev.safePayload.to;
780
+ // deadline anchor for TIMEOUT wrappers: the journaled activation
781
+ // timestamp, identical across replay (never the resume clock)
782
+ if (ev.safePayload.to === 'starting' && inst.nodes[nodeId].startedAt == null) {
783
+ inst.nodes[nodeId].startedAt = Date.parse(ev.observedAt) || null;
784
+ }
587
785
  } else {
588
786
  delivered.push(ev);
589
787
  }
@@ -614,6 +812,44 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
614
812
  await activate(inst, nodeId, { suppressStart: true });
615
813
  }
616
814
  }
815
+
816
+ // IR v2 wrapper post-pass: wrappers replay no journal of their own
817
+ // outcome — they join/resolve deterministically from child states.
818
+ // A crash between fan-out and join is folded here: completed children
819
+ // are never re-activated (their journals are terminal), queued children
820
+ // of an already-started wrapper re-arm via the suppressed activation
821
+ // path, and a non-idempotent child still mid-flight already resolved
822
+ // to recovery_required in the main loop above (propagated to the
823
+ // wrapper by markRecoveryRequired).
824
+ for (const [nodeId, node] of inst.nodeMap) {
825
+ const wstate = inst.nodes[nodeId].state;
826
+ if (node.op === 'PARALLEL' && (wstate === 'starting' || wstate === 'running')) {
827
+ if (wstate === 'starting') await moveNode(inst, nodeId, 'running');
828
+ // a crash between the child's recovery record and the wrapper
829
+ // propagation leaves the join hanging — propagate on replay
830
+ if ((node.children ?? []).some((c) => inst.nodes[c]?.state === 'recovery_required')) {
831
+ await moveNode(inst, nodeId, 'recovery_required', { reason: 'child_recovery' });
832
+ continue;
833
+ }
834
+ for (const childId of node.children ?? []) {
835
+ if (inst.nodes[childId].state === 'queued') {
836
+ await activate(inst, childId, { suppressStart: true });
837
+ }
838
+ }
839
+ const folded = await resolveParallel(inst, nodeId);
840
+ if (folded) report.recovered.push(nodeId);
841
+ }
842
+ if (node.op === 'TIMEOUT' && (wstate === 'starting' || wstate === 'running')) {
843
+ if (wstate === 'starting') await moveNode(inst, nodeId, 'running');
844
+ if (inst.nodes[node.node]?.state === 'queued') {
845
+ await activate(inst, node.node, { suppressStart: true });
846
+ }
847
+ const resolved = isTerminal(inst.nodes[node.node]?.state)
848
+ ? await resolveTimeout(inst, nodeId)
849
+ : (await sweepTimeouts(inst)).length > 0;
850
+ if (resolved) report.recovered.push(nodeId);
851
+ }
852
+ }
617
853
  await persist(inst);
618
854
  report.status = inst.forcedStatus ?? planStatus(inst);
619
855
  return report;
@@ -431,14 +431,14 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
431
431
  learning: {
432
432
  feedback: { enabled: true, maxEvents: 200 },
433
433
  proposals: { minCount: 3, minSessions: 2 },
434
- episodes: { autoWrite: false },
434
+ episodes: { autoWrite: true },
435
435
  tuning: { enabled: true, applyMode: 'manual' },
436
436
  // C52 M04.2 stage keys (SPEC §5 FR-016–FR-018). candidates: repeated
437
437
  // corrections/suppressions/escalations promote to a LearningCandidate
438
438
  // only after minOccurrences + cross-session evidence; report-only at
439
439
  // 'shadow'. overlays: delta overlays on policy fields; base-version
440
440
  // mismatch → conflict, never silent apply. Promotion stays manual.
441
- candidates: { stage: 'off', minOccurrences: 3 },
441
+ candidates: { stage: 'shadow', minOccurrences: 3 },
442
442
  overlays: { stage: 'off' },
443
443
  },
444
444
  // C52 M04.1 compact resumable state (SPEC §5 FR-012–FR-015). Stage 'off'
@@ -342,14 +342,14 @@
342
342
  "minSessions": 2
343
343
  },
344
344
  "episodes": {
345
- "autoWrite": false
345
+ "autoWrite": true
346
346
  },
347
347
  "tuning": {
348
348
  "enabled": true,
349
349
  "applyMode": "manual"
350
350
  },
351
351
  "candidates": {
352
- "stage": "off",
352
+ "stage": "shadow",
353
353
  "minOccurrences": 3
354
354
  },
355
355
  "overlays": {
@@ -772,5 +772,8 @@
772
772
  "specRequired": "true = handoff-create b\u1eaft bu\u1ed9c vi\u1ebft docs/AI_HANDOFF/SPEC.md chi ti\u1ebft tr\u01b0\u1edbc khi t\u1ea1o task."
773
773
  }
774
774
  }
775
+ },
776
+ "observability": {
777
+ "stage": "default"
775
778
  }
776
- }
779
+ }