@ngockhoale/ukit 3.0.8 → 3.0.9

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 +14 -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,149 @@
1
+ // Bounded runtime-context compiler (G5 / TASK-002).
2
+ // Pure module: no I/O, no runtimeConfig reads, no adapter/gate imports.
3
+ // Compiles goal / verified state / evidence / memory records into a frozen
4
+ // BoundedContext under a hard byte budget that includes framing and
5
+ // provenance labels. Every included item carries {source, ref, bytes, age?};
6
+ // every dropped item is listed in omitted[] with {source, ref, reason} —
7
+ // omission is always visible, never silent.
8
+ //
9
+ // Memory records pass through src/core/memory/policy.js eligible();
10
+ // ineligible records are excluded (omitted with the policy reason), and
11
+ // eligible-but-unverified records are labeled 'unverified' and quoted —
12
+ // never injected as instruction.
13
+
14
+ import { eligible } from '../memory/policy.js';
15
+
16
+ const STALE_MS = 15 * 60 * 1000; // default freshness window for evidence
17
+
18
+ const HEADER = '# UKit runtime context';
19
+ const FOOTER = '# end context';
20
+
21
+ function byteLen(s) {
22
+ return Buffer.byteLength(s, 'utf8');
23
+ }
24
+
25
+ function oneLine(text) {
26
+ return String(text).split('\n').join(' ');
27
+ }
28
+
29
+ // Each candidate: { source, ref, label|null, age?, line, required }
30
+ function renderLine(c) {
31
+ const label = c.label ? ` ${c.label}` : '';
32
+ const age = c.age != null ? ` age=${c.age}ms` : '';
33
+ const prefix = c.label === 'unverified' ? '> ' : '';
34
+ return `${prefix}[${c.source}${label} ref=${c.ref}${age}] ${oneLine(c.text)}`;
35
+ }
36
+
37
+ export function compileRuntimeContext({
38
+ task,
39
+ verifiedState,
40
+ evidence,
41
+ memoryRecords,
42
+ budget,
43
+ now,
44
+ } = {}) {
45
+ const nowMs = typeof now === 'number' ? now : Date.now();
46
+
47
+ if (typeof budget !== 'number' || !Number.isFinite(budget) || budget <= 0) {
48
+ return { ok: false, code: 'malformed_input' };
49
+ }
50
+ if (task == null || typeof task !== 'object' || typeof task.goal !== 'string' || task.goal.length === 0) {
51
+ return { ok: false, code: 'malformed_input' };
52
+ }
53
+ if (evidence != null && !Array.isArray(evidence)) {
54
+ return { ok: false, code: 'malformed_input' };
55
+ }
56
+ if (memoryRecords != null && !Array.isArray(memoryRecords)) {
57
+ return { ok: false, code: 'malformed_input' };
58
+ }
59
+
60
+ const candidates = [];
61
+ const omitted = [];
62
+ const push = (c, required = false) => candidates.push({ ...c, required });
63
+ const omit = (source, ref, reason) =>
64
+ omitted.push(Object.freeze({ source, ref, reason }));
65
+
66
+ // Goal is always required — a context with no goal is not a context.
67
+ push({ source: 'task', ref: task.id != null ? String(task.id) : 'task', text: `goal: ${task.goal}` }, true);
68
+
69
+ if (verifiedState != null) {
70
+ const text = typeof verifiedState === 'string' ? verifiedState : verifiedState.summary;
71
+ if (typeof text === 'string' && text.length > 0) {
72
+ push({ source: 'verified-state', ref: verifiedState.ref != null ? String(verifiedState.ref) : 'state', text }, true);
73
+ }
74
+ }
75
+
76
+ for (const ev of evidence || []) {
77
+ if (ev == null || typeof ev !== 'object' || typeof ev.content !== 'string') {
78
+ omit('evidence', ev && ev.ref != null ? String(ev.ref) : 'unknown', 'malformed_input');
79
+ continue;
80
+ }
81
+ const source = typeof ev.source === 'string' ? ev.source : 'evidence';
82
+ const ref = ev.ref != null ? String(ev.ref) : 'unknown';
83
+ const ttl = typeof ev.ttlMs === 'number' ? ev.ttlMs : STALE_MS;
84
+ const age = typeof ev.observedAt === 'number' ? nowMs - ev.observedAt : null;
85
+ if (age != null && age > ttl) {
86
+ omit(source, ref, 'stale');
87
+ continue;
88
+ }
89
+ push({ source, ref, text: ev.content, age });
90
+ }
91
+
92
+ const memContext = {
93
+ projectId:
94
+ (verifiedState && typeof verifiedState.projectId === 'string' ? verifiedState.projectId : null)
95
+ ?? (typeof task.projectId === 'string' ? task.projectId : null),
96
+ now: nowMs,
97
+ };
98
+
99
+ for (const rec of memoryRecords || []) {
100
+ const ref = rec && rec.id != null ? String(rec.id) : 'memory';
101
+ const verdict = eligible(rec, memContext);
102
+ if (!verdict.ok) {
103
+ omit('memory', ref, verdict.reason);
104
+ continue;
105
+ }
106
+ const text = typeof rec.content === 'string' ? rec.content
107
+ : typeof rec.text === 'string' ? rec.text
108
+ : null;
109
+ if (text == null) {
110
+ omit('memory', ref, 'malformed_input');
111
+ continue;
112
+ }
113
+ const unverified = rec.verified !== true && rec.trust_tier !== 'verified';
114
+ push({ source: 'memory', ref, text, label: unverified ? 'unverified' : null });
115
+ }
116
+
117
+ const framingBytes = byteLen(`${HEADER}\n\n${FOOTER}\n`);
118
+ let used = framingBytes;
119
+ const includedLines = [];
120
+ const included = [];
121
+
122
+ for (const c of candidates) {
123
+ const line = renderLine(c);
124
+ const lineBytes = byteLen(line) + 1; // trailing newline
125
+ if (used + lineBytes <= budget) {
126
+ used += lineBytes;
127
+ includedLines.push(line);
128
+ const item = { source: c.source, ref: c.ref, bytes: lineBytes };
129
+ if (c.age != null) item.age = c.age;
130
+ if (c.label) item.label = c.label;
131
+ included.push(Object.freeze(item));
132
+ } else if (c.required) {
133
+ // A required element cannot fit: nothing useful can be emitted.
134
+ return { ok: false, code: 'over_budget' };
135
+ } else {
136
+ omit(c.source, c.ref, 'over_budget');
137
+ }
138
+ }
139
+
140
+ const packet = `${HEADER}\n${includedLines.join('\n')}\n${FOOTER}\n`;
141
+ return {
142
+ ok: true,
143
+ context: Object.freeze({
144
+ packet,
145
+ included: Object.freeze(included),
146
+ omitted: Object.freeze(omitted),
147
+ }),
148
+ };
149
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * agentRuntime/contract.js — decision-first-runtime G1 (DR-02), contract v1.
3
+ *
4
+ * Pure validators for the versioned operation state machine, the SemanticEvent
5
+ * envelope, and side-effect/retry permission rules. No I/O, no host calls.
6
+ * Every rejection is a typed result `{ ok:false, code }` — never a throw.
7
+ *
8
+ * Spec: docs/AI_HANDOFF/SPEC.md §2 (G1-FR01/02/04), §4, §5.
9
+ */
10
+
11
+ export const CONTRACT_VERSION = 1;
12
+
13
+ export const OPERATION_STATES = Object.freeze([
14
+ 'queued',
15
+ 'starting',
16
+ 'running',
17
+ 'cancel_pending',
18
+ 'retry_pending',
19
+ 'recovery_required',
20
+ 'completed',
21
+ 'failed',
22
+ 'cancelled',
23
+ ]);
24
+
25
+ export const TERMINAL_STATES = Object.freeze(['completed', 'failed', 'cancelled']);
26
+
27
+ const TERMINAL_SET = new Set(TERMINAL_STATES);
28
+ const STATE_SET = new Set(OPERATION_STATES);
29
+
30
+ /**
31
+ * Legal edges. `recovery_required` is reachable from every non-terminal state
32
+ * and resolves to exactly one terminal state — it never re-enters `running`.
33
+ */
34
+ const TRANSITIONS = Object.freeze({
35
+ queued: new Set(['starting', 'recovery_required']),
36
+ starting: new Set(['running', 'cancel_pending', 'recovery_required']),
37
+ running: new Set(['completed', 'failed', 'cancel_pending', 'retry_pending', 'recovery_required']),
38
+ cancel_pending: new Set(['cancelled', 'recovery_required']),
39
+ retry_pending: new Set(['running', 'cancel_pending', 'recovery_required']),
40
+ recovery_required: new Set(TERMINAL_STATES),
41
+ completed: new Set(),
42
+ failed: new Set(),
43
+ cancelled: new Set(),
44
+ });
45
+
46
+ export const SIDE_EFFECT_CLASSES = Object.freeze([
47
+ 'pure',
48
+ 'read_only',
49
+ 'idempotent_write',
50
+ 'write',
51
+ 'network',
52
+ 'destructive',
53
+ ]);
54
+
55
+ /** Classes that MAY auto-retry under bounded attempts + backoff (SPEC §5). */
56
+ const AUTO_RETRYABLE = new Set(['pure', 'read_only', 'idempotent_write']);
57
+
58
+ const DEFAULT_MAX_ATTEMPTS = 1;
59
+ export const MAX_SAFE_PAYLOAD_BYTES = 32 * 1024;
60
+
61
+ const ok = () => ({ ok: true });
62
+ const reject = (code) => ({ ok: false, code });
63
+
64
+ export function isTerminal(state) {
65
+ return TERMINAL_SET.has(state);
66
+ }
67
+
68
+ /**
69
+ * Validate an operation state transition.
70
+ *
71
+ * @param {string} prev current state
72
+ * @param {string} next requested next state
73
+ * @param {object} ctx transition context; MUST carry `fencingEpoch` (monotonic
74
+ * owner epoch). When `ctx.ownerEpoch` is provided, `fencingEpoch` must be
75
+ * >= ownerEpoch; a lower or absent epoch is a stale-owner reject.
76
+ * @returns {{ok:true}|{ok:false, code:string}}
77
+ */
78
+ export function validateTransition(prev, next, ctx = {}) {
79
+ if (!STATE_SET.has(prev) || !STATE_SET.has(next)) {
80
+ return reject('unknown_state');
81
+ }
82
+
83
+ const { fencingEpoch, ownerEpoch } = ctx ?? {};
84
+ if (
85
+ !Number.isInteger(fencingEpoch)
86
+ || fencingEpoch < 0
87
+ || (ownerEpoch !== undefined && fencingEpoch < ownerEpoch)
88
+ ) {
89
+ return reject('stale_owner');
90
+ }
91
+
92
+ if (isTerminal(prev)) {
93
+ return reject('terminal_reentry');
94
+ }
95
+
96
+ if (!TRANSITIONS[prev].has(next)) {
97
+ return reject('invalid_transition');
98
+ }
99
+
100
+ return ok();
101
+ }
102
+
103
+ const REQUIRED_EVENT_FIELDS = Object.freeze([
104
+ 'eventId',
105
+ 'operationId',
106
+ 'seq',
107
+ 'eventType',
108
+ 'observedAt',
109
+ 'producerVersion',
110
+ 'contractVersion',
111
+ 'privacyClass',
112
+ 'artifactRefs',
113
+ 'safePayload',
114
+ ]);
115
+
116
+ /**
117
+ * Validate a versioned SemanticEvent envelope (SPEC §2 G1-FR02).
118
+ * Ordering (`seq` cursor, duplicate/gap handling) is enforced by the journal
119
+ * reader — this validator checks envelope shape only.
120
+ *
121
+ * @param {object} event
122
+ * @returns {{ok:true}|{ok:false, code:string}}
123
+ */
124
+ export function validateSemanticEvent(event) {
125
+ if (event === null || typeof event !== 'object' || Array.isArray(event)) {
126
+ return reject('malformed_event');
127
+ }
128
+
129
+ for (const field of REQUIRED_EVENT_FIELDS) {
130
+ if (!(field in event)) {
131
+ return reject('malformed_event');
132
+ }
133
+ }
134
+
135
+ if (typeof event.eventId !== 'string' || event.eventId.length === 0) {
136
+ return reject('malformed_event');
137
+ }
138
+ if (typeof event.operationId !== 'string' || event.operationId.length === 0) {
139
+ return reject('malformed_event');
140
+ }
141
+ if (!Number.isInteger(event.seq) || event.seq < 0) {
142
+ return reject('malformed_event');
143
+ }
144
+ if (typeof event.eventType !== 'string' || event.eventType.length === 0) {
145
+ return reject('malformed_event');
146
+ }
147
+ if (typeof event.observedAt !== 'string' || Number.isNaN(Date.parse(event.observedAt))) {
148
+ return reject('malformed_event');
149
+ }
150
+ if (typeof event.producerVersion !== 'string' || event.producerVersion.length === 0) {
151
+ return reject('malformed_event');
152
+ }
153
+ if (event.contractVersion !== CONTRACT_VERSION) {
154
+ return reject('unsupported_contract_version');
155
+ }
156
+ if (typeof event.privacyClass !== 'string' || event.privacyClass.length === 0) {
157
+ return reject('malformed_event');
158
+ }
159
+ if (!Array.isArray(event.artifactRefs)) {
160
+ return reject('malformed_event');
161
+ }
162
+
163
+ // safePayload is bounded and must never carry raw output (SPEC §2).
164
+ let payloadBytes;
165
+ try {
166
+ payloadBytes = Buffer.byteLength(JSON.stringify(event.safePayload ?? null), 'utf8');
167
+ } catch {
168
+ return reject('malformed_event');
169
+ }
170
+ if (payloadBytes > MAX_SAFE_PAYLOAD_BYTES) {
171
+ return reject('payload_too_large');
172
+ }
173
+
174
+ return ok();
175
+ }
176
+
177
+ /**
178
+ * Validate a SemanticEvent against the per-operation ordering cursor
179
+ * (SPEC §2): `seq` is the causal cursor — strictly monotonic, gap-free.
180
+ * A duplicate `eventId` or already-seen `seq` is an idempotent no-op
181
+ * (`{ok:true, duplicate:true}`); a future `seq` is a typed reject
182
+ * `out_of_order` — never a silent reorder.
183
+ *
184
+ * `cursor` is the caller-owned per-operation state:
185
+ * `{ lastSeq: number, seenEventIds?: Set<string> }`. It is mutated only on a
186
+ * non-duplicate accept (`lastSeq` advances, `eventId` recorded).
187
+ *
188
+ * @param {object} event event already accepted by validateSemanticEvent
189
+ * @param {object} cursor
190
+ * @returns {{ok:true, duplicate?:boolean}|{ok:false, code:string}}
191
+ */
192
+ export function validateEventOrder(event, cursor) {
193
+ if (cursor === null || typeof cursor !== 'object' || !Number.isInteger(cursor.lastSeq) || cursor.lastSeq < 0) {
194
+ return reject('malformed_cursor');
195
+ }
196
+
197
+ const seen = cursor.seenEventIds instanceof Set ? cursor.seenEventIds : null;
198
+ if (seen?.has(event.eventId) || event.seq <= cursor.lastSeq) {
199
+ return { ok: true, duplicate: true };
200
+ }
201
+
202
+ if (event.seq !== cursor.lastSeq + 1) {
203
+ return reject('out_of_order');
204
+ }
205
+
206
+ cursor.lastSeq = event.seq;
207
+ seen?.add(event.eventId);
208
+ return ok();
209
+ }
210
+
211
+ /**
212
+ * Validate whether an attempt is permitted for a side-effect class.
213
+ *
214
+ * `attempt` is 1-based: attempt 1 is the initial execution and is always
215
+ * allowed; attempt >= 2 is a replay and is only permitted for auto-retryable
216
+ * classes within `policy.maxAttempts`. `write`/`network`/`destructive` and any
217
+ * unrecognized class fall into the most conservative bucket: no auto-replay —
218
+ * post-crash they resolve to `recovery_required` (SPEC §5).
219
+ *
220
+ * @param {string} sideEffectClass
221
+ * @param {number} attempt 1-based attempt number
222
+ * @param {object} [policy] `{ maxAttempts }` — total attempts allowed
223
+ * @returns {{ok:true}|{ok:false, code:string}}
224
+ */
225
+ export function validateRetry(sideEffectClass, attempt, policy = {}) {
226
+ if (!Number.isInteger(attempt) || attempt < 1) {
227
+ return reject('invalid_attempt');
228
+ }
229
+
230
+ const maxAttempts = Number.isInteger(policy?.maxAttempts) && policy.maxAttempts >= 1
231
+ ? policy.maxAttempts
232
+ : DEFAULT_MAX_ATTEMPTS;
233
+
234
+ if (attempt === 1) {
235
+ return ok();
236
+ }
237
+
238
+ if (!AUTO_RETRYABLE.has(sideEffectClass)) {
239
+ return reject('retry_unsafe');
240
+ }
241
+
242
+ if (attempt > maxAttempts) {
243
+ return reject('retry_exhausted');
244
+ }
245
+
246
+ return ok();
247
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * agentRuntime/diagnostics.js — decision-first-runtime G7 (DR-10),
3
+ * explainable trace/replay readers (SPEC §2 G7-FR01/02, §4, §5, §7).
4
+ *
5
+ * Two pure readers over the operation journal:
6
+ * renderOperationTimeline → deterministic frozen SanitizedTimeline
7
+ * replayOperation → read-only frozen ReplayResult
8
+ *
9
+ * Privacy contract (SPEC §5): `summary` strings are assembled from the
10
+ * eventType plus a whitelist of enumerated code/state fields — raw
11
+ * `safePayload` text is NEVER copied into output. Values must match a
12
+ * bounded code pattern; anything else is dropped silently.
13
+ *
14
+ * Read-only guarantee: this module performs no writes, spawns, signals,
15
+ * or enqueues. Journal + contract are the only inputs. It performs no
16
+ * config reads (same convention as eventStore.js).
17
+ */
18
+
19
+ import { promises as fs } from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ import {
23
+ CONTRACT_VERSION,
24
+ OPERATION_STATES,
25
+ isTerminal,
26
+ validateTransition,
27
+ } from './contract.js';
28
+ import { readJournal, EventStoreError } from './eventStore.js';
29
+
30
+ export class DiagnosticsError extends Error {
31
+ constructor(code, message) {
32
+ super(message ?? code);
33
+ this.name = 'DiagnosticsError';
34
+ this.code = code;
35
+ }
36
+ }
37
+
38
+ const JOURNAL_DIR = 'events';
39
+ const STATE_SET = new Set(OPERATION_STATES);
40
+
41
+ const journalPath = (dir, operationId) =>
42
+ path.join(dir, JOURNAL_DIR, `${operationId}.jsonl`);
43
+
44
+ async function pathExists(p) {
45
+ try {
46
+ await fs.access(p);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /** Bounded code pattern — states, codes, digests only; never free text. */
54
+ const CODE_RE = /^[a-z0-9_.:-]{1,64}$/;
55
+
56
+ /** Whitelisted safePayload fields whose values are enumerated codes. */
57
+ const CODE_FIELDS = Object.freeze(['code', 'reason', 'status', 'kind', 'attempt']);
58
+ const NUMERIC_FIELDS = Object.freeze(['seq', 'step', 'attempt', 'count', 'durationMs']);
59
+
60
+ function codeValue(v) {
61
+ return typeof v === 'string' && CODE_RE.test(v) ? v : null;
62
+ }
63
+
64
+ /**
65
+ * Build a sanitized summary from eventType + enumerated code fields only.
66
+ * `from`/`to` are emitted only when they are known contract states.
67
+ */
68
+ function summarize(event) {
69
+ const parts = [event.eventType];
70
+ const p = event.safePayload;
71
+ if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
72
+ for (const f of ['from', 'to']) {
73
+ if (STATE_SET.has(p[f])) parts.push(`${f}=${p[f]}`);
74
+ }
75
+ for (const f of CODE_FIELDS) {
76
+ const v = codeValue(p[f]);
77
+ if (v !== null) parts.push(`${f}=${v}`);
78
+ }
79
+ for (const f of NUMERIC_FIELDS) {
80
+ if (Number.isFinite(p[f]) && !['from', 'to'].includes(f) && !CODE_FIELDS.includes(f)) {
81
+ parts.push(`${f}=${p[f]}`);
82
+ }
83
+ }
84
+ }
85
+ return parts.join(' ');
86
+ }
87
+
88
+ /**
89
+ * Read + validate the journal; throws DiagnosticsError('journal_missing')
90
+ * when absent and detects the truncated-tail flag the reader discards.
91
+ */
92
+ async function loadJournal(dir, operationId) {
93
+ const file = journalPath(dir, operationId);
94
+ if (!(await pathExists(file))) {
95
+ throw new DiagnosticsError('journal_missing', `no journal for ${operationId}`);
96
+ }
97
+ let raw;
98
+ try {
99
+ raw = await fs.readFile(file, 'utf8');
100
+ } catch (err) {
101
+ if (err && err.code === 'ENOENT') {
102
+ throw new DiagnosticsError('journal_missing', `no journal for ${operationId}`);
103
+ }
104
+ throw new DiagnosticsError('journal_unreadable', err && err.code ? err.code : 'read_failed');
105
+ }
106
+ const truncatedTail = !raw.endsWith('\n');
107
+ const events = [];
108
+ try {
109
+ for await (const e of readJournal(dir, operationId)) events.push(e);
110
+ } catch (err) {
111
+ if (err instanceof EventStoreError) {
112
+ throw new DiagnosticsError('malformed_event', err.code);
113
+ }
114
+ if (err && err.code === 'ENOENT') {
115
+ // file raced away after the guarded pre-read — same contract as :102
116
+ throw new DiagnosticsError('journal_missing', `no journal for ${operationId}`);
117
+ }
118
+ if (err && typeof err.code === 'string' && err.code !== '') {
119
+ // fs failure inside readJournal's own readFile (e.g. EACCES/EIO) —
120
+ // the DiagnosticsError contract covers the whole loadJournal surface
121
+ throw new DiagnosticsError('journal_unreadable', err.code);
122
+ }
123
+ throw err;
124
+ }
125
+ for (const e of events) {
126
+ if (e.contractVersion !== CONTRACT_VERSION) {
127
+ throw new DiagnosticsError(
128
+ 'unsupported_contract_version',
129
+ `event seq ${e.seq} contractVersion ${e.contractVersion}`,
130
+ );
131
+ }
132
+ }
133
+ return { events, truncatedTail };
134
+ }
135
+
136
+ const gap = (seq, code, detail) => Object.freeze({ seq, code, detail });
137
+
138
+ /**
139
+ * Fold transition events through validateTransition. Returns
140
+ * { derivedState, lastSeq, gaps, boundarySeq } — folding stops at the
141
+ * first replay boundary; events after it are recorded as gaps, never
142
+ * used to infer state (SPEC §7).
143
+ */
144
+ function foldTransitions(events) {
145
+ const gaps = [];
146
+ let state = null;
147
+ let lastSeq = 0;
148
+ let boundarySeq = null;
149
+
150
+ for (const e of events) {
151
+ lastSeq = e.seq;
152
+ if (/^(model\.|tool\.)/.test(e.eventType)) {
153
+ gaps.push(gap(e.seq, 'external_input', `${e.eventType} output is nondeterministic; not re-derived`));
154
+ continue;
155
+ }
156
+ if (boundarySeq !== null) {
157
+ // everything past the boundary is display-only, never inferred
158
+ if (e.eventType === 'operation.transition') {
159
+ gaps.push(gap(e.seq, 'replay_boundary', 'transition after recovery boundary ignored'));
160
+ }
161
+ continue;
162
+ }
163
+ if (e.eventType !== 'operation.transition') continue;
164
+ const to = e.safePayload?.to;
165
+ if (!STATE_SET.has(to)) {
166
+ gaps.push(gap(e.seq, 'replay_boundary', 'transition missing target state'));
167
+ boundarySeq = e.seq;
168
+ continue;
169
+ }
170
+ const prev = state === null ? (STATE_SET.has(e.safePayload?.from) ? e.safePayload.from : 'queued') : state;
171
+ const ctx = { fencingEpoch: Number.isInteger(e.safePayload?.fencingEpoch) ? e.safePayload.fencingEpoch : e.seq };
172
+ const v = validateTransition(prev, to, ctx);
173
+ if (!v.ok) {
174
+ gaps.push(gap(e.seq, 'replay_boundary', `transition ${prev}→${to} rejected: ${v.code}`));
175
+ boundarySeq = e.seq;
176
+ continue;
177
+ }
178
+ state = to;
179
+ if (to === 'recovery_required') {
180
+ gaps.push(gap(e.seq, 'recovery_boundary', 'recovery_required is never resolved by replay alone'));
181
+ boundarySeq = e.seq;
182
+ }
183
+ }
184
+ return { derivedState: state, lastSeq, gaps, boundarySeq };
185
+ }
186
+
187
+ function classifyReplayable(derivedState, gaps, events) {
188
+ if (derivedState === null) {
189
+ return events.length === 0 ? 'none' : 'partial'; // start_unconfirmed window
190
+ }
191
+ if (isTerminal(derivedState) && gaps.length === 0) return 'full';
192
+ return 'partial';
193
+ }
194
+
195
+ /**
196
+ * G7-FR01 — deterministic frozen SanitizedTimeline.
197
+ *
198
+ * @param {string} dir store root
199
+ * @param {string} operationId
200
+ * @returns {Promise<object>} SanitizedTimeline (SPEC §4)
201
+ */
202
+ export async function renderOperationTimeline(dir, operationId, _opts = {}) {
203
+ const { events, truncatedTail } = await loadJournal(dir, operationId);
204
+ const steps = events.map((e) => Object.freeze({
205
+ seq: e.seq,
206
+ eventType: e.eventType,
207
+ observedAt: e.observedAt,
208
+ summary: summarize(e),
209
+ artifactRefs: Object.freeze(
210
+ (Array.isArray(e.artifactRefs) ? e.artifactRefs : []).filter((r) => typeof r === 'string'),
211
+ ),
212
+ }));
213
+ const { derivedState, gaps } = foldTransitions(events);
214
+ return Object.freeze({
215
+ version: 1,
216
+ operationId,
217
+ steps: Object.freeze(steps),
218
+ terminalState: isTerminal(derivedState) ? derivedState : null,
219
+ journalTruncated: truncatedTail,
220
+ gaps: Object.freeze(gaps),
221
+ });
222
+ }
223
+
224
+ /**
225
+ * G7-FR02 — read-only logical replay. MUST NOT spawn, signal, write, or
226
+ * enqueue; the returned frozen object is the only output (SPEC §7).
227
+ *
228
+ * @param {string} dir store root
229
+ * @param {string} operationId
230
+ * @returns {Promise<object>} ReplayResult (SPEC §4)
231
+ */
232
+ export async function replayOperation(dir, operationId, _opts = {}) {
233
+ const { events } = await loadJournal(dir, operationId);
234
+ const { derivedState, lastSeq, gaps } = foldTransitions(events);
235
+ const replayable = classifyReplayable(derivedState, gaps, events);
236
+ return Object.freeze({
237
+ version: 1,
238
+ operationId,
239
+ derivedState: derivedState ?? 'unknown',
240
+ lastSeq,
241
+ replayable,
242
+ gaps: Object.freeze(gaps),
243
+ });
244
+ }