@ngockhoale/ukit 3.1.9 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/memory.js +11 -87
  4. package/src/cli/commands/selfImprove.js +55 -0
  5. package/src/cli/commands/telemetry.js +45 -3
  6. package/src/cli/index.js +7 -0
  7. package/src/core/agentRuntime/adapters.js +177 -0
  8. package/src/core/agentRuntime/contract.js +77 -0
  9. package/src/core/agentRuntime/diagnostics.js +343 -42
  10. package/src/core/agentRuntime/eventStore.js +139 -0
  11. package/src/core/agentRuntime/planCompiler.js +45 -6
  12. package/src/core/agentRuntime/planLibrary.js +43 -7
  13. package/src/core/agentRuntime/plans/bugfix-loop.json +1 -0
  14. package/src/core/agentRuntime/plans/flag-promotion.json +138 -0
  15. package/src/core/agentRuntime/plans/handoff-review-batch.json +1 -0
  16. package/src/core/agentRuntime/plans/release-check.json +2 -1
  17. package/src/core/agentRuntime/promotion.js +147 -9
  18. package/src/core/agentRuntime/runtimeSupport.js +18 -0
  19. package/src/core/agentRuntime/supervisor.js +44 -2
  20. package/src/core/agentRuntime/telemetry.js +123 -0
  21. package/src/core/agentRuntime/vmEngine.js +51 -10
  22. package/src/core/memory/episodes.js +168 -0
  23. package/src/core/metadata.js +21 -0
  24. package/src/core/runInstallPipeline.js +6 -1
  25. package/src/core/runtimeConfig.js +71 -40
  26. package/src/decision/client.js +4 -5
  27. package/src/decision/runtimeDecide.js +183 -5
  28. package/src/learning/selfImprove.js +205 -0
  29. package/src/learning/tunedOverlay.js +112 -0
  30. package/template_project/.claude/hooks/session-episode.sh +35 -15
  31. package/template_project/.claude/ukit/index/route-task.mjs +13 -0
  32. package/template_project/.claude/ukit/index/unic-decision.mjs +1 -2
  33. package/template_project/.claude/ukit/runtime/self-improve-trigger.mjs +98 -0
  34. package/template_project/ukit/storage/config.json +68 -17
@@ -5,15 +5,13 @@
5
5
  * Two pure readers over the operation journal:
6
6
  * renderOperationTimeline → deterministic frozen SanitizedTimeline
7
7
  * replayOperation → read-only frozen ReplayResult
8
- *
9
8
  * Privacy contract (SPEC §5): `summary` strings are assembled from the
10
9
  * eventType plus a whitelist of enumerated code/state fields — raw
11
10
  * `safePayload` text is NEVER copied into output. Values must match a
12
11
  * bounded code pattern; anything else is dropped silently.
13
12
  *
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).
13
+ * Read-only guarantee (readers): the two readers perform no writes,
14
+ * spawns, signals, or enqueues. Journal + contract are the only inputs.
17
15
  *
18
16
  * DF2-FR07 (TASK-008): this module is ALSO the diagnostics-side emit
19
17
  * surface — `emitModelAttempt` and `emitVerificationCompleted` turn a
@@ -21,10 +19,16 @@
21
19
  * recorder records parented onto the caller's run span ctx. Emit helpers
22
20
  * are synchronous, never throw, and never write to journals — the
23
21
  * read-only contract above still holds for the two readers.
22
+ *
23
+ * V-06: `exportSupportBundle` is the one WRITER in this module — a
24
+ * stage-gated (`decisionRuntime.diagnostics`) sanitized support-bundle
25
+ * assembler; see the section comment above its definition. It resolves
26
+ * the injected config's stage rather than reading config itself.
24
27
  */
25
28
 
26
29
  import { promises as fs } from 'node:fs';
27
30
  import path from 'node:path';
31
+ import crypto from 'node:crypto';
28
32
 
29
33
  import {
30
34
  CONTRACT_VERSION,
@@ -32,8 +36,28 @@ import {
32
36
  isTerminal,
33
37
  validateTransition,
34
38
  } from './contract.js';
35
- import { readJournal, EventStoreError } from './eventStore.js';
36
- import { makeSpanContext, emitSpanRecord, typedResource, registryCode } from './telemetry.js';
39
+ import {
40
+ readJournal,
41
+ readJournalExcerpt,
42
+ summarizeEvent,
43
+ EventStoreError,
44
+ } from './eventStore.js';
45
+ import {
46
+ makeSpanContext,
47
+ emitSpanRecord,
48
+ typedResource,
49
+ registryCode,
50
+ buildTraceExcerpt,
51
+ projectRootFromRuntimeDir,
52
+ } from './telemetry.js';
53
+ import { resolveDecisionRuntimeStage } from '../runtimeConfig.js';
54
+ import { scanText } from '../sensitiveValueScanner.js';
55
+ import { writeFileAtomic } from '../fileOps.js';
56
+ import { ioReason } from '../observability/segments/internal.js';
57
+ import { resolveSupportDir } from '../observability/support/paths.js';
58
+ import { buildManifest } from '../observability/support/manifest.js';
59
+ import { projectDirName } from '../observability/support/projector.js';
60
+ import { redactString } from '../observability/privacy/redaction.js';
37
61
 
38
62
  export class DiagnosticsError extends Error {
39
63
  constructor(code, message) {
@@ -58,41 +82,9 @@ async function pathExists(p) {
58
82
  }
59
83
  }
60
84
 
61
- /** Bounded code pattern — states, codes, digests only; never free text. */
62
- const CODE_RE = /^[a-z0-9_.:-]{1,64}$/;
63
-
64
- /** Whitelisted safePayload fields whose values are enumerated codes. */
65
- const CODE_FIELDS = Object.freeze(['code', 'reason', 'status', 'kind', 'attempt']);
66
- const NUMERIC_FIELDS = Object.freeze(['seq', 'step', 'attempt', 'count', 'durationMs']);
67
-
68
- function codeValue(v) {
69
- return typeof v === 'string' && CODE_RE.test(v) ? v : null;
70
- }
71
-
72
- /**
73
- * Build a sanitized summary from eventType + enumerated code fields only.
74
- * `from`/`to` are emitted only when they are known contract states.
75
- */
76
- function summarize(event) {
77
- const parts = [event.eventType];
78
- const p = event.safePayload;
79
- if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
80
- for (const f of ['from', 'to']) {
81
- if (STATE_SET.has(p[f])) parts.push(`${f}=${p[f]}`);
82
- }
83
- for (const f of CODE_FIELDS) {
84
- const v = codeValue(p[f]);
85
- if (v !== null) parts.push(`${f}=${v}`);
86
- }
87
- for (const f of NUMERIC_FIELDS) {
88
- if (Number.isFinite(p[f]) && !['from', 'to'].includes(f) && !CODE_FIELDS.includes(f)) {
89
- parts.push(`${f}=${p[f]}`);
90
- }
91
- }
92
- }
93
- return parts.join(' ');
94
- }
95
-
85
+ // The privacy whitelist (CODE_RE/code fields) and summarizeEvent live in
86
+ // eventStore.js — one convention shared by the timeline reader and the
87
+ // support-bundle journal excerpt.
96
88
  /**
97
89
  * Read + validate the journal; throws DiagnosticsError('journal_missing')
98
90
  * when absent and detects the truncated-tail flag the reader discards.
@@ -213,7 +205,7 @@ export async function renderOperationTimeline(dir, operationId, _opts = {}) {
213
205
  seq: e.seq,
214
206
  eventType: e.eventType,
215
207
  observedAt: e.observedAt,
216
- summary: summarize(e),
208
+ summary: summarizeEvent(e),
217
209
  artifactRefs: Object.freeze(
218
210
  (Array.isArray(e.artifactRefs) ? e.artifactRefs : []).filter((r) => typeof r === 'string'),
219
211
  ),
@@ -346,3 +338,312 @@ export function emitVerificationCompleted(recorder, {
346
338
  emitSpanRecord(telemetry, span, 'verification.completed', payload);
347
339
  return span;
348
340
  }
341
+
342
+
343
+ // --- V-06: support-bundle export -------------------------------------------
344
+ // One sanitized bundle per operation: a privacy-gated trace excerpt plus a
345
+ // sanitized journal excerpt, materialized inside the Data Foundation
346
+ // support layout — `Documents/UKit Support/proj-<sha256>/run-<sha256>/`
347
+ // (or an explicit outDir). Same ownership rules as the projector: flat
348
+ // `ukit-support/1` file set, manifest.json written LAST as the commit
349
+ // point, sha256 checksums + byte sizes in the manifest so
350
+ // `ukit telemetry import` can verify a received bundle.
351
+ //
352
+ // Privacy contract (SPEC §5, never loosened):
353
+ // - stage gate: `decisionRuntime.diagnostics` 'off' → zero writes.
354
+ // - redaction BEFORE persist: every string passes redactString
355
+ // (secrets, absolute paths, PII, control chars) with the secret
356
+ // scanner as a second signal; trace records additionally pass the
357
+ // independent sanitizeForSupport gate.
358
+ // - no raw content: payloads are allowlist-pruned — prompts, diffs,
359
+ // argv, output, paths can never appear (DENIED fields never even
360
+ // reach the excerpt).
361
+ // - final belt: the assembled file set is re-scanned; any residual
362
+ // secret blocks ALL writes (degraded, redaction_incomplete).
363
+ // - bounded: a hard maxBytes cap trims excerpt lines deterministically
364
+ // (journal tail first, then trace tail) and declares truncation in
365
+ // the manifest coverage — never a silent overflow.
366
+
367
+ const BUNDLE_CAPS = Object.freeze({ maxBytes: 256 * 1024 });
368
+ const BUNDLE_FILES = Object.freeze(['README.md', 'journal.jsonl', 'trace.jsonl', 'SUMMARY.md', 'manifest.json']);
369
+
370
+ function sha256Hex(input) {
371
+ return crypto.createHash('sha256').update(input).digest('hex');
372
+ }
373
+
374
+ /** Deterministic within-bundle id pseudonym — deterministic for tests. */
375
+ function bundlePseudonym(value, kind) {
376
+ return `${kind.slice(0, 8)}-${sha256Hex(`v06:${String(value)}`).slice(0, 12)}`;
377
+ }
378
+
379
+ /** Deep string redaction over plain JSON-shaped structures. */
380
+ function redactDeep(value, ctx) {
381
+ if (value === null) return null;
382
+ const t = typeof value;
383
+ if (t === 'boolean') return value;
384
+ if (t === 'number') return Number.isFinite(value) ? value : null;
385
+ if (t === 'string') return redactString(value, ctx);
386
+ if (Array.isArray(value)) return value.map((v) => redactDeep(v, ctx));
387
+ if (isPlainObject(value)) {
388
+ const out = {};
389
+ for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v, ctx);
390
+ return out;
391
+ }
392
+ return null;
393
+ }
394
+
395
+ function renderBundleReadme() {
396
+ return [
397
+ '# UKit run support bundle',
398
+ '',
399
+ 'This directory was written by `ukit telemetry export-run` (V-06).',
400
+ 'It contains a privacy-sanitized trace excerpt and journal excerpt',
401
+ 'for one agent-runtime operation: codes, states, counts and timings',
402
+ 'only — never prompts, diffs, paths, output or secrets.',
403
+ '',
404
+ 'Share it by zipping the whole run-* directory. A maintainer can',
405
+ 'validate it with `ukit telemetry import <path>`.',
406
+ '',
407
+ ].join('\n');
408
+ }
409
+
410
+ function renderBundleSummary({ generatedAt, opPseudonym, journal, trace, caps }) {
411
+ return [
412
+ '# VM run bundle summary',
413
+ '',
414
+ `generated_at: ${generatedAt}`,
415
+ `operation: ${opPseudonym}`,
416
+ `journal_status: ${journal.status}`,
417
+ `journal_entries: ${journal.entries.length}`,
418
+ `journal_truncated: ${journal.truncated}`,
419
+ `trace_status: ${trace.status}`,
420
+ `trace_kept: ${trace.stats.kept}`,
421
+ `trace_rejected: ${trace.stats.rejected}`,
422
+ `caps.maxBytes: ${caps.maxBytes}`,
423
+ '',
424
+ 'Gaps are declared in manifest.json coverage; a missing or corrupt',
425
+ 'journal still produces a bundle — the hole is marked, never hidden.',
426
+ '',
427
+ ].join('\n');
428
+ }
429
+
430
+ /**
431
+ * Export one operation's sanitized support bundle (V-06).
432
+ *
433
+ * @param {string} dir agent-runtime store root (events/, state/)
434
+ * @param {string} operationId
435
+ * @param {object} [opts]
436
+ * @param {object} [opts.config] runtime config — `decisionRuntime.diagnostics`
437
+ * stage 'off' (or absent/malformed) returns `{status:'skipped',
438
+ * reason:'stage_off'}` with zero writes.
439
+ * @param {string} [opts.outDir] bundle target directory; absent →
440
+ * `Documents/UKit Support/proj-<sha256(projectKey)>/run-<sha256(op)>/`
441
+ * via resolveSupportDir + projectDirName.
442
+ * @param {string} [opts.projectKey] project-key input for the proj dir
443
+ * name (defaults to projectRootFromRuntimeDir(dir), then operationId).
444
+ * @param {Array} [opts.trace] SemanticRecords for the trace excerpt.
445
+ * @param {string} [opts.traceRoot] canonical segment root; when given and
446
+ * `trace` is absent, records are read from segments.
447
+ * @param {string} [opts.traceId] restrict a traceRoot read to one trace.
448
+ * @param {number} [opts.maxBytes] bundle byte cap (default 256 KiB).
449
+ * @param {number} [opts.now] injectable clock for deterministic output.
450
+ * @returns {Promise<{status:string, reason?:string, dir?:string,
451
+ * files?:string[], bytes?:number, coverage?:object}>}
452
+ */
453
+ export async function exportSupportBundle(dir, operationId, opts = {}) {
454
+ const none = { status: 'skipped', bytes: 0 };
455
+
456
+ // Stage gate first — 'off' must produce zero writes.
457
+ if (resolveDecisionRuntimeStage(opts.config ?? null, 'diagnostics') === 'off') {
458
+ return { ...none, reason: 'stage_off' };
459
+ }
460
+
461
+ const opId = typeof operationId === 'string' && operationId.length > 0
462
+ ? operationId
463
+ : 'unknown';
464
+ const caps = {
465
+ maxBytes: Number.isInteger(opts.maxBytes) && opts.maxBytes > 0
466
+ ? opts.maxBytes
467
+ : BUNDLE_CAPS.maxBytes,
468
+ };
469
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
470
+ const generatedAt = new Date(now).toISOString();
471
+ const opPseudonym = `op-${sha256Hex(`v06:op:${opId}`).slice(0, 16)}`;
472
+
473
+ // --- resolve bundle dir -------------------------------------------------
474
+ let outDir = typeof opts.outDir === 'string' && opts.outDir.length > 0
475
+ ? opts.outDir
476
+ : null;
477
+ if (outDir === null) {
478
+ const resolved = await resolveSupportDir({
479
+ homeDir: opts.homeDir,
480
+ env: opts.env,
481
+ platform: opts.platform,
482
+ });
483
+ if (!resolved.ok) {
484
+ return { status: 'degraded', reason: resolved.reason, bytes: 0 };
485
+ }
486
+ const projectKey = typeof opts.projectKey === 'string' && opts.projectKey.length > 0
487
+ ? opts.projectKey
488
+ : (projectRootFromRuntimeDir(dir) ?? `standalone:${opId}`);
489
+ outDir = path.join(
490
+ resolved.dir,
491
+ projectDirName(projectKey),
492
+ `run-${sha256Hex(`v06:run:${opId}`).slice(0, 12)}`,
493
+ );
494
+ }
495
+
496
+ // --- journal excerpt (sanitized at the store boundary) -------------------
497
+ const journal = await readJournalExcerpt(dir, opId, { maxEntries: opts.maxJournalEntries });
498
+
499
+ // --- trace excerpt --------------------------------------------------------
500
+ let traceRecords = Array.isArray(opts.trace) ? opts.trace : null;
501
+ let traceReadFailed = false;
502
+ if (traceRecords === null && typeof opts.traceRoot === 'string' && opts.traceRoot.length > 0) {
503
+ try {
504
+ const { readSegments } = await import('../observability/segments/readSegments.js');
505
+ traceRecords = [];
506
+ for await (const record of readSegments(opts.traceRoot, { limit: 4096 })) {
507
+ const match = typeof opts.traceId === 'string' && opts.traceId.length > 0
508
+ ? record?.trace_id === opts.traceId
509
+ : record?.payload?.operation === opId;
510
+ if (match) traceRecords.push(record);
511
+ }
512
+ } catch {
513
+ traceReadFailed = true;
514
+ traceRecords = null;
515
+ }
516
+ }
517
+ const trace = buildTraceExcerpt(traceRecords, {
518
+ maxRecords: opts.maxTraceRecords,
519
+ pseudonymize: bundlePseudonym,
520
+ });
521
+ const traceStatus = traceReadFailed ? 'unreadable'
522
+ : traceRecords === null ? 'absent'
523
+ : trace.stats.kept === 0 && trace.stats.in === 0 ? 'empty' : 'ok';
524
+ trace.status = traceStatus;
525
+
526
+ // --- redact journal excerpt strings (artifact refs are op filenames) ------
527
+ const redactCtx = { scanner: scanText, maxChars: 1024 };
528
+ const journalEntries = journal.entries.map((e) => redactDeep(e, redactCtx));
529
+
530
+ // --- assemble the file set ------------------------------------------------
531
+ const files = {};
532
+ files['README.md'] = renderBundleReadme();
533
+
534
+ const journalLines = journalEntries.map((e) => JSON.stringify(e));
535
+ for (const g of journal.gaps) {
536
+ journalLines.push(JSON.stringify({ gap: g.code, seq: g.seq }));
537
+ }
538
+ if (journal.truncated) {
539
+ journalLines.push(JSON.stringify({ gap: 'journal_truncated', seq: 0 }));
540
+ }
541
+
542
+ const traceLines = [
543
+ ...trace.entries.map((e) => JSON.stringify(e)),
544
+ ...trace.gaps.map((g) => JSON.stringify({ gap: g.code, index: g.index })),
545
+ ];
546
+
547
+ const coverage = () => ({
548
+ journal: {
549
+ status: journal.status,
550
+ entries: journalEntries.length,
551
+ truncated: journal.truncated,
552
+ gaps: journal.gaps,
553
+ },
554
+ trace: {
555
+ status: traceStatus,
556
+ in: trace.stats.in,
557
+ kept: trace.stats.kept,
558
+ rejected: trace.stats.rejected,
559
+ truncated: trace.stats.truncated,
560
+ gaps: trace.gaps.length,
561
+ },
562
+ dropped_lines: 0,
563
+ });
564
+
565
+ // Byte budget: drop excerpt lines deterministically (journal tail first —
566
+ // the oldest-marked data loses before the self-description does), then
567
+ // trace tail, and finally declare the bundle unwritable rather than
568
+ // exceed the cap.
569
+ let dropped = 0;
570
+ const assemble = () => {
571
+ files['journal.jsonl'] = `${journalLines.join('\n')}\n`;
572
+ if (traceLines.length > 0) files['trace.jsonl'] = `${traceLines.join('\n')}\n`;
573
+ else delete files['trace.jsonl'];
574
+ files['SUMMARY.md'] = renderBundleSummary({
575
+ generatedAt, opPseudonym, journal: { ...journal, entries: journalEntries }, trace, caps,
576
+ });
577
+ const cov = coverage();
578
+ cov.dropped_lines = dropped;
579
+ files['manifest.json'] = `${JSON.stringify(buildManifest({
580
+ files,
581
+ generated_at: generatedAt,
582
+ operation_ref_pseudonym: opPseudonym,
583
+ coverage: cov,
584
+ caps,
585
+ }), null, 2)}\n`;
586
+ return Object.entries(files).reduce((s, [, c]) => s + Buffer.byteLength(c, 'utf8'), 0);
587
+ };
588
+
589
+ let total = assemble();
590
+ while (total > caps.maxBytes && (journalLines.length > 0 || traceLines.length > 0)) {
591
+ if (journalLines.length > 0) journalLines.pop();
592
+ else traceLines.pop();
593
+ dropped += 1;
594
+ total = assemble();
595
+ }
596
+ if (total > caps.maxBytes) {
597
+ return { status: 'degraded', reason: 'bundle_over_cap', bytes: 0 };
598
+ }
599
+
600
+ // --- final belt: re-scan assembled bytes; any residual secret blocks all --
601
+ for (const content of Object.values(files)) {
602
+ const scan = scanText(content);
603
+ if (scan.hasSecret) {
604
+ return { status: 'degraded', reason: 'redaction_incomplete', bytes: 0 };
605
+ }
606
+ }
607
+
608
+ // --- fs safety: refuse foreign files, symlinks and non-regular targets ----
609
+ try {
610
+ const st = await fs.lstat(outDir).catch((err) => {
611
+ if (err && err.code === 'ENOENT') return null;
612
+ throw err;
613
+ });
614
+ if (st !== null) {
615
+ if (st.isSymbolicLink()) return { ...none, reason: 'support_path_symlink' };
616
+ if (!st.isDirectory()) return { ...none, reason: 'support_path_not_directory' };
617
+ const listing = await fs.readdir(outDir);
618
+ for (const name of listing) {
619
+ if (!BUNDLE_FILES.includes(name)) return { ...none, reason: 'bundle_dir_foreign' };
620
+ }
621
+ }
622
+ for (const name of Object.keys(files)) {
623
+ const target = path.join(outDir, name);
624
+ const t = await fs.lstat(target).catch((err) => {
625
+ if (err && err.code === 'ENOENT') return null;
626
+ throw err;
627
+ });
628
+ if (t !== null && (t.isSymbolicLink() || !t.isFile())) {
629
+ return { status: 'skipped', reason: 'bundle_file_blocked', bytes: 0 };
630
+ }
631
+ }
632
+ await fs.mkdir(outDir, { recursive: true });
633
+ // manifest.json is written last — it is the commit point.
634
+ for (const name of BUNDLE_FILES) {
635
+ if (files[name] === undefined) continue;
636
+ await writeFileAtomic(path.join(outDir, name), files[name]);
637
+ }
638
+ } catch (err) {
639
+ return { status: 'degraded', reason: ioReason(err), bytes: 0 };
640
+ }
641
+
642
+ return {
643
+ status: 'written',
644
+ dir: outDir,
645
+ files: Object.keys(files),
646
+ bytes: total,
647
+ coverage: JSON.parse(files['manifest.json']).coverage,
648
+ };
649
+ }
@@ -33,6 +33,7 @@ import { withFileLock } from '../fileOps.js';
33
33
 
34
34
  import {
35
35
  CONTRACT_VERSION,
36
+ OPERATION_STATES,
36
37
  validateSemanticEvent,
37
38
  validateEventOrder,
38
39
  } from './contract.js';
@@ -230,6 +231,144 @@ export async function* readJournal(dir, operationId) {
230
231
  }
231
232
  }
232
233
 
234
+ // --- V-06 support-bundle excerpt surface -----------------------------------
235
+ // The privacy whitelist lives on the event itself (single convention —
236
+ // diagnostics.js and the support-bundle assembler share these constants).
237
+ // Values must match CODE_RE to surface; anything else is dropped silently,
238
+ // so free text can never ride a summary into a bundle.
239
+
240
+ const EXCERPT_STATE_SET = new Set(OPERATION_STATES);
241
+
242
+ /** Bounded code pattern — states, codes, digests only; never free text. */
243
+ const CODE_RE = /^[a-z0-9_.:-]{1,64}$/;
244
+
245
+ /** Whitelisted safePayload fields whose values are enumerated codes. */
246
+ const CODE_FIELDS = Object.freeze(['code', 'reason', 'status', 'kind', 'attempt']);
247
+ const NUMERIC_FIELDS = Object.freeze(['seq', 'step', 'attempt', 'count', 'durationMs']);
248
+
249
+ function codeValue(v) {
250
+ return typeof v === 'string' && CODE_RE.test(v) ? v : null;
251
+ }
252
+
253
+ /**
254
+ * Build a sanitized one-line summary from eventType + enumerated code
255
+ * fields only. `from`/`to` are emitted only when they are known contract
256
+ * states; raw `safePayload` text is NEVER copied.
257
+ */
258
+ export function summarizeEvent(event) {
259
+ const parts = [event.eventType];
260
+ const p = event.safePayload;
261
+ if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
262
+ for (const f of ['from', 'to']) {
263
+ if (EXCERPT_STATE_SET.has(p[f])) parts.push(`${f}=${p[f]}`);
264
+ }
265
+ for (const f of CODE_FIELDS) {
266
+ const v = codeValue(p[f]);
267
+ if (v !== null) parts.push(`${f}=${v}`);
268
+ }
269
+ for (const f of NUMERIC_FIELDS) {
270
+ if (Number.isFinite(p[f]) && !['from', 'to'].includes(f) && !CODE_FIELDS.includes(f)) {
271
+ parts.push(`${f}=${p[f]}`);
272
+ }
273
+ }
274
+ }
275
+ return parts.join(' ');
276
+ }
277
+
278
+ /**
279
+ * Sanitized journal excerpt (V-06): a bounded, content-free view of one
280
+ * operation journal for support-bundle assembly. Unlike `readJournal`
281
+ * this API NEVER throws — every failure mode becomes a typed status plus
282
+ * a `gaps` marker so the bundle can declare the hole instead of failing:
283
+ *
284
+ * status 'ok' — journal parsed; `entries` may still be empty
285
+ * status 'missing' — no journal file for this operation
286
+ * status 'corrupt' — mid-file invalid line or malformed event;
287
+ * entries before the bad line are kept
288
+ * status 'unreadable' — io failure opening/reading the file
289
+ *
290
+ * `truncated` flags a crash-torn final line (same tail rule as
291
+ * `readJournal`). `gaps` entries are `{seq, code}` with bounded codes —
292
+ * free-text failure details never reach the caller.
293
+ *
294
+ * @param {string} dir store root
295
+ * @param {string} operationId
296
+ * @param {object} [opts] `{ maxEntries }` (default 512, clamped ≥1)
297
+ * @returns {Promise<{ operationId: string, status: string, truncated: boolean,
298
+ * entries: Array, gaps: Array }>}
299
+ */
300
+ export async function readJournalExcerpt(dir, operationId, opts = {}) {
301
+ const maxEntries = Number.isInteger(opts.maxEntries) ? Math.max(1, opts.maxEntries) : 512;
302
+ const file = journalPath(dir, operationId);
303
+ const gaps = [];
304
+ const entries = [];
305
+ let truncated = false;
306
+
307
+ const excerpt = (e) => ({
308
+ seq: e.seq,
309
+ eventType: e.eventType,
310
+ observedAt: e.observedAt,
311
+ summary: summarizeEvent(e),
312
+ artifactRefs: (Array.isArray(e.artifactRefs) ? e.artifactRefs : [])
313
+ .filter((r) => typeof r === 'string'),
314
+ });
315
+
316
+ if (!(await pathExists(file))) {
317
+ gaps.push({ seq: 0, code: 'journal_missing' });
318
+ return { operationId, status: 'missing', truncated, entries, gaps };
319
+ }
320
+
321
+ let raw;
322
+ try {
323
+ raw = await fs.readFile(file, 'utf8');
324
+ } catch (err) {
325
+ if (err && err.code === 'ENOENT') {
326
+ gaps.push({ seq: 0, code: 'journal_missing' });
327
+ return { operationId, status: 'missing', truncated, entries, gaps };
328
+ }
329
+ gaps.push({ seq: 0, code: 'journal_unreadable' });
330
+ return { operationId, status: 'unreadable', truncated, entries, gaps };
331
+ }
332
+
333
+ // Line-by-line tolerant parse: unlike readJournal's all-or-nothing
334
+ // parseJournalFile, the excerpt keeps the good prefix of a corrupt
335
+ // journal and marks the hole — same torn-tail rule (unterminated or
336
+ // unparseable final line is a crash artifact, never corruption).
337
+ truncated = !raw.endsWith('\n');
338
+ const lines = raw.split('\n');
339
+ if (!truncated) lines.pop();
340
+ let lastSeq = 0;
341
+ for (let i = 0; i < lines.length; i += 1) {
342
+ const line = lines[i];
343
+ if (line === '') continue;
344
+ const isLast = i === lines.length - 1;
345
+ let event;
346
+ try {
347
+ event = JSON.parse(line);
348
+ } catch {
349
+ if (isLast && truncated) break; // torn tail — discarded silently
350
+ gaps.push({ seq: lastSeq, code: 'journal_corrupt' });
351
+ return { operationId, status: 'corrupt', truncated, entries, gaps };
352
+ }
353
+ const shape = validateSemanticEvent(event);
354
+ if (!shape.ok) {
355
+ gaps.push({
356
+ seq: Number.isInteger(event?.seq) ? event.seq : lastSeq,
357
+ code: codeValue(shape.code) ?? 'journal_corrupt',
358
+ });
359
+ return { operationId, status: 'corrupt', truncated, entries, gaps };
360
+ }
361
+ if (entries.length >= maxEntries) {
362
+ gaps.push({ seq: event.seq ?? lastSeq, code: 'excerpt_truncated' });
363
+ break;
364
+ }
365
+ entries.push(excerpt(event));
366
+ lastSeq = Number.isInteger(event.seq) ? event.seq : lastSeq;
367
+ }
368
+
369
+ return { operationId, status: 'ok', truncated, entries, gaps };
370
+ }
371
+
233
372
  /**
234
373
  * Write the guarded operation state file (cache only — the journal is truth).
235
374
  * Atomic temp+rename+fsync.