@playola-radio/slipstream 0.1.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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +132 -0
  3. package/SKILL.md +63 -0
  4. package/THIRD-PARTY-NOTICES.md +36 -0
  5. package/contracts/interface/v2/schema.json +480 -0
  6. package/dist/answer-forwarder.js +58 -0
  7. package/dist/attribution-engine.js +164 -0
  8. package/dist/attribution-producer.js +153 -0
  9. package/dist/attribution-scoring.js +86 -0
  10. package/dist/attribution.js +120 -0
  11. package/dist/blob-source.js +100 -0
  12. package/dist/boundary-registry.js +47 -0
  13. package/dist/cas.js +99 -0
  14. package/dist/change-view.js +201 -0
  15. package/dist/claude-root-transcript.js +110 -0
  16. package/dist/cli.js +629 -0
  17. package/dist/clip-blob-reader.js +104 -0
  18. package/dist/clip-function-parser.js +83 -0
  19. package/dist/clip-language.js +15 -0
  20. package/dist/clip-projection-service.js +240 -0
  21. package/dist/clip-projection-worker.js +18 -0
  22. package/dist/clip-projection.js +585 -0
  23. package/dist/clip-worker-pool.js +118 -0
  24. package/dist/config.js +154 -0
  25. package/dist/control-client.js +98 -0
  26. package/dist/control-protocol.js +72 -0
  27. package/dist/daemon-location.js +33 -0
  28. package/dist/daemon.js +951 -0
  29. package/dist/display-fold.js +332 -0
  30. package/dist/engine.js +112 -0
  31. package/dist/entrypoint.js +17 -0
  32. package/dist/event.js +79 -0
  33. package/dist/evidence-ingest.js +60 -0
  34. package/dist/harness-context/claude.js +42 -0
  35. package/dist/harness-context/codex.js +49 -0
  36. package/dist/harness-context.js +49 -0
  37. package/dist/health.js +65 -0
  38. package/dist/http-reader.js +655 -0
  39. package/dist/http-security.js +50 -0
  40. package/dist/interface-projection.js +243 -0
  41. package/dist/interface-range-resolver.js +230 -0
  42. package/dist/interface-service.js +509 -0
  43. package/dist/interface-ts-pool.js +111 -0
  44. package/dist/interface-ts-worker.js +35 -0
  45. package/dist/interface-v2-comparison.js +135 -0
  46. package/dist/interface-v2-core.js +123 -0
  47. package/dist/interface-v2-typescript.js +448 -0
  48. package/dist/lock.js +197 -0
  49. package/dist/log-reader.js +148 -0
  50. package/dist/log.js +59 -0
  51. package/dist/maintenance.js +255 -0
  52. package/dist/mcp-forwarder.js +161 -0
  53. package/dist/mcp-protocol.js +92 -0
  54. package/dist/platform.js +21 -0
  55. package/dist/projection-admission.js +296 -0
  56. package/dist/projection-trace.js +17 -0
  57. package/dist/public-events.js +27 -0
  58. package/dist/question-hook.js +110 -0
  59. package/dist/questions.js +167 -0
  60. package/dist/reader-runtime.js +38 -0
  61. package/dist/reader.js +93 -0
  62. package/dist/recovery.js +303 -0
  63. package/dist/schema.js +108 -0
  64. package/dist/session.js +866 -0
  65. package/dist/sibling-module.js +10 -0
  66. package/dist/snapshot.js +15 -0
  67. package/dist/storage.js +111 -0
  68. package/dist/store-reader.js +185 -0
  69. package/dist/swift-grammar.js +268 -0
  70. package/dist/swift-interface-extract.js +344 -0
  71. package/dist/swift-interface.js +62 -0
  72. package/dist/swift-parse-host.js +174 -0
  73. package/dist/swift-parse-worker.js +34 -0
  74. package/dist/swift-parse.js +134 -0
  75. package/dist/swift-spans.js +62 -0
  76. package/dist/task-forwarder.js +152 -0
  77. package/dist/transcript/claude.js +199 -0
  78. package/dist/transcript/codex.js +132 -0
  79. package/dist/transcript/discovery.js +365 -0
  80. package/dist/transcript/file-reader.js +128 -0
  81. package/dist/transcript/fs-io.js +287 -0
  82. package/dist/transcript/runner.js +62 -0
  83. package/dist/transcript/steppers.js +30 -0
  84. package/dist/transcript/types.js +30 -0
  85. package/dist/transcript/watcher.js +133 -0
  86. package/dist/tui.js +168 -0
  87. package/package.json +60 -0
  88. package/schemas/projections/clip.v1.json +195 -0
  89. package/schemas/projections/clip.v2.json +212 -0
  90. package/schemas/projections/clip.v3.json +212 -0
  91. package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
  92. package/schemas/slipstream.capture.gap.v1.json +112 -0
  93. package/schemas/slipstream.change.attribution.v1.json +115 -0
  94. package/schemas/slipstream.enrichment.configured.v1.json +77 -0
  95. package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
  96. package/schemas/slipstream.file.baselined.v1.json +122 -0
  97. package/schemas/slipstream.file.changed.v1.json +179 -0
  98. package/schemas/slipstream.harness.evidence.v1.json +145 -0
  99. package/schemas/slipstream.question.answered.v1.json +29 -0
  100. package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
  101. package/schemas/slipstream.question.queued.v1.json +55 -0
  102. package/schemas/slipstream.session.resumed.v1.json +70 -0
  103. package/schemas/slipstream.session.started.v1.json +69 -0
  104. package/schemas/slipstream.task.started.v1.json +72 -0
@@ -0,0 +1,167 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { openLogCursor } from "./log-reader.js";
5
+ import { blobPath } from "./store-reader.js";
6
+ import { sourceFor } from "./event.js";
7
+ export const QUESTION_TTL_MS = 1_800_000;
8
+ export const QUESTION_LIMIT = 16;
9
+ export const MAX_SOURCE_BYTES = 1024 * 1024;
10
+ export const MAX_ANSWER_BYTES = 16 * 1024;
11
+ export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
12
+ export class QuestionError extends Error {
13
+ code;
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.code = code;
17
+ this.name = 'QuestionError';
18
+ }
19
+ }
20
+ function invalidContext(message) { throw new QuestionError('INVALID_CONTEXT', message); }
21
+ function record(value) {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
23
+ }
24
+ function validRange(start, end) {
25
+ return Number.isSafeInteger(start) && Number.isSafeInteger(end)
26
+ && start >= 1 && end >= start && end - start < 200;
27
+ }
28
+ /** Copy only contract fields: caller mutation and unknown fields cannot retarget a request. */
29
+ export function normalizeAsk(value) {
30
+ const req = record(value);
31
+ if (typeof req.session_id !== 'string' || !UUID.test(req.session_id)
32
+ || typeof req.request_id !== 'string' || !UUID.test(req.request_id)) {
33
+ throw new QuestionError('PROTOCOL', 'ask requires canonical lowercase session_id and request_id UUIDs');
34
+ }
35
+ if (typeof req.text !== 'string' || /[\uD800-\uDFFF]/u.test(req.text))
36
+ throw new QuestionError('INVALID_QUESTION', 'question text is required');
37
+ const text = req.text.trim();
38
+ if (Buffer.byteLength(text) < 1 || Buffer.byteLength(text) > 8192) {
39
+ throw new QuestionError('INVALID_QUESTION', 'question must contain 1–8192 UTF-8 bytes after trimming');
40
+ }
41
+ const ctx = record(req.context);
42
+ if (typeof ctx.change_seq !== 'string' || !/^[1-9][0-9]*$/.test(ctx.change_seq))
43
+ invalidContext('change_seq must be a canonical positive decimal string');
44
+ if (typeof ctx.path !== 'string' || Buffer.byteLength(ctx.path) < 1 || Buffer.byteLength(ctx.path) > 4096
45
+ || /[\uD800-\uDFFF]/u.test(ctx.path))
46
+ invalidContext('context path must be a relative source path of at most 4096 UTF-8 bytes');
47
+ if (typeof ctx.snapshot_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(ctx.snapshot_sha256))
48
+ invalidContext('snapshot_sha256 must be a lowercase SHA-256');
49
+ if (!validRange(ctx.line_start, ctx.line_end))
50
+ invalidContext('select 1–200 source lines using inclusive positive integer bounds');
51
+ const reply = req.reply_to_question_id;
52
+ if (reply !== undefined && (typeof reply !== 'string' || !UUID.test(reply)))
53
+ invalidContext('reply_to_question_id must be a canonical lowercase UUID');
54
+ return { session_id: req.session_id, request_id: req.request_id, text, context: {
55
+ change_seq: ctx.change_seq, path: ctx.path, snapshot_sha256: ctx.snapshot_sha256,
56
+ line_start: ctx.line_start, line_end: ctx.line_end,
57
+ }, ...(reply !== undefined ? { reply_to_question_id: reply } : {}) };
58
+ }
59
+ /** Fixed-order normalized request body; identity excludes derived text and target. */
60
+ export function questionBody(req) {
61
+ const c = req.context;
62
+ return JSON.stringify([req.text, c.change_seq, c.path, c.snapshot_sha256, c.line_start, c.line_end, req.reply_to_question_id ?? null]);
63
+ }
64
+ export function selectSource(bytes, range) {
65
+ if (bytes.includes(0))
66
+ invalidContext('source contains NUL');
67
+ let text;
68
+ try {
69
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
70
+ }
71
+ catch {
72
+ return invalidContext('source is not valid UTF-8');
73
+ }
74
+ const lines = text === '' ? [] : text.split('\n');
75
+ if (text.endsWith('\n'))
76
+ lines.pop();
77
+ if (range.line_end > lines.length)
78
+ invalidContext('line range does not exist in the recorded snapshot');
79
+ const selected = lines.slice(range.line_start - 1, range.line_end).join('\n');
80
+ if (Buffer.byteLength(selected) > 16384)
81
+ invalidContext('selected source exceeds 16384 UTF-8 bytes');
82
+ return selected;
83
+ }
84
+ /** Finite committed-log lookup and an actually bounded CAS read. Never reads the worktree. */
85
+ export async function readQuestionContext(input) {
86
+ const { context, sessionId } = input;
87
+ const seq = BigInt(context.change_seq);
88
+ if (seq > input.boundary)
89
+ invalidContext('change is not in the durable prefix');
90
+ let snapshot;
91
+ try {
92
+ const cursor = await openLogCursor(input.logPath, seq - 1n);
93
+ try {
94
+ const events = await cursor.readThrough(seq);
95
+ const event = events[0];
96
+ if (!event)
97
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'durable source record is missing');
98
+ const envelope = JSON.parse(event.raw);
99
+ if (event.type !== 'slipstream.file.changed.v1' || event.seq !== seq
100
+ || event.data.session_id !== sessionId || envelope.source !== sourceFor(sessionId)
101
+ || envelope.id !== context.change_seq || event.data.path !== context.path)
102
+ invalidContext('context does not identify a change in this capture');
103
+ snapshot = (event.data.after ?? {});
104
+ if (snapshot.kind !== 'content' || snapshot.sha256 !== context.snapshot_sha256)
105
+ invalidContext('context does not identify available after-content');
106
+ if (typeof snapshot.size !== 'number' || snapshot.size > MAX_SOURCE_BYTES)
107
+ invalidContext('source snapshot exceeds 1 MiB');
108
+ }
109
+ finally {
110
+ await cursor.close();
111
+ }
112
+ }
113
+ catch (err) {
114
+ if (err instanceof QuestionError)
115
+ throw err;
116
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'cannot read the durable source log');
117
+ }
118
+ let bytes;
119
+ try {
120
+ const handle = await open(blobPath(input.storeDir, context.snapshot_sha256), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
121
+ try {
122
+ if (!(await handle.stat()).isFile())
123
+ throw new Error('not a regular blob');
124
+ const buffer = Buffer.alloc(MAX_SOURCE_BYTES + 1);
125
+ let total = 0;
126
+ while (total < buffer.length) {
127
+ const { bytesRead } = await handle.read(buffer, total, buffer.length - total, total);
128
+ if (bytesRead === 0)
129
+ break;
130
+ total += bytesRead;
131
+ }
132
+ if (total > MAX_SOURCE_BYTES)
133
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'recorded source blob exceeds its declared size');
134
+ bytes = buffer.subarray(0, total);
135
+ }
136
+ finally {
137
+ await handle.close();
138
+ }
139
+ }
140
+ catch (err) {
141
+ if (err instanceof QuestionError)
142
+ throw err;
143
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'recorded source blob is unavailable');
144
+ }
145
+ if (bytes.length !== snapshot.size || createHash('sha256').update(bytes).digest('hex') !== context.snapshot_sha256) {
146
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'recorded source blob does not match its content hash');
147
+ }
148
+ return selectSource(bytes, context);
149
+ }
150
+ export function questionResult(event) {
151
+ const d = event.data;
152
+ return Object.freeze({ session_id: d.session_id, request_id: d.request_id, question_id: d.question_id,
153
+ seq: event.seq, queued_at_ms: d.queued_at_ms, expires_at_ms: d.expires_at_ms, duplicate: false });
154
+ }
155
+ /** Answer text is stored verbatim, so it is checked but never trimmed. Unpaired
156
+ * surrogates are refused for the same reason as in question text. */
157
+ export function assertAnswerText(text) {
158
+ if (typeof text !== 'string' || !/\S/u.test(text) || /[\uD800-\uDFFF]/u.test(text)
159
+ || Buffer.byteLength(text, 'utf8') > MAX_ANSWER_BYTES) {
160
+ throw new QuestionError('INVALID_ANSWER', `answer text must contain a non-whitespace character and be at most ${MAX_ANSWER_BYTES} UTF-8 bytes`);
161
+ }
162
+ }
163
+ export function answerResult(event) {
164
+ const d = event.data;
165
+ return Object.freeze({ session_id: d.session_id, question_id: d.question_id, event_id: event.seq, seq: event.seq,
166
+ answered_at_ms: d.answered_at_ms, duplicate: false });
167
+ }
@@ -0,0 +1,38 @@
1
+ export function liveBoundary(health) {
2
+ const current = () => BigInt(health.snapshot().durable_seq);
3
+ return {
4
+ current,
5
+ waitForAdvance(from, signal) {
6
+ return new Promise((resolve, reject) => {
7
+ if (signal.aborted) {
8
+ reject(new Error('aborted'));
9
+ return;
10
+ }
11
+ let off;
12
+ const onAbort = () => { cleanup(); reject(new Error('aborted')); };
13
+ const cleanup = () => { off?.(); signal.removeEventListener('abort', onAbort); };
14
+ const check = () => { if (current() > from) {
15
+ cleanup();
16
+ resolve();
17
+ } };
18
+ signal.addEventListener('abort', onAbort, { once: true });
19
+ off = health.subscribe(check);
20
+ check(); // race-free: re-check after subscribing
21
+ });
22
+ },
23
+ };
24
+ }
25
+ export function staticBoundary(seq) {
26
+ return {
27
+ current: () => seq,
28
+ waitForAdvance(_from, signal) {
29
+ return new Promise((_resolve, reject) => {
30
+ if (signal.aborted) {
31
+ reject(new Error('aborted'));
32
+ return;
33
+ }
34
+ signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
35
+ });
36
+ },
37
+ };
38
+ }
package/dist/reader.js ADDED
@@ -0,0 +1,93 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { StorageError } from "./storage.js";
5
+ export const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; // 10 MiB
6
+ /**
7
+ * A read is trustworthy only if the file did not change while we were reading
8
+ * it. If size, mtime, or ctime moved between the pre-read and post-read stat,
9
+ * the bytes we hold may be a torn mix of two states — which we must never
10
+ * record as a real endpoint. ctime is included because it cannot be set
11
+ * backward via `utimes`, so it still moves when a writer restores the original
12
+ * mtime to hide a mid-read change. Callers retry, then fall back to
13
+ * `unavailable/unstable`.
14
+ */
15
+ export function isStableAcross(before, after) {
16
+ return before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
17
+ }
18
+ const STABILITY_RETRIES = 3;
19
+ export function createReader(opts) {
20
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
21
+ const retries = STABILITY_RETRIES;
22
+ const openFile = opts.openFile ?? open;
23
+ const read = async (relPath) => {
24
+ const abs = join(opts.root, relPath);
25
+ for (let attempt = 0; attempt <= retries; attempt++) {
26
+ let before;
27
+ try {
28
+ const st = await lstat(abs);
29
+ if (st.isSymbolicLink() || !st.isFile())
30
+ return { kind: 'absent' };
31
+ before = { size: st.size, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs };
32
+ }
33
+ catch (err) {
34
+ if (err.code === 'ENOENT')
35
+ return { kind: 'absent' };
36
+ return { kind: 'unavailable', reason: 'io-error' };
37
+ }
38
+ if (before.size > maxBytes)
39
+ return { kind: 'unavailable', reason: 'oversize' };
40
+ let handle;
41
+ try {
42
+ handle = await openFile(abs, constants.O_RDONLY | constants.O_NONBLOCK);
43
+ }
44
+ catch (err) {
45
+ const code = err.code;
46
+ if (code === 'ENOENT')
47
+ return { kind: 'absent' };
48
+ if (code === 'EACCES' || code === 'EPERM')
49
+ return { kind: 'unavailable', reason: 'unreadable' };
50
+ return { kind: 'unavailable', reason: 'io-error' };
51
+ }
52
+ try {
53
+ const opened = await handle.stat();
54
+ if (!opened.isFile())
55
+ return { kind: 'absent' };
56
+ if (opened.size > maxBytes)
57
+ return { kind: 'unavailable', reason: 'oversize' };
58
+ const bytes = Buffer.alloc(opened.size);
59
+ let offset = 0;
60
+ while (offset < bytes.length) {
61
+ const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
62
+ if (bytesRead === 0)
63
+ break;
64
+ offset += bytesRead;
65
+ }
66
+ const st = await handle.stat();
67
+ if (st.size > maxBytes)
68
+ return { kind: 'unavailable', reason: 'oversize' };
69
+ if (!isStableAcross(before, { size: st.size, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs })) {
70
+ continue; // torn read; re-observe from a fresh stat
71
+ }
72
+ const ref = await opts.cas.put(bytes.subarray(0, offset));
73
+ return { kind: 'content', sha256: ref.sha256, size: ref.size };
74
+ }
75
+ catch (err) {
76
+ // A failure to durably publish the blob is a *storage* fault, not an
77
+ // unreadable source file. Never launder it into an `unavailable`
78
+ // snapshot — propagate so capture can suspend and disclose a gap.
79
+ if (err instanceof StorageError)
80
+ throw err;
81
+ const code = err.code;
82
+ if (code === 'EACCES' || code === 'EPERM')
83
+ return { kind: 'unavailable', reason: 'unreadable' };
84
+ return { kind: 'unavailable', reason: 'io-error' };
85
+ }
86
+ finally {
87
+ await handle.close();
88
+ }
89
+ }
90
+ return { kind: 'unavailable', reason: 'unstable' };
91
+ };
92
+ return { read };
93
+ }
@@ -0,0 +1,303 @@
1
+ import { open, readFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { dirname, isAbsolute } from 'node:path';
4
+ import { fsyncDir } from "./storage.js";
5
+ import { sourceFor } from "./event.js";
6
+ import { PUBLIC_EVENT_TYPES } from "./public-events.js";
7
+ import { loadAllSchemas, validate } from "./schema.js";
8
+ import { snapshotsEqual } from "./snapshot.js";
9
+ /**
10
+ * A mid-log defect that cannot be honestly repaired. Only bytes after the final
11
+ * newline are ever removable (an interrupted append); any *terminated* record
12
+ * that fails to validate means the durable history is untrustworthy, so we stop
13
+ * hard rather than skip an event and silently lie about what was captured.
14
+ */
15
+ export class CorruptLogError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'CorruptLogError';
19
+ }
20
+ }
21
+ /** The envelope constraints every record must satisfy, regardless of type — so an
22
+ * unknown event type cannot smuggle a malformed envelope past validation. */
23
+ const ENVELOPE_SCHEMA = {
24
+ type: 'object',
25
+ required: ['specversion', 'id', 'source', 'type', 'datacontenttype', 'seq', 'time'],
26
+ properties: {
27
+ specversion: { const: '1.0' },
28
+ datacontenttype: { const: 'application/json' },
29
+ id: { type: 'string', pattern: '^[1-9][0-9]*$' },
30
+ source: { type: 'string', pattern: '^urn:slipstream:session:' },
31
+ type: { type: 'string' },
32
+ seq: { type: 'string', pattern: '^[1-9][0-9]*$' },
33
+ time: { type: 'string', format: 'date-time' },
34
+ },
35
+ };
36
+ /** A captured path is relative to the worktree root and never escapes it. A log
37
+ * whose path escapes (absolute, or with a `..` segment) is corrupt — reconciling
38
+ * it would read outside the watched tree. */
39
+ export function assertSafePath(path, at) {
40
+ if (path === '' || isAbsolute(path) || /(^|[\\/])\.\.([\\/]|$)/.test(path)) {
41
+ throw new CorruptLogError(`${at}: unsafe path ${JSON.stringify(path)}`);
42
+ }
43
+ }
44
+ /** Like {@link assertSafePath} but for a directory scope, where the empty string
45
+ * legitimately names the worktree root (a failed root enumeration emits a
46
+ * `baseline-unreadable` gap whose `relative(root, root)` scope path is ""). */
47
+ function assertSafeDir(path, at) {
48
+ if (isAbsolute(path) || /(^|[\\/])\.\.([\\/]|$)/.test(path)) {
49
+ throw new CorruptLogError(`${at}: unsafe path ${JSON.stringify(path)}`);
50
+ }
51
+ }
52
+ /**
53
+ * Validate and replay an existing session log. Throws CorruptLogError on any
54
+ * mid-log defect; truncates only an unterminated trailing suffix. Never uses the
55
+ * lenient test reader — every terminated line must parse and validate here.
56
+ */
57
+ export async function recoverSession(logPath, expectedSessionId, cas) {
58
+ const raw = await readFile(logPath).catch((err) => {
59
+ if (err.code === 'ENOENT')
60
+ return Buffer.alloc(0);
61
+ throw err;
62
+ });
63
+ const lastLf = raw.lastIndexOf(0x0a);
64
+ const keptLen = lastLf + 1; // bytes through the final newline (0 if none)
65
+ const discardedTailBytes = raw.length - keptLen;
66
+ const terminated = raw.subarray(0, keptLen);
67
+ let text;
68
+ try {
69
+ text = new TextDecoder('utf-8', { fatal: true }).decode(terminated);
70
+ }
71
+ catch {
72
+ throw new CorruptLogError('log contains invalid UTF-8 before its final newline');
73
+ }
74
+ const schemas = await loadAllSchemas();
75
+ const expectedSource = sourceFor(expectedSessionId);
76
+ const committed = new Map();
77
+ const baselineUnknownDirs = new Set();
78
+ const storageGapSeqByEpisode = new Map();
79
+ const taskDeclarations = new Map();
80
+ const attributionEvents = [];
81
+ const questions = [];
82
+ const questionAttempts = [];
83
+ const questionAnswers = [];
84
+ // Keep the ordered event arrays for callers, while replay validation uses
85
+ // indexes so a long-lived session is replayed in one pass.
86
+ const queuedSeqsByQuestion = new Map();
87
+ const attemptSeqByQuestion = new Map();
88
+ const answeredQuestionIds = new Set();
89
+ const verifiedBlobs = new Map(); // sha256 -> verified byte length
90
+ let root;
91
+ let maxBytes;
92
+ let baselineCompleted = false;
93
+ let currentTaskId;
94
+ let seq = 0n;
95
+ const verifyBlob = async (snap, where) => {
96
+ if (snap.kind !== 'content')
97
+ return;
98
+ const cached = verifiedBlobs.get(snap.sha256);
99
+ if (cached !== undefined) {
100
+ if (cached !== snap.size) {
101
+ throw new CorruptLogError(`${where}: blob ${snap.sha256} size ${snap.size} != verified ${cached}`);
102
+ }
103
+ return; // hash the bytes once, but re-check each reference's declared size
104
+ }
105
+ let bytes;
106
+ try {
107
+ bytes = await cas.read(snap.sha256);
108
+ }
109
+ catch {
110
+ throw new CorruptLogError(`${where}: referenced blob ${snap.sha256} is missing`);
111
+ }
112
+ if (bytes.length !== snap.size) {
113
+ throw new CorruptLogError(`${where}: blob ${snap.sha256} size ${bytes.length} != ${snap.size}`);
114
+ }
115
+ if (createHash('sha256').update(bytes).digest('hex') !== snap.sha256) {
116
+ throw new CorruptLogError(`${where}: blob content does not hash to ${snap.sha256}`);
117
+ }
118
+ await cas.ensureDurable(snap.sha256); // durability barrier before reuse (Q5)
119
+ verifiedBlobs.set(snap.sha256, bytes.length);
120
+ };
121
+ const lines = text.length === 0 ? [] : text.slice(0, -1).split('\n');
122
+ for (let i = 0; i < lines.length; i++) {
123
+ const line = lines[i];
124
+ const at = `record ${i + 1}`;
125
+ if (line.length === 0)
126
+ throw new CorruptLogError(`${at}: blank terminated line`);
127
+ let event;
128
+ try {
129
+ event = JSON.parse(line);
130
+ }
131
+ catch {
132
+ throw new CorruptLogError(`${at}: invalid JSON`);
133
+ }
134
+ const envelopeErrors = validate(ENVELOPE_SCHEMA, event);
135
+ if (envelopeErrors.length > 0) {
136
+ throw new CorruptLogError(`${at}: envelope invalid: ${envelopeErrors[0]}`);
137
+ }
138
+ seq += 1n;
139
+ const expectedSeq = seq.toString();
140
+ if (event.seq !== expectedSeq) {
141
+ throw new CorruptLogError(`${at}: seq ${event.seq} != expected ${expectedSeq}`);
142
+ }
143
+ if (event.id !== expectedSeq) {
144
+ throw new CorruptLogError(`${at}: id ${event.id} != seq ${expectedSeq}`);
145
+ }
146
+ if (event.source !== expectedSource) {
147
+ throw new CorruptLogError(`${at}: source ${event.source} != ${expectedSource}`);
148
+ }
149
+ if (event.data?.session_id !== expectedSessionId) {
150
+ throw new CorruptLogError(`${at}: session_id disagrees with session directory`);
151
+ }
152
+ // The first record binds identity and worktree; enforce it before any
153
+ // forward-compat skip so an unknown type cannot stand in for session.started.
154
+ if (i === 0 && event.type !== 'slipstream.session.started.v1') {
155
+ throw new CorruptLogError(`${at}: first record must be session.started, got ${event.type}`);
156
+ }
157
+ if (!PUBLIC_EVENT_TYPES.includes(event.type)) {
158
+ continue; // forward-compat: unknown type, seq still advanced
159
+ }
160
+ const schema = schemas.get(event.type);
161
+ const errors = validate(schema, event);
162
+ if (errors.length > 0) {
163
+ throw new CorruptLogError(`${at}: ${event.type} fails schema: ${errors[0]}`);
164
+ }
165
+ if (event.type === 'slipstream.file.changed.v1' ||
166
+ event.type === 'slipstream.harness.evidence.v1' ||
167
+ event.type === 'slipstream.change.attribution.v1' ||
168
+ event.type === 'slipstream.enrichment.configured.v1') {
169
+ attributionEvents.push(event);
170
+ }
171
+ switch (event.type) {
172
+ case 'slipstream.session.started.v1': {
173
+ if (i !== 0)
174
+ throw new CorruptLogError(`${at}: duplicate session.started`);
175
+ root = event.data.root;
176
+ maxBytes = event.data.max_bytes;
177
+ break;
178
+ }
179
+ case 'slipstream.file.baselined.v1': {
180
+ assertSafePath(event.data.path, at);
181
+ await verifyBlob(event.data.snapshot, at);
182
+ committed.set(event.data.path, event.data.snapshot);
183
+ break;
184
+ }
185
+ case 'slipstream.capture.baseline.completed.v1': {
186
+ for (const dir of event.data.unknown_scopes) {
187
+ assertSafeDir(dir, at);
188
+ baselineUnknownDirs.add(dir);
189
+ }
190
+ baselineCompleted = true;
191
+ break;
192
+ }
193
+ case 'slipstream.file.changed.v1': {
194
+ assertSafePath(event.data.path, at);
195
+ await verifyBlob(event.data.before, `${at} before`);
196
+ await verifyBlob(event.data.after, `${at} after`);
197
+ const prior = committed.get(event.data.path);
198
+ if (prior !== undefined && !snapshotsEqual(prior, event.data.before)) {
199
+ throw new CorruptLogError(`${at}: before contradicts replayed state for ${event.data.path}`);
200
+ }
201
+ committed.set(event.data.path, event.data.after);
202
+ break;
203
+ }
204
+ case 'slipstream.capture.gap.v1': {
205
+ if (event.data.reason === 'baseline-unreadable' && 'path' in event.data.scope) {
206
+ assertSafeDir(event.data.scope.path, at);
207
+ baselineUnknownDirs.add(event.data.scope.path);
208
+ }
209
+ if (event.data.reason === 'storage' && event.data.episode_id !== undefined) {
210
+ storageGapSeqByEpisode.set(event.data.episode_id, event.seq);
211
+ }
212
+ break;
213
+ }
214
+ case 'slipstream.question.queued.v1': {
215
+ if (event.subject !== `question/${event.data.question_id}`)
216
+ throw new CorruptLogError(`${at}: question subject disagrees with id`);
217
+ questions.push(event);
218
+ let queuedSeqs = queuedSeqsByQuestion.get(event.data.question_id);
219
+ if (queuedSeqs === undefined) {
220
+ queuedSeqs = new Set();
221
+ queuedSeqsByQuestion.set(event.data.question_id, queuedSeqs);
222
+ }
223
+ queuedSeqs.add(event.seq);
224
+ break;
225
+ }
226
+ case 'slipstream.question.dispatch_attempted.v1': {
227
+ if (event.subject !== `question/${event.data.question_id}`
228
+ || !queuedSeqsByQuestion.get(event.data.question_id)?.has(event.data.queued_seq)
229
+ || attemptSeqByQuestion.has(event.data.question_id)) {
230
+ throw new CorruptLogError(`${at}: dispatch attempt has no unique queued question`);
231
+ }
232
+ questionAttempts.push(event);
233
+ attemptSeqByQuestion.set(event.data.question_id, event.seq);
234
+ break;
235
+ }
236
+ case 'slipstream.question.answered.v1': {
237
+ if (event.subject !== `question/${event.data.question_id}`
238
+ || attemptSeqByQuestion.get(event.data.question_id) !== event.data.attempt_seq
239
+ || answeredQuestionIds.has(event.data.question_id)) {
240
+ throw new CorruptLogError(`${at}: answer has no unique attempted question`);
241
+ }
242
+ questionAnswers.push(event);
243
+ answeredQuestionIds.add(event.data.question_id);
244
+ break;
245
+ }
246
+ case 'slipstream.task.started.v1': {
247
+ // Replayed in order: the last committed declaration is the current task,
248
+ // and its request_id indexes the committed result so a replayed duplicate
249
+ // does not re-append or move the current task backward.
250
+ currentTaskId = event.data.task_id;
251
+ taskDeclarations.set(event.data.request_id, {
252
+ taskId: event.data.task_id,
253
+ title: event.data.title,
254
+ seq: event.seq,
255
+ });
256
+ break;
257
+ }
258
+ case 'slipstream.session.resumed.v1':
259
+ break;
260
+ }
261
+ }
262
+ // A crash before `baseline.completed` means the initial scan never finished:
263
+ // every path we did not baseline is of genuinely unknown prior state, so the
264
+ // whole root is baseline-unknown. Without this, restart reconciliation would
265
+ // fabricate `absent -> content` for pre-existing files it simply never saw.
266
+ if (root !== undefined && !baselineCompleted) {
267
+ baselineUnknownDirs.add('');
268
+ }
269
+ // A recovered seq must be a durable seq. The writer commits bytes to the page
270
+ // cache (writeAll) and only then fsyncs, so a crash mid-outage — exactly when
271
+ // recovery runs — can leave complete, valid, CAS-verified records that never
272
+ // reached the platter. Before recoveredThroughSeq is published as the durable
273
+ // high-water, force the retained prefix to disk (and truncate a torn tail in
274
+ // the same barrier). Without this, a clean recovery would advertise a boundary
275
+ // storage may not hold, and a later crash could move the sequence backwards.
276
+ if (keptLen > 0 || discardedTailBytes > 0) {
277
+ const handle = await open(logPath, 'r+');
278
+ try {
279
+ if (discardedTailBytes > 0)
280
+ await handle.truncate(keptLen);
281
+ await handle.sync();
282
+ }
283
+ finally {
284
+ await handle.close();
285
+ }
286
+ await fsyncDir(dirname(logPath));
287
+ }
288
+ return {
289
+ root,
290
+ maxBytes,
291
+ recoveredThroughSeq: seq,
292
+ discardedTailBytes,
293
+ committed,
294
+ baselineUnknownDirs,
295
+ storageGapSeqByEpisode,
296
+ currentTaskId,
297
+ taskDeclarations,
298
+ attributionEvents,
299
+ questions,
300
+ questionAttempts,
301
+ questionAnswers,
302
+ };
303
+ }