@ddse/acm-runtime 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +393 -0
  3. package/dist/src/checkpoint.d.ts +97 -0
  4. package/dist/src/checkpoint.d.ts.map +1 -0
  5. package/dist/src/checkpoint.js +200 -0
  6. package/dist/src/checkpoint.js.map +1 -0
  7. package/dist/src/execution-transcript.d.ts +30 -0
  8. package/dist/src/execution-transcript.d.ts.map +1 -0
  9. package/dist/src/execution-transcript.js +70 -0
  10. package/dist/src/execution-transcript.js.map +1 -0
  11. package/dist/src/executor.d.ts +49 -0
  12. package/dist/src/executor.d.ts.map +1 -0
  13. package/dist/src/executor.js +390 -0
  14. package/dist/src/executor.js.map +1 -0
  15. package/dist/src/guards.d.ts +7 -0
  16. package/dist/src/guards.d.ts.map +1 -0
  17. package/dist/src/guards.js +13 -0
  18. package/dist/src/guards.js.map +1 -0
  19. package/dist/src/index.d.ts +9 -0
  20. package/dist/src/index.d.ts.map +1 -0
  21. package/dist/src/index.js +10 -0
  22. package/dist/src/index.js.map +1 -0
  23. package/dist/src/ledger.d.ts +12 -0
  24. package/dist/src/ledger.d.ts.map +1 -0
  25. package/dist/src/ledger.js +53 -0
  26. package/dist/src/ledger.js.map +1 -0
  27. package/dist/src/resumable-executor.d.ts +39 -0
  28. package/dist/src/resumable-executor.d.ts.map +1 -0
  29. package/dist/src/resumable-executor.js +354 -0
  30. package/dist/src/resumable-executor.js.map +1 -0
  31. package/dist/src/retry.d.ts +7 -0
  32. package/dist/src/retry.d.ts.map +1 -0
  33. package/dist/src/retry.js +25 -0
  34. package/dist/src/retry.js.map +1 -0
  35. package/dist/src/tool-envelope.d.ts +14 -0
  36. package/dist/src/tool-envelope.d.ts.map +1 -0
  37. package/dist/src/tool-envelope.js +84 -0
  38. package/dist/src/tool-envelope.js.map +1 -0
  39. package/dist/tests/resumable.test.d.ts +2 -0
  40. package/dist/tests/resumable.test.d.ts.map +1 -0
  41. package/dist/tests/resumable.test.js +337 -0
  42. package/dist/tests/resumable.test.js.map +1 -0
  43. package/dist/tsconfig.tsbuildinfo +1 -0
  44. package/package.json +29 -0
  45. package/src/checkpoint.ts +311 -0
  46. package/src/execution-transcript.ts +108 -0
  47. package/src/executor.ts +540 -0
  48. package/src/guards.ts +21 -0
  49. package/src/index.ts +9 -0
  50. package/src/ledger.ts +63 -0
  51. package/src/resumable-executor.ts +471 -0
  52. package/src/retry.ts +37 -0
  53. package/src/tool-envelope.ts +113 -0
  54. package/tests/resumable.test.ts +421 -0
  55. package/tsconfig.json +11 -0
@@ -0,0 +1,540 @@
1
+ // Main execution engine
2
+ import {
3
+ InternalContextScopeImpl,
4
+ type CapabilityRegistry,
5
+ type Context,
6
+ type Goal,
7
+ type LedgerEntry,
8
+ ExternalContextProviderAdapter,
9
+ type NucleusConfig,
10
+ type NucleusFactory,
11
+ type PostcheckResult,
12
+ type Plan,
13
+ type PolicyEngine,
14
+ type RunContext,
15
+ type StreamSink,
16
+ type ToolRegistry,
17
+ } from '@ddse/acm-sdk';
18
+ import { evaluateGuard } from './guards.js';
19
+ import { MemoryLedger } from './ledger.js';
20
+ import { withRetry } from './retry.js';
21
+ import { createInstrumentedToolGetter } from './tool-envelope.js';
22
+
23
+ export type TaskNarrative = {
24
+ reasoning?: string[];
25
+ postcheck?: {
26
+ status: PostcheckResult['status'];
27
+ reason?: string;
28
+ };
29
+ };
30
+
31
+ export type TaskExecutionRecord = {
32
+ output: any;
33
+ narrative?: TaskNarrative;
34
+ };
35
+
36
+ export type ExecutePlanOptions = {
37
+ goal: Goal;
38
+ context: Context;
39
+ plan: Plan;
40
+ capabilityRegistry: CapabilityRegistry;
41
+ toolRegistry: ToolRegistry;
42
+ policy?: PolicyEngine;
43
+ verify?: (taskId: string, output: any, expressions: string[]) => Promise<boolean>;
44
+ stream?: StreamSink;
45
+ ledger?: MemoryLedger;
46
+ nucleusFactory: NucleusFactory;
47
+ nucleusConfig: {
48
+ llmCall: NucleusConfig['llmCall'];
49
+ hooks?: NucleusConfig['hooks'];
50
+ allowedTools?: string[];
51
+ };
52
+ contextProvider?: ExternalContextProviderAdapter;
53
+ };
54
+
55
+ export type ExecutePlanResult = {
56
+ outputsByTask: Record<string, TaskExecutionRecord>;
57
+ ledger: readonly LedgerEntry[];
58
+ goalSummary?: string;
59
+ };
60
+
61
+ export async function executePlan(options: ExecutePlanOptions): Promise<ExecutePlanResult> {
62
+ const {
63
+ goal,
64
+ context,
65
+ plan,
66
+ capabilityRegistry,
67
+ toolRegistry,
68
+ policy,
69
+ verify,
70
+ stream,
71
+ nucleusFactory,
72
+ nucleusConfig,
73
+ contextProvider,
74
+ } = options;
75
+
76
+ const ledger = options.ledger ?? new MemoryLedger();
77
+ const outputs: Record<string, any> = {};
78
+ const executionRecords: Record<string, TaskExecutionRecord> = {};
79
+ const policyContext: Record<string, any> = {};
80
+ const metrics = { costUsd: 0, elapsedSec: 0 };
81
+ const startTime = Date.now();
82
+
83
+ // Log plan selection
84
+ ledger.append('PLAN_SELECTED', {
85
+ planId: plan.id,
86
+ contextRef: plan.contextRef,
87
+ capabilityMapVersion: plan.capabilityMapVersion,
88
+ });
89
+
90
+ // Build execution order based on edges
91
+ const executed = new Set<string>();
92
+ const pending = [...plan.tasks];
93
+
94
+ while (pending.length > 0) {
95
+ const readyTasks = pending.filter(taskSpec => {
96
+ // Check if all dependencies are satisfied
97
+ const incomingEdges = plan.edges.filter(e => e.to === taskSpec.id);
98
+ if (incomingEdges.length === 0) {
99
+ return true; // No dependencies
100
+ }
101
+
102
+ return incomingEdges.every(edge => {
103
+ if (!executed.has(edge.from)) {
104
+ return false;
105
+ }
106
+
107
+ // Evaluate guard if present
108
+ if (edge.guard) {
109
+ const guardResult = evaluateGuard(edge.guard, {
110
+ context,
111
+ outputs,
112
+ policy: policyContext,
113
+ });
114
+
115
+ ledger.append('GUARD_EVAL', {
116
+ edge: `${edge.from}->${edge.to}`,
117
+ guard: edge.guard,
118
+ result: guardResult,
119
+ });
120
+
121
+ return guardResult;
122
+ }
123
+
124
+ return true;
125
+ });
126
+ });
127
+
128
+ if (readyTasks.length === 0) {
129
+ break; // No more tasks can be executed
130
+ }
131
+
132
+ // Execute ready tasks
133
+ for (const taskSpec of readyTasks) {
134
+ pending.splice(pending.indexOf(taskSpec), 1);
135
+
136
+ const capabilityName = taskSpec.capabilityRef || taskSpec.capability;
137
+ if (!capabilityName) {
138
+ throw new Error(`Task ${taskSpec.id} missing capability reference`);
139
+ }
140
+
141
+ const task = capabilityRegistry.resolve(capabilityName);
142
+ if (!task) {
143
+ throw new Error(`Task not found for capability: ${capabilityName}`);
144
+ }
145
+
146
+ const nucleusAllowedTools = new Set<string>(nucleusConfig.allowedTools ?? []);
147
+ for (const tool of taskSpec.tools ?? []) {
148
+ nucleusAllowedTools.add(tool.name);
149
+ }
150
+
151
+ const nucleus = nucleusFactory({
152
+ goalId: goal.id,
153
+ goalIntent: goal.intent,
154
+ planId: plan.id,
155
+ taskId: taskSpec.id,
156
+ contextRef: plan.contextRef,
157
+ context,
158
+ llmCall: nucleusConfig.llmCall,
159
+ hooks: nucleusConfig.hooks,
160
+ allowedTools: Array.from(nucleusAllowedTools),
161
+ });
162
+
163
+ const internalScope = new InternalContextScopeImpl(entry => {
164
+ ledger.append(entry.type, entry.details);
165
+ });
166
+ nucleus.setInternalContext(internalScope);
167
+
168
+ const getTool = createInstrumentedToolGetter({
169
+ taskId: taskSpec.id,
170
+ capability: capabilityName,
171
+ toolRegistry,
172
+ ledger,
173
+ });
174
+
175
+ // Build run context
176
+ const runContext: RunContext = {
177
+ goal,
178
+ context,
179
+ outputs,
180
+ metrics,
181
+ getTool,
182
+ getCapabilityRegistry: () => capabilityRegistry,
183
+ stream,
184
+ nucleus,
185
+ internalContext: internalScope,
186
+ };
187
+
188
+ let preflight = await nucleus.preflight();
189
+ if (preflight.status === 'NEEDS_CONTEXT') {
190
+ const requestedDirectives = preflight.retrievalDirectives;
191
+
192
+ ledger.append('CONTEXT_INTERNALIZED', {
193
+ taskId: taskSpec.id,
194
+ directives: requestedDirectives,
195
+ status: contextProvider ? 'requested' : 'unhandled',
196
+ });
197
+
198
+ if (!contextProvider) {
199
+ throw new Error(
200
+ `Task ${taskSpec.id} requires additional context retrieval: ${preflight.retrievalDirectives.join(', ')}`
201
+ );
202
+ }
203
+
204
+ await contextProvider.fulfill({
205
+ directives: requestedDirectives,
206
+ scope: internalScope,
207
+ runContext,
208
+ nucleus,
209
+ });
210
+
211
+ preflight = await nucleus.preflight();
212
+ if (preflight.status === 'NEEDS_CONTEXT') {
213
+ throw new Error(
214
+ `Task ${taskSpec.id} still requires additional context after adapter execution: ${preflight.retrievalDirectives.join(', ')}`
215
+ );
216
+ }
217
+
218
+ ledger.append('CONTEXT_INTERNALIZED', {
219
+ taskId: taskSpec.id,
220
+ directives: requestedDirectives,
221
+ status: 'resolved',
222
+ });
223
+ }
224
+
225
+ // Policy pre-check
226
+ if (policy) {
227
+ const policyInput = task.policyInput?.(runContext, taskSpec.input) ?? {};
228
+ const decision = await policy.evaluate('task.pre', {
229
+ taskId: taskSpec.id,
230
+ capability: taskSpec.capability,
231
+ ...policyInput,
232
+ });
233
+
234
+ ledger.append('POLICY_PRE', {
235
+ taskId: taskSpec.id,
236
+ decision,
237
+ });
238
+
239
+ if (!decision.allow) {
240
+ throw new Error(`Policy denied task ${taskSpec.id}: ${decision.reason}`);
241
+ }
242
+
243
+ policyContext[taskSpec.id] = decision;
244
+ }
245
+
246
+ // Execute task with retry
247
+ const ledgerBaseline = ledger.getEntries().length;
248
+
249
+ ledger.append('TASK_START', {
250
+ taskId: taskSpec.id,
251
+ capability: capabilityName,
252
+ input: taskSpec.input,
253
+ });
254
+
255
+ stream?.emit('task', { taskId: taskSpec.id, status: 'running' });
256
+
257
+ try {
258
+ const executeTask = async () => task.execute(runContext, taskSpec.input);
259
+ const retryConfig = taskSpec.retry || (taskSpec.retryPolicy
260
+ ? {
261
+ attempts: taskSpec.retryPolicy.maxAttempts || 3,
262
+ backoff: 'exp' as const,
263
+ }
264
+ : undefined);
265
+
266
+ const output = retryConfig ? await withRetry(executeTask, retryConfig) : await executeTask();
267
+ outputs[taskSpec.id] = output;
268
+
269
+ // Policy post-check
270
+ if (policy) {
271
+ const decision = await policy.evaluate('task.post', {
272
+ taskId: taskSpec.id,
273
+ output,
274
+ });
275
+
276
+ ledger.append('POLICY_POST', {
277
+ taskId: taskSpec.id,
278
+ decision,
279
+ });
280
+ }
281
+
282
+ // Verification
283
+ if (verify && taskSpec.verification && taskSpec.verification.length > 0) {
284
+ const verified = await verify(taskSpec.id, output, taskSpec.verification);
285
+
286
+ ledger.append('VERIFICATION', {
287
+ taskId: taskSpec.id,
288
+ expressions: taskSpec.verification,
289
+ result: verified,
290
+ });
291
+
292
+ if (!verified) {
293
+ throw new Error(`Verification failed for task ${taskSpec.id}`);
294
+ }
295
+ }
296
+
297
+ const postcheck = await nucleus.postcheck(output);
298
+ if (postcheck.status === 'NEEDS_COMPENSATION') {
299
+ ledger.append('ERROR', {
300
+ taskId: taskSpec.id,
301
+ stage: 'NUCLEUS_POSTCHECK',
302
+ message: postcheck.reason,
303
+ });
304
+ throw new Error(`Task ${taskSpec.id} requires compensation: ${postcheck.reason}`);
305
+ }
306
+ if (postcheck.status === 'ESCALATE') {
307
+ ledger.append('ERROR', {
308
+ taskId: taskSpec.id,
309
+ stage: 'NUCLEUS_POSTCHECK',
310
+ message: postcheck.reason,
311
+ });
312
+ throw new Error(`Task ${taskSpec.id} escalated: ${postcheck.reason}`);
313
+ }
314
+
315
+ const narrative = buildTaskNarrative(ledger, ledgerBaseline, taskSpec.id, postcheck);
316
+ executionRecords[taskSpec.id] = {
317
+ output,
318
+ narrative,
319
+ };
320
+
321
+ ledger.append('TASK_END', {
322
+ taskId: taskSpec.id,
323
+ output,
324
+ narrative,
325
+ });
326
+
327
+ stream?.emit('task', { taskId: taskSpec.id, status: 'completed', output, narrative });
328
+
329
+ executed.add(taskSpec.id);
330
+ } catch (error: any) {
331
+ ledger.append('ERROR', {
332
+ taskId: taskSpec.id,
333
+ capability: capabilityName,
334
+ message: error.message || 'Unknown error',
335
+ });
336
+
337
+ stream?.emit('task', {
338
+ taskId: taskSpec.id,
339
+ status: 'failed',
340
+ error: error.message || error.toString(),
341
+ });
342
+
343
+ throw error;
344
+ }
345
+ }
346
+ }
347
+
348
+ const goalSummary = await synthesizeGoalSummary({
349
+ goal,
350
+ plan,
351
+ executionRecords,
352
+ context,
353
+ nucleusFactory,
354
+ nucleusConfig,
355
+ ledger,
356
+ stream,
357
+ });
358
+
359
+ metrics.elapsedSec = (Date.now() - startTime) / 1000;
360
+
361
+ return {
362
+ outputsByTask: executionRecords,
363
+ ledger: ledger.getEntries(),
364
+ goalSummary,
365
+ };
366
+ }
367
+
368
+ export function buildTaskNarrative(
369
+ ledger: MemoryLedger,
370
+ baselineIndex: number,
371
+ taskId: string,
372
+ postcheck: PostcheckResult
373
+ ): TaskNarrative | undefined {
374
+ const entries = ledger.getEntries().slice(baselineIndex);
375
+ const reasonings = entries
376
+ .filter(entry => entry.type === 'NUCLEUS_INFERENCE')
377
+ .filter(entry => entry.details?.nucleus?.taskId === taskId)
378
+ .map(entry => (typeof entry.details?.reasoning === 'string' ? entry.details.reasoning.trim() : undefined))
379
+ .filter((text): text is string => Boolean(text && text.length > 0));
380
+
381
+ const narrative: TaskNarrative = {};
382
+ if (reasonings.length > 0) {
383
+ narrative.reasoning = reasonings;
384
+ }
385
+
386
+ narrative.postcheck = {
387
+ status: postcheck.status,
388
+ ...(postcheck.status !== 'COMPLETE' && 'reason' in postcheck
389
+ ? { reason: (postcheck as Extract<PostcheckResult, { reason: string }>).reason }
390
+ : {}),
391
+ } as TaskNarrative['postcheck'];
392
+
393
+ if (!narrative.reasoning && !narrative.postcheck?.reason && narrative.postcheck?.status === 'COMPLETE') {
394
+ // Return undefined if narrative is completely empty beyond a routine COMPLETE status
395
+ return undefined;
396
+ }
397
+
398
+ return narrative;
399
+ }
400
+
401
+ export async function synthesizeGoalSummary(options: {
402
+ goal: Goal;
403
+ plan: Plan;
404
+ executionRecords: Record<string, TaskExecutionRecord>;
405
+ context: Context;
406
+ nucleusFactory: NucleusFactory;
407
+ nucleusConfig: ExecutePlanOptions['nucleusConfig'];
408
+ ledger: MemoryLedger;
409
+ stream?: StreamSink;
410
+ }): Promise<string | undefined> {
411
+ const { goal, plan, executionRecords, context, nucleusFactory, nucleusConfig, ledger, stream } = options;
412
+
413
+ if (plan.tasks.length === 0 || Object.keys(executionRecords).length === 0) {
414
+ return undefined;
415
+ }
416
+
417
+ try {
418
+ const allowedTools = Array.from(new Set(nucleusConfig.allowedTools ?? []));
419
+ const nucleus = nucleusFactory({
420
+ goalId: goal.id,
421
+ goalIntent: goal.intent,
422
+ planId: plan.id,
423
+ taskId: 'goal-summary',
424
+ contextRef: plan.contextRef,
425
+ context,
426
+ llmCall: nucleusConfig.llmCall,
427
+ hooks: nucleusConfig.hooks,
428
+ allowedTools,
429
+ });
430
+
431
+ const prompt = buildGoalSummaryPrompt(goal, plan, executionRecords, context);
432
+ const result = await nucleus.invoke({ prompt, tools: [] });
433
+ const summary = result.reasoning?.trim() ?? '';
434
+ const normalizedSummary = summary.length > 0 ? summary : undefined;
435
+
436
+ ledger.append('GOAL_SUMMARY', {
437
+ goalId: goal.id,
438
+ planId: plan.id,
439
+ summary: normalizedSummary,
440
+ tasks: plan.tasks.map(task => {
441
+ const record = executionRecords[task.id];
442
+ return {
443
+ id: task.id,
444
+ title: task.title,
445
+ objective: task.objective,
446
+ successCriteria: task.successCriteria,
447
+ outputPreview: record ? previewForSummary(record.output) : undefined,
448
+ postcheck: record?.narrative?.postcheck,
449
+ };
450
+ }),
451
+ });
452
+
453
+ if (normalizedSummary) {
454
+ stream?.emit('summary', {
455
+ goalId: goal.id,
456
+ planId: plan.id,
457
+ summary: normalizedSummary,
458
+ });
459
+ }
460
+
461
+ return normalizedSummary;
462
+ } catch (error: any) {
463
+ ledger.append('ERROR', {
464
+ stage: 'GOAL_SUMMARY',
465
+ message: error?.message ?? 'Failed to synthesize goal summary',
466
+ });
467
+ return undefined;
468
+ }
469
+ }
470
+
471
+ function buildGoalSummaryPrompt(
472
+ goal: Goal,
473
+ plan: Plan,
474
+ executionRecords: Record<string, TaskExecutionRecord>,
475
+ context: Context
476
+ ): string {
477
+ const goalIntent = goal.intent ?? goal.id;
478
+ const contextFacts = context?.facts ? JSON.stringify(context.facts, null, 2) : '{}';
479
+ const contextAssumptions = context?.assumptions?.length
480
+ ? `Assumptions:\n- ${context.assumptions.join('\n- ')}`
481
+ : 'Assumptions: none provided';
482
+
483
+ const taskSections = plan.tasks.map(task => {
484
+ const record = executionRecords[task.id];
485
+ const label = task.title || task.capabilityRef || task.capability || task.id;
486
+ const pieces = [
487
+ `Task ${task.id}: ${label}`,
488
+ task.objective ? `Objective: ${task.objective}` : undefined,
489
+ task.successCriteria && task.successCriteria.length > 0
490
+ ? `Success Criteria: ${task.successCriteria.join('; ')}`
491
+ : undefined,
492
+ record ? `Outcome: ${previewForSummary(record.output)}` : 'Outcome: not captured.',
493
+ record?.narrative?.reasoning?.length
494
+ ? `Narrative: ${record.narrative.reasoning.join(' ')}`
495
+ : undefined,
496
+ record?.narrative?.postcheck
497
+ ? `Postcheck: ${record.narrative.postcheck.status}${record.narrative.postcheck.reason ? ` (${record.narrative.postcheck.reason})` : ''}`
498
+ : undefined,
499
+ ].filter(Boolean);
500
+
501
+ return pieces.join('\n');
502
+ });
503
+
504
+ return `You are composing the wrap-up for an ACM execution run.
505
+ Goal intent: ${goalIntent}
506
+ Context reference: ${plan.contextRef}
507
+
508
+ Context facts:
509
+ ${contextFacts}
510
+ ${contextAssumptions}
511
+
512
+ Summarize the outcome in 2-3 sentences for the operator. Highlight what happened, any remaining risks or follow-up, and reference task achievements when relevant.
513
+
514
+ Task outcomes:
515
+ ${taskSections.join('\n\n')}`;
516
+ }
517
+
518
+ function previewForSummary(value: any, maxLength = 240): string {
519
+ if (value === null || value === undefined) {
520
+ return 'No output provided.';
521
+ }
522
+
523
+ let text: string;
524
+ if (typeof value === 'string') {
525
+ text = value.trim();
526
+ } else if (typeof value === 'number' || typeof value === 'boolean') {
527
+ text = String(value);
528
+ } else {
529
+ try {
530
+ text = JSON.stringify(value);
531
+ } catch {
532
+ text = String(value);
533
+ }
534
+ }
535
+
536
+ if (text.length <= maxLength) {
537
+ return text;
538
+ }
539
+ return `${text.slice(0, maxLength - 1)}…`;
540
+ }
package/src/guards.ts ADDED
@@ -0,0 +1,21 @@
1
+ // Guard evaluator
2
+ import type { GuardExpr, Context } from '@ddse/acm-sdk';
3
+
4
+ export function evaluateGuard(
5
+ expr: GuardExpr,
6
+ context: {
7
+ context: Context;
8
+ outputs: Record<string, any>;
9
+ policy: Record<string, any>;
10
+ }
11
+ ): boolean {
12
+ try {
13
+ // Simple expression evaluation
14
+ // Support basic comparisons and logical operators
15
+ const func = new Function('context', 'outputs', 'policy', `return ${expr};`);
16
+ return func(context.context, context.outputs, context.policy);
17
+ } catch (err) {
18
+ console.error(`Guard evaluation failed: ${expr}`, err);
19
+ return false;
20
+ }
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Runtime exports
2
+ export * from './executor.js';
3
+ export * from './guards.js';
4
+ export * from './ledger.js';
5
+ export * from './retry.js';
6
+ export * from './checkpoint.js';
7
+ export * from './resumable-executor.js';
8
+ export * from './tool-envelope.js';
9
+ export * from './execution-transcript.js';
package/src/ledger.ts ADDED
@@ -0,0 +1,63 @@
1
+ // Memory ledger implementation
2
+ import type { LedgerEntry } from '@ddse/acm-sdk';
3
+ import { createHash } from 'crypto';
4
+
5
+ export class MemoryLedger {
6
+ private entries: LedgerEntry[] = [];
7
+ private nextId = 1;
8
+
9
+ append(type: LedgerEntry['type'], details: Record<string, any>, computeDigest = true): LedgerEntry {
10
+ const entry: LedgerEntry = {
11
+ id: `ledger-${this.nextId++}`,
12
+ ts: Date.now(),
13
+ type,
14
+ details,
15
+ };
16
+
17
+ // Compute digest for tamper detection if requested
18
+ if (computeDigest) {
19
+ entry.digest = this.computeDigest(entry);
20
+ }
21
+
22
+ this.entries.push(entry);
23
+ return entry;
24
+ }
25
+
26
+ getEntries(): readonly LedgerEntry[] {
27
+ return [...this.entries];
28
+ }
29
+
30
+ getEntriesByType(type: LedgerEntry['type']): readonly LedgerEntry[] {
31
+ return this.entries.filter(e => e.type === type);
32
+ }
33
+
34
+ clear(): void {
35
+ this.entries = [];
36
+ }
37
+
38
+ // Validate ledger integrity
39
+ validate(): boolean {
40
+ for (const entry of this.entries) {
41
+ if (entry.digest) {
42
+ const computed = this.computeDigest(entry);
43
+ if (computed !== entry.digest) {
44
+ console.error(`Ledger entry ${entry.id} failed integrity check`);
45
+ return false;
46
+ }
47
+ }
48
+ }
49
+ return true;
50
+ }
51
+
52
+ private computeDigest(entry: LedgerEntry): string {
53
+ const normalized = JSON.stringify({
54
+ id: entry.id,
55
+ ts: entry.ts,
56
+ type: entry.type,
57
+ details: entry.details,
58
+ });
59
+ const hash = createHash('sha256');
60
+ hash.update(normalized);
61
+ return hash.digest('hex').substring(0, 32);
62
+ }
63
+ }