@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.
- package/LICENSE +202 -0
- package/README.md +132 -0
- package/SKILL.md +63 -0
- package/THIRD-PARTY-NOTICES.md +36 -0
- package/contracts/interface/v2/schema.json +480 -0
- package/dist/answer-forwarder.js +58 -0
- package/dist/attribution-engine.js +164 -0
- package/dist/attribution-producer.js +153 -0
- package/dist/attribution-scoring.js +86 -0
- package/dist/attribution.js +120 -0
- package/dist/blob-source.js +100 -0
- package/dist/boundary-registry.js +47 -0
- package/dist/cas.js +99 -0
- package/dist/change-view.js +201 -0
- package/dist/claude-root-transcript.js +110 -0
- package/dist/cli.js +629 -0
- package/dist/clip-blob-reader.js +104 -0
- package/dist/clip-function-parser.js +83 -0
- package/dist/clip-language.js +15 -0
- package/dist/clip-projection-service.js +240 -0
- package/dist/clip-projection-worker.js +18 -0
- package/dist/clip-projection.js +585 -0
- package/dist/clip-worker-pool.js +118 -0
- package/dist/config.js +154 -0
- package/dist/control-client.js +98 -0
- package/dist/control-protocol.js +72 -0
- package/dist/daemon-location.js +33 -0
- package/dist/daemon.js +951 -0
- package/dist/display-fold.js +332 -0
- package/dist/engine.js +112 -0
- package/dist/entrypoint.js +17 -0
- package/dist/event.js +79 -0
- package/dist/evidence-ingest.js +60 -0
- package/dist/harness-context/claude.js +42 -0
- package/dist/harness-context/codex.js +49 -0
- package/dist/harness-context.js +49 -0
- package/dist/health.js +65 -0
- package/dist/http-reader.js +655 -0
- package/dist/http-security.js +50 -0
- package/dist/interface-projection.js +243 -0
- package/dist/interface-range-resolver.js +230 -0
- package/dist/interface-service.js +509 -0
- package/dist/interface-ts-pool.js +111 -0
- package/dist/interface-ts-worker.js +35 -0
- package/dist/interface-v2-comparison.js +135 -0
- package/dist/interface-v2-core.js +123 -0
- package/dist/interface-v2-typescript.js +448 -0
- package/dist/lock.js +197 -0
- package/dist/log-reader.js +148 -0
- package/dist/log.js +59 -0
- package/dist/maintenance.js +255 -0
- package/dist/mcp-forwarder.js +161 -0
- package/dist/mcp-protocol.js +92 -0
- package/dist/platform.js +21 -0
- package/dist/projection-admission.js +296 -0
- package/dist/projection-trace.js +17 -0
- package/dist/public-events.js +27 -0
- package/dist/question-hook.js +110 -0
- package/dist/questions.js +167 -0
- package/dist/reader-runtime.js +38 -0
- package/dist/reader.js +93 -0
- package/dist/recovery.js +303 -0
- package/dist/schema.js +108 -0
- package/dist/session.js +866 -0
- package/dist/sibling-module.js +10 -0
- package/dist/snapshot.js +15 -0
- package/dist/storage.js +111 -0
- package/dist/store-reader.js +185 -0
- package/dist/swift-grammar.js +268 -0
- package/dist/swift-interface-extract.js +344 -0
- package/dist/swift-interface.js +62 -0
- package/dist/swift-parse-host.js +174 -0
- package/dist/swift-parse-worker.js +34 -0
- package/dist/swift-parse.js +134 -0
- package/dist/swift-spans.js +62 -0
- package/dist/task-forwarder.js +152 -0
- package/dist/transcript/claude.js +199 -0
- package/dist/transcript/codex.js +132 -0
- package/dist/transcript/discovery.js +365 -0
- package/dist/transcript/file-reader.js +128 -0
- package/dist/transcript/fs-io.js +287 -0
- package/dist/transcript/runner.js +62 -0
- package/dist/transcript/steppers.js +30 -0
- package/dist/transcript/types.js +30 -0
- package/dist/transcript/watcher.js +133 -0
- package/dist/tui.js +168 -0
- package/package.json +60 -0
- package/schemas/projections/clip.v1.json +195 -0
- package/schemas/projections/clip.v2.json +212 -0
- package/schemas/projections/clip.v3.json +212 -0
- package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
- package/schemas/slipstream.capture.gap.v1.json +112 -0
- package/schemas/slipstream.change.attribution.v1.json +115 -0
- package/schemas/slipstream.enrichment.configured.v1.json +77 -0
- package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
- package/schemas/slipstream.file.baselined.v1.json +122 -0
- package/schemas/slipstream.file.changed.v1.json +179 -0
- package/schemas/slipstream.harness.evidence.v1.json +145 -0
- package/schemas/slipstream.question.answered.v1.json +29 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
- package/schemas/slipstream.question.queued.v1.json +55 -0
- package/schemas/slipstream.session.resumed.v1.json +70 -0
- package/schemas/slipstream.session.started.v1.json +69 -0
- package/schemas/slipstream.task.started.v1.json +72 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { lstat as fsLstat, open, readdir, readlink as fsReadlink, realpath as fsRealpath, } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/** The real filesystem IO for transcript files. Missing → `missing`; any other
|
|
4
|
+
* open/read error → `inaccessible` (disclosed, never a silent empty read). The
|
|
5
|
+
* identity, size, and bytes all come from ONE open handle so a concurrent replace
|
|
6
|
+
* cannot supply a different generation's bytes than the id reports. */
|
|
7
|
+
export const nodeTranscriptFileIO = {
|
|
8
|
+
async readFrom(path, start, maxBytes) {
|
|
9
|
+
let handle;
|
|
10
|
+
try {
|
|
11
|
+
handle = await open(path, 'r');
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (err.code === 'ENOENT')
|
|
15
|
+
return { ok: false, reason: 'missing' };
|
|
16
|
+
return { ok: false, reason: 'inaccessible' };
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const s = await handle.stat({ bigint: true });
|
|
20
|
+
const id = { dev: s.dev, ino: s.ino };
|
|
21
|
+
const size = Number(s.size);
|
|
22
|
+
if (size <= start)
|
|
23
|
+
return { ok: true, id, size, bytes: Buffer.alloc(0) };
|
|
24
|
+
const length = Math.min(size - start, maxBytes);
|
|
25
|
+
const buf = Buffer.allocUnsafe(length);
|
|
26
|
+
const { bytesRead } = await handle.read(buf, 0, length, start);
|
|
27
|
+
return { ok: true, id, size, bytes: buf.subarray(0, bytesRead) };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { ok: false, reason: 'inaccessible' };
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await handle.close();
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const FATAL_UTF8 = new TextDecoder('utf8', { fatal: true });
|
|
38
|
+
const FIRST_LINE_CHUNK = 64 * 1024;
|
|
39
|
+
/** A first line longer than this is not line-delimited JSONL; disclose it
|
|
40
|
+
* malformed rather than accumulate unbounded memory. */
|
|
41
|
+
const MAX_FIRST_LINE = 16 * 1024 * 1024;
|
|
42
|
+
/** Head-scan bounds for finding a Claude transcript's first cwd-bearing record.
|
|
43
|
+
* Enough lines to clear a cwd-less preamble; a per-line cap skips a large
|
|
44
|
+
* attachment (its cwd, if any, recurs on the smaller records around it); a total
|
|
45
|
+
* cap bounds the read even when large lines are skipped. */
|
|
46
|
+
export const HEAD_SCAN_LINES = 64;
|
|
47
|
+
export const HEAD_LINE_CAP = 256 * 1024;
|
|
48
|
+
export const HEAD_SCAN_BYTES = 4 * 1024 * 1024;
|
|
49
|
+
function classifyDirError(err) {
|
|
50
|
+
return err.code === 'ENOENT' ? 'missing' : 'inaccessible';
|
|
51
|
+
}
|
|
52
|
+
/** The real filesystem IO for transcript discovery. */
|
|
53
|
+
export const nodeDiscoveryIO = {
|
|
54
|
+
async listDir(dir) {
|
|
55
|
+
try {
|
|
56
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
57
|
+
const paths = entries
|
|
58
|
+
.filter((e) => e.isFile() && e.name.endsWith('.jsonl'))
|
|
59
|
+
.map((e) => join(dir, e.name));
|
|
60
|
+
return { ok: true, paths };
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
return { ok: false, reason: classifyDirError(err) };
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
async listTreeJsonl(dir, limit) {
|
|
67
|
+
const paths = [];
|
|
68
|
+
let truncated = false;
|
|
69
|
+
let incomplete = false;
|
|
70
|
+
const stack = [dir];
|
|
71
|
+
let rootErrored;
|
|
72
|
+
let first = true;
|
|
73
|
+
while (stack.length > 0) {
|
|
74
|
+
const current = stack.pop();
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (first)
|
|
81
|
+
rootErrored = classifyDirError(err);
|
|
82
|
+
else
|
|
83
|
+
incomplete = true; // a subdirectory we could not read: disclose the gap
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
first = false;
|
|
88
|
+
}
|
|
89
|
+
for (const e of entries) {
|
|
90
|
+
const full = join(current, e.name);
|
|
91
|
+
if (e.isDirectory()) {
|
|
92
|
+
stack.push(full);
|
|
93
|
+
}
|
|
94
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
95
|
+
if (paths.length >= limit) {
|
|
96
|
+
truncated = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
paths.push(full);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (rootErrored)
|
|
104
|
+
return { ok: false, reason: rootErrored };
|
|
105
|
+
return { paths, truncated, incomplete };
|
|
106
|
+
},
|
|
107
|
+
async readFirstLine(path) {
|
|
108
|
+
let handle;
|
|
109
|
+
try {
|
|
110
|
+
handle = await open(path, 'r');
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { ok: false, reason: 'inaccessible' };
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
// Identify the exact bytes about to be read, from the SAME open handle, so the
|
|
117
|
+
// generation stamped on the result is the file whose content derived the ctx —
|
|
118
|
+
// not a replacement swapped in between this read and the reader's later stat.
|
|
119
|
+
const idStat = await handle.stat({ bigint: true });
|
|
120
|
+
const id = { dev: idStat.dev, ino: idStat.ino };
|
|
121
|
+
// Read the COMPLETE first line: a Claude first record can exceed one chunk,
|
|
122
|
+
// and a truncated prefix fails to parse, which would silently fall through to
|
|
123
|
+
// slug-trust (mis-binding) or falsely split a multibyte char (false malformed).
|
|
124
|
+
const chunks = [];
|
|
125
|
+
let total = 0;
|
|
126
|
+
let pos = 0;
|
|
127
|
+
let sawByte = false;
|
|
128
|
+
for (;;) {
|
|
129
|
+
const buf = Buffer.allocUnsafe(FIRST_LINE_CHUNK);
|
|
130
|
+
const { bytesRead } = await handle.read(buf, 0, FIRST_LINE_CHUNK, pos);
|
|
131
|
+
if (bytesRead === 0)
|
|
132
|
+
break; // EOF before any newline
|
|
133
|
+
sawByte = true;
|
|
134
|
+
pos += bytesRead;
|
|
135
|
+
const nl = buf.subarray(0, bytesRead).indexOf(0x0a);
|
|
136
|
+
const lineLen = nl >= 0 ? nl : bytesRead;
|
|
137
|
+
// A single line beyond this bound is pathological (not line-delimited
|
|
138
|
+
// JSONL); disclose it malformed rather than read unbounded memory. Checked
|
|
139
|
+
// whether or not the chunk ends the line, so a huge line terminated by a
|
|
140
|
+
// newline cannot slip past the bound an unterminated one is held to.
|
|
141
|
+
if (total + lineLen > MAX_FIRST_LINE)
|
|
142
|
+
return { ok: false, reason: 'malformed' };
|
|
143
|
+
chunks.push(buf.subarray(0, lineLen));
|
|
144
|
+
total += lineLen;
|
|
145
|
+
if (nl >= 0)
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
if (!sawByte)
|
|
149
|
+
return { ok: false, reason: 'empty' };
|
|
150
|
+
let line;
|
|
151
|
+
try {
|
|
152
|
+
// Fatal decode over the COMPLETE line bytes: a lossily-decoded id/cwd would
|
|
153
|
+
// coin a fabricated session identity or membership. Invalid UTF-8 is
|
|
154
|
+
// malformed, not a clean read; decoding the whole line at once keeps a
|
|
155
|
+
// multibyte char that straddles a chunk boundary intact.
|
|
156
|
+
line = FATAL_UTF8.decode(Buffer.concat(chunks, total));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return { ok: false, reason: 'malformed' };
|
|
160
|
+
}
|
|
161
|
+
return { ok: true, line, id };
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return { ok: false, reason: 'inaccessible' };
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await handle.close();
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
async readHeadLines(path) {
|
|
171
|
+
let handle;
|
|
172
|
+
try {
|
|
173
|
+
handle = await open(path, 'r');
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return { ok: false, reason: 'inaccessible' };
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
// The generation of the exact bytes scanned, from the same open handle (see
|
|
180
|
+
// readFirstLine): the id stamped on the binding is the file this head derived.
|
|
181
|
+
const idStat = await handle.stat({ bigint: true });
|
|
182
|
+
const id = { dev: idStat.dev, ino: idStat.ino };
|
|
183
|
+
const lines = [];
|
|
184
|
+
let pos = 0;
|
|
185
|
+
let sawByte = false;
|
|
186
|
+
let truncated = false; // stopped at a line/byte budget, not EOF: more may lie beyond
|
|
187
|
+
let skipped = false; // at least one line was dropped (oversized/undecodable): a gap
|
|
188
|
+
let cur = []; // the current line's bytes, across chunk boundaries
|
|
189
|
+
let curLen = 0;
|
|
190
|
+
let skip = false; // the current line exceeded the per-line cap: discard it
|
|
191
|
+
const finish = (seg) => {
|
|
192
|
+
// Finalize the current line [.. seg]; return true when the line budget is hit.
|
|
193
|
+
if (!skip && curLen + seg.length <= HEAD_LINE_CAP) {
|
|
194
|
+
cur.push(Buffer.from(seg));
|
|
195
|
+
try {
|
|
196
|
+
// Fatal-decode the whole line at once so a multibyte char split across a
|
|
197
|
+
// chunk survives; an undecodable line is skipped, not fabricated.
|
|
198
|
+
lines.push(FATAL_UTF8.decode(Buffer.concat(cur)));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
skipped = true; // an undecodable line dropped: a cwd could have been here
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
skipped = true; // an oversized line dropped: a cwd could have been here
|
|
206
|
+
}
|
|
207
|
+
cur = [];
|
|
208
|
+
curLen = 0;
|
|
209
|
+
skip = false;
|
|
210
|
+
return lines.length >= HEAD_SCAN_LINES;
|
|
211
|
+
};
|
|
212
|
+
outer: for (;;) {
|
|
213
|
+
const buf = Buffer.allocUnsafe(FIRST_LINE_CHUNK);
|
|
214
|
+
const { bytesRead } = await handle.read(buf, 0, FIRST_LINE_CHUNK, pos);
|
|
215
|
+
if (bytesRead === 0)
|
|
216
|
+
break; // EOF: the whole file was read
|
|
217
|
+
sawByte = true;
|
|
218
|
+
pos += bytesRead;
|
|
219
|
+
let start = 0;
|
|
220
|
+
for (let i = 0; i < bytesRead; i += 1) {
|
|
221
|
+
if (buf[i] !== 0x0a)
|
|
222
|
+
continue;
|
|
223
|
+
if (finish(buf.subarray(start, i))) {
|
|
224
|
+
truncated = true; // hit the line budget with more file to read
|
|
225
|
+
break outer;
|
|
226
|
+
}
|
|
227
|
+
start = i + 1;
|
|
228
|
+
}
|
|
229
|
+
if (start < bytesRead) {
|
|
230
|
+
const seg = buf.subarray(start, bytesRead);
|
|
231
|
+
curLen += seg.length;
|
|
232
|
+
if (curLen > HEAD_LINE_CAP) {
|
|
233
|
+
skip = true;
|
|
234
|
+
cur = [];
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
cur.push(Buffer.from(seg));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (pos >= HEAD_SCAN_BYTES) {
|
|
241
|
+
truncated = true; // hit the byte budget
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!sawByte)
|
|
246
|
+
return { ok: false, reason: 'empty' };
|
|
247
|
+
return { ok: true, lines, truncated, skipped, id };
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return { ok: false, reason: 'inaccessible' };
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
await handle.close();
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
async realpath(path) {
|
|
257
|
+
try {
|
|
258
|
+
return await fsRealpath(path);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
async probe(path) {
|
|
265
|
+
let stats;
|
|
266
|
+
try {
|
|
267
|
+
stats = await fsLstat(path);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
// ENOENT (nothing there) and ENOTDIR (a parent component is not a directory,
|
|
271
|
+
// so nothing can exist below it) are both confirmed absence. Anything else
|
|
272
|
+
// (e.g. EACCES) is a failure to inspect, which must NOT masquerade as absence.
|
|
273
|
+
const code = err.code;
|
|
274
|
+
return code === 'ENOENT' || code === 'ENOTDIR' ? { kind: 'absent' } : { kind: 'error' };
|
|
275
|
+
}
|
|
276
|
+
if (!stats.isSymbolicLink())
|
|
277
|
+
return { kind: 'present' };
|
|
278
|
+
try {
|
|
279
|
+
// The RAW literal target: callers resolve it against the filesystem so an
|
|
280
|
+
// intervening symlink is followed before any `..` in the target.
|
|
281
|
+
return { kind: 'symlink', target: await fsReadlink(path) };
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return { kind: 'error' };
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createTranscriptWatcher } from "./watcher.js";
|
|
2
|
+
export function createCoverageRunner(opts) {
|
|
3
|
+
const watchers = opts.harnesses.map((harness) => createTranscriptWatcher({
|
|
4
|
+
harness,
|
|
5
|
+
home: opts.homes[harness],
|
|
6
|
+
root: opts.root,
|
|
7
|
+
codexScanLimit: opts.codexScanLimit,
|
|
8
|
+
discoveryIO: opts.discoveryIO,
|
|
9
|
+
fileIO: opts.fileIO,
|
|
10
|
+
sink: opts.sink,
|
|
11
|
+
publish: opts.publish,
|
|
12
|
+
}));
|
|
13
|
+
const setTimer = opts.setTimer ??
|
|
14
|
+
((ms, fn) => {
|
|
15
|
+
const t = setTimeout(fn, ms);
|
|
16
|
+
t.unref?.();
|
|
17
|
+
return t;
|
|
18
|
+
});
|
|
19
|
+
const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
|
|
20
|
+
let stopped = false;
|
|
21
|
+
let timer;
|
|
22
|
+
let inflight;
|
|
23
|
+
const tick = async () => {
|
|
24
|
+
for (const w of watchers) {
|
|
25
|
+
try {
|
|
26
|
+
await w.tick();
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
opts.onError?.(err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const schedule = () => {
|
|
34
|
+
if (stopped)
|
|
35
|
+
return;
|
|
36
|
+
timer = setTimer(opts.intervalMs, run);
|
|
37
|
+
};
|
|
38
|
+
const run = () => {
|
|
39
|
+
if (stopped)
|
|
40
|
+
return;
|
|
41
|
+
timer = undefined;
|
|
42
|
+
inflight = tick().finally(() => {
|
|
43
|
+
inflight = undefined;
|
|
44
|
+
schedule();
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const start = () => {
|
|
48
|
+
if (stopped || timer !== undefined || inflight !== undefined)
|
|
49
|
+
return;
|
|
50
|
+
run();
|
|
51
|
+
};
|
|
52
|
+
const stop = async () => {
|
|
53
|
+
stopped = true;
|
|
54
|
+
if (timer !== undefined) {
|
|
55
|
+
clearTimer(timer);
|
|
56
|
+
timer = undefined;
|
|
57
|
+
}
|
|
58
|
+
if (inflight)
|
|
59
|
+
await inflight.catch(() => { });
|
|
60
|
+
};
|
|
61
|
+
return { tick, start, stop };
|
|
62
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { claudeStep, initialClaudeState } from "./claude.js";
|
|
2
|
+
import { codexStep, initialCodexState } from "./codex.js";
|
|
3
|
+
/** Bind the pure Claude core to one file's mutable join state. */
|
|
4
|
+
export function claudeStepper(ctx) {
|
|
5
|
+
let state = initialClaudeState();
|
|
6
|
+
return {
|
|
7
|
+
reset: () => {
|
|
8
|
+
state = initialClaudeState();
|
|
9
|
+
},
|
|
10
|
+
step: (record) => {
|
|
11
|
+
const out = claudeStep(state, record, ctx);
|
|
12
|
+
state = out.state;
|
|
13
|
+
return { evidence: out.evidence, diagnostics: out.diagnostics };
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Bind the pure Codex core to one file's mutable join state. */
|
|
18
|
+
export function codexStepper(ctx) {
|
|
19
|
+
let state = initialCodexState();
|
|
20
|
+
return {
|
|
21
|
+
reset: () => {
|
|
22
|
+
state = initialCodexState();
|
|
23
|
+
},
|
|
24
|
+
step: (record) => {
|
|
25
|
+
const out = codexStep(state, record, ctx);
|
|
26
|
+
state = out.state;
|
|
27
|
+
return { evidence: out.evidence, diagnostics: out.diagnostics };
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Relativize native paths into the capture root's space, dropping anything that
|
|
4
|
+
* escapes the root (a different worktree, a parent dir) — such a write cannot
|
|
5
|
+
* match any change this session captures. Returns a `paths` scope with the
|
|
6
|
+
* in-root paths, or `undefined` when nothing remains in scope.
|
|
7
|
+
*
|
|
8
|
+
* Pure and string-only: it assumes `root`/`cwd` are already canonical (the
|
|
9
|
+
* plumbing realpaths them once at bind time). An absolute record path in a
|
|
10
|
+
* confirmed alias namespace (`ctx.rootAliases`) relativizes to the same in-root
|
|
11
|
+
* path as capture's canonical form, so it is kept rather than silently dropped.
|
|
12
|
+
*/
|
|
13
|
+
export function scopeFromPaths(rawPaths, ctx) {
|
|
14
|
+
const roots = [ctx.root, ...(ctx.rootAliases ?? [])];
|
|
15
|
+
const kept = [];
|
|
16
|
+
for (const raw of rawPaths) {
|
|
17
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
18
|
+
continue;
|
|
19
|
+
const abs = isAbsolute(raw) ? raw : resolve(ctx.cwd, raw);
|
|
20
|
+
for (const root of roots) {
|
|
21
|
+
const rel = relative(root, abs);
|
|
22
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
23
|
+
continue;
|
|
24
|
+
if (!kept.includes(rel))
|
|
25
|
+
kept.push(rel);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return kept.length > 0 ? { kind: 'paths', paths: kept } : undefined;
|
|
30
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { discover } from "./discovery.js";
|
|
2
|
+
import { createTranscriptFileReader, } from "./file-reader.js";
|
|
3
|
+
import { claudeStepper, codexStepper } from "./steppers.js";
|
|
4
|
+
/**
|
|
5
|
+
* Fold discovery issues and per-file read results into one honest coverage
|
|
6
|
+
* verdict. A file that was actually read (`readable`/`degraded`) counts as
|
|
7
|
+
* coverage; a `missing`/`inaccessible` file did not. Any issue at all — or a file
|
|
8
|
+
* whose evidence is still held back by ingestion backpressure — downgrades a
|
|
9
|
+
* successful read to `degraded`, so one readable transcript never conceals an
|
|
10
|
+
* unreadable sibling and `readable` never claims a scope whose evidence has not
|
|
11
|
+
* yet been recorded. With nothing read, a hard failure is `unavailable` while a
|
|
12
|
+
* merely-not-yet-written home stays `pending`. An unconfirmed replacement degrades
|
|
13
|
+
* a readable aggregate but stays pending when alone.
|
|
14
|
+
*/
|
|
15
|
+
export function aggregateCoverage(discoveryIssues, files) {
|
|
16
|
+
const issues = [...discoveryIssues];
|
|
17
|
+
let readCount = 0;
|
|
18
|
+
let backpressured = false;
|
|
19
|
+
let withheld = false;
|
|
20
|
+
for (const f of files) {
|
|
21
|
+
if (f.state === 'readable' || f.state === 'degraded') {
|
|
22
|
+
readCount += 1;
|
|
23
|
+
if (f.backpressured)
|
|
24
|
+
backpressured = true;
|
|
25
|
+
for (const d of f.issues)
|
|
26
|
+
issues.push({ kind: d.kind, detail: d.detail });
|
|
27
|
+
}
|
|
28
|
+
else if (f.state === 'unconfirmed') {
|
|
29
|
+
withheld = true;
|
|
30
|
+
// The file at this path was replaced (a new inode) and discovery has not yet
|
|
31
|
+
// re-confirmed the replacement's membership/scope. It is neither read nor a
|
|
32
|
+
// failure: withhold it (pending-compatible, no issue) so a one-tick self-healing
|
|
33
|
+
// replacement race never flaps coverage to unavailable. Next tick's discovery
|
|
34
|
+
// re-derives the binding for the new generation (recreating this reader if it is
|
|
35
|
+
// a confirmed in-root member, or dropping it if it is out-of-root); nothing is
|
|
36
|
+
// dropped, enrichment is delayed one tick.
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
else if (f.state === 'missing') {
|
|
40
|
+
issues.push({ kind: 'missing', detail: `transcript ${f.path ?? 'transcript'} disappeared` });
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
issues.push({ kind: 'inaccessible', detail: `transcript ${f.path ?? 'transcript'} unreadable` });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let state;
|
|
47
|
+
if (readCount > 0) {
|
|
48
|
+
state = issues.length > 0 || backpressured || withheld ? 'degraded' : 'readable';
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const hardFailure = issues.some((i) => i.kind !== 'missing');
|
|
52
|
+
state = hardFailure ? 'unavailable' : 'pending';
|
|
53
|
+
}
|
|
54
|
+
return { state, issues };
|
|
55
|
+
}
|
|
56
|
+
function coverageKey(data) {
|
|
57
|
+
const issues = (data.issues ?? [])
|
|
58
|
+
.map((i) => `${i.kind}:${i.detail}`)
|
|
59
|
+
.sort()
|
|
60
|
+
.join('|');
|
|
61
|
+
return `${data.state}#${issues}`;
|
|
62
|
+
}
|
|
63
|
+
function makeStepper(harness, ctx) {
|
|
64
|
+
return harness === 'claude-code' ? claudeStepper(ctx) : codexStepper(ctx);
|
|
65
|
+
}
|
|
66
|
+
/** The identity that gates reader reuse: the harness session, the resolved scope
|
|
67
|
+
* (canonical cwd + root aliases), AND the file generation. A change in the ctx means
|
|
68
|
+
* the prior reader was reading under a scope discovery has since corrected — most
|
|
69
|
+
* often a provisional binding, made while the transcript was empty, whose real
|
|
70
|
+
* cwd/aliases discovery only learned once records (or a cwd-bearing record past a
|
|
71
|
+
* leading summary) appeared. A change in the generation means the file at this path
|
|
72
|
+
* was replaced (a new inode) and discovery has re-confirmed the replacement in-root:
|
|
73
|
+
* the old reader is pinned to the prior inode and would refuse the new one, so it is
|
|
74
|
+
* dropped and a fresh reader is created against the confirmed new generation. */
|
|
75
|
+
function bindingReaderKey(binding) {
|
|
76
|
+
const { ctx, generation } = binding;
|
|
77
|
+
return `${ctx.harnessSessionId}\0${ctx.cwd}\0${(ctx.rootAliases ?? []).join(',')}\0${generation.dev}:${generation.ino}`;
|
|
78
|
+
}
|
|
79
|
+
export function createTranscriptWatcher(opts) {
|
|
80
|
+
const { harness, home, root, codexScanLimit, discoveryIO, fileIO, sink, publish } = opts;
|
|
81
|
+
const readers = new Map();
|
|
82
|
+
let lastKey;
|
|
83
|
+
const tick = async () => {
|
|
84
|
+
const { bindings, issues: discoveryIssues } = await discover(harness, discoveryIO, home, root, codexScanLimit);
|
|
85
|
+
const perFile = [];
|
|
86
|
+
for (const binding of bindings) {
|
|
87
|
+
// Recreate the reader whenever its bound context or file generation changes: a
|
|
88
|
+
// changed session id is a rotated transcript (the same file name now belongs to
|
|
89
|
+
// a different harness session), a changed cwd/aliases is a provisional binding
|
|
90
|
+
// (made while the transcript was empty, or before a cwd-bearing record appeared
|
|
91
|
+
// past a leading summary) that discovery has since resolved to the real scope,
|
|
92
|
+
// and a changed generation is a replacement (new inode) discovery has re-confirmed
|
|
93
|
+
// in-root. In every case the old reader read under a scope/generation that no
|
|
94
|
+
// longer applies, so drop it and reread from zero; the ingestor's log-derived
|
|
95
|
+
// dedup absorbs the re-read. This never conflicts an invocation: a record already
|
|
96
|
+
// read under the prior binding implies its cwd was known then (aliases are derived
|
|
97
|
+
// from the transcript's own cwd), so its scope does not change on the reread;
|
|
98
|
+
// only records first seen after the resolution get the corrected scope.
|
|
99
|
+
const ctxKey = bindingReaderKey(binding);
|
|
100
|
+
let entry = readers.get(binding.path);
|
|
101
|
+
if (!entry || entry.ctxKey !== ctxKey) {
|
|
102
|
+
entry = {
|
|
103
|
+
reader: createTranscriptFileReader({
|
|
104
|
+
path: binding.path,
|
|
105
|
+
io: fileIO,
|
|
106
|
+
sink,
|
|
107
|
+
stepper: makeStepper(harness, binding.ctx),
|
|
108
|
+
generation: binding.generation,
|
|
109
|
+
}),
|
|
110
|
+
ctxKey,
|
|
111
|
+
};
|
|
112
|
+
readers.set(binding.path, entry);
|
|
113
|
+
}
|
|
114
|
+
const result = await entry.reader.poll();
|
|
115
|
+
perFile.push({
|
|
116
|
+
state: result.state,
|
|
117
|
+
issues: result.issues,
|
|
118
|
+
backpressured: result.backpressured,
|
|
119
|
+
path: binding.path,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const coverage = aggregateCoverage(discoveryIssues, perFile);
|
|
123
|
+
const data = coverage.issues.length > 0
|
|
124
|
+
? { harness, state: coverage.state, issues: coverage.issues }
|
|
125
|
+
: { harness, state: coverage.state };
|
|
126
|
+
const key = coverageKey(data);
|
|
127
|
+
if (key !== lastKey) {
|
|
128
|
+
await publish(data);
|
|
129
|
+
lastKey = key;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
return { tick };
|
|
133
|
+
}
|