@ngockhoale/ukit 3.0.8 → 3.0.10

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 (105) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/manifests/documentation.yaml +11 -0
  3. package/package.json +1 -1
  4. package/scripts/audit/decision-coverage.mjs +29 -2
  5. package/scripts/bench/data-foundation.mjs +52 -3
  6. package/scripts/bench/decision-runtime-baseline.mjs +427 -0
  7. package/scripts/bench/decision-runtime-metrics.mjs +67 -0
  8. package/scripts/bench/decision-runtime-variant.mjs +626 -0
  9. package/scripts/bench/memory-ablation.mjs +495 -0
  10. package/scripts/bench/memory-baseline.mjs +596 -0
  11. package/scripts/bench/memory-bench.mjs +661 -0
  12. package/scripts/bench/memory-canary.mjs +321 -0
  13. package/scripts/bench/memory-corpus.mjs +354 -0
  14. package/scripts/bench/memory-gate.mjs +389 -0
  15. package/scripts/bench/memory-metrics.mjs +179 -0
  16. package/scripts/bench/parallel-agents.mjs +33 -11
  17. package/scripts/bench/recorder-overhead.mjs +204 -0
  18. package/scripts/bench/sqlite-spike.mjs +451 -0
  19. package/scripts/measure-decision-gateway.mjs +306 -0
  20. package/scripts/perf/audit-perf.mjs +35 -17
  21. package/src/bug/triageBug.js +4 -3
  22. package/src/cli/commands/memory.js +357 -63
  23. package/src/context/detectProjectContext.js +11 -1
  24. package/src/core/agentRuntime/adapters.js +254 -0
  25. package/src/core/agentRuntime/artifacts.js +192 -0
  26. package/src/core/agentRuntime/completionGate.js +176 -0
  27. package/src/core/agentRuntime/context.js +149 -0
  28. package/src/core/agentRuntime/contract.js +247 -0
  29. package/src/core/agentRuntime/diagnostics.js +244 -0
  30. package/src/core/agentRuntime/evaluation.js +163 -0
  31. package/src/core/agentRuntime/eventStore.js +404 -0
  32. package/src/core/agentRuntime/liveness.js +60 -0
  33. package/src/core/agentRuntime/planCompiler.js +322 -0
  34. package/src/core/agentRuntime/promotion.js +53 -0
  35. package/src/core/agentRuntime/qualityComparison.js +112 -0
  36. package/src/core/agentRuntime/recovery.js +266 -0
  37. package/src/core/agentRuntime/resourcePolicy.js +78 -0
  38. package/src/core/agentRuntime/runtimeSupport.js +237 -0
  39. package/src/core/agentRuntime/supervisor.js +565 -0
  40. package/src/core/agentRuntime/vmEngine.js +621 -0
  41. package/src/core/codeintel/analogy.js +3 -2
  42. package/src/core/experiments/dynamicWorkflow.js +17 -2
  43. package/src/core/fileOps.js +21 -3
  44. package/src/core/memory/deltaOverlays.js +75 -30
  45. package/src/core/memory/learningCandidates.js +93 -48
  46. package/src/core/memory/memoryFlags.js +83 -0
  47. package/src/core/memory/memoryFreshness.js +190 -0
  48. package/src/core/memory/memoryHit.js +144 -0
  49. package/src/core/memory/migrate.js +69 -189
  50. package/src/core/memory/migrateMapping.js +232 -0
  51. package/src/core/memory/mutateMemory.js +323 -0
  52. package/src/core/memory/policy.js +96 -0
  53. package/src/core/memory/projectIdentity.js +266 -0
  54. package/src/core/memory/recordIndex.js +178 -0
  55. package/src/core/memory/recordStore.js +133 -20
  56. package/src/core/memory/records.js +144 -6
  57. package/src/core/memory/retrieval.js +259 -125
  58. package/src/core/memory/store.js +16 -5
  59. package/src/core/memory/storeBackup.js +226 -0
  60. package/src/core/memory/storeV2.js +63 -26
  61. package/src/core/memory/storeV2Loader.js +30 -12
  62. package/src/core/memory/userMemory.js +38 -20
  63. package/src/core/memory/writeClassification.js +161 -0
  64. package/src/core/memory/writeGuard.js +129 -0
  65. package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
  66. package/src/core/observability/analytics/cohorts.js +148 -0
  67. package/src/core/observability/analytics/storeDigest.js +163 -0
  68. package/src/core/observability/evaluation/experimentPlan.js +95 -0
  69. package/src/core/observability/evaluation/findings.js +99 -0
  70. package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
  71. package/src/core/observability/evaluation/perturbation.js +273 -0
  72. package/src/core/observability/evaluation/replay.js +7 -1
  73. package/src/core/observability/evaluation/scorecard.js +23 -3
  74. package/src/core/observability/rollout.js +11 -7
  75. package/src/core/observability/schema/compatibility.js +135 -0
  76. package/src/core/observability/schema/registry.js +99 -0
  77. package/src/core/observability/schema/validate.js +7 -0
  78. package/src/core/observability/support/import.js +53 -9
  79. package/src/core/observability/support/paths.js +13 -3
  80. package/src/core/observability/support/projector.js +148 -12
  81. package/src/core/output/index.js +12 -2
  82. package/src/core/runtimeConfig.js +83 -0
  83. package/src/core/runtimePaths.js +3 -0
  84. package/src/core/sensitiveValueScanner.js +40 -0
  85. package/src/core/token/index.js +40 -3
  86. package/src/decision/client.js +37 -13
  87. package/src/decision/protocol.js +1 -1
  88. package/src/decision/registry.js +5 -3
  89. package/src/decision/runtimeDecide.js +242 -0
  90. package/src/decision/runtimeFilter.js +150 -0
  91. package/src/decision/runtimeScheduler.js +239 -0
  92. package/src/index/buildIndex.js +13 -12
  93. package/src/index/queryIndex.js +35 -14
  94. package/src/index/relatedTests.js +50 -8
  95. package/src/index/resolveContext.js +9 -4
  96. package/src/manifest/selectItems.js +7 -3
  97. package/src/render/instructionRenderer.js +17 -5
  98. package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
  99. package/template_project/.claude/ukit/index/route-task.mjs +121 -19
  100. package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
  101. package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
  102. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
  103. package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
  104. package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
  105. package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
@@ -0,0 +1,163 @@
1
+ // src/core/agentRuntime/evaluation.js
2
+ // G6 paired A/B evaluation gate (SPEC §2 G6-FR04/06, §4 GateComparison, §5).
3
+ //
4
+ // computeGateComparison({baseline, variant, scoreFloor?}) → GateComparison:
5
+ // { forbiddenFailures, qualityDelta, wallP50Delta, wallP95Delta,
6
+ // verdict, comparable, envMismatch, reasons } (frozen)
7
+ //
8
+ // Verdict naming is spec-frozen (SPEC §4) and intentionally inverted relative
9
+ // to intuition: 'non-inferior' means the variant FAILED the pre-registered
10
+ // non-inferiority contract — i.e. it is *not* non-inferior. Verdict order
11
+ // implements SPEC §5 exactly:
12
+ // 1. forbiddenFailures > 0 in any compared run → 'non-inferior' (hard gate)
13
+ // 2. quality score UNKNOWN / env mismatch / smoke sample → 'unknown'
14
+ // (never waived, never coerced into a number)
15
+ // 3. variant.score < baseline.score - scoreFloor → 'non-inferior'
16
+ // 4. variant p95 > baseline p95 → 'non-inferior' (wall target)
17
+ // 5. otherwise → 'pass'
18
+ // p50 is reported, never gated (SPEC §5).
19
+ //
20
+ // Pure module: no I/O, no config reads. Malformed/absent report inputs throw
21
+ // a typed EvaluationError — a fabricated 'pass' is impossible by construction.
22
+
23
+ import { UNKNOWN } from '../../../scripts/bench/decision-runtime-metrics.mjs';
24
+
25
+ // Pre-registered non-inferiority score floor (metric-spec §6, SPEC §5).
26
+ // Frozen before any variant existed; callers may narrow it, never widen it.
27
+ export const QUALITY_SCORE_FLOOR = 0.05;
28
+
29
+ // Sample that makes a comparison valid (metric-spec §3): 9 runs per scenario.
30
+ // Below this the run is a smoke/dev loop and is non-comparable.
31
+ export const COMPARABLE_RUNS = 9;
32
+
33
+ const ENV_FIELDS = Object.freeze(['sha', 'package', 'node', 'os', 'arch', 'fs']);
34
+
35
+ export class EvaluationError extends Error {
36
+ constructor(message, code = 'evaluation_error') {
37
+ super(message);
38
+ this.name = 'EvaluationError';
39
+ this.code = code;
40
+ }
41
+ }
42
+
43
+ function isObj(v) {
44
+ return v != null && typeof v === 'object' && !Array.isArray(v);
45
+ }
46
+
47
+ function isNumber(v) {
48
+ return typeof v === 'number' && Number.isFinite(v);
49
+ }
50
+
51
+ function isCount(v) {
52
+ return isNumber(v) && v >= 0;
53
+ }
54
+
55
+ /** Require an FR-006-shaped report; throw EvaluationError otherwise. */
56
+ function requireReport(report, label) {
57
+ if (!isObj(report)) {
58
+ throw new EvaluationError(`${label} report missing or not an object`, 'report_malformed');
59
+ }
60
+ if (!isObj(report.summary)) {
61
+ throw new EvaluationError(`${label} report missing summary`, 'report_malformed');
62
+ }
63
+ return report;
64
+ }
65
+
66
+ function compareEnv(baselineEnv, variantEnv) {
67
+ const mismatched = [];
68
+ for (const field of ENV_FIELDS) {
69
+ const a = baselineEnv?.[field];
70
+ const b = variantEnv?.[field];
71
+ if (a == null || b == null) continue; // absent field: recorded elsewhere, not compared
72
+ if (a !== b) mismatched.push(field);
73
+ }
74
+ return mismatched;
75
+ }
76
+
77
+ function delta(variantValue, baselineValue) {
78
+ if (!isNumber(variantValue) || !isNumber(baselineValue)) return UNKNOWN;
79
+ // Round to 1e-6 so reports carry exact deltas, not float residue.
80
+ return Math.round((variantValue - baselineValue) * 1e6) / 1e6;
81
+ }
82
+
83
+ /**
84
+ * Compute the pre-registered G6 GateComparison between a baseline report and
85
+ * a variant report (both FR-006 shape with `summary.forbiddenFailures`).
86
+ *
87
+ * @param {{baseline: object, variant: object, scoreFloor?: number}} input
88
+ * @returns {object} frozen GateComparison
89
+ */
90
+ export function computeGateComparison(input = {}) {
91
+ if (!isObj(input)) {
92
+ throw new EvaluationError('comparison input missing or not an object', 'input_malformed');
93
+ }
94
+ const { baseline, variant } = input;
95
+ const scoreFloor = isNumber(input.scoreFloor) && input.scoreFloor >= 0
96
+ ? input.scoreFloor
97
+ : QUALITY_SCORE_FLOOR;
98
+
99
+ const b = requireReport(baseline, 'baseline');
100
+ const v = requireReport(variant, 'variant');
101
+
102
+ const bs = b.summary;
103
+ const vs = v.summary;
104
+ const reasons = [];
105
+
106
+ const forbiddenFailures = isCount(vs.forbiddenFailures) ? vs.forbiddenFailures : 0;
107
+ const qualityDelta = delta(vs.qualityScore, bs.qualityScore);
108
+ const wallP50Delta = delta(vs.p50, bs.p50);
109
+ const wallP95Delta = delta(vs.p95, bs.p95);
110
+
111
+ // Comparability is recorded separately from the verdict: a smoke run or an
112
+ // environment drift yields valid deltas that must never promote a stage.
113
+ const envMismatch = compareEnv(b.env, v.env);
114
+ let comparable = true;
115
+ if (envMismatch.length > 0) {
116
+ comparable = false;
117
+ reasons.push(`env:${envMismatch.join(',')}`);
118
+ }
119
+ const baselineRuns = isCount(b.runs) ? b.runs : 0;
120
+ const variantRuns = isCount(v.runs) ? v.runs : 0;
121
+ if (baselineRuns < COMPARABLE_RUNS || variantRuns < COMPARABLE_RUNS) {
122
+ comparable = false;
123
+ reasons.push('sample:smoke');
124
+ }
125
+
126
+ // 1. Hard gate: any forbidden failure → non-inferior, no margin applies.
127
+ if (forbiddenFailures > 0) {
128
+ reasons.push('gate:forbidden-failure');
129
+ return freezeResult({ forbiddenFailures, qualityDelta, wallP50Delta, wallP95Delta, verdict: 'non-inferior', comparable, envMismatch, reasons });
130
+ }
131
+
132
+ // 2. UNKNOWN quality on either side cannot establish non-inferiority —
133
+ // never waived (metric-spec §6); non-comparable env/sample → unknown.
134
+ if (qualityDelta === UNKNOWN || !comparable) {
135
+ if (qualityDelta === UNKNOWN) reasons.push('gate:quality-unknown');
136
+ return freezeResult({ forbiddenFailures, qualityDelta, wallP50Delta, wallP95Delta, verdict: 'unknown', comparable, envMismatch, reasons });
137
+ }
138
+
139
+ // 3. Soft quality floor: variant must stay within scoreFloor of baseline.
140
+ if (vs.qualityScore < bs.qualityScore - scoreFloor - Number.EPSILON) {
141
+ reasons.push('gate:score-floor');
142
+ return freezeResult({ forbiddenFailures, qualityDelta, wallP50Delta, wallP95Delta, verdict: 'non-inferior', comparable, envMismatch, reasons });
143
+ }
144
+
145
+ // 4. Wall/resource target: variant p95 ≤ baseline p95. An UNKNOWN wall
146
+ // target cannot establish a pass → unknown.
147
+ if (wallP95Delta === UNKNOWN) {
148
+ reasons.push('gate:wall-unknown');
149
+ return freezeResult({ forbiddenFailures, qualityDelta, wallP50Delta, wallP95Delta, verdict: 'unknown', comparable, envMismatch, reasons });
150
+ }
151
+ if (wallP95Delta > 0) {
152
+ reasons.push('gate:wall-p95');
153
+ return freezeResult({ forbiddenFailures, qualityDelta, wallP50Delta, wallP95Delta, verdict: 'non-inferior', comparable, envMismatch, reasons });
154
+ }
155
+
156
+ // 5. Within every pre-registered bound → pass.
157
+ reasons.push('gate:pass');
158
+ return freezeResult({ forbiddenFailures, qualityDelta, wallP50Delta, wallP95Delta, verdict: 'pass', comparable, envMismatch, reasons });
159
+ }
160
+
161
+ function freezeResult(result) {
162
+ return Object.freeze(result);
163
+ }
@@ -0,0 +1,404 @@
1
+ /**
2
+ * agentRuntime/eventStore.js — decision-first-runtime G1 storage prototype
3
+ * (SPEC §4, §5 G1-FR03/05/06, §6 crash windows).
4
+ *
5
+ * Layout under `dir`:
6
+ * events/<operationId>.jsonl append-only SemanticEvent journal (truth)
7
+ * state/<operationId>.json guarded operation state (cache — replay wins)
8
+ * continuations.jsonl durable continuation registrations
9
+ * consumptions.jsonl durable consumptionKey records
10
+ *
11
+ * Crash windows (SPEC §6):
12
+ * - continuation registration: appended to continuations.jsonl BEFORE the
13
+ * operation is started — the API has no "start"; a registered continuation
14
+ * whose operation journal never appears is `orphaned` and never auto-fires.
15
+ * - event append: a crash mid-line leaves a truncated tail; readers discard
16
+ * the tail and the last good seq remains the cursor.
17
+ * - transition commit: state file is a cache; replay derives state from the
18
+ * journal — no event is ever lost to a missing state write.
19
+ * - consumption commit: consumptionKey records are checked before
20
+ * consumption; a re-delivery of the same consuming event is an idempotent
21
+ * no-op (duplicate), never a second consumption.
22
+ * - migration: output is written to temp files then atomically renamed;
23
+ * corrupt input fails closed before any output is produced.
24
+ *
25
+ * Prototype scope (omp, flag-gated by TASK-004 — this module performs no
26
+ * config reads and writes nothing unless called).
27
+ */
28
+
29
+ import { promises as fs } from 'node:fs';
30
+ import path from 'node:path';
31
+
32
+ import {
33
+ CONTRACT_VERSION,
34
+ validateSemanticEvent,
35
+ validateEventOrder,
36
+ } from './contract.js';
37
+
38
+ export class EventStoreError extends Error {
39
+ constructor(code, message) {
40
+ super(message ?? code);
41
+ this.name = 'EventStoreError';
42
+ this.code = code;
43
+ }
44
+ }
45
+
46
+ const JOURNAL_DIR = 'events';
47
+ const STATE_DIR = 'state';
48
+ const CONTINUATIONS_FILE = 'continuations.jsonl';
49
+ const CONSUMPTIONS_FILE = 'consumptions.jsonl';
50
+
51
+ const journalPath = (dir, operationId) =>
52
+ path.join(dir, JOURNAL_DIR, `${operationId}.jsonl`);
53
+ const statePath = (dir, operationId) =>
54
+ path.join(dir, STATE_DIR, `${operationId}.json`);
55
+
56
+ async function pathExists(p) {
57
+ try {
58
+ await fs.access(p);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /** Atomic write: temp file + fsync + rename (SPEC §6 migration/state windows). */
66
+ async function writeFileAtomic(target, data) {
67
+ const tmp = `${target}.tmp-${process.pid}`;
68
+ const handle = await fs.open(tmp, 'w');
69
+ try {
70
+ await handle.writeFile(data, 'utf8');
71
+ await handle.sync();
72
+ } finally {
73
+ await handle.close();
74
+ }
75
+ await fs.rename(tmp, target);
76
+ }
77
+
78
+ /** Append one line and fsync. */
79
+ async function appendLine(file, obj) {
80
+ await fs.mkdir(path.dirname(file), { recursive: true });
81
+ const handle = await fs.open(file, 'a');
82
+ try {
83
+ await handle.writeFile(`${JSON.stringify(obj)}\n`, 'utf8');
84
+ await handle.sync();
85
+ } finally {
86
+ await handle.close();
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Parse raw JSONL text. A final line without a terminating newline that fails
92
+ * to parse is a crash-truncated tail → discarded, `truncatedTail: true`
93
+ * (logged by caller). A malformed line mid-file is corruption → throws
94
+ * journal_corrupt. Shared by journal files and JSONL sidecars so both follow
95
+ * the same torn-tail/fail-closed contract.
96
+ */
97
+ function parseJsonLines(raw, label) {
98
+ const endsWithNewline = raw.endsWith('\n');
99
+ const lines = raw.split('\n');
100
+ if (endsWithNewline) lines.pop(); // trailing empty segment
101
+
102
+ const records = [];
103
+ for (let i = 0; i < lines.length; i += 1) {
104
+ const line = lines[i];
105
+ if (line === '') continue;
106
+ const isLast = i === lines.length - 1;
107
+ try {
108
+ records.push(JSON.parse(line));
109
+ } catch {
110
+ if (isLast && !endsWithNewline) {
111
+ // crash mid-write: truncated tail is recoverable
112
+ return { records, truncatedTail: true };
113
+ }
114
+ throw new EventStoreError(
115
+ 'journal_corrupt',
116
+ `corrupt journal line ${i + 1} in ${label}`,
117
+ );
118
+ }
119
+ }
120
+ return { records, truncatedTail: false };
121
+ }
122
+
123
+ /**
124
+ * Parse a journal file. Returns `{ events, truncatedTail }`:
125
+ * - a final line without a terminating newline or with unparseable JSON is a
126
+ * crash-truncated tail → discarded, `truncatedTail: true` (logged by caller).
127
+ * - a malformed line mid-file is corruption → throws journal_corrupt.
128
+ */
129
+ async function parseJournalFile(file) {
130
+ if (!(await pathExists(file))) return { events: [], truncatedTail: false };
131
+ const raw = await fs.readFile(file, 'utf8');
132
+ if (raw.length === 0) return { events: [], truncatedTail: false };
133
+ const { records: events, truncatedTail } = parseJsonLines(raw, file);
134
+ return { events, truncatedTail };
135
+ }
136
+
137
+ /**
138
+ * Scan the journal and build the ordering cursor. Mirrors readJournal's
139
+ * semantics: truncated tail is skipped; mid-file corruption throws.
140
+ */
141
+ async function loadCursor(dir, operationId) {
142
+ const { events, truncatedTail } = await parseJournalFile(journalPath(dir, operationId));
143
+ if (truncatedTail) {
144
+ // eslint-disable-next-line no-console
145
+ console.warn(`eventStore: discarded truncated tail line in journal for ${operationId}`);
146
+ }
147
+ const cursor = { lastSeq: 0, seenEventIds: new Set() };
148
+ for (const event of events) {
149
+ const v = validateEventOrder(event, cursor);
150
+ if (!v.ok) {
151
+ throw new EventStoreError(v.code, `journal seq violation at seq ${event.seq}`);
152
+ }
153
+ }
154
+ return cursor;
155
+ }
156
+
157
+ /**
158
+ * Append a validated SemanticEvent to the operation journal. Envelope and
159
+ * ordering are validated against contract.js; duplicate eventId/seq is an
160
+ * idempotent no-op; a future seq rejects out_of_order. The line is fsync'd
161
+ * before returning — once this resolves, the event survives a crash.
162
+ *
163
+ * @param {string} dir store root
164
+ * @param {object} event SemanticEvent (contract v1)
165
+ * @param {object} [opts] `{ hooks: { afterAppend } }` — test fault injection
166
+ * @returns {Promise<{seq:number, duplicate?:boolean}>}
167
+ */
168
+ export async function appendEvent(dir, event, opts = {}) {
169
+ const shape = validateSemanticEvent(event);
170
+ if (!shape.ok) throw new EventStoreError(shape.code, shape.code);
171
+
172
+ const cursor = await loadCursor(dir, event.operationId);
173
+ const order = validateEventOrder(event, cursor);
174
+ if (!order.ok) throw new EventStoreError(order.code, order.code);
175
+ if (order.duplicate) return { seq: event.seq, duplicate: true };
176
+
177
+ await appendLine(journalPath(dir, event.operationId), event);
178
+ await opts.hooks?.afterAppend?.(event);
179
+ return { seq: event.seq };
180
+ }
181
+
182
+ /**
183
+ * Read an operation journal in seq order. Truncated tail lines are discarded
184
+ * (crash-mid-write); mid-file corruption throws journal_corrupt.
185
+ *
186
+ * @param {string} dir
187
+ * @param {string} operationId
188
+ * @returns {AsyncIterable<object>}
189
+ */
190
+ export async function* readJournal(dir, operationId) {
191
+ const { events, truncatedTail } = await parseJournalFile(journalPath(dir, operationId));
192
+ if (truncatedTail) {
193
+ // eslint-disable-next-line no-console
194
+ console.warn(`eventStore: discarded truncated tail line in journal for ${operationId}`);
195
+ }
196
+ for (const event of events) {
197
+ const shape = validateSemanticEvent(event);
198
+ if (!shape.ok) {
199
+ throw new EventStoreError('journal_corrupt', `invalid event envelope at seq ${event?.seq}`);
200
+ }
201
+ yield event;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Write the guarded operation state file (cache only — the journal is truth).
207
+ * Atomic temp+rename+fsync.
208
+ *
209
+ * @param {string} dir
210
+ * @param {string} operationId
211
+ * @param {object} state `{ state, ...meta }`
212
+ * @param {object} [opts] `{ hooks: { beforeWrite } }` — test fault injection
213
+ */
214
+ export async function writeOperationState(dir, operationId, state, opts = {}) {
215
+ await opts.hooks?.beforeWrite?.();
216
+ const file = statePath(dir, operationId);
217
+ await fs.mkdir(path.dirname(file), { recursive: true });
218
+ await writeFileAtomic(file, `${JSON.stringify({ operationId, ...state, contractVersion: CONTRACT_VERSION })}\n`);
219
+ }
220
+
221
+ /**
222
+ * Derive operation state by replaying `operation.transition` events. Proves
223
+ * the state file is a cache: a crash between event append and state write
224
+ * loses nothing.
225
+ *
226
+ * @returns {Promise<{state:string|null, lastSeq:number}>}
227
+ */
228
+ export async function deriveOperationState(dir, operationId) {
229
+ let state = null;
230
+ let lastSeq = 0;
231
+ for await (const event of readJournal(dir, operationId)) {
232
+ lastSeq = event.seq;
233
+ if (event.eventType === 'operation.transition' && event.safePayload?.to) {
234
+ state = event.safePayload.to;
235
+ }
236
+ }
237
+ return { operationId, state, lastSeq };
238
+ }
239
+
240
+ /**
241
+ * Durably register a continuation BEFORE the operation it waits on can be
242
+ * started (register-before-start invariant — this module exposes no "start"
243
+ * API, so registration always precedes any operation activity a caller
244
+ * performs). Appended fsync'd to continuations.jsonl.
245
+ *
246
+ * @param {string} dir
247
+ * @param {object} continuation `{continuationId, planVersion, nodeId, operationId, afterSeq, selector, consumptionKey}`
248
+ */
249
+ export async function registerContinuation(dir, continuation) {
250
+ for (const f of ['continuationId', 'operationId', 'nodeId', 'consumptionKey']) {
251
+ if (typeof continuation?.[f] !== 'string' || continuation[f] === '') {
252
+ throw new EventStoreError('malformed_continuation', `missing ${f}`);
253
+ }
254
+ }
255
+ const record = { ...continuation, status: 'registered' };
256
+ await appendLine(path.join(dir, CONTINUATIONS_FILE), record);
257
+ return record;
258
+ }
259
+
260
+ /**
261
+ * Read a JSONL sidecar (continuations/consumptions). Same contract as
262
+ * parseJournalFile: a crash-torn final line is discarded; mid-file
263
+ * corruption throws journal_corrupt — never a raw SyntaxError.
264
+ */
265
+ async function readRecords(file) {
266
+ if (!(await pathExists(file))) return [];
267
+ const raw = await fs.readFile(file, 'utf8');
268
+ if (raw.length === 0) return [];
269
+ const { records, truncatedTail } = parseJsonLines(raw, file);
270
+ if (truncatedTail) {
271
+ // eslint-disable-next-line no-console
272
+ console.warn(`eventStore: discarded truncated tail line in ${path.basename(file)}`);
273
+ }
274
+ return records;
275
+ }
276
+
277
+ /**
278
+ * Recovery pass: registered continuations whose operation journal never
279
+ * appeared are marked `orphaned` and never auto-fire (SPEC §6 row 1).
280
+ *
281
+ * @returns {Promise<object[]>} continuations with resolved status
282
+ */
283
+ export async function recoverContinuations(dir) {
284
+ const records = await readRecords(path.join(dir, CONTINUATIONS_FILE));
285
+ const out = [];
286
+ for (const c of records) {
287
+ const hasJournal = await pathExists(journalPath(dir, c.operationId));
288
+ out.push({ ...c, status: hasJournal ? c.status : 'orphaned' });
289
+ }
290
+ return out;
291
+ }
292
+
293
+ /**
294
+ * Consume a continuation for a delivered event. Idempotent on
295
+ * `consumptionKey` plus the consuming event's `eventId`: a re-delivery of the
296
+ * same event is a duplicate no-op even if the crash window hit between
297
+ * consumption and the key record (SPEC §6 row 4).
298
+ *
299
+ * @returns {Promise<{consumed:boolean, duplicate?:boolean}>}
300
+ */
301
+ export async function consumeContinuation(dir, continuationId, event) {
302
+ const records = await readRecords(path.join(dir, CONTINUATIONS_FILE));
303
+ const cont = records.find((c) => c.continuationId === continuationId);
304
+ if (!cont) throw new EventStoreError('unknown_continuation', continuationId);
305
+ if (cont.status === 'orphaned') {
306
+ throw new EventStoreError('orphaned_continuation', continuationId);
307
+ }
308
+
309
+ const consumptionFile = path.join(dir, CONSUMPTIONS_FILE);
310
+ const consumed = await readRecords(consumptionFile);
311
+ const hit = consumed.find(
312
+ (r) => r.consumptionKey === cont.consumptionKey || r.eventId === event.eventId,
313
+ );
314
+ if (hit) return { consumed: false, duplicate: true };
315
+
316
+ await appendLine(consumptionFile, {
317
+ continuationId,
318
+ consumptionKey: cont.consumptionKey,
319
+ eventId: event.eventId,
320
+ seq: event.seq,
321
+ consumedAt: new Date().toISOString(),
322
+ });
323
+ return { consumed: true };
324
+ }
325
+
326
+ /**
327
+ * Migration prototype v0→v1. Reads a v0 journal dir (`<op>.jsonl` files with
328
+ * `{id,op,n,kind,at,data}` lines), emits contract-v1 SemanticEvent JSONL files
329
+ * in `outDir`. Deterministic: key order fixed, file order sorted → N runs on
330
+ * the same input are byte-identical. Corrupt input fails closed BEFORE any
331
+ * output is written; output lands via temp file + atomic rename per journal.
332
+ *
333
+ * @param {string} fromDir directory containing v0 `<op>.jsonl` files
334
+ * @param {string} outDir output directory
335
+ * @param {object} opts `{ from: 0, to: 1 }`
336
+ * @returns {Promise<{migrated:number}>}
337
+ */
338
+ export async function migrateJournal(fromDir, outDir, opts = {}) {
339
+ const { from = 0, to = CONTRACT_VERSION } = opts;
340
+ if (from !== 0 || to !== CONTRACT_VERSION) {
341
+ throw new EventStoreError('unsupported_migration', `v${from}→v${to}`);
342
+ }
343
+
344
+ let names;
345
+ try {
346
+ names = (await fs.readdir(fromDir)).filter((n) => n.endsWith('.jsonl')).sort();
347
+ } catch {
348
+ throw new EventStoreError('journal_missing', fromDir);
349
+ }
350
+
351
+ // Phase 1: fully transform all input in memory — fail closed before any
352
+ // output write (no partial migration state).
353
+ const outputs = [];
354
+ for (const name of names) {
355
+ // raced unlink between readdir and read → typed error, not raw fs error
356
+ let raw;
357
+ try {
358
+ raw = await fs.readFile(path.join(fromDir, name), 'utf8');
359
+ } catch (err) {
360
+ throw new EventStoreError('journal_missing', `${name}: ${err.message}`);
361
+ }
362
+ const lines = raw.split('\n').filter((l) => l !== '');
363
+ const events = [];
364
+ for (let i = 0; i < lines.length; i += 1) {
365
+ let rec;
366
+ try {
367
+ rec = JSON.parse(lines[i]);
368
+ } catch {
369
+ throw new EventStoreError('journal_corrupt', `${name} line ${i + 1}`);
370
+ }
371
+ if (
372
+ typeof rec?.id !== 'string' || typeof rec?.op !== 'string'
373
+ || !Number.isInteger(rec?.n) || typeof rec?.kind !== 'string'
374
+ || typeof rec?.at !== 'string'
375
+ ) {
376
+ throw new EventStoreError('journal_corrupt', `${name} line ${i + 1}: malformed v0 record`);
377
+ }
378
+ events.push({
379
+ eventId: rec.id,
380
+ operationId: rec.op,
381
+ seq: rec.n,
382
+ eventType: rec.kind === 'transition' ? 'operation.transition' : rec.kind,
383
+ observedAt: rec.at,
384
+ producerVersion: 'migrated-v0',
385
+ contractVersion: CONTRACT_VERSION,
386
+ privacyClass: 'internal',
387
+ artifactRefs: [],
388
+ safePayload: rec.data ?? {},
389
+ });
390
+ const shape = validateSemanticEvent(events[events.length - 1]);
391
+ if (!shape.ok) {
392
+ throw new EventStoreError('journal_corrupt', `${name} line ${i + 1}: ${shape.code}`);
393
+ }
394
+ }
395
+ outputs.push({ name, body: events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : '') });
396
+ }
397
+
398
+ // Phase 2: write each output via temp + atomic rename.
399
+ await fs.mkdir(outDir, { recursive: true });
400
+ for (const { name, body } of outputs) {
401
+ await writeFileAtomic(path.join(outDir, name), body);
402
+ }
403
+ return { migrated: outputs.reduce((n, o) => n + (o.body ? o.body.trimEnd().split('\n').length : 0), 0) };
404
+ }
@@ -0,0 +1,60 @@
1
+ // src/core/agentRuntime/liveness.js
2
+ // Pure observation classifier for the owned-process supervisor (SPEC §4, §6).
3
+ // Classifies one sampled observation into a *non-operation-terminal* state:
4
+ // 'live_progressing' | 'silent_live' | 'stalled' | 'exited' | 'timeout'
5
+ // 'exited'/'timeout' describe the sample, not the operation — the supervisor
6
+ // owns the real terminal transition via validateTransition.
7
+ // No I/O, no clock: every time value is passed in via the sample.
8
+
9
+ const CLASSES = Object.freeze([
10
+ 'live_progressing',
11
+ 'silent_live',
12
+ 'stalled',
13
+ 'exited',
14
+ 'timeout',
15
+ ]);
16
+
17
+ function num(v) {
18
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
19
+ }
20
+
21
+ /**
22
+ * @param {object} sample { processAlive, bytesWritten, lastOutputAt, startedAt, now }
23
+ * @param {object} [prev] previous sample (bytesWritten used for delta)
24
+ * @param {object} [policy] { silentAfterMs, stallTimeoutMs, wallTimeoutMs, stallAction }
25
+ * @returns {'live_progressing'|'silent_live'|'stalled'|'exited'|'timeout'}
26
+ */
27
+ export function classifyObservation(sample, prev, policy) {
28
+ if (sample == null || typeof sample !== 'object') return 'silent_live';
29
+
30
+ const now = num(sample.now);
31
+ const startedAt = num(sample.startedAt);
32
+ const lastOutputAt = num(sample.lastOutputAt);
33
+ const bytes = num(sample.bytesWritten);
34
+ const alive = sample.processAlive !== false;
35
+
36
+ const p = policy && typeof policy === 'object' ? policy : {};
37
+ const wallTimeoutMs = num(p.wallTimeoutMs) ?? Number.POSITIVE_INFINITY;
38
+ const stallTimeoutMs = num(p.stallTimeoutMs) ?? Number.POSITIVE_INFINITY;
39
+ const silentAfterMs = num(p.silentAfterMs) ?? Number.POSITIVE_INFINITY;
40
+
41
+ // Malformed clock fields → conservative non-terminal observation.
42
+ if (now == null) return 'silent_live';
43
+
44
+ // Wall-clock timeout dominates every other observation, including exit.
45
+ if (startedAt != null && now - startedAt >= wallTimeoutMs) return 'timeout';
46
+
47
+ if (!alive) return 'exited';
48
+
49
+ // Progress: byte delta vs previous sample, or provably fresh output.
50
+ const prevBytes = prev && typeof prev === 'object' ? num(prev.bytesWritten) : null;
51
+ if (bytes != null && prevBytes != null && bytes > prevBytes) return 'live_progressing';
52
+
53
+ // Silence age unknown → treat as fresh (still within the silent window).
54
+ const silenceAge = lastOutputAt != null ? Math.max(0, now - lastOutputAt) : 0;
55
+ if (silenceAge >= stallTimeoutMs) return 'stalled';
56
+ if (silenceAge >= silentAfterMs) return 'silent_live';
57
+ return 'live_progressing';
58
+ }
59
+
60
+ export const LIVENESS_CLASSES = CLASSES;